Backoffice stage 2: chats (support + concierge, files)
- ChatThread per client/provider and channel; messages get files, internal notes, source (bot/mini app) and delivery errors; reply templates. Migration moves old concierge messages into threads. - Routing: subscribers write to the concierge, others to support; /support and /concierge in the client bot; staff can take, transfer, close and move threads. - Telegram: photos/documents from clients and providers, replies with the channel header, files sent from storage, notifications only to the assignee or the channel's staff, "Reply" into a specific thread. - New admin: /chats with list, conversation, notes, files, templates, client panel; "Message" button on the client card; dashboard counts threads awaiting reply. - Client mini app: in-app chat screen sharing the same conversation. - Old admin keeps working on the same data (notes hidden there). - Tests for routing, notes, assign/close/move, lists, recipients; separate test DB via TEST_DB_NAME; dev:chats-demo script. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d452824c62
commit
23ffb2bf1d
46 files changed
+4176
-144
No files matched your search
@@ -10,6 +10,7 @@ import { Clients } from './pages/Clients.tsx';
|
||||
import { ClientCard } from './pages/ClientCard.tsx';
|
||||
import { Team } from './pages/Team.tsx';
|
||||
import { Soon } from './pages/Soon.tsx';
|
||||
import { Chats } from './pages/Chats.tsx';
|
||||
|
||||
export type Role = 'SUPERADMIN' | 'SUPPORT' | 'CONCIERGE' | 'SALES';
|
||||
export interface Me {
|
||||
@@ -42,7 +43,7 @@ interface NavItem {
|
||||
const NAV: NavItem[] = [
|
||||
{ path: '/', key: 'nav.dashboard', icon: 'dashboard', roles: 'all' },
|
||||
{ path: '/clients', key: 'nav.clients', icon: 'clients', roles: ['SUPPORT', 'CONCIERGE'] },
|
||||
{ path: '/chats', key: 'nav.chats', icon: 'chats', stage: 2, roles: ['SUPPORT', 'CONCIERGE'], count: 'chats' },
|
||||
{ path: '/chats', key: 'nav.chats', icon: 'chats', roles: ['SUPPORT', 'CONCIERGE'], count: 'chats' },
|
||||
{ path: '/orders', key: 'nav.orders', icon: 'orders', stage: 3, roles: ['SUPPORT'], count: 'orders' },
|
||||
{ path: '/providers', key: 'nav.providers', icon: 'providers', stage: 3, roles: ['SUPPORT'], group: 'work' },
|
||||
{ path: '/agencies', key: 'nav.agencies', icon: 'agencies', stage: 4, roles: [], group: 'partners' },
|
||||
@@ -71,7 +72,10 @@ function Shell({ me }: { me: Me }) {
|
||||
const current = items.find((n) => n.path === path || (n.path !== '/' && path.startsWith(`${n.path}/`)));
|
||||
let page: ReactNode;
|
||||
const clientId = match('/clients/:id', path)?.id;
|
||||
const threadId = match('/chats/:id', path)?.id;
|
||||
const canChat = items.some((n) => n.path === '/chats');
|
||||
if (path === '/') page = <Dashboard me={me} />;
|
||||
else if ((path === '/chats' || threadId) && canChat) page = <Chats me={me} threadId={threadId} />;
|
||||
else if (path === '/clients' && current) page = <Clients me={me} />;
|
||||
else if (clientId && items.some((n) => n.path === '/clients')) page = <ClientCard id={clientId} me={me} />;
|
||||
else if (path === '/team' && isSuper) page = <Team me={me} />;
|
||||
@@ -82,7 +86,7 @@ function Shell({ me }: { me: Me }) {
|
||||
const tabItems = items.slice(0, 4);
|
||||
|
||||
return (
|
||||
<div className="bo-layout">
|
||||
<div className={`bo-layout${path.startsWith('/chats') ? ' bo-layout--chats' : ''}${threadId ? ' bo-layout--thread' : ''}`}>
|
||||
<aside className="bo-side">
|
||||
<div className="bo-side__logo">
|
||||
<Logo size={22} color="#f6e9dc" />
|
||||
|
||||
@@ -0,0 +1,789 @@
|
||||
/*
|
||||
* Чаты (этап 2). Три колонки: список диалогов · переписка · о клиенте.
|
||||
* Переписка — на кремовом фоне: клиент слева белым, команда справа сливовым, заметки — жёлтым «стикером».
|
||||
*/
|
||||
/* страница чатов занимает ровно экран: прокручиваются список и лента, а не страница */
|
||||
.bo-layout--chats .bo-main {
|
||||
padding-top: 20px;
|
||||
padding-bottom: 20px;
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.bo-layout--chats .bo-banner {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.bo-chat {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
min-height: 0;
|
||||
}
|
||||
.bo-chat--side {
|
||||
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr) 300px;
|
||||
}
|
||||
.bo-chat__list,
|
||||
.bo-chat__conv,
|
||||
.bo-chat__side {
|
||||
background: var(--bo-panel);
|
||||
border-radius: var(--bo-r-panel);
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bo-chat__listhead {
|
||||
padding: 18px 16px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--bc-line);
|
||||
}
|
||||
.bo-chat__title {
|
||||
font-size: 30px;
|
||||
}
|
||||
.bo-chtabs {
|
||||
display: flex;
|
||||
padding: 4px;
|
||||
gap: 4px;
|
||||
border-radius: var(--bc-pill);
|
||||
background: var(--bo-soft);
|
||||
}
|
||||
.bo-chtabs button {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
height: 34px;
|
||||
border: 0;
|
||||
border-radius: var(--bc-pill);
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--bc-ink-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.bo-chtabs button[aria-pressed='true'] {
|
||||
background: #fff;
|
||||
color: var(--bc-ink);
|
||||
box-shadow: 0 2px 8px rgba(30, 27, 26, 0.08);
|
||||
}
|
||||
.bo-chat__dot {
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: var(--bc-pill);
|
||||
background: var(--bc-accent);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
}
|
||||
.bo-chat__search {
|
||||
flex: none;
|
||||
max-width: none;
|
||||
}
|
||||
.bo-chat__search .bo-input {
|
||||
background: var(--bo-soft);
|
||||
min-height: 40px;
|
||||
}
|
||||
.bo-chat__filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.bo-chat__filters .bo-pill {
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
}
|
||||
.bo-chat__pillnum {
|
||||
font-weight: 700;
|
||||
color: var(--bc-accent);
|
||||
}
|
||||
.bo-pill[aria-pressed='true'] .bo-chat__pillnum {
|
||||
color: var(--bc-pink);
|
||||
}
|
||||
.bo-chat__items {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 6px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.bo-chat__none {
|
||||
padding: 32px 20px;
|
||||
text-align: center;
|
||||
color: var(--bc-ink-2);
|
||||
}
|
||||
.bo-thread {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px 10px;
|
||||
border-radius: 18px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.bo-thread:hover {
|
||||
background: var(--bo-soft);
|
||||
}
|
||||
.bo-thread[aria-current='true'] {
|
||||
background: var(--bo-soft);
|
||||
box-shadow: inset 3px 0 0 var(--bc-accent);
|
||||
}
|
||||
.bo-thread[data-closed] {
|
||||
opacity: 0.62;
|
||||
}
|
||||
.bo-thread__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.bo-thread__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.bo-thread__top .bo-plan {
|
||||
height: 20px;
|
||||
padding: 0 7px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.bo-thread__name {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
.bo-thread__time {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--bc-ink-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bo-thread__last {
|
||||
font-size: 14px;
|
||||
color: var(--bc-ink-2);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.bo-thread__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 3px;
|
||||
min-height: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
.bo-thread__meta .bo-nav__count {
|
||||
margin-left: 0;
|
||||
flex: none;
|
||||
}
|
||||
.bo-thread__who {
|
||||
font-size: 12px;
|
||||
color: var(--bc-ink-2);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
.bo-thread__who--none {
|
||||
color: var(--bc-ink-3);
|
||||
}
|
||||
.bo-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 20px;
|
||||
padding: 0 8px;
|
||||
border-radius: 7px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
background: var(--bo-soft);
|
||||
color: var(--bc-ink-2);
|
||||
}
|
||||
.bo-chip--SUPPORT {
|
||||
background: var(--bc-blue);
|
||||
color: var(--bc-blue-ink);
|
||||
}
|
||||
.bo-chip--CONCIERGE {
|
||||
background: var(--bc-pink);
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
.bo-chip--provider {
|
||||
background: var(--bc-docs);
|
||||
color: var(--bc-docs-ink);
|
||||
}
|
||||
.bo-wait-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
padding: 0 8px;
|
||||
border-radius: var(--bc-pill);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bo-wait-chip--calm {
|
||||
background: var(--bc-home);
|
||||
color: var(--bc-home-ink);
|
||||
}
|
||||
.bo-wait-chip--warm {
|
||||
background: var(--bc-perks);
|
||||
color: var(--bc-perks-ink);
|
||||
}
|
||||
.bo-wait-chip--hot {
|
||||
background: var(--bc-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* пустое состояние справа */
|
||||
.bo-chat__pick {
|
||||
margin: auto;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.bo-chat__pickicon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 22px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--bc-pink);
|
||||
color: var(--bc-plum);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* шапка переписки */
|
||||
.bo-chat__conv {
|
||||
container-type: inline-size;
|
||||
}
|
||||
.bo-conv__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px 12px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--bc-line);
|
||||
}
|
||||
@container (max-width: 640px) {
|
||||
.bo-composer__hint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.bo-conv__back {
|
||||
display: none;
|
||||
color: var(--bc-ink);
|
||||
}
|
||||
.bo-conv__who {
|
||||
min-width: 0;
|
||||
flex: 1 1 180px;
|
||||
}
|
||||
.bo-conv__name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
.bo-conv__name > span:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.bo-conv__sub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 10px;
|
||||
font-size: 13px;
|
||||
color: var(--bc-ink-3);
|
||||
margin-top: 3px;
|
||||
}
|
||||
.bo-conv__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.bo-iconbtn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 12px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--bc-ink-2);
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
flex: none;
|
||||
}
|
||||
.bo-iconbtn:hover:not(:disabled),
|
||||
.bo-iconbtn[aria-expanded='true'],
|
||||
.bo-iconbtn[aria-pressed='true'] {
|
||||
background: var(--bo-soft);
|
||||
color: var(--bc-ink);
|
||||
}
|
||||
.bo-iconbtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.bo-assignee {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 34px;
|
||||
padding: 0 12px 0 4px;
|
||||
border-radius: var(--bc-pill);
|
||||
border: 1.5px solid var(--bc-line);
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bo-assignee .bo-avatar {
|
||||
font-size: 10px;
|
||||
}
|
||||
.bo-conv__banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
background: var(--bc-blue);
|
||||
color: var(--bc-blue-ink);
|
||||
}
|
||||
.bo-conv__banner--bot {
|
||||
background: var(--bc-learn);
|
||||
color: var(--bc-learn-ink);
|
||||
}
|
||||
.bo-conv__closed {
|
||||
padding: 8px 18px;
|
||||
font-size: 13px;
|
||||
color: var(--bc-ink-2);
|
||||
background: var(--bc-bg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* лента сообщений */
|
||||
.bo-msgs {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: var(--bc-bg);
|
||||
padding: 16px clamp(12px, 3vw, 28px) 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.bo-msgs__day {
|
||||
align-self: center;
|
||||
margin: 12px 0 6px;
|
||||
padding: 3px 12px;
|
||||
border-radius: var(--bc-pill);
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--bc-ink-3);
|
||||
}
|
||||
.bo-msg {
|
||||
max-width: min(560px, 82%);
|
||||
padding: 10px 14px 8px;
|
||||
border-radius: 20px;
|
||||
font-size: 15px;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.bo-msg--in {
|
||||
align-self: flex-start;
|
||||
background: #fff;
|
||||
border-bottom-left-radius: 6px;
|
||||
box-shadow: 0 1px 0 var(--bc-line);
|
||||
}
|
||||
.bo-msg--out {
|
||||
align-self: flex-end;
|
||||
background: var(--bc-plum);
|
||||
color: #f6e9dc;
|
||||
border-bottom-right-radius: 6px;
|
||||
}
|
||||
.bo-msg--note {
|
||||
align-self: flex-end;
|
||||
background: #fbefbd;
|
||||
color: var(--bc-perks-ink);
|
||||
border: 1.5px dashed rgba(74, 16, 40, 0.22);
|
||||
border-bottom-right-radius: 6px;
|
||||
}
|
||||
.bo-msg__text {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.bo-msg__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
margin-bottom: 4px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.bo-msg__meta {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
opacity: 0.6;
|
||||
text-align: right;
|
||||
}
|
||||
.bo-msg--in .bo-msg__meta {
|
||||
text-align: left;
|
||||
}
|
||||
.bo-msg__err {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: var(--bc-danger);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.bo-msg__img {
|
||||
display: block;
|
||||
margin: -4px -8px 6px;
|
||||
}
|
||||
.bo-msg__img img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 280px;
|
||||
border-radius: 14px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.bo-msg__file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px 8px 8px;
|
||||
margin: 0 -6px 6px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.bo-msg--in .bo-msg__file,
|
||||
.bo-msg--note .bo-msg__file {
|
||||
background: var(--bo-soft);
|
||||
}
|
||||
.bo-msg__file svg {
|
||||
flex: none;
|
||||
}
|
||||
.bo-msg__fname {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.bo-sys {
|
||||
align-self: center;
|
||||
font-size: 12px;
|
||||
color: var(--bc-ink-2);
|
||||
text-align: center;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
/* поле ответа */
|
||||
.bo-composer {
|
||||
padding: 8px 14px 14px;
|
||||
border-top: 1px solid var(--bc-line);
|
||||
background: #fff;
|
||||
}
|
||||
.bo-composer__tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.bo-composer__tabs [role='tab'] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: var(--bc-pill);
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--bc-ink-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
.bo-composer__tabs [role='tab'][aria-selected='true'] {
|
||||
background: var(--bc-ink);
|
||||
color: #fff;
|
||||
}
|
||||
.bo-composer--note .bo-composer__tabs [role='tab'][aria-selected='true'] {
|
||||
background: var(--bc-perks);
|
||||
color: var(--bc-perks-ink);
|
||||
}
|
||||
.bo-composer__tabs [role='tab']:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.bo-composer__hint {
|
||||
font-size: 12px;
|
||||
color: var(--bc-ink-3);
|
||||
}
|
||||
.bo-composer__box {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
padding: 6px 6px 6px 14px;
|
||||
border-radius: 22px;
|
||||
border: 1.5px solid var(--bc-line);
|
||||
background: #fff;
|
||||
}
|
||||
.bo-composer__box:focus-within {
|
||||
border-color: var(--bc-accent);
|
||||
}
|
||||
.bo-composer--note .bo-composer__box {
|
||||
background: #fdf6d8;
|
||||
border-style: dashed;
|
||||
border-color: rgba(74, 16, 40, 0.25);
|
||||
}
|
||||
.bo-composer__input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
resize: none;
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
line-height: 1.45;
|
||||
padding: 7px 0;
|
||||
max-height: 220px;
|
||||
}
|
||||
.bo-composer__tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* всплывающие меню */
|
||||
.bo-pop-anchor {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
.bo-pop {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
min-width: 220px;
|
||||
max-width: min(340px, 86vw);
|
||||
padding: 6px;
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
box-shadow:
|
||||
0 18px 50px rgba(30, 27, 26, 0.18),
|
||||
0 0 0 1px var(--bc-line);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.bo-pop--up {
|
||||
top: auto;
|
||||
bottom: calc(100% + 8px);
|
||||
}
|
||||
.bo-pop__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 40px;
|
||||
padding: 6px 12px;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
color: var(--bc-ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
.bo-pop__item:hover {
|
||||
background: var(--bo-soft);
|
||||
}
|
||||
.bo-pop__item[aria-current='true'] {
|
||||
color: var(--bc-ink-3);
|
||||
}
|
||||
.bo-tpl {
|
||||
width: 340px;
|
||||
padding: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
.bo-tpl__list {
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.bo-tpl__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px 10px;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: none;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
color: var(--bc-ink);
|
||||
}
|
||||
.bo-tpl__item span {
|
||||
font-size: 13px;
|
||||
color: var(--bc-ink-2);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bo-tpl__item:hover {
|
||||
background: var(--bo-soft);
|
||||
}
|
||||
|
||||
/* о клиенте */
|
||||
.bo-chat__side {
|
||||
padding: 16px 18px 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.bo-side-panel__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.bo-minipass {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 20px;
|
||||
background: var(--bo-soft);
|
||||
font-size: 13px;
|
||||
}
|
||||
.bo-minipass__plan {
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 700;
|
||||
font-size: 20px;
|
||||
}
|
||||
.bo-minipass--BAREV_PLUS {
|
||||
background: var(--bc-pink);
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
.bo-minipass--ELITE {
|
||||
background: var(--bc-plum);
|
||||
color: var(--bc-perks);
|
||||
}
|
||||
.bo-side-panel__note {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
background: #fbefbd;
|
||||
color: var(--bc-perks-ink);
|
||||
font-size: 13px;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.bo-side-panel__orders {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* узкий десктоп: «о клиенте» — выезжающая панель поверх */
|
||||
@media (max-width: 1559px) {
|
||||
.bo-chat--side {
|
||||
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
|
||||
}
|
||||
.bo-chat__side {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
width: min(340px, calc(100vw - 24px));
|
||||
z-index: 45;
|
||||
box-shadow: 0 30px 80px rgba(30, 27, 26, 0.28);
|
||||
}
|
||||
}
|
||||
|
||||
/* телефон: список и переписка — отдельными экранами */
|
||||
@media (max-width: 860px) {
|
||||
.bo-layout--chats .bo-main {
|
||||
padding: 12px 10px calc(96px + env(safe-area-inset-bottom));
|
||||
}
|
||||
.bo-layout--thread .bo-main {
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.bo-layout--thread .bo-tabbar,
|
||||
.bo-layout--thread .bo-banner {
|
||||
display: none;
|
||||
}
|
||||
.bo-chat,
|
||||
.bo-chat--side {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.bo-chat--thread .bo-chat__list,
|
||||
.bo-chat:not(.bo-chat--thread) .bo-chat__conv {
|
||||
display: none;
|
||||
}
|
||||
.bo-conv__back {
|
||||
display: inline-grid;
|
||||
}
|
||||
.bo-conv__head {
|
||||
padding: 10px 10px 10px 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
.bo-conv__head > .bo-avatar {
|
||||
display: none;
|
||||
}
|
||||
.bo-hide-sm {
|
||||
display: none;
|
||||
}
|
||||
.bo-msg {
|
||||
max-width: 88%;
|
||||
}
|
||||
.bo-composer {
|
||||
padding: 6px 8px 10px;
|
||||
}
|
||||
.bo-tpl {
|
||||
width: min(320px, calc(100vw - 40px));
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,16 @@ const PATHS = {
|
||||
arrowUp: 'M12 19V5M6 11l6-6 6 6',
|
||||
arrowDown: 'M12 5v14M6 13l6 6 6-6',
|
||||
external: 'M14 4h6v6M20 4l-9 9M18 14v6H4V6h6',
|
||||
clip: 'M20 11.5l-8.2 8.2a5 5 0 0 1-7.1-7.1l8.5-8.5a3.3 3.3 0 0 1 4.7 4.7l-8.5 8.5a1.7 1.7 0 0 1-2.4-2.4l7.8-7.8',
|
||||
bolt: 'M13 3L5 13.5h6L10 21l8-10.5h-6z',
|
||||
lock: 'M6 11h12v9H6zM8.5 11V8a3.5 3.5 0 0 1 7 0v3',
|
||||
file: 'M7 3h7l5 5v13H7zM14 3v5h5',
|
||||
info: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 11v5M12 7.5v.01',
|
||||
swap: 'M4 8h14l-3.5-3.5M20 16H6l3.5 3.5',
|
||||
userPlus: 'M10 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM3 21a7 7 0 0 1 12-4.9M19 14v6M16 17h6',
|
||||
archive: 'M4 5h16v4H4zM5 9v10h14V9M10 13h4',
|
||||
bot: 'M6 9h12v10H6zM12 5v4M9.5 13.5v.01M14.5 13.5v.01M9 17h6M3 13v2M21 13v2',
|
||||
warn: 'M12 3l10 18H2zM12 10v5M12 18v.01',
|
||||
} as const;
|
||||
export type IconName = keyof typeof PATHS;
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ const ru = {
|
||||
'dash.attention': 'Требует внимания',
|
||||
'dash.allClear': 'Срочного нет — всё разобрано.',
|
||||
'att.unassigned': 'Заказы без исполнителя',
|
||||
'att.unreadChats': 'Непрочитанные сообщения',
|
||||
'att.unreadChats': 'Ждут ответа в чатах',
|
||||
'att.webRequests': 'Заявки с сайта',
|
||||
'att.leads': 'Заявки на переезд',
|
||||
'att.appRequests': 'Заявки из приложения',
|
||||
@@ -298,6 +298,107 @@ const ru = {
|
||||
'team.disabled': 'Доступ отключён',
|
||||
'team.you': 'это вы',
|
||||
'team.saved': 'Роли сохранены',
|
||||
|
||||
// ───── чаты (этап 2) ─────
|
||||
'chat.title': 'Чаты',
|
||||
'chat.all': 'Все',
|
||||
'channel.SUPPORT': 'Поддержка',
|
||||
'channel.CONCIERGE': 'Консьерж',
|
||||
'chat.f.waiting': 'Ждут ответа',
|
||||
'chat.f.mine': 'Мои',
|
||||
'chat.f.unassigned': 'Без ответственного',
|
||||
'chat.f.open': 'Открытые',
|
||||
'chat.f.closed': 'Закрытые',
|
||||
'chat.f.all': 'Все',
|
||||
'chat.search': 'Имя, @username, телефон или текст',
|
||||
'chat.emptyList': 'Здесь пусто. Новые сообщения появятся сами.',
|
||||
'chat.emptyFilter': 'Ничего не нашли — попробуйте другой фильтр.',
|
||||
'chat.pick': 'Выберите диалог',
|
||||
'chat.pickHint': 'Сверху — те, кто ждёт ответа дольше всех.',
|
||||
'chat.waiting': 'ждёт {t}',
|
||||
'chat.you': 'Вы',
|
||||
'chat.provider': 'Исполнитель',
|
||||
'chat.unassigned': 'Никто не ведёт',
|
||||
'chat.take': 'Взять себе',
|
||||
'chat.transfer': 'Передать',
|
||||
'chat.transferTo': 'Передать коллеге',
|
||||
'chat.unassign': 'Снять ответственного',
|
||||
'chat.close': 'Закрыть',
|
||||
'chat.reopen': 'Открыть снова',
|
||||
'chat.closedBanner': 'Диалог закрыт. Клиент напишет — откроется сам; ответить можно и сейчас.',
|
||||
'chat.moveTo': 'Перенести в «{channel}»',
|
||||
'chat.moved': 'Диалог перенесён в «{channel}»',
|
||||
'chat.movedAway': 'Диалог перенесён в «{channel}». У вас нет доступа к этому каналу.',
|
||||
'chat.other': 'Есть переписка в канале «{channel}»',
|
||||
'chat.otherOpen': 'Открыть',
|
||||
'chat.bot': 'Диалог ведёт ИИ-консьерж.',
|
||||
'chat.botTake': 'Перехватить',
|
||||
'chat.reply': 'Ответ',
|
||||
'chat.note': 'Заметка',
|
||||
'chat.replyPh': 'Ответ клиенту — придёт ему в Telegram',
|
||||
'chat.notePh': 'Заметка для команды — клиент её не увидит',
|
||||
'chat.send': 'Отправить',
|
||||
'chat.saveNote': 'Сохранить заметку',
|
||||
'chat.attach': 'Прикрепить файл',
|
||||
'chat.templates': 'Шаблоны',
|
||||
'chat.tplManage': 'Настроить шаблоны',
|
||||
'chat.tplEmpty': 'Шаблонов пока нет.',
|
||||
'chat.tplNew': 'Новый шаблон',
|
||||
'chat.tplTitle': 'Название',
|
||||
'chat.tplText': 'Текст',
|
||||
'chat.tplTextHint': '{name} заменится на имя клиента',
|
||||
'chat.tplChannel': 'Канал',
|
||||
'chat.tplBoth': 'Оба канала',
|
||||
'chat.tplDelete': 'Удалить',
|
||||
'chat.tplSaved': 'Шаблон сохранён',
|
||||
'chat.tplSearch': 'Найти шаблон',
|
||||
'chat.noteLabel': 'Заметка · видит только команда',
|
||||
'chat.fromApp': 'из приложения',
|
||||
'chat.undelivered': 'Не доставлено: клиент заблокировал бота или ни разу его не открывал',
|
||||
'chat.fileTooBig': 'Файл больше 10 МБ',
|
||||
'chat.enterHint': 'Enter — отправить · Shift+Enter — новая строка',
|
||||
'chat.providerNotLinked': 'Исполнитель ещё не открыл рабочий бот — пока можно оставить только заметку.',
|
||||
'chat.client': 'О клиенте',
|
||||
'chat.aboutProvider': 'Об исполнителе',
|
||||
'chat.openCard': 'Карточка клиента',
|
||||
'chat.write': 'Написать',
|
||||
'chat.back': 'Все диалоги',
|
||||
'chat.sys.took': '{name} взял(а) диалог',
|
||||
'chat.sys.transferred': '{name} передал(а) диалог: {param}',
|
||||
'chat.sys.unassigned': '{name} снял(а) ответственного',
|
||||
'chat.sys.closed': '{name} закрыл(а) диалог',
|
||||
'chat.sys.reopened': '{name} открыл(а) диалог снова',
|
||||
'chat.sys.moved': '{name} перенёс(ла) диалог в «{param}»',
|
||||
'chat.ago.now': 'только что',
|
||||
'chat.ago.min': '{n} мин',
|
||||
'chat.ago.h': '{n} ч',
|
||||
'chat.ago.d': '{n} дн',
|
||||
'chat.today': 'Сегодня',
|
||||
'chat.yesterday': 'Вчера',
|
||||
'chat.image': 'Фото',
|
||||
'chat.file': 'Файл',
|
||||
'chat.subUntil': 'до {date}',
|
||||
'chat.orders': 'Заказы',
|
||||
'chat.phone': 'Телефон',
|
||||
'chat.lang': 'Язык',
|
||||
'chat.source': 'Откуда',
|
||||
'chat.linked': 'Рабочий бот открыт',
|
||||
'chat.notLinked': 'Рабочий бот не открыт',
|
||||
'chat.details': 'Подробнее',
|
||||
'chat.more': 'Ещё действия',
|
||||
'err.CONCIERGE_SUBSCRIBERS_ONLY': 'Консьерж — только для подписчиков. Сначала выдайте клиенту подписку.',
|
||||
'err.ASSIGNEE_NO_ACCESS': 'У этого сотрудника нет доступа к этому каналу.',
|
||||
'err.PROVIDER_NOT_LINKED': 'Исполнитель ещё не открыл рабочий бот — написать ему пока некуда.',
|
||||
'err.BAD_FILE_TYPE': 'Такой файл не подойдёт. Можно фото, PDF, Word, Excel, текст или ZIP.',
|
||||
'audit.bo.chats.send': 'ответил(а) в чате',
|
||||
'audit.bo.chats.sendFile': 'отправил(а) файл в чате',
|
||||
'audit.bo.chats.assign': 'назначил(а) ответственного в чате',
|
||||
'audit.bo.chats.close': 'закрыл(а) диалог',
|
||||
'audit.bo.chats.reopen': 'открыл(а) диалог снова',
|
||||
'audit.bo.chats.move': 'перенёс(ла) диалог в другой канал',
|
||||
'audit.bo.chats.openFor': 'открыл(а) чат с клиентом',
|
||||
'audit.bo.chats.templates.save': 'изменил(а) шаблон ответа',
|
||||
'audit.bo.chats.templates.remove': 'удалил(а) шаблон ответа',
|
||||
};
|
||||
|
||||
export type BoKey = keyof typeof ru;
|
||||
@@ -376,7 +477,7 @@ const en: Dict = {
|
||||
'dash.attention': 'Needs attention',
|
||||
'dash.allClear': 'Nothing urgent — all caught up.',
|
||||
'att.unassigned': 'Orders without a provider',
|
||||
'att.unreadChats': 'Unread messages',
|
||||
'att.unreadChats': 'Chats awaiting reply',
|
||||
'att.webRequests': 'Website requests',
|
||||
'att.leads': 'Relocation requests',
|
||||
'att.appRequests': 'In-app requests',
|
||||
@@ -595,6 +696,107 @@ const en: Dict = {
|
||||
'team.disabled': 'Access disabled',
|
||||
'team.you': 'you',
|
||||
'team.saved': 'Roles saved',
|
||||
|
||||
// ───── чаты (этап 2) ─────
|
||||
'chat.title': 'Chats',
|
||||
'chat.all': 'All',
|
||||
'channel.SUPPORT': 'Support',
|
||||
'channel.CONCIERGE': 'Concierge',
|
||||
'chat.f.waiting': 'Awaiting reply',
|
||||
'chat.f.mine': 'Mine',
|
||||
'chat.f.unassigned': 'Unassigned',
|
||||
'chat.f.open': 'Open',
|
||||
'chat.f.closed': 'Closed',
|
||||
'chat.f.all': 'All',
|
||||
'chat.search': 'Name, @username, phone or text',
|
||||
'chat.emptyList': 'Nothing here. New messages will appear on their own.',
|
||||
'chat.emptyFilter': 'Nothing found — try another filter.',
|
||||
'chat.pick': 'Pick a conversation',
|
||||
'chat.pickHint': 'Those waiting longest are on top.',
|
||||
'chat.waiting': 'waiting {t}',
|
||||
'chat.you': 'You',
|
||||
'chat.provider': 'Provider',
|
||||
'chat.unassigned': 'Nobody assigned',
|
||||
'chat.take': 'Take it',
|
||||
'chat.transfer': 'Pass on',
|
||||
'chat.transferTo': 'Pass to a colleague',
|
||||
'chat.unassign': 'Remove assignee',
|
||||
'chat.close': 'Close',
|
||||
'chat.reopen': 'Reopen',
|
||||
'chat.closedBanner': 'The conversation is closed. It reopens when the client writes; you can still reply now.',
|
||||
'chat.moveTo': 'Move to {channel}',
|
||||
'chat.moved': 'Moved to {channel}',
|
||||
'chat.movedAway': 'Moved to {channel}. You have no access to that channel.',
|
||||
'chat.other': 'There is also a {channel} conversation',
|
||||
'chat.otherOpen': 'Open',
|
||||
'chat.bot': 'The AI concierge is handling this conversation.',
|
||||
'chat.botTake': 'Take over',
|
||||
'chat.reply': 'Reply',
|
||||
'chat.note': 'Note',
|
||||
'chat.replyPh': 'Reply to the client — goes to their Telegram',
|
||||
'chat.notePh': 'Note for the team — the client won’t see it',
|
||||
'chat.send': 'Send',
|
||||
'chat.saveNote': 'Save note',
|
||||
'chat.attach': 'Attach a file',
|
||||
'chat.templates': 'Templates',
|
||||
'chat.tplManage': 'Manage templates',
|
||||
'chat.tplEmpty': 'No templates yet.',
|
||||
'chat.tplNew': 'New template',
|
||||
'chat.tplTitle': 'Title',
|
||||
'chat.tplText': 'Text',
|
||||
'chat.tplTextHint': '{name} is replaced with the client’s name',
|
||||
'chat.tplChannel': 'Channel',
|
||||
'chat.tplBoth': 'Both channels',
|
||||
'chat.tplDelete': 'Delete',
|
||||
'chat.tplSaved': 'Template saved',
|
||||
'chat.tplSearch': 'Find a template',
|
||||
'chat.noteLabel': 'Note · team only',
|
||||
'chat.fromApp': 'from the app',
|
||||
'chat.undelivered': 'Not delivered: the client blocked the bot or never opened it',
|
||||
'chat.fileTooBig': 'The file is larger than 10 MB',
|
||||
'chat.enterHint': 'Enter to send · Shift+Enter for a new line',
|
||||
'chat.providerNotLinked': 'The provider hasn’t opened the work bot yet — you can only leave a note for now.',
|
||||
'chat.client': 'About the client',
|
||||
'chat.aboutProvider': 'About the provider',
|
||||
'chat.openCard': 'Client card',
|
||||
'chat.write': 'Message',
|
||||
'chat.back': 'All conversations',
|
||||
'chat.sys.took': '{name} took the conversation',
|
||||
'chat.sys.transferred': '{name} passed the conversation to {param}',
|
||||
'chat.sys.unassigned': '{name} removed the assignee',
|
||||
'chat.sys.closed': '{name} closed the conversation',
|
||||
'chat.sys.reopened': '{name} reopened the conversation',
|
||||
'chat.sys.moved': '{name} moved the conversation to {param}',
|
||||
'chat.ago.now': 'just now',
|
||||
'chat.ago.min': '{n} min',
|
||||
'chat.ago.h': '{n} h',
|
||||
'chat.ago.d': '{n} d',
|
||||
'chat.today': 'Today',
|
||||
'chat.yesterday': 'Yesterday',
|
||||
'chat.image': 'Photo',
|
||||
'chat.file': 'File',
|
||||
'chat.subUntil': 'until {date}',
|
||||
'chat.orders': 'Orders',
|
||||
'chat.phone': 'Phone',
|
||||
'chat.lang': 'Language',
|
||||
'chat.source': 'Source',
|
||||
'chat.linked': 'Work bot opened',
|
||||
'chat.notLinked': 'Work bot not opened',
|
||||
'chat.details': 'Details',
|
||||
'chat.more': 'More actions',
|
||||
'err.CONCIERGE_SUBSCRIBERS_ONLY': 'The concierge is for members only. Give the client a subscription first.',
|
||||
'err.ASSIGNEE_NO_ACCESS': 'This team member has no access to this channel.',
|
||||
'err.PROVIDER_NOT_LINKED': 'The provider hasn’t opened the work bot yet.',
|
||||
'err.BAD_FILE_TYPE': 'This file type isn’t supported. Use a photo, PDF, Word, Excel, text or ZIP.',
|
||||
'audit.bo.chats.send': 'replied in a chat',
|
||||
'audit.bo.chats.sendFile': 'sent a file in a chat',
|
||||
'audit.bo.chats.assign': 'assigned a chat',
|
||||
'audit.bo.chats.close': 'closed a conversation',
|
||||
'audit.bo.chats.reopen': 'reopened a conversation',
|
||||
'audit.bo.chats.move': 'moved a conversation to another channel',
|
||||
'audit.bo.chats.openFor': 'opened a chat with a client',
|
||||
'audit.bo.chats.templates.save': 'edited a reply template',
|
||||
'audit.bo.chats.templates.remove': 'deleted a reply template',
|
||||
};
|
||||
|
||||
const hy: Dict = {
|
||||
@@ -670,7 +872,7 @@ const hy: Dict = {
|
||||
'dash.attention': 'Պահանջում է ուշադրություն',
|
||||
'dash.allClear': 'Շտապ ոչինչ չկա․ ամեն ինչ արված է։',
|
||||
'att.unassigned': 'Պատվերներ առանց կատարողի',
|
||||
'att.unreadChats': 'Չկարդացված հաղորդագրություններ',
|
||||
'att.unreadChats': 'Պատասխանի սպասող զրույցներ',
|
||||
'att.webRequests': 'Հայտեր կայքից',
|
||||
'att.leads': 'Տեղափոխման հայտեր',
|
||||
'att.appRequests': 'Հայտեր հավելվածից',
|
||||
@@ -889,6 +1091,107 @@ const hy: Dict = {
|
||||
'team.disabled': 'Մուտքն անջատված է',
|
||||
'team.you': 'դուք',
|
||||
'team.saved': 'Դերերը պահպանված են',
|
||||
|
||||
// ───── чаты (этап 2) ─────
|
||||
'chat.title': 'Զրույցներ',
|
||||
'chat.all': 'Բոլորը',
|
||||
'channel.SUPPORT': 'Աջակցություն',
|
||||
'channel.CONCIERGE': 'Կոնսիերժ',
|
||||
'chat.f.waiting': 'Սպասում են պատասխանի',
|
||||
'chat.f.mine': 'Իմը',
|
||||
'chat.f.unassigned': 'Առանց պատասխանատուի',
|
||||
'chat.f.open': 'Բաց',
|
||||
'chat.f.closed': 'Փակ',
|
||||
'chat.f.all': 'Բոլորը',
|
||||
'chat.search': 'Անուն, @username, հեռախոս կամ տեքստ',
|
||||
'chat.emptyList': 'Այստեղ դատարկ է։ Նոր հաղորդագրությունները կհայտնվեն ինքնուրույն։',
|
||||
'chat.emptyFilter': 'Ոչինչ չգտանք․ փորձեք այլ ֆիլտր։',
|
||||
'chat.pick': 'Ընտրեք զրույցը',
|
||||
'chat.pickHint': 'Վերևում նրանք են, ովքեր ամենաերկար են սպասում։',
|
||||
'chat.waiting': 'սպասում է {t}',
|
||||
'chat.you': 'Դուք',
|
||||
'chat.provider': 'Կատարող',
|
||||
'chat.unassigned': 'Պատասխանատու չկա',
|
||||
'chat.take': 'Վերցնել ինձ',
|
||||
'chat.transfer': 'Փոխանցել',
|
||||
'chat.transferTo': 'Փոխանցել գործընկերոջը',
|
||||
'chat.unassign': 'Հանել պատասխանատուին',
|
||||
'chat.close': 'Փակել',
|
||||
'chat.reopen': 'Կրկին բացել',
|
||||
'chat.closedBanner': 'Զրույցը փակ է։ Կբացվի, երբ հաճախորդը գրի․ կարող եք պատասխանել նաև հիմա։',
|
||||
'chat.moveTo': 'Տեղափոխել «{channel}»',
|
||||
'chat.moved': 'Տեղափոխվեց «{channel}»',
|
||||
'chat.movedAway': 'Տեղափոխվեց «{channel}»։ Դուք մուտք չունեք այդ ալիքին։',
|
||||
'chat.other': 'Կա նաև զրույց «{channel}» ալիքում',
|
||||
'chat.otherOpen': 'Բացել',
|
||||
'chat.bot': 'Զրույցը վարում է ԱԲ կոնսիերժը։',
|
||||
'chat.botTake': 'Վերցնել',
|
||||
'chat.reply': 'Պատասխան',
|
||||
'chat.note': 'Նշում',
|
||||
'chat.replyPh': 'Պատասխան հաճախորդին՝ կգա Telegram-ում',
|
||||
'chat.notePh': 'Նշում թիմի համար՝ հաճախորդը չի տեսնի',
|
||||
'chat.send': 'Ուղարկել',
|
||||
'chat.saveNote': 'Պահել նշումը',
|
||||
'chat.attach': 'Կցել ֆայլ',
|
||||
'chat.templates': 'Ձևանմուշներ',
|
||||
'chat.tplManage': 'Կարգավորել ձևանմուշները',
|
||||
'chat.tplEmpty': 'Ձևանմուշներ դեռ չկան։',
|
||||
'chat.tplNew': 'Նոր ձևանմուշ',
|
||||
'chat.tplTitle': 'Անվանում',
|
||||
'chat.tplText': 'Տեքստ',
|
||||
'chat.tplTextHint': '{name}-ը կփոխարինվի հաճախորդի անունով',
|
||||
'chat.tplChannel': 'Ալիք',
|
||||
'chat.tplBoth': 'Երկու ալիքները',
|
||||
'chat.tplDelete': 'Ջնջել',
|
||||
'chat.tplSaved': 'Ձևանմուշը պահպանված է',
|
||||
'chat.tplSearch': 'Գտնել ձևանմուշ',
|
||||
'chat.noteLabel': 'Նշում · տեսանելի է միայն թիմին',
|
||||
'chat.fromApp': 'հավելվածից',
|
||||
'chat.undelivered': 'Չի առաքվել․ հաճախորդը արգելափակել է բոտը կամ երբեք չի բացել',
|
||||
'chat.fileTooBig': 'Ֆայլը 10 ՄԲ-ից մեծ է',
|
||||
'chat.enterHint': 'Enter՝ ուղարկել · Shift+Enter՝ նոր տող',
|
||||
'chat.providerNotLinked': 'Կատարողը դեռ չի բացել աշխատանքային բոտը․ առայժմ կարող եք թողնել միայն նշում։',
|
||||
'chat.client': 'Հաճախորդի մասին',
|
||||
'chat.aboutProvider': 'Կատարողի մասին',
|
||||
'chat.openCard': 'Հաճախորդի քարտ',
|
||||
'chat.write': 'Գրել',
|
||||
'chat.back': 'Բոլոր զրույցները',
|
||||
'chat.sys.took': '{name}-ը վերցրեց զրույցը',
|
||||
'chat.sys.transferred': '{name}-ը զրույցը փոխանցեց {param}-ին',
|
||||
'chat.sys.unassigned': '{name}-ը հանեց պատասխանատուին',
|
||||
'chat.sys.closed': '{name}-ը փակեց զրույցը',
|
||||
'chat.sys.reopened': '{name}-ը կրկին բացեց զրույցը',
|
||||
'chat.sys.moved': '{name}-ը զրույցը տեղափոխեց «{param}»',
|
||||
'chat.ago.now': 'հենց հիմա',
|
||||
'chat.ago.min': '{n} ր',
|
||||
'chat.ago.h': '{n} ժ',
|
||||
'chat.ago.d': '{n} օր',
|
||||
'chat.today': 'Այսօր',
|
||||
'chat.yesterday': 'Երեկ',
|
||||
'chat.image': 'Լուսանկար',
|
||||
'chat.file': 'Ֆայլ',
|
||||
'chat.subUntil': 'մինչև {date}',
|
||||
'chat.orders': 'Պատվերներ',
|
||||
'chat.phone': 'Հեռախոս',
|
||||
'chat.lang': 'Լեզու',
|
||||
'chat.source': 'Աղբյուր',
|
||||
'chat.linked': 'Աշխատանքային բոտը բացված է',
|
||||
'chat.notLinked': 'Աշխատանքային բոտը բացված չէ',
|
||||
'chat.details': 'Մանրամասներ',
|
||||
'chat.more': 'Այլ գործողություններ',
|
||||
'err.CONCIERGE_SUBSCRIBERS_ONLY': 'Կոնսիերժը միայն բաժանորդների համար է։ Նախ տրամադրեք բաժանորդագրություն։',
|
||||
'err.ASSIGNEE_NO_ACCESS': 'Այս աշխատակիցը մուտք չունի այս ալիքին։',
|
||||
'err.PROVIDER_NOT_LINKED': 'Կատարողը դեռ չի բացել աշխատանքային բոտը։',
|
||||
'err.BAD_FILE_TYPE': 'Այս ֆայլը չի համապատասխանում։ Կարելի է լուսանկար, PDF, Word, Excel, տեքստ կամ ZIP։',
|
||||
'audit.bo.chats.send': 'պատասխանեց զրույցում',
|
||||
'audit.bo.chats.sendFile': 'ֆայլ ուղարկեց զրույցում',
|
||||
'audit.bo.chats.assign': 'նշանակեց զրույցի պատասխանատու',
|
||||
'audit.bo.chats.close': 'փակեց զրույցը',
|
||||
'audit.bo.chats.reopen': 'կրկին բացեց զրույցը',
|
||||
'audit.bo.chats.move': 'զրույցը տեղափոխեց այլ ալիք',
|
||||
'audit.bo.chats.openFor': 'բացեց զրույց հաճախորդի հետ',
|
||||
'audit.bo.chats.templates.save': 'փոխեց պատասխանի ձևանմուշը',
|
||||
'audit.bo.chats.templates.remove': 'ջնջեց պատասխանի ձևանմուշը',
|
||||
};
|
||||
|
||||
const DICTS: Record<BoLocale, Dict> = { ru, en, hy };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import '@barev/ui/styles.css';
|
||||
import './styles.css';
|
||||
import './chats.css';
|
||||
import { ApiProvider, ToastProvider } from '@barev/web';
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
@@ -0,0 +1,945 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { formatDateTime, type Locale } from '@barev/i18n';
|
||||
import { navigate, useToast, useTRPC } from '@barev/web';
|
||||
import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import type { Me } from '../App.tsx';
|
||||
import { errText, useT, type T } from '../i18n.tsx';
|
||||
import { Avatar, Dialog, Field, I, PlanBadge, Skeleton, sourceLabel, type IconName } from '../components/kit.tsx';
|
||||
import { SubDialog } from './SubDialog.tsx';
|
||||
|
||||
type Channel = 'SUPPORT' | 'CONCIERGE';
|
||||
type Filter = 'waiting' | 'mine' | 'unassigned' | 'open' | 'closed' | 'all';
|
||||
const FILTERS: Filter[] = ['open', 'waiting', 'mine', 'unassigned', 'closed'];
|
||||
const MAX_FILE = 10 * 1024 * 1024;
|
||||
const MIN = 60_000;
|
||||
|
||||
/** Каналы, доступные сотруднику: поддержка, консьерж или оба (суперадмин) */
|
||||
export function myChannels(me: Me): Channel[] {
|
||||
if (me.roles.includes('SUPERADMIN')) return ['SUPPORT', 'CONCIERGE'];
|
||||
return (['SUPPORT', 'CONCIERGE'] as const).filter((c) => me.roles.includes(c));
|
||||
}
|
||||
|
||||
const store = {
|
||||
get(key: string) {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
set(key: string, v: string) {
|
||||
try {
|
||||
localStorage.setItem(key, v);
|
||||
} catch {
|
||||
/* приватный режим */
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** «12 мин», «3 ч» — сколько прошло */
|
||||
function ago(t: T, d: Date, now = Date.now()) {
|
||||
const m = Math.floor((now - d.getTime()) / MIN);
|
||||
if (m < 1) return t('chat.ago.now');
|
||||
if (m < 60) return t('chat.ago.min', { n: m });
|
||||
if (m < 60 * 24) return t('chat.ago.h', { n: Math.floor(m / 60) });
|
||||
return t('chat.ago.d', { n: Math.floor(m / 1440) });
|
||||
}
|
||||
|
||||
/** Насколько срочно: до 15 минут — спокойно, до часа — пора, дольше — горит */
|
||||
const urgency = (d: Date) => {
|
||||
const m = (Date.now() - d.getTime()) / MIN;
|
||||
return m < 15 ? 'calm' : m < 60 ? 'warm' : 'hot';
|
||||
};
|
||||
|
||||
/** Перерисовывать «N мин» раз в полминуты */
|
||||
function useTick(ms = 30_000) {
|
||||
const [, set] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => set((n) => n + 1), ms);
|
||||
return () => clearInterval(id);
|
||||
}, [ms]);
|
||||
}
|
||||
|
||||
/** Черновики по диалогам — переключился и вернулся, текст на месте */
|
||||
const drafts = new Map<string, string>();
|
||||
|
||||
export function Chats({ me, threadId }: { me: Me; threadId?: string }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const channels = myChannels(me);
|
||||
const [channel, setChannelState] = useState<Channel | 'ALL'>(() => {
|
||||
const saved = store.get('barev.bo.chats.channel') as Channel | 'ALL' | null;
|
||||
if (saved === 'ALL' && channels.length > 1) return 'ALL';
|
||||
if (saved && channels.includes(saved as Channel)) return saved as Channel;
|
||||
return channels.length > 1 ? 'ALL' : channels[0]!;
|
||||
});
|
||||
const setChannel = (c: Channel | 'ALL') => {
|
||||
setChannelState(c);
|
||||
store.set('barev.bo.chats.channel', c);
|
||||
};
|
||||
const [filter, setFilter] = useState<Filter>('open');
|
||||
const [search, setSearch] = useState('');
|
||||
const [q, setQ] = useState('');
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setQ(search.trim()), 250);
|
||||
return () => clearTimeout(id);
|
||||
}, [search]);
|
||||
// «о клиенте» открыт сразу, только если для него есть место третьей колонкой
|
||||
const [side, setSide] = useState(() => window.matchMedia('(min-width: 1560px)').matches);
|
||||
useTick();
|
||||
|
||||
const counts = useQuery({ ...trpc.bo.chats.counts.queryOptions(), refetchInterval: 15_000 });
|
||||
const list = useQuery({
|
||||
...trpc.bo.chats.list.queryOptions({ channel: channel === 'ALL' ? undefined : channel, filter, search: q || undefined }),
|
||||
refetchInterval: 8_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const waitingIn = (c: Channel | 'ALL') => (counts.data ? (c === 'ALL' ? counts.data.total : counts.data.waiting[c]) : 0);
|
||||
const filterCount = (f: Filter) => {
|
||||
if (!counts.data) return 0;
|
||||
const sum = (r: Record<Channel, number>) => (channel === 'ALL' ? channels.reduce((s, c) => s + r[c], 0) : r[channel]);
|
||||
return f === 'waiting' ? sum(counts.data.waiting) : f === 'mine' ? sum(counts.data.mine) : f === 'unassigned' ? sum(counts.data.unassigned) : 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`bo-chat${threadId ? ' bo-chat--thread' : ''}${threadId && side ? ' bo-chat--side' : ''}`}>
|
||||
<section className="bo-chat__list" aria-label={t('chat.title')}>
|
||||
<div className="bo-chat__listhead">
|
||||
<h1 className="bo-title bo-chat__title">{t('chat.title')}</h1>
|
||||
{channels.length > 1 && (
|
||||
<div className="bo-chtabs" role="group">
|
||||
{(['ALL', ...channels] as const).map((c) => (
|
||||
<button key={c} type="button" aria-pressed={channel === c} onClick={() => setChannel(c)}>
|
||||
{c === 'ALL' ? t('chat.all') : t(`channel.${c}`)}
|
||||
{waitingIn(c) > 0 && <span className="bo-chat__dot">{waitingIn(c)}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<label className="bo-search bo-chat__search">
|
||||
<I name="search" size={18} />
|
||||
<input className="bo-input" type="search" value={search} onChange={(e) => setSearch(e.target.value)} placeholder={t('chat.search')} aria-label={t('chat.search')} />
|
||||
</label>
|
||||
<div className="bo-chat__filters" role="group">
|
||||
{FILTERS.map((f) => {
|
||||
const n = filterCount(f);
|
||||
return (
|
||||
<button key={f} type="button" className="bo-pill" aria-pressed={filter === f} onClick={() => setFilter(f)}>
|
||||
{t(`chat.f.${f}`)}
|
||||
{n > 0 && <span className="bo-chat__pillnum">{n}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<ThreadList items={list.data} loading={list.isPending} activeId={threadId} showChannel={channel === 'ALL'} meId={me.id} filtered={filter !== 'open' || !!q} />
|
||||
</section>
|
||||
|
||||
<section className="bo-chat__conv">
|
||||
{threadId ? (
|
||||
<Conversation key={threadId} id={threadId} me={me} channels={channels} side={side} onToggleSide={() => setSide((s) => !s)} />
|
||||
) : (
|
||||
<div className="bo-chat__pick">
|
||||
<span className="bo-chat__pickicon">
|
||||
<I name="chats" size={30} />
|
||||
</span>
|
||||
<h2 className="bo-h2">{t('chat.pick')}</h2>
|
||||
<p className="bo-muted">{t('chat.pickHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{threadId && side && <PeerPanel id={threadId} me={me} onClose={() => setSide(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───── список диалогов ─────
|
||||
|
||||
type ListItem = {
|
||||
id: string;
|
||||
channel: Channel;
|
||||
status: 'OPEN' | 'CLOSED';
|
||||
name: string;
|
||||
plan: 'BAREV_PLUS' | 'ELITE' | null;
|
||||
providerRole: string | null;
|
||||
assignee: { id: string; name: string } | null;
|
||||
botActive: boolean;
|
||||
waitingSince: Date | null;
|
||||
lastMessageAt: Date;
|
||||
last: { author: string; text: string; file: string | null } | null;
|
||||
unread: number;
|
||||
};
|
||||
|
||||
function ThreadList({ items, loading, activeId, showChannel, meId, filtered }: { items?: ListItem[]; loading: boolean; activeId?: string; showChannel: boolean; meId: string; filtered: boolean }) {
|
||||
const { t } = useT();
|
||||
if (loading && !items)
|
||||
return (
|
||||
<div className="bo-chat__items" style={{ padding: 12, display: 'grid', gap: 8 }}>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} h={68} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
if (!items?.length) return <p className="bo-chat__none">{filtered ? t('chat.emptyFilter') : t('chat.emptyList')}</p>;
|
||||
return (
|
||||
<ul className="bo-chat__items">
|
||||
{items.map((it) => {
|
||||
const lastText = it.last ? (it.last.file ? `📎 ${it.last.file === 'image' ? t('chat.image') : it.last.file}${it.last.text ? ` · ${it.last.text}` : ''}` : it.last.text) : '';
|
||||
const mine = it.last?.author === 'OPERATOR';
|
||||
return (
|
||||
<li key={it.id}>
|
||||
<a href={`#/chats/${it.id}`} className="bo-thread" aria-current={it.id === activeId ? 'true' : undefined} data-closed={it.status === 'CLOSED' || undefined}>
|
||||
<Avatar name={it.name} id={it.id} size={42} />
|
||||
<span className="bo-thread__body">
|
||||
<span className="bo-thread__top">
|
||||
<b className="bo-thread__name">{it.name}</b>
|
||||
{it.providerRole ? <span className="bo-chip bo-chip--provider">{t('chat.provider')}</span> : it.plan && <PlanBadge plan={it.plan} t={t} />}
|
||||
<span className="bo-thread__time">{ago(t, it.lastMessageAt)}</span>
|
||||
</span>
|
||||
<span className="bo-thread__last">
|
||||
{mine && <span className="bo-faint">{t('chat.you')}: </span>}
|
||||
{lastText}
|
||||
</span>
|
||||
<span className="bo-thread__meta">
|
||||
{showChannel && <span className={`bo-chip bo-chip--${it.channel}`}>{t(`channel.${it.channel}`)}</span>}
|
||||
{it.waitingSince && <span className={`bo-wait-chip bo-wait-chip--${urgency(it.waitingSince)}`}>{t('chat.waiting', { t: ago(t, it.waitingSince) })}</span>}
|
||||
{it.botActive && (
|
||||
<span className="bo-chip">
|
||||
<I name="bot" size={13} />
|
||||
</span>
|
||||
)}
|
||||
<span className="bo-grow" />
|
||||
{it.assignee ? (
|
||||
<span className="bo-thread__who" title={it.assignee.name}>
|
||||
{it.assignee.id === meId ? t('chat.f.mine') : it.assignee.name.split(' ')[0]}
|
||||
</span>
|
||||
) : (
|
||||
it.status === 'OPEN' && <span className="bo-thread__who bo-thread__who--none">{t('chat.unassigned')}</span>
|
||||
)}
|
||||
{it.unread > 0 && <span className="bo-nav__count">{it.unread}</span>}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
// ───── диалог ─────
|
||||
|
||||
function Conversation({ id, me, channels, side, onToggleSide }: { id: string; me: Me; channels: Channel[]; side: boolean; onToggleSide: () => void }) {
|
||||
const { t, locale } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const th = useQuery({ ...trpc.bo.chats.get.queryOptions({ id }), refetchInterval: 4_000 });
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.chats.get.queryKey({ id }) });
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.chats.list.queryKey() });
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.chats.counts.queryKey() });
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.dashboard.attention.queryKey() });
|
||||
};
|
||||
const onError = (e: unknown) => toast(errText(t, e), 'error');
|
||||
const assign = useMutation(trpc.bo.chats.assign.mutationOptions({ onSuccess: refresh, onError }));
|
||||
const close = useMutation(trpc.bo.chats.close.mutationOptions({ onSuccess: refresh, onError }));
|
||||
const reopen = useMutation(trpc.bo.chats.reopen.mutationOptions({ onSuccess: refresh, onError }));
|
||||
const move = useMutation(
|
||||
trpc.bo.chats.move.mutationOptions({
|
||||
onSuccess: (r, v) => {
|
||||
refresh();
|
||||
const name = t(`channel.${v.channel}`);
|
||||
if (r.visible) {
|
||||
toast(t('chat.moved', { channel: name }));
|
||||
navigate(`/chats/${r.id}`, { replace: true });
|
||||
} else {
|
||||
toast(t('chat.movedAway', { channel: name }));
|
||||
navigate('/chats', { replace: true });
|
||||
}
|
||||
},
|
||||
onError,
|
||||
}),
|
||||
);
|
||||
|
||||
if (th.isError)
|
||||
return (
|
||||
<div className="bo-chat__pick">
|
||||
<p className="bo-muted">{errText(t, th.error)}</p>
|
||||
<a className="bo-btn bo-btn--ghost bo-btn--sm" href="#/chats">
|
||||
{t('chat.back')}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
if (!th.data)
|
||||
return (
|
||||
<div style={{ padding: 20, display: 'grid', gap: 12 }}>
|
||||
<Skeleton h={56} />
|
||||
<Skeleton h={320} />
|
||||
</div>
|
||||
);
|
||||
const d = th.data;
|
||||
const other = d.other ? t(`channel.${d.other.channel}`) : null;
|
||||
const otherChannel: Channel = d.channel === 'SUPPORT' ? 'CONCIERGE' : 'SUPPORT';
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="bo-conv__head">
|
||||
<a className="bo-conv__back" href="#/chats" aria-label={t('chat.back')}>
|
||||
<I name="back" size={20} />
|
||||
</a>
|
||||
<Avatar name={d.name} id={d.id} size={40} />
|
||||
<div className="bo-conv__who">
|
||||
<div className="bo-conv__name">
|
||||
<span>{d.name}</span>
|
||||
{d.providerRole ? <span className="bo-chip bo-chip--provider">{t('chat.provider')}</span> : <PlanBadge plan={d.plan} t={t} />}
|
||||
</div>
|
||||
<div className="bo-conv__sub">
|
||||
<span className={`bo-chip bo-chip--${d.channel}`}>{t(`channel.${d.channel}`)}</span>
|
||||
{d.username && <span>@{d.username}</span>}
|
||||
<span>{d.locale.toUpperCase()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bo-conv__actions">
|
||||
<AssignMenu
|
||||
channel={d.channel}
|
||||
assignee={d.assignee}
|
||||
me={me}
|
||||
busy={assign.isPending}
|
||||
onAssign={(assigneeId) => assign.mutate({ id, assigneeId })}
|
||||
/>
|
||||
{d.status === 'OPEN' ? (
|
||||
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => close.mutate({ id })} disabled={close.isPending}>
|
||||
<I name="check" size={16} />
|
||||
<span className="bo-hide-sm">{t('chat.close')}</span>
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => reopen.mutate({ id })} disabled={reopen.isPending}>
|
||||
{t('chat.reopen')}
|
||||
</button>
|
||||
)}
|
||||
<Menu
|
||||
label={t('chat.more')}
|
||||
icon="more"
|
||||
items={[
|
||||
d.clientId && { key: 'move', icon: 'swap' as const, label: t('chat.moveTo', { channel: t(`channel.${otherChannel}`) }), onClick: () => move.mutate({ id, channel: otherChannel }) },
|
||||
d.clientId && { key: 'card', icon: 'clients' as const, label: t('chat.openCard'), onClick: () => navigate(`/clients/${d.clientId}`) },
|
||||
]}
|
||||
/>
|
||||
<button type="button" className="bo-iconbtn" aria-pressed={side} onClick={onToggleSide} aria-label={d.clientId ? t('chat.client') : t('chat.aboutProvider')} title={d.clientId ? t('chat.client') : t('chat.aboutProvider')}>
|
||||
<I name="info" size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{d.botActive && (
|
||||
<div className="bo-conv__banner bo-conv__banner--bot">
|
||||
<I name="bot" size={18} />
|
||||
<span className="bo-grow">{t('chat.bot')}</span>
|
||||
<button type="button" className="bo-btn bo-btn--sm" onClick={() => assign.mutate({ id, assigneeId: me.id })}>
|
||||
{t('chat.botTake')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{d.other && other && (
|
||||
<div className="bo-conv__banner">
|
||||
<span className="bo-grow">{t('chat.other', { channel: other })}</span>
|
||||
{channels.includes(d.other.channel) && (
|
||||
<a className="bo-btn bo-btn--ghost bo-btn--sm" href={`#/chats/${d.other.id}`}>
|
||||
{t('chat.otherOpen')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Messages messages={d.messages} locale={locale as Locale} peerName={d.name} />
|
||||
|
||||
{d.status === 'CLOSED' && <div className="bo-conv__closed">{t('chat.closedBanner')}</div>}
|
||||
<Composer
|
||||
threadId={id}
|
||||
peerFirstName={d.name.split(' ')[0] ?? ''}
|
||||
channel={d.channel}
|
||||
replyDisabled={d.providerLinked === false}
|
||||
onSent={refresh}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type Message = {
|
||||
id: string;
|
||||
author: 'CLIENT' | 'PROVIDER' | 'OPERATOR' | 'BOT' | 'SYSTEM';
|
||||
authorName: string | null;
|
||||
text: string;
|
||||
internal: boolean;
|
||||
source: string;
|
||||
file: { name: string | null; mime: string | null; size: number | null } | null;
|
||||
param: string | null;
|
||||
deliveryError: string | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
function Messages({ messages, locale, peerName }: { messages: Message[]; locale: Locale; peerName: string }) {
|
||||
const { t } = useT();
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
const stick = useRef(true);
|
||||
const lastId = messages.at(-1)?.id;
|
||||
// новое сообщение — прокручиваем вниз, если человек и так был внизу
|
||||
useLayoutEffect(() => {
|
||||
const el = box.current;
|
||||
if (el && stick.current) el.scrollTop = el.scrollHeight;
|
||||
}, [lastId]);
|
||||
const time = (d: Date) => formatDateTime(locale, d, { hour: '2-digit', minute: '2-digit' });
|
||||
const dayKey = (d: Date) => formatDateTime(locale, d, { year: 'numeric', month: '2-digit', day: '2-digit' });
|
||||
const today = dayKey(new Date());
|
||||
const yesterday = dayKey(new Date(Date.now() - 86_400_000));
|
||||
const dayLabel = (d: Date) => {
|
||||
const k = dayKey(d);
|
||||
return k === today ? t('chat.today') : k === yesterday ? t('chat.yesterday') : formatDateTime(locale, d, { day: 'numeric', month: 'long' });
|
||||
};
|
||||
|
||||
let prevDay = '';
|
||||
return (
|
||||
<div
|
||||
className="bo-msgs"
|
||||
ref={box}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
||||
}}
|
||||
>
|
||||
{messages.map((m) => {
|
||||
const day = dayKey(m.createdAt);
|
||||
const sep = day !== prevDay ? <div className="bo-msgs__day">{dayLabel(m.createdAt)}</div> : null;
|
||||
prevDay = day;
|
||||
if (m.author === 'SYSTEM')
|
||||
return (
|
||||
<Fragment key={m.id}>
|
||||
{sep}
|
||||
<div className="bo-sys">
|
||||
{t(`chat.sys.${m.text}`, { name: m.authorName ?? '—', param: m.text === 'moved' && m.param ? t(`channel.${m.param}`) : (m.param ?? '') })}
|
||||
<span className="bo-faint"> · {time(m.createdAt)}</span>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
const incoming = m.author === 'CLIENT' || m.author === 'PROVIDER';
|
||||
const kind = incoming ? 'in' : m.internal ? 'note' : 'out';
|
||||
return (
|
||||
<Fragment key={m.id}>
|
||||
{sep}
|
||||
<div className={`bo-msg bo-msg--${kind}`}>
|
||||
{kind === 'note' && (
|
||||
<div className="bo-msg__label">
|
||||
<I name="lock" size={13} />
|
||||
{t('chat.noteLabel')}
|
||||
</div>
|
||||
)}
|
||||
{m.file && <FileView id={m.id} file={m.file} />}
|
||||
{m.text && <div className="bo-msg__text">{m.text}</div>}
|
||||
<div className="bo-msg__meta">
|
||||
{incoming ? peerName.split(' ')[0] : m.author === 'BOT' ? 'AI' : (m.authorName ?? '')}
|
||||
{m.source === 'MINIAPP' && <span> · {t('chat.fromApp')}</span>}
|
||||
<span> · {time(m.createdAt)}</span>
|
||||
</div>
|
||||
{m.deliveryError && (
|
||||
<div className="bo-msg__err">
|
||||
<I name="warn" size={14} />
|
||||
{t('chat.undelivered')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileView({ id, file }: { id: string; file: { name: string | null; mime: string | null; size: number | null } }) {
|
||||
const { t } = useT();
|
||||
const [broken, setBroken] = useState(false);
|
||||
const url = `/files/bo/chat-file/${id}`;
|
||||
if (file.mime?.startsWith('image/') && !broken)
|
||||
return (
|
||||
<a className="bo-msg__img" href={url} target="_blank" rel="noreferrer">
|
||||
<img src={url} alt={file.name ?? t('chat.image')} loading="lazy" onError={() => setBroken(true)} />
|
||||
</a>
|
||||
);
|
||||
return (
|
||||
<a className="bo-msg__file" href={url} target="_blank" rel="noreferrer">
|
||||
<I name="file" size={22} />
|
||||
<span style={{ minWidth: 0 }}>
|
||||
<span className="bo-msg__fname">{file.name ?? t('chat.file')}</span>
|
||||
{file.size != null && <span className="bo-faint bo-small">{fmtSize(file.size)}</span>}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const fmtSize = (n: number) => (n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${Math.round(n / 1024)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`);
|
||||
|
||||
// ───── поле ответа ─────
|
||||
|
||||
function Composer({ threadId, peerFirstName, channel, replyDisabled, onSent }: { threadId: string; peerFirstName: string; channel: Channel; replyDisabled: boolean; onSent: () => void }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const toast = useToast();
|
||||
const [mode, setMode] = useState<'reply' | 'note'>(replyDisabled ? 'note' : 'reply');
|
||||
const [text, setTextState] = useState(() => drafts.get(threadId) ?? '');
|
||||
const setText = (v: string) => {
|
||||
setTextState(v);
|
||||
drafts.set(threadId, v);
|
||||
};
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [tplOpen, setTplOpen] = useState(false);
|
||||
const area = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const note = mode === 'note';
|
||||
|
||||
// поле растёт вместе с текстом
|
||||
useLayoutEffect(() => {
|
||||
const el = area.current;
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${Math.min(el.scrollHeight, 220)}px`;
|
||||
}, [text]);
|
||||
|
||||
const send = useMutation(
|
||||
trpc.bo.chats.send.mutationOptions({
|
||||
onSuccess: () => {
|
||||
setText('');
|
||||
onSent();
|
||||
area.current?.focus();
|
||||
},
|
||||
onError: (e) => toast(errText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
const submit = () => {
|
||||
if (!text.trim() || send.isPending) return;
|
||||
send.mutate({ id: threadId, text, internal: note });
|
||||
};
|
||||
|
||||
const upload = async (file: File) => {
|
||||
if (file.size > MAX_FILE) return toast(t('chat.fileTooBig'), 'error');
|
||||
setUploading(true);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.set('text', text);
|
||||
form.set('internal', String(note));
|
||||
form.set('file', file);
|
||||
const res = await fetch(`/files/bo/chat/${threadId}`, { method: 'POST', body: form, credentials: 'include' });
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(body.error ?? String(res.status));
|
||||
}
|
||||
setText('');
|
||||
onSent();
|
||||
} catch (e) {
|
||||
toast(errText(t, e), 'error');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`bo-composer${note ? ' bo-composer--note' : ''}`}>
|
||||
<div className="bo-composer__tabs" role="tablist">
|
||||
<button type="button" role="tab" aria-selected={!note} disabled={replyDisabled} onClick={() => setMode('reply')}>
|
||||
{t('chat.reply')}
|
||||
</button>
|
||||
<button type="button" role="tab" aria-selected={note} onClick={() => setMode('note')}>
|
||||
<I name="lock" size={13} />
|
||||
{t('chat.note')}
|
||||
</button>
|
||||
<span className="bo-grow" />
|
||||
<span className="bo-composer__hint bo-hide-sm">{t('chat.enterHint')}</span>
|
||||
</div>
|
||||
{replyDisabled && <p className="bo-hint" style={{ margin: '0 4px 8px' }}>{t('chat.providerNotLinked')}</p>}
|
||||
<div className="bo-composer__box">
|
||||
<textarea
|
||||
ref={area}
|
||||
className="bo-composer__input"
|
||||
rows={1}
|
||||
value={text}
|
||||
placeholder={note ? t('chat.notePh') : t('chat.replyPh')}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
maxLength={4000}
|
||||
aria-label={note ? t('chat.note') : t('chat.reply')}
|
||||
/>
|
||||
<div className="bo-composer__tools">
|
||||
<button type="button" className="bo-iconbtn" onClick={() => fileInput.current?.click()} disabled={uploading} aria-label={t('chat.attach')} title={t('chat.attach')}>
|
||||
<I name="clip" size={20} />
|
||||
</button>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
hidden
|
||||
accept="image/*,application/pdf,.doc,.docx,.xls,.xlsx,.txt,.zip"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (f) void upload(f);
|
||||
}}
|
||||
/>
|
||||
<div className="bo-pop-anchor">
|
||||
<button type="button" className="bo-iconbtn" aria-expanded={tplOpen} onClick={() => setTplOpen((o) => !o)} aria-label={t('chat.templates')} title={t('chat.templates')}>
|
||||
<I name="bolt" size={20} />
|
||||
</button>
|
||||
{tplOpen && (
|
||||
<TemplatePicker
|
||||
channel={channel}
|
||||
onPick={(txt) => {
|
||||
setText(txt.replaceAll('{name}', peerFirstName));
|
||||
setTplOpen(false);
|
||||
area.current?.focus();
|
||||
}}
|
||||
onClose={() => setTplOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className={`bo-btn bo-btn--sm ${note ? '' : 'bo-btn--accent'}`} onClick={submit} disabled={!text.trim() || send.isPending || uploading}>
|
||||
<I name={note ? 'lock' : 'send'} size={16} />
|
||||
<span className="bo-hide-sm">{note ? t('chat.saveNote') : t('chat.send')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───── шаблоны ─────
|
||||
|
||||
type Template = { id: string; channel: Channel | null; title: string; text: string };
|
||||
|
||||
function TemplatePicker({ channel, onPick, onClose }: { channel: Channel; onPick: (text: string) => void; onClose: () => void }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const tpl = useQuery(trpc.bo.chats.templates.list.queryOptions());
|
||||
const [q, setQ] = useState('');
|
||||
const [manage, setManage] = useState(false);
|
||||
const ref = useOutside<HTMLDivElement>(() => !manage && onClose());
|
||||
const items = (tpl.data ?? []).filter((x) => (!x.channel || x.channel === channel) && (`${x.title} ${x.text}`.toLowerCase().includes(q.toLowerCase())));
|
||||
return (
|
||||
<>
|
||||
<div className="bo-pop bo-pop--up bo-tpl" ref={ref} role="dialog" aria-label={t('chat.templates')}>
|
||||
<input className="bo-input" autoFocus value={q} onChange={(e) => setQ(e.target.value)} placeholder={t('chat.tplSearch')} style={{ minHeight: 36, padding: '6px 12px' }} />
|
||||
<div className="bo-tpl__list">
|
||||
{items.length === 0 && <p className="bo-hint" style={{ padding: '8px 4px' }}>{t('chat.tplEmpty')}</p>}
|
||||
{items.map((x) => (
|
||||
<button key={x.id} type="button" className="bo-tpl__item" onClick={() => onPick(x.text)}>
|
||||
<b>{x.title}</b>
|
||||
<span>{x.text}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="bo-side__link" style={{ color: 'var(--bc-accent)', padding: '4px' }} onClick={() => setManage(true)}>
|
||||
{t('chat.tplManage')}
|
||||
</button>
|
||||
</div>
|
||||
{manage && <TemplatesDialog templates={tpl.data ?? []} onClose={() => setManage(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplatesDialog({ templates, onClose }: { templates: Template[]; onClose: () => void }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [edit, setEdit] = useState<Partial<Template> | null>(null);
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: trpc.bo.chats.templates.list.queryKey() });
|
||||
const save = useMutation(
|
||||
trpc.bo.chats.templates.save.mutationOptions({
|
||||
onSuccess: () => {
|
||||
refresh();
|
||||
setEdit(null);
|
||||
toast(t('chat.tplSaved'));
|
||||
},
|
||||
onError: (e) => toast(errText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
const remove = useMutation(trpc.bo.chats.templates.remove.mutationOptions({ onSuccess: refresh, onError: (e) => toast(errText(t, e), 'error') }));
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
title={t('chat.templates')}
|
||||
footer={
|
||||
edit ? (
|
||||
<>
|
||||
<button type="button" className="bo-btn bo-btn--ghost" onClick={() => setEdit(null)}>
|
||||
{t('ui.close')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="bo-btn"
|
||||
disabled={!edit.title?.trim() || !edit.text?.trim() || save.isPending}
|
||||
onClick={() => save.mutate({ id: edit.id, channel: edit.channel ?? null, title: edit.title!, text: edit.text! })}
|
||||
>
|
||||
{t('ui.save')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
|
||||
{t('ui.close')}
|
||||
</button>
|
||||
<button type="button" className="bo-btn" onClick={() => setEdit({ channel: null, title: '', text: '' })}>
|
||||
<I name="plus" size={16} />
|
||||
{t('chat.tplNew')}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{edit ? (
|
||||
<div style={{ display: 'grid', gap: 14 }}>
|
||||
<Field label={t('chat.tplTitle')}>
|
||||
<input className="bo-input" value={edit.title ?? ''} maxLength={80} onChange={(e) => setEdit({ ...edit, title: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('chat.tplText')} hint={t('chat.tplTextHint')}>
|
||||
<textarea className="bo-textarea" value={edit.text ?? ''} maxLength={4000} onChange={(e) => setEdit({ ...edit, text: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('chat.tplChannel')}>
|
||||
<select className="bo-select" value={edit.channel ?? ''} onChange={(e) => setEdit({ ...edit, channel: (e.target.value || null) as Channel | null })}>
|
||||
<option value="">{t('chat.tplBoth')}</option>
|
||||
<option value="SUPPORT">{t('channel.SUPPORT')}</option>
|
||||
<option value="CONCIERGE">{t('channel.CONCIERGE')}</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<p className="bo-hint">{t('chat.tplEmpty')}</p>
|
||||
) : (
|
||||
<ul className="bo-list">
|
||||
{templates.map((x) => (
|
||||
<li key={x.id} style={{ alignItems: 'flex-start' }}>
|
||||
<span className="bo-grow" style={{ whiteSpace: 'normal' }}>
|
||||
<b>{x.title}</b>
|
||||
{x.channel && <span className={`bo-chip bo-chip--${x.channel}`} style={{ marginLeft: 8 }}>{t(`channel.${x.channel}`)}</span>}
|
||||
<span className="bo-muted bo-small" style={{ display: 'block', marginTop: 2 }}>
|
||||
{x.text}
|
||||
</span>
|
||||
</span>
|
||||
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setEdit(x)}>
|
||||
{t('chat.details')}
|
||||
</button>
|
||||
<button type="button" className="bo-btn bo-btn--danger bo-btn--sm" onClick={() => remove.mutate({ id: x.id })} aria-label={`${t('chat.tplDelete')}: ${x.title}`}>
|
||||
<I name="close" size={14} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ───── меню ─────
|
||||
|
||||
function useOutside<E extends HTMLElement>(onOutside: () => void) {
|
||||
const ref = useRef<E>(null);
|
||||
const cb = useRef(onOutside);
|
||||
cb.current = onOutside;
|
||||
useEffect(() => {
|
||||
const down = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node) && !(e.target as HTMLElement).closest?.('[data-pop-trigger]')) cb.current();
|
||||
};
|
||||
const key = (e: KeyboardEvent) => e.key === 'Escape' && cb.current();
|
||||
document.addEventListener('mousedown', down);
|
||||
document.addEventListener('keydown', key);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', down);
|
||||
document.removeEventListener('keydown', key);
|
||||
};
|
||||
}, []);
|
||||
return ref;
|
||||
}
|
||||
|
||||
type MenuItem = { key: string; icon?: IconName; label: ReactNode; onClick: () => void; current?: boolean };
|
||||
|
||||
function Menu({ label, icon, items, button }: { label: string; icon?: IconName; items: (MenuItem | null | undefined | false | '')[]; button?: ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useOutside<HTMLDivElement>(() => setOpen(false));
|
||||
const list = items.filter((x): x is MenuItem => !!x);
|
||||
if (list.length === 0) return null;
|
||||
return (
|
||||
<div className="bo-pop-anchor">
|
||||
{button ? (
|
||||
<span data-pop-trigger onClick={() => setOpen((o) => !o)}>
|
||||
{button}
|
||||
</span>
|
||||
) : (
|
||||
<button type="button" data-pop-trigger className="bo-iconbtn" aria-expanded={open} aria-label={label} title={label} onClick={() => setOpen((o) => !o)}>
|
||||
<I name={icon ?? 'more'} size={20} />
|
||||
</button>
|
||||
)}
|
||||
{open && (
|
||||
<div className="bo-pop" ref={ref} role="menu">
|
||||
{list.map((it) => (
|
||||
<button
|
||||
key={it.key}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="bo-pop__item"
|
||||
aria-current={it.current || undefined}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
it.onClick();
|
||||
}}
|
||||
>
|
||||
{it.icon && <I name={it.icon} size={18} />}
|
||||
{it.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssignMenu({ channel, assignee, me, busy, onAssign }: { channel: Channel; assignee: { id: string; name: string } | null; me: Me; busy: boolean; onAssign: (id: string | null) => void }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const colleagues = useQuery(trpc.bo.chats.colleagues.queryOptions({ channel }));
|
||||
const others = (colleagues.data ?? []).filter((c) => c.id !== me.id);
|
||||
// никто не ведёт — кнопка сразу берёт диалог себе
|
||||
if (!assignee)
|
||||
return (
|
||||
<button type="button" className="bo-btn bo-btn--sm" disabled={busy} onClick={() => onAssign(me.id)}>
|
||||
<I name="userPlus" size={16} />
|
||||
<span className="bo-hide-sm">{t('chat.take')}</span>
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<Menu
|
||||
label={t('chat.transfer')}
|
||||
button={
|
||||
<button type="button" className="bo-assignee" disabled={busy} aria-haspopup="menu" title={t('chat.transfer')}>
|
||||
<Avatar name={assignee.name} id={assignee.id} size={26} />
|
||||
<span className="bo-hide-sm">{assignee.id === me.id ? t('chat.f.mine') : assignee.name}</span>
|
||||
</button>
|
||||
}
|
||||
items={[
|
||||
assignee.id !== me.id && { key: 'me', icon: 'userPlus', label: t('chat.take'), onClick: () => onAssign(me.id) },
|
||||
...others.map((c) => ({ key: c.id, label: <span>{t('chat.transferTo')}: <b>{c.name}</b></span>, onClick: () => onAssign(c.id), current: c.id === assignee.id })),
|
||||
{ key: 'none', icon: 'close' as const, label: t('chat.unassign'), onClick: () => onAssign(null) },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ───── панель «о клиенте» ─────
|
||||
|
||||
function PeerPanel({ id, me, onClose }: { id: string; me: Me; onClose: () => void }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const th = useQuery(trpc.bo.chats.get.queryOptions({ id }));
|
||||
const clientId = th.data?.clientId;
|
||||
return (
|
||||
<aside className="bo-chat__side">
|
||||
<div className="bo-side-panel__head">
|
||||
<h2 className="bo-h3">{clientId ? t('chat.client') : t('chat.aboutProvider')}</h2>
|
||||
<button type="button" className="bo-iconbtn" onClick={onClose} aria-label={t('ui.close')}>
|
||||
<I name="close" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
{clientId ? (
|
||||
<ClientSummary clientId={clientId} me={me} />
|
||||
) : th.data ? (
|
||||
<dl className="bo-kv">
|
||||
<dt>{t('chat.lang')}</dt>
|
||||
<dd>{th.data.locale.toUpperCase()}</dd>
|
||||
<dt>Telegram</dt>
|
||||
<dd>{th.data.providerLinked ? t('chat.linked') : t('chat.notLinked')}</dd>
|
||||
</dl>
|
||||
) : (
|
||||
<Skeleton h={200} />
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientSummary({ clientId, me }: { clientId: string; me: Me }) {
|
||||
const { t, date, shared } = useT();
|
||||
const trpc = useTRPC();
|
||||
const c = useQuery(trpc.bo.clients.get.queryOptions({ id: clientId }));
|
||||
const [gift, setGift] = useState(false);
|
||||
const canManage = me.roles.includes('SUPERADMIN') || me.roles.includes('SUPPORT');
|
||||
const current = useMemo(() => {
|
||||
const subs = c.data?.subscriptions ?? [];
|
||||
return subs.find((s) => s.active && s.plan === 'ELITE') ?? subs.find((s) => s.active);
|
||||
}, [c.data]);
|
||||
if (!c.data) return <Skeleton h={260} />;
|
||||
const d = c.data;
|
||||
return (
|
||||
<div className="bo-col" style={{ gap: 16 }}>
|
||||
<div className={`bo-minipass bo-minipass--${current?.plan ?? 'none'}`}>
|
||||
<span className="bo-minipass__plan">{current ? t(`plan.${current.plan}`) : t('plan.none')}</span>
|
||||
{current && <span>{t('chat.subUntil', { date: date(current.endsAt) })}</span>}
|
||||
</div>
|
||||
<dl className="bo-kv">
|
||||
{d.phone && (
|
||||
<>
|
||||
<dt>{t('chat.phone')}</dt>
|
||||
<dd>
|
||||
<a href={`tel:${d.phone.replace(/\s/g, '')}`}>{d.phone}</a>
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
<dt>{t('chat.lang')}</dt>
|
||||
<dd>{d.locale.toUpperCase()}</dd>
|
||||
<dt>{t('chat.source')}</dt>
|
||||
<dd>{sourceLabel(t, d)}</dd>
|
||||
</dl>
|
||||
{d.tags.length > 0 && (
|
||||
<div className="bo-tags">
|
||||
{d.tags.map((tg) => (
|
||||
<span key={tg} className="bo-tag" style={{ background: 'var(--bc-learn)', color: 'var(--bc-learn-ink)' }}>
|
||||
{tg}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{d.adminNote && <p className="bo-side-panel__note">{d.adminNote}</p>}
|
||||
{d.orders.length > 0 && (
|
||||
<div>
|
||||
<h3 className="bo-h3" style={{ marginBottom: 6 }}>
|
||||
{t('chat.orders')} · {d.orders.length}
|
||||
</h3>
|
||||
<ul className="bo-side-panel__orders">
|
||||
{d.orders.slice(0, 3).map((o) => (
|
||||
<li key={o.id}>
|
||||
<span className="bo-faint bo-num">№{o.number}</span> {shared(`services.${o.service}.name`)}
|
||||
<span className="bo-faint bo-small" style={{ display: 'block' }}>
|
||||
{date(o.scheduledAt)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
<a className="bo-btn bo-btn--ghost bo-btn--sm" href={`#/clients/${d.id}`}>
|
||||
{t('chat.openCard')}
|
||||
</a>
|
||||
{canManage && (
|
||||
<button type="button" className="bo-btn bo-btn--accent bo-btn--sm" onClick={() => setGift(true)}>
|
||||
<I name="gift" size={16} />
|
||||
{t('act.gift')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{gift && <SubDialog clientId={d.id} mode="gift" current={current} onClose={() => setGift(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useToast, useTRPC } from '@barev/web';
|
||||
import { navigate, useToast, useTRPC } from '@barev/web';
|
||||
import { myChannels } from './Chats.tsx';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { Me } from '../App.tsx';
|
||||
import { errText, useT, type BoKey } from '../i18n.tsx';
|
||||
@@ -17,6 +18,7 @@ export function ClientCard({ id, me }: { id: string; me: Me }) {
|
||||
const [dlg, setDlg] = useState<{ mode: SubMode; sub?: SubTarget } | null>(null);
|
||||
const isSuper = me.roles.includes('SUPERADMIN');
|
||||
const canManage = isSuper || me.roles.includes('SUPPORT');
|
||||
const canChat = myChannels(me).length > 0;
|
||||
|
||||
if (c.isError)
|
||||
return (
|
||||
@@ -66,12 +68,15 @@ export function ClientCard({ id, me }: { id: string; me: Me }) {
|
||||
<span>{t('card.since', { date: date(d.createdAt) })}</span>
|
||||
</div>
|
||||
</div>
|
||||
{d.username && (
|
||||
<a className="bo-btn bo-btn--ghost" href={`https://t.me/${d.username}`} target="_blank" rel="noreferrer">
|
||||
<I name="telegram" size={16} />
|
||||
{t('card.telegram')}
|
||||
</a>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{canChat && <WriteButton clientId={d.id} />}
|
||||
{d.username && (
|
||||
<a className="bo-btn bo-btn--ghost" href={`https://t.me/${d.username}`} target="_blank" rel="noreferrer">
|
||||
<I name="telegram" size={16} />
|
||||
{t('card.telegram')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="bo-client-grid">
|
||||
@@ -323,6 +328,20 @@ export function ClientCard({ id, me }: { id: string; me: Me }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Открыть переписку с клиентом (или начать её) — в канале, куда он сейчас пишет */
|
||||
function WriteButton({ clientId }: { clientId: string }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const toast = useToast();
|
||||
const open = useMutation(trpc.bo.chats.openFor.mutationOptions({ onSuccess: (r) => navigate(`/chats/${r.id}`), onError: (e) => toast(errText(t, e), 'error') }));
|
||||
return (
|
||||
<button type="button" className="bo-btn bo-btn--accent" onClick={() => open.mutate({ clientId })} disabled={open.isPending}>
|
||||
<I name="chats" size={16} />
|
||||
{t('chat.write')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TagsAndNote({ id, tags, note }: { id: string; tags: string[]; note: string }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
|
||||
@@ -2,12 +2,14 @@ import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// Новая админка. API проксируется — cookie-сессия на том же origin.
|
||||
// BO_API — другой локальный API (например, второй экземпляр на соседнем порту)
|
||||
const api = process.env.BO_API ?? 'http://localhost:4000';
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true,
|
||||
allowedHosts: true,
|
||||
proxy: { '/trpc': 'http://localhost:4000', '/files': 'http://localhost:4000' },
|
||||
proxy: { '/trpc': api, '/files': api },
|
||||
},
|
||||
build: { target: 'es2022', sourcemap: true },
|
||||
});
|
||||
Reference in new issue
Block a user