✨ feat(ui): AI agent card screen (E04 slice 5b, AI-02)

- Route /ai/agents/{id} (router + App agentId plumbing, breadcrumb, "Открыть агента")
- Agent card on shared components: header + 5 tabs via UnderlineTabs
  (Обзор / Инструкции / Знания / История releases / Метрики)
- Real backend data (model, status, releases, knowledge, prompts);
  degrade missing runtime/metrics to "—" like the products screens
- Clean AI tab CSS to the project convention (gap + ::after underline),
  drop baseline-copied margin/cursor overrides on .ai-agent-tabs/.ai-subnav
- Hoist reusable formatDate into shared/utils

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Opus 4.8 committed 2026-06-25 13:20:34 +03:00
1 parent 5ae5de4474
commit 2b3dc300fe
21 files changed
+888 -21

No files matched your search

+6 -2
View File
@@ -21,18 +21,21 @@ export function App() {
const [route, setRoute] = useState<RouteKey>(initialRoute.route);
const [selectedEmployeeId, setSelectedEmployeeId] = useState<number | null>(initialRoute.employeeId);
const [selectedProductId, setSelectedProductId] = useState<number | null>(initialRoute.productId);
const [selectedAgentId, setSelectedAgentId] = useState<number | null>(initialRoute.agentId);
const [data, setData] = useState<AppData>({ employees: [], departments: [], products: [] });
const [dataError, setDataError] = useState(false);
const navigate = useCallback((nextRoute: RouteKey, entityId: number | null = null, replace = false) => {
const nextEmployeeId = nextRoute === "employeeDetail" ? entityId : null;
const nextProductId = nextRoute === "productDetail" ? entityId : null;
const nextAgentId = nextRoute === "aiAgentDetail" ? entityId : null;
const nextPath = pathFromRoute(nextRoute, entityId);
setRoute(nextRoute);
setSelectedEmployeeId(nextEmployeeId);
setSelectedProductId(nextProductId);
setSelectedAgentId(nextAgentId);
if (window.location.pathname !== nextPath) {
const state = { route: nextRoute, employeeId: nextEmployeeId, productId: nextProductId };
const state = { route: nextRoute, employeeId: nextEmployeeId, productId: nextProductId, agentId: nextAgentId };
if (replace) {
window.history.replaceState(state, "", nextPath);
} else {
@@ -71,6 +74,7 @@ export function App() {
setRoute(nextRoute.route);
setSelectedEmployeeId(nextRoute.employeeId);
setSelectedProductId(nextRoute.productId);
setSelectedAgentId(nextRoute.agentId);
};
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
@@ -122,7 +126,7 @@ export function App() {
) : !canAccess(user.role, route) ? (
<PermissionScreen onReturn={() => navigate(defaultRoute(user.role), null, true)} />
) : (
<Shell route={route} setRoute={(nextRoute) => navigate(nextRoute)} selectedEmployeeId={selectedEmployeeId} selectedProductId={selectedProductId} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openProductRoute={(productId) => navigate("productDetail", productId)} user={user} data={data} reload={loadData} onUserUpdated={setUser} onLogout={logout} />
<Shell route={route} setRoute={(nextRoute) => navigate(nextRoute)} selectedEmployeeId={selectedEmployeeId} selectedProductId={selectedProductId} selectedAgentId={selectedAgentId} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openProductRoute={(productId) => navigate("productDetail", productId)} openAgentRoute={(agentId) => navigate("aiAgentDetail", agentId)} user={user} data={data} reload={loadData} onUserUpdated={setUser} onLogout={logout} />
)}
</ConfigProvider>
);
@@ -14,6 +14,7 @@ export function AgentsTable({
setMenuId,
toggleActive,
setRoute,
openAgent,
}: {
agents: AiAgent[];
releases: AiRelease[];
@@ -21,6 +22,7 @@ export function AgentsTable({
setMenuId: (id: number | null) => void;
toggleActive: (agent: AiAgent) => void;
setRoute: (route: RouteKey) => void;
openAgent: (agentId: number) => void;
}) {
return (
<div className="table-card">
@@ -43,7 +45,7 @@ export function AgentsTable({
const accent = productAccent(agent.product.code);
const release = publishedRelease(releases, agent.product.code);
const menuItems = [
{ key: "open", disabled: true, label: <button type="button"><Icon name="external" size={15} />Открыть агента</button> },
{ key: "open", label: <button type="button" onClick={() => { setMenuId(null); openAgent(agent.id); }}><Icon name="external" size={15} />Открыть агента</button> },
{ key: "release", disabled: true, label: <button type="button"><Icon name="plus" size={15} />Создать release candidate</button> },
{ type: "divider" as const },
{
@@ -8,7 +8,7 @@ import { AgentsTable } from "./AgentsTable";
import type { AiAgent } from "./model";
import { useAiAgents } from "./useAiAgents";
export function AiAgentsPage({ setRoute }: { setRoute: (route: RouteKey) => void }) {
export function AiAgentsPage({ setRoute, openAgent }: { setRoute: (route: RouteKey) => void; openAgent: (agentId: number) => void }) {
const { agents, releases, loading, error, reload } = useAiAgents();
const [menuId, setMenuId] = useState<number | null>(null);
@@ -29,7 +29,7 @@ export function AiAgentsPage({ setRoute }: { setRoute: (route: RouteKey) => void
) : (
<>
<AgentsKpiStrip agents={agents} />
<AgentsTable agents={agents} releases={releases} menuId={menuId} setMenuId={setMenuId} toggleActive={toggleActive} setRoute={setRoute} />
<AgentsTable agents={agents} releases={releases} menuId={menuId} setMenuId={setMenuId} toggleActive={toggleActive} setRoute={setRoute} openAgent={openAgent} />
</>
)}
</div>
@@ -0,0 +1,42 @@
import { Icon } from "../../../shared/icons";
import { StatusPill } from "../../../shared/ui";
import { Button } from "../../../shared/ui-controls";
import { productAccent } from "../../../shared/utils";
import type { RouteKey } from "../../../types";
import { dailyBudget, publishedRelease, type AiAgentDetail, type AiReleaseFull } from "./model";
export function AiAgentDetailHeader({
agent,
releases,
setRoute,
}: {
agent: AiAgentDetail;
releases: AiReleaseFull[];
setRoute: (route: RouteKey) => void;
}) {
const accent = productAccent(agent.product.code);
const release = publishedRelease(releases);
return (
<section className="ai-agent-header">
<div className="ai-agent-heading">
<span className="ai-agent-icon" style={{ background: accent.bg, color: accent.color }}><Icon name="robot" size={27} /></span>
<div className="ai-agent-title">
<div className="ai-agent-title-row">
<h1>{agent.name}</h1>
<StatusPill status={agent.isActive ? "active" : "disabled"} />
</div>
<div className="ai-agent-meta">
<div><span>Продукт</span><button type="button" className="ai-agent-meta-link" onClick={() => setRoute("products")}>{agent.product.name}</button></div>
<div><span>Модель</span><code className="ai-mono">{agent.model}</code></div>
<div><span>Текущий release</span>{release ? <code className="ai-mono">{`v${release.version}`}</code> : <b className="ai-agent-meta-empty">—</b>}</div>
<div><span>Лимиты</span><b>{dailyBudget(agent.limits)}</b></div>
</div>
</div>
</div>
<div className="ai-agent-header-actions">
<Button variant="secondary" icon="message" disabled onClick={() => setRoute("aiTestChat")}>Тест-чат</Button>
<Button variant="primary" icon="plus" disabled>Release candidate</Button>
</div>
</section>
);
}
@@ -0,0 +1,46 @@
import { useEffect, useState } from "react";
import { api } from "../../../api/client";
import { EmptyState } from "../../../shared/ui";
import { UnderlineTabs } from "../../../shared/ui-controls";
import type { RouteKey } from "../../../types";
import { AiAgentDetailHeader } from "./AiAgentDetailHeader";
import { AiAgentInstructionsTab } from "./AiAgentInstructionsTab";
import { AiAgentKnowledgeTab } from "./AiAgentKnowledgeTab";
import { AiAgentMetricsTab } from "./AiAgentMetricsTab";
import { AiAgentOverviewTab } from "./AiAgentOverviewTab";
import { AiAgentReleasesTab } from "./AiAgentReleasesTab";
import { agentTabs, type AgentTab } from "./model";
import { useAiAgentDetail } from "./useAiAgentDetail";
export function AiAgentDetailPage({ agentId, setRoute, onAgentLoaded }: { agentId: number | null; setRoute: (route: RouteKey) => void; onAgentLoaded: (name: string | null) => void }) {
const { agent, releases, knowledge, prompts, loading, error, reload } = useAiAgentDetail(agentId);
const [tab, setTab] = useState<AgentTab>("overview");
useEffect(() => {
onAgentLoaded(agent?.name ?? null);
return () => onAgentLoaded(null);
}, [agent, onAgentLoaded]);
async function toggleActive() {
if (!agent) return;
const action = agent.isActive ? "deactivate" : "activate";
await api(`/api/v1/ai/agents/${agent.id}/${action}/`, { method: "POST" }).catch(() => undefined);
reload();
}
if (loading) return <div className="ai-page"><EmptyState title="Загрузка…" /></div>;
if (error || !agent) return <div className="ai-page"><EmptyState title="Не удалось загрузить агента" /></div>;
return (
<div className="ai-agent-page">
<AiAgentDetailHeader agent={agent} releases={releases} setRoute={setRoute} />
<UnderlineTabs className="ai-agent-tabs" items={agentTabs} value={tab} onChange={setTab} />
{tab === "overview" && <AiAgentOverviewTab agent={agent} toggleActive={toggleActive} />}
{tab === "instructions" && <AiAgentInstructionsTab prompts={prompts} />}
{tab === "knowledge" && <AiAgentKnowledgeTab knowledge={knowledge} productName={agent.product.name} />}
{tab === "releases" && <AiAgentReleasesTab releases={releases} />}
{tab === "metrics" && <AiAgentMetricsTab />}
</div>
);
}
@@ -0,0 +1,30 @@
import { Icon } from "../../../shared/icons";
import { EmptyState } from "../../../shared/ui";
import { promptCategoryLabel, publishedVersion, type PromptDoc } from "./model";
export function AiAgentInstructionsTab({ prompts }: { prompts: PromptDoc[] }) {
if (prompts.length === 0) {
return <EmptyState title="Промпты ещё не заданы" />;
}
return (
<div className="ai-instructions">
<div className="ai-notice">
<Icon name="warning" size={17} />
<span>Инструкции редактируются в составе release candidate. Активный release published и доступен только для чтения — изменения применяются после публикации новой версии.</span>
</div>
{prompts.map((prompt, index) => {
const version = publishedVersion(prompt.versions);
return (
<section className="ai-card" key={prompt.id}>
<div className="ai-instruction-head">
<span className="ai-instruction-index">{index + 1}</span>
<h3>{prompt.title}</h3>
<small>{promptCategoryLabel(prompt.category)}</small>
</div>
<pre className="ai-prompt-block">{version?.content?.trim() || "—"}</pre>
</section>
);
})}
</div>
);
}
@@ -0,0 +1,29 @@
import { Icon } from "../../../shared/icons";
import { EmptyState } from "../../../shared/ui";
import { formatDate } from "../../../shared/utils";
import { knowledgeCategoryLabel, publishedVersion, type KnowledgeDoc } from "./model";
export function AiAgentKnowledgeTab({ knowledge, productName }: { knowledge: KnowledgeDoc[]; productName: string }) {
if (knowledge.length === 0) {
return <EmptyState title="Материалы знаний ещё не добавлены" />;
}
return (
<section className="ai-card ai-card--flush">
<div className="ai-knowledge-head">
<h3>Обязательные знания release</h3>
<div className="ai-knowledge-sub">Retrieval index · {productName} KB · {knowledge.length} материалов</div>
</div>
{knowledge.map((doc) => {
const version = publishedVersion(doc.versions);
const meta = [knowledgeCategoryLabel(doc.category), version ? `v${version.version}` : null, `обновлено ${formatDate(doc.updatedAt)}`].filter(Boolean).join(" · ");
return (
<div className="ai-knowledge-row" key={doc.id}>
<span className="ai-knowledge-icon"><Icon name="paperclip" size={16} /></span>
<div className="ai-knowledge-info"><div className="ai-knowledge-title">{doc.title}</div><div className="ai-knowledge-meta">{meta}</div></div>
<span className={doc.isEnabled ? "ai-knowledge-status on" : "ai-knowledge-status off"}>{doc.isEnabled ? "В индексе" : "Отключено"}</span>
</div>
);
})}
</section>
);
}
@@ -0,0 +1,22 @@
import { EmptyState } from "../../../shared/ui";
const KPIS = ["Диалоги · 30 дней", "Продажи · 30 дней", "Конверсия", "Стоимость / продажа"];
export function AiAgentMetricsTab() {
return (
<div className="ai-agent-metrics">
<div className="ai-kpi-strip">
{KPIS.map((label) => (
<div className="ai-kpi-card" key={label}>
<div className="ai-kpi-label">{label}</div>
<div className="ai-kpi-value">—</div>
</div>
))}
</div>
<section className="ai-card">
<h3>Причины передачи оператору · 30 дней</h3>
<EmptyState title="Данных пока нет" />
</section>
</div>
);
}
@@ -0,0 +1,40 @@
import { Icon } from "../../../shared/icons";
import { dailyBudget, type AiAgentDetail } from "./model";
export function AiAgentOverviewTab({ agent, toggleActive }: { agent: AiAgentDetail; toggleActive: () => void }) {
return (
<div className="ai-agent-overview">
<div className="ai-agent-overview-main">
<section className="ai-card">
<h3>Назначение агента</h3>
<p className="ai-agent-purpose">—</p>
</section>
<section className="ai-card">
<h3>Runtime сейчас</h3>
<div className="ai-runtime-grid">
<div><span>Активные диалоги</span><strong>—</strong></div>
<div><span>В очереди на ответ</span><strong>—</strong></div>
<div><span>Ср. время ответа</span><strong>—</strong></div>
</div>
</section>
</div>
<div className="ai-agent-overview-side">
<section className="ai-card">
<h3>Лимиты</h3>
<div className="ai-limit-row"><span>Диалогов в день</span><b>—</b></div>
<div className="ai-limit-bar"><i style={{ width: "0%" }} /></div>
<div className="ai-limit-row"><span>Бюджет в день</span><b>{dailyBudget(agent.limits)}</b></div>
<div className="ai-limit-bar"><i style={{ width: "0%" }} /></div>
</section>
<section className="ai-card">
<h3>Управление</h3>
<button type="button" className="ai-manage-btn" disabled><Icon name="edit" size={16} />Изменить лимиты</button>
<button type="button" className="ai-manage-btn warning" onClick={toggleActive}>
<Icon name={agent.isActive ? "pause" : "bolt"} size={16} />
{agent.isActive ? "Остановить агента" : "Запустить агента"}
</button>
</section>
</div>
</div>
);
}
@@ -0,0 +1,43 @@
import { EmptyState } from "../../../shared/ui";
import { ToneBadge } from "../../../shared/ui-controls";
import { formatDate } from "../../../shared/utils";
import { releaseTone, type AiReleaseFull } from "./model";
export function AiAgentReleasesTab({ releases }: { releases: AiReleaseFull[] }) {
if (releases.length === 0) {
return <EmptyState title="Releases ещё не создавались" />;
}
return (
<div className="table-card">
<div className="ai-agents-scroll">
<table className="baseline-table">
<thead>
<tr>
<th>RELEASE</th>
<th>СТАТУС</th>
<th>ДАТА</th>
<th>АВТОР</th>
<th className="numeric">ДИАЛОГИ</th>
<th className="numeric">КОНВЕРСИЯ</th>
</tr>
</thead>
<tbody>
{releases.map((release) => {
const tone = releaseTone(release.status);
return (
<tr key={release.id}>
<td><code className="ai-mono">{`v${release.version}`}</code></td>
<td><ToneBadge bg={tone.bg} color={tone.color}>{tone.label}</ToneBadge></td>
<td>{formatDate(release.publishedAt ?? release.createdAt)}</td>
<td><span className="product-empty-value">—</span></td>
<td className="numeric"><span className="product-empty-value">—</span></td>
<td className="numeric"><span className="product-empty-value">—</span></td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,120 @@
export type AiAgentDetail = {
id: number;
product: { code: string; name: string };
name: string;
isActive: boolean;
model: string;
modelParams: Record<string, unknown>;
allowedTools: unknown[];
limits: Record<string, unknown>;
createdAt: string;
updatedAt: string;
};
export type DocVersion = {
id: number;
version: number;
status: "DRAFT" | "PUBLISHED" | "ARCHIVED";
content: string;
createdBy: number | null;
createdAt: string;
};
export type KnowledgeDoc = {
id: number;
product: { code: string; name: string };
code: string;
title: string;
category: string;
isEnabled: boolean;
inclusionMode: "MANDATORY" | "RETRIEVAL";
versions: DocVersion[];
createdAt: string;
updatedAt: string;
};
export type PromptDoc = {
id: number;
product: { code: string; name: string };
code: string;
title: string;
category: string;
isEnabled: boolean;
versions: DocVersion[];
createdAt: string;
updatedAt: string;
};
export type AiReleaseFull = {
id: number;
product: { code: string; name: string };
version: number;
status: "DRAFT" | "PUBLISHED" | "ARCHIVED";
model: string;
notes: string;
retrievalIndexVersion: string;
knowledgeVersions: Array<{ document: string; version: number }>;
promptVersions: Array<{ document: string; version: number }>;
createdAt: string;
publishedAt: string | null;
};
export type AgentTab = "overview" | "instructions" | "knowledge" | "releases" | "metrics";
export const agentTabs: Array<{ key: AgentTab; label: string }> = [
{ key: "overview", label: "Обзор" },
{ key: "instructions", label: "Инструкции" },
{ key: "knowledge", label: "Знания" },
{ key: "releases", label: "История releases" },
{ key: "metrics", label: "Метрики" },
];
const RELEASE_TONE: Record<AiReleaseFull["status"], { bg: string; color: string; label: string }> = {
PUBLISHED: { bg: "#f6ffed", color: "#389e0d", label: "Published" },
DRAFT: { bg: "#fff7e6", color: "#d48806", label: "Candidate" },
ARCHIVED: { bg: "#f5f5f5", color: "#8c8c8c", label: "Archived" },
};
export function releaseTone(status: AiReleaseFull["status"]) {
return RELEASE_TONE[status];
}
const KNOWLEDGE_CATEGORY: Record<string, string> = {
OVERVIEW: "Обзор продукта",
AUDIENCE: "Целевая аудитория",
COMMERCIAL: "Коммерческая модель",
TECHNICAL: "Техническая информация",
FAQ: "FAQ",
OBJECTIONS: "Возражения",
LIMITATIONS: "Ограничения",
};
const PROMPT_CATEGORY: Record<string, string> = {
SYSTEM: "Системный промпт",
QUALIFICATION: "Квалификация",
SALES_BEHAVIOR: "Поведение в продаже",
OPERATOR_HANDOFF: "Передача оператору",
};
export function knowledgeCategoryLabel(category: string): string {
return KNOWLEDGE_CATEGORY[category] ?? category;
}
export function promptCategoryLabel(category: string): string {
return PROMPT_CATEGORY[category] ?? category;
}
export function publishedVersion(versions: DocVersion[]): DocVersion | undefined {
return versions.find((version) => version.status === "PUBLISHED") ?? versions[0];
}
export function publishedRelease(releases: AiReleaseFull[]): AiReleaseFull | undefined {
return releases.find((release) => release.status === "PUBLISHED");
}
// agent.limits — свободный JSON; пока бэкенд хранит только дневной лимит стоимости (micro-USD).
export function dailyBudget(limits: Record<string, unknown>): string {
const micros = limits?.dailyCostMicros;
if (typeof micros !== "number" || micros <= 0) return "—";
return `$${(micros / 1_000_000).toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} / день`;
}
@@ -0,0 +1,415 @@
/* AI agent card (AI-02): per-screen styling on shared components. */
.ai-agent-page {
max-width: 1080px;
}
/* --- Header card --- */
.ai-agent-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
padding: 20px 22px;
margin-bottom: 16px;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.ai-agent-heading {
display: flex;
align-items: flex-start;
gap: 16px;
min-width: 0;
}
.ai-agent-icon {
width: 52px;
height: 52px;
border-radius: 13px;
display: flex;
align-items: center;
justify-content: center;
flex: none;
}
.ai-agent-title h1 {
margin: 0;
font-size: 21px;
font-weight: 700;
letter-spacing: -0.01em;
color: #1f1f1f;
}
.ai-agent-title-row {
display: flex;
align-items: center;
gap: 11px;
flex-wrap: wrap;
}
.ai-agent-meta {
display: flex;
align-items: center;
margin-top: 12px;
flex-wrap: wrap;
}
.ai-agent-meta > div {
padding-right: 18px;
margin-right: 18px;
border-right: 1px solid #f0f0f0;
}
.ai-agent-meta > div:last-child {
padding-right: 0;
margin-right: 0;
border-right: 0;
}
.ai-agent-meta span {
display: block;
font-size: 11px;
color: #8c8c8c;
}
.ai-agent-meta b,
.ai-agent-meta code {
display: block;
margin-top: 2px;
font-size: 13.5px;
font-weight: 600;
color: #262626;
}
.ai-agent-meta-empty {
color: #bfbfbf !important;
}
.ai-agent-meta-link {
margin-top: 2px;
padding: 0;
background: none;
border: 0;
font-size: 13.5px;
font-weight: 600;
color: #1677ff;
cursor: pointer;
}
.ai-agent-header-actions {
display: flex;
align-items: center;
gap: 9px;
flex: none;
}
/* --- Tabs --- */
.ai-agent-tabs {
gap: 2px;
margin-bottom: 18px;
border-bottom: 1px solid #e8e8e8;
overflow-x: auto;
}
.ai-agent-tabs button {
padding: 11px 15px;
color: #595959;
font-size: 14px;
font-weight: 500;
}
.ai-agent-tabs button.active {
color: #1677ff;
font-weight: 600;
}
/* --- Generic card --- */
.ai-card {
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
padding: 18px 20px;
}
.ai-card--flush {
padding: 0;
overflow: hidden;
}
.ai-card h3 {
margin: 0 0 13px;
font-size: 14px;
font-weight: 700;
color: #1f1f1f;
}
/* --- Overview --- */
.ai-agent-overview {
display: flex;
align-items: flex-start;
gap: 18px;
}
.ai-agent-overview-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.ai-agent-overview-side {
width: 316px;
flex: none;
display: flex;
flex-direction: column;
gap: 16px;
}
.ai-agent-purpose {
margin: 0;
font-size: 13px;
color: #595959;
line-height: 1.6;
}
.ai-runtime-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1px;
background: #f0f0f0;
border: 1px solid #f0f0f0;
border-radius: 9px;
overflow: hidden;
}
.ai-runtime-grid > div {
background: #fff;
padding: 13px 15px;
}
.ai-runtime-grid span {
font-size: 11.5px;
color: #8c8c8c;
}
.ai-runtime-grid strong {
display: block;
margin-top: 4px;
font-size: 19px;
font-weight: 700;
color: #262626;
}
.ai-limit-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 7px 0;
}
.ai-limit-row span {
font-size: 12.5px;
color: #8c8c8c;
}
.ai-limit-row b {
font-size: 12.5px;
font-weight: 600;
color: #262626;
}
.ai-limit-bar {
height: 6px;
border-radius: 999px;
background: #f0f0f0;
overflow: hidden;
margin: 4px 0 10px;
}
.ai-limit-bar i {
display: block;
height: 6px;
border-radius: 999px;
background: #1677ff;
}
.ai-manage-btn {
width: 100%;
display: flex;
align-items: center;
gap: 9px;
height: 38px;
padding: 0 13px;
border-radius: 9px;
border: 1px solid #e8e8e8;
background: #fff;
color: #262626;
font-size: 13px;
font-weight: 500;
cursor: pointer;
}
.ai-manage-btn + .ai-manage-btn {
margin-top: 8px;
}
.ai-manage-btn:disabled {
color: #bfbfbf;
cursor: not-allowed;
}
.ai-manage-btn.warning {
border-color: #ffe58f;
background: #fffbe6;
color: #d48806;
}
/* --- Instructions --- */
.ai-instructions {
display: flex;
flex-direction: column;
gap: 16px;
}
.ai-notice {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 16px;
background: #fffbe6;
border: 1px solid #ffe58f;
border-radius: 10px;
color: #7c5a00;
font-size: 12.5px;
}
.ai-notice svg {
flex: none;
color: #d48806;
}
.ai-instruction-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.ai-instruction-head h3 {
margin: 0;
}
.ai-instruction-head small {
color: #bfbfbf;
font-size: 11.5px;
}
.ai-instruction-index {
width: 24px;
height: 24px;
border-radius: 6px;
background: #f0f2f5;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
color: #595959;
flex: none;
}
.ai-prompt-block {
margin: 0;
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 9px;
padding: 13px 15px;
font-size: 12.5px;
color: #595959;
line-height: 1.65;
font-family: "SF Mono", ui-monospace, Menlo, monospace;
white-space: pre-wrap;
word-break: break-word;
}
/* --- Knowledge --- */
.ai-knowledge-head {
padding: 15px 18px 12px;
border-bottom: 1px solid #f5f5f5;
}
.ai-knowledge-head h3 {
margin: 0;
}
.ai-knowledge-sub {
margin-top: 3px;
font-size: 11.5px;
color: #bfbfbf;
}
.ai-knowledge-row {
display: flex;
align-items: center;
gap: 13px;
padding: 13px 18px;
border-bottom: 1px solid #f5f5f5;
}
.ai-knowledge-row:last-child {
border-bottom: 0;
}
.ai-knowledge-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
flex: none;
color: #8c8c8c;
}
.ai-knowledge-info {
flex: 1;
min-width: 0;
}
.ai-knowledge-title {
font-size: 13px;
font-weight: 500;
color: #262626;
}
.ai-knowledge-meta {
margin-top: 1px;
font-size: 11.5px;
color: #bfbfbf;
}
.ai-knowledge-status {
font-size: 11.5px;
font-weight: 500;
}
.ai-knowledge-status.on {
color: #389e0d;
}
.ai-knowledge-status.off {
color: #bfbfbf;
}
/* --- Metrics --- */
.ai-agent-metrics {
display: flex;
flex-direction: column;
gap: 16px;
}
@@ -0,0 +1,47 @@
import { useCallback, useEffect, useState } from "react";
import { api } from "../../../api/client";
import type { AiAgentDetail, AiReleaseFull, KnowledgeDoc, PromptDoc } from "./model";
type AgentDetailData = {
agent: AiAgentDetail | null;
releases: AiReleaseFull[];
knowledge: KnowledgeDoc[];
prompts: PromptDoc[];
};
export function useAiAgentDetail(agentId: number | null) {
const [data, setData] = useState<AgentDetailData>({ agent: null, releases: [], knowledge: [], prompts: [] });
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const load = useCallback(async () => {
if (!agentId) {
setError(true);
setLoading(false);
return;
}
setLoading(true);
setError(false);
try {
const { agent } = await api<{ agent: AiAgentDetail }>(`/api/v1/ai/agents/${agentId}/`);
const product = encodeURIComponent(agent.product.code);
const [releases, knowledge, prompts] = await Promise.all([
api<{ items: AiReleaseFull[] }>(`/api/v1/ai/releases/?product=${product}`),
api<{ items: KnowledgeDoc[] }>(`/api/v1/ai/knowledge/?product=${product}`),
api<{ items: PromptDoc[] }>(`/api/v1/ai/prompts/?product=${product}`),
]);
setData({ agent, releases: releases.items, knowledge: knowledge.items, prompts: prompts.items });
} catch {
setError(true);
} finally {
setLoading(false);
}
}, [agentId]);
useEffect(() => {
void load();
}, [load]);
return { ...data, loading, error, reload: load };
}
+1 -3
View File
@@ -10,6 +10,7 @@
}
.ai-subnav-tabs {
gap: 2px;
width: min(1200px, 100%);
max-width: 1200px;
margin: 0 auto;
@@ -17,11 +18,9 @@
.ai-subnav button {
padding: 13px 14px;
margin-bottom: -1px;
color: #595959;
font-size: 13.5px;
font-weight: 500;
cursor: pointer;
}
.ai-subnav button.active {
@@ -31,7 +30,6 @@
.ai-subnav button:disabled {
color: #bfbfbf;
cursor: not-allowed;
}
.ai-page {
+9 -4
View File
@@ -1,3 +1,5 @@
import { useState } from "react";
import type { AppData, Employee, Product, RouteKey, SessionUser } from "../types";
import { AiSubnav } from "../features/ai/AiSubnav";
import { Sidebar } from "./Sidebar";
@@ -5,7 +7,8 @@ import { SalesSidebar } from "./SalesSidebar";
import { ShellRouteContent } from "./ShellRouteContent";
import { TopBar } from "./TopBar";
export function Shell({ route, setRoute, selectedEmployeeId, selectedProductId, openEmployeeRoute, openProductRoute, user, data, reload, onUserUpdated, onLogout }: { route: RouteKey; setRoute: (route: RouteKey) => void; selectedEmployeeId: number | null; selectedProductId: number | null; openEmployeeRoute: (employeeId: number) => void; openProductRoute: (productId: number) => void; user: SessionUser; data: AppData; reload: () => void; onUserUpdated: (user: SessionUser) => void; onLogout: () => void }) {
export function Shell({ route, setRoute, selectedEmployeeId, selectedProductId, selectedAgentId, openEmployeeRoute, openProductRoute, openAgentRoute, user, data, reload, onUserUpdated, onLogout }: { route: RouteKey; setRoute: (route: RouteKey) => void; selectedEmployeeId: number | null; selectedProductId: number | null; selectedAgentId: number | null; openEmployeeRoute: (employeeId: number) => void; openProductRoute: (productId: number) => void; openAgentRoute: (agentId: number) => void; user: SessionUser; data: AppData; reload: () => void; onUserUpdated: (user: SessionUser) => void; onLogout: () => void }) {
const [agentName, setAgentName] = useState<string | null>(null);
const currentEmployee = route === "employeeDetail"
? data.employees.find((employee) => employee.id === selectedEmployeeId) ?? null
: data.employees.find((employee) => employee.email === "a.kotova@edevs.tech") ?? data.employees[0] ?? null;
@@ -22,17 +25,19 @@ export function Shell({ route, setRoute, selectedEmployeeId, selectedProductId,
const isSalesClientDetail = route === "salesClientDetail";
const isSalesOrderDetail = route === "salesOrderDetail";
const isSalesOrders = route === "salesOrderDetail" || route === "salesOrders";
// Подменю AI показываем только на разделах AI-секции, не на карточке агента.
const isAiSection = route === "aiAgents" || route === "aiTestChat" || route === "aiUsage";
// OPERATOR работает только в пространстве продаж, поэтому всегда видит sales-sidebar (SPEC-HUB-0004 §9).
const showSalesSidebar = isSalesWorkspace || user.role === "OPERATOR";
return (
<div className="hub-shell">
{showSalesSidebar ? <SalesSidebar route={route} user={user} setRoute={setRoute} /> : <Sidebar route={route} user={user} setRoute={setRoute} />}
<div className="hub-main">
<TopBar route={route} user={user} currentEmployee={currentEmployee} currentProduct={currentProduct} setRoute={setRoute} />
{route.startsWith("ai") && <AiSubnav route={route} setRoute={setRoute} />}
<TopBar route={route} user={user} currentEmployee={currentEmployee} currentProduct={currentProduct} currentAgentName={agentName} setRoute={setRoute} />
{isAiSection && <AiSubnav route={route} setRoute={setRoute} />}
<main className={`hub-scroll ${isSalesDialogs ? "sales-dialogs-scroll" : ""}`}>
<div className={`hub-page ${isSalesWorkspace ? "sales-workspace-page" : ""} ${isSalesDialogs ? "sales-dialogs-page" : ""} ${isSalesClients ? "sales-clients-page" : ""} ${isSalesClientDetail ? "sales-client-detail-page" : ""} ${isSalesOrderDetail ? "sales-order-detail-page" : ""} ${isSalesOrders ? "sales-orders-page" : ""}`}>
<ShellRouteContent route={route} data={data} currentEmployee={currentEmployee} currentProduct={currentProduct} openEmployee={openEmployee} openProduct={openProduct} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} />
<ShellRouteContent route={route} data={data} currentEmployee={currentEmployee} currentProduct={currentProduct} selectedAgentId={selectedAgentId} openEmployee={openEmployee} openProduct={openProduct} openAgent={openAgentRoute} onAgentLoaded={setAgentName} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} />
</div>
</main>
</div>
@@ -1,4 +1,5 @@
import { AiAgentsPage } from "../features/ai/AiAgentsPage";
import { AiAgentDetailPage } from "../features/ai/detail/AiAgentDetailPage";
import { CommandCenter } from "../features/command/CommandCenter";
import { DepartmentsPage } from "../features/departments/DepartmentsPage";
import { EmployeeDetailPage, EmployeesPage } from "../features/employees/EmployeesPage";
@@ -13,7 +14,7 @@ import { SalesOrdersPage } from "../features/sales/orders/SalesOrdersPage";
import { SalesOverviewPage } from "../features/sales/SalesOverviewPage";
import type { AppData, Employee, Product, RouteKey, SessionUser } from "../types";
export function ShellRouteContent({ route, data, currentEmployee, currentProduct, openEmployee, openProduct, reload, setRoute, user, onUserUpdated, onLogout }: { route: RouteKey; data: AppData; currentEmployee: Employee | null; currentProduct: Product | null; openEmployee: (employee: Employee) => void; openProduct: (product: Product) => void; reload: () => void; setRoute: (route: RouteKey) => void; user: SessionUser; onUserUpdated: (user: SessionUser) => void; onLogout: () => void }) {
export function ShellRouteContent({ route, data, currentEmployee, currentProduct, selectedAgentId, openEmployee, openProduct, openAgent, onAgentLoaded, reload, setRoute, user, onUserUpdated, onLogout }: { route: RouteKey; data: AppData; currentEmployee: Employee | null; currentProduct: Product | null; selectedAgentId: number | null; openEmployee: (employee: Employee) => void; openProduct: (product: Product) => void; openAgent: (agentId: 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} />}
@@ -31,7 +32,8 @@ export function ShellRouteContent({ route, data, currentEmployee, currentProduct
{route === "salesDialogs" && <SalesDialogsPage />}
{route === "salesOrderDetail" && <SalesOrderDetailPage setRoute={setRoute} />}
{route === "salesOrders" && <SalesOrdersPage setRoute={setRoute} />}
{route === "aiAgents" && <AiAgentsPage setRoute={setRoute} />}
{route === "aiAgents" && <AiAgentsPage setRoute={setRoute} openAgent={openAgent} />}
{route === "aiAgentDetail" && <AiAgentDetailPage agentId={selectedAgentId} setRoute={setRoute} onAgentLoaded={onAgentLoaded} />}
</>
);
}
+5 -3
View File
@@ -3,10 +3,11 @@ import { routes } from "../routes";
import type { Employee, Product, RouteKey, SessionUser } from "../types";
import { Icon } from "../shared/icons";
export function TopBar({ route, user, currentEmployee, currentProduct, setRoute }: { route: RouteKey; user: SessionUser; currentEmployee?: Employee | null; currentProduct?: Product | null; setRoute: (route: RouteKey) => void }) {
export function TopBar({ route, user, currentEmployee, currentProduct, currentAgentName, setRoute }: { route: RouteKey; user: SessionUser; currentEmployee?: Employee | null; currentProduct?: Product | null; currentAgentName?: string | null; setRoute: (route: RouteKey) => void }) {
const st = commandCenterModel("today").st;
const isCommand = route === "command";
const isAi = route.startsWith("ai");
const isAiDetail = route === "aiAgentDetail" || route === "aiRelease";
const isAi = route.startsWith("ai") && !isAiDetail;
const isSalesWorkspace = route === "salesOverview" || route === "salesClientDetail" || route === "salesClients" || route === "salesDialogs" || route === "salesOrderDetail" || route === "salesOrders";
return (
<header className="hub-topbar">
@@ -19,7 +20,8 @@ export function TopBar({ route, user, currentEmployee, currentProduct, setRoute
{route === "salesClientDetail" && <><button type="button" onClick={() => setRoute("salesOverview")}>Продажи</button><i>/</i><button type="button" onClick={() => setRoute("salesClients")}>Клиенты</button><i>/</i><strong>{routes[route]}</strong></>}
{route === "salesOrderDetail" && <><button type="button" onClick={() => setRoute("salesOverview")}>Продажи</button><i>/</i><button type="button" onClick={() => setRoute("salesOrders")}>Продажи</button><i>/</i><strong className="topbar-mono">{routes[route]}</strong></>}
{isAi && <><button type="button" onClick={() => setRoute("aiAgents")}>AI</button><i>/</i><strong>{routes[route]}</strong></>}
{route !== "employeeDetail" && route !== "productDetail" && !isSalesWorkspace && !isAi && <strong>{routes[route]}</strong>}
{isAiDetail && <><button type="button" onClick={() => setRoute("aiAgents")}>AI</button><i>/</i><button type="button" onClick={() => setRoute("aiAgents")}>AI-агенты</button><i>/</i><strong>{currentAgentName || routes[route]}</strong></>}
{route !== "employeeDetail" && route !== "productDetail" && !isSalesWorkspace && !isAi && !isAiDetail && <strong>{routes[route]}</strong>}
</div>
<div className="topbar-actions">
{isCommand && <span className="topbar-status" style={{ background: st.bg, borderColor: st.border, color: st.color }}><span style={{ background: st.dot }} />{st.label}</span>}
+11 -1
View File
@@ -4,10 +4,20 @@ import { pathFromRoute, routeFromPath } from "./router";
describe("product routes", () => {
it("parses a product detail URL", () => {
expect(routeFromPath("/products/42")).toEqual({ route: "productDetail", employeeId: null, productId: 42 });
expect(routeFromPath("/products/42")).toEqual({ route: "productDetail", employeeId: null, productId: 42, agentId: null });
});
it("creates a product detail URL", () => {
expect(pathFromRoute("productDetail", 42)).toBe("/products/42");
});
});
describe("ai agent routes", () => {
it("parses an AI agent detail URL", () => {
expect(routeFromPath("/ai/agents/7")).toEqual({ route: "aiAgentDetail", employeeId: null, productId: null, agentId: 7 });
});
it("creates an AI agent detail URL", () => {
expect(pathFromRoute("aiAgentDetail", 7)).toBe("/ai/agents/7");
});
});
+8 -3
View File
@@ -4,11 +4,12 @@ export type RouteState = {
route: RouteKey;
employeeId: number | null;
productId: number | null;
agentId: number | null;
};
export function routeFromPath(pathname: string): RouteState {
const path = pathname.replace(/\/+$/, "") || "/";
const base = { employeeId: null, productId: null };
const base = { employeeId: null, productId: null, agentId: null };
if (path === "/" || path === "/command") return { route: "command", ...base };
if (path === "/departments") return { route: "departments", ...base };
if (path === "/departments/sales") return { route: "salesOverview", ...base };
@@ -20,14 +21,18 @@ export function routeFromPath(pathname: string): RouteState {
if (path === "/employees") return { route: "employees", ...base };
if (path.startsWith("/employees/")) {
const id = Number(path.split("/")[2]);
return Number.isInteger(id) && id > 0 ? { route: "employeeDetail", employeeId: id, productId: null } : { route: "employees", ...base };
return Number.isInteger(id) && id > 0 ? { ...base, route: "employeeDetail", employeeId: id } : { route: "employees", ...base };
}
if (path === "/products") return { route: "products", ...base };
if (path.startsWith("/products/")) {
const id = Number(path.split("/")[2]);
return Number.isInteger(id) && id > 0 ? { route: "productDetail", employeeId: null, productId: id } : { route: "products", ...base };
return Number.isInteger(id) && id > 0 ? { ...base, route: "productDetail", productId: id } : { route: "products", ...base };
}
if (path === "/ai" || path === "/ai/agents") return { route: "aiAgents", ...base };
if (path.startsWith("/ai/agents/")) {
const id = Number(path.split("/")[3]);
return Number.isInteger(id) && id > 0 ? { ...base, route: "aiAgentDetail", agentId: id } : { route: "aiAgents", ...base };
}
if (path === "/ai/test-chat") return { route: "aiTestChat", ...base };
if (path === "/ai/usage") return { route: "aiUsage", ...base };
if (path === "/profile") return { route: "profile", ...base };
+4
View File
@@ -9,3 +9,7 @@ export function productAccent(code: string): { bg: string; color: string } {
if (code === "foxray") return { bg: "#f9f0ff", color: "#722ed1" };
return { bg: "#e6f4ff", color: "#0958d9" };
}
export function formatDate(value: string): string {
return new Intl.DateTimeFormat("ru-RU", { day: "2-digit", month: "long", year: "numeric" }).format(new Date(value));
}
+1
View File
@@ -16,4 +16,5 @@
@import "./features/employees/styles.css";
@import "./features/profile/styles.css";
@import "./features/ai/styles.css";
@import "./features/ai/detail/styles.css";
@import "./shared/state.css";