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:
zabudko-ruandClaude Opus 5.5 committed 2026-10-21 01:50:37 +04:00
1 parent d452824c62
commit 23ffb2bf1d
46 files changed
+4176 -144

No files matched your search

+945
View File
@@ -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>
);
}
+26 -7
View File
@@ -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();