feat(roles): permissions matrix grouped by section + clone-from-template flow
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 41s
CI / Web (React + Vite) (push) Successful in 38s
Docker API / Build + push API (push) Successful in 54s
Docker Web / Build + push Web (push) Successful in 29s
Docker API / Deploy API on stage (push) Successful in 17s
Docker Web / Deploy Web on stage (push) Successful in 11s

RolePermissions расширен с 21 до 32 флагов: добавлены UnitsManage,
DemandsView/Edit/Post (отгрузка контрагенту, не путать с RetailSales),
CounterpartiesDelete, InventoryEdit, LossEdit, EnterEdit (склад-операции),
ReportsFinanceView, ReportsStockView (тонкие отчётные права),
CashRegistersManage и IntegrationsManage (отдельно от OrgSettingsManage).

UI EmployeeRolesPage:
- 7 групп вместо 6: Каталог / Закупки / Продажи / Контрагенты /
  Склад-Остатки / Отчёты / Настройки. Все секции аккордеоном внутри
  модалки (как было — flex-col), но с правильным грануляр-списком.
- Системные роли — чекбоксы disabled (только просмотр; имя/описание
  редактируются).
- При [+ Добавить роль] — сначала открывается модалка выбора шаблона:
  Пустой / Копия Администратора / Копия любой существующей. Дальше
  открывается основная модалка с предзаполненной матрицей.

allPerms() помощник на фронте — зеркало RolePermissions.All() с бэка,
для шаблона «Копия Администратора» в clone-flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-26 12:44:53 +05:00
parent dd3bf5e20f
commit 080564f2b2
3 changed files with 135 additions and 21 deletions

View file

@ -10,6 +10,7 @@ public class RolePermissions
public bool ProductsDelete { get; set; }
public bool ProductGroupsManage { get; set; }
public bool PriceTypesManage { get; set; }
public bool UnitsManage { get; set; }
// Закупки
public bool SuppliesView { get; set; }
@ -17,17 +18,28 @@ public class RolePermissions
public bool SuppliesPost { get; set; }
public bool SuppliesDelete { get; set; }
// Продажи (POS)
// Продажи (отгрузка контрагенту + POS)
public bool DemandsView { get; set; }
public bool DemandsEdit { get; set; }
public bool DemandsPost { get; set; }
public bool RetailSalesOperate { get; set; }
public bool RetailSalesRefund { get; set; }
// Контрагенты
public bool CounterpartiesView { get; set; }
public bool CounterpartiesEdit { get; set; }
public bool CounterpartiesDelete { get; set; }
// Отчёты и остатки
public bool ReportsView { get; set; }
// Склад / Остатки
public bool StocksView { get; set; }
public bool InventoryEdit { get; set; }
public bool LossEdit { get; set; }
public bool EnterEdit { get; set; }
// Отчёты
public bool ReportsView { get; set; }
public bool ReportsFinanceView { get; set; }
public bool ReportsStockView { get; set; }
// Настройки организации
public bool OrgSettingsManage { get; set; }
@ -35,17 +47,22 @@ public class RolePermissions
public bool RolesManage { get; set; }
public bool StoresManage { get; set; }
public bool RetailPointsManage { get; set; }
public bool CashRegistersManage { get; set; }
public bool IntegrationsManage { get; set; }
/// <summary>Полный набор всех true — для системной роли «Администратор».</summary>
public static RolePermissions All() => new()
{
ProductsView = true, ProductsEdit = true, ProductsDelete = true,
ProductGroupsManage = true, PriceTypesManage = true,
ProductGroupsManage = true, PriceTypesManage = true, UnitsManage = true,
SuppliesView = true, SuppliesEdit = true, SuppliesPost = true, SuppliesDelete = true,
DemandsView = true, DemandsEdit = true, DemandsPost = true,
RetailSalesOperate = true, RetailSalesRefund = true,
CounterpartiesView = true, CounterpartiesEdit = true,
ReportsView = true, StocksView = true,
CounterpartiesView = true, CounterpartiesEdit = true, CounterpartiesDelete = true,
StocksView = true, InventoryEdit = true, LossEdit = true, EnterEdit = true,
ReportsView = true, ReportsFinanceView = true, ReportsStockView = true,
OrgSettingsManage = true, EmployeesManage = true, RolesManage = true,
StoresManage = true, RetailPointsManage = true,
CashRegistersManage = true, IntegrationsManage = true,
};
}

