diff --git a/apps/backend/hub_platform/conversations/command.py b/apps/backend/hub_platform/conversations/command.py deleted file mode 100644 index 93a05cc..0000000 --- a/apps/backend/hub_platform/conversations/command.py +++ /dev/null @@ -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, - }, - } diff --git a/apps/backend/hub_platform/conversations/reporting_views.py b/apps/backend/hub_platform/conversations/reporting_views.py index e779f98..60071c0 100644 --- a/apps/backend/hub_platform/conversations/reporting_views.py +++ b/apps/backend/hub_platform/conversations/reporting_views.py @@ -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" diff --git a/apps/backend/hub_platform/conversations/tests.py b/apps/backend/hub_platform/conversations/tests.py index 37fd3e2..3012188 100644 --- a/apps/backend/hub_platform/conversations/tests.py +++ b/apps/backend/hub_platform/conversations/tests.py @@ -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 и подтверждение.""" diff --git a/apps/backend/hub_platform/conversations/urls.py b/apps/backend/hub_platform/conversations/urls.py index a374bb6..b708f2f 100644 --- a/apps/backend/hub_platform/conversations/urls.py +++ b/apps/backend/hub_platform/conversations/urls.py @@ -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//", reporting_views.ClientDetailView.as_view(), name="conversation-client-detail"), path("/", views.ConversationDetailView.as_view(), name="conversation-detail"), diff --git a/apps/backend/hub_platform/identity/tests.py b/apps/backend/hub_platform/identity/tests.py index b72b4ed..8a2f2de 100644 --- a/apps/backend/hub_platform/identity/tests.py +++ b/apps/backend/hub_platform/identity/tests.py @@ -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: diff --git a/apps/internal-ui/src/App.tsx b/apps/internal-ui/src/App.tsx index 0ddb214..abd305b 100644 --- a/apps/internal-ui/src/App.tsx +++ b/apps/internal-ui/src/App.tsx @@ -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 ( - { setResetting(false); window.history.replaceState({}, "", pathFromRoute("command")); }} /> + { setResetting(false); window.history.replaceState({}, "", pathFromRoute("chat")); }} /> ); } diff --git a/apps/internal-ui/src/auth/access.test.ts b/apps/internal-ui/src/auth/access.test.ts index 9a01c6d..4c74656 100644 --- a/apps/internal-ui/src/auth/access.test.ts +++ b/apps/internal-ui/src/auth/access.test.ts @@ -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"); } }); diff --git a/apps/internal-ui/src/auth/access.ts b/apps/internal-ui/src/auth/access.ts index 09d21ea..1364b37 100644 --- a/apps/internal-ui/src/auth/access.ts +++ b/apps/internal-ui/src/auth/access.ts @@ -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"; } diff --git a/apps/internal-ui/src/features/command/CommandCenter.tsx b/apps/internal-ui/src/features/command/CommandCenter.tsx deleted file mode 100644 index dbd662f..0000000 --- a/apps/internal-ui/src/features/command/CommandCenter.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export { CommandCenter } from "./center/CommandCenterPage"; -export { commandCenterModel, fetchCommandOverview, type ApiCommandOverview } from "./center/model"; -export { StatusLabel } from "./center/StatusLabel"; diff --git a/apps/internal-ui/src/features/command/center/CommandCenterPage.tsx b/apps/internal-ui/src/features/command/center/CommandCenterPage.tsx deleted file mode 100644 index 0c44104..0000000 --- a/apps/internal-ui/src/features/command/center/CommandCenterPage.tsx +++ /dev/null @@ -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("today"); - const [overview, setOverview] = useState(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 ( - <> - - - - ); - } - if (overview === null) { - return ( - <> - - - - ); - } - - const vm = commandCenterModel(overview); - return ( - <> - - -
- - -
-
Обновлено: {vm.generatedAt}
- - ); -} diff --git a/apps/internal-ui/src/features/command/center/CommandHeader.tsx b/apps/internal-ui/src/features/command/center/CommandHeader.tsx deleted file mode 100644 index 00acb78..0000000 --- a/apps/internal-ui/src/features/command/center/CommandHeader.tsx +++ /dev/null @@ -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 ( -
-
-

Обзор

-

Состояние компании одним взглядом

-
-
- - -
-
- ); -} diff --git a/apps/internal-ui/src/features/command/center/CommandRail.tsx b/apps/internal-ui/src/features/command/center/CommandRail.tsx deleted file mode 100644 index 15f8d4b..0000000 --- a/apps/internal-ui/src/features/command/center/CommandRail.tsx +++ /dev/null @@ -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 ( - - ); -} - -function SmallAiMetric({ label, value }: { label: string; value: string }) { - return
{label}{value}
; -} diff --git a/apps/internal-ui/src/features/command/center/CompanyStatusBanner.tsx b/apps/internal-ui/src/features/command/center/CompanyStatusBanner.tsx deleted file mode 100644 index 163c295..0000000 --- a/apps/internal-ui/src/features/command/center/CompanyStatusBanner.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import type { CommandVm } from "./model"; - -export function CompanyStatusBanner({ vm }: { vm: CommandVm }) { - return ( -
-
-
- Компания: {vm.st.label} -

{vm.compSummary}

-
-
- - -
-
- ); -} - -function SmallMetric({ label, value }: { label: string; value: string }) { - return
{label}{value}
; -} diff --git a/apps/internal-ui/src/features/command/center/MetricGroup.tsx b/apps/internal-ui/src/features/command/center/MetricGroup.tsx deleted file mode 100644 index c33199c..0000000 --- a/apps/internal-ui/src/features/command/center/MetricGroup.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import type { MetricItem } from "./types"; - -export function MetricGroup({ title, columns, items }: { title: string; columns: 4 | 5; items: MetricItem[] }) { - return ( - <> -
{title}
-
- {items.map((item) => ( -
-
{item.dot && }{item.label}
- {item.value} -
- ))} -
- - ); -} diff --git a/apps/internal-ui/src/features/command/center/RailCard.tsx b/apps/internal-ui/src/features/command/center/RailCard.tsx deleted file mode 100644 index a1dc1bc..0000000 --- a/apps/internal-ui/src/features/command/center/RailCard.tsx +++ /dev/null @@ -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 ( -
-
-
{title}{count && {count}}
- {action && } - {side} -
-
{children}
-
- ); -} diff --git a/apps/internal-ui/src/features/command/center/SalesDepartmentCard.tsx b/apps/internal-ui/src/features/command/center/SalesDepartmentCard.tsx deleted file mode 100644 index 4c0d6c5..0000000 --- a/apps/internal-ui/src/features/command/center/SalesDepartmentCard.tsx +++ /dev/null @@ -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 ( -
-
-

Группы

- {vm.departments.length} активных -
- {vm.departments.map((department) => ( - - ))} -
- ); -} - -function DepartmentCard({ department, setRoute }: { department: DepartmentVm; periodLabelUpper?: string; setRoute: (route: RouteKey) => void }) { - return ( -
-
-
-
-
-

{department.name}

- -
-

{department.subtitle}

-
- -
-
{department.summary}
- -
- ); -} diff --git a/apps/internal-ui/src/features/command/center/StatusLabel.tsx b/apps/internal-ui/src/features/command/center/StatusLabel.tsx deleted file mode 100644 index 6cb5fe1..0000000 --- a/apps/internal-ui/src/features/command/center/StatusLabel.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type { StatusMeta } from "./model"; - -export function StatusLabel({ status }: { status: StatusMeta }) { - return ( - - - {status.label} - - ); -} diff --git a/apps/internal-ui/src/features/command/center/model.ts b/apps/internal-ui/src/features/command/center/model.ts deleted file mode 100644 index b77285e..0000000 --- a/apps/internal-ui/src/features/command/center/model.ts +++ /dev/null @@ -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(`/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 = { - 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 = { - ok: "Все системы в норме, критичных событий нет.", - attention: "Есть события, требующие внимания оператора.", - critical: "Есть критичные проблемы — проверьте раздел «Требует внимания».", -}; - -const ATTENTION_DOT: Record = { dialog: "var(--warning)", integration: "var(--error)" }; - -const INTEGRATION_STATUS: Record = { - OK: { label: "Подключено", color: "var(--success)" }, - ERROR: { label: "Ошибка", color: "var(--error)" }, - UNCHECKED: { label: "Не проверено", color: "var(--n-4)" }, -}; - -const PERIOD_LABEL: Record = { 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; diff --git a/apps/internal-ui/src/features/command/center/styles.css b/apps/internal-ui/src/features/command/center/styles.css deleted file mode 100644 index 8f2c88d..0000000 --- a/apps/internal-ui/src/features/command/center/styles.css +++ /dev/null @@ -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; -} diff --git a/apps/internal-ui/src/features/command/center/types.ts b/apps/internal-ui/src/features/command/center/types.ts deleted file mode 100644 index 482fd4f..0000000 --- a/apps/internal-ui/src/features/command/center/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type CommandPeriod = "today" | "d7" | "d30"; - -export type MetricItem = { - label: string; - value: string; - color?: string; - dot?: string; -}; diff --git a/apps/internal-ui/src/features/support/SupportOverviewPage.tsx b/apps/internal-ui/src/features/support/SupportOverviewPage.tsx deleted file mode 100644 index 44e4f3b..0000000 --- a/apps/internal-ui/src/features/support/SupportOverviewPage.tsx +++ /dev/null @@ -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 ( -
-
-

Обзор отдела поддержки

-

Операционное состояние обращений клиентов продуктов

-
- -
- ); -} diff --git a/apps/internal-ui/src/layout/Shell.tsx b/apps/internal-ui/src/layout/Shell.tsx index f9fcd83..5c4fa08 100644 --- a/apps/internal-ui/src/layout/Shell.tsx +++ b/apps/internal-ui/src/layout/Shell.tsx @@ -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"; diff --git a/apps/internal-ui/src/layout/ShellRouteContent.tsx b/apps/internal-ui/src/layout/ShellRouteContent.tsx index 7dd3ca3..2a4b739 100644 --- a/apps/internal-ui/src/layout/ShellRouteContent.tsx +++ b/apps/internal-ui/src/layout/ShellRouteContent.tsx @@ -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" && } {route === "employees" && } {route === "employeeDetail" && currentEmployee && } {route === "employeeDetail" && !currentEmployee && } @@ -45,7 +42,6 @@ export function ShellRouteContent({ chatScope, setChatScope, chatCounters, chatS )} {route === "salesClients" && } {route === "salesClientDetail" && } - {route === "supportOverview" && } {route === "chat" && ( 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 (
- + / {route === "employeeDetail" && <>/{currentEmployee?.fullName || currentEmployee?.email || routes[route]}} {route === "agentDetail" && <>/{currentAgentName || routes[route]}} {route === "salesClientDetail" && <>/{routes[route]}} - {isSupportWorkspace && route !== "supportPortalDetail" && <>/{routes[route]}} - {route === "supportPortalDetail" && <>//{routes[route]}} + {route === "supportPortalDetail" && <>/{routes[route]}} {isAi && <>/{routes[route]}} {route === "aiKnowledgeDetail" && <>//{currentAgentName || routes[route]}} {route === "aiKnowledgeCreate" && <>//{routes[route]}} - {route !== "employeeDetail" && route !== "agentDetail" && route !== "supportPortalDetail" && route !== "salesClientDetail" && !isSupportWorkspace && !isAi && !isAiDetail && {routes[route]}} + {route !== "employeeDetail" && route !== "agentDetail" && route !== "supportPortalDetail" && route !== "salesClientDetail" && !isAi && !isAiDetail && {routes[route]}}
diff --git a/apps/internal-ui/src/router.test.ts b/apps/internal-ui/src/router.test.ts index 3ffcf8e..6073eb8 100644 --- a/apps/internal-ui/src/router.test.ts +++ b/apps/internal-ui/src/router.test.ts @@ -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 }); }); }); diff --git a/apps/internal-ui/src/router.ts b/apps/internal-ui/src/router.ts index 41fd068..d6681c6 100644 --- a/apps/internal-ui/src/router.ts +++ b/apps/internal-ui/src/router.ts @@ -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`; diff --git a/apps/internal-ui/src/routes.ts b/apps/internal-ui/src/routes.ts index 07199fd..d1c5f6a 100644 --- a/apps/internal-ui/src/routes.ts +++ b/apps/internal-ui/src/routes.ts @@ -2,7 +2,6 @@ import type { RouteKey } from "./types"; export const routes: Record = { administrationAudit: "Аудит", - command: "Обзор", employeeDetail: "Сотрудники", employees: "Сотрудники", profile: "Профиль", @@ -10,7 +9,6 @@ export const routes: Record = { salesClientDetail: "Контакт", salesClients: "Контакты", chat: "Чат", - supportOverview: "Обзор", supportPortals: "Порталы", supportPortalDetail: "Портал поддержки", agents: "Агенты", diff --git a/apps/internal-ui/src/styles.css b/apps/internal-ui/src/styles.css index 5858acd..8c169b1 100644 --- a/apps/internal-ui/src/styles.css +++ b/apps/internal-ui/src/styles.css @@ -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"; diff --git a/apps/internal-ui/src/types.ts b/apps/internal-ui/src/types.ts index cecbff9..8fb02ad 100644 --- a/apps/internal-ui/src/types.ts +++ b/apps/internal-ui/src/types.ts @@ -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[];