mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 17:14:59 +03:00
♻️ feat(ui)!: «Настройки» одним экраном — Организация, Группы, AI-провайдер, Интеграции, Профиль (§8.6)
Вертикальные секции вместо отдельных разделов: «Организация» и «Интеграции» упразднены (старые URL ведут в настройки), IntegrationsPage разобран на IntegrationsSection по роду, добавлено управление группами (создание одним полем, переименование, удаление). «Ещё» в сайдбаре: Обзор, Обзор поддержки, Знания, Использование AI, Аудит. Сотруднику — только профильная часть (тема, акцент, безопасность). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
45605e9a45
commit
ba405bb32b
16 files changed
+345
-86
No files matched your search
@@ -35,7 +35,7 @@ describe("role-based navigation (SPEC-HUB-0031 §3)", () => {
|
||||
expect(canAccess(user, "employees")).toBe(true);
|
||||
expect(canAccess(user, "agents")).toBe(true);
|
||||
expect(canAccess(user, "aiKnowledge")).toBe(true);
|
||||
expect(canAccess(user, "administrationOrganization")).toBe(true);
|
||||
expect(canAccess(user, "administrationAudit")).toBe(true);
|
||||
expect(defaultRoute(user)).toBe("command");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -23,8 +23,8 @@ export function AdministrationPage({
|
||||
const page = useAdministration({ section, user, onUserUpdated });
|
||||
const header = (
|
||||
<PageHeader
|
||||
title="Администрирование"
|
||||
text="Настройки организации и журнал действий"
|
||||
title="Аудит"
|
||||
text="Журнал действий в организации"
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { administrationSection } from "./model";
|
||||
|
||||
describe("administration model", () => {
|
||||
it("maps administration routes to their sections", () => {
|
||||
expect(administrationSection("administrationOrganization")).toBe("organization");
|
||||
expect(administrationSection("administrationAudit")).toBe("audit");
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,7 @@
|
||||
import type { RouteKey, SessionUser } from "../../types";
|
||||
import { hasCapability } from "../../auth/access";
|
||||
|
||||
export type AdministrationRoute = Extract<
|
||||
RouteKey,
|
||||
"administrationOrganization" | "administrationAudit"
|
||||
>;
|
||||
export type AdministrationRoute = Extract<RouteKey, "administrationAudit">;
|
||||
|
||||
export type AdministrationSection = "organization" | "audit";
|
||||
|
||||
|
||||
+25
-51
@@ -2,23 +2,22 @@ import { Modal } from "antd";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { api } from "../../api/client";
|
||||
import { EmptyState, LoadingState, PageHeader } from "../../shared/ui";
|
||||
import { Button, UnderlineTabs } from "../../shared/ui-controls";
|
||||
import { EmptyState, LoadingState } from "../../shared/ui";
|
||||
import { Button } from "../../shared/ui-controls";
|
||||
import { ConnectionsTable } from "./ConnectionsTable";
|
||||
import { IntegrationForm } from "./IntegrationForm";
|
||||
import { KIND_LABEL, type Integration, type IntegrationKind } from "./model";
|
||||
import type { Integration, IntegrationKind } from "./model";
|
||||
import { ProvidersTable } from "./ProvidersTable";
|
||||
|
||||
// Страница «Интеграции» (SPEC-HUB-0025 §2): два таба по родам — подключения
|
||||
// (MESSENGER) и LLM-провайдеры; кнопка создания контекстна активному табу.
|
||||
type FormState = { initial: Integration | null; kind: IntegrationKind } | null;
|
||||
// Секция интеграций одного рода — блок экрана «Настройки» (SPEC-HUB-0031
|
||||
// §8.6): подключения (MESSENGER) и AI-провайдеры (LLM_PROVIDER) — отдельные
|
||||
// вертикальные секции, отдельный раздел «Интеграции» упразднён.
|
||||
|
||||
export function IntegrationsPage() {
|
||||
export function IntegrationsSection({ kind }: { kind: IntegrationKind }) {
|
||||
const [items, setItems] = useState<Integration[]>([]);
|
||||
const [tab, setTab] = useState<IntegrationKind>("MESSENGER");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [form, setForm] = useState<FormState>(null);
|
||||
const [form, setForm] = useState<{ initial: Integration | null } | null>(null);
|
||||
const [testingId, setTestingId] = useState<number | null>(null);
|
||||
const [deleting, setDeleting] = useState<Integration | null>(null);
|
||||
const [deletingError, setDeletingError] = useState<string | null>(null);
|
||||
@@ -28,13 +27,13 @@ export function IntegrationsPage() {
|
||||
setError(false);
|
||||
try {
|
||||
const payload = await api<{ items: Integration[] }>("/api/v1/integrations/");
|
||||
setItems(payload.items);
|
||||
setItems(payload.items.filter((item) => item.kind === kind));
|
||||
} catch {
|
||||
setError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [kind]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
@@ -66,11 +65,6 @@ export function IntegrationsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function startDelete(integration: Integration) {
|
||||
setDeletingError(null);
|
||||
setDeleting(integration);
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleting) return;
|
||||
try {
|
||||
@@ -82,56 +76,36 @@ export function IntegrationsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const connections = items.filter((item) => item.kind === "MESSENGER");
|
||||
const providers = items.filter((item) => item.kind === "LLM_PROVIDER");
|
||||
const isConnections = tab === "MESSENGER";
|
||||
const shown = isConnections ? connections : providers;
|
||||
const isConnections = kind === "MESSENGER";
|
||||
const rowHandlers = {
|
||||
testingId,
|
||||
onTest: test,
|
||||
onEdit: (item: Integration) => setForm({ initial: item, kind: item.kind }),
|
||||
onEdit: (item: Integration) => setForm({ initial: item }),
|
||||
onToggleActive: toggleActive,
|
||||
onDelete: startDelete,
|
||||
onDelete: (item: Integration) => { setDeletingError(null); setDeleting(item); },
|
||||
};
|
||||
|
||||
const header = (
|
||||
<PageHeader
|
||||
title="Интеграции"
|
||||
text="Подключения мессенджеров, почты и LLM-провайдеры · заводятся и проверяются здесь"
|
||||
action={
|
||||
<Button variant="primary" icon="plus" onClick={() => setForm({ initial: null, kind: tab })}>
|
||||
{isConnections ? "Добавить подключение" : "Добавить провайдера"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
if (loading) return <div className="integrations-page">{header}<LoadingState /></div>;
|
||||
if (error) return <div className="integrations-page">{header}<EmptyState title="Не удалось загрузить интеграции" /></div>;
|
||||
if (loading) return <LoadingState />;
|
||||
if (error) return <EmptyState title="Не удалось загрузить интеграции" />;
|
||||
|
||||
return (
|
||||
<div className="integrations-page">
|
||||
{header}
|
||||
<UnderlineTabs
|
||||
className="integrations-tabs"
|
||||
items={[
|
||||
{ key: "MESSENGER", label: KIND_LABEL.MESSENGER, count: connections.length },
|
||||
{ key: "LLM_PROVIDER", label: KIND_LABEL.LLM_PROVIDER, count: providers.length },
|
||||
]}
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
/>
|
||||
{shown.length === 0 ? (
|
||||
<div className="integrations-section">
|
||||
<div className="integrations-section-actions">
|
||||
<Button variant="primary" icon="plus" onClick={() => setForm({ initial: null })}>
|
||||
{isConnections ? "Добавить подключение" : "Добавить провайдера"}
|
||||
</Button>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<EmptyState title={isConnections ? "Подключений пока нет. Добавьте бота, почту или Web-виджет." : "Провайдеров пока нет. Добавьте OpenRouter или Custom endpoint."} />
|
||||
) : isConnections ? (
|
||||
<ConnectionsTable items={connections} {...rowHandlers} />
|
||||
<ConnectionsTable items={items} {...rowHandlers} />
|
||||
) : (
|
||||
<ProvidersTable items={providers} {...rowHandlers} />
|
||||
<ProvidersTable items={items} {...rowHandlers} />
|
||||
)}
|
||||
{form && (
|
||||
<IntegrationForm
|
||||
initial={form.initial}
|
||||
kind={form.kind}
|
||||
kind={kind}
|
||||
onClose={() => setForm(null)}
|
||||
onSaved={() => { setForm(null); void load(); }}
|
||||
/>
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Modal } from "antd";
|
||||
import { useState } from "react";
|
||||
|
||||
import { api } from "../../api/client";
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { Button } from "../../shared/ui-controls";
|
||||
import type { EmployeeGroup } from "../../types";
|
||||
|
||||
// Секция «Группы» экрана настроек (SPEC-HUB-0031 §8.4/§8.6): список с числом
|
||||
// участников, создание — одно поле, переименование и удаление. Состав группы
|
||||
// редактируется на карточке сотрудника.
|
||||
|
||||
export function GroupsSettingsCard({ groups, reload }: { groups: EmployeeGroup[]; reload: () => void }) {
|
||||
const [name, setName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [errorText, setErrorText] = useState("");
|
||||
const [renaming, setRenaming] = useState<EmployeeGroup | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [deleting, setDeleting] = useState<EmployeeGroup | null>(null);
|
||||
const [modalError, setModalError] = useState("");
|
||||
|
||||
async function create() {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed || creating) return;
|
||||
setCreating(true);
|
||||
setErrorText("");
|
||||
try {
|
||||
await api("/api/v1/company/groups/", { method: "POST", body: JSON.stringify({ name: trimmed }) });
|
||||
setName("");
|
||||
reload();
|
||||
} catch (caught) {
|
||||
setErrorText(caught instanceof Error ? caught.message : "Не удалось создать группу");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function rename() {
|
||||
if (!renaming) return;
|
||||
setModalError("");
|
||||
try {
|
||||
await api(`/api/v1/company/groups/${renaming.id}/`, { method: "PATCH", body: JSON.stringify({ name: renameValue.trim() }) });
|
||||
setRenaming(null);
|
||||
reload();
|
||||
} catch (caught) {
|
||||
setModalError(caught instanceof Error ? caught.message : "Не удалось переименовать");
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!deleting) return;
|
||||
setModalError("");
|
||||
try {
|
||||
await api(`/api/v1/company/groups/${deleting.id}/`, { method: "DELETE" });
|
||||
setDeleting(null);
|
||||
reload();
|
||||
} catch (caught) {
|
||||
setModalError(caught instanceof Error ? caught.message : "Не удалось удалить группу");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="profile-card">
|
||||
<h3>Группы</h3>
|
||||
<p className="settings-section-note">Видимость диалогов и охват чата. Состав группы — на карточке сотрудника.</p>
|
||||
<div className="settings-groups-list">
|
||||
{groups.length === 0 && <p className="settings-section-note">Групп пока нет — все видят все диалоги.</p>}
|
||||
{groups.map((group) => (
|
||||
<div className="settings-group-row" key={group.id}>
|
||||
<i className="chat-scope-dot" />
|
||||
<strong>{group.name}</strong>
|
||||
<small>{group.memberCount} чел.</small>
|
||||
<button aria-label="Переименовать" type="button" onClick={() => { setModalError(""); setRenaming(group); setRenameValue(group.name); }}><Icon name="edit" size={14} /></button>
|
||||
<button aria-label="Удалить" type="button" onClick={() => { setModalError(""); setDeleting(group); }}><Icon name="trash" size={14} /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="settings-group-create">
|
||||
<input
|
||||
placeholder="Название новой группы"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
onKeyDown={(event) => { if (event.key === "Enter") void create(); }}
|
||||
/>
|
||||
<Button variant="secondary" icon="plus" disabled={!name.trim() || creating} onClick={() => void create()}>Создать</Button>
|
||||
</div>
|
||||
{errorText && <div className="settings-section-error">{errorText}</div>}
|
||||
{renaming && (
|
||||
<Modal open title="Переименовать группу" onCancel={() => setRenaming(null)} footer={null} destroyOnClose>
|
||||
<div className="integration-form">
|
||||
<input className="settings-modal-input" value={renameValue} onChange={(event) => setRenameValue(event.target.value)} />
|
||||
{modalError && <div className="integration-form-error">{modalError}</div>}
|
||||
<div className="integration-form-actions">
|
||||
<Button variant="secondary" onClick={() => setRenaming(null)}>Отмена</Button>
|
||||
<Button variant="primary" disabled={!renameValue.trim()} onClick={() => void rename()}>Сохранить</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
{deleting && (
|
||||
<Modal open title="Удалить группу?" onCancel={() => setDeleting(null)} footer={null} destroyOnClose>
|
||||
<div className="integration-form">
|
||||
<p>{`«${deleting.name}» будет удалена; её диалоги станут «Без группы».`}</p>
|
||||
{modalError && <div className="integration-form-error">{modalError}</div>}
|
||||
<div className="integration-form-actions">
|
||||
<Button variant="secondary" onClick={() => setDeleting(null)}>Отмена</Button>
|
||||
<Button variant="danger-outline" onClick={() => void remove()}>Удалить</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,90 @@
|
||||
import type { SessionUser } from "../../types";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { isManager } from "../../auth/access";
|
||||
import type { EmployeeGroup, SessionUser } from "../../types";
|
||||
import { OrganizationSettingsForm } from "../administration/OrganizationSettingsForm";
|
||||
import { canManageSettings } from "../administration/model";
|
||||
import { useAdministration } from "../administration/useAdministration";
|
||||
import { IntegrationsSection } from "../integrations/IntegrationsSection";
|
||||
import { ProfileAppearanceCard } from "../profile/ProfileAppearanceCard";
|
||||
import { ProfileNotificationsCard } from "../profile/ProfileNotificationsCard";
|
||||
import { ProfilePasswordForm } from "../profile/ProfilePasswordForm";
|
||||
import { ProfileSessionsCard } from "../profile/ProfileSessionsCard";
|
||||
import { ProfileTotpCard } from "../profile/ProfileTotpCard";
|
||||
import { useProfilePage } from "../profile/useProfilePage";
|
||||
import { EmptyState, LoadingState } from "../../shared/ui";
|
||||
import { GroupsSettingsCard } from "./GroupsSettingsCard";
|
||||
|
||||
export function SettingsPage({ user, onUserUpdated, reload }: { user: SessionUser; onUserUpdated: (user: SessionUser) => void; reload: () => void }) {
|
||||
// «Настройки» (SPEC-HUB-0031 §8.6): один экран с вертикальными секциями —
|
||||
// Организация · Группы · AI-провайдер · Интеграции · Профиль. Отдельные
|
||||
// разделы «Организация» и «Интеграции» упразднены. Сотруднику доступна
|
||||
// только профильная часть (тема, акцент, безопасность).
|
||||
|
||||
export function SettingsPage({ user, onUserUpdated, reload, groups = [] }: { user: SessionUser; onUserUpdated: (user: SessionUser) => void; reload: () => void; groups?: EmployeeGroup[] }) {
|
||||
const profilePage = useProfilePage({ user, onUserUpdated, reload });
|
||||
const manager = isManager(user);
|
||||
|
||||
return (
|
||||
<div className="profile-stack">
|
||||
const profileSections = (
|
||||
<>
|
||||
<ProfileAppearanceCard user={user} onUserUpdated={onUserUpdated} />
|
||||
<ProfilePasswordForm passwords={profilePage.passwords} message={profilePage.passwordMessage} mismatch={profilePage.passwordMismatch} ready={profilePage.passwordReady} saving={profilePage.savingPassword} setPasswords={profilePage.setPasswords} onSubmit={profilePage.updatePassword} />
|
||||
<ProfileNotificationsCard />
|
||||
<ProfileTotpCard user={user} message={profilePage.totpMessage} saving={profilePage.savingTotp} onToggle={profilePage.toggleTotp} />
|
||||
<ProfileSessionsCard message={profilePage.sessionsMessage} revoking={profilePage.revokingSessions} onRevoke={profilePage.revokeOtherSessions} />
|
||||
</>
|
||||
);
|
||||
|
||||
if (!manager) {
|
||||
return <div className="profile-stack">{profileSections}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="profile-stack settings-screen">
|
||||
<SettingsSection title="Организация">
|
||||
<OrganizationSection user={user} onUserUpdated={onUserUpdated} />
|
||||
</SettingsSection>
|
||||
<SettingsSection title="Группы">
|
||||
<GroupsSettingsCard groups={groups} reload={reload} />
|
||||
</SettingsSection>
|
||||
<SettingsSection title="AI-провайдер" note="BYOK: ключи вашей организации для ответов агентов и расшифровки голосовых">
|
||||
<IntegrationsSection kind="LLM_PROVIDER" />
|
||||
</SettingsSection>
|
||||
<SettingsSection title="Интеграции" note="Боты, почта и Web-виджет — точки входа диалогов">
|
||||
<IntegrationsSection kind="MESSENGER" />
|
||||
</SettingsSection>
|
||||
<SettingsSection title="Профиль">{profileSections}</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsSection({ title, note, children }: { title: string; note?: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="settings-section">
|
||||
<header className="settings-section-head">
|
||||
<h2>{title}</h2>
|
||||
{note && <span>{note}</span>}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function OrganizationSection({ user, onUserUpdated }: { user: SessionUser; onUserUpdated: (user: SessionUser) => void }) {
|
||||
const page = useAdministration({ section: "organization", user, onUserUpdated });
|
||||
if (page.loading) return <LoadingState />;
|
||||
if (!page.organization) return <EmptyState title={page.error || "Не удалось загрузить настройки"} />;
|
||||
return (
|
||||
<OrganizationSettingsForm
|
||||
organization={page.organization}
|
||||
canManage={canManageSettings(user)}
|
||||
saving={page.saving}
|
||||
message={page.message}
|
||||
error={page.error}
|
||||
timezones={page.timezones}
|
||||
onChange={page.setOrganization}
|
||||
onSave={() => void page.save()}
|
||||
onUploadLogo={(file) => void page.uploadLogo(file)}
|
||||
onRemoveLogo={() => void page.removeLogo()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/* Экран «Настройки» (SPEC-HUB-0031 §8.6): вертикальные секции. */
|
||||
.settings-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-section-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.settings-section-head h2 {
|
||||
margin: 0;
|
||||
color: var(--n-1);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settings-section-head span {
|
||||
color: var(--n-4);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.settings-section-note {
|
||||
margin: 0;
|
||||
color: var(--n-4);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.settings-section-error {
|
||||
color: var(--error);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.settings-groups-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-group-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 8px 2px;
|
||||
border-bottom: 1px solid var(--n-9);
|
||||
}
|
||||
|
||||
.settings-group-row strong {
|
||||
color: var(--n-1);
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.settings-group-row small {
|
||||
flex: 1;
|
||||
color: var(--n-4);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.settings-group-row button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
color: var(--n-4);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.settings-group-row button:hover {
|
||||
color: var(--n-2);
|
||||
background: var(--n-9);
|
||||
}
|
||||
|
||||
.settings-group-create {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-group-create input,
|
||||
.settings-modal-input {
|
||||
flex: 1;
|
||||
height: 34px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid var(--n-7);
|
||||
border-radius: 8px;
|
||||
color: var(--n-1);
|
||||
background: var(--surface-card);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.settings-modal-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.integrations-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.integrations-section-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@@ -26,7 +26,7 @@ type Step = {
|
||||
|
||||
const STEPS: Step[] = [
|
||||
{ key: "agentCreated", label: "Создать агента", action: "Создать агента", route: "agents", icon: "robot" },
|
||||
{ key: "connectionBound", label: "Подключить точку входа", action: "Подключить", route: "integrations", icon: "plug" },
|
||||
{ key: "connectionBound", label: "Подключить точку входа", action: "Подключить", route: "settings", icon: "plug" },
|
||||
{ key: "employeeInvited", label: "Пригласить сотрудников", action: "Пригласить", route: "employees", icon: "team" },
|
||||
];
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { KnowledgeDetailPage } from "../features/ai/knowledge/KnowledgeDetailPag
|
||||
import { KnowledgeCreatePage } from "../features/ai/knowledge/KnowledgeCreatePage";
|
||||
import { KnowledgePage } from "../features/ai/knowledge/KnowledgePage";
|
||||
import { CommandCenter } from "../features/command/CommandCenter";
|
||||
import { IntegrationsPage } from "../features/integrations/IntegrationsPage";
|
||||
import { EmployeeDetailPage, EmployeesPage } from "../features/employees/EmployeesPage";
|
||||
import { ProfilePage } from "../features/profile/ProfilePage";
|
||||
import { SettingsPage } from "../features/settings/SettingsPage";
|
||||
@@ -36,9 +35,8 @@ export function ShellRouteContent({ chatScope, setChatScope, chatCounters, chatS
|
||||
{route === "employeeDetail" && currentEmployee && <EmployeeDetailPage groups={data.groups} employee={currentEmployee} reload={reload} setRoute={setRoute} user={user} />}
|
||||
{route === "employeeDetail" && !currentEmployee && <EmployeesPage groups={data.groups} employees={data.employees} reload={reload} openEmployee={openEmployee} setRoute={setRoute} user={user} />}
|
||||
{route === "profile" && <ProfilePage user={user} onUserUpdated={onUserUpdated} reload={reload} onLogout={onLogout} />}
|
||||
{route === "settings" && <SettingsPage user={user} onUserUpdated={onUserUpdated} reload={reload} />}
|
||||
{(route === "administrationOrganization"
|
||||
|| route === "administrationAudit") && (
|
||||
{route === "settings" && <SettingsPage user={user} onUserUpdated={onUserUpdated} reload={reload} groups={data.groups} />}
|
||||
{route === "administrationAudit" && (
|
||||
<AdministrationPage
|
||||
route={route}
|
||||
user={user}
|
||||
@@ -71,7 +69,7 @@ export function ShellRouteContent({ chatScope, setChatScope, chatCounters, chatS
|
||||
canManageConnections={hasCapability(user, "integrations.manage")}
|
||||
openAgents={() => setRoute("agents")}
|
||||
openKnowledge={openKnowledge}
|
||||
openIntegrations={() => setRoute("integrations")}
|
||||
openIntegrations={() => setRoute("settings")}
|
||||
setRoute={setRoute}
|
||||
onLoaded={onAgentLoaded}
|
||||
/>
|
||||
@@ -79,7 +77,6 @@ export function ShellRouteContent({ chatScope, setChatScope, chatCounters, chatS
|
||||
{route === "aiKnowledge" && <KnowledgePage openAgent={openAgent} openKnowledge={openKnowledge} setRoute={setRoute} user={user} />}
|
||||
{route === "aiKnowledgeCreate" && <KnowledgeCreatePage openKnowledge={openKnowledge} setRoute={setRoute} />}
|
||||
{route === "aiKnowledgeDetail" && <KnowledgeDetailPage agents={data.agents} canManage={hasCapability(user, "ai.manage")} knowledgeId={selectedKnowledgeId} openAgent={openAgent} setRoute={setRoute} onLoaded={onAgentLoaded} />}
|
||||
{route === "integrations" && <IntegrationsPage />}
|
||||
{route === "supportPortals" && <Suspense fallback={<LoadingState />}><SupportPortalsPage user={user} openPortal={openSupportPortal} /></Suspense>}
|
||||
{route === "supportPortalDetail" && <Suspense fallback={<LoadingState />}><SupportPortalDetailPage portalId={selectedSupportPortalId} products={data.products} user={user} openPortals={() => setRoute("supportPortals")} /></Suspense>}
|
||||
</>
|
||||
|
||||
@@ -44,8 +44,6 @@ const SERVICE_ITEMS: SidebarNavSectionItem[] = [
|
||||
{ activeRoutes: ["supportOverview"], key: "supportOverview", label: "Обзор поддержки" },
|
||||
{ activeRoutes: ["aiKnowledge", "aiKnowledgeCreate", "aiKnowledgeDetail"], key: "aiKnowledge", label: "Знания" },
|
||||
{ activeRoutes: ["aiUsage"], key: "aiUsage", label: "Использование AI" },
|
||||
{ activeRoutes: ["integrations"], key: "integrations", label: "Интеграции" },
|
||||
{ activeRoutes: ["administrationOrganization"], key: "administrationOrganization", label: "Организация" },
|
||||
{ activeRoutes: ["administrationAudit"], key: "administrationAudit", label: "Аудит" },
|
||||
];
|
||||
|
||||
|
||||
@@ -76,28 +76,31 @@ describe("chat route", () => {
|
||||
});
|
||||
|
||||
describe("administration route", () => {
|
||||
it("keeps the old URL as an organization alias", () => {
|
||||
it("sends legacy administration URLs to settings (§8.6)", () => {
|
||||
expect(routeFromPath("/administration")).toEqual({
|
||||
route: "administrationOrganization",
|
||||
route: "settings",
|
||||
...empty,
|
||||
});
|
||||
expect(routeFromPath("/integrations")).toEqual({
|
||||
route: "settings",
|
||||
...empty,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses and creates administration subsection URLs", () => {
|
||||
// Организация переехала в «Настройки» (§8.6); тарифы — устаревший адрес (ADR-HUB-0042).
|
||||
expect(routeFromPath("/administration/organization")).toEqual({
|
||||
route: "administrationOrganization",
|
||||
route: "settings",
|
||||
...empty,
|
||||
});
|
||||
// Устаревший адрес тарифов ведёт в организацию (ADR-HUB-0042).
|
||||
expect(routeFromPath("/administration/subscription")).toEqual({
|
||||
route: "administrationOrganization",
|
||||
route: "settings",
|
||||
...empty,
|
||||
});
|
||||
expect(routeFromPath("/administration/audit")).toEqual({
|
||||
route: "administrationAudit",
|
||||
...empty,
|
||||
});
|
||||
expect(pathFromRoute("administrationOrganization")).toBe("/administration/organization");
|
||||
expect(pathFromRoute("administrationAudit")).toBe("/administration/audit");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,10 +60,11 @@ export function routeFromPath(pathname: string, search = ""): RouteState {
|
||||
return Number.isInteger(id) && id > 0 ? { ...state, route: "aiKnowledgeDetail", knowledgeId: id } : { route: "aiKnowledge", ...state };
|
||||
}
|
||||
if (path === "/ai/usage") return { route: "aiUsage", ...state };
|
||||
if (path === "/integrations") return { route: "integrations", ...state };
|
||||
// /administration/subscription — устаревший адрес тарифов (ADR-HUB-0042), ведёт в организацию.
|
||||
// Устаревшие адреса: интеграции и организация переехали в «Настройки» (§8.6).
|
||||
if (path === "/integrations") return { route: "settings", ...state };
|
||||
// /administration/subscription — устаревший адрес тарифов (ADR-HUB-0042).
|
||||
if (path === "/administration" || path === "/administration/organization" || path === "/administration/subscription") {
|
||||
return { route: "administrationOrganization", ...state };
|
||||
return { route: "settings", ...state };
|
||||
}
|
||||
if (path === "/administration/audit") {
|
||||
return { route: "administrationAudit", ...state };
|
||||
@@ -90,8 +91,6 @@ export function pathFromRoute(route: RouteKey, entityId: number | null = null, p
|
||||
if (route === "aiKnowledge") return `${prefix}/ai/knowledge`;
|
||||
if (route === "aiKnowledgeCreate") return `${prefix}/ai/knowledge/new`;
|
||||
if (route === "aiKnowledgeDetail") return entityId ? `${prefix}/ai/knowledge/${entityId}` : `${prefix}/ai/knowledge`;
|
||||
if (route === "integrations") return `${prefix}/integrations`;
|
||||
if (route === "administrationOrganization") return `${prefix}/administration/organization`;
|
||||
if (route === "administrationAudit") return `${prefix}/administration/audit`;
|
||||
if (route === "profile") return `${prefix}/profile`;
|
||||
if (route === "settings") return `${prefix}/settings`;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { RouteKey } from "./types";
|
||||
|
||||
export const routes: Record<RouteKey, string> = {
|
||||
administrationOrganization: "Организация",
|
||||
administrationAudit: "Аудит",
|
||||
command: "Обзор",
|
||||
employeeDetail: "Сотрудники",
|
||||
@@ -20,5 +19,4 @@ export const routes: Record<RouteKey, string> = {
|
||||
aiKnowledgeCreate: "Создание знания",
|
||||
aiKnowledgeDetail: "Знание",
|
||||
aiUsage: "Использование AI",
|
||||
integrations: "Интеграции",
|
||||
};
|
||||
@@ -24,5 +24,6 @@
|
||||
@import "./features/ai/knowledge/styles-category.css";
|
||||
@import "./features/integrations/styles.css";
|
||||
@import "./features/administration/styles.css";
|
||||
@import "./features/settings/styles.css";
|
||||
@import "./features/notifications/styles.css";
|
||||
@import "./shared/state.css";
|
||||
@@ -115,7 +115,7 @@ export type Product = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type RouteKey = "administrationOrganization" | "administrationAudit" | "command" | "employeeDetail" | "employees" | "profile" | "settings" | "salesClientDetail" | "salesClients" | "chat" | "supportOverview" | "supportPortals" | "supportPortalDetail" | "agents" | "agentDetail" | "aiKnowledge" | "aiKnowledgeCreate" | "aiKnowledgeDetail" | "aiUsage" | "integrations";
|
||||
export type RouteKey = "administrationAudit" | "command" | "employeeDetail" | "employees" | "profile" | "settings" | "salesClientDetail" | "salesClients" | "chat" | "supportOverview" | "supportPortals" | "supportPortalDetail" | "agents" | "agentDetail" | "aiKnowledge" | "aiKnowledgeCreate" | "aiKnowledgeDetail" | "aiUsage";
|
||||
|
||||
export type AppData = {
|
||||
employees: Employee[];
|
||||
|
||||
Reference in new issue
Block a user