sozday-sovremennyy-adaptivny/app.js

1780 lines
71 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Состояние приложения
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();