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, decimal? Salary, string? TaxNumber, string? Description, string? ImageUrl,
Guid RoleId, string RoleName, Guid RoleId, string RoleName,
bool IsActive, DateTime? FiredAt, bool IsActive, DateTime? FiredAt,
IReadOnlyList<Guid> RetailPointIds); IReadOnlyList<Guid> RetailPointIds,
bool IsOwner, bool IsSelf);
public record EmployeeInput( public record EmployeeInput(
string LastName, string FirstName, string? MiddleName, string LastName, string FirstName, string? MiddleName,
@ -50,6 +51,14 @@ public record EmployeeCreateResult(EmployeeDto Employee, string? GeneratedPasswo
public async Task<ActionResult<PagedResult<EmployeeDto>>> List( public async Task<ActionResult<PagedResult<EmployeeDto>>> List(
[FromQuery] PagedRequest req, CancellationToken ct) [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(); var q = _db.Employees.AsNoTracking().Include(e => e.Role).Include(e => e.RetailPointAssignments).AsQueryable();
if (!string.IsNullOrWhiteSpace(req.Search)) 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.Salary, e.TaxNumber, e.Description, e.ImageUrl,
e.RoleId, e.Role.Name, e.RoleId, e.Role.Name,
e.IsActive, e.FiredAt, 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); .ToListAsync(ct);
return new PagedResult<EmployeeDto> return new PagedResult<EmployeeDto>
{ Items = items, Total = total, Page = req.Page, PageSize = req.Take }; { 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) 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() return await _db.Employees.AsNoTracking()
.Include(e => e.Role) .Include(e => e.Role)
.Include(e => e.RetailPointAssignments) .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.Salary, e.TaxNumber, e.Description, e.ImageUrl,
e.RoleId, e.Role.Name, e.RoleId, e.Role.Name,
e.IsActive, e.FiredAt, 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); .FirstOrDefaultAsync(ct);
} }

View file

@ -33,6 +33,12 @@ interface EmployeeDto {
isActive: boolean isActive: boolean
firedAt: string | null firedAt: string | null
retailPointIds: string[] retailPointIds: string[]
/** Владелец организации (Organization.AccountOwnerUserId == Employee.UserId).
* Удалить такого через DELETE /employees/{id} нельзя; UI должен это
* показывать (бейдж + disabled-кнопка + объяснение). */
isOwner: boolean
/** Текущий вошедший пользователь — это сам этот Employee. Нельзя удалить. */
isSelf: boolean
} }
interface Form { interface Form {
@ -69,6 +75,12 @@ export function EmployeesPage() {
// Сгенерированный пароль возвращается с сервера один раз — показываем // Сгенерированный пароль возвращается с сервера один раз — показываем
// в отдельной модалке, чтобы админ передал сотруднику. // в отдельной модалке, чтобы админ передал сотруднику.
const [createdAccount, setCreatedAccount] = useState<{ email: string; password: string } | null>(null) 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({ const roles = useQuery({
queryKey: ['employee-roles-lookup'], queryKey: ['employee-roles-lookup'],
@ -152,18 +164,28 @@ export function EmployeesPage() {
sortKey={sortKey} sortKey={sortKey}
sortOrder={sortOrder} sortOrder={sortOrder}
onSortChange={setSort} onSortChange={setSort}
onRowClick={(r) => setForm({ onRowClick={(r) => {
id: r.id, lastName: r.lastName, firstName: r.firstName, middleName: r.middleName ?? '', setActiveEmployee(r)
position: r.position ?? '', email: r.email ?? '', phone: r.phone ?? '', setForm({
salary: r.salary != null ? String(r.salary) : '', id: r.id, lastName: r.lastName, firstName: r.firstName, middleName: r.middleName ?? '',
taxNumber: r.taxNumber ?? '', description: r.description ?? '', imageUrl: r.imageUrl ?? '', position: r.position ?? '', email: r.email ?? '', phone: r.phone ?? '',
roleId: r.roleId, isActive: r.isActive, retailPointIds: r.retailPointIds, salary: r.salary != null ? String(r.salary) : '',
createAccount: false, taxNumber: r.taxNumber ?? '', description: r.description ?? '', imageUrl: r.imageUrl ?? '',
})} roleId: r.roleId, isActive: r.isActive, retailPointIds: r.retailPointIds,
createAccount: false,
})
}}
columns={[ columns={[
{ header: 'ФИО', cell: (r) => ( { header: 'ФИО', cell: (r) => (
<div> <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>} {r.position && <div className="text-xs text-slate-400">{r.position}</div>}
</div> </div>
)}, )},
@ -182,18 +204,53 @@ export function EmployeesPage() {
<Modal <Modal
open={!!form} open={!!form}
onClose={() => setForm(null)} onClose={() => { setForm(null); setActiveEmployee(null) }}
title={form?.id ? 'Редактировать сотрудника' : 'Новый сотрудник'} title={form?.id ? 'Редактировать сотрудника' : 'Новый сотрудник'}
width="max-w-xl" width="max-w-xl"
footer={ footer={
<> <>
{form?.id && ( {form?.id && (
<Button variant="danger" size="sm" onClick={async () => { <Button
if (confirm('Удалить сотрудника?')) { variant="danger"
await remove.mutateAsync(form.id!) size="sm"
setForm(null) 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" /> Удалить <Trash2 className="w-4 h-4" /> Удалить
</Button> </Button>
)} )}
@ -333,6 +390,20 @@ export function EmployeesPage() {
</div> </div>
)} )}
</Modal> </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>
</> </>
) )
} }