26
This commit is contained in:
495
public/app.js
Normal file
495
public/app.js
Normal file
@@ -0,0 +1,495 @@
|
||||
const dropZone = document.getElementById('dropZone');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const uploadBtn = document.getElementById('uploadBtn');
|
||||
const uploadStatus = document.getElementById('uploadStatus');
|
||||
const statusEl = document.getElementById('status');
|
||||
|
||||
let selectedFile = null;
|
||||
|
||||
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('drag-over'); });
|
||||
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('drag-over');
|
||||
if (e.dataTransfer.files.length) {
|
||||
selectedFile = e.dataTransfer.files[0];
|
||||
uploadBtn.disabled = false;
|
||||
dropZone.querySelector('.drop-text').textContent = 'Выбран: ' + selectedFile.name;
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.addEventListener('change', () => {
|
||||
if (fileInput.files.length) {
|
||||
selectedFile = fileInput.files[0];
|
||||
uploadBtn.disabled = false;
|
||||
dropZone.querySelector('.drop-text').textContent = 'Выбран: ' + selectedFile.name;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('uploadForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if (!selectedFile) return;
|
||||
|
||||
uploadBtn.disabled = true;
|
||||
uploadStatus.textContent = 'Загрузка...';
|
||||
uploadStatus.className = '';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('pdf', selectedFile);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/upload', { method: 'POST', body: formData });
|
||||
const data = await res.json();
|
||||
if (data.error) {
|
||||
uploadStatus.textContent = 'Ошибка: ' + data.error;
|
||||
uploadStatus.className = 'error';
|
||||
} else {
|
||||
uploadStatus.textContent = data.message;
|
||||
uploadStatus.className = 'success';
|
||||
statusEl.textContent = 'Данные загружены';
|
||||
statusEl.classList.add('loaded');
|
||||
loadFilters();
|
||||
loadAll();
|
||||
}
|
||||
} catch (err) {
|
||||
uploadStatus.textContent = 'Ошибка: ' + err.message;
|
||||
uploadStatus.className = 'error';
|
||||
}
|
||||
|
||||
selectedFile = null;
|
||||
fileInput.value = '';
|
||||
uploadBtn.disabled = true;
|
||||
dropZone.querySelector('.drop-text').textContent = 'Перетащите PDF сюда или кликните для выбора';
|
||||
});
|
||||
|
||||
document.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
document.getElementById(tab.dataset.tab).classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
function getFilterParams() {
|
||||
const params = {};
|
||||
const student = document.getElementById('gfStudent').value.trim();
|
||||
const subject = document.getElementById('gfSubject').value.trim();
|
||||
const teacher = document.getElementById('gfTeacher').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 (from) params.date_from = from.slice(5);
|
||||
if (to) params.date_to = to.slice(5);
|
||||
return params;
|
||||
}
|
||||
|
||||
function buildQuery(params) {
|
||||
const q = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) q.set(k, v);
|
||||
return q.toString();
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
const params = getFilterParams();
|
||||
const qs = buildQuery(params);
|
||||
|
||||
const [statsRes, dailyRes] = await Promise.all([
|
||||
fetch('/api/stats?' + qs),
|
||||
fetch('/api/daily?' + qs + '&limit=150&offset=0')
|
||||
]);
|
||||
|
||||
const stats = await statsRes.json();
|
||||
const daily = await dailyRes.json();
|
||||
|
||||
renderSummary(stats.summary);
|
||||
renderAlerts(stats);
|
||||
renderSubjectsTable(stats);
|
||||
renderTeachersTable(stats);
|
||||
renderClassesTable(stats);
|
||||
renderDailyTable(daily);
|
||||
loadPivot();
|
||||
}
|
||||
|
||||
async function loadFilters() {
|
||||
try {
|
||||
const res = await fetch('/api/filters');
|
||||
const data = await res.json();
|
||||
if (data.error) return;
|
||||
|
||||
const fill = (id, items) => {
|
||||
const dl = document.getElementById(id);
|
||||
if (dl) dl.innerHTML = (items || []).map(v => `<option value="${v}">`).join('');
|
||||
};
|
||||
fill('dlStudents', data.students);
|
||||
fill('dlSubjects', data.subjects);
|
||||
fill('dlTeachers', data.teachers);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderSummary(summary) {
|
||||
const cards = document.getElementById('summaryCards');
|
||||
if (!summary || !summary.totalStudents) {
|
||||
cards.innerHTML = '<div class="empty-state">Нет данных. Загрузите PDF журнала.</div>';
|
||||
return;
|
||||
}
|
||||
cards.innerHTML = `
|
||||
<div class="card"><div class="value">${summary.totalClasses}</div><div class="label">Классов</div></div>
|
||||
<div class="card"><div class="value">${summary.totalStudents}</div><div class="label">Учеников</div></div>
|
||||
<div class="card"><div class="value">${summary.totalTeachers}</div><div class="label">Учителей</div></div>
|
||||
<div class="card"><div class="value">${summary.totalSubjects}</div><div class="label">Предметов</div></div>
|
||||
<div class="card"><div class="value">${summary.totalDaily}</div><div class="label">Подневных оценок</div></div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAlerts(stats) {
|
||||
const alerts = document.getElementById('alerts');
|
||||
let html = '';
|
||||
|
||||
if (stats.subjectsWithFewGrades && stats.subjectsWithFewGrades.length > 0) {
|
||||
for (const s of stats.subjectsWithFewGrades) {
|
||||
html += `<div class="alert warning">⚠️ ${s.warning}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (stats.teachersWithNoGrades && stats.teachersWithNoGrades.length > 0) {
|
||||
for (const t of stats.teachersWithNoGrades) {
|
||||
html += `<div class="alert danger">❌ Учитель «${t.name}» (${t.subject_name}) — нет отметок</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!html) {
|
||||
const total = stats.summary ? stats.summary.totalDaily : 0;
|
||||
if (total > 0) html = '<div class="alert info">Все предметы имеют достаточное количество оценок. Проблем не обнаружено.</div>';
|
||||
else html = '<div class="alert info">Загрузите PDF журнала для отображения предупреждений.</div>';
|
||||
}
|
||||
|
||||
alerts.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderSubjectsTable(stats) {
|
||||
const tbody = document.querySelector('#subjectsTable tbody');
|
||||
const arr = stats.gradeCountBySubject || [];
|
||||
if (!arr.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="11" class="empty-state">Нет данных</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
const threshold = 10;
|
||||
|
||||
tbody.innerHTML = arr.map(s => {
|
||||
const cls = s.student_count < threshold ? 'low-grades' : '';
|
||||
return `<tr>
|
||||
<td class="${cls}">${s.subject}</td>
|
||||
<td class="num ${cls}">${s.total}</td>
|
||||
<td class="num">${s.fives}</td>
|
||||
<td class="num">${s.fours}</td>
|
||||
<td class="num">${s.threes}</td>
|
||||
<td class="num">${s.twos}</td>
|
||||
<td class="num">${s.n_count || 0}</td>
|
||||
<td class="num">${s.b_count || 0}</td>
|
||||
<td class="num">${s.u_count || 0}</td>
|
||||
<td class="num">${s.avg_grade !== null ? s.avg_grade : '—'}</td>
|
||||
<td class="num">${s.student_count}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderTeachersTable(stats) {
|
||||
const tbody = document.querySelector('#teachersTable tbody');
|
||||
const arr = stats.gradeCountByTeacher || [];
|
||||
const noGrades = new Set((stats.teachersWithNoGrades || []).map(t => t.name));
|
||||
|
||||
let html = '';
|
||||
for (const t of arr) {
|
||||
const cls = noGrades.has(t.teacher) ? 'no-grades' : '';
|
||||
html += `<tr>
|
||||
<td class="${cls}">${t.teacher}</td>
|
||||
<td>${t.subject_name}</td>
|
||||
<td class="num ${cls}">${t.total}</td>
|
||||
<td class="num">${t.fives}</td>
|
||||
<td class="num">${t.fours}</td>
|
||||
<td class="num">${t.threes}</td>
|
||||
<td class="num">${t.twos}</td>
|
||||
<td class="num">${t.n_count || 0}</td>
|
||||
<td class="num">${t.b_count || 0}</td>
|
||||
<td class="num">${t.u_count || 0}</td>
|
||||
<td class="num">${t.student_count || '—'}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
if ((stats.teachersWithNoGrades || []).length > 0 && !arr.length) {
|
||||
for (const t of stats.teachersWithNoGrades) {
|
||||
html += `<tr>
|
||||
<td class="no-grades">${t.name}</td>
|
||||
<td>${t.subject_name}</td>
|
||||
<td class="num no-grades">0</td>
|
||||
<td class="num">0</td><td class="num">0</td><td class="num">0</td><td class="num">0</td><td class="num">0</td><td class="num">0</td><td class="num">0</td><td class="num">0</td>
|
||||
</tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!html) html = '<tr><td colspan="11" class="empty-state">Нет данных</td></tr>';
|
||||
tbody.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderClassesTable(stats) {
|
||||
const tbody = document.querySelector('#classesTable tbody');
|
||||
const arr = stats.gradeCountByClass || [];
|
||||
if (!arr.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="10" class="empty-state">Нет данных</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = arr.map(c => `<tr>
|
||||
<td>${c.class}</td>
|
||||
<td class="num">${c.student_count}</td>
|
||||
<td class="num">${c.total}</td>
|
||||
<td class="num">${c.fives}</td>
|
||||
<td class="num">${c.fours}</td>
|
||||
<td class="num">${c.threes}</td>
|
||||
<td class="num">${c.twos}</td>
|
||||
<td class="num">${c.n_count || 0}</td>
|
||||
<td class="num">${c.b_count || 0}</td>
|
||||
<td class="num">${c.u_count || 0}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
function renderDailyTable(data) {
|
||||
const tbody = document.querySelector('#dailyTable tbody');
|
||||
if (!data.rows || !data.rows.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="empty-state">Нет данных</td></tr>';
|
||||
document.getElementById('dailyPagination').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.rows.map(r => {
|
||||
const g = r.grade || r.symbol || '—';
|
||||
return `<tr>
|
||||
<td>${r.grade_date}</td>
|
||||
<td>${r.student}</td>
|
||||
<td>${r.class}</td>
|
||||
<td>${r.subject}</td>
|
||||
<td>${r.teacher}</td>
|
||||
<td class="num">${g}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
const totalPages = Math.ceil(data.total / 150);
|
||||
document.getElementById('dailyPagination').innerHTML = totalPages > 1 ? `
|
||||
<button ${true ? 'disabled' : ''} onclick="dailyNav(0)">←</button>
|
||||
<span>стр. 1 / ${totalPages}</span>
|
||||
<button ${totalPages > 1 ? '' : 'disabled'} onclick="dailyNav(1)">→</button>
|
||||
` : '';
|
||||
}
|
||||
|
||||
window.dailyPage = 0;
|
||||
window.dailyNav = async function(page) {
|
||||
window.dailyPage = page;
|
||||
const params = getFilterParams();
|
||||
const qs = buildQuery(params) + '&limit=150&offset=' + (page * 150);
|
||||
const res = await fetch('/api/daily?' + qs);
|
||||
const data = await res.json();
|
||||
renderDailyTable(data);
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||
document.querySelector('[data-tab="daily"]').classList.add('active');
|
||||
document.getElementById('daily').classList.add('active');
|
||||
};
|
||||
|
||||
document.getElementById('gfApply').addEventListener('click', () => loadAll());
|
||||
document.getElementById('gfReset').addEventListener('click', () => {
|
||||
document.getElementById('gfStudent').value = '';
|
||||
document.getElementById('gfSubject').value = '';
|
||||
document.getElementById('gfTeacher').value = '';
|
||||
document.getElementById('gfDateFrom').value = '';
|
||||
document.getElementById('gfDateTo').value = '';
|
||||
loadAll();
|
||||
});
|
||||
|
||||
const modal = document.getElementById('clearModal');
|
||||
document.getElementById('clearBtn').addEventListener('click', () => {
|
||||
modal.classList.add('open');
|
||||
loadClassList();
|
||||
});
|
||||
document.getElementById('modalClose').addEventListener('click', () => modal.classList.remove('open'));
|
||||
modal.addEventListener('click', (e) => { if (e.target === modal) modal.classList.remove('open'); });
|
||||
|
||||
async function loadClassList() {
|
||||
try {
|
||||
const res = await fetch('/api/stats');
|
||||
const stats = await res.json();
|
||||
const dl = document.getElementById('dlClasses');
|
||||
if (dl && stats.classes) {
|
||||
dl.innerHTML = stats.classes.map(c => `<option value="${c.name}">`).join('');
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
document.getElementById('clearClassBtn').addEventListener('click', async () => {
|
||||
const name = document.getElementById('clearClass').value.trim();
|
||||
if (!name) return;
|
||||
const msg = document.getElementById('clearClassMsg');
|
||||
if (!confirm(`Удалить класс ${name} и все его данные?`)) return;
|
||||
msg.className = '';
|
||||
msg.textContent = '...';
|
||||
try {
|
||||
const res = await fetch('/api/classes/' + encodeURIComponent(name), { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
msg.className = data.error ? 'error' : 'success';
|
||||
msg.textContent = data.message || data.error;
|
||||
if (data.ok) { loadFilters(); loadAll(); }
|
||||
} catch (e) {
|
||||
msg.className = 'error';
|
||||
msg.textContent = e.message;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('clearParallelBtn').addEventListener('click', async () => {
|
||||
const grade = document.getElementById('clearParallel').value.trim();
|
||||
if (!grade) return;
|
||||
const msg = document.getElementById('clearParallelMsg');
|
||||
if (!confirm(`Удалить ВСЕ классы параллели ${grade}?`)) return;
|
||||
msg.className = '';
|
||||
msg.textContent = '...';
|
||||
try {
|
||||
const res = await fetch('/api/parallel/' + encodeURIComponent(grade), { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
msg.className = data.error ? 'error' : 'success';
|
||||
msg.textContent = data.message || data.error;
|
||||
if (data.ok) { loadFilters(); loadAll(); }
|
||||
} catch (e) {
|
||||
msg.className = 'error';
|
||||
msg.textContent = e.message;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('clearAllBtn').addEventListener('click', async () => {
|
||||
const msg = document.getElementById('clearAllMsg');
|
||||
if (!confirm('Удалить ВСЕ данные без возможности восстановления?')) return;
|
||||
if (!confirm('Точно?')) return;
|
||||
msg.className = '';
|
||||
msg.textContent = '...';
|
||||
try {
|
||||
const res = await fetch('/api/data', { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
msg.className = data.error ? 'error' : 'success';
|
||||
msg.textContent = data.message || data.error || 'Всё удалено';
|
||||
if (data.ok) { loadFilters(); loadAll(); }
|
||||
} catch (e) {
|
||||
msg.className = 'error';
|
||||
msg.textContent = e.message;
|
||||
}
|
||||
});
|
||||
|
||||
let pivotMode = 'month';
|
||||
|
||||
document.querySelectorAll('.per-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.per-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
pivotMode = btn.dataset.per;
|
||||
loadPivot();
|
||||
});
|
||||
});
|
||||
|
||||
async function loadPivot() {
|
||||
const params = getFilterParams();
|
||||
params.period = pivotMode;
|
||||
const qs = buildQuery(params);
|
||||
|
||||
document.getElementById('pivotInfo').textContent = 'Загрузка...';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/pivot?' + qs);
|
||||
const data = await res.json();
|
||||
if (data.error) {
|
||||
document.getElementById('pivotTable').innerHTML = '<tr><td class="empty-state">' + data.error + '</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
renderPivot(data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPivot(data) {
|
||||
const thead = document.querySelector('#pivotTable thead');
|
||||
const tbody = document.querySelector('#pivotTable tbody');
|
||||
|
||||
document.getElementById('pivotInfo').textContent = data.rows.length + ' строк, ' + data.periods.length + ' периодов';
|
||||
|
||||
let headHtml = '<tr><th>Предмет / Учитель / Ученик</th>';
|
||||
for (const lbl of data.periodLabels) {
|
||||
headHtml += '<th>' + lbl + '</th>';
|
||||
}
|
||||
headHtml += '</tr>';
|
||||
thead.innerHTML = headHtml;
|
||||
|
||||
if (!data.rows.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="' + (data.periods.length + 1) + '" class="empty-state">Нет данных</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
const subjectGroups = {};
|
||||
for (const row of data.rows) {
|
||||
if (!subjectGroups[row.subject]) subjectGroups[row.subject] = [];
|
||||
subjectGroups[row.subject].push(row);
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const [subject, rows] of Object.entries(subjectGroups).sort()) {
|
||||
const subjId = 's_' + subject.replace(/[^a-zа-яё0-9]/gi, '_');
|
||||
html += '<tr class="pivot-subject-row" data-group="' + subjId + '">';
|
||||
html += '<td class="pivot-subject" onclick="toggleGroup(\'' + subjId + '\')"><span class="toggle" id="tog_' + subjId + '">▶</span> ' + subject + '</td>';
|
||||
for (let i = 0; i < data.periods.length; i++) html += '<td></td>';
|
||||
html += '</tr>';
|
||||
|
||||
const teacherGroups = {};
|
||||
for (const row of rows) {
|
||||
if (!teacherGroups[row.teacher]) teacherGroups[row.teacher] = [];
|
||||
teacherGroups[row.teacher].push(row);
|
||||
}
|
||||
|
||||
for (const [teacher, trows] of Object.entries(teacherGroups)) {
|
||||
const tchId = subjId + '_t_' + teacher.replace(/[^a-zа-яё0-9]/gi, '_');
|
||||
html += '<tr class="pivot-hidden ' + subjId + ' pivot-teacher-row" data-group="' + tchId + '">';
|
||||
html += '<td class="pivot-teacher" onclick="toggleGroup(\'' + tchId + '\')"><span class="toggle" id="tog_' + tchId + '">▶</span> ' + teacher + '</td>';
|
||||
for (let i = 0; i < data.periods.length; i++) html += '<td></td>';
|
||||
html += '</tr>';
|
||||
|
||||
for (const row of trows) {
|
||||
html += '<tr class="pivot-hidden ' + subjId + ' ' + tchId + '">';
|
||||
html += '<td class="pivot-student">' + row.student + '</td>';
|
||||
for (let i = 0; i < data.periods.length; i++) {
|
||||
const val = row.cells[i] || '';
|
||||
html += '<td class="pivot-cell">' + val + '</td>';
|
||||
}
|
||||
html += '</tr>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tbody.innerHTML = html;
|
||||
}
|
||||
|
||||
window.toggleGroup = function(groupId) {
|
||||
const rows = document.querySelectorAll('.' + groupId);
|
||||
const tog = document.getElementById('tog_' + groupId);
|
||||
if (!rows.length) return;
|
||||
const firstRow = rows[0];
|
||||
if (firstRow.classList.contains('pivot-hidden')) {
|
||||
rows.forEach(r => r.classList.remove('pivot-hidden'));
|
||||
if (tog) tog.classList.add('open');
|
||||
} else {
|
||||
rows.forEach(r => r.classList.add('pivot-hidden'));
|
||||
if (tog) tog.classList.remove('open');
|
||||
}
|
||||
};
|
||||
|
||||
loadFilters();
|
||||
loadAll();
|
||||
148
public/index.html
Normal file
148
public/index.html
Normal file
@@ -0,0 +1,148 @@
|
||||
<!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>
|
||||
<header>
|
||||
<h1>📊 Школьный журнал</h1>
|
||||
<span id="status" class="status">Нет данных</span>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="upload-section">
|
||||
<form id="uploadForm">
|
||||
<label class="drop-zone" id="dropZone">
|
||||
<input type="file" id="fileInput" accept=".pdf" hidden>
|
||||
<span class="drop-text">Перетащите PDF сюда или кликните для выбора</span>
|
||||
<span class="drop-hint">(журнал класса)</span>
|
||||
</label>
|
||||
<button type="submit" id="uploadBtn" disabled>Загрузить</button>
|
||||
<button type="button" id="clearBtn" class="btn-danger">Очистить данные</button>
|
||||
<div id="uploadStatus"></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="global-filters">
|
||||
<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="date" id="gfDateFrom" title="Дата с">
|
||||
<input type="date" id="gfDateTo" title="Дата по">
|
||||
<button id="gfApply">Применить</button>
|
||||
<button id="gfReset" class="btn-reset">Сбросить</button>
|
||||
<datalist id="dlStudents"></datalist>
|
||||
<datalist id="dlSubjects"></datalist>
|
||||
<datalist id="dlTeachers"></datalist>
|
||||
</section>
|
||||
|
||||
<section class="tabs">
|
||||
<button class="tab active" data-tab="dashboard">Дашборд</button>
|
||||
<button class="tab" data-tab="subjects">Предметы</button>
|
||||
<button class="tab" data-tab="teachers">Учителя</button>
|
||||
<button class="tab" data-tab="classes">Классы</button>
|
||||
<button class="tab" data-tab="daily">Подневно</button>
|
||||
<button class="tab" data-tab="pivot">Сводка</button>
|
||||
</section>
|
||||
|
||||
<section id="dashboard" class="tab-content active">
|
||||
<div class="summary-cards" id="summaryCards"></div>
|
||||
<div class="alerts" id="alerts"></div>
|
||||
</section>
|
||||
|
||||
<section id="subjects" class="tab-content">
|
||||
<h2>Статистика по предметам</h2>
|
||||
<div class="table-wrapper">
|
||||
<table id="subjectsTable">
|
||||
<thead><tr><th>Предмет</th><th>Всего</th><th>5</th><th>4</th><th>3</th><th>2</th><th>Н</th><th>Б</th><th>У</th><th>Ср. балл</th><th>Учеников</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="teachers" class="tab-content">
|
||||
<h2>Статистика по учителям</h2>
|
||||
<div class="table-wrapper">
|
||||
<table id="teachersTable">
|
||||
<thead><tr><th>Учитель</th><th>Предмет</th><th>Всего</th><th>5</th><th>4</th><th>3</th><th>2</th><th>Н</th><th>Б</th><th>У</th><th>Учеников</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="classes" class="tab-content">
|
||||
<h2>Статистика по классам</h2>
|
||||
<div class="table-wrapper">
|
||||
<table id="classesTable">
|
||||
<thead><tr><th>Класс</th><th>Учеников</th><th>Всего</th><th>5</th><th>4</th><th>3</th><th>2</th><th>Н</th><th>Б</th><th>У</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="daily" class="tab-content">
|
||||
<h2>Подневные оценки</h2>
|
||||
<div class="table-wrapper">
|
||||
<table id="dailyTable">
|
||||
<thead><tr><th>Дата</th><th>Ученик</th><th>Класс</th><th>Предмет</th><th>Учитель</th><th>Оценка</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="dailyPagination"></div>
|
||||
</section>
|
||||
|
||||
<section id="pivot" class="tab-content">
|
||||
<h2>Сводка по периодам</h2>
|
||||
<div class="period-selector">
|
||||
<button class="per-btn active" data-per="month">Месяц</button>
|
||||
<button class="per-btn" data-per="week">Неделя</button>
|
||||
<button class="per-btn" data-per="quarter">Четверть</button>
|
||||
<span class="per-info" id="pivotInfo"></span>
|
||||
</div>
|
||||
<div class="pivot-scroll">
|
||||
<table id="pivotTable">
|
||||
<thead></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="modal-overlay" id="clearModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Очистить данные</h3>
|
||||
<button class="modal-close" id="modalClose">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-option" data-action="class">
|
||||
<strong>Определённый класс</strong>
|
||||
<input type="text" id="clearClass" placeholder="Например: 5Е" list="dlClasses">
|
||||
<datalist id="dlClasses"></datalist>
|
||||
<button id="clearClassBtn" class="btn-danger">Удалить класс</button>
|
||||
<span id="clearClassMsg"></span>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="modal-option" data-action="parallel">
|
||||
<strong>Целую параллель</strong>
|
||||
<input type="text" id="clearParallel" placeholder="Например: 5">
|
||||
<button id="clearParallelBtn" class="btn-danger">Удалить параллель</button>
|
||||
<span id="clearParallelMsg"></span>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="modal-option danger-zone" data-action="all">
|
||||
<strong>Всю базу данных</strong>
|
||||
<p>Удаляет все классы, учеников, оценки без возможности восстановления.</p>
|
||||
<button id="clearAllBtn" class="btn-danger">Удалить всё</button>
|
||||
<span id="clearAllMsg"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
414
public/style.css
Normal file
414
public/style.css
Normal file
@@ -0,0 +1,414 @@
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #f0f2f5;
|
||||
color: #1a1a2e;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
header {
|
||||
background: #1a1a2e;
|
||||
color: #fff;
|
||||
padding: 16px 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
header h1 { font-size: 22px; font-weight: 600; }
|
||||
|
||||
.status { font-size: 13px; opacity: 0.7; }
|
||||
.status.loaded { color: #4ade80; }
|
||||
|
||||
main { max-width: 1200px; margin: 0 auto; padding: 24px; }
|
||||
|
||||
.upload-section {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.upload-section form {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
flex: 1;
|
||||
border: 2px dashed #c0c0d0;
|
||||
border-radius: 10px;
|
||||
padding: 28px 20px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: block;
|
||||
}
|
||||
.drop-zone:hover, .drop-zone.drag-over {
|
||||
border-color: #4f46e5;
|
||||
background: #f5f3ff;
|
||||
}
|
||||
.drop-text { display: block; font-size: 15px; color: #555; margin-bottom: 4px; }
|
||||
.drop-hint { font-size: 12px; color: #999; }
|
||||
|
||||
button {
|
||||
background: #4f46e5;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 12px 28px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
button:hover:not(:disabled) { background: #4338ca; }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
#uploadStatus {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
min-width: 200px;
|
||||
}
|
||||
#uploadStatus.success { color: #16a34a; }
|
||||
#uploadStatus.error { color: #dc2626; }
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
background: #fff;
|
||||
border-radius: 12px 12px 0 0;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
}
|
||||
.tab {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
background: #fff;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border-bottom: 3px solid transparent;
|
||||
transition: all 0.15s;
|
||||
border-radius: 0;
|
||||
}
|
||||
.tab:hover { color: #4f46e5; background: #f9f9fb; }
|
||||
.tab.active { color: #4f46e5; border-bottom-color: #4f46e5; font-weight: 600; }
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
background: #fff;
|
||||
padding: 24px;
|
||||
border-radius: 0 0 12px 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
}
|
||||
.tab-content.active { display: block; }
|
||||
|
||||
.summary-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.card {
|
||||
background: #f8f9fc;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.card .value { font-size: 28px; font-weight: 700; color: #4f46e5; }
|
||||
.card .label { font-size: 13px; color: #666; margin-top: 4px; }
|
||||
|
||||
.alerts { display: flex; flex-direction: column; gap: 8px; }
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.alert.warning { background: #fef3c7; color: #92400e; border: 1px solid #fcd34d; }
|
||||
.alert.danger { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; }
|
||||
.alert.info { background: #e0f2fe; color: #075985; border: 1px solid #7dd3fc; }
|
||||
|
||||
.table-wrapper { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #f0f0f0; }
|
||||
th { background: #f8f9fc; font-weight: 600; color: #555; font-size: 13px; white-space: nowrap; }
|
||||
tr:hover td { background: #f9f9fb; }
|
||||
td.num { text-align: center; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.low-grades { color: #dc2626; font-weight: 600; }
|
||||
.no-grades { color: #d97706; font-weight: 600; }
|
||||
|
||||
h2 { font-size: 18px; margin-bottom: 16px; color: #1a1a2e; }
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
.filters input {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #d0d0d0;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
width: 140px;
|
||||
}
|
||||
.filters input:focus {
|
||||
outline: none;
|
||||
border-color: #4f46e5;
|
||||
}
|
||||
|
||||
.global-filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 14px 20px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
}
|
||||
.global-filters input {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #d0d0d0;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
width: 140px;
|
||||
}
|
||||
.global-filters input[type="date"] {
|
||||
width: 145px;
|
||||
}
|
||||
.global-filters .btn-reset {
|
||||
background: transparent;
|
||||
color: #666;
|
||||
border: 1px solid #d0d0d0;
|
||||
margin-left: auto;
|
||||
}
|
||||
.global-filters .btn-reset:hover {
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
}
|
||||
.btn-danger {
|
||||
background: #dc2626;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #b91c1c;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.4);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal-overlay.open {
|
||||
display: flex;
|
||||
}
|
||||
.modal {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
width: 480px;
|
||||
max-width: 95vw;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 8px 30px rgba(0,0,0,0.18);
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 18px 22px 12px;
|
||||
}
|
||||
.modal-header h3 { font-size: 17px; }
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.modal-close:hover { color: #333; }
|
||||
.modal-body {
|
||||
padding: 0 22px 20px;
|
||||
}
|
||||
.modal-option {
|
||||
padding: 12px 0;
|
||||
}
|
||||
.modal-option strong {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.modal-option input {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #d0d0d0;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
width: 160px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.modal-option input:focus {
|
||||
outline: none;
|
||||
border-color: #4f46e5;
|
||||
}
|
||||
.modal-option hr {
|
||||
border: none;
|
||||
border-top: 1px solid #eee;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.modal-option p {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin: 6px 0 10px;
|
||||
}
|
||||
.danger-zone {
|
||||
background: #fef2f2;
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.modal-option span {
|
||||
font-size: 12px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.modal-option span.success { color: #16a34a; }
|
||||
.modal-option span.error { color: #dc2626; }
|
||||
|
||||
.period-selector {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.per-btn {
|
||||
padding: 6px 16px;
|
||||
border: 1px solid #d0d0d0;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
color: #555;
|
||||
}
|
||||
.per-btn.active {
|
||||
background: #4f46e5;
|
||||
color: #fff;
|
||||
border-color: #4f46e5;
|
||||
}
|
||||
.per-info {
|
||||
margin-left: auto;
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.pivot-scroll {
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
#pivotTable {
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
min-width: 100%;
|
||||
}
|
||||
#pivotTable th, #pivotTable td {
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #e5e5e5;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
#pivotTable th {
|
||||
background: #f0f2f5;
|
||||
font-weight: 600;
|
||||
color: #444;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
#pivotTable thead th:first-child {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
#pivotTable td:first-child {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
background: #fff;
|
||||
font-weight: 500;
|
||||
z-index: 0;
|
||||
min-width: 160px;
|
||||
border-right: 2px solid #d0d0d0;
|
||||
}
|
||||
#pivotTable tr:hover td {
|
||||
background: #f8f9fc;
|
||||
}
|
||||
#pivotTable tr:hover td:first-child {
|
||||
background: #f0f2f5;
|
||||
}
|
||||
.pivot-subject {
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
.pivot-subject .toggle {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
font-size: 11px;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.pivot-subject .toggle.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.pivot-teacher {
|
||||
padding-left: 20px !important;
|
||||
color: #555;
|
||||
}
|
||||
.pivot-student {
|
||||
padding-left: 36px !important;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
.pivot-cell {
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #333;
|
||||
}
|
||||
.pivot-hidden { display: none; }
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.pagination button {
|
||||
padding: 6px 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.upload-section form { flex-direction: column; }
|
||||
button { width: 100%; }
|
||||
.tabs { overflow-x: auto; }
|
||||
.tab { padding: 10px 14px; font-size: 13px; }
|
||||
}
|
||||
Reference in New Issue
Block a user