Чат с клиентом из карточки через бот анкеты
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
7fdabd07a4
commit
dba03b8970
19 files changed
+5145
-62
No files matched your search
@@ -104,6 +104,10 @@ pnpm dev:web # http://localhost:5174
|
|||||||
боте или кнопка в разделе «Клиенты»). Соискатель отвечает на вопросы — в CRM появляется клиент,
|
боте или кнопка в разделе «Клиенты»). Соискатель отвечает на вопросы — в CRM появляется клиент,
|
||||||
закреплённый за агентом, а агент получает в бот вопрос «Показать подходящие объекты?».
|
закреплённый за агентом, а агент получает в бот вопрос «Показать подходящие объекты?».
|
||||||
Телефон обязателен: пропустить нельзя, номер отправляется кнопкой «Отправить мой номер» или пишется вручную.
|
Телефон обязателен: пропустить нельзя, номер отправляется кнопкой «Отправить мой номер» или пишется вручную.
|
||||||
|
- **Чат с клиентом** (карточка клиента): переписка с клиентом, заполнившим анкету, через клиентский бот.
|
||||||
|
Пишут ответственный агент и руководитель — текст, фото и файлы; клиент видит ответ с именем агента.
|
||||||
|
Сообщения клиента попадают в чат (фото — ещё и в фото карточки), агенту приходит уведомление в основной
|
||||||
|
бот с кнопкой «Открыть»; в списке клиентов — счётчик непрочитанных. Вложения хранятся у Telegram.
|
||||||
- **Страница подборки для клиента**: `<кабинет>/<компания>/<код>` — объекты, прикреплённые к клиенту,
|
- **Страница подборки для клиента**: `<кабинет>/<компания>/<код>` — объекты, прикреплённые к клиенту,
|
||||||
и запись на показ по дням. Адрес и ссылка на List.am на странице скрыты. Запись на показ приходит
|
и запись на показ по дням. Адрес и ссылка на List.am на странице скрыты. Запись на показ приходит
|
||||||
создателю ссылки в бот и появляется в карточке клиента.
|
создателю ссылки в бот и появляется в карточке клиента.
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
CREATE TABLE "client_messages" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"client_id" integer NOT NULL,
|
||||||
|
"company_id" integer NOT NULL,
|
||||||
|
"direction" text NOT NULL,
|
||||||
|
"author_id" integer,
|
||||||
|
"text" text,
|
||||||
|
"file_id" text,
|
||||||
|
"file_kind" text,
|
||||||
|
"file_name" text,
|
||||||
|
"read_at" timestamp with time zone,
|
||||||
|
"at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "client_messages" ADD CONSTRAINT "client_messages_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "client_messages" ADD CONSTRAINT "client_messages_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "client_messages" ADD CONSTRAINT "client_messages_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "client_messages_client_idx" ON "client_messages" USING btree ("client_id","id");
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -134,6 +134,13 @@
|
|||||||
"when": 1792954204010,
|
"when": 1792954204010,
|
||||||
"tag": "0018_elite_magdalene",
|
"tag": "0018_elite_magdalene",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 19,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1792969839044,
|
||||||
|
"tag": "0019_strange_ultimates",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+14
-2
@@ -76,6 +76,8 @@ import { registerBarevRoutes } from './barev/routes.ts';
|
|||||||
import { validSlug } from './showcase/service.ts';
|
import { validSlug } from './showcase/service.ts';
|
||||||
import { registerSupportRoutes } from './support/routes.ts';
|
import { registerSupportRoutes } from './support/routes.ts';
|
||||||
import { telegramFileSender, type FileSender } from './support/service.ts';
|
import { telegramFileSender, type FileSender } from './support/service.ts';
|
||||||
|
import { registerClientChatRoutes, telegramClientDownload, type ClientBot } from './clients/chat.ts';
|
||||||
|
import { HttpTelegram } from './telegram/client.ts';
|
||||||
import { contactOf, enqueueForSuperadmins } from './telegram/outbox.ts';
|
import { contactOf, enqueueForSuperadmins } from './telegram/outbox.ts';
|
||||||
import { registerBotStartRoutes } from './telegram/start.ts';
|
import { registerBotStartRoutes } from './telegram/start.ts';
|
||||||
import { enqueueText } from './telegram/outbox.ts';
|
import { enqueueText } from './telegram/outbox.ts';
|
||||||
@@ -118,6 +120,8 @@ export interface AppDeps {
|
|||||||
fetchImage?: ImageFetcher;
|
fetchImage?: ImageFetcher;
|
||||||
/** Отправка в группу компании в Telegram; в тестах подменяется. */
|
/** Отправка в группу компании в Telegram; в тестах подменяется. */
|
||||||
groupSender?: GroupSender;
|
groupSender?: GroupSender;
|
||||||
|
/** Клиентский бот для чата с клиентом из карточки; в тестах подменяется. */
|
||||||
|
clientBot?: ClientBot | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Email = z.string().trim().toLowerCase().pipe(z.email());
|
const Email = z.string().trim().toLowerCase().pipe(z.email());
|
||||||
@@ -128,7 +132,7 @@ const Lang_ = z.enum(LANGS);
|
|||||||
const langOf = (code: string | undefined): Lang =>
|
const langOf = (code: string | undefined): Lang =>
|
||||||
(LANGS as readonly string[]).includes(code ?? '') ? (code as Lang) : code === 'am' ? 'hy' : 'ru';
|
(LANGS as readonly string[]).includes(code ?? '') ? (code as Lang) : code === 'am' ? 'hy' : 'ru';
|
||||||
|
|
||||||
export function buildApp({ db, config, mailer = consoleMailer, source = null, payments = null, sendFile, fetchImage, groupSender }: AppDeps) {
|
export function buildApp({ db, config, mailer = consoleMailer, source = null, payments = null, sendFile, fetchImage, groupSender, clientBot }: AppDeps) {
|
||||||
const app = Fastify({ logger: false });
|
const app = Fastify({ logger: false });
|
||||||
void app.register(cookie);
|
void app.register(cookie);
|
||||||
// Файлы (вложения поддержки, фото клиентов) приходят телом запроса как есть; предел размера задаёт каждый маршрут.
|
// Файлы (вложения поддержки, фото клиентов) приходят телом запроса как есть; предел размера задаёт каждый маршрут.
|
||||||
@@ -866,7 +870,15 @@ export function buildApp({ db, config, mailer = consoleMailer, source = null, pa
|
|||||||
});
|
});
|
||||||
|
|
||||||
registerLeadRoutes(app, db, { member, manager, writable }, config, fetchImage, source, groupSender);
|
registerLeadRoutes(app, db, { member, manager, writable }, config, fetchImage, source, groupSender);
|
||||||
registerClientRoutes(app, db, { member, manager, writable }, config);
|
// Клиентский бот для чата из карточки клиента: без токена чат выключен.
|
||||||
|
const chatBot: ClientBot | null =
|
||||||
|
clientBot !== undefined
|
||||||
|
? clientBot
|
||||||
|
: config.clientBotToken
|
||||||
|
? { tg: new HttpTelegram(config.clientBotToken), sendFile: telegramFileSender(config.clientBotToken), download: telegramClientDownload(config.clientBotToken) }
|
||||||
|
: null;
|
||||||
|
registerClientRoutes(app, db, { member, manager, writable }, config, chatBot);
|
||||||
|
registerClientChatRoutes(app, db, { member, manager, writable }, chatBot);
|
||||||
registerDepartmentRoutes(app, db, { member, manager, writable });
|
registerDepartmentRoutes(app, db, { member, manager, writable });
|
||||||
registerPipelineRoutes(app, db, { member, manager, writable });
|
registerPipelineRoutes(app, db, { member, manager, writable });
|
||||||
registerArchiveRoutes(app, db, { member, manager, writable });
|
registerArchiveRoutes(app, db, { member, manager, writable });
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { and, asc, desc, eq, isNull, sql } from 'drizzle-orm';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { Db } from '../db/client.ts';
|
||||||
|
import { clientMessages, intakeSessions, users } from '../db/schema.ts';
|
||||||
|
import { intakeTexts } from '../intake/texts.ts';
|
||||||
|
import type { Guards, MemberCtx } from '../leads/routes.ts';
|
||||||
|
import { badRequest, conflict, forbidden } from '../lib/errors.ts';
|
||||||
|
import type { FileSender } from '../support/service.ts';
|
||||||
|
import { TelegramError, type TelegramApi } from '../telegram/client.ts';
|
||||||
|
import { esc } from '../telegram/texts.ts';
|
||||||
|
import { loadClient } from './service.ts';
|
||||||
|
|
||||||
|
// Чат с клиентом, оставившим анкету в клиентском боте. Пишут ответственный агент и руководитель — из
|
||||||
|
// карточки клиента в CRM; клиент получает ответ в клиентском боте с именем агента. Сообщения клиента
|
||||||
|
// приходят в intake/service.ts (receiveClientMessage). Вложения хранятся у Telegram клиентского бота.
|
||||||
|
|
||||||
|
/** Клиентский бот: отправка текста и файлов, скачивание вложений по их идентификатору. */
|
||||||
|
export interface ClientBot {
|
||||||
|
tg: TelegramApi;
|
||||||
|
sendFile: FileSender;
|
||||||
|
/** Файл по идентификатору Telegram; null — не нашёлся. */
|
||||||
|
download: (fileId: string) => Promise<{ data: Buffer; type: string | null; path: string } | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Скачивание вложения клиентского бота через Bot API (токен наружу не уходит). */
|
||||||
|
export const telegramClientDownload = (token: string): ClientBot['download'] => async (fileId) => {
|
||||||
|
const base = 'https://api.telegram.org';
|
||||||
|
const info = (await (await fetch(`${base}/bot${token}/getFile?file_id=${encodeURIComponent(fileId)}`)).json()) as {
|
||||||
|
ok: boolean;
|
||||||
|
result?: { file_path?: string };
|
||||||
|
};
|
||||||
|
if (!info.ok || !info.result?.file_path) return null;
|
||||||
|
const file = await fetch(`${base}/file/bot${token}/${info.result.file_path}`);
|
||||||
|
if (!file.ok) return null;
|
||||||
|
return { data: Buffer.from(await file.arrayBuffer()), type: file.headers.get('content-type'), path: info.result.file_path };
|
||||||
|
};
|
||||||
|
|
||||||
|
const Id = z.object({ id: z.coerce.number().int().positive() });
|
||||||
|
const MAX_FILE = 20 * 1024 * 1024;
|
||||||
|
|
||||||
|
/** Чат клиента в клиентском боте: последняя анкета, привязанная к карточке. */
|
||||||
|
export async function clientChat(db: Db, clientId: number) {
|
||||||
|
const [s] = await db
|
||||||
|
.select({ chatId: intakeSessions.chatId, lang: intakeSessions.lang })
|
||||||
|
.from(intakeSessions)
|
||||||
|
.where(eq(intakeSessions.clientId, clientId))
|
||||||
|
.orderBy(desc(intakeSessions.id))
|
||||||
|
.limit(1);
|
||||||
|
return s ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сколько сообщений клиента ещё не прочитано ответственным. */
|
||||||
|
export const unreadMessages = sql<number>`(select count(*)::int from client_messages m where m.client_id = "clients"."id" and m.direction = 'in' and m.read_at is null)`;
|
||||||
|
|
||||||
|
export function registerClientChatRoutes(app: FastifyInstance, db: Db, g: Guards, bot: ClientBot | null) {
|
||||||
|
/** Читать и писать в чат могут ответственный агент и руководитель — как и видеть контакты клиента. */
|
||||||
|
const own = async (ctx: MemberCtx, id: number) => {
|
||||||
|
const client = await loadClient(db, ctx, id);
|
||||||
|
if (ctx.role !== 'manager' && client.assigneeId !== ctx.user.id) throw forbidden('NOT_YOUR_CLIENT');
|
||||||
|
return client;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Куда отправлять: чат клиента и подпись «Имя, агент». */
|
||||||
|
const target = async (ctx: MemberCtx, clientId: number) => {
|
||||||
|
if (!bot) throw badRequest('CLIENT_BOT_DISABLED');
|
||||||
|
const chat = await clientChat(db, clientId);
|
||||||
|
if (!chat) throw conflict('NO_CLIENT_CHAT');
|
||||||
|
return { chat, signature: `<b>${esc(ctx.user.name)}, ${esc(intakeTexts(chat.lang).agent)}:</b>` };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Клиент заблокировал бота — понятная ошибка вместо «что-то пошло не так». */
|
||||||
|
const delivered = async <T>(send: () => Promise<T>): Promise<T> => {
|
||||||
|
try {
|
||||||
|
return await send();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TelegramError && error.code === 403) throw conflict('CLIENT_BLOCKED_BOT');
|
||||||
|
if (error instanceof TelegramError && error.code === 400) throw conflict('CLIENT_CHAT_UNAVAILABLE');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
app.get('/api/clients/:id/messages', async (req) => {
|
||||||
|
const ctx = g.member(req);
|
||||||
|
const { id } = Id.parse(req.params);
|
||||||
|
const client = await own(ctx, id);
|
||||||
|
// Сообщения клиента прочитаны, когда чат открыл ответственный (руководитель, заглянув, их не «съедает»).
|
||||||
|
if (client.assigneeId === ctx.user.id) {
|
||||||
|
await db
|
||||||
|
.update(clientMessages)
|
||||||
|
.set({ readAt: new Date() })
|
||||||
|
.where(and(eq(clientMessages.clientId, id), eq(clientMessages.direction, 'in'), isNull(clientMessages.readAt)));
|
||||||
|
}
|
||||||
|
const messages = await db
|
||||||
|
.select({
|
||||||
|
id: clientMessages.id,
|
||||||
|
direction: clientMessages.direction,
|
||||||
|
text: clientMessages.text,
|
||||||
|
fileKind: clientMessages.fileKind,
|
||||||
|
fileName: clientMessages.fileName,
|
||||||
|
authorName: users.name,
|
||||||
|
at: clientMessages.at,
|
||||||
|
})
|
||||||
|
.from(clientMessages)
|
||||||
|
.leftJoin(users, eq(users.id, clientMessages.authorId))
|
||||||
|
.where(eq(clientMessages.clientId, id))
|
||||||
|
.orderBy(asc(clientMessages.id))
|
||||||
|
.limit(500);
|
||||||
|
return { available: Boolean(bot && (await clientChat(db, id))), messages };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/clients/:id/messages', async (req) => {
|
||||||
|
const ctx = g.writable(g.member(req));
|
||||||
|
const { id } = Id.parse(req.params);
|
||||||
|
const { text } = z.object({ text: z.string().trim().min(1).max(3500) }).parse(req.body);
|
||||||
|
const client = await own(ctx, id);
|
||||||
|
const { chat, signature } = await target(ctx, id);
|
||||||
|
await delivered(() => bot!.tg.sendMessage(chat.chatId, `${signature}\n${esc(text)}`));
|
||||||
|
const [m] = await db.insert(clientMessages).values({ clientId: id, companyId: client.companyId, direction: 'out', authorId: ctx.user.id, text }).returning();
|
||||||
|
return { id: m!.id };
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Файл или фото: имя, тип и подпись — в адресе, сам файл — в теле запроса. */
|
||||||
|
app.post('/api/clients/:id/messages/file', { bodyLimit: MAX_FILE }, async (req) => {
|
||||||
|
const ctx = g.writable(g.member(req));
|
||||||
|
const { id } = Id.parse(req.params);
|
||||||
|
const q = z
|
||||||
|
.object({
|
||||||
|
name: z.string().trim().min(1).max(200),
|
||||||
|
type: z.string().trim().max(100).default('application/octet-stream'),
|
||||||
|
text: z.string().trim().max(900).optional(),
|
||||||
|
})
|
||||||
|
.parse(req.query);
|
||||||
|
if (!Buffer.isBuffer(req.body) || req.body.length === 0) throw badRequest('VALIDATION');
|
||||||
|
const client = await own(ctx, id);
|
||||||
|
const { chat, signature } = await target(ctx, id);
|
||||||
|
const caption = [signature, q.text ? esc(q.text) : ''].filter(Boolean).join('\n');
|
||||||
|
const sent = await delivered(() => bot!.sendFile(chat.chatId, { name: q.name, type: q.type, data: req.body as Buffer }, caption));
|
||||||
|
const [m] = await db
|
||||||
|
.insert(clientMessages)
|
||||||
|
.values({
|
||||||
|
clientId: id, companyId: client.companyId, direction: 'out', authorId: ctx.user.id,
|
||||||
|
text: q.text || null, fileId: sent.fileId, fileKind: sent.kind, fileName: q.name,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return { id: m!.id };
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Вложение из чата: файл лежит у Telegram клиентского бота. */
|
||||||
|
app.get('/api/clients/:id/messages/:messageId/file', async (req, reply) => {
|
||||||
|
const ctx = g.member(req);
|
||||||
|
const { id, messageId } = z.object({ id: z.coerce.number().int().positive(), messageId: z.coerce.number().int().positive() }).parse(req.params);
|
||||||
|
await own(ctx, id);
|
||||||
|
const [m] = await db.select().from(clientMessages).where(and(eq(clientMessages.id, messageId), eq(clientMessages.clientId, id)));
|
||||||
|
if (!m?.fileId || !bot) throw badRequest('FILE_NOT_FOUND');
|
||||||
|
const file = await bot.download(m.fileId);
|
||||||
|
if (!file) throw badRequest('FILE_NOT_FOUND');
|
||||||
|
const name = m.fileName ?? file.path.split('/').pop() ?? 'file';
|
||||||
|
const type = m.fileKind === 'photo' ? 'image/jpeg' : (file.type ?? 'application/octet-stream');
|
||||||
|
return reply
|
||||||
|
.header('content-type', type)
|
||||||
|
.header('cache-control', 'private, max-age=3600')
|
||||||
|
.header('content-disposition', `${m.fileKind === 'photo' ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(name)}`)
|
||||||
|
.send(file.data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Для карточки: есть ли чат и сколько непрочитанных. */
|
||||||
|
export async function chatSummary(db: Db, clientId: number, bot: ClientBot | null) {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ n: sql<number>`count(*)::int` })
|
||||||
|
.from(clientMessages)
|
||||||
|
.where(and(eq(clientMessages.clientId, clientId), eq(clientMessages.direction, 'in'), isNull(clientMessages.readAt)));
|
||||||
|
const [any] = await db.select({ id: clientMessages.id }).from(clientMessages).where(eq(clientMessages.clientId, clientId)).limit(1);
|
||||||
|
return { available: Boolean(bot && (await clientChat(db, clientId))), unread: row?.n ?? 0, hasMessages: Boolean(any) };
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { chatSummary, unreadMessages, type ClientBot } from './chat.ts';
|
||||||
import { CLIENT_KINDS, ClientRequest, CommissionRule, CURRENCIES, fitsHousehold, Household, matchesClientRequest, RENT_TERMS, type Currency, type DealType } from '@lr/shared';
|
import { CLIENT_KINDS, ClientRequest, CommissionRule, CURRENCIES, fitsHousehold, Household, matchesClientRequest, RENT_TERMS, type Currency, type DealType } from '@lr/shared';
|
||||||
import { and, desc, eq, ilike, inArray, isNull, notInArray, or, sql, type SQL } from 'drizzle-orm';
|
import { and, desc, eq, ilike, inArray, isNull, notInArray, or, sql, type SQL } from 'drizzle-orm';
|
||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
@@ -45,7 +46,7 @@ const PHOTO_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'ima
|
|||||||
const MAX_PHOTO = 5 * 1024 * 1024;
|
const MAX_PHOTO = 5 * 1024 * 1024;
|
||||||
const MAX_PHOTOS = 10;
|
const MAX_PHOTOS = 10;
|
||||||
|
|
||||||
export function registerClientRoutes(app: FastifyInstance, db: Db, g: Guards, config: Config) {
|
export function registerClientRoutes(app: FastifyInstance, db: Db, g: Guards, config: Config, chatBot: ClientBot | null = null) {
|
||||||
type Ctx = ReturnType<Guards['member']>;
|
type Ctx = ReturnType<Guards['member']>;
|
||||||
const actor = (ctx: Ctx): Actor => ctx;
|
const actor = (ctx: Ctx): Actor => ctx;
|
||||||
|
|
||||||
@@ -102,6 +103,7 @@ export function registerClientRoutes(app: FastifyInstance, db: Db, g: Guards, co
|
|||||||
assigneeName: users.name,
|
assigneeName: users.name,
|
||||||
departmentName: departments.name,
|
departmentName: departments.name,
|
||||||
leadsCount: sql<number>`(select count(*)::int from client_leads cl where cl.client_id = "clients"."id")`,
|
leadsCount: sql<number>`(select count(*)::int from client_leads cl where cl.client_id = "clients"."id")`,
|
||||||
|
unreadMessages,
|
||||||
})
|
})
|
||||||
.from(clients)
|
.from(clients)
|
||||||
.leftJoin(users, eq(users.id, clients.assigneeId))
|
.leftJoin(users, eq(users.id, clients.assigneeId))
|
||||||
@@ -109,7 +111,11 @@ export function registerClientRoutes(app: FastifyInstance, db: Db, g: Guards, co
|
|||||||
.where(and(...where))
|
.where(and(...where))
|
||||||
.orderBy(desc(clients.createdAt))
|
.orderBy(desc(clients.createdAt))
|
||||||
.limit(300);
|
.limit(300);
|
||||||
return rows.map((r) => ({ ...present(ctx, r.client, r.assigneeName), departmentName: r.departmentName, leadsCount: r.leadsCount }));
|
return rows.map((r) => ({
|
||||||
|
...present(ctx, r.client, r.assigneeName), departmentName: r.departmentName, leadsCount: r.leadsCount,
|
||||||
|
// Непрочитанные сообщения клиента в чате — тем, кто видит чат (ответственному и руководителю).
|
||||||
|
unreadMessages: ctx.role === 'manager' || r.client.assigneeId === ctx.user.id ? r.unreadMessages : 0,
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/clients-summary', async (req) => {
|
app.get('/api/clients-summary', async (req) => {
|
||||||
@@ -194,6 +200,8 @@ export function registerClientRoutes(app: FastifyInstance, db: Db, g: Guards, co
|
|||||||
/** Страница подборки для клиента, если ссылка создана. */
|
/** Страница подборки для клиента, если ссылка создана. */
|
||||||
pageUrl: page?.slug ? pageUrl(config, ctx.company, page.slug, page.token) : null,
|
pageUrl: page?.slug ? pageUrl(config, ctx.company, page.slug, page.token) : null,
|
||||||
viewings: await clientViewings(db, id),
|
viewings: await clientViewings(db, id),
|
||||||
|
/** Чат с клиентом в клиентском боте — ответственному и руководителю. */
|
||||||
|
chat: ctx.role === 'manager' || client.assigneeId === ctx.user.id ? await chatSummary(db, id, chatBot) : null,
|
||||||
leads: linked,
|
leads: linked,
|
||||||
deal: d ? { ...dealView(d.deal, names), leadId: d.deal.leadId, leadTitle: d.leadTitle } : null,
|
deal: d ? { ...dealView(d.deal, names), leadId: d.deal.leadId, leadTitle: d.leadTitle } : null,
|
||||||
events: history.map(({ event: e, userName }) => ({
|
events: history.map(({ event: e, userName }) => ({
|
||||||
|
|||||||
@@ -396,6 +396,33 @@ export const clientPhotos = pgTable(
|
|||||||
(t) => [index('client_photos_client_idx').on(t.clientId)],
|
(t) => [index('client_photos_client_idx').on(t.clientId)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Чат с клиентом, оставившим анкету в клиентском боте. in — клиент написал в бот, out — ответ агента
|
||||||
|
* или руководителя из карточки клиента. Вложения хранятся у Telegram (клиентского бота): здесь только
|
||||||
|
* идентификатор файла.
|
||||||
|
*/
|
||||||
|
export const clientMessages = pgTable(
|
||||||
|
'client_messages',
|
||||||
|
{
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
clientId: integer('client_id').notNull().references(() => clients.id, { onDelete: 'cascade' }),
|
||||||
|
companyId: integer('company_id').notNull().references(() => companies.id),
|
||||||
|
/** in | out */
|
||||||
|
direction: text('direction').notNull(),
|
||||||
|
/** Кто ответил (для out). */
|
||||||
|
authorId: integer('author_id').references(() => users.id),
|
||||||
|
text: text('text'),
|
||||||
|
fileId: text('file_id'),
|
||||||
|
/** photo | file */
|
||||||
|
fileKind: text('file_kind'),
|
||||||
|
fileName: text('file_name'),
|
||||||
|
/** Когда ответственный прочитал сообщение клиента (для in). */
|
||||||
|
readAt: ts('read_at'),
|
||||||
|
at: ts('at').notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [index('client_messages_client_idx').on(t.clientId, t.id)],
|
||||||
|
);
|
||||||
|
|
||||||
/** Объекты, предложенные или показанные клиенту. */
|
/** Объекты, предложенные или показанные клиенту. */
|
||||||
export const clientLeads = pgTable(
|
export const clientLeads = pgTable(
|
||||||
'client_leads',
|
'client_leads',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { normalizePhone, RENT_TERMS, YEREVAN_DISTRICTS, type ClientRequest, type
|
|||||||
import { and, desc, eq, isNotNull, sql } from 'drizzle-orm';
|
import { and, desc, eq, isNotNull, sql } from 'drizzle-orm';
|
||||||
import { newToken } from '../auth/sessions.ts';
|
import { newToken } from '../auth/sessions.ts';
|
||||||
import type { Db } from '../db/client.ts';
|
import type { Db } from '../db/client.ts';
|
||||||
import { clientPhotos, clients, companies, events, intakeLinks, intakeSessions, outbox, users } from '../db/schema.ts';
|
import { clientMessages, clientPhotos, clients, companies, events, intakeLinks, intakeSessions, outbox, users } from '../db/schema.ts';
|
||||||
import type { Config } from '../lib/config.ts';
|
import type { Config } from '../lib/config.ts';
|
||||||
import { statusByCode } from '../pipelines/service.ts';
|
import { statusByCode } from '../pipelines/service.ts';
|
||||||
import { REMOVE_KEYBOARD, type Keyboard, type TelegramApi, type TgUpdate } from '../telegram/client.ts';
|
import { REMOVE_KEYBOARD, type Keyboard, type TelegramApi, type TgUpdate } from '../telegram/client.ts';
|
||||||
@@ -256,23 +256,57 @@ async function complete(db: Db, s: Session, a: Answers) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Сообщение клиента в бот после анкеты: текст и (или) вложение — у Telegram клиентского бота. */
|
||||||
|
export interface IncomingClientMessage {
|
||||||
|
text: string | null;
|
||||||
|
fileId?: string;
|
||||||
|
fileKind?: 'photo' | 'file';
|
||||||
|
fileName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сообщения клиента подряд не подтверждаются каждый раз: «передали агенту» — не чаще раза в 30 минут. */
|
||||||
|
const ACK_GAP_MS = 30 * 60_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Клиент написал в бот после анкеты (например, прислал телефон по просьбе агента). Сообщение — в историю
|
* Клиент написал в бот после анкеты — сообщение попадает в чат карточки клиента. Номер телефона из текста
|
||||||
* карточки, номер — в карточку, если его там нет; агенту и суперадминам — уведомление в основной бот.
|
* сохраняется в карточку, если его там нет. Ответственному агенту — уведомление в основной бот с кнопкой
|
||||||
|
* «Открыть». Возвращает, нужно ли подтвердить клиенту, что сообщение передано.
|
||||||
*/
|
*/
|
||||||
async function saveReply(db: Db, clientId: number, reply: string): Promise<boolean> {
|
export async function receiveClientMessage(db: Db, clientId: number, m: IncomingClientMessage): Promise<{ ack: boolean } | null> {
|
||||||
return db.transaction(async (tx) => {
|
return db.transaction(async (tx) => {
|
||||||
const [client] = await tx.select().from(clients).where(eq(clients.id, clientId));
|
const [client] = await tx.select().from(clients).where(eq(clients.id, clientId));
|
||||||
if (!client) return false;
|
if (!client) return null;
|
||||||
const message = reply.slice(0, 1000);
|
const text = m.text?.trim().slice(0, 3500) || null;
|
||||||
const phone = message.replace(/\D/g, '').length >= 6 ? normalizePhone(message) : null;
|
const [prev] = await tx
|
||||||
|
.select({ at: clientMessages.at })
|
||||||
|
.from(clientMessages)
|
||||||
|
.where(and(eq(clientMessages.clientId, clientId), eq(clientMessages.direction, 'in')))
|
||||||
|
.orderBy(desc(clientMessages.id))
|
||||||
|
.limit(1);
|
||||||
|
await tx.insert(clientMessages).values({
|
||||||
|
clientId, companyId: client.companyId, direction: 'in', text,
|
||||||
|
fileId: m.fileId ?? null, fileKind: m.fileKind ?? null, fileName: m.fileName ?? null,
|
||||||
|
});
|
||||||
|
const phone = text && text.replace(/\D/g, '').length >= 8 ? normalizePhone(text) : null;
|
||||||
const phoneSaved = Boolean(phone && !client.phone);
|
const phoneSaved = Boolean(phone && !client.phone);
|
||||||
if (phoneSaved) await tx.update(clients).set({ phone }).where(eq(clients.id, clientId));
|
if (phoneSaved) await tx.update(clients).set({ phone }).where(eq(clients.id, clientId));
|
||||||
await tx.insert(events).values({ companyId: client.companyId, clientId, userId: null, type: 'comment', text: `Клиент написал в бот анкеты: ${message}` });
|
const body = [text, m.fileKind === 'photo' ? '📷' : m.fileKind === 'file' ? `📎 ${m.fileName ?? ''}`.trim() : null].filter(Boolean).join('\n');
|
||||||
const admins = await tx.select({ id: users.id }).from(users).where(and(eq(users.role, 'superadmin'), isNotNull(users.telegramId), sql`${users.removedAt} is null`));
|
const note = [`💬 ${client.name}:`, body, phoneSaved ? `📞 Телефон ${phone} сохранён в карточку.` : ''].filter(Boolean).join('\n');
|
||||||
const note = [`💬 ${client.name} (клиент #${clientId}) написал(а) в бот анкеты:`, message, phoneSaved ? `📞 Телефон ${phone} сохранён в карточку.` : ''].filter(Boolean).join('\n');
|
await tx.insert(outbox).values({ kind: 'text', userId: client.assigneeId, payload: { raw: note, openPath: `/clients/${clientId}` } });
|
||||||
await enqueueRaw(tx, [...new Set([client.assigneeId, ...admins.map((a) => a.id)])], note);
|
return { ack: !prev || Date.now() - prev.at.getTime() > ACK_GAP_MS };
|
||||||
return true;
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Фото клиента — ещё и в фото карточки (до 10 штук, до 5 МБ). Не вышло — фото всё равно есть в чате. */
|
||||||
|
async function addCardPhoto(db: Db, clientId: number, fileId: string, download?: FileDownloader) {
|
||||||
|
const [client] = await db.select().from(clients).where(eq(clients.id, clientId));
|
||||||
|
if (!client) return;
|
||||||
|
const [{ n } = { n: 0 }] = await db.select({ n: sql<number>`count(*)::int` }).from(clientPhotos).where(eq(clientPhotos.clientId, clientId));
|
||||||
|
if (n >= 10) return;
|
||||||
|
const file = await download?.(fileId).catch(() => null);
|
||||||
|
if (!file || file.data.length > 5 * 1024 * 1024) return;
|
||||||
|
await db.insert(clientPhotos).values({
|
||||||
|
clientId, companyId: client.companyId, contentType: file.type, size: file.data.length, data: file.data.toString('base64'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,25 +389,22 @@ export async function handleIntakeUpdate({ db, tg, download }: IntakeDeps, updat
|
|||||||
}
|
}
|
||||||
const st = intakeTexts(session.lang);
|
const st = intakeTexts(session.lang);
|
||||||
|
|
||||||
// После анкеты: фото добавляются к карточке клиента.
|
// После анкеты: всё, что клиент пишет, — в чат карточки клиента; фото ещё и к фото карточки.
|
||||||
if (session.step === 'done') {
|
if (session.step === 'done') {
|
||||||
if (update.callback_query) return tg.answerCallback(update.callback_query.id);
|
if (update.callback_query) return tg.answerCallback(update.callback_query.id);
|
||||||
const photo = update.message?.photo?.at(-1)?.file_id;
|
const msg = update.message;
|
||||||
const reply = update.message?.contact?.phone_number ?? text;
|
if (!session.clientId || !msg) return void (await tg.sendMessage(chatId, st.after));
|
||||||
if (!photo && reply && session.clientId) {
|
const photo = msg.photo?.at(-1)?.file_id;
|
||||||
return void (await tg.sendMessage(chatId, (await saveReply(db, session.clientId, reply)) ? st.replySaved : st.after));
|
const incoming: IncomingClientMessage = {
|
||||||
}
|
text: msg.contact?.phone_number ?? msg.text ?? msg.caption ?? null,
|
||||||
if (!photo || !session.clientId) return void (await tg.sendMessage(chatId, st.after));
|
...(photo ? { fileId: photo, fileKind: 'photo' as const } : msg.document ? { fileId: msg.document.file_id, fileKind: 'file' as const, fileName: msg.document.file_name ?? null } : {}),
|
||||||
const [client] = await db.select().from(clients).where(eq(clients.id, session.clientId));
|
};
|
||||||
if (!client) return void (await tg.sendMessage(chatId, st.after));
|
if (!incoming.text && !incoming.fileId) return void (await tg.sendMessage(chatId, st.after));
|
||||||
const [{ n } = { n: 0 }] = await db.select({ n: sql<number>`count(*)::int` }).from(clientPhotos).where(eq(clientPhotos.clientId, client.id));
|
const saved = await receiveClientMessage(db, session.clientId, incoming);
|
||||||
if (n >= 10) return void (await tg.sendMessage(chatId, st.photoLimit));
|
if (!saved) return void (await tg.sendMessage(chatId, st.after));
|
||||||
const file = await download?.(photo).catch(() => null);
|
if (photo) await addCardPhoto(db, session.clientId, photo, download);
|
||||||
if (!file || file.data.length > 5 * 1024 * 1024) return void (await tg.sendMessage(chatId, st.photoFail));
|
if (saved.ack) await tg.sendMessage(chatId, esc(st.replySaved));
|
||||||
await db.insert(clientPhotos).values({
|
return;
|
||||||
clientId: client.id, companyId: client.companyId, contentType: file.type, size: file.data.length, data: file.data.toString('base64'),
|
|
||||||
});
|
|
||||||
return void (await tg.sendMessage(chatId, st.photoSaved));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const step = session.step as Step;
|
const step = session.step as Step;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const ru = {
|
|||||||
skip: 'Пропустить',
|
skip: 'Пропустить',
|
||||||
done: (agent: string) => `Спасибо, анкета отправлена. Агент ${agent} свяжется с вами.\n\nЕсли хотите, пришлите сюда фото — себя, питомца — они добавятся к анкете.`,
|
done: (agent: string) => `Спасибо, анкета отправлена. Агент ${agent} свяжется с вами.\n\nЕсли хотите, пришлите сюда фото — себя, питомца — они добавятся к анкете.`,
|
||||||
photoSaved: 'Фото добавлено к анкете.', photoLimit: 'К анкете уже приложено 10 фото — больше добавить нельзя.', photoFail: 'Не получилось сохранить фото. Попробуйте другое, до 5 МБ.',
|
photoSaved: 'Фото добавлено к анкете.', photoLimit: 'К анкете уже приложено 10 фото — больше добавить нельзя.', photoFail: 'Не получилось сохранить фото. Попробуйте другое, до 5 МБ.',
|
||||||
replySaved: 'Спасибо, передали агенту — он скоро свяжется с вами.',
|
replySaved: 'Спасибо, передали агенту — он скоро свяжется с вами.', agent: 'агент',
|
||||||
after: 'Анкета уже у агента — он свяжется с вами. Чтобы заполнить её заново, откройте ссылку от агента ещё раз.',
|
after: 'Анкета уже у агента — он свяжется с вами. Чтобы заполнить её заново, откройте ссылку от агента ещё раз.',
|
||||||
useButtons: 'Выберите вариант кнопкой под сообщением.',
|
useButtons: 'Выберите вариант кнопкой под сообщением.',
|
||||||
langSet: 'Язык: русский. Сменить язык — команда /lang.',
|
langSet: 'Язык: русский. Сменить язык — команда /lang.',
|
||||||
@@ -51,7 +51,7 @@ const hy: Texts = {
|
|||||||
skip: 'Բաց թողնել',
|
skip: 'Բաց թողնել',
|
||||||
done: (agent) => `Շնորհակալություն, հարցաթերթիկն ուղարկված է։ Գործակալ ${agent}-ը կկապվի Ձեզ հետ։\n\nԵթե ցանկանում եք, ուղարկեք այստեղ լուսանկար՝ Ձեր կամ ընտանի կենդանու, այն կավելացվի հարցաթերթիկին։`,
|
done: (agent) => `Շնորհակալություն, հարցաթերթիկն ուղարկված է։ Գործակալ ${agent}-ը կկապվի Ձեզ հետ։\n\nԵթե ցանկանում եք, ուղարկեք այստեղ լուսանկար՝ Ձեր կամ ընտանի կենդանու, այն կավելացվի հարցաթերթիկին։`,
|
||||||
photoSaved: 'Լուսանկարն ավելացվեց հարցաթերթիկին։', photoLimit: 'Հարցաթերթիկին արդեն կցված է 10 լուսանկար։', photoFail: 'Չհաջողվեց պահպանել լուսանկարը։ Փորձեք մեկ այլը՝ մինչև 5 ՄԲ։',
|
photoSaved: 'Լուսանկարն ավելացվեց հարցաթերթիկին։', photoLimit: 'Հարցաթերթիկին արդեն կցված է 10 լուսանկար։', photoFail: 'Չհաջողվեց պահպանել լուսանկարը։ Փորձեք մեկ այլը՝ մինչև 5 ՄԲ։',
|
||||||
replySaved: 'Շնորհակալություն, փոխանցեցինք գործակալին — նա շուտով կկապվի Ձեզ հետ։',
|
replySaved: 'Շնորհակալություն, փոխանցեցինք գործակալին — նա շուտով կկապվի Ձեզ հետ։', agent: 'գործակալ',
|
||||||
after: 'Հարցաթերթիկն արդեն գործակալի մոտ է, նա կկապվի Ձեզ հետ։ Նորից լրացնելու համար կրկին բացեք գործակալի հղումը։',
|
after: 'Հարցաթերթիկն արդեն գործակալի մոտ է, նա կկապվի Ձեզ հետ։ Նորից լրացնելու համար կրկին բացեք գործակալի հղումը։',
|
||||||
useButtons: 'Ընտրեք տարբերակը հաղորդագրության տակի կոճակով։',
|
useButtons: 'Ընտրեք տարբերակը հաղորդագրության տակի կոճակով։',
|
||||||
langSet: 'Լեզուն՝ հայերեն։ Լեզուն փոխելու համար՝ /lang հրամանը։',
|
langSet: 'Լեզուն՝ հայերեն։ Լեզուն փոխելու համար՝ /lang հրամանը։',
|
||||||
@@ -78,7 +78,7 @@ const en: Texts = {
|
|||||||
skip: 'Skip',
|
skip: 'Skip',
|
||||||
done: (agent) => `Thank you, the questionnaire has been sent. Agent ${agent} will contact you.\n\nIf you like, send a photo here — of yourself or your pet — it will be added to the questionnaire.`,
|
done: (agent) => `Thank you, the questionnaire has been sent. Agent ${agent} will contact you.\n\nIf you like, send a photo here — of yourself or your pet — it will be added to the questionnaire.`,
|
||||||
photoSaved: 'The photo was added to the questionnaire.', photoLimit: 'There are already 10 photos attached.', photoFail: 'Could not save the photo. Try another one, up to 5 MB.',
|
photoSaved: 'The photo was added to the questionnaire.', photoLimit: 'There are already 10 photos attached.', photoFail: 'Could not save the photo. Try another one, up to 5 MB.',
|
||||||
replySaved: 'Thank you, we have passed it to the agent — they will contact you soon.',
|
replySaved: 'Thank you, we have passed it to the agent — they will contact you soon.', agent: 'agent',
|
||||||
after: 'The agent already has your questionnaire and will contact you. To fill it in again, open the agent’s link once more.',
|
after: 'The agent already has your questionnaire and will contact you. To fill it in again, open the agent’s link once more.',
|
||||||
useButtons: 'Choose an option with a button under the message.',
|
useButtons: 'Choose an option with a button under the message.',
|
||||||
langSet: 'Language: English. To change it, use /lang.',
|
langSet: 'Language: English. To change it, use /lang.',
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import {
|
|||||||
type Lead,
|
type Lead,
|
||||||
} from './service.ts';
|
} from './service.ts';
|
||||||
|
|
||||||
type MemberCtx = { user: typeof users.$inferSelect; role: Role; company: typeof companies.$inferSelect; access: AccessState };
|
export type MemberCtx = { user: typeof users.$inferSelect; role: Role; company: typeof companies.$inferSelect; access: AccessState };
|
||||||
|
|
||||||
export interface Guards {
|
export interface Guards {
|
||||||
member: (req: FastifyRequest) => MemberCtx;
|
member: (req: FastifyRequest) => MemberCtx;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { TelegramError } from './telegram/client.ts';
|
||||||
import { and, eq } from 'drizzle-orm';
|
import { and, eq } from 'drizzle-orm';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
import { hashPassword } from './auth/password.ts';
|
import { hashPassword } from './auth/password.ts';
|
||||||
@@ -171,19 +172,59 @@ describe('анкета в клиентском боте', () => {
|
|||||||
household: { who: 'couple', children: { count: 2, age: '3 и 7 лет' } },
|
household: { who: 'couple', children: { count: 2, age: '3 и 7 лет' } },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Фото после анкеты добавляется к карточке клиента.
|
// После анкеты всё, что пишет клиент, — в чат карточки; фото ещё и к фото карточки.
|
||||||
await handleIntakeUpdate({ db: t.db, tg: seekerBot.tg, config, download: async () => ({ data: Buffer.from('JPEG'), type: 'image/jpeg' }) },
|
await handleIntakeUpdate({ db: t.db, tg: seekerBot.tg, config, download: async () => ({ data: Buffer.from('JPEG'), type: 'image/jpeg' }) },
|
||||||
fromSeeker(seeker, { message: { chat: { id: seeker }, from: { id: seeker, first_name: 'Анна' }, photo: [{ file_id: 's' }, { file_id: 'big' }] } }));
|
fromSeeker(seeker, { message: { chat: { id: seeker }, from: { id: seeker, first_name: 'Анна' }, photo: [{ file_id: 's' }, { file_id: 'big' }], caption: 'Это мы' } }));
|
||||||
expect(last(seeker).text).toContain('Фото добавлено');
|
|
||||||
expect(await t.db.select().from(clientPhotos).where(eq(clientPhotos.clientId, client!.id))).toHaveLength(1);
|
|
||||||
|
|
||||||
// Текст после анкеты не теряется: попадает в историю карточки, агенту приходит уведомление.
|
|
||||||
await say(seeker, 'Удобно звонить после 18:00');
|
|
||||||
expect(last(seeker).text).toContain('передали агенту');
|
expect(last(seeker).text).toContain('передали агенту');
|
||||||
const notes = await t.db.select().from(events).where(and(eq(events.clientId, client!.id), eq(events.type, 'comment')));
|
expect(await t.db.select().from(clientPhotos).where(eq(clientPhotos.clientId, client!.id))).toHaveLength(1);
|
||||||
expect(notes.at(-1)!.text).toContain('после 18:00');
|
// Второе сообщение подряд клиенту не подтверждается — бот не засоряет переписку.
|
||||||
|
const repliesBefore = seekerBot.inbox(seeker).length;
|
||||||
|
await say(seeker, 'Удобно звонить после 18:00');
|
||||||
|
expect(seekerBot.inbox(seeker)).toHaveLength(repliesBefore);
|
||||||
|
await handleIntakeUpdate({ db: t.db, tg: seekerBot.tg, config },
|
||||||
|
fromSeeker(seeker, { message: { chat: { id: seeker }, from: { id: seeker, first_name: 'Анна' }, document: { file_id: 'doc1', file_name: 'паспорт.pdf' } } }));
|
||||||
await flush();
|
await flush();
|
||||||
expect(main.inbox(agent.chat).some((m) => m.text.includes('Удобно звонить после 18:00'))).toBe(true);
|
const note = main.inbox(agent.chat).find((m) => m.text.includes('Удобно звонить после 18:00'))!;
|
||||||
|
expect(note.text).toContain('💬 Анна:');
|
||||||
|
|
||||||
|
// Карточка: чат есть, 3 непрочитанных; список клиентов показывает счётчик.
|
||||||
|
const chatCard = (await agent.api.get(`/api/clients/${client!.id}`)).body;
|
||||||
|
expect(chatCard.chat).toEqual({ available: true, unread: 3, hasMessages: true });
|
||||||
|
expect(((await agent.api.get('/api/clients')).body as { id: number; unreadMessages: number }[]).find((x) => x.id === client!.id)!.unreadMessages).toBe(3);
|
||||||
|
const chat = (await agent.api.get(`/api/clients/${client!.id}/messages`)).body;
|
||||||
|
expect(chat.available).toBe(true);
|
||||||
|
expect(chat.messages.map((m: { direction: string; text: string | null; fileKind: string | null; fileName: string | null }) => [m.direction, m.text, m.fileKind, m.fileName])).toEqual([
|
||||||
|
['in', 'Это мы', 'photo', null],
|
||||||
|
['in', 'Удобно звонить после 18:00', null, null],
|
||||||
|
['in', null, 'file', 'паспорт.pdf'],
|
||||||
|
]);
|
||||||
|
// Открыл ответственный — прочитано.
|
||||||
|
expect((await agent.api.get(`/api/clients/${client!.id}`)).body.chat.unread).toBe(0);
|
||||||
|
// Вложение клиента открывается из CRM.
|
||||||
|
const docId = chat.messages[2].id;
|
||||||
|
const file = await agent.api.get(`/api/clients/${client!.id}/messages/${docId}/file`);
|
||||||
|
expect(file.status).toBe(200);
|
||||||
|
expect(file.headers['content-disposition']).toContain(encodeURIComponent('паспорт.pdf'));
|
||||||
|
|
||||||
|
// Ответ агента уходит клиенту в клиентский бот с именем агента.
|
||||||
|
expect((await agent.api.post(`/api/clients/${client!.id}/messages`, { text: 'Позвоню в 18:30' })).status).toBe(200);
|
||||||
|
const answer = t.clientBotTg.inbox(seeker).at(-1)!;
|
||||||
|
expect(answer.text).toBe('<b>Арам, агент:</b>\nПозвоню в 18:30');
|
||||||
|
// Файл — тоже, с подписью.
|
||||||
|
expect((await agent.api.post(`/api/clients/${client!.id}/messages/file?name=plan.pdf&type=application/pdf&text=${encodeURIComponent('План квартиры')}`, Buffer.from('%PDF'))).status).toBe(200);
|
||||||
|
expect(t.clientBotFiles.at(-1)).toMatchObject({ chatId: seeker, name: 'plan.pdf', caption: '<b>Арам, агент:</b>\nПлан квартиры' });
|
||||||
|
const after = (await agent.api.get(`/api/clients/${client!.id}/messages`)).body.messages;
|
||||||
|
expect(after.slice(-2).map((m: { direction: string; authorName: string; fileName: string | null }) => [m.direction, m.authorName, m.fileName])).toEqual([
|
||||||
|
['out', 'Арам', null],
|
||||||
|
['out', 'Арам', 'plan.pdf'],
|
||||||
|
]);
|
||||||
|
// Клиент заблокировал бота — понятная ошибка.
|
||||||
|
t.clientBotTg.failOnce(new TelegramError(403, 'Forbidden: bot was blocked by the user'));
|
||||||
|
expect((await agent.api.post(`/api/clients/${client!.id}/messages`, { text: 'Алло?' })).body.code).toBe('CLIENT_BLOCKED_BOT');
|
||||||
|
// Клиент заведён вручную, анкеты не было — писать некуда.
|
||||||
|
const manual = (await agent.api.post('/api/clients', { name: 'Без бота', kind: 'buyer', request: {} })).body.id;
|
||||||
|
expect((await agent.api.get(`/api/clients/${manual}`)).body.chat).toEqual({ available: false, unread: 0, hasMessages: false });
|
||||||
|
expect((await agent.api.post(`/api/clients/${manual}/messages`, { text: 'Здравствуйте' })).body.code).toBe('NO_CLIENT_CHAT');
|
||||||
|
|
||||||
// Автору ссылки в основной бот: сводка и вопрос с кнопками.
|
// Автору ссылки в основной бот: сводка и вопрос с кнопками.
|
||||||
await flush();
|
await flush();
|
||||||
|
|||||||
@@ -79,10 +79,13 @@ export interface OutboxDeps {
|
|||||||
sleep?: (ms: number) => Promise<void>;
|
sleep?: (ms: number) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const openButton = (config: Config, lang: string, leadId: number): Keyboard[number] => {
|
const openButton = (config: Config, lang: string, leadId: number): Keyboard[number] => openPathButton(config, lang, `/leads/${leadId}`);
|
||||||
|
|
||||||
|
/** Кнопка «Открыть» страницу CRM в Mini App. */
|
||||||
|
const openPathButton = (config: Config, lang: string, path: string): Keyboard[number] => {
|
||||||
// Telegram принимает в кнопках только https-адреса; на локальном стенде кнопки «Открыть» нет.
|
// Telegram принимает в кнопках только https-адреса; на локальном стенде кнопки «Открыть» нет.
|
||||||
if (!config.appUrl.startsWith('https://')) return [];
|
if (!config.appUrl.startsWith('https://')) return [];
|
||||||
return [{ text: texts(lang).open, webApp: `${config.appUrl}/leads/${leadId}` }];
|
return [{ text: texts(lang).open, webApp: `${config.appUrl}${path}` }];
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Отправляет одну пачку сообщений из очереди. Возвращает число обработанных. */
|
/** Отправляет одну пачку сообщений из очереди. Возвращает число обработанных. */
|
||||||
@@ -117,7 +120,7 @@ export async function processOutbox({ db, tg, config, sleep }: OutboxDeps): Prom
|
|||||||
const t = texts(user.lang);
|
const t = texts(user.lang);
|
||||||
|
|
||||||
if (job.kind === 'text') {
|
if (job.kind === 'text') {
|
||||||
const p = job.payload as { raw?: string; key?: string; args?: unknown[]; buttons?: { key: string; callback: string }[] };
|
const p = job.payload as { raw?: string; key?: string; args?: unknown[]; buttons?: { key: string; callback: string }[]; openPath?: string };
|
||||||
// Кнопки под сообщением: подпись берётся из текстов бота на языке получателя.
|
// Кнопки под сообщением: подпись берётся из текстов бота на языке получателя.
|
||||||
const buttons: Keyboard | undefined = p.buttons?.length
|
const buttons: Keyboard | undefined = p.buttons?.length
|
||||||
? [p.buttons.map((b) => ({ text: String((t as unknown as Record<string, unknown>)[b.key] ?? b.key), callback: b.callback }))]
|
? [p.buttons.map((b) => ({ text: String((t as unknown as Record<string, unknown>)[b.key] ?? b.key), callback: b.callback }))]
|
||||||
@@ -129,7 +132,8 @@ export async function processOutbox({ db, tg, config, sleep }: OutboxDeps): Prom
|
|||||||
: typeof fn === 'function'
|
: typeof fn === 'function'
|
||||||
? (fn as (...a: unknown[]) => string)(...(p.args ?? []).map((a) => (typeof a === 'string' ? esc(a) : a)))
|
? (fn as (...a: unknown[]) => string)(...(p.args ?? []).map((a) => (typeof a === 'string' ? esc(a) : a)))
|
||||||
: String(fn ?? '');
|
: String(fn ?? '');
|
||||||
if (text) await tg.sendMessage(chatId, text, buttons ?? (job.leadId ? [openButton(config, user.lang, job.leadId)].filter((r) => r.length) : undefined));
|
const open = p.openPath ? openPathButton(config, user.lang, p.openPath) : job.leadId ? openButton(config, user.lang, job.leadId) : [];
|
||||||
|
if (text) await tg.sendMessage(chatId, text, buttons ?? (open.length ? [open] : undefined));
|
||||||
await finish();
|
await finish();
|
||||||
} else if (job.kind === 'lead') {
|
} else if (job.kind === 'lead') {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
|
|||||||
+18
-4
@@ -55,18 +55,32 @@ export async function testApp(name = 'app', source: PagedSource | null = null, p
|
|||||||
album: async (bot, photos) => void groupSent.push({ kind: 'album', chatId: bot.chatId, threadId: bot.threadId, photos: photos.length }),
|
album: async (bot, photos) => void groupSent.push({ kind: 'album', chatId: bot.chatId, threadId: bot.threadId, photos: photos.length }),
|
||||||
text: async (bot, text) => void groupSent.push({ kind: 'text', chatId: bot.chatId, threadId: bot.threadId, text }),
|
text: async (bot, text) => void groupSent.push({ kind: 'text', chatId: bot.chatId, threadId: bot.threadId, text }),
|
||||||
};
|
};
|
||||||
const app = buildApp({ db, config, source, payments, sendFile, groupSender, fetchImage: async () => testImage, mailer: { send: async (m) => void mails.push(m) } });
|
/** Клиентский бот для чата из карточки клиента: что ушло клиенту. */
|
||||||
|
const clientBotTg = fakeTelegram();
|
||||||
|
const clientBotFiles: { chatId: number; name: string; type: string; caption: string }[] = [];
|
||||||
|
const clientBot = {
|
||||||
|
tg: clientBotTg.tg,
|
||||||
|
sendFile: (async (chatId, file, caption) => {
|
||||||
|
clientBotFiles.push({ chatId, name: file.name, type: file.type, caption });
|
||||||
|
return { fileId: `client-file-${clientBotFiles.length}`, kind: file.type.startsWith('image/') ? 'photo' : 'file' };
|
||||||
|
}) as FileSender,
|
||||||
|
download: async (fileId: string) => ({ data: Buffer.from(`file:${fileId}`), type: 'application/pdf', path: `documents/${fileId}.pdf` }),
|
||||||
|
};
|
||||||
|
const app = buildApp({ db, config, source, payments, sendFile, groupSender, clientBot, fetchImage: async () => testImage, mailer: { send: async (m) => void mails.push(m) } });
|
||||||
|
|
||||||
/** Клиент с собственной сессией. */
|
/** Клиент с собственной сессией. */
|
||||||
const client = (token?: string, host?: string) => {
|
const client = (token?: string, host?: string) => {
|
||||||
const call = async (method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE', path: string, body?: unknown) => {
|
const call = async (method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE', path: string, body?: unknown) => {
|
||||||
|
const raw = Buffer.isBuffer(body);
|
||||||
const res = await app.inject({
|
const res = await app.inject({
|
||||||
method,
|
method,
|
||||||
url: path,
|
url: path,
|
||||||
payload: body as object | undefined,
|
payload: body as object | undefined,
|
||||||
headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(host ? { host } : {}) },
|
headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(host ? { host } : {}), ...(raw ? { 'content-type': 'application/octet-stream' } : {}) },
|
||||||
});
|
});
|
||||||
return { status: res.statusCode, body: res.json() as any };
|
let parsed: any;
|
||||||
|
try { parsed = res.json(); } catch { parsed = res.rawPayload; }
|
||||||
|
return { status: res.statusCode, body: parsed, headers: res.headers };
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
get: (p: string) => call('GET', p),
|
get: (p: string) => call('GET', p),
|
||||||
@@ -87,7 +101,7 @@ export async function testApp(name = 'app', source: PagedSource | null = null, p
|
|||||||
return client(res.body.token as string);
|
return client(res.body.token as string);
|
||||||
};
|
};
|
||||||
|
|
||||||
return { app, db, config, mails, sentFiles, groupSent, client, register, close: async () => { await app.close(); await close(); } };
|
return { app, db, config, mails, sentFiles, groupSent, clientBotTg, clientBotFiles, client, register, close: async () => { await app.close(); await close(); } };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FakeMessage extends SentMessage {
|
export interface FakeMessage extends SentMessage {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { ClientDialog, useRequestLine } from './ClientsPage.tsx';
|
|||||||
import { CommissionBlock } from './commission.tsx';
|
import { CommissionBlock } from './commission.tsx';
|
||||||
import { ClientPhotos, useHouseholdLine } from './household.tsx';
|
import { ClientPhotos, useHouseholdLine } from './household.tsx';
|
||||||
import { ClientPage } from './showcase.tsx';
|
import { ClientPage } from './showcase.tsx';
|
||||||
|
import { ClientChat } from './ClientChat.tsx';
|
||||||
import { useCrm } from './i18n.ts';
|
import { useCrm } from './i18n.ts';
|
||||||
import { CommentBox, DealNote, Drawer, History, NextAction, StatusChip, useStatusChange } from './parts.tsx';
|
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>
|
</section>
|
||||||
|
|
||||||
|
{cl.chat && (cl.chat.available || cl.chat.hasMessages) && <ClientChat clientId={id} canWrite={canWork} />}
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h3 className="label">{x.request}</h3>
|
<h3 className="label">{x.request}</h3>
|
||||||
<p className={request ? '' : 'text-sm text-muted'}>{request || x.noRequest}</p>
|
<p className={request ? '' : 'text-sm text-muted'}>{request || x.noRequest}</p>
|
||||||
|
|||||||
@@ -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 { 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 { 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 { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router';
|
import { useNavigate, useParams } from 'react-router';
|
||||||
import { api, type Me } from '../api.ts';
|
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}>
|
<Kanban items={clients.data!} statuses={pipeline.data.statuses} onOpen={open} onMove={status.request} canDrag={canMove}>
|
||||||
{(cl) => (
|
{(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>
|
<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>}
|
{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>}
|
{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}>
|
<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)}>
|
<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="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 className="block truncate text-xs text-muted">{[x.kind[cl.kind], requestLine(cl.request)].filter(Boolean).join(' · ')}</span>
|
||||||
</span>
|
</span>
|
||||||
<StatusChip status={statusOf(cl)} />
|
<StatusChip status={statusOf(cl)} />
|
||||||
@@ -342,7 +342,7 @@ export function ClientsPage({ me }: { me: Me }) {
|
|||||||
{clients.data!.map((cl) => (
|
{clients.data!.map((cl) => (
|
||||||
<tr key={cl.id} className="cursor-pointer transition-colors hover:bg-raised/50" onClick={() => open(cl.id)}>
|
<tr key={cl.id} className="cursor-pointer transition-colors hover:bg-raised/50" onClick={() => open(cl.id)}>
|
||||||
<td className="px-4 py-3">
|
<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>
|
<p className="text-xs text-muted">{[x.kind[cl.kind], cl.phone].filter(Boolean).join(' · ')}</p>
|
||||||
</td>
|
</td>
|
||||||
<td className="max-w-xs px-4 py-3 text-muted"><span className="line-clamp-2">{requestLine(cl.request) || '—'}</span></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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -108,6 +108,8 @@ export interface Client {
|
|||||||
nextActionNote: string | null;
|
nextActionNote: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
leadsCount?: number;
|
leadsCount?: number;
|
||||||
|
/** Непрочитанные сообщения клиента в чате. */
|
||||||
|
unreadMessages?: number;
|
||||||
departmentName?: string | null;
|
departmentName?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +122,8 @@ export interface ClientLead extends Pick<Lead, 'id' | 'title' | 'price' | 'curre
|
|||||||
|
|
||||||
export interface ClientCard extends Client {
|
export interface ClientCard extends Client {
|
||||||
photos: { id: number; createdAt: string }[];
|
photos: { id: number; createdAt: string }[];
|
||||||
|
/** Чат с клиентом в боте анкеты; null — чат виден только ответственному и руководителю. */
|
||||||
|
chat: { available: boolean; unread: number; hasMessages: boolean } | null;
|
||||||
pageUrl: string | null;
|
pageUrl: string | null;
|
||||||
commission: CommissionInfo;
|
commission: CommissionInfo;
|
||||||
viewings: { leadId: number; day: string; note: string | null; title: string }[];
|
viewings: { leadId: number; day: string; note: string | null; title: string }[];
|
||||||
|
|||||||
@@ -206,6 +206,14 @@ const ru = {
|
|||||||
photoProgress: (done: number, total: number) => `Проверено ${done} из ${total}`, photoStarted: 'Запуск', photoNone: 'За сутки нет объявлений с лидами — догружать нечего',
|
photoProgress: (done: number, total: number) => `Проверено ${done} из ${total}`, photoStarted: 'Запуск', photoNone: 'За сутки нет объявлений с лидами — догружать нечего',
|
||||||
photoOk: 'В порядке', photoCover: 'Была только обложка', photoBroken: 'Фото не открывались', photoLoaded: 'Загружено заново', photoRemoved: 'Сняты с List.am', photoFailed: 'Не удалось прочитать', photoError: 'Последняя ошибка', photoStoppedKey: 'Остановлено: ключ ридера Jina не работает — скорее всего, кончился баланс. Пополните баланс или замените ключ в настройках ниже и нажмите кнопку снова. Без ключа полные галереи не загрузить.',
|
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: {
|
dp: {
|
||||||
favorite: 'Избранное', unseen: 'Не просмотренные', addFav: 'В избранное', removeFav: 'Убрать из избранного', unseenMark: 'Не просмотрен',
|
favorite: 'Избранное', unseen: 'Не просмотренные', addFav: 'В избранное', removeFav: 'Убрать из избранного', unseenMark: 'Не просмотрен',
|
||||||
currency: 'Валюта', cur: { AMD: 'АМД', USD: 'ДОЛ' } as Record<string, string>, priceHint: 'Цены в другой валюте пересчитываются по курсу',
|
currency: 'Валюта', cur: { AMD: 'АМД', USD: 'ДОЛ' } as Record<string, string>, priceHint: 'Цены в другой валюте пересчитываются по курсу',
|
||||||
@@ -232,7 +240,7 @@ const ru = {
|
|||||||
PAIR_NOT_LINKED: 'Сначала прикрепите объект к клиенту', DEAL_HAS_PAIR: 'По этой сделке уже указана другая пара. Сначала отмените ту сделку',
|
PAIR_NOT_LINKED: 'Сначала прикрепите объект к клиенту', DEAL_HAS_PAIR: 'По этой сделке уже указана другая пара. Сначала отмените ту сделку',
|
||||||
BAD_STATUS: 'Такого статуса нет в воронке', SYSTEM_STATUS: 'Системный статус удалить нельзя', STATUS_IN_USE: 'В статусе есть карточки: выберите, куда их перевести',
|
BAD_STATUS: 'Такого статуса нет в воронке', SYSTEM_STATUS: 'Системный статус удалить нельзя', STATUS_IN_USE: 'В статусе есть карточки: выберите, куда их перевести',
|
||||||
SYSTEM_REASON: 'Системную причину удалить нельзя', LAST_REASON: 'Нужна хотя бы одна причина отказа',
|
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: 'Отменить можно только последнюю выплату сотрудника',
|
MONTH_PAID: 'Месяц уже закрыт выплатой. Чтобы изменить его, отмените отметку', PAYOUT_NOT_LAST: 'Отменить можно только последнюю выплату сотрудника',
|
||||||
} as Record<string, string>,
|
} as Record<string, string>,
|
||||||
};
|
};
|
||||||
@@ -445,6 +453,14 @@ const hy: Dict = {
|
|||||||
photoProgress: (done, total) => `Ստուգված է ${done}-ը ${total}-ից`, photoStarted: 'Գործարկում', photoNone: 'Վերջին օրում լիդով հայտարարություններ չկան՝ լրացնելու բան չկա',
|
photoProgress: (done, total) => `Ստուգված է ${done}-ը ${total}-ից`, photoStarted: 'Գործարկում', photoNone: 'Վերջին օրում լիդով հայտարարություններ չկան՝ լրացնելու բան չկա',
|
||||||
photoOk: 'Կարգին են', photoCover: 'Միայն շապիկ էր', photoBroken: 'Լուսանկարները չէին բացվում', photoLoaded: 'Բեռնված է նորից', photoRemoved: 'Հանված են List.am-ից', photoFailed: 'Չհաջողվեց կարդալ', photoError: 'Վերջին սխալը', photoStoppedKey: 'Կանգնեցված է․ Jina ընթերցիչի բանալին չի աշխատում — հավանաբար մնացորդը սպառվել է։ Համալրեք մնացորդը կամ փոխեք բանալին ստորև կարգավորումներում և սեղմեք կոճակը կրկին։ Առանց բանալու ամբողջ պատկերասրահները չեն բեռնվի։',
|
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: {
|
dp: {
|
||||||
favorite: 'Ընտրված', unseen: 'Չդիտված', addFav: 'Ավելացնել ընտրվածներին', removeFav: 'Հանել ընտրվածներից', unseenMark: 'Չդիտված',
|
favorite: 'Ընտրված', unseen: 'Չդիտված', addFav: 'Ավելացնել ընտրվածներին', removeFav: 'Հանել ընտրվածներից', unseenMark: 'Չդիտված',
|
||||||
currency: 'Արժույթ', cur: { AMD: 'ՀՀԴ', USD: 'ԴՈԼ' } as Record<string, string>, priceHint: 'Այլ արժույթով գները վերահաշվարկվում են փոխարժեքով',
|
currency: 'Արժույթ', cur: { AMD: 'ՀՀԴ', USD: 'ԴՈԼ' } as Record<string, string>, priceHint: 'Այլ արժույթով գները վերահաշվարկվում են փոխարժեքով',
|
||||||
@@ -472,7 +488,7 @@ const hy: Dict = {
|
|||||||
PAIR_NOT_LINKED: 'Նախ կցեք օբյեկտը հաճախորդին', DEAL_HAS_PAIR: 'Այս գործարքում արդեն նշված է այլ զույգ։ Նախ չեղարկեք այն',
|
PAIR_NOT_LINKED: 'Նախ կցեք օբյեկտը հաճախորդին', DEAL_HAS_PAIR: 'Այս գործարքում արդեն նշված է այլ զույգ։ Նախ չեղարկեք այն',
|
||||||
BAD_STATUS: 'Ձագարում նման կարգավիճակ չկա', SYSTEM_STATUS: 'Համակարգային կարգավիճակը հնարավոր չէ ջնջել', STATUS_IN_USE: 'Կարգավիճակում քարտեր կան. ընտրեք, թե ուր տեղափոխել',
|
BAD_STATUS: 'Ձագարում նման կարգավիճակ չկա', SYSTEM_STATUS: 'Համակարգային կարգավիճակը հնարավոր չէ ջնջել', STATUS_IN_USE: 'Կարգավիճակում քարտեր կան. ընտրեք, թե ուր տեղափոխել',
|
||||||
SYSTEM_REASON: 'Համակարգային պատճառը հնարավոր չէ ջնջել', LAST_REASON: 'Անհրաժեշտ է առնվազն մեկ մերժման պատճառ',
|
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',
|
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.',
|
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: {
|
dp: {
|
||||||
favorite: 'Favourites', unseen: 'Not viewed', addFav: 'Add to favourites', removeFav: 'Remove from favourites', unseenMark: 'Not viewed',
|
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',
|
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',
|
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',
|
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',
|
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',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user