feat(web): Employees + Roles pages with permissions matrix
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:
parent
c714ec265c
commit
f5cccb6f10
|
|
@ -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 />} />
|
||||||
|
|
|
||||||
|
|
@ -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: 'Роли' },
|
||||||
]},
|
]},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
198
src/food-market.web/src/pages/EmployeeRolesPage.tsx
Normal file
198
src/food-market.web/src/pages/EmployeeRolesPage.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
293
src/food-market.web/src/pages/EmployeesPage.tsx
Normal file
293
src/food-market.web/src/pages/EmployeesPage.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue