Files
Maxim Dolgolyov 0d4c658d93 refactor(assignments): единый модуль assignment-utils.js (тип/«сдано»/срочность)
Логика классификации типа задания и статуса «сдано» дублировалась в трёх местах
(dashboard.html, homework.html, assignmentController.js) и начала расходиться. Вынес в
один UMD-модуль frontend/js/assignment-utils.js (грузится и в браузере, и в Node через
require — как svg-sanitize.js): type(a), isDone(a, sub, opts), urgencyScore(a).

Нюанс «сдано» для upload/file параметризован: вид ученика (acceptedOnly) — закрыто только
при принятой сдаче; учитель/обзор долгов — любая сдача не на доработке. Поведение всех трёх
поверхностей сохранено 1:1.

- homework.html: asgnType/asgnDone/urgencyScore → тонкие делегаты в AssignmentUtils.
- dashboard.html: urgencyScore делегирует; classify и upload-ветка buildAssignCard через
  AssignmentUtils.type/isDone (заодно корректнее: учебник-ДЗ больше не путается с upload).
- assignmentController: classOutstanding/_assignTypeOf → AssignmentUtils.

Verified: AU-контракт 25/25 (типы, isDone teacher vs acceptedOnly, порядок urgency);
интеграция 8/8 (classOutstanding те же 14 уч./42 просрочено; homework делегирует). node --check
всех файлов + инлайна обоих HTML.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 14:09:34 +03:00