View file

@ -1914,21 +1914,33 @@ protected override void BuildModel(ModelBuilder modelBuilder)
o.Property<bool>("ProductsDelete").HasColumnType("boolean");
o.Property<bool>("ProductGroupsManage").HasColumnType("boolean");
o.Property<bool>("PriceTypesManage").HasColumnType("boolean");
o.Property<bool>("UnitsManage").HasColumnType("boolean");
o.Property<bool>("SuppliesView").HasColumnType("boolean");
o.Property<bool>("SuppliesEdit").HasColumnType("boolean");
o.Property<bool>("SuppliesPost").HasColumnType("boolean");
o.Property<bool>("SuppliesDelete").HasColumnType("boolean");
o.Property<bool>("DemandsView").HasColumnType("boolean");
o.Property<bool>("DemandsEdit").HasColumnType("boolean");
o.Property<bool>("DemandsPost").HasColumnType("boolean");
o.Property<bool>("RetailSalesOperate").HasColumnType("boolean");
o.Property<bool>("RetailSalesRefund").HasColumnType("boolean");
o.Property<bool>("CounterpartiesView").HasColumnType("boolean");
o.Property<bool>("CounterpartiesEdit").HasColumnType("boolean");
o.Property<bool>("ReportsView").HasColumnType("boolean");
o.Property<bool>("CounterpartiesDelete").HasColumnType("boolean");
o.Property<bool>("StocksView").HasColumnType("boolean");
o.Property<bool>("InventoryEdit").HasColumnType("boolean");
o.Property<bool>("LossEdit").HasColumnType("boolean");
o.Property<bool>("EnterEdit").HasColumnType("boolean");
o.Property<bool>("ReportsView").HasColumnType("boolean");
o.Property<bool>("ReportsFinanceView").HasColumnType("boolean");
o.Property<bool>("ReportsStockView").HasColumnType("boolean");
o.Property<bool>("OrgSettingsManage").HasColumnType("boolean");
o.Property<bool>("EmployeesManage").HasColumnType("boolean");
o.Property<bool>("RolesManage").HasColumnType("boolean");
o.Property<bool>("StoresManage").HasColumnType("boolean");
o.Property<bool>("RetailPointsManage").HasColumnType("boolean");
o.Property<bool>("CashRegistersManage").HasColumnType("boolean");
o.Property<bool>("IntegrationsManage").HasColumnType("boolean");
o.HasKey("EmployeeRoleId");
o.ToJson("permissions");
o.WithOwner().HasForeignKey("EmployeeRoleId");

View file

