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:
parent
0e4b7868c9
commit
e8a28ba1f6
|
|
@ -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)
|
|||
}
|
||||
}
|
||||
|
||||
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<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))
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
Loading…
Reference in a new issue