Чат с клиентом из карточки через бот анкеты

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-26 03:17:12 +04:00
1 parent 7fdabd07a4
commit dba03b8970
19 files changed
+5145 -62

No files matched your search

+154
View File
@@ -0,0 +1,154 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Paperclip, Send, X } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { api, ApiError } from '../api.ts';
import { ErrorNote } from '../components/ui.tsx';
import { useAction } from '../hooks.ts';
import { useCrm } from './i18n.ts';
interface ChatMessage {
id: number;
direction: 'in' | 'out';
text: string | null;
fileKind: 'photo' | 'file' | null;
fileName: string | null;
authorName: string | null;
at: string;
}
const MAX_FILE = 20 * 1024 * 1024;
const size = (bytes: number) => (bytes < 1024 * 1024 ? `${Math.max(1, Math.round(bytes / 1024))} КБ` : `${(bytes / 1024 / 1024).toFixed(1)} МБ`);
/**
* Чат с клиентом из карточки: переписка в боте анкеты. Сообщения клиента слева, ответы агентов справа.
* Открытый чат обновляется сам; у ответственного открытие отмечает сообщения клиента прочитанными.
*/
export function ClientChat({ clientId, canWrite }: { clientId: number; canWrite: boolean }) {
const { c, dateTime } = useCrm();
const x = c.ch;
const qc = useQueryClient();
const chat = useQuery({
queryKey: ['client', clientId, 'messages'],
queryFn: () => api<{ available: boolean; messages: ChatMessage[] }>(`/clients/${clientId}/messages`),
refetchInterval: 5_000,
});
const [text, setText] = useState('');
const [file, setFile] = useState<File | null>(null);
const picker = useRef<HTMLInputElement>(null);
const list = useRef<HTMLOListElement>(null);
// Прочитанные сообщения убирают счётчики в карточке и в списке клиентов.
const count = chat.data?.messages.length ?? 0;
const lastIn = chat.data?.messages.filter((m) => m.direction === 'in').at(-1)?.id;
useEffect(() => {
if (lastIn === undefined) return;
void qc.invalidateQueries({ queryKey: ['client', clientId], exact: true });
void qc.invalidateQueries({ queryKey: ['clients'] });
}, [lastIn, clientId, qc]);
// Новые сообщения — прокрутка вниз (внутри чата, а не всей карточки).
useEffect(() => {
if (list.current) list.current.scrollTop = list.current.scrollHeight;
}, [count]);
// Файл уходит телом запроса как есть; текст в этом случае становится подписью к нему.
const send = useAction(async () => {
if (!file) return api(`/clients/${clientId}/messages`, { method: 'POST', body: { text } });
if (file.size > MAX_FILE) throw new ApiError(400, 'FILE_TOO_LARGE');
const query = new URLSearchParams({ name: file.name, type: file.type || 'application/octet-stream' });
if (text.trim()) query.set('text', text.trim().slice(0, 900));
const res = await fetch(`/api/clients/${clientId}/messages/file?${query}`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/octet-stream' },
body: file,
});
if (!res.ok) throw new ApiError(res.status, ((await res.json().catch(() => ({}))) as { code?: string }).code ?? 'INTERNAL');
return res.json();
}, ['client', clientId, 'messages']);
const ready = Boolean(text.trim() || file);
const submit = () => ready && !send.isPending && send.mutate(undefined, { onSuccess: () => { setText(''); setFile(null); } });
const d = chat.data;
const fileUrl = (m: ChatMessage) => `/api/clients/${clientId}/messages/${m.id}/file`;
return (
<section aria-labelledby={`chat-${clientId}`}>
<h3 id={`chat-${clientId}`} className="label">{x.title}</h3>
<div className="overflow-hidden rounded-xl border border-line">
{!d ? (
<div className="p-3"><ErrorNote error={chat.error} /></div>
) : (
<>
<ol ref={list} className="max-h-96 min-h-24 space-y-2.5 overflow-y-auto px-3 py-3" aria-live="polite">
{d.messages.length === 0 && <li className="py-4 text-center text-sm text-muted">{d.available ? x.empty : x.unavailable}</li>}
{d.messages.map((m) => {
const out = m.direction === 'out';
return (
<li key={m.id} className={`flex ${out ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[85%] rounded-2xl px-3.5 py-2 ${out ? 'bg-capture/15' : 'bg-raised'}`}>
<p className="mb-0.5 text-[11px] font-medium text-muted">{out ? (m.authorName ?? '—') : x.client}</p>
{m.fileKind === 'photo' && (
<a href={fileUrl(m)} target="_blank" rel="noopener noreferrer">
<img src={fileUrl(m)} alt={x.photo} loading="lazy" className="mb-1.5 max-h-60 rounded-lg" />
</a>
)}
{m.fileKind === 'file' && (
<a href={fileUrl(m)} className="mb-1.5 flex items-center gap-2 break-all text-signal hover:underline">
<Paperclip size={15} className="shrink-0" aria-hidden="true" />
{m.fileName ?? x.file}
</a>
)}
{m.text && <p className="whitespace-pre-wrap break-words text-sm">{m.text}</p>}
<p className="mt-1 text-right text-[11px] tabular-nums text-muted">{dateTime(m.at)}</p>
</div>
</li>
);
})}
</ol>
{canWrite && d.available && (
<form className="border-t border-line p-2.5" onSubmit={(e) => { e.preventDefault(); submit(); }}>
<ErrorNote error={send.error} />
{file && (
<p className="mb-2 flex items-center gap-2 text-sm">
<Paperclip size={15} className="shrink-0 text-signal" aria-hidden="true" />
<span className="min-w-0 truncate">{file.name}</span>
<span className="shrink-0 tabular-nums text-muted">{size(file.size)}</span>
<button type="button" className="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg text-muted hover:text-text" aria-label={x.removeFile} title={x.removeFile} onClick={() => setFile(null)}>
<X size={15} />
</button>
</p>
)}
<div className="flex items-end gap-2">
<input ref={picker} type="file" className="sr-only" tabIndex={-1} aria-hidden="true" onChange={(e) => { setFile(e.target.files?.[0] ?? null); e.target.value = ''; }} />
<button type="button" className="btn btn-ghost shrink-0 px-3" aria-label={x.attach} title={x.attach} onClick={() => picker.current?.click()}>
<Paperclip size={17} aria-hidden="true" />
</button>
<textarea
className="field max-h-40 min-h-11 flex-1 py-2.5"
rows={1}
aria-label={x.placeholder}
placeholder={file ? x.caption : x.placeholder}
maxLength={file ? 900 : 3500}
value={text}
onPaste={(e) => {
// Скриншот из буфера обмена прикладывается как файл.
const pasted = [...e.clipboardData.files][0];
if (pasted) { e.preventDefault(); setFile(pasted); }
}}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) submit(); }}
/>
<button className="btn btn-primary shrink-0" disabled={!ready || send.isPending} title={x.sendHint} aria-label={x.send}>
<Send size={16} aria-hidden="true" />
</button>
</div>
</form>
)}
</>
)}
</div>
{d?.available && <p className="mt-1.5 text-xs text-muted">{x.hint}</p>}
</section>
);
}
+3
View File
@@ -10,6 +10,7 @@ import { ClientDialog, useRequestLine } from './ClientsPage.tsx';
import { CommissionBlock } from './commission.tsx';
import { ClientPhotos, useHouseholdLine } from './household.tsx';
import { ClientPage } from './showcase.tsx';
import { ClientChat } from './ClientChat.tsx';
import { useCrm } from './i18n.ts';
import { CommentBox, DealNote, Drawer, History, NextAction, StatusChip, useStatusChange } from './parts.tsx';
@@ -120,6 +121,8 @@ export function ClientDrawer({ id, me, onClose }: { id: number; me: Me; onClose:
)}
</section>
{cl.chat && (cl.chat.available || cl.chat.hasMessages) && <ClientChat clientId={id} canWrite={canWork} />}
<section>
<h3 className="label">{x.request}</h3>
<p className={request ? '' : 'text-sm text-muted'}>{request || x.noRequest}</p>
+16 -4
View File
@@ -1,6 +1,6 @@
import { CLIENT_KINDS, CURRENCIES, PROPERTY_TYPES, REGIONS, RENT_TERMS, YEREVAN_DISTRICTS, type ClientKind, type ClientRequest, type RentTerm } from '@lr/shared';
import { useQuery } from '@tanstack/react-query';
import { Columns3, List, Plus, Search, Send } from 'lucide-react';
import { Columns3, List, MessageSquare, Plus, Search, Send } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router';
import { api, type Me } from '../api.ts';
@@ -305,7 +305,7 @@ export function ClientsPage({ me }: { me: Me }) {
<Kanban items={clients.data!} statuses={pipeline.data.statuses} onOpen={open} onMove={status.request} canDrag={canMove}>
{(cl) => (
<>
<p className="font-medium">{cl.name}</p>
<p className="flex items-center gap-2 font-medium">{cl.name}{Boolean(cl.unreadMessages) && <UnreadBadge n={cl.unreadMessages!} />}</p>
<p className="mt-0.5 text-xs text-muted">{x.kind[cl.kind]}{cl.leadsCount ? ` · ${x.objects(cl.leadsCount)}` : ''}</p>
{requestLine(cl.request) && <p className="mt-1.5 line-clamp-2 text-sm leading-snug text-muted">{requestLine(cl.request)}</p>}
{tab === 'all' && <p className="mt-1.5 truncate text-xs text-muted">{cl.assigneeName}</p>}
@@ -321,7 +321,7 @@ export function ClientsPage({ me }: { me: Me }) {
<li key={cl.id}>
<button type="button" className="card flex w-full cursor-pointer items-start justify-between gap-3 p-3 text-left" onClick={() => open(cl.id)}>
<span className="min-w-0">
<span className="block font-medium">{cl.name}</span>
<span className="flex items-center gap-2 font-medium">{cl.name}{Boolean(cl.unreadMessages) && <UnreadBadge n={cl.unreadMessages!} />}</span>
<span className="block truncate text-xs text-muted">{[x.kind[cl.kind], requestLine(cl.request)].filter(Boolean).join(' · ')}</span>
</span>
<StatusChip status={statusOf(cl)} />
@@ -342,7 +342,7 @@ export function ClientsPage({ me }: { me: Me }) {
{clients.data!.map((cl) => (
<tr key={cl.id} className="cursor-pointer transition-colors hover:bg-raised/50" onClick={() => open(cl.id)}>
<td className="px-4 py-3">
<button type="button" className="block cursor-pointer text-left font-medium" onClick={(e) => { e.stopPropagation(); open(cl.id); }}>{cl.name}</button>
<button type="button" className="flex cursor-pointer items-center gap-2 text-left font-medium" onClick={(e) => { e.stopPropagation(); open(cl.id); }}>{cl.name}{Boolean(cl.unreadMessages) && <UnreadBadge n={cl.unreadMessages!} />}</button>
<p className="text-xs text-muted">{[x.kind[cl.kind], cl.phone].filter(Boolean).join(' · ')}</p>
</td>
<td className="max-w-xs px-4 py-3 text-muted"><span className="line-clamp-2">{requestLine(cl.request) || '—'}</span></td>
@@ -364,3 +364,15 @@ export function ClientsPage({ me }: { me: Me }) {
</>
);
}
/** Счётчик новых сообщений клиента в чате. */
function UnreadBadge({ n }: { n: number }) {
const { c } = useCrm();
return (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-signal/15 px-1.5 py-0.5 text-[11px] font-semibold leading-none text-signal tabular-nums" title={c.ch.unread(n)}>
<MessageSquare size={11} aria-hidden="true" />
{n}
<span className="sr-only">{c.ch.unread(n)}</span>
</span>
);
}
+4
View File
@@ -108,6 +108,8 @@ export interface Client {
nextActionNote: string | null;
createdAt: string;
leadsCount?: number;
/** Непрочитанные сообщения клиента в чате. */
unreadMessages?: number;
departmentName?: string | null;
}
@@ -120,6 +122,8 @@ export interface ClientLead extends Pick<Lead, 'id' | 'title' | 'price' | 'curre
export interface ClientCard extends Client {
photos: { id: number; createdAt: string }[];
/** Чат с клиентом в боте анкеты; null — чат виден только ответственному и руководителю. */
chat: { available: boolean; unread: number; hasMessages: boolean } | null;
pageUrl: string | null;
commission: CommissionInfo;
viewings: { leadId: number; day: string; note: string | null; title: string }[];
+27 -3
View File
@@ -206,6 +206,14 @@ const ru = {
photoProgress: (done: number, total: number) => `Проверено ${done} из ${total}`, photoStarted: 'Запуск', photoNone: 'За сутки нет объявлений с лидами — догружать нечего',
photoOk: 'В порядке', photoCover: 'Была только обложка', photoBroken: 'Фото не открывались', photoLoaded: 'Загружено заново', photoRemoved: 'Сняты с List.am', photoFailed: 'Не удалось прочитать', photoError: 'Последняя ошибка', photoStoppedKey: 'Остановлено: ключ ридера Jina не работает — скорее всего, кончился баланс. Пополните баланс или замените ключ в настройках ниже и нажмите кнопку снова. Без ключа полные галереи не загрузить.',
},
ch: {
title: 'Чат с клиентом', hint: 'Переписка в Telegram-боте анкеты: клиент видит ваши сообщения с вашим именем.',
empty: 'Сообщений пока нет. Напишите клиенту — он получит сообщение в боте, где заполнял анкету.',
unavailable: 'Клиент не заполнял анкету в Telegram-боте — написать ему отсюда нельзя.',
placeholder: 'Сообщение клиенту', caption: 'Подпись к файлу (необязательно)', send: 'Отправить', sendHint: 'Ctrl + Enter — отправить',
attach: 'Приложить фото или файл', removeFile: 'Убрать файл', photo: 'Фото', file: 'Файл', client: 'Клиент',
unread: (n: number) => `Новых сообщений: ${n}`,
},
dp: {
favorite: 'Избранное', unseen: 'Не просмотренные', addFav: 'В избранное', removeFav: 'Убрать из избранного', unseenMark: 'Не просмотрен',
currency: 'Валюта', cur: { AMD: 'АМД', USD: 'ДОЛ' } as Record<string, string>, priceHint: 'Цены в другой валюте пересчитываются по курсу',
@@ -232,7 +240,7 @@ const ru = {
PAIR_NOT_LINKED: 'Сначала прикрепите объект к клиенту', DEAL_HAS_PAIR: 'По этой сделке уже указана другая пара. Сначала отмените ту сделку',
BAD_STATUS: 'Такого статуса нет в воронке', SYSTEM_STATUS: 'Системный статус удалить нельзя', STATUS_IN_USE: 'В статусе есть карточки: выберите, куда их перевести',
SYSTEM_REASON: 'Системную причину удалить нельзя', LAST_REASON: 'Нужна хотя бы одна причина отказа',
DEAL_CLOSED: 'Сделка закрыта: статус меняет руководитель', TELEGRAM_DISABLED: 'Бот не настроен', PARSER_DISABLED: 'Парсер не настроен', BAD_LISTAM_URL: 'Это не ссылка на объявление List.am', LISTAM_NOT_FOUND: 'Такого объявления на List.am нет — возможно, его сняли', LISTAM_UNAVAILABLE: 'Не удалось прочитать объявление с List.am, попробуйте ещё раз', HAS_DEAL: 'У карточки есть сделка: удалить её может только суперадмин', GROUP_BOT_NOT_SET: 'Бот не настроен', GROUP_SEND_FAILED: 'Не удалось отправить в Telegram, попробуйте ещё раз', TELEGRAM_NOT_LINKED: 'Telegram не привязан к боту', BOT_CHAT_UNAVAILABLE: 'Бот не может вам написать: откройте бота и нажмите «Start»', BOT_SEND_FAILED: 'Не удалось отправить в бот, попробуйте ещё раз', DEPARTMENT_NOT_EMPTY: 'В отделе есть сотрудники, клиенты или объекты в работе — сначала переведите их', BAD_DEPARTMENT: 'Такого отдела нет', DEPARTMENT_HAS_LEAD: 'В отделе этого сотрудника уже есть карточка по этому объявлению', PHOTO_REPAIR_RUNNING: 'Догрузка фото уже идёт',
DEAL_CLOSED: 'Сделка закрыта: статус меняет руководитель', TELEGRAM_DISABLED: 'Бот не настроен', PARSER_DISABLED: 'Парсер не настроен', BAD_LISTAM_URL: 'Это не ссылка на объявление List.am', LISTAM_NOT_FOUND: 'Такого объявления на List.am нет — возможно, его сняли', LISTAM_UNAVAILABLE: 'Не удалось прочитать объявление с List.am, попробуйте ещё раз', HAS_DEAL: 'У карточки есть сделка: удалить её может только суперадмин', GROUP_BOT_NOT_SET: 'Бот не настроен', GROUP_SEND_FAILED: 'Не удалось отправить в Telegram, попробуйте ещё раз', TELEGRAM_NOT_LINKED: 'Telegram не привязан к боту', BOT_CHAT_UNAVAILABLE: 'Бот не может вам написать: откройте бота и нажмите «Start»', BOT_SEND_FAILED: 'Не удалось отправить в бот, попробуйте ещё раз', DEPARTMENT_NOT_EMPTY: 'В отделе есть сотрудники, клиенты или объекты в работе — сначала переведите их', BAD_DEPARTMENT: 'Такого отдела нет', DEPARTMENT_HAS_LEAD: 'В отделе этого сотрудника уже есть карточка по этому объявлению', PHOTO_REPAIR_RUNNING: 'Догрузка фото уже идёт', NO_CLIENT_CHAT: 'Клиент не заполнял анкету в боте — написать ему нельзя', CLIENT_BLOCKED_BOT: 'Клиент остановил или заблокировал бота — сообщение не доставлено', CLIENT_CHAT_UNAVAILABLE: 'Чат с клиентом недоступен в Telegram', CLIENT_BOT_DISABLED: 'Бот анкеты не настроен', FILE_NOT_FOUND: 'Файл не найден в Telegram',
MONTH_PAID: 'Месяц уже закрыт выплатой. Чтобы изменить его, отмените отметку', PAYOUT_NOT_LAST: 'Отменить можно только последнюю выплату сотрудника',
} as Record<string, string>,
};
@@ -445,6 +453,14 @@ const hy: Dict = {
photoProgress: (done, total) => `Ստուգված է ${done}-ը ${total}-ից`, photoStarted: 'Գործարկում', photoNone: 'Վերջին օրում լիդով հայտարարություններ չկան՝ լրացնելու բան չկա',
photoOk: 'Կարգին են', photoCover: 'Միայն շապիկ էր', photoBroken: 'Լուսանկարները չէին բացվում', photoLoaded: 'Բեռնված է նորից', photoRemoved: 'Հանված են List.am-ից', photoFailed: 'Չհաջողվեց կարդալ', photoError: 'Վերջին սխալը', photoStoppedKey: 'Կանգնեցված է․ Jina ընթերցիչի բանալին չի աշխատում — հավանաբար մնացորդը սպառվել է։ Համալրեք մնացորդը կամ փոխեք բանալին ստորև կարգավորումներում և սեղմեք կոճակը կրկին։ Առանց բանալու ամբողջ պատկերասրահները չեն բեռնվի։',
},
ch: {
title: 'Զրույց հաճախորդի հետ', hint: 'Նամակագրություն հարցաթերթիկի Telegram-բոտում․ հաճախորդը տեսնում է Ձեր հաղորդագրությունները Ձեր անունով։',
empty: 'Հաղորդագրություններ դեռ չկան։ Գրեք հաճախորդին՝ նա կստանա հաղորդագրությունը այն բոտում, որտեղ լրացրել է հարցաթերթիկը։',
unavailable: 'Հաճախորդը չի լրացրել հարցաթերթիկը Telegram-բոտում՝ այստեղից նրան գրել հնարավոր չէ։',
placeholder: 'Հաղորդագրություն հաճախորդին', caption: 'Ֆայլի ստորագրություն (ըստ ցանկության)', send: 'Ուղարկել', sendHint: 'Ctrl + Enter՝ ուղարկել',
attach: 'Կցել լուսանկար կամ ֆայլ', removeFile: 'Հեռացնել ֆայլը', photo: 'Լուսանկար', file: 'Ֆայլ', client: 'Հաճախորդ',
unread: (n) => `Նոր հաղորդագրություններ՝ ${n}`,
},
dp: {
favorite: 'Ընտրված', unseen: 'Չդիտված', addFav: 'Ավելացնել ընտրվածներին', removeFav: 'Հանել ընտրվածներից', unseenMark: 'Չդիտված',
currency: 'Արժույթ', cur: { AMD: 'ՀՀԴ', USD: 'ԴՈԼ' } as Record<string, string>, priceHint: 'Այլ արժույթով գները վերահաշվարկվում են փոխարժեքով',
@@ -472,7 +488,7 @@ const hy: Dict = {
PAIR_NOT_LINKED: 'Նախ կցեք օբյեկտը հաճախորդին', DEAL_HAS_PAIR: 'Այս գործարքում արդեն նշված է այլ զույգ։ Նախ չեղարկեք այն',
BAD_STATUS: 'Ձագարում նման կարգավիճակ չկա', SYSTEM_STATUS: 'Համակարգային կարգավիճակը հնարավոր չէ ջնջել', STATUS_IN_USE: 'Կարգավիճակում քարտեր կան. ընտրեք, թե ուր տեղափոխել',
SYSTEM_REASON: 'Համակարգային պատճառը հնարավոր չէ ջնջել', LAST_REASON: 'Անհրաժեշտ է առնվազն մեկ մերժման պատճառ',
DEAL_CLOSED: 'Գործարքը փակված է. կարգավիճակը փոխում է ղեկավարը', TELEGRAM_DISABLED: 'Բոտը կարգավորված չէ', PARSER_DISABLED: 'Պարսերը կարգավորված չէ', BAD_LISTAM_URL: 'Սա List.am-ի հայտարարության հղում չէ', LISTAM_NOT_FOUND: 'Նման հայտարարություն List.am-ում չկա — հնարավոր է՝ հանվել է', LISTAM_UNAVAILABLE: 'Չհաջողվեց կարդալ հայտարարությունը List.am-ից, փորձեք կրկին', HAS_DEAL: 'Քարտն ունի գործարք․ այն կարող է ջնջել միայն սուպերադմինը', GROUP_BOT_NOT_SET: 'Բոտը կարգավորված չէ', GROUP_SEND_FAILED: 'Չհաջողվեց ուղարկել Telegram, փորձեք կրկին', TELEGRAM_NOT_LINKED: 'Telegram-ը կապված չէ բոտին', BOT_CHAT_UNAVAILABLE: 'Բոտը չի կարող գրել ձեզ․ բացեք բոտը և սեղմեք «Start»', BOT_SEND_FAILED: 'Չհաջողվեց ուղարկել բոտին, փորձեք կրկին', DEPARTMENT_NOT_EMPTY: 'Բաժնում կան աշխատակիցներ, հաճախորդներ կամ աշխատանքի մեջ գտնվող օբյեկտներ․ նախ տեղափոխեք դրանք', BAD_DEPARTMENT: 'Նման բաժին չկա', DEPARTMENT_HAS_LEAD: 'Այս աշխատակցի բաժնում արդեն կա այս հայտարարության քարտը', PHOTO_REPAIR_RUNNING: 'Լուսանկարների լրացումն արդեն ընթացքում է',
DEAL_CLOSED: 'Գործարքը փակված է. կարգավիճակը փոխում է ղեկավարը', TELEGRAM_DISABLED: 'Բոտը կարգավորված չէ', PARSER_DISABLED: 'Պարսերը կարգավորված չէ', BAD_LISTAM_URL: 'Սա List.am-ի հայտարարության հղում չէ', LISTAM_NOT_FOUND: 'Նման հայտարարություն List.am-ում չկա — հնարավոր է՝ հանվել է', LISTAM_UNAVAILABLE: 'Չհաջողվեց կարդալ հայտարարությունը List.am-ից, փորձեք կրկին', HAS_DEAL: 'Քարտն ունի գործարք․ այն կարող է ջնջել միայն սուպերադմինը', GROUP_BOT_NOT_SET: 'Բոտը կարգավորված չէ', GROUP_SEND_FAILED: 'Չհաջողվեց ուղարկել Telegram, փորձեք կրկին', TELEGRAM_NOT_LINKED: 'Telegram-ը կապված չէ բոտին', BOT_CHAT_UNAVAILABLE: 'Բոտը չի կարող գրել ձեզ․ բացեք բոտը և սեղմեք «Start»', BOT_SEND_FAILED: 'Չհաջողվեց ուղարկել բոտին, փորձեք կրկին', DEPARTMENT_NOT_EMPTY: 'Բաժնում կան աշխատակիցներ, հաճախորդներ կամ աշխատանքի մեջ գտնվող օբյեկտներ․ նախ տեղափոխեք դրանք', BAD_DEPARTMENT: 'Նման բաժին չկա', DEPARTMENT_HAS_LEAD: 'Այս աշխատակցի բաժնում արդեն կա այս հայտարարության քարտը', PHOTO_REPAIR_RUNNING: 'Լուսանկարների լրացումն արդեն ընթացքում է', NO_CLIENT_CHAT: 'Հաճախորդը չի լրացրել հարցաթերթիկը բոտում՝ նրան գրել հնարավոր չէ', CLIENT_BLOCKED_BOT: 'Հաճախորդը կանգնեցրել կամ արգելափակել է բոտը՝ հաղորդագրությունը չի առաքվել', CLIENT_CHAT_UNAVAILABLE: 'Զրույցը հաճախորդի հետ հասանելի չէ Telegram-ում', CLIENT_BOT_DISABLED: 'Հարցաթերթիկի բոտը կարգավորված չէ', FILE_NOT_FOUND: 'Ֆայլը չի գտնվել Telegram-ում',
},
};
@@ -682,6 +698,14 @@ const en: Dict = {
photoProgress: (done, total) => `Checked ${done} of ${total}`, photoStarted: 'Started', photoNone: 'No listings with leads in the last 24 hours — nothing to load',
photoOk: 'Fine', photoCover: 'Cover only', photoBroken: 'Photos did not open', photoLoaded: 'Reloaded', photoRemoved: 'Removed from List.am', photoFailed: 'Could not read', photoError: 'Last error', photoStoppedKey: 'Stopped: the Jina reader key does not work — most likely the balance ran out. Top up or replace the key in the settings below and press the button again. Full galleries cannot be loaded without a key.',
},
ch: {
title: 'Chat with the client', hint: 'Conversation in the questionnaire Telegram bot: the client sees your messages with your name.',
empty: 'No messages yet. Write to the client — they will get it in the bot where they filled in the questionnaire.',
unavailable: 'The client did not fill in the questionnaire in the Telegram bot, so you cannot write to them from here.',
placeholder: 'Message to the client', caption: 'File caption (optional)', send: 'Send', sendHint: 'Ctrl + Enter to send',
attach: 'Attach a photo or file', removeFile: 'Remove file', photo: 'Photo', file: 'File', client: 'Client',
unread: (n) => `New messages: ${n}`,
},
dp: {
favorite: 'Favourites', unseen: 'Not viewed', addFav: 'Add to favourites', removeFav: 'Remove from favourites', unseenMark: 'Not viewed',
currency: 'Currency', cur: { AMD: 'AMD', USD: 'USD' } as Record<string, string>, priceHint: 'Prices in another currency are converted at the current rate',
@@ -709,7 +733,7 @@ const en: Dict = {
PAIR_NOT_LINKED: 'Attach the property to the client first', DEAL_HAS_PAIR: 'This deal already has another pair. Cancel that deal first',
BAD_STATUS: 'No such status in the pipeline', SYSTEM_STATUS: 'A system status cannot be deleted', STATUS_IN_USE: 'The status has cards: choose where to move them',
SYSTEM_REASON: 'A system reason cannot be deleted', LAST_REASON: 'At least one reject reason is required',
DEAL_CLOSED: 'The deal is closed: only the manager changes the status', TELEGRAM_DISABLED: 'The bot is not configured', PARSER_DISABLED: 'The parser is not configured', BAD_LISTAM_URL: 'This is not a List.am listing link', LISTAM_NOT_FOUND: 'There is no such listing on List.am — it may have been removed', LISTAM_UNAVAILABLE: 'Could not read the listing from List.am, please try again', HAS_DEAL: 'The card has a deal: only the superadmin can delete it', GROUP_BOT_NOT_SET: 'Bot is not set up', GROUP_SEND_FAILED: 'Could not send to Telegram, please try again', TELEGRAM_NOT_LINKED: 'Telegram is not linked to the bot', BOT_CHAT_UNAVAILABLE: 'The bot cannot message you: open the bot and press “Start”', BOT_SEND_FAILED: 'Could not send to the bot, please try again', DEPARTMENT_NOT_EMPTY: 'The department has employees, clients or properties in work — move them first', BAD_DEPARTMENT: 'No such department', DEPARTMENT_HAS_LEAD: 'This employee’s department already has a card for this listing', PHOTO_REPAIR_RUNNING: 'Photo top-up is already running',
DEAL_CLOSED: 'The deal is closed: only the manager changes the status', TELEGRAM_DISABLED: 'The bot is not configured', PARSER_DISABLED: 'The parser is not configured', BAD_LISTAM_URL: 'This is not a List.am listing link', LISTAM_NOT_FOUND: 'There is no such listing on List.am — it may have been removed', LISTAM_UNAVAILABLE: 'Could not read the listing from List.am, please try again', HAS_DEAL: 'The card has a deal: only the superadmin can delete it', GROUP_BOT_NOT_SET: 'Bot is not set up', GROUP_SEND_FAILED: 'Could not send to Telegram, please try again', TELEGRAM_NOT_LINKED: 'Telegram is not linked to the bot', BOT_CHAT_UNAVAILABLE: 'The bot cannot message you: open the bot and press “Start”', BOT_SEND_FAILED: 'Could not send to the bot, please try again', DEPARTMENT_NOT_EMPTY: 'The department has employees, clients or properties in work — move them first', BAD_DEPARTMENT: 'No such department', DEPARTMENT_HAS_LEAD: 'This employee’s department already has a card for this listing', PHOTO_REPAIR_RUNNING: 'Photo top-up is already running', NO_CLIENT_CHAT: 'The client did not fill in the questionnaire in the bot, so you cannot write to them', CLIENT_BLOCKED_BOT: 'The client stopped or blocked the bot — the message was not delivered', CLIENT_CHAT_UNAVAILABLE: 'The chat with the client is unavailable in Telegram', CLIENT_BOT_DISABLED: 'The questionnaire bot is not configured', FILE_NOT_FOUND: 'File not found in Telegram',
},
};