Files
Learn_System/frontend/js/admin/sections/access.js
T
Maxim Dolgolyov 4549b4e819 feat(access): Фаза 1b — управление доступом к симуляциям в админке
Бэкенд /api/access обобщён на тип 'sim': catalog отдаёт симуляции (lab_sims),
summary/matrix/class — карты по всем типам. Админ-секция «Доступ» теперь
показывает «Симуляции» во всех трёх режимах (по контенту / по классу / матрица)
+ поиск; helpers (bucket/keyName/itemsOf) обобщены через карты типов
(CONTENT_TYPES=textbook,exam,sim; course зарезервирован). Теперь админ/учитель
могут открывать/закрывать конкретные симуляции классам и ученикам — закрыт UX-
разрыв из 1a (новые классы без UI-управления). Тест: каталог включает sims; 210 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 13:24:08 +03:00

483 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* admin → access section — открыть/закрыть доступ к учебникам и экзаменам
* для классов и отдельных учеников. Модель allowlist: по умолчанию закрыто,
* правило ученика важнее правила класса.
*
* Два режима:
* • «По контенту» — выбрать учебник/экзамен → раздать классам (+ ученики).
* • «По классу» — выбрать класс → отметить доступный ему контент. */
(function () {
'use strict';
let inited = false;
let _catalog = null; // { textbooks:[], exams:[] }
let _targets = null; // { classes:[{id,name,students:[]}], looseStudents:[] }
let _summary = { totalClasses: 0, textbooks: {}, exams: {} };
let _mode = 'content'; // 'content' | 'class'
// content-mode state
let _selContent = null; // { type, ref, title }
let _rules = { classRules: {}, studentRules: {} };
const _open = new Set(); // развёрнутые классы (показ учеников)
// class-mode state
let _selClass = null; // { id, name }
let _classOpen = { textbooks: new Set(), exams: new Set() };
// matrix-mode state
let _matrix = null; // { classes:[{id,name}], open:{ [cid]:{textbook:[],exam:[]} } }
let _mSearch = '';
// content-mode left search
let _leftSearch = '';
const SUBJ_LABEL = { math: 'Математика', physics: 'Физика', phys: 'Физика', chemistry: 'Химия',
chem: 'Химия', biology: 'Биология', bio: 'Биология', informatics: 'Информатика',
russian: 'Русский язык', english: 'Английский', geography: 'География', history: 'История' };
const esc = (s) => (window.LS && LS.esc ? LS.esc(s) : String(s == null ? '' : s));
const BUCKET = { textbook: 'textbooks', exam: 'exams', sim: 'sims', course: 'courses' };
const KEYNAME = { textbook: 'slug', exam: 'exam_key', sim: 'id', course: 'id' };
const TYPE_LABEL = { textbook: 'Учебники', exam: 'Экзамены', sim: 'Симуляции', course: 'Курсы' };
const TYPE_BADGE = { textbook: 'Учебник', exam: 'Экзамен', sim: 'Симуляция', course: 'Курс' };
const CONTENT_TYPES = ['textbook', 'exam', 'sim']; // course добавим отдельным шагом
const bucket = (type) => BUCKET[type] || (type + 's');
const keyName = (type) => KEYNAME[type] || 'id';
const itemsOf = (type) => (_catalog && _catalog[bucket(type)]) || [];
function contentTitle(type, ref) {
const it = itemsOf(type).find(x => x[keyName(type)] === ref);
return it ? it.title : ref;
}
async function load() {
const root = document.getElementById('acc-root');
try {
[_catalog, _targets, _summary] = await Promise.all([
LS.accessCatalog(), LS.accessTargets(), LS.accessSummary(),
]);
renderRoot();
} catch (e) {
root.innerHTML = `<p style="color:var(--danger);font-size:13px">Ошибка загрузки: ${esc(e.message)}</p>`;
}
}
/* ── каркас: переключатель режимов + две колонки / матрица ── */
function renderRoot() {
const root = document.getElementById('acc-root');
const seg = (m, label, pos) => {
const radius = pos === 'first' ? 'border-radius:8px 0 0 8px'
: pos === 'last' ? 'border-radius:0 8px 8px 0;border-left:none' : 'border-left:none';
return `<button onclick="accMode('${m}')" style="border:1px solid var(--border);
background:${_mode === m ? 'var(--accent,#4f46e5)' : 'transparent'};color:${_mode === m ? '#fff' : 'var(--text-3)'};
font-size:13px;padding:6px 16px;cursor:pointer;font-family:inherit;${radius}">${label}</button>`;
};
const tabs = `<div style="margin-bottom:16px;display:inline-flex">
${seg('content', 'По контенту', 'first')}${seg('class', 'По классу', 'mid')}${seg('matrix', 'Матрица', 'last')}
</div>`;
if (_mode === 'matrix') {
root.innerHTML = tabs + `<div class="adm-panel" id="acc-matrix" style="padding:14px"></div>`;
renderMatrix();
return;
}
root.innerHTML = tabs + `
<div class="acc-layout" style="display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap">
<div class="adm-panel" id="acc-left" style="flex:0 0 290px;max-width:330px;padding:10px"></div>
<div class="adm-panel" id="acc-right" style="flex:1;min-width:340px;padding:18px"></div>
</div>`;
renderLeft();
renderRight();
}
/* ── badge «N/M» ── */
function badge(open, total) {
const has = open > 0;
return `<span style="font-size:11px;font-weight:600;padding:2px 8px;border-radius:20px;
background:${has ? 'var(--ok-soft,#dcfce7)' : 'var(--border-h,#eee)'};
color:${has ? 'var(--ok,#16a34a)' : 'var(--muted)'}">${open}/${total}</span>`;
}
/* ── список контента в левой колонке (с поиском + подзаголовками по предмету) ── */
function contentItemBtn(type, it, total) {
const ref = it[keyName(type)];
const active = _selContent && _selContent.type === type && _selContent.ref === ref;
const open = (_summary[bucket(type)] || {})[ref] || 0;
return `<button class="acc-item${active ? ' active' : ''}" onclick="accSelContent('${type}','${esc(ref)}')"
style="display:flex;width:100%;align-items:center;justify-content:space-between;gap:8px;text-align:left;border:none;
background:${active ? 'var(--accent-soft,#eef2ff)' : 'transparent'};padding:8px 10px;border-radius:8px;
cursor:pointer;font-family:inherit;font-size:13.5px;color:var(--text-1);margin-bottom:2px">
<span style="font-weight:${active ? 600 : 500};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(it.title)}</span>
${badge(open, total)}</button>`;
}
function contentLeftList() {
const total = _summary.totalClasses || 0;
const term = _leftSearch.trim().toLowerCase();
const match = (it) => !term || (it.title || '').toLowerCase().includes(term);
let html = '';
CONTENT_TYPES.forEach(type => {
const items = itemsOf(type).filter(match);
if (!items.length) return;
html += `<div style="font-weight:600;font-size:13px;color:var(--text-3);padding:12px 8px 6px">${TYPE_LABEL[type]}</div>`;
if (type === 'textbook') {
let lastSubj = null;
items.forEach(it => {
const sj = it.subject || '';
if (sj !== lastSubj) {
lastSubj = sj;
html += `<div style="font-size:10.5px;color:var(--muted);padding:6px 10px 2px;text-transform:uppercase;letter-spacing:.04em">${esc(SUBJ_LABEL[sj] || sj || 'Прочее')}</div>`;
}
html += contentItemBtn('textbook', it, total);
});
} else {
items.forEach(it => { html += contentItemBtn(type, it, total); });
}
});
return html || empty('Ничего не найдено');
}
function leftSearch(v) {
_leftSearch = v;
const el = document.getElementById('acc-left-list');
if (el) el.innerHTML = contentLeftList();
}
/* ── ЛЕВАЯ колонка ── */
function renderLeft() {
const left = document.getElementById('acc-left');
if (_mode === 'content') {
left.innerHTML = `
<input type="text" placeholder="Поиск по названию…" value="${esc(_leftSearch)}" oninput="accLeftSearch(this.value)"
style="width:100%;box-sizing:border-box;margin-bottom:8px;padding:7px 10px;border:1px solid var(--border);border-radius:8px;background:var(--card);color:var(--text-1);font-family:inherit;font-size:13px">
<div id="acc-left-list">${contentLeftList()}</div>`;
} else {
const classes = _targets.classes || [];
left.innerHTML = `
<div style="font-weight:600;font-size:13px;color:var(--text-3);padding:6px 8px">Классы</div>
${classes.length ? classes.map(c => {
const active = _selClass && _selClass.id === c.id;
return `<button class="acc-item${active ? ' active' : ''}" onclick="accSelClass(${c.id})"
style="display:block;width:100%;text-align:left;border:none;background:${active ? 'var(--accent-soft,#eef2ff)' : 'transparent'};
padding:8px 10px;border-radius:8px;cursor:pointer;font-family:inherit;font-size:13.5px;color:var(--text-1);margin-bottom:2px">
<span style="font-weight:${active ? 600 : 500}">${esc(c.name)}</span>
${c.teacher_name ? `<span style="color:var(--muted);font-size:12px"> · ${esc(c.teacher_name)}</span>` : ''}</button>`;
}).join('') : empty('Нет классов')}`;
}
}
const empty = (t) => `<p style="color:var(--muted);font-size:12px;padding:6px 10px">${t}</p>`;
/* ── ПРАВАЯ колонка ── */
function renderRight() {
const right = document.getElementById('acc-right');
if (_mode === 'content') {
if (!_selContent) { right.innerHTML = hint('Выберите учебник или экзамен слева, чтобы настроить доступ.'); return; }
renderContentDetail(right);
} else {
if (!_selClass) { right.innerHTML = hint('Выберите класс слева, чтобы открыть ему учебники и экзамены.'); return; }
renderClassDetail(right);
}
}
const hint = (t) => `<div style="color:var(--muted);font-size:14px">${t}</div>`;
/* ════════ режим «По контенту» ════════ */
async function selContent(type, ref) {
_selContent = { type, ref, title: contentTitle(type, ref) };
renderLeft();
const right = document.getElementById('acc-right');
right.innerHTML = '<p style="color:var(--muted);font-size:13px">Загрузка…</p>';
try {
_rules = await LS.accessRules(type, ref);
renderRight();
} catch (e) { right.innerHTML = `<p style="color:var(--danger);font-size:13px">Ошибка: ${esc(e.message)}</p>`; }
}
function studentTri(uid) {
const v = _rules.studentRules[uid];
const state = v === 1 ? 'open' : v === 0 ? 'closed' : 'inherit';
const btn = (val, label, on) =>
`<button onclick="accSetStudent(${uid},${val})"
style="border:1px solid var(--border);background:${on ? 'var(--accent,#4f46e5)' : 'transparent'};
color:${on ? '#fff' : 'var(--text-3)'};font-size:11.5px;padding:3px 9px;cursor:pointer;font-family:inherit;
${val === 'null' ? 'border-radius:7px 0 0 7px' : val === 0 ? 'border-radius:0 7px 7px 0;border-left:none' : 'border-left:none'}">${label}</button>`;
return `<span style="display:inline-flex">
${btn('null', 'Наследовать', state === 'inherit')}${btn(1, 'Открыт', state === 'open')}${btn(0, 'Закрыт', state === 'closed')}</span>`;
}
/* эффективный доступ ученика: что он реально видит и почему */
function effBadge(uid, classOpen) {
const v = _rules.studentRules[uid];
let open, why;
if (v === 1) { open = true; why = 'лично'; }
else if (v === 0) { open = false; why = 'лично'; }
else { open = !!classOpen; why = classOpen ? 'по классу' : 'по умолч.'; }
return `<span title="итоговый доступ" style="font-size:11px;padding:2px 7px;border-radius:20px;white-space:nowrap;
background:${open ? 'var(--ok-soft,#dcfce7)' : 'var(--border-h,#eee)'};color:${open ? 'var(--ok,#16a34a)' : 'var(--muted)'}">${open ? 'видит' : 'не видит'} · ${why}</span>`;
}
function classRowContent(c) {
const openToClass = _rules.classRules[c.id] === 1;
const expanded = _open.has(c.id);
const students = c.students || [];
const studentsHtml = expanded ? `
<div style="padding:6px 0 10px 26px">
${students.length ? students.map(s => `
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;padding:5px 0;border-top:1px solid var(--border-soft,#f0f0f0)">
<span style="font-size:13px;color:var(--text-1)">${esc(s.name || s.email)}</span>
<span style="display:inline-flex;align-items:center;gap:8px">${effBadge(s.id, openToClass)}${studentTri(s.id)}</span>
</div>`).join('') : '<p style="color:var(--muted);font-size:12px;margin:4px 0">В классе нет учеников</p>'}
</div>` : '';
return `
<div style="border:1px solid var(--border);border-radius:10px;margin-bottom:10px;padding:10px 12px">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px">
<button onclick="accToggleExpand(${c.id})"
style="border:none;background:transparent;cursor:pointer;font-family:inherit;font-size:14px;font-weight:600;color:var(--text-1);display:flex;align-items:center;gap:6px">
<span style="display:inline-block;transition:transform .15s;transform:rotate(${expanded ? 90 : 0}deg)">▸</span>
${esc(c.name)}${c.teacher_name ? `<span style="font-weight:400;color:var(--muted);font-size:12px">· ${esc(c.teacher_name)}</span>` : ''}
</button>
<label style="display:inline-flex;align-items:center;gap:8px;font-size:12.5px;color:var(--text-3);cursor:pointer">
<span>${openToClass ? 'Открыт' : 'Закрыт'}</span>
<input type="checkbox" ${openToClass ? 'checked' : ''} onchange="accSetClass(${c.id}, this.checked)">
</label>
</div>${studentsHtml}
</div>`;
}
function looseRow(s) {
const open = _rules.studentRules[s.id] === 1;
return `
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;padding:7px 12px;border:1px solid var(--border);border-radius:9px;margin-bottom:6px">
<span style="font-size:13px;color:var(--text-1)">${esc(s.name || s.email)} <span style="color:var(--muted);font-size:11.5px">${esc(s.email)}</span></span>
<label style="display:inline-flex;align-items:center;gap:8px;font-size:12.5px;color:var(--text-3);cursor:pointer">
<span>${open ? 'Открыт' : 'Закрыт'}</span>
<input type="checkbox" ${open ? 'checked' : ''} onchange="accSetStudent(${s.id}, this.checked ? 1 : null)">
</label>
</div>`;
}
function renderContentDetail(right) {
const classes = _targets.classes || [];
const loose = _targets.looseStudents || [];
right.innerHTML = `
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px">
<div style="font-size:16px;font-weight:700;color:var(--text-1)">${esc(_selContent.title)}</div>
<span class="badge ${_selContent.type === 'exam' ? 'badge-warn' : 'badge-info'}" style="font-size:12px">${TYPE_BADGE[_selContent.type] || 'Контент'}</span>
</div>
${classes.length ? `
<div style="display:flex;gap:8px;margin-bottom:14px">
<button class="adm-btn adm-btn-small" onclick="accBulk(1)">Открыть всем классам</button>
<button class="adm-btn adm-btn-small" style="background:var(--border-h);color:var(--text-3)" onclick="accBulk(0)">Закрыть у всех</button>
</div>` : ''}
${classes.length ? classes.map(classRowContent).join('') : '<p style="color:var(--muted);font-size:13px">Нет классов.</p>'}
${loose.length ? `
<div style="margin-top:18px">
<div style="font-weight:600;font-size:13px;color:var(--text-3);margin-bottom:8px">Отдельные ученики (без класса)</div>
${loose.map(looseRow).join('')}
</div>` : ''}`;
}
/* пересчёт бейджа для текущего контента по отображаемым классам */
function recountContent() {
if (!_selContent) return;
const open = (_targets.classes || []).filter(c => _rules.classRules[c.id] === 1).length;
_summary[bucket(_selContent.type)][_selContent.ref] = open;
}
async function setClass(classId, checked) {
const allow = checked ? 1 : null;
try {
await LS.accessSetRule(_selContent.type, _selContent.ref, 'class', classId, allow);
if (allow === 1) _rules.classRules[classId] = 1; else delete _rules.classRules[classId];
recountContent(); renderLeft(); renderRight();
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); renderRight(); }
}
async function bulk(allow) {
if (!allow && !confirm(`Закрыть «${_selContent.title}» у всех классов?`)) return;
const classes = _targets.classes || [];
try {
await Promise.all(classes.map(c =>
LS.accessSetRule(_selContent.type, _selContent.ref, 'class', c.id, allow ? 1 : null)));
for (const c of classes) { if (allow) _rules.classRules[c.id] = 1; else delete _rules.classRules[c.id]; }
recountContent(); renderLeft(); renderRight();
LS.toast(allow ? 'Открыто всем классам' : 'Закрыто у всех классов', 'success');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); selContent(_selContent.type, _selContent.ref); }
}
async function setStudent(uid, allow) {
if (allow === 'null') allow = null;
try {
await LS.accessSetRule(_selContent.type, _selContent.ref, 'student', uid, allow);
if (allow === 1) _rules.studentRules[uid] = 1;
else if (allow === 0) _rules.studentRules[uid] = 0;
else delete _rules.studentRules[uid];
renderRight();
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); renderRight(); }
}
function toggleExpand(classId) {
if (_open.has(classId)) _open.delete(classId); else _open.add(classId);
renderRight();
}
/* ════════ режим «По классу» ════════ */
async function selClass(id) {
const c = (_targets.classes || []).find(x => x.id === id);
_selClass = { id, name: c ? c.name : ('#' + id) };
renderLeft();
const right = document.getElementById('acc-right');
right.innerHTML = '<p style="color:var(--muted);font-size:13px">Загрузка…</p>';
try {
const open = await LS.accessClassOpen(id);
_classOpen = {};
CONTENT_TYPES.forEach(t => { _classOpen[bucket(t)] = new Set(open[bucket(t)] || []); });
renderRight();
} catch (e) { right.innerHTML = `<p style="color:var(--danger);font-size:13px">Ошибка: ${esc(e.message)}</p>`; }
}
function classContentRow(type, it) {
const ref = it[keyName(type)];
const open = _classOpen[bucket(type)].has(ref);
return `
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 12px;border:1px solid var(--border);border-radius:9px;margin-bottom:6px">
<span style="font-size:13.5px;color:var(--text-1)">${esc(it.title)}
<span class="badge ${type === 'exam' ? 'badge-warn' : 'badge-info'}" style="font-size:10.5px;margin-left:6px">${TYPE_BADGE[type] || 'Контент'}</span></span>
<label style="display:inline-flex;align-items:center;gap:8px;font-size:12.5px;color:var(--text-3);cursor:pointer">
<span>${open ? 'Открыт' : 'Закрыт'}</span>
<input type="checkbox" ${open ? 'checked' : ''} onchange="accClassToggle('${type}','${esc(ref)}', this.checked)">
</label>
</div>`;
}
function renderClassDetail(right) {
let html = `
<div style="font-size:16px;font-weight:700;color:var(--text-1);margin-bottom:14px">Класс «${esc(_selClass.name)}»</div>
<div style="display:flex;gap:8px;margin-bottom:14px">
<button class="adm-btn adm-btn-small" onclick="accClassBulk(1)">Открыть весь контент</button>
<button class="adm-btn adm-btn-small" style="background:var(--border-h);color:var(--text-3)" onclick="accClassBulk(0)">Закрыть весь</button>
</div>`;
CONTENT_TYPES.forEach(type => {
const items = itemsOf(type);
html += `<div style="font-weight:600;font-size:13px;color:var(--text-3);margin:14px 0 8px">${TYPE_LABEL[type]}</div>`;
html += items.length ? items.map(it => classContentRow(type, it)).join('') : empty('Нет');
});
right.innerHTML = html;
}
function bumpSummary(type, ref, delta) {
const b = _summary[bucket(type)];
const cur = b[ref] || 0;
b[ref] = Math.max(0, Math.min(_summary.totalClasses || 0, cur + delta));
}
async function classToggle(type, ref, checked) {
try {
await LS.accessSetRule(type, ref, 'class', _selClass.id, checked ? 1 : null);
const set = _classOpen[bucket(type)];
const was = set.has(ref);
if (checked) set.add(ref); else set.delete(ref);
if (checked && !was) bumpSummary(type, ref, +1);
if (!checked && was) bumpSummary(type, ref, -1);
renderRight();
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); selClass(_selClass.id); }
}
async function classBulk(allow) {
if (!allow && !confirm(`Закрыть весь контент у класса «${_selClass.name}»?`)) return;
const all = CONTENT_TYPES.flatMap(t => itemsOf(t).map(it => [t, it[keyName(t)]]));
try {
await Promise.all(all.map(([type, ref]) =>
LS.accessSetRule(type, ref, 'class', _selClass.id, allow ? 1 : null)));
for (const [type, ref] of all) {
const set = _classOpen[bucket(type)];
const was = set.has(ref);
if (allow) { set.add(ref); if (!was) bumpSummary(type, ref, +1); }
else { set.delete(ref); if (was) bumpSummary(type, ref, -1); }
}
renderRight();
LS.toast(allow ? 'Открыт весь контент классу' : 'Закрыт весь контент', 'success');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); selClass(_selClass.id); }
}
/* ════════ режим «Матрица» (класс × контент одним экраном) ════════ */
function matrixHeadCells(classes) {
return classes.map(c =>
`<th style="padding:6px 8px;font-size:11.5px;font-weight:600;color:var(--text-3);white-space:nowrap;border-bottom:1px solid var(--border)">${esc(c.name)}</th>`).join('');
}
function matrixBody() {
const classes = _matrix.classes || [];
const term = _mSearch.trim().toLowerCase();
const match = (it) => !term || (it.title || '').toLowerCase().includes(term);
const section = (type, items) => {
const rows = (items || []).filter(match).map(it => {
const ref = it[keyName(type)];
const cells = classes.map(c => {
const open = ((_matrix.open[c.id] || {})[type] || []).includes(ref);
return `<td style="text-align:center;border-bottom:1px solid var(--border-soft,#f0f0f0)">
<input type="checkbox" ${open ? 'checked' : ''} onchange="accMx('${type}','${esc(ref)}',${c.id},this.checked)" title="${esc(c.name)} · ${esc(it.title)}"></td>`;
}).join('');
return `<tr><th scope="row" style="text-align:left;padding:6px 10px;font-size:13px;font-weight:500;color:var(--text-1);white-space:nowrap;position:sticky;left:0;background:var(--card,#fff);border-bottom:1px solid var(--border-soft,#f0f0f0)">${esc(it.title)}</th>${cells}</tr>`;
}).join('');
if (!rows) return '';
return `<tr><th colspan="${classes.length + 1}" style="text-align:left;padding:10px 10px 4px;font-size:12px;font-weight:700;color:var(--text-3)">${TYPE_LABEL[type] || type}</th></tr>${rows}`;
};
const body = CONTENT_TYPES.map(t => section(t, itemsOf(t))).join('');
return body || `<tr><td colspan="${classes.length + 1}" style="padding:10px">${empty('Ничего не найдено')}</td></tr>`;
}
async function renderMatrix() {
const root = document.getElementById('acc-matrix');
if (!root) return;
if (!_matrix) {
root.innerHTML = '<p style="color:var(--muted);font-size:13px">Загрузка…</p>';
try { _matrix = await LS.accessMatrix(); }
catch (e) { root.innerHTML = `<p style="color:var(--danger);font-size:13px">Ошибка: ${esc(e.message)}</p>`; return; }
}
const classes = _matrix.classes || [];
if (!classes.length) { root.innerHTML = empty('Нет классов'); return; }
root.innerHTML = `
<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap">
<input type="text" placeholder="Поиск по названию…" value="${esc(_mSearch)}" oninput="accMxSearch(this.value)"
style="flex:1;min-width:200px;max-width:320px;padding:7px 11px;border:1px solid var(--border);border-radius:8px;background:var(--card);color:var(--text-1);font-family:inherit;font-size:13px">
<span style="font-size:12px;color:var(--muted)">отметьте, какой класс видит контент</span>
</div>
<div style="overflow:auto;max-height:70vh">
<table style="border-collapse:collapse;min-width:100%">
<thead><tr><th style="position:sticky;left:0;background:var(--card,#fff);border-bottom:1px solid var(--border);z-index:1"></th>${matrixHeadCells(classes)}</tr></thead>
<tbody id="acc-mx-body">${matrixBody()}</tbody>
</table>
</div>`;
}
async function mxToggle(type, ref, classId, checked) {
try {
await LS.accessSetRule(type, ref, 'class', classId, checked ? 1 : null);
const o = _matrix.open[classId] || (_matrix.open[classId] = { textbook: [], exam: [] });
const arr = o[type] || (o[type] = []);
const i = arr.indexOf(ref);
if (checked && i < 0) arr.push(ref);
if (!checked && i >= 0) arr.splice(i, 1);
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); _matrix = null; renderMatrix(); }
}
function mxSearch(v) { _mSearch = v; const b = document.getElementById('acc-mx-body'); if (b) b.innerHTML = matrixBody(); }
/* ── режим ── */
function setMode(m) {
if (m === _mode) return;
_mode = m;
if (m === 'matrix') _matrix = null; // всегда свежая матрица
renderRoot();
}
window.accMode = setMode;
window.accSelContent = selContent;
window.accSetClass = setClass;
window.accBulk = bulk;
window.accSetStudent = setStudent;
window.accToggleExpand = toggleExpand;
window.accSelClass = selClass;
window.accClassToggle = classToggle;
window.accClassBulk = classBulk;
window.accMx = mxToggle;
window.accMxSearch = mxSearch;
window.accLeftSearch = leftSearch;
window.AdminSections = window.AdminSections || {};
window.AdminSections.access = {
init: async () => { if (inited) return; inited = true; await load(); },
reload: load,
};
})();