diff --git a/backend/src/controllers/assignmentController.js b/backend/src/controllers/assignmentController.js index 42698e5..7695e70 100644 --- a/backend/src/controllers/assignmentController.js +++ b/backend/src/controllers/assignmentController.js @@ -529,14 +529,21 @@ function createDirectAssignment(req, res) { if (!student) return res.status(404).json({ error: 'Ученик с таким email не найден' }); } - // Учитель может выдать личное задание только ученику из своего класса + // Учитель может выдать личное задание ученику из своего класса ИЛИ из «Мои ученики» if (req.user.role === 'teacher') { const inClass = db.prepare(` SELECT 1 FROM class_members cm JOIN classes c ON c.id = cm.class_id WHERE cm.user_id = ? AND c.teacher_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; diff --git a/backend/src/controllers/classController.js b/backend/src/controllers/classController.js index b7babe4..240569d 100644 --- a/backend/src/controllers/classController.js +++ b/backend/src/controllers/classController.js @@ -355,14 +355,20 @@ function listStudents(req, res) { ).all(); return res.json(rows); } - // Teacher: only students in their classes + // Teacher: students in their classes + personal students (teacher_students) const rows = db.prepare(` - SELECT DISTINCT u.id, u.name, u.email FROM users u - JOIN class_members cm ON cm.user_id = u.id - JOIN classes c ON c.id = cm.class_id - WHERE c.teacher_id = ? AND u.role IN ('student','free_student') - ORDER BY u.name - `).all(req.user.id); + SELECT id, name, email FROM ( + SELECT DISTINCT u.id, u.name, u.email FROM users u + JOIN class_members cm ON cm.user_id = u.id + JOIN classes c ON c.id = cm.class_id + WHERE c.teacher_id = ? AND u.role IN ('student','free_student') + UNION + 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); } diff --git a/backend/src/db/migrations/006_teacher_students.sql b/backend/src/db/migrations/006_teacher_students.sql new file mode 100644 index 0000000..ee34192 --- /dev/null +++ b/backend/src/db/migrations/006_teacher_students.sql @@ -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); diff --git a/backend/src/routes/teacherStudents.js b/backend/src/routes/teacherStudents.js new file mode 100644 index 0000000..9657a8a --- /dev/null +++ b/backend/src/routes/teacherStudents.js @@ -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; diff --git a/backend/src/server.js b/backend/src/server.js index 1fb4bbb..816fb58 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -52,6 +52,7 @@ const redBookRoutes = require('./routes/red-book'); const parentRoutes = require('./routes/parent'); const exam9Routes = require('./routes/exam9'); const textbookRoutes = require('./routes/textbooks'); +const teacherStudentsRoutes = require('./routes/teacherStudents'); 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/exam9', exam9Routes); app.use('/api/textbooks', textbookRoutes); +app.use('/api/teacher-students', teacherStudentsRoutes); /* ── Public features endpoint (merges global + per-class for authenticated students) ── */ const _featDb = require('./db/db'); diff --git a/frontend/my-students.html b/frontend/my-students.html new file mode 100644 index 0000000..47214ec --- /dev/null +++ b/frontend/my-students.html @@ -0,0 +1,294 @@ + + + + + + Мои ученики — LearnSpace + + + + + + +
+ +
+
+
+
+ + + + + + +
+
+
Мои ученики
+
Личный список — для назначения заданий ученикам без класса
+
+
+ +
+
Добавить ученика
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+ +
+
Загрузка…
+
+ +
+
+
+ + + + + + + + + + diff --git a/js/sidebar.js b/js/sidebar.js index c3d6de9..95535a2 100644 --- a/js/sidebar.js +++ b/js/sidebar.js @@ -52,6 +52,7 @@ ${L('/teacher-guide', 'book-marked', 'Руководство', { cls: 'sb-teacher-only', hidden: !isTch })} ${L('/board', 'layout-dashboard', 'Доска', { id: 'btn-board', hidden: true })} ${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('/theory', 'brain', 'Теория')} ${L('/lab', 'atom', 'Лаборатория')}