feat(super-admin): полное управление сотрудниками любой орги
Some checks failed
CI / Backend (.NET 8) (push) Successful in 47s
CI / Web (React + Vite) (push) Successful in 41s
Docker API / Build + push API (push) Successful in 1m10s
Docker Web / Build + push Web (push) Successful in 32s
Docker API / Deploy API on stage (push) Successful in 17s
Docker Web / Deploy Web on stage (push) Successful in 12s
CI / POS (WPF, Windows) (push) Has been cancelled

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 — переход на страницу прямо из списка орг.
This commit is contained in:
nns 2026-04-28 14:21:08 +05:00
parent f54c8bb5b7
commit 3849cb3547
4 changed files with 826 additions and 1 deletions

View file

@ -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;
/// <summary>SuperAdmin: управление сотрудниками любой организации в обход
/// tenant-фильтра и обычных гардов (роль главного администратора, self-delete
/// и т.д.). Все мутации обязательно сопровождаются reason (минимум 10
/// символов) и пишутся в super_admin_audit_log.
///
/// Tenant-эндпойнты `/api/organization/employees/*` оставляем для обычных
/// админов организации с их гардами; этот контроллер — только для
/// Супер-администратора платформы.</summary>
[ApiController]
[Authorize(Roles = "SuperAdmin")]
[Route("api/super-admin/organizations/{orgId:guid}/employees")]
public class SuperAdminEmployeesController : ControllerBase
{
private readonly AppDbContext _db;
private readonly UserManager<User> _userMgr;
public SuperAdminEmployeesController(AppDbContext db, UserManager<User> 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<Guid> 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<Guid>? RetailPointIds);
public record CreateInput(
string Reason,
string LastName, string FirstName, string? MiddleName,
string? Position, string? Email, string? Phone,
Guid RoleId, bool IsActive,
IReadOnlyList<Guid>? 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<ActionResult<PagedResult<EmployeeRow>>> 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<Guid, User>()
: 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<EmployeeRow>
{ Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<EmployeeDetail>> 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<ActionResult<EmployeeDetail>> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<ActionResult<ResetPasswordResult>> 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<IActionResult> 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<EmployeeDetail?> 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<char>
{
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());
}
}

View file

@ -34,6 +34,7 @@ import { SuperAdminLayout } from '@/components/SuperAdminLayout'
import { TenantRouteGuard } from '@/components/TenantRouteGuard' import { TenantRouteGuard } from '@/components/TenantRouteGuard'
import { ProtectedRoute } from '@/components/ProtectedRoute' import { ProtectedRoute } from '@/components/ProtectedRoute'
import { NoOrganizationPage } from '@/pages/NoOrganizationPage' import { NoOrganizationPage } from '@/pages/NoOrganizationPage'
import { SuperAdminOrgEmployeesPage } from '@/pages/SuperAdminOrgEmployeesPage'
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@ -64,6 +65,7 @@ export default function App() {
<Route index element={<SuperAdminDashboardPage />} /> <Route index element={<SuperAdminDashboardPage />} />
<Route path="organizations" element={<SuperAdminOrganizationsPage />} /> <Route path="organizations" element={<SuperAdminOrganizationsPage />} />
<Route path="organizations/new" element={<SuperAdminOrgCreatePage />} /> <Route path="organizations/new" element={<SuperAdminOrgCreatePage />} />
<Route path="organizations/:id/employees" element={<SuperAdminOrgEmployeesPage />} />
<Route path="audit-log" element={<SuperAdminAuditLogPage />} /> <Route path="audit-log" element={<SuperAdminAuditLogPage />} />
<Route path="countries" element={<CountriesPage />} /> <Route path="countries" element={<CountriesPage />} />
<Route path="groups" element={<ProductGroupsPage />} /> <Route path="groups" element={<ProductGroupsPage />} />

View file

@ -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<OrgInfo>(`/api/super-admin/organizations/${orgId}`)).data,
enabled: !!orgId,
})
const URL = `/api/super-admin/organizations/${orgId}/employees`
const list = useCatalogList<EmployeeRow>(URL, { includeInactive: true })
const roles = useQuery({
queryKey: ['sa-employee-roles', orgId],
queryFn: async () => {
// Роли хранятся в tenant-scoped таблице — обходим через X-Org-Override.
const res = await api.get<PagedResult<EmployeeRoleDto>>('/api/organization/employee-roles?pageSize=200',
{ headers: { 'X-Org-Override': orgId ?? '' } })
return res.data.items
},
enabled: !!orgId,
})
const [form, setForm] = useState<EmpForm | null>(null)
const [activeRow, setActiveRow] = useState<EmployeeRow | null>(null)
const [resetFor, setResetFor] = useState<EmployeeRow | null>(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<string | null>(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 (
<>
<ListPageShell
title={`Сотрудники · ${orgName}`}
description="Полный доступ супер-администратора к сотрудникам организации. Каждое действие пишется в журнал."
actions={
<>
<Button variant="secondary" onClick={() => navigate('/super-admin/organizations')}> К организациям</Button>
<SearchBar value={list.search} onChange={list.setSearch} />
<Button onClick={openCreate}>
<Plus className="w-4 h-4" /> Добавить сотрудника
</Button>
</>
}
footer={list.data && list.data.total > 0 && (
<Pagination page={list.page} pageSize={list.data.pageSize} total={list.data.total} onPageChange={list.setPage} />
)}
>
<DataTable
rows={list.data?.items ?? []}
isLoading={list.isLoading}
rowKey={(r) => r.id}
columns={[
{ header: 'ФИО', cell: (r) => (
<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">Главный администратор</span>
)}
</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: '140px', cell: (r) => r.hasAccount
? r.accountActive
? <span className="text-xs text-emerald-600">активна</span>
: <span className="text-xs text-rose-600">заблокирована</span>
: <span className="text-xs text-slate-400">нет</span> },
{ header: 'Сотрудник', width: '120px', cell: (r) => r.isActive
? <span className="text-xs text-emerald-600">Активен</span>
: <span className="text-xs text-slate-400">Уволен</span> },
{ header: 'Last login', width: '120px', cell: (r) => r.lastLoginAt
? new Date(r.lastLoginAt).toLocaleDateString('ru') : '—' },
{ header: '', width: '180px', cell: (r) => (
<div className="flex gap-1.5" onClick={(e) => e.stopPropagation()}>
<button title="Редактировать" onClick={() => openEdit(r)}
className="p-1.5 text-blue-600 hover:bg-blue-50 rounded"><Pencil className="w-4 h-4" /></button>
{r.hasAccount && (
<button title="Сбросить пароль" onClick={() => { setResetFor(r); setResetReason('') }}
className="p-1.5 text-amber-600 hover:bg-amber-50 rounded"><KeyRound className="w-4 h-4" /></button>
)}
{r.hasAccount && (
<button
title={r.accountActive ? 'Заблокировать учётку' : 'Разблокировать учётку'}
onClick={() => { setToggleConfirm({ row: r, activate: !r.accountActive, target: 'account' }); setToggleReason('') }}
className="p-1.5 text-rose-600 hover:bg-rose-50 rounded">
{r.accountActive ? <PowerOff className="w-4 h-4" /> : <Power className="w-4 h-4" />}
</button>
)}
<button
title={r.isActive ? 'Деактивировать сотрудника' : 'Активировать сотрудника'}
onClick={() => { setToggleConfirm({ row: r, activate: !r.isActive, target: 'employee' }); setToggleReason('') }}
className="p-1.5 text-slate-600 hover:bg-slate-100 rounded">
{r.isActive ? <PowerOff className="w-4 h-4" /> : <Power className="w-4 h-4" />}
</button>
</div>
)},
]}
/>
</ListPageShell>
{/* Создание/редактирование */}
<Modal
open={!!form}
onClose={closeForm}
title={form?.id
? `Редактировать ${activeRow?.isOwner ? 'главного администратора' : 'сотрудника'}`
: 'Новый сотрудник'}
width="max-w-xl"
footer={<>
<Button variant="secondary" onClick={closeForm}>Отмена</Button>
<Button onClick={save}
disabled={!form?.lastName || !form?.firstName || !form?.roleId || (form?.reason?.trim().length ?? 0) < 10}>
Сохранить
</Button>
</>}>
{form && (
<div className="space-y-3">
{activeRow?.isOwner && (
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-md px-2.5 py-2">
Это главный администратор организации. SuperAdmin может менять любую роль и активность
но при деактивации главного администратора организация останется без владельца.
</p>
)}
<Field label="Причина изменения (≥ 10 символов, в журнал)">
<TextArea rows={2} value={form.reason} onChange={(e) => setForm({ ...form, reason: e.target.value })}
placeholder="Например: запрос клиента №42 — повысить кассира до администратора" />
</Field>
<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>
<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 })} />
</Field>
<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 })}
className="w-full h-10 rounded-md border border-slate-300 bg-white px-3 text-sm">
<option value=""> выберите </option>
{roles.data?.map((r) => (
<option key={r.id} value={r.id}>
{r.name}{r.isSystem ? ' (системная)' : ''}
</option>
))}
</select>
</Field>
<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.createAccount && (
<Field label="Email учётной записи">
<TextInput type="email" value={form.accountEmail || form.email}
onChange={(e) => setForm({ ...form, accountEmail: e.target.value })}
placeholder="name@example.kz" />
</Field>
)}
</>
)}
</div>
)}
</Modal>
{/* Сброс пароля */}
<Modal open={!!resetFor} onClose={() => setResetFor(null)}
title="Сбросить пароль"
footer={<>
<Button variant="secondary" onClick={() => setResetFor(null)}>Отмена</Button>
<Button variant="danger" onClick={doReset} disabled={resetReason.trim().length < 10}>Сбросить</Button>
</>}>
{resetFor && (
<div className="space-y-3">
<p className="text-sm text-slate-700">
Будет сгенерирован новый временный пароль для <strong>{resetFor.email ?? '—'}</strong>.
Все активные сессии этого юзера будут оборваны.
</p>
<Field label="Причина (≥ 10 символов)">
<TextArea rows={2} value={resetReason} onChange={(e) => setResetReason(e.target.value)} />
</Field>
</div>
)}
</Modal>
{/* Результат сброса/создания — пароль one-shot */}
<Modal open={!!resetResult} onClose={() => setResetResult(null)}
title="Временный пароль"
footer={<Button onClick={() => setResetResult(null)}>Готово</Button>}>
{resetResult && (
<div className="space-y-3">
<p className="text-sm text-slate-700">
Передайте логин и пароль пользователю. <strong>Этот пароль показывается один раз.</strong>
</p>
<Field label="Email">
<div className="flex gap-2">
<TextInput value={resetResult.email} readOnly />
<Button variant="secondary" size="sm"
onClick={() => navigator.clipboard?.writeText(resetResult.email)}>
<Copy className="w-4 h-4" />
</Button>
</div>
</Field>
<Field label="Временный пароль">
<div className="flex gap-2">
<TextInput value={resetResult.tempPassword} readOnly className="font-mono" />
<Button variant="secondary" size="sm"
onClick={() => navigator.clipboard?.writeText(resetResult.tempPassword)}>
<Copy className="w-4 h-4" />
</Button>
</div>
</Field>
</div>
)}
</Modal>
{/* Подтверждение toggle (employee/account) */}
<Modal open={!!toggleConfirm} onClose={() => setToggleConfirm(null)}
title={toggleConfirm
? (toggleConfirm.target === 'account'
? (toggleConfirm.activate ? 'Разблокировать учётную запись' : 'Заблокировать учётную запись')
: (toggleConfirm.activate ? 'Активировать сотрудника' : 'Деактивировать сотрудника'))
: ''}
footer={<>
<Button variant="secondary" onClick={() => setToggleConfirm(null)}>Отмена</Button>
<Button variant="danger" onClick={doToggle} disabled={toggleReason.trim().length < 10}>Подтвердить</Button>
</>}>
{toggleConfirm && (
<div className="space-y-3">
<p className="text-sm text-slate-700">
{toggleConfirm.target === 'account'
? (toggleConfirm.activate
? <>Разблокировать вход <strong>{toggleConfirm.row.email}</strong>. Юзер сможет залогиниться при следующей попытке.</>
: <>Заблокировать вход <strong>{toggleConfirm.row.email}</strong>. Все активные сессии оборвутся.</>)
: (toggleConfirm.activate
? <>Активировать сотрудника <strong>{toggleConfirm.row.lastName} {toggleConfirm.row.firstName}</strong>.</>
: <>Деактивировать сотрудника <strong>{toggleConfirm.row.lastName} {toggleConfirm.row.firstName}</strong>. Учётная запись остаётся.</>)}
</p>
<Field label="Причина (≥ 10 символов)">
<TextArea rows={2} value={toggleReason} onChange={(e) => setToggleReason(e.target.value)} />
</Field>
</div>
)}
</Modal>
<Modal open={!!errorMsg} onClose={() => setErrorMsg(null)}
title="Ошибка"
footer={<Button onClick={() => setErrorMsg(null)}>Понятно</Button>}>
<p className="text-sm text-rose-700 whitespace-pre-line">{errorMsg}</p>
</Modal>
</>
)
}

View file

@ -1,7 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Plus, Archive, RotateCcw, Trash2, LogIn } from 'lucide-react' import { Plus, Archive, RotateCcw, Trash2, LogIn, Users } from 'lucide-react'
import { api, setOrgOverride } from '@/lib/api' import { api, setOrgOverride } from '@/lib/api'
import { ListPageShell } from '@/components/ListPageShell' import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable' import { DataTable } from '@/components/DataTable'
@ -116,6 +116,11 @@ export function SuperAdminOrganizationsPage() {
<LogIn className="w-4 h-4" /> <LogIn className="w-4 h-4" />
</button> </button>
)} )}
<button title="Сотрудники организации"
onClick={() => navigate(`/super-admin/organizations/${r.id}/employees`)}
className="p-1.5 text-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 rounded">
<Users className="w-4 h-4" />
</button>
{!r.isArchived ? ( {!r.isArchived ? (
<button title="Архивировать" onClick={() => { setArchiveOf(r); setConfirmName('') }} <button title="Архивировать" onClick={() => { setArchiveOf(r); setConfirmName('') }}
className="p-1.5 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded"> className="p-1.5 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded">