feat(dashboard): teacher view polish — chips, bars, KPIs, groups, mobile

P0 visual polish:
- adm-actions: grouped layout (teaching/content/admin) with 3-col grid at wide, responsive
- thick 8px colored progress bars (green ≥75 / amber 50-74 / pink <50)
- session % rendered as colored chip (tinted bg + border)
- hover state on .adm-sess-row and .asgn-row in admin-grid
- empty states with Lucide icon + CTA button (inbox/users/clock)
- class-name badge on assignment row (disambiguates duplicates)
- relative timestamp on session rows via relativeAgo()
- search input above assignment list (filterAdminAssignments())
- adm-act-icon bumped 16px → 20px; card hover: scale + shadow

P1 header KPIs + urgency:
- dh-kpi-row: classes / students / active-asgn / pending chips under greeting
- isTeacherUrgent(): assignments within 48h get pink border + срочно badge
- adm-act-badge: count badge on Мои классы and Работы cards
- loadTeacherKPIs() fetches /api/classes + teacherAssignments() in parallel

P2 grouping + mobile + micro:
- chevron-right icons on Все/Все классы section links
- mobile ≤640: single-column groups, KPI chips wrap, sess-rows wrap
- mobile ≤480: adm-act-group single column
- dark mode rules for new elements

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-17 15:25:34 +03:00
parent d1d20c4c86
commit d3b1cd75a0
+260 -43
View File
@@ -418,22 +418,31 @@
.admin-grid .widget { padding: 18px; border-radius: 16px; }
.admin-grid .w-head { margin-bottom: 10px; }
/* Quick-action buttons grid */
.adm-actions { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
/* Quick-action buttons grid — grouped layout */
.adm-actions { display: grid; grid-template-columns: 2fr 2fr 1fr; gap: 16px; }
.adm-act-group { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; }
@media (max-width: 900px) { .adm-actions { grid-template-columns: 1fr 1fr; } }
@media (max-width: 640px) { .adm-actions { grid-template-columns: 1fr; } }
.adm-act {
display: flex; align-items: center; gap: 10px;
padding: 12px 14px; border-radius: 12px;
border: 1.5px solid rgba(15,23,42,0.07); background: #fff;
font-family: 'Manrope', sans-serif; font-size: 0.8rem; font-weight: 700;
color: #0F172A; cursor: pointer; transition: all 0.15s;
text-decoration: none;
color: #0F172A; cursor: pointer; transition: all 0.18s;
text-decoration: none; position: relative;
}
.adm-act:hover { border-color: rgba(155,93,229,0.25); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(15,23,42,0.08); }
.adm-act:hover { border-color: rgba(155,93,229,0.25); transform: translateY(-2px) scale(1.01); box-shadow: 0 6px 20px rgba(15,23,42,0.10); }
.adm-act-icon {
width: 32px; height: 32px; border-radius: 8px;
width: 36px; height: 36px; border-radius: 9px;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.adm-act-icon svg, .adm-act-icon i { width: 16px; height: 16px; stroke: #fff; stroke-width: 2; }
.adm-act-icon svg, .adm-act-icon i { width: 20px; height: 20px; stroke: #fff; stroke-width: 2; }
.adm-act-badge {
position: absolute; top: 6px; right: 8px;
background: #E0335E; color: #fff; font-size: 0.62rem; font-weight: 800;
padding: 1px 5px; border-radius: 999px; font-family: 'Unbounded', sans-serif;
line-height: 1.4;
}
/* Compact stat chips row for admin header */
.adm-stat-chips { display: flex; gap: 10px; flex-shrink: 0; flex-wrap: wrap; }
@@ -468,11 +477,67 @@
.adm-sess-name { flex: 1; font-weight: 600; color: #0F172A; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.adm-sess-subj { color: var(--text-3); font-weight: 600; min-width: 60px; }
.adm-sess-pct { font-family: 'Unbounded', sans-serif; font-weight: 900; font-size: 0.74rem; min-width: 36px; text-align: right; }
/* Colored chip for session % */
.adm-sess-chip {
display: inline-flex; padding: 3px 9px; border-radius: 999px;
font-size: 0.74rem; font-weight: 700; min-width: 42px; justify-content: center;
font-family: 'Unbounded', sans-serif;
}
.adm-sess-ago { color: var(--text-3); font-size: 0.7rem; flex-shrink: 0; }
/* Hover on rows in admin-grid */
.admin-grid .adm-sess-row:hover,
.admin-grid .asgn-row:hover {
background: rgba(155,93,229,0.04);
cursor: pointer;
}
/* Thick colored progress bar */
.admin-grid .ar-prog-bar { height: 8px !important; border-radius: 4px; overflow: hidden; background: rgba(15,23,42,0.06); }
.admin-grid .ar-prog-fill { height: 100%; border-radius: 4px; }
/* Empty states */
.adm-empty { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 24px 12px; text-align: center; color: var(--text-3); }
.adm-empty i { display: block; }
.adm-empty-text { font-size: 0.82rem; }
.adm-empty-cta {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 16px; border-radius: 9px; font-size: 0.78rem; font-weight: 700;
background: var(--violet); color: #fff; text-decoration: none;
transition: opacity 0.15s;
}
.adm-empty-cta:hover { opacity: 0.85; }
/* Class badge on assignment row */
.asgn-class-badge {
display: inline-flex; padding: 1px 6px; border-radius: 6px;
font-size: 0.68rem; font-weight: 700;
background: rgba(155,93,229,0.08); color: #7c3aed;
margin-left: 4px; vertical-align: middle;
}
/* Urgency highlight */
.admin-grid .asgn-urgent { border-left: 3px solid var(--pink, #F15BB5); padding-left: 9px !important; }
.asgn-fire {
background: rgba(241,91,181,0.12); color: var(--pink, #F15BB5);
padding: 1px 7px; border-radius: 999px; font-size: 0.68rem; font-weight: 700; margin-left: 6px;
}
/* KPI row */
.dh-kpi-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 6px; }
.dh-kpi { font-size: 0.84rem; color: var(--text-3); }
.dh-kpi strong { color: var(--text); font-weight: 700; }
.dh-kpi.warn strong { color: var(--amber, #F59E0B); }
/* Admin assignment search */
.adm-asgn-search {
width: 100%; padding: 7px 10px; border: 1px solid var(--border);
border-radius: 8px; font: inherit; font-size: 0.82rem;
margin-bottom: 10px; background: var(--bg, #fff); color: var(--text);
box-sizing: border-box;
}
.adm-asgn-search:focus { outline: none; border-color: var(--violet); }
/* Dark mode for admin */
.app-layout.dark .adm-act { background: #1A1D27; border-color: rgba(255,255,255,0.06); color: #E8ECF2; }
.app-layout.dark .adm-stat-chip { background: rgba(255,255,255,0.04); border-color: rgba(255,255,255,0.06); }
.app-layout.dark .adm-sess-name { color: #E8ECF2; }
.app-layout.dark .adm-asgn-search { background: #1A1D27; color: #E8ECF2; border-color: rgba(255,255,255,0.1); }
.app-layout.dark .adm-empty-text { color: var(--text-3); }
.app-layout.dark .asgn-class-badge { background: rgba(155,93,229,0.15); color: #b07de0; }
/* C1: Teacher class summary widget */
.class-summary { display: flex; flex-direction: column; gap: 12px; }
@@ -1079,7 +1144,15 @@
.main-grid { grid-template-columns: 1fr; }
.action-cards { grid-template-columns: 1fr; }
.admin-grid { grid-template-columns: 1fr; gap: 14px; }
.adm-actions { grid-template-columns: repeat(2, 1fr); }
.adm-actions { grid-template-columns: 1fr; gap: 10px; }
.adm-act-group { grid-template-columns: 1fr 1fr; }
/* KPI chips wrap to 2 cols */
.dh-kpi-row { gap: 8px; }
/* Session rows: wrap on small screens */
.adm-sess-row { flex-wrap: wrap; }
.adm-sess-subj { min-width: unset; }
/* Assignment title: allow wrapping */
.admin-grid .ar-title { white-space: normal; }
.theory-courses-grid { grid-template-columns: 1fr; }
.subj-charts-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
@@ -1152,8 +1225,9 @@
.stat-ring { min-width: 60px; }
.dh-greeting { font-size: 0.82rem; }
/* Admin quick actions: stay 2 columns (narrow enough) */
/* Admin quick actions: narrow */
.adm-actions { gap: 8px; }
.adm-act-group { grid-template-columns: 1fr; }
.adm-act { padding: 10px 10px; font-size: 0.76rem; gap: 8px; }
/* Assignment rows tighter */
@@ -1213,6 +1287,12 @@
<div class="dh-text">
<div class="dh-greeting" id="dh-greeting">Привет, <span id="user-name"></span></div>
<div class="dh-sub" id="dh-sub">Выбери тест и начни</div>
<div class="dh-kpi-row" id="dh-kpi-row" style="display:none">
<span class="dh-kpi"><strong id="kpi-classes"></strong> классов</span>
<span class="dh-kpi"><strong id="kpi-students"></strong> учеников</span>
<span class="dh-kpi"><strong id="kpi-active-asgn"></strong> активных заданий</span>
<span class="dh-kpi warn" id="kpi-pending-wrap" style="display:none"><strong id="kpi-pending"></strong> требуют внимания</span>
</div>
</div>
<div class="dh-stats" id="dh-stats" style="display:none">
<div class="stat-ring" id="sr-sessions"></div>
@@ -1291,26 +1371,32 @@
<!-- ADMIN ZONE: Quick actions (hidden for students) -->
<div id="admin-actions-zone" style="display:none;margin-bottom:18px">
<div class="adm-actions" id="adm-actions-grid">
<a class="adm-act" href="/classes">
<div class="adm-act-icon" style="background:#9B5DE5"><i data-lucide="graduation-cap"></i></div>
Мои классы
</a>
<a class="adm-act" href="/admin">
<div class="adm-act-icon" style="background:#06B6D4"><i data-lucide="settings"></i></div>
Управление
</a>
<a class="adm-act" href="/board">
<div class="adm-act-icon" style="background:#06D6A0"><i data-lucide="layout-dashboard"></i></div>
Доска
</a>
<a class="adm-act" href="/library">
<div class="adm-act-icon" style="background:#F59E0B"><i data-lucide="book-open"></i></div>
Библиотека
</a>
<a class="adm-act" href="/homework">
<div class="adm-act-icon" style="background:#F15BB5"><i data-lucide="file-check"></i></div>
Работы
</a>
<div class="adm-act-group">
<a class="adm-act" href="/classes">
<div class="adm-act-icon" style="background:#9B5DE5"><i data-lucide="graduation-cap"></i></div>
Мои классы
</a>
<a class="adm-act" href="/homework">
<div class="adm-act-icon" style="background:#F15BB5"><i data-lucide="file-check"></i></div>
Работы
</a>
</div>
<div class="adm-act-group">
<a class="adm-act" href="/board">
<div class="adm-act-icon" style="background:#06D6A0"><i data-lucide="layout-dashboard"></i></div>
Доска
</a>
<a class="adm-act" href="/library">
<div class="adm-act-icon" style="background:#F59E0B"><i data-lucide="book-open"></i></div>
Библиотека
</a>
</div>
<div class="adm-act-group">
<a class="adm-act" href="/admin">
<div class="adm-act-icon" style="background:#06B6D4"><i data-lucide="settings"></i></div>
Управление
</a>
</div>
</div>
</div>
@@ -1320,7 +1406,7 @@
<div class="widget" id="w-admin-assignments">
<div class="w-head">
<div class="w-title">Задания</div>
<a class="w-more" href="/classes">Все классы</a>
<a class="w-more" href="/classes">Все классы <i data-lucide="chevron-right" style="width:13px;height:13px;vertical-align:-2px"></i></a>
</div>
<div id="admin-assignments-list"><div id="admin-assignments-sk"></div></div>
</div>
@@ -1328,7 +1414,7 @@
<div class="widget" id="w-admin-classes">
<div class="w-head">
<div class="w-title">Классы</div>
<a class="w-more" href="/classes">Все</a>
<a class="w-more" href="/classes">Все <i data-lucide="chevron-right" style="width:13px;height:13px;vertical-align:-2px"></i></a>
</div>
<div class="class-summary" id="admin-classes-body"></div>
</div>
@@ -1336,7 +1422,7 @@
<div class="widget" id="w-admin-sessions">
<div class="w-head">
<div class="w-title">Последние сессии</div>
<a class="w-more" href="/admin">Все</a>
<a class="w-more" href="/admin">Все <i data-lucide="chevron-right" style="width:13px;height:13px;vertical-align:-2px"></i></a>
</div>
<div class="adm-sessions" id="admin-sessions-body"></div>
</div>
@@ -1987,6 +2073,29 @@
return 1e12; // no deadline <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> last
}
/* ── Is assignment urgent for teacher (within 48h) ── */
function isTeacherUrgent(a) {
if (!a.deadline) return false;
const ms = parseDate(a.deadline).getTime() - Date.now();
return ms > 0 && ms < 48 * 3600 * 1000;
}
/* ── Relative timestamp helper ("12 мин назад", "сегодня", "вчера") ── */
function relativeAgo(dateStr) {
if (!dateStr) return '';
const d = parseDate(dateStr);
const diffMs = Date.now() - d.getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return 'только что';
if (diffMin < 60) return `${diffMin} мин назад`;
const diffH = Math.floor(diffMin / 60);
if (diffH < 24) return `${diffH} ч назад`;
const diffD = Math.floor(diffH / 24);
if (diffD === 1) return 'вчера';
if (diffD < 7) return `${diffD} дн назад`;
return d.toLocaleDateString('ru', { day: 'numeric', month: 'short' });
}
function buildAssignCard(a, idx, isFirst = false) {
const dl = a.deadline ? new Date(a.deadline).toLocaleDateString('ru',{day:'numeric',month:'short'}) : null;
const dlMs = a.deadline ? new Date(a.deadline) - Date.now() : Infinity;
@@ -2001,12 +2110,18 @@
const done = a.completed_count || 0;
const total = a.total_members || 0;
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
const meta = [classStr, SUBJ[a.subject_slug] || a.subject_slug, dl ? `до ${dl}` : null].filter(Boolean).join(' · ');
return `<div class="asgn-wrap"><div class="asgn-row stagger-item" style="--i:${idx};--ac:${sColor}" id="asgn-row-${a.id}" onclick="toggleAsgn(${a.id},event)">
const barColor = pct >= 75 ? '#059652' : pct >= 50 ? '#F59E0B' : '#F15BB5';
const urgent = isTeacherUrgent(a);
const titleHtml = esc(a.title)
+ (a.class_name && a.class_name !== 'Личное задание' ? `<span class="asgn-class-badge">${esc(a.class_name)}</span>` : '')
+ (urgent ? `<span class="asgn-fire">срочно</span>` : '');
const meta = [SUBJ[a.subject_slug] || a.subject_slug, dl ? `до ${dl}` : null].filter(Boolean).join(' · ');
const urgentCls = urgent ? ' asgn-urgent' : '';
return `<div class="asgn-wrap"><div class="asgn-row stagger-item${urgentCls}" style="--i:${idx};--ac:${sColor}" id="asgn-row-${a.id}" onclick="toggleAsgn(${a.id},event)">
<div class="ar-icon" style="background:${sColor}18;color:${sColor}">${lci(iconName)}</div>
<div class="ar-body"><div class="ar-title">${esc(a.title)}</div><div class="ar-meta">${meta}</div></div>
<div class="ar-body"><div class="ar-title">${titleHtml}</div><div class="ar-meta">${meta}</div></div>
<div class="ar-progress">
<div class="ar-prog-bar"><div class="ar-prog-fill" style="width:${pct}%;background:${sColor}"></div></div>
<div class="ar-prog-bar"><div class="ar-prog-fill" style="width:${pct}%;background:${barColor}"></div></div>
<span class="ar-prog-text">${done} / ${total}</span>
</div>
<a class="ar-btn-ghost" href="/classes" onclick="event.stopPropagation()">Подробнее</a>
@@ -3500,6 +3615,61 @@
} catch { /* silent */ }
}
/* ══ ADMIN: KPI chips in header ═════════════════════════════════════ */
async function loadTeacherKPIs() {
if (!isTeacher) return;
const kpiRow = document.getElementById('dh-kpi-row');
if (!kpiRow) return;
try {
const [classes, assignments] = await Promise.all([
LS.api('/api/classes'),
LS.teacherAssignments(),
]);
const classCount = (classes || []).length;
const studentCount = (classes || []).reduce((s, c) => s + (c.member_count || 0), 0);
const now = Date.now();
const activeAsgn = (assignments || []).filter(a =>
!a.deadline || parseDate(a.deadline).getTime() >= now
).length;
// Pending: overdue with submissions missing
const pending = (assignments || []).filter(a =>
a.deadline && parseDate(a.deadline).getTime() < now &&
(a.completed_count || 0) < (a.total_members || 0)
).length;
document.getElementById('kpi-classes').textContent = classCount;
document.getElementById('kpi-students').textContent = studentCount;
document.getElementById('kpi-active-asgn').textContent = activeAsgn;
kpiRow.style.display = '';
if (pending > 0) {
document.getElementById('kpi-pending').textContent = pending;
document.getElementById('kpi-pending-wrap').style.display = '';
}
// Add badge to «Мои классы» card
if (classCount > 0) {
const classCard = document.querySelector('.adm-act[href="/classes"]');
if (classCard && !classCard.querySelector('.adm-act-badge')) {
const badge = document.createElement('span');
badge.className = 'adm-act-badge';
badge.textContent = classCount;
classCard.appendChild(badge);
}
}
// Add badge to «Работы» card if pending
if (pending > 0) {
const workCard = document.querySelector('.adm-act[href="/homework"]');
if (workCard && !workCard.querySelector('.adm-act-badge')) {
const badge = document.createElement('span');
badge.className = 'adm-act-badge';
badge.textContent = pending;
workCard.appendChild(badge);
}
}
} catch { /* silent */ }
}
/* ══ ADMIN: Load classes into admin panel ═══════════════════════════ */
async function loadAdminClasses() {
if (!isTeacher) return;
@@ -3508,7 +3678,12 @@
try {
const classes = await LS.api('/api/classes');
if (!classes || !classes.length) {
body.innerHTML = '<div style="font-size:0.8rem;color:var(--text-3);padding:8px 0">Нет классов</div>';
body.innerHTML = `<div class="adm-empty">
<div class="adm-empty-icon"><i data-lucide="users" style="width:32px;height:32px;color:var(--text-3)"></i></div>
<div class="adm-empty-text">Классов пока нет</div>
<a class="adm-empty-cta" href="/classes">+ Создать класс</a>
</div>`;
reIcons();
return;
}
const colors = ['#9B5DE5','#06D6A0','#F59E0B','#06B6D4','#E0335E'];
@@ -3542,17 +3717,32 @@
const data = await LS.api('/api/admin/sessions?limit=8');
const rows = Array.isArray(data) ? data : (data.rows || []);
if (!rows.length) {
body.innerHTML = '<div style="font-size:0.8rem;color:var(--text-3);padding:8px 0">Нет сессий</div>';
body.innerHTML = `<div class="adm-empty">
<div class="adm-empty-icon"><i data-lucide="clock" style="width:32px;height:32px;color:var(--text-3)"></i></div>
<div class="adm-empty-text">Сессий пока нет. Ученики не начинали тесты.</div>
</div>`;
reIcons();
return;
}
body.innerHTML = rows.slice(0, 8).map(s => {
const pct = s.total > 0 ? Math.round(s.score / s.total * 100) : 0;
const pc = pct >= 75 ? '#059652' : pct >= 50 ? '#F59E0B' : '#E0335E';
const dt = s.started_at ? parseDate(s.started_at).toLocaleDateString('ru', {day:'numeric',month:'short'}) : '';
return `<div class="adm-sess-row">
const ago = relativeAgo(s.started_at);
if (s.status !== 'completed') {
const chip = `<span class="adm-sess-chip" style="background:rgba(15,23,42,0.06);color:var(--text-3);border:1px solid rgba(15,23,42,0.10)">…</span>`;
return `<div class="adm-sess-row" onclick="window.location='/admin#sessions'" style="cursor:pointer">
<span class="adm-sess-name">${esc(s.user_name || s.email || '—')}</span>
<span class="adm-sess-subj">${esc(s.subject_name || '')}</span>
${ago ? `<span class="adm-sess-ago">${ago}</span>` : ''}
${chip}
</div>`;
}
const chip = `<span class="adm-sess-chip" style="background:${pc}22;color:${pc};border:1px solid ${pc}44">${pct}%</span>`;
return `<div class="adm-sess-row" onclick="window.location='/admin#sessions'" style="cursor:pointer">
<span class="adm-sess-name">${esc(s.user_name || s.email || '—')}</span>
<span class="adm-sess-subj">${esc(s.subject_name || '')}</span>
<span class="adm-sess-pct" style="color:${pc}">${s.status === 'completed' ? pct + '%' : ''}</span>
${ago ? `<span class="adm-sess-ago">${ago}</span>` : ''}
${chip}
</div>`;
}).join('');
} catch {
@@ -3568,11 +3758,18 @@
try {
const list = await LS.teacherAssignments();
if (!list.length) {
listEl.innerHTML = '<div style="font-size:0.8rem;color:var(--text-3);padding:8px 0">Заданий пока нет</div>';
listEl.innerHTML = `<div class="adm-empty">
<div class="adm-empty-icon"><i data-lucide="inbox" style="width:32px;height:32px;color:var(--text-3)"></i></div>
<div class="adm-empty-text">Заданий пока нет</div>
<a class="adm-empty-cta" href="/classes">+ Создать первое задание</a>
</div>`;
reIcons();
return;
}
const sorted = [...list].sort((a, b) => urgencyScore(a) - urgencyScore(b));
listEl.innerHTML = `<div class="tests-list">${sorted.slice(0, 8).map((a, i) => buildAssignCard(a, i)).join('')}</div>`;
// Save for search filtering
window._adminAssignmentsSorted = sorted;
listEl.innerHTML = `<input class="adm-asgn-search" id="adm-asgn-search" type="search" placeholder="Поиск задания..." oninput="filterAdminAssignments(this.value)"><div class="tests-list" id="admin-assignments-inner">${sorted.slice(0, 8).map((a, i) => buildAssignCard(a, i)).join('')}</div>`;
if (list.length > 8) {
listEl.innerHTML += `<div style="text-align:center;margin-top:8px"><a class="w-more" href="/classes">Ещё ${list.length - 8} заданий</a></div>`;
}
@@ -3582,6 +3779,25 @@
}
}
/* ── Filter admin assignments by search query ── */
function filterAdminAssignments(q) {
const innerEl = document.getElementById('admin-assignments-inner');
if (!innerEl || !window._adminAssignmentsSorted) return;
const ql = q.toLowerCase().trim();
const filtered = ql
? window._adminAssignmentsSorted.filter(a =>
(a.title || '').toLowerCase().includes(ql) ||
(a.class_name || '').toLowerCase().includes(ql) ||
(SUBJ[a.subject_slug] || a.subject_slug || '').toLowerCase().includes(ql)
)
: window._adminAssignmentsSorted;
const show = filtered.slice(0, 8);
innerEl.innerHTML = show.length
? show.map((a, i) => buildAssignCard(a, i)).join('')
: '<div class="adm-empty"><div class="adm-empty-text">Ничего не найдено</div></div>';
reIcons();
}
/* ══ Load all student widgets ═════════════════════════════════════ */
/* Show a widget section, but respect the cfg-hidden flag */
function showWidget(id) {
@@ -3776,6 +3992,7 @@
if (isTeacher) {
// Admin/Teacher: compact admin layout
loadAdminStats();
loadTeacherKPIs();
loadAdminAssignments();
loadAdminClasses();
loadAdminSessions();