mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 01:14:58 +03:00
✨ feat(webchat): перенести настройки WEB-подключения на страницу
This commit is contained in:
1 parent
36c95199a0
commit
dc6e38b689
26 files changed
+438
-43
No files matched your search
@@ -0,0 +1,37 @@
|
||||
---
|
||||
id: T-008
|
||||
title: Страница веб-подключения с разделами «Основные» и «Удалить подключение»
|
||||
milestone: M01
|
||||
status: done
|
||||
depends_on:
|
||||
- T-007
|
||||
order: 2
|
||||
spec: "0002"
|
||||
created: 2026-09-29
|
||||
branch: skaro/T-008-stranitsa-veb-podklyucheniya-s
|
||||
---
|
||||
|
||||
## Цель
|
||||
|
||||
Редактирование WEB-подключения переезжает из модалки на страницу /settings/integrations/{id} с субменю: Основные · Данные с сайта · Форма перед чатом · Оформление · Удалить подключение (SPEC-0002 R-11).
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- [x] Клик по веб-подключению в настройках открывает страницу /settings/integrations/{id}, прямая ссылка на неё работает после перезагрузки
|
||||
- [x] «Основные» содержит название, разрешённые домены с прежней валидацией и код вставки; в шапке страницы — код вставки с кнопкой «Копировать»
|
||||
- [x] «Удалить подключение» делает то же, что прежнее удаление, с подтверждением
|
||||
- [x] Пункты «Данные с сайта», «Форма перед чатом», «Оформление» есть в меню, но их содержимое не маскируется под готовое — до своих задач раздел не показывается или показывает только заголовок по согласованию с владельцем
|
||||
- [x] Создание подключения и редактирование других провайдеров по-прежнему в модалке IntegrationForm
|
||||
- [x] Страница — composition-only, файлы не больше ~200–300 строк, все тексты через t()
|
||||
|
||||
## Заметки
|
||||
|
||||
Макет: design/baseline/Веб-чат · поля и оформление (шапка «Сайт obed.ru», субменю кадров W1–W3); раздел «Основные» в макете не нарисован — по README это текущее содержимое модалки. Сейчас: features/integrations/IntegrationForm.tsx, features/settings/SettingsPage.tsx. PATCH должен сохранять прочие ключи config (title, accent, consent и т.д.), а не затирать их.
|
||||
|
||||
## Итог
|
||||
|
||||
Тестовые учётные данные нашёл в [манифесте демо-набора](C:/Users/drmar/AppData/Roaming/Skaro/worktrees/70a06986-4881-4d6d-865c-fa6636fc00b4/T-008/apps/backend/chatballs/identity/demo_seed/data/ru/organization.json): учётные записи указаны в `accounts`, общий пароль — в `demoPassword`. Ссылку на источник и указание не запускать seed ради проверки добавил в [AGENTS.md](C:/Users/drmar/AppData/Roaming/Skaro/worktrees/70a06986-4881-4d6d-865c-fa6636fc00b4/T-008/AGENTS.md).
|
||||
|
||||
Задача T-008 готова и зафиксирована двумя коммитами. На штатном frontend и реальном backend проверил прямую ссылку после перезагрузки, поля и валидацию, копирование кода, сохранность остальных настроек, диалог удаления, недоступные будущие разделы и прежние модалки для создания и других провайдеров. Само удаление демо-подключения не выполнял; проверил подтверждение и прежний DELETE-вызов в коде. Сборка и 30 связанных тестов прошли, структура файлов проверена.
|
||||
|
||||
Skaro отметил все 6 критериев выполненными и показал карточку для слияния задачи.
|
||||
@@ -11,6 +11,7 @@
|
||||
Используй glab, gh если доступны.
|
||||
|
||||
Учетные данные / доступы:
|
||||
- Для штатной локальной браузерной проверки тестовые учётные записи демо-сотрудников перечислены в `apps/backend/chatballs/identity/demo_seed/data/ru/organization.json` (`accounts`, включая роль `ADMIN`); общий пароль — поле `demoPassword` того же файла. Это также описано в `.skaro/specs/0006-demonstratsionnye-dannye.md` основной копии проекта (R-5). Проверить, что демо-набор уже установлен в используемой базе; не запускать seed ради проверки.
|
||||
- Без явного указания владельца не менять, не сбрасывать, не bootstrap-ить и не пересоздавать никакие учетные данные, пароли, TOTP, recovery-коды, сессии и права доступа.
|
||||
- Если для проверки нужен вход в приложение, использовать только уже существующие учетные данные, предоставленные владельцем, и не выполнять команды, которые могут изменить пароль или состояние аккаунта.
|
||||
- Любые команды управления аккаунтами, включая `bootstrap_owner`, password reset/change, seed пользователей и изменение ролей, требуют отдельного явного подтверждения владельца перед запуском.
|
||||
|
||||
@@ -250,7 +250,7 @@ export function App() {
|
||||
// Ключ организации — на провайдере: при переключении сокет обязан
|
||||
// переоткрыться на адрес новой, а не остаться на прежней.
|
||||
<RealtimeProvider key={user.organizationPublicId}>
|
||||
<Shell key={user.organizationPublicId} route={navigation.route} setRoute={(nextRoute) => navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedChannelId={navigation.selectedChannelId} openChannelRoute={(channelId) => navigate("agentDetail", channelId)} selectedSupportPortalId={navigation.selectedSupportPortalId} openSupportPortalRoute={(portalId) => navigate("supportPortalDetail", portalId)} portalSettingsSection={navigation.selectedPortalSection} openPortalSettingsRoute={(portalId, section) => navigate("supportPortalSettings", `${portalId}/${section ?? ""}`)} settingsSection={navigation.selectedSettingsSection} openSettingsRoute={(section) => navigate("settings", section)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openAgentRoute={(agentId) => navigate("agentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("knowledgeDetail", knowledgeId)} openKnowledgeEditorRoute={(knowledgeId) => (knowledgeId === null ? navigate("knowledgeCreate") : navigate("knowledgeEdit", knowledgeId))} openConversationRoute={(conversationId) => navigate("chat", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} onSwitchOrganization={switchOrganization} onOrganizationCreated={finishOrganizationCreate} />
|
||||
<Shell key={user.organizationPublicId} route={navigation.route} setRoute={(nextRoute) => navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedChannelId={navigation.selectedChannelId} openChannelRoute={(channelId) => navigate("agentDetail", channelId)} selectedIntegrationId={navigation.selectedIntegrationId} openWebIntegrationRoute={(id) => navigate("webIntegrationSettings", id)} selectedSupportPortalId={navigation.selectedSupportPortalId} openSupportPortalRoute={(portalId) => navigate("supportPortalDetail", portalId)} portalSettingsSection={navigation.selectedPortalSection} openPortalSettingsRoute={(portalId, section) => navigate("supportPortalSettings", `${portalId}/${section ?? ""}`)} settingsSection={navigation.selectedSettingsSection} openSettingsRoute={(section) => navigate("settings", section)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openAgentRoute={(agentId) => navigate("agentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("knowledgeDetail", knowledgeId)} openKnowledgeEditorRoute={(knowledgeId) => (knowledgeId === null ? navigate("knowledgeCreate") : navigate("knowledgeEdit", knowledgeId))} openConversationRoute={(conversationId) => navigate("chat", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} onSwitchOrganization={switchOrganization} onOrganizationCreated={finishOrganizationCreate} />
|
||||
</RealtimeProvider>
|
||||
)}
|
||||
</ConfigProvider>
|
||||
|
||||
@@ -42,7 +42,9 @@ export function ConnectionsTable({ items, testingId, onTest, onEdit, onToggleAct
|
||||
<td>
|
||||
<div className="product-cell">
|
||||
<ConnectionIcon provider={item.provider} />
|
||||
<span><strong>{item.name}</strong><small>{subtitle(item)}</small></span>
|
||||
<span>{item.provider === "WEB"
|
||||
? <button className="link is-strong" type="button" onClick={() => onEdit(item)}>{item.name}</button>
|
||||
: <strong>{item.name}</strong>}<small>{subtitle(item)}</small></span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Modal } from "antd";
|
||||
import { useState } from "react";
|
||||
|
||||
import { api } from "../../api/client";
|
||||
import { t } from "../../i18n";
|
||||
import { Button } from "../../shared/ui-controls";
|
||||
import type { Integration } from "./model";
|
||||
|
||||
export function DeleteIntegrationDialog({ integration, onClose, onDeleted }: {
|
||||
integration: Integration;
|
||||
onClose: () => void;
|
||||
onDeleted: () => void;
|
||||
}) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function confirmDelete() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api(`/api/v1/integrations/${integration.id}/`, { method: "DELETE" });
|
||||
onDeleted();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : t("settings.could_not_delete"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open title={t("settings.delete_integration")} onCancel={onClose} footer={null} destroyOnClose>
|
||||
<div className="integration-form">
|
||||
<p>{t("settings.will_be_deleted_irreversible", { name: integration.name })}</p>
|
||||
{error && <div className="integration-form-error">{error}</div>}
|
||||
<div className="integration-form-actions">
|
||||
<Button variant="secondary" disabled={busy} onClick={onClose}>{t("common.cancel")}</Button>
|
||||
<Button variant="danger-outline" disabled={busy} onClick={() => void confirmDelete()}>{t("common.delete")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Modal } from "antd";
|
||||
import { useState } from "react";
|
||||
|
||||
import { api } from "../../api/client";
|
||||
import { DeleteIntegrationDialog } from "./DeleteIntegrationDialog";
|
||||
import { EmptyState } from "../../shared/ui";
|
||||
import { Button } from "../../shared/ui-controls";
|
||||
import { ConnectionsTable } from "./ConnectionsTable";
|
||||
import type { Integration, IntegrationKind } from "./model";
|
||||
import { ProvidersTable } from "./ProvidersTable";
|
||||
@@ -22,7 +21,6 @@ export function IntegrationsSection({ kind, items, reload, onEdit }: {
|
||||
}) {
|
||||
const [testingId, setTestingId] = useState<number | null>(null);
|
||||
const [deleting, setDeleting] = useState<Integration | null>(null);
|
||||
const [deletingError, setDeletingError] = useState<string | null>(null);
|
||||
|
||||
async function test(integration: Integration) {
|
||||
setTestingId(integration.id);
|
||||
@@ -47,24 +45,13 @@ export function IntegrationsSection({ kind, items, reload, onEdit }: {
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleting) return;
|
||||
try {
|
||||
await api(`/api/v1/integrations/${deleting.id}/`, { method: "DELETE" });
|
||||
setDeleting(null);
|
||||
reload();
|
||||
} catch (caught) {
|
||||
setDeletingError(caught instanceof Error ? caught.message : t("settings.could_not_delete"));
|
||||
}
|
||||
}
|
||||
|
||||
const isConnections = kind === "MESSENGER";
|
||||
const rowHandlers = {
|
||||
testingId,
|
||||
onTest: test,
|
||||
onEdit,
|
||||
onToggleActive: toggleActive,
|
||||
onDelete: (item: Integration) => { setDeletingError(null); setDeleting(item); },
|
||||
onDelete: (item: Integration) => setDeleting(item),
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -76,21 +63,7 @@ export function IntegrationsSection({ kind, items, reload, onEdit }: {
|
||||
) : (
|
||||
<ProvidersTable items={items} {...rowHandlers} />
|
||||
)}
|
||||
{isConnections && items.length > 0 && (
|
||||
<p className="settings-section-note">{t("settings.web_widget_embed_snippet_copied")}</p>
|
||||
)}
|
||||
{deleting && (
|
||||
<Modal open title={t("settings.delete_integration")} onCancel={() => setDeleting(null)} footer={null} destroyOnClose>
|
||||
<div className="integration-form">
|
||||
<p>{t("settings.will_be_deleted_irreversible", { name: deleting.name })}</p>
|
||||
{deletingError && <div className="integration-form-error">{deletingError}</div>}
|
||||
<div className="integration-form-actions">
|
||||
<Button variant="secondary" onClick={() => setDeleting(null)}>{t("common.cancel")}</Button>
|
||||
<Button variant="danger-outline" onClick={() => void confirmDelete()}>{t("common.delete")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
{deleting && <DeleteIntegrationDialog integration={deleting} onClose={() => setDeleting(null)} onDeleted={() => { setDeleting(null); reload(); }} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { api } from "../../api/client";
|
||||
import { t } from "../../i18n";
|
||||
import { FormField } from "../../shared/form-controls";
|
||||
import { Button, CopyButton } from "../../shared/ui-controls";
|
||||
import {
|
||||
formatAllowedOrigins, invalidAllowedOrigin, parseAllowedOrigins,
|
||||
webWidgetSnippet, type Integration,
|
||||
} from "./model";
|
||||
|
||||
export function WebIntegrationBasics({ integration, onSaved }: {
|
||||
integration: Integration;
|
||||
onSaved: (updated: Integration) => void;
|
||||
}) {
|
||||
const [name, setName] = useState(integration.name);
|
||||
const [allowedOrigins, setAllowedOrigins] = useState(formatAllowedOrigins(integration.config.allowedOrigins));
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const origins = parseAllowedOrigins(allowedOrigins);
|
||||
const badOrigin = invalidAllowedOrigin(origins);
|
||||
const ready = Boolean(name.trim() && origins.length && !badOrigin);
|
||||
const snippet = integration.webChatWidget ? webWidgetSnippet(integration.webChatWidget.publicKey) : "";
|
||||
|
||||
async function save() {
|
||||
if (!ready) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api<{ integration: Integration }>(`/api/v1/integrations/${integration.id}/`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ name: name.trim(), config: { ...integration.config, allowedOrigins: origins } }),
|
||||
});
|
||||
onSaved(response.integration);
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : t("common.could_not_save"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="portal-settings-card web-integration-basics">
|
||||
<FormField label={t("common.title")} value={name} onChange={setName} />
|
||||
<div>
|
||||
<FormField
|
||||
label={t("settings.allowed_domains")}
|
||||
value={allowedOrigins}
|
||||
onChange={setAllowedOrigins}
|
||||
error={badOrigin && t("settings.unclear_domain", { domain: badOrigin })}
|
||||
placeholder={t("settings.example_com_example_com_comma")}
|
||||
/>
|
||||
<div className="integration-form-hint">{t("settings.sites_where_widget_may_open")}</div>
|
||||
</div>
|
||||
{snippet && <div className="web-integration-snippet">
|
||||
<FormField label={t("settings.embed_snippet_site")} mono value={snippet} />
|
||||
<CopyButton value={snippet} label={t("common.copy")} className="secondary-button" />
|
||||
</div>}
|
||||
{error && <div className="integration-form-error">{error}</div>}
|
||||
<div className="portal-settings-actions">
|
||||
<Button variant="primary" disabled={!ready || busy} onClick={() => void save()}>
|
||||
{busy ? t("ai.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { t } from "../../i18n";
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { CopyButton } from "../../shared/ui-controls";
|
||||
import { webWidgetSnippet, type Integration } from "./model";
|
||||
|
||||
export function WebIntegrationHeader({ integration, onOpenSettings, onBack }: {
|
||||
integration: Integration;
|
||||
onOpenSettings: () => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const snippet = integration.webChatWidget ? webWidgetSnippet(integration.webChatWidget.publicKey) : "";
|
||||
const working = integration.isActive && integration.status === "OK";
|
||||
|
||||
return (
|
||||
<header className="web-integration-head">
|
||||
<nav className="portal-breadcrumbs">
|
||||
<button className="link is-strong" type="button" onClick={onOpenSettings}>{t("common.settings")}</button>
|
||||
<span>/</span>
|
||||
<button className="link is-muted" type="button" onClick={onBack}>{t("common.integrations")}</button>
|
||||
<span>/</span><b>{integration.name}</b>
|
||||
</nav>
|
||||
<div className="web-integration-title">
|
||||
<span className="web-integration-icon"><Icon name="message" size={17} strokeWidth={1.9} /></span>
|
||||
<h2>{integration.name}</h2>
|
||||
{working && <span className="web-integration-status">{t("portals.working")}</span>}
|
||||
<span className="web-integration-agent">
|
||||
{integration.channel
|
||||
? t("settings.web_integration_agent", { name: integration.channel.name })
|
||||
: t("common.web_widget")}
|
||||
</span>
|
||||
</div>
|
||||
{snippet && <div className="web-integration-embed">
|
||||
<span className="web-integration-embed-code">
|
||||
<code>{snippet}</code>
|
||||
<CopyButton value={snippet} className="web-integration-copy" />
|
||||
</span>
|
||||
<span>{t("settings.embed_before_body_end")}</span>
|
||||
</div>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useState } from "react";
|
||||
import "../support-portals/styles-detail.css";
|
||||
import "../support-portals/styles-settings.css";
|
||||
|
||||
import { t } from "../../i18n";
|
||||
import { SectionMenu, type SectionMenuItem } from "../../shared/SectionMenu";
|
||||
import { EmptyState, LoadingState } from "../../shared/ui";
|
||||
import { DeleteIntegrationDialog } from "./DeleteIntegrationDialog";
|
||||
import { WebIntegrationBasics } from "./WebIntegrationBasics";
|
||||
import { WebIntegrationHeader } from "./WebIntegrationHeader";
|
||||
import { useWebIntegration } from "./useWebIntegration";
|
||||
|
||||
type Section = "basics" | "fields" | "form" | "look" | "danger";
|
||||
|
||||
const sections: SectionMenuItem<Section>[] = [
|
||||
{ key: "basics", label: t("portals.basics"), icon: "settings" },
|
||||
{ key: "fields", label: t("settings.site_data"), icon: "code", disabled: true },
|
||||
{ key: "form", label: t("settings.pre_chat_form"), icon: "doc", disabled: true },
|
||||
{ key: "look", label: t("common.appearance"), icon: "paint", disabled: true, divider: true },
|
||||
{ key: "danger", label: t("settings.delete_connection"), icon: "trash", danger: true },
|
||||
];
|
||||
|
||||
export function WebIntegrationPage({ integrationId, onOpenSettings, onBack }: {
|
||||
integrationId: number;
|
||||
onOpenSettings: () => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const { integration, loading, failed, setIntegration } = useWebIntegration(integrationId);
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
|
||||
if (loading) return <LoadingState />;
|
||||
if (failed) return <EmptyState title={t("settings.could_not_load_integrations")} />;
|
||||
if (!integration) return <EmptyState title={t("settings.web_integration_not_found")} />;
|
||||
|
||||
return (
|
||||
<section className="web-integration-page">
|
||||
<WebIntegrationHeader integration={integration} onOpenSettings={onOpenSettings} onBack={onBack} />
|
||||
<div className="portal-settings-layout">
|
||||
<SectionMenu
|
||||
items={sections}
|
||||
activeKey="basics"
|
||||
note={t("settings.web_changes_apply_after_save")}
|
||||
onSelect={(section) => { if (section === "danger") setConfirmingDelete(true); }}
|
||||
/>
|
||||
<div className="portal-settings-content">
|
||||
<div className="portal-settings-inner">
|
||||
<div className="portal-settings-heading">
|
||||
<h3>{t("portals.basics")}</h3>
|
||||
</div>
|
||||
<WebIntegrationBasics key={integration.id} integration={integration} onSaved={setIntegration} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{confirmingDelete && <DeleteIntegrationDialog integration={integration} onClose={() => setConfirmingDelete(false)} onDeleted={onBack} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { api } from "../../api/client";
|
||||
import type { Integration } from "./model";
|
||||
|
||||
export function useWebIntegration(id: number) {
|
||||
const [integration, setIntegration] = useState<Integration | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
setLoading(true);
|
||||
setFailed(false);
|
||||
api<{ items: Integration[] }>("/api/v1/integrations/")
|
||||
.then(({ items }) => setIntegration(items.find((item) => item.id === id && item.provider === "WEB") ?? null))
|
||||
.catch(() => setFailed(true))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
return { integration, loading, failed, reload, setIntegration };
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
.web-integration-page {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
background: var(--surface-feed);
|
||||
}
|
||||
|
||||
.web-integration-head {
|
||||
flex: none;
|
||||
padding: 18px 28px 16px;
|
||||
border-bottom: 1px solid var(--n-8);
|
||||
background: var(--surface-card);
|
||||
}
|
||||
|
||||
.web-integration-title, .web-integration-embed {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
}
|
||||
|
||||
.web-integration-title { margin-bottom: 10px; }
|
||||
.web-integration-title h2 {
|
||||
margin: 0;
|
||||
color: var(--n-1);
|
||||
font-size: 21px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
.web-integration-icon {
|
||||
display: grid;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, #0f9b8e 14%, var(--surface-card));
|
||||
color: #0f9b8e;
|
||||
}
|
||||
|
||||
.web-integration-status {
|
||||
padding: 4px 9px;
|
||||
border-radius: 6px;
|
||||
background: var(--success-bg);
|
||||
color: var(--success-text);
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.web-integration-agent { color: var(--n-4); font-size: 12.5px; }
|
||||
.web-integration-embed { gap: 8px; color: var(--n-4); font-size: 12px; }
|
||||
.web-integration-embed-code {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
max-width: 650px;
|
||||
height: 34px;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 0 5px 0 11px;
|
||||
border: 1px solid var(--n-7);
|
||||
border-radius: 9px;
|
||||
background: var(--surface-sidebar);
|
||||
}
|
||||
.web-integration-embed-code code {
|
||||
overflow: hidden;
|
||||
color: var(--n-2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12.5px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.web-integration-copy {
|
||||
display: grid;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--n-4);
|
||||
cursor: pointer;
|
||||
}
|
||||
.web-integration-copy:hover { background: var(--n-9); color: var(--n-2); }
|
||||
.web-integration-basics .integration-form-hint { margin-top: 4px; }
|
||||
.web-integration-snippet { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; }
|
||||
.web-integration-snippet .readonly-field { width: 100%; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.web-integration-head { padding: 16px; }
|
||||
.web-integration-title, .web-integration-embed { flex-wrap: wrap; }
|
||||
.web-integration-page .portal-settings-layout { flex-direction: column; }
|
||||
.web-integration-page .section-menu { width: 100%; border-right: 0; border-bottom: 1px solid var(--n-8); }
|
||||
.web-integration-page .section-menu-gap, .web-integration-page .section-menu p { display: none; }
|
||||
.web-integration-page .portal-settings-inner { padding: 20px 16px; }
|
||||
}
|
||||
@@ -42,13 +42,14 @@ const SECTION_TOUR_TARGET: Partial<Record<SettingsSectionKey, TourTarget>> = {
|
||||
// «Платформа» кадра в базлайне не имеет — раздел собран по стандарту N6
|
||||
// («Хранилище файлов»): заголовок карточки, поля, ряд действий.
|
||||
|
||||
export function SettingsPage({ user, onUserUpdated, reload, groups = [], section, openSection, setRoute }: {
|
||||
export function SettingsPage({ user, onUserUpdated, reload, groups = [], section, openSection, openWebIntegration, setRoute }: {
|
||||
user: SessionUser;
|
||||
onUserUpdated: (user: SessionUser) => void;
|
||||
reload: () => void;
|
||||
groups?: EmployeeGroup[];
|
||||
section: SettingsSectionKey | null;
|
||||
openSection: (section: SettingsSectionKey | null) => void;
|
||||
openWebIntegration: (id: number) => void;
|
||||
setRoute: (route: RouteKey) => void;
|
||||
}) {
|
||||
const manager = isManager(user);
|
||||
@@ -159,7 +160,7 @@ export function SettingsPage({ user, onUserUpdated, reload, groups = [], section
|
||||
integrations={integrations}
|
||||
providers={providers}
|
||||
connections={connections}
|
||||
onEditIntegration={(item) => setForm({ kind: item.kind, initial: item })}
|
||||
onEditIntegration={(item) => item.provider === "WEB" ? openWebIntegration(item.id) : setForm({ kind: item.kind, initial: item })}
|
||||
templates={templates}
|
||||
onEditTemplate={(item) => setTemplateForm({ initial: item })}
|
||||
/>
|
||||
|
||||
@@ -1527,6 +1527,7 @@ export const en: Record<MessageKey, Message> = {
|
||||
"settings.default_model": "Default model",
|
||||
"settings.delete_group": "Delete the group?",
|
||||
"settings.delete_integration": "Delete the integration?",
|
||||
"settings.delete_connection": "Delete connection",
|
||||
"settings.demo_contents": "Operators and groups, agents with connections, knowledge, conversations in every state, voice messages, reply templates, a support portal, calls and notifications. Removed as a whole — your own data is untouched.",
|
||||
"settings.demo_data": "Demo data",
|
||||
"settings.demo_data_being_installed_sections": "The demo data is being installed — the sections fill up in a few seconds.",
|
||||
@@ -1542,6 +1543,7 @@ export const en: Record<MessageKey, Message> = {
|
||||
"settings.email_not_configured_nothing_sent": "Email is not configured: nothing is sent, and inviting an operator will not work.",
|
||||
"settings.email_sent_to": "An email was sent to {email}. If it does not arrive, check the spam folder.",
|
||||
"settings.embed_snippet_site": "Embed snippet for the site",
|
||||
"settings.embed_before_body_end": "insert before </body>",
|
||||
"settings.endpoint_non_aws_providers": "Endpoint (for non-AWS providers)",
|
||||
"settings.entry_point": "Entry point",
|
||||
"settings.entry_point_off": " · switched off",
|
||||
@@ -1565,6 +1567,7 @@ export const en: Record<MessageKey, Message> = {
|
||||
"settings.installing": "Installing",
|
||||
"settings.installing_2": "Installing…",
|
||||
"settings.integration_actions": "Integration actions",
|
||||
"settings.pre_chat_form": "Pre-chat form",
|
||||
"settings.knowledge_attachments_voice_messages_photos": "Knowledge attachments, voice messages, photos and logos",
|
||||
"settings.knowledge_attachments_voice_messages_photos_2": "Knowledge attachments, voice messages, photos and logos will be written to the bucket under per-organization prefixes. Files already uploaded stay available from disk until they are migrated.",
|
||||
"settings.language": "Interface language",
|
||||
@@ -1628,6 +1631,7 @@ export const en: Record<MessageKey, Message> = {
|
||||
"settings.settings_available_owner_administrators": "Settings are available to the owner and administrators",
|
||||
"settings.sign_as_demo_operator_private": "Sign in as a demo operator in a private window to see the system through their eyes:",
|
||||
"settings.sites_where_widget_may_open": "Sites where the widget may open: a domain, subdomains via “*.” or a full origin with a port. Everywhere else the chat answers “Chat is temporarily unavailable”",
|
||||
"settings.site_data": "Site data",
|
||||
"settings.smtp_server": "SMTP server",
|
||||
"settings.storage_reachable_test_object_was": "The storage is reachable: a test object was written and deleted.",
|
||||
"settings.switched_off": "· switched off",
|
||||
@@ -1653,6 +1657,9 @@ export const en: Record<MessageKey, Message> = {
|
||||
"settings.voice_transcription_model": "Voice transcription model",
|
||||
"settings.was_already_created_by_installation": "was already created by the installation — there is nothing to enter.",
|
||||
"settings.web_widget_embed_snippet_copied": "The web widget embed snippet is copied from its agent's card.",
|
||||
"settings.web_changes_apply_after_save": "Changes appear on the site as soon as you save — there is no need to replace the embed snippet.",
|
||||
"settings.web_integration_agent": "Web widget · agent “{name}”",
|
||||
"settings.web_integration_not_found": "Web connection not found",
|
||||
"settings.where_customer_operator_can_record": "Where a customer and an operator can record voice messages and start audio and video calls.",
|
||||
"settings.where_keep_files": "Where to keep files",
|
||||
"settings.will_be_deleted_irreversible": "“{name}” will be deleted. This cannot be undone.",
|
||||
|
||||
@@ -1528,6 +1528,7 @@ export const ru = {
|
||||
"settings.default_model": "Модель по умолчанию",
|
||||
"settings.delete_group": "Удалить группу?",
|
||||
"settings.delete_integration": "Удалить интеграцию?",
|
||||
"settings.delete_connection": "Удалить подключение",
|
||||
"settings.demo_contents": "Сотрудники и группы, агенты с подключениями, знания, диалоги во всех состояниях, голосовые, шаблоны ответов, портал поддержки, звонки и уведомления. Удаляются целиком — ваши данные не затрагиваются.",
|
||||
"settings.demo_data": "Демо-данные",
|
||||
"settings.demo_data_being_installed_sections": "Демо-данные устанавливаются — разделы наполнятся через несколько секунд.",
|
||||
@@ -1543,6 +1544,7 @@ export const ru = {
|
||||
"settings.email_not_configured_nothing_sent": "Почта не настроена: письма никуда не уходят, пригласить сотрудника не получится.",
|
||||
"settings.email_sent_to": "Письмо отправлено на {email}. Не пришло — проверьте папку «Спам».",
|
||||
"settings.embed_snippet_site": "Код вставки на сайт",
|
||||
"settings.embed_before_body_end": "вставьте перед </body>",
|
||||
"settings.endpoint_non_aws_providers": "Endpoint (для не-AWS провайдеров)",
|
||||
"settings.entry_point": "Точка входа",
|
||||
"settings.entry_point_off": " · выключена",
|
||||
@@ -1566,6 +1568,7 @@ export const ru = {
|
||||
"settings.installing": "Устанавливаются",
|
||||
"settings.installing_2": "Устанавливаются…",
|
||||
"settings.integration_actions": "Действия интеграции",
|
||||
"settings.pre_chat_form": "Форма перед чатом",
|
||||
"settings.knowledge_attachments_voice_messages_photos": "Вложения знаний, голосовые, фото и логотипы",
|
||||
"settings.knowledge_attachments_voice_messages_photos_2": "Вложения знаний, голосовые, фото и логотипы будут записываться в бакет под префиксами организаций. Уже загруженные файлы остаются доступны с диска, пока не перенесены.",
|
||||
"settings.language": "Язык интерфейса",
|
||||
@@ -1629,6 +1632,7 @@ export const ru = {
|
||||
"settings.settings_available_owner_administrators": "Настройки доступны владельцу и администратору",
|
||||
"settings.sign_as_demo_operator_private": "Войдите под демо-сотрудником в приватном окне, чтобы увидеть систему его глазами:",
|
||||
"settings.sites_where_widget_may_open": "Сайты, на которых виджету разрешено открываться: домен, поддомены через «*.» или полный origin с портом. На остальных чат ответит «Чат временно недоступен»",
|
||||
"settings.site_data": "Данные с сайта",
|
||||
"settings.smtp_server": "SMTP-сервер",
|
||||
"settings.storage_reachable_test_object_was": "Хранилище доступно: пробный объект записан и удалён.",
|
||||
"settings.switched_off": "· выключена",
|
||||
@@ -1654,6 +1658,9 @@ export const ru = {
|
||||
"settings.voice_transcription_model": "Модель расшифровки голосовых",
|
||||
"settings.was_already_created_by_installation": "уже создан установкой — вводить его не нужно.",
|
||||
"settings.web_widget_embed_snippet_copied": "Код вставки Web-виджета копируется на карточке его агента.",
|
||||
"settings.web_changes_apply_after_save": "Изменения применяются к виджету на сайте сразу после сохранения — перевставлять код не нужно.",
|
||||
"settings.web_integration_agent": "Веб-виджет · агент «{name}»",
|
||||
"settings.web_integration_not_found": "Веб-подключение не найдено",
|
||||
"settings.where_customer_operator_can_record": "Где клиент и сотрудник могут записывать голосовые и начинать аудио- и видеозвонки.",
|
||||
"settings.where_keep_files": "Где хранить файлы",
|
||||
"settings.will_be_deleted_irreversible": "«{name}» будет удалена. Действие необратимо.",
|
||||
|
||||
@@ -19,7 +19,7 @@ import { OnboardingProvider, useOnboardingState } from "../features/onboarding/u
|
||||
import { UpdateBanner } from "../features/updates/UpdateBanner";
|
||||
import { ShellRouteContent } from "./ShellRouteContent";
|
||||
|
||||
export function Shell({ route, setRoute, settingsSection, openSettingsRoute, selectedEmployeeId, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, selectedChannelId, selectedSupportPortalId, portalSettingsSection, openChannelRoute, openSupportPortalRoute, openPortalSettingsRoute, openEmployeeRoute, openAgentRoute, openKnowledgeRoute, openKnowledgeEditorRoute, openConversationRoute, openClientRoute, user, data, reload, onUserUpdated, onLogout, onSwitchOrganization, onOrganizationCreated }: { route: RouteKey; setRoute: (route: RouteKey) => void; settingsSection: SettingsSectionKey | null; openSettingsRoute: (section: SettingsSectionKey | null) => void; selectedEmployeeId: number | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; selectedChannelId: number | null; selectedSupportPortalId: number | null; portalSettingsSection: PortalSettingsSectionKey | null; openEmployeeRoute: (employeeId: number) => void; openAgentRoute: (agentId: number) => void; openKnowledgeRoute: (knowledgeId: number) => void;
|
||||
export function Shell({ route, setRoute, settingsSection, openSettingsRoute, selectedEmployeeId, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, selectedChannelId, selectedIntegrationId, openWebIntegrationRoute, selectedSupportPortalId, portalSettingsSection, openChannelRoute, openSupportPortalRoute, openPortalSettingsRoute, openEmployeeRoute, openAgentRoute, openKnowledgeRoute, openKnowledgeEditorRoute, openConversationRoute, openClientRoute, user, data, reload, onUserUpdated, onLogout, onSwitchOrganization, onOrganizationCreated }: { route: RouteKey; setRoute: (route: RouteKey) => void; settingsSection: SettingsSectionKey | null; openSettingsRoute: (section: SettingsSectionKey | null) => void; selectedEmployeeId: number | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; selectedChannelId: number | null; selectedIntegrationId: number | null; openWebIntegrationRoute: (id: number) => void; selectedSupportPortalId: number | null; portalSettingsSection: PortalSettingsSectionKey | null; openEmployeeRoute: (employeeId: number) => void; openAgentRoute: (agentId: number) => void; openKnowledgeRoute: (knowledgeId: number) => void;
|
||||
openKnowledgeEditorRoute: (knowledgeId: number | null) => void; openConversationRoute: (conversationId: number) => void; openClientRoute: (clientId: number) => void; openChannelRoute: (channelId: number) => void; openSupportPortalRoute: (portalId: number) => void; openPortalSettingsRoute: (portalId: number, section?: PortalSettingsSectionKey) => void; user: SessionUser; data: AppData; reload: () => void; onUserUpdated: (user: SessionUser) => void; onLogout: () => void; onSwitchOrganization: (organizationPublicId: string) => void; onOrganizationCreated: (identity: AuthenticatedUser, organizationPublicId: string) => void }) {
|
||||
const [notifications, setNotifications] = useState<AppNotification[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
@@ -118,7 +118,7 @@ export function Shell({ route, setRoute, settingsSection, openSettingsRoute, sel
|
||||
|| route === "knowledgeCategories" || route === "knowledgeImport";
|
||||
const isKnowledgeEditor = route === "knowledgeCreate" || route === "knowledgeEdit";
|
||||
// «Настройки» занимают всю область как чат: своё субменю 240px и своя лента.
|
||||
const isSettings = route === "settings";
|
||||
const isSettings = route === "settings" || route === "webIntegrationSettings";
|
||||
// «Профиль» — своя лента на --surface-feed и мобильные подэкраны (кадр M).
|
||||
const isProfile = route === "profile";
|
||||
// «Контакты» — своя лента на --surface-feed (кадры K1–K5).
|
||||
@@ -143,7 +143,7 @@ export function Shell({ route, setRoute, settingsSection, openSettingsRoute, sel
|
||||
«назад» живут в самой странице, уведомления — в меню профиля сайдбара. */}
|
||||
<main className={`hub-scroll ${isDialogsWorkspace ? "sales-dialogs-scroll" : ""} ${isAiFullWidth ? "ai-fullwidth-scroll" : ""} ${isSettings ? "settings-scroll" : ""} ${isProfile ? "profile-scroll" : ""} ${isContacts ? "contacts-scroll" : ""} ${isAgents ? "agents-scroll" : ""} ${isEmployees ? "employees-scroll" : ""} ${isAudit ? "audit-scroll" : ""} ${isPortals ? "portals-scroll" : ""} ${isKnowledge || isKnowledgeEditor ? "knowledge-scroll" : ""}`}>
|
||||
<div key={route} className={`hub-page enter-surface ${isSalesWorkspace || isSupportWorkspace ? "sales-workspace-page" : ""} ${isDialogsWorkspace ? "sales-dialogs-page" : ""} ${isAiFullWidth ? "ai-fullwidth-page" : ""} ${isSettings ? "settings-page" : ""} ${isProfile ? "profile-page-shell" : ""} ${isContacts ? "contacts-page-shell" : ""} ${isAgents ? "agents-page-shell" : ""} ${isEmployees ? "employees-page-shell" : ""} ${isAudit ? "audit-page-shell" : ""} ${isKnowledge ? "knowledge-page-shell" : ""} ${isKnowledgeEditor ? "knowledge-editor-shell" : ""} ${isPortals ? "portals-page-shell" : ""}`}>
|
||||
<ShellRouteContent settingsSection={settingsSection} openSettings={openSettingsRoute} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} chatScopeSwitcher={manager} route={route} data={data} selectedEmployeeId={selectedEmployeeId} selectedAgentId={selectedAgentId} selectedKnowledgeId={selectedKnowledgeId} selectedConversationId={selectedConversationId} selectedClientId={selectedClientId} openClient={openClientRoute} selectedChannelId={selectedChannelId} openChannel={openChannelRoute} selectedSupportPortalId={selectedSupportPortalId} portalSettingsSection={portalSettingsSection} openSupportPortal={openSupportPortalRoute} openPortalSettings={openPortalSettingsRoute} openConversation={openConversationRoute} openEmployee={openEmployee} openAgent={openAgentRoute} openKnowledge={openKnowledgeRoute} openKnowledgeEditor={openKnowledgeEditorRoute} onAgentLoaded={() => undefined} onChannelLoaded={() => undefined} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} onOpenSidebar={() => setSidebarExpanded(true)} onOrganizationCreated={onOrganizationCreated} />
|
||||
<ShellRouteContent settingsSection={settingsSection} openSettings={openSettingsRoute} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} chatScopeSwitcher={manager} route={route} data={data} selectedEmployeeId={selectedEmployeeId} selectedAgentId={selectedAgentId} selectedKnowledgeId={selectedKnowledgeId} selectedConversationId={selectedConversationId} selectedClientId={selectedClientId} openClient={openClientRoute} selectedChannelId={selectedChannelId} openChannel={openChannelRoute} selectedIntegrationId={selectedIntegrationId} openWebIntegration={openWebIntegrationRoute} selectedSupportPortalId={selectedSupportPortalId} portalSettingsSection={portalSettingsSection} openSupportPortal={openSupportPortalRoute} openPortalSettings={openPortalSettingsRoute} openConversation={openConversationRoute} openEmployee={openEmployee} openAgent={openAgentRoute} openKnowledge={openKnowledgeRoute} openKnowledgeEditor={openKnowledgeEditorRoute} onAgentLoaded={() => undefined} onChannelLoaded={() => undefined} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} onOpenSidebar={() => setSidebarExpanded(true)} onOrganizationCreated={onOrganizationCreated} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AgentDetailPage } from "../features/agents/AgentDetailPage";
|
||||
import { EmployeeDetailPage, EmployeesPage } from "../features/employees/EmployeesPage";
|
||||
import { ProfilePage } from "../features/profile/ProfilePage";
|
||||
import { SettingsPage } from "../features/settings/SettingsPage";
|
||||
import { WebIntegrationPage } from "../features/integrations/WebIntegrationPage";
|
||||
import { AuditPage } from "../features/administration/AuditPage";
|
||||
import { OrganizationCreatePage } from "../features/organizations/OrganizationCreatePage";
|
||||
import { ChatPage } from "../features/chat/ChatPage";
|
||||
@@ -43,14 +44,15 @@ const SupportPortalDetailPage = lazy(() => import("../features/support-portals/S
|
||||
(module) => ({ default: module.SupportPortalDetailPage }),
|
||||
));
|
||||
|
||||
export function ShellRouteContent({ settingsSection, openSettings, chatScope, setChatScope, chatCounters, chatScopeSwitcher, route, data, selectedEmployeeId, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, openClient, selectedChannelId, openChannel, selectedSupportPortalId, portalSettingsSection, openSupportPortal, openPortalSettings, openConversation, openEmployee, openAgent, openKnowledge, openKnowledgeEditor, onAgentLoaded, onChannelLoaded, reload, setRoute, user, onUserUpdated, onLogout, onOpenSidebar, onOrganizationCreated }: { settingsSection: SettingsSectionKey | null; openSettings: (section: SettingsSectionKey | null) => void; chatScope: DialogScope; setChatScope: (scope: DialogScope) => void; chatCounters: ConversationCounters | null; chatScopeSwitcher: boolean; route: RouteKey; data: AppData; selectedEmployeeId: number | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; openClient: (clientId: number) => void; selectedChannelId: number | null; openChannel: (channelId: number) => void; selectedSupportPortalId: number | null; portalSettingsSection: PortalSettingsSectionKey | null; openSupportPortal: (portalId: number) => void; openPortalSettings: (portalId: number, section?: PortalSettingsSectionKey) => void; openConversation: (conversationId: number) => void; openEmployee: (employee: Employee) => void; openAgent: (agentId: number) => void; openKnowledge: (knowledgeId: number) => void; openKnowledgeEditor: (knowledgeId: number | null) => void; onAgentLoaded: (name: string | null) => void; onChannelLoaded: (name: string | null) => void; reload: () => void; setRoute: (route: RouteKey) => void; user: SessionUser; onUserUpdated: (user: SessionUser) => void; onLogout: () => void; onOpenSidebar: () => void; onOrganizationCreated: (identity: AuthenticatedUser, organizationPublicId: string) => void }) {
|
||||
export function ShellRouteContent({ settingsSection, openSettings, chatScope, setChatScope, chatCounters, chatScopeSwitcher, route, data, selectedEmployeeId, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, openClient, selectedChannelId, openChannel, selectedIntegrationId, openWebIntegration, selectedSupportPortalId, portalSettingsSection, openSupportPortal, openPortalSettings, openConversation, openEmployee, openAgent, openKnowledge, openKnowledgeEditor, onAgentLoaded, onChannelLoaded, reload, setRoute, user, onUserUpdated, onLogout, onOpenSidebar, onOrganizationCreated }: { settingsSection: SettingsSectionKey | null; openSettings: (section: SettingsSectionKey | null) => void; chatScope: DialogScope; setChatScope: (scope: DialogScope) => void; chatCounters: ConversationCounters | null; chatScopeSwitcher: boolean; route: RouteKey; data: AppData; selectedEmployeeId: number | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; openClient: (clientId: number) => void; selectedChannelId: number | null; openChannel: (channelId: number) => void; selectedIntegrationId: number | null; openWebIntegration: (id: number) => void; selectedSupportPortalId: number | null; portalSettingsSection: PortalSettingsSectionKey | null; openSupportPortal: (portalId: number) => void; openPortalSettings: (portalId: number, section?: PortalSettingsSectionKey) => void; openConversation: (conversationId: number) => void; openEmployee: (employee: Employee) => void; openAgent: (agentId: number) => void; openKnowledge: (knowledgeId: number) => void; openKnowledgeEditor: (knowledgeId: number | null) => void; onAgentLoaded: (name: string | null) => void; onChannelLoaded: (name: string | null) => void; reload: () => void; setRoute: (route: RouteKey) => void; user: SessionUser; onUserUpdated: (user: SessionUser) => void; onLogout: () => void; onOpenSidebar: () => void; onOrganizationCreated: (identity: AuthenticatedUser, organizationPublicId: string) => void }) {
|
||||
return (
|
||||
<>
|
||||
{route === "employees" && <EmployeesPage groups={data.groups} openEmployee={openEmployee} setRoute={setRoute} user={user} />}
|
||||
{route === "employeeDetail" && selectedEmployeeId != null && <EmployeeDetailPage groups={data.groups} employeeId={selectedEmployeeId} setRoute={setRoute} />}
|
||||
{route === "employeeDetail" && selectedEmployeeId == null && <EmployeesPage groups={data.groups} openEmployee={openEmployee} setRoute={setRoute} user={user} />}
|
||||
{route === "profile" && <ProfilePage user={user} onUserUpdated={onUserUpdated} reload={reload} onLogout={onLogout} onBack={() => setRoute("chat")} />}
|
||||
{route === "settings" && <SettingsPage user={user} onUserUpdated={onUserUpdated} reload={reload} groups={data.groups} section={settingsSection} openSection={openSettings} setRoute={setRoute} />}
|
||||
{route === "settings" && <SettingsPage user={user} onUserUpdated={onUserUpdated} reload={reload} groups={data.groups} section={settingsSection} openSection={openSettings} openWebIntegration={openWebIntegration} setRoute={setRoute} />}
|
||||
{route === "webIntegrationSettings" && selectedIntegrationId !== null && <WebIntegrationPage integrationId={selectedIntegrationId} onOpenSettings={() => openSettings(null)} onBack={() => openSettings("integrations")} />}
|
||||
{route === "administrationAudit" && <AuditPage />}
|
||||
{route === "organizationCreate" && <OrganizationCreatePage user={user} onCreated={onOrganizationCreated} onBack={() => setRoute("chat")} />}
|
||||
{route === "salesClients" && <SalesClientsPage openClient={openClient} openIntegrations={() => openSettings("integrations")} />}
|
||||
|
||||
@@ -174,7 +174,7 @@ export function Sidebar({
|
||||
<SidebarLink activeRoutes={["employees", "employeeDetail"]} icon="team" label={t("common.operators")} route={route} routeKey="employees" setRoute={setRoute} target="nav-employees" />
|
||||
<SidebarLink activeRoutes={["supportPortals", "supportPortalDetail", "supportPortalSettings"]} icon="globe" label={t("common.portals")} route={route} routeKey="supportPortals" setRoute={setRoute} />
|
||||
<SidebarLink activeRoutes={["knowledge", "knowledgeDetail", "knowledgeCreate", "knowledgeEdit", "knowledgeCategories", "knowledgeImport"]} icon="book" label={t("common.knowledge_base")} route={route} routeKey="knowledge" setRoute={setRoute} target="nav-knowledge" />
|
||||
<SidebarLink activeRoutes={["settings", "administrationAudit"]} icon="settings" label={t("common.settings")} route={route} routeKey="settings" setRoute={setRoute} />
|
||||
<SidebarLink activeRoutes={["settings", "webIntegrationSettings", "administrationAudit"]} icon="settings" label={t("common.settings")} route={route} routeKey="settings" setRoute={setRoute} />
|
||||
</nav>
|
||||
) : route === "profile" ? (
|
||||
/* Кадр P2: на «Профиле» у сотрудника сайдбар без навигации — фильтровать
|
||||
|
||||
@@ -9,6 +9,7 @@ const empty = {
|
||||
knowledgeId: null,
|
||||
clientId: null,
|
||||
channelId: null,
|
||||
integrationId: null,
|
||||
supportPortalId: null,
|
||||
portalSettingsSection: null,
|
||||
settingsSection: null,
|
||||
@@ -204,6 +205,13 @@ describe("channel routes", () => {
|
||||
});
|
||||
|
||||
describe("settings routes", () => {
|
||||
it("restores a web connection page from its direct URL", () => {
|
||||
expect(routeFromPath("/settings/integrations/12")).toEqual({ ...empty, route: "webIntegrationSettings", integrationId: 12 });
|
||||
expect(routeFromPath("/settings/integrations/12/")).toEqual({ ...empty, route: "webIntegrationSettings", integrationId: 12 });
|
||||
expect(routeFromPath("/settings/integrations/nope")).toEqual({ ...empty, route: "settings", settingsSection: "integrations" });
|
||||
expect(pathFromRoute("webIntegrationSettings", 12)).toBe("/settings/integrations/12");
|
||||
});
|
||||
|
||||
it("parses the settings screen and its submenu sections", () => {
|
||||
expect(routeFromPath("/settings")).toEqual({ route: "settings", ...empty });
|
||||
expect(routeFromPath("/settings/groups")).toEqual({ ...empty, route: "settings", settingsSection: "groups" });
|
||||
|
||||
@@ -14,6 +14,7 @@ export type RouteState = {
|
||||
knowledgeId: number | null;
|
||||
clientId: number | null;
|
||||
channelId: number | null;
|
||||
integrationId: number | null;
|
||||
supportPortalId: number | null;
|
||||
portalSettingsSection: PortalSettingsSectionKey | null;
|
||||
settingsSection: SettingsSectionKey | null;
|
||||
@@ -24,12 +25,12 @@ export function routeFromPath(pathname: string, search = ""): RouteState {
|
||||
// Страница создания организации живёт вне организации: у неё ещё нет
|
||||
// адреса, а человек попадает сюда из переключателя любой из своих (A1).
|
||||
if (normalized === "/organizations/new") {
|
||||
return { route: "organizationCreate", organizationPublicId: null, employeeId: null, agentId: null, knowledgeId: null, clientId: null, channelId: null, supportPortalId: null, portalSettingsSection: null, settingsSection: null };
|
||||
return { route: "organizationCreate", organizationPublicId: null, employeeId: null, agentId: null, knowledgeId: null, clientId: null, channelId: null, integrationId: null, supportPortalId: null, portalSettingsSection: null, settingsSection: null };
|
||||
}
|
||||
const match = normalized.match(/^\/organizations\/([0-9a-f-]{36})(\/.*)?$/i);
|
||||
const organizationPublicId = match?.[1] ?? null;
|
||||
const path = match ? match[2] || "/" : normalized;
|
||||
const base = { employeeId: null, agentId: null, knowledgeId: null, clientId: null, channelId: null, supportPortalId: null, portalSettingsSection: null, settingsSection: null };
|
||||
const base = { employeeId: null, agentId: null, knowledgeId: null, clientId: null, channelId: null, integrationId: null, supportPortalId: null, portalSettingsSection: null, settingsSection: null };
|
||||
const state = { organizationPublicId, ...base };
|
||||
// Chat-first (SPEC-CHATBALLS-0031): корень и устаревшие адреса командного центра и
|
||||
// разделённых чатов ведут в единый «Чат».
|
||||
@@ -111,6 +112,12 @@ export function routeFromPath(pathname: string, search = ""): RouteState {
|
||||
}
|
||||
if (path === "/profile") return { route: "profile", ...state };
|
||||
if (path === "/settings") return { route: "settings", ...state };
|
||||
if (path.startsWith("/settings/integrations/")) {
|
||||
const id = Number(path.slice("/settings/integrations/".length));
|
||||
return Number.isInteger(id) && id > 0
|
||||
? { ...state, route: "webIntegrationSettings", integrationId: id }
|
||||
: { ...state, route: "settings", settingsSection: "integrations" };
|
||||
}
|
||||
if (path.startsWith("/settings/")) {
|
||||
return { ...state, route: "settings", settingsSection: settingsSectionKey(path.slice("/settings/".length)) };
|
||||
}
|
||||
@@ -144,6 +151,7 @@ export function pathFromRoute(route: RouteKey, entityId: number | string | null
|
||||
if (route === "knowledgeEdit") return entityId ? `${prefix}/knowledge/${entityId}/edit` : `${prefix}/knowledge/new`;
|
||||
if (route === "administrationAudit") return `${prefix}/administration/audit`;
|
||||
if (route === "profile") return `${prefix}/profile`;
|
||||
if (route === "webIntegrationSettings") return entityId ? `${prefix}/settings/integrations/${entityId}` : `${prefix}/settings/integrations`;
|
||||
// У «Настроек» вместо id — ключ раздела субменю (кадры N1–N7).
|
||||
if (route === "settings") return settingsSectionKey(String(entityId)) ? `${prefix}/settings/${entityId}` : `${prefix}/settings`;
|
||||
return `${prefix}/profile`;
|
||||
|
||||
@@ -7,6 +7,7 @@ export const routes: Record<RouteKey, string> = {
|
||||
employees: t("common.operators"),
|
||||
profile: t("common.profile"),
|
||||
settings: t("common.settings"),
|
||||
webIntegrationSettings: t("common.integrations"),
|
||||
salesClientDetail: t("common.contact"),
|
||||
salesClients: t("common.contacts"),
|
||||
chat: t("common.chat"),
|
||||
|
||||
@@ -6,6 +6,18 @@ import { PORTAL_SETTINGS_SECTIONS } from "../features/support-portals/sections";
|
||||
import { SectionMenu } from "./SectionMenu";
|
||||
|
||||
describe("SectionMenu", () => {
|
||||
it("keeps planned sections visible without opening them", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<SectionMenu
|
||||
items={[{ key: "basics", label: t("portals.basics"), icon: "settings" }, { key: "fields", label: t("settings.site_data"), icon: "code", disabled: true }]}
|
||||
activeKey="basics"
|
||||
onSelect={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(markup).toContain(t("settings.site_data"));
|
||||
expect(markup).toContain('disabled=""');
|
||||
});
|
||||
|
||||
it("renders the portal sections, hint, divider, and dangerous item", () => {
|
||||
const items = PORTAL_SETTINGS_SECTIONS.map((item) => ({
|
||||
...item,
|
||||
|
||||
@@ -7,6 +7,7 @@ export type SectionMenuItem<Key extends string> = {
|
||||
hint?: { text: string; tone?: "ok" | "muted" };
|
||||
divider?: boolean;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function SectionMenu<Key extends string>({ items, activeKey, note, onSelect }: {
|
||||
@@ -22,6 +23,7 @@ export function SectionMenu<Key extends string>({ items, activeKey, note, onSele
|
||||
<button
|
||||
className={`section-menu-item${item.key === activeKey ? " is-active" : ""}${item.danger ? " is-danger" : ""}`}
|
||||
type="button"
|
||||
disabled={item.disabled}
|
||||
onClick={() => onSelect(item.key)}
|
||||
>
|
||||
<Icon name={item.icon} size={16} strokeWidth={1.9} />
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
}
|
||||
|
||||
.section-menu-item:hover { background: var(--n-9); }
|
||||
.section-menu-item:disabled { cursor: default; }
|
||||
.section-menu-item:disabled:hover { background: transparent; }
|
||||
|
||||
.section-menu-item.is-active {
|
||||
background: var(--primary-bg);
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
@import "./features/ai/knowledge/styles-editor.css";
|
||||
@import "./features/ai/knowledge/styles-pages.css";
|
||||
@import "./features/integrations/styles.css";
|
||||
@import "./features/integrations/web-integration.css";
|
||||
@import "./features/administration/styles.css";
|
||||
@import "./features/organizations/styles.css";
|
||||
@import "./features/settings/styles.css";
|
||||
|
||||
@@ -124,7 +124,7 @@ export type EmployeeAuditEvent = {
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type RouteKey = "administrationAudit" | "employeeDetail" | "employees" | "profile" | "settings" | "salesClientDetail" | "salesClients" | "chat" | "supportPortals" | "supportPortalDetail" | "supportPortalSettings" | "agents" | "agentDetail" | "knowledge" | "knowledgeDetail" | "knowledgeCreate" | "knowledgeEdit" | "knowledgeCategories" | "knowledgeImport" | "aiUsage" | "organizationCreate";
|
||||
export type RouteKey = "administrationAudit" | "employeeDetail" | "employees" | "profile" | "settings" | "webIntegrationSettings" | "salesClientDetail" | "salesClients" | "chat" | "supportPortals" | "supportPortalDetail" | "supportPortalSettings" | "agents" | "agentDetail" | "knowledge" | "knowledgeDetail" | "knowledgeCreate" | "knowledgeEdit" | "knowledgeCategories" | "knowledgeImport" | "aiUsage" | "organizationCreate";
|
||||
|
||||
export type AppData = {
|
||||
groups: EmployeeGroup[];
|
||||
|
||||
@@ -19,6 +19,7 @@ export function useRouteNavigation(
|
||||
const [selectedConversationId, setSelectedConversationId] = useState<number | null>(null);
|
||||
const [selectedClientId, setSelectedClientId] = useState(initialRoute.clientId);
|
||||
const [selectedChannelId, setSelectedChannelId] = useState(initialRoute.channelId);
|
||||
const [selectedIntegrationId, setSelectedIntegrationId] = useState(initialRoute.integrationId);
|
||||
const [selectedSupportPortalId, setSelectedSupportPortalId] = useState(initialRoute.supportPortalId);
|
||||
const [selectedPortalSection, setSelectedPortalSection] = useState(initialRoute.portalSettingsSection);
|
||||
const [selectedSettingsSection, setSelectedSettingsSection] = useState(initialRoute.settingsSection);
|
||||
@@ -31,6 +32,7 @@ export function useRouteNavigation(
|
||||
setSelectedConversationId(null);
|
||||
setSelectedClientId(next.clientId);
|
||||
setSelectedChannelId(next.channelId);
|
||||
setSelectedIntegrationId(next.integrationId);
|
||||
setSelectedSupportPortalId(next.supportPortalId);
|
||||
setSelectedPortalSection(next.portalSettingsSection);
|
||||
setSelectedSettingsSection(next.settingsSection);
|
||||
@@ -51,6 +53,7 @@ export function useRouteNavigation(
|
||||
knowledgeId: (nextRoute === "knowledgeDetail" || nextRoute === "knowledgeEdit") && typeof entityId === "number" ? entityId : null,
|
||||
clientId: nextRoute === "salesClientDetail" && typeof entityId === "number" ? entityId : null,
|
||||
channelId: null,
|
||||
integrationId: nextRoute === "webIntegrationSettings" && typeof entityId === "number" ? entityId : null,
|
||||
supportPortalId: nextRoute === "supportPortalDetail" && typeof entityId === "number"
|
||||
? entityId
|
||||
// У настроек портала entityId — «id/раздел»: id портала и ключ раздела.
|
||||
@@ -78,6 +81,7 @@ export function useRouteNavigation(
|
||||
selectedConversationId,
|
||||
selectedClientId,
|
||||
selectedChannelId,
|
||||
selectedIntegrationId,
|
||||
selectedSupportPortalId,
|
||||
selectedPortalSection,
|
||||
selectedSettingsSection,
|
||||
|
||||
Reference in new issue
Block a user