feat(employees): бейдж «Владелец» + блокировка удаления с объяснением
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 45s
CI / Web (React + Vite) (push) Successful in 39s
Docker API / Build + push API (push) Successful in 1m8s
Docker Web / Build + push Web (push) Successful in 32s
Docker API / Deploy API on stage (push) Successful in 18s
Docker Web / Deploy Web on stage (push) Successful in 12s

Жалоба юзера: «нажимаю удалить владельца магазина — диалог "удалить
сотрудника?" — нажимаю — ничего не происходит». Раньше кнопка «Удалить»
для Owner была доступна, на сервере отвечала 403 с понятным сообщением,
но фронт ошибку не ловил — модалка зависала.

— EmployeeDto теперь возвращает isOwner (Org.AccountOwnerUserId ==
  Employee.UserId) и isSelf (UserId текущего залогиненного юзера).
  List + Get обновлены: подгружают AccountOwnerUserId и текущий sub
  из JWT, проставляют флаги в проекции.

— Таблица сотрудников: рядом с ФИО владельца — бейдж «Владелец»
  (amber-100/800).

— Кнопка «Удалить» в модалке редактирования:
  · disabled для Owner и для self с tooltip-объяснением;
  · клик по disabled-кнопке через onClick-handler показывает спец-
    модалку: «Нельзя удалить владельца магазина — сначала передайте
    управление другому пользователю. Организация не может остаться
    без владельца. Удалить или деактивировать саму организацию может
    только Супер-администратор платформы.»;
  · self-delete объясняется отдельным текстом (Настройки → Аккаунт →
    Покинуть организацию);
  · обычное удаление — confirm с именем сотрудника и пояснением что
    это soft-delete (деактивация).
  · 403/любая ошибка от сервера ловится в try/catch и показывается
    в той же модалке «Не удалось удалить» — больше не «ничего не
    происходит».

Smoke: API эмплоя возвращает isOwner=true,isSelf=false для Owner'а в
override-режиме SuperAdmin'а.
This commit is contained in:
nns 2026-04-27 18:58:08 +05:00
parent 691448201d
commit 2691b7d78b
2 changed files with 109 additions and 18 deletions

View file

@ -32,7 +32,8 @@ public record EmployeeDto(
decimal? Salary, string? TaxNumber, string? Description, string? ImageUrl,
Guid RoleId, string RoleName,
bool IsActive, DateTime? FiredAt,
IReadOnlyList<Guid> RetailPointIds);
IReadOnlyList<Guid> RetailPointIds,
bool IsOwner, bool IsSelf);
public record EmployeeInput(
string LastName, string FirstName, string? MiddleName,
@ -50,6 +51,14 @@ public record EmployeeCreateResult(EmployeeDto Employee, string? GeneratedPasswo
public async Task<ActionResult<PagedResult<EmployeeDto>>> List(
[FromQuery] PagedRequest req, CancellationToken ct)
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var ownerUserId = await _db.Organizations.IgnoreQueryFilters()
.Where(o => o.Id == orgId)
.Select(o => o.AccountOwnerUserId)
.FirstOrDefaultAsync(ct);
var currentUserId = ParseUserId(User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? User.FindFirst("sub")?.Value);
var q = _db.Employees.AsNoTracking().Include(e => e.Role).Include(e => e.RetailPointAssignments).AsQueryable();
if (!string.IsNullOrWhiteSpace(req.Search))
{
@ -70,7 +79,9 @@ public record EmployeeCreateResult(EmployeeDto Employee, string? GeneratedPasswo
e.Salary, e.TaxNumber, e.Description, e.ImageUrl,
e.RoleId, e.Role.Name,
e.IsActive, e.FiredAt,
e.RetailPointAssignments.Select(a => a.RetailPointId).ToList()))
e.RetailPointAssignments.Select(a => a.RetailPointId).ToList(),
e.UserId != null && ownerUserId == e.UserId,
e.UserId != null && currentUserId != null && e.UserId == currentUserId))
.ToListAsync(ct);
return new PagedResult<EmployeeDto>
{ Items = items, Total = total, Page = req.Page, PageSize = req.Take };
@ -215,6 +226,13 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
private async Task<EmployeeDto?> ProjectAsync(Guid id, CancellationToken ct)
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var ownerUserId = await _db.Organizations.IgnoreQueryFilters()
.Where(o => o.Id == orgId)
.Select(o => o.AccountOwnerUserId)
.FirstOrDefaultAsync(ct);
var currentUserId = ParseUserId(User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? User.FindFirst("sub")?.Value);
return await _db.Employees.AsNoTracking()
.Include(e => e.Role)
.Include(e => e.RetailPointAssignments)
@ -225,7 +243,9 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
e.Salary, e.TaxNumber, e.Description, e.ImageUrl,
e.RoleId, e.Role.Name,
e.IsActive, e.FiredAt,
e.RetailPointAssignments.Select(a => a.RetailPointId).ToList()))
e.RetailPointAssignments.Select(a => a.RetailPointId).ToList(),
e.UserId != null && ownerUserId == e.UserId,
e.UserId != null && currentUserId != null && e.UserId == currentUserId))
.FirstOrDefaultAsync(ct);
}

