This commit is contained in:
2026-06-19 11:24:17 +05:00
commit b9251ed056
11 changed files with 11284 additions and 0 deletions

495
public/app.js Normal file
View 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
View 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">&times;</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
View 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; }
}