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,
Guid RoleId, string RoleName,
bool IsActive, DateTime? FiredAt,
bool IsDeleted, DateTime? DeletedAt,
// active | fired | deleted — производное от IsActive/IsDeleted, удобно для UI-бейджа
string Status,
IReadOnlyList<Guid> RetailPointIds,
bool IsOwner, bool IsSelf);
@ -49,7 +52,9 @@ public record EmployeeCreateResult(EmployeeDto Employee, string? GeneratedPasswo
[HttpGet]
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 ownerUserId = await _db.Organizations.IgnoreQueryFilters()
@ -60,6 +65,16 @@ public record EmployeeCreateResult(EmployeeDto Employee, string? GeneratedPasswo
?? User.FindFirst("sub")?.Value);
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))
{
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.RoleId, e.Role.Name,
e.IsActive, e.FiredAt,
e.IsDeleted, e.DeletedAt,
e.IsDeleted ? "deleted" : (e.IsActive ? "active" : "fired"),
e.RetailPointAssignments.Select(a => a.RetailPointId).ToList(),
e.UserId != null && ownerUserId == e.UserId,
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);
if (e is null) return NotFound();
// Soft-delete c гардами: главного администратора — нельзя, себя — нельзя.
// Главный администратор удаляется только Супер-администратором платформы.
// Если кому-то критично hard-delete (cleanup при удалении org) — это идёт
// через SuperAdmin консоль, а не через этот эндпоинт.
// Двухступенчатое удаление:
// IsActive=true → этот endpoint выполняет «увольнение» (Fired).
// IsActive=false && IsDeleted=false → этот endpoint выполняет soft-delete.
// IsDeleted=true → 409, уже удалён.
// Гарды (главный админ + self) применяются на ОБОИХ шагах.
var currentUserId = ParseUserId(User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? User.FindFirst("sub")?.Value);
if (currentUserId is not null && e.UserId == currentUserId)
{
return StatusCode(StatusCodes.Status403Forbidden, new
{
error = "Нельзя удалить себя. Покинуть организацию можно через Настройки → Аккаунт → Покинуть организацию.",
error = "Нельзя уволить или удалить себя. Покинуть организацию можно через Настройки → Аккаунт → Покинуть организацию.",
});
}
@ -249,8 +267,21 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
}
}
if (e.IsDeleted)
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);
return NoContent();
}
@ -276,6 +307,8 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
e.Salary, e.TaxNumber, e.Description, e.ImageUrl,
e.RoleId, e.Role.Name,
e.IsActive, e.FiredAt,
e.IsDeleted, e.DeletedAt,
e.IsDeleted ? "deleted" : (e.IsActive ? "active" : "fired"),
e.RetailPointAssignments.Select(a => a.RetailPointId).ToList(),
e.UserId != null && ownerUserId == e.UserId,
e.UserId != null && currentUserId != null && e.UserId == currentUserId))

View file

