v2
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"'}[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 += `¶llel=${encodeURIComponent(par)}`;
|
||||
url += `?parallel=${encodeURIComponent(par)}`;
|
||||
}
|
||||
window.open(url, '_blank');
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user