/
school25_dev
/
smena
Обзор
Документация
Войти
/
school25_dev
/
smena
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
master
public/admin.html
1 030 строк
50 KB
kalugin66
СниСну
01 июл 2026, 21:07
01 июл 2026, 21:07
dd00d94
Код
Авторство
О чём код?
<!-- admin.html --> <!DOCTYPE html> <html lang="ru"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Лагерь «Смена» - Админ-панель</title> <link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800;900&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> <link rel="stylesheet" href="/admin.css"> <style> .schedule-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; padding: 4px 0; font-size: 12px; } .schedule-toolbar .btn { padding: 4px 10px; font-size: 12px; line-height: 1.4; border-radius: 4px; } .schedule-toolbar select, .schedule-toolbar label { font-size: 12px; margin: 0; padding: 2px 4px; } .schedule-toolbar input[type="checkbox"] { margin: 0 2px 0 0; vertical-align: middle; } .panel-header { padding: 8px 16px; margin-bottom: 4px; } .panel-header h3 { margin: 0 0 4px 0; font-size: 18px; } .schedule-panel-body { padding: 0 4px; } .schedule-table-wrap { overflow-x: auto; } </style> </head> <body> <div class="toast-container" id="toastContainer"></div> <div class="auth-screen" id="authScreen"> <div class="auth-box"> <h2>Вход в админ-панель</h2> <!-- Динамическое название лагеря на странице входа --> <p id="authCampName">Лагерь «Смена» - система управления</p> <input type="text" id="loginInput" placeholder="Логин"> <input type="password" id="passwordInput" placeholder="Пароль" onkeypress="if(event.key==='Enter')login()" style="margin-top:12px"> <button onclick="login()" style="margin-top:20px">Войти</button> <div class="auth-error" id="authError"></div> </div> </div> <div class="app-layout" id="appLayout"> <aside class="sidebar"> <div class="sidebar-header"> <div class="sidebar-logo">🏕️</div> <div class="sidebar-title" id="sidebarTitle">Лагерь «Смена»</div> <div class="sidebar-subtitle">Админ-панель</div> </div> <nav class="sidebar-nav"> <button class="nav-item active" data-tab="settings" onclick="showTab('settings')"><span class="nav-icon">⚙️</span><span>Настройки</span></button> <button class="nav-item" data-tab="vozhaty" onclick="showTab('vozhaty')"><span class="nav-icon">👥</span><span>Вожатые</span><span class="nav-count" id="vozhatyCount">0</span></button> <button class="nav-item" data-tab="performers" onclick="showTab('performers')"><span class="nav-icon">🎭</span><span>Исполнители</span><span class="nav-count" id="performersCount">0</span></button> <button class="nav-item" data-tab="circles" onclick="showTab('circles')"><span class="nav-icon">🏅</span><span>Кружки</span><span class="nav-count" id="circlesCount">0</span></button> <button class="nav-item" data-tab="squads" onclick="showTab('squads')"><span class="nav-icon">👥</span><span>Отряды</span><span class="nav-count" id="squadsCount">0</span></button> <button class="nav-item" data-tab="schedule" onclick="showTab('schedule')"><span class="nav-icon">📅</span><span>Расписание</span><span class="nav-count" id="scheduleCount">0</span></button> </nav> <div class="sidebar-footer"> <a href="/" class="nav-item" style="text-decoration:none;cursor:pointer"><span class="nav-icon">🏕️</span><span>На сайт</span></a> <button class="nav-item logout" onclick="logout()"><span class="nav-icon">🚪</span><span>Выйти</span></button> </div> </aside> <main class="main-area"> <div class="top-bar"> <div class="top-bar-title" id="topBarTitle">🏕️ Лагерь «Смена»</div> <div class="top-bar-right"> <div class="user-avatar-small" id="userAvatar">A</div> <span class="user-name" id="userName"></span> </div> </div> <div class="content"> <div class="panel active" id="panel-settings"> <div class="panel-header"> <h3>⚙️ Общие настройки</h3> </div> <div class="settings-grid" id="settingsGrid"></div> <div class="toolbar" style="margin-top: 20px;"> <button class="btn btn-primary" onclick="saveSettings()">💾 Сохранить настройки</button> </div> </div> <div class="panel" id="panel-vozhaty"> <div class="panel-header"> <h3>👥 Вожатые</h3> <div style="display:flex;gap:8px;flex-wrap:wrap"> <input type="text" placeholder="Поиск..." oninput="filterVozhaty(this.value)" style="padding:8px 12px;border:2px solid var(--gray-200);border-radius:var(--radius);font-size:14px;min-width:180px"> <button class="btn btn-primary" onclick="openVozhatyModal()">+ Добавить вожатого</button> </div> </div> <div id="vozhatyGrid" class="cards-grid"></div> </div> <div class="panel" id="panel-performers"> <div class="panel-header"> <h3>🎭 Исполнители</h3> <div style="display:flex;gap:8px;flex-wrap:wrap"> <input type="text" placeholder="Поиск..." oninput="filterPerformers(this.value)" style="padding:8px 12px;border:2px solid var(--gray-200);border-radius:var(--radius);font-size:14px;min-width:180px"> <button class="btn btn-primary" onclick="openPerformerModal()">+ Добавить исполнителя</button> </div> </div> <div id="performersGrid" class="cards-grid"></div> </div> <div class="panel" id="panel-circles"> <div class="panel-header"> <h3>🏅 Кружки</h3> <div style="display:flex;gap:8px;flex-wrap:wrap"> <input type="text" placeholder="Поиск..." oninput="filterCircles(this.value)" style="padding:8px 12px;border:2px solid var(--gray-200);border-radius:var(--radius);font-size:14px;min-width:180px"> <button class="btn btn-primary" onclick="openCircleModal()">+ Добавить кружок</button> </div> </div> <div id="circlesGrid" class="cards-grid"></div> </div> <div class="panel" id="panel-squads"> <div class="panel-header"> <h3>👥 Отряды</h3> <div style="display:flex;gap:8px;flex-wrap:wrap"> <input type="text" placeholder="Поиск..." oninput="filterSquads(this.value)" style="padding:8px 12px;border:2px solid var(--gray-200);border-radius:var(--radius);font-size:14px;min-width:180px"> <button class="btn btn-primary" onclick="openSquadModal()">+ Добавить отряд</button> </div> </div> <div id="squadsGrid" class="cards-grid"></div> </div> <div class="panel" id="panel-schedule"> <div class="panel-header"> <h3>📅 Расписание</h3> <div class="schedule-toolbar"> <select id="filterDay"><option value="">Все дни</option></select> <label><input type="checkbox" id="showEmptySlots" checked onchange="renderScheduleGrid()"> Показывать свободное</label> <button class="btn btn-primary" onclick="openScheduleModal()">+ Добавить</button> <button class="btn btn-secondary" onclick="exportCSV()">📥 Экспорт CSV</button> <button class="btn btn-warning" onclick="generateSchedule(event)">🎲 Авто-распределение</button> <button class="btn btn-danger" onclick="clearSchedule()">🗑️ Очистить всё</button> </div> </div> <div class="schedule-panel-body" id="scheduleGrid"></div> </div> </div> </main> </div> <!-- Модальные окна --> <div class="modal" id="vozhatyModal"> <div class="modal-content" style="position:relative"> <button class="modal-close" onclick="closeModal('vozhatyModal')">×</button> <h3>👥 <span id="vozhatyModalTitle">Добавить вожатого</span></h3> <input type="hidden" id="vozhatyId"> <label>Имя <span class="required">*</span></label> <input type="text" id="vozhatyName" placeholder="ФИО вожатого"> <label>Телефон</label> <input type="text" id="vozhatyPhone" placeholder="+7 (999) 000-00-00"> <div class="modal-buttons"> <button class="btn-cancel" onclick="closeModal('vozhatyModal')">Отмена</button> <button class="btn-save" onclick="saveVozhaty()">Сохранить</button> </div> </div> </div> <div class="modal" id="performerModal"> <div class="modal-content" style="position:relative"> <button class="modal-close" onclick="closeModal('performerModal')">×</button> <h3>🎭 <span id="performerModalTitle">Добавить исполнителя</span></h3> <input type="hidden" id="performerId"> <label>ФИО исполнителя <span class="required">*</span></label> <input type="text" id="performerName" placeholder="ФИО исполнителя"> <div class="modal-buttons"> <button class="btn-cancel" onclick="closeModal('performerModal')">Отмена</button> <button class="btn-save" onclick="savePerformer()">Сохранить</button> </div> </div> </div> <div class="modal" id="circleModal"> <div class="modal-content" style="position:relative"> <button class="modal-close" onclick="closeModal('circleModal')">×</button> <h3>🏅 <span id="circleModalTitle">Добавить кружок</span></h3> <input type="hidden" id="circleId"> <label>Название <span class="required">*</span></label> <input type="text" id="circleName" placeholder="Название кружка"> <label>Кол-во занятий в день</label> <input type="number" id="circleNeed" min="1" max="10" value="1"> <label>Категория</label> <select id="circleCategory"> <option value="0">🎲 Бесплатный</option> <option value="1">💰 Платный</option> <option value="2">🚌 Выезд</option> </select> <label>Длительность (минут)</label> <input type="number" id="circleDuration" min="10" max="180" value="40"> <label style="margin-top:16px">Исполнители</label> <div class="checkbox-group" id="circlePerformersCheckboxes"></div> <div class="modal-buttons"> <button class="btn-cancel" onclick="closeModal('circleModal')">Отмена</button> <button class="btn-save" onclick="saveCircle()">Сохранить</button> </div> </div> </div> <div class="modal" id="squadModal"> <div class="modal-content" style="position:relative"> <button class="modal-close" onclick="closeModal('squadModal')">×</button> <h3>👥 <span id="squadModalTitle">Добавить отряд</span></h3> <input type="hidden" id="squadId"> <label>Название <span class="required">*</span></label> <input type="text" id="squadName" placeholder="Название отряда"> <label>Баллы</label> <input type="number" id="squadPoints" min="0" value="0"> <label>Вожатый</label> <select id="squadVozhaty"><option value="">— Не назначен —</option></select> <div class="modal-buttons"> <button class="btn-cancel" onclick="closeModal('squadModal')">Отмена</button> <button class="btn-save" onclick="saveSquad()">Сохранить</button> </div> </div> </div> <div class="modal" id="scheduleModal"> <div class="modal-content" style="position:relative"> <button class="modal-close" onclick="closeModal('scheduleModal')">×</button> <h3>📅 <span id="scheduleModalTitle">Добавить занятие</span></h3> <input type="hidden" id="scheduleId"> <label>Отряд <span class="required">*</span></label> <select id="scheduleSquad"></select> <label>Кружок <span class="required">*</span></label> <select id="scheduleCircle"></select> <label>День <span class="required">*</span></label> <select id="scheduleDay"></select> <label>Время начала <span class="required">*</span></label> <div style="display:flex;gap:10px;align-items:center"> <select id="scheduleStartH" style="width:80px"></select> <span style="font-size:18px;font-weight:600">:</span> <select id="scheduleStartM" style="width:80px"></select> </div> <label style="margin-top:16px">Время окончания</label> <div style="display:flex;gap:10px;align-items:center"> <select id="scheduleEndH" style="width:80px" disabled></select> <span style="font-size:18px;font-weight:600">:</span> <select id="scheduleEndM" style="width:80px" disabled></select> </div> <label class="toggle-label" style="margin-top:10px"> <input type="checkbox" id="scheduleEndOverride" onchange="toggleEndTimeOverride()"> <div class="toggle-switch"></div> <span>Переопределить время окончания</span> </label> <div class="modal-buttons"> <button class="btn-cancel" onclick="closeModal('scheduleModal')">Отмена</button> <button class="btn-save" onclick="saveSchedule()">Сохранить</button> </div> </div> </div> <script> // Список доступных дней смены (только указанные даты) const DAYS_LIST = [1,2,3,4,5,8,9,10,11,15,16,17,18,19,22]; let token = localStorage.getItem('camp_token'); let user = null; let vozhaty = [], performers = [], circles = [], squads = [], scheduleItems = []; let settingsData = {}; let campStartDate = "2026-06-01"; const MONTHS = ["января","февраля","марта","апреля","мая","июня","июля","августа","сентября","октября","ноября","декабря"]; let settings = {}; let expandedSquads = new Set(); // Функция обновления названия на странице входа function updateAuthTitle() { const savedName = localStorage.getItem('camp_name'); const defaultName = 'Лагерь «Смена»'; const name = savedName || defaultName; document.getElementById('authCampName').textContent = name + ' - система управления'; } // Обновление всех заголовков в админке (топ-бар и сайдбар) function updateTitles() { const campName = settingsData.camp_name || localStorage.getItem('camp_name') || 'Лагерь «Смена»'; document.getElementById('topBarTitle').textContent = '🏕️ ' + campName; document.getElementById('sidebarTitle').textContent = campName; // Также сохраняем в localStorage для страницы входа localStorage.setItem('camp_name', campName); // Обновляем и страницу входа (на случай, если она ещё видна) updateAuthTitle(); } function getDateStr(day) { if (!campStartDate) return 'День ' + day; const d = new Date(campStartDate); d.setDate(d.getDate() + day - 1); return d.getDate() + ' ' + MONTHS[d.getMonth()]; } function showToast(message, type = 'success') { const container = document.getElementById('toastContainer'); const toast = document.createElement('div'); toast.className = `toast ${type}`; const icons = { success: "✓", error: "✕", warning: "⚠", info: "ℹ" }; toast.innerHTML = `<span class="toast-icon">${icons[type] || icons.success}</span><span>${message}</span>`; container.appendChild(toast); setTimeout(() => { toast.style.animation = 'toastOut 0.3s ease forwards'; setTimeout(() => toast.remove(), 300); }, 3000); } function formatTimeValue(input) { let val = input.value.replace(/\D/g, ''); if (val.length > 4) val = val.slice(0, 4); if (val.length >= 2) { val = val.slice(0, -2) + ':' + val.slice(-2); } if (val.length === 2 && parseInt(val) > 23) val = '23'; if (val.length > 2 && parseInt(val.slice(0, 2)) > 23) val = '23:' + val.slice(3); if (val.length > 2 && parseInt(val.slice(3)) > 59) val = val.slice(0, 3) + '59'; input.value = val; } // При загрузке страницы обновляем заголовок входа updateAuthTitle(); if (token) checkAuth(); else document.getElementById('authScreen').classList.add('active'); async function checkAuth() { try { const res = await fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } }); if (!res.ok) throw new Error(); user = await res.json(); showMain(); loadAll(); } catch (e) { localStorage.removeItem('camp_token'); document.getElementById('authScreen').classList.add('active'); } } async function login() { const loginInput = document.getElementById('loginInput'); const passwordInput = document.getElementById('passwordInput'); const authError = document.getElementById('authError'); const res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ login: loginInput.value, password: passwordInput.value }) }); const data = await res.json(); if (res.ok) { token = data.token; user = data.user; localStorage.setItem('camp_token', token); showMain(); loadAll(); } else { authError.textContent = data.error || 'Ошибка входа'; authError.classList.add('show'); } } function logout() { localStorage.removeItem('camp_token'); location.reload(); } function showMain() { document.getElementById('authScreen').classList.remove('active'); document.getElementById('appLayout').style.display = 'flex'; document.getElementById('userName').textContent = user.full_name || user.login; document.getElementById('userAvatar').textContent = (user.full_name || user.login).charAt(0).toUpperCase(); } function showTab(tab) { document.querySelectorAll('.panel').forEach(p => p.classList.remove('active')); document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); document.querySelector(`.nav-item[data-tab="${tab}"]`).classList.add('active'); document.getElementById('panel-' + tab).classList.add('active'); } // --- API helpers with error handling --- async function apiGet(url) { const res = await fetch(url, { headers: { 'Authorization': 'Bearer ' + token } }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || 'Ошибка запроса'); } return res.json(); } async function apiPost(url, body) { const res = await fetch(url, { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || 'Ошибка запроса'); } return res.json(); } async function apiPut(url, body) { const res = await fetch(url, { method: 'PUT', headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || 'Ошибка запроса'); } return res.json(); } async function apiDelete(url) { const res = await fetch(url, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token } }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || 'Ошибка запроса'); } return res.json(); } async function loadAll() { const [v, p, c, s, sched, sett] = await Promise.all([ apiGet('/api/vozhaty'), apiGet('/api/performers'), apiGet('/api/circles'), apiGet('/api/squads'), apiGet('/api/schedule'), apiGet('/api/settings') ]); vozhaty = v; performers = p; circles = c; squads = s; scheduleItems = sched; settingsData = sett; if (sett.camp_start_date) campStartDate = sett.camp_start_date; // Обновляем заголовки и сохраняем название в localStorage updateTitles(); renderAll(); } function renderAll() { document.getElementById('vozhatyCount').textContent = vozhaty.length; document.getElementById('performersCount').textContent = performers.length; document.getElementById('circlesCount').textContent = circles.length; document.getElementById('squadsCount').textContent = squads.length; document.getElementById('scheduleCount').textContent = scheduleItems.length; renderSettings(); renderVozhaty(); renderPerformers(); renderCircles(); renderSquads(); renderScheduleGrid(); renderDayFilter(); } function renderDayFilter() { const sel = document.getElementById('filterDay'); sel.innerHTML = '<option value="">Все дни</option>' + DAYS_LIST.map(d => `<option value="${d}">${getDateStr(d)}</option>`).join(''); sel.removeEventListener('change', renderScheduleGrid); sel.addEventListener('change', renderScheduleGrid); } function parseTimeStr(timeStr) { const [h, m] = String(timeStr).split(':').map(Number); return h * 60 + m; } function renderScheduleGrid() { const grid = document.getElementById("scheduleGrid"); const filterDay = document.getElementById("filterDay").value; const showEmpty = document.getElementById("showEmptySlots").checked; if (!scheduleItems.length && !showEmpty) { grid.innerHTML = '<div class="empty-state"><div class="empty-icon">📅</div><h4>Расписание пусто</h4><p>Добавьте занятия или нажмите «Авто»</p></div>'; return; } const DAY_START = parseTimeStr(settingsData.day_start_time || "9:10"); const DAY_END = DAY_START + 12 * 60; const selDay = filterDay ? parseInt(filterDay) : null; const slots = []; for (let t = DAY_START; t < DAY_END; t += 15) slots.push(t); const hasEvent = {}; slots.forEach(s => { const sE = s + 15; hasEvent[s] = scheduleItems.some(it => { if (selDay && it.day !== selDay) return false; const st = parseTimeStr(it.start_time); const et = parseTimeStr(it.end_time); return st < sE && s < et; }); }); if (!selDay) { grid.innerHTML = squads.map(sq => { const si = scheduleItems.filter(s => s.squad_id === sq.id); const days = DAYS_LIST; const ex = expandedSquads.has(sq.id); return '<div class="squad-row ' + (ex ? 'expanded' : '') + '" id="squad-' + sq.id + '"><div class="squad-header" onclick="toggleSquad(' + sq.id + ')">👥 ' + sq.name + (sq.vozhaty_name ? '<span class="squad-vozhaty"> — ' + sq.vozhaty_name + '</span>' : '') + '<span style="margin-left:auto;font-size:12px;color:#9CA3AF">' + days.length + ' дн.</span></div><div class="squad-body">' + days.map(d => { const di = si.filter(s => s.day === d).sort((a,b) => parseTimeStr(a.start_time) - parseTimeStr(b.start_time)); return '<div class="day-card"><div class="day-card-title">' + getDateStr(d) + '</div><div class="day-card-body">' + di.map(s => { const c = s.is_paid === 2 ? 'field-trip' : s.is_paid ? 'paid' : 'free'; return '<div class="sched-cell ' + c + '" onclick="openScheduleModal(' + s.id + ')"><div class="cell-name">' + (s.circle_name || '?') + '</div><div class="cell-time">' + s.start_time + '–' + s.end_time + '</div></div>'; }).join('') + (showEmpty ? '<div class="sched-cell-empty" onclick="openEmptySlot(' + sq.id + ', ' + d + ', null)"></div>' : '') + '</div></div>'; }).join('') + '</div></div>'; }).join(''); if (expandedSquads.size === 0) document.querySelectorAll('.squad-row').forEach(el => el.classList.add('expanded')); return; } const vs = showEmpty ? slots : slots.filter(s => hasEvent[s]); let r = '<div class="schedule-table-wrap"><table class="schedule-table"><thead><tr><th>Время</th>'; squads.forEach(sq => { r += '<th>' + sq.name + '</th>'; }); r += '</table></thead><tbody>'; vs.forEach((slot, si) => { const sE = slot + 15; r += '<tr class="' + (si % 2 === 0 ? 'even' : 'odd') + '"><td class="time-cell">' + formatEndTime(slot) + '–' + formatEndTime(sE) + '</td>'; squads.forEach(sq => { const evs = scheduleItems.filter(s => s.day === selDay && s.squad_id === sq.id).filter(s => { const st = parseTimeStr(s.start_time); const et = parseTimeStr(s.end_time); return st < sE && s < et; }); if (evs.length > 0) { r += '<td>'; evs.forEach(ev => { const c = ev.is_paid === 2 ? 'field-trip' : ev.is_paid ? 'paid' : 'free'; r += '<div class="sched-cell ' + c + '" onclick="openScheduleModal(' + ev.id + ')"><div class="cell-name">' + (ev.circle_name || '?') + '</div><div class="cell-time">' + ev.start_time + '–' + ev.end_time + '</div></div>'; }); r += '</td>'; } else { r += '<td><div class="sched-cell-empty" onclick="openEmptySlot(' + sq.id + ', ' + selDay + ', ' + JSON.stringify(formatEndTime(slot)) + ')"></div></td>'; } }); r += '</tr>'; }); r += '</tbody></table></div>'; grid.innerHTML = r; } function toggleSquad(id) { const el = document.getElementById('squad-' + id); if (expandedSquads.has(id)) { expandedSquads.delete(id); el.classList.remove('expanded'); } else { expandedSquads.add(id); el.classList.add('expanded'); } } function formatEndTime(minutes) { return `${String(Math.floor(minutes / 60)).padStart(2,'0')}:${String(minutes % 60).padStart(2,'0')}`; } function openEmptySlot(squadId, day, startTime) { openScheduleModal(); document.getElementById('scheduleSquad').value = squadId; document.getElementById('scheduleDay').value = day; if (startTime) { const [h, m] = startTime.split(':'); document.getElementById('scheduleStartH').value = h; document.getElementById('scheduleStartM').value = m; } autoCalculateEndTime(); } function renderSettings() { const grid = document.getElementById('settingsGrid'); const fields = [ { key: 'min_free_circles', label: 'Мин. бесплатных в день', type: 'number', min: 0, max: 20 }, { key: 'max_free_circles', label: 'Макс. бесплатных в день', type: 'number', min: 0, max: 20 }, { key: 'lesson_duration', label: 'Длительность занятия (мин)', type: 'number', min: 10, max: 120 }, { key: 'break_duration', label: 'Длительность перерыва (мин)', type: 'number', min: 0, max: 60 }, { key: 'camp_name', label: 'Название лагеря', type: 'text' }, { key: 'day_start_time', label: 'Начало дня', type: 'text', placeholder: "09:10" }, { key: 'camp_start_date', label: 'Дата первого дня', type: 'date' } ]; grid.innerHTML = fields.map(f => { const val = settingsData[f.key] || ''; return `<div class="setting-item"> <label>${f.label}</label> <input type="${f.type}" id="setting-${f.key}" value="${val}" ${f.min !== undefined ? `min="${f.min}" max="${f.max}"` : ''} placeholder="${f.placeholder || ''}" oninput="${f.key === 'day_start_time' ? 'formatTimeValue(this)' : ''}"> </div>`; }).join(''); } function renderVozhaty(data) { const items = data || vozhaty; const grid = document.getElementById('vozhatyGrid'); if (!items.length) { grid.innerHTML = '<div class="empty-state"><div class="empty-state-icon">👥</div><h4>Нет вожатых</h4><p>Добавьте первого вожатого</p><button class="btn btn-primary" onclick="openVozhatyModal()">+ Добавить</button></div>'; return; } grid.innerHTML = items.map(v => `<div class="card"> <div class="card-header"><div class="card-title">${v.name}</div><div class="user-avatar" style="background:var(--primary-light);color:var(--primary-dark)">${v.name.charAt(0)}</div></div> <div class="card-meta">📞 ${v.phone || 'не указан'}</div> <div class="card-actions"> <button class="btn btn-ghost btn-sm" onclick="openVozhatyModal(${v.id})">✏️</button> <button class="btn btn-ghost btn-sm" style="color:var(--danger)" onclick="deleteVozhaty(${v.id})">🗑️</button> </div> </div>`).join(''); } function renderPerformers(data) { const items = data || performers; const grid = document.getElementById('performersGrid'); if (!items.length) { grid.innerHTML = '<div class="empty-state"><div class="empty-state-icon">🎭</div><h4>Нет исполнителей</h4><p>Добавьте первого исполнителя</p><button class="btn btn-primary" onclick="openPerformerModal()">+ Добавить</button></div>'; return; } grid.innerHTML = items.map(p => `<div class="card"> <div class="card-header"><div class="card-title">${p.name}</div><div class="user-avatar" style="background:var(--secondary-light);color:var(--secondary)">🎭</div></div> <div class="card-actions"> <button class="btn btn-ghost btn-sm" onclick="openPerformerModal(${p.id})">✏️</button> <button class="btn btn-ghost btn-sm" style="color:var(--danger)" onclick="deletePerformer(${p.id})">🗑️</button> </div> </div>`).join(''); } function renderCircles(data) { const items = data || circles; const grid = document.getElementById('circlesGrid'); if (!items.length) { grid.innerHTML = '<div class="empty-state"><div class="empty-state-icon">🏅</div><h4>Нет кружков</h4><p>Добавьте первый кружок</p><button class="btn btn-primary" onclick="openCircleModal()">+ Добавить</button></div>'; return; } grid.innerHTML = items.map(c => `<div class="card"> <div class="card-header"> <div class="card-title">${c.name}</div> <span class="card-badge ${c.is_paid === 2 ? 'field-trip' : c.is_paid ? 'paid' : 'free'}">${c.is_paid === 2 ? '🚌 Выезд' : c.is_paid ? '💰 Платный' : '🎲 Бесплатный'}</span> </div> <div class="card-meta">⏱ ${c.duration} мин · 📊 ${c.need_per_squad} раз/день</div> <div class="card-actions"> <button class="btn btn-ghost btn-sm" onclick="openCircleModal(${c.id})">✏️</button> <button class="btn btn-ghost btn-sm" style="color:var(--danger)" onclick="deleteCircle(${c.id})">🗑️</button> </div> </div>`).join(''); } function renderSquads(data) { const items = data || squads; const grid = document.getElementById('squadsGrid'); if (!items.length) { grid.innerHTML = '<div class="empty-state"><div class="empty-state-icon">👥</div><h4>Нет отрядов</h4><p>Добавьте первый отряд</p><button class="btn btn-primary" onclick="openSquadModal()">+ Добавить</button></div>'; return; } grid.innerHTML = items.map(s => `<div class="card"> <div class="card-header"> <div class="card-title">${s.name}</div> <span class="card-badge free">👥</span> </div> <div class="card-meta">👤 ${s.vozhaty_name || 'не назначен'}</div> <div class="card-actions"> <button class="btn btn-ghost btn-sm" onclick="openSquadModal(${s.id})">✏️</button> <button class="btn btn-ghost btn-sm" style="color:var(--danger)" onclick="deleteSquad(${s.id})">🗑️</button> </div> </div>`).join(''); } // --- Save functions with try/catch --- async function saveSettings() { try { const fields = ['min_free_circles', 'max_free_circles', 'lesson_duration', 'break_duration', 'day_start_time', 'camp_start_date', 'camp_name']; const updates = fields.map(key => ({ key, value: document.getElementById('setting-' + key).value })); const res = await apiPost('/api/settings', updates); showToast(res.message || 'Настройки сохранены'); await loadAll(); } catch (e) { showToast(e.message || 'Ошибка сохранения настроек', 'error'); } } function openVozhatyModal(id) { const v = vozhaty.find(x => x.id === id); document.getElementById('vozhatyModalTitle').textContent = id ? 'Редактировать вожатого' : 'Добавить вожатого'; document.getElementById('vozhatyId').value = id || ''; document.getElementById('vozhatyName').value = v?.name || ''; document.getElementById('vozhatyPhone').value = v?.phone || ''; document.getElementById('vozhatyModal').classList.add('active'); } function openPerformerModal(id) { const p = performers.find(x => x.id === id); document.getElementById('performerModalTitle').textContent = id ? 'Редактировать исполнителя' : 'Добавить исполнителя'; document.getElementById('performerId').value = id || ''; document.getElementById('performerName').value = p?.name || ''; document.getElementById('performerModal').classList.add('active'); } function openCircleModal(id) { const c = circles.find(x => x.id === id); document.getElementById('circleModalTitle').textContent = id ? 'Редактировать кружок' : 'Добавить кружок'; document.getElementById('circleId').value = id || ''; document.getElementById('circleName').value = c?.name || ''; document.getElementById('circleNeed').value = c?.need_per_squad || 1; document.getElementById('circleCategory').value = c?.is_paid ?? 0; document.getElementById('circleDuration').value = c?.duration || 40; const perfIds = c?.performer_ids || []; document.getElementById('circlePerformersCheckboxes').innerHTML = performers.map(p => `<label class="checkbox-item"><input type="checkbox" value="${p.id}" ${perfIds.includes(p.id) ? 'checked' : ''}>${p.name}</label>` ).join(''); document.getElementById('circleModal').classList.add('active'); } function openSquadModal(id) { const s = squads.find(x => x.id === id); document.getElementById('squadModalTitle').textContent = id ? 'Редактировать отряд' : 'Добавить отряд'; document.getElementById('squadId').value = id || ''; document.getElementById('squadName').value = s?.name || ''; document.getElementById('squadPoints').value = s?.points || 0; const sel = document.getElementById('squadVozhaty'); sel.innerHTML = '<option value="">— Не назначен —</option>' + vozhaty.map(v => `<option value="${v.id}" ${s?.vozhaty_id == v.id ? 'selected' : ''}>${v.name}</option>`).join(''); document.getElementById('squadModal').classList.add('active'); } function toggleEndTimeOverride() { const override = document.getElementById('scheduleEndOverride').checked; document.getElementById('scheduleEndH').disabled = !override; document.getElementById('scheduleEndM').disabled = !override; } function autoCalculateEndTime() { if (document.getElementById('scheduleEndOverride').checked) return; const startH = parseInt(document.getElementById('scheduleStartH').value); const startM = parseInt(document.getElementById('scheduleStartM').value); const lessonDuration = parseInt(settingsData.lesson_duration) || 40; const startMin = startH * 60 + startM; const endMin = startMin + lessonDuration; const endH = String(Math.floor(endMin / 60) % 24).padStart(2, '0'); const endM = String(endMin % 60).padStart(2, '0'); document.getElementById('scheduleEndH').value = endH; document.getElementById('scheduleEndM').value = endM; } function openScheduleModal(id) { const s = scheduleItems.find(x => x.id === id); document.getElementById('scheduleEndOverride').checked = false; toggleEndTimeOverride(); document.getElementById('scheduleModalTitle').textContent = id ? 'Редактировать' : 'Добавить занятие'; document.getElementById('scheduleId').value = id || ''; document.getElementById('scheduleSquad').innerHTML = squads.map(sq => `<option value="${sq.id}" ${s?.squad_id == sq.id ? 'selected' : ''}>${sq.name}</option>`).join(''); document.getElementById('scheduleCircle').innerHTML = circles.map(c => `<option value="${c.id}" ${s?.circle_id == c.id ? 'selected' : ''}>${c.name} ${c.is_paid === 2 ? '(🚌)' : c.is_paid ? '(💰)' : '(🎲)'}</option>`).join(''); const daySelect = document.getElementById('scheduleDay'); daySelect.innerHTML = DAYS_LIST.map(d => `<option value="${d}" ${s?.day == d ? 'selected' : ''}>${getDateStr(d)}</option>`).join(''); if (!s) { daySelect.value = DAYS_LIST[0]; } const startTime = s?.start_time || '09:10'; const [h, m] = startTime.split(':'); document.getElementById('scheduleStartH').value = h; document.getElementById('scheduleStartM').value = m; if (s?.end_time) { const [eh, em] = s.end_time.split(':'); if (id) { const autoEnd = getAutoEndTime(startTime); if (s.end_time !== autoEnd) { document.getElementById('scheduleEndOverride').checked = true; toggleEndTimeOverride(); } } document.getElementById('scheduleEndH').value = eh; document.getElementById('scheduleEndM').value = em; } else { autoCalculateEndTime(); } document.getElementById('scheduleModal').classList.add('active'); } function getAutoEndTime(startTime) { const [h, m] = startTime.split(':'); const lessonDuration = parseInt(settingsData.lesson_duration) || 40; const startMin = parseInt(h) * 60 + parseInt(m); const endMin = startMin + lessonDuration; return `${String(Math.floor(endMin / 60) % 24).padStart(2, '0')}:${String(endMin % 60).padStart(2, '0')}`; } function closeModal(id) { document.getElementById(id).classList.remove('active'); } function padTimeOptions(selectEl, max, step=1) { let html = ''; for (let i = 0; i <= max; i += step) { html += `<option value="${String(i).padStart(2,'0')}">${String(i).padStart(2,'0')}</option>`; } selectEl.innerHTML = html; } document.addEventListener('DOMContentLoaded', () => { padTimeOptions(document.getElementById('scheduleStartH'), 23); padTimeOptions(document.getElementById('scheduleStartM'), 59, 5); padTimeOptions(document.getElementById('scheduleEndH'), 23); padTimeOptions(document.getElementById('scheduleEndM'), 59, 5); document.getElementById('scheduleStartH').addEventListener('change', autoCalculateEndTime); document.getElementById('scheduleStartM').addEventListener('change', autoCalculateEndTime); }); async function saveVozhaty() { try { const id = document.getElementById('vozhatyId').value; const name = document.getElementById('vozhatyName').value.trim(); if (!name) { showToast('Введите имя', 'error'); return; } const phone = document.getElementById('vozhatyPhone').value.trim(); const body = { name, phone }; if (id) { await apiPut(`/api/vozhaty/${id}`, body); showToast('Вожатый обновлён'); } else { await apiPost('/api/vozhaty', body); showToast('Вожатый добавлен'); } closeModal('vozhatyModal'); await loadAll(); } catch (e) { showToast(e.message || 'Ошибка сохранения', 'error'); } } async function deleteVozhaty(id) { if (!confirm('Удалить вожатого?')) return; try { await apiDelete('/api/vozhaty/' + id); showToast('Вожатый удалён'); await loadAll(); } catch (e) { showToast(e.message || 'Ошибка удаления', 'error'); } } async function savePerformer() { try { const id = document.getElementById('performerId').value; const name = document.getElementById('performerName').value.trim(); if (!name) { showToast('Введите ФИО', 'error'); return; } if (id) { await apiPut(`/api/performers/${id}`, { name }); showToast('Исполнитель обновлён'); } else { await apiPost('/api/performers', { name }); showToast('Исполнитель добавлен'); } closeModal('performerModal'); await loadAll(); } catch (e) { showToast(e.message || 'Ошибка сохранения', 'error'); } } async function deletePerformer(id) { if (!confirm('Удалить исполнителя?')) return; try { await apiDelete('/api/performers/' + id); showToast('Исполнитель удалён'); await loadAll(); } catch (e) { showToast(e.message || 'Ошибка удаления', 'error'); } } async function saveCircle() { try { const id = document.getElementById('circleId').value; const name = document.getElementById('circleName').value.trim(); if (!name) { showToast('Введите название', 'error'); return; } const body = { name, need_per_squad: parseInt(document.getElementById('circleNeed').value), is_paid: parseInt(document.getElementById('circleCategory').value), duration: parseInt(document.getElementById('circleDuration').value) }; let circleId = id; if (id) { await apiPut(`/api/circles/${id}`, body); showToast('Кружок обновлён'); } else { const res = await apiPost('/api/circles', body); circleId = res.circle.id; showToast('Кружок добавлен'); } const performerCbs = document.querySelectorAll('#circlePerformersCheckboxes input[type="checkbox"]:checked'); const performer_ids = Array.from(performerCbs).map(cb => parseInt(cb.value)); await apiPut(`/api/circles/${circleId}/performers`, { performer_ids }); closeModal('circleModal'); await loadAll(); } catch (e) { showToast(e.message || 'Ошибка сохранения кружка', 'error'); } } async function deleteCircle(id) { if (!confirm('Удалить кружок?')) return; try { await apiDelete('/api/circles/' + id); showToast('Кружок удалён'); await loadAll(); } catch (e) { showToast(e.message || 'Ошибка удаления', 'error'); } } async function saveSquad() { try { const id = document.getElementById('squadId').value; const name = document.getElementById('squadName').value.trim(); if (!name) { showToast('Введите название', 'error'); return; } const body = { name, points: parseInt(document.getElementById('squadPoints').value) || 0, vozhaty_id: document.getElementById('squadVozhaty').value || null }; if (id) { await apiPut(`/api/squads/${id}`, body); showToast('Отряд обновлён'); } else { await apiPost('/api/squads', body); showToast('Отряд добавлен'); } closeModal('squadModal'); await loadAll(); } catch (e) { showToast(e.message || 'Ошибка сохранения отряда', 'error'); } } async function deleteSquad(id) { if (!confirm('Удалить отряд?')) return; try { await apiDelete('/api/squads/' + id); showToast('Отряд удалён'); await loadAll(); } catch (e) { showToast(e.message || 'Ошибка удаления', 'error'); } } async function saveSchedule() { try { const id = document.getElementById('scheduleId').value; const squad_id = parseInt(document.getElementById('scheduleSquad').value); const circle_id = parseInt(document.getElementById('scheduleCircle').value); const day = parseInt(document.getElementById('scheduleDay').value); const startH = document.getElementById('scheduleStartH').value; const startM = document.getElementById('scheduleStartM').value; const start_time = `${startH}:${startM}`; const endH = document.getElementById('scheduleEndH').value; const endM = document.getElementById('scheduleEndM').value; const end_time = `${endH}:${endM}`; const body = { squad_id, circle_id, day, start_time, end_time }; if (id) { await apiPut(`/api/schedule/${id}`, body); showToast('Занятие обновлено'); } else { await apiPost('/api/schedule', body); showToast('Занятие добавлено'); } closeModal('scheduleModal'); await loadAll(); } catch (e) { showToast(e.message || 'Ошибка сохранения занятия', 'error'); } } async function deleteSchedule(id) { if (!confirm('Удалить занятие?')) return; try { await apiDelete('/api/schedule/' + id); showToast('Занятие удалено'); await loadAll(); } catch (e) { showToast(e.message || 'Ошибка удаления', 'error'); } } async function clearSchedule() { if (!confirm('Очистить всё расписание?')) return; try { await apiDelete('/api/schedule/clear'); showToast('Расписание очищено'); await loadAll(); } catch (e) { showToast(e.message || 'Ошибка очистки', 'error'); } } async function generateSchedule(event) { const btn = event.target; btn.classList.add('loading'); btn.disabled = true; try { const res = await apiPost('/api/schedule/generate', { days: DAYS_LIST }); if (res.error) { showToast(res.error || 'Ошибка генерации', 'error'); } else { showToast(`Сгенерировано ${res.count} занятий`); } await loadAll(); } catch (e) { showToast(e.message || 'Ошибка генерации', 'error'); } finally { btn.classList.remove('loading'); btn.disabled = false; } } // --- Search --- let searchFilters = { vozhaty: '', performers: '', circles: '', squads: '' }; function filterVozhaty(val) { searchFilters.vozhaty = val.toLowerCase(); renderVozhaty(); } function filterPerformers(val) { searchFilters.performers = val.toLowerCase(); renderPerformers(); } function filterCircles(val) { searchFilters.circles = val.toLowerCase(); renderCircles(); } function filterSquads(val) { searchFilters.squads = val.toLowerCase(); renderSquads(); } const _origRenderVozhaty = renderVozhaty; renderVozhaty = function(filteredArr) { const data = filteredArr || (searchFilters.vozhaty ? vozhaty.filter(v => v.name.toLowerCase().includes(searchFilters.vozhaty)) : vozhaty); _origRenderVozhaty.call(this, data); }; const _origRenderPerformers = renderPerformers; renderPerformers = function(filteredArr) { const data = filteredArr || (searchFilters.performers ? performers.filter(p => p.name.toLowerCase().includes(searchFilters.performers)) : performers); _origRenderPerformers.call(this, data); }; const _origRenderCircles = renderCircles; renderCircles = function(filteredArr) { const data = filteredArr || (searchFilters.circles ? circles.filter(c => c.name.toLowerCase().includes(searchFilters.circles)) : circles); _origRenderCircles.call(this, data); }; const _origRenderSquads = renderSquads; renderSquads = function(filteredArr) { const data = filteredArr || (searchFilters.squads ? squads.filter(s => s.name.toLowerCase().includes(searchFilters.squads)) : squads); _origRenderSquads.call(this, data); }; // --- CSV Export --- function exportCSV() { if (!scheduleItems.length) { showToast('Нет данных для экспорта', 'warning'); return; } const headers = ['Отряд', 'Вожатый', 'День', 'Кружок', 'Тип', 'Начало', 'Конец']; const rows = scheduleItems.map(s => [ s.squad_name || '', s.vozhaty_name || '', 'День ' + s.day, s.circle_name || '', s.is_paid === 2 ? 'Выезд' : s.is_paid ? 'Платный' : 'Бесплатный', s.start_time, s.end_time ]); const csv = [headers.join(';'), ...rows.map(r => r.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.setAttribute('download', 'smena_schedule.csv'); document.body.appendChild(link); link.click(); document.body.removeChild(link); showToast('CSV экспортирован'); } </script> </body> </html>