Compare commits

..

No commits in common. "main" and "fix/tradein-team-list-order" have entirely different histories.

12 changed files with 18 additions and 1639 deletions

View file

@ -11,13 +11,6 @@
# Users managed via caddy/users.caddy.snippet (git history = audit trail).
# Public exclusions: /health (liveness probe), /preview/* (static mockups).
#
# #2558: с 2026-07 basic_auth гейтит ТОЛЬКО Site Finder (`/`, `/api/*`,
# `/analytics` и т.д.). `/trade-in/*` (+ `/sale-share` redirect) вынесены ВЫШЕ
# import'ау trade-in своя авторизация (форма входа + opaque session-cookie,
# см. #2552) поверх RBAC (`tradein-mvp/backend/app/core/rbac.py`). Site Finder
# всё ещё легаси-пилотный basic_auth (roles.yaml dual-mode остаётся живым для
# него — НЕ трогать caddy/users.caddy.snippet).
#
# IMPORTANT: route { } block is required to preserve directive order.
# Without route { }, Caddy executes directives in hard-coded default order
# (basic_auth runs before handle), making /health and /preview/* exclusions
@ -77,56 +70,26 @@ gendsgn.ru {
# Оба ДО auth-import, иначе ассеты страницы уходят в @tradein (под auth) → 401 → без CSS.
@uipreview path /trade-in/ui-preview/* /trade-in/_next/static/*
handle @uipreview {
reverse_proxy tradein-frontend:3000 {
# #2558 review: тот же периметр-scrub, что и у /trade-in/api/* и
# @tradein ниже — этот блок тоже теперь ДО basic_auth, клиент
# мог бы прислать свой X-Authenticated-User. Сейчас инертно
# (страница статична, у tradein-frontend нет секрета для
# X-Internal-Auth-Secret), но убираем ради единообразия периметра,
# а не полагаясь на то, что downstream ничего не делает с заголовком.
header_up -X-Authenticated-User
}
reverse_proxy tradein-frontend:3000
}
# #2558: Trade-In MVP subproject (tradein-mvp/) — gendesign-tradein docker
# stack, подключен через gendesign_shared network. Секция ЦЕЛИКОМ ДО
# `import caddy/users.caddy.snippet` ниже — /trade-in имеет собственную
# авторизацию (форма входа + opaque session-cookie, #2552; RBAC-проверка
# роли внутри tradein-backend, `app/core/rbac.py`), Site Finder basic_auth
# ей больше не нужен и не должен применяться (short-circuit сверху вниз,
# как /health и /preview/* выше).
#
# X-Authenticated-User — ЯВНОЕ УДАЛЕНИЕ (`header_up -X-Authenticated-User`),
# НЕ `header_up X-Authenticated-User {http.auth.user.id}`. Причина: этот
# блок больше не идёт ПОСЛЕ basic_auth, поэтому `{http.auth.user.id}`
# никогда не резолвится авторизованным юзером на этом пути.
# Проверено эмпирически (echo-стенд на образе caddy:2, `caddy adapt`):
# старая Set-форма (`header_up X-Authenticated-User {http.auth.user.id}`)
# НЕ пропустила бы клиентский заголовок насквозь и НЕ оставила бы поле
# пустым — Caddy подставляет НЕРАЗРЕШЁННЫЙ плейсхолдер как ЛИТЕРАЛЬНУЮ
# строку (`ReplaceKnown`), т.е. upstream получил бы буквально
# `X-Authenticated-User: {http.auth.user.id}`. Для backend (auth_mode=
# "dual", `app/core/config.py`) это НЕ подмена личности — legacy path
# (`rbac.py:186`) сделал бы `get_role("{http.auth.user.id}")`, юзер не
# найден в roles.yaml → 403 для всех. Т.е. старая форма была бы не
# security-дырой, а fail-closed-but-сломанной (все trade-in запросы без
# session-cookie получали бы 403 вместо ожидаемого 401/редиректа на логин).
# `-Field` остаётся правильным выбором не потому что Set был бы дырой, а
# потому что это ЕДИНСТВЕННАЯ форма с явно задокументированной семантикой
# "удалить заголовок" (Caddyfile reverse_proxy directive: `-<field>` =
# delete) — корректное поведение не должно зависеть от того, как именно
# Caddy трактует нерезолвленный/пустой плейсхолдер в Set-операции.
# X-Internal-Auth-Secret НЕ трогаем — #2213-секрет всегда перезаписывается
# из env (Set-операция с непустым значением, никак не связана с auth-гейтом
# basic_auth), это единственное, что теперь отсекает подделку заголовков
# изнутри gendesign_shared network для legacy dual-mode пути.
# Auth gate (applies to all routes below within this route block).
import caddy/users.caddy.snippet
# Trade-In MVP subproject (tradein-mvp/) — gendesign-tradein docker stack,
# подключен через gendesign_shared network. Routes ДО универсального handle
# потому что Caddy матчит handle-блоки сверху вниз.
handle /trade-in/api/* {
# `handle_path /trade-in/api/*` стрипал бы целиком /trade-in/api;
# FastAPI router замаунтен на /api/v1/trade-in/* — нужен strip только
# префикса basePath /trade-in (Next.js basePath leak).
uri strip_prefix /trade-in
reverse_proxy tradein-backend:8000 {
header_up -X-Authenticated-User
header_up X-Authenticated-User {http.auth.user.id}
# #2213 defense-in-depth: общий секрет Caddy↔tradein-backend. header_up
# с value ПЕРЕЗАПИСЫВАЕТ (стирает) любой клиентский X-Internal-Auth-Secret —
# тот же механизм, что защищает X-Authenticated-User выше. Пусто пока
# TRADEIN_INTERNAL_AUTH_SECRET не задан в .env (fail-open, backend не проверяет).
header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
}
}
@ -135,16 +98,6 @@ gendsgn.ru {
# Next basePath=/trade-in → редиректим на канонический /trade-in/sale-share
# (тот же tradein-frontend контейнер; query-string сохраняется). True vanity-URL
# в адресной строке требует отдельного Next-app с basePath=/sale-share.
# #2558: перенесён ВЫШЕ auth-import вместе с trade-in — редирект ведёт на
# /trade-in/sale-share, для которого теперь нет Caddy basic_auth (как и
# для остального /trade-in). Это НЕ делает страницу публичной: она всё
# ещё за собственной авторизацией trade-in — `RouteGuard` во фронте
# (`app/layout.tsx`, «Доступ: pilot + admin») и сессия для
# `/api/v1/buildings/sale-share*` на бэке; без валидной сессии юзер
# получит редирект на /login, а не контент. Смысл переноса — не открыть
# страницу всем, а убрать несогласованность: короткий URL не должен быть
# строже (Caddy basic_auth) целевого адреса, к которому и так уже нет
# basic_auth-барьера (только собственный login trade-in).
@saleshare path /sale-share /sale-share/
handle @saleshare {
redir /trade-in/sale-share permanent
@ -157,17 +110,13 @@ gendsgn.ru {
handle @tradein {
# Next.js basePath=/trade-in — фронт сам ждёт префикса в URL
reverse_proxy tradein-frontend:3000 {
# См. комментарий над /trade-in/api/* выше — та же логика (явное
# удаление вместо Set с пустым {http.auth.user.id}).
header_up -X-Authenticated-User
header_up X-Authenticated-User {http.auth.user.id}
# #2213: симметрично с /trade-in/api/* — перезаписываем секрет из env
# (стирает клиентский), на случай SSR-forwardʼa фронтом в backend.
header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
}
}
# Auth gate — с #2558 применяется ТОЛЬКО к Site Finder (handle /api/* и
# handle {} ниже). Trade-In уже отработал и short-circuit'нул выше.
import caddy/users.caddy.snippet
handle /api/* {
reverse_proxy backend:8000 {
header_up X-Authenticated-User {http.auth.user.id}

View file

@ -1,127 +0,0 @@
-- Migration 193: seed существующих юзеров в tradein_users + ASCII-CHECK на username
-- (issue #2557, эпик #2549)
--
-- WHY:
-- Migration 192 создала schema (tradein_users/tradein_sessions), но без данных —
-- DB-backed auth не может заработать, пока реальные аккаунты (сейчас живущие только
-- в auth/roles.yaml + caddy/users.caddy.snippet, legacy Caddy basic-auth) не отражены
-- в таблице. Эта миграция переносит org-карту, утверждённую владельцем продукта,
-- в данные — без единого пароля (см. WHAT.2) и без Python-кода (backend wiring — #2556,
-- team-UI для проставления паролей — отдельная задача, тоже #2556).
--
-- ASCII-CHECK (deep-review #2561, обязательное требование ДО прод-данных):
-- rbac кодирует session-username через `encode("latin-1", "replace")`. Кириллические
-- логины ОДИНАКОВОЙ длины схлопываются в одну и ту же byte-строку под этой кодировкой
-- ("иванов" и "петров" оба 6 кириллических символов -> оба превращаются в одинаковую
-- строку из '?' одной длины) -> общий downstream-identity между разными людьми, общая
-- квота, взаимный IDOR (один видит сессии/данные другого). Все текущие org-логины уже
-- ASCII (admin/kopylov/praktika/userN), поэтому constraint не конфликтует с seed'ом
-- ниже; он существует, чтобы navsegda запретить будущим кириллическим логинам попасть
-- в таблицу — fail-closed на уровне схемы, а не на уровне доверия к тому, что кто-то
-- не забудет проверить в UI/API layer.
--
-- WHAT:
-- 1. ASCII-CHECK: tradein_users_username_ascii_ck CHECK (username ~ '^[A-Za-z0-9._-]{3,64}$').
-- Добавлен ДО seed-инсертов ниже для читаемости файла (CHECK — immediate constraint,
-- Postgres валидирует им и ROW-строки транзакции независимо от того, в каком месте
-- файла он объявлен относительно INSERT, так что порядок сам по себе не критичен).
-- 2. Seed — org-карта, утверждённая владельцем продукта (2026-07-30):
-- admin role=admin, manager_id=NULL, is_active=true (владелец)
-- kopylov role=manager, manager_id=NULL, is_active=true (отдельный клиент)
-- praktika role=manager, manager_id=NULL, is_active=true (ГК «Практика»)
-- user1, user3-10 role=employee, manager_id=NULL, is_active=true (свободные слоты, без org)
-- user2 role=employee, manager_id=NULL, is_active=false («Брусника», доступ
-- закрыт 2026-07-30)
-- password_hash = NULL для ВСЕХ — пароли админ проставит вручную через team-UI (#2556).
-- NULL-hash делает password-логин невозможным для этой строки, но НЕ снимает доступ:
-- в переходный период работает только legacy Caddy basic-auth fallback (dual-mode,
-- см. комментарий password_hash в 192_tradein_users_auth.sql) — никто не теряет доступ
-- из-за этой миграции.
-- display_name = 'Копылов' для kopylov (источник — auth.py::_USERNAME_PROFILE, уже
-- задокументированная фамилия). Для остальных — NULL, реальных данных нет, не выдумываем.
-- НЕ мигрируем admintest/pilottest/analysttest/expiredtest — temp QA-фикстуры
-- (auth/roles.yaml), остаются только там, в DB-backed auth не нужны.
--
-- IDEMPOTENCY:
-- - ADD CONSTRAINT через DO-блок с проверкой pg_constraint (Postgres не поддерживает
-- `ADD CONSTRAINT IF NOT EXISTS` для CHECK) — паттерн из
-- 189_account_estimate_usage_nonnegative.sql.
-- - INSERT ... ON CONFLICT (username) DO UPDATE, но НЕ безусловно: password_hash,
-- manager_id, display_name, org_name, email защищены COALESCE(текущее, EXCLUDED) —
-- если админ уже проставил пароль / назначил manager_id (team-API #2563 пишет
-- manager_id при создании сотрудника менеджером) / поменял display_name вручную
-- через team-UI (#2556) между двумя прогонами этого файла (например ручной re-apply
-- при recovery — обычный auto-apply тречит filename в _schema_migrations и не
-- запускает файл дважды на одном окружении, но scratch/staging БД такого
-- трекинга не имеют), повторный прогон НЕ должен затереть это состояние NULL-ом /
-- seed-дефолтом. Deep-review #2564 нашёл это живым багом: manager_id, назначенный
-- через #2563, тихо обнулялся повторным прогоном сида — employee выпадал из
-- `_LIST_EMPLOYEES_BY_MANAGER_SQL`, менеджер переставал видеть его в дашборде.
-- role намеренно синкается с EXCLUDED (не защищён) — это и есть источник истины
-- org-карты из этой миграции; если владелец продукта поправит эту таблицу новой
-- миграцией поверх, DO UPDATE-ветка должна донести исправление роли, а не
-- заморозить первый прогон навсегда.
-- - is_active НАМЕРЕННО отсутствует в SET (не COALESCE — колонка NOT NULL DEFAULT
-- true, COALESCE(NOT NULL, x) никогда не берёт x, это была бы мёртвая, вводящая в
-- заблуждение симметрия с password_hash/manager_id, deep-review #2564 medium).
-- Открытие/закрытие доступа (is_active) — решение владельца продукта, принимается
-- через UI (#2556), НЕ повторным прогоном этого seed-файла: после первой вставки
-- колонка сознательно «замораживается» на текущем значении в БД, seed её больше
-- не трогает.
--
-- Dependencies: 192_tradein_users_auth.sql (создаёт tradein_users, tradein_sessions).
-- Deploy order: после 192 (Foundation). Backend auth-код (login/password-verify) и
-- team-UI (#2556) — отдельные PR'ы ПОСЛЕ этой миграции (SQL-схема+данные первыми, см.
-- .claude/rules/sql.md "Migration order").
BEGIN;
-- Часть 1: ASCII-CHECK (immediate constraint — валидирует и вставляемые ниже строки).
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'tradein_users_username_ascii_ck'
) THEN
ALTER TABLE tradein_users
ADD CONSTRAINT tradein_users_username_ascii_ck
CHECK (username ~ '^[A-Za-z0-9._-]{3,64}$');
END IF;
END $$;
COMMENT ON CONSTRAINT tradein_users_username_ascii_ck ON tradein_users IS
'Fail-closed защита от кириллических/не-ASCII логинов (deep-review #2561): '
'rbac кодирует session-username через encode("latin-1","replace"), не-ASCII '
'логины одинаковой длины схлопываются в общий downstream-identity (IDOR).';
-- Часть 2: seed org-карты (владелец продукта, 2026-07-30).
INSERT INTO tradein_users
(username, password_hash, role, manager_id, display_name, org_name, email, is_active)
VALUES
('admin', NULL, 'admin', NULL, NULL, NULL, NULL, true),
('kopylov', NULL, 'manager', NULL, 'Копылов', NULL, NULL, true),
('praktika', NULL, 'manager', NULL, NULL, NULL, NULL, true),
('user1', NULL, 'employee', NULL, NULL, NULL, NULL, true),
('user2', NULL, 'employee', NULL, NULL, NULL, NULL, false),
('user3', NULL, 'employee', NULL, NULL, NULL, NULL, true),
('user4', NULL, 'employee', NULL, NULL, NULL, NULL, true),
('user5', NULL, 'employee', NULL, NULL, NULL, NULL, true),
('user6', NULL, 'employee', NULL, NULL, NULL, NULL, true),
('user7', NULL, 'employee', NULL, NULL, NULL, NULL, true),
('user8', NULL, 'employee', NULL, NULL, NULL, NULL, true),
('user9', NULL, 'employee', NULL, NULL, NULL, NULL, true),
('user10', NULL, 'employee', NULL, NULL, NULL, NULL, true)
ON CONFLICT (username) DO UPDATE SET
role = EXCLUDED.role,
-- manager_id защищён COALESCE: team-API (#2563) пишет manager_id при назначении
-- сотрудника менеджером, повторный прогон seed'а не должен тихо обнулять эту связь.
manager_id = COALESCE(tradein_users.manager_id, EXCLUDED.manager_id),
password_hash = COALESCE(tradein_users.password_hash, EXCLUDED.password_hash),
display_name = COALESCE(tradein_users.display_name, EXCLUDED.display_name),
org_name = COALESCE(tradein_users.org_name, EXCLUDED.org_name),
email = COALESCE(tradein_users.email, EXCLUDED.email),
-- is_active НЕ в SET: NOT NULL DEFAULT true колонка, COALESCE был бы мёртвым кодом
-- (см. IDEMPOTENCY выше) — open/close доступа решается через UI (#2556), не seed'ом.
updated_at = now();
COMMIT;

View file

@ -1,180 +0,0 @@
"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 её уже прячет).
*
* #2556 fix (эпик #2549, live-audit): team-роуты требуют session-cookie
* (см. team-api.ts header comment) легаси Caddy-заголовок (`/me` 200 с
* ролью admin/manager) через него НЕ проходит. Юзер, прошедший только Caddy
* basic_auth (без входа в форму `/login`), видел здесь непонятную красную
* плашку «Не удалось выполнить запрос». Ловим именно 401 от employeesQ
* ОТДЕЛЬНО от 403 (403 = «нет прав», это ожидаемо и есть у другой роли; 401
* = «нет сессии вообще», это решается логином) и уводим на /login?next=/team
* тот же redirect-паттерн, что RouteGuard.tsx использует для 401 от /me.
*/
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
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;
const TEAM_LOGIN_NEXT = `/login?next=${encodeURIComponent("/team")}`;
export default function TeamPage() {
const meQ = useMe();
const router = useRouter();
// Вычисляем ДО 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, isAllowedRole);
const [showCreateForm, setShowCreateForm] = useState(false);
const [quotaEmployee, setQuotaEmployee] = useState<Employee | null>(null);
const [historyEmployee, setHistoryEmployee] = useState<Employee | null>(null);
// 401 = легаси Caddy-роль прошла role-gate выше, но team-эндпоинт требует
// session-cookie, которой у юзера нет (см. header comment). Отдельно от 403
// (та ветка — «роль есть, прав недостаточно», ниже).
const sessionRequired =
employeesQ.error instanceof HTTPError && employeesQ.error.status === 401;
// Редирект — побочный эффект (нельзя router.push во время рендера), тот же
// паттерн, что RouteGuard.tsx использует для 401 от /me. NODE_ENV-гейт
// зеркалит RouteGuard: в dev без Caddy/логина 401 — обычное дело, авто-
// редирект там мешал бы локальной разработке; вместо него — сообщение с
// кнопкой ниже.
useEffect(() => {
if (!sessionRequired) return;
if (process.env.NODE_ENV !== "production") return;
router.push(TEAM_LOGIN_NEXT);
}, [sessionRequired, router]);
// Роль ещё не загружена — не решаем ничего, 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>
</>
);
}
if (!isAllowedRole) {
return <NoAccessScreen variant="user" />;
}
if (sessionRequired) {
// Prod: редирект уже запущен эффектом выше — не мигаем error-текстом,
// пока навигация не завершится (тот же приём, что RouteGuard.tsx).
if (process.env.NODE_ENV === "production") return null;
return (
<>
<Topbar active="team" />
<main className="page scraper-page" style={{ maxWidth: 1180 }}>
<h1 className="scraper-h1">Команда</h1>
<p className="scraper-result scraper-result--error">
Войдите в систему, чтобы управлять командой.
</p>
<a
href={TEAM_LOGIN_NEXT}
className="team-btn-primary"
style={{ display: "inline-block", textDecoration: "none" }}
>
Войти
</a>
</main>
</>
);
}
// 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

