From 3849cb354768bfce8f0e1df4a3de26d81eb5e96a Mon Sep 17 00:00:00 2001 From: nns <278048682+nurdotnet@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:21:08 +0500 Subject: [PATCH] =?UTF-8?q?feat(super-admin):=20=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D0=BE=D0=B5=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D1=81=D0=BE=D1=82=D1=80=D1=83=D0=B4=D0=BD=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=D0=BC=D0=B8=20=D0=BB=D1=8E=D0=B1=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=BE=D1=80=D0=B3=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API: SuperAdminEmployeesController на /api/super-admin/organizations/{orgId}/employees - GET (с пагинацией, поиском, includeInactive=true по умолчанию) - GET /{id} — детали + флаги isOwner/hasAccount/accountActive - POST — создать сотрудника, опционально с учёткой (генерация temp password) - PUT /{id} — изменить ФИО/контакты/роль/активность БЕЗ tenant-гардов (можно править главного администратора) - DELETE /{id}?reason=… — soft-delete; если удаляем главного администратора — снимаем org.AccountOwnerUserId - POST /{id}/toggle-active — активировать/деактивировать запись Employee - POST /{id}/account/toggle-active — заблокировать/разблокировать AppUser (revoke valid OpenIddictTokens при блокировке) - POST /{id}/reset-password — сгенерировать новый temp password, revoke все active токены, вернуть one-shot Все мутации требуют reason ≥ 10 символов и пишутся в SuperAdminAuditLog (actionType: SA_CreateEmployee / SA_EditEmployee / SA_ActivateEmployee / SA_DeactivateEmployee / SA_ActivateAccount / SA_DeactivateAccount / SA_ResetPassword / SA_DeleteEmployee). Эндпойнты [Authorize(Roles=SuperAdmin)], обходят tenant-фильтр через IgnoreQueryFilters(). Web: новая страница SuperAdminOrgEmployeesPage по `/super-admin/organizations/:id/employees`. Таблица сотрудников орги (включая неактивных), бейдж «Главный администратор», статус учётки (активна/заблокирована/нет). Иконки: редактировать, сбросить пароль, блокировка учётки, активность сотрудника. Каждое действие открывает модалку с обязательным полем «Причина» (≥10 символов) — она уходит в audit-log. Сгенерированный пароль показывается one-shot с copy-кнопкой. Кнопка «Сотрудники» (Users-icon) добавлена в actions колонку SuperAdminOrganizationsPage — переход на страницу прямо из списка орг. --- .../SuperAdminEmployeesController.cs | 411 ++++++++++++++++++ src/food-market.web/src/App.tsx | 2 + .../src/pages/SuperAdminOrgEmployeesPage.tsx | 407 +++++++++++++++++ .../src/pages/SuperAdminOrganizationsPage.tsx | 7 +- 4 files changed, 826 insertions(+), 1 deletion(-) create mode 100644 src/food-market.api/Controllers/SuperAdmin/SuperAdminEmployeesController.cs create mode 100644 src/food-market.web/src/pages/SuperAdminOrgEmployeesPage.tsx diff --git a/src/food-market.api/Controllers/SuperAdmin/SuperAdminEmployeesController.cs b/src/food-market.api/Controllers/SuperAdmin/SuperAdminEmployeesController.cs new file mode 100644 index 0000000..3d5967d --- /dev/null +++ b/src/food-market.api/Controllers/SuperAdmin/SuperAdminEmployeesController.cs @@ -0,0 +1,411 @@ +using System.Security.Claims; +using foodmarket.Application.Common; +using foodmarket.Domain.Organizations; +using foodmarket.Infrastructure.Identity; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.SuperAdmin; + +/// SuperAdmin: управление сотрудниками любой организации в обход +/// tenant-фильтра и обычных гардов (роль главного администратора, self-delete +/// и т.д.). Все мутации обязательно сопровождаются reason (минимум 10 +/// символов) и пишутся в super_admin_audit_log. +/// +/// Tenant-эндпойнты `/api/organization/employees/*` оставляем для обычных +/// админов организации с их гардами; этот контроллер — только для +/// Супер-администратора платформы. +[ApiController] +[Authorize(Roles = "SuperAdmin")] +[Route("api/super-admin/organizations/{orgId:guid}/employees")] +public class SuperAdminEmployeesController : ControllerBase +{ + private readonly AppDbContext _db; + private readonly UserManager _userMgr; + + public SuperAdminEmployeesController(AppDbContext db, UserManager userMgr) + { + _db = db; _userMgr = userMgr; + } + + public record EmployeeRow( + Guid Id, Guid? UserId, string LastName, string FirstName, string? MiddleName, + string? Position, string? Email, string? Phone, + Guid RoleId, string RoleName, bool IsRoleSystem, + bool IsActive, DateTime? FiredAt, + bool HasAccount, bool AccountActive, DateTime? LastLoginAt, + bool IsOwner); + + public record EmployeeDetail( + Guid Id, Guid OrganizationId, Guid? UserId, + string LastName, string FirstName, string? MiddleName, + string? Position, string? Email, string? Phone, + decimal? Salary, string? TaxNumber, string? Description, string? ImageUrl, + Guid RoleId, string RoleName, + bool IsActive, DateTime? FiredAt, + IReadOnlyList RetailPointIds, + bool IsOwner, bool HasAccount, bool AccountActive, string? AccountEmail); + + public record EmployeeInput( + string LastName, string FirstName, string? MiddleName, + string? Position, string? Email, string? Phone, + decimal? Salary, string? TaxNumber, string? Description, string? ImageUrl, + Guid RoleId, bool IsActive, + IReadOnlyList? RetailPointIds); + + public record CreateInput( + string Reason, + string LastName, string FirstName, string? MiddleName, + string? Position, string? Email, string? Phone, + Guid RoleId, bool IsActive, + IReadOnlyList? RetailPointIds, + bool CreateAccount, string? AccountEmail); + + public record UpdateInput(string Reason, EmployeeInput Employee); + + public record ResetPasswordInput(string Reason); + public record ResetPasswordResult(string Email, string TempPassword); + + public record ToggleActiveInput(string Reason, bool IsActive); + + public record ToggleAccountActiveInput(string Reason, bool IsActive); + + [HttpGet] + public async Task>> List( + Guid orgId, [FromQuery] PagedRequest req, + [FromQuery] bool includeInactive = true, + CancellationToken ct = default) + { + var orgExists = await _db.Organizations.IgnoreQueryFilters().AnyAsync(o => o.Id == orgId, ct); + if (!orgExists) return NotFound(); + + var ownerUserId = await _db.Organizations.IgnoreQueryFilters() + .Where(o => o.Id == orgId).Select(o => o.AccountOwnerUserId).FirstOrDefaultAsync(ct); + + var q = _db.Employees.IgnoreQueryFilters().AsNoTracking() + .Include(e => e.Role) + .Where(e => e.OrganizationId == orgId); + if (!includeInactive) q = q.Where(e => e.IsActive); + if (!string.IsNullOrWhiteSpace(req.Search)) + { + var s = req.Search.Trim().ToLower(); + q = q.Where(e => + e.LastName.ToLower().Contains(s) || e.FirstName.ToLower().Contains(s) || + (e.Email != null && e.Email.ToLower().Contains(s))); + } + var total = await q.CountAsync(ct); + + // Загружаем + проецируем после материализации, чтобы JOIN на users + // не упёрся в IgnoreQueryFilters (Identity-таблица не tenant-scoped, + // но избегаем сложного group-join'а в EF выражениях). + var rows = await q.OrderByDescending(e => e.IsActive).ThenBy(e => e.LastName) + .Skip(req.Skip).Take(req.Take).ToListAsync(ct); + var userIds = rows.Where(r => r.UserId != null).Select(r => r.UserId!.Value).ToList(); + var userMap = userIds.Count == 0 + ? new Dictionary() + : await _db.Users.IgnoreQueryFilters().Where(u => userIds.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id, ct); + + var items = rows.Select(e => + { + User? u = e.UserId != null && userMap.TryGetValue(e.UserId.Value, out var uu) ? uu : null; + return new EmployeeRow( + e.Id, e.UserId, e.LastName, e.FirstName, e.MiddleName, + e.Position, e.Email, e.Phone, + e.RoleId, e.Role.Name, e.Role.IsSystem, + e.IsActive, e.FiredAt, + u is not null, u?.IsActive ?? false, u?.LastLoginAt, + e.UserId != null && ownerUserId == e.UserId); + }).ToList(); + return new PagedResult + { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; + } + + [HttpGet("{id:guid}")] + public async Task> Get(Guid orgId, Guid id, CancellationToken ct) + { + var dto = await ProjectAsync(orgId, id, ct); + return dto is null ? NotFound() : Ok(dto); + } + + [HttpPost] + public async Task> Create(Guid orgId, [FromBody] CreateInput input, CancellationToken ct) + { + if (!ValidateReason(input.Reason, out var reasonError)) return BadRequest(new { error = reasonError }); + var org = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(o => o.Id == orgId, ct); + if (org is null) return NotFound(); + var role = await _db.EmployeeRoles.IgnoreQueryFilters() + .FirstOrDefaultAsync(r => r.Id == input.RoleId && r.OrganizationId == orgId, ct); + if (role is null) return BadRequest(new { error = "Роль не найдена в этой организации." }); + + Guid? newUserId = null; + string? tempPassword = null; + if (input.CreateAccount) + { + var accountEmail = (input.AccountEmail ?? input.Email ?? "").Trim(); + if (string.IsNullOrWhiteSpace(accountEmail)) + return BadRequest(new { error = "Для учётной записи нужен email." }); + var existing = await _userMgr.FindByEmailAsync(accountEmail); + if (existing is not null) + return BadRequest(new { error = $"Пользователь с email «{accountEmail}» уже существует." }); + tempPassword = SuperAdminOrganizationsController_Utils.GenerateTempPassword(); + var u = new User + { + UserName = accountEmail, Email = accountEmail, EmailConfirmed = true, + FullName = $"{input.LastName} {input.FirstName}".Trim(), + OrganizationId = orgId, IsActive = true, + }; + var ur = await _userMgr.CreateAsync(u, tempPassword); + if (!ur.Succeeded) + return BadRequest(new { error = string.Join("; ", ur.Errors.Select(x => x.Description)) }); + await _userMgr.AddToRoleAsync(u, "Admin"); + newUserId = u.Id; + } + + var emp = new Employee + { + OrganizationId = orgId, UserId = newUserId, + LastName = input.LastName, FirstName = input.FirstName, MiddleName = input.MiddleName, + Position = input.Position, Email = input.Email, Phone = input.Phone, + RoleId = input.RoleId, IsActive = input.IsActive, + }; + foreach (var rp in input.RetailPointIds ?? []) + emp.RetailPointAssignments.Add(new EmployeeRetailPointAssignment { OrganizationId = orgId, RetailPointId = rp }); + _db.Employees.Add(emp); + await _db.SaveChangesAsync(ct); + + await LogAsync("SA_CreateEmployee", orgId, + $"Создан сотрудник «{emp.LastName} {emp.FirstName}» в «{org.Name}»", + input.Reason, + $"{{\"employeeId\":\"{emp.Id}\",\"role\":\"{role.Name}\",\"accountCreated\":{(newUserId != null ? "true" : "false")}}}", + ct); + + var dto = await ProjectAsync(orgId, emp.Id, ct); + if (tempPassword is not null) + return Ok(new { employee = dto, generatedPassword = tempPassword }); + return Ok(new { employee = dto }); + } + + [HttpPut("{id:guid}")] + public async Task Update(Guid orgId, Guid id, [FromBody] UpdateInput input, CancellationToken ct) + { + if (!ValidateReason(input.Reason, out var reasonError)) return BadRequest(new { error = reasonError }); + var e = await _db.Employees.IgnoreQueryFilters().Include(x => x.RetailPointAssignments) + .FirstOrDefaultAsync(x => x.Id == id && x.OrganizationId == orgId, ct); + if (e is null) return NotFound(); + + var prevRoleId = e.RoleId; var prevActive = e.IsActive; + e.LastName = input.Employee.LastName; + e.FirstName = input.Employee.FirstName; + e.MiddleName = input.Employee.MiddleName; + e.Position = input.Employee.Position; + e.Email = input.Employee.Email; + e.Phone = input.Employee.Phone; + e.Salary = input.Employee.Salary; + e.TaxNumber = input.Employee.TaxNumber; + e.Description = input.Employee.Description; + e.ImageUrl = input.Employee.ImageUrl; + e.RoleId = input.Employee.RoleId; + var nowActive = input.Employee.IsActive; + if (e.IsActive && !nowActive) e.FiredAt = DateTime.UtcNow; + if (!e.IsActive && nowActive) e.FiredAt = null; + e.IsActive = nowActive; + + _db.EmployeeRetailPointAssignments.RemoveRange(e.RetailPointAssignments); + e.RetailPointAssignments.Clear(); + foreach (var rp in input.Employee.RetailPointIds ?? []) + e.RetailPointAssignments.Add(new EmployeeRetailPointAssignment { OrganizationId = orgId, RetailPointId = rp }); + + await _db.SaveChangesAsync(ct); + + await LogAsync("SA_EditEmployee", orgId, + $"Изменён сотрудник «{e.LastName} {e.FirstName}»", input.Reason, + $"{{\"employeeId\":\"{e.Id}\",\"roleChanged\":{(prevRoleId != e.RoleId ? "true" : "false")},\"activeChanged\":{(prevActive != e.IsActive ? "true" : "false")}}}", + ct); + return NoContent(); + } + + [HttpPost("{id:guid}/toggle-active")] + public async Task ToggleActive(Guid orgId, Guid id, [FromBody] ToggleActiveInput input, CancellationToken ct) + { + if (!ValidateReason(input.Reason, out var reasonError)) return BadRequest(new { error = reasonError }); + var e = await _db.Employees.IgnoreQueryFilters() + .FirstOrDefaultAsync(x => x.Id == id && x.OrganizationId == orgId, ct); + if (e is null) return NotFound(); + if (e.IsActive == input.IsActive) return NoContent(); + + e.IsActive = input.IsActive; + e.FiredAt = input.IsActive ? null : DateTime.UtcNow; + await _db.SaveChangesAsync(ct); + + await LogAsync(input.IsActive ? "SA_ActivateEmployee" : "SA_DeactivateEmployee", orgId, + $"{(input.IsActive ? "Активирован" : "Деактивирован")} сотрудник «{e.LastName} {e.FirstName}»", + input.Reason, + $"{{\"employeeId\":\"{e.Id}\"}}", ct); + return NoContent(); + } + + [HttpPost("{id:guid}/account/toggle-active")] + public async Task ToggleAccountActive(Guid orgId, Guid id, [FromBody] ToggleAccountActiveInput input, CancellationToken ct) + { + if (!ValidateReason(input.Reason, out var reasonError)) return BadRequest(new { error = reasonError }); + var e = await _db.Employees.IgnoreQueryFilters() + .FirstOrDefaultAsync(x => x.Id == id && x.OrganizationId == orgId, ct); + if (e is null || e.UserId is null) return NotFound(); + var u = await _db.Users.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == e.UserId, ct); + if (u is null) return NotFound(); + if (u.IsActive == input.IsActive) return NoContent(); + u.IsActive = input.IsActive; + await _db.SaveChangesAsync(ct); + + if (!input.IsActive) + { + // Revoke active OpenIddict tokens чтобы существующие сессии оборвались. + await _db.Database.ExecuteSqlRawAsync( + "UPDATE \"OpenIddictTokens\" SET \"Status\" = 'revoked' WHERE \"Status\" = 'valid' AND \"Subject\" = {0}", + new object[] { u.Id.ToString() }); + } + + await LogAsync(input.IsActive ? "SA_ActivateAccount" : "SA_DeactivateAccount", orgId, + $"{(input.IsActive ? "Активирована" : "Деактивирована")} учётная запись «{u.Email}»", + input.Reason, + $"{{\"employeeId\":\"{e.Id}\",\"userId\":\"{u.Id}\"}}", ct); + return NoContent(); + } + + [HttpPost("{id:guid}/reset-password")] + public async Task> ResetPassword(Guid orgId, Guid id, [FromBody] ResetPasswordInput input, CancellationToken ct) + { + if (!ValidateReason(input.Reason, out var reasonError)) return BadRequest(new { error = reasonError }); + var e = await _db.Employees.IgnoreQueryFilters() + .FirstOrDefaultAsync(x => x.Id == id && x.OrganizationId == orgId, ct); + if (e is null || e.UserId is null) + return BadRequest(new { error = "У сотрудника нет учётной записи." }); + var u = await _userMgr.FindByIdAsync(e.UserId.Value.ToString()); + if (u is null) return NotFound(); + + var temp = SuperAdminOrganizationsController_Utils.GenerateTempPassword(); + var rm = await _userMgr.RemovePasswordAsync(u); + if (!rm.Succeeded) + return BadRequest(new { error = string.Join("; ", rm.Errors.Select(x => x.Description)) }); + var add = await _userMgr.AddPasswordAsync(u, temp); + if (!add.Succeeded) + return BadRequest(new { error = string.Join("; ", add.Errors.Select(x => x.Description)) }); + + // Revoke все активные tokens — пусть юзер войдёт заново с новым паролем. + await _db.Database.ExecuteSqlRawAsync( + "UPDATE \"OpenIddictTokens\" SET \"Status\" = 'revoked' WHERE \"Status\" = 'valid' AND \"Subject\" = {0}", + new object[] { u.Id.ToString() }); + + await LogAsync("SA_ResetPassword", orgId, + $"Сброшен пароль учётной записи «{u.Email}»", input.Reason, + $"{{\"employeeId\":\"{e.Id}\",\"userId\":\"{u.Id}\"}}", ct); + return new ResetPasswordResult(u.Email!, temp); + } + + [HttpDelete("{id:guid}")] + public async Task Delete(Guid orgId, Guid id, [FromQuery] string? reason, CancellationToken ct) + { + if (!ValidateReason(reason, out var reasonError)) return BadRequest(new { error = reasonError }); + var e = await _db.Employees.IgnoreQueryFilters() + .FirstOrDefaultAsync(x => x.Id == id && x.OrganizationId == orgId, ct); + if (e is null) return NotFound(); + + var ownerUserId = await _db.Organizations.IgnoreQueryFilters() + .Where(o => o.Id == orgId).Select(o => o.AccountOwnerUserId).FirstOrDefaultAsync(ct); + if (e.UserId is not null && ownerUserId == e.UserId) + { + // Если удаляем главного администратора — снимаем owner-маркер с орги, + // иначе он будет указывать в никуда. SuperAdmin обязан до или после + // назначить нового через PATCH /change-owner. + var o = await _db.Organizations.IgnoreQueryFilters().FirstAsync(x => x.Id == orgId, ct); + o.AccountOwnerUserId = null; + } + + e.IsActive = false; + e.FiredAt ??= DateTime.UtcNow; + await _db.SaveChangesAsync(ct); + + await LogAsync("SA_DeleteEmployee", orgId, + $"Деактивирован сотрудник «{e.LastName} {e.FirstName}»", + reason, + $"{{\"employeeId\":\"{e.Id}\"}}", ct); + return NoContent(); + } + + private async Task ProjectAsync(Guid orgId, Guid id, CancellationToken ct) + { + var e = await _db.Employees.IgnoreQueryFilters().AsNoTracking() + .Include(x => x.Role).Include(x => x.RetailPointAssignments) + .FirstOrDefaultAsync(x => x.Id == id && x.OrganizationId == orgId, ct); + if (e is null) return null; + var ownerUserId = await _db.Organizations.IgnoreQueryFilters() + .Where(o => o.Id == orgId).Select(o => o.AccountOwnerUserId).FirstOrDefaultAsync(ct); + User? u = null; + if (e.UserId is not null) + u = await _db.Users.IgnoreQueryFilters().AsNoTracking().FirstOrDefaultAsync(x => x.Id == e.UserId, ct); + return new EmployeeDetail( + e.Id, e.OrganizationId, e.UserId, + e.LastName, e.FirstName, e.MiddleName, + e.Position, e.Email, e.Phone, + 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.UserId != null && ownerUserId == e.UserId, + u is not null, u?.IsActive ?? false, u?.Email); + } + + private static bool ValidateReason(string? reason, out string error) + { + if (string.IsNullOrWhiteSpace(reason) || reason.Trim().Length < 10) + { + error = "Укажите причину (минимум 10 символов) — она пишется в журнал супер-админа."; + return false; + } + error = ""; + return true; + } + + private async Task LogAsync(string actionType, Guid orgId, string description, string? reason, string changesJson, CancellationToken ct) + { + var userIdRaw = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"); + Guid.TryParse(userIdRaw, out var uid); + _db.SuperAdminAuditLogs.Add(new SuperAdminAuditLog + { + SuperAdminUserId = uid, + ActionType = actionType, OrganizationId = orgId, + Description = description, Reason = reason, + ChangesJson = changesJson, + IpAddress = HttpContext?.Connection?.RemoteIpAddress?.ToString() ?? "", + }); + await _db.SaveChangesAsync(ct); + } +} + +internal static class SuperAdminOrganizationsController_Utils +{ + // Дублируем локально, чтобы не выносить статику из соседнего контроллера. + public static string GenerateTempPassword() + { + const string lower = "abcdefghkmnpqrstuvwxyz"; + const string upper = "ABCDEFGHKMNPQRSTUVWXYZ"; + const string digits = "23456789"; + const string special = "!@#$%&*"; + var rnd = new Random(); + var chars = new List + { + upper[rnd.Next(upper.Length)], + lower[rnd.Next(lower.Length)], + digits[rnd.Next(digits.Length)], + special[rnd.Next(special.Length)], + }; + var pool = lower + upper + digits; + for (var i = 0; i < 8; i++) chars.Add(pool[rnd.Next(pool.Length)]); + return new string(chars.OrderBy(_ => rnd.Next()).ToArray()); + } +} diff --git a/src/food-market.web/src/App.tsx b/src/food-market.web/src/App.tsx index 2562825..607e5d1 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -34,6 +34,7 @@ import { SuperAdminLayout } from '@/components/SuperAdminLayout' import { TenantRouteGuard } from '@/components/TenantRouteGuard' import { ProtectedRoute } from '@/components/ProtectedRoute' import { NoOrganizationPage } from '@/pages/NoOrganizationPage' +import { SuperAdminOrgEmployeesPage } from '@/pages/SuperAdminOrgEmployeesPage' const queryClient = new QueryClient({ defaultOptions: { @@ -64,6 +65,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/food-market.web/src/pages/SuperAdminOrgEmployeesPage.tsx b/src/food-market.web/src/pages/SuperAdminOrgEmployeesPage.tsx new file mode 100644 index 0000000..04925a0 --- /dev/null +++ b/src/food-market.web/src/pages/SuperAdminOrgEmployeesPage.tsx @@ -0,0 +1,407 @@ +import { useState } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' +import { Plus, KeyRound, Power, PowerOff, Pencil, 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, TextArea, Checkbox } from '@/components/Field' +import { useCatalogList } from '@/lib/useCatalog' +import type { PagedResult } from '@/lib/types' +import type { EmployeeRoleDto } from '@/pages/EmployeeRolesPage' + +interface OrgInfo { id: string; name: string } + +interface EmployeeRow { + 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; isRoleSystem: boolean + isActive: boolean; firedAt: string | null + hasAccount: boolean; accountActive: boolean; lastLoginAt: string | null + isOwner: boolean +} + +interface EmpForm { + id?: string + reason: string + lastName: string; firstName: string; middleName: string + position: string; email: string; phone: string + roleId: string; isActive: boolean + retailPointIds: string[] + createAccount: boolean; accountEmail: string +} + +const blankForm = (): EmpForm => ({ + reason: '', lastName: '', firstName: '', middleName: '', + position: '', email: '', phone: '', + roleId: '', isActive: true, + retailPointIds: [], + createAccount: false, accountEmail: '', +}) + +/** SuperAdmin-страница: управление сотрудниками выбранной организации. + * Использует /api/super-admin/organizations/{orgId}/employees — обходит + * tenant-фильтр и tenant-гарды (можно править главного администратора, + * деактивировать аккаунт, сбрасывать пароль). Каждое мутирующее действие + * требует reason ≥10 символов и пишется в SuperAdminAuditLog. */ +export function SuperAdminOrgEmployeesPage() { + const { id: orgId } = useParams<{ id: string }>() + const navigate = useNavigate() + + const orgQuery = useQuery({ + queryKey: ['/api/super-admin/organizations', orgId], + queryFn: async () => (await api.get(`/api/super-admin/organizations/${orgId}`)).data, + enabled: !!orgId, + }) + + const URL = `/api/super-admin/organizations/${orgId}/employees` + const list = useCatalogList(URL, { includeInactive: true }) + + const roles = useQuery({ + queryKey: ['sa-employee-roles', orgId], + queryFn: async () => { + // Роли хранятся в tenant-scoped таблице — обходим через X-Org-Override. + const res = await api.get>('/api/organization/employee-roles?pageSize=200', + { headers: { 'X-Org-Override': orgId ?? '' } }) + return res.data.items + }, + enabled: !!orgId, + }) + + const [form, setForm] = useState(null) + const [activeRow, setActiveRow] = useState(null) + const [resetFor, setResetFor] = useState(null) + const [resetReason, setResetReason] = useState('') + const [resetResult, setResetResult] = useState<{ email: string; tempPassword: string } | null>(null) + const [toggleConfirm, setToggleConfirm] = useState<{ row: EmployeeRow; activate: boolean; target: 'employee' | 'account' } | null>(null) + const [toggleReason, setToggleReason] = useState('') + const [errorMsg, setErrorMsg] = useState(null) + + const openCreate = () => setForm(blankForm()) + const openEdit = (r: EmployeeRow) => { + setActiveRow(r) + setForm({ + id: r.id, reason: '', + 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: [], + createAccount: false, accountEmail: '', + }) + } + const closeForm = () => { setForm(null); setActiveRow(null) } + + const save = async () => { + if (!form) return + setErrorMsg(null) + try { + if (form.id) { + await api.put(`${URL}/${form.id}`, { + reason: form.reason, + employee: { + lastName: form.lastName, firstName: form.firstName, middleName: form.middleName || null, + position: form.position || null, email: form.email || null, phone: form.phone || null, + salary: null, taxNumber: null, description: null, imageUrl: null, + roleId: form.roleId, isActive: form.isActive, + retailPointIds: form.retailPointIds, + }, + }) + } else { + const res = await api.post(URL, { + reason: form.reason, + 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.createAccount, + accountEmail: form.accountEmail || form.email || null, + }) as { data: { generatedPassword?: string; employee: { email: string | null } } } + if (res.data.generatedPassword && res.data.employee.email) { + setResetResult({ email: res.data.employee.email, tempPassword: res.data.generatedPassword }) + } + } + closeForm() + list.refetch() + } catch (e) { + const err = e as { response?: { data?: { error?: string } }, message?: string } + setErrorMsg(err.response?.data?.error ?? err.message ?? 'Не удалось сохранить') + } + } + + const doReset = async () => { + if (!resetFor) return + setErrorMsg(null) + try { + const res = await api.post<{ email: string; tempPassword: string }>( + `${URL}/${resetFor.id}/reset-password`, { reason: resetReason }) + setResetResult(res.data) + setResetFor(null); setResetReason('') + } catch (e) { + const err = e as { response?: { data?: { error?: string } }, message?: string } + setErrorMsg(err.response?.data?.error ?? err.message ?? 'Не удалось сбросить пароль') + } + } + + const doToggle = async () => { + if (!toggleConfirm) return + setErrorMsg(null) + try { + const path = toggleConfirm.target === 'account' + ? `${URL}/${toggleConfirm.row.id}/account/toggle-active` + : `${URL}/${toggleConfirm.row.id}/toggle-active` + await api.post(path, { reason: toggleReason, isActive: toggleConfirm.activate }) + setToggleConfirm(null); setToggleReason('') + list.refetch() + } catch (e) { + const err = e as { response?: { data?: { error?: string } }, message?: string } + setErrorMsg(err.response?.data?.error ?? err.message ?? 'Не удалось') + } + } + + const orgName = orgQuery.data?.name ?? '…' + + return ( + <> + + + + + + } + footer={list.data && list.data.total > 0 && ( + + )} + > + r.id} + columns={[ + { header: 'ФИО', cell: (r) => ( +
+
+ {r.lastName} {r.firstName} {r.middleName ?? ''} + {r.isOwner && ( + Главный администратор + )} +
+ {r.position &&
{r.position}
} +
+ )}, + { header: 'Роль', width: '160px', cell: (r) => r.roleName }, + { header: 'Email', width: '220px', cell: (r) => r.email ?? '—' }, + { header: 'Учётка', width: '140px', cell: (r) => r.hasAccount + ? r.accountActive + ? активна + : заблокирована + : нет }, + { header: 'Сотрудник', width: '120px', cell: (r) => r.isActive + ? Активен + : Уволен }, + { header: 'Last login', width: '120px', cell: (r) => r.lastLoginAt + ? new Date(r.lastLoginAt).toLocaleDateString('ru') : '—' }, + { header: '', width: '180px', cell: (r) => ( +
e.stopPropagation()}> + + {r.hasAccount && ( + + )} + {r.hasAccount && ( + + )} + +
+ )}, + ]} + /> +
+ + {/* Создание/редактирование */} + + + + }> + {form && ( +
+ {activeRow?.isOwner && ( +

+ Это главный администратор организации. SuperAdmin может менять любую роль и активность — + но при деактивации главного администратора организация останется без владельца. +

+ )} + +