feat(tradein/ui): team-дашборд менеджера — сотрудники, квоты, история (#2556) #2565

Merged
lekss361 merged 2 commits from feat/tradein-team-dashboard into main 2026-07-30 19:27:32 +00:00
3 changed files with 112 additions and 85 deletions
Showing only changes of commit 1ed0338b95 - Show all commits

View file

@ -29,8 +29,15 @@ const PAGE_LIMIT = 50;
export default function TeamPage() {
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 employeesQ = useEmployees(PAGE_LIMIT, offset);
const employeesQ = useEmployees(PAGE_LIMIT, offset, isAllowedRole);
const [showCreateForm, setShowCreateForm] = useState(false);
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) {
return <NoAccessScreen variant="user" />;
}

View file

@ -82,91 +82,102 @@ export function EmployeeTable({
);
}
if (employees.length === 0) {
return <p className="scraper-hint">Сотрудников пока нет.</p>;
}
const isEmpty = employees.length === 0;
// Пустая ПЕРВАЯ страница = сотрудников вообще нет (предлагаем создать).
// Пустая страница при offset>0 = перешли за границу списка (напр. ровно
// 50/100/150 сотрудников и клик «Далее» после последней полной страницы)
// — это НЕ «сотрудников нет», нужен путь назад, а не тупик без пейджера.
const isEmptyPastEnd = isEmpty && offset > 0;
return (
<>
<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"
{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-action-btn team-action-btn--danger"
: "team-action-btn"
? "team-status-badge team-status-badge--active"
: "team-status-badge team-status-badge--blocked"
}
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>
{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>
) : null}
</Fragment>
))}
</tbody>
</table>
{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
@ -176,10 +187,16 @@ export function EmployeeTable({
>
Назад
</button>
<span>
{offset + 1}{offset + employees.length}
</span>
<button type="button" disabled={!hasNextPage} onClick={() => onOffsetChange(offset + limit)}>
{!isEmpty ? (
<span>
{offset + 1}{offset + employees.length}
</span>
) : null}
<button
type="button"
disabled={isEmpty || !hasNextPage}
onClick={() => onOffsetChange(offset + limit)}
>
Далее
</button>
</div>

View file

@ -81,12 +81,18 @@ const EMPLOYEES_LIST_KEY = ["team", "employees"] as const;
/**
* GET /api/v1/team/employees?limit=&offset=
* 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[]>({
queryKey: [...EMPLOYEES_LIST_KEY, limit, offset],
queryFn: () =>
apiFetch<Employee[]>(`${BASE}/employees?limit=${limit}&offset=${offset}`),
enabled,
staleTime: 15_000,
retry: false,
});