@ -32,10 +32,18 @@ public class Employee : TenantEntity
public EmployeeRole Role { get; set; } = null!;
/// <summary>Активен ли сотрудник. False — заблокирован, не может логиниться.
/// Удаление физически не делаем (FK из документов), просто IsActive=false.</summary>
/// Двухступенчатое удаление: сначала «Уволить» (IsActive=false + FiredAt),
/// затем «Удалить» (IsDeleted=true + DeletedAt). Физически не удаляем
/// никогда — у сотрудника есть FK из документов (продаж, поставок).</summary>
public bool IsActive { get; set; } = true;
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; }
= 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'
type EmployeeStatus = 'active' | 'fired' | 'deleted'
interface EmployeeDto {
id: string
userId: string | null
@ -33,6 +35,9 @@ interface EmployeeDto {
roleName: string
isActive: boolean
firedAt: string | null
isDeleted: boolean
deletedAt: string | null
status: EmployeeStatus
retailPointIds: string[]
/** Главный администратор организации (Organization.AccountOwnerUserId ==
* Employee.UserId). Любые изменения этой записи (роль, активность,
@ -74,7 +79,6 @@ const blankForm = (): Form => ({
})
export function EmployeesPage() {
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<EmployeeDto>(URL)
const { update, remove } = useCatalogMutations(URL, URL)
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 (
<>
<ListPageShell
@ -159,23 +168,34 @@ export function EmployeesPage() {
description="Учётные записи и сотрудники без логина. Привязка к ролям, для кассиров — к конкретным кассам."
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())}>
<Plus className="w-4 h-4" /> Добавить сотрудника
</Button>
</>
}
footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
footer={list.data && list.data.total > 0 && (
<Pagination page={list.page} pageSize={list.data.pageSize} total={list.data.total} onPageChange={list.setPage} />
)}
>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rows={list.data?.items ?? []}
isLoading={list.isLoading}
rowKey={(r) => r.id}
sortKey={sortKey}
sortOrder={sortOrder}
onSortChange={setSort}
sortKey={list.sortKey}
sortOrder={list.sortOrder}
onSortChange={list.setSort}
onRowClick={(r) => {
setActiveEmployee(r)
setForm({
@ -191,7 +211,11 @@ export function EmployeesPage() {
{ header: 'ФИО', cell: (r) => (
<div>
<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 && (
<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
? <span className="text-xs text-emerald-600">есть</span>
: <span className="text-xs text-slate-400">нет</span> },
{ header: 'Статус', width: '110px', cell: (r) => r.isActive
? <span className="text-xs text-emerald-600">Активен</span>
: <span className="text-xs text-slate-400">Уволен</span> },
{ header: 'Статус', width: '110px', cell: (r) => {
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>
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>
@ -221,20 +247,20 @@ export function EmployeesPage() {
width="max-w-xl"
footer={
<>
{form?.id && (
{form?.id && activeEmployee && activeEmployee.status !== 'deleted' && (
<Button
variant="danger"
size="sm"
disabled={activeEmployee?.isOwner || activeEmployee?.isSelf}
disabled={activeEmployee.isOwner || activeEmployee.isSelf}
title={
activeEmployee?.isOwner
activeEmployee.isOwner
? 'Главного администратора может удалить только Супер-администратор платформы'
: activeEmployee?.isSelf
? 'Нельзя удалить себя'
: activeEmployee.isSelf
? 'Нельзя уволить или удалить себя'
: undefined
}
onClick={async () => {
if (activeEmployee?.isOwner) {
if (activeEmployee.isOwner) {
setBlockedDelete({
title: 'Действие заблокировано',
body:
@ -244,28 +270,37 @@ export function EmployeesPage() {
})
return
}
if (activeEmployee?.isSelf) {
if (activeEmployee.isSelf) {
setBlockedDelete({
title: 'Нельзя удалить себя',
title: 'Нельзя уволить или удалить себя',
body:
'Свою учётную запись нельзя удалить из этой страницы. ' +
'Свою учётную запись нельзя изменить из этой страницы. ' +
'Покинуть организацию можно через Настройки → Аккаунт → Покинуть организацию.',
})
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 {
await remove.mutateAsync(form.id!)
list.refetch?.()
setForm(null); setActiveEmployee(null)
} catch (e) {
const err = e as { response?: { data?: { error?: string } }, message?: string }
const msg = err.response?.data?.error ?? err.message ?? 'Не удалось удалить сотрудника'
setBlockedDelete({ title: 'Не удалось удалить', body: msg })
const msg = err.response?.data?.error ?? err.message ?? 'Не удалось выполнить операцию'
setBlockedDelete({ title: 'Не удалось выполнить', body: msg })
}
}}>
<Trash2 className="w-4 h-4" /> Удалить
<Trash2 className="w-4 h-4" />
{activeEmployee.status === 'active' ? 'Уволить' : 'Удалить'}
</Button>
)}
{form?.id && activeEmployee?.status === 'deleted' && (
<span className="text-xs text-slate-500 italic">Сотрудник удалён изменения недоступны.</span>
)}
<Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button>
<Button onClick={save} disabled={!form?.lastName || !form.firstName || !form.roleId}>Сохранить</Button>
</>