feat(employees): бейдж «Владелец» + блокировка удаления с объяснением
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 45s
CI / Web (React + Vite) (push) Successful in 39s
Docker API / Build + push API (push) Successful in 1m8s
Docker Web / Build + push Web (push) Successful in 32s
Docker API / Deploy API on stage (push) Successful in 18s
Docker Web / Deploy Web on stage (push) Successful in 12s
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 45s
CI / Web (React + Vite) (push) Successful in 39s
Docker API / Build + push API (push) Successful in 1m8s
Docker Web / Build + push Web (push) Successful in 32s
Docker API / Deploy API on stage (push) Successful in 18s
Docker Web / Deploy Web on stage (push) Successful in 12s
Жалоба юзера: «нажимаю удалить владельца магазина — диалог "удалить
сотрудника?" — нажимаю — ничего не происходит». Раньше кнопка «Удалить»
для Owner была доступна, на сервере отвечала 403 с понятным сообщением,
но фронт ошибку не ловил — модалка зависала.
— EmployeeDto теперь возвращает isOwner (Org.AccountOwnerUserId ==
Employee.UserId) и isSelf (UserId текущего залогиненного юзера).
List + Get обновлены: подгружают AccountOwnerUserId и текущий sub
из JWT, проставляют флаги в проекции.
— Таблица сотрудников: рядом с ФИО владельца — бейдж «Владелец»
(amber-100/800).
— Кнопка «Удалить» в модалке редактирования:
· disabled для Owner и для self с tooltip-объяснением;
· клик по disabled-кнопке через onClick-handler показывает спец-
модалку: «Нельзя удалить владельца магазина — сначала передайте
управление другому пользователю. Организация не может остаться
без владельца. Удалить или деактивировать саму организацию может
только Супер-администратор платформы.»;
· self-delete объясняется отдельным текстом (Настройки → Аккаунт →
Покинуть организацию);
· обычное удаление — confirm с именем сотрудника и пояснением что
это soft-delete (деактивация).
· 403/любая ошибка от сервера ловится в try/catch и показывается
в той же модалке «Не удалось удалить» — больше не «ничего не
происходит».
Smoke: API эмплоя возвращает isOwner=true,isSelf=false для Owner'а в
override-режиме SuperAdmin'а.
This commit is contained in:
parent
691448201d
commit
2691b7d78b
|
|
@ -32,7 +32,8 @@ 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,
|
||||||
IReadOnlyList<Guid> RetailPointIds);
|
IReadOnlyList<Guid> RetailPointIds,
|
||||||
|
bool IsOwner, bool IsSelf);
|
||||||
|
|
||||||
public record EmployeeInput(
|
public record EmployeeInput(
|
||||||
string LastName, string FirstName, string? MiddleName,
|
string LastName, string FirstName, string? MiddleName,
|
||||||
|
|
@ -50,6 +51,14 @@ public record EmployeeCreateResult(EmployeeDto Employee, string? GeneratedPasswo
|
||||||
public async Task<ActionResult<PagedResult<EmployeeDto>>> List(
|
public async Task<ActionResult<PagedResult<EmployeeDto>>> List(
|
||||||
[FromQuery] PagedRequest req, CancellationToken ct)
|
[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();
|
var q = _db.Employees.AsNoTracking().Include(e => e.Role).Include(e => e.RetailPointAssignments).AsQueryable();
|
||||||
if (!string.IsNullOrWhiteSpace(req.Search))
|
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.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.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);
|
.ToListAsync(ct);
|
||||||
return new PagedResult<EmployeeDto>
|
return new PagedResult<EmployeeDto>
|
||||||
{ Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
{ Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||||
|
|
@ -215,6 +226,13 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
|
|
||||||
private async Task<EmployeeDto?> ProjectAsync(Guid id, CancellationToken ct)
|
private async Task<EmployeeDto?> 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()
|
return await _db.Employees.AsNoTracking()
|
||||||
.Include(e => e.Role)
|
.Include(e => e.Role)
|
||||||
.Include(e => e.RetailPointAssignments)
|
.Include(e => e.RetailPointAssignments)
|
||||||
|
|
@ -225,7 +243,9 @@ 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.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);
|
.FirstOrDefaultAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,12 @@ interface EmployeeDto {
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
firedAt: string | null
|
firedAt: string | null
|
||||||
retailPointIds: string[]
|
retailPointIds: string[]
|
||||||
|
/** Владелец организации (Organization.AccountOwnerUserId == Employee.UserId).
|
||||||
|
* Удалить такого через DELETE /employees/{id} — нельзя; UI должен это
|
||||||
|
* показывать (бейдж + disabled-кнопка + объяснение). */
|
||||||
|
isOwner: boolean
|
||||||
|
/** Текущий вошедший пользователь — это сам этот Employee. Нельзя удалить. */
|
||||||
|
isSelf: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Form {
|
interface Form {
|
||||||
|
|
@ -69,6 +75,12 @@ export function EmployeesPage() {
|
||||||
// Сгенерированный пароль возвращается с сервера один раз — показываем
|
// Сгенерированный пароль возвращается с сервера один раз — показываем
|
||||||
// в отдельной модалке, чтобы админ передал сотруднику.
|
// в отдельной модалке, чтобы админ передал сотруднику.
|
||||||
const [createdAccount, setCreatedAccount] = useState<{ email: string; password: string } | null>(null)
|
const [createdAccount, setCreatedAccount] = useState<{ email: string; password: string } | null>(null)
|
||||||
|
// Текущий открытый сотрудник — нужен чтобы знать isOwner/isSelf для футера
|
||||||
|
// модалки (form содержит только редактируемые поля и не знает про маркеры).
|
||||||
|
const [activeEmployee, setActiveEmployee] = useState<EmployeeDto | null>(null)
|
||||||
|
// Объяснение почему нельзя удалить — показывается вместо confirm() когда
|
||||||
|
// пытаешься удалить владельца или себя. Текст с инструкцией.
|
||||||
|
const [blockedDelete, setBlockedDelete] = useState<{ title: string; body: string } | null>(null)
|
||||||
|
|
||||||
const roles = useQuery({
|
const roles = useQuery({
|
||||||
queryKey: ['employee-roles-lookup'],
|
queryKey: ['employee-roles-lookup'],
|
||||||
|
|
@ -152,18 +164,28 @@ export function EmployeesPage() {
|
||||||
sortKey={sortKey}
|
sortKey={sortKey}
|
||||||
sortOrder={sortOrder}
|
sortOrder={sortOrder}
|
||||||
onSortChange={setSort}
|
onSortChange={setSort}
|
||||||
onRowClick={(r) => setForm({
|
onRowClick={(r) => {
|
||||||
id: r.id, lastName: r.lastName, firstName: r.firstName, middleName: r.middleName ?? '',
|
setActiveEmployee(r)
|
||||||
position: r.position ?? '', email: r.email ?? '', phone: r.phone ?? '',
|
setForm({
|
||||||
salary: r.salary != null ? String(r.salary) : '',
|
id: r.id, lastName: r.lastName, firstName: r.firstName, middleName: r.middleName ?? '',
|
||||||
taxNumber: r.taxNumber ?? '', description: r.description ?? '', imageUrl: r.imageUrl ?? '',
|
position: r.position ?? '', email: r.email ?? '', phone: r.phone ?? '',
|
||||||
roleId: r.roleId, isActive: r.isActive, retailPointIds: r.retailPointIds,
|
salary: r.salary != null ? String(r.salary) : '',
|
||||||
createAccount: false,
|
taxNumber: r.taxNumber ?? '', description: r.description ?? '', imageUrl: r.imageUrl ?? '',
|
||||||
})}
|
roleId: r.roleId, isActive: r.isActive, retailPointIds: r.retailPointIds,
|
||||||
|
createAccount: false,
|
||||||
|
})
|
||||||
|
}}
|
||||||
columns={[
|
columns={[
|
||||||
{ header: 'ФИО', cell: (r) => (
|
{ header: 'ФИО', cell: (r) => (
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">{r.lastName} {r.firstName} {r.middleName ?? ''}</div>
|
<div className="font-medium flex items-center gap-2">
|
||||||
|
<span>{r.lastName} {r.firstName} {r.middleName ?? ''}</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">
|
||||||
|
Владелец
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{r.position && <div className="text-xs text-slate-400">{r.position}</div>}
|
{r.position && <div className="text-xs text-slate-400">{r.position}</div>}
|
||||||
</div>
|
</div>
|
||||||
)},
|
)},
|
||||||
|
|
@ -182,18 +204,53 @@ export function EmployeesPage() {
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={!!form}
|
open={!!form}
|
||||||
onClose={() => setForm(null)}
|
onClose={() => { setForm(null); setActiveEmployee(null) }}
|
||||||
title={form?.id ? 'Редактировать сотрудника' : 'Новый сотрудник'}
|
title={form?.id ? 'Редактировать сотрудника' : 'Новый сотрудник'}
|
||||||
width="max-w-xl"
|
width="max-w-xl"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
{form?.id && (
|
{form?.id && (
|
||||||
<Button variant="danger" size="sm" onClick={async () => {
|
<Button
|
||||||
if (confirm('Удалить сотрудника?')) {
|
variant="danger"
|
||||||
await remove.mutateAsync(form.id!)
|
size="sm"
|
||||||
setForm(null)
|
disabled={activeEmployee?.isOwner || activeEmployee?.isSelf}
|
||||||
|
title={
|
||||||
|
activeEmployee?.isOwner
|
||||||
|
? 'Владельца удалить нельзя — нужно передать управление другому пользователю'
|
||||||
|
: activeEmployee?.isSelf
|
||||||
|
? 'Нельзя удалить себя'
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
}}>
|
onClick={async () => {
|
||||||
|
if (activeEmployee?.isOwner) {
|
||||||
|
setBlockedDelete({
|
||||||
|
title: 'Нельзя удалить владельца магазина',
|
||||||
|
body:
|
||||||
|
'Чтобы удалить администратора магазина, сначала передайте управление другому пользователю. ' +
|
||||||
|
'Организация не может остаться без владельца.\n\n' +
|
||||||
|
'Удалить или деактивировать саму организацию может только Супер-администратор платформы.',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (activeEmployee?.isSelf) {
|
||||||
|
setBlockedDelete({
|
||||||
|
title: 'Нельзя удалить себя',
|
||||||
|
body:
|
||||||
|
'Свою учётную запись нельзя удалить из этой страницы. ' +
|
||||||
|
'Покинуть организацию можно через Настройки → Аккаунт → Покинуть организацию.',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!confirm(`Удалить сотрудника «${activeEmployee?.lastName ?? ''} ${activeEmployee?.firstName ?? ''}»?\n\nСотрудник будет деактивирован, его учётная запись потеряет доступ к организации. Историю операций сохраняем.`)) return
|
||||||
|
try {
|
||||||
|
await remove.mutateAsync(form.id!)
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
}}>
|
||||||
<Trash2 className="w-4 h-4" /> Удалить
|
<Trash2 className="w-4 h-4" /> Удалить
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -333,6 +390,20 @@ export function EmployeesPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={!!blockedDelete}
|
||||||
|
onClose={() => setBlockedDelete(null)}
|
||||||
|
title={blockedDelete?.title ?? ''}
|
||||||
|
width="max-w-md"
|
||||||
|
footer={
|
||||||
|
<Button onClick={() => setBlockedDelete(null)}>Понятно</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-slate-700 dark:text-slate-200 whitespace-pre-line">
|
||||||
|
{blockedDelete?.body}
|
||||||
|
</p>
|
||||||
|
</Modal>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue