From e8a28ba1f6f624aa7991abb3742afdb63ea353bb Mon Sep 17 00:00:00 2001 From: nns <278048682+nurdotnet@users.noreply.github.com> Date: Wed, 6 May 2026 11:26:38 +0500 Subject: [PATCH] =?UTF-8?q?feat(employees):=20=D0=B4=D0=B2=D1=83=D1=85?= =?UTF-8?q?=D1=81=D1=82=D1=83=D0=BF=D0=B5=D0=BD=D1=87=D0=B0=D1=82=D0=BE?= =?UTF-8?q?=D0=B5=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=E2=80=94=20=C2=AB=D1=83=D0=B2=D0=BE=D0=BB=D0=B8=D1=82=D1=8C?= =?UTF-8?q?=C2=BB=20=E2=86=92=20=C2=AB=D1=83=D0=B4=D0=B0=D0=BB=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Полное физическое удаление сотрудника невозможно — у него 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 — отображение «Иванов И.И. (удалён)» работает автоматически. --- .../Organizations/EmployeesController.cs | 49 +++++++++-- .../Organizations/Employee.cs | 10 ++- ...260506000000_Phase5a_EmployeeSoftDelete.cs | 46 ++++++++++ .../src/pages/EmployeesPage.tsx | 87 +++++++++++++------ 4 files changed, 157 insertions(+), 35 deletions(-) create mode 100644 src/food-market.infrastructure/Persistence/Migrations/20260506000000_Phase5a_EmployeeSoftDelete.cs diff --git a/src/food-market.api/Controllers/Organizations/EmployeesController.cs b/src/food-market.api/Controllers/Organizations/EmployeesController.cs index 299a8cf..0af6fe6 100644 --- a/src/food-market.api/Controllers/Organizations/EmployeesController.cs +++ b/src/food-market.api/Controllers/Organizations/EmployeesController.cs @@ -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 RetailPointIds, bool IsOwner, bool IsSelf); @@ -49,7 +52,9 @@ public record EmployeeCreateResult(EmployeeDto Employee, string? GeneratedPasswo [HttpGet] public async Task>> 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 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 Delete(Guid id, CancellationToken ct) } } - e.IsActive = false; - e.FiredAt ??= DateTime.UtcNow; + 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 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)) diff --git a/src/food-market.domain/Organizations/Employee.cs b/src/food-market.domain/Organizations/Employee.cs index 3fa8a79..8650b15 100644 --- a/src/food-market.domain/Organizations/Employee.cs +++ b/src/food-market.domain/Organizations/Employee.cs @@ -32,10 +32,18 @@ public class Employee : TenantEntity public EmployeeRole Role { get; set; } = null!; /// Активен ли сотрудник. False — заблокирован, не может логиниться. - /// Удаление физически не делаем (FK из документов), просто IsActive=false. + /// Двухступенчатое удаление: сначала «Уволить» (IsActive=false + FiredAt), + /// затем «Удалить» (IsDeleted=true + DeletedAt). Физически не удаляем + /// никогда — у сотрудника есть FK из документов (продаж, поставок). public bool IsActive { get; set; } = true; public DateTime? FiredAt { get; set; } + /// Soft-delete-флаг. Ставится только из состояния «уволен» (IsActive=false). + /// В UI и связанных документах отображается «Иванов И.И. (удалён)». + /// В списках сотрудников по умолчанию скрыты, доступны через фильтр. + public bool IsDeleted { get; set; } + public DateTime? DeletedAt { get; set; } + public ICollection RetailPointAssignments { get; set; } = new List(); } diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260506000000_Phase5a_EmployeeSoftDelete.cs b/src/food-market.infrastructure/Persistence/Migrations/20260506000000_Phase5a_EmployeeSoftDelete.cs new file mode 100644 index 0000000..f68b550 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260506000000_Phase5a_EmployeeSoftDelete.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Двухступенчатое удаление сотрудника: добавлены IsDeleted и + /// DeletedAt. Ранее было только IsActive=false (увольнение); теперь: + /// IsActive=true — активный + /// IsActive=false + FiredAt — уволен + /// IsActive=false + IsDeleted=true + DeletedAt — soft-deleted + /// Физически Employee никогда не удаляем (FK из retail_sales, supplies). + public partial class Phase5a_EmployeeSoftDelete : Migration + { + protected override void Up(MigrationBuilder b) + { + b.AddColumn( + name: "IsDeleted", + schema: "public", + table: "employees", + type: "boolean", + nullable: false, + defaultValue: false); + + b.AddColumn( + 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"); + } + } +} diff --git a/src/food-market.web/src/pages/EmployeesPage.tsx b/src/food-market.web/src/pages/EmployeesPage.tsx index 9381014..bdb217b 100644 --- a/src/food-market.web/src/pages/EmployeesPage.tsx +++ b/src/food-market.web/src/pages/EmployeesPage.tsx @@ -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(URL) const { update, remove } = useCatalogMutations(URL, URL) const [form, setForm] = useState
(null) // Сгенерированный пароль возвращается с сервера один раз — показываем @@ -152,6 +156,11 @@ export function EmployeesPage() { }) } + // Фильтр по статусу сотрудника. По умолчанию показываем только активных + // и уволенных; «удалённые» — отдельный режим (read-only список архива). + const [statusFilter, setStatusFilter] = useState<'default' | 'active' | 'fired' | 'deleted' | 'all'>('default') + const list = useCatalogList(URL, statusFilter === 'default' ? {} : { status: statusFilter }) + return ( <> - + + } - footer={data && data.total > 0 && ( - + footer={list.data && list.data.total > 0 && ( + )} > 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) => (
- {r.lastName} {r.firstName} {r.middleName ?? ''} + + {r.lastName} {r.firstName} {r.middleName ?? ''} + + {r.status === 'fired' && (уволен)} + {r.status === 'deleted' && (удалён)} {r.isOwner && ( Главный администратор @@ -207,9 +231,11 @@ export function EmployeesPage() { { header: 'Учётка', width: '110px', cell: (r) => r.userId ? есть : нет }, - { header: 'Статус', width: '110px', cell: (r) => r.isActive - ? Активен - : Уволен }, + { header: 'Статус', width: '110px', cell: (r) => { + if (r.status === 'deleted') return Удалён + if (r.status === 'fired') return Уволен + return Активен + }}, ]} /> @@ -221,20 +247,20 @@ export function EmployeesPage() { width="max-w-xl" footer={ <> - {form?.id && ( + {form?.id && activeEmployee && activeEmployee.status !== 'deleted' && ( )} + {form?.id && activeEmployee?.status === 'deleted' && ( + Сотрудник удалён — изменения недоступны. + )}