feat(web): Employees + Roles pages with permissions matrix
Some checks failed
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
CI / Backend (.NET 8) (push) Successful in 42s
CI / Web (React + Vite) (push) Successful in 38s
Docker Web / Build + push Web (push) Has been cancelled

EmployeesPage (/settings/employees):
- Таблица: ФИО + должность, Роль, Email, Телефон, Учётка (есть/нет),
  Статус (Активен/Уволен).
- Модалка добавления: ФИО + Position + Email + Phone + Role.
  Если выбрана роль «Кассир» — появляется блок «Кассы» с чекбоксами
  привязки к RetailPoint'ам (multi-select).
- Чекбокс «Создать учётную запись» (по умолчанию ✓): сервер
  возвращает generatedPassword один раз, показываем в отдельной
  модалке с copy-кнопками логина и временного пароля.
- Update/Delete как обычно. Снять Активен → серверная установка FiredAt.

EmployeeRolesPage (/settings/employee-roles):
- Таблица системных + кастомных ролей с счётчиком активных прав
  (N/21). Системные помечены бэйджем «Системная».
- Модалка edit: имя, описание, матрица прав сгруппированная по 6
  блокам (Каталог/Закупки/Продажи/Контрагенты/Отчёты/Настройки).
  Удаление кнопка только для кастомных.

Меню «Настройки организации» дополнено пунктами «Сотрудники»
(иконка UserCog) и «Роли» (иконка Shield).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-26 12:06:03 +05:00
parent 062eb44fbb
commit 033f20e215
4 changed files with 498 additions and 1 deletions

View file

@ -13,6 +13,8 @@ import { ProductsPage } from '@/pages/ProductsPage'
import { ProductEditPage } from '@/pages/ProductEditPage' import { ProductEditPage } from '@/pages/ProductEditPage'
import { MoySkladImportPage } from '@/pages/MoySkladImportPage' import { MoySkladImportPage } from '@/pages/MoySkladImportPage'
import { OrganizationSettingsPage } from '@/pages/OrganizationSettingsPage' import { OrganizationSettingsPage } from '@/pages/OrganizationSettingsPage'
import { EmployeesPage } from '@/pages/EmployeesPage'
import { EmployeeRolesPage } from '@/pages/EmployeeRolesPage'
import { StockPage } from '@/pages/StockPage' import { StockPage } from '@/pages/StockPage'
import { StockMovementsPage } from '@/pages/StockMovementsPage' import { StockMovementsPage } from '@/pages/StockMovementsPage'
import { SuppliesPage } from '@/pages/SuppliesPage' import { SuppliesPage } from '@/pages/SuppliesPage'
@ -60,6 +62,8 @@ export default function App() {
<Route path="/sales/retail/:id" element={<RetailSaleEditPage />} /> <Route path="/sales/retail/:id" element={<RetailSaleEditPage />} />
<Route path="/admin/import/moysklad" element={<MoySkladImportPage />} /> <Route path="/admin/import/moysklad" element={<MoySkladImportPage />} />
<Route path="/settings/organization" element={<OrganizationSettingsPage />} /> <Route path="/settings/organization" element={<OrganizationSettingsPage />} />
<Route path="/settings/employees" element={<EmployeesPage />} />
<Route path="/settings/employee-roles" element={<EmployeeRolesPage />} />
</Route> </Route>
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />

View file

@ -6,7 +6,7 @@ import { logout } from '@/lib/auth'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { import {
LayoutDashboard, Package, FolderTree, Ruler, Tag, LayoutDashboard, Package, FolderTree, Ruler, Tag,
Users, Warehouse, Store as StoreIcon, Globe, LogOut, Download, Users, Warehouse, Store as StoreIcon, Globe, LogOut, Download, UserCog, Shield,
Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X,
} from 'lucide-react' } from 'lucide-react'
import { Logo } from './Logo' import { Logo } from './Logo'
@ -57,6 +57,8 @@ function buildNav(): NavSection[] {
{ to: '/settings/organization', icon: Settings, label: 'Общие' }, { to: '/settings/organization', icon: Settings, label: 'Общие' },
{ to: '/catalog/stores', icon: Warehouse, label: 'Склады' }, { to: '/catalog/stores', icon: Warehouse, label: 'Склады' },
{ to: '/catalog/retail-points', icon: StoreIcon, label: 'Кассы' }, { to: '/catalog/retail-points', icon: StoreIcon, label: 'Кассы' },
{ to: '/settings/employees', icon: UserCog, label: 'Сотрудники' },
{ to: '/settings/employee-roles', icon: Shield, label: 'Роли' },
]}, ]},
] ]
} }

View file

@ -0,0 +1,198 @@
import { useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput, TextArea, Checkbox } from '@/components/Field'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
const URL = '/api/organization/employee-roles'
export interface RolePermissions {
productsView: boolean; productsEdit: boolean; productsDelete: boolean
productGroupsManage: boolean; priceTypesManage: boolean
suppliesView: boolean; suppliesEdit: boolean; suppliesPost: boolean; suppliesDelete: boolean
retailSalesOperate: boolean; retailSalesRefund: boolean
counterpartiesView: boolean; counterpartiesEdit: boolean
reportsView: boolean; stocksView: boolean
orgSettingsManage: boolean; employeesManage: boolean; rolesManage: boolean
storesManage: boolean; retailPointsManage: boolean
}
export interface EmployeeRoleDto {
id: string; name: string; description: string | null
isSystem: boolean; sortOrder: number; permissions: RolePermissions
}
interface Form {
id?: string
name: string
description: string
isSystem: boolean
permissions: RolePermissions
}
const blankPerms = (): RolePermissions => ({
productsView: false, productsEdit: false, productsDelete: false,
productGroupsManage: false, priceTypesManage: false,
suppliesView: false, suppliesEdit: false, suppliesPost: false, suppliesDelete: false,
retailSalesOperate: false, retailSalesRefund: false,
counterpartiesView: false, counterpartiesEdit: false,
reportsView: false, stocksView: false,
orgSettingsManage: false, employeesManage: false, rolesManage: false,
storesManage: false, retailPointsManage: false,
})
const blankForm = (): Form => ({ name: '', description: '', isSystem: false, permissions: blankPerms() })
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: 'priceTypesManage', label: 'Управление типами цен' },
]},
{ title: 'Закупки', perms: [
{ key: 'suppliesView', label: 'Просмотр приёмок' },
{ key: 'suppliesEdit', label: 'Редактирование приёмок' },
{ key: 'suppliesPost', label: 'Проведение приёмок' },
{ key: 'suppliesDelete', label: 'Удаление приёмок' },
]},
{ title: 'Продажи', perms: [
{ key: 'retailSalesOperate', label: 'Работа на кассе' },
{ key: 'retailSalesRefund', label: 'Возвраты' },
]},
{ title: 'Контрагенты', perms: [
{ key: 'counterpartiesView', label: 'Просмотр' },
{ key: 'counterpartiesEdit', label: 'Редактирование' },
]},
{ title: 'Отчёты и остатки', perms: [
{ key: 'reportsView', label: 'Просмотр отчётов' },
{ key: 'stocksView', label: 'Просмотр остатков' },
]},
{ title: 'Настройки организации', perms: [
{ key: 'orgSettingsManage', label: 'Общие настройки' },
{ key: 'employeesManage', label: 'Сотрудники' },
{ key: 'rolesManage', label: 'Роли' },
{ key: 'storesManage', label: 'Склады' },
{ key: 'retailPointsManage', label: 'Кассы' },
]},
]
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 save = async () => {
if (!form) return
const payload = { name: form.name, description: form.description || null, permissions: form.permissions }
if (form.id) await update.mutateAsync({ id: form.id, input: payload })
else await create.mutateAsync(payload)
setForm(null)
}
return (
<>
<ListPageShell
title="Роли сотрудников"
description="Системные роли можно редактировать (галки прав), но не удалять. Кастомные — полный CRUD."
actions={
<>
<SearchBar value={search} onChange={setSearch} />
<Button onClick={() => setForm(blankForm())}>
<Plus className="w-4 h-4" /> Добавить роль
</Button>
</>
}
footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)}
>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
sortKey={sortKey}
sortOrder={sortOrder}
onSortChange={setSort}
onRowClick={(r) => setForm({
id: r.id, name: r.name, description: r.description ?? '',
isSystem: r.isSystem, permissions: { ...blankPerms(), ...r.permissions },
})}
columns={[
{ header: 'Название', cell: (r) => (
<div>
<div className="font-medium">{r.name}</div>
{r.description && <div className="text-xs text-slate-400">{r.description}</div>}
</div>
)},
{ header: 'Тип', width: '140px', cell: (r) => r.isSystem
? <span className="text-xs px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-800">Системная</span>
: <span className="text-xs text-slate-500">Кастомная</span> },
{ header: 'Прав активно', width: '160px', className: 'text-right', cell: (r) => {
const count = Object.values(r.permissions ?? {}).filter(Boolean).length
return <span className="font-mono">{count} / {Object.keys(r.permissions ?? {}).length}</span>
}},
]}
/>
</ListPageShell>
<Modal
open={!!form}
onClose={() => setForm(null)}
title={form?.id ? 'Редактировать роль' : 'Новая роль'}
width="max-w-2xl"
footer={
<>
{form?.id && !form.isSystem && (
<Button variant="danger" size="sm" onClick={async () => {
if (confirm('Удалить роль?')) {
await remove.mutateAsync(form.id!)
setForm(null)
}
}}>
<Trash2 className="w-4 h-4" /> Удалить
</Button>
)}
<Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button>
<Button onClick={save} disabled={!form?.name}>Сохранить</Button>
</>
}
>
{form && (
<div className="space-y-4">
<Field label="Название *">
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field>
<Field label="Описание">
<TextArea rows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</Field>
<div className="border-t border-slate-200 dark:border-slate-700 pt-3 space-y-4">
<h3 className="text-sm font-semibold">Права</h3>
{PERM_GROUPS.map((g) => (
<div key={g.title}>
<div className="text-xs font-semibold uppercase tracking-wide text-slate-500 mb-2">{g.title}</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
{g.perms.map((p) => (
<Checkbox
key={p.key}
label={p.label}
checked={form.permissions[p.key]}
onChange={(v) => setForm({ ...form, permissions: { ...form.permissions, [p.key]: v } })}
/>
))}
</div>
</div>
))}
</div>
</div>
)}
</Modal>
</>
)
}

