This commit is contained in:
2026-07-01 11:05:25 +05:00
parent e62deda962
commit 7f5ffc8301
6 changed files with 156 additions and 45 deletions

View File

@@ -42,11 +42,57 @@ h2 { margin: 15px 0 8px; font-size: 16px; }
.search-bar { margin-bottom: 15px; }
.filter-bar {
display: flex;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.filter-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
cursor: pointer;
padding: 4px 10px;
border-radius: 4px;
background: #eee;
transition: background 0.2s;
}
.filter-label:hover { background: #ddd; }
.filter-gia { color: #0066cc; }
.filter-gve { color: #666; }
.filter-nodata { color: #c0392b; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 4px; overflow: hidden; }
table th, table td { padding: 8px 10px; text-align: left; border-bottom: 1px solid #eee; }
table th { background: #eee; font-weight: 600; }
table tr:hover { background: #fafafa; }
.row-warning { background: #ffe6e6 !important; }
.row-warning:hover { background: #ffd6d6 !important; }
.banner-warning {
background: #fff3cd;
border: 1px solid #ffc107;
color: #856404;
padding: 10px 15px;
border-radius: 6px;
margin-bottom: 12px;
font-size: 14px;
font-weight: 500;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 3px;
font-size: 11px;
font-weight: 600;
}
.badge-gia { background: #d4edff; color: #0056b3; }
.badge-gve { background: #eee; color: #555; }
.card {
background: #fff;
padding: 15px 20px;

View File

@@ -74,26 +74,42 @@ router.post('/import', upload.single('xlsfile'), (req, res) => {
router.get('/students', (req, res) => {
const search = req.query.search || '';
let rows;
const filter = req.query.filter || '';
const baseQuery = `
SELECT s.*,
COUNT(er.id) as exam_count,
SUM(CASE WHEN s2.is_mandatory = 0 THEN 1 ELSE 0 END) as elective_count,
SUM(CASE WHEN s2.is_mandatory = 1 AND er.grade IS NOT NULL THEN 1 ELSE 0 END) as mandatory_grades
FROM students s
LEFT JOIN exam_results er ON er.student_id = s.id
LEFT JOIN subjects s2 ON s2.code = er.subject_code
`;
const groupClause = 'GROUP BY s.id';
const orderClause = 'ORDER BY s.class, s.last_name, s.first_name';
let whereClauses = [];
let params = [];
if (search) {
const like = `%${search}%`;
rows = dbAll(`
SELECT s.*, COUNT(er.id) as exam_count
FROM students s
LEFT JOIN exam_results er ON er.student_id = s.id
WHERE s.last_name LIKE ? OR s.first_name LIKE ? OR s.patronymic LIKE ? OR s.class LIKE ?
GROUP BY s.id
ORDER BY s.class, s.last_name, s.first_name
`, [like, like, like, like]);
} else {
rows = dbAll(`
SELECT s.*, COUNT(er.id) as exam_count
FROM students s
LEFT JOIN exam_results er ON er.student_id = s.id
GROUP BY s.id
ORDER BY s.class, s.last_name, s.first_name
`);
whereClauses.push('(s.last_name LIKE ? OR s.first_name LIKE ? OR s.patronymic LIKE ? OR s.class LIKE ?)');
params.push(like, like, like, like);
}
let havingClause = '';
if (filter === 'gia' || filter === 'oge') {
havingClause = 'HAVING elective_count > 0';
} else if (filter === 'gve') {
havingClause = 'HAVING elective_count = 0';
} else if (filter === 'nodata') {
havingClause = 'HAVING mandatory_grades < 2';
}
const wherePart = whereClauses.length ? 'WHERE ' + whereClauses.join(' AND ') : '';
const sql = `${baseQuery} ${wherePart} ${groupClause} ${havingClause} ${orderClause}`;
const rows = dbAll(sql, params);
res.json(rows);
});

View File

@@ -170,7 +170,6 @@ function declineMalePatr(name) {
router.get('/batch-print', requireAuth, (req, res) => {
const { class: cls, parallel, type } = req.query;
if (!type || !['gia', 'gve'].includes(type)) return res.status(400).send('Укажите type=gia или type=gve');
let students;
if (cls) {
@@ -197,11 +196,15 @@ router.get('/batch-print', requireAuth, (req, res) => {
const mathResult = results.find(r => r.subject_code === '02') || null;
const electives = results.filter(r => !r.is_mandatory);
// Автоопределение: если есть элективы — ОГЭ, иначе ГВЭ; ручной type переопределяет
const autoType = electives.length > 0 ? 'gia' : 'gve';
const finalType = (type === 'gia' || type === 'gve') ? type : autoType;
const gender = detectGender(st.patronymic);
const fioDative = formatNameDative(st, gender);
let allSubjects;
if (type === 'gia') {
if (finalType === 'gia') {
allSubjects = [
rusResult || { subject_name: 'Русский язык', primary_score: null, grade: null },
mathResult || { subject_name: 'Математика', primary_score: null, grade: null },
@@ -220,11 +223,12 @@ router.get('/batch-print', requireAuth, (req, res) => {
dateStr,
verbEnding: gender === 'm' ? 'обучался' : 'обучалась',
verbEnding2: gender === 'm' ? 'получил' : 'получила',
allSubjects
allSubjects,
type: finalType
};
});
res.render('batch-certificate', { type, students: studentData });
res.render('batch-certificate', { students: studentData });
});
module.exports = router;

View File

@@ -2,7 +2,7 @@
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Справки — <%= type === 'gia' ? 'ГИА' : 'ГВЭ' %> (пакет)</title>
<title>Справки (пакет)</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
@@ -107,7 +107,7 @@
</div>
<div class="subtitle">
<% if (type === 'gia') { %>
<% if (data.type === 'gia') { %>
в форме основного государственного экзамена
<% } else { %>
в форме государственного выпускного экзамена
@@ -123,7 +123,7 @@
школе № 25 имени В.Г. Феофанова по основной образовательной программе основного общего
образования и по результатам государственной итоговой аттестации
<%= data.verbEnding2 %> в 2025/2026 учебном году по учебным предметам следующие отметки
<% if (type === 'gia') { %>(количество первичных баллов)<% } %>:
<% if (data.type === 'gia') { %>(количество первичных баллов)<% } %>:
</div>
<table>
@@ -131,7 +131,7 @@
<tr>
<th>№</th>
<th>Наименование учебных предметов</th>
<% if (type === 'gia') { %>
<% if (data.type === 'gia') { %>
<th>Количество первичных баллов</th>
<% } %>
<th>Отметка по пятибалльной шкале</th>
@@ -142,7 +142,7 @@
<tr>
<td><%= i + 1 %></td>
<td><%= s.subject_name %></td>
<% if (type === 'gia') { %>
<% if (data.type === 'gia') { %>
<td><%= s.primary_score !== null ? s.primary_score : '—' %></td>
<% } %>
<td><%= s.grade !== null ? s.grade + ' (' + gradeText(s.grade) + ')' : '—' %></td>

View File

@@ -17,6 +17,8 @@
<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">
@@ -37,16 +39,26 @@
</label>
</div>
<div class="form-row" style="margin-top:8px">
<label>
<input type="radio" name="batch-type" value="gia" checked> ГИА (ОГЭ)
</label>
<label style="margin-left:15px">
<input type="radio" name="batch-type" value="gve"> ГВЭ
</label>
<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>
</div>
<div class="search-bar">
<input type="text" id="search" class="input" placeholder="Поиск по ФИО или классу...">
</div>
@@ -60,6 +72,7 @@
<th>Отчество</th>
<th>Класс</th>
<th>Предметов</th>
<th>Тип</th>
<th>Действия</th>
</tr>
</thead>
@@ -95,11 +108,17 @@
<script>
let allStudents = [];
async function loadStudents(search = '') {
const url = search ? `/api/students?search=${encodeURIComponent(search)}` : '/api/students';
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() {
@@ -118,26 +137,42 @@
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 renderTable(students) {
const tbody = document.querySelector('#students-table tbody');
tbody.innerHTML = students.map((s, i) => `
<tr>
tbody.innerHTML = students.map((s, i) => {
const hasMandatory = (s.mandatory_grades || 0) >= 2;
const rowClass = !hasMandatory ? 'row-warning' : '';
const examType = (s.elective_count || 0) > 0 ? 'ОГЭ' : 'ГВЭ';
const examTypeClass = (s.elective_count || 0) > 0 ? 'badge-gia' : 'badge-gve';
return `
<tr class="${rowClass}">
<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><a href="/student/${s.id}" class="btn btn-sm">Открыть</a></td>
</tr>
`).join('');
</tr>`;
}).join('');
}
function esc(s) { return (s || '').replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
document.getElementById('search').addEventListener('input', (e) => {
loadStudents(e.target.value);
});
document.getElementById('search').addEventListener('input', () => loadStudents());
function onFilterChange() { loadStudents(); }
document.getElementById('clear-data').addEventListener('click', async () => {
if (!confirm('Удалить всех учеников и результаты?')) return;
@@ -147,16 +182,15 @@
document.getElementById('batch-print-btn').addEventListener('click', () => {
const scope = document.querySelector('input[name="batch-scope"]:checked').value;
const type = document.querySelector('input[name="batch-type"]:checked').value;
let url = `/batch-print?type=${type}`;
let url = '/batch-print';
if (scope === 'class') {
const cls = document.getElementById('class-select').value;
if (!cls) return alert('Выберите класс');
url += `&class=${encodeURIComponent(cls)}`;
url += `?class=${encodeURIComponent(cls)}`;
} else if (scope === 'parallel') {
const par = document.getElementById('parallel-select').value;
if (!par) return alert('Выберите параллель');
url += `&parallel=${encodeURIComponent(par)}`;
url += `?parallel=${encodeURIComponent(par)}`;
}
window.open(url, '_blank');
});

View File

@@ -17,6 +17,17 @@
<h1><%= student.last_name %> <%= student.first_name %> <%= student.patronymic %></h1>
<p>Класс: <strong><%= student.class %></strong></p>
<% const hasRus = rusResult && rusResult.grade !== null; const hasMath = mathResult && mathResult.grade !== null; %>
<% if (!hasRus || !hasMath) { %>
<div class="banner-warning">
Не заполнены оценки:
<%= !hasRus ? 'русский язык' : '' %>
<%= !hasRus && !hasMath ? 'и' : '' %>
<%= !hasMath ? 'математика' : '' %>.
Заполните обязательные предметы перед печатью.
</div>
<% } %>
<div class="card">
<h2>Обязательные предметы</h2>
<form id="mandatory-form" class="mandatory-form">