diff --git a/apps/internal-ui/src/auth/access.test.ts b/apps/internal-ui/src/auth/access.test.ts index ab7b45e..9a01c6d 100644 --- a/apps/internal-ui/src/auth/access.test.ts +++ b/apps/internal-ui/src/auth/access.test.ts @@ -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"); } }); diff --git a/apps/internal-ui/src/features/administration/AdministrationPage.tsx b/apps/internal-ui/src/features/administration/AdministrationPage.tsx index 7c7a3cc..59af443 100644 --- a/apps/internal-ui/src/features/administration/AdministrationPage.tsx +++ b/apps/internal-ui/src/features/administration/AdministrationPage.tsx @@ -23,8 +23,8 @@ export function AdministrationPage({ const page = useAdministration({ section, user, onUserUpdated }); const header = ( ); diff --git a/apps/internal-ui/src/features/administration/model.test.ts b/apps/internal-ui/src/features/administration/model.test.ts index 9b2e52d..529748c 100644 --- a/apps/internal-ui/src/features/administration/model.test.ts +++ b/apps/internal-ui/src/features/administration/model.test.ts @@ -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"); }); }); diff --git a/apps/internal-ui/src/features/administration/model.ts b/apps/internal-ui/src/features/administration/model.ts index 778ca4b..670bfa7 100644 --- a/apps/internal-ui/src/features/administration/model.ts +++ b/apps/internal-ui/src/features/administration/model.ts @@ -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; export type AdministrationSection = "organization" | "audit"; diff --git a/apps/internal-ui/src/features/integrations/IntegrationsPage.tsx b/apps/internal-ui/src/features/integrations/IntegrationsSection.tsx similarity index 61% rename from apps/internal-ui/src/features/integrations/IntegrationsPage.tsx rename to apps/internal-ui/src/features/integrations/IntegrationsSection.tsx index d23e9da..e3e8591 100644 --- a/apps/internal-ui/src/features/integrations/IntegrationsPage.tsx +++ b/apps/internal-ui/src/features/integrations/IntegrationsSection.tsx @@ -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([]); - const [tab, setTab] = useState("MESSENGER"); const [loading, setLoading] = useState(true); const [error, setError] = useState(false); - const [form, setForm] = useState(null); + const [form, setForm] = useState<{ initial: Integration | null } | null>(null); const [testingId, setTestingId] = useState(null); const [deleting, setDeleting] = useState(null); const [deletingError, setDeletingError] = useState(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 = ( - setForm({ initial: null, kind: tab })}> - {isConnections ? "Добавить подключение" : "Добавить провайдера"} - - } - /> - ); - - if (loading) return
{header}
; - if (error) return
{header}
; + if (loading) return ; + if (error) return ; return ( -
- {header} - - {shown.length === 0 ? ( +
+
+ +
+ {items.length === 0 ? ( ) : isConnections ? ( - + ) : ( - + )} {form && ( setForm(null)} onSaved={() => { setForm(null); void load(); }} /> diff --git a/apps/internal-ui/src/features/settings/GroupsSettingsCard.tsx b/apps/internal-ui/src/features/settings/GroupsSettingsCard.tsx new file mode 100644 index 0000000..b36f476 --- /dev/null +++ b/apps/internal-ui/src/features/settings/GroupsSettingsCard.tsx @@ -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(null); + const [renameValue, setRenameValue] = useState(""); + const [deleting, setDeleting] = useState(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 ( +
+

Группы

+

Видимость диалогов и охват чата. Состав группы — на карточке сотрудника.

+
+ {groups.length === 0 &&

Групп пока нет — все видят все диалоги.

} + {groups.map((group) => ( +
+ + {group.name} + {group.memberCount} чел. + + +
+ ))} +
+
+ setName(event.target.value)} + onKeyDown={(event) => { if (event.key === "Enter") void create(); }} + /> + +
+ {errorText &&
{errorText}
} + {renaming && ( + setRenaming(null)} footer={null} destroyOnClose> +
+ setRenameValue(event.target.value)} /> + {modalError &&
{modalError}
} +
+ + +
+
+
+ )} + {deleting && ( + setDeleting(null)} footer={null} destroyOnClose> +
+

{`«${deleting.name}» будет удалена; её диалоги станут «Без группы».`}

+ {modalError &&
{modalError}
} +
+ + +
+
+
+ )} +
+ ); +} diff --git a/apps/internal-ui/src/features/settings/SettingsPage.tsx b/apps/internal-ui/src/features/settings/SettingsPage.tsx index 164d517..a10a454 100644 --- a/apps/internal-ui/src/features/settings/SettingsPage.tsx +++ b/apps/internal-ui/src/features/settings/SettingsPage.tsx @@ -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 ( -
+ const profileSections = ( + <> + + ); + + if (!manager) { + return
{profileSections}
; + } + + return ( +
+ + + + + + + + + + + + + {profileSections}
); } + +function SettingsSection({ title, note, children }: { title: string; note?: string; children: ReactNode }) { + return ( +
+
+

{title}

+ {note && {note}} +
+ {children} +
+ ); +} + +function OrganizationSection({ user, onUserUpdated }: { user: SessionUser; onUserUpdated: (user: SessionUser) => void }) { + const page = useAdministration({ section: "organization", user, onUserUpdated }); + if (page.loading) return ; + if (!page.organization) return ; + return ( + void page.save()} + onUploadLogo={(file) => void page.uploadLogo(file)} + onRemoveLogo={() => void page.removeLogo()} + /> + ); +} diff --git a/apps/internal-ui/src/features/settings/styles.css b/apps/internal-ui/src/features/settings/styles.css new file mode 100644 index 0000000..dd7cde7 --- /dev/null +++ b/apps/internal-ui/src/features/settings/styles.css @@ -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; +} diff --git a/apps/internal-ui/src/layout/LaunchChecklist.tsx b/apps/internal-ui/src/layout/LaunchChecklist.tsx index b1f5b00..2ef63e5 100644 --- a/apps/internal-ui/src/layout/LaunchChecklist.tsx +++ b/apps/internal-ui/src/layout/LaunchChecklist.tsx @@ -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" }, ]; diff --git a/apps/internal-ui/src/layout/ShellRouteContent.tsx b/apps/internal-ui/src/layout/ShellRouteContent.tsx index c20d769..7dd3ca3 100644 --- a/apps/internal-ui/src/layout/ShellRouteContent.tsx +++ b/apps/internal-ui/src/layout/ShellRouteContent.tsx @@ -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 && } {route === "employeeDetail" && !currentEmployee && } {route === "profile" && } - {route === "settings" && } - {(route === "administrationOrganization" - || route === "administrationAudit") && ( + {route === "settings" && } + {route === "administrationAudit" && ( 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" && } {route === "aiKnowledgeCreate" && } {route === "aiKnowledgeDetail" && } - {route === "integrations" && } {route === "supportPortals" && }>} {route === "supportPortalDetail" && }> setRoute("supportPortals")} />} diff --git a/apps/internal-ui/src/layout/Sidebar.tsx b/apps/internal-ui/src/layout/Sidebar.tsx index 501ae1b..fa6698b 100644 --- a/apps/internal-ui/src/layout/Sidebar.tsx +++ b/apps/internal-ui/src/layout/Sidebar.tsx @@ -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: "Аудит" }, ]; diff --git a/apps/internal-ui/src/router.test.ts b/apps/internal-ui/src/router.test.ts index 55a1cf4..3ffcf8e 100644 --- a/apps/internal-ui/src/router.test.ts +++ b/apps/internal-ui/src/router.test.ts @@ -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"); }); }); diff --git a/apps/internal-ui/src/router.ts b/apps/internal-ui/src/router.ts index 097992f..41fd068 100644 --- a/apps/internal-ui/src/router.ts +++ b/apps/internal-ui/src/router.ts @@ -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`; diff --git a/apps/internal-ui/src/routes.ts b/apps/internal-ui/src/routes.ts index 3e98578..07199fd 100644 --- a/apps/internal-ui/src/routes.ts +++ b/apps/internal-ui/src/routes.ts @@ -1,7 +1,6 @@ import type { RouteKey } from "./types"; export const routes: Record = { - administrationOrganization: "Организация", administrationAudit: "Аудит", command: "Обзор", employeeDetail: "Сотрудники", @@ -20,5 +19,4 @@ export const routes: Record = { aiKnowledgeCreate: "Создание знания", aiKnowledgeDetail: "Знание", aiUsage: "Использование AI", - integrations: "Интеграции", }; diff --git a/apps/internal-ui/src/styles.css b/apps/internal-ui/src/styles.css index 16bd9e4..5858acd 100644 --- a/apps/internal-ui/src/styles.css +++ b/apps/internal-ui/src/styles.css @@ -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"; diff --git a/apps/internal-ui/src/types.ts b/apps/internal-ui/src/types.ts index 3f84134..cecbff9 100644 --- a/apps/internal-ui/src/types.ts +++ b/apps/internal-ui/src/types.ts @@ -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[];