View file

@ -0,0 +1,293 @@
import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Plus, Trash2, Copy } from 'lucide-react'
import { api } from '@/lib/api'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput, Select, Checkbox } from '@/components/Field'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import type { PagedResult, RetailPoint } from '@/lib/types'
import type { EmployeeRoleDto } from '@/pages/EmployeeRolesPage'
const URL = '/api/organization/employees'
interface EmployeeDto {
id: string
userId: string | null
lastName: string
firstName: string
middleName: string | null
position: string | null
email: string | null
phone: string | null
roleId: string
roleName: string
isActive: boolean
firedAt: string | null
retailPointIds: string[]
}
interface Form {
id?: string
lastName: string
firstName: string
middleName: string
position: string
email: string
phone: string
roleId: string
isActive: boolean
retailPointIds: string[]
createAccount: boolean
}
const blankForm = (): Form => ({
lastName: '', firstName: '', middleName: '', position: '',
email: '', phone: '',
roleId: '', isActive: true,
retailPointIds: [],
createAccount: true,
})
export function EmployeesPage() {
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<EmployeeDto>(URL)
const { update, remove } = useCatalogMutations(URL, URL)
const [form, setForm] = useState<Form | null>(null)
// Сгенерированный пароль возвращается с сервера один раз — показываем
// в отдельной модалке, чтобы админ передал сотруднику.
const [createdAccount, setCreatedAccount] = useState<{ email: string; password: string } | null>(null)
const roles = useQuery({
queryKey: ['employee-roles-lookup'],
queryFn: async () => (await api.get<PagedResult<EmployeeRoleDto>>('/api/organization/employee-roles?pageSize=200')).data.items,
staleTime: 60_000,
})
const retailPoints = useQuery({
queryKey: ['retail-points-lookup'],
queryFn: async () => (await api.get<PagedResult<RetailPoint>>('/api/catalog/retail-points?pageSize=200')).data.items,
staleTime: 60_000,
})
// Дефолт роли для нового сотрудника — первая по сортировке (Менеджер
// если есть, иначе Кассир — селект всё равно покажет полный список).
useEffect(() => {
if (form && !form.roleId && roles.data?.length) {
const def = roles.data.find((r) => r.name === 'Менеджер') ?? roles.data[0]
setForm({ ...form, roleId: def.id })
}
}, [form, roles.data])
const selectedRole = roles.data?.find((r) => r.id === form?.roleId)
const isCashier = selectedRole?.name === 'Кассир'
const save = async () => {
if (!form) return
const payload = {
lastName: form.lastName, firstName: form.firstName, middleName: form.middleName || null,
position: form.position || null, email: form.email || null, phone: form.phone || null,
roleId: form.roleId, isActive: form.isActive,
retailPointIds: form.retailPointIds,
createAccount: !form.id && form.createAccount,
}
if (form.id) {
await update.mutateAsync({ id: form.id, input: payload })
setForm(null)
} else {
const res = await api.post<{ employee: EmployeeDto; generatedPassword: string | null }>(URL, payload)
setForm(null)
// Если сервер вернул password — показываем модалку one-shot.
if (res.data.generatedPassword && res.data.employee.email) {
setCreatedAccount({ email: res.data.employee.email, password: res.data.generatedPassword })
}
}
}
const toggleRP = (id: string) => {
if (!form) return
const has = form.retailPointIds.includes(id)
setForm({
...form,
retailPointIds: has ? form.retailPointIds.filter((x) => x !== id) : [...form.retailPointIds, id],
})
}
return (
<>
<ListPageShell
title="Сотрудники"
description="Учётные записи и сотрудники без логина. Привязка к ролям, для кассиров — к конкретным кассам."
actions={
<>
<SearchBar value={search} onChange={setSearch} />
<Button onClick={() => setForm(blankForm())}>
<Plus className="w-4 h-4" /> Добавить сотрудника
</Button>
</>
}
footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)}
>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
sortKey={sortKey}
sortOrder={sortOrder}
onSortChange={setSort}
onRowClick={(r) => setForm({
id: r.id, lastName: r.lastName, firstName: r.firstName, middleName: r.middleName ?? '',
position: r.position ?? '', email: r.email ?? '', phone: r.phone ?? '',
roleId: r.roleId, isActive: r.isActive, retailPointIds: r.retailPointIds,
createAccount: false,
})}
columns={[
{ header: 'ФИО', cell: (r) => (
<div>
<div className="font-medium">{r.lastName} {r.firstName} {r.middleName ?? ''}</div>
{r.position && <div className="text-xs text-slate-400">{r.position}</div>}
</div>
)},
{ header: 'Роль', width: '160px', cell: (r) => r.roleName },
{ header: 'Email', width: '220px', cell: (r) => r.email ?? '—' },
{ header: 'Телефон', width: '150px', cell: (r) => r.phone ?? '—' },
{ header: 'Учётка', width: '110px', cell: (r) => r.userId
? <span className="text-xs text-emerald-600">есть</span>
: <span className="text-xs text-slate-400">нет</span> },
{ header: 'Статус', width: '110px', cell: (r) => r.isActive
? <span className="text-xs text-emerald-600">Активен</span>
: <span className="text-xs text-slate-400">Уволен</span> },
]}
/>
</ListPageShell>
<Modal
open={!!form}
onClose={() => setForm(null)}
title={form?.id ? 'Редактировать сотрудника' : 'Новый сотрудник'}
width="max-w-xl"
footer={
<>
{form?.id && (
<Button variant="danger" size="sm" onClick={async () => {
if (confirm('Удалить сотрудника?')) {
await remove.mutateAsync(form.id!)
setForm(null)
}
}}>
<Trash2 className="w-4 h-4" /> Удалить
</Button>
)}
<Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button>
<Button onClick={save} disabled={!form?.lastName || !form.firstName || !form.roleId}>Сохранить</Button>
</>
}
>
{form && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<Field label="Фамилия *">
<TextInput value={form.lastName} onChange={(e) => setForm({ ...form, lastName: e.target.value })} />
</Field>
<Field label="Имя *">
<TextInput value={form.firstName} onChange={(e) => setForm({ ...form, firstName: e.target.value })} />
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Отчество">
<TextInput value={form.middleName} onChange={(e) => setForm({ ...form, middleName: e.target.value })} />
</Field>
<Field label="Должность">
<TextInput value={form.position} onChange={(e) => setForm({ ...form, position: e.target.value })} placeholder="Кассир, кладовщик…" />
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Email">
<TextInput type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />
</Field>
<Field label="Телефон">
<TextInput value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
</Field>
</div>
<Field label="Роль *">
<Select value={form.roleId} onChange={(e) => setForm({ ...form, roleId: e.target.value })}>
<option value=""></option>
{roles.data?.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
</Select>
</Field>
{isCashier && (
<div>
<div className="text-sm font-medium mb-1.5">Кассы</div>
<div className="border border-slate-200 dark:border-slate-700 rounded-md p-2 max-h-40 overflow-auto space-y-1">
{retailPoints.data?.length === 0 && (
<div className="text-xs text-slate-400">Нет касс. Добавь в Настройках.</div>
)}
{retailPoints.data?.map((rp) => (
<Checkbox
key={rp.id}
label={rp.name}
checked={form.retailPointIds.includes(rp.id)}
onChange={() => toggleRP(rp.id)}
/>
))}
</div>
<p className="text-xs text-slate-500 mt-1">Если ничего не выбрано кассир работает на всех кассах.</p>
</div>
)}
<Checkbox
label="Активен"
checked={form.isActive}
onChange={(v) => setForm({ ...form, isActive: v })}
/>
{!form.id && (
<Checkbox
label="Создать учётную запись (выдать логин)"
checked={form.createAccount}
onChange={(v) => setForm({ ...form, createAccount: v })}
/>
)}
{!form.id && form.createAccount && !form.email && (
<p className="text-xs text-amber-600">Для создания учётки укажи email он будет логином.</p>
)}
</div>
)}
</Modal>
<Modal
open={!!createdAccount}
onClose={() => setCreatedAccount(null)}
title="Учётная запись создана"
footer={<Button onClick={() => setCreatedAccount(null)}>Готово</Button>}
>
{createdAccount && (
<div className="space-y-3">
<p className="text-sm text-slate-600 dark:text-slate-300">
Передайте сотруднику данные для входа. Это окно показывается один раз
после закрытия пароль восстановить нельзя, придётся сбросить.
</p>
<Field label="Логин (email)">
<div className="flex gap-2">
<TextInput value={createdAccount.email} readOnly />
<Button variant="secondary" size="sm" onClick={() => navigator.clipboard?.writeText(createdAccount.email)}>
<Copy className="w-4 h-4" />
</Button>
</div>
</Field>
<Field label="Временный пароль">
<div className="flex gap-2">
<TextInput value={createdAccount.password} readOnly className="font-mono" />
<Button variant="secondary" size="sm" onClick={() => navigator.clipboard?.writeText(createdAccount.password)}>
<Copy className="w-4 h-4" />
</Button>
</div>
</Field>
</div>
)}
</Modal>
</>
)
}