0d4c658d93
Логика классификации типа задания и статуса «сдано» дублировалась в трёх местах (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>
756 lines
37 KiB
HTML
756 lines
37 KiB
HTML
<!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>Оценка (0–100)</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>
|