feat(ui): раздел Контакты, статусы Лид/Клиент, фиксы бейджа и запроса контакта

- Клиенты → Контакты: сайдбар, крошки, роуты, тексты detail-страницы
- таблица: колонка Статус (Лид/Клиент — из оплаченных заказов), реальные
  телефон и @логин вместо заглушек, фильтры Лиды/Клиенты, поиск по
  имени/телефону/логину, реальные счётчики вместо фейковых 248
- сайдбар компании: группа «Инструменты» с неактивной «Доской»
- fix: бейдж «Диалоги» считает только очередь (PAUSED) — взятые оператором
  диалоги без ответа больше не подсвечиваются
- fix: состояние «Контакт запрошен» сбрасывается при смене диалога

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Fable 5 committed 2026-07-10 23:56:24 +03:00
1 parent b3e7d9e60e
commit 2e542a4a49
20 files changed
+91 -41

No files matched your search

@@ -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,
@@ -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(),
@@ -11,15 +11,15 @@ export function SalesClientsPage({ openClient }: { openClient: (id: number) => v
return (
<>
<SalesClientsHeader shownCount={clients.rows.length} />
<SalesClientsHeader shownCount={clients.rows.length} totalCount={data.length} />
{loading ? (
<LoadingState />
) : error ? (
<EmptyState title="Не удалось загрузить клиентов" />
<EmptyState title="Не удалось загрузить контакты" />
) : (
<>
<SalesClientsFilters clients={clients} />
<SalesClientsTable clients={clients} openClient={openClient} />
<SalesClientsTable clients={clients} openClient={openClient} totalCount={data.length} />
</>
)}
</>
@@ -18,7 +18,7 @@ export function SalesClientDetailPage({ contactId, setRoute }: { contactId: numb
const { client, loading, error } = useClientDetail(contactId);
if (loading) return <LoadingState />;
if (error || !client) return <EmptyState title="Не удалось загрузить клиента" />;
if (error || !client) return <EmptyState title="Не удалось загрузить контакт" />;
return (
<>
@@ -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 <EmptyState title="У клиента ещё нет диалогов" />;
if (dialogs.length === 0) return <EmptyState title="У контакта ещё нет диалогов" />;
return (
<div className="sales-client-list-card">
{dialogs.map((dialog) => (
@@ -30,7 +30,7 @@ export function SalesClientHeader({ client, setRoute }: { client: ClientDetailVm
</div>
<div className="sales-client-detail-actions">
<Button className="sales-client-primary" icon="message" variant="primary" onClick={() => setRoute("salesDialogs")}>Открыть диалог</Button>
<button className={`sales-client-more ${menuOpen ? "active" : ""}`} type="button" aria-label="Действия клиента" onClick={() => setMenuOpen((value) => !value)}><Icon name="more" size={18} /></button>
<button className={`sales-client-more ${menuOpen ? "active" : ""}`} type="button" aria-label="Действия контакта" onClick={() => setMenuOpen((value) => !value)}><Icon name="more" size={18} /></button>
{menuOpen && <SalesClientMenu />}
</div>
</div>
@@ -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 <EmptyState title="У клиента ещё нет заказов" />;
if (orders.length === 0) return <EmptyState title="У контакта ещё нет заказов" />;
return (
<div className="sales-client-table-card">
<table className="sales-client-detail-table">
@@ -25,8 +25,11 @@ export function SalesClientRow({ client, menu, openClient, setMenu }: { client:
</div>
</td>
<td>
<div className={`sales-client-contact ${client.anon ? "muted" : ""}`}>{client.email}</div>
<small className="sales-client-phone">{client.phone}</small>
<span className="sales-client-status" style={{ background: client.statusMeta.bg, color: client.statusMeta.color }}>{client.statusMeta.label}</span>
</td>
<td>
<div className={`sales-client-contact ${client.phone ? "" : "muted"}`}>{client.phone || "—"}</div>
<small className="sales-client-phone">{client.username ? `@${client.username}` : ""}</small>
</td>
<td>
<div className="sales-client-tags">
@@ -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 (
<div className="sales-clients-filterbar">
<SearchInput className="sales-clients-search" value={clients.query} onChange={clients.setQuery} placeholder="Поиск по имени или email…" />
<SearchInput className="sales-clients-search" value={clients.query} onChange={clients.setQuery} placeholder="Поиск по имени, телефону или логину…" />
{clients.dropdown !== null && <button className="sales-clients-dd-scrim" type="button" aria-label="Закрыть фильтр" onClick={clients.closeDropdown} />}
<SalesFilterDropdown
active={clients.productFilter.length > 0 || clients.dropdown === "products"}
@@ -29,7 +29,8 @@ export function SalesClientsFilters({ clients }: { clients: SalesClientsState })
onToggleOption={(code) => clients.toggleChannel(code)}
/>
<button className={`sales-clients-chip ${clients.openOnly ? "active" : ""}`} type="button" onClick={clients.toggleOpenOnly}><span />С открытым диалогом</button>
<button className={`sales-clients-chip ${clients.buyerOnly ? "active" : ""}`} type="button" onClick={clients.toggleBuyerOnly}><Icon name="check" size={13} />Есть покупка</button>
<button className={`sales-clients-chip ${clients.statusFilter === "lead" ? "active" : ""}`} type="button" onClick={() => clients.toggleStatus("lead")}><span style={{ background: statusMap.lead.color }} />Лиды</button>
<button className={`sales-clients-chip ${clients.statusFilter === "client" ? "active" : ""}`} type="button" onClick={() => clients.toggleStatus("client")}><span style={{ background: statusMap.client.color }} />Клиенты</button>
<div className="sales-clients-filter-spacer" />
<button className="sales-clients-reset" type="button" onClick={clients.reset}>Сбросить</button>
</div>
@@ -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 (
<div className="sales-clients-header">
<div>
<h1>Клиенты</h1>
<p>Контакты отдела продаж · показано <b>{shownCount}</b> из 248</p>
<h1>Контакты</h1>
<p>Лиды и клиенты отдела продаж · показано <b>{shownCount}</b> из {totalCount}</p>
</div>
<div className="sales-clients-header-actions">
<ActionButton icon="columns">Столбцы</ActionButton>
@@ -1,5 +1,5 @@
import { TablePagination } from "../../../shared/ui-controls";
export function SalesClientsPagination({ shownCount }: { shownCount: number }) {
return <TablePagination className="sales-clients-pagination" shown={shownCount} total={248} pages={[1, 2, 3, "ellipsis", 25]} pageSizeLabel="10 / стр." />;
export function SalesClientsPagination({ shownCount, totalCount }: { shownCount: number; totalCount: number }) {
return <TablePagination className="sales-clients-pagination" shown={shownCount} total={totalCount} pages={[1]} pageSizeLabel={`${Math.max(shownCount, 1)} / стр.`} />;
}
@@ -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 && <button className="sales-clients-menu-scrim" type="button" aria-label="Закрыть меню" onClick={() => clients.setMenu(null)} />}
@@ -13,8 +13,9 @@ export function SalesClientsTable({ clients, openClient }: { clients: SalesClien
<table className="sales-clients-table">
<thead>
<tr>
<th>КЛИЕНТ</th>
<th>КОНТАКТЫ</th>
<th>КОНТАКТ</th>
<th>СТАТУС</th>
<th>ТЕЛЕФОН / ЛОГИН</th>
<th>КАНАЛЫ</th>
<th>ПРОДУКТЫ</th>
<SortableTh label="ПОСЛ. ДИАЛОГ" sortKey="last" clients={clients} />
@@ -30,7 +31,7 @@ export function SalesClientsTable({ clients, openClient }: { clients: SalesClien
</table>
</div>
{clients.rows.length === 0 && <SalesClientsEmpty />}
<SalesClientsPagination shownCount={clients.rows.length} />
<SalesClientsPagination shownCount={clients.rows.length} totalCount={totalCount} />
</div>
</>
);
@@ -48,7 +49,7 @@ function SalesClientsEmpty() {
return (
<div className="sales-clients-empty">
<div><Icon name="search" size={22} /></div>
<strong>Клиенты не найдены</strong>
<strong>Контакты не найдены</strong>
<span>Измените условия фильтра или сбросьте их.</span>
</div>
);
@@ -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<SalesClient, "channels" | "products"> & {
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<ClientStatus, { label: string; color: string; bg: string }>;
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<SalesClient["mode"], string>;
// Реальный клиент с бэкенда (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, " ")}`,
};
}
@@ -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;
@@ -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<typeof useSalesClients>;
@@ -10,7 +10,8 @@ export function useSalesClients(salesClients: SalesClient[]) {
const [productFilter, setProductFilter] = useState<ClientProductCode[]>([]);
const [channelFilter, setChannelFilter] = useState<ClientChannelCode[]>([]);
const [openOnly, setOpenOnly] = useState(false);
const [buyerOnly, setBuyerOnly] = useState(false);
// Фильтр по статусу контакта: null — все, иначе только лиды/клиенты.
const [statusFilter, setStatusFilter] = useState<ClientStatus | null>(null);
const [sortKey, setSortKey] = useState<ClientSortKey>("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<ClientSortKey, keyof typeof salesClients[number]>;
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,
@@ -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 <div className="sales-client-context"><p className="sales-context-muted">Выберите диалог</p></div>;
}
+1 -1
View File
@@ -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 (
+2
View File
@@ -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 },
+1 -1
View File
@@ -18,7 +18,7 @@ export function TopBar({ route, user, currentEmployee, currentProduct, currentAg
{route === "employeeDetail" && <><button type="button" onClick={() => setRoute("employees")}>Сотрудники</button><i>/</i><strong>{currentEmployee?.fullName || currentEmployee?.email || routes[route]}</strong></>}
{route === "productDetail" && <><button type="button" onClick={() => setRoute("products")}>Продукты</button><i>/</i><strong>{currentProduct?.name || routes[route]}</strong></>}
{isSalesWorkspace && route !== "salesClientDetail" && route !== "salesOrderDetail" && <><button type="button" onClick={() => setRoute("salesOverview")}>Продажи</button><i>/</i><strong>{routes[route]}</strong></>}
{route === "salesClientDetail" && <><button type="button" onClick={() => setRoute("salesOverview")}>Продажи</button><i>/</i><button type="button" onClick={() => setRoute("salesClients")}>Клиенты</button><i>/</i><strong>{routes[route]}</strong></>}
{route === "salesClientDetail" && <><button type="button" onClick={() => setRoute("salesOverview")}>Продажи</button><i>/</i><button type="button" onClick={() => setRoute("salesClients")}>Контакты</button><i>/</i><strong>{routes[route]}</strong></>}
{route === "salesOrderDetail" && <><button type="button" onClick={() => setRoute("salesOverview")}>Продажи</button><i>/</i><button type="button" onClick={() => setRoute("salesOrders")}>Продажи</button><i>/</i><strong className="topbar-mono">{routes[route]}</strong></>}
{isSupportWorkspace && <><button type="button" onClick={() => setRoute("supportOverview")}>Поддержка</button><i>/</i><strong>{routes[route]}</strong></>}
{isAi && <><button type="button" onClick={() => setRoute("aiAgents")}>AI</button><i>/</i><strong>{routes[route]}</strong></>}
+2 -2
View File
@@ -8,8 +8,8 @@ export const routes: Record<RouteKey, string> = {
productDetail: "Продукты",
products: "Продукты",
profile: "Профиль",
salesClientDetail: "Клиент",
salesClients: "Клиенты",
salesClientDetail: "Контакт",
salesClients: "Контакты",
salesDialogs: "Диалоги",
salesOrderDetail: "Заказ",
salesOrders: "Продажи",