diff --git a/apps/internal-ui/src/App.tsx b/apps/internal-ui/src/App.tsx index 0066b8a..0e63a37 100644 --- a/apps/internal-ui/src/App.tsx +++ b/apps/internal-ui/src/App.tsx @@ -151,7 +151,7 @@ export function App() { ) : !canAccess(user, navigation.route) ? ( navigate(defaultRoute(user), null, true)} /> ) : ( - navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedProductId={navigation.selectedProductId} selectedProductCode={navigation.selectedProductCode} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedOrderId={navigation.selectedOrderId} openOrderRoute={(orderId) => navigate("salesOrderDetail", orderId)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openProductRoute={(productId) => navigate("productDetail", productId)} openAgentCreateRoute={(productCode) => navigate("aiAgentCreate", null, false, productCode)} openAgentRoute={(agentId) => navigate("aiAgentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("aiKnowledgeDetail", knowledgeId)} openConversationRoute={(conversationId) => navigate("salesDialogs", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} /> + navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedProductId={navigation.selectedProductId} selectedProductCode={navigation.selectedProductCode} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedOrderId={navigation.selectedOrderId} selectedChannelId={navigation.selectedChannelId} openChannelRoute={(channelId) => navigate("channelDetail", channelId)} openOrderRoute={(orderId) => navigate("salesOrderDetail", orderId)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openProductRoute={(productId) => navigate("productDetail", productId)} openAgentCreateRoute={(productCode) => navigate("aiAgentCreate", null, false, productCode)} openAgentRoute={(agentId) => navigate("aiAgentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("aiKnowledgeDetail", knowledgeId)} openConversationRoute={(conversationId) => navigate("salesDialogs", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} /> )} ); diff --git a/apps/internal-ui/src/auth/access.ts b/apps/internal-ui/src/auth/access.ts index 8bc8050..83031b1 100644 --- a/apps/internal-ui/src/auth/access.ts +++ b/apps/internal-ui/src/auth/access.ts @@ -18,6 +18,9 @@ const ROUTE_ACCESS: Partial> = { aiKnowledgeDetail: { capability: "ai.view" }, aiUsage: { capability: "ai.view" }, integrations: { capability: "integrations.view" }, + channels: { capability: "channels.view" }, + channelCreate: { capability: "channels.manage" }, + channelDetail: { capability: "channels.view" }, salesOverview: { capability: "sales.view", departmentCode: "sales" }, salesDialogs: { capability: "conversations.view", departmentCode: "sales" }, salesClients: { capability: "customers.view", departmentCode: "sales" }, diff --git a/apps/internal-ui/src/features/ai/detail/AiAgentDetailHeader.tsx b/apps/internal-ui/src/features/ai/detail/AiAgentDetailHeader.tsx index 3d05a92..d272226 100644 --- a/apps/internal-ui/src/features/ai/detail/AiAgentDetailHeader.tsx +++ b/apps/internal-ui/src/features/ai/detail/AiAgentDetailHeader.tsx @@ -3,7 +3,7 @@ import { StatusPill } from "../../../shared/ui"; import { productAccent } from "../../../shared/utils"; import { dailyBudget, type AiAgentDetail } from "./model"; -export function AiAgentDetailHeader({ agent, onEditChannel }: { agent: AiAgentDetail; onEditChannel: () => void }) { +export function AiAgentDetailHeader({ agent, onOpenChannel }: { agent: AiAgentDetail; onOpenChannel: () => void }) { const accent = productAccent(agent.channel.code); return (
@@ -15,7 +15,7 @@ export function AiAgentDetailHeader({ agent, onEditChannel }: { agent: AiAgentDe
-
Канал
+
Канал
Модель{agent.model}
Знания{agent.knowledge.length > 0 ? `${agent.knowledge.length} выбрано` : "—"}
Лимиты{dailyBudget(agent.limits)}
diff --git a/apps/internal-ui/src/features/ai/detail/AiAgentDetailPage.tsx b/apps/internal-ui/src/features/ai/detail/AiAgentDetailPage.tsx index 8ac9710..ea9e5dd 100644 --- a/apps/internal-ui/src/features/ai/detail/AiAgentDetailPage.tsx +++ b/apps/internal-ui/src/features/ai/detail/AiAgentDetailPage.tsx @@ -5,7 +5,6 @@ import { EmptyState, LoadingState } from "../../../shared/ui"; import { UnderlineTabs } from "../../../shared/ui-controls"; import { AiAgentDetailHeader } from "./AiAgentDetailHeader"; import { AgentEditForm } from "./AgentEditForm"; -import { ChannelEditForm } from "./ChannelEditForm"; import { AiAgentInstructionsTab } from "./AiAgentInstructionsTab"; import { AiAgentKnowledgeTab } from "./AiAgentKnowledgeTab"; import { AiAgentMetricsTab } from "./AiAgentMetricsTab"; @@ -13,11 +12,10 @@ import { AiAgentOverviewTab } from "./AiAgentOverviewTab"; import { agentTabs, type AgentTab } from "./model"; import { useAiAgentDetail } from "./useAiAgentDetail"; -export function AiAgentDetailPage({ agentId, openKnowledge, onAgentLoaded }: { agentId: number | null; openKnowledge: (knowledgeId: number) => void; onAgentLoaded: (name: string | null) => void }) { +export function AiAgentDetailPage({ agentId, openKnowledge, openChannel, onAgentLoaded }: { agentId: number | null; openKnowledge: (knowledgeId: number) => void; openChannel: (channelId: number) => void; onAgentLoaded: (name: string | null) => void }) { const { agent, library, loading, error, reload } = useAiAgentDetail(agentId); const [tab, setTab] = useState("overview"); const [editOpen, setEditOpen] = useState(false); - const [channelEdit, setChannelEdit] = useState(false); useEffect(() => { onAgentLoaded(agent?.name ?? null); @@ -36,14 +34,13 @@ export function AiAgentDetailPage({ agentId, openKnowledge, onAgentLoaded }: { a return (
- setChannelEdit(true)} /> + openChannel(agent.channel.id)} /> {tab === "overview" && setEditOpen(true)} />} {tab === "instructions" && } {tab === "knowledge" && } {tab === "metrics" && } {editOpen && setEditOpen(false)} onSaved={() => { setEditOpen(false); reload(); }} />} - {channelEdit && setChannelEdit(false)} onSaved={() => { setChannelEdit(false); reload(); }} />}
); } diff --git a/apps/internal-ui/src/features/ai/detail/ChannelEditForm.tsx b/apps/internal-ui/src/features/ai/detail/ChannelEditForm.tsx deleted file mode 100644 index e079352..0000000 --- a/apps/internal-ui/src/features/ai/detail/ChannelEditForm.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Modal } from "antd"; -import { useState } from "react"; - -import { api } from "../../../api/client"; -import { FormField } from "../../../shared/form-controls"; -import { Button } from "../../../shared/ui-controls"; -import type { ChannelRef } from "../model"; - -export function ChannelEditForm({ channel, onClose, onSaved }: { channel: ChannelRef; onClose: () => void; onSaved: () => void }) { - const [name, setName] = useState(channel.name); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); - - const ready = name.trim().length > 0; - - async function submit() { - if (!ready) return; - setSubmitting(true); - setError(null); - try { - await api(`/api/v1/channels/${channel.id}/`, { method: "PATCH", body: JSON.stringify({ name: name.trim() }) }); - onSaved(); - } catch (caught) { - setError(caught instanceof Error ? caught.message : "Не удалось сохранить"); - } finally { - setSubmitting(false); - } - } - - return ( - -
- - {error &&
{error}
} -
- - -
-
-
- ); -} diff --git a/apps/internal-ui/src/features/channels/ChannelBadge.tsx b/apps/internal-ui/src/features/channels/ChannelBadge.tsx new file mode 100644 index 0000000..bd1fd9e --- /dev/null +++ b/apps/internal-ui/src/features/channels/ChannelBadge.tsx @@ -0,0 +1,31 @@ +import { channelMeta } from "../conversations/data"; +import type { ChannelKey } from "../conversations/types"; + +const PROVIDER_KEY: Record = { MAX: "MAX", TELEGRAM: "TG", WEB: "WEB" }; +const SHORT_LABEL: Record = { MAX: "MAX", TG: "TG", WEB: "Web" }; + +/** + * Точка + короткий лейбл на токенах провайдера (ADR-HUB-0013). + * + * Применяется экономно — только в колонке «Подключения» списка и в секции + * «Подключения» карточки, чтобы экраны не превращались в цветную мозаику. + * Провайдеры различаются бейджем, а не бренд-логотипами. + */ +export function ChannelBadge({ provider }: { provider: string }) { + const key = PROVIDER_KEY[provider]; + if (!key) { + return {provider}; + } + const meta = channelMeta[key]; + return ( + + + {SHORT_LABEL[key]} + + ); +} + +export function providerLabel(provider: string): string { + const key = PROVIDER_KEY[provider]; + return key ? channelMeta[key].label : provider; +} diff --git a/apps/internal-ui/src/features/channels/ChannelCreateWizard.tsx b/apps/internal-ui/src/features/channels/ChannelCreateWizard.tsx new file mode 100644 index 0000000..89e6ab6 --- /dev/null +++ b/apps/internal-ui/src/features/channels/ChannelCreateWizard.tsx @@ -0,0 +1,265 @@ +import { useEffect, useMemo, useState } from "react"; + +import { ApiError, api } from "../../api/client"; +import { Icon } from "../../shared/icons"; +import { Button } from "../../shared/ui-controls"; +import type { Department, Product } from "../../types"; +import { ChannelBadge, providerLabel } from "./ChannelBadge"; +import { bindConnection, createChannel } from "./api"; +import { OPERATOR_POLICY, PRESETS, PRESET_LABELS, slugify } from "./model"; +import type { Channel, PolicyPreset, PolicyViolation } from "./types"; +import "./styles.css"; + +type Integration = { id: number; kind: string; provider: string; name: string; channelId: number | null }; + +const STEPS = ["Канал", "Назначение", "Подключения"]; + +export function ChannelCreateWizard({ + departments, + products, + openChannel, + openChannels, + openAgentCreate, +}: { + departments: Department[]; + products: Product[]; + openChannel: (channelId: number) => void; + openChannels: () => void; + openAgentCreate: () => void; +}) { + const [step, setStep] = useState(0); + const [name, setName] = useState(""); + const [code, setCode] = useState(""); + const [codeTouched, setCodeTouched] = useState(false); + const [departmentId, setDepartmentId] = useState(null); + const [productId, setProductId] = useState(null); + const [preset, setPreset] = useState("CUSTOM"); + const [created, setCreated] = useState(null); + const [integrations, setIntegrations] = useState([]); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const effectiveCode = codeTouched ? code : slugify(name); + + useEffect(() => { + if (step !== 2) return; + void api<{ items: Integration[] }>("/api/v1/integrations/") + .then((response) => + setIntegrations(response.items.filter((item) => item.kind === "MESSENGER")), + ) + .catch(() => setIntegrations([])); + }, [step]); + + // SALES и SUPPORT требуют продукта — иначе нарушают P1-P3. + const presetNeedsProduct = preset === "SALES" || preset === "SUPPORT"; + const canSubmit = Boolean(name.trim() && effectiveCode) && (!presetNeedsProduct || productId !== null); + + const policySummary = useMemo(() => { + if (preset === "CUSTOM") return OPERATOR_POLICY; + return PRESETS[preset]; + }, [preset]); + + async function submit() { + setBusy(true); + setError(null); + try { + const response = await createChannel({ + code: effectiveCode, + name: name.trim(), + departmentId, + productId, + policyPreset: preset, + policy: preset === "CUSTOM" ? OPERATOR_POLICY : undefined, + }); + setCreated(response.channel); + setStep(2); + } catch (submitError) { + if (submitError instanceof ApiError) { + const violations = (submitError.payload as { violations?: PolicyViolation[] }).violations; + setError( + violations?.length + ? violations.map((item) => `${item.detail} (${item.rule})`).join("; ") + : submitError.message, + ); + } + } finally { + setBusy(false); + } + } + + return ( +
+

Создание канала

+

+ Три шага. Настройка агента и знаний в мастер не входит — агент создаётся отдельным + действием в разделе AI. Канал создаётся по завершении шага 2. +

+ +
+ {STEPS.map((label, index) => ( +
+ {index < step || created ? : index + 1} + {label} +
+ ))} +
+ + {error &&
{error}
} + + {step === 0 && ( +
+
+

Канал

+
+
+ + + +
+
+ + +
+
+ )} + + {step === 1 && ( +
+
+

Назначение

+
+
+ + +
+
+ {Object.entries(policySummary).map(([flag, value]) => ( + {flag} + ))} +
+
+ + +
+
+ )} + + {step === 2 && created && ( +
+
+
+

Подключения

+ Шаг можно пропустить +
+ {integrations.length === 0 ? ( +

Свободных подключений нет — их можно привязать позже в карточке канала.

+ ) : ( +
+ {integrations.map((integration) => { + const bound = created.connections.some((item) => item.id === integration.id); + return ( +
+ +
+ {integration.name} + {providerLabel(integration.provider)} +
+ +
+ ); + })} +
+ )} +
+ +
+ + Канал создан +

+ {created.name} — операторский канал: AI-агента нет, диалоги ведут операторы. + Канал принимает подключения, диалоги, заказы и attribution. +

+
+ + +
+ { + event.preventDefault(); + openAgentCreate(); + }} + > + Нужен AI-агент? Создайте его в разделе AI + + +
+
+ )} +
+ ); +} diff --git a/apps/internal-ui/src/features/channels/ChannelDetailPage.tsx b/apps/internal-ui/src/features/channels/ChannelDetailPage.tsx new file mode 100644 index 0000000..64cfea2 --- /dev/null +++ b/apps/internal-ui/src/features/channels/ChannelDetailPage.tsx @@ -0,0 +1,359 @@ +import { useEffect, useState } from "react"; + +import { ApiError } from "../../api/client"; +import { Icon } from "../../shared/icons"; +import { EmptyState, LoadingState } from "../../shared/ui"; +import { Button } from "../../shared/ui-controls"; +import { formatDate, productAccent } from "../../shared/utils"; +import type { Department, Product, RouteKey } from "../../types"; +import { ChannelBadge, providerLabel } from "./ChannelBadge"; +import { ChannelPolicySection } from "./ChannelPolicySection"; +import { deleteChannel, loadChannel, unbindConnection, updateChannel } from "./api"; +import { BLOCKER_LABELS } from "./model"; +import type { Channel, DeletionBlocker, PolicyFlag, PolicyViolation } from "./types"; +import "./styles.css"; + +type Feedback = { kind: "error" | "warning"; text: string } | null; + +export function ChannelDetailPage({ + channelId, + departments, + products, + canManage, + setRoute, + openAgent, + openChannels, +}: { + channelId: number | null; + departments: Department[]; + products: Product[]; + canManage: boolean; + setRoute: (route: RouteKey) => void; + openAgent: (agentId: number) => void; + openChannels: () => void; +}) { + const [channel, setChannel] = useState(null); + const [missing, setMissing] = useState(false); + const [feedback, setFeedback] = useState(null); + const [blockers, setBlockers] = useState(null); + + async function reload() { + if (channelId === null) return; + try { + const response = await loadChannel(channelId); + setChannel(response.channel); + setMissing(false); + } catch (error) { + // Канал вне scope не раскрывает своё существование. + if (error instanceof ApiError && error.status === 404) setMissing(true); + } + } + + useEffect(() => { + void reload(); + }, [channelId]); + + async function patch(body: Parameters[1]) { + if (!channel) return; + setFeedback(null); + try { + const response = await updateChannel(channel.id, body); + setChannel(response.channel); + const warning = response.warnings?.find((item) => item.code === "agent_still_active"); + if (warning) { + setFeedback({ + kind: "warning", + text: "Канал деактивирован, но агент остаётся активным и продолжает занимать слот. Остановите его на странице агента.", + }); + } + } catch (error) { + if (error instanceof ApiError) { + const violations = (error.payload as { violations?: PolicyViolation[] }).violations; + setFeedback({ + kind: "error", + text: violations?.length + ? violations.map((item) => `${item.detail} (${item.rule})`).join("; ") + : error.message, + }); + } + } + } + + async function remove() { + if (!channel) return; + try { + await deleteChannel(channel.id); + openChannels(); + } catch (error) { + if (error instanceof ApiError) { + const payload = error.payload as { blockers?: DeletionBlocker[] }; + if (payload.blockers) setBlockers(payload.blockers); + else setFeedback({ kind: "error", text: error.message }); + } + } + } + + if (missing) return ; + if (!channel) return ; + + const accent = channel.product ? productAccent(channel.product.code) : null; + + return ( +
+ + +
+
+

{channel.name}

+
+ {channel.code} + · + {channel.departmentName ?? Без отдела} + · + {channel.product && accent ? ( + + + {channel.product.name} + + ) : ( + — непродуктовый + )} +
+
+
+ + + {channel.isActive ? "Активен" : "Архивный"} + + {canManage && ( + + )} + {canManage && ( + + )} +
+
+ + {feedback &&
{feedback.text}
} + + {blockers && ( +
+
+ Канал нельзя удалить +

Есть связанные записи. Вместо удаления используйте деактивацию — история сохранится.

+
+
+ {blockers.map((item) => ( +
+ {BLOCKER_LABELS[item.type] ?? item.type} + {item.count} +
+ ))} +
+
+ + {channel.isActive && ( + + )} +
+
+ )} + +
+
+

Назначение

+ Отдел и продукт редактируются здесь +
+
+ + +
+ Код + {channel.code} +
+
+ Создан + {formatDate(channel.createdAt)} +
+
+
+ + patch({ policy: { [flag]: value } })} + /> + +
+
+

Подключения

+
+ {channel.connections.length === 0 ? ( +

Подключений нет. Привяжите их в разделе «Интеграции».

+ ) : ( +
+ {channel.connections.map((connection) => ( +
+ +
+ {connection.name} + {providerLabel(connection.provider)} +
+ + + {connection.status} + + {canManage && ( + + )} +
+ ))} +
+ )} +
+ + {/* Одна строка сводки и переход наружу: редактор агента остаётся на его странице. */} +
+
AI-АГЕНТ
+ {channel.agent ? ( +
+ + + +
+ {channel.agent.name} + · + + + {channel.agent.status === "ACTIVE" ? "Активен" : "Черновик"} + + · + {channel.agent.model} +
+ { + event.preventDefault(); + openAgent(channel.agent!.id); + }} + > + Открыть агента + + +
+ ) : ( +
+ + + + Агента нет, канал ведут операторы +

Валидное состояние канала. Агент создаётся отдельным действием в разделе AI.

+ { + event.preventDefault(); + setRoute("aiAgentCreate"); + }} + > + Создать агента в разделе AI + + +
+ )} +
+ +
+
+

Счётчики

+
+
+
+ ОТКРЫТЫЕ ДИАЛОГИ + {channel.counters.openConversations} +
+
+ ПОДКЛЮЧЕНИЯ + {channel.counters.connections} +
+
+
+
+ ); +} diff --git a/apps/internal-ui/src/features/channels/ChannelPolicySection.tsx b/apps/internal-ui/src/features/channels/ChannelPolicySection.tsx new file mode 100644 index 0000000..e20107f --- /dev/null +++ b/apps/internal-ui/src/features/channels/ChannelPolicySection.tsx @@ -0,0 +1,64 @@ +import { POLICY_FLAGS, POLICY_LABELS, PRESET_LABELS, flagLock, presetOf } from "./model"; +import type { Channel, PolicyFlag } from "./types"; + +/** + * Пять флагов с однострочным пояснением следствия каждого (SPEC-HUB-0027 §11.2). + * + * Флаг, запрещённый инвариантом при текущем продукте, показан выключенным и + * заблокированным с причиной — он не игнорируется молча при сохранении. + */ +export function ChannelPolicySection({ + channel, + canManage, + onToggle, +}: { + channel: Channel; + canManage: boolean; + onToggle: (flag: PolicyFlag, value: boolean) => void; +}) { + const hasProduct = channel.product !== null; + return ( +
+
+

Политика

+ Пресет: {PRESET_LABELS[presetOf(channel.policy)]} +
+ {POLICY_FLAGS.map((flag) => { + const lock = flagLock(flag, channel.policy, hasProduct); + const checked = channel.policy[flag]; + const disabled = !canManage || lock !== null; + return ( +
+ +
+
+ {POLICY_LABELS[flag].title} + {lock && Запрещено инвариантом} +
+

+ {lock ? ( + <> + {lock.reason} (инвариант {lock.rule}). {lock.fix} + + ) : ( + POLICY_LABELS[flag].hint + )} +

+
+
+ ); + })} +

+ Инварианты проверяются по итоговому состоянию: частичное сохранение запрещено. +

+
+ ); +} diff --git a/apps/internal-ui/src/features/channels/ChannelsPage.tsx b/apps/internal-ui/src/features/channels/ChannelsPage.tsx new file mode 100644 index 0000000..79ae436 --- /dev/null +++ b/apps/internal-ui/src/features/channels/ChannelsPage.tsx @@ -0,0 +1,119 @@ +import { useEffect, useMemo, useState } from "react"; + +import { ApiError } from "../../api/client"; +import { PageHeader } from "../../shared/ui"; +import { Button, SearchInput, UnderlineTabs } from "../../shared/ui-controls"; +import { Icon } from "../../shared/icons"; +import { EmptyState, LoadingState } from "../../shared/ui"; +import { listChannels, updateChannel } from "./api"; +import { ChannelsTable } from "./ChannelsTable"; +import { departmentTabs, filterChannels } from "./model"; +import type { Channel } from "./types"; +import "./styles.css"; + +export function ChannelsPage({ + canManage, + openChannel, + openChannelCreate, + openAgent, +}: { + canManage: boolean; + openChannel: (channelId: number) => void; + openChannelCreate: () => void; + openAgent: (agentId: number) => void; +}) { + const [channels, setChannels] = useState(null); + const [error, setError] = useState(null); + const [tab, setTab] = useState("all"); + const [search, setSearch] = useState(""); + const [showArchived, setShowArchived] = useState(false); + + async function reload() { + try { + const response = await listChannels(); + setChannels(response.items); + setError(null); + } catch (loadError) { + setError(loadError instanceof ApiError ? loadError.message : "Не удалось загрузить каналы"); + } + } + + useEffect(() => { + void reload(); + }, []); + + const tabs = useMemo(() => departmentTabs(channels ?? []), [channels]); + const visible = useMemo( + () => filterChannels(channels ?? [], { tab, search, showArchived }), + [channels, tab, search, showArchived], + ); + + async function toggleActive(channel: Channel) { + await updateChannel(channel.id, { isActive: !channel.isActive }); + await reload(); + } + + if (error) return ; + if (!channels) return ; + + const archived = channels.filter((channel) => !channel.isActive).length; + + return ( + <> + + Создать канал + + ) : undefined + } + /> + {channels.length === 0 ? ( +
+ + + + Создайте первый канал +

+ Канал маршрутизирует диалоги от подключений к отделу, продукту и — при необходимости — + к AI-агенту. +

+ {canManage && ( + + )} +
+ ) : ( + <> +
+ +
+ + +
+
+ + + )} + + ); +} diff --git a/apps/internal-ui/src/features/channels/ChannelsTable.tsx b/apps/internal-ui/src/features/channels/ChannelsTable.tsx new file mode 100644 index 0000000..639a369 --- /dev/null +++ b/apps/internal-ui/src/features/channels/ChannelsTable.tsx @@ -0,0 +1,182 @@ +import { useState } from "react"; + +import { Icon } from "../../shared/icons"; +import { productAccent } from "../../shared/utils"; +import { ChannelBadge } from "./ChannelBadge"; +import type { Channel } from "./types"; + +function AgentCell({ channel, openAgent }: { channel: Channel; openAgent: (agentId: number) => void }) { + if (!channel.isActive) { + return ( + + + Архивный + + ); + } + if (!channel.agent) { + // Канал без агента — валидное операторское состояние, а не ошибка. + return ( +
+ Нет агента + операторский канал +
+ ); + } + const active = channel.agent.status === "ACTIVE"; + return ( + + ); +} + +export function ChannelsTable({ + channels, + canManage, + openChannel, + openAgent, + toggleActive, + footer, +}: { + channels: Channel[]; + canManage: boolean; + openChannel: (channelId: number) => void; + openAgent: (agentId: number) => void; + toggleActive: (channel: Channel) => Promise; + footer: string; +}) { + const [menuId, setMenuId] = useState(null); + + return ( +
+ + + + + + + + + + + + {channels.map((channel) => { + const accent = channel.product ? productAccent(channel.product.code) : null; + return ( + openChannel(channel.id)} + > + + + + + + + + ); + })} + +
КАНАЛПРОДУКТПОДКЛЮЧЕНИЯAI-АГЕНТДИАЛОГИ +
+ { + event.preventDefault(); + openChannel(channel.id); + }} + > + {channel.name} + +
+ {channel.code} + {" · "} + {channel.departmentName ?? Без отдела} +
+
+ {channel.product && accent ? ( + + + {channel.product.name} + + ) : ( + — непродуктовый + )} + + {channel.connections.length ? ( + + {channel.connections.map((connection) => ( + + ))} + + ) : ( + — + )} + + + {channel.counters.openConversations} + + {menuId === channel.id && ( +
event.stopPropagation()}> + + {/* Пункты, недоступные по capability, не показываются вовсе. */} + {canManage && ( + + )} +
+ )} +
+
+ {footer} + Счётчик «Диалоги» — открытые диалоги канала +
+
+ ); +} diff --git a/apps/internal-ui/src/features/channels/api.ts b/apps/internal-ui/src/features/channels/api.ts new file mode 100644 index 0000000..3783fe6 --- /dev/null +++ b/apps/internal-ui/src/features/channels/api.ts @@ -0,0 +1,82 @@ +import { api } from "../../api/client"; +import type { Channel, ChannelPolicy, PolicyPreset } from "./types"; + +export type ChannelCreateInput = { + code: string; + name: string; + departmentId: number | null; + productId: number | null; + policyPreset: PolicyPreset; + policy?: ChannelPolicy; + connectionIds?: number[]; +}; + +export type ChannelUpdateInput = { + name?: string; + departmentId?: number | null; + productId?: number | null; + isActive?: boolean; + policy?: Partial; +}; + +export type ChannelResponse = { + channel: Channel; + warnings?: { code: string; agentId: number }[]; +}; + +export function listChannels(): Promise<{ items: Channel[] }> { + return api("/api/v1/channels/"); +} + +export function loadChannel(channelId: number): Promise<{ channel: Channel }> { + return api(`/api/v1/channels/${channelId}/`); +} + +export function createChannel(input: ChannelCreateInput): Promise<{ channel: Channel }> { + // policy передаётся только для CUSTOM: пресет и явная политика + // взаимоисключающи (SPEC-HUB-0027 §6.3). + const body: Record = { + code: input.code, + name: input.name, + departmentId: input.departmentId, + productId: input.productId, + policyPreset: input.policyPreset, + }; + if (input.policyPreset === "CUSTOM") body.policy = input.policy; + if (input.connectionIds?.length) body.connectionIds = input.connectionIds; + return api("/api/v1/channels/", { method: "POST", body: JSON.stringify(body) }); +} + +export function updateChannel( + channelId: number, + input: ChannelUpdateInput, +): Promise { + return api(`/api/v1/channels/${channelId}/`, { + method: "PATCH", + body: JSON.stringify(input), + }); +} + +export function deleteChannel(channelId: number): Promise { + return api(`/api/v1/channels/${channelId}/`, { method: "DELETE" }); +} + +export function bindConnection( + channelId: number, + integrationId: number, + force = false, +): Promise<{ channel: Channel }> { + return api(`/api/v1/channels/${channelId}/connections/`, { + method: "POST", + body: JSON.stringify(force ? { integrationId, force: true } : { integrationId }), + }); +} + +export function unbindConnection( + channelId: number, + integrationId: number, +): Promise<{ channel: Channel }> { + return api(`/api/v1/channels/${channelId}/connections/${integrationId}/`, { + method: "DELETE", + }); +} diff --git a/apps/internal-ui/src/features/channels/model.test.ts b/apps/internal-ui/src/features/channels/model.test.ts new file mode 100644 index 0000000..d7de30d --- /dev/null +++ b/apps/internal-ui/src/features/channels/model.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; + +import { + OPERATOR_POLICY, + PRESETS, + departmentTabs, + filterChannels, + flagLock, + presetOf, + slugify, +} from "./model"; +import type { Channel } from "./types"; + +function channel(overrides: Partial): Channel { + return { + id: 1, + code: "code", + name: "Канал", + isActive: true, + product: null, + departmentId: null, + department: null, + departmentName: null, + agent: null, + connections: [], + policy: OPERATOR_POLICY, + counters: { openConversations: 0, connections: 0 }, + createdAt: "2026-07-01T00:00:00+00:00", + updatedAt: "2026-07-01T00:00:00+00:00", + ...overrides, + }; +} + +describe("channel policy invariants", () => { + it("locks commercial flags on a non-product channel", () => { + expect(flagLock("allowCheckoutActions", OPERATOR_POLICY, false)?.rule).toBe("P1"); + expect(flagLock("allowSalesAttribution", OPERATOR_POLICY, false)?.rule).toBe("P2"); + expect(flagLock("requiresAuthenticatedProductIdentity", OPERATOR_POLICY, false)?.rule).toBe("P3"); + }); + + it("unlocks commercial flags once a product is assigned", () => { + for (const flag of ["allowCheckoutActions", "allowSalesAttribution"] as const) { + expect(flagLock(flag, OPERATOR_POLICY, true)).toBeNull(); + } + }); + + it("locks anonymous flags while authenticated identity is required", () => { + expect(flagLock("allowAnonymousSessions", PRESETS.SUPPORT, true)?.rule).toBe("P4"); + expect(flagLock("allowSelfReportedContact", PRESETS.SUPPORT, true)?.rule).toBe("P5"); + // Без обязательной идентичности те же флаги свободны. + expect(flagLock("allowAnonymousSessions", PRESETS.SALES, true)).toBeNull(); + }); + + it("names the preset behind a flag combination", () => { + expect(presetOf(PRESETS.SALES)).toBe("SALES"); + expect(presetOf(PRESETS.SUPPORT)).toBe("SUPPORT"); + expect(presetOf(OPERATOR_POLICY)).toBe("CUSTOM"); + }); +}); + +describe("channel code generation", () => { + it("transliterates a russian name into an embeddable slug", () => { + expect(slugify("Партнёрская линия")).toBe("partnerskaya-liniya"); + expect(slugify("FoxRay — продажи")).toBe("foxray-prodazhi"); + }); + + it("never produces leading, trailing or repeated separators", () => { + expect(slugify(" ??? Канал !!! ")).toBe("kanal"); + }); +}); + +describe("channel list filtering", () => { + const channels = [ + channel({ id: 1, code: "foxray-sales", name: "FoxRay — продажи", departmentId: 1, departmentName: "Продажи" }), + channel({ id: 2, code: "foxray-support", name: "FoxRay — поддержка", departmentId: 2, departmentName: "Поддержка" }), + channel({ id: 3, code: "partners", name: "Партнёрская линия" }), + channel({ id: 4, code: "old", name: "Старый", departmentId: 1, departmentName: "Продажи", isActive: false }), + ]; + + it("builds a tab per department plus «Без отдела»", () => { + expect(departmentTabs(channels).map((tab) => [tab.key, tab.label, tab.count])).toEqual([ + ["all", "Все", 4], + ["2", "Поддержка", 1], + ["1", "Продажи", 2], + ["none", "Без отдела", 1], + ]); + }); + + it("hides archived channels until the toggle is on", () => { + const visible = filterChannels(channels, { tab: "all", search: "", showArchived: false }); + expect(visible.map((item) => item.code)).toEqual(["foxray-sales", "foxray-support", "partners"]); + + const withArchived = filterChannels(channels, { tab: "all", search: "", showArchived: true }); + expect(withArchived).toHaveLength(4); + }); + + it("filters by department tab including channels without one", () => { + const orphans = filterChannels(channels, { tab: "none", search: "", showArchived: false }); + expect(orphans.map((item) => item.code)).toEqual(["partners"]); + }); + + it("searches by name and code", () => { + expect( + filterChannels(channels, { tab: "all", search: "support", showArchived: false }).map((i) => i.code), + ).toEqual(["foxray-support"]); + expect( + filterChannels(channels, { tab: "all", search: "партнёр", showArchived: false }).map((i) => i.code), + ).toEqual(["partners"]); + }); +}); diff --git a/apps/internal-ui/src/features/channels/model.ts b/apps/internal-ui/src/features/channels/model.ts new file mode 100644 index 0000000..3dece4a --- /dev/null +++ b/apps/internal-ui/src/features/channels/model.ts @@ -0,0 +1,202 @@ +import type { Channel, ChannelPolicy, PolicyFlag, PolicyPreset } from "./types"; + +export const POLICY_FLAGS: PolicyFlag[] = [ + "requiresAuthenticatedProductIdentity", + "allowAnonymousSessions", + "allowSelfReportedContact", + "allowSalesAttribution", + "allowCheckoutActions", +]; + +/** Однострочное пояснение следствия каждого флага (SPEC-HUB-0027 §11.2). */ +export const POLICY_LABELS: Record = { + requiresAuthenticatedProductIdentity: { + title: "Обязательная продуктовая идентичность", + hint: "Диалог требует авторизации как владелец продукта.", + }, + allowAnonymousSessions: { + title: "Анонимные сессии", + hint: "Разрешает диалоги без идентификации пользователя.", + }, + allowSelfReportedContact: { + title: "Самозаявленный контакт", + hint: "Позволяет пользователю указать контакт вручную.", + }, + allowSalesAttribution: { + title: "Attribution продаж", + hint: "Связывает продажу с диалогом канала.", + }, + allowCheckoutActions: { + title: "Коммерческие действия (checkout)", + hint: "Разрешает оформление заказа в диалоге.", + }, +}; + +export const PRESETS: Record, ChannelPolicy> = { + SALES: { + requiresAuthenticatedProductIdentity: false, + allowAnonymousSessions: true, + allowSelfReportedContact: true, + allowSalesAttribution: true, + allowCheckoutActions: true, + }, + SUPPORT: { + requiresAuthenticatedProductIdentity: true, + allowAnonymousSessions: false, + allowSelfReportedContact: false, + allowSalesAttribution: false, + allowCheckoutActions: false, + }, +}; + +export const OPERATOR_POLICY: ChannelPolicy = { + requiresAuthenticatedProductIdentity: false, + allowAnonymousSessions: true, + allowSelfReportedContact: true, + allowSalesAttribution: false, + allowCheckoutActions: false, +}; + +export type FlagLock = { rule: string; reason: string; fix?: string } | null; + +/** + * Причина, по которой флаг запрещён инвариантом при текущем состоянии канала. + * + * Выводится из продукта и обязательной идентичности, а не приходит с сервера: + * правила P1-P5 детерминированы, а источником прав остаётся backend — он + * отклоняет запрос независимо от того, что показал интерфейс. Показать причину + * обязательно: флаг не должен молча игнорироваться при сохранении (§11.2). + */ +export function flagLock(flag: PolicyFlag, policy: ChannelPolicy, hasProduct: boolean): FlagLock { + if (!hasProduct) { + if (flag === "allowCheckoutActions") { + return { + rule: "P1", + reason: "Коммерческие действия недоступны непродуктовому каналу", + fix: "Назначьте продукт каналу, чтобы включить.", + }; + } + if (flag === "allowSalesAttribution") { + return { + rule: "P2", + reason: "Attribution недоступна непродуктовому каналу", + fix: "Свяжите канал с продуктом, чтобы включить.", + }; + } + if (flag === "requiresAuthenticatedProductIdentity") { + return { + rule: "P3", + reason: "Продуктовая идентичность требует продукта", + fix: "Назначьте продукт каналу, чтобы включить.", + }; + } + } + if (policy.requiresAuthenticatedProductIdentity) { + if (flag === "allowAnonymousSessions") { + return { + rule: "P4", + reason: "Анонимные сессии несовместимы с обязательной идентичностью", + fix: "Выключите обязательную идентичность, чтобы включить.", + }; + } + if (flag === "allowSelfReportedContact") { + return { + rule: "P5", + reason: "Самозаявленный контакт несовместим с обязательной идентичностью", + fix: "Выключите обязательную идентичность, чтобы включить.", + }; + } + } + return null; +} + +/** Пресет, которому соответствует текущий набор флагов, иначе «Свой». */ +export function presetOf(policy: ChannelPolicy): PolicyPreset { + const same = (preset: ChannelPolicy) => + POLICY_FLAGS.every((flag) => preset[flag] === policy[flag]); + if (same(PRESETS.SALES)) return "SALES"; + if (same(PRESETS.SUPPORT)) return "SUPPORT"; + return "CUSTOM"; +} + +export const PRESET_LABELS: Record = { + SALES: "Продажи", + SUPPORT: "Поддержка", + CUSTOM: "Свой", +}; + +/** `code` попадает в embed-URL виджета, поэтому только латиница, цифры и дефис. */ +export function slugify(name: string): string { + const map: Record = { + а: "a", б: "b", в: "v", г: "g", д: "d", е: "e", ё: "e", ж: "zh", з: "z", + и: "i", й: "i", к: "k", л: "l", м: "m", н: "n", о: "o", п: "p", р: "r", + с: "s", т: "t", у: "u", ф: "f", х: "h", ц: "c", ч: "ch", ш: "sh", щ: "sch", + ъ: "", ы: "y", ь: "", э: "e", ю: "yu", я: "ya", + }; + return name + .toLowerCase() + .split("") + .map((char) => map[char] ?? char) + .join("") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 64); +} + +export const BLOCKER_LABELS: Record = { + conversations: "Диалоги", + orders: "Заказы", + attributionTokens: "Attribution-токены", + connections: "Подключения", + supportContracts: "Support-контракты", + agent: "Агент", + llmInvocations: "Вызовы LLM", +}; + +export function blockerSummary(blockers: { type: string; count: number }[]): string { + return blockers + .map((item) => `${(BLOCKER_LABELS[item.type] ?? item.type).toLowerCase()}: ${item.count}`) + .join(", "); +} + +export type DepartmentTab = { key: string; label: string; count: number }; + +/** Табы-фильтры: Все, по одному на отдел видимых каналов, Без отдела (§11.1). */ +export function departmentTabs(channels: Channel[]): DepartmentTab[] { + const byDepartment = new Map(); + let orphans = 0; + for (const channel of channels) { + if (channel.departmentId === null) { + orphans += 1; + continue; + } + const key = String(channel.departmentId); + const existing = byDepartment.get(key); + const label = channel.departmentName ?? channel.department ?? key; + byDepartment.set(key, { label, count: (existing?.count ?? 0) + 1 }); + } + const tabs: DepartmentTab[] = [{ key: "all", label: "Все", count: channels.length }]; + for (const [key, value] of [...byDepartment.entries()].sort((a, b) => + a[1].label.localeCompare(b[1].label, "ru"), + )) { + tabs.push({ key, label: value.label, count: value.count }); + } + if (orphans) tabs.push({ key: "none", label: "Без отдела", count: orphans }); + return tabs; +} + +export function filterChannels( + channels: Channel[], + { tab, search, showArchived }: { tab: string; search: string; showArchived: boolean }, +): Channel[] { + const query = search.trim().toLowerCase(); + return channels.filter((channel) => { + if (!showArchived && !channel.isActive) return false; + if (tab === "none" && channel.departmentId !== null) return false; + if (tab !== "all" && tab !== "none" && String(channel.departmentId) !== tab) return false; + if (!query) return true; + return ( + channel.name.toLowerCase().includes(query) || channel.code.toLowerCase().includes(query) + ); + }); +} diff --git a/apps/internal-ui/src/features/channels/styles.css b/apps/internal-ui/src/features/channels/styles.css new file mode 100644 index 0000000..cc6d77a --- /dev/null +++ b/apps/internal-ui/src/features/channels/styles.css @@ -0,0 +1,994 @@ +/* Каналы обработки — DG-02 baseline. Плотная enterprise-вёрстка: крупные + карточки и большие пустые отступы не используются (ADR-HUB-0013). */ + +.channels-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + border-bottom: 1px solid #e8eaee; + margin-bottom: 14px; +} + +.channels-toolbar-tools { + display: flex; + align-items: center; + gap: 10px; + padding-bottom: 6px; +} + +.channels-archive-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 12.5px; + color: #5b6472; + cursor: pointer; +} + +.channels-archive-toggle input { + display: none; +} + +.channels-archive-toggle span { + position: relative; + width: 34px; + height: 20px; + border-radius: 999px; + background: #ccd1d9; + flex: none; + transition: background 0.15s; +} + +.channels-archive-toggle span::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: #fff; + transition: transform 0.15s; +} + +.channels-archive-toggle input:checked + span { + background: #1677ff; +} + +.channels-archive-toggle input:checked + span::after { + transform: translateX(14px); +} + +.channels-table td { + vertical-align: middle; +} + +.channels-table tr.is-archived { + background: #fcfcfc; +} + +.channels-table tr.is-archived .channel-name { + color: #8a9099; +} + +.channel-name { + font-weight: 600; + color: #1f2530; +} + +.channel-sub { + font-size: 12px; + color: #8a9099; + margin-top: 2px; +} + +.channel-sub code { + font-family: var(--font-mono, ui-monospace, monospace); +} + +.channel-chip { + display: inline-flex; + align-items: center; + padding: 0 7px; + border-radius: 999px; + background: #f1f3f5; + font-size: 10.5px; + font-weight: 600; + color: #8a9099; +} + +.channel-muted { + color: #8a9099; + font-size: 12.5px; +} + +.channel-product-tag { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 9px; + border-radius: 999px; + font-size: 11.5px; + font-weight: 600; +} + +.channel-product-tag i { + width: 6px; + height: 6px; + border-radius: 50%; +} + +.channel-badges { + display: inline-flex; + gap: 5px; +} + +.channel-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 7px; + border-radius: 5px; + font-size: 10.5px; + font-weight: 600; +} + +.channel-badge i { + width: 5px; + height: 5px; + border-radius: 50%; +} + +.channel-badge--plain { + background: #f1f3f5; + color: #5b6472; +} + +.channel-status { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; +} + +.channel-status i { + width: 7px; + height: 7px; + border-radius: 50%; +} + +.channel-status--active, +.channel-status--ok { + color: #389e0d; +} + +.channel-status--active i, +.channel-status--ok i { + background: #52c41a; +} + +.channel-status--draft, +.channel-status--unchecked { + color: #8a9099; +} + +.channel-status--draft i, +.channel-status--unchecked i { + background: #ccd1d9; +} + +.channel-status--error { + color: #cf1322; +} + +.channel-status--error i { + background: #ff4d4f; +} + +.channel-status--archived { + color: #8a9099; +} + +.channel-status--archived i { + background: transparent; + border: 1.5px solid #b4bac2; +} + +.channel-agent-cell { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 3px; +} + +.channel-agent-cell a { + font-weight: 600; + color: #1677ff; +} + +.channel-agent-empty span { + font-size: 12.5px; + color: #8a9099; +} + +.channel-agent-empty em { + display: block; + font-size: 11px; + color: #b4bac2; + font-style: normal; + margin-top: 2px; +} + +.channel-actions { + position: relative; + text-align: right; + width: 44px; +} + +.channel-menu-trigger { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 7px; + border: 1px solid transparent; + background: none; + color: #8a9099; + cursor: pointer; +} + +.channel-menu-trigger.is-open { + border-color: #adcffb; + background: #e6f4ff; + color: #0958d9; +} + +.channel-row-menu { + position: absolute; + top: 40px; + right: 12px; + z-index: 5; + width: 236px; + background: #fff; + border: 1px solid #dfe3e8; + border-radius: 12px; + box-shadow: 0 12px 32px rgba(15, 20, 30, 0.14); + padding: 6px; + text-align: left; +} + +.channel-row-menu button { + display: flex; + align-items: center; + gap: 9px; + width: 100%; + padding: 8px 10px; + border: none; + border-radius: 7px; + background: none; + font-size: 13px; + color: #1f2530; + cursor: pointer; +} + +.channel-row-menu button:hover { + background: #f5f7fa; +} + +.channel-row-menu button.is-warning { + color: #d48806; +} + +.channels-table-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 11px 16px; + border-top: 1px solid #e8eaee; + font-size: 12.5px; + color: #8a9099; +} + +.channels-empty { + background: #fff; + border: 1px solid #e8eaee; + border-radius: 16px; + padding: 48px 24px; + text-align: center; + max-width: 420px; + margin: 24px auto; +} + +.channels-empty-mark { + display: flex; + align-items: center; + justify-content: center; + width: 46px; + height: 46px; + border-radius: 12px; + background: #e6f4ff; + color: #1677ff; + margin: 0 auto 14px; +} + +.channels-empty strong { + display: block; + font-size: 14px; + color: #1f2530; + margin-bottom: 5px; +} + +.channels-empty p { + margin: 0 0 16px; + font-size: 12.5px; + color: #8a9099; + line-height: 1.5; +} + +/* ---------- Карточка канала: одна страница, без вкладок ---------- */ + +.channel-detail { + display: flex; + flex-direction: column; + gap: 16px; +} + +.channel-breadcrumb { + display: flex; + align-items: center; + gap: 8px; + font-size: 12.5px; + color: #8a9099; +} + +.channel-breadcrumb a { + color: #5b6472; + font-weight: 500; +} + +.channel-breadcrumb strong { + color: #1f2530; +} + +.channel-detail-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; +} + +.channel-detail-header h1 { + margin: 0 0 5px; + font-size: 22px; + font-weight: 700; + letter-spacing: -0.02em; + color: #131923; +} + +.channel-detail-meta { + display: flex; + align-items: center; + gap: 10px; + font-size: 12.5px; + color: #8a9099; +} + +.channel-detail-meta code { + font-family: var(--font-mono, ui-monospace, monospace); +} + +.channel-detail-product { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.channel-detail-product i { + width: 6px; + height: 6px; + border-radius: 50%; +} + +.channel-detail-actions { + display: flex; + align-items: center; + gap: 10px; + flex: none; +} + +.channel-state-pill { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12.5px; + font-weight: 600; + padding: 5px 11px; + border-radius: 999px; + border: 1px solid transparent; +} + +.channel-state-pill i { + width: 7px; + height: 7px; + border-radius: 50%; +} + +.channel-state-pill.is-active { + color: #389e0d; + background: #f6ffed; + border-color: #b7eb8f; +} + +.channel-state-pill.is-active i { + background: #52c41a; +} + +.channel-state-pill.is-archived { + color: #8a9099; + background: #f5f7fa; + border-color: #e8eaee; +} + +.channel-state-pill.is-archived i { + border: 1.5px solid #b4bac2; +} + +.channel-feedback { + padding: 12px 14px; + border-radius: 10px; + font-size: 13px; + line-height: 1.5; +} + +.channel-feedback.is-error { + background: #fff2f0; + border: 1px solid #ffccc7; + color: #cf1322; +} + +.channel-feedback.is-warning { + background: #fffbe6; + border: 1px solid #ffe58f; + color: #d48806; +} + +.channel-card-section { + background: #fff; + border: 1px solid #e8eaee; + border-radius: 14px; + box-shadow: 0 1px 2px rgba(15, 20, 30, 0.04); + padding: 20px; +} + +.channel-card-section header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 14px; +} + +.channel-card-section header h3 { + margin: 0 auto 0 0; + font-size: 15px; + font-weight: 600; + color: #131923; +} + +.channel-card-section header span { + font-size: 12px; + color: #8a9099; +} + +.channel-section-eyebrow { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.05em; + color: #8a9099; + margin-bottom: 12px; +} + +.channel-assignment { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px 24px; + font-size: 13px; +} + +.channel-assignment label, +.channel-readonly { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 0; + border-bottom: 1px solid #f1f3f5; +} + +.channel-assignment label > span, +.channel-readonly > span { + color: #8a9099; +} + +.channel-assignment select { + height: 32px; + min-width: 180px; + padding: 0 8px; + border: 1px solid #d9dee5; + border-radius: 8px; + background: #fff; + font-size: 13px; + color: #1f2530; +} + +.channel-assignment select:disabled { + background: #f5f7fa; + color: #8a9099; +} + +.channel-policy-row { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 14px 0; + border-top: 1px solid #f1f3f5; +} + +.channel-policy-row.is-locked { + background: #fffbe6; + margin: 0 -20px; + padding-left: 20px; + padding-right: 20px; +} + +.channel-policy-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 13.5px; + font-weight: 600; + color: #1f2530; +} + +.channel-policy-row.is-locked .channel-policy-title { + color: #8a9099; +} + +.channel-policy-text p { + margin: 3px 0 0; + font-size: 12px; + color: #8a9099; + line-height: 1.5; +} + +.channel-policy-row.is-locked .channel-policy-text p { + color: #d48806; +} + +.channel-lock-badge { + display: inline-flex; + align-items: center; + font-size: 10.5px; + font-weight: 600; + color: #d48806; + background: #fff; + border: 1px solid #ffe58f; + padding: 1px 7px; + border-radius: 999px; +} + +.channel-policy-note { + margin: 12px 0 0; + font-size: 12px; + color: #8a9099; + line-height: 1.5; +} + +.channel-switch input { + display: none; +} + +.channel-switch span { + position: relative; + display: block; + width: 40px; + height: 22px; + border-radius: 999px; + background: #e8eaee; + cursor: pointer; + flex: none; + margin-top: 1px; +} + +.channel-switch span::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 18px; + height: 18px; + border-radius: 50%; + background: #b4bac2; + transition: transform 0.15s; +} + +.channel-switch input:checked + span { + background: #1677ff; +} + +.channel-switch input:checked + span::after { + background: #fff; + transform: translateX(18px); +} + +.channel-switch input:disabled + span { + cursor: not-allowed; + opacity: 0.75; +} + +.channel-connections { + display: flex; + flex-direction: column; + gap: 8px; +} + +.channel-connection { + display: flex; + align-items: center; + gap: 11px; + padding: 11px 12px; + border: 1px solid #e8eaee; + border-radius: 10px; +} + +.channel-connection-text { + flex: 1; + line-height: 1.3; +} + +.channel-connection-text strong { + display: block; + font-size: 13px; + color: #1f2530; +} + +.channel-connection-text span { + font-size: 11.5px; + color: #8a9099; +} + +.channel-agent-row { + display: flex; + align-items: center; + gap: 12px; +} + +.channel-agent-mark { + display: flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: 9px; + background: #f9f0ff; + color: #722ed1; + flex: none; +} + +.channel-agent-summary { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + min-width: 0; + font-size: 13.5px; +} + +.channel-agent-summary code { + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 12px; + color: #8a9099; +} + +.channel-agent-link, +.channel-agent-none a { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12.5px; + font-weight: 600; + color: #1677ff; + flex: none; +} + +.channel-agent-none { + text-align: center; + padding: 18px 8px; +} + +.channel-agent-none .channel-agent-mark { + margin: 0 auto 12px; + width: 44px; + height: 44px; + border-radius: 11px; +} + +.channel-agent-none strong { + display: block; + font-size: 14px; + color: #1f2530; + margin-bottom: 5px; +} + +.channel-agent-none p { + margin: 0 0 16px; + font-size: 12.5px; + color: #8a9099; + line-height: 1.5; +} + +.channel-counters { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.channel-counters > div { + background: #fafbfc; + border: 1px solid #e8eaee; + border-radius: 12px; + padding: 16px 18px; +} + +.channel-counters span { + display: block; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + color: #8a9099; + margin-bottom: 8px; +} + +.channel-counters b { + font-size: 26px; + font-weight: 700; + color: #131923; + font-variant-numeric: tabular-nums; +} + +.channel-blockers { + background: #fff; + border: 1px solid #ffccc7; + border-radius: 14px; + overflow: hidden; +} + +.channel-blockers-head { + padding: 18px 20px 14px; + border-bottom: 1px solid #e8eaee; +} + +.channel-blockers-head strong { + display: block; + font-size: 15px; + color: #131923; + margin-bottom: 4px; +} + +.channel-blockers-head p { + margin: 0; + font-size: 12.5px; + color: #5b6472; + line-height: 1.5; +} + +.channel-blockers-list { + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px 20px; +} + +.channel-blockers-list > div { + display: flex; + align-items: center; + justify-content: space-between; + padding: 9px 12px; + border: 1px solid #e8eaee; + border-radius: 8px; + font-size: 12.5px; +} + +.channel-blockers-list b { + font-variant-numeric: tabular-nums; +} + +.channel-blockers-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 14px 20px; + border-top: 1px solid #e8eaee; +} + +/* ---------- Мастер создания: три шага ---------- */ + +.channel-wizard h1 { + margin: 0 0 4px; + font-size: 22px; + font-weight: 700; + letter-spacing: -0.02em; + color: #131923; +} + +.channel-wizard-lead { + margin: 0 0 20px; + font-size: 13.5px; + color: #5b6472; +} + +.channel-stepper { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; + max-width: 720px; +} + +.channel-step { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + font-weight: 600; + color: #8a9099; +} + +.channel-step + .channel-step::before { + content: ""; + width: 48px; + height: 2px; + background: #e8eaee; +} + +.channel-step span { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 50%; + background: #e8eaee; + color: #8a9099; + font-size: 12px; + flex: none; +} + +.channel-step.is-done { + color: #1f2530; +} + +.channel-step.is-done span { + background: #52c41a; + color: #fff; +} + +.channel-wizard-fields { + display: flex; + flex-direction: column; + gap: 14px; + max-width: 520px; +} + +.channel-wizard-fields label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 13px; +} + +.channel-wizard-fields label > span { + font-weight: 600; + color: #1f2530; +} + +.channel-wizard-fields input, +.channel-wizard-fields select { + height: 36px; + padding: 0 10px; + border: 1px solid #d9dee5; + border-radius: 8px; + font-size: 13px; + color: #1f2530; + background: #fff; +} + +.channel-wizard-fields em { + font-size: 11.5px; + font-style: normal; + color: #8a9099; +} + +.channel-wizard-fields em.is-warning { + color: #d48806; +} + +.channel-wizard-policy { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 14px; +} + +.channel-wizard-policy span { + padding: 2px 8px; + border-radius: 999px; + background: #f1f3f5; + font-size: 10.5px; + color: #8a9099; +} + +.channel-wizard-policy span.is-on { + background: #e6f4ff; + color: #0958d9; + font-weight: 600; +} + +.channel-wizard-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 20px; +} + +.channel-wizard-actions.is-centered { + justify-content: center; +} + +.channel-wizard-final { + display: flex; + flex-direction: column; + gap: 16px; +} + +.channel-wizard-done { + text-align: center; +} + +.channel-wizard-check { + display: flex; + align-items: center; + justify-content: center; + width: 52px; + height: 52px; + border-radius: 14px; + background: #f6ffed; + color: #389e0d; + margin: 0 auto 16px; +} + +.channel-wizard-done strong { + display: block; + font-size: 18px; + color: #131923; + margin-bottom: 6px; +} + +.channel-wizard-done p { + margin: 0 auto 20px; + max-width: 440px; + font-size: 13.5px; + color: #5b6472; + line-height: 1.55; +} + +.channel-wizard-agent-hint { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 16px; + font-size: 12.5px; + font-weight: 600; + color: #722ed1; +} diff --git a/apps/internal-ui/src/features/channels/types.ts b/apps/internal-ui/src/features/channels/types.ts new file mode 100644 index 0000000..0559ce7 --- /dev/null +++ b/apps/internal-ui/src/features/channels/types.ts @@ -0,0 +1,47 @@ +export type ChannelPolicy = { + requiresAuthenticatedProductIdentity: boolean; + allowAnonymousSessions: boolean; + allowSelfReportedContact: boolean; + allowSalesAttribution: boolean; + allowCheckoutActions: boolean; +}; + +export type PolicyFlag = keyof ChannelPolicy; + +export type PolicyPreset = "SALES" | "SUPPORT" | "CUSTOM"; + +/** Сводка агента для перехода на его страницу. Конфигурации агента здесь нет. */ +export type ChannelAgent = { + id: number; + name: string; + status: string; + model: string; +}; + +export type ChannelConnection = { + id: number; + provider: string; + name: string; + status: string; +}; + +export type Channel = { + id: number; + code: string; + name: string; + isActive: boolean; + product: { id: number; code: string; name: string } | null; + departmentId: number | null; + department: string | null; + departmentName: string | null; + agent: ChannelAgent | null; + connections: ChannelConnection[]; + policy: ChannelPolicy; + counters: { openConversations: number; connections: number }; + createdAt: string; + updatedAt: string; +}; + +export type DeletionBlocker = { type: string; count: number }; + +export type PolicyViolation = { rule: string; field: string; detail: string }; diff --git a/apps/internal-ui/src/layout/Shell.tsx b/apps/internal-ui/src/layout/Shell.tsx index 3ee9fc5..49f52b7 100644 --- a/apps/internal-ui/src/layout/Shell.tsx +++ b/apps/internal-ui/src/layout/Shell.tsx @@ -12,7 +12,7 @@ import { ShellRouteContent } from "./ShellRouteContent"; import { TopBar } from "./TopBar"; import { canAccess } from "../auth/access"; -export function Shell({ route, setRoute, selectedEmployeeId, selectedProductId, selectedProductCode, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, selectedOrderId, openEmployeeRoute, openProductRoute, openAgentCreateRoute, openAgentRoute, openKnowledgeRoute, openConversationRoute, openClientRoute, openOrderRoute, user, data, reload, onUserUpdated, onLogout }: { route: RouteKey; setRoute: (route: RouteKey) => void; selectedEmployeeId: number | null; selectedProductId: number | null; selectedProductCode: string | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; selectedOrderId: number | null; openEmployeeRoute: (employeeId: number) => void; openProductRoute: (productId: number) => void; openAgentCreateRoute: (productCode: string | null) => void; openAgentRoute: (agentId: number) => void; openKnowledgeRoute: (knowledgeId: number) => void; openConversationRoute: (conversationId: number) => void; openClientRoute: (clientId: number) => void; openOrderRoute: (orderId: number) => void; user: SessionUser; data: AppData; reload: () => void; onUserUpdated: (user: SessionUser) => void; onLogout: () => void }) { +export function Shell({ route, setRoute, selectedEmployeeId, selectedProductId, selectedProductCode, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, selectedOrderId, selectedChannelId, openChannelRoute, openEmployeeRoute, openProductRoute, openAgentCreateRoute, openAgentRoute, openKnowledgeRoute, openConversationRoute, openClientRoute, openOrderRoute, user, data, reload, onUserUpdated, onLogout }: { route: RouteKey; setRoute: (route: RouteKey) => void; selectedEmployeeId: number | null; selectedProductId: number | null; selectedProductCode: string | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; selectedOrderId: number | null; selectedChannelId: number | null; openEmployeeRoute: (employeeId: number) => void; openProductRoute: (productId: number) => void; openAgentCreateRoute: (productCode: string | null) => void; openAgentRoute: (agentId: number) => void; openKnowledgeRoute: (knowledgeId: number) => void; openConversationRoute: (conversationId: number) => void; openClientRoute: (clientId: number) => void; openOrderRoute: (orderId: number) => void; openChannelRoute: (channelId: number) => void; user: SessionUser; data: AppData; reload: () => void; onUserUpdated: (user: SessionUser) => void; onLogout: () => void }) { const [agentName, setAgentName] = useState(null); const [notifications, setNotifications] = useState([]); const [unreadCount, setUnreadCount] = useState(0); @@ -107,7 +107,7 @@ export function Shell({ route, setRoute, selectedEmployeeId, selectedProductId, { setNotifOpen(true); void loadNotifications(); }} />
- +
diff --git a/apps/internal-ui/src/layout/ShellRouteContent.tsx b/apps/internal-ui/src/layout/ShellRouteContent.tsx index 0b1f4f9..b1c79b0 100644 --- a/apps/internal-ui/src/layout/ShellRouteContent.tsx +++ b/apps/internal-ui/src/layout/ShellRouteContent.tsx @@ -5,6 +5,9 @@ import { KnowledgeDetailPage } from "../features/ai/knowledge/KnowledgeDetailPag import { KnowledgeCreatePage } from "../features/ai/knowledge/KnowledgeCreatePage"; import { KnowledgePage } from "../features/ai/knowledge/KnowledgePage"; import { CommandCenter } from "../features/command/CommandCenter"; +import { ChannelsPage } from "../features/channels/ChannelsPage"; +import { ChannelDetailPage } from "../features/channels/ChannelDetailPage"; +import { ChannelCreateWizard } from "../features/channels/ChannelCreateWizard"; import { IntegrationsPage } from "../features/integrations/IntegrationsPage"; import { DepartmentsPage } from "../features/departments/DepartmentsPage"; import { AccessProfilesPage, EmployeeDetailPage, EmployeesPage } from "../features/employees/EmployeesPage"; @@ -23,7 +26,7 @@ import { SupportOverviewPage } from "../features/support/SupportOverviewPage"; import type { AppData, Employee, Product, RouteKey, SessionUser } from "../types"; import { hasCapability } from "../auth/access"; -export function ShellRouteContent({ route, data, currentEmployee, currentProduct, selectedProductCode, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, openClient, selectedOrderId, openOrder, openConversation, openEmployee, openProduct, openAgentCreate, openAgent, openKnowledge, onAgentLoaded, reload, setRoute, user, onUserUpdated, onLogout }: { route: RouteKey; data: AppData; currentEmployee: Employee | null; currentProduct: Product | null; selectedProductCode: string | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; openClient: (clientId: number) => void; selectedOrderId: number | null; openOrder: (orderId: number) => void; openConversation: (conversationId: number) => void; openEmployee: (employee: Employee) => void; openProduct: (product: Product) => void; openAgentCreate: (productCode: string | null) => void; openAgent: (agentId: number) => void; openKnowledge: (knowledgeId: number) => void; onAgentLoaded: (name: string | null) => void; reload: () => void; setRoute: (route: RouteKey) => void; user: SessionUser; onUserUpdated: (user: SessionUser) => void; onLogout: () => void }) { +export function ShellRouteContent({ route, data, currentEmployee, currentProduct, selectedProductCode, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, openClient, selectedOrderId, openOrder, selectedChannelId, openChannel, openConversation, openEmployee, openProduct, openAgentCreate, openAgent, openKnowledge, onAgentLoaded, reload, setRoute, user, onUserUpdated, onLogout }: { route: RouteKey; data: AppData; currentEmployee: Employee | null; currentProduct: Product | null; selectedProductCode: string | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; openClient: (clientId: number) => void; selectedOrderId: number | null; openOrder: (orderId: number) => void; selectedChannelId: number | null; openChannel: (channelId: number) => void; openConversation: (conversationId: number) => void; openEmployee: (employee: Employee) => void; openProduct: (product: Product) => void; openAgentCreate: (productCode: string | null) => void; openAgent: (agentId: number) => void; openKnowledge: (knowledgeId: number) => void; onAgentLoaded: (name: string | null) => void; reload: () => void; setRoute: (route: RouteKey) => void; user: SessionUser; onUserUpdated: (user: SessionUser) => void; onLogout: () => void }) { return ( <> {route === "command" && } @@ -47,10 +50,13 @@ export function ShellRouteContent({ route, data, currentEmployee, currentProduct {route === "salesOrders" && } {route === "aiAgents" && } {route === "aiAgentCreate" && } - {route === "aiAgentDetail" && } + {route === "aiAgentDetail" && } {route === "aiKnowledge" && } {route === "aiKnowledgeCreate" && } {route === "aiKnowledgeDetail" && } + {route === "channels" && setRoute("channelCreate")} openAgent={openAgent} />} + {route === "channelDetail" && setRoute("channels")} />} + {route === "channelCreate" && setRoute("channels")} openAgentCreate={() => openAgentCreate(null)} />} {route === "integrations" && } ); diff --git a/apps/internal-ui/src/layout/Sidebar.tsx b/apps/internal-ui/src/layout/Sidebar.tsx index 773b674..d15f5db 100644 --- a/apps/internal-ui/src/layout/Sidebar.tsx +++ b/apps/internal-ui/src/layout/Sidebar.tsx @@ -11,6 +11,7 @@ export function Sidebar({ route, user, setRoute, onLogout }: { route: RouteKey; { key: "departments" as const, label: "Отделы", icon: "building" as const }, { key: "employees" as const, label: "Сотрудники", icon: "team" as const }, { key: "products" as const, label: "Продукты", icon: "box" as const }, + { key: "channels" as const, label: "Каналы", icon: "split" as const }, { label: "ПЛАТФОРМА", group: true }, { key: "aiAgents" as const, label: "AI", icon: "robot" as const }, { key: "integrations" as const, label: "Интеграции", icon: "plug" as const }, @@ -29,7 +30,7 @@ export function Sidebar({ route, user, setRoute, onLogout }: { route: RouteKey; if ("divider" in item) return
; const nextRoute = "key" in item ? item.key : null; if (nextRoute && !canAccess(user, nextRoute)) return null; - const active = nextRoute === route || (nextRoute === "employees" && (route === "employeeDetail" || route === "accessProfiles")) || (nextRoute === "products" && route === "productDetail") || (nextRoute === "aiAgents" && route.startsWith("ai")); + const active = nextRoute === route || (nextRoute === "employees" && (route === "employeeDetail" || route === "accessProfiles")) || (nextRoute === "products" && route === "productDetail") || (nextRoute === "aiAgents" && route.startsWith("ai")) || (nextRoute === "channels" && route.startsWith("channel")); return (