Merge pull request 'feat(tradein/ui): team-дашборд менеджера — сотрудники, квоты, история (#2556)' (#2565) from feat/tradein-team-dashboard into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m27s
Deploy Trade-In / deploy (push) Successful in 1m1s
All checks were successful
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m27s
Deploy Trade-In / deploy (push) Successful in 1m1s
This commit is contained in:
commit
80d54cb5cd
8 changed files with 1327 additions and 3 deletions
127
tradein-mvp/frontend/src/app/team/page.tsx
Normal file
127
tradein-mvp/frontend/src/app/team/page.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
"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();
|
||||
// Вычисляем ДО 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);
|
||||
|
||||
// Роль ещё не загружена — не решаем ничего, 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" />;
|
||||
}
|
||||
|
||||
// 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
219
tradein-mvp/frontend/src/components/team/CreateEmployeeForm.tsx
Normal file
219
tradein-mvp/frontend/src/components/team/CreateEmployeeForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
205
tradein-mvp/frontend/src/components/team/EmployeeTable.tsx
Normal file
205
tradein-mvp/frontend/src/components/team/EmployeeTable.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
"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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
144
tradein-mvp/frontend/src/components/team/QuotaEditor.tsx
Normal file
144
tradein-mvp/frontend/src/components/team/QuotaEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
195
tradein-mvp/frontend/src/lib/team-api.ts
Normal file
195
tradein-mvp/frontend/src/lib/team-api.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
"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 → все).
|
||||
*
|
||||
* `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 "Не удалось выполнить запрос. Попробуйте ещё раз";
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue