🔥 feat(ui)!: снос командного центра и «Обзора поддержки» (SPEC-HUB-0031)

По спеке навигация менеджера — шесть пунктов, командный центр удаляется.
Удалены features/command, SupportOverviewPage, роуты command/supportOverview
и бэкенд-сводка conversations/command-overview/ (conversations/command.py).
Корень «/» и дефолтный маршрут менеджера — «Чат»; устаревшие
/command и /departments/support ведут в «Чат» и на «Доску».

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Fable 5.1 committed 2026-09-05 14:14:57 +03:00
1 parent 3c2d6ea511
commit e5e51ce695
30 files changed
+20 -1153

No files matched your search

@@ -1,160 +0,0 @@
"""Командный центр: реальная сводка уровня компании (без выдуманных чисел).
Отделы упразднены (ADR-HUB-0043): карточки строятся по настраиваемым группам
организации плюс блок «Без группы». «Требует внимания» и состояние интеграций —
из реальных данных; расходы AI — из LlmInvocation.
"""
from __future__ import annotations
from django.conf import settings
from django.db.models import Count, Sum
from django.utils import timezone
from hub_platform.ai.models import AIAgent, LlmInvocation
from hub_platform.conversations.models import Conversation, ControlMode, LifecycleState
from hub_platform.conversations.stats import _ACTIVE_WINDOW, _window
from hub_platform.identity.group_models import EmployeeGroup
from hub_platform.integrations.models import Integration, IntegrationKind, IntegrationStatus
from hub_platform.tenancy.context import TenantContext
def _dialog_block(open_qs, now) -> dict:
return {
"open": open_qs.count(),
"activeNow": open_qs.filter(last_activity_at__gte=now - _ACTIVE_WINDOW).count(),
"onAI": open_qs.filter(control_mode=ControlMode.AI).count(),
"onOperators": open_qs.filter(control_mode=ControlMode.HUMAN).count(),
# Очередь: никем не взятые (та же семантика, что бейдж «Диалоги»).
"waiting": open_qs.filter(control_mode=ControlMode.PAUSED).count(),
}
def _minutes_since(moment, now) -> int:
return max(0, int((now - moment).total_seconds() // 60))
def command_center_overview(context: TenantContext, period: str) -> dict:
organization_id = context.organization_id
now = timezone.now()
start, _ = _window(period, now)
open_qs = Conversation.objects.filter(organization_id=organization_id, lifecycle=LifecycleState.OPEN)
groups = list(
EmployeeGroup.objects.filter(organization_id=organization_id)
.annotate(member_count=Count("member_links", distinct=True))
.order_by("name")
)
agents_by_group = dict(
AIAgent.objects.filter(
channel__organization_id=organization_id,
status="ACTIVE",
channel__group__isnull=False,
)
.values_list("channel__group_id")
.annotate(c=Count("id"))
)
cards = []
for group in groups:
group_open = open_qs.filter(group=group)
cards.append(
{
"code": str(group.id),
"name": group.name,
"route": "chat",
"employees": group.member_count,
"aiAgents": agents_by_group.get(group.id, 0),
"dialogs": _dialog_block(group_open, now),
}
)
ungrouped_open = open_qs.filter(group__isnull=True)
if not groups or ungrouped_open.exists():
cards.append(
{
"code": "none",
"name": "Без группы",
"route": "chat",
"employees": 0,
"aiAgents": AIAgent.objects.filter(
channel__organization_id=organization_id,
status="ACTIVE",
channel__group__isnull=True,
).count(),
"dialogs": _dialog_block(ungrouped_open, now),
}
)
# «Требует внимания»: очередь диалогов + ошибки интеграций.
attention: list[dict] = []
queue = (
open_qs.filter(control_mode=ControlMode.PAUSED)
.select_related("contact", "support_identity_snapshot", "channel")
.order_by("last_activity_at")[:4]
)
for conversation in queue:
snapshot = conversation.support_identity_snapshot
who = (conversation.contact.name if conversation.contact_id else "") or (snapshot.display_name if snapshot else "") or "Гость"
attention.append(
{
"kind": "dialog",
"title": f"Диалог ждёт оператора · {who}",
"meta": conversation.channel.name,
"minutes": _minutes_since(conversation.last_activity_at, now),
}
)
integrations = []
error_count = 0
for integration in Integration.objects.filter(organization_id=organization_id):
if integration.kind == IntegrationKind.LLM_PROVIDER:
group_label = "AI-провайдер"
elif integration.config.get("purpose") == "notifications":
group_label = "Бот уведомлений"
else:
group_label = "Канал"
if integration.status == IntegrationStatus.ERROR:
error_count += 1
attention.append(
{
"kind": "integration",
"title": f"Ошибка интеграции · {integration.name}",
"meta": group_label,
"minutes": _minutes_since(integration.last_checked_at or integration.updated_at, now),
}
)
integrations.append({"name": integration.name, "group": group_label, "status": integration.status})
invocations = LlmInvocation.objects.filter(channel__organization_id=organization_id, created_at__gte=start)
ai_totals = invocations.aggregate(cost=Sum("cost_micros"), tokens=Sum("total_tokens"))
period_dialogs = Conversation.objects.filter(organization_id=organization_id, created_at__gte=start).count()
total_waiting = sum(card["dialogs"]["waiting"] for card in cards)
if error_count:
status = "critical"
elif total_waiting:
status = "attention"
else:
status = "ok"
return {
"period": period,
"generatedAt": now.isoformat(),
"company": {
"status": status,
"departments": len(cards),
"openDialogs": open_qs.count(),
},
"departments": cards,
"attention": attention,
"integrations": integrations,
"ai": {
"spendMicros": ai_totals["cost"] or 0,
# Дневной лимит стоимости (USD micros); 0 = не задан. Прогресс-бар
# осмыслен только для периода «Сегодня».
"dailyLimitMicros": int(getattr(settings, "CUS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS", 0) or 0),
"tokens": ai_totals["tokens"] or 0,
"dialogs": period_dialogs,
},
}
@@ -2,7 +2,6 @@ from rest_framework.request import Request
from rest_framework.response import Response
from hub_platform.conversations.clients import client_detail, clients_overview
from hub_platform.conversations.command import command_center_overview
from hub_platform.conversations.models import Contact
from hub_platform.conversations.stats import sales_overview_stats
from hub_platform.conversations.view_base import ConversationViewBase
@@ -16,16 +15,6 @@ class ConversationStatsView(ConversationViewBase):
return Response(sales_overview_stats(request.tenant_context, period))
class CommandOverviewView(ConversationViewBase):
required_capability = "company.view"
def get(self, request: Request) -> Response:
period = request.query_params.get("period", "today")
if period not in ("today", "d7", "d30"):
period = "today"
return Response(command_center_overview(request.tenant_context, period))
class ClientsView(ConversationViewBase):
required_capability = "customers.view"
@@ -341,45 +341,6 @@ class ConversationSearchTests(TestCase):
self.assertEqual(self._search("соколов"), [maria.id])
class CommandOverviewTests(TestCase):
"""Сводка командного центра: карточки по группам, реальные метрики, доступ OWNER."""
def setUp(self) -> None:
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
self.organization = Organization.objects.get(slug="edevs")
self.operators = self.organization.employee_groups.get(name="Операторы")
self.channel = Channel.objects.create(
organization=self.organization, code="foxray-sales", name="FoxRay — продажи",
group=self.operators,
)
self.integration = _messenger_connection(self.channel)
contact = Contact.objects.create(organization=self.organization, name="Иван")
Conversation.objects.create(
organization=self.organization, channel=self.channel, connection=self.integration,
contact=contact, group=self.operators, control_mode=ControlMode.PAUSED,
)
self.client = APIClient()
self.client.login(username="owner@edevs.tech", password="temporary-password")
def test_overview_returns_groups_and_attention(self) -> None:
response = self.client.get("/api/v1/conversations/command-overview/?period=today")
self.assertEqual(response.status_code, 200)
payload = response.json()
codes = [d["code"] for d in payload["departments"]]
self.assertIn(str(self.operators.id), codes)
operators = next(
d for d in payload["departments"] if d["code"] == str(self.operators.id)
)
self.assertEqual(operators["name"], "Операторы")
self.assertEqual(operators["dialogs"]["open"], 1)
self.assertEqual(operators["dialogs"]["waiting"], 1)
self.assertNotIn("commerce", operators)
self.assertEqual(payload["company"]["status"], "attention")
self.assertTrue(any(item["kind"] == "dialog" for item in payload["attention"]))
# Интеграции отражены с группой.
self.assertTrue(any(row["group"] == "Канал" for row in payload["integrations"]))
class WebchatContactTests(TestCase):
"""Веб-виджет: клиент отправляет телефон формой — он сохраняется в Contact,
в переписке появляются kind=contact и подтверждение."""
@@ -5,7 +5,6 @@ from hub_platform.conversations import chat_extras_views, reporting_views, views
urlpatterns = [
path("", views.ConversationListView.as_view(), name="conversation-list"),
path("stats/", reporting_views.ConversationStatsView.as_view(), name="conversation-stats"),
path("command-overview/", reporting_views.CommandOverviewView.as_view(), name="conversation-command-overview"),
path("clients/", reporting_views.ClientsView.as_view(), name="conversation-clients"),
path("clients/<int:contact_id>/", reporting_views.ClientDetailView.as_view(), name="conversation-client-detail"),
path("<int:conversation_id>/", views.ConversationDetailView.as_view(), name="conversation-detail"),
+4 -4
View File
@@ -918,8 +918,8 @@ class EmployeeGovernanceTests(TestCase):
def test_admin_has_normal_capabilities(self) -> None:
self._make("admin@edevs.tech", EmployeeRole.ADMIN)
client = self._client("admin@edevs.tech")
overview = client.get("/api/v1/conversations/command-overview/?period=today")
self.assertEqual(overview.status_code, 200)
audit = client.get("/api/v1/company/administration/audit/")
self.assertEqual(audit.status_code, 200)
product = client.post(
"/api/v1/company/products/create/",
data=json.dumps({"code": "academy", "name": "Academy"}),
@@ -927,9 +927,9 @@ class EmployeeGovernanceTests(TestCase):
)
self.assertEqual(product.status_code, 201)
def test_employee_denied_command_overview(self) -> None:
def test_employee_denied_audit(self) -> None:
self._make("emp@edevs.tech", EmployeeRole.EMPLOYEE)
response = self._client("emp@edevs.tech").get("/api/v1/conversations/command-overview/?period=today")
response = self._client("emp@edevs.tech").get("/api/v1/company/administration/audit/")
self.assertEqual(response.status_code, 403)
def test_payload_permissions_reflect_actor(self) -> None:
+2 -2
View File
@@ -128,14 +128,14 @@ export function App() {
setActiveOrganization(null);
clearOrganizationPreference();
setTotpChallenge(null);
navigate("command", null, true, null, null);
navigate("chat", null, true, null, null);
setData({ employees: [], groups: [], products: [], agents: [] });
}
if (resetting) {
return (
<ConfigProvider theme={antdTheme}>
<AuthResetPassword onDone={() => { setResetting(false); window.history.replaceState({}, "", pathFromRoute("command")); }} />
<AuthResetPassword onDone={() => { setResetting(false); window.history.replaceState({}, "", pathFromRoute("chat")); }} />
</ConfigProvider>
);
}
+1 -2
View File
@@ -31,12 +31,11 @@ describe("role-based navigation (SPEC-HUB-0031 §3)", () => {
for (const role of ["OWNER", "ADMIN"] as const) {
const user = userWith(role);
expect(isManager(user)).toBe(true);
expect(canAccess(user, "command")).toBe(true);
expect(canAccess(user, "employees")).toBe(true);
expect(canAccess(user, "agents")).toBe(true);
expect(canAccess(user, "aiKnowledge")).toBe(true);
expect(canAccess(user, "administrationAudit")).toBe(true);
expect(defaultRoute(user)).toBe("command");
expect(defaultRoute(user)).toBe("chat");
}
});
-1
View File
@@ -25,6 +25,5 @@ export function canAccess(user: SessionUser, route: RouteKey): boolean {
}
export function defaultRoute(user: SessionUser): RouteKey {
if (isManager(user)) return "command";
return "chat";
}
@@ -1,3 +0,0 @@
export { CommandCenter } from "./center/CommandCenterPage";
export { commandCenterModel, fetchCommandOverview, type ApiCommandOverview } from "./center/model";
export { StatusLabel } from "./center/StatusLabel";
@@ -1,55 +0,0 @@
import { useEffect, useState } from "react";
import type { AppData, RouteKey } from "../../../types";
import { EmptyState, LoadingState } from "../../../shared/ui";
import { CommandHeader } from "./CommandHeader";
import { CommandRail } from "./CommandRail";
import { CompanyStatusBanner } from "./CompanyStatusBanner";
import { DepartmentsColumn } from "./SalesDepartmentCard";
import { commandCenterModel, fetchCommandOverview, type ApiCommandOverview } from "./model";
import type { CommandPeriod } from "./types";
export function CommandCenter({ data: _data, setRoute }: { data: AppData; setRoute: (route: RouteKey) => void }) {
const [period, setPeriod] = useState<CommandPeriod>("today");
const [overview, setOverview] = useState<ApiCommandOverview | null>(null);
const [error, setError] = useState(false);
useEffect(() => {
let active = true;
setError(false);
fetchCommandOverview(period)
.then((data) => { if (active) setOverview(data); })
.catch(() => { if (active) setError(true); });
return () => { active = false; };
}, [period]);
if (error) {
return (
<>
<CommandHeader period={period} setPeriod={setPeriod} />
<EmptyState title="Не удалось загрузить сводку" />
</>
);
}
if (overview === null) {
return (
<>
<CommandHeader period={period} setPeriod={setPeriod} />
<LoadingState />
</>
);
}
const vm = commandCenterModel(overview);
return (
<>
<CommandHeader period={period} setPeriod={setPeriod} />
<CompanyStatusBanner vm={vm} />
<div className="command-two-column">
<DepartmentsColumn setRoute={setRoute} vm={vm} />
<CommandRail vm={vm} />
</div>
<div className="command-updated">Обновлено: {vm.generatedAt}</div>
</>
);
}
@@ -1,18 +0,0 @@
import { Segmented } from "../../../shared/ui";
import { Button } from "../../../shared/ui-controls";
import type { CommandPeriod } from "./types";
export function CommandHeader({ period, setPeriod }: { period: CommandPeriod; setPeriod: (period: CommandPeriod) => void }) {
return (
<div className="command-page-header">
<div>
<h1>Обзор</h1>
<p>Состояние компании одним взглядом</p>
</div>
<div className="command-header-actions">
<Segmented value={period} setValue={setPeriod} items={[["today", "Сегодня"], ["d7", "7 дней"], ["d30", "30 дней"]]} />
<Button className="refresh-button" icon="refresh" variant="secondary">Обновить</Button>
</div>
</div>
);
}
@@ -1,53 +0,0 @@
import { Icon } from "../../../shared/icons";
import type { CommandVm } from "./model";
import { RailCard } from "./RailCard";
export function CommandRail({ vm }: { vm: CommandVm }) {
return (
<aside className="command-rail">
<RailCard title="Требует внимания" icon="warning" iconColor="#faad14" count={String(vm.attention.length)}>
{vm.attention.length === 0 && <div className="attention-empty">Нет событий, требующих внимания</div>}
{vm.attention.map((item) => (
<button className="attention-row" type="button" key={item.title}>
<span style={{ background: item.dot }} />
<span><strong>{item.title}</strong><small>{item.meta}</small></span>
<em>{item.time}</em>
</button>
))}
</RailCard>
<RailCard title="Состояние интеграций" icon="plug" iconColor="#595959" side={<span style={{ color: vm.intHeadColor }}>{vm.okCount}</span>}>
{vm.integrations.map((item) => (
<div className="integration-row" key={item.name}>
<span style={{ background: item.dot }} />
<span><strong>{item.name}</strong><small>{item.group}</small></span>
<em style={{ color: item.statusColor }}>{item.statusLabel}</em>
</div>
))}
</RailCard>
<section className="ai-spend-card">
<div className="rail-card-head">
<div><Icon name="bolt" size={17} /><strong>Расходы AI</strong></div>
<span>{vm.periodLabel}</span>
</div>
<div className="ai-spend-main"><strong>{vm.aiSpendStr}</strong>{vm.hasBudget && <span>из {vm.budgetStr}</span>}</div>
{vm.hasBudget ? (
<>
<div className="ai-progress"><span style={{ width: `${vm.aiPct}%`, background: vm.aiBarColor }} /></div>
<p>{vm.aiPct}% дневного бюджета</p>
</>
) : (
<p>Дневной лимит не задан</p>
)}
<div className="ai-grid">
<SmallAiMetric label="Токены" value={vm.aiTokens} />
<SmallAiMetric label="Диалоги" value={vm.aiDialogs} />
<SmallAiMetric label="Цена диалога" value={vm.costPerDialog} />
</div>
</section>
</aside>
);
}
function SmallAiMetric({ label, value }: { label: string; value: string }) {
return <div><span>{label}</span><strong>{value}</strong></div>;
}
@@ -1,21 +0,0 @@
import type { CommandVm } from "./model";
export function CompanyStatusBanner({ vm }: { vm: CommandVm }) {
return (
<section className="company-status-banner" style={{ borderLeftColor: vm.st.dot }}>
<div className="company-status-dot" style={{ background: vm.st.bg }}><span style={{ background: vm.st.dot }} /></div>
<div className="company-status-text">
<strong>Компания: {vm.st.label}</strong>
<p>{vm.compSummary}</p>
</div>
<div className="company-status-metrics">
<SmallMetric label="Группы" value={vm.banner.departments} />
<SmallMetric label="Открытые диалоги" value={vm.banner.open} />
</div>
</section>
);
}
function SmallMetric({ label, value }: { label: string; value: string }) {
return <div><span>{label}</span><strong>{value}</strong></div>;
}
@@ -1,17 +0,0 @@
import type { MetricItem } from "./types";
export function MetricGroup({ title, columns, items }: { title: string; columns: 4 | 5; items: MetricItem[] }) {
return (
<>
<div className="metric-group-title">{title}</div>
<div className="metric-group-grid" style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }}>
{items.map((item) => (
<div className="metric-cell" key={item.label}>
<div>{item.dot && <span style={{ background: item.dot }} />}{item.label}</div>
<strong style={{ color: item.color ?? "var(--text-body)" }}>{item.value}</strong>
</div>
))}
</div>
</>
);
}
@@ -1,16 +0,0 @@
import type { ReactNode } from "react";
import { Icon } from "../../../shared/icons";
export function RailCard({ title, icon, iconColor, count, action, side, children }: { title: string; icon: "plug" | "warning"; iconColor: string; count?: string; action?: string; side?: ReactNode; children: ReactNode }) {
return (
<section className="rail-card">
<div className="rail-card-head">
<div><span className="rail-icon" style={{ color: iconColor }}><Icon name={icon} size={17} /></span><strong>{title}</strong>{count && <b>{count}</b>}</div>
{action && <button className="link" type="button">{action}</button>}
{side}
</div>
<div className="rail-card-body">{children}</div>
</section>
);
}
@@ -1,40 +0,0 @@
import type { RouteKey } from "../../../types";
import { Icon } from "../../../shared/icons";
import { Button } from "../../../shared/ui-controls";
import type { CommandVm, DepartmentVm } from "./model";
import { MetricGroup } from "./MetricGroup";
import { StatusLabel } from "./StatusLabel";
export function DepartmentsColumn({ setRoute, vm }: { setRoute: (route: RouteKey) => void; vm: CommandVm }) {
return (
<section className="command-left">
<div className="section-head">
<h2>Группы</h2>
<span>{vm.departments.length} активных</span>
</div>
{vm.departments.map((department) => (
<DepartmentCard department={department} periodLabelUpper={vm.periodLabelUpper} setRoute={setRoute} key={department.code} />
))}
</section>
);
}
function DepartmentCard({ department, setRoute }: { department: DepartmentVm; periodLabelUpper?: string; setRoute: (route: RouteKey) => void }) {
return (
<article className="sales-card">
<div className="sales-head">
<div className="sales-icon"><Icon name={department.icon} size={23} /></div>
<div className="sales-title">
<div>
<h3>{department.name}</h3>
<StatusLabel status={department.status} />
</div>
<p>{department.subtitle}</p>
</div>
<Button className="sales-open" icon="arrow" iconSize={16} variant="primary" onClick={() => setRoute(department.route as RouteKey)}>Перейти</Button>
</div>
<div className="dept-summary">{department.summary}</div>
<MetricGroup title="ДИАЛОГИ — СЕЙЧАС" columns={5} items={department.dialogItems} />
</article>
);
}
@@ -1,10 +0,0 @@
import type { StatusMeta } from "./model";
export function StatusLabel({ status }: { status: StatusMeta }) {
return (
<span className="command-status-label" style={{ background: status.bg, borderColor: status.border }}>
<span style={{ background: status.dot }} />
<em style={{ color: status.color }}>{status.label}</em>
</span>
);
}
@@ -1,145 +0,0 @@
import { api } from "../../../api/client";
import type { CommandPeriod, MetricItem } from "./types";
// Реальная сводка командного центра (conversations/command.py) — без мок-данных.
export type ApiDialogBlock = { open: number; activeNow: number; onAI: number; onOperators: number; waiting: number };
export type ApiDepartment = {
code: string;
name: string;
route: string;
employees: number;
aiAgents: number;
dialogs: ApiDialogBlock;
};
export type ApiCommandOverview = {
period: CommandPeriod;
generatedAt: string;
company: { status: "ok" | "attention" | "critical"; departments: number; openDialogs: number };
departments: ApiDepartment[];
attention: Array<{ kind: "dialog" | "integration"; title: string; meta: string; minutes: number }>;
integrations: Array<{ name: string; group: string; status: "OK" | "ERROR" | "UNCHECKED" }>;
ai: { spendMicros: number; dailyLimitMicros: number; tokens: number; dialogs: number };
};
export const fetchCommandOverview = (period: CommandPeriod) =>
api<ApiCommandOverview>(`/api/v1/conversations/command-overview/?period=${period}`);
const usd = (micros: number) => `$${(micros / 1_000_000).toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
function tokensLabel(tokens: number): string {
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toLocaleString("ru-RU", { maximumFractionDigits: 1 })}M`;
if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}K`;
return String(tokens);
}
function timeLabel(minutes: number): string {
if (minutes < 60) return `${minutes} мин`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} ч`;
return `${Math.floor(hours / 24)} д`;
}
export type StatusMeta = { label: string; color: string; bg: string; border: string; dot: string };
const STATUS_META: Record<ApiCommandOverview["company"]["status"], StatusMeta> = {
ok: { label: "Штатно", color: "var(--success-text)", bg: "var(--success-bg)", border: "var(--success-border)", dot: "var(--success)" },
attention: { label: "Требует внимания", color: "var(--warning-text)", bg: "var(--warning-bg-strong)", border: "var(--warning-border)", dot: "var(--warning)" },
critical: { label: "Критично", color: "var(--error-text)", bg: "var(--error-bg)", border: "var(--error-border)", dot: "var(--error)" },
};
const COMPANY_SUMMARY: Record<ApiCommandOverview["company"]["status"], string> = {
ok: "Все системы в норме, критичных событий нет.",
attention: "Есть события, требующие внимания оператора.",
critical: "Есть критичные проблемы — проверьте раздел «Требует внимания».",
};
const ATTENTION_DOT: Record<string, string> = { dialog: "var(--warning)", integration: "var(--error)" };
const INTEGRATION_STATUS: Record<string, { label: string; color: string }> = {
OK: { label: "Подключено", color: "var(--success)" },
ERROR: { label: "Ошибка", color: "var(--error)" },
UNCHECKED: { label: "Не проверено", color: "var(--n-4)" },
};
const PERIOD_LABEL: Record<CommandPeriod, string> = { today: "Сегодня", d7: "7 дней", d30: "30 дней" };
export type DepartmentVm = {
code: string;
name: string;
route: string;
icon: "shop" | "wrench";
subtitle: string;
status: StatusMeta;
summary: string;
dialogItems: MetricItem[];
};
export function commandCenterModel(data: ApiCommandOverview) {
const st = STATUS_META[data.company.status];
const periodLabel = PERIOD_LABEL[data.period];
const departments: DepartmentVm[] = data.departments.map((department) => {
const d = department.dialogs;
return {
code: department.code,
name: department.name,
route: department.route,
icon: department.name === "Поддержка" ? "wrench" : "shop",
subtitle: `Сотрудники: ${department.employees} · Агенты: ${department.aiAgents}`,
status: d.waiting > 0 ? STATUS_META.attention : STATUS_META.ok,
summary: d.waiting > 0 ? `В очереди ${d.waiting} — нужен оператор.` : "Очередь оператора пуста.",
dialogItems: [
{ label: "Открытые диалоги", value: String(d.open) },
{ label: "Активны за 15 мин", value: String(d.activeNow) },
{ label: "На AI", value: String(d.onAI), dot: "var(--ai)" },
{ label: "На операторах", value: String(d.onOperators), dot: "var(--primary)" },
{ label: "Ожидают оператора", value: String(d.waiting), color: d.waiting > 0 ? "var(--warning-text)" : "var(--text-body)" },
],
};
});
const attention = data.attention.map((item) => ({
dot: ATTENTION_DOT[item.kind] ?? "var(--warning)",
title: item.title,
meta: item.meta,
time: timeLabel(item.minutes),
}));
const integrations = data.integrations.map((item) => {
const status = INTEGRATION_STATUS[item.status] ?? INTEGRATION_STATUS.UNCHECKED;
return { name: item.name, group: item.group, dot: status.color, statusLabel: status.label, statusColor: status.color };
});
const okCount = data.integrations.filter((item) => item.status === "OK").length;
// Прогресс бюджета осмыслен только для «Сегодня» и при заданном дневном лимите.
const hasBudget = data.period === "today" && data.ai.dailyLimitMicros > 0;
const aiPct = hasBudget ? Math.min(100, Math.round((data.ai.spendMicros / data.ai.dailyLimitMicros) * 100)) : 0;
return {
st,
compSummary: COMPANY_SUMMARY[data.company.status],
banner: {
departments: String(data.company.departments),
open: String(data.company.openDialogs),
},
departments,
attention,
integrations,
okCount: `${okCount}/${data.integrations.length} в норме`,
intHeadColor: okCount === data.integrations.length ? "var(--success-text)" : "var(--warning-text)",
periodLabel,
periodLabelUpper: periodLabel.toUpperCase(),
aiSpendStr: usd(data.ai.spendMicros),
budgetStr: hasBudget ? usd(data.ai.dailyLimitMicros) : "",
hasBudget,
aiPct,
aiBarColor: aiPct >= 85 ? "var(--error)" : aiPct >= 70 ? "var(--warning)" : "var(--primary)",
aiTokens: tokensLabel(data.ai.tokens),
aiDialogs: String(data.ai.dialogs),
costPerDialog: data.ai.dialogs > 0 ? usd(data.ai.spendMicros / data.ai.dialogs) : "—",
generatedAt: new Date(data.generatedAt).toLocaleString("ru-RU", { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" }),
};
}
export type CommandVm = ReturnType<typeof commandCenterModel>;
@@ -1,500 +0,0 @@
.command-page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
margin-bottom: 20px;
}
.command-page-header h1 {
margin: 0;
color: var(--n-1);
font-size: 26px;
font-weight: 700;
line-height: 1.2;
}
.command-page-header p {
margin: 7px 0 0;
color: var(--n-4);
font-size: 13px;
}
.command-header-actions {
display: flex;
align-items: center;
gap: 10px;
}
.refresh-button {
height: 36px;
gap: 7px;
padding: 0 14px;
color: var(--n-3);
border-radius: 7px;
font-size: 13px;
font-weight: 600;
}
.company-status-banner {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
padding: 16px 18px 16px 16px;
background: var(--surface-card);
border: 1px solid var(--n-8);
border-left: 4px solid var(--success);
border-radius: 10px;
}
.company-status-dot {
width: 38px;
height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
border-radius: 50%;
}
.company-status-dot span {
width: 11px;
height: 11px;
border-radius: 50%;
}
.company-status-text {
min-width: 0;
flex: 1;
}
.company-status-text strong {
display: block;
color: var(--n-1);
font-size: 16px;
font-weight: 700;
}
.company-status-text p {
margin: 5px 0 0;
color: var(--n-4);
font-size: 13px;
}
.company-status-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(112px, 1fr));
gap: 12px;
flex: 0 0 412px;
}
.company-status-metrics div {
min-height: 52px;
padding: 9px 11px;
background: var(--n-10);
border: 1px solid var(--n-9);
border-radius: 8px;
}
.company-status-metrics span {
display: block;
color: var(--n-4);
font-size: 11px;
line-height: 1.2;
}
.company-status-metrics strong {
display: block;
margin-top: 6px;
color: var(--n-2);
font-size: 18px;
font-weight: 700;
line-height: 1;
}
.company-status-metrics strong.success {
color: var(--success-text);
}
.command-two-column {
display: flex;
align-items: flex-start;
gap: 20px;
}
.command-left {
min-width: 0;
flex: 1;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.section-head h2 {
margin: 0;
color: var(--n-1);
font-size: 18px;
font-weight: 700;
}
.section-head span {
color: var(--n-4);
font-size: 13px;
}
.sales-card {
padding: 20px;
background: var(--surface-card);
border: 1px solid var(--n-8);
border-radius: 10px;
}
/* Карточки отделов идут друг под другом — с зазором. */
.sales-card + .sales-card {
margin-top: 16px;
}
.sales-head {
display: flex;
align-items: flex-start;
gap: 14px;
}
.sales-icon {
width: 44px;
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
color: var(--primary);
background: var(--primary-bg);
border-radius: 10px;
}
.sales-title {
min-width: 0;
flex: 1;
}
.sales-title div {
display: flex;
align-items: center;
gap: 9px;
}
.sales-title h3 {
margin: 0;
color: var(--n-1);
font-size: 20px;
font-weight: 700;
}
.sales-title p {
margin: 7px 0 0;
color: var(--n-4);
font-size: 13px;
}
.command-status-label {
height: 24px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 9px;
border: 1px solid;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.command-status-label span {
width: 7px;
height: 7px;
border-radius: 50%;
}
.command-status-label em {
font-style: normal;
}
.sales-open {
height: 36px;
flex: none;
gap: 7px;
padding: 0 13px;
border-radius: 7px;
font-size: 13px;
}
.dept-summary {
margin-top: 16px;
padding: 13px 14px;
color: var(--n-3);
background: var(--n-10);
border: 1px solid var(--n-9);
border-radius: 8px;
font-size: 13px;
line-height: 1.45;
}
.metric-group-title {
margin: 18px 0 9px;
color: var(--n-4);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
}
.metric-group-grid {
display: grid;
gap: 10px;
}
.metric-cell {
min-height: 74px;
padding: 12px;
background: var(--surface-card);
border: 1px solid var(--n-8);
border-radius: 9px;
}
.metric-cell div {
display: flex;
align-items: center;
gap: 6px;
min-height: 28px;
color: var(--n-4);
font-size: 12px;
line-height: 1.25;
}
.metric-cell div span {
width: 7px;
height: 7px;
flex: none;
border-radius: 50%;
}
.metric-cell strong {
display: block;
margin-top: 8px;
color: var(--n-2);
font-size: 22px;
font-weight: 700;
line-height: 1;
}
.command-rail {
width: 344px;
display: flex;
flex: none;
flex-direction: column;
gap: 16px;
}
.rail-card,
.ai-spend-card {
padding: 16px;
background: var(--surface-card);
border: 1px solid var(--n-8);
border-radius: 10px;
}
.rail-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 13px;
}
.rail-card-head > div {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.rail-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
}
.rail-card-head strong {
color: var(--n-1);
font-size: 15px;
font-weight: 700;
}
.rail-card-head b {
min-width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 6px;
color: var(--warning);
background: var(--warning-bg);
border: 1px solid var(--warning-border);
border-radius: 999px;
font-size: 12px;
font-weight: 700;
}
.rail-card-head > .link,
.rail-card-head > span {
flex: none;
color: var(--primary);
font-size: 12px;
font-weight: 600;
text-decoration: none;
}
.rail-card-body {
display: flex;
flex-direction: column;
gap: 9px;
}
.attention-row,
.integration-row {
display: grid;
align-items: start;
gap: 9px;
padding: 10px;
background: var(--n-10);
border: 1px solid var(--n-9);
border-radius: 8px;
text-decoration: none;
}
.attention-row {
grid-template-columns: 8px minmax(0, 1fr) auto;
width: 100%;
text-align: left;
cursor: pointer;
}
.integration-row {
grid-template-columns: 8px minmax(0, 1fr) auto;
}
.attention-row > span:first-child,
.integration-row > span:first-child {
width: 8px;
height: 8px;
margin-top: 5px;
border-radius: 50%;
}
.attention-row strong,
.integration-row strong {
display: block;
color: var(--n-1);
font-size: 13px;
font-weight: 600;
line-height: 1.35;
}
.attention-row small,
.integration-row small {
display: block;
margin-top: 3px;
color: var(--n-4);
font-size: 12px;
line-height: 1.25;
}
.attention-row em,
.integration-row em {
color: var(--n-4);
font-size: 12px;
font-style: normal;
line-height: 1.35;
white-space: nowrap;
}
.ai-spend-main {
display: flex;
align-items: baseline;
gap: 6px;
margin-bottom: 10px;
}
.ai-spend-main strong {
color: var(--n-1);
font-size: 22px;
font-weight: 700;
}
.ai-spend-main span {
color: var(--n-4);
font-size: 12px;
}
.ai-progress {
height: 8px;
overflow: hidden;
background: var(--n-9);
border-radius: 999px;
}
.ai-progress span {
height: 100%;
display: block;
border-radius: inherit;
}
.ai-spend-card p {
margin: 8px 0 12px;
color: var(--n-4);
font-size: 12px;
}
.ai-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.ai-grid div {
padding: 9px 8px;
background: var(--n-10);
border: 1px solid var(--n-9);
border-radius: 8px;
}
.ai-grid span {
display: block;
color: var(--n-4);
font-size: 11px;
}
.ai-grid strong {
display: block;
margin-top: 6px;
color: var(--n-2);
font-size: 14px;
font-weight: 700;
}
.command-updated {
margin-top: 14px;
color: var(--n-5);
font-size: 12px;
}
.attention-empty {
padding: 10px 0;
color: var(--n-4);
font-size: 12.5px;
}
@@ -1,8 +0,0 @@
export type CommandPeriod = "today" | "d7" | "d30";
export type MetricItem = {
label: string;
value: string;
color?: string;
dot?: string;
};
@@ -1,17 +0,0 @@
import { EmptyState } from "../../shared/ui";
// SPEC-HUB-0010 §12: Support Overview показывает операционные метрики (открытые
// обращения, ожидают оператора, AI/оператор, закрытые, breakdown по продуктам).
// Sales/revenue/conversion запрещены. Backend support-метрики — следующий этап
// (stats.py сейчас sales-only), поэтому посадочная без выдуманных данных.
export function SupportOverviewPage() {
return (
<div className="sales-overview-header">
<div>
<h1>Обзор отдела поддержки</h1>
<p>Операционное состояние обращений клиентов продуктов</p>
</div>
<EmptyState title="Метрики поддержки появятся на следующем этапе" />
</div>
);
}
+1 -1
View File
@@ -87,7 +87,7 @@ export function Shell({ route, setRoute, selectedEmployeeId, selectedProductCode
openEmployeeRoute(employee.id);
}
const isSalesWorkspace = route === "salesClientDetail" || route === "salesClients" || route === "chat";
const isSupportWorkspace = route === "supportOverview" || route === "supportPortals" || route === "supportPortalDetail";
const isSupportWorkspace = route === "supportPortals" || route === "supportPortalDetail";
const isDialogsWorkspace = route === "chat";
const isSalesClients = route === "salesClients";
const isSalesClientDetail = route === "salesClientDetail";
@@ -5,7 +5,6 @@ import { AgentDetailPage } from "../features/agents/AgentDetailPage";
import { KnowledgeDetailPage } from "../features/ai/knowledge/KnowledgeDetailPage";
import { KnowledgeCreatePage } from "../features/ai/knowledge/KnowledgeCreatePage";
import { KnowledgePage } from "../features/ai/knowledge/KnowledgePage";
import { CommandCenter } from "../features/command/CommandCenter";
import { EmployeeDetailPage, EmployeesPage } from "../features/employees/EmployeesPage";
import { ProfilePage } from "../features/profile/ProfilePage";
import { SettingsPage } from "../features/settings/SettingsPage";
@@ -15,7 +14,6 @@ import type { DialogScope } from "../features/conversations/ConversationWorkspac
import type { ConversationCounters } from "../features/conversations/model";
import { SalesClientDetailPage } from "../features/sales/client-detail/SalesClientDetailPage";
import { SalesClientsPage } from "../features/sales/SalesClientsPage";
import { SupportOverviewPage } from "../features/support/SupportOverviewPage";
import { LoadingState } from "../shared/ui";
import type { AppData, Employee, RouteKey, SessionUser } from "../types";
import { hasCapability } from "../auth/access";
@@ -30,7 +28,6 @@ const SupportPortalDetailPage = lazy(() => import("../features/support-portals/S
export function ShellRouteContent({ chatScope, setChatScope, chatCounters, chatScopeSwitcher, route, data, currentEmployee, selectedProductCode, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, openClient, selectedChannelId, openChannel, selectedSupportPortalId, openSupportPortal, openConversation, openEmployee, openAgentCreate, openAgent, openKnowledge, onAgentLoaded, onChannelLoaded, reload, setRoute, user, onUserUpdated, onLogout }: { chatScope: DialogScope; setChatScope: (scope: DialogScope) => void; chatCounters: ConversationCounters | null; chatScopeSwitcher: boolean; route: RouteKey; data: AppData; currentEmployee: Employee | null; selectedProductCode: string | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; openClient: (clientId: number) => void; selectedChannelId: number | null; openChannel: (channelId: number) => void; selectedSupportPortalId: number | null; openSupportPortal: (portalId: number) => void; openConversation: (conversationId: number) => void; openEmployee: (employee: Employee) => void; openAgentCreate: (productCode: string | null) => void; openAgent: (agentId: number) => void; openKnowledge: (knowledgeId: number) => void; onAgentLoaded: (name: string | null) => void; onChannelLoaded: (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} />}
{route === "employees" && <EmployeesPage groups={data.groups} employees={data.employees} reload={reload} openEmployee={openEmployee} setRoute={setRoute} user={user} />}
{route === "employeeDetail" && currentEmployee && <EmployeeDetailPage groups={data.groups} employee={currentEmployee} reload={reload} setRoute={setRoute} user={user} />}
{route === "employeeDetail" && !currentEmployee && <EmployeesPage groups={data.groups} employees={data.employees} reload={reload} openEmployee={openEmployee} setRoute={setRoute} user={user} />}
@@ -45,7 +42,6 @@ export function ShellRouteContent({ chatScope, setChatScope, chatCounters, chatS
)}
{route === "salesClients" && <SalesClientsPage openClient={openClient} />}
{route === "salesClientDetail" && <SalesClientDetailPage contactId={selectedClientId} openConversation={openConversation} />}
{route === "supportOverview" && <SupportOverviewPage />}
{route === "chat" && (
<ChatPage
initialConversationId={selectedConversationId}
-2
View File
@@ -40,8 +40,6 @@ function SidebarLink({ activeRoutes, badge, icon, label, route, routeKey, setRou
}
const SERVICE_ITEMS: SidebarNavSectionItem[] = [
{ activeRoutes: ["command"], key: "command", label: "Обзор" },
{ activeRoutes: ["supportOverview"], key: "supportOverview", label: "Обзор поддержки" },
{ activeRoutes: ["aiKnowledge", "aiKnowledgeCreate", "aiKnowledgeDetail"], key: "aiKnowledge", label: "Знания" },
{ activeRoutes: ["aiUsage"], key: "aiUsage", label: "Использование AI" },
{ activeRoutes: ["administrationAudit"], key: "administrationAudit", label: "Аудит" },
+3 -6
View File
@@ -3,24 +3,21 @@ import type { Employee, RouteKey, SessionUser } from "../types";
import { Icon } from "../shared/icons";
export function TopBar({ route, user, currentEmployee, currentAgentName, currentChannelName, setRoute, unreadCount = 0, onOpenNotifications }: { route: RouteKey; user: SessionUser; currentEmployee?: Employee | null; currentAgentName?: string | null; currentChannelName?: string | null; setRoute: (route: RouteKey) => void; unreadCount?: number; onOpenNotifications?: () => void }) {
const isCommand = route === "command";
const isAiDetail = route === "aiKnowledgeCreate" || route === "aiKnowledgeDetail";
const isAi = route.startsWith("ai") && !isAiDetail;
const isSupportWorkspace = route === "supportOverview" || route === "supportPortals" || route === "supportPortalDetail";
return (
<header className="hub-topbar">
<div className="breadcrumbs">
<button type="button" onClick={() => setRoute("command")}>{user.organizationName}</button>
<button type="button" onClick={() => setRoute("chat")}>{user.organizationName}</button>
<i>/</i>
{route === "employeeDetail" && <><button type="button" onClick={() => setRoute("employees")}>Сотрудники</button><i>/</i><strong>{currentEmployee?.fullName || currentEmployee?.email || routes[route]}</strong></>}
{route === "agentDetail" && <><button type="button" onClick={() => setRoute("agents")}>Агенты</button><i>/</i><strong>{currentAgentName || routes[route]}</strong></>}
{route === "salesClientDetail" && <><button type="button" onClick={() => setRoute("salesClients")}>Контакты</button><i>/</i><strong>{routes[route]}</strong></>}
{isSupportWorkspace && route !== "supportPortalDetail" && <><button type="button" onClick={() => setRoute("supportOverview")}>Поддержка</button><i>/</i><strong>{routes[route]}</strong></>}
{route === "supportPortalDetail" && <><button type="button" onClick={() => setRoute("supportOverview")}>Поддержка</button><i>/</i><button type="button" onClick={() => setRoute("supportPortals")}>Порталы</button><i>/</i><strong>{routes[route]}</strong></>}
{route === "supportPortalDetail" && <><button type="button" onClick={() => setRoute("supportPortals")}>{routes.supportPortals}</button><i>/</i><strong>{routes[route]}</strong></>}
{isAi && <><button type="button" onClick={() => setRoute("aiKnowledge")}>AI</button><i>/</i><strong>{routes[route]}</strong></>}
{route === "aiKnowledgeDetail" && <><button type="button" onClick={() => setRoute("aiKnowledge")}>AI</button><i>/</i><button type="button" onClick={() => setRoute("aiKnowledge")}>Знания</button><i>/</i><strong>{currentAgentName || routes[route]}</strong></>}
{route === "aiKnowledgeCreate" && <><button type="button" onClick={() => setRoute("aiKnowledge")}>AI</button><i>/</i><button type="button" onClick={() => setRoute("aiKnowledge")}>Знания</button><i>/</i><strong>{routes[route]}</strong></>}
{route !== "employeeDetail" && route !== "agentDetail" && route !== "supportPortalDetail" && route !== "salesClientDetail" && !isSupportWorkspace && !isAi && !isAiDetail && <strong>{routes[route]}</strong>}
{route !== "employeeDetail" && route !== "agentDetail" && route !== "supportPortalDetail" && route !== "salesClientDetail" && !isAi && !isAiDetail && <strong>{routes[route]}</strong>}
</div>
<div className="topbar-actions">
<button className="icon-button" aria-label="Уведомления" onClick={onOpenNotifications}><Icon name="bell" size={18} />{unreadCount > 0 && <b>{unreadCount > 99 ? "99+" : unreadCount}</b>}</button>
+2 -6
View File
@@ -54,12 +54,8 @@ describe("ai knowledge routes", () => {
});
describe("support routes", () => {
it("parses the support overview URL", () => {
expect(routeFromPath("/departments/support")).toEqual({ route: "supportOverview", ...empty });
});
it("creates the support overview URL", () => {
expect(pathFromRoute("supportOverview")).toBe("/departments/support");
it("redirects the legacy support overview URL to the board", () => {
expect(routeFromPath("/departments/support")).toEqual({ route: "supportPortals", ...empty });
});
});
+6 -7
View File
@@ -19,12 +19,13 @@ export function routeFromPath(pathname: string, search = ""): RouteState {
const path = match ? match[2] || "/" : normalized;
const base = { employeeId: null, productCode: null, agentId: null, knowledgeId: null, clientId: null, channelId: null, supportPortalId: null };
const state = { organizationPublicId, ...base };
if (path === "/" || path === "/command") return { route: "command", ...state };
// Устаревшие адреса разделённых чатов ведут в единый «Чат».
if (path === "/chat" || path === "/departments/sales" || path === "/departments/sales/dialogs" || path === "/departments/support/dialogs") {
// Chat-first (SPEC-HUB-0031): корень и устаревшие адреса командного центра и
// разделённых чатов ведут в единый «Чат».
if (path === "/" || path === "/command" || path === "/chat" || path === "/departments/sales" || path === "/departments/sales/dialogs" || path === "/departments/support/dialogs") {
return { route: "chat", ...state };
}
if (path === "/departments/support") return { route: "supportOverview", ...state };
// Устаревший «Обзор поддержки» ведёт на «Доску».
if (path === "/departments/support") return { route: "supportPortals", ...state };
if (path === "/departments/sales/clients") return { route: "salesClients", ...state };
if (path.startsWith("/departments/sales/clients/")) {
const id = Number(path.split("/")[4]);
@@ -71,13 +72,11 @@ export function routeFromPath(pathname: string, search = ""): RouteState {
}
if (path === "/profile") return { route: "profile", ...state };
if (path === "/settings") return { route: "settings", ...state };
return { route: "command", ...state };
return { route: "chat", ...state };
}
export function pathFromRoute(route: RouteKey, entityId: number | null = null, productCode: string | null = null, organizationPublicId: string | null = null): string {
const prefix = organizationPublicId ? `/organizations/${organizationPublicId}` : "";
if (route === "command") return `${prefix}/`;
if (route === "supportOverview") return `${prefix}/departments/support`;
if (route === "salesClients") return `${prefix}/departments/sales/clients`;
if (route === "salesClientDetail") return entityId ? `${prefix}/departments/sales/clients/${entityId}` : `${prefix}/departments/sales/clients`;
if (route === "chat") return `${prefix}/chat`;
-2
View File
@@ -2,7 +2,6 @@ import type { RouteKey } from "./types";
export const routes: Record<RouteKey, string> = {
administrationAudit: "Аудит",
command: "Обзор",
employeeDetail: "Сотрудники",
employees: "Сотрудники",
profile: "Профиль",
@@ -10,7 +9,6 @@ export const routes: Record<RouteKey, string> = {
salesClientDetail: "Контакт",
salesClients: "Контакты",
chat: "Чат",
supportOverview: "Обзор",
supportPortals: "Порталы",
supportPortalDetail: "Портал поддержки",
agents: "Агенты",
-1
View File
@@ -6,7 +6,6 @@
@import "./layout/styles.css";
@import "./layout/topbar.css";
@import "./layout/page.css";
@import "./features/command/center/styles.css";
@import "./features/sales/clients/styles.css";
@import "./features/sales/client-detail/styles.css";
@import "./features/sales/dialogs/styles.css";
+1 -1
View File
@@ -115,7 +115,7 @@ export type Product = {
updatedAt: string;
};
export type RouteKey = "administrationAudit" | "command" | "employeeDetail" | "employees" | "profile" | "settings" | "salesClientDetail" | "salesClients" | "chat" | "supportOverview" | "supportPortals" | "supportPortalDetail" | "agents" | "agentDetail" | "aiKnowledge" | "aiKnowledgeCreate" | "aiKnowledgeDetail" | "aiUsage";
export type RouteKey = "administrationAudit" | "employeeDetail" | "employees" | "profile" | "settings" | "salesClientDetail" | "salesClients" | "chat" | "supportPortals" | "supportPortalDetail" | "agents" | "agentDetail" | "aiKnowledge" | "aiKnowledgeCreate" | "aiKnowledgeDetail" | "aiUsage";
export type AppData = {
employees: Employee[];