diff --git a/apps/backend/hub_platform/conversations/clients.py b/apps/backend/hub_platform/conversations/clients.py index 0c8b1ed..44f0a1d 100644 --- a/apps/backend/hub_platform/conversations/clients.py +++ b/apps/backend/hub_platform/conversations/clients.py @@ -47,7 +47,8 @@ def clients_overview(organization_id: int) -> list[dict]: Prefetch( "conversations", queryset=Conversation.objects.select_related("channel", "channel__product", "connection").order_by("-last_activity_at"), - ) + ), + "identities", ) paid_by_contact = { row["contact_id"]: row @@ -78,6 +79,11 @@ def clients_overview(organization_id: int) -> list[dict]: "id": contact.id, "cid": f"CUS-{contact.id}", "name": contact.name or "Гость", + "phone": contact.phone, + # Первый непустой @логин среди identity каналов (остальные — в карточке). + "username": next((identity.username for identity in contact.identities.all() if identity.username), ""), + # Статус выводится из данных: есть оплаченный заказ — клиент, иначе лид. + "status": "client" if paid else "lead", "channels": sorted(channels), "products": sorted(products), "openDialogs": open_dialogs, diff --git a/apps/backend/hub_platform/conversations/stats.py b/apps/backend/hub_platform/conversations/stats.py index 7647825..9416858 100644 --- a/apps/backend/hub_platform/conversations/stats.py +++ b/apps/backend/hub_platform/conversations/stats.py @@ -73,7 +73,10 @@ def sales_overview_stats(organization_id: int, period: str) -> dict: open_qs = Conversation.objects.filter(organization_id=organization_id, lifecycle=LifecycleState.OPEN) open_dialogs = open_qs.count() - waiting = open_qs.filter(expected_responder=ExpectedResponder.OPERATOR).count() + # «Ждут оператора» = очередь: диалоги, которые никто не взял (PAUSED). + # Взятые оператором (HUMAN), но ещё без ответа, очередью не считаются — + # иначе бейдж «Диалоги» показывает число при полностью разобранном inbox. + waiting = open_qs.filter(control_mode=ControlMode.PAUSED).count() ops = { "openDialogs": open_dialogs, "activeNow": open_qs.filter(last_activity_at__gte=now - _ACTIVE_WINDOW).count(), diff --git a/apps/internal-ui/src/features/sales/SalesClientsPage.tsx b/apps/internal-ui/src/features/sales/SalesClientsPage.tsx index 2a3640a..b22851f 100644 --- a/apps/internal-ui/src/features/sales/SalesClientsPage.tsx +++ b/apps/internal-ui/src/features/sales/SalesClientsPage.tsx @@ -11,15 +11,15 @@ export function SalesClientsPage({ openClient }: { openClient: (id: number) => v return ( <> - + {loading ? ( ) : error ? ( - + ) : ( <> - + )} diff --git a/apps/internal-ui/src/features/sales/client-detail/SalesClientDetailPage.tsx b/apps/internal-ui/src/features/sales/client-detail/SalesClientDetailPage.tsx index a10c600..d95526d 100644 --- a/apps/internal-ui/src/features/sales/client-detail/SalesClientDetailPage.tsx +++ b/apps/internal-ui/src/features/sales/client-detail/SalesClientDetailPage.tsx @@ -18,7 +18,7 @@ export function SalesClientDetailPage({ contactId, setRoute }: { contactId: numb const { client, loading, error } = useClientDetail(contactId); if (loading) return ; - if (error || !client) return ; + if (error || !client) return ; return ( <> diff --git a/apps/internal-ui/src/features/sales/client-detail/SalesClientDialogsTab.tsx b/apps/internal-ui/src/features/sales/client-detail/SalesClientDialogsTab.tsx index ae1103e..bbaea33 100644 --- a/apps/internal-ui/src/features/sales/client-detail/SalesClientDialogsTab.tsx +++ b/apps/internal-ui/src/features/sales/client-detail/SalesClientDialogsTab.tsx @@ -3,7 +3,7 @@ import type { RouteKey } from "../../../types"; import type { ClientDetailVm } from "./model"; export function SalesClientDialogsTab({ dialogs, setRoute }: { dialogs: ClientDetailVm["dialogs"]; setRoute: (route: RouteKey) => void }) { - if (dialogs.length === 0) return ; + if (dialogs.length === 0) return ; return (
{dialogs.map((dialog) => ( diff --git a/apps/internal-ui/src/features/sales/client-detail/SalesClientHeader.tsx b/apps/internal-ui/src/features/sales/client-detail/SalesClientHeader.tsx index 0def6da..7b3c708 100644 --- a/apps/internal-ui/src/features/sales/client-detail/SalesClientHeader.tsx +++ b/apps/internal-ui/src/features/sales/client-detail/SalesClientHeader.tsx @@ -30,7 +30,7 @@ export function SalesClientHeader({ client, setRoute }: { client: ClientDetailVm
- + {menuOpen && }
diff --git a/apps/internal-ui/src/features/sales/client-detail/SalesClientOrdersTab.tsx b/apps/internal-ui/src/features/sales/client-detail/SalesClientOrdersTab.tsx index af15ff4..ab9bddb 100644 --- a/apps/internal-ui/src/features/sales/client-detail/SalesClientOrdersTab.tsx +++ b/apps/internal-ui/src/features/sales/client-detail/SalesClientOrdersTab.tsx @@ -2,7 +2,7 @@ import { EmptyState } from "../../../shared/ui"; import type { ClientDetailVm } from "./model"; export function SalesClientOrdersTab({ orders }: { orders: ClientDetailVm["orders"] }) { - if (orders.length === 0) return ; + if (orders.length === 0) return ; return (
diff --git a/apps/internal-ui/src/features/sales/clients/SalesClientRow.tsx b/apps/internal-ui/src/features/sales/clients/SalesClientRow.tsx index 51515b6..afe6751 100644 --- a/apps/internal-ui/src/features/sales/clients/SalesClientRow.tsx +++ b/apps/internal-ui/src/features/sales/clients/SalesClientRow.tsx @@ -25,8 +25,11 @@ export function SalesClientRow({ client, menu, openClient, setMenu }: { client: +
-
{client.email}
- {client.phone} + {client.statusMeta.label} +
+
{client.phone || "—"}
+ {client.username ? `@${client.username}` : ""}
diff --git a/apps/internal-ui/src/features/sales/clients/SalesClientsFilters.tsx b/apps/internal-ui/src/features/sales/clients/SalesClientsFilters.tsx index a225bf9..4387096 100644 --- a/apps/internal-ui/src/features/sales/clients/SalesClientsFilters.tsx +++ b/apps/internal-ui/src/features/sales/clients/SalesClientsFilters.tsx @@ -1,12 +1,12 @@ import { Icon } from "../../../shared/icons"; import { SearchInput } from "../../../shared/ui-controls"; -import { channelOptions, productOptions } from "./model"; +import { channelOptions, productOptions, statusMap } from "./model"; import type { SalesClientsState } from "./useSalesClients"; export function SalesClientsFilters({ clients }: { clients: SalesClientsState }) { return (
- + {clients.dropdown !== null && - + +
diff --git a/apps/internal-ui/src/features/sales/clients/SalesClientsHeader.tsx b/apps/internal-ui/src/features/sales/clients/SalesClientsHeader.tsx index e577c34..a614dc3 100644 --- a/apps/internal-ui/src/features/sales/clients/SalesClientsHeader.tsx +++ b/apps/internal-ui/src/features/sales/clients/SalesClientsHeader.tsx @@ -1,11 +1,11 @@ import { ActionButton } from "../../../shared/ui-controls"; -export function SalesClientsHeader({ shownCount }: { shownCount: number }) { +export function SalesClientsHeader({ shownCount, totalCount }: { shownCount: number; totalCount: number }) { return (
-

Клиенты

-

Контакты отдела продаж · показано {shownCount} из 248

+

Контакты

+

Лиды и клиенты отдела продаж · показано {shownCount} из {totalCount}

Столбцы diff --git a/apps/internal-ui/src/features/sales/clients/SalesClientsPagination.tsx b/apps/internal-ui/src/features/sales/clients/SalesClientsPagination.tsx index 701970f..4ced827 100644 --- a/apps/internal-ui/src/features/sales/clients/SalesClientsPagination.tsx +++ b/apps/internal-ui/src/features/sales/clients/SalesClientsPagination.tsx @@ -1,5 +1,5 @@ import { TablePagination } from "../../../shared/ui-controls"; -export function SalesClientsPagination({ shownCount }: { shownCount: number }) { - return ; +export function SalesClientsPagination({ shownCount, totalCount }: { shownCount: number; totalCount: number }) { + return ; } diff --git a/apps/internal-ui/src/features/sales/clients/SalesClientsTable.tsx b/apps/internal-ui/src/features/sales/clients/SalesClientsTable.tsx index 092ec62..8fea067 100644 --- a/apps/internal-ui/src/features/sales/clients/SalesClientsTable.tsx +++ b/apps/internal-ui/src/features/sales/clients/SalesClientsTable.tsx @@ -4,7 +4,7 @@ import { SalesClientsPagination } from "./SalesClientsPagination"; import type { ClientSortKey } from "./model"; import type { SalesClientsState } from "./useSalesClients"; -export function SalesClientsTable({ clients, openClient }: { clients: SalesClientsState; openClient: (id: number) => void }) { +export function SalesClientsTable({ clients, openClient, totalCount }: { clients: SalesClientsState; openClient: (id: number) => void; totalCount: number }) { return ( <> {clients.menu &&
{clients.rows.length === 0 && } - +
); @@ -48,7 +49,7 @@ function SalesClientsEmpty() { return (
- Клиенты не найдены + Контакты не найдены Измените условия фильтра или сбросьте их.
); diff --git a/apps/internal-ui/src/features/sales/clients/model.ts b/apps/internal-ui/src/features/sales/clients/model.ts index e9ad7ae..7a82eff 100644 --- a/apps/internal-ui/src/features/sales/clients/model.ts +++ b/apps/internal-ui/src/features/sales/clients/model.ts @@ -3,14 +3,17 @@ export type ClientChannelCode = "MAX" | "TG" | "WEB"; export type ClientSortKey = "last" | "open" | "orders" | "total"; export type ClientDropdown = "products" | "channels"; +export type ClientStatus = "lead" | "client"; + export type SalesClient = { id: number; name: string; initials: string; avatarBg: string; cid: string; - email: string; phone: string; + username: string; + status: ClientStatus; anon?: boolean; channels: ClientChannelCode[]; products: ClientProductCode[]; @@ -27,10 +30,17 @@ export type SalesClientRowVm = Omit & { openColor: string; totalColor: string; totalLabel: string; + statusMeta: { label: string; color: string; bg: string }; channels: Array<{ label: string; full: string; color: string; bg: string }>; products: Array<{ name: string; color: string; bg: string }>; }; +// Статус контакта: лид (писал, не покупал) / клиент (есть оплаченный заказ). +export const statusMap = { + lead: { label: "Лид", color: "#d48806", bg: "#fff7e6" }, + client: { label: "Клиент", color: "#389e0d", bg: "#f6ffed" }, +} satisfies Record; + export const channelMap = { MAX: { label: "MAX", full: "MAX", color: "#6b5be0", bg: "#f2f0ff" }, TG: { label: "TG", full: "Telegram", color: "#2f8fd0", bg: "#eaf6fd" }, @@ -60,11 +70,14 @@ const statusDot = { closed: "#bfbfbf", } satisfies Record; -// Реальный клиент с бэкенда (conversations/clients.py). +// Реальный контакт с бэкенда (conversations/clients.py). export type ApiClient = { id: number; cid: string; name: string; + phone: string; + username: string; + status: ClientStatus; channels: ClientChannelCode[]; products: ClientProductCode[]; openDialogs: number; @@ -106,8 +119,9 @@ export function toSalesClient(api: ApiClient): SalesClient { initials: initialsOf(api.name), avatarBg: isGuest ? "#8c8c8c" : avatarColor(api.cid), cid: api.cid, - email: isGuest ? "без контакта" : "—", - phone: "идентификация по каналу", + phone: api.phone, + username: api.username, + status: api.status, anon: isGuest, channels: api.channels, products: api.products, @@ -128,6 +142,7 @@ export function toSalesClientRow(client: SalesClient): SalesClientRowVm { lastDot: statusDot[client.mode], openColor: client.openDialogs > 0 ? "#d48806" : "#bfbfbf", totalColor: client.total > 0 ? "#262626" : "#bfbfbf", + statusMeta: statusMap[client.status], totalLabel: client.total === 0 ? "—" : `₽${client.total.toLocaleString("ru-RU").replace(/\u00a0/g, " ")}`, }; } diff --git a/apps/internal-ui/src/features/sales/clients/styles.css b/apps/internal-ui/src/features/sales/clients/styles.css index 664b14d..66a4374 100644 --- a/apps/internal-ui/src/features/sales/clients/styles.css +++ b/apps/internal-ui/src/features/sales/clients/styles.css @@ -348,6 +348,16 @@ font-size: 11.5px; } +/* Статус контакта: Лид / Клиент (цвета — statusMap в model.ts). */ +.sales-client-status { + display: inline-block; + padding: 3px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 600; + white-space: nowrap; +} + .sales-client-tags, .sales-client-products { display: flex; diff --git a/apps/internal-ui/src/features/sales/clients/useSalesClients.ts b/apps/internal-ui/src/features/sales/clients/useSalesClients.ts index 1116e2f..82bffa6 100644 --- a/apps/internal-ui/src/features/sales/clients/useSalesClients.ts +++ b/apps/internal-ui/src/features/sales/clients/useSalesClients.ts @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; -import type { ClientChannelCode, ClientDropdown, ClientProductCode, ClientSortKey, SalesClient } from "./model"; +import type { ClientChannelCode, ClientDropdown, ClientProductCode, ClientSortKey, ClientStatus, SalesClient } from "./model"; import { toSalesClientRow } from "./model"; export type SalesClientsState = ReturnType; @@ -10,7 +10,8 @@ export function useSalesClients(salesClients: SalesClient[]) { const [productFilter, setProductFilter] = useState([]); const [channelFilter, setChannelFilter] = useState([]); const [openOnly, setOpenOnly] = useState(false); - const [buyerOnly, setBuyerOnly] = useState(false); + // Фильтр по статусу контакта: null — все, иначе только лиды/клиенты. + const [statusFilter, setStatusFilter] = useState(null); const [sortKey, setSortKey] = useState("last"); const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); const [menu, setMenu] = useState<{ id: string; left: number; top: number } | null>(null); @@ -19,17 +20,17 @@ export function useSalesClients(salesClients: SalesClient[]) { const rows = useMemo(() => { const normalizedQuery = query.trim().toLowerCase(); const filtered = salesClients.filter((client) => { - return (!normalizedQuery || client.name.toLowerCase().includes(normalizedQuery) || client.email.toLowerCase().includes(normalizedQuery)) + return (!normalizedQuery || client.name.toLowerCase().includes(normalizedQuery) || client.phone.includes(normalizedQuery) || client.username.toLowerCase().includes(normalizedQuery)) && (productFilter.length === 0 || client.products.some((product) => productFilter.includes(product))) && (channelFilter.length === 0 || client.channels.some((channel) => channelFilter.includes(channel))) && (!openOnly || client.openDialogs > 0) - && (!buyerOnly || client.orders > 0); + && (statusFilter === null || client.status === statusFilter); }); const key = { last: "last", open: "openDialogs", orders: "orders", total: "total" } satisfies Record; return [...filtered] .sort((left, right) => sortDir === "asc" ? Number(left[key[sortKey]]) - Number(right[key[sortKey]]) : Number(right[key[sortKey]]) - Number(left[key[sortKey]])) .map(toSalesClientRow); - }, [salesClients, buyerOnly, channelFilter, openOnly, productFilter, query, sortDir, sortKey]); + }, [salesClients, statusFilter, channelFilter, openOnly, productFilter, query, sortDir, sortKey]); function setQuery(value: string) { setQueryState(value); @@ -60,7 +61,7 @@ export function useSalesClients(salesClients: SalesClient[]) { setProductFilter([]); setChannelFilter([]); setOpenOnly(false); - setBuyerOnly(false); + setStatusFilter(null); setMenu(null); setDropdown(null); } @@ -71,7 +72,7 @@ export function useSalesClients(salesClients: SalesClient[]) { productFilter, channelFilter, openOnly, - buyerOnly, + statusFilter, dropdown, menu, setMenu, @@ -81,7 +82,8 @@ export function useSalesClients(salesClients: SalesClient[]) { toggleDropdown, closeDropdown: () => setDropdown(null), toggleOpenOnly: () => { setOpenOnly((current) => !current); setMenu(null); setDropdown(null); }, - toggleBuyerOnly: () => { setBuyerOnly((current) => !current); setMenu(null); setDropdown(null); }, + // Повторный клик по активному статусу снимает фильтр (возврат к «все»). + toggleStatus: (status: ClientStatus) => { setStatusFilter((current) => (current === status ? null : status)); setMenu(null); setDropdown(null); }, sortBy, sortArrow: (key: ClientSortKey) => sortKey === key ? (sortDir === "asc" ? "↑" : "↓") : "", reset, diff --git a/apps/internal-ui/src/features/sales/dialogs/context/ClientContext.tsx b/apps/internal-ui/src/features/sales/dialogs/context/ClientContext.tsx index 1fc460e..527f9f8 100644 --- a/apps/internal-ui/src/features/sales/dialogs/context/ClientContext.tsx +++ b/apps/internal-ui/src/features/sales/dialogs/context/ClientContext.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { channelMeta } from "../../../conversations/data"; import { FieldRow } from "../../../conversations/FieldRow"; @@ -19,6 +19,13 @@ export function ClientContext({ dialog, detail }: { dialog: ConversationListItem const [justRequested, setJustRequested] = useState(false); const [requestError, setRequestError] = useState(false); + // Локальное состояние кнопки принадлежит конкретному диалогу — при переключении сбрасываем. + useEffect(() => { + setRequesting(false); + setJustRequested(false); + setRequestError(false); + }, [detail?.id]); + if (!dialog) { return

Выберите диалог

; } diff --git a/apps/internal-ui/src/layout/SalesSidebar.tsx b/apps/internal-ui/src/layout/SalesSidebar.tsx index e2a9ec6..ac5eb87 100644 --- a/apps/internal-ui/src/layout/SalesSidebar.tsx +++ b/apps/internal-ui/src/layout/SalesSidebar.tsx @@ -6,7 +6,7 @@ export function SalesSidebar({ route, user, setRoute, waitingCount = 0 }: { rout const nav = [ { key: "salesOverview" as const, label: "Обзор", icon: "grid" as const }, { key: "salesDialogs" as const, label: "Диалоги", icon: "message" as const, badge: waitingCount > 0 ? String(waitingCount) : undefined }, - { key: "salesClients" as const, label: "Клиенты", icon: "team" as const }, + { key: "salesClients" as const, label: "Контакты", icon: "team" as const }, { key: "salesOrders" as const, label: "Продажи", icon: "cart" as const }, ]; return ( diff --git a/apps/internal-ui/src/layout/Sidebar.tsx b/apps/internal-ui/src/layout/Sidebar.tsx index e75b137..d1315d8 100644 --- a/apps/internal-ui/src/layout/Sidebar.tsx +++ b/apps/internal-ui/src/layout/Sidebar.tsx @@ -9,6 +9,8 @@ export function Sidebar({ route, user, setRoute }: { route: RouteKey; user: Sess { key: "departments" as const, label: "Отделы", icon: "building" as const }, { key: "employees" as const, label: "Сотрудники", icon: "team" as const }, { key: "products" as const, label: "Продукты", icon: "box" as const }, + { label: "ИНСТРУМЕНТЫ", group: true }, + { label: "Доска", icon: "columns" as const, disabled: true }, { label: "ПЛАТФОРМА", group: true }, { key: "aiAgents" as const, label: "AI", icon: "robot" as const }, { key: "integrations" as const, label: "Интеграции", icon: "plug" as const }, diff --git a/apps/internal-ui/src/layout/TopBar.tsx b/apps/internal-ui/src/layout/TopBar.tsx index b5298ea..a8193b0 100644 --- a/apps/internal-ui/src/layout/TopBar.tsx +++ b/apps/internal-ui/src/layout/TopBar.tsx @@ -18,7 +18,7 @@ export function TopBar({ route, user, currentEmployee, currentProduct, currentAg {route === "employeeDetail" && <>/{currentEmployee?.fullName || currentEmployee?.email || routes[route]}} {route === "productDetail" && <>/{currentProduct?.name || routes[route]}} {isSalesWorkspace && route !== "salesClientDetail" && route !== "salesOrderDetail" && <>/{routes[route]}} - {route === "salesClientDetail" && <>//{routes[route]}} + {route === "salesClientDetail" && <>//{routes[route]}} {route === "salesOrderDetail" && <>//{routes[route]}} {isSupportWorkspace && <>/{routes[route]}} {isAi && <>/{routes[route]}} diff --git a/apps/internal-ui/src/routes.ts b/apps/internal-ui/src/routes.ts index 4ee7dcb..88418dd 100644 --- a/apps/internal-ui/src/routes.ts +++ b/apps/internal-ui/src/routes.ts @@ -8,8 +8,8 @@ export const routes: Record = { productDetail: "Продукты", products: "Продукты", profile: "Профиль", - salesClientDetail: "Клиент", - salesClients: "Клиенты", + salesClientDetail: "Контакт", + salesClients: "Контакты", salesDialogs: "Диалоги", salesOrderDetail: "Заказ", salesOrders: "Продажи",