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
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:
parent
f54c8bb5b7
commit
3849cb3547
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
|||
<Route index element={<SuperAdminDashboardPage />} />
|
||||
<Route path="organizations" element={<SuperAdminOrganizationsPage />} />
|
||||
<Route path="organizations/new" element={<SuperAdminOrgCreatePage />} />
|
||||
<Route path="organizations/:id/employees" element={<SuperAdminOrgEmployeesPage />} />
|
||||
<Route path="audit-log" element={<SuperAdminAuditLogPage />} />
|
||||
<Route path="countries" element={<CountriesPage />} />
|
||||
<Route path="groups" element={<ProductGroupsPage />} />
|
||||
|
|
|
|||
407
src/food-market.web/src/pages/SuperAdminOrgEmployeesPage.tsx
Normal file
407
src/food-market.web/src/pages/SuperAdminOrgEmployeesPage.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
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 { ListPageShell } from '@/components/ListPageShell'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
|
|
@ -116,6 +116,11 @@ export function SuperAdminOrganizationsPage() {
|
|||
<LogIn className="w-4 h-4" />
|
||||
</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 ? (
|
||||
<button title="Архивировать" onClick={() => { setArchiveOf(r); setConfirmName('') }}
|
||||
className="p-1.5 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded">
|
||||
|
|
|
|||
Loading…
Reference in a new issue