@ -53,7 +53,6 @@ import type {
TradeInEstimateInput,
} from "@/types/trade-in";
import { HTTPError } from "@/lib/api";
import { isPathAllowed } from "@/lib/isPathAllowed";
import {
useEstimate,
useEstimateHistory,
@ -667,21 +666,6 @@ export default function TradeInV2Page() {
};
}, [me.data]);
// #2556 (эпик #2549): вход в раздел «Команда» из v2-навигации — раньше
// TopNav (v2) не знал про team-дашборд вообще, он был достижим только из
// legacy Topbar.tsx (пункт в NAV_ITEMS), а `/trade-in/` редиректит на
// `/trade-in/v2` → дашборд существовал, но был недостижим кликом. Тот же
// двойной гейт, что и Topbar.NAV_ITEMS "team": isPathAllowed(scopePath) —
// DB-роль `manager` получает голый `/api/v1/team/**` в allowed_paths (см.
// DB_ROLE_PATHS, tradein-mvp/backend/app/services/auth_session.py) — ПЛЮС
// roleGate поверх, потому что legacy-роль `analyst` (auth/roles.yaml,
// paths: "/**", без явного deny на /api/v1/team) тоже прошла бы
// isPathAllowed, но не должна видеть «Команда» (scope: только admin/manager).
const showTeamNavItem =
me.data != null &&
isPathAllowed(me.data.allowed_paths, me.data.deny_paths, "/api/v1/team") &&
(me.data.role === "admin" || me.data.role === "manager");
// Analog price pins for the 01 map, projected from the real estimate. No
// estimate → mapMarkers(null) → [] → ParamsPanel renders only the subject pin
// (Finding #2: never the static fixture price markers).
@ -994,7 +978,6 @@ export default function TradeInV2Page() {
reports={reportsCount ?? 0}
user={topNavUser}
onLogout={() => logoutMutation.mutate()}
showTeamLink={showTeamNavItem}
/>
</nav>
<main

View file

@ -1,219 +0,0 @@
"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

@ -1,94 +0,0 @@
"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

@ -1,205 +0,0 @@
"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>
</>
);
}