@ -13,13 +13,16 @@ const URL = '/api/organization/employee-roles'
export interface RolePermissions {
productsView: boolean; productsEdit: boolean; productsDelete: boolean
productGroupsManage: boolean; priceTypesManage: boolean
productGroupsManage: boolean; priceTypesManage: boolean; unitsManage: boolean
suppliesView: boolean; suppliesEdit: boolean; suppliesPost: boolean; suppliesDelete: boolean
demandsView: boolean; demandsEdit: boolean; demandsPost: boolean
retailSalesOperate: boolean; retailSalesRefund: boolean
counterpartiesView: boolean; counterpartiesEdit: boolean
reportsView: boolean; stocksView: boolean
counterpartiesView: boolean; counterpartiesEdit: boolean; counterpartiesDelete: boolean
stocksView: boolean; inventoryEdit: boolean; lossEdit: boolean; enterEdit: boolean
reportsView: boolean; reportsFinanceView: boolean; reportsStockView: boolean
orgSettingsManage: boolean; employeesManage: boolean; rolesManage: boolean
storesManage: boolean; retailPointsManage: boolean
storesManage: boolean; retailPointsManage: boolean; cashRegistersManage: boolean
integrationsManage: boolean
}
export interface EmployeeRoleDto {
@ -37,24 +40,42 @@ interface Form {
const blankPerms = (): RolePermissions => ({
productsView: false, productsEdit: false, productsDelete: false,
productGroupsManage: false, priceTypesManage: false,
productGroupsManage: false, priceTypesManage: false, unitsManage: false,
suppliesView: false, suppliesEdit: false, suppliesPost: false, suppliesDelete: false,
demandsView: false, demandsEdit: false, demandsPost: false,
retailSalesOperate: false, retailSalesRefund: false,
counterpartiesView: false, counterpartiesEdit: false,
reportsView: false, stocksView: false,
counterpartiesView: false, counterpartiesEdit: false, counterpartiesDelete: false,
stocksView: false, inventoryEdit: false, lossEdit: false, enterEdit: false,
reportsView: false, reportsFinanceView: false, reportsStockView: false,
orgSettingsManage: false, employeesManage: false, rolesManage: false,
storesManage: false, retailPointsManage: false,
storesManage: false, retailPointsManage: false, cashRegistersManage: false,
integrationsManage: false,
})
export const allPerms = (): RolePermissions => ({
productsView: true, productsEdit: true, productsDelete: true,
productGroupsManage: true, priceTypesManage: true, unitsManage: true,
suppliesView: true, suppliesEdit: true, suppliesPost: true, suppliesDelete: true,
demandsView: true, demandsEdit: true, demandsPost: true,
retailSalesOperate: true, retailSalesRefund: true,
counterpartiesView: true, counterpartiesEdit: true, counterpartiesDelete: true,
stocksView: true, inventoryEdit: true, lossEdit: true, enterEdit: true,
reportsView: true, reportsFinanceView: true, reportsStockView: true,
orgSettingsManage: true, employeesManage: true, rolesManage: true,
storesManage: true, retailPointsManage: true, cashRegistersManage: true,
integrationsManage: true,
})
const blankForm = (): Form => ({ name: '', description: '', isSystem: false, permissions: blankPerms() })
const PERM_GROUPS: { title: string; perms: { key: keyof RolePermissions; label: string }[] }[] = [
export const PERM_GROUPS: { title: string; perms: { key: keyof RolePermissions; label: string }[] }[] = [
{ title: 'Каталог', perms: [
{ key: 'productsView', label: 'Просмотр товаров' },
{ key: 'productsEdit', label: 'Редактирование товаров' },
{ key: 'productsDelete', label: 'Удаление товаров' },
{ key: 'productGroupsManage', label: 'Управление группами товаров' },
{ key: 'productGroupsManage', label: 'Управление группами' },
{ key: 'priceTypesManage', label: 'Управление типами цен' },
{ key: 'unitsManage', label: 'Единицы измерения' },
]},
{ title: 'Закупки', perms: [
{ key: 'suppliesView', label: 'Просмотр приёмок' },
@ -63,16 +84,27 @@ const PERM_GROUPS: { title: string; perms: { key: keyof RolePermissions; label:
{ key: 'suppliesDelete', label: 'Удаление приёмок' },
]},
{ title: 'Продажи', perms: [
{ key: 'demandsView', label: 'Просмотр отгрузок' },
{ key: 'demandsEdit', label: 'Редактирование отгрузок' },
{ key: 'demandsPost', label: 'Проведение отгрузок' },
{ key: 'retailSalesOperate', label: 'Работа на кассе' },
{ key: 'retailSalesRefund', label: 'Возвраты' },
{ key: 'retailSalesRefund', label: 'Возвраты на кассе' },
]},
{ title: 'Контрагенты', perms: [
{ key: 'counterpartiesView', label: 'Просмотр' },
{ key: 'counterpartiesEdit', label: 'Редактирование' },
{ key: 'counterpartiesDelete', label: 'Удаление' },
]},
{ title: 'Отчёты и остатки', perms: [
{ key: 'reportsView', label: 'Просмотр отчётов' },
{ title: 'Склад / Остатки', perms: [
{ key: 'stocksView', label: 'Просмотр остатков' },
{ key: 'inventoryEdit', label: 'Инвентаризация' },
{ key: 'lossEdit', label: 'Списание' },
{ key: 'enterEdit', label: 'Оприходование' },
]},
{ title: 'Отчёты', perms: [
{ key: 'reportsView', label: 'Сводные отчёты' },
{ key: 'reportsFinanceView', label: 'Финансы / касса' },
{ key: 'reportsStockView', label: 'Остатки и обороты' },
]},
{ title: 'Настройки организации', perms: [
{ key: 'orgSettingsManage', label: 'Общие настройки' },
@ -80,6 +112,8 @@ const PERM_GROUPS: { title: string; perms: { key: keyof RolePermissions; label:
{ key: 'rolesManage', label: 'Роли' },
{ key: 'storesManage', label: 'Склады' },
{ key: 'retailPointsManage', label: 'Кассы' },
{ key: 'cashRegistersManage', label: 'Кассовые аппараты (ККМ)' },
{ key: 'integrationsManage', label: 'Интеграции (МойСклад и т.п.)' },
]},
]
@ -87,6 +121,9 @@ export function EmployeeRolesPage() {
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<EmployeeRoleDto>(URL)
const { create, update, remove } = useCatalogMutations(URL, URL)
const [form, setForm] = useState<Form | null>(null)
// Шаг выбора шаблона: показывается ПЕРЕД редактированием матрицы при добавлении новой роли.
const [pickTemplate, setPickTemplate] = useState(false)
const [templateId, setTemplateId] = useState<string>('blank')
const save = async () => {
if (!form) return
@ -104,7 +141,7 @@ export function EmployeeRolesPage() {
actions={
<>
<SearchBar value={search} onChange={setSearch} />
<Button onClick={() => setForm(blankForm())}>
<Button onClick={() => { setPickTemplate(true); setTemplateId('blank') }}>
<Plus className="w-4 h-4" /> Добавить роль
</Button>
</>
@ -142,6 +179,53 @@ export function EmployeeRolesPage() {
/>
</ListPageShell>
{/* Шаг выбора шаблона — открывается до основной модалки. */}
<Modal
open={pickTemplate}
onClose={() => setPickTemplate(false)}
title="Новая роль — выберите шаблон"
footer={
<>
<Button variant="secondary" onClick={() => setPickTemplate(false)}>Отмена</Button>
<Button onClick={() => {
let perms = blankPerms()
if (templateId === 'all') perms = allPerms()
else if (templateId !== 'blank') {
const src = data?.items.find((r) => r.id === templateId)
if (src) perms = { ...blankPerms(), ...src.permissions }
}
setPickTemplate(false)
setForm({ ...blankForm(), permissions: perms })
}}>Продолжить</Button>
</>
}
>
<div className="space-y-2">
<p className="text-sm text-slate-500 mb-3">
Стартовый набор прав. После создания роли сможешь дополнительно настроить
каждый пункт через матрицу прав.
</p>
<label className="flex items-start gap-2 p-2 rounded hover:bg-slate-50 dark:hover:bg-slate-800 cursor-pointer">
<input type="radio" name="tpl" checked={templateId === 'blank'} onChange={() => setTemplateId('blank')} className="mt-1" />
<span><span className="font-medium">Пустой</span><br /><span className="text-xs text-slate-500">Все права отключены, нужно ставить галки самому.</span></span>
</label>
<label className="flex items-start gap-2 p-2 rounded hover:bg-slate-50 dark:hover:bg-slate-800 cursor-pointer">
<input type="radio" name="tpl" checked={templateId === 'all'} onChange={() => setTemplateId('all')} className="mt-1" />
<span><span className="font-medium">Копия Администратора</span><br /><span className="text-xs text-slate-500">Все права включены потом убери ненужные.</span></span>
</label>
{data?.items.map((r) => (
<label key={r.id} className="flex items-start gap-2 p-2 rounded hover:bg-slate-50 dark:hover:bg-slate-800 cursor-pointer">
<input type="radio" name="tpl" checked={templateId === r.id} onChange={() => setTemplateId(r.id)} className="mt-1" />
<span>
<span className="font-medium">Копия «{r.name}»</span>
{r.isSystem && <span className="ml-2 text-xs px-1.5 py-0.5 rounded bg-slate-100 dark:bg-slate-800">системная</span>}
{r.description && <><br /><span className="text-xs text-slate-500">{r.description}</span></>}
</span>
</label>
))}
</div>
</Modal>
<Modal
open={!!form}
onClose={() => setForm(null)}
@ -183,6 +267,7 @@ export function EmployeeRolesPage() {
key={p.key}
label={p.label}
checked={form.permissions[p.key]}
disabled={form.isSystem}
onChange={(v) => setForm({ ...form, permissions: { ...form.permissions, [p.key]: v } })}
/>
))}