✨ feat(ui): add the channels section

SPEC-HUB-0027 §10-§11, этап 4. Раздел «Каналы» в группе КОМПАНИЯ после
«Продукты»: список с фильтрами по отделу, карточка канала одной страницей и
мастер создания из трёх шагов. Вёрстка следует принятому design baseline
DG-02 (кадры A-E/S).

Карточка не имеет вкладок. Секции идут сверху вниз: Назначение, Политика,
Подключения, AI-агент, Счётчики. Секция агента — одна строка сводки
(имя · статус · модель) и ссылка наружу на его существующую страницу: полей
агента, знаний и их счётчиков в карточке нет, как и ссылки на саму себя.

Секция «Политика» показывает причину недоступности флага, запрещённого
инвариантом при текущем продукте. Причина выводится на клиенте из продукта и
обязательной идентичности: правила P1-P5 детерминированы, а источником прав
остаётся backend — он отклоняет запрос независимо от того, что показал
интерфейс. Флаг не игнорируется молча при сохранении.

Модалка ChannelEditForm удалена: ссылка на канал в шапке агента теперь ведёт
в карточку канала. Маршруты aiAgents, aiAgentCreate, aiAgentDetail,
aiKnowledge* и aiUsage не изменены и не редиректят — покрыто тестом.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Opus 4.8 committed 2026-07-21 13:33:40 +03:00
1 parent 2b97f08b19
commit 4c77a4e5b8
24 files changed
+2520 -57

No files matched your search

