feat(groups): assign tutor to group

This commit is contained in:
dev
2026-09-19 13:17:40 +03:00
parent 1ef81f9d1c
commit 5e2533b876
5 changed files with 64 additions and 10 deletions
+2
View File
@@ -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,
+2
View File
@@ -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,
+1
View File
@@ -14,6 +14,7 @@
<div class="add-row" style="align-items:center;gap:8px">
<input type="text" id="newGroup" placeholder="Название группы" style="flex:1;max-width:300px">
<select id="newGroupBranch" style="background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;min-width:180px"><option value="">— без филиала —</option></select>
<select id="newGroupTutor" style="background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;min-width:160px"><option value="">— не назначен —</option></select>
<button type="button" id="addGroupBtn">Добавить</button>
</div>
<div class="list" id="groupList"></div>
+26 -2
View File
@@ -1,5 +1,6 @@
const DAYS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
let branchesCache = [];
let tutorsCache = [];
let allGroups = [];
let currentCover = null;
@@ -8,6 +9,16 @@ async function loadBranchesCache() {
if (res.ok) branchesCache = await res.json();
}
async function loadTutorsCache() {
const res = await fetch(`${API}/api/users/tutors`, { headers: hdr() });
if (res.ok) tutorsCache = await res.json();
}
function tutorSelectHTML(selectedId) {
const opts = tutorsCache.map(t => `<option value="${t.id}" ${t.id === selectedId ? 'selected' : ''}>${esc(t.name || t.username)}</option>`).join('');
return `<div><label>Тутор</label><select class="tutor-select" style="background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 8px;font-size:.8rem"><option value="">— не назначен —</option>${opts}</select></div>`;
}
function branchSelectHTML(selectedId) {
const opts = branchesCache.map(b => `<option value="${b.id}" ${b.id === selectedId ? 'selected' : ''}>${esc(b.name)}</option>`).join('');
return `<div><label>Филиал</label><select class="branch-select" style="background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 8px;font-size:.8rem"><option value="">— без филиала —</option>${opts}</select></div>`;
@@ -15,6 +26,7 @@ function branchSelectHTML(selectedId) {
async function loadGroups() {
await loadBranchesCache();
await loadTutorsCache();
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
const groups = await res.json();
allGroups = groups;
@@ -33,6 +45,7 @@ async function loadGroups() {
</div>
<div class="group-schedule">
${branchSelectHTML(g.branch_id)}
${tutorSelectHTML(g.tutor_id)}
<div><label>День</label>
<select id="dow-${g.id}">
<option value="">—</option>
@@ -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 = '<option value="">— без филиала —</option>' + branches.map(b => `<option value="${b.id}">${esc(b.name)}</option>`).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 = '<option value="">— не назначен —</option>' + tutors.map(t => `<option value="${t.id}">${esc(t.name || t.username)}</option>`).join('');
}
}
(async () => {
+33 -8
View File
@@ -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();