diff --git a/db/init.sql b/db/init.sql
index 8e314ad..b0922e9 100644
--- a/db/init.sql
+++ b/db/init.sql
@@ -182,6 +182,8 @@ CREATE TABLE IF NOT EXISTS users (
created_at TIMESTAMPTZ DEFAULT now()
);
+ALTER TABLE groups ADD COLUMN IF NOT EXISTS tutor_id INT REFERENCES users(id) ON DELETE SET NULL;
+
CREATE TABLE IF NOT EXISTS user_branches (
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
branch_id INT NOT NULL REFERENCES branches(id) ON DELETE CASCADE,
diff --git a/db/migration.sql b/db/migration.sql
index 289f707..5a31d58 100644
--- a/db/migration.sql
+++ b/db/migration.sql
@@ -154,6 +154,8 @@ CREATE TABLE IF NOT EXISTS user_branches (
PRIMARY KEY (user_id, branch_id)
);
+ALTER TABLE groups ADD COLUMN IF NOT EXISTS tutor_id INT REFERENCES users(id) ON DELETE SET NULL;
+
CREATE TABLE IF NOT EXISTS sessions (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
diff --git a/public/groups.html b/public/groups.html
index 0871e6e..d97f77d 100644
--- a/public/groups.html
+++ b/public/groups.html
@@ -14,6 +14,7 @@
${branchSelectHTML(g.branch_id)}
+ ${tutorSelectHTML(g.tutor_id)}
День
—
@@ -66,13 +79,15 @@ async function saveGroup(id) {
const groupCard = nameInput.closest('.group-card');
const branchSelect = groupCard ? groupCard.querySelector('.branch-select') : null;
const branch_id = branchSelect ? branchSelect.value : '';
+ const tutorSelect = groupCard ? groupCard.querySelector('.tutor-select') : null;
+ const tutor_id = tutorSelect ? (tutorSelect.value || null) : undefined;
const dow = document.getElementById(`dow-${id}`).value;
const ts = document.getElementById(`ts-${id}`).value;
const te = document.getElementById(`te-${id}`).value;
const res = await fetch(`${API}/api/groups/${id}`, {
method: 'PUT',
headers: hdrJson(),
- body: JSON.stringify({ name, branch_id: branch_id || null, day_of_week: dow !== '' ? parseInt(dow) : null, time_start: ts || null, time_end: te || null })
+ body: JSON.stringify({ name, branch_id: branch_id || null, tutor_id, day_of_week: dow !== '' ? parseInt(dow) : null, time_start: ts || null, time_end: te || null })
});
if (res.ok) {
loadGroups();
@@ -87,9 +102,12 @@ async function addGroup() {
const name = input.value.trim();
if (!name) return;
await loadBranchesCache();
+ await loadTutorsCache();
const branchSelect = document.getElementById('newGroupBranch');
const branch_id = branchSelect ? branchSelect.value : '';
- const res = await fetch(`${API}/api/groups`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name, branch_id: branch_id || null }) });
+ const tutorSelect = document.getElementById('newGroupTutor');
+ const tutor_id = tutorSelect && tutorSelect.value ? tutorSelect.value : null;
+ const res = await fetch(`${API}/api/groups`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name, branch_id: branch_id || null, tutor_id }) });
if (res.ok) { input.value = ''; loadGroups(); }
else { const e = await res.json(); alert(e.error); }
}
@@ -414,6 +432,12 @@ async function populateNewGroupBranch() {
const sel = document.getElementById('newGroupBranch');
sel.innerHTML = '— без филиала — ' + branches.map(b => `${esc(b.name)} `).join('');
}
+ const tRes = await fetch(`${API}/api/users/tutors`, { headers: hdr() });
+ if (tRes.ok) {
+ const tutors = await tRes.json();
+ const tSel = document.getElementById('newGroupTutor');
+ tSel.innerHTML = '— не назначен — ' + tutors.map(t => `${esc(t.name || t.username)} `).join('');
+ }
}
(async () => {
diff --git a/server.js b/server.js
index fead479..b806561 100644
--- a/server.js
+++ b/server.js
@@ -773,6 +773,7 @@ async function ensureUserTables() {
await pool.query(`CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at)`);
await pool.query('ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS user_id INT REFERENCES users(id) ON DELETE SET NULL');
+ await pool.query('ALTER TABLE groups ADD COLUMN IF NOT EXISTS tutor_id INT REFERENCES users(id) ON DELETE SET NULL');
}
async function ensureFirstAdmin() {
@@ -883,12 +884,20 @@ app.get('/api/users', requireAuth, requireAdmin, async (_, res) => {
COALESCE(array_agg(ub.branch_id) FILTER (WHERE ub.branch_id IS NOT NULL), '{}') AS branch_ids
FROM users u
LEFT JOIN user_branches ub ON ub.user_id = u.id
+ WHERE u.role = 'tutor'
GROUP BY u.id
ORDER BY u.id`
);
res.json(rows.map(r => ({ ...r, branch_ids: r.branch_ids || [] })));
});
+app.get('/api/users/tutors', requireAuth, async (_, res) => {
+ const { rows } = await pool.query(
+ `SELECT id, name, username FROM users WHERE role = 'tutor' AND is_active = true ORDER BY COALESCE(NULLIF(name, ''), username)`
+ );
+ res.json(rows);
+});
+
app.get('/api/users/branches', requireAuth, async (req, res) => {
const s = branchScope(req.user);
const params = [];
@@ -1627,8 +1636,8 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
}
for (const x of ndata.groups) {
await client.query(
- 'INSERT INTO groups (id, name, created_at, day_of_week, time_start, time_end, branch_id) VALUES ($1,$2,$3,$4,$5,$6,$7)',
- [x.id, x.name, x.created_at, x.day_of_week, x.time_start, x.time_end, x.branch_id]
+ 'INSERT INTO groups (id, name, created_at, day_of_week, time_start, time_end, branch_id, tutor_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)',
+ [x.id, x.name, x.created_at, x.day_of_week, x.time_start, x.time_end, x.branch_id, x.tutor_id]
);
}
for (const x of ndata.students) {
@@ -2091,7 +2100,7 @@ app.get('/api/groups/active', apiLimiter, async (_, res) => {
});
app.put('/api/groups/:id', requireAuth, async (req, res) => {
- const { name, day_of_week, time_start, time_end, branch_id } = req.body;
+ const { name, day_of_week, time_start, time_end, branch_id, tutor_id } = req.body;
if (!(await groupBelongsToBranches(req.user, req.params.id))) {
return res.status(403).json({ error: 'Нет доступа к этой группе' });
}
@@ -2099,6 +2108,13 @@ app.put('/api/groups/:id', requireAuth, async (req, res) => {
if (!isAdmin && branch_id !== undefined) {
return res.status(403).json({ error: 'Назначение филиала — только для администратора' });
}
+ const tutorProvided = tutor_id !== undefined;
+ let effectiveTutor = null;
+ if (tutor_id !== undefined && tutor_id !== null && tutor_id !== '') {
+ const t = await pool.query(`SELECT id FROM users WHERE id = $1 AND role = 'tutor'`, [tutor_id]);
+ if (!t.rows.length) return res.status(400).json({ error: 'Пользователь не найден или не является тутором' });
+ effectiveTutor = t.rows[0].id;
+ }
try {
const { rows } = await pool.query(
`UPDATE groups SET
@@ -2106,12 +2122,15 @@ app.put('/api/groups/:id', requireAuth, async (req, res) => {
day_of_week = $2,
time_start = $3,
time_end = $4,
- branch_id = $5
- WHERE id = $6 RETURNING *`,
+ branch_id = $5,
+ tutor_id = CASE WHEN $6::boolean THEN $7::int ELSE tutor_id END
+ WHERE id = $8 RETURNING *`,
[name,
day_of_week === undefined || day_of_week === null || day_of_week === '' ? null : day_of_week,
time_start || null, time_end || null,
branch_id === undefined || branch_id === null || branch_id === '' ? null : branch_id,
+ tutorProvided,
+ tutorProvided ? effectiveTutor : null,
req.params.id]
);
await logAudit(req, 'group.update', { id: req.params.id, ...req.body });
@@ -2125,17 +2144,23 @@ app.put('/api/groups/:id', requireAuth, async (req, res) => {
});
app.post('/api/groups', requireAuth, async (req, res) => {
- const { name, branch_id } = req.body;
+ const { name, branch_id, tutor_id } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
const isAdmin = req.user.role === 'admin';
const effectiveBranch = branch_id === undefined || branch_id === null || branch_id === '' ? null : Number(branch_id);
if (!isAdmin && branch_id !== undefined && branch_id !== null && branch_id !== '') {
return res.status(403).json({ error: 'Назначение филиала — только для администратора' });
}
+ let effectiveTutor = null;
+ if (tutor_id !== undefined && tutor_id !== null && tutor_id !== '') {
+ const t = await pool.query(`SELECT id FROM users WHERE id = $1 AND role = 'tutor'`, [tutor_id]);
+ if (!t.rows.length) return res.status(400).json({ error: 'Пользователь не найден или не является тутором' });
+ effectiveTutor = t.rows[0].id;
+ }
try {
const { rows } = await pool.query(
- 'INSERT INTO groups (name, branch_id) VALUES ($1, $2) RETURNING *',
- [name.trim(), isAdmin ? effectiveBranch : null]
+ 'INSERT INTO groups (name, branch_id, tutor_id) VALUES ($1, $2, $3) RETURNING *',
+ [name.trim(), isAdmin ? effectiveBranch : null, effectiveTutor]
);
await logAudit(req, 'group.create', { id: rows[0].id, name: name.trim(), branch_id });
invalidateGroups();