From 2691b7d78bcf1a4fc29826cdf4968a281d091233 Mon Sep 17 00:00:00 2001 From: nns <278048682+nurdotnet@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:58:08 +0500 Subject: [PATCH] =?UTF-8?q?feat(employees):=20=D0=B1=D0=B5=D0=B9=D0=B4?= =?UTF-8?q?=D0=B6=20=C2=AB=D0=92=D0=BB=D0=B0=D0=B4=D0=B5=D0=BB=D0=B5=D1=86?= =?UTF-8?q?=C2=BB=20+=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20=D1=81=20=D0=BE=D0=B1=D1=8A=D1=8F=D1=81=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Жалоба юзера: «нажимаю удалить владельца магазина — диалог "удалить сотрудника?" — нажимаю — ничего не происходит». Раньше кнопка «Удалить» для 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'а. --- .../Organizations/EmployeesController.cs | 26 ++++- .../src/pages/EmployeesPage.tsx | 101 +++++++++++++++--- 2 files changed, 109 insertions(+), 18 deletions(-) diff --git a/src/food-market.api/Controllers/Organizations/EmployeesController.cs b/src/food-market.api/Controllers/Organizations/EmployeesController.cs index e7e4969..80a7839 100644 --- a/src/food-market.api/Controllers/Organizations/EmployeesController.cs +++ b/src/food-market.api/Controllers/Organizations/EmployeesController.cs @@ -32,7 +32,8 @@ public record EmployeeDto( decimal? Salary, string? TaxNumber, string? Description, string? ImageUrl, Guid RoleId, string RoleName, bool IsActive, DateTime? FiredAt, - IReadOnlyList RetailPointIds); + IReadOnlyList 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>> 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 { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; @@ -215,6 +226,13 @@ public async Task Delete(Guid id, CancellationToken ct) private async Task 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 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); } diff --git a/src/food-market.web/src/pages/EmployeesPage.tsx b/src/food-market.web/src/pages/EmployeesPage.tsx index d6040ee..00e393f 100644 --- a/src/food-market.web/src/pages/EmployeesPage.tsx +++ b/src/food-market.web/src/pages/EmployeesPage.tsx @@ -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(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) => (
-
{r.lastName} {r.firstName} {r.middleName ?? ''}
+
+ {r.lastName} {r.firstName} {r.middleName ?? ''} + {r.isOwner && ( + + Владелец + + )} +
{r.position &&
{r.position}
}
)}, @@ -182,18 +204,53 @@ export function EmployeesPage() { setForm(null)} + onClose={() => { setForm(null); setActiveEmployee(null) }} title={form?.id ? 'Редактировать сотрудника' : 'Новый сотрудник'} width="max-w-xl" footer={ <> {form?.id && ( - )} @@ -333,6 +390,20 @@ export function EmployeesPage() { )} + + setBlockedDelete(null)} + title={blockedDelete?.title ?? ''} + width="max-w-md" + footer={ + + } + > +

+ {blockedDelete?.body} +

+
) }