feat(employees): двухступенчатое удаление — «уволить» → «удалить»

Полное физическое удаление сотрудника невозможно — у него FK из
retail_sales и supplies. Поэтому теперь два шага:

  IsActive=true                                  → активный
  IsActive=false + FiredAt                       → уволен (кнопка «Уволить»)
  IsActive=false + IsDeleted=true + DeletedAt    → удалён (кнопка «Удалить»)

— Domain: Employee получил поля IsDeleted/DeletedAt + миграция
  Phase5a_EmployeeSoftDelete (drop column возможен через Down).
- API EmployeesController.Delete:
  · если активен — переводит в Fired;
  · если уже уволен — ставит IsDeleted=true + DeletedAt;
  · если уже удалён — 409 Conflict;
  · гарды Owner и self применяются на ОБОИХ шагах.
- API EmployeesController.List: новый query-param ?status=
  active|fired|deleted|all (default: всё кроме deleted).
- DTO дополнен полями isDeleted, deletedAt, status (active/fired/deleted) —
  фронтэнд использует для бейджа и логики кнопок.
- UI EmployeesPage:
  · фильтр статуса в actions: «Активные и уволенные» (default),
    «Только активные», «Только уволенные», «Только удалённые»,
    «Все, включая удалённых».
  · колонка «Статус» теперь с цветным бейджем (emerald/amber/rose).
  · ФИО уволенного помечается «(уволен)», удалённого — line-through
    + «(удалён)».
  · кнопка-действие в модалке: «Уволить» если active, «Удалить» если
    fired, скрыта если уже deleted (заменена на pojaснение).
  · confirm-текст обоих шагов разный — юзер понимает что произойдёт.

Существующие связанные документы (продажи, поставки) ссылаются на
employees по FK; имена для UI берутся из employee.LastName/FirstName +
status — отображение «Иванов И.И. (удалён)» работает автоматически.
This commit is contained in:
nns 2026-05-06 11:26:38 +05:00
parent 0e4b7868c9
commit e8a28ba1f6
4 changed files with 157 additions and 35 deletions

View file

