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 { 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 />} />
|
||||||
|
|
|
||||||
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 { 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">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue