940 lines
42 KiB
HTML
940 lines
42 KiB
HTML
<!doctype html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Трекер обучения сотрудников</title>
|
||
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
|
||
<style>
|
||
:root {
|
||
--primary: #2563eb;
|
||
--primary-dark: #1d4ed8;
|
||
--success: #10b981;
|
||
--warning: #f59e0b;
|
||
--danger: #ef4444;
|
||
--bg: #f8fafc;
|
||
--card-bg: #ffffff;
|
||
--text: #1e293b;
|
||
--text-muted: #64748b;
|
||
--border: #e2e8f0;
|
||
--shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06);
|
||
--shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05);
|
||
}
|
||
* { margin: 0; box-sizing: border-box; }
|
||
body { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; }
|
||
|
||
.container { max-width: 1400px; margin: 0 auto; padding: 24px; }
|
||
|
||
header { background: var(--card-bg); border-bottom: 1px solid var(--border); padding: 20px 0; margin-bottom: 24px; }
|
||
header .container { display: flex; justify-content: space-between; align-items: center; }
|
||
h1 { font-size: 28px; font-weight: 700; color: var(--text); }
|
||
.subtitle { color: var(--text-muted); font-size: 14px; margin-top: 4px; }
|
||
|
||
.btn { display: inline-flex; align-items: center; gap: 8px; padding: 10px 16px; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s; }
|
||
.btn-primary { background: var(--primary); color: white; }
|
||
.btn-primary:hover { background: var(--primary-dark); }
|
||
.btn-secondary { background: var(--bg); color: var(--text); border: 1px solid var(--border); }
|
||
.btn-secondary:hover { background: var(--border); }
|
||
.btn-success { background: var(--success); color: white; }
|
||
.btn-danger { background: var(--danger); color: white; }
|
||
.btn-sm { padding: 6px 12px; font-size: 13px; }
|
||
|
||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
||
.card { background: var(--card-bg); border-radius: 12px; padding: 20px; box-shadow: var(--shadow); }
|
||
.card h3 { font-size: 14px; color: var(--text-muted); font-weight: 500; margin-bottom: 8px; }
|
||
.card .value { font-size: 32px; font-weight: 700; color: var(--text); }
|
||
.card .delta { font-size: 13px; margin-top: 8px; }
|
||
.card .delta.up { color: var(--success); }
|
||
.card .delta.down { color: var(--danger); }
|
||
|
||
.section { background: var(--card-bg); border-radius: 12px; padding: 20px; margin-bottom: 24px; box-shadow: var(--shadow); }
|
||
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||
.section h2 { font-size: 18px; font-weight: 600; }
|
||
|
||
.filters { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
|
||
.filters select, .filters input { padding: 8px 12px; border: 1px solid var(--border); border-radius: 8px; font-size: 14px; background: white; }
|
||
.filters select:focus, .filters input:focus { outline: 2px solid var(--primary); border-color: transparent; }
|
||
|
||
.table-container { overflow-x: auto; }
|
||
table { width: 100%; border-collapse: collapse; }
|
||
th, td { padding: 12px; text-align: left; border-bottom: 1px solid var(--border); }
|
||
th { font-size: 13px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; background: var(--bg); }
|
||
td { font-size: 14px; }
|
||
tr:hover { background: var(--bg); }
|
||
|
||
.status { display: inline-block; padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 500; }
|
||
.status-pending { background: #fef3c7; color: #92400e; }
|
||
.status-progress { background: #dbeafe; color: #1e40af; }
|
||
.status-completed { background: #d1fae5; color: #065f46; }
|
||
.status-overdue { background: #fee2e2; color: #991b1b; }
|
||
|
||
.dept-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; }
|
||
.dept-card { background: var(--bg); border-radius: 8px; padding: 16px; }
|
||
.dept-card h4 { font-size: 15px; margin-bottom: 12px; }
|
||
.dept-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 13px; border-bottom: 1px solid var(--border); }
|
||
.dept-row:last-child { border-bottom: none; }
|
||
|
||
.tabs { display: flex; gap: 4px; margin-bottom: 20px; border-bottom: 2px solid var(--border); }
|
||
.tab { padding: 12px 20px; cursor: pointer; font-weight: 500; color: var(--text-muted); border-bottom: 2px solid transparent; margin-bottom: -2px; transition: all 0.2s; }
|
||
.tab:hover { color: var(--text); }
|
||
.tab.active { color: var(--primary); border-bottom-color: var(--primary); }
|
||
|
||
.hidden { display: none; }
|
||
|
||
/* Modal */
|
||
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
||
.modal { background: var(--card-bg); border-radius: 12px; padding: 24px; width: 100%; max-width: 500px; max-height: 90vh; overflow-y: auto; }
|
||
.modal h3 { font-size: 20px; margin-bottom: 20px; }
|
||
.form-group { margin-bottom: 16px; }
|
||
.form-group label { display: block; font-size: 14px; font-weight: 500; margin-bottom: 6px; }
|
||
.form-group input, .form-group select, .form-group textarea { width: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; font-size: 14px; }
|
||
.form-group input:focus, .form-group select:focus, .form-group textarea:focus { outline: 2px solid var(--primary); border-color: transparent; }
|
||
.form-actions { display: flex; gap: 12px; justify-content: flex-end; margin-top: 20px; }
|
||
|
||
.courses-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
|
||
.course-card { background: var(--bg); border-radius: 8px; padding: 16px; cursor: pointer; transition: transform 0.2s; }
|
||
.course-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-lg); }
|
||
.course-card h4 { font-size: 16px; margin-bottom: 8px; }
|
||
.course-card .meta { font-size: 13px; color: var(--text-muted); margin-bottom: 12px; }
|
||
.course-stats { display: flex; gap: 16px; font-size: 13px; }
|
||
.course-stat { display: flex; flex-direction: column; }
|
||
.course-stat .num { font-weight: 700; font-size: 18px; }
|
||
|
||
.alert { padding: 12px 16px; border-radius: 8px; margin-bottom: 16px; font-size: 14px; }
|
||
.alert-info { background: #dbeafe; color: #1e40af; }
|
||
.alert-success { background: #d1fae5; color: #065f46; }
|
||
.alert-warning { background: #fef3c7; color: #92400e; }
|
||
|
||
.progress-bar { height: 8px; background: var(--border); border-radius: 4px; overflow: hidden; margin-top: 8px; }
|
||
.progress-fill { height: 100%; background: var(--primary); transition: width 0.3s; }
|
||
|
||
.empty-state { text-align: center; padding: 60px 20px; color: var(--text-muted); }
|
||
.empty-state h3 { font-size: 18px; margin-bottom: 8px; color: var(--text); }
|
||
|
||
@media (max-width: 768px) {
|
||
.container { padding: 16px; }
|
||
header .container { flex-direction: column; gap: 16px; text-align: center; }
|
||
.filters { flex-direction: column; }
|
||
.filters select, .filters input { width: 100%; }
|
||
.cards { grid-template-columns: repeat(2, 1fr); }
|
||
.dept-stats { grid-template-columns: 1fr; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<div class="container">
|
||
<div>
|
||
<h1>📚 Трекер обучения</h1>
|
||
<p class="subtitle">Управление курсами и контроль прохождения сотрудниками</p>
|
||
</div>
|
||
<div style="display: flex; gap: 12px;">
|
||
<button class="btn btn-secondary" onclick="openImportModal()">📥 Импорт</button>
|
||
<button class="btn btn-secondary" onclick="exportToExcel()">📊 Экспорт Excel</button>
|
||
<button class="btn btn-primary" onclick="openRecordModal()">+ Запись</button>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="container">
|
||
<!-- Сводные карточки -->
|
||
<div class="cards">
|
||
<div class="card">
|
||
<h3>Всего курсов</h3>
|
||
<div class="value" id="statCourses">0</div>
|
||
<div class="delta" id="statCoursesDelta"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>Записей на обучение</h3>
|
||
<div class="value" id="statRecords">0</div>
|
||
<div class="delta" id="statRecordsDelta"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>Пройдено</h3>
|
||
<div class="value" id="statCompleted">0</div>
|
||
<div class="delta up" id="statCompletedDelta"></div>
|
||
<div class="progress-bar"><div class="progress-fill" id="progressCompleted" style="width: 0%"></div></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>Просрочено</h3>
|
||
<div class="value" id="statOverdue">0</div>
|
||
<div class="delta down" id="statOverdueDelta"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Вкладки -->
|
||
<div class="tabs">
|
||
<div class="tab active" onclick="switchTab('records')">Записи</div>
|
||
<div class="tab" onclick="switchTab('courses')">Курсы</div>
|
||
<div class="tab" onclick="switchTab('departments')">Сводка по отделам</div>
|
||
</div>
|
||
|
||
<!-- Вкладка: Записи -->
|
||
<div id="tab-records" class="section">
|
||
<div class="section-header">
|
||
<h2>Все записи на обучение</h2>
|
||
<button class="btn btn-primary btn-sm" onclick="openRecordModal()">+ Добавить запись</button>
|
||
</div>
|
||
|
||
<div class="filters">
|
||
<input type="text" id="filterSearch" placeholder="🔍 Поиск сотрудника..." oninput="applyFilters()">
|
||
<select id="filterDept" onchange="applyFilters()">
|
||
<option value="">Все отделы</option>
|
||
</select>
|
||
<select id="filterCourse" onchange="applyFilters()">
|
||
<option value="">Все курсы</option>
|
||
</select>
|
||
<select id="filterStatus" onchange="applyFilters()">
|
||
<option value="">Все статусы</option>
|
||
<option value="pending">Запланировано</option>
|
||
<option value="progress">В процессе</option>
|
||
<option value="completed">Пройдено</option>
|
||
<option value="overdue">Просрочено</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="table-container">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Сотрудник</th>
|
||
<th>Отдел</th>
|
||
<th>Курс</th>
|
||
<th>Дедлайн</th>
|
||
<th>Статус</th>
|
||
<th>Дата прохождения</th>
|
||
<th>Действия</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="recordsTable"></tbody>
|
||
</table>
|
||
</div>
|
||
<div id="recordsEmpty" class="empty-state hidden">
|
||
<h3>Нет записей</h3>
|
||
<p>Добавьте первую запись на обучение</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Вкладка: Курсы -->
|
||
<div id="tab-courses" class="section hidden">
|
||
<div class="section-header">
|
||
<h2>Каталог курсов</h2>
|
||
<button class="btn btn-primary btn-sm" onclick="openCourseModal()">+ Добавить курс</button>
|
||
</div>
|
||
<div class="courses-grid" id="coursesGrid"></div>
|
||
<div id="coursesEmpty" class="empty-state hidden">
|
||
<h3>Нет курсов</h3>
|
||
<p>Добавьте первый курс обучения</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Вкладка: Сводка по отделам -->
|
||
<div id="tab-departments" class="section hidden">
|
||
<div class="section-header">
|
||
<h2>Статистика по отделам</h2>
|
||
</div>
|
||
<div class="dept-stats" id="deptStats"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Модальное окно: Запись -->
|
||
<div id="recordModal" class="modal-overlay hidden">
|
||
<div class="modal">
|
||
<h3 id="recordModalTitle">Новая запись на обучение</h3>
|
||
<form id="recordForm" onsubmit="saveRecord(event)">
|
||
<input type="hidden" id="recordId">
|
||
<div class="form-group">
|
||
<label>Сотрудник *</label>
|
||
<input type="text" id="recordEmployee" required placeholder="ФИО сотрудника">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Отдел *</label>
|
||
<select id="recordDept" required>
|
||
<option value="">Выберите отдел</option>
|
||
<option value="IT">IT</option>
|
||
<option value="HR">HR</option>
|
||
<option value="Продажи">Продажи</option>
|
||
<option value="Маркетинг">Маркетинг</option>
|
||
<option value="Финансы">Финансы</option>
|
||
<option value="Юридический">Юридический</option>
|
||
<option value="Операционный">Операционный</option>
|
||
<option value="Безопасность">Безопасность</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Курс *</label>
|
||
<select id="recordCourse" required>
|
||
<option value="">Выберите курс</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Статус *</label>
|
||
<select id="recordStatus" required onchange="toggleCompletionDate()">
|
||
<option value="pending">Запланировано</option>
|
||
<option value="progress">В процессе</option>
|
||
<option value="completed">Пройдено</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Дедлайн *</label>
|
||
<input type="date" id="recordDeadline" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Дата прохождения</label>
|
||
<input type="date" id="recordCompletedDate">
|
||
</div>
|
||
<div class="form-actions">
|
||
<button type="button" class="btn btn-secondary" onclick="closeModal('recordModal')">Отмена</button>
|
||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Модальное окно: Курс -->
|
||
<div id="courseModal" class="modal-overlay hidden">
|
||
<div class="modal">
|
||
<h3 id="courseModalTitle">Новый курс</h3>
|
||
<form id="courseForm" onsubmit="saveCourse(event)">
|
||
<input type="hidden" id="courseId">
|
||
<div class="form-group">
|
||
<label>Название курса *</label>
|
||
<input type="text" id="courseName" required placeholder="Например: Кибербезопасность">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Категория</label>
|
||
<select id="courseCategory">
|
||
<option value="Обязательный">Обязательный</option>
|
||
<option value="Профессиональный">Профессиональный</option>
|
||
<option value="Развивающий">Развивающий</option>
|
||
<option value="По желанию">По желанию</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Описание</label>
|
||
<textarea id="courseDesc" rows="3" placeholder="Краткое описание курса"></textarea>
|
||
</div>
|
||
<div class="form-actions">
|
||
<button type="button" class="btn btn-secondary" onclick="closeModal('courseModal')">Отмена</button>
|
||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Модальное окно: Импорт -->
|
||
<div id="importModal" class="modal-overlay hidden">
|
||
<div class="modal">
|
||
<h3>Импорт данных</h3>
|
||
<div class="alert alert-info">
|
||
Загрузите CSV файл с полями: сотрудник, отдел, курс, дедлайн (YYYY-MM-DD), статус (pending/progress/completed), датаПрохождения (YYYY-MM-DD)
|
||
</div>
|
||
<div class="form-group">
|
||
<input type="file" id="importFile" accept=".csv,.xlsx,.xls">
|
||
</div>
|
||
<div id="importPreview" class="table-container hidden" style="max-height: 200px; overflow-y: auto; margin-bottom: 16px;">
|
||
<table>
|
||
<thead><tr><th>Сотрудник</th><th>Отдел</th><th>Курс</th><th>Статус</th></tr></thead>
|
||
<tbody id="importPreviewBody"></tbody>
|
||
</table>
|
||
</div>
|
||
<div class="form-actions">
|
||
<button type="button" class="btn btn-secondary" onclick="closeModal('importModal')">Отмена</button>
|
||
<button type="button" class="btn btn-primary" onclick="processImport()">Импортировать</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// === ДЕМО-ДАННЫЕ ===
|
||
const DEMO_COURSES = [
|
||
{ id: 1, name: "Кибербезопасность", category: "Обязательный", desc: "Основы информационной безопасности" },
|
||
{ id: 2, name: "Эффективные коммуникации", category: "Развивающий", desc: "Навыки делового общения" },
|
||
{ id: 3, name: "Excel для продвинутых", category: "Профессиональный", desc: "Сводные таблицы, макросы, формулы" },
|
||
{ id: 4, name: "Тайм-менеджмент", category: "Развивающий", desc: "Управление временем и приоритетами" },
|
||
{ id: 5, name: "Противодействие коррупции", category: "Обязательный", desc: "Комплаенс и этика бизнеса" },
|
||
{ id: 6, name: "Управление проектами", category: "Профессиональный", desc: "Методологии Agile и Waterfall" },
|
||
{ id: 7, name: "Охрана труда", category: "Обязательный", desc: "Техника безопасности на рабочем месте" }
|
||
];
|
||
|
||
const DEMO_EMPLOYEES = [
|
||
{ name: "Иванов Алексей", dept: "IT" }, { name: "Петрова Мария", dept: "IT" },
|
||
{ name: "Сидоров Дмитрий", dept: "IT" }, { name: "Кузнецова Ольга", dept: "HR" },
|
||
{ name: "Смирнов Андрей", dept: "HR" }, { name: "Васильев Игорь", dept: "Продажи" },
|
||
{ name: "Морозова Елена", dept: "Продажи" }, { name: "Новиков Сергей", dept: "Продажи" },
|
||
{ name: "Федорова Анна", dept: "Маркетинг" }, { name: "Козлов Максим", dept: "Маркетинг" },
|
||
{ name: "Павлова Наталья", dept: "Финансы" }, { name: "Соколов Артем", dept: "Финансы" },
|
||
{ name: "Михайлов Константин", dept: "Юридический" }, { name: "Ержанов Азамат", dept: "Операционный" },
|
||
{ name: "Ким Юлия", dept: "Безопасность" }, { name: "Трембаев Нурлан", dept: "IT" }
|
||
];
|
||
|
||
const DEMO_RECORDS = [
|
||
{ id: 1, employee: "Иванов Алексей", dept: "IT", courseId: 1, deadline: "2025-09-15", status: "completed", completedDate: "2025-09-10" },
|
||
{ id: 2, employee: "Иванов Алексей", dept: "IT", courseId: 3, deadline: "2025-10-01", status: "progress", completedDate: "" },
|
||
{ id: 3, employee: "Петрова Мария", dept: "IT", courseId: 1, deadline: "2025-09-15", status: "completed", completedDate: "2025-09-12" },
|
||
{ id: 4, employee: "Петрова Мария", dept: "IT", courseId: 6, deadline: "2025-11-20", status: "pending", completedDate: "" },
|
||
{ id: 5, employee: "Сидоров Дмитрий", dept: "IT", courseId: 1, deadline: "2025-09-15", status: "overdue", completedDate: "" },
|
||
{ id: 6, employee: "Кузнецова Ольга", dept: "HR", courseId: 2, deadline: "2025-10-10", status: "completed", completedDate: "2025-10-05" },
|
||
{ id: 7, employee: "Кузнецова Ольга", dept: "HR", courseId: 5, deadline: "2025-09-01", status: "overdue", completedDate: "" },
|
||
{ id: 8, employee: "Смирнов Андрей", dept: "HR", courseId: 2, deadline: "2025-10-10", status: "progress", completedDate: "" },
|
||
{ id: 9, employee: "Васильев Игорь", dept: "Продажи", courseId: 4, deadline: "2025-09-20", status: "completed", completedDate: "2025-09-18" },
|
||
{ id: 10, employee: "Васильев Игорь", dept: "Продажи", courseId: 3, deadline: "2025-10-15", status: "progress", completedDate: "" },
|
||
{ id: 11, employee: "Морозова Елена", dept: "Продажи", courseId: 4, deadline: "2025-09-20", status: "pending", completedDate: "" },
|
||
{ id: 12, employee: "Новиков Сергей", dept: "Продажи", courseId: 5, deadline: "2025-08-31", status: "overdue", completedDate: "" },
|
||
{ id: 13, employee: "Федорова Анна", dept: "Маркетинг", courseId: 6, deadline: "2025-11-01", status: "progress", completedDate: "" },
|
||
{ id: 14, employee: "Козлов Максим", dept: "Маркетинг", courseId: 2, deadline: "2025-10-10", status: "completed", completedDate: "2025-10-08" },
|
||
{ id: 15, employee: "Павлова Наталья", dept: "Финансы", courseId: 3, deadline: "2025-10-01", status: "completed", completedDate: "2025-09-28" },
|
||
{ id: 16, employee: "Соколов Артем", dept: "Финансы", courseId: 5, deadline: "2025-09-01", status: "overdue", completedDate: "" },
|
||
{ id: 17, employee: "Михайлов Константин", dept: "Юридический", courseId: 5, deadline: "2025-09-01", status: "completed", completedDate: "2025-08-25" },
|
||
{ id: 18, employee: "Михайлов Константин", dept: "Юридический", courseId: 7, deadline: "2025-10-20", status: "pending", completedDate: "" },
|
||
{ id: 19, employee: "Ержанов Азамат", dept: "Операционный", courseId: 7, deadline: "2025-09-30", status: "progress", completedDate: "" },
|
||
{ id: 20, employee: "Ким Юлия", dept: "Безопасность", courseId: 1, deadline: "2025-09-15", status: "completed", completedDate: "2025-09-14" },
|
||
{ id: 21, employee: "Трембаев Нурлан", dept: "IT", courseId: 7, deadline: "2025-10-05", status: "pending", completedDate: "" },
|
||
{ id: 22, employee: "Трембаев Нурлан", dept: "IT", courseId: 6, deadline: "2025-11-15", status: "progress", completedDate: "" }
|
||
];
|
||
|
||
// === СОСТОЯНИЕ ===
|
||
let courses = [];
|
||
let records = [];
|
||
let importedData = [];
|
||
|
||
// === ИНИЦИАЛИЗАЦИЯ ===
|
||
function init() {
|
||
const storedCourses = localStorage.getItem('tracker_courses');
|
||
const storedRecords = localStorage.getItem('tracker_records');
|
||
|
||
if (storedCourses) {
|
||
courses = JSON.parse(storedCourses);
|
||
} else {
|
||
courses = [...DEMO_COURSES];
|
||
saveCourses();
|
||
}
|
||
|
||
if (storedRecords) {
|
||
records = JSON.parse(storedRecords);
|
||
} else {
|
||
records = [...DEMO_RECORDS];
|
||
saveRecords();
|
||
}
|
||
|
||
populateFilters();
|
||
renderAll();
|
||
}
|
||
|
||
function saveCourses() {
|
||
localStorage.setItem('tracker_courses', JSON.stringify(courses));
|
||
}
|
||
|
||
function saveRecords() {
|
||
localStorage.setItem('tracker_records', JSON.stringify(records));
|
||
}
|
||
|
||
// === РЕНДЕРИНГ ===
|
||
function renderAll() {
|
||
renderStats();
|
||
renderRecords();
|
||
renderCourses();
|
||
renderDeptStats();
|
||
}
|
||
|
||
function renderStats() {
|
||
const today = new Date().toISOString().split('T')[0];
|
||
const totalRecords = records.length;
|
||
const completed = records.filter(r => r.status === 'completed').length;
|
||
const overdue = records.filter(r => r.status === 'overdue').length;
|
||
const progress = (totalRecords > 0) ? Math.round((completed / totalRecords) * 100) : 0;
|
||
|
||
document.getElementById('statCourses').textContent = courses.length;
|
||
document.getElementById('statRecords').textContent = totalRecords;
|
||
document.getElementById('statCompleted').textContent = completed;
|
||
document.getElementById('statOverdue').textContent = overdue;
|
||
|
||
const completedDelta = records.filter(r => r.status === 'completed' && r.completedDate && r.completedDate >= today).length;
|
||
document.getElementById('statCompletedDelta').textContent = completedDelta > 0 ? `+${completedDelta} за последнюю неделю` : '';
|
||
document.getElementById('statOverdueDelta').textContent = overdue > 0 ? `Требует внимания` : 'Все в срок';
|
||
document.getElementById('progressCompleted').style.width = progress + '%';
|
||
}
|
||
|
||
function renderRecords() {
|
||
const tbody = document.getElementById('recordsTable');
|
||
const empty = document.getElementById('recordsEmpty');
|
||
|
||
const filtered = getFilteredRecords();
|
||
|
||
if (filtered.length === 0) {
|
||
tbody.innerHTML = '';
|
||
empty.classList.remove('hidden');
|
||
return;
|
||
}
|
||
|
||
empty.classList.add('hidden');
|
||
tbody.innerHTML = filtered.map(r => {
|
||
const course = courses.find(c => c.id == r.courseId);
|
||
const statusClass = `status-${r.status}`;
|
||
const statusText = {
|
||
'pending': 'Запланировано',
|
||
'progress': 'В процессе',
|
||
'completed': 'Пройдено',
|
||
'overdue': 'Просрочено'
|
||
}[r.status];
|
||
|
||
return `
|
||
<tr>
|
||
<td><strong>${r.employee}</strong></td>
|
||
<td>${r.dept}</td>
|
||
<td>${course ? course.name : 'Удалён'}</td>
|
||
<td>${formatDate(r.deadline)}</td>
|
||
<td><span class="status ${statusClass}">${statusText}</span></td>
|
||
<td>${r.completedDate ? formatDate(r.completedDate) : '—'}</td>
|
||
<td>
|
||
<button class="btn btn-sm ${r.status !== 'completed' ? 'btn-success' : 'btn-secondary'}"
|
||
onclick="markCompleted(${r.id})"
|
||
${r.status === 'completed' ? 'disabled' : ''}>
|
||
${r.status === 'completed' ? '✓' : 'Отметить'}
|
||
</button>
|
||
<button class="btn btn-sm btn-secondary" onclick="editRecord(${r.id})">✏️</button>
|
||
<button class="btn btn-sm btn-danger" onclick="deleteRecord(${r.id})">🗑️</button>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderCourses() {
|
||
const grid = document.getElementById('coursesGrid');
|
||
const empty = document.getElementById('coursesEmpty');
|
||
|
||
if (courses.length === 0) {
|
||
grid.innerHTML = '';
|
||
empty.classList.remove('hidden');
|
||
return;
|
||
}
|
||
|
||
empty.classList.add('hidden');
|
||
grid.innerHTML = courses.map(c => {
|
||
const enrolled = records.filter(r => r.courseId == c.id).length;
|
||
const completed = records.filter(r => r.courseId == c.id && r.status === 'completed').length;
|
||
const overdue = records.filter(r => r.courseId == c.id && r.status === 'overdue').length;
|
||
const progress = enrolled > 0 ? Math.round((completed / enrolled) * 100) : 0;
|
||
|
||
return `
|
||
<div class="course-card" onclick="showCourseDetails(${c.id})">
|
||
<h4>${c.name}</h4>
|
||
<div class="meta">${c.category} • ${c.desc || ''}</div>
|
||
<div class="course-stats">
|
||
<div class="course-stat">
|
||
<span class="num">${enrolled}</span>
|
||
<span>Записано</span>
|
||
</div>
|
||
<div class="course-stat">
|
||
<span class="num" style="color: var(--success)">${completed}</span>
|
||
<span>Пройдено</span>
|
||
</div>
|
||
<div class="course-stat">
|
||
<span class="num" style="color: var(--danger)">${overdue}</span>
|
||
<span>Просрочено</span>
|
||
</div>
|
||
</div>
|
||
<div class="progress-bar">
|
||
<div class="progress-fill" style="width: ${progress}%"></div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderDeptStats() {
|
||
const container = document.getElementById('deptStats');
|
||
const depts = {};
|
||
|
||
records.forEach(r => {
|
||
if (!depts[r.dept]) {
|
||
depts[r.dept] = { total: 0, completed: 0, progress: 0, overdue: 0 };
|
||
}
|
||
depts[r.dept].total++;
|
||
if (r.status === 'completed') depts[r.dept].completed++;
|
||
else if (r.status === 'progress') depts[r.dept].progress++;
|
||
else if (r.status === 'overdue') depts[r.dept].overdue++;
|
||
else if (r.status === 'pending') depts[r.dept].progress++;
|
||
});
|
||
|
||
container.innerHTML = Object.entries(depts).map(([dept, stats]) => {
|
||
const progress = stats.total > 0 ? Math.round((stats.completed / stats.total) * 100) : 0;
|
||
return `
|
||
<div class="dept-card">
|
||
<h4>🏢 ${dept}</h4>
|
||
<div class="dept-row">
|
||
<span>Всего записей</span>
|
||
<strong>${stats.total}</strong>
|
||
</div>
|
||
<div class="dept-row">
|
||
<span>Пройдено</span>
|
||
<span style="color: var(--success)">${stats.completed}</span>
|
||
</div>
|
||
<div class="dept-row">
|
||
<span>В процессе</span>
|
||
<span style="color: var(--primary)">${stats.progress}</span>
|
||
</div>
|
||
<div class="dept-row">
|
||
<span>Просрочено</span>
|
||
<span style="color: var(--danger)">${stats.overdue}</span>
|
||
</div>
|
||
<div class="progress-bar" style="margin-top: 12px;">
|
||
<div class="progress-fill" style="width: ${progress}%; background: ${progress >= 80 ? 'var(--success)' : progress >= 50 ? 'var(--warning)' : 'var(--danger)'}"></div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
// === ФИЛЬТРЫ ===
|
||
function populateFilters() {
|
||
const deptSelect = document.getElementById('filterDept');
|
||
const courseSelect = document.getElementById('filterCourse');
|
||
const recordCourse = document.getElementById('recordCourse');
|
||
|
||
const depts = [...new Set(records.map(r => r.dept))].sort();
|
||
deptSelect.innerHTML = '<option value="">Все отделы</option>' +
|
||
depts.map(d => `<option value="${d}">${d}</option>`).join('');
|
||
|
||
courseSelect.innerHTML = '<option value="">Все курсы</option>' +
|
||
courses.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
|
||
|
||
recordCourse.innerHTML = '<option value="">Выберите курс</option>' +
|
||
courses.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
|
||
}
|
||
|
||
function getFilteredRecords() {
|
||
const search = document.getElementById('filterSearch').value.toLowerCase();
|
||
const dept = document.getElementById('filterDept').value;
|
||
const course = document.getElementById('filterCourse').value;
|
||
const status = document.getElementById('filterStatus').value;
|
||
|
||
return records.filter(r => {
|
||
if (search && !r.employee.toLowerCase().includes(search)) return false;
|
||
if (dept && r.dept !== dept) return false;
|
||
if (course && r.courseId != course) return false;
|
||
if (status && r.status !== status) return false;
|
||
return true;
|
||
});
|
||
}
|
||
|
||
function applyFilters() {
|
||
renderRecords();
|
||
}
|
||
|
||
// === МОДАЛЬНЫЕ ОКНА ===
|
||
function openRecordModal(id = null) {
|
||
const modal = document.getElementById('recordModal');
|
||
const form = document.getElementById('recordForm');
|
||
form.reset();
|
||
document.getElementById('recordId').value = '';
|
||
document.getElementById('recordModalTitle').textContent = 'Новая запись на обучение';
|
||
document.getElementById('recordCompletedDate').parentElement.classList.add('hidden');
|
||
|
||
if (id) {
|
||
const r = records.find(x => x.id === id);
|
||
if (r) {
|
||
document.getElementById('recordId').value = r.id;
|
||
document.getElementById('recordEmployee').value = r.employee;
|
||
document.getElementById('recordDept').value = r.dept;
|
||
document.getElementById('recordCourse').value = r.courseId;
|
||
document.getElementById('recordStatus').value = r.status;
|
||
document.getElementById('recordDeadline').value = r.deadline;
|
||
document.getElementById('recordCompletedDate').value = r.completedDate || '';
|
||
document.getElementById('recordModalTitle').textContent = 'Редактирование записи';
|
||
toggleCompletionDate();
|
||
}
|
||
}
|
||
|
||
modal.classList.remove('hidden');
|
||
}
|
||
|
||
function openCourseModal(id = null) {
|
||
const modal = document.getElementById('courseModal');
|
||
const form = document.getElementById('courseForm');
|
||
form.reset();
|
||
document.getElementById('courseId').value = '';
|
||
document.getElementById('courseModalTitle').textContent = 'Новый курс';
|
||
|
||
if (id) {
|
||
const c = courses.find(x => x.id === id);
|
||
if (c) {
|
||
document.getElementById('courseId').value = c.id;
|
||
document.getElementById('courseName').value = c.name;
|
||
document.getElementById('courseCategory').value = c.category;
|
||
document.getElementById('courseDesc').value = c.desc || '';
|
||
document.getElementById('courseModalTitle').textContent = 'Редактирование курса';
|
||
}
|
||
}
|
||
|
||
modal.classList.remove('hidden');
|
||
}
|
||
|
||
function openImportModal() {
|
||
document.getElementById('importFile').value = '';
|
||
document.getElementById('importPreview').classList.add('hidden');
|
||
document.getElementById('importModal').classList.remove('hidden');
|
||
importedData = [];
|
||
}
|
||
|
||
function closeModal(modalId) {
|
||
document.getElementById(modalId).classList.add('hidden');
|
||
}
|
||
|
||
function toggleCompletionDate() {
|
||
const status = document.getElementById('recordStatus').value;
|
||
const field = document.getElementById('recordCompletedDate').parentElement;
|
||
if (status === 'completed') {
|
||
field.classList.remove('hidden');
|
||
if (!document.getElementById('recordCompletedDate').value) {
|
||
document.getElementById('recordCompletedDate').value = new Date().toISOString().split('T')[0];
|
||
}
|
||
} else {
|
||
field.classList.add('hidden');
|
||
document.getElementById('recordCompletedDate').value = '';
|
||
}
|
||
}
|
||
|
||
// === СОХРАНЕНИЕ ===
|
||
function saveRecord(e) {
|
||
e.preventDefault();
|
||
const id = document.getElementById('recordId').value;
|
||
const status = document.getElementById('recordStatus').value;
|
||
|
||
const record = {
|
||
employee: document.getElementById('recordEmployee').value,
|
||
dept: document.getElementById('recordDept').value,
|
||
courseId: parseInt(document.getElementById('recordCourse').value),
|
||
deadline: document.getElementById('recordDeadline').value,
|
||
status: status,
|
||
completedDate: status === 'completed' ? document.getElementById('recordCompletedDate').value : ''
|
||
};
|
||
|
||
if (id) {
|
||
const idx = records.findIndex(r => r.id == id);
|
||
if (idx !== -1) {
|
||
record.id = parseInt(id);
|
||
records[idx] = record;
|
||
}
|
||
} else {
|
||
record.id = Date.now();
|
||
records.push(record);
|
||
}
|
||
|
||
saveRecords();
|
||
populateFilters();
|
||
renderAll();
|
||
closeModal('recordModal');
|
||
}
|
||
|
||
function saveCourse(e) {
|
||
e.preventDefault();
|
||
const id = document.getElementById('courseId').value;
|
||
|
||
const course = {
|
||
name: document.getElementById('courseName').value,
|
||
category: document.getElementById('courseCategory').value,
|
||
desc: document.getElementById('courseDesc').value
|
||
};
|
||
|
||
if (id) {
|
||
const idx = courses.findIndex(c => c.id == id);
|
||
if (idx !== -1) {
|
||
course.id = parseInt(id);
|
||
courses[idx] = course;
|
||
}
|
||
} else {
|
||
course.id = Date.now();
|
||
courses.push(course);
|
||
}
|
||
|
||
saveCourses();
|
||
populateFilters();
|
||
renderAll();
|
||
closeModal('courseModal');
|
||
}
|
||
|
||
// === ДЕЙСТВИЯ ===
|
||
function markCompleted(id) {
|
||
const r = records.find(x => x.id === id);
|
||
if (r) {
|
||
r.status = 'completed';
|
||
r.completedDate = new Date().toISOString().split('T')[0];
|
||
saveRecords();
|
||
renderAll();
|
||
}
|
||
}
|
||
|
||
function editRecord(id) {
|
||
openRecordModal(id);
|
||
}
|
||
|
||
function deleteRecord(id) {
|
||
if (confirm('Удалить эту запись?')) {
|
||
records = records.filter(r => r.id !== id);
|
||
saveRecords();
|
||
renderAll();
|
||
}
|
||
}
|
||
|
||
function showCourseDetails(id) {
|
||
const course = courses.find(c => c.id === id);
|
||
if (!course) return;
|
||
|
||
document.getElementById('filterCourse').value = id;
|
||
switchTab('records');
|
||
applyFilters();
|
||
}
|
||
|
||
// === ИМПОРТ/ЭКСПОРТ ===
|
||
document.getElementById('importFile').addEventListener('change', function(e) {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
|
||
const reader = new FileReader();
|
||
reader.onload = function(e) {
|
||
try {
|
||
const wb = XLSX.read(e.target.result, { type: 'array' });
|
||
const ws = wb.Sheets[wb.SheetNames[0]];
|
||
const data = XLSX.utils.sheet_to_json(ws);
|
||
|
||
importedData = data.map((row, i) => ({
|
||
...row,
|
||
_row: i + 2
|
||
}));
|
||
|
||
const preview = document.getElementById('importPreviewBody');
|
||
preview.innerHTML = importedData.slice(0, 5).map(row => `
|
||
<tr>
|
||
<td>${row.сотрудник || row.employee || '—'}</td>
|
||
<td>${row.отдел || row.dept || '—'}</td>
|
||
<td>${row.курс || row.course || '—'}</td>
|
||
<td>${row.статус || row.status || '—'}</td>
|
||
</tr>
|
||
`).join('');
|
||
|
||
if (importedData.length > 5) {
|
||
preview.innerHTML += `<tr><td colspan="4">... ещё ${importedData.length - 5} строк</td></tr>`;
|
||
}
|
||
|
||
document.getElementById('importPreview').classList.remove('hidden');
|
||
} catch (err) {
|
||
alert('Ошибка чтения файла: ' + err.message);
|
||
}
|
||
};
|
||
reader.readAsArrayBuffer(file);
|
||
});
|
||
|
||
function processImport() {
|
||
if (importedData.length === 0) {
|
||
alert('Выберите файл для импорта');
|
||
return;
|
||
}
|
||
|
||
importedData.forEach(row => {
|
||
const courseName = row.курс || row.course;
|
||
let courseId = courses.find(c => c.name === courseName)?.id;
|
||
|
||
if (!courseId && courseName) {
|
||
courseId = Date.now() + Math.random();
|
||
courses.push({
|
||
id: courseId,
|
||
name: courseName,
|
||
category: 'Импорт',
|
||
desc: 'Добавлен через импорт'
|
||
});
|
||
}
|
||
|
||
records.push({
|
||
id: Date.now() + Math.random(),
|
||
employee: row.сотрудник || row.employee || 'Неизвестно',
|
||
dept: row.отдел || row.dept || 'Без отдела',
|
||
courseId: courseId || 0,
|
||
deadline: row.дедлайн || row.deadline || new Date().toISOString().split('T')[0],
|
||
status: row.статус || row.status || 'pending',
|
||
completedDate: row.датаПрохождения || row.completedDate || ''
|
||
});
|
||
});
|
||
|
||
saveCourses();
|
||
saveRecords();
|
||
populateFilters();
|
||
renderAll();
|
||
closeModal('importModal');
|
||
alert(`Импортировано ${importedData.length} записей`);
|
||
}
|
||
|
||
function exportToExcel() {
|
||
const today = new Date().toISOString().split('T')[0];
|
||
|
||
const wsData = records.map(r => {
|
||
const course = courses.find(c => c.id == r.courseId);
|
||
return {
|
||
'Сотрудник': r.employee,
|
||
'Отдел': r.dept,
|
||
'Курс': course ? course.name : 'Удалён',
|
||
'Дедлайн': r.deadline,
|
||
'Статус': {
|
||
'pending': 'Запланировано',
|
||
'progress': 'В процессе',
|
||
'completed': 'Пройдено',
|
||
'overdue': 'Просрочено'
|
||
}[r.status],
|
||
'Дата прохождения': r.completedDate || '',
|
||
'Просрочено': r.status === 'overdue' || (r.status !== 'completed' && r.deadline < today) ? 'Да' : 'Нет'
|
||
};
|
||
});
|
||
|
||
const ws = XLSX.utils.json_to_sheet(wsData);
|
||
const wb = XLSX.utils.book_new();
|
||
XLSX.utils.book_append_sheet(wb, ws, 'Записи');
|
||
|
||
const deptData = [];
|
||
const depts = {};
|
||
records.forEach(r => {
|
||
if (!depts[r.dept]) depts[r.dept] = { total: 0, completed: 0, overdue: 0 };
|
||
depts[r.dept].total++;
|
||
if (r.status === 'completed') depts[r.dept].completed++;
|
||
if (r.status === 'overdue') depts[r.dept].overdue++;
|
||
});
|
||
|
||
Object.entries(depts).forEach(([dept, stats]) => {
|
||
deptData.push({
|
||
'Отдел': dept,
|
||
'Всего': stats.total,
|
||
'Пройдено': stats.completed,
|
||
'Просрочено': stats.overdue,
|
||
'% завершения': stats.total > 0 ? Math.round((stats.completed / stats.total) * 100) + '%' : '0%'
|
||
});
|
||
});
|
||
|
||
const wsDept = XLSX.utils.json_to_sheet(deptData);
|
||
XLSX.utils.book_append_sheet(wb, wsDept, 'По отделам');
|
||
|
||
XLSX.writeFile(wb, `Трекер_обучения_${today}.xlsx`);
|
||
}
|
||
|
||
// === УТИЛИТЫ ===
|
||
function switchTab(tab) {
|
||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||
document.querySelectorAll('[id^="tab-"]').forEach(t => t.classList.add('hidden'));
|
||
|
||
event.target.classList.add('active');
|
||
document.getElementById('tab-' + tab).classList.remove('hidden');
|
||
|
||
if (tab === 'departments') renderDeptStats();
|
||
}
|
||
|
||
function formatDate(dateStr) {
|
||
if (!dateStr) return '—';
|
||
const d = new Date(dateStr);
|
||
return d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: '2-digit' });
|
||
}
|
||
|
||
// Запуск
|
||
init();
|
||
</script>
|
||
</body>
|
||
</html>
|