This commit is contained in:
2026-06-19 16:59:04 +05:00
parent e362391f94
commit 901aabf2d2
8 changed files with 769 additions and 34 deletions

0
12-20 Normal file
View File

View File

@@ -76,11 +76,15 @@ function getFilterParams() {
const student = document.getElementById('gfStudent').value.trim();
const subject = document.getElementById('gfSubject').value.trim();
const teacher = document.getElementById('gfTeacher').value.trim();
const classVal = document.getElementById('gfClass').value.trim();
const parallel = document.getElementById('gfParallel').value.trim();
const from = document.getElementById('gfDateFrom').value;
const to = document.getElementById('gfDateTo').value;
if (student) params.student = student;
if (subject) params.subject = subject;
if (teacher) params.teacher = teacher;
if (classVal) params.class = classVal;
if (parallel) params.parallel = parallel;
if (from) params.date_from = from.slice(5);
if (to) params.date_to = to.slice(5);
return params;
@@ -111,6 +115,7 @@ async function loadAll() {
renderClassesTable(stats);
renderDailyTable(daily);
loadPivot();
loadControl();
}
async function loadFilters() {
@@ -126,6 +131,7 @@ async function loadFilters() {
fill('dlStudents', data.students);
fill('dlSubjects', data.subjects);
fill('dlTeachers', data.teachers);
fill('dlClasses', data.classes);
} catch (e) {
console.error(e);
}
@@ -305,6 +311,8 @@ document.getElementById('gfReset').addEventListener('click', () => {
document.getElementById('gfStudent').value = '';
document.getElementById('gfSubject').value = '';
document.getElementById('gfTeacher').value = '';
document.getElementById('gfClass').value = '';
document.getElementById('gfParallel').value = '';
document.getElementById('gfDateFrom').value = '';
document.getElementById('gfDateTo').value = '';
loadAll();
@@ -491,5 +499,166 @@ window.toggleGroup = function(groupId) {
}
};
let controlMode = 'homework';
document.querySelectorAll('.ctrl-tab').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.ctrl-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
controlMode = btn.dataset.ctrl;
document.getElementById('ctrlFiltersHW').style.display = controlMode === 'homework' ? '' : 'none';
document.getElementById('ctrlFiltersGrades').style.display = controlMode === 'grades' ? '' : 'none';
document.getElementById('ctrlFiltersDvoiki').style.display = controlMode === 'dvoiki' ? '' : 'none';
document.getElementById('ctrlTableWrap').style.display = controlMode === 'dvoiki' ? 'none' : '';
document.getElementById('dvoikiCards').style.display = controlMode === 'dvoiki' ? '' : 'none';
loadControl();
});
});
document.getElementById('ctrlNoTopic').addEventListener('change', loadControl);
document.getElementById('ctrlNoHomework').addEventListener('change', loadControl);
document.getElementById('ctrlMinGrades').addEventListener('change', loadControl);
async function loadControl() {
const params = getFilterParams();
if (controlMode === 'dvoiki') {
const qs = buildQuery(params);
try {
const res = await fetch('/api/dvoiki?' + qs);
const data = await res.json();
if (data.error) return;
const allRows = data.rows || [];
const openCount = allRows.filter(r => !r.resolved).length;
const closedCount = allRows.filter(r => r.resolved).length;
renderDvoikiPills(allRows.length, openCount, closedCount);
let rows = allRows;
if (window._dvoikiFilter === 'open') rows = rows.filter(r => !r.resolved);
else if (window._dvoikiFilter === 'closed') rows = rows.filter(r => r.resolved);
renderDvoikiCards(rows);
} catch (e) {
console.error(e);
}
return;
}
if (controlMode === 'homework') {
if (document.getElementById('ctrlNoTopic').checked) params.no_topic = 'true';
if (document.getElementById('ctrlNoHomework').checked) params.no_homework = 'true';
} else {
params.low_grades = 'true';
params.min_grades = document.getElementById('ctrlMinGrades').value || '3';
}
const qs = buildQuery(params);
try {
const res = await fetch('/api/lessons?' + qs);
const data = await res.json();
if (data.error) return;
const thead = document.querySelector('#controlTable thead');
thead.innerHTML = '<tr><th>Дата</th><th>Предмет</th><th>Учитель</th><th>Тема</th><th>ДЗ</th><th>Всего</th><th>Числ.</th><th>Симв.</th><th>Оценки</th></tr>';
const tbody = document.querySelector('#controlTable tbody');
if (!data.rows || !data.rows.length) {
tbody.innerHTML = '<tr><td colspan="9" class="empty-state">Нет данных</td></tr>';
return;
}
const minNumeric = data.min_grades || 3;
tbody.innerHTML = data.rows.map(r => {
let topicCls = '';
let hwCls = '';
let rowCls = '';
if (controlMode === 'homework') {
if (!r.topic) topicCls = 'missing';
if (!r.homework) hwCls = 'missing';
} else {
if (r.grades_numeric < minNumeric) rowCls = 'red-row';
}
return `<tr class="${rowCls}">
<td>${r.lesson_date}</td>
<td>${r.subject}</td>
<td>${r.teacher}</td>
<td class="${topicCls}">${r.topic || '—'}</td>
<td class="${hwCls}">${r.homework || '—'}</td>
<td class="num">${r.grades_total}</td>
<td class="num">${r.grades_numeric}</td>
<td class="num">${r.grades_symbol}</td>
<td class="grades-cell">${r.grades_list || ''}</td>
</tr>`;
}).join('');
} catch (e) {
console.error(e);
}
}
let _dvoikiFilter = 'all';
function renderDvoikiPills(total, open, closed) {
window._dvoikiFilter = window._dvoikiFilter || 'all';
const pills = [
{ key: 'all', label: 'Все', count: total, active: window._dvoikiFilter === 'all' },
{ key: 'open', label: '⚠️ Незакрыто', count: open, active: window._dvoikiFilter === 'open' },
{ key: 'closed', label: '✅ Закрыто', count: closed, active: window._dvoikiFilter === 'closed' }
];
document.getElementById('dvoikiPills').innerHTML = pills.map(p =>
`<button class="pill ${p.active ? 'active' : ''}" data-filter="${p.key}">${p.label} (${p.count})</button>`
).join('');
document.querySelectorAll('.pill').forEach(btn => {
btn.addEventListener('click', () => {
window._dvoikiFilter = btn.dataset.filter;
loadControl();
});
});
}
function renderDvoikiCards(rows) {
const container = document.getElementById('dvoikiCards');
if (!rows.length) {
container.innerHTML = '<div class="empty-state">Нет цепочек</div>';
return;
}
const gradeClass = (g) => {
if (g === '2') return 's-2';
if (g === '3') return 's-3';
if (g === '4' || g === '5') return 's-45';
if (g === '—') return 's-empty';
return 's-sym';
};
container.innerHTML = rows.map(r => {
const cardCls = r.resolved ? 'd-card resolved' : 'd-card unresolved';
const statusIcon = r.resolved ? '✅' : '⚠️';
const statusText = r.resolved ? `Закрыт (${r.reason})` : `Не закрыт — ${r.reason}`;
const stepsHtml = r.chain.map(c =>
`<div class="step">
<div class="step-date">${c.date}</div>
<div class="step-grade ${gradeClass(c.grade)}">${c.grade}</div>
</div>`
).join('');
return `<div class="${cardCls}">
<div class="d-card-top">
<span class="d-card-status">${statusIcon} ${statusText}</span>
<span class="d-card-steps">${r.chain.length} шаг${r.chain.length > 1 ? (r.chain.length < 5 ? 'а' : 'ов') : ''}</span>
</div>
<div class="d-card-student">${r.student}</div>
<div class="d-card-subject">${r.subject} · ${r.teacher}</div>
<div class="step-grid">${stepsHtml}</div>
</div>`;
}).join('');
}
loadFilters();
loadAll();

View File

@@ -30,6 +30,8 @@
<input type="text" id="gfStudent" placeholder="Ученик..." list="dlStudents">
<input type="text" id="gfSubject" placeholder="Предмет..." list="dlSubjects">
<input type="text" id="gfTeacher" placeholder="Учитель..." list="dlTeachers">
<input type="text" id="gfClass" placeholder="Класс..." list="dlClasses">
<input type="text" id="gfParallel" placeholder="Параллель...">
<input type="date" id="gfDateFrom" title="Дата с">
<input type="date" id="gfDateTo" title="Дата по">
<button id="gfApply">Применить</button>
@@ -46,6 +48,7 @@
<button class="tab" data-tab="classes">Классы</button>
<button class="tab" data-tab="daily">Подневно</button>
<button class="tab" data-tab="pivot">Сводка</button>
<button class="tab" data-tab="control">Контроль</button>
</section>
<section id="dashboard" class="tab-content active">
@@ -109,6 +112,32 @@
</table>
</div>
</section>
<section id="control" class="tab-content">
<h2>Контроль</h2>
<div class="control-tabs">
<button class="ctrl-tab active" data-ctrl="homework">📋 Домашние задания</button>
<button class="ctrl-tab" data-ctrl="grades">📊 Контроль оценок</button>
<button class="ctrl-tab" data-ctrl="dvoiki">🎯 Двойки</button>
</div>
<div class="control-filters" id="ctrlFiltersHW">
<label><input type="checkbox" id="ctrlNoTopic"> Без темы</label>
<label><input type="checkbox" id="ctrlNoHomework"> Без ДЗ</label>
</div>
<div class="control-filters" id="ctrlFiltersGrades" style="display:none">
<label>Мин. числовых оценок: <input type="number" id="ctrlMinGrades" value="3" min="0" max="100" style="width:60px"></label>
</div>
<div class="control-filters" id="ctrlFiltersDvoiki" style="display:none">
<div class="dvoiki-pills" id="dvoikiPills"></div>
</div>
<div class="table-wrapper" id="ctrlTableWrap">
<table id="controlTable">
<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>
<div class="dvoiki-cards" id="dvoikiCards" style="display:none"></div>
</section>
</main>
<div class="modal-overlay" id="clearModal">

