mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
✨ 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:
1 parent
5ae5de4474
commit
2b3dc300fe
21 files changed
+888
-21
No files matched your search
@@ -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 };
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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";
|
||||
Reference in new issue
Block a user