@
feat: teacher_students — назначения ученикам без класса
Новая модель «Мои ученики» — учитель связывает с собой учеников
независимо от классов (репетиторский сценарий).
Backend:
- Таблица teacher_students (teacher_id, student_id, added_at, note)
+ индекс на student_id для обратного поиска
- GET/POST/PATCH/DELETE /api/teacher-students — управление списком
- Добавление по email с проверкой роли student/free_student
- Уведомление ученику при добавлении
- createDirectAssignment: проверка inClass расширена до
inClass OR (teacher_id, student_id) в teacher_students
- listStudents (/api/classes/students): возвращает объединение
учеников из классов + из teacher_students. Это автоматически
обновляет student-picker в /textbooks без правок UI.
Frontend:
- /my-students — таблица личных учеников + форма добавления
по email + заметка + счётчик созданных заданий
- Сайдбар: пункт «Мои ученики» (user-plus, только для учителей)
Миграция 006_teacher_students.sql.
Что работает end-to-end:
- Добавить ученика на /my-students
- Открыть /textbooks → «Назначить» → «Ученику» → ученик ищется
в общем списке (классовые + личные)
- Создаётся запись в assignments с user_id, видна ученику на
дашборде с пометкой «Личное задание»
@
This commit is contained in:
@@ -529,14 +529,21 @@ function createDirectAssignment(req, res) {
|
|||||||
if (!student) return res.status(404).json({ error: 'Ученик с таким email не найден' });
|
if (!student) return res.status(404).json({ error: 'Ученик с таким email не найден' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Учитель может выдать личное задание только ученику из своего класса
|
// Учитель может выдать личное задание ученику из своего класса ИЛИ из «Мои ученики»
|
||||||
if (req.user.role === 'teacher') {
|
if (req.user.role === 'teacher') {
|
||||||
const inClass = db.prepare(`
|
const inClass = db.prepare(`
|
||||||
SELECT 1 FROM class_members cm
|
SELECT 1 FROM class_members cm
|
||||||
JOIN classes c ON c.id = cm.class_id
|
JOIN classes c ON c.id = cm.class_id
|
||||||
WHERE cm.user_id = ? AND c.teacher_id = ?
|
WHERE cm.user_id = ? AND c.teacher_id = ?
|
||||||
`).get(student.id, req.user.id);
|
`).get(student.id, req.user.id);
|
||||||
if (!inClass) return res.status(403).json({ error: 'Ученик не входит ни в один из ваших классов' });
|
const linked = inClass ? null : db.prepare(
|
||||||
|
'SELECT 1 FROM teacher_students WHERE teacher_id=? AND student_id=?'
|
||||||
|
).get(req.user.id, student.id);
|
||||||
|
if (!inClass && !linked) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Ученик не входит в ваши классы и не добавлен в «Мои ученики». Добавьте его на странице «Мои ученики».',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test_id = test_id ? Number(test_id) : null;
|
test_id = test_id ? Number(test_id) : null;
|
||||||
|
|||||||
@@ -355,14 +355,20 @@ function listStudents(req, res) {
|
|||||||
).all();
|
).all();
|
||||||
return res.json(rows);
|
return res.json(rows);
|
||||||
}
|
}
|
||||||
// Teacher: only students in their classes
|
// Teacher: students in their classes + personal students (teacher_students)
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
|
SELECT id, name, email FROM (
|
||||||
SELECT DISTINCT u.id, u.name, u.email FROM users u
|
SELECT DISTINCT u.id, u.name, u.email FROM users u
|
||||||
JOIN class_members cm ON cm.user_id = u.id
|
JOIN class_members cm ON cm.user_id = u.id
|
||||||
JOIN classes c ON c.id = cm.class_id
|
JOIN classes c ON c.id = cm.class_id
|
||||||
WHERE c.teacher_id = ? AND u.role IN ('student','free_student')
|
WHERE c.teacher_id = ? AND u.role IN ('student','free_student')
|
||||||
ORDER BY u.name
|
UNION
|
||||||
`).all(req.user.id);
|
SELECT u.id, u.name, u.email FROM users u
|
||||||
|
JOIN teacher_students ts ON ts.student_id = u.id
|
||||||
|
WHERE ts.teacher_id = ? AND u.role IN ('student','free_student')
|
||||||
|
)
|
||||||
|
ORDER BY name
|
||||||
|
`).all(req.user.id, req.user.id);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Personal student list for tutoring scenarios (student doesn't need to be in any class)
|
||||||
|
CREATE TABLE teacher_students (
|
||||||
|
teacher_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
student_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
added_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
PRIMARY KEY (teacher_id, student_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_teacher_students_student ON teacher_students(student_id);
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
'use strict';
|
||||||
|
const router = require('express').Router();
|
||||||
|
const db = require('../db/db');
|
||||||
|
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||||
|
const { emit } = require('../sse');
|
||||||
|
|
||||||
|
router.use(authMiddleware);
|
||||||
|
router.use(requireRole('teacher', 'admin'));
|
||||||
|
|
||||||
|
/* GET /api/teacher-students — my linked students (not in classes, can be assigned to) */
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT u.id, u.name, u.email, ts.added_at, ts.note,
|
||||||
|
(SELECT COUNT(*) FROM assignments WHERE user_id = u.id AND created_by = ?) AS assignment_count
|
||||||
|
FROM teacher_students ts
|
||||||
|
JOIN users u ON u.id = ts.student_id
|
||||||
|
WHERE ts.teacher_id = ?
|
||||||
|
ORDER BY u.name
|
||||||
|
`).all(req.user.id, req.user.id);
|
||||||
|
res.json({ students: rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
/* POST /api/teacher-students — add by email
|
||||||
|
body: { email: string, note?: string } */
|
||||||
|
router.post('/', (req, res) => {
|
||||||
|
const email = (req.body?.email || '').trim().toLowerCase();
|
||||||
|
const note = String(req.body?.note || '').slice(0, 200);
|
||||||
|
if (!email) return res.status(400).json({ error: 'email обязателен' });
|
||||||
|
|
||||||
|
const student = db.prepare(
|
||||||
|
"SELECT id, name, email FROM users WHERE email = ? AND role IN ('student','free_student')"
|
||||||
|
).get(email);
|
||||||
|
if (!student) return res.status(404).json({ error: 'Ученик с таким email не зарегистрирован' });
|
||||||
|
|
||||||
|
// Can't add yourself
|
||||||
|
if (student.id === req.user.id) return res.status(400).json({ error: 'Нельзя добавить себя' });
|
||||||
|
|
||||||
|
// Check if already added
|
||||||
|
const existing = db.prepare(
|
||||||
|
'SELECT 1 FROM teacher_students WHERE teacher_id=? AND student_id=?'
|
||||||
|
).get(req.user.id, student.id);
|
||||||
|
if (existing) return res.status(409).json({ error: 'Ученик уже добавлен в ваш список' });
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO teacher_students (teacher_id, student_id, note) VALUES (?, ?, ?)'
|
||||||
|
).run(req.user.id, student.id, note);
|
||||||
|
|
||||||
|
// Notify student
|
||||||
|
try {
|
||||||
|
emit(student.id, {
|
||||||
|
type: 'notification', notif_type: 'teacher_added',
|
||||||
|
message: `${req.user.name || 'Учитель'} добавил вас как своего ученика`,
|
||||||
|
link: '/profile',
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
res.status(201).json({ ok: true, student });
|
||||||
|
});
|
||||||
|
|
||||||
|
/* PATCH /api/teacher-students/:student_id — update note */
|
||||||
|
router.patch('/:student_id', (req, res) => {
|
||||||
|
const sid = Number(req.params.student_id);
|
||||||
|
const link = db.prepare('SELECT 1 FROM teacher_students WHERE teacher_id=? AND student_id=?').get(req.user.id, sid);
|
||||||
|
if (!link) return res.status(404).json({ error: 'Связь не найдена' });
|
||||||
|
const note = String(req.body?.note || '').slice(0, 200);
|
||||||
|
db.prepare('UPDATE teacher_students SET note=? WHERE teacher_id=? AND student_id=?')
|
||||||
|
.run(note, req.user.id, sid);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/* DELETE /api/teacher-students/:student_id */
|
||||||
|
router.delete('/:student_id', (req, res) => {
|
||||||
|
const sid = Number(req.params.student_id);
|
||||||
|
const r = db.prepare(
|
||||||
|
'DELETE FROM teacher_students WHERE teacher_id=? AND student_id=?'
|
||||||
|
).run(req.user.id, sid);
|
||||||
|
if (r.changes === 0) return res.status(404).json({ error: 'Связь не найдена' });
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -52,6 +52,7 @@ const redBookRoutes = require('./routes/red-book');
|
|||||||
const parentRoutes = require('./routes/parent');
|
const parentRoutes = require('./routes/parent');
|
||||||
const exam9Routes = require('./routes/exam9');
|
const exam9Routes = require('./routes/exam9');
|
||||||
const textbookRoutes = require('./routes/textbooks');
|
const textbookRoutes = require('./routes/textbooks');
|
||||||
|
const teacherStudentsRoutes = require('./routes/teacherStudents');
|
||||||
|
|
||||||
const { requestId, errorHandler } = require('./middleware/errorHandler');
|
const { requestId, errorHandler } = require('./middleware/errorHandler');
|
||||||
|
|
||||||
@@ -170,6 +171,7 @@ app.use('/api/biochem', require('./routes/biochem'));
|
|||||||
app.use('/api/parent', parentRoutes);
|
app.use('/api/parent', parentRoutes);
|
||||||
app.use('/api/exam9', exam9Routes);
|
app.use('/api/exam9', exam9Routes);
|
||||||
app.use('/api/textbooks', textbookRoutes);
|
app.use('/api/textbooks', textbookRoutes);
|
||||||
|
app.use('/api/teacher-students', teacherStudentsRoutes);
|
||||||
|
|
||||||
/* ── Public features endpoint (merges global + per-class for authenticated students) ── */
|
/* ── Public features endpoint (merges global + per-class for authenticated students) ── */
|
||||||
const _featDb = require('./db/db');
|
const _featDb = require('./db/db');
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
<!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>
|
||||||
|
.sb-content { padding: 0; overflow-y: auto; }
|
||||||
|
.ms-wrap { max-width: 1000px; margin: 0 auto; padding: 32px 24px 80px; width: 100%; }
|
||||||
|
|
||||||
|
.ms-header { display:flex; align-items:center; gap:14px; margin-bottom:26px; }
|
||||||
|
.ms-icon {
|
||||||
|
width:52px; height:52px; border-radius:14px; flex-shrink:0;
|
||||||
|
background:linear-gradient(135deg, rgba(155,93,229,.25), rgba(6,214,224,.18));
|
||||||
|
border:1.5px solid rgba(255,255,255,.1);
|
||||||
|
display:flex; align-items:center; justify-content:center;
|
||||||
|
}
|
||||||
|
.ms-icon svg { width:26px; height:26px; stroke:#9B5DE5; stroke-width:1.8; fill:none; }
|
||||||
|
.ms-title { font-family:'Unbounded',sans-serif; font-size:1.3rem; font-weight:800; letter-spacing:-.02em; }
|
||||||
|
.ms-sub { font-size:.82rem; color:var(--text-2); margin-top:2px; }
|
||||||
|
|
||||||
|
/* Add form */
|
||||||
|
.ms-add {
|
||||||
|
background:var(--surface); border:1.5px solid var(--border); border-radius:14px;
|
||||||
|
padding:18px 20px; margin-bottom:24px;
|
||||||
|
}
|
||||||
|
.ms-add-title {
|
||||||
|
font-family:'Unbounded',sans-serif; font-size:.95rem; font-weight:800;
|
||||||
|
margin-bottom:12px;
|
||||||
|
}
|
||||||
|
.ms-add-row { display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end; }
|
||||||
|
.ms-add-field { flex:1; min-width:200px; }
|
||||||
|
.ms-add-field label {
|
||||||
|
display:block; font-size:.72rem; font-weight:700; color:var(--text-2);
|
||||||
|
text-transform:uppercase; letter-spacing:.05em; margin-bottom:6px;
|
||||||
|
}
|
||||||
|
.ms-input {
|
||||||
|
width:100%; padding:10px 14px; border:1.5px solid var(--border-h);
|
||||||
|
border-radius:10px; background:var(--surface); color:var(--text);
|
||||||
|
font-family:'Manrope',sans-serif; font-size:.9rem;
|
||||||
|
}
|
||||||
|
.ms-input:focus { outline:none; border-color:var(--violet); }
|
||||||
|
.ms-add-btn {
|
||||||
|
padding:10px 22px; border-radius:10px;
|
||||||
|
background:var(--violet); border:none; color:#fff;
|
||||||
|
font-family:'Manrope',sans-serif; font-size:.88rem; font-weight:700;
|
||||||
|
cursor:pointer; transition:filter .15s;
|
||||||
|
display:inline-flex; align-items:center; gap:6px;
|
||||||
|
}
|
||||||
|
.ms-add-btn:hover { filter:brightness(1.08); }
|
||||||
|
.ms-add-btn:disabled { opacity:.55; cursor:not-allowed; }
|
||||||
|
.ms-add-btn svg { width:15px; height:15px; }
|
||||||
|
.ms-add-msg {
|
||||||
|
margin-top:10px; padding:8px 12px; border-radius:8px;
|
||||||
|
font-size:.84rem; display:none;
|
||||||
|
}
|
||||||
|
.ms-add-msg.error { display:block; background:rgba(241,91,68,.1); border:1px solid rgba(241,91,68,.3); color:#F94144; }
|
||||||
|
.ms-add-msg.success { display:block; background:rgba(6,214,160,.1); border:1px solid rgba(6,214,160,.3); color:#06D6A0; }
|
||||||
|
|
||||||
|
/* Students list */
|
||||||
|
.ms-list {
|
||||||
|
background:var(--surface); border:1.5px solid var(--border); border-radius:14px;
|
||||||
|
overflow:hidden;
|
||||||
|
}
|
||||||
|
.ms-row {
|
||||||
|
display:grid; grid-template-columns: 36px 1.5fr 2fr 1fr auto auto;
|
||||||
|
gap:14px; padding:14px 20px; align-items:center;
|
||||||
|
border-bottom:1px solid var(--border);
|
||||||
|
transition:background .12s;
|
||||||
|
}
|
||||||
|
.ms-row:last-child { border-bottom:none; }
|
||||||
|
.ms-row:hover { background:rgba(155,93,229,.04); }
|
||||||
|
.ms-row.head {
|
||||||
|
background:rgba(155,93,229,.06);
|
||||||
|
font-family:'Unbounded',sans-serif; font-size:.72rem; font-weight:800;
|
||||||
|
color:var(--text-2); text-transform:uppercase; letter-spacing:.05em;
|
||||||
|
}
|
||||||
|
.ms-row.head:hover { background:rgba(155,93,229,.06); }
|
||||||
|
.ms-avatar {
|
||||||
|
width:36px; height:36px; border-radius:50%;
|
||||||
|
background:linear-gradient(135deg, var(--violet), #06D6E0);
|
||||||
|
color:#fff; font-weight:800; font-size:.95rem;
|
||||||
|
display:flex; align-items:center; justify-content:center;
|
||||||
|
}
|
||||||
|
.ms-name { font-weight:700; font-size:.95rem; }
|
||||||
|
.ms-email { font-size:.82rem; color:var(--text-2); }
|
||||||
|
.ms-meta { font-size:.78rem; color:var(--text-3); }
|
||||||
|
.ms-btn {
|
||||||
|
padding:7px 12px; border-radius:9px;
|
||||||
|
border:1.5px solid var(--border-h); background:transparent; color:var(--text);
|
||||||
|
font-family:'Manrope',sans-serif; font-size:.82rem; font-weight:700;
|
||||||
|
cursor:pointer; transition:all .15s;
|
||||||
|
display:inline-flex; align-items:center; gap:5px; text-decoration:none;
|
||||||
|
}
|
||||||
|
.ms-btn:hover { border-color:var(--violet); color:var(--violet); }
|
||||||
|
.ms-btn.primary { background:var(--violet); border-color:var(--violet); color:#fff; }
|
||||||
|
.ms-btn.primary:hover { background:#7e3eca; color:#fff; }
|
||||||
|
.ms-btn.danger { color:var(--text-3); }
|
||||||
|
.ms-btn.danger:hover { color:#F94144; border-color:#F94144; }
|
||||||
|
.ms-btn svg { width:13px; height:13px; }
|
||||||
|
|
||||||
|
.ms-empty {
|
||||||
|
padding:60px 20px; text-align:center; color:var(--text-3);
|
||||||
|
}
|
||||||
|
.ms-empty svg { width:48px; height:48px; opacity:.5; margin-bottom:14px; stroke:var(--text-3); }
|
||||||
|
.ms-empty-title { font-family:'Unbounded',sans-serif; font-weight:800; color:var(--text); margin-bottom:6px; }
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.ms-row { grid-template-columns: 36px 1fr auto; gap:10px; }
|
||||||
|
.ms-row > :nth-child(3), .ms-row > :nth-child(4) { display:none; }
|
||||||
|
.ms-row.head { display:none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-layout">
|
||||||
|
<aside class="sidebar" id="app-sidebar"></aside>
|
||||||
|
<div class="sb-content">
|
||||||
|
<div class="ms-wrap">
|
||||||
|
<header class="ms-header">
|
||||||
|
<div class="ms-icon">
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
|
||||||
|
<circle cx="9" cy="7" r="4"/>
|
||||||
|
<line x1="19" y1="8" x2="19" y2="14"/>
|
||||||
|
<line x1="22" y1="11" x2="16" y2="11"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="ms-title">Мои ученики</div>
|
||||||
|
<div class="ms-sub">Личный список — для назначения заданий ученикам без класса</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="ms-add">
|
||||||
|
<div class="ms-add-title">Добавить ученика</div>
|
||||||
|
<form class="ms-add-row" onsubmit="event.preventDefault(); addStudent()">
|
||||||
|
<div class="ms-add-field">
|
||||||
|
<label>Email ученика</label>
|
||||||
|
<input type="email" class="ms-input" id="ms-email" placeholder="student@example.com" required />
|
||||||
|
</div>
|
||||||
|
<div class="ms-add-field" style="flex:1.2">
|
||||||
|
<label>Заметка (опционально)</label>
|
||||||
|
<input type="text" class="ms-input" id="ms-note" placeholder="например: репетиторство по математике" maxlength="200" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="submit" class="ms-add-btn" id="ms-add-btn">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
|
Добавить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="ms-add-msg" id="ms-add-msg"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="ms-list-container">
|
||||||
|
<div class="ms-empty">Загрузка…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||||||
|
<script src="/js/api.js"></script>
|
||||||
|
<script src="/js/sidebar.js"></script>
|
||||||
|
<script src="/js/notifications.js"></script>
|
||||||
|
<script src="/js/search.js"></script>
|
||||||
|
<script src="/js/mobile.js"></script>
|
||||||
|
<script>
|
||||||
|
(async function () {
|
||||||
|
const user = LS.initPage();
|
||||||
|
if (!user || (user.role !== 'teacher' && user.role !== 'admin')) {
|
||||||
|
location.href = '/dashboard'; return;
|
||||||
|
}
|
||||||
|
LS.showBoardIfAllowed();
|
||||||
|
LS.hideDisabledFeatures();
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
||||||
|
}
|
||||||
|
function initials(name) {
|
||||||
|
return (name || '').trim().split(/\s+/).slice(0, 2).map(w => w[0] || '').join('').toUpperCase() || '?';
|
||||||
|
}
|
||||||
|
function fmtDate(s) {
|
||||||
|
if (!s) return '—';
|
||||||
|
try { return new Date(s.includes('T') ? s : s.replace(' ', 'T') + 'Z').toLocaleDateString('ru-RU'); }
|
||||||
|
catch { return s; }
|
||||||
|
}
|
||||||
|
|
||||||
|
let students = [];
|
||||||
|
|
||||||
|
async function loadStudents() {
|
||||||
|
try {
|
||||||
|
const r = await LS.api('/api/teacher-students');
|
||||||
|
students = r.students || [];
|
||||||
|
render();
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('ms-list-container').innerHTML = `<div class="ms-empty">Ошибка: ${esc(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const el = document.getElementById('ms-list-container');
|
||||||
|
if (!students.length) {
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="ms-empty">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>
|
||||||
|
<div class="ms-empty-title">Список пуст</div>
|
||||||
|
<div>Добавьте ученика по email — после этого можно назначать ему задания и чтение учебника, даже если он не в вашем классе.</div>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="ms-list">
|
||||||
|
<div class="ms-row head">
|
||||||
|
<div></div>
|
||||||
|
<div>Ученик</div>
|
||||||
|
<div>Email</div>
|
||||||
|
<div>Заданий</div>
|
||||||
|
<div>Добавлен</div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
${students.map(s => `
|
||||||
|
<div class="ms-row">
|
||||||
|
<div class="ms-avatar">${esc(initials(s.name))}</div>
|
||||||
|
<div>
|
||||||
|
<div class="ms-name">${esc(s.name)}</div>
|
||||||
|
${s.note ? `<div class="ms-meta">${esc(s.note)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="ms-email">${esc(s.email)}</div>
|
||||||
|
<div class="ms-meta"><b style="color:var(--text)">${s.assignment_count || 0}</b></div>
|
||||||
|
<div class="ms-meta">${fmtDate(s.added_at)}</div>
|
||||||
|
<div style="display:flex;gap:6px">
|
||||||
|
<a class="ms-btn primary" href="/classes?assign_to=${s.id}" title="Перейти к назначению">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
|
Задание
|
||||||
|
</a>
|
||||||
|
<button class="ms-btn danger" onclick="removeStudent(${s.id}, '${esc(s.name).replace(/'/g, "\\'")}')" title="Удалить из списка">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>`).join('')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addStudent = async function () {
|
||||||
|
const emailEl = document.getElementById('ms-email');
|
||||||
|
const noteEl = document.getElementById('ms-note');
|
||||||
|
const msgEl = document.getElementById('ms-add-msg');
|
||||||
|
const btn = document.getElementById('ms-add-btn');
|
||||||
|
const email = emailEl.value.trim();
|
||||||
|
if (!email) return;
|
||||||
|
|
||||||
|
msgEl.className = 'ms-add-msg';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await LS.api('/api/teacher-students', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { email, note: noteEl.value.trim() || null },
|
||||||
|
});
|
||||||
|
msgEl.className = 'ms-add-msg success';
|
||||||
|
msgEl.textContent = `«${r.student.name}» добавлен в ваш список`;
|
||||||
|
emailEl.value = '';
|
||||||
|
noteEl.value = '';
|
||||||
|
await loadStudents();
|
||||||
|
} catch (e) {
|
||||||
|
msgEl.className = 'ms-add-msg error';
|
||||||
|
msgEl.textContent = e.message || 'Ошибка';
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.removeStudent = async function (id, name) {
|
||||||
|
if (!confirm(`Убрать «${name}» из списка «Мои ученики»?\n\nСозданные задания не удалятся.`)) return;
|
||||||
|
try {
|
||||||
|
await LS.api('/api/teacher-students/' + id, { method: 'DELETE' });
|
||||||
|
students = students.filter(s => s.id !== id);
|
||||||
|
render();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Ошибка: ' + e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await loadStudents();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -52,6 +52,7 @@
|
|||||||
${L('/teacher-guide', 'book-marked', 'Руководство', { cls: 'sb-teacher-only', hidden: !isTch })}
|
${L('/teacher-guide', 'book-marked', 'Руководство', { cls: 'sb-teacher-only', hidden: !isTch })}
|
||||||
${L('/board', 'layout-dashboard', 'Доска', { id: 'btn-board', hidden: true })}
|
${L('/board', 'layout-dashboard', 'Доска', { id: 'btn-board', hidden: true })}
|
||||||
${L('/classes', 'graduation-cap', 'Классы', { id: 'btn-classes', hidden: !isTch })}
|
${L('/classes', 'graduation-cap', 'Классы', { id: 'btn-classes', hidden: !isTch })}
|
||||||
|
${L('/my-students', 'user-plus', 'Мои ученики', { cls: 'sb-teacher-only', hidden: !isTch })}
|
||||||
${L('/library', 'book-open', 'Библиотека')}
|
${L('/library', 'book-open', 'Библиотека')}
|
||||||
${L('/theory', 'brain', 'Теория')}
|
${L('/theory', 'brain', 'Теория')}
|
||||||
${L('/lab', 'atom', 'Лаборатория')}
|
${L('/lab', 'atom', 'Лаборатория')}
|
||||||
|
|||||||
Reference in New Issue
Block a user