diff --git a/src/food-market.api/Controllers/Organizations/EmployeesController.cs b/src/food-market.api/Controllers/Organizations/EmployeesController.cs index e7e4969..80a7839 100644 --- a/src/food-market.api/Controllers/Organizations/EmployeesController.cs +++ b/src/food-market.api/Controllers/Organizations/EmployeesController.cs @@ -32,7 +32,8 @@ public record EmployeeDto( decimal? Salary, string? TaxNumber, string? Description, string? ImageUrl, Guid RoleId, string RoleName, bool IsActive, DateTime? FiredAt, - IReadOnlyList RetailPointIds); + IReadOnlyList RetailPointIds, + bool IsOwner, bool IsSelf); public record EmployeeInput( string LastName, string FirstName, string? MiddleName, @@ -50,6 +51,14 @@ public record EmployeeCreateResult(EmployeeDto Employee, string? GeneratedPasswo public async Task>> List( [FromQuery] PagedRequest req, CancellationToken ct) { + var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant."); + var ownerUserId = await _db.Organizations.IgnoreQueryFilters() + .Where(o => o.Id == orgId) + .Select(o => o.AccountOwnerUserId) + .FirstOrDefaultAsync(ct); + var currentUserId = ParseUserId(User.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? User.FindFirst("sub")?.Value); + var q = _db.Employees.AsNoTracking().Include(e => e.Role).Include(e => e.RetailPointAssignments).AsQueryable(); if (!string.IsNullOrWhiteSpace(req.Search)) { @@ -70,7 +79,9 @@ 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.RetailPointAssignments.Select(a => a.RetailPointId).ToList())) + e.RetailPointAssignments.Select(a => a.RetailPointId).ToList(), + e.UserId != null && ownerUserId == e.UserId, + e.UserId != null && currentUserId != null && e.UserId == currentUserId)) .ToListAsync(ct); return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; @@ -215,6 +226,13 @@ public async Task Delete(Guid id, CancellationToken ct) private async Task ProjectAsync(Guid id, CancellationToken ct) { + var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant."); + var ownerUserId = await _db.Organizations.IgnoreQueryFilters() + .Where(o => o.Id == orgId) + .Select(o => o.AccountOwnerUserId) + .FirstOrDefaultAsync(ct); + var currentUserId = ParseUserId(User.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? User.FindFirst("sub")?.Value); return await _db.Employees.AsNoTracking() .Include(e => e.Role) .Include(e => e.RetailPointAssignments) @@ -225,7 +243,9 @@ 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.RetailPointAssignments.Select(a => a.RetailPointId).ToList())) + e.RetailPointAssignments.Select(a => a.RetailPointId).ToList(), + e.UserId != null && ownerUserId == e.UserId, + e.UserId != null && currentUserId != null && e.UserId == currentUserId)) .FirstOrDefaultAsync(ct); } diff --git a/src/food-market.web/src/pages/EmployeesPage.tsx b/src/food-market.web/src/pages/EmployeesPage.tsx index d6040ee..00e393f 100644 --- a/src/food-market.web/src/pages/EmployeesPage.tsx +++ b/src/food-market.web/src/pages/EmployeesPage.tsx @@ -33,6 +33,12 @@ interface EmployeeDto { isActive: boolean firedAt: string | null retailPointIds: string[] + /** Владелец организации (Organization.AccountOwnerUserId == Employee.UserId). + * Удалить такого через DELETE /employees/{id} — нельзя; UI должен это + * показывать (бейдж + disabled-кнопка + объяснение). */ + isOwner: boolean + /** Текущий вошедший пользователь — это сам этот Employee. Нельзя удалить. */ + isSelf: boolean } interface Form { @@ -69,6 +75,12 @@ export function EmployeesPage() { // Сгенерированный пароль возвращается с сервера один раз — показываем // в отдельной модалке, чтобы админ передал сотруднику. const [createdAccount, setCreatedAccount] = useState<{ email: string; password: string } | null>(null) + // Текущий открытый сотрудник — нужен чтобы знать isOwner/isSelf для футера + // модалки (form содержит только редактируемые поля и не знает про маркеры). + const [activeEmployee, setActiveEmployee] = useState(null) + // Объяснение почему нельзя удалить — показывается вместо confirm() когда + // пытаешься удалить владельца или себя. Текст с инструкцией. + const [blockedDelete, setBlockedDelete] = useState<{ title: string; body: string } | null>(null) const roles = useQuery({ queryKey: ['employee-roles-lookup'], @@ -152,18 +164,28 @@ export function EmployeesPage() { sortKey={sortKey} sortOrder={sortOrder} onSortChange={setSort} - onRowClick={(r) => setForm({ - id: r.id, lastName: r.lastName, firstName: r.firstName, middleName: r.middleName ?? '', - position: r.position ?? '', email: r.email ?? '', phone: r.phone ?? '', - salary: r.salary != null ? String(r.salary) : '', - taxNumber: r.taxNumber ?? '', description: r.description ?? '', imageUrl: r.imageUrl ?? '', - roleId: r.roleId, isActive: r.isActive, retailPointIds: r.retailPointIds, - createAccount: false, - })} + onRowClick={(r) => { + setActiveEmployee(r) + setForm({ + id: r.id, lastName: r.lastName, firstName: r.firstName, middleName: r.middleName ?? '', + position: r.position ?? '', email: r.email ?? '', phone: r.phone ?? '', + salary: r.salary != null ? String(r.salary) : '', + taxNumber: r.taxNumber ?? '', description: r.description ?? '', imageUrl: r.imageUrl ?? '', + roleId: r.roleId, isActive: r.isActive, retailPointIds: r.retailPointIds, + createAccount: false, + }) + }} columns={[ { header: 'ФИО', cell: (r) => (
-
{r.lastName} {r.firstName} {r.middleName ?? ''}
+
+ {r.lastName} {r.firstName} {r.middleName ?? ''} + {r.isOwner && ( + + Владелец + + )} +
{r.position &&
{r.position}
}
)}, @@ -182,18 +204,53 @@ export function EmployeesPage() { setForm(null)} + onClose={() => { setForm(null); setActiveEmployee(null) }} title={form?.id ? 'Редактировать сотрудника' : 'Новый сотрудник'} width="max-w-xl" footer={ <> {form?.id && ( - )} @@ -333,6 +390,20 @@ export function EmployeesPage() { )} + + setBlockedDelete(null)} + title={blockedDelete?.title ?? ''} + width="max-w-md" + footer={ + + } + > +

+ {blockedDelete?.body} +

+
) }