26ba289019
- css/ls.css: --text-3 #8898AA → #56687A (5.1:1 contrast), min-height 44px on .btn-primary/.btn-ghost/.sb-link, new .icon-btn utility (44×44px) - js/api.js: lsConfirm — role=dialog, aria-modal, aria-labelledby, Tab focus trap, restore focus on close; lsToast — aria-live=polite on container, role=alert on errors; live quiz — role=dialog, role=radiogroup, role=radio, aria-checked, keyboard support - test-run.html: q-opt divs — role=radio/checkbox, aria-checked, tabindex, keyboard enter/space; confirm modal — role=dialog, aria-modal; btn-flag — aria-pressed; dots — aria-label, aria-current; touch targets 44px - board.html: btn-del-ann — aria-label; reaction buttons — aria-label, aria-pressed - All 18 HTML files: replace hardcoded color:#8898AA with color:var(--text-3) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2576 lines
149 KiB
HTML
2576 lines
149 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" />
|
||
<style>
|
||
|
||
/* ── Split layout ── */
|
||
.sb-content.classes-split { display: flex; height: 100vh; overflow: hidden; background: #f4f5f8; }
|
||
|
||
/* Left: class list */
|
||
.cl-side { width: 300px; flex-shrink: 0; border-right: 1px solid var(--border); background: #fff; display: flex; flex-direction: column; overflow: hidden; }
|
||
.cl-side-header { padding: 20px 16px 14px; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
|
||
.cl-side-title { font-family: 'Unbounded', sans-serif; font-size: 0.95rem; font-weight: 800; flex: 1; }
|
||
.btn-new-cl { width: 32px; height: 32px; border: none; border-radius: 10px; background: var(--grad-1); color: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: transform 0.15s, box-shadow 0.15s; flex-shrink: 0; }
|
||
.btn-new-cl:hover { transform: scale(1.1); box-shadow: 0 4px 14px rgba(6,214,224,0.35); }
|
||
.btn-new-cl svg { width: 16px; height: 16px; }
|
||
.cl-list { flex: 1; overflow-y: auto; padding: 10px 8px; display: flex; flex-direction: column; gap: 3px; }
|
||
|
||
.cl-item { display: flex; align-items: center; gap: 12px; padding: 12px 13px; border-radius: 14px; cursor: pointer; transition: background 0.15s, border-color 0.15s, box-shadow 0.15s; border: 1.5px solid transparent; }
|
||
.cl-item:hover { background: rgba(155,93,229,0.05); border-color: rgba(155,93,229,0.10); }
|
||
.cl-item.active { background: rgba(155,93,229,0.09); border-color: rgba(155,93,229,0.22); box-shadow: 0 2px 10px rgba(155,93,229,0.10); }
|
||
.cl-avatar { width: 44px; height: 44px; border-radius: 13px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 800; color: #fff; }
|
||
.cl-info { flex: 1; min-width: 0; }
|
||
.cl-name { font-size: 0.88rem; font-weight: 700; color: #0F172A; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.25; }
|
||
.cl-meta { font-size: 0.73rem; color: var(--text-3); margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.cl-chevron { color: #cbd5e1; flex-shrink: 0; transition: color 0.15s; }
|
||
.cl-item.active .cl-chevron { color: var(--violet); }
|
||
.cl-item:hover .cl-chevron { color: #94a3b8; }
|
||
.cl-works-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--pink); flex-shrink: 0; }
|
||
.cl-empty-side { padding: 40px 16px; text-align: center; color: var(--text-3); font-size: 0.8rem; line-height: 1.6; }
|
||
|
||
/* Right: class detail */
|
||
.cl-main { flex: 1; overflow-y: auto; display: flex; flex-direction: column; min-width: 0; }
|
||
|
||
@keyframes panelFadeIn {
|
||
from { opacity: 0; transform: translateY(10px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
#detail-panel.panel-visible,
|
||
#personal-panel.panel-visible { animation: panelFadeIn 0.22s ease forwards; }
|
||
.cl-placeholder { display: flex; flex-direction: column; align-items: center; justify-content: center; flex: 1; gap: 14px; padding: 80px 40px; text-align: center; }
|
||
.cl-placeholder-icon { width: 72px; height: 72px; border-radius: 22px; background: linear-gradient(135deg, rgba(155,93,229,0.12), rgba(6,214,224,0.08)); display: flex; align-items: center; justify-content: center; margin-bottom: 4px; }
|
||
.cl-placeholder-icon svg { width: 32px; height: 32px; stroke: var(--violet); stroke-width: 1.6; }
|
||
.cl-placeholder-title { font-family: 'Unbounded', sans-serif; font-size: 0.95rem; font-weight: 800; color: #0F172A; }
|
||
.cl-placeholder-sub { font-size: 0.82rem; color: var(--text-3); max-width: 220px; line-height: 1.6; }
|
||
.cl-detail-wrap { padding: 28px 32px 60px; flex: 1; }
|
||
|
||
.btn-primary { padding: 10px 24px; border: none; border-radius: var(--r-pill); background: var(--grad-1); color: #fff; font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 700; cursor: pointer; transition: transform var(--tr); }
|
||
.btn-primary:hover { transform: translateY(-1px); }
|
||
.btn-delete-class { padding: 7px 16px; border: 1.5px solid rgba(241,91,181,0.3); border-radius: var(--r-pill); background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.8rem; font-weight: 600; color: var(--text-3); cursor: pointer; transition: all var(--tr); }
|
||
.btn-delete-class:hover { border-color: var(--pink); color: #c0306a; background: rgba(241,91,181,0.06); }
|
||
|
||
/* ── Detail panel ── */
|
||
.detail-panel { background: #fff; border: 1.5px solid var(--border); border-radius: var(--r-lg); padding: 24px 28px; box-shadow: 0 2px 12px rgba(15,23,42,0.06); }
|
||
.invite-badge {
|
||
display: inline-flex; align-items: center; gap: 5px;
|
||
padding: 4px 12px; border-radius: var(--r-pill);
|
||
background: rgba(155,93,229,0.07); color: var(--violet);
|
||
font-family: monospace; font-size: 0.8rem; font-weight: 700;
|
||
letter-spacing: 0.05em; cursor: pointer;
|
||
transition: background var(--tr);
|
||
border: 1px dashed rgba(155,93,229,0.25);
|
||
}
|
||
.invite-badge:hover { background: rgba(155,93,229,0.14); }
|
||
.detail-header { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 28px; flex-wrap: wrap; }
|
||
.detail-title { font-family: 'Unbounded', sans-serif; font-size: 1.2rem; font-weight: 800; }
|
||
.detail-sub { font-size: 0.84rem; color: var(--text-3); margin-top: 4px; }
|
||
.detail-actions { display: flex; gap: 8px; margin-left: auto; flex-wrap: wrap; }
|
||
|
||
.tabs { display: flex; gap: 4px; margin-bottom: 24px; background: rgba(15,23,42,0.05); border-radius: var(--r-pill); padding: 4px; width: fit-content; }
|
||
.tab-btn { padding: 7px 20px; border: none; border-radius: var(--r-pill); background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 600; color: var(--text-3); cursor: pointer; transition: all var(--tr); }
|
||
.tab-btn.active { background: #fff; color: var(--text); box-shadow: 0 2px 8px rgba(15,23,42,0.08); }
|
||
.tab-pane { display: none; }
|
||
.tab-pane.active { display: block; }
|
||
|
||
/* ── Members table ── */
|
||
.table-wrap { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; overflow: hidden; margin-bottom: 20px; }
|
||
table { width: 100%; border-collapse: collapse; }
|
||
th { padding: 10px 14px; text-align: left; font-size: 0.72rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.06em; border-bottom: 1px solid var(--border); background: rgba(238,242,255,0.5); }
|
||
td { padding: 11px 14px; font-size: 0.83rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||
tr:last-child td { border-bottom: none; }
|
||
.pct-cell { font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 700; }
|
||
.pct-hi { color: var(--green); } .pct-mid { color: var(--amber); } .pct-lo { color: var(--pink); }
|
||
|
||
/* ── Assignments ── */
|
||
.assign-list { display: flex; flex-direction: column; gap: 12px; }
|
||
.assign-item { border: 1px solid var(--border); border-radius: 14px; padding: 16px 20px; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; transition: border-color var(--tr); }
|
||
.assign-item:hover { border-color: var(--border-h); }
|
||
.assign-title { font-size: 0.9rem; font-weight: 700; flex: 1; }
|
||
.assign-meta { font-size: 0.76rem; color: var(--text-3); margin-top: 3px; }
|
||
.assign-progress { text-align: right; }
|
||
.assign-pct { font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800; color: var(--violet); }
|
||
.assign-sub { font-size: 0.72rem; color: var(--text-3); }
|
||
.deadline-badge { display: inline-block; padding: 2px 9px; border-radius: var(--r-pill); font-size: 0.7rem; font-weight: 700; }
|
||
.deadline-ok { background: rgba(6,214,100,0.1); color: var(--green); }
|
||
.deadline-soon { background: rgba(255,179,71,0.12); color: var(--amber); }
|
||
.deadline-over { background: rgba(241,91,181,0.1); color: var(--pink); }
|
||
|
||
/* ── Student search ── */
|
||
.student-search-wrap { position: relative; flex: 1; max-width: 360px; }
|
||
.student-search-wrap .form-input { width: 100%; }
|
||
.student-dropdown { position: absolute; top: calc(100% + 4px); left: 0; right: 0; background: #fff; border: 1.5px solid var(--border-h); border-radius: 12px; box-shadow: 0 8px 32px rgba(15,23,42,0.12); max-height: 240px; overflow-y: auto; z-index: 50; display: none; }
|
||
.student-dropdown.open { display: block; }
|
||
.student-opt { padding: 10px 14px; cursor: pointer; font-size: 0.86rem; transition: background var(--tr); }
|
||
.student-opt:hover, .student-opt.focused { background: rgba(155,93,229,0.07); }
|
||
.student-opt-name { font-weight: 700; color: var(--text); }
|
||
.student-opt-email { font-size: 0.76rem; color: var(--text-3); }
|
||
.student-opt-empty { padding: 12px 14px; color: var(--text-3); font-size: 0.84rem; text-align: center; }
|
||
|
||
/* ── Assignment type toggle ── */
|
||
.atype-tabs { display: flex; gap: 0; background: rgba(15,23,42,0.05); border-radius: var(--r-pill); padding: 3px; margin-bottom: 20px; flex-wrap: wrap; }
|
||
.atype-tab { flex: 1 1 auto; padding: 7px 10px; border: none; border-radius: var(--r-pill); background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.78rem; font-weight: 600; color: var(--text-3); cursor: pointer; transition: all var(--tr); text-align: center; white-space: nowrap; }
|
||
.atype-tab.active { background: #fff; color: var(--text); box-shadow: 0 2px 8px rgba(15,23,42,0.08); }
|
||
|
||
/* ── File picker ── */
|
||
.file-picker-search { width: 100%; }
|
||
.file-picker-list { max-height: 200px; overflow-y: auto; border: 1.5px solid var(--border-h); border-radius: 10px; margin-top: 8px; }
|
||
.file-pick-item { padding: 10px 14px; cursor: pointer; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; transition: background var(--tr); }
|
||
.file-pick-item:last-child { border-bottom: none; }
|
||
.file-pick-item:hover { background: rgba(155,93,229,0.05); }
|
||
.file-pick-item.selected { background: rgba(155,93,229,0.10); }
|
||
.file-pick-name { font-size: 0.86rem; font-weight: 600; flex: 1; }
|
||
.file-pick-subj { font-size: 0.74rem; color: var(--text-3); }
|
||
.file-pick-check { color: var(--violet); font-size: 1rem; }
|
||
|
||
/* ── Modal ── */
|
||
.modal-overlay { position: fixed; inset: 0; background: rgba(15,23,42,0.35); backdrop-filter: blur(4px); z-index: 200; display: none; align-items: center; justify-content: center; padding: 20px; }
|
||
.modal-overlay.open { display: flex; }
|
||
.modal { background: #fff; border-radius: var(--r-lg); padding: 32px; width: 100%; max-width: 520px; max-height: 90vh; overflow-y: auto; box-shadow: 0 24px 80px rgba(15,23,42,0.2); }
|
||
.modal-title { font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800; margin-bottom: 24px; }
|
||
.form-group { margin-bottom: 16px; }
|
||
.form-label { display: block; font-size: 0.76rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; }
|
||
.form-input, .form-textarea, .form-select { width: 100%; padding: 10px 14px; border: 1.5px solid var(--border-h); border-radius: 10px; font-family: 'Manrope', sans-serif; font-size: 0.88rem; color: var(--text); background: #fafbff; transition: border-color var(--tr); }
|
||
.form-input:focus, .form-textarea:focus, .form-select:focus { outline: none; border-color: var(--violet); }
|
||
.btn-cancel { padding: 10px 22px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 600; color: var(--text-3); cursor: pointer; }
|
||
.btn-save { padding: 10px 28px; border: none; border-radius: var(--r-pill); background: var(--grad-1); color: #fff; font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 700; cursor: pointer; }
|
||
.btn-save:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
|
||
.spinner { width: 32px; height: 32px; border: 3px solid var(--border); border-top-color: var(--violet); border-radius: 50%; animation: spin 0.8s linear infinite; margin: 30px auto; }
|
||
|
||
/* ── announcements ── */
|
||
.ann-item { border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; margin-bottom: 10px; display: flex; gap: 12px; }
|
||
.ann-body { flex: 1; }
|
||
.ann-author { font-size: 0.78rem; font-weight: 700; color: var(--violet); }
|
||
.ann-text { font-size: 0.87rem; margin-top: 4px; white-space: pre-wrap; word-break: break-word; line-height: 1.5; }
|
||
.ann-date { font-size: 0.72rem; color: var(--text-3); margin-top: 6px; }
|
||
.ann-del { background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 1rem; padding: 0; transition: color var(--tr); }
|
||
.ann-del:hover { color: var(--pink); }
|
||
|
||
/* ── notification bell ── */
|
||
.notif-bell-wrap { position: relative; }
|
||
.notif-badge { position: absolute; top: -4px; right: -4px; min-width: 18px; height: 18px; padding: 0 4px; background: var(--pink); color: #fff; border-radius: 99px; font-size: 0.65rem; font-weight: 700; display: flex; align-items: center; justify-content: center; }
|
||
.notif-drop-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 14px 8px; border-bottom: 1px solid var(--border); }
|
||
.notif-drop-title { font-family: 'Unbounded', sans-serif; font-size: 0.75rem; font-weight: 800; }
|
||
.notif-read-all { background: none; border: none; font-size: 0.72rem; color: var(--violet); cursor: pointer; font-family: 'Manrope', sans-serif; font-weight: 600; }
|
||
.notif-item { display: flex; gap: 8px; padding: 10px 14px; border-bottom: 1px solid var(--border); cursor: pointer; text-decoration: none; color: inherit; transition: background var(--tr); }
|
||
.notif-item:last-child { border-bottom: none; }
|
||
.notif-item:hover { background: rgba(155,93,229,0.04); }
|
||
.notif-item.unread { background: rgba(155,93,229,0.06); }
|
||
.notif-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--violet); flex-shrink: 0; margin-top: 5px; }
|
||
.notif-dot.read { background: transparent; border: 1.5px solid var(--border-h); }
|
||
.notif-msg { font-size: 0.79rem; line-height: 1.4; flex: 1; }
|
||
.notif-time { font-size: 0.68rem; color: var(--text-3); margin-top: 2px; }
|
||
.notif-empty { padding: 24px 14px; text-align: center; color: var(--text-3); font-size: 0.82rem; }
|
||
.empty { text-align: center; padding: 30px; color: var(--text-3); font-size: 0.88rem; }
|
||
.error { color: var(--pink); font-size: 0.85rem; padding: 8px 0; }
|
||
.toast { position: fixed; bottom: 28px; left: 50%; transform: translateX(-50%); background: #0F172A; color: #fff; padding: 10px 24px; border-radius: var(--r-pill); font-size: 0.85rem; font-weight: 600; z-index: 999; opacity: 0; transition: opacity 0.3s; pointer-events: none; }
|
||
.toast.show { opacity: 1; }
|
||
|
||
/* ── Teacher dashboard ── */
|
||
.dash-stat-row { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 24px; }
|
||
.dash-stat-card { flex: 1; min-width: 120px; background: rgba(155,93,229,0.06); border: 1.5px solid rgba(155,93,229,0.14); border-radius: 14px; padding: 16px 20px; }
|
||
.dash-stat-val { font-family: 'Unbounded', sans-serif; font-size: 1.5rem; font-weight: 900; color: var(--violet); }
|
||
.dash-stat-lbl { font-size: 0.75rem; color: var(--text-3); font-weight: 600; margin-top: 4px; }
|
||
.dash-assign-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); }
|
||
.dash-assign-row:last-child { border-bottom: none; }
|
||
.dash-assign-bar-wrap { flex: 1; height: 6px; background: rgba(15,23,42,0.08); border-radius: 99px; overflow: hidden; min-width: 80px; }
|
||
.dash-assign-bar { height: 100%; border-radius: 99px; background: linear-gradient(90deg,#06D6E0,#9B5DE5); transition: width .5s ease; }
|
||
.dash-lagging { background: rgba(241,91,181,0.07); border: 1.5px solid rgba(241,91,181,0.18); border-radius: 12px; padding: 10px 14px; font-size: 0.82rem; color: var(--pink); margin-top: 4px; display: inline-block; }
|
||
|
||
/* ── Journal / grade matrix ── */
|
||
.journal-wrap { overflow-x: auto; }
|
||
.journal-table { border-collapse: collapse; min-width: 100%; }
|
||
.journal-table th { padding: 8px 12px; font-size: 0.7rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.06em; border-bottom: 1.5px solid var(--border); background: rgba(238,242,255,0.5); white-space: nowrap; }
|
||
.journal-table td { padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.82rem; white-space: nowrap; text-align: center; }
|
||
.journal-table td:first-child { text-align: left; font-weight: 600; color: var(--text); }
|
||
.journal-table tr:last-child td { border-bottom: none; }
|
||
.j-cell-hi { background: rgba(6,214,100,0.12); color: #059669; font-family: 'Unbounded',sans-serif; font-size: 0.75rem; font-weight: 700; border-radius: 6px; padding: 3px 7px; }
|
||
.j-cell-mid { background: rgba(255,179,71,0.14); color: #b45309; font-family: 'Unbounded',sans-serif; font-size: 0.75rem; font-weight: 700; border-radius: 6px; padding: 3px 7px; }
|
||
.j-cell-lo { background: rgba(241,91,181,0.12); color: #be185d; font-family: 'Unbounded',sans-serif; font-size: 0.75rem; font-weight: 700; border-radius: 6px; padding: 3px 7px; }
|
||
.j-cell-none { color: var(--text-3); font-size: 0.76rem; }
|
||
|
||
/* ── Settings tab ── */
|
||
.settings-section { background: #fff; border: 1.5px solid var(--border); border-radius: 14px; padding: 20px 24px; margin-bottom: 16px; }
|
||
.settings-section-title { font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800; margin-bottom: 16px; color: var(--text); }
|
||
.settings-danger { border-color: rgba(241,91,181,0.3); background: rgba(241,91,181,0.03); }
|
||
.settings-danger .settings-section-title { color: #be185d; }
|
||
.invite-regen-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||
.invite-code-display { font-family: monospace; font-size: 1.1rem; font-weight: 700; color: var(--violet); background: rgba(155,93,229,0.08); border: 1.5px dashed rgba(155,93,229,0.3); border-radius: 10px; padding: 8px 18px; letter-spacing: 0.08em; }
|
||
|
||
/* results overlay */
|
||
.results-overlay { position: fixed; inset: 0; background: rgba(15,23,42,0.45); backdrop-filter: blur(6px); z-index: 300; display: none; align-items: center; justify-content: center; padding: 20px; }
|
||
.results-overlay.open { display: flex; }
|
||
.results-box { background: #fff; border-radius: var(--r-lg); padding: 28px 32px 32px; width: 100%; max-width: 740px; max-height: 90vh; overflow-y: auto; box-shadow: 0 24px 80px rgba(15,23,42,0.2); }
|
||
|
||
/* overlay header */
|
||
.res-header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
|
||
.res-header-title { font-family: 'Unbounded',sans-serif; font-weight: 800; font-size: 1rem; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.res-csv-btn { display: inline-flex; align-items: center; gap: 5px; padding: 6px 14px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); background: transparent; font-family: 'Manrope',sans-serif; font-size: 0.78rem; font-weight: 600; color: var(--text-2); cursor: pointer; transition: all var(--tr); white-space: nowrap; }
|
||
.res-csv-btn:hover { border-color: var(--violet); color: var(--violet); }
|
||
.res-close-btn { padding: 6px 14px; border: 1.5px solid rgba(241,91,181,0.3); border-radius: var(--r-pill); background: transparent; font-family: 'Manrope',sans-serif; font-size: 0.78rem; font-weight: 600; color: #c0306a; cursor: pointer; transition: all var(--tr); white-space: nowrap; }
|
||
.res-close-btn:hover { border-color: var(--pink); background: rgba(241,91,181,0.06); }
|
||
|
||
/* overlay tabs */
|
||
.res-tabs { display: flex; gap: 4px; background: rgba(15,23,42,0.05); border-radius: var(--r-pill); padding: 4px; margin-bottom: 20px; width: fit-content; }
|
||
.res-tab-btn { padding: 6px 18px; border: none; border-radius: var(--r-pill); background: transparent; font-family: 'Manrope',sans-serif; font-size: 0.82rem; font-weight: 600; color: var(--text-3); cursor: pointer; transition: all var(--tr); }
|
||
.res-tab-btn.active { background: #fff; color: var(--text); box-shadow: 0 2px 8px rgba(15,23,42,0.08); }
|
||
|
||
/* summary stats chips */
|
||
.res-stat-row { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
|
||
.res-stat-chip { flex: 1; min-width: 90px; background: rgba(155,93,229,0.06); border: 1.5px solid rgba(155,93,229,0.13); border-radius: 12px; padding: 12px 16px; text-align: center; }
|
||
.res-stat-val { font-family: 'Unbounded',sans-serif; font-size: 1.3rem; font-weight: 900; color: var(--violet); }
|
||
.res-stat-lbl { font-size: 0.7rem; color: var(--text-3); font-weight: 600; margin-top: 3px; }
|
||
|
||
/* completion bar */
|
||
.res-prog-wrap { height: 6px; background: rgba(15,23,42,0.08); border-radius: 99px; overflow: hidden; margin-bottom: 20px; }
|
||
.res-prog-fill { height: 100%; background: linear-gradient(90deg,#06D6E0,#9B5DE5); border-radius: 99px; transition: width .5s ease; }
|
||
|
||
/* score distribution */
|
||
.res-distrib { margin-bottom: 20px; }
|
||
.res-distrib-title { font-size: 0.72rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing:.06em; margin-bottom: 10px; }
|
||
.res-distrib-bars { display: flex; gap: 6px; align-items: flex-end; height: 72px; }
|
||
.res-bar-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 4px; }
|
||
.res-bar-fill { width: 100%; border-radius: 6px 6px 0 0; min-height: 4px; transition: height .4s ease; }
|
||
.res-bar-lbl { font-size: 0.66rem; color: var(--text-3); font-weight: 600; text-align: center; line-height: 1.2; }
|
||
.res-bar-cnt { font-size: 0.72rem; font-weight: 700; color: var(--text); }
|
||
|
||
/* student list */
|
||
.res-row { display: flex; align-items: center; gap: 14px; padding: 10px 12px; border-radius: 10px; cursor: pointer; transition: background var(--tr); }
|
||
.res-row:hover { background: rgba(155,93,229,0.05); }
|
||
.res-row + .res-row { margin-top: 2px; }
|
||
.res-rank { font-family: 'Unbounded', sans-serif; font-size: 0.85rem; font-weight: 800; min-width: 28px; color: var(--text-3); }
|
||
.res-name { flex: 1; }
|
||
.res-pct { font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800; }
|
||
.res-score { font-size: 0.8rem; color: var(--text-3); }
|
||
.res-pending { color: var(--text-3); font-style: italic; font-size: 0.82rem; }
|
||
|
||
/* question stats tab */
|
||
.qs-item { display: flex; align-items: center; gap: 12px; padding: 10px 14px; border-radius: 10px; margin-bottom: 6px; background: rgba(15,23,42,0.02); border: 1px solid var(--border); }
|
||
.qs-num { font-family: 'Unbounded',sans-serif; font-size: 0.72rem; font-weight: 800; color: var(--text-3); min-width: 20px; }
|
||
.qs-text { flex: 1; font-size: 0.82rem; font-weight: 500; line-height: 1.4; color: var(--text); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||
.qs-bar-wrap { width: 90px; height: 6px; background: rgba(15,23,42,0.08); border-radius: 99px; overflow: hidden; flex-shrink: 0; }
|
||
.qs-bar-fill { height: 100%; border-radius: 99px; }
|
||
.qs-pct { font-family: 'Unbounded',sans-serif; font-size: 0.78rem; font-weight: 800; min-width: 38px; text-align: right; flex-shrink: 0; }
|
||
.qs-cnt { font-size: 0.7rem; color: var(--text-3); min-width: 54px; text-align: right; flex-shrink: 0; }
|
||
|
||
/* drill-down review */
|
||
.res-drill { display: none; }
|
||
.res-drill.open { display: block; }
|
||
.res-drill-header { display: flex; align-items: center; gap: 10px; margin-bottom: 20px; }
|
||
.res-back-btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 16px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); background: transparent; font-family: 'Manrope',sans-serif; font-size: 0.82rem; font-weight: 600; color: var(--text-2); cursor: pointer; transition: all var(--tr); }
|
||
.res-back-btn:hover { border-color: var(--violet); color: var(--violet); }
|
||
.res-drill-name { font-family: 'Unbounded',sans-serif; font-size: 0.95rem; font-weight: 800; }
|
||
.res-drill-pct { font-family: 'Unbounded',sans-serif; font-size: 0.85rem; font-weight: 800; margin-left: auto; }
|
||
|
||
/* question review items */
|
||
.rq-item { border: 1.5px solid var(--border); border-radius: 12px; padding: 14px 16px; margin-bottom: 10px; }
|
||
.rq-item.correct { border-color: rgba(6,214,100,0.3); background: rgba(6,214,100,0.04); }
|
||
.rq-item.wrong { border-color: rgba(241,91,181,0.3); background: rgba(241,91,181,0.04); }
|
||
.rq-item.skipped { border-color: rgba(15,23,42,0.12); background: rgba(15,23,42,0.02); }
|
||
.rq-num { font-size: 0.68rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing:.05em; margin-bottom: 5px; }
|
||
.rq-text { font-size: 0.88rem; font-weight: 600; line-height: 1.45; margin-bottom: 10px; }
|
||
.rq-opts { display: flex; flex-direction: column; gap: 5px; }
|
||
.rq-opt { display: flex; align-items: center; gap: 8px; font-size: 0.82rem; padding: 5px 10px; border-radius: 8px; }
|
||
.rq-opt.chosen { background: rgba(241,91,181,0.1); color: #be185d; font-weight: 600; }
|
||
.rq-opt.answer { background: rgba(6,214,100,0.1); color: #059669; font-weight: 600; }
|
||
.rq-opt.both { background: rgba(6,214,100,0.12); color: #059669; font-weight: 700; }
|
||
.rq-icon { font-size: 0.9rem; flex-shrink: 0; }
|
||
.rq-expl { margin-top: 10px; font-size: 0.79rem; color: var(--text-3); background: rgba(155,93,229,0.05); border-radius: 8px; padding: 8px 12px; line-height: 1.5; }
|
||
|
||
/* ── Mobile responsive ── */
|
||
@media (max-width: 1000px) {
|
||
.cl-side { width: 240px; }
|
||
}
|
||
@media (max-width: 900px) {
|
||
.cl-side { width: 200px; }
|
||
.cl-chevron { display: none; }
|
||
}
|
||
@media (max-width: 768px) {
|
||
/* Split → stacked with toggle */
|
||
.sb-content.classes-split { flex-direction: column; height: auto; overflow: visible; }
|
||
.cl-side {
|
||
width: 100%; border-right: none; border-bottom: 1px solid var(--border);
|
||
max-height: none; overflow: visible;
|
||
}
|
||
.cl-side-header { padding: 14px 12px 10px; }
|
||
.cl-list {
|
||
display: flex; flex-direction: row; overflow-x: auto; overflow-y: hidden;
|
||
gap: 6px; padding: 8px 10px; -webkit-overflow-scrolling: touch;
|
||
scrollbar-width: none;
|
||
}
|
||
.cl-list::-webkit-scrollbar { display: none; }
|
||
.cl-item {
|
||
flex-shrink: 0; padding: 8px 12px; border-radius: 12px;
|
||
min-width: auto; gap: 8px;
|
||
}
|
||
.cl-avatar { width: 32px; height: 32px; border-radius: 10px; font-size: 0.7rem; }
|
||
.cl-avatar svg, .cl-avatar i { width: 16px !important; height: 16px !important; }
|
||
.cl-name { font-size: 0.78rem; max-width: 100px; }
|
||
.cl-meta { display: none; }
|
||
.cl-item-personal { padding: 8px 12px; }
|
||
.cl-item-personal .cl-avatar { width: 32px; height: 32px; }
|
||
.cl-personal-sep { display: none; }
|
||
|
||
.cl-main { min-height: 50vh; }
|
||
.cl-detail-wrap { padding: 16px 12px 40px; }
|
||
.cl-placeholder { padding: 40px 20px; }
|
||
|
||
/* Detail panel */
|
||
.detail-panel { padding: 16px 14px; border-radius: 14px; }
|
||
.detail-header { gap: 10px; flex-direction: column; }
|
||
.detail-title { font-size: 0.95rem; }
|
||
.detail-sub { font-size: 0.78rem; word-break: break-word; }
|
||
.detail-actions { margin-left: 0; width: 100%; justify-content: flex-start; }
|
||
.detail-actions .btn-ghost,
|
||
.detail-actions .invite-badge { font-size: 0.72rem; padding: 5px 10px; }
|
||
|
||
/* Tabs → scrollable */
|
||
.tabs {
|
||
width: 100%; overflow-x: auto; flex-wrap: nowrap;
|
||
scrollbar-width: none; -webkit-overflow-scrolling: touch;
|
||
}
|
||
.tabs::-webkit-scrollbar { display: none; }
|
||
.tab-btn { padding: 6px 14px; font-size: 0.76rem; white-space: nowrap; flex-shrink: 0; }
|
||
|
||
/* Tables */
|
||
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||
table { min-width: 500px; }
|
||
th, td { padding: 8px 10px; font-size: 0.76rem; }
|
||
|
||
/* Assignments */
|
||
.assign-item { padding: 12px 14px; gap: 10px; }
|
||
.assign-title { font-size: 0.82rem; }
|
||
.assign-item .btn-ghost { font-size: 0.72rem; padding: 5px 10px; }
|
||
|
||
/* Modals */
|
||
.modal { max-width: calc(100vw - 24px); margin: 0 12px; border-radius: 18px; }
|
||
.modal-title { font-size: 0.95rem; }
|
||
|
||
/* Icon picker */
|
||
.icon-picker { grid-template-columns: repeat(auto-fill, 36px); max-height: 140px; }
|
||
.ip-btn { width: 36px; height: 36px; }
|
||
.ip-btn svg { width: 16px; height: 16px; }
|
||
.ip-preview { width: 44px; height: 44px; }
|
||
.ip-preview svg { width: 22px; height: 22px; }
|
||
|
||
/* Assignment type tabs */
|
||
.atype-tabs { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; }
|
||
.atype-tabs::-webkit-scrollbar { display: none; }
|
||
.atype-tab { font-size: 0.72rem; padding: 6px 8px; white-space: nowrap; flex-shrink: 0; }
|
||
|
||
/* Works cards */
|
||
.work-card { flex-wrap: wrap; gap: 8px; }
|
||
.work-meta { width: 100%; display: flex; align-items: center; gap: 8px; justify-content: flex-start; }
|
||
|
||
/* Settings */
|
||
.settings-section { padding: 14px 0; }
|
||
|
||
/* Announcements */
|
||
.announcement-card { padding: 14px; }
|
||
|
||
/* Journal table */
|
||
.journal-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||
.journal-table { min-width: 500px; }
|
||
}
|
||
|
||
@media (max-width: 480px) {
|
||
.cl-detail-wrap { padding: 12px 8px 30px; }
|
||
.detail-panel { padding: 12px 10px; }
|
||
.detail-title { font-size: 0.88rem; }
|
||
.tabs { gap: 2px; padding: 3px; }
|
||
.tab-btn { padding: 5px 10px; font-size: 0.72rem; }
|
||
.modal { padding: 18px 14px; }
|
||
}
|
||
|
||
/* ── Submissions (works tab) ── */
|
||
.works-filters { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 18px; align-items: center; }
|
||
.works-chip {
|
||
padding: 4px 14px; border-radius: 99px; border: 1.5px solid rgba(15,23,42,0.1);
|
||
background: #fff; color: var(--text-3);
|
||
font-family: 'Manrope', sans-serif; font-size: 0.74rem; font-weight: 700;
|
||
cursor: pointer; transition: all 0.15s;
|
||
}
|
||
.works-chip.active { background: #0F172A; color: #fff; border-color: #0F172A; }
|
||
.works-chip:hover:not(.active) { border-color: rgba(155,93,229,0.35); color: var(--violet); }
|
||
|
||
.work-card {
|
||
background: #fff; border: 1.5px solid rgba(15,23,42,0.08);
|
||
border-left: 4px solid var(--wc, #9B5DE5);
|
||
border-radius: 14px; padding: 14px 16px;
|
||
display: flex; align-items: flex-start; gap: 14px;
|
||
cursor: pointer; transition: all 0.18s;
|
||
box-shadow: 0 1px 4px rgba(15,23,42,0.04);
|
||
margin-bottom: 8px;
|
||
}
|
||
.work-card:hover { box-shadow: 0 6px 24px rgba(15,23,42,0.1); border-color: rgba(15,23,42,0.14); }
|
||
.work-card.reviewed { --wc: #059652; }
|
||
.work-card.new { --wc: #06D6E0; }
|
||
.work-icon {
|
||
width: 36px; height: 36px; border-radius: 10px; flex-shrink: 0;
|
||
background: rgba(155,93,229,0.08); color: var(--violet);
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.work-icon svg { width: 18px; height: 18px; }
|
||
.work-body { flex: 1; min-width: 0; }
|
||
.work-student { font-size: 0.88rem; font-weight: 700; color: #0F172A; margin-bottom: 2px; }
|
||
.work-assign { font-size: 0.76rem; color: var(--violet); font-weight: 600; margin-bottom: 3px; }
|
||
.work-file { font-size: 0.74rem; color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.work-msg { font-size: 0.76rem; color: #3D4F6B; margin-top: 5px; line-height: 1.4; font-style: italic; }
|
||
.work-note { font-size: 0.74rem; color: #059652; margin-top: 4px; font-style: italic; }
|
||
.work-meta { font-size: 0.70rem; color: var(--text-3); white-space: nowrap; flex-shrink: 0; text-align: right; }
|
||
.work-status {
|
||
display: inline-flex; align-items: center; gap: 4px;
|
||
padding: 2px 9px; border-radius: 99px; font-size: 0.70rem; font-weight: 700;
|
||
margin-bottom: 6px;
|
||
}
|
||
.work-status.new { background: rgba(6,214,224,0.1); color: #06aab3; }
|
||
.work-status.reviewed { background: rgba(5,150,82,0.1); color: #059652; }
|
||
.work-grade {
|
||
display: inline-flex; align-items: center; gap: 4px;
|
||
padding: 2px 9px; border-radius: 99px; font-size: 0.72rem; font-weight: 800;
|
||
background: rgba(155,93,229,0.1); color: var(--violet);
|
||
margin-left: 6px;
|
||
}
|
||
.work-grade.high { background: rgba(5,150,82,0.12); color: #059652; }
|
||
.work-grade.mid { background: rgba(255,193,7,0.14); color: #c07c00; }
|
||
.work-grade.low { background: rgba(241,91,181,0.12); color: #c0306a; }
|
||
.work-del-btn {
|
||
margin-top: 6px; border: none; background: transparent; color: #c0306a;
|
||
cursor: pointer; opacity: 0.4; transition: opacity 0.15s; padding: 4px;
|
||
border-radius: 6px; display: flex; align-items: center; justify-content: center;
|
||
margin-left: auto;
|
||
}
|
||
.work-del-btn:hover { opacity: 1; background: rgba(241,91,181,0.1); }
|
||
|
||
/* grade input row */
|
||
.grade-input-row {
|
||
display: flex; align-items: center; gap: 10px; margin-bottom: 14px;
|
||
}
|
||
.grade-input-row label { font-size: 0.8rem; font-weight: 600; color: #3D4F6B; white-space: nowrap; }
|
||
.grade-input {
|
||
width: 80px; padding: 6px 10px; border: 1.5px solid rgba(15,23,42,0.15); border-radius: 8px;
|
||
font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 700; color: #0F172A;
|
||
background: #fff; text-align: center;
|
||
}
|
||
.grade-input:focus { outline: none; border-color: var(--violet); }
|
||
.grade-letter {
|
||
font-size: 1.1rem; font-weight: 900;
|
||
width: 34px; height: 34px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
|
||
background: rgba(155,93,229,0.08); color: var(--violet); flex-shrink: 0;
|
||
}
|
||
|
||
/* review modal */
|
||
#review-modal .modal { max-width: 480px; }
|
||
.review-file-link {
|
||
display: inline-flex; align-items: center; gap: 6px;
|
||
padding: 7px 14px; border-radius: 10px; font-size: 0.82rem; font-weight: 600;
|
||
background: rgba(155,93,229,0.07); color: var(--violet); text-decoration: none;
|
||
border: 1.5px solid rgba(155,93,229,0.2); transition: background 0.15s;
|
||
}
|
||
.review-file-link:hover { background: rgba(155,93,229,0.14); }
|
||
|
||
/* ── Icon picker ── */
|
||
.icon-picker-label { font-size: 0.78rem; font-weight: 600; color: var(--text-3); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||
.icon-picker {
|
||
display: grid; grid-template-columns: repeat(auto-fill, 42px); gap: 4px;
|
||
max-height: 180px; overflow-y: auto; padding: 8px;
|
||
background: rgba(15,23,42,0.02); border: 1.5px solid rgba(15,23,42,0.08);
|
||
border-radius: 12px;
|
||
}
|
||
.icon-picker::-webkit-scrollbar { width: 5px; }
|
||
.icon-picker::-webkit-scrollbar-thumb { background: rgba(15,23,42,0.12); border-radius: 99px; }
|
||
.ip-btn {
|
||
width: 42px; height: 42px; border: 2px solid transparent; border-radius: 10px;
|
||
background: #fff; cursor: pointer; display: flex;
|
||
align-items: center; justify-content: center; transition: all 0.12s;
|
||
color: #3D4F6B;
|
||
}
|
||
.ip-btn svg { width: 20px; height: 20px; }
|
||
.ip-btn:hover { border-color: rgba(155,93,229,0.3); transform: scale(1.1); color: var(--violet); }
|
||
.ip-btn.selected { border-color: var(--violet); background: rgba(155,93,229,0.1); color: var(--violet); box-shadow: 0 0 0 2px rgba(155,93,229,0.15); }
|
||
.ip-preview {
|
||
width: 52px; height: 52px; border-radius: 14px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
background: rgba(155,93,229,0.08); border: 2px solid rgba(155,93,229,0.15);
|
||
flex-shrink: 0; color: var(--violet);
|
||
}
|
||
.ip-preview svg { width: 28px; height: 28px; }
|
||
.ip-row { display: flex; align-items: center; gap: 14px; margin-bottom: 14px; }
|
||
.color-picker { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
|
||
.cp-btn {
|
||
width: 26px; height: 26px; border-radius: 50%; border: 2.5px solid transparent;
|
||
cursor: pointer; transition: all 0.12s; flex-shrink: 0;
|
||
}
|
||
.cp-btn:hover { transform: scale(1.2); }
|
||
.cp-btn.selected { border-color: #0F172A; box-shadow: 0 0 0 2px #fff, 0 0 0 4px currentColor; }
|
||
|
||
/* ── Personal assignments ── */
|
||
.cl-personal-sep { height: 1px; background: var(--border); margin: 6px 10px 8px; }
|
||
.cl-item-personal .cl-avatar {
|
||
background: linear-gradient(135deg, #F15BB5, #FF9F1C);
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.pa-item {
|
||
display: flex; align-items: flex-start; gap: 14px;
|
||
padding: 14px 16px; border: 1.5px solid rgba(15,23,42,0.08);
|
||
border-radius: 12px; margin-bottom: 8px; background: #fff;
|
||
transition: border-color var(--tr), box-shadow var(--tr);
|
||
}
|
||
.pa-item:hover { border-color: rgba(241,91,181,0.25); box-shadow: 0 2px 12px rgba(15,23,42,0.05); }
|
||
.pa-student {
|
||
display: inline-flex; align-items: center; gap: 4px;
|
||
padding: 2px 10px; border-radius: 999px;
|
||
background: rgba(241,91,181,0.10); color: #9b3c7e;
|
||
font-size: 0.72rem; font-weight: 700;
|
||
}
|
||
.pa-status-done {
|
||
display: inline-flex; align-items: center; gap: 4px;
|
||
padding: 2px 10px; border-radius: 999px;
|
||
background: rgba(6,214,100,0.10); color: #059669;
|
||
font-size: 0.72rem; font-weight: 700;
|
||
}
|
||
.pa-status-pending {
|
||
display: inline-flex; align-items: center; gap: 4px;
|
||
padding: 2px 10px; border-radius: 999px;
|
||
background: rgba(15,23,42,0.06); color: var(--text-3);
|
||
font-size: 0.72rem; font-weight: 700;
|
||
}
|
||
</style>
|
||
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||
</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 classes-split">
|
||
|
||
<!-- Left: class list sidebar -->
|
||
<div class="cl-side">
|
||
<div class="cl-side-header">
|
||
<span class="cl-side-title">Классы</span>
|
||
<a href="/gradebook" class="btn-new-cl" style="background:rgba(155,93,229,0.15);color:var(--violet)" title="Журнал оценок">
|
||
<i data-lucide="table"></i>
|
||
</a>
|
||
<button class="btn-new-cl" onclick="openCreateClass()" title="Создать класс">
|
||
<i data-lucide="plus"></i>
|
||
</button>
|
||
</div>
|
||
<div class="cl-item cl-item-personal" onclick="openPersonalAssignments()" id="cc-personal">
|
||
<div class="cl-avatar">
|
||
<svg class="ic" viewBox="0 0 24 24" style="width:18px;height:18px;stroke:#fff;stroke-width:2;fill:none"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||
</div>
|
||
<div class="cl-info">
|
||
<div class="cl-name">Личные задания</div>
|
||
<div class="cl-meta" id="pa-sidebar-count">—</div>
|
||
</div>
|
||
<i data-lucide="chevron-right" class="cl-chevron" style="width:15px;height:15px"></i>
|
||
</div>
|
||
<div class="cl-personal-sep"></div>
|
||
<div class="cl-list" id="classes-grid"><div class="spinner" style="margin:20px auto;width:24px;height:24px;border-width:2px"></div></div>
|
||
</div>
|
||
|
||
<!-- Right: class content -->
|
||
<div class="cl-main">
|
||
|
||
<!-- Empty state -->
|
||
<div class="cl-placeholder" id="cl-placeholder">
|
||
<div class="cl-placeholder-icon"><i data-lucide="graduation-cap"></i></div>
|
||
<div class="cl-placeholder-title">Выберите класс</div>
|
||
<div class="cl-placeholder-sub">Нажмите на класс слева или создайте новый</div>
|
||
<button class="btn-primary" onclick="openCreateClass()" style="margin-top:4px">+ Создать класс</button>
|
||
</div>
|
||
|
||
<!-- Personal Assignments Panel -->
|
||
<div id="personal-panel" style="display:none">
|
||
<div class="cl-detail-wrap">
|
||
<div class="detail-panel">
|
||
<div class="detail-header">
|
||
<div style="flex:1;min-width:0">
|
||
<div class="detail-title">Личные задания</div>
|
||
<div class="detail-sub">Задания, выданные напрямую конкретным ученикам</div>
|
||
</div>
|
||
</div>
|
||
<div id="personal-list" style="margin-top:20px"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Detail panel -->
|
||
<div id="detail-panel" style="display:none">
|
||
<div class="cl-detail-wrap">
|
||
<div class="detail-panel">
|
||
<div class="detail-header">
|
||
<div style="flex:1;min-width:0">
|
||
<div class="detail-title" id="d-name"></div>
|
||
<div class="detail-sub" id="d-sub"></div>
|
||
</div>
|
||
<div class="detail-actions">
|
||
<button class="btn-ghost" onclick="copyInvite()"><i data-lucide="link-2" style="width:13px;height:13px;vertical-align:-2px"></i> Ссылка</button>
|
||
<button class="btn-primary" onclick="openCreateAssignment()">+ Задание</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tabs">
|
||
<button class="tab-btn active" data-tab="dash" onclick="switchDetailTab(this)">Дашборд</button>
|
||
<button class="tab-btn" data-tab="members" onclick="switchDetailTab(this)">Ученики</button>
|
||
<button class="tab-btn" data-tab="assign" onclick="switchDetailTab(this)">Задания</button>
|
||
<button class="tab-btn" data-tab="journal" onclick="switchDetailTab(this)">Журнал</button>
|
||
<button class="tab-btn" data-tab="announce" onclick="switchDetailTab(this)">Объявления</button>
|
||
<button class="tab-btn" data-tab="works" onclick="switchDetailTab(this)"><i data-lucide="paperclip" style="width:13px;height:13px;vertical-align:-2px"></i> Работы</button>
|
||
<button class="tab-btn" data-tab="theory" onclick="switchDetailTab(this)"><i data-lucide="brain" style="width:13px;height:13px;vertical-align:-2px"></i> Теория</button>
|
||
<button class="tab-btn" data-tab="settings" onclick="switchDetailTab(this)"><i data-lucide="settings" style="width:13px;height:13px;vertical-align:-2px"></i> Настройки</button>
|
||
</div>
|
||
|
||
<!-- Dashboard -->
|
||
<div class="tab-pane active" id="dtab-dash">
|
||
<div id="dash-content"><div class="spinner"></div></div>
|
||
</div>
|
||
|
||
<!-- Members -->
|
||
<div class="tab-pane" id="dtab-members">
|
||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px;flex-wrap:wrap">
|
||
<div class="student-search-wrap">
|
||
<input class="form-input" id="add-member-search" placeholder="Поиск ученика по имени или email…" autocomplete="off"
|
||
oninput="filterStudents(this.value)" onfocus="openStudentDrop()" onblur="setTimeout(closeStudentDrop,180)" />
|
||
<div class="student-dropdown" id="student-drop"></div>
|
||
</div>
|
||
<button class="btn-ghost" onclick="doAddMember()" id="btn-add-member" disabled>+ Добавить</button>
|
||
<span id="add-member-err" style="font-size:0.82rem;color:var(--pink)"></span>
|
||
</div>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead><tr><th>Имя</th><th>Email</th><th>Тестов</th><th>Средний %</th><th>Вступил</th><th></th></tr></thead>
|
||
<tbody id="d-members"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Assignments -->
|
||
<div class="tab-pane" id="dtab-assign">
|
||
<div class="assign-list" id="d-assignments"></div>
|
||
</div>
|
||
|
||
<!-- Journal -->
|
||
<div class="tab-pane" id="dtab-journal">
|
||
<div id="journal-content"><div class="spinner"></div></div>
|
||
</div>
|
||
|
||
<!-- Works (student submissions) -->
|
||
<div class="tab-pane" id="dtab-works">
|
||
<div id="works-content"><div class="spinner"></div></div>
|
||
</div>
|
||
|
||
<!-- Theory -->
|
||
<div class="tab-pane" id="dtab-theory">
|
||
<div id="theory-content"><div class="spinner"></div></div>
|
||
</div>
|
||
|
||
<!-- Settings -->
|
||
<div class="tab-pane" id="dtab-settings">
|
||
<div class="settings-section">
|
||
<div class="settings-section-title">Основная информация</div>
|
||
<div class="ip-row">
|
||
<div class="ip-preview" id="set-icon-preview"><i data-lucide="book-open"></i></div>
|
||
<div style="flex:1">
|
||
<div class="form-group" style="margin-bottom:0">
|
||
<label class="form-label">Название класса</label>
|
||
<input class="form-input" id="set-name" placeholder="11А · Биология" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<div class="icon-picker-label">Иконка класса</div>
|
||
<div class="icon-picker" id="set-icon-picker"></div>
|
||
<div class="icon-picker-label" style="margin-top:10px">Цвет</div>
|
||
<div class="color-picker" id="set-color-picker"></div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Описание</label>
|
||
<textarea class="form-textarea" id="set-desc" rows="2" placeholder="Подготовка к ЦТ 2026"></textarea>
|
||
</div>
|
||
<button class="btn-primary" onclick="saveClassSettings()">Сохранить изменения</button>
|
||
</div>
|
||
<div class="settings-section">
|
||
<div class="settings-section-title">Код приглашения</div>
|
||
<div class="invite-regen-row">
|
||
<div class="invite-code-display" id="set-invite-code">—</div>
|
||
<button class="btn-ghost" onclick="copyInvite()"><i data-lucide="link-2" style="width:13px;height:13px;vertical-align:-2px"></i> Скопировать ссылку</button>
|
||
<button class="btn-ghost" onclick="doRegenerateCode()"><i data-lucide="refresh-cw" style="width:13px;height:13px;vertical-align:-2px"></i> Обновить код</button>
|
||
</div>
|
||
<div style="margin-top:10px;font-size:0.77rem;color:var(--text-3);line-height:1.5">
|
||
После обновления старый код перестанет работать. Ученики, которые уже вступили, останутся в классе.
|
||
</div>
|
||
</div>
|
||
<div class="settings-section" id="set-modules">
|
||
<div class="settings-section-title">Модули класса</div>
|
||
<p style="font-size:0.82rem;color:var(--text-2);margin-bottom:14px;line-height:1.5">Отключённые модули скроются у учеников этого класса.</p>
|
||
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:16px">
|
||
<label style="display:flex;align-items:center;gap:10px;cursor:pointer;font-size:0.85rem;font-weight:600;color:var(--text-1)">
|
||
<input type="checkbox" id="feat-gamification" style="width:16px;height:16px;accent-color:var(--violet);cursor:pointer">
|
||
<svg class="ic" viewBox="0 0 24 24"><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2z"/></svg> Геймификация (XP, уровни, рейтинг)
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:10px;cursor:pointer;font-size:0.85rem;font-weight:600;color:var(--text-1)">
|
||
<input type="checkbox" id="feat-collection" style="width:16px;height:16px;accent-color:var(--violet);cursor:pointer">
|
||
🃏 Коллекция
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:10px;cursor:pointer;font-size:0.85rem;font-weight:600;color:var(--text-1)">
|
||
<input type="checkbox" id="feat-hangman" style="width:16px;height:16px;accent-color:var(--violet);cursor:pointer">
|
||
<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg> Виселица
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:10px;cursor:pointer;font-size:0.85rem;font-weight:600;color:var(--text-1)">
|
||
<input type="checkbox" id="feat-crossword" style="width:16px;height:16px;accent-color:var(--violet);cursor:pointer">
|
||
<svg class="ic" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg> Кроссворд
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:10px;cursor:pointer;font-size:0.85rem;font-weight:600;color:var(--text-1)">
|
||
<input type="checkbox" id="feat-red_book" style="width:16px;height:16px;accent-color:var(--violet);cursor:pointer">
|
||
<svg class="ic" viewBox="0 0 24 24"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/></svg> Красная книга
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:10px;cursor:pointer;font-size:0.85rem;font-weight:600;color:var(--text-1)">
|
||
<input type="checkbox" id="feat-pet" style="width:16px;height:16px;accent-color:var(--violet);cursor:pointer">
|
||
<svg class="ic" viewBox="0 0 24 24"><circle cx="11" cy="4" r="2"/><circle cx="18" cy="8" r="2"/><circle cx="20" cy="16" r="2"/><path d="M9 10a5 5 0 0 1 5 5v3.5a3.5 3.5 0 0 1-6.84 1.045Q6.52 17.48 4.46 16.84A3.5 3.5 0 0 1 5.5 10Z"/></svg> Питомец
|
||
</label>
|
||
</div>
|
||
<button class="btn-primary" onclick="saveModules()">Сохранить модули</button>
|
||
</div>
|
||
<div class="settings-section settings-danger">
|
||
<div class="settings-section-title">Опасная зона</div>
|
||
<p style="font-size:0.83rem;color:var(--text-2);margin-bottom:14px;line-height:1.5">Удаление класса необратимо. Все задания, участники и объявления будут удалены.</p>
|
||
<button class="btn-delete-class" onclick="confirmDeleteClass()"><i data-lucide="triangle-alert" style="width:13px;height:13px;vertical-align:-2px"></i> Удалить класс</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Announcements -->
|
||
<div class="tab-pane" id="dtab-announce">
|
||
<div style="display:flex;gap:10px;margin-bottom:20px;align-items:flex-end">
|
||
<textarea class="form-textarea" id="announce-text" rows="2" placeholder="Текст объявления…" style="flex:1;resize:vertical"></textarea>
|
||
<button class="btn-save" id="announce-send-btn" onclick="sendAnnouncement()" style="white-space:nowrap">Отправить</button>
|
||
</div>
|
||
<div id="announce-list"><div class="spinner"></div></div>
|
||
</div>
|
||
</div><!-- /detail-panel (.detail-panel card) -->
|
||
</div><!-- /cl-detail-wrap -->
|
||
</div><!-- /#detail-panel -->
|
||
|
||
</div><!-- /cl-main -->
|
||
</div><!-- /sb-content -->
|
||
|
||
<!-- Modal: Create class -->
|
||
<div class="modal-overlay" id="modal-class" onclick="closeOnOverlay(event,'modal-class')">
|
||
<div class="modal">
|
||
<div class="modal-title">Создать класс</div>
|
||
<div class="ip-row">
|
||
<div class="ip-preview" id="c-icon-preview"><i data-lucide="book-open"></i></div>
|
||
<div style="flex:1">
|
||
<div class="form-group" style="margin-bottom:0">
|
||
<label class="form-label">Название класса</label>
|
||
<input class="form-input" id="c-name" placeholder="11А · Биология" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<div class="icon-picker-label">Иконка класса</div>
|
||
<div class="icon-picker" id="c-icon-picker"></div>
|
||
<div class="icon-picker-label" style="margin-top:10px">Цвет</div>
|
||
<div class="color-picker" id="c-color-picker"></div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Описание (необязательно)</label>
|
||
<textarea class="form-textarea" id="c-desc" rows="2" placeholder="Подготовка к ЦТ 2026"></textarea>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn-cancel" onclick="closeModal('modal-class')">Отмена</button>
|
||
<button class="btn-save" id="btn-save-class" onclick="saveClass()">Создать</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modal: Create assignment -->
|
||
<div class="modal-overlay" id="modal-assign" onclick="closeOnOverlay(event,'modal-assign')">
|
||
<div class="modal">
|
||
<div class="modal-title" id="modal-assign-title">Новое задание</div>
|
||
|
||
<!-- Шаблоны -->
|
||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:18px">
|
||
<select class="form-select" id="tpl-select" style="flex:1;font-size:0.82rem">
|
||
<option value="">— Загрузить шаблон —</option>
|
||
</select>
|
||
<button class="btn-ghost" onclick="loadTemplateIntoForm()" style="padding:7px 14px;white-space:nowrap">Загрузить</button>
|
||
</div>
|
||
|
||
<!-- Тип контента: случайные / готовый тест / файл -->
|
||
<div class="atype-tabs">
|
||
<button class="atype-tab active" id="atype-random-btn" onclick="setAssignType('random')"><i data-lucide="shuffle" style="width:13px;height:13px;vertical-align:-2px"></i> Случайные</button>
|
||
<button class="atype-tab" id="atype-fixtest-btn" onclick="setAssignType('fixed_test')"><i data-lucide="clipboard-list" style="width:13px;height:13px;vertical-align:-2px"></i> Готовый тест</button>
|
||
<button class="atype-tab" id="atype-file-btn" onclick="setAssignType('file')"><i data-lucide="paperclip" style="width:13px;height:13px;vertical-align:-2px"></i> Файл</button>
|
||
<button class="atype-tab" id="atype-upload-btn" onclick="setAssignType('upload')"><i data-lucide="upload" style="width:13px;height:13px;vertical-align:-2px"></i> Сдать работу</button>
|
||
</div>
|
||
<div id="a-type-hint" style="font-size:0.76rem;color:var(--text-3);margin:-12px 0 16px;padding:0 4px;line-height:1.5">
|
||
Вопросы подбираются случайно из базы по выбранному предмету
|
||
</div>
|
||
|
||
<!-- Кому: класс / ученик / несколько классов + ДЗ-переключатель -->
|
||
<div style="display:flex;gap:10px;align-items:flex-start;margin-bottom:16px;flex-wrap:wrap">
|
||
<div class="form-group" style="flex:1;min-width:180px;margin-bottom:0">
|
||
<label class="form-label">Кому</label>
|
||
<select class="form-select" id="a-target" onchange="onTargetChange()">
|
||
<option value="class">Всему классу</option>
|
||
<option value="multi">Нескольким классам…</option>
|
||
</select>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:4px;padding-top:22px">
|
||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:0.86rem;font-weight:600;color:var(--text-2)">
|
||
<input type="checkbox" id="a-is-hw" onchange="onHwChange()" style="width:16px;height:16px;accent-color:var(--violet)">
|
||
<i data-lucide="book-open" style="width:15px;height:15px;vertical-align:-3px;color:var(--violet)"></i> Домашнее задание
|
||
</label>
|
||
<div id="a-hw-hint" style="display:none;font-size:0.72rem;color:var(--text-3);line-height:1.45;max-width:200px">
|
||
Доступно в любое время, без ограничений режима
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">Название</label>
|
||
<input class="form-input" id="a-title" placeholder="Домашнее задание №3" />
|
||
</div>
|
||
|
||
<!-- Поля для случайных вопросов -->
|
||
<div id="a-test-fields">
|
||
<div class="form-group">
|
||
<label class="form-label">Предмет</label>
|
||
<select class="form-select" id="a-subject">
|
||
<option value="bio">Биология</option>
|
||
<option value="chem">Химия</option>
|
||
<option value="math">Математика</option>
|
||
<option value="phys">Физика</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group" id="a-mode-group">
|
||
<label class="form-label">Режим</label>
|
||
<select class="form-select" id="a-mode">
|
||
<option value="exam">Экзамен</option>
|
||
<option value="practice">Тренировка</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Количество вопросов</label>
|
||
<select class="form-select" id="a-count">
|
||
<option value="10">10</option>
|
||
<option value="25" selected>25</option>
|
||
<option value="40">40</option>
|
||
<option value="60">60</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Поля для готового теста -->
|
||
<div id="a-fixtest-fields" style="display:none">
|
||
<div class="form-group">
|
||
<label class="form-label">Выбери тест из библиотеки</label>
|
||
<input class="form-input file-picker-search" id="a-test-search" placeholder="Поиск теста…" oninput="filterTestList(this.value)" />
|
||
<div class="file-picker-list" id="a-test-list"><div class="student-opt-empty">Загрузка…</div></div>
|
||
<div id="a-test-selected" style="display:none;margin-top:8px;font-size:0.84rem;color:var(--violet);font-weight:600"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Поля для файла -->
|
||
<div id="a-file-fields" style="display:none">
|
||
<div class="form-group">
|
||
<label class="form-label">Файл из библиотеки</label>
|
||
<input class="form-input file-picker-search" id="a-file-search" placeholder="Поиск файла…" oninput="filterFileList(this.value)" />
|
||
<div class="file-picker-list" id="a-file-list"><div class="student-opt-empty">Загрузка…</div></div>
|
||
<div id="a-file-selected" style="display:none;margin-top:8px;font-size:0.84rem;color:var(--violet);font-weight:600"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">Дедлайн <span style="font-weight:400;text-transform:none;font-size:0.72rem;color:var(--text-3)">(необязательно — если не задан, задание открыто бессрочно)</span></label>
|
||
<input class="form-input" id="a-deadline" type="datetime-local" />
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">Попытки <span style="font-weight:400;text-transform:none;font-size:0.72rem;color:var(--text-3)">(сколько раз ученик может пройти задание)</span></label>
|
||
<select class="form-select" id="a-max-attempts">
|
||
<option value="0">Без ограничений</option>
|
||
<option value="1">1 попытка</option>
|
||
<option value="2">2 попытки</option>
|
||
<option value="3">3 попытки</option>
|
||
<option value="5">5 попыток</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- Multi-class selector (shown when target=multi) -->
|
||
<div id="a-multi-classes" style="display:none">
|
||
<div class="form-label" style="margin-bottom:8px">Выберите классы</div>
|
||
<div id="a-multi-class-list" style="display:flex;flex-direction:column;gap:6px;max-height:160px;overflow-y:auto;border:1.5px solid var(--border);border-radius:12px;padding:10px 12px"></div>
|
||
</div>
|
||
|
||
<div class="modal-footer">
|
||
<button class="btn-ghost" onclick="promptSaveTemplate()" style="margin-right:auto;font-size:0.8rem"><i data-lucide="save" style="width:13px;height:13px;vertical-align:-2px"></i> Сохранить шаблон</button>
|
||
<button class="btn-cancel" onclick="closeModal('modal-assign')">Отмена</button>
|
||
<button class="btn-save" id="btn-save-assign" onclick="saveAssignment()">Создать</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Results overlay -->
|
||
<div class="results-overlay" id="results-overlay" onclick="closeOnOverlay(event,'results-overlay')">
|
||
<div class="results-box">
|
||
|
||
<!-- ── Header ── -->
|
||
<div class="res-header">
|
||
<div class="res-header-title" id="res-title">Результаты</div>
|
||
<button class="res-csv-btn" onclick="exportResultsCSV()" id="res-csv-btn" style="display:none">
|
||
<i data-lucide="download" style="width:13px;height:13px"></i> CSV
|
||
</button>
|
||
<button class="res-close-btn" onclick="closeModal('results-overlay')">Закрыть <svg class="ic" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||
</div>
|
||
|
||
<!-- ── Tabs ── -->
|
||
<div class="res-tabs" id="res-tabs" style="display:none">
|
||
<button class="res-tab-btn active" data-restab="students" onclick="switchResTab(this)">Ученики</button>
|
||
<button class="res-tab-btn" data-restab="questions" onclick="switchResTab(this)">По вопросам</button>
|
||
</div>
|
||
|
||
<!-- ── Tab: Students ── -->
|
||
<div id="restab-students">
|
||
<!-- stat chips -->
|
||
<div class="res-stat-row" id="res-stats" style="display:none">
|
||
<div class="res-stat-chip"><div class="res-stat-val" id="res-done-n">0</div><div class="res-stat-lbl">Сдали</div></div>
|
||
<div class="res-stat-chip"><div class="res-stat-val" id="res-total-n">0</div><div class="res-stat-lbl">Всего</div></div>
|
||
<div class="res-stat-chip"><div class="res-stat-val" id="res-avg-n">—</div><div class="res-stat-lbl">Средний %</div></div>
|
||
<div class="res-stat-chip"><div class="res-stat-val" id="res-best-n">—</div><div class="res-stat-lbl">Лучший %</div></div>
|
||
</div>
|
||
<!-- completion bar -->
|
||
<div class="res-prog-wrap" id="res-prog-wrap" style="display:none">
|
||
<div class="res-prog-fill" id="res-prog-bar" style="width:0%"></div>
|
||
</div>
|
||
<!-- score distribution -->
|
||
<div class="res-distrib" id="res-distrib" style="display:none">
|
||
<div class="res-distrib-title">Распределение баллов</div>
|
||
<div class="res-distrib-bars" id="res-distrib-bars"></div>
|
||
</div>
|
||
<div style="font-size:0.72rem;color:var(--text-3);margin-bottom:10px;padding:0 2px" id="res-click-hint" style="display:none">Нажмите на ученика, чтобы посмотреть его ответы</div>
|
||
<div id="res-list"></div>
|
||
</div>
|
||
|
||
<!-- ── Tab: Questions ── -->
|
||
<div id="restab-questions" style="display:none">
|
||
<div id="res-q-list"><div class="spinner"></div></div>
|
||
</div>
|
||
|
||
<!-- ── Drill-down: individual student review ── -->
|
||
<div class="res-drill" id="res-drill">
|
||
<div class="res-drill-header">
|
||
<button class="res-back-btn" onclick="resDrillBack()"><i data-lucide="arrow-left" style="width:14px;height:14px"></i> Назад</button>
|
||
<div class="res-drill-name" id="res-drill-name"></div>
|
||
<div class="res-drill-pct" id="res-drill-pct"></div>
|
||
</div>
|
||
<div id="res-drill-list"></div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modal: Edit assignment -->
|
||
<div class="modal-overlay" id="modal-edit-assign" onclick="closeOnOverlay(event,'modal-edit-assign')">
|
||
<div class="modal">
|
||
<div class="modal-title">Редактировать задание</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Название</label>
|
||
<input class="form-input" id="ea-title" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Дедлайн</label>
|
||
<input class="form-input" id="ea-deadline" type="datetime-local" />
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn-cancel" onclick="closeModal('modal-edit-assign')">Отмена</button>
|
||
<button class="btn-save" id="btn-save-edit-assign" onclick="saveEditAssignment()">Сохранить</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Review submission modal -->
|
||
<div class="modal-overlay" id="review-modal" onclick="if(event.target===this)closeReviewModal()">
|
||
<div class="modal">
|
||
<div class="modal-title">Работа ученика</div>
|
||
<div id="rv-student" style="font-size:0.88rem;color:#3D4F6B;font-weight:600;margin-bottom:4px"></div>
|
||
<div id="rv-assign" style="font-size:0.78rem;color:var(--violet);margin-bottom:14px"></div>
|
||
<a id="rv-file-link" class="review-file-link" href="#" target="_blank" style="margin-bottom:14px;display:inline-flex">
|
||
<i data-lucide="download" style="width:14px;height:14px"></i>
|
||
<span id="rv-file-name"></span>
|
||
</a>
|
||
<div id="rv-message" style="background:#f8f9fc;border-radius:12px;padding:12px 14px;font-size:0.82rem;color:#3D4F6B;line-height:1.5;margin-bottom:14px;display:none"></div>
|
||
<div class="grade-input-row">
|
||
<label>Оценка (0–100):</label>
|
||
<input class="grade-input" id="rv-grade" type="number" min="0" max="100" placeholder="—" oninput="updateGradeLetter()">
|
||
<div class="grade-letter" id="rv-grade-letter">—</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Комментарий учителя</label>
|
||
<textarea class="form-input" id="rv-note" rows="2" placeholder="Молодец! Или: нужно доработать раздел 2…" style="resize:vertical"></textarea>
|
||
</div>
|
||
<div class="modal-footer" style="justify-content:space-between">
|
||
<button class="btn-cancel" onclick="closeReviewModal()">Закрыть</button>
|
||
<button class="btn-save" id="btn-mark-reviewed" onclick="doMarkReviewed()">Отметить проверенным</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<script src="/js/api.js"></script>
|
||
<script src="/js/sidebar.js"></script>
|
||
<script src="/js/notifications.js"></script>
|
||
<script>
|
||
const { user, isTeacher, isAdmin } = LS.initPage();
|
||
if (!user) throw new Error('Not logged in');
|
||
if (!isTeacher) { window.location.href='/dashboard'; throw new Error(); }
|
||
|
||
const SUBJECTS = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика', other:'Задание' };
|
||
const MODES = { exam:'Экзамен', practice:'Тренировка' };
|
||
|
||
function fmtDate(d) { return d ? new Date(d).toLocaleDateString('ru',{day:'numeric',month:'short',year:'numeric'}) : '—'; }
|
||
function pctCls(p) { return p===null?'':p>=75?'pct-hi':p>=50?'pct-mid':'pct-lo'; }
|
||
function toast(msg) {
|
||
const el = document.getElementById('toast');
|
||
el.textContent = msg; el.classList.add('show');
|
||
setTimeout(() => el.classList.remove('show'), 2200);
|
||
}
|
||
function deadlineBadge(d) {
|
||
if (!d) return '';
|
||
const dt = new Date(d), now = new Date(), diff = (dt - now) / 3600000;
|
||
if (dt < now) return `<span class="deadline-badge deadline-over">просрочено ${fmtDate(d)}</span>`;
|
||
if (diff < 48) return `<span class="deadline-badge deadline-soon">до ${fmtDate(d)}</span>`;
|
||
return `<span class="deadline-badge deadline-ok">до ${fmtDate(d)}</span>`;
|
||
}
|
||
|
||
/* ══ Classes list ══ */
|
||
let classes = [], currentClass = null, _classAssignments = [];
|
||
|
||
async function loadClasses() {
|
||
try {
|
||
classes = await LS.getClasses();
|
||
renderClasses();
|
||
} catch (e) {
|
||
document.getElementById('classes-grid').innerHTML = `<div class="error">${esc(e.message)}</div>`;
|
||
}
|
||
// Update personal assignments sidebar count in background
|
||
LS.teacherAssignments().then(all => {
|
||
updatePaSidebarCount(all.filter(a => a.class_id === 0).length);
|
||
}).catch(() => {});
|
||
}
|
||
|
||
const CC_GRADIENTS = [
|
||
'linear-gradient(135deg,#06D6E0,#9B5DE5)',
|
||
'linear-gradient(135deg,#F15BB5,#9B5DE5)',
|
||
'linear-gradient(135deg,#06D664,#06B6D4)',
|
||
'linear-gradient(135deg,#FF9F1C,#F15BB5)',
|
||
'linear-gradient(135deg,#3B82F6,#9B5DE5)',
|
||
'linear-gradient(135deg,#06D6E0,#06D664)',
|
||
];
|
||
function classGradient(id) { return CC_GRADIENTS[id % CC_GRADIENTS.length]; }
|
||
function classInitials(name) {
|
||
return name.trim().split(/\s+/).slice(0, 2).map(w => w[0]?.toUpperCase() || '').join('') || '?';
|
||
}
|
||
|
||
function renderClasses() {
|
||
const grid = document.getElementById('classes-grid');
|
||
if (!classes.length) {
|
||
grid.innerHTML = `<div class="cl-empty-side">Классов нет.<br>Нажмите <strong>+</strong>, чтобы создать первый.</div>`;
|
||
return;
|
||
}
|
||
grid.innerHTML = classes.map(c => {
|
||
const grad = classGradient(c.id);
|
||
const initials = classInitials(c.name);
|
||
const isActive = currentClass?.id === c.id;
|
||
let avatar;
|
||
if (c.cover_emoji) {
|
||
const p = parseIconValue(c.cover_emoji);
|
||
avatar = `<div class="cl-avatar" style="background:${p.color}18;color:${p.color}"><i data-lucide="${p.icon}" style="width:20px;height:20px"></i></div>`;
|
||
} else {
|
||
avatar = `<div class="cl-avatar" style="background:${grad}">${initials}</div>`;
|
||
}
|
||
return `<div class="cl-item${isActive ? ' active' : ''}" onclick="openClass(${c.id})" id="cc-${c.id}">
|
||
${avatar}
|
||
<div class="cl-info">
|
||
<div class="cl-name">${esc(c.name)}</div>
|
||
<div class="cl-meta">${c.member_count} уч · ${c.assignment_count} зад</div>
|
||
</div>
|
||
<i data-lucide="chevron-right" class="cl-chevron" style="width:15px;height:15px"></i>
|
||
</div>`;
|
||
}).join('');
|
||
if (window.lucide) lucide.createIcons();
|
||
}
|
||
|
||
async function openClass(id) {
|
||
document.querySelectorAll('.cl-item').forEach(c => c.classList.remove('active'));
|
||
document.getElementById('cc-' + id)?.classList.add('active');
|
||
|
||
document.getElementById('cl-placeholder').style.display = 'none';
|
||
document.getElementById('personal-panel').style.display = 'none';
|
||
const panel = document.getElementById('detail-panel');
|
||
panel.classList.remove('panel-visible');
|
||
panel.style.display = 'block';
|
||
void panel.offsetWidth; // reflow to restart animation
|
||
panel.classList.add('panel-visible');
|
||
// Reset to dashboard tab
|
||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === 'dash'));
|
||
document.querySelectorAll('.tab-pane').forEach(p => p.classList.toggle('active', p.id === 'dtab-dash'));
|
||
document.getElementById('dash-content').innerHTML = '<div class="spinner"></div>';
|
||
document.getElementById('theory-content').innerHTML = '<div class="spinner"></div>';
|
||
_theoryLoaded = null;
|
||
|
||
try {
|
||
const d = await LS.getClassDetail(id);
|
||
currentClass = d;
|
||
document.getElementById('d-name').textContent = d.name;
|
||
document.getElementById('d-sub').innerHTML = esc(d.description || '') + ' · Код: <strong style="color:var(--violet);letter-spacing:0.05em;user-select:all">' + esc(d.invite_code) + '</strong> <button onclick="navigator.clipboard.writeText(\'' + esc(d.invite_code) + '\');LS.toast(\'Код скопирован\',\'success\')" style="background:none;border:1px solid var(--border);border-radius:6px;padding:2px 8px;font-size:0.72rem;cursor:pointer;color:var(--text-2);margin-left:4px" title="Копировать код"><svg class="ic" viewBox="0 0 24 24" style="width:12px;height:12px"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>';
|
||
renderMembers(d.members);
|
||
renderAssignments(d.assignments, d.members.length);
|
||
renderDashboard(d);
|
||
} catch (e) {
|
||
document.getElementById('dash-content').innerHTML = `<div class="error">${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderMembers(members) {
|
||
const tbody = document.getElementById('d-members');
|
||
if (!members.length) {
|
||
tbody.innerHTML = '<tr><td colspan="6"><div class="empty">Нет учеников. Поделитесь кодом приглашения.</div></td></tr>';
|
||
return;
|
||
}
|
||
tbody.innerHTML = members.map(m => {
|
||
const pc = pctCls(m.avg_pct);
|
||
return `<tr>
|
||
<td><strong>${esc(m.name)}</strong></td>
|
||
<td style="color:var(--text-3)">${esc(m.email)}</td>
|
||
<td>${m.tests_count}</td>
|
||
<td><span class="pct-cell ${pc}">${m.avg_pct!==null?m.avg_pct+'%':'—'}</span></td>
|
||
<td style="color:var(--text-3);font-size:0.78rem">${fmtDate(m.joined_at)}</td>
|
||
<td><button class="btn-danger" onclick="kickMember(${m.id},'${esc(m.name)}')">Удалить</button></td>
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderAssignments(assignments, totalMembers) {
|
||
_classAssignments = assignments;
|
||
const el = document.getElementById('d-assignments');
|
||
if (!assignments.length) {
|
||
el.innerHTML = '<div class="empty">Заданий нет. Нажмите «+ Задание» чтобы создать.</div>';
|
||
return;
|
||
}
|
||
el.innerHTML = assignments.map(a => {
|
||
const isFile = !!a.file_id;
|
||
const meta = isFile
|
||
? `<i data-lucide="paperclip" style="width:12px;height:12px;vertical-align:-1px"></i> ${esc(a.file_title || 'Файл')} · ${SUBJECTS[a.subject_slug]||a.subject_slug} ${deadlineBadge(a.deadline)}`
|
||
: `${SUBJECTS[a.subject_slug]||a.subject_slug} · ${MODES[a.mode]||a.mode} · ${a.count} вопросов ${deadlineBadge(a.deadline)}`;
|
||
return `<div class="assign-item">
|
||
<div style="flex:1">
|
||
<div class="assign-title">${esc(a.title)}</div>
|
||
<div class="assign-meta">${meta}</div>
|
||
</div>
|
||
<div class="assign-progress">
|
||
<div class="assign-pct">${a.completed_count} / ${a.total_members}</div>
|
||
<div class="assign-sub">${isFile ? 'скачали' : 'выполнили'}</div>
|
||
</div>
|
||
<div style="display:flex;gap:8px">
|
||
${!isFile ? `<button class="btn-ghost" style="font-size:0.78rem;padding:6px 14px" onclick="showResults(${a.id},'${esc(a.title)}')">Результаты</button>` : ''}
|
||
<button class="btn-ghost" style="font-size:0.78rem;padding:6px 14px" onclick="openEditAssignment(${a.id})">Изменить</button>
|
||
<button class="btn-danger" onclick="removeAssignment(${a.id})">Удалить</button>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
/* ══ Detail tabs ══ */
|
||
function switchDetailTab(btn) {
|
||
const name = btn.dataset.tab;
|
||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
document.getElementById('dtab-' + name).classList.add('active');
|
||
if (name === 'announce') loadAnnouncements();
|
||
if (name === 'dash') loadClassDashboard();
|
||
if (name === 'journal') loadJournal();
|
||
if (name === 'settings') loadSettings();
|
||
if (name === 'works') loadClassWorks();
|
||
if (name === 'theory') loadClassTheory();
|
||
}
|
||
|
||
/* ══ Invite ══ */
|
||
function copyCode(code) {
|
||
const url = `${location.origin}/dashboard?join=${code}`;
|
||
navigator.clipboard.writeText(url).then(() => toast('Ссылка скопирована!'));
|
||
}
|
||
function copyInvite() {
|
||
if (!currentClass) return;
|
||
copyCode(currentClass.invite_code);
|
||
}
|
||
|
||
/* ══ Create class ══ */
|
||
function openCreateClass() {
|
||
document.getElementById('c-name').value = '';
|
||
document.getElementById('c-desc').value = '';
|
||
_selectedIcon = 'book-open';
|
||
_selectedColor = '#9B5DE5';
|
||
renderIconPreview('c-icon-preview');
|
||
renderIconPicker('c-icon-picker', 'c-icon-preview');
|
||
renderColorPicker('c-color-picker', 'c-icon-preview');
|
||
openModal('modal-class');
|
||
}
|
||
/* ══ Icon picker (Lucide SVG) ══════════════════════════════════════ */
|
||
const CLASS_ICONS = [
|
||
// Биология / Химия / Наука
|
||
'flask-conical','flask-round','microscope','atom','dna','test-tubes','pill','syringe',
|
||
'scan-eye','heart-pulse','activity','thermometer','biohazard','bug','leaf','trees',
|
||
'flower-2','sprout','apple','carrot','egg','fish','bird','cat','dog','rabbit','turtle','squirrel',
|
||
// Математика / Физика
|
||
'calculator','sigma','pi','percent','binary','triangle','hexagon','circle-dot',
|
||
'magnet','zap','lightbulb','sun','moon','cloud-lightning','orbit','satellite',
|
||
// География / История
|
||
'globe','map','compass','mountain','waves','landmark','castle','flag',
|
||
// Учёба / Школа
|
||
'book-open','book-marked','notebook-pen','notebook-text','library','scroll-text',
|
||
'graduation-cap','school','backpack','pencil','pen-tool','highlighter','clipboard-list',
|
||
'file-text','file-check','brain','sparkles','puzzle','blocks',
|
||
// IT / Технологии
|
||
'laptop','monitor','keyboard','cpu','code','terminal','database','wifi','bot','circuit-board',
|
||
// Искусство / Музыка
|
||
'palette','brush','paintbrush','camera','clapperboard','music','music-2','mic','headphones','drama',
|
||
// Спорт
|
||
'trophy','medal','target','flame','timer','bike','dumbbell','footprints',
|
||
// Общие / Абстрактные
|
||
'rocket','gem','crown','star','heart','shield','key','lock','award','badge-check',
|
||
'square','circle','triangle','diamond','hexagon','octagon','pentagon',
|
||
];
|
||
let _selectedIcon = 'book-open';
|
||
let _selectedColor = '#9B5DE5';
|
||
|
||
const ICON_COLORS = [
|
||
{ color: '#9B5DE5', label: 'Фиолетовый' },
|
||
{ color: '#3B82F6', label: 'Синий' },
|
||
{ color: '#06B6D4', label: 'Голубой' },
|
||
{ color: '#06D6A0', label: 'Зелёный' },
|
||
{ color: '#059652', label: 'Тёмно-зелёный' },
|
||
{ color: '#F59E0B', label: 'Жёлтый' },
|
||
{ color: '#F97316', label: 'Оранжевый' },
|
||
{ color: '#E83A1E', label: 'Красный' },
|
||
{ color: '#F15BB5', label: 'Розовый' },
|
||
{ color: '#8B5CF6', label: 'Индиго' },
|
||
{ color: '#64748B', label: 'Серый' },
|
||
{ color: '#0F172A', label: 'Чёрный' },
|
||
];
|
||
|
||
function parseIconValue(val) {
|
||
if (!val) return { icon: 'book-open', color: '#9B5DE5' };
|
||
const parts = val.split(':');
|
||
return { icon: parts[0] || 'book-open', color: parts[1] || '#9B5DE5' };
|
||
}
|
||
function encodeIconValue(icon, color) {
|
||
return color === '#9B5DE5' ? icon : icon + ':' + color;
|
||
}
|
||
|
||
function renderIconPreview(previewId) {
|
||
const el = document.getElementById(previewId);
|
||
if (!el) return;
|
||
el.style.color = _selectedColor;
|
||
el.style.background = _selectedColor + '18';
|
||
el.style.borderColor = _selectedColor + '30';
|
||
el.innerHTML = `<i data-lucide="${_selectedIcon}"></i>`;
|
||
if (window.lucide) lucide.createIcons({ nodes: [el] });
|
||
}
|
||
|
||
function renderIconPicker(containerId, previewId) {
|
||
const el = document.getElementById(containerId);
|
||
if (!el) return;
|
||
el.innerHTML = CLASS_ICONS.map(ic =>
|
||
`<button type="button" class="ip-btn${ic === _selectedIcon ? ' selected' : ''}" data-icon="${ic}"><i data-lucide="${ic}"></i></button>`
|
||
).join('');
|
||
if (window.lucide) lucide.createIcons({ nodes: [el] });
|
||
el.onclick = (e) => {
|
||
const btn = e.target.closest('.ip-btn');
|
||
if (!btn) return;
|
||
_selectedIcon = btn.dataset.icon;
|
||
renderIconPreview(previewId);
|
||
el.querySelectorAll('.ip-btn').forEach(b => b.classList.toggle('selected', b.dataset.icon === _selectedIcon));
|
||
};
|
||
}
|
||
|
||
function renderColorPicker(containerId, previewId) {
|
||
const el = document.getElementById(containerId);
|
||
if (!el) return;
|
||
el.innerHTML = ICON_COLORS.map(c =>
|
||
`<button type="button" class="cp-btn${c.color === _selectedColor ? ' selected' : ''}" data-color="${c.color}" title="${c.label}" style="background:${c.color}"></button>`
|
||
).join('');
|
||
el.onclick = (e) => {
|
||
const btn = e.target.closest('.cp-btn');
|
||
if (!btn) return;
|
||
_selectedColor = btn.dataset.color;
|
||
renderIconPreview(previewId);
|
||
el.querySelectorAll('.cp-btn').forEach(b => b.classList.toggle('selected', b.dataset.color === _selectedColor));
|
||
};
|
||
}
|
||
|
||
async function saveClass() {
|
||
const name = document.getElementById('c-name').value.trim();
|
||
const description = document.getElementById('c-desc').value.trim();
|
||
if (!name) return LS.toast('Введите название', 'warn');
|
||
const btn = document.getElementById('btn-save-class');
|
||
btn.disabled = true;
|
||
try {
|
||
const c = await LS.createClass({ name, description, cover_emoji: encodeIconValue(_selectedIcon, _selectedColor) });
|
||
closeModal('modal-class');
|
||
toast('Класс создан! Код: ' + c.invite_code);
|
||
await loadClasses();
|
||
openClass(c.id);
|
||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
finally { btn.disabled = false; }
|
||
}
|
||
|
||
/* ══ Delete class ══ */
|
||
async function confirmDeleteClass() {
|
||
if (!currentClass) return;
|
||
if (!await LS.confirm(`Удалить класс «${currentClass.name}»?\nВсе данные будут удалены.`, { title: 'Удалить класс', confirmText: 'Удалить' })) return;
|
||
try {
|
||
await LS.deleteClass(currentClass.id);
|
||
currentClass = null;
|
||
document.getElementById('detail-panel').style.display = 'none';
|
||
document.getElementById('cl-placeholder').style.display = '';
|
||
toast('Класс удалён');
|
||
await loadClasses();
|
||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
/* ══ Student search ══ */
|
||
let allStudents = [], selectedStudent = null;
|
||
|
||
async function loadStudents() {
|
||
if (allStudents.length) return;
|
||
try { allStudents = await LS.getStudentsList(); } catch {}
|
||
}
|
||
|
||
function filterStudents(q) {
|
||
const drop = document.getElementById('student-drop');
|
||
const lq = q.toLowerCase();
|
||
const matches = q.length < 1
|
||
? allStudents.slice(0, 60)
|
||
: allStudents.filter(s => s.name.toLowerCase().includes(lq) || s.email.toLowerCase().includes(lq)).slice(0, 40);
|
||
if (!matches.length) {
|
||
drop.innerHTML = '<div class="student-opt-empty">Ничего не найдено</div>';
|
||
} else {
|
||
drop.innerHTML = matches.map(s => `
|
||
<div class="student-opt" onmousedown="selectStudent(${s.id},'${esc(s.name)}','${esc(s.email)}')">
|
||
<div class="student-opt-name">${esc(s.name)}</div>
|
||
<div class="student-opt-email">${esc(s.email)}</div>
|
||
</div>`).join('');
|
||
}
|
||
drop.classList.add('open');
|
||
if (selectedStudent) { selectedStudent = null; document.getElementById('btn-add-member').disabled = true; }
|
||
}
|
||
|
||
function selectStudent(id, name, email) {
|
||
selectedStudent = { id, name, email };
|
||
document.getElementById('add-member-search').value = `${name} (${email})`;
|
||
document.getElementById('student-drop').classList.remove('open');
|
||
document.getElementById('btn-add-member').disabled = false;
|
||
}
|
||
|
||
async function openStudentDrop() {
|
||
await loadStudents();
|
||
filterStudents(document.getElementById('add-member-search').value);
|
||
}
|
||
function closeStudentDrop() { document.getElementById('student-drop').classList.remove('open'); }
|
||
|
||
/* ══ Add member ══ */
|
||
async function doAddMember() {
|
||
if (!currentClass || !selectedStudent) return;
|
||
const errEl = document.getElementById('add-member-err');
|
||
errEl.textContent = '';
|
||
try {
|
||
const r = await LS.addClassMember(currentClass.id, null, selectedStudent.id);
|
||
document.getElementById('add-member-search').value = '';
|
||
selectedStudent = null;
|
||
document.getElementById('btn-add-member').disabled = true;
|
||
toast(`${r.name} добавлен в класс`);
|
||
await openClass(currentClass.id);
|
||
} catch (e) { errEl.textContent = e.message; }
|
||
}
|
||
|
||
/* ══ Kick member ══ */
|
||
async function kickMember(uid, name) {
|
||
if (!await LS.confirm(`Удалить ${name} из класса?`, { title: 'Удалить ученика', confirmText: 'Удалить' })) return;
|
||
try {
|
||
await LS.kickMember(currentClass.id, uid);
|
||
await openClass(currentClass.id);
|
||
toast('Ученик удалён');
|
||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
/* ══ Assignment type ══ */
|
||
let _assignType = 'random';
|
||
let _allFiles = null, _selectedFileId = null;
|
||
let _allTests = null, _selectedTestId = null;
|
||
|
||
const TYPE_HINTS = {
|
||
random: 'Вопросы подбираются случайно из базы по выбранному предмету',
|
||
fixed_test: 'Все ученики получат одинаковый тест из библиотеки в фиксированном порядке',
|
||
file: 'Ученикам будет доступна ссылка для скачивания файла из библиотеки',
|
||
upload: 'Ученик должен загрузить файл (реферат, лабораторную и т.д.)',
|
||
};
|
||
|
||
function setAssignType(type) {
|
||
_assignType = type;
|
||
document.getElementById('atype-random-btn') .classList.toggle('active', type === 'random');
|
||
document.getElementById('atype-fixtest-btn').classList.toggle('active', type === 'fixed_test');
|
||
document.getElementById('atype-file-btn') .classList.toggle('active', type === 'file');
|
||
document.getElementById('atype-upload-btn') .classList.toggle('active', type === 'upload');
|
||
document.getElementById('a-test-fields') .style.display = type === 'random' ? '' : 'none';
|
||
document.getElementById('a-fixtest-fields') .style.display = type === 'fixed_test' ? '' : 'none';
|
||
document.getElementById('a-file-fields') .style.display = type === 'file' ? '' : 'none';
|
||
document.getElementById('a-type-hint').textContent = TYPE_HINTS[type] || '';
|
||
if (type === 'file' && !_allFiles) loadFilePicker();
|
||
if (type === 'fixed_test' && !_allTests) loadTestPicker();
|
||
// Upload type: auto-check homework, hide test/file fields
|
||
if (type === 'upload') {
|
||
document.getElementById('a-is-hw').checked = true;
|
||
onHwChange();
|
||
}
|
||
}
|
||
|
||
function onHwChange() {
|
||
const isHw = document.getElementById('a-is-hw').checked;
|
||
document.getElementById('a-mode-group').style.display = isHw ? 'none' : '';
|
||
document.getElementById('a-hw-hint').style.display = isHw ? '' : 'none';
|
||
}
|
||
|
||
async function loadFilePicker() {
|
||
try {
|
||
_allFiles = await LS.getFiles();
|
||
renderFileList('');
|
||
} catch { _allFiles = []; }
|
||
}
|
||
|
||
async function loadTestPicker() {
|
||
try {
|
||
_allTests = await LS.getTests();
|
||
renderTestList('');
|
||
} catch { _allTests = []; }
|
||
}
|
||
|
||
function renderTestList(q) {
|
||
const el = document.getElementById('a-test-list');
|
||
if (!_allTests) { el.innerHTML = '<div class="student-opt-empty">Загрузка…</div>'; return; }
|
||
const lq = q.toLowerCase();
|
||
const items = q ? _allTests.filter(t => (t.title||'').toLowerCase().includes(lq)) : _allTests;
|
||
if (!items.length) { el.innerHTML = '<div class="student-opt-empty">Тестов нет</div>'; return; }
|
||
el.innerHTML = items.map(t => `
|
||
<div class="file-pick-item${_selectedTestId === t.id ? ' selected' : ''}" onclick="selectTest(${t.id},'${esc(t.title||'Тест')}')">
|
||
<div style="flex:1">
|
||
<div class="file-pick-name">${esc(t.title||'Тест')}</div>
|
||
<div class="file-pick-subj">${SUBJECTS[t.subject_slug]||t.subject_slug||''} · ${t.question_count||'?'} вопросов</div>
|
||
</div>
|
||
${_selectedTestId === t.id ? '<div class="file-pick-check"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg></div>' : ''}
|
||
</div>`).join('');
|
||
}
|
||
|
||
function filterTestList(q) { renderTestList(q); }
|
||
|
||
function selectTest(id, title) {
|
||
_selectedTestId = id;
|
||
renderTestList(document.getElementById('a-test-search').value);
|
||
const sel = document.getElementById('a-test-selected');
|
||
sel.innerHTML = '<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Выбран: ' + title;
|
||
sel.style.display = '';
|
||
}
|
||
|
||
function renderFileList(q) {
|
||
const el = document.getElementById('a-file-list');
|
||
if (!_allFiles) { el.innerHTML = '<div class="student-opt-empty">Загрузка…</div>'; return; }
|
||
const lq = q.toLowerCase();
|
||
const items = q ? _allFiles.filter(f => (f.title||'').toLowerCase().includes(lq)) : _allFiles;
|
||
if (!items.length) { el.innerHTML = '<div class="student-opt-empty">Нет файлов</div>'; return; }
|
||
el.innerHTML = items.map(f => `
|
||
<div class="file-pick-item${_selectedFileId === f.id ? ' selected' : ''}" onclick="selectFile(${f.id},'${esc(f.title||'Файл')}','${f.subject_slug||''}')">
|
||
<div style="flex:1">
|
||
<div class="file-pick-name">${esc(f.title||'Файл')}</div>
|
||
<div class="file-pick-subj">${SUBJECTS[f.subject_slug]||f.subject_slug||''}</div>
|
||
</div>
|
||
${_selectedFileId === f.id ? '<div class="file-pick-check"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg></div>' : ''}
|
||
</div>`).join('');
|
||
}
|
||
|
||
function filterFileList(q) { renderFileList(q); }
|
||
|
||
function selectFile(id, title, subject_slug) {
|
||
_selectedFileId = id;
|
||
renderFileList(document.getElementById('a-file-search').value);
|
||
const sel = document.getElementById('a-file-selected');
|
||
sel.innerHTML = '<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Выбран: ' + title;
|
||
sel.style.display = '';
|
||
}
|
||
|
||
/* ══ Target selector ══ */
|
||
function buildTargetSelect(members) {
|
||
const sel = document.getElementById('a-target');
|
||
sel.innerHTML =
|
||
`<option value="class">Всему классу</option>` +
|
||
`<option value="multi">Нескольким классам…</option>` +
|
||
members.map(m => `<option value="${m.id}">${esc(m.name)} (лично)</option>`).join('');
|
||
}
|
||
|
||
function onTargetChange() {
|
||
const val = document.getElementById('a-target').value;
|
||
const isHw = document.getElementById('a-is-hw');
|
||
if (val !== 'class' && val !== 'multi') { isHw.checked = true; }
|
||
// Show/hide multi-class selector
|
||
const multiDiv = document.getElementById('a-multi-classes');
|
||
if (val === 'multi') {
|
||
multiDiv.style.display = '';
|
||
buildMultiClassList();
|
||
} else {
|
||
multiDiv.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function buildMultiClassList() {
|
||
const el = document.getElementById('a-multi-class-list');
|
||
const others = (classes || []).filter(c => c.id !== currentClass?.id);
|
||
// Always include current class (pre-checked)
|
||
const all = currentClass ? [currentClass, ...others] : others;
|
||
el.innerHTML = all.map(c => `
|
||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:0.85rem;font-weight:600;color:var(--text-2);padding:2px 0">
|
||
<input type="checkbox" value="${c.id}"${c.id === currentClass?.id ? ' checked' : ''} style="width:15px;height:15px;accent-color:var(--violet)">
|
||
${esc(c.name)}
|
||
</label>`).join('');
|
||
}
|
||
|
||
/* ══ Templates ══ */
|
||
let _templates = [];
|
||
|
||
async function refreshTemplates() {
|
||
try {
|
||
_templates = await LS.listTemplates();
|
||
const sel = document.getElementById('tpl-select');
|
||
sel.innerHTML = '<option value="">— Загрузить шаблон —</option>' +
|
||
_templates.map(t => `<option value="${t.id}">${esc(t.label)}</option>`).join('');
|
||
} catch { _templates = []; }
|
||
}
|
||
|
||
function loadTemplateIntoForm() {
|
||
const id = Number(document.getElementById('tpl-select').value);
|
||
if (!id) return LS.toast('Выберите шаблон', 'warn');
|
||
const tpl = _templates.find(t => t.id === id);
|
||
if (!tpl) return;
|
||
|
||
if (tpl.file_id) {
|
||
setAssignType('file');
|
||
_selectedFileId = tpl.file_id;
|
||
document.getElementById('a-file-selected').innerHTML = '<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Файл из шаблона';
|
||
document.getElementById('a-file-selected').style.display = '';
|
||
} else if (tpl.test_id) {
|
||
setAssignType('fixed_test');
|
||
_selectedTestId = tpl.test_id;
|
||
document.getElementById('a-test-selected').innerHTML = '<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Тест из шаблона';
|
||
document.getElementById('a-test-selected').style.display = '';
|
||
} else {
|
||
setAssignType('random');
|
||
const subj = document.getElementById('a-subject');
|
||
if (subj) subj.value = tpl.subject_slug || 'bio';
|
||
const mode = document.getElementById('a-mode');
|
||
if (mode) mode.value = tpl.mode || 'exam';
|
||
const count = document.getElementById('a-count');
|
||
if (count) count.value = tpl.count || 25;
|
||
}
|
||
document.getElementById('a-is-hw').checked = !!tpl.is_homework;
|
||
onHwChange();
|
||
LS.toast('Шаблон загружен', 'success');
|
||
}
|
||
|
||
async function promptSaveTemplate() {
|
||
const label = window.prompt('Название шаблона:',
|
||
document.getElementById('a-title').value.trim() || 'Мой шаблон');
|
||
if (!label) return;
|
||
|
||
const isHw = document.getElementById('a-is-hw').checked;
|
||
let payload = { label, is_homework: isHw ? 1 : 0 };
|
||
|
||
if (_assignType === 'file') {
|
||
if (!_selectedFileId) return LS.toast('Выберите файл перед сохранением', 'warn');
|
||
const f = (_allFiles||[]).find(x => x.id === _selectedFileId);
|
||
payload = { ...payload, subject_slug: f?.subject_slug || 'bio', mode: 'exam', count: 1, file_id: _selectedFileId };
|
||
} else if (_assignType === 'fixed_test') {
|
||
if (!_selectedTestId) return LS.toast('Выберите тест перед сохранением', 'warn');
|
||
const t = (_allTests||[]).find(x => x.id === _selectedTestId);
|
||
payload = { ...payload, subject_slug: t?.subject_slug || 'bio', mode: 'exam', count: t?.question_count || 25, test_id: _selectedTestId };
|
||
} else {
|
||
payload = {
|
||
...payload,
|
||
subject_slug: document.getElementById('a-subject').value,
|
||
mode: isHw ? 'exam' : document.getElementById('a-mode').value,
|
||
count: Number(document.getElementById('a-count').value),
|
||
};
|
||
}
|
||
|
||
try {
|
||
await LS.saveTemplate(payload);
|
||
await refreshTemplates();
|
||
LS.toast('Шаблон сохранён', 'success');
|
||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
/* ══ Create assignment ══ */
|
||
function openCreateAssignment() {
|
||
if (!currentClass) return;
|
||
document.getElementById('a-title').value = '';
|
||
document.getElementById('a-deadline').value = '';
|
||
document.getElementById('a-is-hw').checked = false;
|
||
const ma = document.getElementById('a-max-attempts');
|
||
if (ma) ma.value = '0';
|
||
document.getElementById('a-mode-group').style.display = '';
|
||
document.getElementById('a-multi-classes').style.display = 'none';
|
||
_selectedFileId = null;
|
||
document.getElementById('a-file-selected').style.display = 'none';
|
||
document.getElementById('a-file-search').value = '';
|
||
_selectedTestId = null;
|
||
document.getElementById('a-test-selected').style.display = 'none';
|
||
document.getElementById('a-test-search').value = '';
|
||
setAssignType('random');
|
||
buildTargetSelect(currentClass.members || []);
|
||
refreshTemplates();
|
||
openModal('modal-assign');
|
||
}
|
||
|
||
async function saveAssignment() {
|
||
if (!currentClass) return;
|
||
const title = document.getElementById('a-title').value.trim();
|
||
const deadline = document.getElementById('a-deadline').value || null;
|
||
const isHw = document.getElementById('a-is-hw').checked;
|
||
const target = document.getElementById('a-target').value;
|
||
const maxAttempts = Number(document.getElementById('a-max-attempts')?.value || 0);
|
||
if (!title) return LS.toast('Введите название задания', 'warn');
|
||
|
||
let payload;
|
||
if (_assignType === 'upload') {
|
||
payload = { title, mode: 'exam', count: 1, deadline, is_homework: 1, max_attempts: 0 };
|
||
} else if (_assignType === 'file') {
|
||
if (!_selectedFileId) return LS.toast('Выберите файл из библиотеки', 'warn');
|
||
const f = _allFiles.find(x => x.id === _selectedFileId);
|
||
payload = { title, file_id: _selectedFileId, subject_slug: f?.subject_slug || 'bio', mode: 'exam', count: 1, deadline, is_homework: isHw ? 1 : 0, max_attempts: maxAttempts };
|
||
} else if (_assignType === 'fixed_test') {
|
||
if (!_selectedTestId) return LS.toast('Выберите тест из списка', 'warn');
|
||
const t = _allTests.find(x => x.id === _selectedTestId);
|
||
payload = { title, test_id: _selectedTestId, subject_slug: t?.subject_slug || 'bio', mode: 'exam', count: t?.question_count || 25, deadline, is_homework: isHw ? 1 : 0, max_attempts: maxAttempts };
|
||
} else {
|
||
payload = {
|
||
title,
|
||
subject_slug: document.getElementById('a-subject').value,
|
||
mode: isHw ? 'exam' : document.getElementById('a-mode').value,
|
||
count: Number(document.getElementById('a-count').value),
|
||
deadline,
|
||
is_homework: isHw ? 1 : 0,
|
||
max_attempts: maxAttempts,
|
||
};
|
||
}
|
||
|
||
const btn = document.getElementById('btn-save-assign');
|
||
btn.disabled = true;
|
||
try {
|
||
if (target === 'multi') {
|
||
const checked = [...document.querySelectorAll('#a-multi-class-list input[type=checkbox]:checked')];
|
||
const class_ids = checked.map(cb => Number(cb.value));
|
||
if (!class_ids.length) { LS.toast('Выберите хотя бы один класс', 'warn'); return; }
|
||
const result = await LS.bulkCreateAssignment({ ...payload, class_ids });
|
||
closeModal('modal-assign');
|
||
LS.toast(`Задание создано для ${result.count} класс(ов)`, 'success');
|
||
await openClass(currentClass.id);
|
||
document.querySelector('[data-tab="assign"]').click();
|
||
} else if (target === 'class') {
|
||
await LS.createAssignment(currentClass.id, payload);
|
||
closeModal('modal-assign');
|
||
toast(isHw ? 'ДЗ выдано' : 'Задание создано');
|
||
await openClass(currentClass.id);
|
||
document.querySelector('[data-tab="assign"]').click();
|
||
} else {
|
||
// Конкретному ученику
|
||
await LS.createDirectAssignment({ ...payload, student_id: Number(target) });
|
||
closeModal('modal-assign');
|
||
toast(isHw ? 'ДЗ выдано' : 'Задание выдано');
|
||
await openClass(currentClass.id);
|
||
document.querySelector('[data-tab="assign"]').click();
|
||
}
|
||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
finally { btn.disabled = false; }
|
||
}
|
||
|
||
/* ══ Delete assignment ══ */
|
||
async function removeAssignment(id) {
|
||
if (!await LS.confirm('Удалить задание?', { title: 'Удалить задание', confirmText: 'Удалить' })) return;
|
||
try {
|
||
await LS.deleteAssignment(id);
|
||
toast('Задание удалено');
|
||
await openClass(currentClass.id);
|
||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
/* ══ Edit assignment ══ */
|
||
let _editingAssign = null;
|
||
function openEditAssignment(id) {
|
||
_editingAssign = _classAssignments.find(x => x.id === id);
|
||
if (!_editingAssign) return;
|
||
document.getElementById('ea-title').value = _editingAssign.title;
|
||
const dl = _editingAssign.deadline;
|
||
document.getElementById('ea-deadline').value = dl ? dl.replace(' ', 'T').slice(0, 16) : '';
|
||
openModal('modal-edit-assign');
|
||
}
|
||
async function saveEditAssignment() {
|
||
if (!_editingAssign) return;
|
||
const title = document.getElementById('ea-title').value.trim();
|
||
if (!title) return LS.toast('Введите название', 'warn');
|
||
const deadline = document.getElementById('ea-deadline').value || null;
|
||
const btn = document.getElementById('btn-save-edit-assign');
|
||
btn.disabled = true;
|
||
try {
|
||
await LS.updateAssignment(_editingAssign.id, {
|
||
title,
|
||
deadline,
|
||
subject_slug: _editingAssign.subject_slug || 'bio',
|
||
mode: _editingAssign.mode || 'exam',
|
||
count: _editingAssign.count || 25,
|
||
test_id: _editingAssign.test_id || null,
|
||
});
|
||
closeModal('modal-edit-assign');
|
||
toast('Изменения сохранены');
|
||
await openClass(currentClass.id);
|
||
document.querySelector('[data-tab="assign"]').click();
|
||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
finally { btn.disabled = false; }
|
||
}
|
||
|
||
/* ══ Results overlay ══════════════════════════════════════════════════ */
|
||
let _currentResultsAssignId = null;
|
||
let _currentResultsData = null; // cache for CSV
|
||
let _qStatsLoaded = false;
|
||
|
||
function switchResTab(btn) {
|
||
document.querySelectorAll('.res-tab-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
const tab = btn.dataset.restab;
|
||
document.getElementById('restab-students').style.display = tab === 'students' ? '' : 'none';
|
||
document.getElementById('restab-questions').style.display = tab === 'questions' ? '' : 'none';
|
||
document.getElementById('res-csv-btn').style.display = tab === 'students' ? '' : 'none';
|
||
if (tab === 'questions') loadResQuestionStats();
|
||
}
|
||
|
||
async function showResults(id, title) {
|
||
_currentResultsAssignId = id;
|
||
_currentResultsData = null;
|
||
_qStatsLoaded = false;
|
||
document.getElementById('res-title').textContent = title;
|
||
document.getElementById('res-list').innerHTML = '<div class="spinner"></div>';
|
||
document.getElementById('res-drill').classList.remove('open');
|
||
document.getElementById('restab-students').style.display = '';
|
||
document.getElementById('restab-questions').style.display = 'none';
|
||
document.getElementById('res-tabs').style.display = 'none';
|
||
document.getElementById('res-stats').style.display = 'none';
|
||
document.getElementById('res-distrib').style.display = 'none';
|
||
document.getElementById('res-prog-wrap').style.display = 'none';
|
||
document.getElementById('res-csv-btn').style.display = 'none';
|
||
// reset tabs
|
||
document.querySelectorAll('.res-tab-btn').forEach((b,i) => b.classList.toggle('active', i===0));
|
||
openModal('results-overlay');
|
||
|
||
try {
|
||
const { results } = await LS.assignmentResults(id);
|
||
_currentResultsData = { results, title };
|
||
const done = results.filter(r => r.session_status === 'completed');
|
||
const pending = results.filter(r => r.session_status !== 'completed');
|
||
|
||
if (done.length > 0) {
|
||
// Stats chips
|
||
const avg = Math.round(done.reduce((s,r) => s + (r.percent||0), 0) / done.length);
|
||
const best = Math.max(...done.map(r => r.percent||0));
|
||
document.getElementById('res-done-n').textContent = done.length;
|
||
document.getElementById('res-total-n').textContent = results.length;
|
||
document.getElementById('res-avg-n').textContent = avg + '%';
|
||
document.getElementById('res-best-n').textContent = best + '%';
|
||
document.getElementById('res-stats').style.display = '';
|
||
// Completion bar
|
||
document.getElementById('res-prog-bar').style.width = Math.round(done.length / results.length * 100) + '%';
|
||
document.getElementById('res-prog-wrap').style.display = '';
|
||
// Score distribution
|
||
const buckets = [
|
||
{ label: '0–25%', min: 0, max: 25, color: '#F15BB5' },
|
||
{ label: '26–50%', min: 26, max: 50, color: '#FFB347' },
|
||
{ label: '51–75%', min: 51, max: 75, color: '#06B6D4' },
|
||
{ label: '76–100%',min: 76, max: 100, color: '#06D664' },
|
||
];
|
||
const maxCnt = Math.max(...buckets.map(b => done.filter(r => r.percent >= b.min && r.percent <= b.max).length), 1);
|
||
document.getElementById('res-distrib-bars').innerHTML = buckets.map(b => {
|
||
const cnt = done.filter(r => r.percent >= b.min && r.percent <= b.max).length;
|
||
const h = Math.round((cnt / maxCnt) * 56);
|
||
return `<div class="res-bar-col">
|
||
<div class="res-bar-cnt">${cnt || ''}</div>
|
||
<div class="res-bar-fill" style="height:${h}px;background:${b.color};opacity:0.85"></div>
|
||
<div class="res-bar-lbl">${b.label}</div>
|
||
</div>`;
|
||
}).join('');
|
||
document.getElementById('res-distrib').style.display = '';
|
||
document.getElementById('res-csv-btn').style.display = '';
|
||
document.getElementById('res-tabs').style.display = '';
|
||
}
|
||
|
||
// Student list
|
||
const rank = (r, i) => {
|
||
const medal = ['<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/></svg>','<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/></svg>','<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/></svg>'][i] || (i+1)+'.';
|
||
const pc = pctCls(r.percent);
|
||
const clickable = r.session_id ? `onclick="openResDrill(${r.session_id},'${esc(r.name)}',${r.percent??'null'})"` : '';
|
||
return `<div class="res-row" ${clickable} title="${r.session_id ? 'Посмотреть ответы' : ''}">
|
||
<div class="res-rank">${medal}</div>
|
||
<div class="res-name">
|
||
<strong>${esc(r.name)}</strong>
|
||
<div style="font-size:0.75rem;color:var(--text-3)">${esc(r.email)}</div>
|
||
</div>
|
||
<div style="text-align:right">
|
||
<div class="res-pct ${pc}">${r.percent!==null?r.percent+'%':'—'}</div>
|
||
<div class="res-score">${r.score??'—'} / ${r.total??'—'}</div>
|
||
</div>
|
||
${r.session_id ? `<div style="color:var(--text-3);opacity:.4"><i data-lucide="chevron-right" style="width:15px;height:15px"></i></div>` : ''}
|
||
</div>`;
|
||
};
|
||
|
||
document.getElementById('res-list').innerHTML =
|
||
(done.length ? `<div style="font-size:0.72rem;color:var(--text-3);margin-bottom:8px;padding:0 2px">Нажмите на ученика — увидите его ответы</div>` + done.map(rank).join('') : '') +
|
||
(pending.length ? `<div style="margin-top:16px;font-family:'Unbounded',sans-serif;font-size:0.72rem;color:var(--text-3);font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin-bottom:8px">Не выполнили</div>` +
|
||
pending.map(r => `<div class="res-row"><div class="res-rank" style="color:var(--text-3)">—</div><div class="res-name"><strong>${esc(r.name)}</strong><div style="font-size:0.75rem;color:var(--text-3)">${esc(r.email)}</div></div><div class="res-pending">Не сдано</div></div>`).join('') : '');
|
||
|
||
if (!results.length) document.getElementById('res-list').innerHTML = '<div class="empty">Нет данных</div>';
|
||
if (window.lucide) lucide.createIcons();
|
||
} catch (e) {
|
||
document.getElementById('res-list').innerHTML = `<div class="error">${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
/* ── Question stats tab ── */
|
||
async function loadResQuestionStats() {
|
||
if (_qStatsLoaded) return;
|
||
const el = document.getElementById('res-q-list');
|
||
el.innerHTML = '<div class="spinner"></div>';
|
||
try {
|
||
const { stats, session_count } = await LS.assignmentQuestionStats(_currentResultsAssignId);
|
||
_qStatsLoaded = true;
|
||
if (!stats.length) { el.innerHTML = '<div class="empty">Нет данных — никто не сдал это задание</div>'; return; }
|
||
el.innerHTML = `<div style="font-size:0.72rem;color:var(--text-3);margin-bottom:12px">На основе ${session_count} сданных работ. Сортировка: от самых сложных вопросов.</div>` +
|
||
stats.map((q, idx) => {
|
||
const pct = q.error_pct || 0;
|
||
const color = pct >= 70 ? '#F15BB5' : pct >= 40 ? '#FFB347' : '#06D664';
|
||
return `<div class="qs-item">
|
||
<div class="qs-num">${idx+1}</div>
|
||
<div class="qs-text">${esc(q.question_text)}</div>
|
||
<div class="qs-bar-wrap"><div class="qs-bar-fill" style="width:${pct}%;background:${color}"></div></div>
|
||
<div class="qs-pct" style="color:${color}">${pct}%</div>
|
||
<div class="qs-cnt">${q.wrong}/${q.total} ошибок</div>
|
||
</div>`;
|
||
}).join('');
|
||
} catch (e) { el.innerHTML = `<div class="error">${esc(e.message)}</div>`; }
|
||
}
|
||
|
||
/* ── CSV export ── */
|
||
function exportResultsCSV() {
|
||
if (!_currentResultsData) return;
|
||
const { results, title } = _currentResultsData;
|
||
const rows = [['Имя', 'Email', 'Баллы', 'Всего', '%', 'Статус', 'Дата сдачи']];
|
||
results.forEach(r => {
|
||
const dt = r.finished_at ? new Date(r.finished_at.includes('T') ? r.finished_at : r.finished_at.replace(' ','T')+'Z').toLocaleString('ru') : '';
|
||
rows.push([r.name, r.email, r.score??'', r.total??'', r.percent!=null?r.percent+'%':'', r.session_status==='completed'?'Сдано':'Не сдано', dt]);
|
||
});
|
||
const csv = rows.map(row => row.map(v => `"${String(v).replace(/"/g,'""')}"`).join(',')).join('\n');
|
||
const a = document.createElement('a');
|
||
a.href = 'data:text/csv;charset=utf-8,\uFEFF' + encodeURIComponent(csv);
|
||
a.download = `results_${title.replace(/[^а-яёa-z0-9]/gi,'_')}.csv`;
|
||
a.click();
|
||
}
|
||
|
||
/* ── Drill-down: individual student review ── */
|
||
async function openResDrill(session_id, name, pct) {
|
||
document.getElementById('restab-students').style.display = 'none';
|
||
document.getElementById('restab-questions').style.display = 'none';
|
||
document.getElementById('res-tabs').style.display = 'none';
|
||
document.getElementById('res-csv-btn').style.display = 'none';
|
||
document.getElementById('res-drill').classList.add('open');
|
||
document.getElementById('res-drill-name').textContent = name;
|
||
const pctEl = document.getElementById('res-drill-pct');
|
||
pctEl.textContent = pct !== null ? pct + '%' : '—';
|
||
pctEl.className = 'res-drill-pct ' + pctCls(pct);
|
||
const listEl = document.getElementById('res-drill-list');
|
||
listEl.innerHTML = '<div class="spinner"></div>';
|
||
document.querySelector('.results-box').scrollTop = 0;
|
||
try {
|
||
const data = await LS.assignmentSessionReview(_currentResultsAssignId, session_id);
|
||
const review = data.review || [];
|
||
if (!review.length) { listEl.innerHTML = '<div class="empty">Нет данных</div>'; return; }
|
||
listEl.innerHTML = review.map((q, idx) => {
|
||
const status = q.is_correct === null ? 'skipped' : q.is_correct ? 'correct' : 'wrong';
|
||
const statusIcon = q.is_correct === null ? '<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg>' : q.is_correct ? '<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>' : '<svg class="ic" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
|
||
const statusColor = q.is_correct === null ? 'var(--text-3)' : q.is_correct ? 'var(--green)' : 'var(--pink)';
|
||
let answerHtml = '';
|
||
if (q.type === 'short_answer') {
|
||
answerHtml = `<div class="rq-opts">
|
||
<div class="rq-opt ${q.is_correct ? 'both' : 'chosen'}"><span class="rq-icon">${q.is_correct?'<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>':'<svg class="ic" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>'}</span> Ответ: ${esc(q.answer_text||'—')}</div>
|
||
${!q.is_correct ? `<div class="rq-opt answer"><span class="rq-icon"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg></span> Правильно: ${esc(q.correct_text||'')}</div>` : ''}
|
||
</div>`;
|
||
} else if (q.type === 'matching') {
|
||
let pairs = {}; try { pairs = JSON.parse(q.answer_text||'{}'); } catch {}
|
||
answerHtml = '<div class="rq-opts">' + q.options.map(o => {
|
||
const given = pairs[String(o.id)], ok = given === o.match_pair;
|
||
return `<div class="rq-opt ${ok?'answer':given?'chosen':''}">
|
||
<span class="rq-icon">${ok?'<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>':given?'<svg class="ic" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>':'<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg>'}</span>
|
||
${esc(o.text)} <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> <strong>${esc(given||'—')}</strong>${!ok&&o.match_pair?` <span style="color:var(--green)">(${esc(o.match_pair)})</span>`:''}
|
||
</div>`;
|
||
}).join('') + '</div>';
|
||
} else {
|
||
let chosen = q.type === 'multi'
|
||
? (()=>{try{return JSON.parse(q.answer_text||'[]');}catch{return [];}})()
|
||
: (q.chosen_option_id ? [q.chosen_option_id] : []);
|
||
answerHtml = '<div class="rq-opts">' + q.options.map(o => {
|
||
const ic = chosen.includes(o.id), ir = o.is_correct;
|
||
const cls = ic&&ir?'both':ic?'chosen':ir?'answer':'';
|
||
const icon = ic&&ir?'<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>':ic?'<svg class="ic" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>':ir?'<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>':'<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg>';
|
||
return cls
|
||
? `<div class="rq-opt ${cls}"><span class="rq-icon">${icon}</span>${esc(o.text)}</div>`
|
||
: `<div class="rq-opt" style="color:var(--text-3)">${esc(o.text)}</div>`;
|
||
}).join('') + '</div>';
|
||
}
|
||
return `<div class="rq-item ${status}">
|
||
<div class="rq-num" style="color:${statusColor}">${statusIcon} Вопрос ${idx+1}</div>
|
||
<div class="rq-text">${esc(q.text)}</div>
|
||
${answerHtml}
|
||
${q.explanation?`<div class="rq-expl"><strong>Пояснение:</strong> ${esc(q.explanation)}</div>`:''}
|
||
</div>`;
|
||
}).join('');
|
||
} catch (e) { listEl.innerHTML = `<div class="error">${esc(e.message)}</div>`; }
|
||
}
|
||
|
||
function resDrillBack() {
|
||
document.getElementById('res-drill').classList.remove('open');
|
||
document.getElementById('restab-students').style.display = '';
|
||
document.getElementById('restab-questions').style.display = 'none';
|
||
const hasTabs = _currentResultsData?.results?.some(r => r.session_status === 'completed');
|
||
document.getElementById('res-tabs').style.display = hasTabs ? '' : 'none';
|
||
document.getElementById('res-csv-btn').style.display = hasTabs ? '' : 'none';
|
||
document.querySelector('.results-box').scrollTop = 0;
|
||
if (window.lucide) lucide.createIcons();
|
||
}
|
||
|
||
/* ══ Объявления ══════════════════════════════════════════════════════ */
|
||
async function loadAnnouncements() {
|
||
if (!currentClass) return;
|
||
const el = document.getElementById('announce-list');
|
||
el.innerHTML = '<div class="spinner"></div>';
|
||
try {
|
||
const items = await LS.getAnnouncements(currentClass.id);
|
||
if (!items.length) { el.innerHTML = '<div class="empty">Объявлений нет</div>'; return; }
|
||
el.innerHTML = items.map(a => `
|
||
<div class="ann-item">
|
||
<div class="ann-body">
|
||
<div class="ann-author">${esc(a.author_name)}</div>
|
||
<div class="ann-text">${esc(a.text)}</div>
|
||
<div class="ann-date">${parseDate(a.created_at).toLocaleString('ru',{day:'numeric',month:'short',hour:'2-digit',minute:'2-digit'})}</div>
|
||
</div>
|
||
<button class="ann-del" onclick="delAnnouncement(${a.id})" title="Удалить"><svg class="ic" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||
</div>`).join('');
|
||
} catch (e) { el.innerHTML = `<div class="error">${esc(e.message)}</div>`; }
|
||
}
|
||
|
||
async function sendAnnouncement() {
|
||
const text = document.getElementById('announce-text').value.trim();
|
||
if (!text) return;
|
||
const btn = document.getElementById('announce-send-btn');
|
||
btn.disabled = true;
|
||
try {
|
||
await LS.createAnnouncement(currentClass.id, text);
|
||
document.getElementById('announce-text').value = '';
|
||
await loadAnnouncements();
|
||
LS.toast('Объявление отправлено', 'success');
|
||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
finally { btn.disabled = false; }
|
||
}
|
||
|
||
async function delAnnouncement(id) {
|
||
if (!await LS.confirm('Объявление будет удалено для всех студентов.', { title: 'Удалить объявление?', confirmText: 'Удалить', danger: true })) return;
|
||
try {
|
||
await LS.deleteAnnouncement(currentClass.id, id);
|
||
await loadAnnouncements();
|
||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
/* ══ Работы учеников (submissions) ══════════════════════════════════ */
|
||
let _worksFilter = 'all', _worksData = [];
|
||
|
||
async function loadClassWorks() {
|
||
if (!currentClass) return;
|
||
const el = document.getElementById('works-content');
|
||
el.innerHTML = '<div class="spinner"></div>';
|
||
try {
|
||
_worksData = await LS.getClassSubmissions(currentClass.id);
|
||
renderWorks();
|
||
} catch (e) { el.innerHTML = `<div class="error">${esc(e.message)}</div>`; }
|
||
}
|
||
|
||
function renderWorks() {
|
||
const el = document.getElementById('works-content');
|
||
let list = _worksData;
|
||
if (_worksFilter === 'new') list = list.filter(w => w.status === 'new');
|
||
if (_worksFilter === 'reviewed') list = list.filter(w => w.status === 'reviewed');
|
||
|
||
const newCount = _worksData.filter(w => w.status === 'new').length;
|
||
const reviewedCount = _worksData.filter(w => w.status === 'reviewed').length;
|
||
|
||
if (!_worksData.length) {
|
||
el.innerHTML = '<div class="empty">Работ нет. Ученики ещё не прикрепляли файлы.</div>';
|
||
return;
|
||
}
|
||
|
||
el.innerHTML = `
|
||
<div class="works-filters">
|
||
<button class="works-chip${_worksFilter==='all'?' active':''}" onclick="setWorksFilter('all')">Все (${_worksData.length})</button>
|
||
<button class="works-chip${_worksFilter==='new'?' active':''}" onclick="setWorksFilter('new')">На проверке (${newCount})</button>
|
||
<button class="works-chip${_worksFilter==='reviewed'?' active':''}" onclick="setWorksFilter('reviewed')">Проверено (${reviewedCount})</button>
|
||
</div>
|
||
<div id="works-list">
|
||
${!list.length ? '<div class="empty">Нет работ в этой категории.</div>' : list.map(w => `
|
||
<div class="work-card ${w.status}" onclick="openReviewModal(${w.id})">
|
||
<div class="work-icon"><i data-lucide="file-text" style="width:18px;height:18px"></i></div>
|
||
<div class="work-body">
|
||
<div class="work-student">${esc(w.student_name)}</div>
|
||
${w.assignment_title ? `<div class="work-assign">${esc(w.assignment_title)}</div>` : ''}
|
||
<div class="work-file"><i data-lucide="paperclip" style="width:11px;height:11px;vertical-align:-1px;margin-right:3px"></i>${esc(w.original_name)}</div>
|
||
${w.message ? `<div class="work-msg">"${esc(w.message)}"</div>` : ''}
|
||
${w.teacher_note ? `<div class="work-note"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> ${esc(w.teacher_note)}</div>` : ''}
|
||
</div>
|
||
<div class="work-meta">
|
||
<div style="display:flex;align-items:center;gap:6px;justify-content:flex-end;margin-bottom:4px">
|
||
<div class="work-status ${w.status}" style="margin-bottom:0">${w.status === 'reviewed' ? LS.icon('check-circle',14)+' Проверено' : LS.icon('clock',14)+' На проверке'}</div>
|
||
${w.grade != null ? `<span class="work-grade ${w.grade>=80?'high':w.grade>=50?'mid':'low'}">${w.grade}</span>` : ''}
|
||
</div>
|
||
<div>${parseDate(w.submitted_at).toLocaleDateString('ru',{day:'numeric',month:'short'})}</div>
|
||
<button class="work-del-btn" onclick="event.stopPropagation();deleteClassWork(${w.id})" title="Удалить работу"><i data-lucide="trash-2" style="width:13px;height:13px"></i></button>
|
||
</div>
|
||
</div>`).join('')}
|
||
</div>`;
|
||
if (window.lucide) lucide.createIcons();
|
||
}
|
||
|
||
function setWorksFilter(f) {
|
||
_worksFilter = f;
|
||
renderWorks();
|
||
}
|
||
|
||
async function deleteClassWork(id) {
|
||
if (!await LS.confirm('Удалить работу ученика? Действие записывается в журнал.', { title: 'Удаление работы', confirmText: 'Удалить', danger: true })) return;
|
||
try {
|
||
await LS.deleteSubmission(id);
|
||
_worksData = _worksData.filter(w => w.id !== id);
|
||
renderWorks();
|
||
LS.toast('Работа удалена', 'info');
|
||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||
}
|
||
|
||
let _reviewSubId = null;
|
||
|
||
function gradeLetter(g) {
|
||
if (g == null) return '—';
|
||
if (g >= 90) return 'A';
|
||
if (g >= 75) return 'B';
|
||
if (g >= 60) return 'C';
|
||
if (g >= 40) return 'D';
|
||
return 'F';
|
||
}
|
||
|
||
function updateGradeLetter() {
|
||
const val = document.getElementById('rv-grade').value;
|
||
const g = val === '' ? null : Number(val);
|
||
const el = document.getElementById('rv-grade-letter');
|
||
const letter = gradeLetter(g);
|
||
el.textContent = letter;
|
||
el.style.background = g == null ? 'rgba(155,93,229,0.08)' :
|
||
g >= 80 ? 'rgba(5,150,82,0.12)' : g >= 50 ? 'rgba(255,193,7,0.14)' : 'rgba(241,91,181,0.12)';
|
||
el.style.color = g == null ? 'var(--violet)' :
|
||
g >= 80 ? '#059652' : g >= 50 ? '#c07c00' : '#c0306a';
|
||
}
|
||
|
||
function openReviewModal(subId) {
|
||
const w = _worksData.find(x => x.id === subId);
|
||
if (!w) return;
|
||
_reviewSubId = subId;
|
||
document.getElementById('rv-student').textContent = w.student_name + (w.student_email ? ` · ${w.student_email}` : '');
|
||
document.getElementById('rv-assign').textContent = w.assignment_title ? `Задание: ${w.assignment_title}` : '';
|
||
document.getElementById('rv-file-name').textContent = w.original_name;
|
||
document.getElementById('rv-file-link').href = LS.submissionDownloadUrl(subId);
|
||
const msgEl = document.getElementById('rv-message');
|
||
if (w.message) { msgEl.textContent = `"${w.message}"`; msgEl.style.display = ''; }
|
||
else msgEl.style.display = 'none';
|
||
document.getElementById('rv-note').value = w.teacher_note || '';
|
||
document.getElementById('rv-grade').value = w.grade != null ? w.grade : '';
|
||
updateGradeLetter();
|
||
const btn = document.getElementById('btn-mark-reviewed');
|
||
btn.textContent = w.status === 'reviewed' ? 'Обновить' : 'Отметить проверенным';
|
||
document.getElementById('review-modal').classList.add('open');
|
||
if (window.lucide) lucide.createIcons();
|
||
}
|
||
|
||
function closeReviewModal() {
|
||
document.getElementById('review-modal').classList.remove('open');
|
||
}
|
||
|
||
async function doMarkReviewed() {
|
||
if (!_reviewSubId) return;
|
||
const note = document.getElementById('rv-note').value.trim();
|
||
const gradeRaw = document.getElementById('rv-grade').value;
|
||
const grade = gradeRaw === '' ? null : Number(gradeRaw);
|
||
const btn = document.getElementById('btn-mark-reviewed');
|
||
btn.disabled = true;
|
||
try {
|
||
await LS.reviewSubmission(_reviewSubId, { status: 'reviewed', teacher_note: note || null, grade });
|
||
const w = _worksData.find(x => x.id === _reviewSubId);
|
||
if (w) { w.status = 'reviewed'; w.teacher_note = note || null; w.grade = grade; }
|
||
closeReviewModal();
|
||
LS.toast('Работа проверена' + (grade != null ? ` · ${grade}/100 (${gradeLetter(grade)})` : ''), 'success');
|
||
renderWorks();
|
||
} catch (e) {
|
||
LS.toast('Ошибка: ' + e.message, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
/* ══ Уведомления — handled by notifications.js ══ */
|
||
|
||
/* ══ Teacher Dashboard ══════════════════════════════════════════════ */
|
||
function renderDashboard(d) {
|
||
const { members, assignments } = d;
|
||
const totalMembers = members.length;
|
||
const totalAssigns = assignments.length;
|
||
const avgPct = members.length
|
||
? Math.round(members.filter(m => m.avg_pct !== null).reduce((s, m) => s + (m.avg_pct || 0), 0) / (members.filter(m => m.avg_pct !== null).length || 1))
|
||
: null;
|
||
|
||
// Find lagging students: those with 0 completions across all assignments
|
||
const lagging = members.filter(m => m.tests_count === 0);
|
||
|
||
let html = `<div class="dash-stat-row">
|
||
<div class="dash-stat-card"><div class="dash-stat-val">${totalMembers}</div><div class="dash-stat-lbl">Учеников</div></div>
|
||
<div class="dash-stat-card"><div class="dash-stat-val">${totalAssigns}</div><div class="dash-stat-lbl">Заданий</div></div>
|
||
<div class="dash-stat-card"><div class="dash-stat-val">${avgPct !== null ? avgPct + '%' : '—'}</div><div class="dash-stat-lbl">Средний балл</div></div>
|
||
</div>`;
|
||
|
||
if (assignments.length) {
|
||
html += `<div style="font-family:'Unbounded',sans-serif;font-size:0.75rem;font-weight:700;color:var(--text-3);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px">Выполнение заданий</div>
|
||
<div style="border:1.5px solid var(--border);border-radius:14px;overflow:hidden;margin-bottom:20px">`;
|
||
assignments.forEach(a => {
|
||
const pct = totalMembers ? Math.round(a.completed_count / totalMembers * 100) : 0;
|
||
html += `<div class="dash-assign-row" style="padding:10px 16px">
|
||
<div style="flex:1;min-width:0">
|
||
<div style="font-size:0.85rem;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(a.title)}</div>
|
||
<div style="font-size:0.72rem;color:var(--text-3);margin-top:2px">${a.completed_count} из ${totalMembers} сдали</div>
|
||
</div>
|
||
<div class="dash-assign-bar-wrap" style="max-width:120px">
|
||
<div class="dash-assign-bar" style="width:${pct}%"></div>
|
||
</div>
|
||
<div style="min-width:36px;text-align:right;font-family:'Unbounded',sans-serif;font-size:0.78rem;font-weight:800;color:${pct>=75?'var(--green)':pct>=40?'var(--amber)':'var(--pink)'}">${pct}%</div>
|
||
</div>`;
|
||
});
|
||
html += '</div>';
|
||
}
|
||
|
||
if (lagging.length) {
|
||
html += `<div style="font-family:'Unbounded',sans-serif;font-size:0.75rem;font-weight:700;color:var(--text-3);text-transform:uppercase;letter-spacing:.06em;margin-bottom:10px">Не приступали к тестам</div>
|
||
<div style="display:flex;flex-wrap:wrap;gap:8px">`;
|
||
lagging.forEach(m => {
|
||
html += `<span class="dash-lagging">${esc(m.name)}</span>`;
|
||
});
|
||
html += '</div>';
|
||
} else if (totalMembers > 0) {
|
||
html += `<div style="color:var(--green);font-size:0.84rem;font-weight:600"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Все ученики уже проходили тесты</div>`;
|
||
}
|
||
|
||
if (!totalMembers) {
|
||
html = `<div class="empty">В классе нет учеников. Поделитесь кодом приглашения.</div>`;
|
||
}
|
||
|
||
document.getElementById('dash-content').innerHTML = html;
|
||
}
|
||
|
||
async function loadClassDashboard() {
|
||
if (!currentClass) return;
|
||
renderDashboard(currentClass);
|
||
}
|
||
|
||
/* ══ Journal ════════════════════════════════════════════════════════ */
|
||
async function loadJournal() {
|
||
if (!currentClass) return;
|
||
const el = document.getElementById('journal-content');
|
||
el.innerHTML = '<div class="spinner"></div>';
|
||
try {
|
||
const { members, assignments, results } = await LS.classJournal(currentClass.id);
|
||
if (!assignments.length || !members.length) {
|
||
el.innerHTML = '<div class="empty">Нет данных для журнала. Добавьте учеников и задания.</div>';
|
||
return;
|
||
}
|
||
// Build lookup map: [userId][assignmentId] = percent
|
||
const map = {};
|
||
results.forEach(r => {
|
||
if (!map[r.user_id]) map[r.user_id] = {};
|
||
map[r.user_id][r.assignment_id] = r.percent;
|
||
});
|
||
|
||
const avgRow = {};
|
||
assignments.forEach(a => {
|
||
const vals = members.map(m => map[m.id]?.[a.id]).filter(v => v != null);
|
||
avgRow[a.id] = vals.length ? Math.round(vals.reduce((s,v)=>s+v,0)/vals.length) : null;
|
||
});
|
||
|
||
let th = '<th>Ученик</th>';
|
||
assignments.forEach(a => { th += `<th title="${esc(a.title)}">${esc(a.title.length > 16 ? a.title.slice(0,14)+'…' : a.title)}</th>`; });
|
||
th += '<th>Средний</th>';
|
||
|
||
const rows = members.map(m => {
|
||
let cells = `<td>${esc(m.name)}</td>`;
|
||
let sum = 0, cnt = 0;
|
||
assignments.forEach(a => {
|
||
const v = map[m.id]?.[a.id];
|
||
if (v != null) { sum += v; cnt++; }
|
||
cells += `<td>${v != null ? `<span class="${v>=75?'j-cell-hi':v>=50?'j-cell-mid':'j-cell-lo'}">${v}%</span>` : '<span class="j-cell-none">—</span>'}</td>`;
|
||
});
|
||
const avg = cnt ? Math.round(sum/cnt) : null;
|
||
cells += `<td>${avg != null ? `<span class="${avg>=75?'j-cell-hi':avg>=50?'j-cell-mid':'j-cell-lo'}">${avg}%</span>` : '<span class="j-cell-none">—</span>'}</td>`;
|
||
return `<tr>${cells}</tr>`;
|
||
}).join('');
|
||
|
||
const avgCells = assignments.map(a => {
|
||
const v = avgRow[a.id];
|
||
return `<td>${v != null ? `<span class="${v>=75?'j-cell-hi':v>=50?'j-cell-mid':'j-cell-lo'}">${v}%</span>` : '<span class="j-cell-none">—</span>'}</td>`;
|
||
}).join('');
|
||
|
||
el.innerHTML = `<div class="journal-wrap">
|
||
<table class="journal-table">
|
||
<thead><tr>${th}</tr></thead>
|
||
<tbody>${rows}
|
||
<tr style="font-weight:700;background:rgba(238,242,255,0.5)">
|
||
<td style="font-size:0.75rem;color:var(--text-3);font-family:'Unbounded',sans-serif;font-weight:700">Средний</td>${avgCells}<td></td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>`;
|
||
} catch (e) { el.innerHTML = `<div class="error">${esc(e.message)}</div>`; }
|
||
}
|
||
|
||
/* ══ Settings ═══════════════════════════════════════════════════════ */
|
||
function loadSettings() {
|
||
if (!currentClass) return;
|
||
document.getElementById('set-name').value = currentClass.name || '';
|
||
document.getElementById('set-desc').value = currentClass.description || '';
|
||
const parsed = parseIconValue(currentClass.cover_emoji);
|
||
_selectedIcon = parsed.icon;
|
||
_selectedColor = parsed.color;
|
||
renderIconPreview('set-icon-preview');
|
||
renderIconPicker('set-icon-picker', 'set-icon-preview');
|
||
renderColorPicker('set-color-picker', 'set-icon-preview');
|
||
document.getElementById('set-invite-code').textContent = currentClass.invite_code || '—';
|
||
// Populate module toggles (all enabled by default if no features set)
|
||
const f = currentClass.features || {};
|
||
const FEATS = ['gamification', 'collection', 'hangman', 'crossword', 'red_book', 'pet'];
|
||
for (const key of FEATS) {
|
||
const el = document.getElementById('feat-' + key);
|
||
if (el) el.checked = f[key] !== false; // default enabled
|
||
}
|
||
}
|
||
|
||
async function saveModules() {
|
||
if (!currentClass) return;
|
||
const FEATS = ['gamification', 'collection', 'hangman', 'crossword', 'red_book', 'pet'];
|
||
const features = {};
|
||
for (const key of FEATS) {
|
||
const el = document.getElementById('feat-' + key);
|
||
if (el) features[key] = el.checked;
|
||
}
|
||
try {
|
||
const updated = await LS.updateClass(currentClass.id, { features });
|
||
currentClass = { ...currentClass, ...updated };
|
||
LS.clearFeaturesCache?.(); // force re-check on next navigation
|
||
LS.toast('Модули сохранены', 'success');
|
||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
async function saveClassSettings() {
|
||
if (!currentClass) return;
|
||
const name = document.getElementById('set-name').value.trim();
|
||
const description = document.getElementById('set-desc').value.trim();
|
||
if (!name) return LS.toast('Введите название', 'warn');
|
||
try {
|
||
const updated = await LS.updateClass(currentClass.id, { name, description, cover_emoji: encodeIconValue(_selectedIcon, _selectedColor) });
|
||
currentClass = { ...currentClass, ...updated };
|
||
document.getElementById('d-name').textContent = updated.name;
|
||
document.getElementById('d-sub').textContent = (updated.description || '') + ' · Код: ' + updated.invite_code;
|
||
// Update in classes list
|
||
const idx = classes.findIndex(c => c.id === currentClass.id);
|
||
if (idx >= 0) classes[idx] = { ...classes[idx], ...updated };
|
||
renderClasses();
|
||
LS.toast('Настройки сохранены', 'success');
|
||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
async function doRegenerateCode() {
|
||
if (!currentClass) return;
|
||
if (!await LS.confirm('Старый код приглашения перестанет работать. Продолжить?', { title: 'Обновить код', confirmText: 'Обновить' })) return;
|
||
try {
|
||
const { invite_code } = await LS.regenerateInviteCode(currentClass.id);
|
||
currentClass.invite_code = invite_code;
|
||
document.getElementById('set-invite-code').textContent = invite_code;
|
||
document.getElementById('d-sub').textContent = (currentClass.description || '') + ' · Код: ' + invite_code;
|
||
LS.toast('Код обновлён: ' + invite_code, 'success');
|
||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
/* ══ Theory tab ══════════════════════════════════════════════════════ */
|
||
let _theoryLoaded = false;
|
||
|
||
async function loadClassTheory() {
|
||
if (!currentClass) return;
|
||
if (_theoryLoaded === currentClass.id) return;
|
||
_theoryLoaded = currentClass.id;
|
||
const el = document.getElementById('theory-content');
|
||
el.innerHTML = '<div class="spinner"></div>';
|
||
try {
|
||
const courses = await LS.api('/api/courses/class/' + currentClass.id);
|
||
const allCourses = await LS.api('/api/courses');
|
||
|
||
const BANNER_CLASS = { bio:'cc-banner-bio', chem:'cc-banner-chem', math:'cc-banner-math', phys:'cc-banner-phys' };
|
||
const SUBJ_LABEL = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика' };
|
||
|
||
const assignedIds = new Set((courses || []).map(c => c.id));
|
||
const available = (allCourses || []).filter(c => !assignedIds.has(c.id));
|
||
|
||
let html = `<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap;gap:8px">
|
||
<div style="font-family:'Unbounded',sans-serif;font-size:0.82rem;font-weight:800;color:#0F172A">
|
||
Курсы класса <span style="color:var(--text-3);font-weight:600">(${(courses||[]).length})</span>
|
||
</div>
|
||
${available.length ? `<button class="btn-ghost" onclick="openAssignCourseModal()" style="font-size:0.8rem">
|
||
<i data-lucide="plus" style="width:13px;height:13px;vertical-align:-2px"></i> Добавить курс
|
||
</button>` : ''}
|
||
</div>`;
|
||
|
||
if (!(courses||[]).length) {
|
||
html += `<div style="text-align:center;padding:40px;color:var(--text-3);font-size:0.85rem;background:#fff;border-radius:16px;border:1.5px dashed rgba(155,93,229,0.15)">
|
||
Нет назначенных курсов. Нажмите «Добавить курс», чтобы назначить курс классу.
|
||
</div>`;
|
||
} else {
|
||
html += `<div style="display:flex;flex-direction:column;gap:10px">` +
|
||
(courses || []).map(c => {
|
||
const bannerClass = BANNER_CLASS[c.subjectSlug] || 'cc-banner-other';
|
||
const pct = c.lessonCount > 0 ? Math.round(c.doneCount / c.lessonCount * 100) : 0;
|
||
return `<div style="background:#fff;border:1.5px solid rgba(15,23,42,0.07);border-radius:16px;padding:14px 18px;display:flex;align-items:center;gap:14px">
|
||
<a href="/course?id=${c.id}" style="flex:1;display:flex;align-items:center;gap:12px;text-decoration:none;color:inherit;min-width:0">
|
||
<span style="font-size:1.6rem">${c.coverEmoji || LS.icon('book-open',20)}</span>
|
||
<div style="flex:1;min-width:0">
|
||
<div style="font-size:0.9rem;font-weight:700;color:#0F172A;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(c.title)}</div>
|
||
<div style="font-size:0.74rem;color:var(--text-3);margin-top:2px">${esc(SUBJ_LABEL[c.subjectSlug] || c.subjectSlug || '')} · ${c.lessonCount} уроков</div>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:8px;flex-shrink:0">
|
||
<div style="width:80px;height:5px;border-radius:99px;background:rgba(15,23,42,0.07)">
|
||
<div style="height:100%;border-radius:99px;background:var(--violet);width:${pct}%"></div>
|
||
</div>
|
||
<span style="font-size:0.72rem;font-weight:700;color:var(--violet)">${pct}%</span>
|
||
</div>
|
||
</a>
|
||
<button onclick="unassignCourse(${c.id})" title="Убрать из класса"
|
||
style="width:28px;height:28px;border-radius:8px;border:none;background:rgba(239,71,111,0.08);color:#EF476F;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0">
|
||
<i data-lucide="x" style="width:13px;height:13px"></i>
|
||
</button>
|
||
</div>`;
|
||
}).join('') + `</div>`;
|
||
}
|
||
|
||
el.innerHTML = html;
|
||
if (window.lucide) lucide.createIcons();
|
||
|
||
// Store available for modal
|
||
el._available = available;
|
||
} catch (e) {
|
||
el.innerHTML = `<div class="empty">Ошибка загрузки: ${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
async function unassignCourse(courseId) {
|
||
if (!currentClass) return;
|
||
if (!await LS.confirm('Убрать курс из класса?', { title: 'Убрать курс', confirmText: 'Убрать', danger: true })) return;
|
||
try {
|
||
await LS.api('/api/courses/class/' + currentClass.id + '/' + courseId, { method: 'DELETE' });
|
||
_theoryLoaded = null;
|
||
loadClassTheory();
|
||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||
}
|
||
|
||
function openAssignCourseModal() {
|
||
const el = document.getElementById('theory-content');
|
||
const available = el._available || [];
|
||
if (!available.length) { LS.toast('Нет доступных курсов для назначения', 'warn'); return; }
|
||
const SUBJ_LABEL = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика' };
|
||
const sel = document.createElement('select');
|
||
sel.id = 'assign-course-select';
|
||
sel.style.cssText = 'width:100%;padding:10px 12px;border:1.5px solid rgba(15,23,42,0.15);border-radius:12px;font-family:Manrope,sans-serif;font-size:0.92rem;margin-bottom:16px';
|
||
available.forEach(c => {
|
||
const o = document.createElement('option');
|
||
o.value = c.id;
|
||
o.textContent = (SUBJ_LABEL[c.subjectSlug] || c.subjectSlug || '') + ' · ' + c.title;
|
||
sel.appendChild(o);
|
||
});
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(15,23,42,0.4);backdrop-filter:blur(6px);z-index:400;display:flex;align-items:center;justify-content:center;padding:20px';
|
||
overlay.innerHTML = `<div style="background:#fff;border-radius:24px;padding:32px;width:100%;max-width:440px;box-shadow:0 32px 80px rgba(15,23,42,0.22)">
|
||
<div style="font-family:'Unbounded',sans-serif;font-size:0.92rem;font-weight:800;margin-bottom:20px">Назначить курс классу</div>
|
||
<div id="assign-course-sel-wrap"></div>
|
||
<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:4px">
|
||
<button id="assign-cancel-btn" style="padding:10px 22px;border:1.5px solid rgba(15,23,42,0.15);border-radius:999px;background:transparent;font-family:Manrope,sans-serif;font-size:0.88rem;font-weight:600;color:var(--text-3);cursor:pointer">Отмена</button>
|
||
<button id="assign-confirm-btn" style="padding:10px 28px;border:none;border-radius:999px;background:var(--violet);color:#fff;font-family:Manrope,sans-serif;font-size:0.88rem;font-weight:700;cursor:pointer">Назначить</button>
|
||
</div>
|
||
</div>`;
|
||
overlay.querySelector('#assign-course-sel-wrap').appendChild(sel);
|
||
document.body.appendChild(overlay);
|
||
overlay.querySelector('#assign-cancel-btn').onclick = () => overlay.remove();
|
||
overlay.onclick = e => { if (e.target === overlay) overlay.remove(); };
|
||
overlay.querySelector('#assign-confirm-btn').onclick = async () => {
|
||
const courseId = parseInt(sel.value);
|
||
if (!courseId) return;
|
||
try {
|
||
await LS.api('/api/courses/class/' + currentClass.id + '/assign', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ courseId }),
|
||
});
|
||
overlay.remove();
|
||
_theoryLoaded = null;
|
||
loadClassTheory();
|
||
} catch (e) { alert(e.message || 'Ошибка назначения'); }
|
||
};
|
||
}
|
||
|
||
/* ══ Modal helpers ══ */
|
||
function openModal(id) { document.getElementById(id).classList.add('open'); }
|
||
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
|
||
function closeOnOverlay(e, id) { if (e.target === document.getElementById(id)) closeModal(id); }
|
||
|
||
/* ══ Personal assignments panel ══ */
|
||
async function openPersonalAssignments() {
|
||
document.querySelectorAll('.cl-item').forEach(c => c.classList.remove('active'));
|
||
document.getElementById('cc-personal').classList.add('active');
|
||
currentClass = null;
|
||
|
||
document.getElementById('cl-placeholder').style.display = 'none';
|
||
document.getElementById('detail-panel').style.display = 'none';
|
||
|
||
const panel = document.getElementById('personal-panel');
|
||
panel.classList.remove('panel-visible');
|
||
panel.style.display = 'block';
|
||
void panel.offsetWidth;
|
||
panel.classList.add('panel-visible');
|
||
|
||
document.getElementById('personal-list').innerHTML = '<div class="spinner"></div>';
|
||
try {
|
||
const all = await LS.teacherAssignments();
|
||
const list = all.filter(a => a.class_id === 0);
|
||
renderPersonalList(list);
|
||
updatePaSidebarCount(list.length);
|
||
} catch (e) {
|
||
document.getElementById('personal-list').innerHTML = `<div class="error">${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function updatePaSidebarCount(n) {
|
||
const s = n === 0 ? 'нет заданий'
|
||
: n % 10 === 1 && n % 100 !== 11 ? n + ' задание'
|
||
: n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? n + ' задания'
|
||
: n + ' заданий';
|
||
document.getElementById('pa-sidebar-count').textContent = s;
|
||
}
|
||
|
||
function renderPersonalList(list) {
|
||
const el = document.getElementById('personal-list');
|
||
if (!list.length) {
|
||
el.innerHTML = `<div class="empty" style="padding:30px 0">
|
||
Личных заданий пока нет.<br>
|
||
<span style="font-size:0.82rem;color:var(--text-3)">
|
||
Назначьте задание конкретному ученику: выберите класс → «+ Задание» → в поле «Кому» выберите ученика.
|
||
</span>
|
||
</div>`;
|
||
return;
|
||
}
|
||
el.innerHTML = list.map(a => {
|
||
const subject = SUBJECTS[a.subject_slug] || a.subject_slug;
|
||
const done = (a.completed_count || 0) >= 1;
|
||
const statusHtml = done
|
||
? `<span class="pa-status-done"><svg class="ic" viewBox="0 0 24 24" style="width:11px;height:11px;stroke-width:2.5"><polyline points="20 6 9 17 4 12"/></svg> Выполнено</span>`
|
||
: `<span class="pa-status-pending"><svg class="ic" viewBox="0 0 24 24" style="width:11px;height:11px;stroke-width:2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> Ожидает</span>`;
|
||
const meta = [subject, a.mode ? (MODES[a.mode] || a.mode) : null, a.count ? a.count + ' вопр.' : null].filter(Boolean).join(' · ');
|
||
const safeTitle = esc(a.title).replace(/'/g, "\\'");
|
||
return `<div class="pa-item">
|
||
<div style="flex:1;min-width:0">
|
||
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-bottom:6px">
|
||
<span class="pa-student">
|
||
<svg class="ic" viewBox="0 0 24 24" style="width:10px;height:10px;stroke-width:2;fill:none"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||
${esc(a.target_user_name || '—')}
|
||
</span>
|
||
${statusHtml}
|
||
${deadlineBadge(a.deadline)}
|
||
</div>
|
||
<div class="assign-title" style="font-size:0.9rem">${esc(a.title)}</div>
|
||
<div class="assign-meta">${meta}</div>
|
||
</div>
|
||
<div style="display:flex;gap:8px;align-items:center;flex-shrink:0;margin-top:2px">
|
||
${!a.file_id ? `<button class="btn-ghost" style="font-size:0.78rem;padding:6px 14px" onclick="showResults(${a.id},'${safeTitle}')">Результаты</button>` : ''}
|
||
<button class="btn-danger" onclick="removePersonalAssignment(${a.id})">Удалить</button>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
if (window.lucide) lucide.createIcons();
|
||
}
|
||
|
||
async function removePersonalAssignment(id) {
|
||
if (!await LS.confirm('Удалить задание?', { title: 'Удалить задание', confirmText: 'Удалить' })) return;
|
||
try {
|
||
await LS.deleteAssignment(id);
|
||
toast('Задание удалено');
|
||
openPersonalAssignments();
|
||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
loadClasses();
|
||
loadStudents(); // preload for student search
|
||
LS.notif.init();
|
||
|
||
// Real-time SSE for page-specific events
|
||
LS.connectSSE(ev => {
|
||
if (ev.type === 'session') {
|
||
LS.toast(ev.message, 'info');
|
||
if (currentClass) openClass(currentClass.id); // refresh assignment progress
|
||
} else if (ev.type === 'join') {
|
||
LS.toast(ev.message, 'info');
|
||
loadClasses();
|
||
}
|
||
});
|
||
|
||
if (window.lucide) lucide.createIcons();
|
||
</script>
|
||
</div>
|
||
<script src="/js/search.js"></script>
|
||
<script src="/js/mobile.js"></script>
|
||
</body>
|
||
</html>
|