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,
|
||||
Guid RoleId, string RoleName,
|
||||
bool IsActive, DateTime? FiredAt,
|
||||
IReadOnlyList<Guid> RetailPointIds);
|
||||
IReadOnlyList<Guid> 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<ActionResult<PagedResult<EmployeeDto>>> 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<EmployeeDto>
|
||||
{ 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)
|
||||
{
|
||||
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<IActionResult> 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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<EmployeeDto | null>(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({
|
||||
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) => (
|
||||
<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>}
|
||||
</div>
|
||||
)},
|
||||
|
|
@ -182,16 +204,51 @@ export function EmployeesPage() {
|
|||
|
||||
<Modal
|
||||
open={!!form}
|
||||
onClose={() => setForm(null)}
|
||||
onClose={() => { setForm(null); setActiveEmployee(null) }}
|
||||
title={form?.id ? 'Редактировать сотрудника' : 'Новый сотрудник'}
|
||||
width="max-w-xl"
|
||||
footer={
|
||||
<>
|
||||
{form?.id && (
|
||||
<Button variant="danger" size="sm" onClick={async () => {
|
||||
if (confirm('Удалить сотрудника?')) {
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
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)
|
||||
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" /> Удалить
|
||||
|
|
@ -333,6 +390,20 @@ export function EmployeesPage() {
|
|||
</div>
|
||||
)}
|
||||
</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