| | | 1 | | @model List<RolePageMatrixRow> |
| | | 2 | | @{ |
| | 0 | 3 | | var role = (OzonDomains.Models.Authorization.AppRole)ViewBag.Role; |
| | 0 | 4 | | var presetJson = ViewBag.Studio2RolePagePresetsJson as string ?? "{}"; |
| | 0 | 5 | | var json = new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web); |
| | | 6 | | } |
| | | 7 | | |
| | | 8 | | <section class="admin-hero"> |
| | | 9 | | <h1>Матрица страниц</h1> |
| | 0 | 10 | | <p class="subtitle">Настройка доступа к страницам для роли @role.DisplayName.</p> |
| | | 11 | | <p class="subtitle-help">Изменения сохраняются сразу по строке; галка в заголовке колонки — для всех страниц.</p> |
| | | 12 | | </section> |
| | | 13 | | |
| | | 14 | | <nav class="admin-quick-links"> |
| | 0 | 15 | | <a asp-area="Admin" asp-controller="Roles" asp-action="Edit" asp-route-id="@role.Id"><i class="bi bi-arrow-left"></i |
| | | 16 | | <a asp-area="Admin" asp-controller="Pages" asp-action="Index"><i class="bi bi-window-stack"></i>Реестр страниц</a> |
| | | 17 | | <a asp-area="Admin" asp-controller="Users" asp-action="Index"><i class="bi bi-people"></i>Пользователи</a> |
| | | 18 | | </nav> |
| | | 19 | | |
| | | 20 | | <div class="card mb-3"> |
| | | 21 | | <div class="card-body d-flex justify-content-between align-items-center"> |
| | | 22 | | <div> |
| | | 23 | | <div class="text-muted small">Роль</div> |
| | 0 | 24 | | <h5 class="mb-0">@role.DisplayName <small class="text-muted">(<code>@role.Name</code>)</small></h5> |
| | | 25 | | </div> |
| | | 26 | | <div> |
| | 0 | 27 | | <a asp-action="Edit" asp-route-id="@role.Id" class="btn btn-outline-secondary btn-sm"><i class="bi bi-arrow- |
| | | 28 | | </div> |
| | | 29 | | </div> |
| | | 30 | | </div> |
| | | 31 | | |
| | | 32 | | <div class="admin-toolbar admin-surface"> |
| | | 33 | | <div class="admin-matrix-toolbar"> |
| | | 34 | | <div class="btn-group btn-group-sm" role="group" aria-label="Пресеты Studio2"> |
| | | 35 | | <button type="button" class="btn btn-outline-primary" id="btnPresetOrders" title="Просмотр + создание: весь |
| | | 36 | | <button type="button" class="btn btn-outline-primary" id="btnPresetTx" title="Просмотр + создание: все типы |
| | | 37 | | <button type="button" class="btn btn-outline-primary" id="btnPresetBoth" title="Просмотр + создание: заказы |
| | | 38 | | </div> |
| | | 39 | | <div class="dropdown d-inline-block"> |
| | | 40 | | <button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" dat |
| | | 41 | | <ul class="dropdown-menu admin-scroll-menu" id="txSinglePresetMenu"> |
| | | 42 | | <li><h6 class="dropdown-header">Основные</h6></li> |
| | | 43 | | <li><a class="dropdown-item" href="#" data-tx-preset="txOrderToSupplier">Заказан поставщику</a></li> |
| | | 44 | | <li><a class="dropdown-item" href="#" data-tx-preset="txOrderedToSeller">Заказан реализатору</a></li> |
| | | 45 | | <li><a class="dropdown-item" href="#" data-tx-preset="txShippedBySupplier">Отгружен поставщиком</a></li> |
| | | 46 | | <li><a class="dropdown-item" href="#" data-tx-preset="txShippedToSeller">Отгружен реализатором</a></li> |
| | | 47 | | <li><a class="dropdown-item" href="#" data-tx-preset="txShippedToCommissioner">Отгружен комиссионеру</a> |
| | | 48 | | <li><a class="dropdown-item" href="#" data-tx-preset="txAcceptedToWarehouse">Принят на склад</a></li> |
| | | 49 | | <li><a class="dropdown-item" href="#" data-tx-preset="txShippedToClient">Отгружен клиенту</a></li> |
| | | 50 | | <li><h6 class="dropdown-header">Возвраты</h6></li> |
| | | 51 | | <li><a class="dropdown-item" href="#" data-tx-preset="txCustomerReturn">Возврат покупателя</a></li> |
| | | 52 | | <li><a class="dropdown-item" href="#" data-tx-preset="txReturnToSupplier">Возвращено поставщику</a></li> |
| | | 53 | | <li><a class="dropdown-item" href="#" data-tx-preset="txReturnToWarehouse">Возврат на склад</a></li> |
| | | 54 | | <li><a class="dropdown-item" href="#" data-tx-preset="txReturnTransfer">Перемещение</a></li> |
| | | 55 | | <li><h6 class="dropdown-header">Склад</h6></li> |
| | | 56 | | <li><a class="dropdown-item" href="#" data-tx-preset="txWarehouseChange">Изменение склада</a></li> |
| | | 57 | | <li><a class="dropdown-item" href="#" data-tx-preset="txWarehouseTransfer">Перемещение между складами</a |
| | | 58 | | <li><h6 class="dropdown-header">Отмена</h6></li> |
| | | 59 | | <li><a class="dropdown-item" href="#" data-tx-preset="txToCancellation">К отмене</a></li> |
| | | 60 | | <li><a class="dropdown-item" href="#" data-tx-preset="txCancelled">Отменен</a></li> |
| | | 61 | | </ul> |
| | | 62 | | </div> |
| | | 63 | | <button type="button" class="btn btn-sm btn-outline-success" id="btnCheckAllView">Все «Просмотр»</button> |
| | | 64 | | <button type="button" class="btn btn-sm btn-outline-danger" id="btnClearAll">Очистить</button> |
| | | 65 | | <span class="admin-field-hint ms-2"><i class="bi bi-check-circle"></i> Авто-сохранение по строке</span> |
| | | 66 | | </div> |
| | | 67 | | </div> |
| | | 68 | | </div> |
| | | 69 | | |
| | 0 | 70 | | @await Html.PartialAsync("~/Areas/Studio2/Views/Shared/_BsxDirectoryGrid.cshtml", new BsxDirectoryGridViewModel("admRole |
| | 0 | 71 | | "Строки-разделы — группы меню Studio2. Фильтр колонки права показывает только отмеченные или только снятые.")) |
| | 0 | 72 | | <script type="application/json" id="admRolePagesData">@Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model, json))< |
| | | 73 | | |
| | | 74 | | @section Scripts { |
| | | 75 | | <script> |
| | | 76 | | (function () { |
| | | 77 | | const A = window.AdminGrid, esc = A.esc; |
| | 0 | 78 | | const roleId = @role.Id; |
| | 0 | 79 | | const studio2Presets = @Html.Raw(presetJson); |
| | | 80 | | const COLS = ['canView', 'canCreate', 'canEdit', 'canDelete', 'canExport']; |
| | 0 | 81 | | const saveOneUrl = '@Url.Action("SavePagePermission")?roleId=' + roleId; |
| | 0 | 82 | | const saveBulkUrl = '@Url.Action("SavePagePermissionsBulk")?roleId=' + roleId; |
| | | 83 | | const rows = A.data('admRolePagesData').map(r => Object.assign(r, { id: r.pageId })); |
| | | 84 | | let api = null; |
| | | 85 | | |
| | | 86 | | const toast = (title, message, variant) => window.AppToast ? AppToast.show({ title, message, variant: variant || |
| | | 87 | | const input = r => ({ PageId: r.pageId, CanView: !!r.canView, CanCreate: !!r.canCreate, CanEdit: !!r.canEdit, Ca |
| | | 88 | | const redraw = list => api && api.redrawRows({ rowNodes: list.map(r => api.getRowNode(String(r.id))).filter(Bool |
| | | 89 | | const post = (url, body) => fetch(url, { |
| | | 90 | | method: 'POST', credentials: 'same-origin', |
| | | 91 | | headers: { 'Content-Type': 'application/json', RequestVerificationToken: BsxGrid.token() }, |
| | | 92 | | body: JSON.stringify(body) |
| | | 93 | | }); |
| | | 94 | | const dataRows = () => rows.filter(r => !r.isSectionHeader); |
| | | 95 | | |
| | | 96 | | async function saveOne(row) { |
| | | 97 | | row.__saving = true; redraw([row]); |
| | | 98 | | const resp = await post(saveOneUrl, input(row)).catch(() => null); |
| | | 99 | | row.__saving = false; redraw([row]); |
| | | 100 | | if (!resp || !resp.ok) toast('Ошибка сохранения', 'Не удалось сохранить строку. Повторите или обновите стран |
| | | 101 | | } |
| | | 102 | | |
| | | 103 | | /** Один POST вместо сотен — иначе срабатывает rate limit (429). */ |
| | | 104 | | async function saveBulk(list, flash) { |
| | | 105 | | if (!list.length) return true; |
| | | 106 | | list.forEach(r => { r.__saving = true; if (flash) r.__flash = true; }); |
| | | 107 | | redraw(list); |
| | | 108 | | const resp = await post(saveBulkUrl, list.map(input)).catch(() => null); |
| | | 109 | | list.forEach(r => { r.__saving = false; }); |
| | | 110 | | redraw(list); |
| | | 111 | | if (flash) setTimeout(() => { list.forEach(r => { r.__flash = false; }); redraw(list); }, 4500); |
| | | 112 | | if (resp && resp.ok) return true; |
| | | 113 | | toast('Ошибка сохранения', 'Не удалось сохранить (возможен лимит запросов — обновите страницу и повторите).' |
| | | 114 | | return false; |
| | | 115 | | } |
| | | 116 | | |
| | | 117 | | function setColumn(field, on) { |
| | | 118 | | const changed = dataRows().filter(r => !!r[field] !== on); |
| | | 119 | | changed.forEach(r => { r[field] = on; }); |
| | | 120 | | return saveBulk(changed); |
| | | 121 | | } |
| | | 122 | | |
| | | 123 | | async function applyPreset(presetKey, label) { |
| | | 124 | | const codes = studio2Presets && studio2Presets[presetKey]; |
| | | 125 | | if (!codes || !codes.length) { toast('Пресет', 'Пресет не найден. Обновите страницу.', 'danger'); return; } |
| | | 126 | | const byCode = new Map(dataRows().map(r => [r.code, r])); |
| | | 127 | | const changed = [], missing = []; |
| | | 128 | | codes.forEach(code => { |
| | | 129 | | const r = byCode.get(code); |
| | | 130 | | if (!r) { missing.push(code); return; } |
| | | 131 | | let dirty = false; |
| | | 132 | | COLS.forEach(c => { const on = c === 'canView' || c === 'canCreate'; if (!!r[c] !== on) { r[c] = on; dir |
| | | 133 | | if (dirty) changed.push(r); |
| | | 134 | | }); |
| | | 135 | | if (changed.length) api.ensureNodeVisible(api.getRowNode(String(changed[0].id)), 'middle'); |
| | | 136 | | const ok = await saveBulk(changed, true); |
| | | 137 | | if (!ok) return; |
| | | 138 | | let body = changed.length |
| | | 139 | | ? 'Обновлено строк: ' + changed.length + '. Изменённые строки подсвечены на ~4 с.' |
| | | 140 | | : 'Все нужные права «Просмотр» и «Создание» для пресета «' + label + '» уже стоят — строки не трогали.'; |
| | | 141 | | if (missing.length) body += ' В реестре не найдено страниц: ' + missing.length + '.'; |
| | | 142 | | toast('Пресет: ' + label, body, changed.length ? 'success' : 'info'); |
| | | 143 | | } |
| | | 144 | | |
| | | 145 | | const onToggleAll = (field, checked) => setColumn(field, checked); |
| | | 146 | | A.grid({ |
| | | 147 | | key: 'admRolePages', prefix: 'admRolePages', rows: rows, rowHeight: 44, |
| | | 148 | | columnDefs: [ |
| | | 149 | | { |
| | | 150 | | colId: 'page', field: 'displayName', headerName: 'Страница', minWidth: 320, flex: 3, sortable: false |
| | | 151 | | getQuickFilterText: p => p.data.displayName + ' ' + p.data.code, |
| | | 152 | | cellRenderer: p => { |
| | | 153 | | const r = p.data; if (!r) return ''; |
| | | 154 | | return '<span class="admin-tree-cell" style="padding-left:' + (r.depth * 18) + 'px">' + |
| | | 155 | | (r.depth > 0 ? '<i class="fas fa-turn-up fa-rotate-90"></i>' : '') + |
| | | 156 | | (r.icon ? '<i class="' + esc(r.icon) + ' ' + esc(r.accentClass || '') + '" aria-hidden="true |
| | | 157 | | '<span class="bsx-cell-stack"><b>' + esc(r.displayName) + '</b><span class="admin-code">' + |
| | | 158 | | } |
| | | 159 | | }, |
| | | 160 | | A.checkboxCol('canView', 'Просмотр', { onToggle: (r, f, v) => { r[f] = v; saveOne(r); }, onToggleAll }), |
| | | 161 | | A.checkboxCol('canCreate', 'Создание', { onToggle: (r, f, v) => { r[f] = v; saveOne(r); }, onToggleAll } |
| | | 162 | | A.checkboxCol('canEdit', 'Редакт.', { onToggle: (r, f, v) => { r[f] = v; saveOne(r); }, onToggleAll }), |
| | | 163 | | A.checkboxCol('canDelete', 'Удаление', { onToggle: (r, f, v) => { r[f] = v; saveOne(r); }, onToggleAll } |
| | | 164 | | A.checkboxCol('canExport', 'Экспорт', { onToggle: (r, f, v) => { r[f] = v; saveOne(r); }, onToggleAll }) |
| | | 165 | | ], |
| | | 166 | | rowClassRules: { |
| | | 167 | | 'admin-row-saving': p => !!(p.data && p.data.__saving), |
| | | 168 | | 'admin-row-flash': p => !!(p.data && p.data.__flash) |
| | | 169 | | }, |
| | | 170 | | gridOptions: { |
| | | 171 | | isFullWidthRow: p => !!(p.rowNode.data && p.rowNode.data.isSectionHeader), |
| | | 172 | | fullWidthCellRenderer: p => { |
| | | 173 | | const r = p.data; |
| | | 174 | | return '<div class="admin-matrix-section" style="padding-left:' + (12 + r.depth * 18) + 'px">' + |
| | | 175 | | (r.icon ? '<i class="' + esc(r.icon) + ' ' + esc(r.accentClass || '') + '" aria-hidden="true"></ |
| | | 176 | | }, |
| | | 177 | | overlayNoRowsTemplate: '<span class="bsx-grid-empty">Нет зарегистрированных страниц. Синхронизируйте рее |
| | | 178 | | }, |
| | | 179 | | onReady: a => { api = a; } |
| | | 180 | | }); |
| | | 181 | | |
| | | 182 | | document.getElementById('btnPresetOrders')?.addEventListener('click', () => applyPreset('ordersMenu', 'Заказы (с |
| | | 183 | | document.getElementById('btnPresetTx')?.addEventListener('click', () => applyPreset('transactionsMenu', 'Все тра |
| | | 184 | | document.getElementById('btnPresetBoth')?.addEventListener('click', () => applyPreset('ordersAndTransactions', ' |
| | | 185 | | document.getElementById('txSinglePresetMenu')?.addEventListener('click', e => { |
| | | 186 | | const a = e.target && e.target.closest('a[data-tx-preset]'); |
| | | 187 | | if (!a) return; |
| | | 188 | | e.preventDefault(); |
| | | 189 | | const dd = document.getElementById('ddTxOne'); |
| | | 190 | | if (dd && window.bootstrap) bootstrap.Dropdown.getOrCreateInstance(dd).hide(); |
| | | 191 | | applyPreset(a.getAttribute('data-tx-preset'), 'Одна транзакция: ' + (a.textContent.trim() || a.getAttribute( |
| | | 192 | | }); |
| | | 193 | | document.getElementById('btnCheckAllView').addEventListener('click', () => setColumn('canView', true)); |
| | | 194 | | document.getElementById('btnClearAll').addEventListener('click', async () => { |
| | | 195 | | const approved = await window.adminConfirm({ title: 'Снять все права', message: 'Снять все права страниц для |
| | | 196 | | if (!approved) return; |
| | | 197 | | const changed = dataRows().filter(r => COLS.some(c => r[c])); |
| | | 198 | | changed.forEach(r => COLS.forEach(c => { r[c] = false; })); |
| | | 199 | | if (await saveBulk(changed, true)) toast('Права очищены', 'Сняты все права по страницам этой роли; изменённы |
| | | 200 | | }); |
| | | 201 | | })(); |
| | | 202 | | </script> |
| | | 203 | | } |