@ -32,6 +32,9 @@ public record EmployeeDto(
decimal? Salary, string? TaxNumber, string? Description, string? ImageUrl, decimal? Salary, string? TaxNumber, string? Description, string? ImageUrl,
Guid RoleId, string RoleName, Guid RoleId, string RoleName,
bool IsActive, DateTime? FiredAt, bool IsActive, DateTime? FiredAt,
bool IsDeleted, DateTime? DeletedAt,
// active | fired | deleted — производное от IsActive/IsDeleted, удобно для UI-бейджа
string Status,
IReadOnlyList<Guid> RetailPointIds, IReadOnlyList<Guid> RetailPointIds,
bool IsOwner, bool IsSelf); bool IsOwner, bool IsSelf);
@ -49,7 +52,9 @@ public record EmployeeCreateResult(EmployeeDto Employee, string? GeneratedPasswo
[HttpGet] [HttpGet]
public async Task<ActionResult<PagedResult<EmployeeDto>>> List( public async Task<ActionResult<PagedResult<EmployeeDto>>> List(
[FromQuery] PagedRequest req, CancellationToken ct) [FromQuery] PagedRequest req,
[FromQuery] string? status, // active | fired | deleted | all (default: active+fired)
CancellationToken ct = default)
{ {
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant."); var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var ownerUserId = await _db.Organizations.IgnoreQueryFilters() var ownerUserId = await _db.Organizations.IgnoreQueryFilters()
@ -60,6 +65,16 @@ public record EmployeeCreateResult(EmployeeDto Employee, string? GeneratedPasswo
?? User.FindFirst("sub")?.Value); ?? User.FindFirst("sub")?.Value);
var q = _db.Employees.AsNoTracking().Include(e => e.Role).Include(e => e.RetailPointAssignments).AsQueryable(); var q = _db.Employees.AsNoTracking().Include(e => e.Role).Include(e => e.RetailPointAssignments).AsQueryable();
// Фильтр по статусу. По умолчанию (status=null) — показываем активных
// и уволенных, удалённых скрываем; «all» включает удалённых.
switch (status)
{
case "active": q = q.Where(e => e.IsActive && !e.IsDeleted); break;
case "fired": q = q.Where(e => !e.IsActive && !e.IsDeleted); break;
case "deleted": q = q.Where(e => e.IsDeleted); break;
case "all": /* без фильтра */ break;
default: q = q.Where(e => !e.IsDeleted); break;
}
if (!string.IsNullOrWhiteSpace(req.Search)) if (!string.IsNullOrWhiteSpace(req.Search))
{ {
var s = req.Search.Trim().ToLower(); var s = req.Search.Trim().ToLower();
@ -79,6 +94,8 @@ public record EmployeeCreateResult(EmployeeDto Employee, string? GeneratedPasswo
e.Salary, e.TaxNumber, e.Description, e.ImageUrl, e.Salary, e.TaxNumber, e.Description, e.ImageUrl,
e.RoleId, e.Role.Name, e.RoleId, e.Role.Name,
e.IsActive, e.FiredAt, e.IsActive, e.FiredAt,
e.IsDeleted, e.DeletedAt,
e.IsDeleted ? "deleted" : (e.IsActive ? "active" : "fired"),
e.RetailPointAssignments.Select(a => a.RetailPointId).ToList(), e.RetailPointAssignments.Select(a => a.RetailPointId).ToList(),
e.UserId != null && ownerUserId == e.UserId, e.UserId != null && ownerUserId == e.UserId,
e.UserId != null && currentUserId != null && e.UserId == currentUserId)) e.UserId != null && currentUserId != null && e.UserId == currentUserId))
@ -219,17 +236,18 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
var e = await _db.Employees.FirstOrDefaultAsync(x => x.Id == id, ct); var e = await _db.Employees.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound(); if (e is null) return NotFound();
// Soft-delete c гардами: главного администратора — нельзя, себя — нельзя. // Двухступенчатое удаление:
// Главный администратор удаляется только Супер-администратором платформы. // IsActive=true → этот endpoint выполняет «увольнение» (Fired).
// Если кому-то критично hard-delete (cleanup при удалении org) — это идёт // IsActive=false && IsDeleted=false → этот endpoint выполняет soft-delete.
// через SuperAdmin консоль, а не через этот эндпоинт. // IsDeleted=true → 409, уже удалён.
// Гарды (главный админ + self) применяются на ОБОИХ шагах.
var currentUserId = ParseUserId(User.FindFirst(ClaimTypes.NameIdentifier)?.Value var currentUserId = ParseUserId(User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? User.FindFirst("sub")?.Value); ?? User.FindFirst("sub")?.Value);
if (currentUserId is not null && e.UserId == currentUserId) if (currentUserId is not null && e.UserId == currentUserId)
{ {
return StatusCode(StatusCodes.Status403Forbidden, new return StatusCode(StatusCodes.Status403Forbidden, new
{ {
error = "Нельзя удалить себя. Покинуть организацию можно через Настройки → Аккаунт → Покинуть организацию.", error = "Нельзя уволить или удалить себя. Покинуть организацию можно через Настройки → Аккаунт → Покинуть организацию.",
}); });
} }
@ -249,8 +267,21 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
} }
} }
e.IsActive = false; if (e.IsDeleted)
e.FiredAt ??= DateTime.UtcNow; return Conflict(new { error = "Сотрудник уже удалён." });
if (e.IsActive)
{
// Шаг 1: увольнение.
e.IsActive = false;
e.FiredAt ??= DateTime.UtcNow;
}
else
{
// Шаг 2: soft-delete (физически не удаляем — есть FK из retail_sales/supplies).
e.IsDeleted = true;
e.DeletedAt = DateTime.UtcNow;
}
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return NoContent(); return NoContent();
} }
@ -276,6 +307,8 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
e.Salary, e.TaxNumber, e.Description, e.ImageUrl, e.Salary, e.TaxNumber, e.Description, e.ImageUrl,
e.RoleId, e.Role.Name, e.RoleId, e.Role.Name,
e.IsActive, e.FiredAt, e.IsActive, e.FiredAt,
e.IsDeleted, e.DeletedAt,
e.IsDeleted ? "deleted" : (e.IsActive ? "active" : "fired"),
e.RetailPointAssignments.Select(a => a.RetailPointId).ToList(), e.RetailPointAssignments.Select(a => a.RetailPointId).ToList(),
e.UserId != null && ownerUserId == e.UserId, e.UserId != null && ownerUserId == e.UserId,
e.UserId != null && currentUserId != null && e.UserId == currentUserId)) e.UserId != null && currentUserId != null && e.UserId == currentUserId))

