All checks were successful
CI Trade-In / changes (pull_request) Successful in 14s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 14s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m30s
EmployeeTable: убран ранний return при пустом списке — на offset>0 (ровно 50/100/150 сотрудников) пейджер и кнопка «Назад» теперь остаются доступны; текст различает «сотрудников вообще нет» (offset=0) и «страница за концом списка» (offset>0). team/page.tsx: useEmployees получает enabled=isAllowedRole, вычисленный ДО вызова хука — прямой заход employee/analyst/pilot на /team больше не шлёт обречённый GET до отрисовки role-gate.
205 lines
7.4 KiB
TypeScript
205 lines
7.4 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* Таблица сотрудников для team-дашборда менеджера (#2556, эпик #2549).
|
||
*
|
||
* Блокировка/разблокировка — прямое действие в строке (один PATCH-запрос
|
||
* с `is_active`), с `window.confirm` перед деструктивным изменением (спек
|
||
* DoD: «действия деструктивные — с подтверждением»). Квота + сброс пароля
|
||
* вынесены в отдельный модал (`QuotaEditor`) — они бьют в тот же PATCH
|
||
* эндпоинт одним запросом, см. `app/api/v1/team.py::update_employee`.
|
||
*/
|
||
|
||
import { Fragment, useState } from "react";
|
||
|
||
import type { Employee } from "@/lib/team-api";
|
||
import { teamErrorMessage, useUpdateEmployee } from "@/lib/team-api";
|
||
|
||
function formatDate(iso: string): string {
|
||
try {
|
||
return new Date(iso).toLocaleDateString("ru-RU", {
|
||
day: "2-digit",
|
||
month: "2-digit",
|
||
year: "numeric",
|
||
});
|
||
} catch {
|
||
return iso;
|
||
}
|
||
}
|
||
|
||
function QuotaCell({ quota }: { quota: Employee["quota"] }) {
|
||
if (quota.unlimited) {
|
||
return <span className="team-quota-unlimited">без лимита</span>;
|
||
}
|
||
const low = quota.remaining <= 0;
|
||
return (
|
||
<span className={low ? "team-quota-low" : undefined}>
|
||
{quota.used}/{quota.limit}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
interface EmployeeTableProps {
|
||
employees: Employee[];
|
||
onEditQuota: (employee: Employee) => void;
|
||
onViewHistory: (employee: Employee) => void;
|
||
limit: number;
|
||
offset: number;
|
||
onOffsetChange: (offset: number) => void;
|
||
hasNextPage: boolean;
|
||
}
|
||
|
||
export function EmployeeTable({
|
||
employees,
|
||
onEditQuota,
|
||
onViewHistory,
|
||
limit,
|
||
offset,
|
||
onOffsetChange,
|
||
hasNextPage,
|
||
}: EmployeeTableProps) {
|
||
const updateMutation = useUpdateEmployee();
|
||
const [pendingId, setPendingId] = useState<number | null>(null);
|
||
const [rowError, setRowError] = useState<{ id: number; message: string } | null>(null);
|
||
|
||
function handleToggleActive(employee: Employee) {
|
||
const nextActive = !employee.is_active;
|
||
const confirmed = window.confirm(
|
||
nextActive
|
||
? `Разблокировать сотрудника «${employee.username}»?`
|
||
: `Заблокировать сотрудника «${employee.username}»? Все его текущие сессии будут завершены.`,
|
||
);
|
||
if (!confirmed) return;
|
||
|
||
setRowError(null);
|
||
setPendingId(employee.id);
|
||
updateMutation.mutate(
|
||
{ employeeId: employee.id, patch: { is_active: nextActive } },
|
||
{
|
||
onSettled: () => setPendingId(null),
|
||
onError: (error) => setRowError({ id: employee.id, message: teamErrorMessage(error) }),
|
||
},
|
||
);
|
||
}
|
||
|
||
const isEmpty = employees.length === 0;
|
||
// Пустая ПЕРВАЯ страница = сотрудников вообще нет (предлагаем создать).
|
||
// Пустая страница при offset>0 = перешли за границу списка (напр. ровно
|
||
// 50/100/150 сотрудников и клик «Далее» после последней полной страницы)
|
||
// — это НЕ «сотрудников нет», нужен путь назад, а не тупик без пейджера.
|
||
const isEmptyPastEnd = isEmpty && offset > 0;
|
||
|
||
return (
|
||
<>
|
||
{isEmpty ? (
|
||
<p className="scraper-hint">
|
||
{isEmptyPastEnd
|
||
? "На этой странице пусто — вы перешли за конец списка."
|
||
: "Сотрудников пока нет. Добавьте первого через «+ Добавить сотрудника»."}
|
||
</p>
|
||
) : (
|
||
<table className="runs-table team-table">
|
||
<thead>
|
||
<tr>
|
||
<th scope="col">Логин</th>
|
||
<th scope="col">Имя</th>
|
||
<th scope="col">Статус</th>
|
||
<th scope="col">Квота</th>
|
||
<th scope="col">Создан</th>
|
||
<th scope="col">Действия</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{employees.map((employee) => (
|
||
<Fragment key={employee.id}>
|
||
<tr>
|
||
<td>{employee.username}</td>
|
||
<td className="run-muted">
|
||
{employee.display_name ?? "—"}
|
||
{employee.org_name ? ` · ${employee.org_name}` : ""}
|
||
</td>
|
||
<td>
|
||
<span
|
||
className={
|
||
employee.is_active
|
||
? "team-status-badge team-status-badge--active"
|
||
: "team-status-badge team-status-badge--blocked"
|
||
}
|
||
>
|
||
{employee.is_active ? "Активен" : "Заблокирован"}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<QuotaCell quota={employee.quota} />
|
||
</td>
|
||
<td className="run-muted">{formatDate(employee.created_at)}</td>
|
||
<td>
|
||
<div className="team-actions">
|
||
<button
|
||
type="button"
|
||
className="team-action-btn"
|
||
onClick={() => onViewHistory(employee)}
|
||
>
|
||
История
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="team-action-btn"
|
||
onClick={() => onEditQuota(employee)}
|
||
>
|
||
Изменить
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={
|
||
employee.is_active
|
||
? "team-action-btn team-action-btn--danger"
|
||
: "team-action-btn"
|
||
}
|
||
disabled={pendingId === employee.id}
|
||
onClick={() => handleToggleActive(employee)}
|
||
>
|
||
{employee.is_active ? "Заблокировать" : "Разблокировать"}
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
{rowError?.id === employee.id ? (
|
||
<tr>
|
||
<td colSpan={6} style={{ padding: "0 10px 8px" }}>
|
||
<p className="team-form-error" style={{ margin: 0 }}>
|
||
{rowError.message}
|
||
</p>
|
||
</td>
|
||
</tr>
|
||
) : null}
|
||
</Fragment>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
|
||
<div className="team-pager">
|
||
<button
|
||
type="button"
|
||
disabled={offset === 0}
|
||
onClick={() => onOffsetChange(Math.max(0, offset - limit))}
|
||
>
|
||
← Назад
|
||
</button>
|
||
{!isEmpty ? (
|
||
<span>
|
||
{offset + 1}–{offset + employees.length}
|
||
</span>
|
||
) : null}
|
||
<button
|
||
type="button"
|
||
disabled={isEmpty || !hasNextPage}
|
||
onClick={() => onOffsetChange(offset + limit)}
|
||
>
|
||
Далее →
|
||
</button>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|