1780 lines
71 KiB
JavaScript
1780 lines
71 KiB
JavaScript
// Состояние приложения
|
||
const state = {
|
||
user: null,
|
||
currentPage: null,
|
||
currentCourse: null,
|
||
currentLesson: null,
|
||
userData: null,
|
||
allData: null,
|
||
notificationsOpen: false
|
||
};
|
||
|
||
// API утилиты
|
||
const api = {
|
||
async post(url, data) {
|
||
const res = await fetch(url, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(data)
|
||
});
|
||
return res.json();
|
||
},
|
||
|
||
async get(url) {
|
||
const res = await fetch(url);
|
||
return res.json();
|
||
},
|
||
|
||
async put(url, data) {
|
||
const res = await fetch(url, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(data)
|
||
});
|
||
return res.json();
|
||
}
|
||
};
|
||
|
||
// Утилиты
|
||
function formatDate(dateStr) {
|
||
if (!dateStr) return '';
|
||
const d = new Date(dateStr);
|
||
return d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||
}
|
||
|
||
function formatDateTime(dateStr) {
|
||
if (!dateStr) return '';
|
||
const d = new Date(dateStr);
|
||
return d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||
}
|
||
|
||
function getCourseStatus(assignment) {
|
||
const today = new Date().toISOString().split('T')[0];
|
||
if (assignment.status === 'completed') return 'completed';
|
||
if (assignment.dueDate < today) return 'overdue';
|
||
const progress = state.userData?.progress?.filter(p => {
|
||
const a = state.userData.assignments.find(x => x.id === p.assignmentId);
|
||
return a?.id === assignment.id;
|
||
}).length || 0;
|
||
return progress > 0 ? 'in_progress' : 'not_started';
|
||
}
|
||
|
||
function getCourseProgress(assignment) {
|
||
const course = state.allData?.courses?.find(c => c.id === assignment.courseId);
|
||
if (!course) return 0;
|
||
const totalLessons = course.modules.reduce((sum, m) => sum + m.lessons.length, 0);
|
||
if (totalLessons === 0) return 0;
|
||
const completed = state.userData?.progress?.filter(p => {
|
||
const a = state.userData.assignments.find(x => x.id === p.assignmentId);
|
||
return a?.id === assignment.id && p.completed;
|
||
}).length || 0;
|
||
return Math.round((completed / totalLessons) * 100);
|
||
}
|
||
|
||
// Рендеринг страниц
|
||
function render() {
|
||
const app = document.getElementById('app');
|
||
if (!state.user) {
|
||
renderLoginPage(app);
|
||
return;
|
||
}
|
||
|
||
switch (state.currentPage) {
|
||
case 'dashboard':
|
||
renderDashboard(app);
|
||
break;
|
||
case 'courses':
|
||
renderCourses(app);
|
||
break;
|
||
case 'course':
|
||
renderCourseDetail(app);
|
||
break;
|
||
case 'lesson':
|
||
renderLesson(app);
|
||
break;
|
||
case 'quiz':
|
||
renderQuiz(app);
|
||
break;
|
||
case 'certificates':
|
||
renderCertificates(app);
|
||
break;
|
||
case 'manager':
|
||
renderManagerDashboard(app);
|
||
break;
|
||
case 'admin':
|
||
renderAdminDashboard(app);
|
||
break;
|
||
case 'admin-courses':
|
||
renderAdminCourses(app);
|
||
break;
|
||
case 'admin-users':
|
||
renderAdminUsers(app);
|
||
break;
|
||
case 'admin-assignments':
|
||
renderAdminAssignments(app);
|
||
break;
|
||
case 'admin-reports':
|
||
renderAdminReports(app);
|
||
break;
|
||
case 'admin-course-builder':
|
||
renderAdminCourseBuilder(app);
|
||
break;
|
||
case 'admin-assignment-create':
|
||
renderAdminAssignmentCreate(app);
|
||
break;
|
||
default:
|
||
renderDashboard(app);
|
||
}
|
||
}
|
||
|
||
// Страница входа
|
||
function renderLoginPage(container) {
|
||
container.innerHTML = `
|
||
<div class="login-page">
|
||
<div class="login-card">
|
||
<div class="login-logo">
|
||
<h1>🎓 Академия компании</h1>
|
||
<p>Платформа обучения и развития сотрудников</p>
|
||
</div>
|
||
<div class="login-error" id="loginError"></div>
|
||
<form id="loginForm">
|
||
<div class="form-group">
|
||
<label class="form-label">Логин</label>
|
||
<input type="text" class="form-input" id="login" placeholder="Введите логин" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Пароль</label>
|
||
<div class="input-with-icon">
|
||
<input type="password" class="form-input" id="password" placeholder="Введите пароль" required>
|
||
<button type="button" class="input-toggle" onclick="togglePassword()">👁</button>
|
||
</div>
|
||
</div>
|
||
<div class="forgot-password">
|
||
<a href="#" onclick="alert('Обратитесь к администратору для восстановления пароля')">Забыли пароль?</a>
|
||
</div>
|
||
<button type="submit" class="btn btn-primary btn-lg" style="width: 100%">Войти</button>
|
||
</form>
|
||
<div style="margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--gray-200); font-size: 12px; color: var(--gray-500);">
|
||
<strong>Тестовые аккаунты:</strong><br>
|
||
Сотрудник: employee / employee123<br>
|
||
Руководитель: manager / manager123<br>
|
||
Администратор: admin / admin123
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const login = document.getElementById('login').value;
|
||
const password = document.getElementById('password').value;
|
||
const errorEl = document.getElementById('loginError');
|
||
|
||
try {
|
||
const result = await api.post('/api/login', { login, password });
|
||
if (result.ok) {
|
||
state.user = result.user;
|
||
state.currentPage = 'dashboard';
|
||
await loadUserData();
|
||
render();
|
||
} else {
|
||
errorEl.textContent = result.error || 'Неверный логин или пароль';
|
||
errorEl.classList.add('show');
|
||
}
|
||
} catch (err) {
|
||
errorEl.textContent = 'Ошибка подключения к серверу';
|
||
errorEl.classList.add('show');
|
||
}
|
||
});
|
||
}
|
||
|
||
function togglePassword() {
|
||
const input = document.getElementById('password');
|
||
input.type = input.type === 'password' ? 'text' : 'password';
|
||
}
|
||
|
||
// Загрузка данных пользователя
|
||
async function loadUserData() {
|
||
if (state.user.role === 'admin') {
|
||
const allData = await api.get('/api/admin/data');
|
||
state.allData = allData.data;
|
||
} else {
|
||
const userData = await api.get(`/api/user/${state.user.id}`);
|
||
state.userData = userData.data;
|
||
}
|
||
}
|
||
|
||
// Layout с боковым меню
|
||
function renderLayout(container, content) {
|
||
const roleNames = { employee: 'Сотрудник', manager: 'Руководитель', admin: 'Администратор' };
|
||
const unreadCount = state.userData?.notifications?.filter(n => !n.read).length || 0;
|
||
|
||
let adminMenu = '';
|
||
if (state.user.role === 'admin') {
|
||
adminMenu = `
|
||
<a class="sidebar-nav-item ${state.currentPage === 'admin' ? 'active' : ''}" onclick="navigate('admin')">
|
||
<span class="sidebar-nav-icon">📊</span>
|
||
<span>Дашборд</span>
|
||
</a>
|
||
<a class="sidebar-nav-item ${state.currentPage === 'admin-courses' ? 'active' : ''}" onclick="navigate('admin-courses')">
|
||
<span class="sidebar-nav-icon">📚</span>
|
||
<span>Курсы</span>
|
||
</a>
|
||
<a class="sidebar-nav-item ${state.currentPage === 'admin-users' ? 'active' : ''}" onclick="navigate('admin-users')">
|
||
<span class="sidebar-nav-icon">👥</span>
|
||
<span>Сотрудники</span>
|
||
</a>
|
||
<a class="sidebar-nav-item ${state.currentPage === 'admin-assignments' ? 'active' : ''}" onclick="navigate('admin-assignments')">
|
||
<span class="sidebar-nav-icon">📋</span>
|
||
<span>Назначения</span>
|
||
</a>
|
||
<a class="sidebar-nav-item ${state.currentPage === 'admin-reports' ? 'active' : ''}" onclick="navigate('admin-reports')">
|
||
<span class="sidebar-nav-icon">📈</span>
|
||
<span>Отчёты</span>
|
||
</a>
|
||
`;
|
||
}
|
||
|
||
container.innerHTML = `
|
||
<div class="layout">
|
||
<aside class="sidebar" id="sidebar">
|
||
<div class="sidebar-header">
|
||
<div class="sidebar-logo">🎓 Академия</div>
|
||
<div class="sidebar-user">
|
||
<div class="sidebar-user-name">${state.user.name}</div>
|
||
<div class="sidebar-user-role">${roleNames[state.user.role]}</div>
|
||
</div>
|
||
</div>
|
||
<nav class="sidebar-nav">
|
||
${state.user.role !== 'admin' ? `
|
||
<a class="sidebar-nav-item ${state.currentPage === 'dashboard' ? 'active' : ''}" onclick="navigate('dashboard')">
|
||
<span class="sidebar-nav-icon">🏠</span>
|
||
<span>Главная</span>
|
||
</a>
|
||
<a class="sidebar-nav-item ${state.currentPage === 'courses' ? 'active' : ''}" onclick="navigate('courses')">
|
||
<span class="sidebar-nav-icon">📚</span>
|
||
<span>Мои курсы</span>
|
||
</a>
|
||
<a class="sidebar-nav-item ${state.currentPage === 'certificates' ? 'active' : ''}" onclick="navigate('certificates')">
|
||
<span class="sidebar-nav-icon">🏆</span>
|
||
<span>Сертификаты</span>
|
||
</a>
|
||
` : ''}
|
||
${state.user.role === 'manager' ? `
|
||
<a class="sidebar-nav-item ${state.currentPage === 'manager' ? 'active' : ''}" onclick="navigate('manager')">
|
||
<span class="sidebar-nav-icon">📊</span>
|
||
<span>Команда</span>
|
||
</a>
|
||
` : ''}
|
||
${adminMenu}
|
||
<a class="sidebar-nav-item" onclick="logout()">
|
||
<span class="sidebar-nav-icon">🚪</span>
|
||
<span>Выйти</span>
|
||
</a>
|
||
</nav>
|
||
</aside>
|
||
<div class="main-content">
|
||
<div class="main-header">
|
||
<button class="mobile-menu-btn" onclick="toggleSidebar()">☰</button>
|
||
<h1 class="main-title">${getPageTitle()}</h1>
|
||
<div class="header-actions">
|
||
${state.user.role !== 'admin' ? `
|
||
<button class="notification-btn" onclick="toggleNotifications()">
|
||
🔔
|
||
${unreadCount > 0 ? `<span class="notification-badge">${unreadCount}</span>` : ''}
|
||
</button>
|
||
` : ''}
|
||
<button class="logout-btn" onclick="logout()">Выйти</button>
|
||
</div>
|
||
</div>
|
||
${content}
|
||
</div>
|
||
<div class="notifications-panel ${state.notificationsOpen ? 'open' : ''}" id="notificationsPanel">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||
<h2 style="font-size: 20px;">Уведомления</h2>
|
||
<button onclick="toggleNotifications()" style="background: none; border: none; font-size: 24px; cursor: pointer;">✕</button>
|
||
</div>
|
||
<div id="notificationsList"></div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
if (state.user.role !== 'admin') {
|
||
renderNotifications();
|
||
}
|
||
}
|
||
|
||
function getPageTitle() {
|
||
const titles = {
|
||
dashboard: 'Главная',
|
||
courses: 'Мои курсы',
|
||
course: 'Курс',
|
||
lesson: 'Урок',
|
||
quiz: 'Тестирование',
|
||
certificates: 'Сертификаты',
|
||
manager: 'Кабинет руководителя',
|
||
admin: 'Панель администратора',
|
||
'admin-courses': 'Управление курсами',
|
||
'admin-users': 'Сотрудники',
|
||
'admin-assignments': 'Назначения',
|
||
'admin-reports': 'Отчёты',
|
||
'admin-course-builder': 'Конструктор курса',
|
||
'admin-assignment-create': 'Назначение курса'
|
||
};
|
||
return titles[state.currentPage] || 'Академия';
|
||
}
|
||
|
||
function navigate(page, params = {}) {
|
||
state.currentPage = page;
|
||
state.currentCourse = params.course || null;
|
||
state.currentLesson = params.lesson || null;
|
||
state.notificationsOpen = false;
|
||
|
||
// Закрываем мобильное меню
|
||
document.getElementById('sidebar')?.classList.remove('open');
|
||
|
||
render();
|
||
window.scrollTo(0, 0);
|
||
}
|
||
|
||
function toggleSidebar() {
|
||
document.getElementById('sidebar').classList.toggle('open');
|
||
}
|
||
|
||
function toggleNotifications() {
|
||
state.notificationsOpen = !state.notificationsOpen;
|
||
if (state.notificationsOpen) {
|
||
renderNotifications();
|
||
}
|
||
render();
|
||
}
|
||
|
||
function renderNotifications() {
|
||
const panel = document.getElementById('notificationsPanel');
|
||
if (!panel) return;
|
||
|
||
const list = document.getElementById('notificationsList');
|
||
if (!state.userData?.notifications?.length) {
|
||
list.innerHTML = '<div class="empty-state"><p>Нет уведомлений</p></div>';
|
||
return;
|
||
}
|
||
|
||
list.innerHTML = state.userData.notifications
|
||
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||
.map(n => `
|
||
<div class="notification-item ${!n.read ? 'unread' : ''}" onclick="markNotificationRead(${n.id})">
|
||
<div class="notification-title">${n.title}</div>
|
||
<div class="notification-message">${n.message}</div>
|
||
<div class="notification-time">${formatDateTime(n.createdAt)}</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
async function markNotificationRead(id) {
|
||
await api.put(`/api/notifications/${id}`, { read: true });
|
||
await loadUserData();
|
||
renderNotifications();
|
||
}
|
||
|
||
function logout() {
|
||
state.user = null;
|
||
state.userData = null;
|
||
state.currentPage = null;
|
||
render();
|
||
}
|
||
|
||
// Дашборд сотрудника
|
||
function renderDashboard(container) {
|
||
const assignments = state.userData?.assignments || [];
|
||
const statusCounts = {
|
||
not_started: assignments.filter(a => getCourseStatus(a) === 'not_started').length,
|
||
in_progress: assignments.filter(a => getCourseStatus(a) === 'in_progress').length,
|
||
completed: assignments.filter(a => getCourseStatus(a) === 'completed').length,
|
||
overdue: assignments.filter(a => getCourseStatus(a) === 'overdue').length
|
||
};
|
||
|
||
const totalProgress = assignments.length > 0
|
||
? Math.round(assignments.reduce((sum, a) => sum + getCourseProgress(a), 0) / assignments.length)
|
||
: 0;
|
||
|
||
const upcomingDeadlines = assignments
|
||
.filter(a => getCourseStatus(a) !== 'completed' && getCourseStatus(a) !== 'overdue')
|
||
.sort((a, b) => new Date(a.dueDate) - new Date(b.dueDate))
|
||
.slice(0, 3);
|
||
|
||
const content = `
|
||
<div class="stats-grid">
|
||
<div class="stat-card">
|
||
<div class="stat-value">${assignments.length}</div>
|
||
<div class="stat-label">Всего курсов</div>
|
||
</div>
|
||
<div class="stat-card success">
|
||
<div class="stat-value">${statusCounts.completed}</div>
|
||
<div class="stat-label">Завершено</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value">${statusCounts.in_progress}</div>
|
||
<div class="stat-label">В процессе</div>
|
||
</div>
|
||
<div class="stat-card danger">
|
||
<div class="stat-value">${statusCounts.overdue}</div>
|
||
<div class="stat-label">Просрочено</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" style="margin-bottom: 30px;">
|
||
<div class="card-header" style="display: flex; justify-content: space-between; align-items: center;">
|
||
<h2 class="card-title">Общий прогресс обучения</h2>
|
||
<span style="font-size: 24px; font-weight: 700; color: var(--primary);">${totalProgress}%</span>
|
||
</div>
|
||
<div class="progress-bar" style="height: 12px;">
|
||
<div class="progress-fill in-progress" style="width: ${totalProgress}%"></div>
|
||
</div>
|
||
</div>
|
||
|
||
${upcomingDeadlines.length > 0 ? `
|
||
<div class="card" style="margin-bottom: 30px;">
|
||
<div class="card-header">
|
||
<h2 class="card-title">Ближайшие сроки</h2>
|
||
</div>
|
||
${upcomingDeadlines.map(a => {
|
||
const course = state.allData?.courses?.find(c => c.id === a.courseId) || state.userData?.courses?.find(c => c.id === a.courseId);
|
||
return `
|
||
<div style="display: flex; justify-content: space-between; padding: 12px 0; border-bottom: 1px solid var(--gray-200);">
|
||
<span>${course?.title || 'Курс'}</span>
|
||
<span style="color: var(--danger); font-weight: 600;">До ${formatDate(a.dueDate)}</span>
|
||
</div>
|
||
`;
|
||
}).join('')}
|
||
</div>
|
||
` : ''}
|
||
|
||
<div class="section-header">
|
||
<h2 class="section-title">Мои курсы</h2>
|
||
<button class="btn btn-outline btn-sm" onclick="navigate('courses')">Все курсы →</button>
|
||
</div>
|
||
<div class="courses-grid">
|
||
${assignments.slice(0, 3).map(a => renderCourseCard(a)).join('')}
|
||
</div>
|
||
|
||
<div style="margin-top: 30px; text-align: center;">
|
||
<button class="btn btn-primary btn-lg" onclick="navigate('courses')">Продолжить обучение</button>
|
||
</div>
|
||
`;
|
||
|
||
renderLayout(container, content);
|
||
}
|
||
|
||
function renderCourseCard(assignment) {
|
||
const course = state.allData?.courses?.find(c => c.id === assignment.courseId);
|
||
if (!course) return '';
|
||
|
||
const status = getCourseStatus(assignment);
|
||
const progress = getCourseProgress(assignment);
|
||
const category = state.allData?.categories?.find(c => c.id === course.categoryId);
|
||
|
||
const statusLabels = {
|
||
not_started: 'Не начат',
|
||
in_progress: 'В процессе',
|
||
completed: 'Завершён',
|
||
overdue: 'Просрочен'
|
||
};
|
||
|
||
return `
|
||
<div class="course-card">
|
||
<img src="${course.cover}" alt="${course.title}" class="course-cover">
|
||
<div class="course-content">
|
||
${category ? `<span class="course-category" style="background: ${category.color}">${category.name}</span>` : ''}
|
||
<span class="course-status ${status}">${statusLabels[status]}</span>
|
||
<h3 class="course-title">${course.title}</h3>
|
||
<p class="course-description">${course.description}</p>
|
||
<div class="course-meta">
|
||
<span>⏱ ${course.duration} ч</span>
|
||
<span>📚 ${course.modules.reduce((sum, m) => sum + m.lessons.length, 0)} уроков</span>
|
||
</div>
|
||
${status !== 'not_started' ? `
|
||
<div class="course-progress">
|
||
<div class="progress-bar">
|
||
<div class="progress-fill ${status}" style="width: ${progress}%"></div>
|
||
</div>
|
||
<div class="progress-text">${progress}% пройдено</div>
|
||
</div>
|
||
` : ''}
|
||
<button class="btn btn-primary" style="width: 100%" onclick="navigate('course', { course: ${course.id} })">
|
||
${status === 'completed' ? 'Повторить' : status === 'not_started' ? 'Начать' : 'Продолжить'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Каталог курсов
|
||
function renderCourses(container) {
|
||
const assignments = state.userData?.assignments || [];
|
||
const [searchTerm, setSearchTerm] = state.courseSearch || ['', null];
|
||
const [statusFilter, setStatusFilter] = state.courseStatusFilter || ['all', null];
|
||
const [categoryFilter, setCategoryFilter] = state.courseCategoryFilter || ['all', null];
|
||
|
||
const filtered = assignments.filter(a => {
|
||
const course = state.allData?.courses?.find(c => c.id === a.courseId);
|
||
if (!course) return false;
|
||
|
||
const matchesSearch = course.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
course.description.toLowerCase().includes(searchTerm.toLowerCase());
|
||
const matchesStatus = statusFilter === 'all' || getCourseStatus(a) === statusFilter;
|
||
const matchesCategory = categoryFilter === 'all' || course.categoryId === parseInt(categoryFilter);
|
||
|
||
return matchesSearch && matchesStatus && matchesCategory;
|
||
});
|
||
|
||
const content = `
|
||
<div class="filters-bar">
|
||
<input type="text" class="search-input" placeholder="Поиск курсов..." value="${searchTerm}"
|
||
onchange="state.courseSearch = [this.value, null]; render();">
|
||
<select class="filter-select" onchange="state.courseStatusFilter = [this.value, null]; render();">
|
||
<option value="all">Все статусы</option>
|
||
<option value="not_started" ${statusFilter === 'not_started' ? 'selected' : ''}>Не начат</option>
|
||
<option value="in_progress" ${statusFilter === 'in_progress' ? 'selected' : ''}>В процессе</option>
|
||
<option value="completed" ${statusFilter === 'completed' ? 'selected' : ''}>Завершён</option>
|
||
<option value="overdue" ${statusFilter === 'overdue' ? 'selected' : ''}>Просрочен</option>
|
||
</select>
|
||
<select class="filter-select" onchange="state.courseCategoryFilter = [this.value, null]; render();">
|
||
<option value="all">Все категории</option>
|
||
${state.allData?.categories?.map(c => `
|
||
<option value="${c.id}" ${categoryFilter == c.id ? 'selected' : ''}>${c.name}</option>
|
||
`).join('')}
|
||
</select>
|
||
<button class="btn btn-outline" onclick="state.courseSearch = ['']; state.courseStatusFilter = ['all']; state.courseCategoryFilter = ['all']; render();">
|
||
Сбросить
|
||
</button>
|
||
</div>
|
||
<div class="courses-grid">
|
||
${filtered.length > 0 ? filtered.map(a => renderCourseCard(a)).join('') : `
|
||
<div class="empty-state" style="grid-column: 1/-1;">
|
||
<div class="empty-state-icon">📚</div>
|
||
<div class="empty-state-title">Курсы не найдены</div>
|
||
<div class="empty-state-text">Попробуйте изменить параметры поиска</div>
|
||
</div>
|
||
`}
|
||
</div>
|
||
`;
|
||
|
||
renderLayout(container, content);
|
||
}
|
||
|
||
// Страница курса
|
||
function renderCourseDetail(container) {
|
||
const course = state.allData?.courses?.find(c => c.id === state.currentCourse);
|
||
const assignment = state.userData?.assignments?.find(a => a.courseId === course?.id);
|
||
|
||
if (!course || !assignment) {
|
||
container.innerHTML = '<div class="empty-state"><p>Курс не найден</p></div>';
|
||
return;
|
||
}
|
||
|
||
const status = getCourseStatus(assignment);
|
||
const progress = getCourseProgress(assignment);
|
||
const category = state.allData?.categories?.find(c => c.id === course.categoryId);
|
||
|
||
const allLessons = course.modules.flatMap(m => m.lessons);
|
||
const completedLessons = state.userData?.progress?.filter(p => {
|
||
const a = state.userData.assignments.find(x => x.id === p.assignmentId);
|
||
return a?.id === assignment.id && p.completed;
|
||
}).length || 0;
|
||
|
||
const nextLesson = allLessons.find((l, i) => {
|
||
const prevLessons = allLessons.slice(0, i);
|
||
return !prevLessons.every(pl => {
|
||
const p = state.userData?.progress?.find(prog => {
|
||
const a = state.userData.assignments.find(x => x.id === prog.assignmentId);
|
||
return a?.id === assignment.id && prog.lessonId === pl.id;
|
||
});
|
||
return p?.completed;
|
||
});
|
||
});
|
||
|
||
const content = `
|
||
<div class="course-detail-header">
|
||
<div class="course-detail-grid">
|
||
<div class="course-detail-info">
|
||
${category ? `<span class="course-category" style="background: ${category.color}">${category.name}</span>` : ''}
|
||
<h1>${course.title}</h1>
|
||
<div class="course-detail-meta">
|
||
<div class="course-detail-meta-item">👤 ${course.author}</div>
|
||
<div class="course-detail-meta-item">⏱ ${course.duration} часов</div>
|
||
<div class="course-detail-meta-item">📚 ${allLessons.length} уроков</div>
|
||
<div class="course-detail-meta-item">📅 До ${formatDate(assignment.dueDate)}</div>
|
||
</div>
|
||
<p class="course-detail-description">${course.description}</p>
|
||
</div>
|
||
<div class="course-sidebar">
|
||
<div class="course-sidebar-title">Ваш прогресс</div>
|
||
<div class="progress-bar" style="margin-bottom: 10px;">
|
||
<div class="progress-fill ${status}" style="width: ${progress}%"></div>
|
||
</div>
|
||
<div style="display: flex; justify-content: space-between; margin-bottom: 20px;">
|
||
<span>${completedLessons} из ${allLessons.length} уроков</span>
|
||
<strong>${progress}%</strong>
|
||
</div>
|
||
<button class="btn btn-primary" style="width: 100%" onclick="navigate('lesson', { course: ${course.id}, lesson: ${nextLesson?.id || allLessons[0]?.id} })">
|
||
${status === 'completed' ? 'Повторить курс' : 'Продолжить обучение'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="course-detail-grid">
|
||
<div>
|
||
<h2 style="margin-bottom: 20px;">Содержание курса</h2>
|
||
${course.modules.map((module, mi) => `
|
||
<div class="card" style="margin-bottom: 20px;">
|
||
<div class="card-header">
|
||
<h3 class="card-title">Модуль ${mi + 1}: ${module.title}</h3>
|
||
</div>
|
||
<ul class="course-modules" style="list-style: none;">
|
||
${module.lessons.map(l => {
|
||
const isCompleted = state.userData?.progress?.some(p => {
|
||
const a = state.userData.assignments.find(x => x.id === p.assignmentId);
|
||
return a?.id === assignment.id && p.lessonId === l.id && p.completed;
|
||
});
|
||
const icon = l.type === 'video' ? '🎥' : l.type === 'quiz' ? '📝' : '📄';
|
||
return `
|
||
<li class="lesson-item ${isCompleted ? 'completed' : ''}" onclick="navigate('lesson', { course: ${course.id}, lesson: ${l.id} })">
|
||
<span class="lesson-icon">${icon}</span>
|
||
<span>${l.title}</span>
|
||
${isCompleted ? ' ✓' : ''}
|
||
</li>
|
||
`;
|
||
}).join('')}
|
||
</ul>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
renderLayout(container, content);
|
||
}
|
||
|
||
// Страница урока
|
||
function renderLesson(container) {
|
||
const course = state.allData?.courses?.find(c => c.id === state.currentCourse);
|
||
const assignment = state.userData?.assignments?.find(a => a.courseId === course?.id);
|
||
const lesson = course?.modules.flatMap(m => m.lessons).find(l => l.id === state.currentLesson);
|
||
|
||
if (!course || !assignment || !lesson) {
|
||
container.innerHTML = '<div class="empty-state"><p>Урок не найден</p></div>';
|
||
return;
|
||
}
|
||
|
||
const allLessons = course.modules.flatMap(m => m.lessons);
|
||
const currentIndex = allLessons.findIndex(l => l.id === lesson.id);
|
||
const prevLesson = allLessons[currentIndex - 1];
|
||
const nextLesson = allLessons[currentIndex + 1];
|
||
|
||
const isCompleted = state.userData?.progress?.some(p => {
|
||
const a = state.userData.assignments.find(x => x.id === p.assignmentId);
|
||
return a?.id === assignment.id && p.lessonId === lesson.id && p.completed;
|
||
});
|
||
|
||
let lessonContent = '';
|
||
if (lesson.type === 'video') {
|
||
lessonContent = `
|
||
<iframe src="${lesson.content}" title="${lesson.title}" allowfullscreen></iframe>
|
||
<p>Длительность: ${lesson.duration} мин</p>
|
||
`;
|
||
} else if (lesson.type === 'quiz') {
|
||
lessonContent = `
|
||
<div class="quiz-container">
|
||
<div class="quiz-header">
|
||
<div>
|
||
<strong>Вопросов:</strong> ${lesson.questions.length}<br>
|
||
<strong>Проходной балл:</strong> ${course.passScore}%
|
||
</div>
|
||
<button class="btn btn-primary" onclick="navigate('quiz', { course: ${course.id}, lesson: ${lesson.id}, assignment: ${assignment.id} })">
|
||
Начать тестирование
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
} else {
|
||
lessonContent = `<div>${lesson.content}</div>`;
|
||
}
|
||
|
||
const content = `
|
||
<div class="lesson-viewer">
|
||
<div class="card" style="height: fit-content;">
|
||
<div class="course-sidebar-title">Содержание курса</div>
|
||
<ul class="course-modules" style="list-style: none; margin-top: 15px;">
|
||
${course.modules.map((module, mi) => `
|
||
<li style="margin-bottom: 15px;">
|
||
<div style="font-weight: 600; font-size: 13px; color: var(--gray-600); margin-bottom: 8px;">Модуль ${mi + 1}</div>
|
||
${module.lessons.map(l => {
|
||
const completed = state.userData?.progress?.some(p => {
|
||
const a = state.userData.assignments.find(x => x.id === p.assignmentId);
|
||
return a?.id === assignment.id && p.lessonId === l.id && p.completed;
|
||
});
|
||
const icon = l.type === 'video' ? '🎥' : l.type === 'quiz' ? '📝' : '📄';
|
||
const active = l.id === lesson.id ? 'active' : '';
|
||
return `
|
||
<div class="lesson-item ${active} ${completed ? 'completed' : ''}" onclick="navigate('lesson', { course: ${course.id}, lesson: ${l.id} })">
|
||
<span class="lesson-icon">${icon}</span>
|
||
<span style="font-size: 13px;">${l.title}</span>
|
||
${completed ? ' ✓' : ''}
|
||
</div>
|
||
`;
|
||
}).join('')}
|
||
</li>
|
||
`).join('')}
|
||
</ul>
|
||
</div>
|
||
<div class="lesson-content">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||
<span style="color: var(--gray-500); font-size: 14px;">${course.title}</span>
|
||
<span style="font-weight: 600;">${currentIndex + 1} / ${allLessons.length}</span>
|
||
</div>
|
||
<h2>${lesson.title}</h2>
|
||
${lessonContent}
|
||
<div class="lesson-nav">
|
||
<button class="btn btn-outline" ${!prevLesson ? 'disabled' : ''} onclick="navigate('lesson', { course: ${course.id}, lesson: ${prevLesson?.id} })">
|
||
← Предыдущий урок
|
||
</button>
|
||
${!isCompleted && lesson.type !== 'quiz' ? `
|
||
<button class="btn btn-success" onclick="markLessonComplete(${assignment.id}, ${lesson.id})">
|
||
Отметить как пройденный ✓
|
||
</button>
|
||
` : ''}
|
||
<button class="btn btn-primary" ${!nextLesson ? 'disabled' : ''} onclick="navigate('lesson', { course: ${course.id}, lesson: ${nextLesson?.id} })">
|
||
Следующий урок →
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
renderLayout(container, content);
|
||
}
|
||
|
||
async function markLessonComplete(assignmentId, lessonId) {
|
||
await api.post('/api/progress', { assignmentId, lessonId });
|
||
await loadUserData();
|
||
render();
|
||
}
|
||
|
||
// Тестирование
|
||
function renderQuiz(container) {
|
||
const course = state.allData?.courses?.find(c => c.id === state.currentCourse);
|
||
const assignment = state.userData?.assignments?.find(a => a.courseId === course?.id);
|
||
const lesson = course?.modules.flatMap(m => m.lessons).find(l => l.id === state.currentLesson);
|
||
|
||
if (!course || !assignment || !lesson) {
|
||
container.innerHTML = '<div class="empty-state"><p>Тест не найден</p></div>';
|
||
return;
|
||
}
|
||
|
||
const [quizState, setQuizState] = state.currentQuiz || [{
|
||
currentQuestion: 0,
|
||
answers: [],
|
||
completed: false,
|
||
result: null
|
||
}];
|
||
|
||
if (quizState.completed && quizState.result) {
|
||
const isPassed = quizState.result.passed;
|
||
const content = `
|
||
<div class="quiz-result">
|
||
<div class="result-score ${isPassed ? 'passed' : 'failed'}">${quizState.result.score}%</div>
|
||
<div class="result-status ${isPassed ? 'passed' : 'failed'}">
|
||
${isPassed ? '✓ Тест пройден!' : '✕ Тест не пройден'}
|
||
</div>
|
||
<p>Правильных ответов: ${quizState.result.correct} из ${lesson.questions.length}</p>
|
||
${!isPassed && course.maxAttempts > 1 ? `
|
||
<button class="btn btn-primary" style="margin-top: 20px;" onclick="restartQuiz()">
|
||
Попробовать снова
|
||
</button>
|
||
` : ''}
|
||
${isPassed ? `
|
||
<button class="btn btn-outline" style="margin-top: 20px;" onclick="navigate('course', { course: ${course.id} })">
|
||
Вернуться к курсу
|
||
</button>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
renderLayout(container, content);
|
||
return;
|
||
}
|
||
|
||
const question = lesson.questions[quizState.currentQuestion];
|
||
if (!question) {
|
||
container.innerHTML = '<div class="empty-state"><p>Вопрос не найден</p></div>';
|
||
return;
|
||
}
|
||
|
||
const content = `
|
||
<div class="quiz-container">
|
||
<div class="quiz-header">
|
||
<div>
|
||
<strong>Вопрос ${quizState.currentQuestion + 1} из ${lesson.questions.length}</strong><br>
|
||
<span style="color: var(--gray-500); font-size: 13px;">${question.type === 'single' ? 'Один правильный ответ' : question.type === 'multiple' ? 'Несколько правильных ответов' : 'Верно/Неверно'}</span>
|
||
</div>
|
||
</div>
|
||
<div class="quiz-question">
|
||
<div class="question-text">${question.text}</div>
|
||
<div class="quiz-options">
|
||
${question.type === 'truefalse' ? `
|
||
<label class="quiz-option ${quizState.answers[quizState.currentQuestion] === true ? 'selected' : ''}">
|
||
<input type="radio" name="answer" onchange="selectQuizAnswer(${quizState.currentQuestion}, true)" ${quizState.answers[quizState.currentQuestion] === true ? 'checked' : ''}>
|
||
<span>Верно</span>
|
||
</label>
|
||
<label class="quiz-option ${quizState.answers[quizState.currentQuestion] === false ? 'selected' : ''}">
|
||
<input type="radio" name="answer" onchange="selectQuizAnswer(${quizState.currentQuestion}, false)" ${quizState.answers[quizState.currentQuestion] === false ? 'checked' : ''}>
|
||
<span>Неверно</span>
|
||
</label>
|
||
` : question.type === 'single' ? `
|
||
${question.options.map((opt, i) => `
|
||
<label class="quiz-option ${quizState.answers[quizState.currentQuestion] === i ? 'selected' : ''}">
|
||
<input type="radio" name="answer" onchange="selectQuizAnswer(${quizState.currentQuestion}, ${i})" ${quizState.answers[quizState.currentQuestion] === i ? 'checked' : ''}>
|
||
<span>${opt}</span>
|
||
</label>
|
||
`).join('')}
|
||
` : question.type === 'multiple' ? `
|
||
${question.options.map((opt, i) => `
|
||
<label class="quiz-option ${(quizState.answers[quizState.currentQuestion] || []).includes(i) ? 'selected' : ''}">
|
||
<input type="checkbox" onchange="selectQuizAnswerMultiple(${quizState.currentQuestion}, ${i}, this.checked)" ${(quizState.answers[quizState.currentQuestion] || []).includes(i) ? 'checked' : ''}>
|
||
<span>${opt}</span>
|
||
</label>
|
||
`).join('')}
|
||
` : ''}
|
||
</div>
|
||
</div>
|
||
<div style="display: flex; justify-content: space-between; margin-top: 20px;">
|
||
<button class="btn btn-outline" ${quizState.currentQuestion === 0 ? 'disabled' : ''} onclick="prevQuestion()">
|
||
← Назад
|
||
</button>
|
||
${quizState.currentQuestion < lesson.questions.length - 1 ? `
|
||
<button class="btn btn-primary" onclick="nextQuestion()">
|
||
Далее →
|
||
</button>
|
||
` : `
|
||
<button class="btn btn-success" onclick="submitQuiz(${assignment.id}, ${lesson.id})">
|
||
Завершить тест ✓
|
||
</button>
|
||
`}
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
renderLayout(container, content);
|
||
}
|
||
|
||
function selectQuizAnswer(questionIndex, answer) {
|
||
const quizState = state.currentQuiz || [{ currentQuestion: 0, answers: [], completed: false }];
|
||
quizState[0].answers[questionIndex] = answer;
|
||
state.currentQuiz = [quizState[0]];
|
||
render();
|
||
}
|
||
|
||
function selectQuizAnswerMultiple(questionIndex, optionIndex, isChecked) {
|
||
const quizState = state.currentQuiz || [{ currentQuestion: 0, answers: [], completed: false }];
|
||
if (!quizState[0].answers[questionIndex]) {
|
||
quizState[0].answers[questionIndex] = [];
|
||
}
|
||
if (isChecked) {
|
||
quizState[0].answers[questionIndex].push(optionIndex);
|
||
} else {
|
||
quizState[0].answers[questionIndex] = quizState[0].answers[questionIndex].filter(i => i !== optionIndex);
|
||
}
|
||
state.currentQuiz = [quizState[0]];
|
||
render();
|
||
}
|
||
|
||
function prevQuestion() {
|
||
const quizState = state.currentQuiz || [{ currentQuestion: 0, answers: [], completed: false }];
|
||
if (quizState[0].currentQuestion > 0) {
|
||
quizState[0].currentQuestion--;
|
||
state.currentQuiz = [quizState[0]];
|
||
render();
|
||
}
|
||
}
|
||
|
||
function nextQuestion() {
|
||
const quizState = state.currentQuiz || [{ currentQuestion: 0, answers: [], completed: false }];
|
||
if (quizState[0].currentQuestion < state.allData.courses.find(c => c.id === state.currentCourse).modules.flatMap(m => m.lessons).find(l => l.id === state.currentLesson).questions.length - 1) {
|
||
quizState[0].currentQuestion++;
|
||
state.currentQuiz = [quizState[0]];
|
||
render();
|
||
}
|
||
}
|
||
|
||
async function submitQuiz(assignmentId, lessonId) {
|
||
const quizState = state.currentQuiz || [{ answers: [] }];
|
||
const answers = quizState[0].answers.map((answer, i) => ({
|
||
questionId: i + 1,
|
||
answer
|
||
}));
|
||
|
||
const result = await api.post('/api/quiz', { assignmentId, lessonId, answers });
|
||
if (result.ok) {
|
||
quizState[0].completed = true;
|
||
quizState[0].result = { score: result.score, passed: result.passed, correct: Math.round(result.score / 100 * state.allData.courses.find(c => c.id === state.currentCourse).modules.flatMap(m => m.lessons).find(l => l.id === lessonId).questions.length) };
|
||
state.currentQuiz = [quizState[0]];
|
||
render();
|
||
}
|
||
}
|
||
|
||
function restartQuiz() {
|
||
state.currentQuiz = [{ currentQuestion: 0, answers: [], completed: false }];
|
||
render();
|
||
}
|
||
|
||
// Сертификаты
|
||
function renderCertificates(container) {
|
||
const certificates = state.userData?.certificates || [];
|
||
|
||
const content = `
|
||
${certificates.length > 0 ? `
|
||
<div class="courses-grid">
|
||
${certificates.map(cert => {
|
||
const course = state.allData?.courses?.find(c => c.id === cert.courseId);
|
||
return `
|
||
<div class="card" style="cursor: pointer;" onclick="viewCertificate(${cert.id})">
|
||
<div style="text-align: center; padding: 40px 20px;">
|
||
<div style="font-size: 64px; margin-bottom: 20px;">🏆</div>
|
||
<h3 style="margin-bottom: 10px;">${course?.title || 'Курс'}</h3>
|
||
<p style="color: var(--gray-500); margin-bottom: 15px;">Выдан ${formatDate(cert.issueDate)}</p>
|
||
<span class="badge badge-success">Действителен до ${formatDate(cert.validUntil)}</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('')}
|
||
</div>
|
||
` : `
|
||
<div class="empty-state">
|
||
<div class="empty-state-icon">🏆</div>
|
||
<div class="empty-state-title">Нет сертификатов</div>
|
||
<div class="empty-state-text">Завершите курсы чтобы получить сертификаты</div>
|
||
</div>
|
||
`}
|
||
`;
|
||
|
||
renderLayout(container, content);
|
||
}
|
||
|
||
function viewCertificate(id) {
|
||
const cert = state.userData.certificates.find(c => c.id === id);
|
||
const course = state.allData.courses.find(c => c.id === cert.courseId);
|
||
const user = state.userData.user;
|
||
|
||
const certWindow = window.open('', '_blank');
|
||
certWindow.document.write(`
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<title>Сертификат - ${user.name}</title>
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body {
|
||
font-family: 'Times New Roman', serif;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
min-height: 100vh;
|
||
background: #f0f0f0;
|
||
}
|
||
.certificate {
|
||
background: white;
|
||
border: 10px solid #1e3a5f;
|
||
padding: 60px;
|
||
text-align: center;
|
||
max-width: 800px;
|
||
position: relative;
|
||
}
|
||
.logo { font-size: 60px; margin-bottom: 20px; }
|
||
.title { font-size: 42px; font-weight: 700; color: #1e3a5f; margin-bottom: 10px; }
|
||
.subtitle { font-size: 18px; color: #666; margin-bottom: 40px; }
|
||
.name {
|
||
font-size: 36px;
|
||
font-weight: 700;
|
||
color: #333;
|
||
border-bottom: 3px solid #1e3a5f;
|
||
display: inline-block;
|
||
padding: 0 60px 15px;
|
||
margin-bottom: 30px;
|
||
}
|
||
.course { font-size: 24px; color: #1e3a5f; margin-bottom: 30px; }
|
||
.date { font-size: 14px; color: #999; }
|
||
.number { position: absolute; bottom: 20px; right: 40px; font-size: 12px; color: #ccc; }
|
||
@media print {
|
||
body { background: white; }
|
||
.no-print { display: none; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<button class="no-print" onclick="window.print(); return false;" style="position: fixed; top: 20px; left: 20px; padding: 10px 20px; cursor: pointer;">📄 Печать / Скачать PDF</button>
|
||
<div class="certificate">
|
||
<div class="logo">🎓</div>
|
||
<div class="title">СЕРТИФИКАТ</div>
|
||
<div class="subtitle">Настоящим подтверждается, что</div>
|
||
<div class="name">${user.name}</div>
|
||
<div class="subtitle">успешно завершил курс</div>
|
||
<div class="course">${course?.title || 'Курс'}</div>
|
||
<div class="date">${formatDate(cert.issueDate)}</div>
|
||
<div class="number">${cert.certificateNumber}</div>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
`);
|
||
certWindow.document.close();
|
||
}
|
||
|
||
// Кабинет руководителя
|
||
async function renderManagerDashboard(container) {
|
||
const managerData = await api.get(`/api/manager/${state.user.id}`);
|
||
const data = managerData.data;
|
||
|
||
if (!data) {
|
||
container.innerHTML = '<div class="empty-state"><p>Нет данных по сотрудникам</p></div>';
|
||
return;
|
||
}
|
||
|
||
const content = `
|
||
<div class="stats-grid">
|
||
<div class="stat-card">
|
||
<div class="stat-value">${data.totalEmployees}</div>
|
||
<div class="stat-label">Сотрудников</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value">${data.totalAssignments}</div>
|
||
<div class="stat-label">Назначено курсов</div>
|
||
</div>
|
||
<div class="stat-card success">
|
||
<div class="stat-value">${data.completed}</div>
|
||
<div class="stat-label">Завершено</div>
|
||
</div>
|
||
<div class="stat-card danger">
|
||
<div class="stat-value">${data.overdue}</div>
|
||
<div class="stat-label">Просрочено</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h2 class="card-title">Сотрудники</h2>
|
||
</div>
|
||
<table class="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>ФИО</th>
|
||
<th>Должность</th>
|
||
<th>Назначено</th>
|
||
<th>Завершено</th>
|
||
<th>В процессе</th>
|
||
<th>Просрочено</th>
|
||
<th>Действия</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${data.employees.map(emp => `
|
||
<tr>
|
||
<td>${emp.name}</td>
|
||
<td>${emp.position}</td>
|
||
<td>${emp.assignmentsCount}</td>
|
||
<td><span class="badge badge-success">${emp.completedCount}</span></td>
|
||
<td><span class="badge badge-info">${emp.inProgressCount}</span></td>
|
||
<td><span class="badge badge-danger">${emp.overdueCount}</span></td>
|
||
<td>
|
||
<button class="btn btn-sm btn-outline" onclick="alert('Детали сотрудника: ${emp.name}')">
|
||
Подробнее
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
`;
|
||
|
||
renderLayout(container, content);
|
||
}
|
||
|
||
// Панель администратора
|
||
async function renderAdminDashboard(container) {
|
||
const dashboard = await api.get('/api/admin/dashboard');
|
||
const data = dashboard.data;
|
||
|
||
const content = `
|
||
<div class="stats-grid">
|
||
<div class="stat-card">
|
||
<div class="stat-value">${data.totalUsers}</div>
|
||
<div class="stat-label">Сотрудников</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value">${data.totalCourses}</div>
|
||
<div class="stat-label">Курсов</div>
|
||
</div>
|
||
<div class="stat-card success">
|
||
<div class="stat-value">${data.completionRate}%</div>
|
||
<div class="stat-label">Завершение</div>
|
||
</div>
|
||
<div class="stat-card danger">
|
||
<div class="stat-value">${data.overdueAssignments}</div>
|
||
<div class="stat-label">Просрочено</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value">${data.avgScore}%</div>
|
||
<div class="stat-label">Средний балл</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h2 class="card-title">Быстрые действия</h2>
|
||
</div>
|
||
<div style="display: flex; gap: 15px; flex-wrap: wrap;">
|
||
<button class="btn btn-primary" onclick="navigate('admin-courses')">Управление курсами</button>
|
||
<button class="btn btn-primary" onclick="navigate('admin-users')">Сотрудники</button>
|
||
<button class="btn btn-primary" onclick="navigate('admin-assignments')">Назначения</button>
|
||
<button class="btn btn-primary" onclick="navigate('admin-reports')">Отчёты</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
renderLayout(container, content);
|
||
}
|
||
|
||
function renderAdminCourses(container) {
|
||
const courses = state.allData?.courses || [];
|
||
|
||
const content = `
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||
<h2 style="font-size: 20px;">Все курсы</h2>
|
||
<button class="btn btn-primary" onclick="state.editCourseId = null; navigate('admin-course-builder')">+ Создать курс</button>
|
||
</div>
|
||
<table class="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Название</th>
|
||
<th>Категория</th>
|
||
<th>Автор</th>
|
||
<th>Длительность</th>
|
||
<th>Уроков</th>
|
||
<th>Статус</th>
|
||
<th>Действия</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${courses.map(c => {
|
||
const category = state.allData.categories.find(cat => cat.id === c.categoryId);
|
||
const lessonsCount = c.modules.reduce((sum, m) => sum + m.lessons.length, 0);
|
||
return `
|
||
<tr>
|
||
<td>${c.title}</td>
|
||
<td><span class="badge" style="background: ${category?.color || '#gray'}; color: white;">${category?.name || 'Без категории'}</span></td>
|
||
<td>${c.author}</td>
|
||
<td>${c.duration} ч</td>
|
||
<td>${lessonsCount}</td>
|
||
<td><span class="badge ${c.status === 'published' ? 'badge-success' : 'badge-warning'}">${c.status === 'published' ? 'Опубликован' : 'Черновик'}</span></td>
|
||
<td>
|
||
<button class="btn btn-sm btn-outline" onclick="state.editCourseId = ${c.id}; navigate('admin-course-builder')">✏️</button>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('')}
|
||
</tbody>
|
||
</table>
|
||
`;
|
||
|
||
renderLayout(container, content);
|
||
}
|
||
|
||
function renderAdminUsers(container) {
|
||
const users = state.allData?.users || [];
|
||
|
||
const content = `
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||
<h2 style="font-size: 20px;">Сотрудники</h2>
|
||
<button class="btn btn-primary" onclick="alert('Добавление сотрудника в разработке')">+ Добавить</button>
|
||
</div>
|
||
<table class="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>ФИО</th>
|
||
<th>Роль</th>
|
||
<th>Подразделение</th>
|
||
<th>Должность</th>
|
||
<th>Логин</th>
|
||
<th>Действия</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${users.map(u => `
|
||
<tr>
|
||
<td>${u.name}</td>
|
||
<td><span class="badge ${u.role === 'admin' ? 'badge-danger' : u.role === 'manager' ? 'badge-info' : 'badge-secondary'}">${u.role === 'admin' ? 'Админ' : u.role === 'manager' ? 'Руководитель' : 'Сотрудник'}</span></td>
|
||
<td>${u.department}</td>
|
||
<td>${u.position}</td>
|
||
<td>${u.login}</td>
|
||
<td>
|
||
<button class="btn btn-sm btn-outline" onclick="alert('Редактирование: ${u.name}')">✏️</button>
|
||
</td>
|
||
</tr>
|
||
`).join('')}
|
||
</tbody>
|
||
</table>
|
||
`;
|
||
|
||
renderLayout(container, content);
|
||
}
|
||
|
||
function renderAdminAssignments(container) {
|
||
const assignments = state.allData?.assignments || [];
|
||
|
||
const content = `
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||
<h2 style="font-size: 20px;">Назначения курсов</h2>
|
||
<button class="btn btn-primary" onclick="navigate('admin-assignment-create')">+ Назначить курс</button>
|
||
</div>
|
||
<table class="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Курс</th>
|
||
<th>Сотрудник</th>
|
||
<th>Дата начала</th>
|
||
<th>Срок</th>
|
||
<th>Статус</th>
|
||
<th>Обязательно</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${assignments.slice(0, 20).map(a => {
|
||
const course = state.allData.courses.find(c => c.id === a.courseId);
|
||
const user = state.allData.users.find(u => u.id === a.userId);
|
||
return `
|
||
<tr>
|
||
<td>${course?.title || 'Курс'}</td>
|
||
<td>${user?.name || 'Сотрудник'}</td>
|
||
<td>${formatDate(a.startDate)}</td>
|
||
<td>${formatDate(a.dueDate)}</td>
|
||
<td><span class="badge badge-${a.status === 'completed' ? 'success' : a.status === 'overdue' ? 'danger' : 'info'}">${a.status}</span></td>
|
||
<td>${a.mandatory ? '✓' : ''}</td>
|
||
</tr>
|
||
`;
|
||
}).join('')}
|
||
</tbody>
|
||
</table>
|
||
`;
|
||
|
||
renderLayout(container, content);
|
||
}
|
||
|
||
function renderAdminReports(container) {
|
||
const content = `
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h2 class="card-title">Формирование отчётов</h2>
|
||
</div>
|
||
<div style="display: flex; flex-direction: column; gap: 15px; max-width: 600px;">
|
||
<div>
|
||
<label class="form-label">Тип отчёта</label>
|
||
<select class="form-input" id="reportType">
|
||
<option value="employees">По сотрудникам</option>
|
||
<option value="departments">По подразделениям</option>
|
||
<option value="courses">По курсам</option>
|
||
<option value="tests">По тестированию</option>
|
||
<option value="overdue">По просроченному обучению</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="form-label">Период</label>
|
||
<div style="display: flex; gap: 10px;">
|
||
<input type="date" class="form-input" id="reportFrom" value="${new Date(new Date().getTime() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]}">
|
||
<span style="align-self: center;">—</span>
|
||
<input type="date" class="form-input" id="reportTo" value="${new Date().toISOString().split('T')[0]}">
|
||
</div>
|
||
</div>
|
||
<div style="display: flex; gap: 10px; margin-top: 20px;">
|
||
<button class="btn btn-primary" onclick="generateReport()">Сформировать</button>
|
||
<button class="btn btn-outline" onclick="exportReport()">📊 CSV</button>
|
||
<button class="btn btn-outline" onclick="window.print()">🖨 Печать</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="reportResult" style="margin-top: 30px;"></div>
|
||
`;
|
||
|
||
renderLayout(container, content);
|
||
}
|
||
|
||
let currentReportData = null;
|
||
|
||
function generateReport() {
|
||
const type = document.getElementById('reportType').value;
|
||
const from = document.getElementById('reportFrom').value;
|
||
const to = document.getElementById('reportTo').value;
|
||
|
||
let data = [];
|
||
let columns = [];
|
||
|
||
if (type === 'employees') {
|
||
columns = ['Сотрудник', 'Подразделение', 'Должность', 'Назначено', 'Завершено', 'В процессе', 'Просрочено'];
|
||
data = state.allData.users.filter(u => u.role === 'employee').map(u => {
|
||
const assignments = state.allData.assignments.filter(a => a.userId === u.id);
|
||
return {
|
||
employee: u.name,
|
||
department: u.department,
|
||
position: u.position,
|
||
assigned: assignments.length,
|
||
completed: assignments.filter(a => a.status === 'completed').length,
|
||
inProgress: assignments.filter(a => a.status === 'in_progress').length,
|
||
overdue: assignments.filter(a => a.status === 'overdue').length
|
||
};
|
||
});
|
||
} else if (type === 'departments') {
|
||
columns = ['Подразделение', 'Сотрудников', 'Назначений', 'Завершено', 'Просрочено', '% завершения'];
|
||
data = state.allData.departments.map(d => {
|
||
const deptUsers = state.allData.users.filter(u => u.department === d.name && u.role === 'employee');
|
||
const assignments = state.allData.assignments.filter(a => deptUsers.some(u => u.id === a.userId));
|
||
const completed = assignments.filter(a => a.status === 'completed').length;
|
||
return {
|
||
department: d.name,
|
||
employees: deptUsers.length,
|
||
assigned: assignments.length,
|
||
completed,
|
||
overdue: assignments.filter(a => a.status === 'overdue').length,
|
||
completionRate: assignments.length > 0 ? Math.round((completed / assignments.length) * 100) : 0
|
||
};
|
||
});
|
||
} else if (type === 'courses') {
|
||
columns = ['Курс', 'Категория', 'Назначений', 'Завершено', 'В процессе', 'Просрочено', 'Ср. балл'];
|
||
data = state.allData.courses.map(c => {
|
||
const assignments = state.allData.assignments.filter(a => a.courseId === c.id);
|
||
const attempts = state.allData.quizAttempts.filter(q => {
|
||
const assignment = state.allData.assignments.find(a => a.id === q.assignmentId);
|
||
return assignment && assignment.courseId === c.id;
|
||
});
|
||
const avgScore = attempts.length > 0 ? Math.round(attempts.reduce((sum, q) => sum + q.score, 0) / attempts.length) : 0;
|
||
return {
|
||
course: c.title,
|
||
category: state.allData.categories.find(cat => cat.id === c.categoryId)?.name || '',
|
||
assigned: assignments.length,
|
||
completed: assignments.filter(a => a.status === 'completed').length,
|
||
inProgress: assignments.filter(a => a.status === 'in_progress').length,
|
||
overdue: assignments.filter(a => a.status === 'overdue').length,
|
||
avgScore
|
||
};
|
||
});
|
||
} else if (type === 'tests') {
|
||
columns = ['Сотрудник', 'Курс', 'Дата', 'Балл', 'Результат', 'Попытка'];
|
||
const quizAttempts = state.allData.quizAttempts.map(q => {
|
||
const assignment = state.allData.assignments.find(a => a.id === q.assignmentId);
|
||
const user = state.allData.users.find(u => u.id === assignment?.userId);
|
||
const course = state.allData.courses.find(c => c.id === assignment?.courseId);
|
||
return {
|
||
employee: user?.name || '',
|
||
course: course?.title || '',
|
||
date: formatDateTime(q.attemptAt),
|
||
score: q.score,
|
||
passed: q.passed ? 'Пройден' : 'Не пройден',
|
||
attempt: q.id
|
||
};
|
||
});
|
||
data = quizAttempts;
|
||
} else if (type === 'overdue') {
|
||
columns = ['Сотрудник', 'Курс', 'Дата назначения', 'Срок', 'Дней просрочки'];
|
||
const today = new Date();
|
||
data = state.allData.assignments.filter(a => a.status === 'overdue').map(a => {
|
||
const user = state.allData.users.find(u => u.id === a.userId);
|
||
const course = state.allData.courses.find(c => c.id === a.courseId);
|
||
const dueDate = new Date(a.dueDate);
|
||
const daysOverdue = Math.floor((today - dueDate) / (1000 * 60 * 60 * 24));
|
||
return {
|
||
employee: user?.name || '',
|
||
course: course?.title || '',
|
||
assigned: formatDate(a.startDate),
|
||
due: formatDate(a.dueDate),
|
||
daysOverdue
|
||
};
|
||
});
|
||
}
|
||
|
||
currentReportData = { data, columns };
|
||
|
||
const resultDiv = document.getElementById('reportResult');
|
||
if (data.length === 0) {
|
||
resultDiv.innerHTML = '<div class="empty-state"><p>Нет данных за выбранный период</p></div>';
|
||
return;
|
||
}
|
||
|
||
resultDiv.innerHTML = `
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h3 class="card-title">Результаты отчёта (${data.length} записей)</h3>
|
||
</div>
|
||
<table class="data-table">
|
||
<thead>
|
||
<tr>${columns.map(c => `<th>${c}</th>`).join('')}</tr>
|
||
</thead>
|
||
<tbody>
|
||
${data.map(row => `
|
||
<tr>${columns.map(c => `<td>${row[c.toLowerCase()] || row[c] || ''}</td>`).join('')}</tr>
|
||
`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function exportReport() {
|
||
if (!currentReportData || currentReportData.data.length === 0) {
|
||
alert('Сначала сформируйте отчёт');
|
||
return;
|
||
}
|
||
|
||
const { data, columns } = currentReportData;
|
||
const csvData = data.map(row => {
|
||
const csvRow = {};
|
||
columns.forEach(col => {
|
||
const key = col.toLowerCase();
|
||
csvRow[col] = row[key] || row[col] || '';
|
||
});
|
||
return csvRow;
|
||
});
|
||
|
||
const filename = `otchet_${new Date().toISOString().split('T')[0]}.csv`;
|
||
exportReportCSV(csvData, filename);
|
||
}
|
||
|
||
// Конструктор курсов
|
||
function renderAdminCourseBuilder(container) {
|
||
const courseId = state.editCourseId;
|
||
const course = courseId ? state.allData.courses.find(c => c.id === courseId) : null;
|
||
|
||
const content = `
|
||
<div style="max-width: 900px;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||
<h2 style="font-size: 20px;">${course ? 'Редактирование курса' : 'Создание курса'}</h2>
|
||
<button class="btn btn-outline" onclick="navigate('admin-courses')">← Назад к списку</button>
|
||
</div>
|
||
|
||
<div class="card" style="margin-bottom: 20px;">
|
||
<div class="card-header"><h3 class="card-title">Основная информация</h3></div>
|
||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px;">
|
||
<div>
|
||
<label class="form-label">Название курса</label>
|
||
<input type="text" class="form-input" id="courseTitle" value="${course?.title || ''}" placeholder="Введите название">
|
||
</div>
|
||
<div>
|
||
<label class="form-label">Категория</label>
|
||
<select class="form-input" id="courseCategory">
|
||
${state.allData.categories.map(c => `<option value="${c.id}" ${course?.categoryId === c.id ? 'selected' : ''}>${c.name}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
<div style="grid-column: 1/-1;">
|
||
<label class="form-label">Описание</label>
|
||
<textarea class="form-input" id="courseDescription" rows="3" placeholder="Описание курса">${course?.description || ''}</textarea>
|
||
</div>
|
||
<div>
|
||
<label class="form-label">Автор</label>
|
||
<input type="text" class="form-input" id="courseAuthor" value="${course?.author || ''}" placeholder="ФИО автора">
|
||
</div>
|
||
<div>
|
||
<label class="form-label">Длительность (часов)</label>
|
||
<input type="number" class="form-input" id="courseDuration" value="${course?.duration || ''}" placeholder="8">
|
||
</div>
|
||
<div>
|
||
<label class="form-label">Проходной балл (%)</label>
|
||
<input type="number" class="form-input" id="coursePassScore" value="${course?.passScore || 70}" placeholder="70">
|
||
</div>
|
||
<div>
|
||
<label class="form-label">Попыток на тест</label>
|
||
<input type="number" class="form-input" id="courseMaxAttempts" value="${course?.maxAttempts || 3}" placeholder="3">
|
||
</div>
|
||
<div style="grid-column: 1/-1;">
|
||
<label class="form-label">Обложка (URL)</label>
|
||
<input type="text" class="form-input" id="courseCover" value="${course?.cover || 'https://picsum.photos/seed/course/400/225'}" placeholder="https://...">
|
||
</div>
|
||
<div style="grid-column: 1/-1;">
|
||
<label style="display: flex; align-items: center; gap: 8px;">
|
||
<input type="checkbox" id="courseSequential" ${course?.sequential ? 'checked' : ''}>
|
||
Последовательное прохождение (урок за уроком)
|
||
</label>
|
||
</div>
|
||
<div style="grid-column: 1/-1;">
|
||
<label style="display: flex; align-items: center; gap: 8px;">
|
||
<input type="checkbox" id="courseCertificate" ${course?.certificateEnabled ? 'checked' : ''}>
|
||
Выдавать сертификат после завершения
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" style="margin-bottom: 20px;">
|
||
<div class="card-header" style="display: flex; justify-content: space-between; align-items: center;">
|
||
<h3 class="card-title">Модули и уроки</h3>
|
||
<button class="btn btn-sm btn-primary" onclick="addModule()">+ Добавить модуль</button>
|
||
</div>
|
||
<div id="modulesContainer">
|
||
${course?.modules.map((module, mi) => `
|
||
<div style="border: 1px solid var(--gray-200); border-radius: var(--radius); padding: 15px; margin-bottom: 15px;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||
<input type="text" class="form-input" value="${module.title}" placeholder="Название модуля" style="flex: 1; margin-right: 10px;">
|
||
<button class="btn btn-sm btn-danger" onclick="removeModule(${mi})">Удалить</button>
|
||
</div>
|
||
<div style="margin-left: 20px;">
|
||
${module.lessons.map((lesson, li) => `
|
||
<div style="display: flex; gap: 10px; align-items: center; margin-bottom: 10px; padding: 10px; background: var(--gray-50); border-radius: var(--radius);">
|
||
<span style="font-size: 18px;">${lesson.type === 'video' ? '🎥' : lesson.type === 'quiz' ? '📝' : '📄'}</span>
|
||
<span style="flex: 1; font-size: 14px;">${lesson.title}</span>
|
||
<span style="font-size: 12px; color: var(--gray-500);">${lesson.duration} мин</span>
|
||
<button class="btn btn-sm btn-outline" onclick="editLesson(${mi}, ${li})">✏️</button>
|
||
<button class="btn btn-sm btn-danger" onclick="removeLesson(${mi}, ${li})">✕</button>
|
||
</div>
|
||
`).join('')}
|
||
<button class="btn btn-sm btn-outline" onclick="addLesson(${mi})">+ Добавить урок</button>
|
||
</div>
|
||
</div>
|
||
`).join('') || '<p style="color: var(--gray-500); text-align: center; padding: 20px;">Модули не добавлены</p>'}
|
||
</div>
|
||
</div>
|
||
|
||
<div style="display: flex; gap: 10px; justify-content: flex-end;">
|
||
<button class="btn btn-outline" onclick="saveCourseDraft()">💾 Сохранить как черновик</button>
|
||
<button class="btn btn-success" onclick="publishCourse()">🚀 Опубликовать</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
renderLayout(container, content);
|
||
}
|
||
|
||
// Создание назначения
|
||
function renderAdminAssignmentCreate(container) {
|
||
const content = `
|
||
<div style="max-width: 700px;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||
<h2 style="font-size: 20px;">Назначение курса</h2>
|
||
<button class="btn btn-outline" onclick="navigate('admin-assignments')">← Назад</button>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div style="display: grid; gap: 20px;">
|
||
<div>
|
||
<label class="form-label">Выберите курс</label>
|
||
<select class="form-input" id="assignCourse">
|
||
<option value="">-- Выберите курс --</option>
|
||
${state.allData.courses.filter(c => c.status === 'published').map(c => `
|
||
<option value="${c.id}">${c.title}</option>
|
||
`).join('')}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="form-label">Кому назначить</label>
|
||
<select class="form-input" id="assignType" onchange="toggleAssignTarget()">
|
||
<option value="users">Отдельным сотрудникам</option>
|
||
<option value="department">Подразделению</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div id="usersSelect">
|
||
<label class="form-label">Сотрудники</label>
|
||
<div style="max-height: 200px; overflow-y: auto; border: 1px solid var(--gray-300); border-radius: var(--radius); padding: 10px;">
|
||
${state.allData.users.filter(u => u.role === 'employee').map(u => `
|
||
<label style="display: flex; align-items: center; gap: 8px; padding: 5px 0;">
|
||
<input type="checkbox" class="user-checkbox" value="${u.id}">
|
||
<span>${u.name} (${u.department})</span>
|
||
</label>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
|
||
<div id="departmentSelect" class="hidden">
|
||
<label class="form-label">Подразделение</label>
|
||
<select class="form-input" id="assignDepartment">
|
||
${state.allData.departments.map(d => `<option value="${d.id}">${d.name}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
|
||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px;">
|
||
<div>
|
||
<label class="form-label">Дата начала</label>
|
||
<input type="date" class="form-input" id="assignStartDate" value="${new Date().toISOString().split('T')[0]}">
|
||
</div>
|
||
<div>
|
||
<label class="form-label">Срок завершения</label>
|
||
<input type="date" class="form-input" id="assignDueDate" value="${new Date(new Date().getTime() + 14 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]}">
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label style="display: flex; align-items: center; gap: 8px;">
|
||
<input type="checkbox" id="assignMandatory" checked>
|
||
Обязательное обучение
|
||
</label>
|
||
</div>
|
||
|
||
<div style="display: flex; gap: 10px; margin-top: 10px;">
|
||
<button class="btn btn-primary" onclick="createAssignment()">📋 Назначить курс</button>
|
||
<button class="btn btn-outline" onclick="navigate('admin-assignments')">Отмена</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
renderLayout(container, content);
|
||
}
|
||
|
||
function toggleAssignTarget() {
|
||
const type = document.getElementById('assignType').value;
|
||
document.getElementById('usersSelect').classList.toggle('hidden', type === 'department');
|
||
document.getElementById('departmentSelect').classList.toggle('hidden', type === 'users');
|
||
}
|
||
|
||
async function createAssignment() {
|
||
const courseId = parseInt(document.getElementById('assignCourse').value);
|
||
if (!courseId) {
|
||
alert('Выберите курс');
|
||
return;
|
||
}
|
||
|
||
const assignType = document.getElementById('assignType').value;
|
||
let userIds = [];
|
||
|
||
if (assignType === 'users') {
|
||
userIds = Array.from(document.querySelectorAll('.user-checkbox:checked')).map(cb => parseInt(cb.value));
|
||
} else {
|
||
const deptId = parseInt(document.getElementById('assignDepartment').value);
|
||
userIds = state.allData.users
|
||
.filter(u => u.department === state.allData.departments.find(d => d.id === deptId)?.name)
|
||
.map(u => u.id);
|
||
}
|
||
|
||
if (userIds.length === 0) {
|
||
alert('Выберите хотя бы одного сотрудника');
|
||
return;
|
||
}
|
||
|
||
const startDate = document.getElementById('assignStartDate').value;
|
||
const dueDate = document.getElementById('assignDueDate').value;
|
||
const mandatory = document.getElementById('assignMandatory').checked;
|
||
|
||
const result = await api.post('/api/assignments', {
|
||
courseId,
|
||
userIds,
|
||
assignedBy: state.user.id,
|
||
startDate,
|
||
dueDate,
|
||
mandatory
|
||
});
|
||
|
||
if (result.ok) {
|
||
alert(`Курс назначен ${userIds.length} сотрудникам(а)`);
|
||
navigate('admin-assignments');
|
||
} else {
|
||
alert('Ошибка при назначении');
|
||
}
|
||
}
|
||
|
||
function addModule() {
|
||
alert('Функция добавления модуля в разработке - используйте API напрямую');
|
||
}
|
||
|
||
function addLesson(moduleIndex) {
|
||
alert('Функция добавления урока в разработке - используйте API напрямую');
|
||
}
|
||
|
||
function removeModule(index) {
|
||
if (confirm('Удалить этот модуль со всеми уроками?')) {
|
||
// Логика удаления
|
||
}
|
||
}
|
||
|
||
function removeLesson(moduleIndex, lessonIndex) {
|
||
if (confirm('Удалить этот урок?')) {
|
||
// Логика удаления
|
||
}
|
||
}
|
||
|
||
function editLesson(moduleIndex, lessonIndex) {
|
||
alert('Редактирование урока в разработке');
|
||
}
|
||
|
||
async function saveCourseDraft() {
|
||
const title = document.getElementById('courseTitle').value;
|
||
const description = document.getElementById('courseDescription').value;
|
||
const categoryId = parseInt(document.getElementById('courseCategory').value);
|
||
const author = document.getElementById('courseAuthor').value;
|
||
const duration = parseInt(document.getElementById('courseDuration').value);
|
||
const passScore = parseInt(document.getElementById('coursePassScore').value);
|
||
const maxAttempts = parseInt(document.getElementById('courseMaxAttempts').value);
|
||
const cover = document.getElementById('courseCover').value;
|
||
const sequential = document.getElementById('courseSequential').checked;
|
||
const certificateEnabled = document.getElementById('courseCertificate').checked;
|
||
|
||
if (!title || !description) {
|
||
alert('Заполните название и описание');
|
||
return;
|
||
}
|
||
|
||
const result = await api.post('/api/courses', {
|
||
title,
|
||
description,
|
||
categoryId,
|
||
author,
|
||
duration,
|
||
passScore,
|
||
maxAttempts,
|
||
cover,
|
||
sequential,
|
||
certificateEnabled,
|
||
modules: []
|
||
});
|
||
|
||
if (result.ok) {
|
||
alert('Черновик сохранён');
|
||
state.editCourseId = result.course.id;
|
||
navigate('admin-courses');
|
||
}
|
||
}
|
||
|
||
async function publishCourse() {
|
||
if (!state.editCourseId) {
|
||
alert('Сначала сохраните курс как черновик');
|
||
return;
|
||
}
|
||
|
||
const result = await api.put(`/api/courses/${state.editCourseId}`, {
|
||
status: 'published'
|
||
});
|
||
|
||
if (result.ok) {
|
||
alert('Курс опубликован!');
|
||
navigate('admin-courses');
|
||
}
|
||
}
|
||
|
||
// Экспорт отчётов в CSV
|
||
function exportReportCSV(data, filename) {
|
||
if (!data || data.length === 0) {
|
||
alert('Нет данных для экспорта');
|
||
return;
|
||
}
|
||
|
||
const headers = Object.keys(data[0]);
|
||
const csv = [
|
||
headers.join(','),
|
||
...data.map(row => headers.map(h => `"${String(row[h] || '').replace(/"/g, '""')}"`).join(','))
|
||
].join('\n');
|
||
|
||
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
|
||
const link = document.createElement('a');
|
||
link.href = URL.createObjectURL(blob);
|
||
link.download = filename;
|
||
link.click();
|
||
}
|
||
|
||
// Инициализация
|
||
render();
|