+1 -1
View File
@@ -151,7 +151,7 @@ export function App() {
) : !canAccess(user, navigation.route) ? (
<PermissionScreen onReturn={() => navigate(defaultRoute(user), null, true)} />
) : (
<Shell route={navigation.route} setRoute={(nextRoute) => 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} />
<Shell route={navigation.route} setRoute={(nextRoute) => 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} />
)}
</ConfigProvider>
);
+3
View File
@@ -18,6 +18,9 @@ const ROUTE_ACCESS: Partial<Record<RouteKey, RouteAccess>> = {
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" },
@@ -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 (
<section className="ai-agent-header">
@@ -15,7 +15,7 @@ export function AiAgentDetailHeader({ agent, onEditChannel }: { agent: AiAgentDe
<StatusPill status={agent.isActive ? "active" : "disabled"} />
</div>
<div className="ai-agent-meta">
<div><span>Канал</span><button type="button" className="ai-agent-meta-link" onClick={onEditChannel}>{agent.channel.name}</button></div>
<div><span>Канал</span><button type="button" className="ai-agent-meta-link" onClick={onOpenChannel}>{agent.channel.name}</button></div>
<div><span>Модель</span><code className="ai-mono">{agent.model}</code></div>
<div><span>Знания</span><b>{agent.knowledge.length > 0 ? `${agent.knowledge.length} выбрано` : "—"}</b></div>
<div><span>Лимиты</span><b>{dailyBudget(agent.limits)}</b></div>
@@ -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<AgentTab>("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 (
<div className="ai-agent-page">
<AiAgentDetailHeader agent={agent} onEditChannel={() => setChannelEdit(true)} />
<AiAgentDetailHeader agent={agent} onOpenChannel={() => openChannel(agent.channel.id)} />
<UnderlineTabs className="ai-agent-tabs" items={agentTabs} value={tab} onChange={setTab} />
{tab === "overview" && <AiAgentOverviewTab agent={agent} toggleActive={toggleActive} onEdit={() => setEditOpen(true)} />}
{tab === "instructions" && <AiAgentInstructionsTab agent={agent} onChanged={reload} />}
{tab === "knowledge" && <AiAgentKnowledgeTab agent={agent} library={library} openKnowledge={openKnowledge} onChanged={reload} />}
{tab === "metrics" && <AiAgentMetricsTab />}
{editOpen && <AgentEditForm agent={agent} onClose={() => setEditOpen(false)} onSaved={() => { setEditOpen(false); reload(); }} />}
{channelEdit && <ChannelEditForm channel={agent.channel} onClose={() => setChannelEdit(false)} onSaved={() => { setChannelEdit(false); reload(); }} />}
</div>
);
}
@@ -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<string | null>(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 (
<Modal open title="Изменить канал" onCancel={onClose} footer={null} destroyOnClose>
<div className="integration-form">
<FormField label="Название канала" value={name} onChange={setName} placeholder="название канала" />
{error && <div className="integration-form-error">{error}</div>}
<div className="integration-form-actions">
<Button variant="secondary" onClick={onClose}>Отмена</Button>
<Button variant="primary" disabled={!ready || submitting} onClick={submit}>{submitting ? "Сохранение…" : "Сохранить"}</Button>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,31 @@
import { channelMeta } from "../conversations/data";
import type { ChannelKey } from "../conversations/types";
const PROVIDER_KEY: Record<string, ChannelKey> = { MAX: "MAX", TELEGRAM: "TG", WEB: "WEB" };
const SHORT_LABEL: Record<ChannelKey, string> = { MAX: "MAX", TG: "TG", WEB: "Web" };
/**
* Точка + короткий лейбл на токенах провайдера (ADR-HUB-0013).
*
* Применяется экономно — только в колонке «Подключения» списка и в секции
* «Подключения» карточки, чтобы экраны не превращались в цветную мозаику.
* Провайдеры различаются бейджем, а не бренд-логотипами.
*/
export function ChannelBadge({ provider }: { provider: string }) {
const key = PROVIDER_KEY[provider];
if (!key) {
return <span className="channel-badge channel-badge--plain">{provider}</span>;
}
const meta = channelMeta[key];
return (
<span className="channel-badge" style={{ background: meta.bg, color: meta.color }}>
<i style={{ background: meta.color }} />
{SHORT_LABEL[key]}
</span>
);
}
export function providerLabel(provider: string): string {
const key = PROVIDER_KEY[provider];
return key ? channelMeta[key].label : provider;
}
@@ -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<number | null>(null);
const [productId, setProductId] = useState<number | null>(null);
const [preset, setPreset] = useState<PolicyPreset>("CUSTOM");
const [created, setCreated] = useState<Channel | null>(null);
const [integrations, setIntegrations] = useState<Integration[]>([]);
const [error, setError] = useState<string | null>(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 (
<div className="channel-wizard">
<h1>Создание канала</h1>
<p className="channel-wizard-lead">
Три шага. Настройка агента и знаний в мастер не входит — агент создаётся отдельным
действием в разделе AI. Канал создаётся по завершении шага 2.
</p>
<div className="channel-stepper">
{STEPS.map((label, index) => (
<div className={`channel-step ${index <= step ? "is-done" : ""}`} key={label}>
<span>{index < step || created ? <Icon name="check" size={14} /> : index + 1}</span>
{label}
</div>
))}
</div>
{error && <div className="channel-feedback is-error">{error}</div>}
{step === 0 && (
<section className="channel-card-section">
<header>
<h3>Канал</h3>
</header>
<div className="channel-wizard-fields">
<label>
<span>Название</span>
<input value={name} onChange={(event) => setName(event.target.value)} placeholder="Партнёрская линия" />
</label>
<label>
<span>Код</span>
<input
value={effectiveCode}
onChange={(event) => {
setCodeTouched(true);
setCode(event.target.value);
}}
placeholder="partners"
/>
<em>Входит в embed-URL виджета и не меняется после создания.</em>
</label>
<label>
<span>Отдел</span>
<select
value={departmentId ?? ""}
onChange={(event) => setDepartmentId(event.target.value ? Number(event.target.value) : null)}
>
<option value="">Без отдела</option>
{departments.map((department) => (
<option value={department.id} key={department.id}>{department.name}</option>
))}
</select>
</label>
</div>
<div className="channel-wizard-actions">
<Button variant="secondary" onClick={openChannels}>Отмена</Button>
<Button variant="primary" disabled={!name.trim() || !effectiveCode} onClick={() => setStep(1)}>
Далее
</Button>
</div>
</section>
)}
{step === 1 && (
<section className="channel-card-section">
<header>
<h3>Назначение</h3>
</header>
<div className="channel-wizard-fields">
<label>
<span>Продукт</span>
<select
value={productId ?? ""}
onChange={(event) => setProductId(event.target.value ? Number(event.target.value) : null)}
>
<option value="">— непродуктовый</option>
{products.map((product) => (
<option value={product.id} key={product.id}>{product.name}</option>
))}
</select>
</label>
<label>
<span>Пресет политики</span>
<select value={preset} onChange={(event) => setPreset(event.target.value as PolicyPreset)}>
{(["SALES", "SUPPORT", "CUSTOM"] as PolicyPreset[]).map((item) => (
<option value={item} key={item}>{PRESET_LABELS[item]}</option>
))}
</select>
{presetNeedsProduct && productId === null && (
<em className="is-warning">
Пресет «{PRESET_LABELS[preset]}» требует продукта — иначе нарушаются инварианты P1–P3.
</em>
)}
</label>
</div>
<div className="channel-wizard-policy">
{Object.entries(policySummary).map(([flag, value]) => (
<span key={flag} className={value ? "is-on" : ""}>{flag}</span>
))}
</div>
<div className="channel-wizard-actions">
<Button variant="secondary" onClick={() => setStep(0)}>Назад</Button>
<Button variant="primary" disabled={!canSubmit || busy} onClick={submit}>
Создать канал
</Button>
</div>
</section>
)}
{step === 2 && created && (
<div className="channel-wizard-final">
<section className="channel-card-section">
<header>
<h3>Подключения</h3>
<span>Шаг можно пропустить</span>
</header>
{integrations.length === 0 ? (
<p className="channel-muted">Свободных подключений нет — их можно привязать позже в карточке канала.</p>
) : (
<div className="channel-connections">
{integrations.map((integration) => {
const bound = created.connections.some((item) => item.id === integration.id);
return (
<div className="channel-connection" key={integration.id}>
<ChannelBadge provider={integration.provider} />
<div className="channel-connection-text">
<strong>{integration.name}</strong>
<span>{providerLabel(integration.provider)}</span>
</div>
<Button
variant="secondary"
disabled={bound}
onClick={async () => {
const response = await bindConnection(created.id, integration.id);
setCreated(response.channel);
}}
>
{bound ? "Привязано" : "Привязать"}
</Button>
</div>
);
})}
</div>
)}
</section>
<section className="channel-card-section channel-wizard-done">
<span className="channel-wizard-check"><Icon name="check" size={28} /></span>
<strong>Канал создан</strong>
<p>
<b>{created.name}</b> — операторский канал: AI-агента нет, диалоги ведут операторы.
Канал принимает подключения, диалоги, заказы и attribution.
</p>
<div className="channel-wizard-actions is-centered">
<Button variant="primary" onClick={() => openChannel(created.id)}>
Открыть карточку канала
</Button>
<Button variant="secondary" onClick={openChannels}>К списку каналов</Button>
</div>
<a
href="#"
className="channel-wizard-agent-hint"
onClick={(event) => {
event.preventDefault();
openAgentCreate();
}}
>
Нужен AI-агент? Создайте его в разделе AI
<Icon name="arrow" size={14} />
</a>
</section>
</div>
)}
</div>
);
}
@@ -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<Channel | null>(null);
const [missing, setMissing] = useState(false);
const [feedback, setFeedback] = useState<Feedback>(null);
const [blockers, setBlockers] = useState<DeletionBlocker[] | null>(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<typeof updateChannel>[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 <EmptyState title="Канал не найден" />;
if (!channel) return <LoadingState />;
const accent = channel.product ? productAccent(channel.product.code) : null;
return (
<div className="channel-detail">
<div className="channel-breadcrumb">
<a
href="#"
onClick={(event) => {
event.preventDefault();
openChannels();
}}
>
Каналы
</a>
<span>/</span>
<strong>{channel.name}</strong>
</div>
<div className="channel-detail-header">
<div>
<h1>{channel.name}</h1>
<div className="channel-detail-meta">
<code>{channel.code}</code>
<span>·</span>
{channel.departmentName ?? <span className="channel-chip">Без отдела</span>}
<span>·</span>
{channel.product && accent ? (
<span className="channel-detail-product">
<i style={{ background: accent.color }} />
{channel.product.name}
</span>
) : (
<span className="channel-muted">— непродуктовый</span>
)}
</div>
</div>
<div className="channel-detail-actions">
<span
className={`channel-state-pill ${channel.isActive ? "is-active" : "is-archived"}`}
>
<i />
{channel.isActive ? "Активен" : "Архивный"}
</span>
{canManage && (
<Button
variant="secondary"
icon="pause"
onClick={() => patch({ isActive: !channel.isActive })}
>
{channel.isActive ? "Деактивировать" : "Активировать"}
</Button>
)}
{canManage && (
<Button variant="danger-outline" icon="trash" onClick={remove}>
Удалить
</Button>
)}
</div>
</div>
{feedback && <div className={`channel-feedback is-${feedback.kind}`}>{feedback.text}</div>}
{blockers && (
<div className="channel-blockers">
<div className="channel-blockers-head">
<strong>Канал нельзя удалить</strong>
<p>Есть связанные записи. Вместо удаления используйте деактивацию — история сохранится.</p>
</div>
<div className="channel-blockers-list">
{blockers.map((item) => (
<div key={item.type}>
<span>{BLOCKER_LABELS[item.type] ?? item.type}</span>
<b>{item.count}</b>
</div>
))}
</div>
<div className="channel-blockers-actions">
<Button variant="secondary" onClick={() => setBlockers(null)}>
Закрыть
</Button>
{channel.isActive && (
<Button
variant="secondary"
onClick={async () => {
setBlockers(null);
await patch({ isActive: false });
}}
>
Деактивировать вместо удаления
</Button>
)}
</div>
</div>
)}
<section className="channel-card-section">
<header>
<h3>Назначение</h3>
<span>Отдел и продукт редактируются здесь</span>
</header>
<div className="channel-assignment">
<label>
<span>Отдел</span>
<select
value={channel.departmentId ?? ""}
disabled={!canManage}
onChange={(event) =>
patch({ departmentId: event.target.value ? Number(event.target.value) : null })
}
>
<option value="">Без отдела</option>
{departments.map((department) => (
<option value={department.id} key={department.id}>
{department.name}
</option>
))}
</select>
</label>
<label>
<span>Продукт</span>
<select
value={channel.product?.id ?? ""}
disabled={!canManage}
onChange={(event) =>
patch({ productId: event.target.value ? Number(event.target.value) : null })
}
>
<option value="">— непродуктовый</option>
{products.map((product) => (
<option value={product.id} key={product.id}>
{product.name}
</option>
))}
</select>
</label>
<div className="channel-readonly">
<span>Код</span>
<code>{channel.code}</code>
</div>
<div className="channel-readonly">
<span>Создан</span>
<b>{formatDate(channel.createdAt)}</b>
</div>
</div>
</section>
<ChannelPolicySection
channel={channel}
canManage={canManage}
onToggle={(flag: PolicyFlag, value) => patch({ policy: { [flag]: value } })}
/>
<section className="channel-card-section">
<header>
<h3>Подключения</h3>
</header>
{channel.connections.length === 0 ? (
<p className="channel-muted">Подключений нет. Привяжите их в разделе «Интеграции».</p>
) : (
<div className="channel-connections">
{channel.connections.map((connection) => (
<div className="channel-connection" key={connection.id}>
<ChannelBadge provider={connection.provider} />
<div className="channel-connection-text">
<strong>{connection.name}</strong>
<span>{providerLabel(connection.provider)}</span>
</div>
<span className={`channel-status channel-status--${connection.status.toLowerCase()}`}>
<i />
{connection.status}
</span>
{canManage && (
<Button
variant="secondary"
onClick={async () => {
await unbindConnection(channel.id, connection.id);
await reload();
}}
>
Отвязать
</Button>
)}
</div>
))}
</div>
)}
</section>
{/* Одна строка сводки и переход наружу: редактор агента остаётся на его странице. */}
<section className="channel-card-section channel-agent-section">
<div className="channel-section-eyebrow">AI-АГЕНТ</div>
{channel.agent ? (
<div className="channel-agent-row">
<span className="channel-agent-mark">
<Icon name="robot" size={18} />
</span>
<div className="channel-agent-summary">
<b>{channel.agent.name}</b>
<span>·</span>
<span
className={`channel-status ${
channel.agent.status === "ACTIVE" ? "channel-status--active" : "channel-status--draft"
}`}
>
<i />
{channel.agent.status === "ACTIVE" ? "Активен" : "Черновик"}
</span>
<span>·</span>
<code>{channel.agent.model}</code>
</div>
<a
href="#"
className="channel-agent-link"
onClick={(event) => {
event.preventDefault();
openAgent(channel.agent!.id);
}}
>
Открыть агента
<Icon name="arrow" size={14} />
</a>
</div>
) : (
<div className="channel-agent-none">
<span className="channel-agent-mark">
<Icon name="robot" size={22} />
</span>
<strong>Агента нет, канал ведут операторы</strong>
<p>Валидное состояние канала. Агент создаётся отдельным действием в разделе AI.</p>
<a
href="#"
onClick={(event) => {
event.preventDefault();
setRoute("aiAgentCreate");
}}
>
Создать агента в разделе AI
<Icon name="arrow" size={14} />
</a>
</div>
)}
</section>
<section className="channel-card-section">
<header>
<h3>Счётчики</h3>
</header>
<div className="channel-counters">
<div>
<span>ОТКРЫТЫЕ ДИАЛОГИ</span>
<b>{channel.counters.openConversations}</b>
</div>
<div>
<span>ПОДКЛЮЧЕНИЯ</span>
<b>{channel.counters.connections}</b>
</div>
</div>
</section>
</div>
);
}
@@ -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 (
<section className="channel-card-section channel-policy">
<header>
<h3>Политика</h3>
<span>Пресет: {PRESET_LABELS[presetOf(channel.policy)]}</span>
</header>
{POLICY_FLAGS.map((flag) => {
const lock = flagLock(flag, channel.policy, hasProduct);
const checked = channel.policy[flag];
const disabled = !canManage || lock !== null;
return (
<div className={`channel-policy-row ${lock ? "is-locked" : ""}`} key={flag}>
<label className="channel-switch">
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(event) => onToggle(flag, event.target.checked)}
/>
<span />
</label>
<div className="channel-policy-text">
<div className="channel-policy-title">
{POLICY_LABELS[flag].title}
{lock && <span className="channel-lock-badge">Запрещено инвариантом</span>}
</div>
<p>
{lock ? (
<>
{lock.reason} <b>(инвариант {lock.rule})</b>. {lock.fix}
</>
) : (
POLICY_LABELS[flag].hint
)}
</p>
</div>
</div>
);
})}
<p className="channel-policy-note">
Инварианты проверяются по итоговому состоянию: частичное сохранение запрещено.
</p>
</section>
);
}
@@ -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<Channel[] | null>(null);
const [error, setError] = useState<string | null>(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 <EmptyState title={error} />;
if (!channels) return <LoadingState />;
const archived = channels.filter((channel) => !channel.isActive).length;
return (
<>
<PageHeader
title="Каналы"
text="Точка маршрутизации диалогов: связывает отдел, продукт, подключения и — необязательно — AI-агента"
action={
canManage ? (
<Button variant="primary" icon="plus" onClick={openChannelCreate}>
Создать канал
</Button>
) : undefined
}
/>
{channels.length === 0 ? (
<div className="channels-empty">
<span className="channels-empty-mark">
<Icon name="split" size={24} />
</span>
<strong>Создайте первый канал</strong>
<p>
Канал маршрутизирует диалоги от подключений к отделу, продукту и — при необходимости —
к AI-агенту.
</p>
{canManage && (
<Button variant="primary" onClick={openChannelCreate}>
Создать канал
</Button>
)}
</div>
) : (
<>
<div className="channels-toolbar">
<UnderlineTabs items={tabs} value={tab} onChange={setTab} />
<div className="channels-toolbar-tools">
<SearchInput placeholder="Поиск по имени и коду" value={search} onChange={setSearch} />
<label className="channels-archive-toggle">
<input
type="checkbox"
checked={showArchived}
onChange={(event) => setShowArchived(event.target.checked)}
/>
<span />
Показывать архивные
</label>
</div>
</div>
<ChannelsTable
channels={visible}
canManage={canManage}
openChannel={openChannel}
openAgent={openAgent}
toggleActive={toggleActive}
footer={`${channels.length} каналов${archived ? ` · ${archived} архивных` : ""}`}
/>
</>
)}
</>
);
}
@@ -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 (
<span className="channel-status channel-status--archived">
<i />
Архивный
</span>
);
}
if (!channel.agent) {
// Канал без агента — валидное операторское состояние, а не ошибка.
return (
<div className="channel-agent-empty">
<span>Нет агента</span>
<em>операторский канал</em>
</div>
);
}
const active = channel.agent.status === "ACTIVE";
return (
<div className="channel-agent-cell">
<a
href="#"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
openAgent(channel.agent!.id);
}}
>
{channel.agent.name}
</a>
<span className={`channel-status ${active ? "channel-status--active" : "channel-status--draft"}`}>
<i />
{active ? "Активен" : "Черновик"}
</span>
</div>
);
}
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<void>;
footer: string;
}) {
const [menuId, setMenuId] = useState<number | null>(null);
return (
<div className="table-card channels-table-card">
<table className="baseline-table channels-table">
<thead>
<tr>
<th>КАНАЛ</th>
<th>ПРОДУКТ</th>
<th>ПОДКЛЮЧЕНИЯ</th>
<th>AI-АГЕНТ</th>
<th className="numeric">ДИАЛОГИ</th>
<th aria-label="Действия" />
</tr>
</thead>
<tbody>
{channels.map((channel) => {
const accent = channel.product ? productAccent(channel.product.code) : null;
return (
<tr
key={channel.id}
className={channel.isActive ? "" : "is-archived"}
onClick={() => openChannel(channel.id)}
>
<td>
<a
href="#"
className="channel-name"
onClick={(event) => {
event.preventDefault();
openChannel(channel.id);
}}
>
{channel.name}
</a>
<div className="channel-sub">
<code>{channel.code}</code>
{" · "}
{channel.departmentName ?? <span className="channel-chip">Без отдела</span>}
</div>
</td>
<td>
{channel.product && accent ? (
<span
className="channel-product-tag"
style={{ background: accent.bg, color: accent.color }}
>
<i style={{ background: accent.color }} />
{channel.product.name}
</span>
) : (
<span className="channel-muted">— непродуктовый</span>
)}
</td>
<td>
{channel.connections.length ? (
<span className="channel-badges">
{channel.connections.map((connection) => (
<ChannelBadge key={connection.id} provider={connection.provider} />
))}
</span>
) : (
<span className="channel-muted">—</span>
)}
</td>
<td>
<AgentCell channel={channel} openAgent={openAgent} />
</td>
<td className="numeric">{channel.counters.openConversations}</td>
<td className="channel-actions">
<button
type="button"
className={`channel-menu-trigger ${menuId === channel.id ? "is-open" : ""}`}
onClick={(event) => {
event.stopPropagation();
setMenuId(menuId === channel.id ? null : channel.id);
}}
>
<Icon name="more" size={16} />
</button>
{menuId === channel.id && (
<div className="channel-row-menu" onClick={(event) => event.stopPropagation()}>
<button
type="button"
onClick={() => {
setMenuId(null);
openChannel(channel.id);
}}
>
<Icon name="external" size={15} />
Открыть
</button>
{/* Пункты, недоступные по capability, не показываются вовсе. */}
{canManage && (
<button
type="button"
className="is-warning"
onClick={async () => {
setMenuId(null);
await toggleActive(channel);
}}
>
<Icon name="pause" size={15} />
{channel.isActive ? "Деактивировать" : "Активировать"}
</button>
)}
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
<div className="channels-table-footer">
<span>{footer}</span>
<span>Счётчик «Диалоги» — открытые диалоги канала</span>
</div>
</div>
);
}
@@ -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<ChannelPolicy>;
};
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<string, unknown> = {
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<ChannelResponse> {
return api(`/api/v1/channels/${channelId}/`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function deleteChannel(channelId: number): Promise<void> {
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",
});
}
@@ -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>): 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"]);
});
});
@@ -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<PolicyFlag, { title: string; hint: string }> = {
requiresAuthenticatedProductIdentity: {
title: "Обязательная продуктовая идентичность",
hint: "Диалог требует авторизации как владелец продукта.",
},
allowAnonymousSessions: {
title: "Анонимные сессии",
hint: "Разрешает диалоги без идентификации пользователя.",
},
allowSelfReportedContact: {
title: "Самозаявленный контакт",
hint: "Позволяет пользователю указать контакт вручную.",
},
allowSalesAttribution: {
title: "Attribution продаж",
hint: "Связывает продажу с диалогом канала.",
},
allowCheckoutActions: {
title: "Коммерческие действия (checkout)",
hint: "Разрешает оформление заказа в диалоге.",
},
};
export const PRESETS: Record<Exclude<PolicyPreset, "CUSTOM">, 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<PolicyPreset, string> = {
SALES: "Продажи",
SUPPORT: "Поддержка",
CUSTOM: "Свой",
};
/** `code` попадает в embed-URL виджета, поэтому только латиница, цифры и дефис. */
export function slugify(name: string): string {
const map: Record<string, string> = {
а: "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<string, string> = {
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<string, { label: string; count: number }>();
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)
);
});
}
@@ -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;
}
@@ -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 };
+2 -2
View File
@@ -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<string | null>(null);
const [notifications, setNotifications] = useState<AppNotification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
@@ -107,7 +107,7 @@ export function Shell({ route, setRoute, selectedEmployeeId, selectedProductId,
<TopBar route={route} user={user} currentEmployee={currentEmployee} currentProduct={currentProduct} currentAgentName={agentName} setRoute={setRoute} unreadCount={unreadCount} onOpenNotifications={() => { setNotifOpen(true); void loadNotifications(); }} />
<main className={`hub-scroll ${isDialogsWorkspace ? "sales-dialogs-scroll" : ""} ${isAiFullWidth ? "ai-fullwidth-scroll" : ""}`}>
<div className={`hub-page ${isSalesWorkspace || isSupportWorkspace ? "sales-workspace-page" : ""} ${isDialogsWorkspace ? "sales-dialogs-page" : ""} ${isSalesClients ? "sales-clients-page" : ""} ${isSalesClientDetail ? "sales-client-detail-page" : ""} ${isSalesOrderDetail ? "sales-order-detail-page" : ""} ${isSalesOrders ? "sales-orders-page" : ""} ${isAiFullWidth ? "ai-fullwidth-page" : ""} ${isKnowledgeLibrary ? "ai-knowledge-library-page" : ""} ${isKnowledgeEditor ? "ai-knowledge-editor-page" : ""}`}>
<ShellRouteContent route={route} data={data} currentEmployee={currentEmployee} currentProduct={currentProduct} selectedProductCode={selectedProductCode} selectedAgentId={selectedAgentId} selectedKnowledgeId={selectedKnowledgeId} selectedConversationId={selectedConversationId} selectedClientId={selectedClientId} openClient={openClientRoute} selectedOrderId={selectedOrderId} openOrder={openOrderRoute} openConversation={openConversationRoute} openEmployee={openEmployee} openProduct={openProduct} openAgentCreate={openAgentCreateRoute} openAgent={openAgentRoute} openKnowledge={openKnowledgeRoute} onAgentLoaded={setAgentName} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} />
<ShellRouteContent route={route} data={data} currentEmployee={currentEmployee} currentProduct={currentProduct} selectedProductCode={selectedProductCode} selectedAgentId={selectedAgentId} selectedKnowledgeId={selectedKnowledgeId} selectedConversationId={selectedConversationId} selectedClientId={selectedClientId} openClient={openClientRoute} selectedOrderId={selectedOrderId} openOrder={openOrderRoute} selectedChannelId={selectedChannelId} openChannel={openChannelRoute} openConversation={openConversationRoute} openEmployee={openEmployee} openProduct={openProduct} openAgentCreate={openAgentCreateRoute} openAgent={openAgentRoute} openKnowledge={openKnowledgeRoute} onAgentLoaded={setAgentName} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} />
</div>
</main>
</div>
@@ -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" && <CommandCenter data={data} setRoute={setRoute} />}
@@ -47,10 +50,13 @@ export function ShellRouteContent({ route, data, currentEmployee, currentProduct
{route === "salesOrders" && <SalesRegistryPage products={data.products} setRoute={setRoute} openSale={openOrder} openDialog={openConversation} />}
{route === "aiAgents" && <AiAgentsPage agents={data.agents} reload={reload} openAgentCreate={openAgentCreate} openAgent={openAgent} />}
{route === "aiAgentCreate" && <AiAgentCreatePage selectedProductCode={selectedProductCode} reload={reload} setRoute={setRoute} openAgent={openAgent} />}
{route === "aiAgentDetail" && <AiAgentDetailPage agentId={selectedAgentId} openKnowledge={openKnowledge} onAgentLoaded={onAgentLoaded} />}
{route === "aiAgentDetail" && <AiAgentDetailPage agentId={selectedAgentId} openKnowledge={openKnowledge} openChannel={openChannel} onAgentLoaded={onAgentLoaded} />}
{route === "aiKnowledge" && <KnowledgePage departments={data.departments} openAgent={openAgent} openKnowledge={openKnowledge} setRoute={setRoute} user={user} />}
{route === "aiKnowledgeCreate" && <KnowledgeCreatePage departments={data.departments} openKnowledge={openKnowledge} setRoute={setRoute} />}
{route === "aiKnowledgeDetail" && <KnowledgeDetailPage agents={data.agents} canManage={hasCapability(user, "ai.manage")} departments={data.departments} knowledgeId={selectedKnowledgeId} openAgent={openAgent} setRoute={setRoute} onLoaded={onAgentLoaded} />}
{route === "channels" && <ChannelsPage canManage={hasCapability(user, "channels.manage")} openChannel={openChannel} openChannelCreate={() => setRoute("channelCreate")} openAgent={openAgent} />}
{route === "channelDetail" && <ChannelDetailPage channelId={selectedChannelId} departments={data.departments} products={data.products} canManage={hasCapability(user, "channels.manage")} setRoute={setRoute} openAgent={openAgent} openChannels={() => setRoute("channels")} />}
{route === "channelCreate" && <ChannelCreateWizard departments={data.departments} products={data.products} openChannel={openChannel} openChannels={() => setRoute("channels")} openAgentCreate={() => openAgentCreate(null)} />}
{route === "integrations" && <IntegrationsPage />}
</>
);
+2 -1
View File
@@ -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 <div className="hub-nav-divider" key={index} />;
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 (
<div key={item.label}>
<button className={`hub-nav-item ${active ? "is-active" : ""}`} disabled={item.disabled} onClick={() => nextRoute && setRoute(nextRoute)}>
+26
View File
@@ -11,6 +11,7 @@ const empty = {
knowledgeId: null,
clientId: null,
orderId: null,
channelId: null,
};
describe("employee access routes", () => {
@@ -107,3 +108,28 @@ describe("organization routes", () => {
);
});
});
describe("channel routes", () => {
it("parses the list, wizard and card URLs", () => {
expect(routeFromPath("/channels")).toEqual({ route: "channels", ...empty });
expect(routeFromPath("/channels/new")).toEqual({ route: "channelCreate", ...empty });
expect(routeFromPath("/channels/12")).toEqual({ route: "channelDetail", ...empty, channelId: 12 });
});
it("falls back to the list for a malformed channel id", () => {
expect(routeFromPath("/channels/abc")).toEqual({ route: "channels", ...empty });
});
it("builds channel paths", () => {
expect(pathFromRoute("channels")).toBe("/channels");
expect(pathFromRoute("channelCreate")).toBe("/channels/new");
expect(pathFromRoute("channelDetail", 12)).toBe("/channels/12");
expect(pathFromRoute("channelDetail", null)).toBe("/channels");
});
it("keeps AI routes untouched", () => {
expect(routeFromPath("/ai/agents")).toEqual({ route: "aiAgents", ...empty });
expect(routeFromPath("/ai/knowledge")).toEqual({ route: "aiKnowledge", ...empty });
expect(pathFromRoute("aiAgentDetail", 7)).toBe("/ai/agents/7");
});
});
+11 -1
View File
@@ -10,6 +10,7 @@ export type RouteState = {
knowledgeId: number | null;
clientId: number | null;
orderId: number | null;
channelId: number | null;
};
export function routeFromPath(pathname: string, search = ""): RouteState {
@@ -17,7 +18,7 @@ export function routeFromPath(pathname: string, search = ""): RouteState {
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, productId: null, productCode: null, agentId: null, knowledgeId: null, clientId: null, orderId: null };
const base = { employeeId: null, productId: null, productCode: null, agentId: null, knowledgeId: null, clientId: null, orderId: null, channelId: null };
const state = { organizationPublicId, ...base };
if (path === "/" || path === "/command") return { route: "command", ...state };
if (path === "/departments") return { route: "departments", ...state };
@@ -46,6 +47,12 @@ export function routeFromPath(pathname: string, search = ""): RouteState {
const id = Number(path.split("/")[2]);
return Number.isInteger(id) && id > 0 ? { ...state, route: "productDetail", productId: id } : { route: "products", ...state };
}
if (path === "/channels") return { route: "channels", ...state };
if (path === "/channels/new") return { route: "channelCreate", ...state };
if (path.startsWith("/channels/")) {
const id = Number(path.split("/")[2]);
return Number.isInteger(id) && id > 0 ? { ...state, route: "channelDetail", channelId: id } : { route: "channels", ...state };
}
if (path === "/ai" || path === "/ai/agents") return { route: "aiAgents", ...state };
if (path === "/ai/agents/new") {
const productCode = new URLSearchParams(search).get("product");
@@ -92,6 +99,9 @@ export function pathFromRoute(route: RouteKey, entityId: number | null = null, p
if (route === "aiKnowledge") return `${prefix}/ai/knowledge`;
if (route === "aiKnowledgeCreate") return `${prefix}/ai/knowledge/new`;
if (route === "aiKnowledgeDetail") return entityId ? `${prefix}/ai/knowledge/${entityId}` : `${prefix}/ai/knowledge`;
if (route === "channels") return `${prefix}/channels`;
if (route === "channelCreate") return `${prefix}/channels/new`;
if (route === "channelDetail") return entityId ? `${prefix}/channels/${entityId}` : `${prefix}/channels`;
if (route === "integrations") return `${prefix}/integrations`;
if (route === "profile") return `${prefix}/profile`;
if (route === "settings") return `${prefix}/settings`;
+3
View File
@@ -26,4 +26,7 @@ export const routes: Record<RouteKey, string> = {
aiKnowledgeDetail: "Знание",
aiUsage: "Использование AI",
integrations: "Интеграции",
channels: "Каналы",
channelCreate: "Создание канала",
channelDetail: "Карточка канала",
};
+1 -1
View File
@@ -171,7 +171,7 @@ export type ProductOffer = {
prices: ProductPrice[];
};
export type RouteKey = "accessProfiles" | "command" | "departments" | "employeeDetail" | "employees" | "productDetail" | "products" | "profile" | "settings" | "salesClientDetail" | "salesClients" | "salesDialogs" | "salesOrderDetail" | "salesOrders" | "salesOverview" | "supportOverview" | "supportDialogs" | "aiAgents" | "aiAgentCreate" | "aiAgentDetail" | "aiKnowledge" | "aiKnowledgeCreate" | "aiKnowledgeDetail" | "aiUsage" | "integrations";
export type RouteKey = "accessProfiles" | "command" | "departments" | "employeeDetail" | "employees" | "productDetail" | "products" | "profile" | "settings" | "salesClientDetail" | "salesClients" | "salesDialogs" | "salesOrderDetail" | "salesOrders" | "salesOverview" | "supportOverview" | "supportDialogs" | "aiAgents" | "aiAgentCreate" | "aiAgentDetail" | "aiKnowledge" | "aiKnowledgeCreate" | "aiKnowledgeDetail" | "aiUsage" | "integrations" | "channels" | "channelCreate" | "channelDetail";
export type AppData = {
employees: Employee[];
@@ -16,6 +16,7 @@ export function useRouteNavigation(
const [selectedConversationId, setSelectedConversationId] = useState<number | null>(null);
const [selectedClientId, setSelectedClientId] = useState(initialRoute.clientId);
const [selectedOrderId, setSelectedOrderId] = useState(initialRoute.orderId);
const [selectedChannelId, setSelectedChannelId] = useState(initialRoute.channelId);
const applyRouteState = useCallback((next: RouteState) => {
setRoute(next.route);
@@ -27,6 +28,7 @@ export function useRouteNavigation(
setSelectedConversationId(null);
setSelectedClientId(next.clientId);
setSelectedOrderId(next.orderId);
setSelectedChannelId(next.channelId);
}, []);
const navigate = useCallback((
@@ -46,6 +48,7 @@ export function useRouteNavigation(
knowledgeId: nextRoute === "aiKnowledgeDetail" ? entityId : null,
clientId: nextRoute === "salesClientDetail" ? entityId : null,
orderId: nextRoute === "salesOrderDetail" ? entityId : null,
channelId: nextRoute === "channelDetail" ? entityId : null,
};
applyRouteState(nextState);
setSelectedConversationId(
@@ -72,6 +75,7 @@ export function useRouteNavigation(
selectedConversationId,
selectedClientId,
selectedOrderId,
selectedChannelId,
applyRouteState,
navigate,
};