View file

@ -1,144 +0,0 @@
"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,7 +7,6 @@ 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 пусто до тех
@ -85,8 +84,7 @@ export type ActiveTab =
| "cian"
| "yandex"
| "audit"
| "analytics"
| "team";
| "analytics";
interface TopbarProps {
active: ActiveTab;
@ -113,13 +111,6 @@ 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/**).
@ -171,16 +162,6 @@ 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) {
@ -195,10 +176,8 @@ 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) &&
(item.roleGate ? item.roleGate(data.role) : true),
? NAV_ITEMS.filter((item) =>
isPathAllowed(data.allowed_paths, data.deny_paths, item.scopePath),
)
: NAV_ITEMS;

View file

@ -2820,322 +2820,3 @@ 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);
}

View file

@ -10,8 +10,6 @@
import { useState } from "react";
import type { CSSProperties } from "react";
import { API_BASE_URL } from "@/lib/api";
import { tokens } from "./tokens";
import { navLabels, version } from "./fixtures";
import { useSupportChat } from "./SupportChatContext";
@ -37,12 +35,6 @@ interface TopNavProps {
user?: TopNavUser;
// Sign out — page wires this to logout(); closes the menu first.
onLogout?: () => void;
// #2556 (эпик #2549): показывать пункт «Команда» в user-меню — вычисляется
// страницей из useMe() (роль admin/manager + isPathAllowed на /api/v1/team,
// тот же двойной гейт, что и Topbar.tsx NAV_ITEMS "team"). false/undefined
// → пункт не рендерится (роль employee/pilot/analyst/expired или /me ещё
// не загрузился).
showTeamLink?: boolean;
}
// Neutral fallback when the user prop is absent (loading / unauthenticated).
@ -65,24 +57,6 @@ const menuItemStyle: CSSProperties = {
transition: "background .12s",
};
/** Inline Users icon (lucide-react `Users` SVG path, stroke 1.2) для
пункта «Команда» в user-меню (#2556). Тот же inline-SVG паттерн (15x15,
tokens.muted), что и остальные иконки этого дропдауна. */
function UsersIcon() {
return (
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
<circle cx="5.3" cy="5" r="2.2" stroke={tokens.muted} strokeWidth="1.2" />
<path
d="M1 13c0-2.6 1.9-3.9 4.3-3.9S9.6 10.4 9.6 13"
stroke={tokens.muted}
strokeWidth="1.2"
/>
<path d="M9.3 1.8a2.1 2.1 0 0 1 0 4" stroke={tokens.muted} strokeWidth="1.2" />
<path d="M11 9.5c1.9.4 3 1.6 3 3.5" stroke={tokens.muted} strokeWidth="1.2" />
</svg>
);
}
// Профиль / Настройки have no pages yet — render them dimmed and
// non-interactive (no hover class, default cursor) so they read as disabled.
// «Помощь» used to be in this group too, then linked out to the Telegram
@ -100,7 +74,6 @@ export default function TopNav({
reports = 0,
user,
onLogout,
showTeamLink = false,
}: TopNavProps) {
const [userOpen, setUserOpen] = useState(false);
const { openChat } = useSupportChat();
@ -516,28 +489,6 @@ export default function TopNav({
Помощь
</button>
{/* «Команда» (#2556, эпик #2549) team-дашборд (`/team`) отдельный
роут, не таб SectionOverlay, поэтому это ссылка в user-меню
рядом с «Выйти», а не пункт в tnav-tab-ряду выше: v2-табы
это разделы ОДНОЙ страницы (переключают `active`/onNavigate),
а «Команда» уводит на другой URL. Гейт (showTeamLink) считает
страница из useMe() здесь только рендер. */}
{showTeamLink && (
<a
role="menuitem"
href={`${API_BASE_URL}/team`}
className="tnav-menuitem"
style={{
...menuItemStyle,
textDecoration: "none",
}}
onClick={() => setUserOpen(false)}
>
<UsersIcon />
Команда
</a>
)}
<div
style={{
height: "1px",

View file

@ -1,195 +0,0 @@
"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 все).
*
* `enabled` (default true) вызывающая страница обязана передать `false`
* для ролей, которым эндпоинт всё равно ответит 401/403 (employee/analyst/
* pilot и т.п.): без этого прямой заход на /team шлёт обречённый round-trip
* ДО отрисовки role-gate, который лишний и палит наличие эндпоинта.
*/
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,
});
}
/**
* 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 "Не удалось выполнить запрос. Попробуйте ещё раз";
}