feat(tradein/ui): team-дашборд менеджера — сотрудники, квоты, история (#2556)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
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 1m9s

Единая страница /team для ролей admin/manager (backend сам скоупит список
по org-изоляции) — таблица сотрудников с пагинацией, создание сотрудника
с ручным паролем, изменение месячной квоты + сброс пароля одним PATCH,
drawer с историей оценок. Nav-пункт «Команда» в Topbar виден только
admin/manager (доп. roleGate поверх isPathAllowed — legacy analyst-роль
иначе тоже прошла бы path-фильтр).
This commit is contained in:
bot-backend 2026-07-30 22:02:41 +03:00
parent 4128564341
commit f112a4affb
8 changed files with 1300 additions and 3 deletions

View file

@ -0,0 +1,123 @@
"use client";
/**
* Team-дашборд менеджера (#2556, эпик #2549) единая страница для ролей
* `admin` и `manager` (backend сам скоупит список по org-изоляции, см.
* `app/api/v1/team.py::list_employees`), UI не дублируется под роли.
*
* Доступ: `useMe().role` проверяется на клиенте ДО первого запроса (не ждём
* доомed 403 round-trip), но HTTPError 403 от самого списка сотрудников тоже
* ловится тот же defense-in-depth паттерн, что и `app/admin/audit/page.tsx`
* (прямой заход на URL не крашит страницу, даже если nav её уже прячет).
*/
import { useState } from "react";
import "@/components/trade-in/trade-in.css";
import { NoAccessScreen } from "@/components/auth/NoAccessScreen";
import { CreateEmployeeForm } from "@/components/team/CreateEmployeeForm";
import { EmployeeHistoryDrawer } from "@/components/team/EmployeeHistoryDrawer";
import { EmployeeTable } from "@/components/team/EmployeeTable";
import { QuotaEditor } from "@/components/team/QuotaEditor";
import { Topbar } from "@/components/trade-in/Topbar";
import { HTTPError } from "@/lib/api";
import type { Employee } from "@/lib/team-api";
import { teamErrorMessage, useEmployees } from "@/lib/team-api";
import { useMe } from "@/lib/useMe";
const PAGE_LIMIT = 50;
export default function TeamPage() {
const meQ = useMe();
const [offset, setOffset] = useState(0);
const employeesQ = useEmployees(PAGE_LIMIT, offset);
const [showCreateForm, setShowCreateForm] = useState(false);
const [quotaEmployee, setQuotaEmployee] = useState<Employee | null>(null);
const [historyEmployee, setHistoryEmployee] = useState<Employee | null>(null);
// Роль ещё не загружена — не решаем ничего, RouteGuard уже отрисовал
// страницу (401/403 верхнего уровня он ловит сам), здесь только role-gate.
if (meQ.isPending) {
return (
<>
<Topbar active="team" />
<main className="page scraper-page" style={{ maxWidth: 1180 }}>
<p className="scraper-hint">Загрузка</p>
</main>
</>
);
}
const role = meQ.data?.role;
const isAllowedRole = role === "admin" || role === "manager";
if (!isAllowedRole) {
return <NoAccessScreen variant="user" />;
}
// Backend 403 регардлесс роли фронта (напр. session истекла между /me и
// /employees) — тот же fallback, что и в admin/audit.
if (employeesQ.error instanceof HTTPError && employeesQ.error.status === 403) {
return <NoAccessScreen variant="user" />;
}
const employees = employeesQ.data ?? [];
const hasNextPage = employees.length === PAGE_LIMIT;
return (
<>
<Topbar active="team" />
<main className="page scraper-page" style={{ maxWidth: 1180 }}>
<h1 className="scraper-h1">Команда</h1>
<p className="scraper-subtitle">
Сотрудники, их доступ и месячные квоты на оценку. История оценок по клику
«История».
</p>
<section className="scraper-section">
<div className="team-toolbar">
<h2 style={{ margin: 0 }}>Сотрудники</h2>
<button
type="button"
className="team-btn-primary"
onClick={() => setShowCreateForm(true)}
>
+ Добавить сотрудника
</button>
</div>
{employeesQ.isPending ? <p className="scraper-hint">Загрузка</p> : null}
{employeesQ.isError && !(employeesQ.error instanceof HTTPError && employeesQ.error.status === 403) ? (
<p className="scraper-result scraper-result--error">
{teamErrorMessage(employeesQ.error)}
</p>
) : null}
{employeesQ.data ? (
<EmployeeTable
employees={employees}
onEditQuota={setQuotaEmployee}
onViewHistory={setHistoryEmployee}
limit={PAGE_LIMIT}
offset={offset}
onOffsetChange={setOffset}
hasNextPage={hasNextPage}
/>
) : null}
</section>
</main>
{showCreateForm ? <CreateEmployeeForm onClose={() => setShowCreateForm(false)} /> : null}
{quotaEmployee ? (
<QuotaEditor employee={quotaEmployee} onClose={() => setQuotaEmployee(null)} />
) : null}
{historyEmployee ? (
<EmployeeHistoryDrawer
employee={historyEmployee}
onClose={() => setHistoryEmployee(null)}
/>
) : null}
</>
);
}

View file

@ -0,0 +1,219 @@
"use client";
/**
* Модалка создания сотрудника (#2556, эпик #2549) `POST /api/v1/team/employees`.
*
* Пароль задаётся вручную менеджером/админом email-рассылки нет (см. issue
* DoD), поэтому после успешного создания показываем явное «передайте пароль
* сотруднику» вместо тихого закрытия модалки.
*/
import { useState } from "react";
import type { FormEvent } from "react";
import {
USERNAME_HINT,
USERNAME_PATTERN,
teamErrorMessage,
useCreateEmployee,
} from "@/lib/team-api";
interface CreateEmployeeFormProps {
onClose: () => void;
}
export function CreateEmployeeForm({ onClose }: CreateEmployeeFormProps) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [displayName, setDisplayName] = useState("");
const [orgName, setOrgName] = useState("");
const [email, setEmail] = useState("");
const [monthlyLimit, setMonthlyLimit] = useState("");
const [usernameTouched, setUsernameTouched] = useState(false);
const createMutation = useCreateEmployee();
const usernameValid = USERNAME_PATTERN.test(username);
const usernameInvalid = usernameTouched && username.length > 0 && !usernameValid;
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setUsernameTouched(true);
if (createMutation.isPending) return;
if (!USERNAME_PATTERN.test(username)) return;
if (password.length === 0) return;
const parsedLimit = monthlyLimit.trim() === "" ? undefined : Number(monthlyLimit);
createMutation.mutate({
username: username.trim(),
password,
display_name: displayName.trim() || undefined,
org_name: orgName.trim() || undefined,
email: email.trim() || undefined,
monthly_limit:
parsedLimit !== undefined && Number.isFinite(parsedLimit) && parsedLimit >= 1
? Math.trunc(parsedLimit)
: undefined,
});
}
if (createMutation.isSuccess) {
return (
<div className="team-modal-backdrop" role="presentation" onClick={onClose}>
<div
className="team-modal"
role="dialog"
aria-modal="true"
aria-label="Сотрудник создан"
onClick={(e) => e.stopPropagation()}
>
<h2>Сотрудник создан</h2>
<p className="team-form-success">
Логин «{createMutation.data.username}» готов. Передайте пароль сотруднику
он не сохраняется в системе и не отправляется автоматически.
</p>
<div className="team-modal-actions">
<button type="button" className="team-btn-primary" onClick={onClose}>
Готово
</button>
</div>
</div>
</div>
);
}
return (
<div className="team-modal-backdrop" role="presentation" onClick={onClose}>
<form
className="team-modal"
role="dialog"
aria-modal="true"
aria-label="Новый сотрудник"
onClick={(e) => e.stopPropagation()}
onSubmit={handleSubmit}
>
<h2>Новый сотрудник</h2>
<p className="team-modal-subtitle">
Пароль задаётся вручную передайте его сотруднику лично, рассылки нет.
</p>
{createMutation.isError ? (
<p role="alert" className="team-form-error">
{teamErrorMessage(createMutation.error)}
</p>
) : null}
<div className="team-field">
<label htmlFor="team-new-username">Логин</label>
<input
id="team-new-username"
name="username"
type="text"
autoComplete="off"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
onBlur={() => setUsernameTouched(true)}
disabled={createMutation.isPending}
aria-invalid={usernameInvalid}
/>
<p className="team-field-hint">{USERNAME_HINT}</p>
{usernameInvalid ? (
<p className="team-field-hint" style={{ color: "var(--danger, #b3261e)" }}>
Логин не соответствует формату
</p>
) : null}
</div>
<div className="team-field team-password-row">
<label htmlFor="team-new-password">Пароль</label>
<input
id="team-new-password"
name="password"
type={showPassword ? "text" : "password"}
autoComplete="new-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={createMutation.isPending}
/>
<button
type="button"
className="team-password-toggle"
onClick={() => setShowPassword((v) => !v)}
tabIndex={-1}
>
{showPassword ? "Скрыть" : "Показать"}
</button>
</div>
<div className="team-field">
<label htmlFor="team-new-display-name">Имя (необязательно)</label>
<input
id="team-new-display-name"
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
disabled={createMutation.isPending}
/>
</div>
<div className="team-field">
<label htmlFor="team-new-org">Организация (необязательно)</label>
<input
id="team-new-org"
type="text"
value={orgName}
onChange={(e) => setOrgName(e.target.value)}
disabled={createMutation.isPending}
/>
</div>
<div className="team-field">
<label htmlFor="team-new-email">Email (необязательно)</label>
<input
id="team-new-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={createMutation.isPending}
/>
</div>
<div className="team-field">
<label htmlFor="team-new-limit">Месячный лимит оценок (необязательно)</label>
<input
id="team-new-limit"
type="number"
min={1}
step={1}
placeholder="по умолчанию"
value={monthlyLimit}
onChange={(e) => setMonthlyLimit(e.target.value)}
disabled={createMutation.isPending}
/>
</div>
<div className="team-modal-actions">
<button
type="button"
className="team-btn-secondary"
onClick={onClose}
disabled={createMutation.isPending}
>
Отмена
</button>
<button
type="submit"
className="team-btn-primary"
disabled={createMutation.isPending || password.length === 0 || !usernameValid}
>
{createMutation.isPending ? "Создаём…" : "Создать"}
</button>
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,94 @@
"use client";
/**
* Drawer с историей оценок сотрудника (#2556, эпик #2549)
* `GET /api/v1/team/employees/{id}/history`.
*/
import { teamErrorMessage, useEmployeeHistory } from "@/lib/team-api";
import type { Employee } from "@/lib/team-api";
function formatDateTime(iso: string): string {
try {
return new Date(iso).toLocaleString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
function formatPrice(value: number | null): string {
if (value === null) return "—";
return `${value.toLocaleString("ru-RU")}`;
}
interface EmployeeHistoryDrawerProps {
employee: Employee;
onClose: () => void;
}
export function EmployeeHistoryDrawer({ employee, onClose }: EmployeeHistoryDrawerProps) {
const historyQ = useEmployeeHistory(employee.id, 50, 0);
return (
<>
<div className="team-drawer-backdrop" role="presentation" onClick={onClose} />
<aside
className="team-drawer"
role="dialog"
aria-modal="true"
aria-label={`История оценок сотрудника ${employee.username}`}
>
<div className="team-drawer-header">
<div>
<h2>История оценок</h2>
<p className="scraper-hint" style={{ margin: 0 }}>
{employee.display_name ?? employee.username}
</p>
</div>
<button
type="button"
className="team-drawer-close"
onClick={onClose}
aria-label="Закрыть"
>
×
</button>
</div>
{historyQ.isPending ? <p className="scraper-hint">Загрузка</p> : null}
{historyQ.isError ? (
<p className="scraper-result scraper-result--error">
{teamErrorMessage(historyQ.error)}
</p>
) : null}
{historyQ.data && historyQ.data.length === 0 ? (
<p className="scraper-hint">У сотрудника пока нет оценок.</p>
) : null}
{historyQ.data && historyQ.data.length > 0 ? (
<div>
{historyQ.data.map((entry, i) => (
<div className="team-history-row" key={entry.estimate_id ?? i}>
<p className="team-history-address">{entry.address ?? "Адрес не указан"}</p>
<p className="team-history-meta">
{formatDateTime(entry.created_at)}
{entry.rooms ? ` · ${entry.rooms}к` : ""}
{entry.area_m2 ? ` · ${entry.area_m2} м²` : ""}
{entry.median_price !== null ? ` · ${formatPrice(entry.median_price)}` : ""}
{entry.confidence ? ` · точность: ${entry.confidence}` : ""}
{entry.n_analogs !== null ? ` · аналогов: ${entry.n_analogs}` : ""}
</p>
</div>
))}
</div>
) : null}
</aside>
</>
);
}

View file

@ -0,0 +1,188 @@
"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) }),
},
);
}
if (employees.length === 0) {
return <p className="scraper-hint">Сотрудников пока нет.</p>;
}
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"
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>
<span>
{offset + 1}{offset + employees.length}
</span>
<button type="button" disabled={!hasNextPage} onClick={() => onOffsetChange(offset + limit)}>
Далее
</button>
</div>
</>
);
}

View file

@ -0,0 +1,144 @@
"use client";
/**
* Модалка изменения сотрудника (#2556, эпик #2549) месячная квота + (опц.)
* сброс пароля. Один PATCH-запрос `/api/v1/team/employees/{id}` backend
* принимает `monthly_limit` и `new_password` в одном теле (см.
* `app/api/v1/team.py::update_employee`), поэтому оба поля живут в одной
* форме вместо двух раздельных round-trip'ов.
*
* Явная установка `monthly_limit` ВСЕГДА сбрасывает `unlimited=false` на
* бэкенде (см. `_upsert_quota_override`) предупреждаем об этом в тексте,
* если у сотрудника сейчас безлимит.
*/
import { useState } from "react";
import type { FormEvent } from "react";
import type { Employee, UpdateEmployeeInput } from "@/lib/team-api";
import { teamErrorMessage, useUpdateEmployee } from "@/lib/team-api";
interface QuotaEditorProps {
employee: Employee;
onClose: () => void;
}
export function QuotaEditor({ employee, onClose }: QuotaEditorProps) {
const [monthlyLimit, setMonthlyLimit] = useState(
employee.quota.unlimited ? "" : String(employee.quota.limit),
);
const [newPassword, setNewPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const updateMutation = useUpdateEmployee();
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
if (updateMutation.isPending) return;
const patch: UpdateEmployeeInput = {};
const trimmedLimit = monthlyLimit.trim();
if (trimmedLimit !== "") {
const parsed = Number(trimmedLimit);
if (Number.isFinite(parsed) && parsed >= 1) {
patch.monthly_limit = Math.trunc(parsed);
}
}
if (newPassword.length > 0) {
patch.new_password = newPassword;
}
if (Object.keys(patch).length === 0) {
onClose();
return;
}
updateMutation.mutate(
{ employeeId: employee.id, patch },
{ onSuccess: onClose },
);
}
return (
<div className="team-modal-backdrop" role="presentation" onClick={onClose}>
<form
className="team-modal"
role="dialog"
aria-modal="true"
aria-label={`Изменить сотрудника ${employee.username}`}
onClick={(e) => e.stopPropagation()}
onSubmit={handleSubmit}
>
<h2>Изменить сотрудника</h2>
<p className="team-modal-subtitle">{employee.username}</p>
{updateMutation.isError ? (
<p role="alert" className="team-form-error">
{teamErrorMessage(updateMutation.error)}
</p>
) : null}
<div className="team-field">
<label htmlFor="team-quota-limit">Месячный лимит оценок</label>
<input
id="team-quota-limit"
type="number"
min={1}
step={1}
value={monthlyLimit}
onChange={(e) => setMonthlyLimit(e.target.value)}
disabled={updateMutation.isPending}
/>
<p className="team-field-hint">
Сейчас использовано {employee.quota.used}
{employee.quota.unlimited
? " · без лимита"
: ` из ${employee.quota.limit} (осталось ${employee.quota.remaining})`}
{employee.quota.unlimited
? ". Заполнение поля снимет безлимитный статус."
: ""}
</p>
</div>
<div className="team-field team-password-row">
<label htmlFor="team-quota-password">Новый пароль (необязательно)</label>
<input
id="team-quota-password"
type={showPassword ? "text" : "password"}
autoComplete="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={updateMutation.isPending}
/>
<button
type="button"
className="team-password-toggle"
onClick={() => setShowPassword((v) => !v)}
tabIndex={-1}
>
{showPassword ? "Скрыть" : "Показать"}
</button>
<p className="team-field-hint">
Смена пароля завершает все текущие сессии сотрудника.
</p>
</div>
<div className="team-modal-actions">
<button
type="button"
className="team-btn-secondary"
onClick={onClose}
disabled={updateMutation.isPending}
>
Отмена
</button>
<button type="submit" className="team-btn-primary" disabled={updateMutation.isPending}>
{updateMutation.isPending ? "Сохраняем…" : "Сохранить"}
</button>
</div>
</form>
</div>
);
}

View file

@ -7,6 +7,7 @@ import { API_BASE_URL, HTTPError } from "@/lib/api";
import { isPathAllowed } from "@/lib/isPathAllowed"; import { isPathAllowed } from "@/lib/isPathAllowed";
import { safeUrl } from "@/lib/safeUrl"; import { safeUrl } from "@/lib/safeUrl";
import { useBrand } from "@/lib/useBrand"; import { useBrand } from "@/lib/useBrand";
import type { Role } from "@/lib/useMe";
import { useMe } from "@/lib/useMe"; import { useMe } from "@/lib/useMe";
/** Telegram-канал обратной связи для пилота. Build-time env пусто до тех /** Telegram-канал обратной связи для пилота. Build-time env пусто до тех
@ -84,7 +85,8 @@ export type ActiveTab =
| "cian" | "cian"
| "yandex" | "yandex"
| "audit" | "audit"
| "analytics"; | "analytics"
| "team";
interface TopbarProps { interface TopbarProps {
active: ActiveTab; active: ActiveTab;
@ -111,6 +113,13 @@ const NAV_ITEMS: Array<{
href: string; href: string;
scopePath: string; scopePath: string;
label: string; label: string;
// Доп. gate ПОВЕРХ isPathAllowed — нужен для "team": DB-роль `manager`
// получает `/api/v1/team/**` в allowed_paths (см. DB_ROLE_PATHS,
// tradein-mvp/backend/app/services/auth_session.py), но legacy-роль
// `analyst` (auth/roles.yaml, paths: "/**", БЕЗ явного deny на /api/v1/team)
// тоже прошла бы обычный isPathAllowed-фильтр — analyst не должен видеть
// пункт «Команда» (#2556 scope: только admin/manager).
roleGate?: (role: Role) => boolean;
}> = [ }> = [
{ key: "estimate", href: "/", scopePath: "/trade-in/", label: "Оценка" }, { key: "estimate", href: "/", scopePath: "/trade-in/", label: "Оценка" },
// Доля квартир дома в продаже — доступно pilot (scopePath под /trade-in/**). // Доля квартир дома в продаже — доступно pilot (scopePath под /trade-in/**).
@ -162,6 +171,16 @@ const NAV_ITEMS: Array<{
scopePath: "/trade-in/api/v1/admin/analytics", scopePath: "/trade-in/api/v1/admin/analytics",
label: "Активность", label: "Активность",
}, },
// Team-дашборд менеджера (#2556, эпик #2549) — CRUD сотрудников/квоты/история.
// scopePath БЕЗ /trade-in-префикса — DB_ROLE_PATHS отдаёт голый /api/v1/team/**
// (не /trade-in/api/v1/team/**), см. комментарий у roleGate выше.
{
key: "team",
href: "/team",
scopePath: "/api/v1/team",
label: "Команда",
roleGate: (role) => role === "admin" || role === "manager",
},
]; ];
export function Topbar({ active }: TopbarProps) { export function Topbar({ active }: TopbarProps) {
@ -176,8 +195,10 @@ export function Topbar({ active }: TopbarProps) {
const isDev401 = error instanceof HTTPError && error.status === 401; const isDev401 = error instanceof HTTPError && error.status === 401;
const items = const items =
data && !isDev401 data && !isDev401
? NAV_ITEMS.filter((item) => ? NAV_ITEMS.filter(
isPathAllowed(data.allowed_paths, data.deny_paths, item.scopePath), (item) =>
isPathAllowed(data.allowed_paths, data.deny_paths, item.scopePath) &&
(item.roleGate ? item.roleGate(data.role) : true),
) )
: NAV_ITEMS; : NAV_ITEMS;

View file

@ -2820,3 +2820,322 @@ html, body { overflow-x: clip; }
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: 16px; gap: 16px;
} }
/* ── Team dashboard (#2556, эпик #2549) ── */
.team-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.team-status-badge {
padding: 3px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
white-space: nowrap;
}
.team-status-badge--active {
background: var(--success-soft, #dcfce7);
color: var(--success, #0a7a3a);
}
.team-status-badge--blocked {
background: var(--danger-soft, #fee2e2);
color: var(--danger, #b3261e);
}
.team-quota-unlimited {
color: var(--fg-secondary, #5b6066);
font-size: 11px;
}
.team-quota-low {
color: var(--danger, #b3261e);
font-weight: 600;
}
.team-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.team-action-btn {
padding: 4px 10px;
background: var(--bg-card-alt, #fafbfc);
color: var(--fg-primary, #111111);
border: 1px solid var(--border-strong, #d1d5db);
border-radius: 4px;
font-size: 11px;
cursor: pointer;
}
.team-action-btn:hover:not(:disabled) {
background: var(--accent-soft, #dbeafe);
border-color: var(--accent, #1d4ed8);
}
.team-action-btn:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.team-action-btn--danger {
color: var(--danger, #b3261e);
border-color: var(--danger, #b3261e);
}
.team-action-btn--danger:hover:not(:disabled) {
background: var(--danger-soft, #fee2e2);
}
.team-pager {
display: flex;
align-items: center;
gap: 12px;
margin-top: 16px;
font-size: 13px;
color: var(--fg-secondary, #5b6066);
}
.team-pager button {
padding: 6px 12px;
background: var(--bg-card, #ffffff);
border: 1px solid var(--border-strong, #d1d5db);
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
.team-pager button:hover:not(:disabled) {
background: var(--accent-soft, #dbeafe);
border-color: var(--accent, #1d4ed8);
}
.team-pager button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
/* Modal (CreateEmployeeForm / QuotaEditor) box-shadow допустим для
modals/popovers per .claude/rules/ui-tokens.md. */
.team-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 48px 16px;
z-index: 100;
overflow-y: auto;
}
.team-modal {
background: var(--bg-card, #ffffff);
border: 1px solid var(--border-card, #e6e8ec);
border-radius: 12px;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.2);
padding: 24px;
max-width: 440px;
width: 100%;
}
.team-modal h2 {
font-size: 18px;
font-weight: 600;
margin: 0 0 4px;
color: var(--fg-primary, #111111);
}
.team-modal p.team-modal-subtitle {
font-size: 13px;
color: var(--fg-secondary, #5b6066);
margin: 0 0 20px;
}
.team-field {
margin-bottom: 14px;
}
.team-field label {
display: block;
font-size: 12px;
font-weight: 500;
color: var(--fg-secondary, #5b6066);
margin-bottom: 6px;
}
.team-field input {
width: 100%;
box-sizing: border-box;
height: 38px;
padding: 0 12px;
border: 1px solid var(--border-card, #e6e8ec);
border-radius: 8px;
font-size: 14px;
color: var(--fg-primary, #111111);
background: var(--bg-card, #ffffff);
font-family: inherit;
}
.team-field input:focus-visible {
outline: none;
border-color: var(--accent, #1d4ed8);
box-shadow: 0 0 0 2px var(--accent-soft, #dbeafe);
}
.team-field-hint {
margin: 6px 0 0;
font-size: 11px;
color: var(--fg-tertiary, #73767e);
}
.team-password-row {
position: relative;
}
.team-password-toggle {
position: absolute;
right: 8px;
top: 30px;
background: none;
border: none;
color: var(--fg-secondary, #5b6066);
font-size: 11px;
cursor: pointer;
padding: 4px 6px;
}
.team-form-error {
margin: 0 0 16px;
padding: 8px 12px;
border-radius: 8px;
background: var(--danger-soft, #fee2e2);
color: var(--danger, #b3261e);
font-size: 13px;
line-height: 1.4;
}
.team-form-success {
margin: 0 0 16px;
padding: 8px 12px;
border-radius: 8px;
background: var(--success-soft, #dcfce7);
color: var(--success, #0a7a3a);
font-size: 13px;
line-height: 1.4;
}
.team-modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
}
.team-btn-primary {
padding: 9px 16px;
background: var(--accent, #1d4ed8);
color: #fff;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.team-btn-primary:hover:not(:disabled) {
background: var(--accent-hover, #1e40af);
}
.team-btn-primary:disabled {
background: var(--border-strong, #d1d5db);
cursor: not-allowed;
}
.team-btn-secondary {
padding: 9px 16px;
background: var(--bg-card, #ffffff);
color: var(--fg-primary, #111111);
border: 1px solid var(--border-strong, #d1d5db);
border-radius: 8px;
font-size: 14px;
cursor: pointer;
}
.team-btn-secondary:hover:not(:disabled) {
background: var(--bg-card-alt, #fafbfc);
}
/* Drawer (EmployeeHistoryDrawer) */
.team-drawer-backdrop {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.35);
z-index: 100;
}
.team-drawer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: min(480px, 100vw);
background: var(--bg-card, #ffffff);
border-left: 1px solid var(--border-card, #e6e8ec);
box-shadow: -16px 0 40px rgba(15, 23, 42, 0.15);
z-index: 101;
overflow-y: auto;
padding: 24px;
}
.team-drawer-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.team-drawer-header h2 {
font-size: 18px;
font-weight: 600;
margin: 0 0 4px;
color: var(--fg-primary, #111111);
}
.team-drawer-close {
background: none;
border: none;
font-size: 20px;
line-height: 1;
color: var(--fg-secondary, #5b6066);
cursor: pointer;
padding: 4px 6px;
}
.team-history-row {
padding: 10px 0;
border-bottom: 1px solid var(--border-soft, #eef0f3);
}
.team-history-row:last-child {
border-bottom: none;
}
.team-history-address {
font-size: 13px;
color: var(--fg-primary, #111111);
margin: 0 0 2px;
}
.team-history-meta {
font-size: 11px;
color: var(--fg-secondary, #5b6066);
}

View file

@ -0,0 +1,189 @@
"use client";
/**
* Team-management API client (#2556, эпик #2549) CRUD сотрудников, квоты,
* история. Mirrors `app/api/v1/team.py` + `app/schemas/team.py` (tradein-mvp
* backend, PR #2554/#2563). Hand-written types tradein-mvp/frontend has no
* `codegen` script (см. `src/lib/admin-audit-api.ts` для того же паттерна).
*
* Auth: team-роуты требуют session-cookie (`current_team_actor` в team.py
* читает ТОЛЬКО cookie, не legacy X-Authenticated-User). `apiFetch` не задаёт
* `credentials` явно запросы same-origin (basePath `/trade-in` за тем же
* Caddy), браузер по умолчанию шлёт cookie на same-origin fetch.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch, HTTPError } from "./api";
const BASE = "/api/v1/team";
// Тот же regex что и `app.schemas.team._USERNAME_RE` — клиентская валидация
// зеркалит серверную, чтобы показывать понятную подсказку ДО round-trip.
export const USERNAME_PATTERN = /^[A-Za-z0-9._-]{3,64}$/;
export const USERNAME_HINT =
"Латиница, цифры, точка, дефис, подчёркивание; 364 символа";
// ---- Types (mirror app/schemas/team.py) ------------------------------------
export interface QuotaStatus {
limit: number;
used: number;
remaining: number;
unlimited: boolean;
}
export interface Employee {
id: number;
username: string;
display_name: string | null;
org_name: string | null;
email: string | null;
is_active: boolean;
manager_id: number | null;
created_at: string;
quota: QuotaStatus;
}
export interface EmployeeHistoryEntry {
estimate_id: string | null;
address: string | null;
area_m2: string | null;
rooms: string | null;
median_price: number | null;
confidence: string | null;
n_analogs: number | null;
created_at: string;
}
export interface CreateEmployeeInput {
username: string;
password: string;
display_name?: string;
org_name?: string;
email?: string;
monthly_limit?: number;
}
export interface UpdateEmployeeInput {
is_active?: boolean;
monthly_limit?: number;
display_name?: string;
org_name?: string;
email?: string;
new_password?: string;
}
// ---- Hooks -------------------------------------------------------------------
const EMPLOYEES_LIST_KEY = ["team", "employees"] as const;
/**
* GET /api/v1/team/employees?limit=&offset=
* Backend сам скоупит по роли (manager только свои, admin все).
*/
export function useEmployees(limit: number, offset: number) {
return useQuery<Employee[]>({
queryKey: [...EMPLOYEES_LIST_KEY, limit, offset],
queryFn: () =>
apiFetch<Employee[]>(`${BASE}/employees?limit=${limit}&offset=${offset}`),
staleTime: 15_000,
retry: false,
});
}
/**
* GET /api/v1/team/employees/{id}/history?limit=&offset=
* `enabled: false` пока employeeId === null ленивая загрузка при открытии drawer.
*/
export function useEmployeeHistory(
employeeId: number | null,
limit = 50,
offset = 0,
) {
return useQuery<EmployeeHistoryEntry[]>({
queryKey: ["team", "employees", employeeId, "history", limit, offset],
queryFn: () =>
apiFetch<EmployeeHistoryEntry[]>(
`${BASE}/employees/${employeeId}/history?limit=${limit}&offset=${offset}`,
),
enabled: employeeId !== null,
staleTime: 15_000,
retry: false,
});
}
/** POST /api/v1/team/employees — создать сотрудника. */
export function useCreateEmployee() {
const queryClient = useQueryClient();
return useMutation<Employee, Error, CreateEmployeeInput>({
mutationFn: (input) =>
apiFetch<Employee>(`${BASE}/employees`, {
method: "POST",
body: JSON.stringify(input),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: EMPLOYEES_LIST_KEY });
},
});
}
/**
* PATCH /api/v1/team/employees/{id} частичное обновление (блокировка,
* квота, профиль, сброс пароля любая комбинация полей в одном запросе,
* зеркалит `EmployeeUpdateRequest`).
*/
export function useUpdateEmployee() {
const queryClient = useQueryClient();
return useMutation<
Employee,
Error,
{ employeeId: number; patch: UpdateEmployeeInput }
>({
mutationFn: ({ employeeId, patch }) =>
apiFetch<Employee>(`${BASE}/employees/${employeeId}`, {
method: "PATCH",
body: JSON.stringify(patch),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: EMPLOYEES_LIST_KEY });
},
});
}
// ---- Error mapping -----------------------------------------------------------
/**
* Достаёт человеко-читаемый текст из FastAPI error body либо `{detail:
* string}` (наши ручные `HTTPException(...)`), либо Pydantic-валидация
* `{detail: [{msg: string, ...}]}` (напр. username не прошёл field_validator
* на этапе парсинга тела запроса, ДО хендлера).
*/
function extractDetailMessage(body: unknown): string | null {
if (body === null || typeof body !== "object" || !("detail" in body)) {
return null;
}
const detail = (body as { detail?: unknown }).detail;
if (typeof detail === "string") return detail;
if (Array.isArray(detail) && detail.length > 0) {
const first: unknown = detail[0];
if (first !== null && typeof first === "object" && "msg" in first) {
const msg = (first as { msg?: unknown }).msg;
if (typeof msg === "string") return msg;
}
}
return null;
}
/** Русский текст ошибки для team-мутаций/запросов — по коду статуса. */
export function teamErrorMessage(error: unknown): string {
if (error instanceof HTTPError) {
if (error.status === 409) return "Логин уже занят";
if (error.status === 403) return "Недостаточно прав";
if (error.status === 404) return "Сотрудник не найден";
if (error.status === 422) {
return extractDetailMessage(error.body) ?? "Проверьте правильность заполнения формы";
}
}
return "Не удалось выполнить запрос. Попробуйте ещё раз";
}