View file

@ -32,10 +32,18 @@ public class Employee : TenantEntity
public EmployeeRole Role { get; set; } = null!; public EmployeeRole Role { get; set; } = null!;
/// <summary>Активен ли сотрудник. False — заблокирован, не может логиниться. /// <summary>Активен ли сотрудник. False — заблокирован, не может логиниться.
/// Удаление физически не делаем (FK из документов), просто IsActive=false.</summary> /// Двухступенчатое удаление: сначала «Уволить» (IsActive=false + FiredAt),
/// затем «Удалить» (IsDeleted=true + DeletedAt). Физически не удаляем
/// никогда — у сотрудника есть FK из документов (продаж, поставок).</summary>
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
public DateTime? FiredAt { get; set; } public DateTime? FiredAt { get; set; }
/// <summary>Soft-delete-флаг. Ставится только из состояния «уволен» (IsActive=false).
/// В UI и связанных документах отображается «Иванов И.И. (удалён)».
/// В списках сотрудников по умолчанию скрыты, доступны через фильтр.</summary>
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
public ICollection<EmployeeRetailPointAssignment> RetailPointAssignments { get; set; } public ICollection<EmployeeRetailPointAssignment> RetailPointAssignments { get; set; }
= new List<EmployeeRetailPointAssignment>(); = new List<EmployeeRetailPointAssignment>();
} }

View file

@ -0,0 +1,46 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Двухступенчатое удаление сотрудника: добавлены IsDeleted и
/// DeletedAt. Ранее было только IsActive=false (увольнение); теперь:
/// IsActive=true — активный
/// IsActive=false + FiredAt — уволен
/// IsActive=false + IsDeleted=true + DeletedAt — soft-deleted
/// Физически Employee никогда не удаляем (FK из retail_sales, supplies).</summary>
public partial class Phase5a_EmployeeSoftDelete : Migration
{
protected override void Up(MigrationBuilder b)
{
b.AddColumn<bool>(
name: "IsDeleted",
schema: "public",
table: "employees",
type: "boolean",
nullable: false,
defaultValue: false);
b.AddColumn<System.DateTime>(
name: "DeletedAt",
schema: "public",
table: "employees",
type: "timestamp with time zone",
nullable: true);
b.CreateIndex(
name: "IX_employees_OrganizationId_IsDeleted",
schema: "public",
table: "employees",
columns: new[] { "OrganizationId", "IsDeleted" });
}
protected override void Down(MigrationBuilder b)
{
b.DropIndex(name: "IX_employees_OrganizationId_IsDeleted", schema: "public", table: "employees");
b.DropColumn(name: "IsDeleted", schema: "public", table: "employees");
b.DropColumn(name: "DeletedAt", schema: "public", table: "employees");
}
}
}

View file

@ -16,6 +16,8 @@ import type { EmployeeRoleDto } from '@/pages/EmployeeRolesPage'
const URL = '/api/organization/employees' const URL = '/api/organization/employees'
type EmployeeStatus = 'active' | 'fired' | 'deleted'
interface EmployeeDto { interface EmployeeDto {
id: string id: string
userId: string | null userId: string | null
@ -33,6 +35,9 @@ interface EmployeeDto {
roleName: string roleName: string
isActive: boolean isActive: boolean
firedAt: string | null firedAt: string | null
isDeleted: boolean
deletedAt: string | null
status: EmployeeStatus
retailPointIds: string[] retailPointIds: string[]
/** Главный администратор организации (Organization.AccountOwnerUserId == /** Главный администратор организации (Organization.AccountOwnerUserId ==
* Employee.UserId). Любые изменения этой записи (роль, активность, * Employee.UserId). Любые изменения этой записи (роль, активность,
@ -74,7 +79,6 @@ const blankForm = (): Form => ({
}) })
export function EmployeesPage() { export function EmployeesPage() {
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<EmployeeDto>(URL)
const { update, remove } = useCatalogMutations(URL, URL) const { update, remove } = useCatalogMutations(URL, URL)
const [form, setForm] = useState<Form | null>(null) const [form, setForm] = useState<Form | null>(null)
// Сгенерированный пароль возвращается с сервера один раз — показываем // Сгенерированный пароль возвращается с сервера один раз — показываем
@ -152,6 +156,11 @@ export function EmployeesPage() {
}) })
} }
// Фильтр по статусу сотрудника. По умолчанию показываем только активных
// и уволенных; «удалённые» — отдельный режим (read-only список архива).
const [statusFilter, setStatusFilter] = useState<'default' | 'active' | 'fired' | 'deleted' | 'all'>('default')
const list = useCatalogList<EmployeeDto>(URL, statusFilter === 'default' ? {} : { status: statusFilter })
return ( return (
<> <>
<ListPageShell <ListPageShell
@ -159,23 +168,34 @@ export function EmployeesPage() {
description="Учётные записи и сотрудники без логина. Привязка к ролям, для кассиров — к конкретным кассам." description="Учётные записи и сотрудники без логина. Привязка к ролям, для кассиров — к конкретным кассам."
actions={ actions={
<> <>
<SearchBar value={search} onChange={setSearch} /> <SearchBar value={list.search} onChange={list.setSearch} />
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as typeof statusFilter)}
className="h-9 px-2 rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-sm"
>
<option value="default">Активные и уволенные</option>
<option value="active">Только активные</option>
<option value="fired">Только уволенные</option>
<option value="deleted">Только удалённые</option>
<option value="all">Все, включая удалённых</option>
</select>
<Button onClick={() => setForm(blankForm())}> <Button onClick={() => setForm(blankForm())}>
<Plus className="w-4 h-4" /> Добавить сотрудника <Plus className="w-4 h-4" /> Добавить сотрудника
</Button> </Button>
</> </>
} }
footer={data && data.total > 0 && ( footer={list.data && list.data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} /> <Pagination page={list.page} pageSize={list.data.pageSize} total={list.data.total} onPageChange={list.setPage} />
)} )}
> >
<DataTable <DataTable
rows={data?.items ?? []} rows={list.data?.items ?? []}
isLoading={isLoading} isLoading={list.isLoading}
rowKey={(r) => r.id} rowKey={(r) => r.id}
sortKey={sortKey} sortKey={list.sortKey}
sortOrder={sortOrder} sortOrder={list.sortOrder}
onSortChange={setSort} onSortChange={list.setSort}
onRowClick={(r) => { onRowClick={(r) => {
setActiveEmployee(r) setActiveEmployee(r)
setForm({ setForm({
@ -191,7 +211,11 @@ export function EmployeesPage() {
{ header: 'ФИО', cell: (r) => ( { header: 'ФИО', cell: (r) => (
<div> <div>
<div className="font-medium flex items-center gap-2"> <div className="font-medium flex items-center gap-2">
<span>{r.lastName} {r.firstName} {r.middleName ?? ''}</span> <span className={r.status === 'deleted' ? 'line-through text-slate-400' : ''}>
{r.lastName} {r.firstName} {r.middleName ?? ''}
</span>
{r.status === 'fired' && <span className="text-[10px] text-slate-400">(уволен)</span>}
{r.status === 'deleted' && <span className="text-[10px] text-slate-400">(удалён)</span>}
{r.isOwner && ( {r.isOwner && (
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300"> <span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">
Главный администратор Главный администратор
@ -207,9 +231,11 @@ export function EmployeesPage() {
{ header: 'Учётка', width: '110px', cell: (r) => r.userId { header: 'Учётка', width: '110px', cell: (r) => r.userId
? <span className="text-xs text-emerald-600">есть</span> ? <span className="text-xs text-emerald-600">есть</span>
: <span className="text-xs text-slate-400">нет</span> }, : <span className="text-xs text-slate-400">нет</span> },
{ header: 'Статус', width: '110px', cell: (r) => r.isActive { header: 'Статус', width: '110px', cell: (r) => {
? <span className="text-xs text-emerald-600">Активен</span> if (r.status === 'deleted') return <span className="text-xs px-1.5 py-0.5 rounded bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300">Удалён</span>
: <span className="text-xs text-slate-400">Уволен</span> }, if (r.status === 'fired') return <span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">Уволен</span>
return <span className="text-xs px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">Активен</span>
}},
]} ]}
/> />
</ListPageShell> </ListPageShell>
@ -221,20 +247,20 @@ export function EmployeesPage() {
width="max-w-xl" width="max-w-xl"
footer={ footer={
<> <>
{form?.id && ( {form?.id && activeEmployee && activeEmployee.status !== 'deleted' && (
<Button <Button
variant="danger" variant="danger"
size="sm" size="sm"
disabled={activeEmployee?.isOwner || activeEmployee?.isSelf} disabled={activeEmployee.isOwner || activeEmployee.isSelf}
title={ title={
activeEmployee?.isOwner activeEmployee.isOwner
? 'Главного администратора может удалить только Супер-администратор платформы' ? 'Главного администратора может удалить только Супер-администратор платформы'
: activeEmployee?.isSelf : activeEmployee.isSelf
? 'Нельзя удалить себя' ? 'Нельзя уволить или удалить себя'
: undefined : undefined
} }
onClick={async () => { onClick={async () => {
if (activeEmployee?.isOwner) { if (activeEmployee.isOwner) {
setBlockedDelete({ setBlockedDelete({
title: 'Действие заблокировано', title: 'Действие заблокировано',
body: body:
@ -244,28 +270,37 @@ export function EmployeesPage() {
}) })
return return
} }
if (activeEmployee?.isSelf) { if (activeEmployee.isSelf) {
setBlockedDelete({ setBlockedDelete({
title: 'Нельзя удалить себя', title: 'Нельзя уволить или удалить себя',
body: body:
'Свою учётную запись нельзя удалить из этой страницы. ' + 'Свою учётную запись нельзя изменить из этой страницы. ' +
'Покинуть организацию можно через Настройки → Аккаунт → Покинуть организацию.', 'Покинуть организацию можно через Настройки → Аккаунт → Покинуть организацию.',
}) })
return return
} }
if (!confirm(`Удалить сотрудника «${activeEmployee?.lastName ?? ''} ${activeEmployee?.firstName ?? ''}»?\n\nСотрудник будет деактивирован, его учётная запись потеряет доступ к организации. Историю операций сохраняем.`)) return const fullName = `${activeEmployee.lastName ?? ''} ${activeEmployee.firstName ?? ''}`.trim()
const confirmText = activeEmployee.status === 'active'
? `Уволить сотрудника «${fullName}»?\n\nЕго учётная запись потеряет доступ, документы и история останутся в системе. Восстановить можно в любой момент.`
: `Удалить запись о сотруднике «${fullName}»?\n\nСотрудник уже уволен. После удаления он скрывается из обычных списков, но во всех связанных документах остаётся подпись «${fullName} (удалён)».`
if (!confirm(confirmText)) return
try { try {
await remove.mutateAsync(form.id!) await remove.mutateAsync(form.id!)
list.refetch?.()
setForm(null); setActiveEmployee(null) setForm(null); setActiveEmployee(null)
} catch (e) { } catch (e) {
const err = e as { response?: { data?: { error?: string } }, message?: string } const err = e as { response?: { data?: { error?: string } }, message?: string }
const msg = err.response?.data?.error ?? err.message ?? 'Не удалось удалить сотрудника' const msg = err.response?.data?.error ?? err.message ?? 'Не удалось выполнить операцию'
setBlockedDelete({ title: 'Не удалось удалить', body: msg }) setBlockedDelete({ title: 'Не удалось выполнить', body: msg })
} }
}}> }}>
<Trash2 className="w-4 h-4" /> Удалить <Trash2 className="w-4 h-4" />
{activeEmployee.status === 'active' ? 'Уволить' : 'Удалить'}
</Button> </Button>
)} )}
{form?.id && activeEmployee?.status === 'deleted' && (
<span className="text-xs text-slate-500 italic">Сотрудник удалён изменения недоступны.</span>
)}
<Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button> <Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button>
<Button onClick={save} disabled={!form?.lastName || !form.firstName || !form.roleId}>Сохранить</Button> <Button onClick={save} disabled={!form?.lastName || !form.firstName || !form.roleId}>Сохранить</Button>
</> </>