View File

@@ -393,6 +393,211 @@ h2 { font-size: 18px; margin-bottom: 16px; color: #1a1a2e; }
color: #333;
}
.pivot-hidden { display: none; }
.control-tabs {
display: flex;
gap: 0;
margin-bottom: 12px;
}
.ctrl-tab {
padding: 7px 18px;
border: 1px solid #d0d0d0;
background: #fff;
font-size: 13px;
cursor: pointer;
color: #555;
}
.ctrl-tab:first-child { border-radius: 6px 0 0 6px; }
.ctrl-tab:last-child { border-radius: 0 6px 6px 0; }
.ctrl-tab.active {
background: #4f46e5;
color: #fff;
border-color: #4f46e5;
}
.control-filters {
display: flex;
gap: 16px;
align-items: center;
margin-bottom: 12px;
font-size: 13px;
}
.control-filters label {
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
}
.control-filters input[type="checkbox"] {
cursor: pointer;
}
.control-filters input[type="number"] {
padding: 4px 6px;
border: 1px solid #d0d0d0;
border-radius: 4px;
font-size: 13px;
}
#controlTable td.missing {
background: #fee2e2;
color: #991b1b;
font-style: italic;
}
#controlTable tr.red-row td {
background: #fee2e2;
}
#controlTable td.grades-cell {
max-width: 300px;
white-space: normal;
word-break: break-all;
font-size: 12px;
}
#controlTable tr.red-row td {
background: #fee2e2;
}
#controlTable tr.green-row td {
background: #dcfce7;
}
.chain-mini {
border-collapse: collapse;
display: inline-table;
vertical-align: middle;
}
.chain-mini td {
padding: 2px 5px;
border: 1px solid #e5e5e5;
font-size: 12px;
text-align: center;
background: #fff;
}
.cm-date {
color: #888;
font-size: 11px !important;
}
.cm-grade {
font-weight: 600;
min-width: 22px;
}
.cm-bad {
color: #dc2626;
}
.control-filters select {
padding: 4px 8px;
border: 1px solid #d0d0d0;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
}
.dvoiki-pills {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.pill {
padding: 7px 16px;
border: 1px solid #d0d0d0;
background: #fff;
border-radius: 20px;
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
color: #555;
}
.pill:hover {
border-color: #4f46e5;
color: #4f46e5;
}
.pill.active {
background: #4f46e5;
color: #fff;
border-color: #4f46e5;
}
.dvoiki-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
gap: 14px;
margin-top: 14px;
}
.d-card {
background: #fff;
border-radius: 12px;
padding: 16px 18px;
border: 1px solid #e5e5e5;
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
}
.d-card.unresolved {
border-left: 4px solid #dc2626;
}
.d-card.resolved {
border-left: 4px solid #16a34a;
}
.d-card-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.d-card-status {
font-size: 12px;
color: #888;
}
.d-card-steps {
font-size: 11px;
color: #aaa;
}
.d-card-student {
font-size: 15px;
font-weight: 600;
color: #1a1a2e;
margin-bottom: 3px;
}
.d-card-subject {
font-size: 13px;
color: #666;
margin-bottom: 12px;
}
.step-grid {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.step {
display: flex;
flex-direction: column;
align-items: center;
min-width: 42px;
}
.step-date {
font-size: 10px;
color: #999;
margin-bottom: 3px;
}
.step-grade {
width: 28px;
height: 28px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
color: #fff;
}
.s-2 { background: #dc2626; }
.s-3 { background: #f59e0b; }
.s-45 { background: #16a34a; }
.s-sym { background: #e5e5e5; color: #888; }
.s-empty {
background: #fff;
color: #999;
border: 1px dashed #ccc;
font-weight: 400;
}
.pagination {
display: flex;
justify-content: center;

242
server.js
View File

@@ -4,12 +4,32 @@ const path = require('path');
const fs = require('fs');
const { parsePdfBuffer } = require('./src/parser');
const { upsertClass, upsertStudent, upsertSubject, upsertTeacher, upsertGradeValue, insertDailyGradeValue, db } = require('./src/db');
const { upsertClass, upsertStudent, upsertSubject, upsertTeacher, upsertGradeValue, insertDailyGradeValue, insertLessonPlanValue, db } = require('./src/db');
const { getFullStats, getSubjectDetails, getTeacherDetails, getClassDetails } = require('./src/stats');
const app = express();
const PORT = process.env.PORT || 3000;
function buildClassWhere(query) {
const parts = [];
const params = [];
if (query.class) {
parts.push('c.name LIKE ?');
params.push('%' + query.class + '%');
}
if (query.parallel) {
parts.push('c.name LIKE ?');
params.push(query.parallel + '%');
}
return { sql: parts.length ? 'AND ' + parts.join(' AND ') : '', params };
}
function CYRILLIC_MIN(text, min) {
if (!text) return false;
const m = text.match(/[а-яё]/gi);
return (m && m.length >= min);
}
const UPLOADS_DIR = path.join(__dirname, 'uploads');
if (!fs.existsSync(UPLOADS_DIR)) fs.mkdirSync(UPLOADS_DIR, { recursive: true });
@@ -44,6 +64,7 @@ app.post('/api/upload', upload.single('pdf'), async (req, res) => {
db.prepare(`DELETE FROM daily_grades WHERE student_id IN (SELECT id FROM students WHERE class_id = ?)`).run(classId);
db.prepare(`DELETE FROM grades WHERE student_id IN (SELECT id FROM students WHERE class_id = ?)`).run(classId);
db.prepare(`DELETE FROM lesson_plans WHERE subject_id IN (SELECT id FROM subjects)`).run();
const subjectIds = {};
for (const subj of parsed.subjects) {
@@ -102,6 +123,20 @@ app.post('/api/upload', upload.single('pdf'), async (req, res) => {
}
}
let lessonCount = 0;
for (const lp of parsed.lessonPlans) {
const subjectId = subjectIds[lp.subject];
if (!subjectId) continue;
let tId = null;
if (lp.teacher && teacherIds[lp.teacher]) {
tId = teacherIds[lp.teacher];
} else if (teacherBySubject[lp.subject]) {
tId = teacherIds[teacherBySubject[lp.subject]] || null;
}
insertLessonPlanValue(subjectId, tId, lp.date, lp.topic, lp.homework);
lessonCount++;
}
fs.unlinkSync(req.file.path);
res.json({
@@ -112,7 +147,8 @@ app.post('/api/upload', upload.single('pdf'), async (req, res) => {
teachers: parsed.teachers.length,
quarterlyGrades: quarterlyCount,
dailyGrades: dailyCount,
message: `Загружен класс ${parsed.className}: ${parsed.students.length} учеников, ${parsed.subjects.length} предметов, ${quarterlyCount} четвертных, ${dailyCount} подневных оценок`
lessons: lessonCount,
message: `Загружен класс ${parsed.className}: ${parsed.students.length} учеников, ${parsed.subjects.length} предметов, ${quarterlyCount} четвертных, ${dailyCount} подневных, ${lessonCount} уроков`
});
} catch (e) {
if (req.file && fs.existsSync(req.file.path)) {
@@ -160,7 +196,8 @@ app.get('/api/filters', (req, res) => {
const students = db.prepare(`SELECT DISTINCT s.full_name FROM daily_grades d JOIN students s ON d.student_id = s.id ORDER BY s.full_name`).all().map(r => r.full_name);
const subjects = db.prepare(`SELECT DISTINCT sj.name FROM daily_grades d JOIN subjects sj ON d.subject_id = sj.id ORDER BY sj.name`).all().map(r => r.name);
const teachers = db.prepare(`SELECT DISTINCT t.name FROM daily_grades d JOIN teachers t ON d.teacher_id = t.id WHERE t.name IS NOT NULL ORDER BY t.name`).all().map(r => r.name);
res.json({ students, subjects, teachers });
const classes = db.prepare(`SELECT DISTINCT c.name FROM daily_grades d JOIN students s ON d.student_id = s.id JOIN classes c ON s.class_id = c.id ORDER BY c.name`).all().map(r => r.name);
res.json({ students, subjects, teachers, classes });
} catch (e) {
res.status(500).json({ error: e.message });
}
@@ -169,6 +206,7 @@ app.get('/api/filters', (req, res) => {
app.get('/api/daily', (req, res) => {
try {
const { student, subject, teacher, date_from, date_to, limit, offset } = req.query;
const cls = buildClassWhere(req.query);
let sql = `
SELECT d.id, s.full_name as student, c.name as class, sj.name as subject,
COALESCE(t.name, '—') as teacher,
@@ -179,9 +217,9 @@ app.get('/api/daily', (req, res) => {
JOIN classes c ON s.class_id = c.id
JOIN subjects sj ON d.subject_id = sj.id
LEFT JOIN teachers t ON d.teacher_id = t.id
WHERE 1=1
WHERE 1=1 ${cls.sql}
`;
const params = [];
const params = [...cls.params];
if (student) { sql += ' AND s.full_name LIKE ?'; params.push('%' + student + '%'); }
if (subject) { sql += ' AND sj.name LIKE ?'; params.push('%' + subject + '%'); }
@@ -211,11 +249,12 @@ app.get('/api/daily', (req, res) => {
let countSql = `
SELECT COUNT(*) as c FROM daily_grades d
JOIN students s ON d.student_id = s.id
JOIN classes c ON s.class_id = c.id
JOIN subjects sj ON d.subject_id = sj.id
LEFT JOIN teachers t ON d.teacher_id = t.id
WHERE 1=1
WHERE 1=1 ${cls.sql}
`;
let countParams = [];
let countParams = [...cls.params];
if (student) { countSql += ' AND s.full_name LIKE ?'; countParams.push('%' + student + '%'); }
if (subject) { countSql += ' AND sj.name LIKE ?'; countParams.push('%' + subject + '%'); }
if (teacher) { countSql += ' AND t.name LIKE ?'; countParams.push('%' + teacher + '%'); }
@@ -246,6 +285,7 @@ app.get('/api/daily', (req, res) => {
app.get('/api/pivot', (req, res) => {
try {
const { period, student, subject, teacher, date_from, date_to } = req.query;
const cls = buildClassWhere(req.query);
const mode = period === 'week' ? 'week' : period === 'quarter' ? 'quarter' : 'month';
let sql = `
@@ -258,10 +298,11 @@ app.get('/api/pivot', (req, res) => {
FROM daily_grades d
JOIN subjects sj ON d.subject_id = sj.id
JOIN students s ON d.student_id = s.id
JOIN classes c ON s.class_id = c.id
LEFT JOIN teachers t ON d.teacher_id = t.id
WHERE 1=1
WHERE 1=1 ${cls.sql}
`;
const params = [];
const params = [...cls.params];
if (student) { sql += ' AND s.full_name LIKE ?'; params.push('%' + student + '%'); }
if (subject) { sql += ' AND sj.name LIKE ?'; params.push('%' + subject + '%'); }
@@ -398,6 +439,189 @@ app.get('/api/pivot', (req, res) => {
}
});
app.get('/api/lessons', (req, res) => {
try {
const { subject, teacher, date_from, date_to, no_topic, no_homework, low_grades, min_grades } = req.query;
const cls = buildClassWhere(req.query);
const minNumeric = parseInt(min_grades) || 3;
let sql = `
SELECT lp.lesson_date,
sj.name as subject,
COALESCE(t.name, '—') as teacher,
lp.topic,
lp.homework,
COUNT(d.id) as grades_total,
SUM(CASE WHEN d.grade IS NOT NULL THEN 1 ELSE 0 END) as grades_numeric,
SUM(CASE WHEN d.symbol IS NOT NULL THEN 1 ELSE 0 END) as grades_symbol,
GROUP_CONCAT(COALESCE(CAST(d.grade AS TEXT), d.symbol), ',') as grades_list
FROM lesson_plans lp
JOIN subjects sj ON lp.subject_id = sj.id
LEFT JOIN teachers t ON lp.teacher_id = t.id
LEFT JOIN daily_grades d ON d.subject_id = lp.subject_id AND d.grade_date = lp.lesson_date
WHERE 1=1
`;
const params = [];
if (cls.params.length > 0) {
sql += ` AND lp.id IN (
SELECT DISTINCT lp2.id FROM lesson_plans lp2
JOIN daily_grades d2 ON d2.subject_id = lp2.subject_id AND d2.grade_date = lp2.lesson_date
JOIN students s2 ON d2.student_id = s2.id
JOIN classes c2 ON s2.class_id = c2.id
WHERE 1=1 ${cls.sql}
)`;
params.push(...cls.params);
}
if (subject) { sql += ' AND sj.name LIKE ?'; params.push('%' + subject + '%'); }
if (teacher) { sql += ' AND t.name LIKE ?'; params.push('%' + teacher + '%'); }
if (date_from && date_to) {
if (date_from <= date_to) {
sql += ' AND lp.lesson_date >= ? AND lp.lesson_date <= ?';
params.push(date_from, date_to);
} else {
sql += ' AND (lp.lesson_date >= ? OR lp.lesson_date <= ?)';
params.push(date_from, date_to);
}
} else if (date_from) {
sql += ' AND lp.lesson_date >= ?';
params.push(date_from);
} else if (date_to) {
sql += ' AND lp.lesson_date <= ?';
params.push(date_to);
}
if (no_topic === 'true') sql += ' AND lp.topic IS NULL';
if (no_homework === 'true') sql += ' AND lp.homework IS NULL';
sql += ' GROUP BY lp.lesson_date, lp.subject_id';
if (low_grades === 'true') {
sql += ' HAVING SUM(CASE WHEN d.grade IS NOT NULL THEN 1 ELSE 0 END) < ?';
params.push(minNumeric);
}
sql += ' ORDER BY lp.lesson_date, sj.name';
const rows = db.prepare(sql).all(...params);
for (const r of rows) {
r.lesson_date = r.lesson_date.substring(3, 5) + '.' + r.lesson_date.substring(0, 2);
r.grades_numeric = r.grades_numeric || 0;
r.grades_symbol = r.grades_symbol || 0;
r.grades_total = r.grades_total || 0;
r.topic_invalid = !!(r.topic && !CYRILLIC_MIN(r.topic, 3));
r.homework_invalid = !!(r.homework && !CYRILLIC_MIN(r.homework, 3));
if (r.topic_invalid) r.topic = null;
if (r.homework_invalid) r.homework = null;
}
res.json({ rows, min_grades: minNumeric });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.get('/api/dvoiki', (req, res) => {
try {
const { student, subject, teacher, date_from, date_to } = req.query;
const cls = buildClassWhere(req.query);
let sql = `
SELECT d.grade_date, d.grade, d.symbol,
sj.name as subject,
COALESCE(t.name, '—') as teacher,
s.full_name as student,
c.name as class
FROM daily_grades d
JOIN students s ON d.student_id = s.id
JOIN classes c ON s.class_id = c.id
JOIN subjects sj ON d.subject_id = sj.id
LEFT JOIN teachers t ON d.teacher_id = t.id
WHERE 1=1 ${cls.sql}
`;
const params = [...cls.params];
if (student) { sql += ' AND s.full_name LIKE ?'; params.push('%' + student + '%'); }
if (subject) { sql += ' AND sj.name LIKE ?'; params.push('%' + subject + '%'); }
if (teacher) { sql += ' AND t.name LIKE ?'; params.push('%' + teacher + '%'); }
if (date_from && date_to) {
if (date_from <= date_to) {
sql += ' AND d.grade_date >= ? AND d.grade_date <= ?';
params.push(date_from, date_to);
} else {
sql += ' AND (d.grade_date >= ? OR d.grade_date <= ?)';
params.push(date_from, date_to);
}
} else if (date_from) {
sql += ' AND d.grade_date >= ?';
params.push(date_from);
} else if (date_to) {
sql += ' AND d.grade_date <= ?';
params.push(date_to);
}
sql += ' ORDER BY s.full_name, sj.name, d.grade_date';
const rows = db.prepare(sql).all(...params);
const chains = [];
const byPair = {};
for (const r of rows) {
const key = r.student + '|' + r.subject;
if (!byPair[key]) byPair[key] = [];
byPair[key].push(r);
}
for (const [key, grades] of Object.entries(byPair)) {
let chain = null;
for (const g of grades) {
if (g.grade === 2) {
if (!chain) {
chain = { student: g.student, class: g.class, subject: g.subject, teacher: g.teacher, started: g.grade_date, chain: [], resolved: false, reason: '' };
}
chain.chain.push({ date: g.grade_date, grade: String(g.grade) });
} else if (chain && g.grade != null && g.grade >= 3) {
chain.chain.push({ date: g.grade_date, grade: String(g.grade) });
chain.resolved = true;
chain.reason = 'оценка ' + g.grade;
chains.push(chain);
chain = null;
} else if (chain) {
chain.chain.push({ date: g.grade_date, grade: g.grade !== null ? String(g.grade) : (g.symbol || '—') });
if (g.grade === null && g.symbol === null) {
chain.reason = 'пустая клетка';
chains.push(chain);
chain = null;
}
}
}
if (chain && !chain.resolved) {
chain.reason = chain.reason || 'нет уроков после';
chains.push(chain);
}
}
chains.sort((a, b) => b.started.localeCompare(a.started) || a.student.localeCompare(b.student));
for (const c of chains) {
c.started = c.started.substring(3, 5) + '.' + c.started.substring(0, 2);
for (const g of c.chain) {
g.date = g.date.substring(3, 5) + '.' + g.date.substring(0, 2);
}
}
res.json({ rows: chains });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.delete('/api/classes/:name', (req, res) => {
try {
const cls = db.prepare(`SELECT id FROM classes WHERE name = ?`).get(req.params.name);

View File

@@ -67,6 +67,21 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_daily_subject ON daily_grades(subject_id);
CREATE INDEX IF NOT EXISTS idx_daily_teacher ON daily_grades(teacher_id);
CREATE INDEX IF NOT EXISTS idx_daily_date ON daily_grades(grade_date);
CREATE TABLE IF NOT EXISTS lesson_plans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subject_id INTEGER NOT NULL,
teacher_id INTEGER,
lesson_date TEXT NOT NULL,
topic TEXT,
homework TEXT,
FOREIGN KEY (subject_id) REFERENCES subjects(id),
FOREIGN KEY (teacher_id) REFERENCES teachers(id)
);
CREATE INDEX IF NOT EXISTS idx_lp_subject ON lesson_plans(subject_id);
CREATE INDEX IF NOT EXISTS idx_lp_teacher ON lesson_plans(teacher_id);
CREATE INDEX IF NOT EXISTS idx_lp_date ON lesson_plans(lesson_date);
`);
const getClass = db.prepare(`SELECT id FROM classes WHERE name = ?`);
@@ -126,8 +141,17 @@ function insertDailyGradeValue(studentId, subjectId, teacherId, gradeDate, grade
insertDailyGrade.run(studentId, subjectId, teacherId || null, gradeDate, num, sym);
}
const insertLessonPlan = db.prepare(`
INSERT OR REPLACE INTO lesson_plans (subject_id, teacher_id, lesson_date, topic, homework)
VALUES (?, ?, ?, ?, ?)
`);
function insertLessonPlanValue(subjectId, teacherId, lessonDate, topic, homework) {
insertLessonPlan.run(subjectId, teacherId || null, lessonDate, topic || null, homework || null);
}
function clearAll() {
db.exec('DELETE FROM daily_grades; DELETE FROM grades; DELETE FROM teachers; DELETE FROM students; DELETE FROM subjects; DELETE FROM classes;');
db.exec('DELETE FROM lesson_plans; DELETE FROM daily_grades; DELETE FROM grades; DELETE FROM teachers; DELETE FROM students; DELETE FROM subjects; DELETE FROM classes;');
}
module.exports = {
@@ -138,6 +162,7 @@ module.exports = {
upsertTeacher,
upsertGradeValue,
insertDailyGradeValue,
insertLessonPlanValue,
clearAll,
DATA_DIR
};

View File

@@ -379,6 +379,75 @@ function nextSubjectIndex(text, from) {
return m ? m.index : text.length;
}
const HW_KEYWORDS = [
'Повторить', 'Читать', 'Выполнить', 'Выучить', 'Подготовить',
'Решить', 'Решаем', 'Сдать', 'Записать', 'Написать', 'Ответить',
'Принести', 'Перейти', 'Письменно', 'Дз:', 'ДЗ:', 'дз:', 'Д.з.',
'Стр.', 'с.', 'Выполняем', 'Готовимся', 'Работа', 'Повторяем',
'Сделать', 'Нарисовать', 'Перерисовать', 'Посмотреть', 'Прочитать'
];
function splitTopicHomework(text) {
if (!text) return { topic: null, homework: null };
for (const kw of HW_KEYWORDS) {
const idx = text.indexOf(kw);
if (idx > 0) {
const topic = text.substring(0, idx).trim().replace(/\s+$/, '');
const homework = text.substring(idx).trim();
return {
topic: topic || null,
homework: homework || null
};
}
}
return { topic: text.trim() || null, homework: null };
}
function parseLessonPlans(text) {
const results = [];
const sections = splitSubjectSections(text);
for (const sec of sections) {
const teacher = extractTeacherFromSection(sec.text);
const dateRe = /^(\d{2}\.\d{2})\s?(.*)/gm;
let m;
while ((m = dateRe.exec(sec.text)) !== null) {
const dateParts = m[1].split('.');
const sortableDate = dateParts.length === 2 ? dateParts[1] + '-' + dateParts[0] : m[1];
const rawText = m[2].trim();
if (/^\d+$/.test(rawText)) continue;
if (/^(?:За|Дано|Число|МЕСЯЦ|ЧИСЛО|Список|№)/i.test(rawText)) continue;
if (rawText.length < 2) {
results.push({
subject: sec.name,
teacher,
date: sortableDate,
topic: null,
homework: null
});
continue;
}
const { topic, homework } = splitTopicHomework(rawText);
results.push({
subject: sec.name,
teacher,
date: sortableDate,
topic,
homework
});
}
}
return results;
}
async function parsePdfBuffer(buffer, filename) {
const text = await extractText(buffer);
if (!text || text.length < 100) throw new Error('Не удалось извлечь текст из PDF');
@@ -403,6 +472,8 @@ async function parsePdfBuffer(buffer, filename) {
const dailyGrades = parseDailyGrades(text);
const lessonPlans = parseLessonPlans(text);
const finalSubjects = summarySubjects.length > 0 ? summarySubjects : subjects;
const allGrades = summaryGrades.length > 0 ? summaryGrades : [];
@@ -414,6 +485,7 @@ async function parsePdfBuffer(buffer, filename) {
teachers,
allGrades,
dailyGrades,
lessonPlans,
rawSummary: summaryGrades
};
}

View File

@@ -15,6 +15,14 @@ function buildFilter(filters) {
conditions.push('t.name LIKE ?');
params.push('%' + filters.teacher + '%');
}
if (filters.class) {
conditions.push('c.name LIKE ?');
params.push('%' + filters.class + '%');
}
if (filters.parallel) {
conditions.push('c.name LIKE ?');
params.push(filters.parallel + '%');
}
if (filters.date_from && filters.date_to) {
if (filters.date_from <= filters.date_to) {
conditions.push('d.grade_date >= ? AND d.grade_date <= ?');
@@ -61,31 +69,33 @@ function getFullStats(filters = {}) {
SUM(CASE WHEN d.symbol = 'Б' THEN 1 ELSE 0 END) as b_count,
SUM(CASE WHEN d.symbol = 'У' THEN 1 ELSE 0 END) as u_count,
ROUND(AVG(CASE WHEN d.grade IS NOT NULL THEN CAST(d.grade AS REAL) END), 2) as avg_grade
FROM daily_grades d
JOIN subjects sj ON d.subject_id = sj.id
JOIN students s ON d.student_id = s.id
LEFT JOIN teachers t ON d.teacher_id = t.id
WHERE 1=1 ${f.where}
GROUP BY sj.name ORDER BY student_count DESC, total DESC
`).all(...f.params);
FROM daily_grades d
JOIN subjects sj ON d.subject_id = sj.id
JOIN students s ON d.student_id = s.id
JOIN classes c ON s.class_id = c.id
LEFT JOIN teachers t ON d.teacher_id = t.id
WHERE 1=1 ${f.where}
GROUP BY sj.name ORDER BY student_count DESC, total DESC
`).all(...f.params);
const gradeCountByTeacher = db.prepare(`
SELECT t.name as teacher,
GROUP_CONCAT(DISTINCT sj.name) as subject_name,
COUNT(*) as total,
COUNT(DISTINCT d.student_id) as student_count,
SUM(CASE WHEN d.grade = 5 THEN 1 ELSE 0 END) as fives,
SUM(CASE WHEN d.grade = 4 THEN 1 ELSE 0 END) as fours,
SUM(CASE WHEN d.grade = 3 THEN 1 ELSE 0 END) as threes,
SUM(CASE WHEN d.grade = 2 THEN 1 ELSE 0 END) as twos,
SUM(CASE WHEN d.symbol = 'Н' THEN 1 ELSE 0 END) as n_count,
SUM(CASE WHEN d.symbol = 'Б' THEN 1 ELSE 0 END) as b_count,
SUM(CASE WHEN d.symbol = 'У' THEN 1 ELSE 0 END) as u_count
FROM daily_grades d
JOIN teachers t ON d.teacher_id = t.id
JOIN subjects sj ON d.subject_id = sj.id
JOIN students s ON d.student_id = s.id
WHERE 1=1 ${f.where}
const gradeCountByTeacher = db.prepare(`
SELECT t.name as teacher,
GROUP_CONCAT(DISTINCT sj.name) as subject_name,
COUNT(*) as total,
COUNT(DISTINCT d.student_id) as student_count,
SUM(CASE WHEN d.grade = 5 THEN 1 ELSE 0 END) as fives,
SUM(CASE WHEN d.grade = 4 THEN 1 ELSE 0 END) as fours,
SUM(CASE WHEN d.grade = 3 THEN 1 ELSE 0 END) as threes,
SUM(CASE WHEN d.grade = 2 THEN 1 ELSE 0 END) as twos,
SUM(CASE WHEN d.symbol = 'Н' THEN 1 ELSE 0 END) as n_count,
SUM(CASE WHEN d.symbol = 'Б' THEN 1 ELSE 0 END) as b_count,
SUM(CASE WHEN d.symbol = 'У' THEN 1 ELSE 0 END) as u_count
FROM daily_grades d
JOIN teachers t ON d.teacher_id = t.id
JOIN subjects sj ON d.subject_id = sj.id
JOIN students s ON d.student_id = s.id
JOIN classes c ON s.class_id = c.id
WHERE 1=1 ${f.where}
GROUP BY t.id ORDER BY total ASC
`).all(...f.params);
@@ -135,6 +145,7 @@ function getFullStats(filters = {}) {
const totalDaily = db.prepare(`
SELECT COUNT(*) as c FROM daily_grades d
JOIN students s ON d.student_id = s.id
JOIN classes c ON s.class_id = c.id
JOIN subjects sj ON d.subject_id = sj.id
LEFT JOIN teachers t ON d.teacher_id = t.id
WHERE 1=1 ${f.where}