231 lines
9.5 KiB
Plaintext
231 lines
9.5 KiB
Plaintext
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Ученики — Справки ГИА/ГВЭ</title>
|
||
<link rel="stylesheet" href="/style.css">
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<h1>Ученики</h1>
|
||
|
||
<nav class="nav">
|
||
<a href="/" class="btn">Список учеников</a>
|
||
<a href="/import" class="btn btn-green">Импорт XLS</a>
|
||
<a href="/history" class="btn" style="background:#5a3e85">История</a>
|
||
<span class="user-bar"><%= user ? user.name : '' %></span>
|
||
<button id="logout-btn" class="btn btn-logout">Выйти</button>
|
||
</nav>
|
||
|
||
<div id="warning-banner" class="banner-warning" style="display:none"></div>
|
||
|
||
<div class="card batch-panel">
|
||
<h2>Пакетная печать</h2>
|
||
<div class="form-row">
|
||
<label>
|
||
<input type="radio" name="batch-scope" value="class" checked onchange="toggleBatchMode()"> Класс
|
||
</label>
|
||
<select id="class-select" class="input">
|
||
<option value="">— загрузка... —</option>
|
||
</select>
|
||
<label style="margin-left:15px">
|
||
<input type="radio" name="batch-scope" value="parallel" onchange="toggleBatchMode()"> Параллель
|
||
</label>
|
||
<select id="parallel-select" class="input" disabled>
|
||
<option value="">— загрузка... —</option>
|
||
</select>
|
||
<label style="margin-left:15px">
|
||
<input type="radio" name="batch-scope" value="all" onchange="toggleBatchMode()"> Все ученики
|
||
</label>
|
||
</div>
|
||
<div class="form-row" style="margin-top:8px">
|
||
<button id="batch-print-btn" class="btn btn-blue" style="margin-left:15px">Печать справок</button>
|
||
<span style="font-size:12px; color:#888; margin-left:10px">тип определяется автоматически</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="filter-bar">
|
||
<label class="filter-label">
|
||
<input type="radio" name="filter" value="all" checked onchange="onFilterChange()"> Все
|
||
</label>
|
||
<label class="filter-label filter-gia">
|
||
<input type="radio" name="filter" value="gia" onchange="onFilterChange()"> ОГЭ (ГИА)
|
||
</label>
|
||
<label class="filter-label filter-gve">
|
||
<input type="radio" name="filter" value="gve" onchange="onFilterChange()"> ГВЭ
|
||
</label>
|
||
<label class="filter-label filter-nodata">
|
||
<input type="radio" name="filter" value="nodata" onchange="onFilterChange()"> Нет данных
|
||
</label>
|
||
<label class="filter-label" style="background:#ffe0e0;color:#900">
|
||
<input type="radio" name="filter" value="conflict" onchange="onFilterChange()"> Конфликт
|
||
</label>
|
||
<label class="filter-label" style="background:#fff0d0;color:#860">
|
||
<input type="radio" name="filter" value="secondyear" onchange="onFilterChange()"> Второгодники
|
||
</label>
|
||
<label class="filter-label" style="background:#fdd;color:#a00">
|
||
<input type="radio" name="filter" value="lowgrade" onchange="onFilterChange()"> Отметка < 3
|
||
</label>
|
||
</div>
|
||
|
||
<div class="search-bar">
|
||
<input type="text" id="search" class="input" placeholder="Поиск по ФИО или классу...">
|
||
</div>
|
||
|
||
<table id="students-table">
|
||
<thead>
|
||
<tr>
|
||
<th>№</th>
|
||
<th>Фамилия</th>
|
||
<th>Имя</th>
|
||
<th>Отчество</th>
|
||
<th>Класс</th>
|
||
<th>Предметов</th>
|
||
<th>Тип</th>
|
||
<th>Предметы (оценка)</th>
|
||
<th>Действия</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody></tbody>
|
||
</table>
|
||
|
||
<div class="toolbar">
|
||
<button id="clear-data" class="btn btn-danger">Очистить все данные</button>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
window.__CSRF_TOKEN = '<%= csrfToken %>';
|
||
|
||
const _origFetch = window.fetch.bind(window);
|
||
window.fetch = function(input, init = {}) {
|
||
init.credentials = 'include';
|
||
const method = (init.method || 'GET').toUpperCase();
|
||
if (['POST','PUT','PATCH','DELETE'].includes(method)) {
|
||
if (!init.headers) init.headers = {};
|
||
init.headers['X-CSRF-Token'] = window.__CSRF_TOKEN || '';
|
||
}
|
||
return _origFetch(input, init);
|
||
};
|
||
|
||
async function checkAuth() {
|
||
const r = await fetch('/api/user');
|
||
if (!r.ok) window.location.href = '/login';
|
||
}
|
||
checkAuth();
|
||
</script>
|
||
|
||
<script>
|
||
let allStudents = [];
|
||
|
||
async function loadStudents() {
|
||
const search = document.getElementById('search').value;
|
||
const filter = document.querySelector('input[name="filter"]:checked').value;
|
||
const params = new URLSearchParams();
|
||
if (search) params.set('search', search);
|
||
if (filter && filter !== 'all') params.set('filter', filter);
|
||
const url = '/api/students' + (params.toString() ? '?' + params.toString() : '');
|
||
const res = await fetch(url);
|
||
allStudents = await res.json();
|
||
renderTable(allStudents);
|
||
updateBanner(allStudents);
|
||
}
|
||
|
||
async function loadClasses() {
|
||
const res = await fetch('/api/classes');
|
||
const classes = await res.json();
|
||
const clsSel = document.getElementById('class-select');
|
||
clsSel.innerHTML = classes.map(c => `<option value="${esc(c)}">${esc(c)}</option>`).join('');
|
||
const parallels = [...new Set(classes.map(c => c.charAt(0)).filter(Boolean))].sort();
|
||
const parSel = document.getElementById('parallel-select');
|
||
parSel.innerHTML = parallels.map(p => `<option value="${esc(p)}">${esc(p)}-е классы</option>`).join('');
|
||
}
|
||
|
||
function toggleBatchMode() {
|
||
const scope = document.querySelector('input[name="batch-scope"]:checked').value;
|
||
document.getElementById('class-select').disabled = scope !== 'class';
|
||
document.getElementById('parallel-select').disabled = scope !== 'parallel';
|
||
}
|
||
|
||
function updateBanner(students) {
|
||
const nodata = students.filter(s => (s.mandatory_grades || 0) < 2);
|
||
const banner = document.getElementById('warning-banner');
|
||
if (nodata.length > 0) {
|
||
banner.style.display = 'block';
|
||
banner.innerHTML = `⚠ ${nodata.length} учеников без оценок по русскому/математике`;
|
||
} else {
|
||
banner.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function formatSubjectsList(list) {
|
||
if (!list) return '—';
|
||
return list.split(' / ').map(item => {
|
||
const m = item.match(/^(.*?)\s*(\d)\s*$/);
|
||
if (m && parseInt(m[2]) < 3) return `<span style="color:#c0392b;font-weight:600">${esc(item)}</span>`;
|
||
return esc(item);
|
||
}).join(' / ');
|
||
}
|
||
|
||
function renderTable(students) {
|
||
const tbody = document.querySelector('#students-table tbody');
|
||
tbody.innerHTML = students.map((s, i) => {
|
||
const isSecondYear = (s.elective_count || 0) === 0 && s.exam_count === 1;
|
||
const hasMandatory = isSecondYear ? (s.mandatory_grades || 0) >= 1 : (s.mandatory_grades || 0) >= 2;
|
||
const rowClass = !hasMandatory ? 'row-warning' : '';
|
||
const isOge = (s.elective_count || 0) > 0 || isSecondYear;
|
||
const examType = isOge ? 'ОГЭ' : 'ГВЭ';
|
||
const examTypeClass = isOge ? 'badge-gia' : 'badge-gve';
|
||
return `
|
||
<tr class="${rowClass}" onclick="window.location.href='/student/${s.id}'" style="cursor:pointer">
|
||
<td>${i + 1}</td>
|
||
<td>${esc(s.last_name)}</td>
|
||
<td>${esc(s.first_name)}</td>
|
||
<td>${esc(s.patronymic)}</td>
|
||
<td>${esc(s.class)}</td>
|
||
<td>${s.exam_count}</td>
|
||
<td><span class="badge ${examTypeClass}">${examType}</span></td>
|
||
<td style="font-size:11px;max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(s.subjects_list || '')}">${formatSubjectsList(s.subjects_list)}</td>
|
||
<td onclick="event.stopPropagation()"><a href="/student/${s.id}" class="btn btn-sm">Открыть</a></td>
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
|
||
function esc(s) { return (s || '').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
||
|
||
document.getElementById('search').addEventListener('input', () => loadStudents());
|
||
function onFilterChange() { loadStudents(); }
|
||
|
||
document.getElementById('clear-data').addEventListener('click', async () => {
|
||
if (!confirm('Удалить всех учеников и результаты?')) return;
|
||
await fetch('/api/data', { method: 'DELETE' });
|
||
loadStudents();
|
||
});
|
||
|
||
document.getElementById('batch-print-btn').addEventListener('click', () => {
|
||
const scope = document.querySelector('input[name="batch-scope"]:checked').value;
|
||
let url = '/batch-print';
|
||
if (scope === 'class') {
|
||
const cls = document.getElementById('class-select').value;
|
||
if (!cls) return alert('Выберите класс');
|
||
url += `?class=${encodeURIComponent(cls)}`;
|
||
} else if (scope === 'parallel') {
|
||
const par = document.getElementById('parallel-select').value;
|
||
if (!par) return alert('Выберите параллель');
|
||
url += `?parallel=${encodeURIComponent(par)}`;
|
||
}
|
||
window.open(url, '_blank');
|
||
});
|
||
|
||
document.getElementById('logout-btn').addEventListener('click', async () => {
|
||
await fetch('/api/logout', { method: 'POST' });
|
||
window.location.href = '/login';
|
||
});
|
||
|
||
loadStudents();
|
||
loadClasses();
|
||
</script>
|
||
</body>
|
||
</html>
|