fix(tradein/ui): пагинация на границе страницы + запрос списка только для admin/manager (#2556)
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
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.
This commit is contained in:
parent
f112a4affb
commit
1ed0338b95
3 changed files with 112 additions and 85 deletions
|
|
@ -29,8 +29,15 @@ const PAGE_LIMIT = 50;
|
||||||
|
|
||||||
export default function TeamPage() {
|
export default function TeamPage() {
|
||||||
const meQ = useMe();
|
const meQ = useMe();
|
||||||
|
// Вычисляем ДО useEmployees (не после) — иначе для employee/analyst/pilot
|
||||||
|
// при прямом заходе на /team улетает обречённый GET (401/403) ещё до того,
|
||||||
|
// как ниже отрисуется role-gate. Порядок хуков не меняется — это просто
|
||||||
|
// производное значение, не условный вызов хука.
|
||||||
|
const role = meQ.data?.role;
|
||||||
|
const isAllowedRole = role === "admin" || role === "manager";
|
||||||
|
|
||||||
const [offset, setOffset] = useState(0);
|
const [offset, setOffset] = useState(0);
|
||||||
const employeesQ = useEmployees(PAGE_LIMIT, offset);
|
const employeesQ = useEmployees(PAGE_LIMIT, offset, isAllowedRole);
|
||||||
|
|
||||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
const [quotaEmployee, setQuotaEmployee] = useState<Employee | null>(null);
|
const [quotaEmployee, setQuotaEmployee] = useState<Employee | null>(null);
|
||||||
|
|
@ -49,9 +56,6 @@ export default function TeamPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const role = meQ.data?.role;
|
|
||||||
const isAllowedRole = role === "admin" || role === "manager";
|
|
||||||
|
|
||||||
if (!isAllowedRole) {
|
if (!isAllowedRole) {
|
||||||
return <NoAccessScreen variant="user" />;
|
return <NoAccessScreen variant="user" />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,91 +82,102 @@ export function EmployeeTable({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (employees.length === 0) {
|
const isEmpty = employees.length === 0;
|
||||||
return <p className="scraper-hint">Сотрудников пока нет.</p>;
|
// Пустая ПЕРВАЯ страница = сотрудников вообще нет (предлагаем создать).
|
||||||
}
|
// Пустая страница при offset>0 = перешли за границу списка (напр. ровно
|
||||||
|
// 50/100/150 сотрудников и клик «Далее» после последней полной страницы)
|
||||||
|
// — это НЕ «сотрудников нет», нужен путь назад, а не тупик без пейджера.
|
||||||
|
const isEmptyPastEnd = isEmpty && offset > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<table className="runs-table team-table">
|
{isEmpty ? (
|
||||||
<thead>
|
<p className="scraper-hint">
|
||||||
<tr>
|
{isEmptyPastEnd
|
||||||
<th scope="col">Логин</th>
|
? "На этой странице пусто — вы перешли за конец списка."
|
||||||
<th scope="col">Имя</th>
|
: "Сотрудников пока нет. Добавьте первого через «+ Добавить сотрудника»."}
|
||||||
<th scope="col">Статус</th>
|
</p>
|
||||||
<th scope="col">Квота</th>
|
) : (
|
||||||
<th scope="col">Создан</th>
|
<table className="runs-table team-table">
|
||||||
<th scope="col">Действия</th>
|
<thead>
|
||||||
</tr>
|
<tr>
|
||||||
</thead>
|
<th scope="col">Логин</th>
|
||||||
<tbody>
|
<th scope="col">Имя</th>
|
||||||
{employees.map((employee) => (
|
<th scope="col">Статус</th>
|
||||||
<Fragment key={employee.id}>
|
<th scope="col">Квота</th>
|
||||||
<tr>
|
<th scope="col">Создан</th>
|
||||||
<td>{employee.username}</td>
|
<th scope="col">Действия</th>
|
||||||
<td className="run-muted">
|
</tr>
|
||||||
{employee.display_name ?? "—"}
|
</thead>
|
||||||
{employee.org_name ? ` · ${employee.org_name}` : ""}
|
<tbody>
|
||||||
</td>
|
{employees.map((employee) => (
|
||||||
<td>
|
<Fragment key={employee.id}>
|
||||||
<span
|
<tr>
|
||||||
className={
|
<td>{employee.username}</td>
|
||||||
employee.is_active
|
<td className="run-muted">
|
||||||
? "team-status-badge team-status-badge--active"
|
{employee.display_name ?? "—"}
|
||||||
: "team-status-badge team-status-badge--blocked"
|
{employee.org_name ? ` · ${employee.org_name}` : ""}
|
||||||
}
|
</td>
|
||||||
>
|
<td>
|
||||||
{employee.is_active ? "Активен" : "Заблокирован"}
|
<span
|
||||||
</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={
|
className={
|
||||||
employee.is_active
|
employee.is_active
|
||||||
? "team-action-btn team-action-btn--danger"
|
? "team-status-badge team-status-badge--active"
|
||||||
: "team-action-btn"
|
: "team-status-badge team-status-badge--blocked"
|
||||||
}
|
}
|
||||||
disabled={pendingId === employee.id}
|
|
||||||
onClick={() => handleToggleActive(employee)}
|
|
||||||
>
|
>
|
||||||
{employee.is_active ? "Заблокировать" : "Разблокировать"}
|
{employee.is_active ? "Активен" : "Заблокирован"}
|
||||||
</button>
|
</span>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
<td>
|
||||||
</tr>
|
<QuotaCell quota={employee.quota} />
|
||||||
{rowError?.id === employee.id ? (
|
</td>
|
||||||
<tr>
|
<td className="run-muted">{formatDate(employee.created_at)}</td>
|
||||||
<td colSpan={6} style={{ padding: "0 10px 8px" }}>
|
<td>
|
||||||
<p className="team-form-error" style={{ margin: 0 }}>
|
<div className="team-actions">
|
||||||
{rowError.message}
|
<button
|
||||||
</p>
|
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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : null}
|
{rowError?.id === employee.id ? (
|
||||||
</Fragment>
|
<tr>
|
||||||
))}
|
<td colSpan={6} style={{ padding: "0 10px 8px" }}>
|
||||||
</tbody>
|
<p className="team-form-error" style={{ margin: 0 }}>
|
||||||
</table>
|
{rowError.message}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="team-pager">
|
<div className="team-pager">
|
||||||
<button
|
<button
|
||||||
|
|
@ -176,10 +187,16 @@ export function EmployeeTable({
|
||||||
>
|
>
|
||||||
← Назад
|
← Назад
|
||||||
</button>
|
</button>
|
||||||
<span>
|
{!isEmpty ? (
|
||||||
{offset + 1}–{offset + employees.length}
|
<span>
|
||||||
</span>
|
{offset + 1}–{offset + employees.length}
|
||||||
<button type="button" disabled={!hasNextPage} onClick={() => onOffsetChange(offset + limit)}>
|
</span>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isEmpty || !hasNextPage}
|
||||||
|
onClick={() => onOffsetChange(offset + limit)}
|
||||||
|
>
|
||||||
Далее →
|
Далее →
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -81,12 +81,18 @@ const EMPLOYEES_LIST_KEY = ["team", "employees"] as const;
|
||||||
/**
|
/**
|
||||||
* GET /api/v1/team/employees?limit=&offset=
|
* GET /api/v1/team/employees?limit=&offset=
|
||||||
* Backend сам скоупит по роли (manager → только свои, admin → все).
|
* Backend сам скоупит по роли (manager → только свои, admin → все).
|
||||||
|
*
|
||||||
|
* `enabled` (default true) — вызывающая страница обязана передать `false`
|
||||||
|
* для ролей, которым эндпоинт всё равно ответит 401/403 (employee/analyst/
|
||||||
|
* pilot и т.п.): без этого прямой заход на /team шлёт обречённый round-trip
|
||||||
|
* ДО отрисовки role-gate, который лишний и палит наличие эндпоинта.
|
||||||
*/
|
*/
|
||||||
export function useEmployees(limit: number, offset: number) {
|
export function useEmployees(limit: number, offset: number, enabled = true) {
|
||||||
return useQuery<Employee[]>({
|
return useQuery<Employee[]>({
|
||||||
queryKey: [...EMPLOYEES_LIST_KEY, limit, offset],
|
queryKey: [...EMPLOYEES_LIST_KEY, limit, offset],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
apiFetch<Employee[]>(`${BASE}/employees?limit=${limit}&offset=${offset}`),
|
apiFetch<Employee[]>(`${BASE}/employees?limit=${limit}&offset=${offset}`),
|
||||||
|
enabled,
|
||||||
staleTime: 15_000,
|
staleTime: 15_000,
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue