diff --git a/tradein-mvp/frontend/src/app/team/page.tsx b/tradein-mvp/frontend/src/app/team/page.tsx new file mode 100644 index 00000000..bf0016de --- /dev/null +++ b/tradein-mvp/frontend/src/app/team/page.tsx @@ -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(null); + const [historyEmployee, setHistoryEmployee] = useState(null); + + // Роль ещё не загружена — не решаем ничего, RouteGuard уже отрисовал + // страницу (401/403 верхнего уровня он ловит сам), здесь только role-gate. + if (meQ.isPending) { + return ( + <> + +
+

Загрузка…

+
+ + ); + } + + const role = meQ.data?.role; + const isAllowedRole = role === "admin" || role === "manager"; + + if (!isAllowedRole) { + return ; + } + + // Backend 403 регардлесс роли фронта (напр. session истекла между /me и + // /employees) — тот же fallback, что и в admin/audit. + if (employeesQ.error instanceof HTTPError && employeesQ.error.status === 403) { + return ; + } + + const employees = employeesQ.data ?? []; + const hasNextPage = employees.length === PAGE_LIMIT; + + return ( + <> + +
+

Команда

+

+ Сотрудники, их доступ и месячные квоты на оценку. История оценок — по клику + «История». +

+ +
+
+

Сотрудники

+ +
+ + {employeesQ.isPending ?

Загрузка…

: null} + {employeesQ.isError && !(employeesQ.error instanceof HTTPError && employeesQ.error.status === 403) ? ( +

+ {teamErrorMessage(employeesQ.error)} +

+ ) : null} + + {employeesQ.data ? ( + + ) : null} +
+
+ + {showCreateForm ? setShowCreateForm(false)} /> : null} + {quotaEmployee ? ( + setQuotaEmployee(null)} /> + ) : null} + {historyEmployee ? ( + setHistoryEmployee(null)} + /> + ) : null} + + ); +} diff --git a/tradein-mvp/frontend/src/components/team/CreateEmployeeForm.tsx b/tradein-mvp/frontend/src/components/team/CreateEmployeeForm.tsx new file mode 100644 index 00000000..dafbad4a --- /dev/null +++ b/tradein-mvp/frontend/src/components/team/CreateEmployeeForm.tsx @@ -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) { + 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 ( +
+
e.stopPropagation()} + > +

Сотрудник создан

+

+ Логин «{createMutation.data.username}» готов. Передайте пароль сотруднику — + он не сохраняется в системе и не отправляется автоматически. +

+
+ +
+
+
+ ); + } + + return ( +
+
e.stopPropagation()} + onSubmit={handleSubmit} + > +

Новый сотрудник

+

+ Пароль задаётся вручную — передайте его сотруднику лично, рассылки нет. +

+ + {createMutation.isError ? ( +

+ {teamErrorMessage(createMutation.error)} +

+ ) : null} + +
+ + setUsername(e.target.value)} + onBlur={() => setUsernameTouched(true)} + disabled={createMutation.isPending} + aria-invalid={usernameInvalid} + /> +

{USERNAME_HINT}

+ {usernameInvalid ? ( +

+ Логин не соответствует формату +

+ ) : null} +
+ +
+ + setPassword(e.target.value)} + disabled={createMutation.isPending} + /> + +
+ +
+ + setDisplayName(e.target.value)} + disabled={createMutation.isPending} + /> +
+ +
+ + setOrgName(e.target.value)} + disabled={createMutation.isPending} + /> +
+ +
+ + setEmail(e.target.value)} + disabled={createMutation.isPending} + /> +
+ +
+ + setMonthlyLimit(e.target.value)} + disabled={createMutation.isPending} + /> +
+ +
+ + +
+
+
+ ); +} diff --git a/tradein-mvp/frontend/src/components/team/EmployeeHistoryDrawer.tsx b/tradein-mvp/frontend/src/components/team/EmployeeHistoryDrawer.tsx new file mode 100644 index 00000000..0d55066e --- /dev/null +++ b/tradein-mvp/frontend/src/components/team/EmployeeHistoryDrawer.tsx @@ -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 ( + <> +
+ + + ); +} diff --git a/tradein-mvp/frontend/src/components/team/EmployeeTable.tsx b/tradein-mvp/frontend/src/components/team/EmployeeTable.tsx new file mode 100644 index 00000000..62439c5d --- /dev/null +++ b/tradein-mvp/frontend/src/components/team/EmployeeTable.tsx @@ -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 без лимита; + } + const low = quota.remaining <= 0; + return ( + + {quota.used}/{quota.limit} + + ); +} + +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(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

Сотрудников пока нет.

; + } + + return ( + <> + + + + + + + + + + + + + {employees.map((employee) => ( + + + + + + + + + + {rowError?.id === employee.id ? ( + + + + ) : null} + + ))} + +
ЛогинИмяСтатусКвотаСозданДействия
{employee.username} + {employee.display_name ?? "—"} + {employee.org_name ? ` · ${employee.org_name}` : ""} + + + {employee.is_active ? "Активен" : "Заблокирован"} + + + + {formatDate(employee.created_at)} +
+ + + +
+
+

+ {rowError.message} +

+
+ +
+ + + {offset + 1}–{offset + employees.length} + + +
+ + ); +} diff --git a/tradein-mvp/frontend/src/components/team/QuotaEditor.tsx b/tradein-mvp/frontend/src/components/team/QuotaEditor.tsx new file mode 100644 index 00000000..3eabd3eb --- /dev/null +++ b/tradein-mvp/frontend/src/components/team/QuotaEditor.tsx @@ -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) { + 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 ( +
+
e.stopPropagation()} + onSubmit={handleSubmit} + > +

Изменить сотрудника

+

{employee.username}

+ + {updateMutation.isError ? ( +

+ {teamErrorMessage(updateMutation.error)} +

+ ) : null} + +
+ + setMonthlyLimit(e.target.value)} + disabled={updateMutation.isPending} + /> +

+ Сейчас использовано {employee.quota.used} + {employee.quota.unlimited + ? " · без лимита" + : ` из ${employee.quota.limit} (осталось ${employee.quota.remaining})`} + {employee.quota.unlimited + ? ". Заполнение поля снимет безлимитный статус." + : ""} +

+
+ +
+ + setNewPassword(e.target.value)} + disabled={updateMutation.isPending} + /> + +

+ Смена пароля завершает все текущие сессии сотрудника. +

+
+ +
+ + +
+
+
+ ); +} diff --git a/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx b/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx index 094dda63..c7b88064 100644 --- a/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx @@ -7,6 +7,7 @@ import { API_BASE_URL, HTTPError } from "@/lib/api"; import { isPathAllowed } from "@/lib/isPathAllowed"; import { safeUrl } from "@/lib/safeUrl"; import { useBrand } from "@/lib/useBrand"; +import type { Role } from "@/lib/useMe"; import { useMe } from "@/lib/useMe"; /** Telegram-канал обратной связи для пилота. Build-time env — пусто до тех @@ -84,7 +85,8 @@ export type ActiveTab = | "cian" | "yandex" | "audit" - | "analytics"; + | "analytics" + | "team"; interface TopbarProps { active: ActiveTab; @@ -111,6 +113,13 @@ const NAV_ITEMS: Array<{ href: string; scopePath: 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: "Оценка" }, // Доля квартир дома в продаже — доступно pilot (scopePath под /trade-in/**). @@ -162,6 +171,16 @@ const NAV_ITEMS: Array<{ scopePath: "/trade-in/api/v1/admin/analytics", 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) { @@ -176,8 +195,10 @@ export function Topbar({ active }: TopbarProps) { const isDev401 = error instanceof HTTPError && error.status === 401; const items = data && !isDev401 - ? NAV_ITEMS.filter((item) => - isPathAllowed(data.allowed_paths, data.deny_paths, item.scopePath), + ? NAV_ITEMS.filter( + (item) => + isPathAllowed(data.allowed_paths, data.deny_paths, item.scopePath) && + (item.roleGate ? item.roleGate(data.role) : true), ) : NAV_ITEMS; diff --git a/tradein-mvp/frontend/src/components/trade-in/trade-in.css b/tradein-mvp/frontend/src/components/trade-in/trade-in.css index 6a7bcbfe..b4bc3ec7 100644 --- a/tradein-mvp/frontend/src/components/trade-in/trade-in.css +++ b/tradein-mvp/frontend/src/components/trade-in/trade-in.css @@ -2820,3 +2820,322 @@ html, body { overflow-x: clip; } grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); 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); +} diff --git a/tradein-mvp/frontend/src/lib/team-api.ts b/tradein-mvp/frontend/src/lib/team-api.ts new file mode 100644 index 00000000..0c0e4bb0 --- /dev/null +++ b/tradein-mvp/frontend/src/lib/team-api.ts @@ -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 = + "Латиница, цифры, точка, дефис, подчёркивание; 3–64 символа"; + +// ---- 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({ + queryKey: [...EMPLOYEES_LIST_KEY, limit, offset], + queryFn: () => + apiFetch(`${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({ + queryKey: ["team", "employees", employeeId, "history", limit, offset], + queryFn: () => + apiFetch( + `${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({ + mutationFn: (input) => + apiFetch(`${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(`${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 "Не удалось выполнить запрос. Попробуйте ещё раз"; +}