756 lines
37 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Домашние задания — LearnSpace</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/ls.css" />
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<style>
.sb-content { background: #f4f5f8; }
.container { max-width: 960px; margin: 0 auto; padding: 28px 32px 100px; }
.page-title {
font-family: 'Unbounded', sans-serif; font-size: 1.1rem; font-weight: 800;
color: #0F172A; margin-bottom: 6px;
}
.page-sub { font-size: 0.82rem; color: var(--text-3); margin-bottom: 24px; }
/* ── top bar with class selector ── */
.hw-top { display: flex; align-items: center; gap: 14px; margin-bottom: 24px; flex-wrap: wrap; }
.hw-class-sel {
padding: 8px 14px; border-radius: 12px; border: 1.5px solid rgba(15,23,42,0.12);
background: #fff; font-family: 'Manrope', sans-serif; font-size: 0.82rem;
font-weight: 600; color: #0F172A; cursor: pointer; min-width: 180px;
}
.hw-status-filters { display: flex; gap: 4px; flex-wrap: wrap; }
.hw-sf-btn {
padding: 6px 14px; border-radius: 999px; border: 1.5px solid rgba(15,23,42,0.1);
background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.75rem;
font-weight: 600; color: var(--text-3); cursor: pointer; transition: all .15s;
}
.hw-sf-btn:hover { border-color: var(--violet); color: var(--violet); }
.hw-sf-btn.active { background: rgba(155,93,229,0.08); border-color: var(--violet); color: var(--violet); }
/* ── submission cards ── */
.hw-list { display: flex; flex-direction: column; gap: 10px; }
.hw-card {
background: #fff; border-radius: 16px; padding: 18px 20px;
border: 1px solid rgba(15,23,42,0.06);
display: flex; align-items: flex-start; gap: 14px;
transition: all .15s;
}
.hw-card:hover { border-color: rgba(155,93,229,0.15); box-shadow: 0 2px 12px rgba(15,23,42,0.05); }
.hw-card-icon {
width: 42px; height: 42px; border-radius: 12px; display: flex;
align-items: center; justify-content: center; flex-shrink: 0;
}
.hw-card-body { flex: 1; min-width: 0; }
.hw-card-title { font-size: 0.88rem; font-weight: 700; color: #0F172A; margin-bottom: 4px; }
.hw-card-meta { font-size: 0.75rem; color: var(--text-3); display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 6px; }
.hw-card-note { font-size: 0.78rem; color: #3D4F6B; background: rgba(15,23,42,0.03); padding: 8px 12px; border-radius: 10px; margin-top: 8px; line-height: 1.5; }
.hw-card-note strong { color: #0F172A; }
.hw-card-actions { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.hw-card-right { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; flex-shrink: 0; }
/* status badges */
.hw-badge {
padding: 4px 10px; border-radius: 999px; font-size: 0.68rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.03em; white-space: nowrap;
}
.hw-badge-new { background: rgba(6,214,224,0.12); color: #06B6D4; }
.hw-badge-reviewed { background: rgba(155,93,229,0.12); color: #9B5DE5; }
.hw-badge-revision { background: rgba(245,158,11,0.12); color: #F59E0B; }
.hw-badge-resubmitted{ background: rgba(6,214,100,0.12); color: #06D664; }
.hw-badge-accepted { background: rgba(16,185,129,0.12); color: #10B981; }
.hw-grade {
font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800;
padding: 4px 10px; border-radius: 10px; min-width: 50px; text-align: center;
}
.hw-grade-high { background: rgba(16,185,129,0.1); color: #10B981; }
.hw-grade-mid { background: rgba(245,158,11,0.1); color: #F59E0B; }
.hw-grade-low { background: rgba(239,71,111,0.1); color: #EF476F; }
/* action buttons */
.hw-btn {
padding: 6px 14px; border-radius: 10px; border: 1.5px solid rgba(15,23,42,0.12);
background: #fff; font-family: 'Manrope', sans-serif; font-size: 0.75rem;
font-weight: 600; color: #3D4F6B; cursor: pointer; transition: all .15s;
display: flex; align-items: center; gap: 5px;
}
.hw-btn:hover { border-color: var(--violet); color: var(--violet); }
.hw-btn-primary { background: var(--grad-1); color: #fff; border-color: transparent; }
.hw-btn-primary:hover { opacity: .88; color: #fff; }
.hw-btn-danger { border-color: rgba(239,71,111,0.2); color: #EF476F; }
.hw-btn-danger:hover { background: rgba(239,71,111,0.06); }
.hw-btn-accept { border-color: rgba(16,185,129,0.3); color: #10B981; }
.hw-btn-accept:hover { background: rgba(16,185,129,0.06); }
.hw-btn-revision { border-color: rgba(245,158,11,0.3); color: #F59E0B; }
.hw-btn-revision:hover { background: rgba(245,158,11,0.06); }
/* upload area */
.hw-upload-area {
background: #fff; border-radius: 16px; border: 2px dashed rgba(15,23,42,0.12);
padding: 28px; text-align: center; margin-bottom: 24px;
transition: border-color .15s, background .15s; cursor: pointer;
}
.hw-upload-area:hover, .hw-upload-area.dragover {
border-color: var(--violet); background: rgba(155,93,229,0.02);
}
.hw-upload-icon { color: var(--text-3); margin-bottom: 8px; }
.hw-upload-text { font-size: 0.85rem; font-weight: 600; color: #3D4F6B; margin-bottom: 4px; }
.hw-upload-hint { font-size: 0.72rem; color: var(--text-3); }
/* review modal (inline) */
.hw-review-panel {
background: rgba(255,255,255,0.95); border: 1.5px solid rgba(15,23,42,0.08);
border-radius: 14px; padding: 16px; margin-top: 10px;
}
.hw-review-panel label { font-size: 0.75rem; font-weight: 700; color: #3D4F6B; display: block; margin-bottom: 4px; }
.hw-review-panel textarea, .hw-review-panel input {
width: 100%; padding: 8px 12px; border: 1.5px solid rgba(15,23,42,0.1); border-radius: 10px;
font-family: 'Manrope', sans-serif; font-size: 0.82rem; margin-bottom: 10px;
outline: none; transition: border-color .15s;
}
.hw-review-panel textarea:focus, .hw-review-panel input:focus { border-color: var(--violet); }
.hw-review-panel textarea { min-height: 60px; resize: vertical; }
/* empty state */
.hw-empty { text-align: center; padding: 60px 20px; color: var(--text-3); }
.hw-empty-icon { font-size: 3rem; margin-bottom: 12px; opacity: 0.3; }
.hw-empty-text { font-size: 0.88rem; font-weight: 600; }
/* student name in teacher view */
.hw-student-name { font-size: 0.78rem; font-weight: 700; color: var(--violet); }
/* ── section titles (multi-block student view) ── */
.hw-sec-title {
font-family: 'Unbounded', sans-serif; font-size: 0.92rem; font-weight: 800;
color: #0F172A; margin: 4px 0 12px; display: flex; align-items: center; gap: 9px;
}
.hw-sec-count {
font-family: 'Manrope', sans-serif; font-size: 0.7rem; font-weight: 700;
color: var(--violet); background: rgba(155,93,229,0.1);
padding: 2px 9px; border-radius: 999px;
}
#hw-active-wrap { margin-bottom: 28px; }
/* ── active assignment cards ── */
.hw-acard {
background: #fff; border-radius: 16px; padding: 16px 18px;
border: 1px solid rgba(15,23,42,0.06); border-left: 3px solid var(--ac, #9B5DE5);
display: flex; align-items: center; gap: 14px; transition: all .15s;
}
.hw-acard:hover { box-shadow: 0 2px 12px rgba(15,23,42,0.06); }
.hw-acard.over { border-left-color: #EF476F; }
.hw-acard.urgent { border-left-color: #F59E0B; }
.hw-acard-icon {
width: 42px; height: 42px; border-radius: 12px; display: flex;
align-items: center; justify-content: center; flex-shrink: 0;
}
.hw-acard-body { flex: 1; min-width: 0; }
.hw-acard-title { font-size: 0.88rem; font-weight: 700; color: #0F172A; margin-bottom: 4px; }
.hw-acard-meta { font-size: 0.74rem; color: var(--text-3); display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.hw-acard-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.hw-dl-chip { font-size: 0.7rem; font-weight: 700; padding: 3px 9px; border-radius: 999px; white-space: nowrap; }
.hw-dl-soon { background: rgba(245,158,11,0.12); color: #F59E0B; }
.hw-dl-over { background: rgba(239,71,111,0.12); color: #EF476F; }
.hw-dl-ok { background: rgba(15,23,42,0.05); color: var(--text-3); }
.hw-sub-chip { font-size: 0.68rem; font-weight: 700; padding: 3px 9px; border-radius: 999px; white-space: nowrap; }
@media (max-width: 768px) {
.container { padding: 16px 14px 80px; }
.hw-top { gap: 8px; }
.hw-class-sel { flex: 1; min-width: 0; }
.hw-card { flex-direction: column; gap: 10px; }
.hw-card-right { flex-direction: row; align-items: center; justify-content: flex-start; width: 100%; }
.hw-card-actions { flex-wrap: wrap; }
.hw-upload-area { padding: 20px 16px; }
.hw-acard { flex-wrap: wrap; }
.hw-acard-right { width: 100%; justify-content: flex-end; }
}
@media (max-width: 480px) {
.container { padding: 12px 10px 80px; }
.hw-status-filters { gap: 4px; }
.hw-sf-btn { font-size: 0.72rem; padding: 5px 10px; }
.hw-card { padding: 14px 14px; }
.hw-btn { font-size: 0.72rem; padding: 5px 10px; }
}
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<div class="notif-drop" id="notif-drop"></div>
<div class="sb-content">
<div class="container">
<div class="page-title">Домашние задания</div>
<div class="page-sub" id="hw-sub">Загрузка…</div>
<!-- Student: active assignments (что нужно сделать) -->
<div id="hw-active-wrap" style="display:none">
<div class="hw-sec-title">Актуальные задания <span class="hw-sec-count" id="hw-active-count"></span></div>
<div class="hw-list" id="hw-active-list"></div>
</div>
<!-- Student: upload area -->
<div id="hw-upload-wrap" style="display:none">
<div class="hw-sec-title">Сдать работу</div>
<div class="hw-upload-area" id="hw-upload-area" onclick="document.getElementById('hw-file-input').click()">
<div class="hw-upload-icon"><i data-lucide="upload-cloud" style="width:36px;height:36px"></i></div>
<div class="hw-upload-text">Загрузить работу</div>
<div class="hw-upload-hint">PDF, Word, изображения · до 50 МБ</div>
<div class="hw-upload-hint" id="hw-selected-file" style="color:var(--violet);font-weight:600;margin-top:6px"></div>
</div>
<input type="file" id="hw-file-input" style="display:none"
accept=".pdf,.doc,.docx,.ppt,.pptx,.xls,.xlsx,.png,.jpg,.jpeg,.gif,.webp,.txt" />
<div style="display:flex;gap:10px;margin-bottom:24px;flex-wrap:wrap;align-items:flex-end">
<div style="flex:1;min-width:200px">
<label style="font-size:.72rem;font-weight:700;color:var(--text-3);display:block;margin-bottom:4px">Задание (необязательно)</label>
<select id="hw-assignment-sel" class="hw-class-sel" style="width:100%">
<option value="">— Без привязки к заданию —</option>
</select>
</div>
<div style="flex:1;min-width:200px">
<label style="font-size:.72rem;font-weight:700;color:var(--text-3);display:block;margin-bottom:4px">Комментарий</label>
<input type="text" id="hw-message" class="hw-class-sel" style="width:100%" placeholder="Опишите работу…" />
</div>
<button class="hw-btn hw-btn-primary" id="hw-submit-btn" onclick="submitHomework()" disabled>
<i data-lucide="send" style="width:13px;height:13px"></i> Отправить
</button>
</div>
</div>
<!-- Teacher: class selector -->
<div class="hw-top" id="hw-top-teacher" style="display:none">
<select class="hw-class-sel" id="hw-class-sel" onchange="loadTeacherSubmissions()">
<option value="">Выберите класс</option>
</select>
<div class="hw-status-filters">
<button class="hw-sf-btn active" onclick="filterStatus(null,this)">Все</button>
<button class="hw-sf-btn" onclick="filterStatus('new',this)">Новые</button>
<button class="hw-sf-btn" onclick="filterStatus('resubmitted',this)">Повторные</button>
<button class="hw-sf-btn" onclick="filterStatus('revision',this)">На доработке</button>
<button class="hw-sf-btn" onclick="filterStatus('reviewed',this)">Проверены</button>
<button class="hw-sf-btn" onclick="filterStatus('accepted',this)">Приняты</button>
</div>
</div>
<!-- Student: status filters -->
<div class="hw-sec-title" id="hw-mysubs-title" style="display:none">Мои сдачи</div>
<div class="hw-top" id="hw-top-student" style="display:none">
<div class="hw-status-filters">
<button class="hw-sf-btn active" onclick="filterStatus(null,this)">Все</button>
<button class="hw-sf-btn" onclick="filterStatus('new',this)">Отправлено</button>
<button class="hw-sf-btn" onclick="filterStatus('revision',this)">На доработке</button>
<button class="hw-sf-btn" onclick="filterStatus('reviewed',this)">Проверено</button>
<button class="hw-sf-btn" onclick="filterStatus('accepted',this)">Принято</button>
</div>
</div>
<div class="hw-list" id="hw-list"></div>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script src="/js/assignment-utils.js"></script>
<script>
const { user, isTeacher, isAdmin } = LS.initPage();
if (!user) throw new Error('Not logged in');
function fmtDate(s) {
if (!s) return '—';
const d = new Date(s.includes('T') ? s : s.replace(' ','T')+'Z');
return d.toLocaleDateString('ru',{day:'numeric',month:'short'}) + ' ' + d.toLocaleTimeString('ru',{hour:'2-digit',minute:'2-digit'});
}
function fmtSize(b) {
if (b < 1024) return b + ' Б';
if (b < 1024*1024) return (b/1024).toFixed(1) + ' КБ';
return (b/1024/1024).toFixed(1) + ' МБ';
}
if (isAdmin) {
const btnAdmin = document.getElementById('btn-admin');
if (btnAdmin) btnAdmin.style.display = '';
}
LS.showBoardIfAllowed();
LS.notif.init();
/* ── state ── */
let _submissions = [];
let _statusFilter = null;
let _selectedFile = null;
let _studentClassId = null;
const STATUS_LABELS = {
new: 'Новое', reviewed: 'Проверено', revision: 'На доработке',
resubmitted: 'Повторно', accepted: 'Принято'
};
/* subject label/colour/icon maps (как на дашборде) */
const SUBJ = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика', other:'Задание' };
const SUBJ_ICONS = { bio:'dna', chem:'flask-conical', math:'calculator', phys:'zap', other:'file-check' };
const SUBJ_COLORS = { bio:'#9B5DE5', chem:'#06D6A0', math:'#06B6D4', phys:'#F59E0B', other:'#7c3aed' };
let _assignments = []; // актуальные задания (LS.myAssignments)
let _subByAsgn = new Map(); // assignment_id -> последняя сдача
/* ── filter ── */
function filterStatus(st, btn) {
_statusFilter = st;
document.querySelectorAll('.hw-sf-btn').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
renderSubmissions();
}
/* ── STUDENT VIEW ── */
async function initStudent() {
document.getElementById('hw-sub').textContent = 'Ваши актуальные задания и сданные работы';
document.getElementById('hw-top-student').style.display = '';
document.getElementById('hw-mysubs-title').style.display = '';
// Find student's class (нужен для загрузки работ без привязки к заданию)
try {
const classes = await LS.myClasses();
if (classes.length) {
_studentClassId = classes[0].id;
document.getElementById('hw-upload-wrap').style.display = '';
}
} catch {}
// Грузим актуальные задания (все классы) + сдачи параллельно
try {
const [assigns, subs] = await Promise.all([
LS.myAssignments().catch(() => []),
LS.getMySubmissions().catch(() => []),
]);
_assignments = Array.isArray(assigns) ? assigns : [];
_submissions = Array.isArray(subs) ? subs : [];
_subByAsgn.clear();
_submissions.forEach(s => { if (s.assignment_id) _subByAsgn.set(s.assignment_id, s); });
populateAssignmentSelect(_assignments);
renderActiveAssignments();
renderSubmissions();
} catch {
document.getElementById('hw-list').innerHTML = '<div class="hw-empty"><div class="hw-empty-text">Ошибка загрузки</div></div>';
}
// File input
const fileInput = document.getElementById('hw-file-input');
fileInput.addEventListener('change', () => {
_selectedFile = fileInput.files[0] || null;
document.getElementById('hw-selected-file').textContent = _selectedFile ? _selectedFile.name : '';
document.getElementById('hw-submit-btn').disabled = !_selectedFile;
});
// Drag and drop
const area = document.getElementById('hw-upload-area');
area.addEventListener('dragover', e => { e.preventDefault(); area.classList.add('dragover'); });
area.addEventListener('dragleave', () => area.classList.remove('dragover'));
area.addEventListener('drop', e => {
e.preventDefault(); area.classList.remove('dragover');
if (e.dataTransfer.files.length) {
_selectedFile = e.dataTransfer.files[0];
document.getElementById('hw-selected-file').textContent = _selectedFile.name;
document.getElementById('hw-submit-btn').disabled = false;
}
});
}
async function submitHomework() {
if (!_selectedFile) return;
const sel = document.getElementById('hw-assignment-sel');
const assignId = sel.value;
// Класс берём от выбранного задания (важно для учеников в нескольких классах),
// иначе — первый класс ученика.
let classId = _studentClassId;
if (assignId && sel.selectedOptions[0] && sel.selectedOptions[0].dataset.class) {
classId = sel.selectedOptions[0].dataset.class;
}
if (!classId) { LS.toast('Вы не состоите в классе', 'error'); return; }
const btn = document.getElementById('hw-submit-btn');
btn.disabled = true;
try {
const fd = new FormData();
fd.append('file', _selectedFile);
fd.append('class_id', classId);
if (assignId) fd.append('assignment_id', assignId);
const msg = document.getElementById('hw-message').value.trim();
if (msg) fd.append('message', msg);
await LS.submitWork(fd);
LS.toast('Работа отправлена!', 'success');
// Reset
_selectedFile = null;
document.getElementById('hw-file-input').value = '';
document.getElementById('hw-selected-file').textContent = '';
document.getElementById('hw-message').value = '';
document.getElementById('hw-assignment-sel').value = '';
// Reload
_submissions = await LS.getMySubmissions();
syncStudentLists();
} catch (e) {
LS.toast(e.message || 'Ошибка отправки', 'error');
} finally { btn.disabled = !_selectedFile; }
}
/* Пересобрать карту сдач и перерисовать обе студенческие секции. */
function syncStudentLists() {
_subByAsgn.clear();
_submissions.forEach(s => { if (s.assignment_id) _subByAsgn.set(s.assignment_id, s); });
renderActiveAssignments();
renderSubmissions();
}
async function resubmitHomework(subId) {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.pdf,.doc,.docx,.ppt,.pptx,.xls,.xlsx,.png,.jpg,.jpeg,.gif,.webp,.txt';
input.onchange = async () => {
const file = input.files[0];
if (!file) return;
try {
const fd = new FormData();
fd.append('file', file);
await LS.resubmitWork(subId, fd);
LS.toast('Работа отправлена повторно!', 'success');
_submissions = await LS.getMySubmissions();
syncStudentLists();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
};
input.click();
}
async function deleteHomework(id) {
if (!await LS.confirm('Удалить эту работу?', { title: 'Удаление', confirmText: 'Удалить' })) return;
try {
await LS.deleteSubmission(id);
_submissions = _submissions.filter(s => s.id !== id);
syncStudentLists();
LS.toast('Удалено', 'info');
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
async function deleteSubmissionAdmin(id) {
if (!await LS.confirm('Удалить работу ученика? Это действие будет записано в журнал.', { title: 'Удаление работы', confirmText: 'Удалить', danger: true })) return;
try {
await LS.deleteSubmission(id);
_submissions = _submissions.filter(s => s.id !== id);
renderSubmissions();
LS.toast('Работа удалена', 'info');
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
/* ══ АКТУАЛЬНЫЕ ЗАДАНИЯ (что нужно сделать) ══════════════════════════ */
// Тип / «сдано» / срочность — из общего модуля AssignmentUtils (тот же, что у дашборда
// и сервера). Вид ученика: upload/file закрыт ТОЛЬКО при принятой сдаче (acceptedOnly).
function asgnType(a) { return AssignmentUtils.type(a); }
function asgnDone(a) { return AssignmentUtils.isDone(a, _subByAsgn.get(a.id), { acceptedOnly: true }); }
function urgencyScore(a) { return AssignmentUtils.urgencyScore(a); }
function deadlineChip(a) {
if (!a.deadline) return '<span class="hw-dl-chip hw-dl-ok">Без срока</span>';
const dlMs = new Date(a.deadline) - Date.now();
const date = new Date(a.deadline).toLocaleDateString('ru', { day: 'numeric', month: 'short' });
if (dlMs < 0) return `<span class="hw-dl-chip hw-dl-over">Просрочено · ${date}</span>`;
if (dlMs < 24 * 3600 * 1000) return `<span class="hw-dl-chip hw-dl-soon">Сегодня · до ${date}</span>`;
const days = Math.ceil(dlMs / 86400000);
const txt = days === 1 ? '1 день' : `${days} дн.`;
return `<span class="hw-dl-chip hw-dl-ok">${txt} · до ${date}</span>`;
}
function actionFor(a) {
const t = asgnType(a);
if (t === 'textbook') {
let hash = '';
if (a.textbook_paragraphs) { const m = String(a.textbook_paragraphs).match(/^\s*(\d+)/); if (m) hash = '#p' + m[1]; }
const href = `/textbook/${a.textbook_slug || ''}${hash}`;
return `<a class="hw-btn hw-btn-primary" href="${href}">${(a.textbook_read_count || 0) > 0 ? 'Продолжить' : 'Открыть'}</a>`;
}
if (t === 'file') {
const sub = _subByAsgn.get(a.id);
const submit = sub && sub.status !== 'revision'
? `<span class="hw-badge hw-badge-${sub.status}">${STATUS_LABELS[sub.status] || sub.status}</span>`
: `<button class="hw-btn hw-btn-primary" onclick="sdatNow(${a.id})">${sub ? 'Пересдать' : 'Сдать'}</button>`;
return `<a class="hw-btn" href="${LS.downloadFileUrl(a.file_id)}" target="_blank" download>Скачать</a>${submit}`;
}
if (t === 'upload') {
const sub = _subByAsgn.get(a.id);
if (sub && sub.status !== 'revision') {
return `<span class="hw-badge hw-badge-${sub.status}">${STATUS_LABELS[sub.status] || sub.status}</span>`;
}
return `<button class="hw-btn hw-btn-primary" onclick="sdatNow(${a.id})">${sub ? 'Пересдать' : 'Сдать'}</button>`;
}
// test
const inProgress = a.session_status === 'in_progress';
const isDone = a.session_status === 'completed';
const label = inProgress ? 'Продолжить' : (isDone && a.mode === 'repeat') ? 'Повторить' : 'Начать';
return `<button class="hw-btn hw-btn-primary" onclick="startAsgn(event,${a.id},'${a.mode || 'exam'}')">${label}</button>`;
}
function activeCardHtml(a) {
const t = asgnType(a);
const color = SUBJ_COLORS[a.subject_slug] || (t === 'textbook' ? '#7c3aed' : '#9B5DE5');
const icon = t === 'textbook' ? 'book-open-text'
: t === 'file' ? 'paperclip'
: t === 'upload' ? 'upload'
: (SUBJ_ICONS[a.subject_slug] || 'file-text');
const dlMs = a.deadline ? new Date(a.deadline) - Date.now() : Infinity;
const over = dlMs < 0;
const urgent = !over && dlMs < 24 * 3600 * 1000;
const cls = over ? ' over' : urgent ? ' urgent' : '';
const classStr = a.class_id ? esc(a.class_name) : 'Личное';
const subjStr = SUBJ[a.subject_slug] || (t === 'textbook' ? 'Чтение' : '');
const meta = [classStr, subjStr].filter(Boolean).join(' · ');
return `<div class="hw-acard${cls}" style="--ac:${color}">
<div class="hw-acard-icon" style="background:${color}1a;color:${color}"><i data-lucide="${icon}" style="width:20px;height:20px"></i></div>
<div class="hw-acard-body">
<div class="hw-acard-title">${esc(a.title)}</div>
<div class="hw-acard-meta">${meta ? `<span>${meta}</span>` : ''}${deadlineChip(a)}</div>
</div>
<div class="hw-acard-right">${actionFor(a)}</div>
</div>`;
}
function renderActiveAssignments() {
const wrap = document.getElementById('hw-active-wrap');
const list = document.getElementById('hw-active-list');
if (!wrap || !list) return;
// Только задания с флагом ДЗ (is_homework) — это страница «Домашние задания»,
// обычные тесты/экзамены сюда не попадают.
const active = _assignments
.filter(a => a.is_homework && !asgnDone(a))
.sort((x, y) => urgencyScore(x) - urgencyScore(y));
if (!active.length) { wrap.style.display = 'none'; return; }
wrap.style.display = '';
document.getElementById('hw-active-count').textContent = active.length;
list.innerHTML = active.map(activeCardHtml).join('');
lucide.createIcons();
}
// Наполнить выпадашку «Задание» при загрузке работы — по ВСЕМ классам ученика.
function populateAssignmentSelect(list) {
const sel = document.getElementById('hw-assignment-sel');
if (!sel) return;
sel.querySelectorAll('option[data-asgn]').forEach(o => o.remove());
// Привязать загрузку можно только к ДЗ, куда ученик сдаёт файл (тип upload/file).
list.filter(a => a.is_homework && (asgnType(a) === 'upload' || asgnType(a) === 'file')).forEach(a => {
const opt = document.createElement('option');
opt.value = a.id;
opt.textContent = a.title + (a.class_name && a.class_name !== 'Личное задание' ? ' · ' + a.class_name : '');
opt.dataset.asgn = '1';
if (a.class_id) opt.dataset.class = a.class_id;
sel.appendChild(opt);
});
}
// Начать/продолжить тест-задание (как на дашборде).
async function startAsgn(e, id, mode) {
const btn = e.currentTarget;
const orig = btn.textContent;
btn.disabled = true; btn.textContent = '…';
try {
const r = await LS.startAssignment(id);
if (r.error && r.max_attempts) {
LS.toast(`Исчерпан лимит попыток (${r.attempts_used}/${r.max_attempts})`, 'warn');
btn.disabled = false; btn.textContent = orig; return;
}
const aMode = r.assignment_mode || mode || 'exam';
if (r.status === 'completed' && aMode !== 'repeat') location.href = `/test-result?session=${r.session_id}`;
else location.href = `/test-run?session=${r.session_id}&assignment_mode=${aMode}`;
} catch (err) {
const isLimit = err.message && (err.message.includes('лимит') || err.message.includes('Исчерпан'));
LS.toast(isLimit ? err.message : ('Ошибка: ' + err.message), isLimit ? 'warn' : 'error');
btn.disabled = false; btn.textContent = orig;
}
}
// «Сдать» из карточки → прокрутить к области загрузки и преднабрать задание.
function sdatNow(assignId) {
const wrap = document.getElementById('hw-upload-wrap');
if (!wrap || wrap.style.display === 'none') {
LS.toast('Загрузка работ доступна участникам класса', 'warn'); return;
}
const sel = document.getElementById('hw-assignment-sel');
if (sel && [...sel.options].some(o => o.value == assignId)) sel.value = String(assignId);
wrap.scrollIntoView({ behavior: 'smooth', block: 'start' });
const area = document.getElementById('hw-upload-area');
if (area) { area.classList.add('dragover'); setTimeout(() => area.classList.remove('dragover'), 1200); }
}
/* ── TEACHER VIEW ── */
async function initTeacher() {
document.getElementById('hw-sub').textContent = 'Проверяйте работы учеников и ставьте оценки';
document.getElementById('hw-top-teacher').style.display = '';
try {
const classes = await LS.getClasses();
const sel = document.getElementById('hw-class-sel');
classes.forEach(c => {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.name;
sel.appendChild(opt);
});
if (classes.length) {
sel.value = classes[0].id;
loadTeacherSubmissions();
}
} catch {}
}
async function loadTeacherSubmissions() {
const classId = document.getElementById('hw-class-sel').value;
if (!classId) { _submissions = []; renderSubmissions(); return; }
document.getElementById('hw-list').innerHTML = LS.skeleton(3);
try {
_submissions = await LS.getClassSubmissions(classId);
renderSubmissions();
} catch {
document.getElementById('hw-list').innerHTML = '<div class="hw-empty"><div class="hw-empty-text">Ошибка загрузки</div></div>';
}
}
function openReviewPanel(subId) {
const el = document.getElementById('review-' + subId);
if (el) { el.style.display = el.style.display === 'none' ? '' : 'none'; return; }
}
async function submitReview(subId, action) {
const noteEl = document.getElementById('note-' + subId);
const gradeEl = document.getElementById('grade-' + subId);
const teacher_note = noteEl?.value?.trim() || undefined;
const grade = gradeEl?.value ? Number(gradeEl.value) : undefined;
let status;
if (action === 'accept') status = 'accepted';
else if (action === 'revision') status = 'revision';
else status = 'reviewed';
try {
await LS.reviewSubmission(subId, { status, teacher_note, grade });
LS.toast(action === 'accept' ? 'Работа принята!' : action === 'revision' ? 'Отправлено на доработку' : 'Проверено', 'success');
loadTeacherSubmissions();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
/* ── RENDER ── */
function renderSubmissions() {
const list = document.getElementById('hw-list');
const filtered = _statusFilter ? _submissions.filter(s => s.status === _statusFilter) : _submissions;
if (!filtered.length) {
list.innerHTML = `<div class="hw-empty">
<div class="hw-empty-icon"><i data-lucide="inbox" style="width:48px;height:48px"></i></div>
<div class="hw-empty-text">${_submissions.length ? 'Нет работ с этим статусом' : 'Работ пока нет'}</div>
</div>`;
lucide.createIcons();
return;
}
list.innerHTML = filtered.map(s => {
const badge = `<span class="hw-badge hw-badge-${s.status}">${STATUS_LABELS[s.status] || s.status}</span>`;
const gradeHtml = s.grade != null
? `<div class="hw-grade ${s.grade >= 80 ? 'hw-grade-high' : s.grade >= 50 ? 'hw-grade-mid' : 'hw-grade-low'}">${s.grade}</div>`
: '';
const iconBg = s.status === 'accepted' ? 'rgba(16,185,129,0.1)' :
s.status === 'revision' ? 'rgba(245,158,11,0.1)' :
'rgba(155,93,229,0.1)';
const iconColor = s.status === 'accepted' ? '#10B981' :
s.status === 'revision' ? '#F59E0B' : '#9B5DE5';
const iconName = s.status === 'accepted' ? 'check-circle' :
s.status === 'revision' ? 'alert-circle' : 'file-text';
let noteHtml = '';
if (s.teacher_note) noteHtml = `<div class="hw-card-note"><strong>Учитель:</strong> ${esc(s.teacher_note)}</div>`;
if (s.message) noteHtml += `<div class="hw-card-note" style="margin-top:6px"><strong>Комментарий:</strong> ${esc(s.message)}</div>`;
let actionsHtml = '';
if (isTeacher || isAdmin) {
actionsHtml = `
<div class="hw-card-actions">
<a class="hw-btn" href="${LS.submissionDownloadUrl(s.id)}" target="_blank">
<i data-lucide="download" style="width:12px;height:12px"></i> Скачать
</a>
<button class="hw-btn" onclick="openReviewPanel(${s.id})">
<i data-lucide="pencil" style="width:12px;height:12px"></i> Проверить
</button>
<button class="hw-btn hw-btn-danger" onclick="deleteSubmissionAdmin(${s.id})">
<i data-lucide="trash-2" style="width:12px;height:12px"></i>
</button>
</div>
<div class="hw-review-panel" id="review-${s.id}" style="display:none">
<label>Комментарий</label>
<textarea id="note-${s.id}" placeholder="Замечания, рекомендации…">${esc(s.teacher_note || '')}</textarea>
<label>Оценка (0100)</label>
<input type="number" id="grade-${s.id}" min="0" max="100" value="${s.grade != null ? s.grade : ''}" placeholder="—" />
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="hw-btn hw-btn-accept" onclick="submitReview(${s.id},'accept')">
<i data-lucide="check" style="width:12px;height:12px"></i> Принять
</button>
<button class="hw-btn hw-btn-revision" onclick="submitReview(${s.id},'revision')">
<i data-lucide="rotate-ccw" style="width:12px;height:12px"></i> На доработку
</button>
<button class="hw-btn" onclick="submitReview(${s.id},'reviewed')">Проверено</button>
</div>
</div>`;
} else {
// Student actions
const canResubmit = s.status === 'revision';
const canDelete = ['new', 'revision', 'resubmitted'].includes(s.status);
actionsHtml = `<div class="hw-card-actions">
<a class="hw-btn" href="${LS.submissionDownloadUrl(s.id)}" target="_blank">
<i data-lucide="download" style="width:12px;height:12px"></i> Скачать
</a>
${canResubmit ? `<button class="hw-btn hw-btn-primary" onclick="resubmitHomework(${s.id})"><i data-lucide="upload" style="width:12px;height:12px"></i> Пересдать</button>` : ''}
${canDelete ? `<button class="hw-btn hw-btn-danger" onclick="deleteHomework(${s.id})"><i data-lucide="trash-2" style="width:12px;height:12px"></i></button>` : ''}
</div>`;
}
const studentLine = (isTeacher || isAdmin) && s.student_name
? `<span class="hw-student-name">${esc(s.student_name)}</span> · `
: '';
return `<div class="hw-card">
<div class="hw-card-icon" style="background:${iconBg};color:${iconColor}">
<i data-lucide="${iconName}" style="width:20px;height:20px"></i>
</div>
<div class="hw-card-body">
<div class="hw-card-title">${esc(s.assignment_title || s.original_name)}</div>
<div class="hw-card-meta">
<span>${studentLine}${fmtDate(s.submitted_at)}</span>
<span>${esc(s.original_name)} · ${fmtSize(s.size || 0)}</span>
</div>
${noteHtml}
${actionsHtml}
</div>
<div class="hw-card-right">
${badge}
${gradeHtml}
</div>
</div>`;
}).join('');
lucide.createIcons();
}
/* ── init ── */
if (isTeacher || isAdmin) initTeacher();
else initStudent();
if (window.lucide) lucide.createIcons();
</script>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
</body>
</html>