View file

@ -33,6 +33,12 @@ interface EmployeeDto {
isActive: boolean
firedAt: string | null
retailPointIds: string[]
/** Владелец организации (Organization.AccountOwnerUserId == Employee.UserId).
* Удалить такого через DELETE /employees/{id} нельзя; UI должен это
* показывать (бейдж + disabled-кнопка + объяснение). */
isOwner: boolean
/** Текущий вошедший пользователь — это сам этот Employee. Нельзя удалить. */
isSelf: boolean
}
interface Form {
@ -69,6 +75,12 @@ export function EmployeesPage() {
// Сгенерированный пароль возвращается с сервера один раз — показываем
// в отдельной модалке, чтобы админ передал сотруднику.
const [createdAccount, setCreatedAccount] = useState<{ email: string; password: string } | null>(null)
// Текущий открытый сотрудник — нужен чтобы знать isOwner/isSelf для футера
// модалки (form содержит только редактируемые поля и не знает про маркеры).
const [activeEmployee, setActiveEmployee] = useState<EmployeeDto | null>(null)
// Объяснение почему нельзя удалить — показывается вместо confirm() когда
// пытаешься удалить владельца или себя. Текст с инструкцией.
const [blockedDelete, setBlockedDelete] = useState<{ title: string; body: string } | null>(null)
const roles = useQuery({
queryKey: ['employee-roles-lookup'],
@ -152,18 +164,28 @@ export function EmployeesPage() {
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 ?? '',
salary: r.salary != null ? String(r.salary) : '',
taxNumber: r.taxNumber ?? '', description: r.description ?? '', imageUrl: r.imageUrl ?? '',
roleId: r.roleId, isActive: r.isActive, retailPointIds: r.retailPointIds,
createAccount: false,
})}
onRowClick={(r) => {
setActiveEmployee(r)
setForm({
id: r.id, lastName: r.lastName, firstName: r.firstName, middleName: r.middleName ?? '',
position: r.position ?? '', email: r.email ?? '', phone: r.phone ?? '',
salary: r.salary != null ? String(r.salary) : '',
taxNumber: r.taxNumber ?? '', description: r.description ?? '', imageUrl: r.imageUrl ?? '',
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>
<div className="font-medium flex items-center gap-2">
<span>{r.lastName} {r.firstName} {r.middleName ?? ''}</span>
{r.isOwner && (
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">
Владелец
</span>
)}
</div>
{r.position && <div className="text-xs text-slate-400">{r.position}</div>}
</div>
)},
@ -182,18 +204,53 @@ export function EmployeesPage() {
<Modal
open={!!form}
onClose={() => setForm(null)}
onClose={() => { setForm(null); setActiveEmployee(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)
<Button
variant="danger"
size="sm"
disabled={activeEmployee?.isOwner || activeEmployee?.isSelf}
title={
activeEmployee?.isOwner
? 'Владельца удалить нельзя — нужно передать управление другому пользователю'
: activeEmployee?.isSelf
? 'Нельзя удалить себя'
: undefined
}
}}>
onClick={async () => {
if (activeEmployee?.isOwner) {
setBlockedDelete({
title: 'Нельзя удалить владельца магазина',
body:
'Чтобы удалить администратора магазина, сначала передайте управление другому пользователю. ' +
'Организация не может остаться без владельца.\n\n' +
'Удалить или деактивировать саму организацию может только Супер-администратор платформы.',
})
return
}
if (activeEmployee?.isSelf) {
setBlockedDelete({
title: 'Нельзя удалить себя',
body:
'Свою учётную запись нельзя удалить из этой страницы. ' +
'Покинуть организацию можно через Настройки → Аккаунт → Покинуть организацию.',
})
return
}
if (!confirm(`Удалить сотрудника «${activeEmployee?.lastName ?? ''} ${activeEmployee?.firstName ?? ''}»?\n\nСотрудник будет деактивирован, его учётная запись потеряет доступ к организации. Историю операций сохраняем.`)) return
try {
await remove.mutateAsync(form.id!)
setForm(null); setActiveEmployee(null)
} catch (e) {
const err = e as { response?: { data?: { error?: string } }, message?: string }
const msg = err.response?.data?.error ?? err.message ?? 'Не удалось удалить сотрудника'
setBlockedDelete({ title: 'Не удалось удалить', body: msg })
}
}}>
<Trash2 className="w-4 h-4" /> Удалить
</Button>
)}
@ -333,6 +390,20 @@ export function EmployeesPage() {
</div>
)}
</Modal>
<Modal
open={!!blockedDelete}
onClose={() => setBlockedDelete(null)}
title={blockedDelete?.title ?? ''}
width="max-w-md"
footer={
<Button onClick={() => setBlockedDelete(null)}>Понятно</Button>
}
>
<p className="text-sm text-slate-700 dark:text-slate-200 whitespace-pre-line">
{blockedDelete?.body}
</p>
</Modal>
</>
)
}