diff --git a/12-20 b/12-20
new file mode 100644
index 0000000..e69de29
diff --git a/public/app.js b/public/app.js
index 2b9dd9b..1bfdc47 100644
--- a/public/app.js
+++ b/public/app.js
@@ -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 = '
| Дата | Предмет | Учитель | Тема | ДЗ | Всего | Числ. | Симв. | Оценки |
';
+
+ const tbody = document.querySelector('#controlTable tbody');
+ if (!data.rows || !data.rows.length) {
+ tbody.innerHTML = '| Нет данных |
';
+ 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 `
+ | ${r.lesson_date} |
+ ${r.subject} |
+ ${r.teacher} |
+ ${r.topic || '—'} |
+ ${r.homework || '—'} |
+ ${r.grades_total} |
+ ${r.grades_numeric} |
+ ${r.grades_symbol} |
+ ${r.grades_list || ''} |
+
`;
+ }).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 =>
+ ``
+ ).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 = 'Нет цепочек
';
+ 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 =>
+ `
+
${c.date}
+
${c.grade}
+
`
+ ).join('');
+
+ return `
+
+ ${statusIcon} ${statusText}
+ ${r.chain.length} шаг${r.chain.length > 1 ? (r.chain.length < 5 ? 'а' : 'ов') : ''}
+
+
${r.student}
+
${r.subject} · ${r.teacher}
+
${stepsHtml}
+
`;
+ }).join('');
+}
+
loadFilters();
loadAll();
diff --git a/public/index.html b/public/index.html
index c7364b5..772c75e 100644
--- a/public/index.html
+++ b/public/index.html
@@ -30,6 +30,8 @@
+
+
@@ -46,6 +48,7 @@
+
+
+
diff --git a/public/style.css b/public/style.css
index 88cf5fa..4f1bf11 100644
--- a/public/style.css
+++ b/public/style.css
@@ -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;
diff --git a/server.js b/server.js
index b3c6d9f..bfef2af 100644
--- a/server.js
+++ b/server.js
@@ -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);
diff --git a/src/db.js b/src/db.js
index 5efac65..87d22f6 100644
--- a/src/db.js
+++ b/src/db.js
@@ -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
};
diff --git a/src/parser.js b/src/parser.js
index ae1d97a..d8cab3e 100644
--- a/src/parser.js
+++ b/src/parser.js
@@ -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
};
}
diff --git a/src/stats.js b/src/stats.js
index 63bf029..bafacb7 100644
--- a/src/stats.js
+++ b/src/stats.js
@@ -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}