mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
✨ 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:
1 parent
2b97f08b19
commit
4c77a4e5b8
24 files changed
+2520
-57
No files matched your search
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 />}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
|
||||
@@ -26,4 +26,7 @@ export const routes: Record<RouteKey, string> = {
|
||||
aiKnowledgeDetail: "Знание",
|
||||
aiUsage: "Использование AI",
|
||||
integrations: "Интеграции",
|
||||
channels: "Каналы",
|
||||
channelCreate: "Создание канала",
|
||||
channelDetail: "Карточка канала",
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user