diff --git a/.skaro/tasks/T-008-stranitsa-veb-podklyucheniya-s-razdelami.md b/.skaro/tasks/T-008-stranitsa-veb-podklyucheniya-s-razdelami.md new file mode 100644 index 0000000..f27b8d2 --- /dev/null +++ b/.skaro/tasks/T-008-stranitsa-veb-podklyucheniya-s-razdelami.md @@ -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 критериев выполненными и показал карточку для слияния задачи. diff --git a/AGENTS.md b/AGENTS.md index 13b5c11..eb95e3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 пользователей и изменение ролей, требуют отдельного явного подтверждения владельца перед запуском. diff --git a/apps/internal-ui/src/App.tsx b/apps/internal-ui/src/App.tsx index 41bbd45..b3134d0 100644 --- a/apps/internal-ui/src/App.tsx +++ b/apps/internal-ui/src/App.tsx @@ -250,7 +250,7 @@ export function App() { // Ключ организации — на провайдере: при переключении сокет обязан // переоткрыться на адрес новой, а не остаться на прежней. - 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} /> + 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} /> )} diff --git a/apps/internal-ui/src/features/integrations/ConnectionsTable.tsx b/apps/internal-ui/src/features/integrations/ConnectionsTable.tsx index 3c35627..dcfbe65 100644 --- a/apps/internal-ui/src/features/integrations/ConnectionsTable.tsx +++ b/apps/internal-ui/src/features/integrations/ConnectionsTable.tsx @@ -42,7 +42,9 @@ export function ConnectionsTable({ items, testingId, onTest, onEdit, onToggleAct
- {item.name}{subtitle(item)} + {item.provider === "WEB" + ? + : {item.name}}{subtitle(item)}
diff --git a/apps/internal-ui/src/features/integrations/DeleteIntegrationDialog.tsx b/apps/internal-ui/src/features/integrations/DeleteIntegrationDialog.tsx new file mode 100644 index 0000000..08d9251 --- /dev/null +++ b/apps/internal-ui/src/features/integrations/DeleteIntegrationDialog.tsx @@ -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(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 ( + +
+

{t("settings.will_be_deleted_irreversible", { name: integration.name })}

+ {error &&
{error}
} +
+ + +
+
+
+ ); +} diff --git a/apps/internal-ui/src/features/integrations/IntegrationsSection.tsx b/apps/internal-ui/src/features/integrations/IntegrationsSection.tsx index a9dafa0..8f1dac0 100644 --- a/apps/internal-ui/src/features/integrations/IntegrationsSection.tsx +++ b/apps/internal-ui/src/features/integrations/IntegrationsSection.tsx @@ -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(null); const [deleting, setDeleting] = useState(null); - const [deletingError, setDeletingError] = useState(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 }: { ) : ( )} - {isConnections && items.length > 0 && ( -

{t("settings.web_widget_embed_snippet_copied")}

- )} - {deleting && ( - setDeleting(null)} footer={null} destroyOnClose> -
-

{t("settings.will_be_deleted_irreversible", { name: deleting.name })}

- {deletingError &&
{deletingError}
} -
- - -
-
-
- )} + {deleting && setDeleting(null)} onDeleted={() => { setDeleting(null); reload(); }} />} ); } diff --git a/apps/internal-ui/src/features/integrations/WebIntegrationBasics.tsx b/apps/internal-ui/src/features/integrations/WebIntegrationBasics.tsx new file mode 100644 index 0000000..4a0d190 --- /dev/null +++ b/apps/internal-ui/src/features/integrations/WebIntegrationBasics.tsx @@ -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(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 ( +
+ +
+ +
{t("settings.sites_where_widget_may_open")}
+
+ {snippet &&
+ + +
} + {error &&
{error}
} +
+ +
+
+ ); +} diff --git a/apps/internal-ui/src/features/integrations/WebIntegrationHeader.tsx b/apps/internal-ui/src/features/integrations/WebIntegrationHeader.tsx new file mode 100644 index 0000000..e49b419 --- /dev/null +++ b/apps/internal-ui/src/features/integrations/WebIntegrationHeader.tsx @@ -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 ( +
+ +
+ +

{integration.name}

+ {working && {t("portals.working")}} + + {integration.channel + ? t("settings.web_integration_agent", { name: integration.channel.name }) + : t("common.web_widget")} + +
+ {snippet &&
+ + {snippet} + + + {t("settings.embed_before_body_end")} +
} +
+ ); +} diff --git a/apps/internal-ui/src/features/integrations/WebIntegrationPage.tsx b/apps/internal-ui/src/features/integrations/WebIntegrationPage.tsx new file mode 100644 index 0000000..7c676b3 --- /dev/null +++ b/apps/internal-ui/src/features/integrations/WebIntegrationPage.tsx @@ -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
[] = [ + { 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 ; + if (failed) return ; + if (!integration) return ; + + return ( +
+ +
+ { if (section === "danger") setConfirmingDelete(true); }} + /> +
+
+
+

{t("portals.basics")}

+
+ +
+
+
+ {confirmingDelete && setConfirmingDelete(false)} onDeleted={onBack} />} +
+ ); +} diff --git a/apps/internal-ui/src/features/integrations/useWebIntegration.ts b/apps/internal-ui/src/features/integrations/useWebIntegration.ts new file mode 100644 index 0000000..689592b --- /dev/null +++ b/apps/internal-ui/src/features/integrations/useWebIntegration.ts @@ -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(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 }; +} diff --git a/apps/internal-ui/src/features/integrations/web-integration.css b/apps/internal-ui/src/features/integrations/web-integration.css new file mode 100644 index 0000000..2f5c036 --- /dev/null +++ b/apps/internal-ui/src/features/integrations/web-integration.css @@ -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; } +} diff --git a/apps/internal-ui/src/features/settings/SettingsPage.tsx b/apps/internal-ui/src/features/settings/SettingsPage.tsx index a38df40..a3460a3 100644 --- a/apps/internal-ui/src/features/settings/SettingsPage.tsx +++ b/apps/internal-ui/src/features/settings/SettingsPage.tsx @@ -42,13 +42,14 @@ const SECTION_TOUR_TARGET: Partial> = { // «Платформа» кадра в базлайне не имеет — раздел собран по стандарту 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 })} /> diff --git a/apps/internal-ui/src/i18n/en.ts b/apps/internal-ui/src/i18n/en.ts index 230f4e8..31fef2f 100644 --- a/apps/internal-ui/src/i18n/en.ts +++ b/apps/internal-ui/src/i18n/en.ts @@ -1527,6 +1527,7 @@ export const en: Record = { "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 = { "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 ", "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 = { "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 = { "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 = { "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.", diff --git a/apps/internal-ui/src/i18n/ru.ts b/apps/internal-ui/src/i18n/ru.ts index 6632efe..c12abfa 100644 --- a/apps/internal-ui/src/i18n/ru.ts +++ b/apps/internal-ui/src/i18n/ru.ts @@ -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": "вставьте перед ", "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}» будет удалена. Действие необратимо.", diff --git a/apps/internal-ui/src/layout/Shell.tsx b/apps/internal-ui/src/layout/Shell.tsx index f55324f..856e160 100644 --- a/apps/internal-ui/src/layout/Shell.tsx +++ b/apps/internal-ui/src/layout/Shell.tsx @@ -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([]); 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 «назад» живут в самой странице, уведомления — в меню профиля сайдбара. */}
- undefined} onChannelLoaded={() => undefined} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} onOpenSidebar={() => setSidebarExpanded(true)} onOrganizationCreated={onOrganizationCreated} /> + undefined} onChannelLoaded={() => undefined} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} onOpenSidebar={() => setSidebarExpanded(true)} onOrganizationCreated={onOrganizationCreated} />
diff --git a/apps/internal-ui/src/layout/ShellRouteContent.tsx b/apps/internal-ui/src/layout/ShellRouteContent.tsx index e3a230a..eaa89a2 100644 --- a/apps/internal-ui/src/layout/ShellRouteContent.tsx +++ b/apps/internal-ui/src/layout/ShellRouteContent.tsx @@ -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" && } {route === "employeeDetail" && selectedEmployeeId != null && } {route === "employeeDetail" && selectedEmployeeId == null && } {route === "profile" && setRoute("chat")} />} - {route === "settings" && } + {route === "settings" && } + {route === "webIntegrationSettings" && selectedIntegrationId !== null && openSettings(null)} onBack={() => openSettings("integrations")} />} {route === "administrationAudit" && } {route === "organizationCreate" && setRoute("chat")} />} {route === "salesClients" && openSettings("integrations")} />} diff --git a/apps/internal-ui/src/layout/Sidebar.tsx b/apps/internal-ui/src/layout/Sidebar.tsx index 6e9c77d..68002bb 100644 --- a/apps/internal-ui/src/layout/Sidebar.tsx +++ b/apps/internal-ui/src/layout/Sidebar.tsx @@ -174,7 +174,7 @@ export function Sidebar({ - + ) : route === "profile" ? ( /* Кадр P2: на «Профиле» у сотрудника сайдбар без навигации — фильтровать diff --git a/apps/internal-ui/src/router.test.ts b/apps/internal-ui/src/router.test.ts index 79c86ae..da9a73e 100644 --- a/apps/internal-ui/src/router.test.ts +++ b/apps/internal-ui/src/router.test.ts @@ -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" }); diff --git a/apps/internal-ui/src/router.ts b/apps/internal-ui/src/router.ts index 141a425..b499c81 100644 --- a/apps/internal-ui/src/router.ts +++ b/apps/internal-ui/src/router.ts @@ -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`; diff --git a/apps/internal-ui/src/routes.ts b/apps/internal-ui/src/routes.ts index 7746374..6d242ad 100644 --- a/apps/internal-ui/src/routes.ts +++ b/apps/internal-ui/src/routes.ts @@ -7,6 +7,7 @@ export const routes: Record = { 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"), diff --git a/apps/internal-ui/src/shared/SectionMenu.test.tsx b/apps/internal-ui/src/shared/SectionMenu.test.tsx index 3aa8e63..619ff6f 100644 --- a/apps/internal-ui/src/shared/SectionMenu.test.tsx +++ b/apps/internal-ui/src/shared/SectionMenu.test.tsx @@ -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( + 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, diff --git a/apps/internal-ui/src/shared/SectionMenu.tsx b/apps/internal-ui/src/shared/SectionMenu.tsx index b9e1811..d2ab2d7 100644 --- a/apps/internal-ui/src/shared/SectionMenu.tsx +++ b/apps/internal-ui/src/shared/SectionMenu.tsx @@ -7,6 +7,7 @@ export type SectionMenuItem = { hint?: { text: string; tone?: "ok" | "muted" }; divider?: boolean; danger?: boolean; + disabled?: boolean; }; export function SectionMenu({ items, activeKey, note, onSelect }: { @@ -22,6 +23,7 @@ export function SectionMenu({ items, activeKey, note, onSele