Backoffice stage 2: chats (support + concierge, files)
- ChatThread per client/provider and channel; messages get files, internal notes, source (bot/mini app) and delivery errors; reply templates. Migration moves old concierge messages into threads. - Routing: subscribers write to the concierge, others to support; /support and /concierge in the client bot; staff can take, transfer, close and move threads. - Telegram: photos/documents from clients and providers, replies with the channel header, files sent from storage, notifications only to the assignee or the channel's staff, "Reply" into a specific thread. - New admin: /chats with list, conversation, notes, files, templates, client panel; "Message" button on the client card; dashboard counts threads awaiting reply. - Client mini app: in-app chat screen sharing the same conversation. - Old admin keeps working on the same data (notes hidden there). - Tests for routing, notes, assign/close/move, lists, recipients; separate test DB via TEST_DB_NAME; dev:chats-demo script. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d452824c62
commit
23ffb2bf1d
46 files changed
+4176
-144
No files matched your search
@@ -79,7 +79,7 @@ const ru = {
|
||||
'dash.attention': 'Требует внимания',
|
||||
'dash.allClear': 'Срочного нет — всё разобрано.',
|
||||
'att.unassigned': 'Заказы без исполнителя',
|
||||
'att.unreadChats': 'Непрочитанные сообщения',
|
||||
'att.unreadChats': 'Ждут ответа в чатах',
|
||||
'att.webRequests': 'Заявки с сайта',
|
||||
'att.leads': 'Заявки на переезд',
|
||||
'att.appRequests': 'Заявки из приложения',
|
||||
@@ -298,6 +298,107 @@ const ru = {
|
||||
'team.disabled': 'Доступ отключён',
|
||||
'team.you': 'это вы',
|
||||
'team.saved': 'Роли сохранены',
|
||||
|
||||
// ───── чаты (этап 2) ─────
|
||||
'chat.title': 'Чаты',
|
||||
'chat.all': 'Все',
|
||||
'channel.SUPPORT': 'Поддержка',
|
||||
'channel.CONCIERGE': 'Консьерж',
|
||||
'chat.f.waiting': 'Ждут ответа',
|
||||
'chat.f.mine': 'Мои',
|
||||
'chat.f.unassigned': 'Без ответственного',
|
||||
'chat.f.open': 'Открытые',
|
||||
'chat.f.closed': 'Закрытые',
|
||||
'chat.f.all': 'Все',
|
||||
'chat.search': 'Имя, @username, телефон или текст',
|
||||
'chat.emptyList': 'Здесь пусто. Новые сообщения появятся сами.',
|
||||
'chat.emptyFilter': 'Ничего не нашли — попробуйте другой фильтр.',
|
||||
'chat.pick': 'Выберите диалог',
|
||||
'chat.pickHint': 'Сверху — те, кто ждёт ответа дольше всех.',
|
||||
'chat.waiting': 'ждёт {t}',
|
||||
'chat.you': 'Вы',
|
||||
'chat.provider': 'Исполнитель',
|
||||
'chat.unassigned': 'Никто не ведёт',
|
||||
'chat.take': 'Взять себе',
|
||||
'chat.transfer': 'Передать',
|
||||
'chat.transferTo': 'Передать коллеге',
|
||||
'chat.unassign': 'Снять ответственного',
|
||||
'chat.close': 'Закрыть',
|
||||
'chat.reopen': 'Открыть снова',
|
||||
'chat.closedBanner': 'Диалог закрыт. Клиент напишет — откроется сам; ответить можно и сейчас.',
|
||||
'chat.moveTo': 'Перенести в «{channel}»',
|
||||
'chat.moved': 'Диалог перенесён в «{channel}»',
|
||||
'chat.movedAway': 'Диалог перенесён в «{channel}». У вас нет доступа к этому каналу.',
|
||||
'chat.other': 'Есть переписка в канале «{channel}»',
|
||||
'chat.otherOpen': 'Открыть',
|
||||
'chat.bot': 'Диалог ведёт ИИ-консьерж.',
|
||||
'chat.botTake': 'Перехватить',
|
||||
'chat.reply': 'Ответ',
|
||||
'chat.note': 'Заметка',
|
||||
'chat.replyPh': 'Ответ клиенту — придёт ему в Telegram',
|
||||
'chat.notePh': 'Заметка для команды — клиент её не увидит',
|
||||
'chat.send': 'Отправить',
|
||||
'chat.saveNote': 'Сохранить заметку',
|
||||
'chat.attach': 'Прикрепить файл',
|
||||
'chat.templates': 'Шаблоны',
|
||||
'chat.tplManage': 'Настроить шаблоны',
|
||||
'chat.tplEmpty': 'Шаблонов пока нет.',
|
||||
'chat.tplNew': 'Новый шаблон',
|
||||
'chat.tplTitle': 'Название',
|
||||
'chat.tplText': 'Текст',
|
||||
'chat.tplTextHint': '{name} заменится на имя клиента',
|
||||
'chat.tplChannel': 'Канал',
|
||||
'chat.tplBoth': 'Оба канала',
|
||||
'chat.tplDelete': 'Удалить',
|
||||
'chat.tplSaved': 'Шаблон сохранён',
|
||||
'chat.tplSearch': 'Найти шаблон',
|
||||
'chat.noteLabel': 'Заметка · видит только команда',
|
||||
'chat.fromApp': 'из приложения',
|
||||
'chat.undelivered': 'Не доставлено: клиент заблокировал бота или ни разу его не открывал',
|
||||
'chat.fileTooBig': 'Файл больше 10 МБ',
|
||||
'chat.enterHint': 'Enter — отправить · Shift+Enter — новая строка',
|
||||
'chat.providerNotLinked': 'Исполнитель ещё не открыл рабочий бот — пока можно оставить только заметку.',
|
||||
'chat.client': 'О клиенте',
|
||||
'chat.aboutProvider': 'Об исполнителе',
|
||||
'chat.openCard': 'Карточка клиента',
|
||||
'chat.write': 'Написать',
|
||||
'chat.back': 'Все диалоги',
|
||||
'chat.sys.took': '{name} взял(а) диалог',
|
||||
'chat.sys.transferred': '{name} передал(а) диалог: {param}',
|
||||
'chat.sys.unassigned': '{name} снял(а) ответственного',
|
||||
'chat.sys.closed': '{name} закрыл(а) диалог',
|
||||
'chat.sys.reopened': '{name} открыл(а) диалог снова',
|
||||
'chat.sys.moved': '{name} перенёс(ла) диалог в «{param}»',
|
||||
'chat.ago.now': 'только что',
|
||||
'chat.ago.min': '{n} мин',
|
||||
'chat.ago.h': '{n} ч',
|
||||
'chat.ago.d': '{n} дн',
|
||||
'chat.today': 'Сегодня',
|
||||
'chat.yesterday': 'Вчера',
|
||||
'chat.image': 'Фото',
|
||||
'chat.file': 'Файл',
|
||||
'chat.subUntil': 'до {date}',
|
||||
'chat.orders': 'Заказы',
|
||||
'chat.phone': 'Телефон',
|
||||
'chat.lang': 'Язык',
|
||||
'chat.source': 'Откуда',
|
||||
'chat.linked': 'Рабочий бот открыт',
|
||||
'chat.notLinked': 'Рабочий бот не открыт',
|
||||
'chat.details': 'Подробнее',
|
||||
'chat.more': 'Ещё действия',
|
||||
'err.CONCIERGE_SUBSCRIBERS_ONLY': 'Консьерж — только для подписчиков. Сначала выдайте клиенту подписку.',
|
||||
'err.ASSIGNEE_NO_ACCESS': 'У этого сотрудника нет доступа к этому каналу.',
|
||||
'err.PROVIDER_NOT_LINKED': 'Исполнитель ещё не открыл рабочий бот — написать ему пока некуда.',
|
||||
'err.BAD_FILE_TYPE': 'Такой файл не подойдёт. Можно фото, PDF, Word, Excel, текст или ZIP.',
|
||||
'audit.bo.chats.send': 'ответил(а) в чате',
|
||||
'audit.bo.chats.sendFile': 'отправил(а) файл в чате',
|
||||
'audit.bo.chats.assign': 'назначил(а) ответственного в чате',
|
||||
'audit.bo.chats.close': 'закрыл(а) диалог',
|
||||
'audit.bo.chats.reopen': 'открыл(а) диалог снова',
|
||||
'audit.bo.chats.move': 'перенёс(ла) диалог в другой канал',
|
||||
'audit.bo.chats.openFor': 'открыл(а) чат с клиентом',
|
||||
'audit.bo.chats.templates.save': 'изменил(а) шаблон ответа',
|
||||
'audit.bo.chats.templates.remove': 'удалил(а) шаблон ответа',
|
||||
};
|
||||
|
||||
export type BoKey = keyof typeof ru;
|
||||
@@ -376,7 +477,7 @@ const en: Dict = {
|
||||
'dash.attention': 'Needs attention',
|
||||
'dash.allClear': 'Nothing urgent — all caught up.',
|
||||
'att.unassigned': 'Orders without a provider',
|
||||
'att.unreadChats': 'Unread messages',
|
||||
'att.unreadChats': 'Chats awaiting reply',
|
||||
'att.webRequests': 'Website requests',
|
||||
'att.leads': 'Relocation requests',
|
||||
'att.appRequests': 'In-app requests',
|
||||
@@ -595,6 +696,107 @@ const en: Dict = {
|
||||
'team.disabled': 'Access disabled',
|
||||
'team.you': 'you',
|
||||
'team.saved': 'Roles saved',
|
||||
|
||||
// ───── чаты (этап 2) ─────
|
||||
'chat.title': 'Chats',
|
||||
'chat.all': 'All',
|
||||
'channel.SUPPORT': 'Support',
|
||||
'channel.CONCIERGE': 'Concierge',
|
||||
'chat.f.waiting': 'Awaiting reply',
|
||||
'chat.f.mine': 'Mine',
|
||||
'chat.f.unassigned': 'Unassigned',
|
||||
'chat.f.open': 'Open',
|
||||
'chat.f.closed': 'Closed',
|
||||
'chat.f.all': 'All',
|
||||
'chat.search': 'Name, @username, phone or text',
|
||||
'chat.emptyList': 'Nothing here. New messages will appear on their own.',
|
||||
'chat.emptyFilter': 'Nothing found — try another filter.',
|
||||
'chat.pick': 'Pick a conversation',
|
||||
'chat.pickHint': 'Those waiting longest are on top.',
|
||||
'chat.waiting': 'waiting {t}',
|
||||
'chat.you': 'You',
|
||||
'chat.provider': 'Provider',
|
||||
'chat.unassigned': 'Nobody assigned',
|
||||
'chat.take': 'Take it',
|
||||
'chat.transfer': 'Pass on',
|
||||
'chat.transferTo': 'Pass to a colleague',
|
||||
'chat.unassign': 'Remove assignee',
|
||||
'chat.close': 'Close',
|
||||
'chat.reopen': 'Reopen',
|
||||
'chat.closedBanner': 'The conversation is closed. It reopens when the client writes; you can still reply now.',
|
||||
'chat.moveTo': 'Move to {channel}',
|
||||
'chat.moved': 'Moved to {channel}',
|
||||
'chat.movedAway': 'Moved to {channel}. You have no access to that channel.',
|
||||
'chat.other': 'There is also a {channel} conversation',
|
||||
'chat.otherOpen': 'Open',
|
||||
'chat.bot': 'The AI concierge is handling this conversation.',
|
||||
'chat.botTake': 'Take over',
|
||||
'chat.reply': 'Reply',
|
||||
'chat.note': 'Note',
|
||||
'chat.replyPh': 'Reply to the client — goes to their Telegram',
|
||||
'chat.notePh': 'Note for the team — the client won’t see it',
|
||||
'chat.send': 'Send',
|
||||
'chat.saveNote': 'Save note',
|
||||
'chat.attach': 'Attach a file',
|
||||
'chat.templates': 'Templates',
|
||||
'chat.tplManage': 'Manage templates',
|
||||
'chat.tplEmpty': 'No templates yet.',
|
||||
'chat.tplNew': 'New template',
|
||||
'chat.tplTitle': 'Title',
|
||||
'chat.tplText': 'Text',
|
||||
'chat.tplTextHint': '{name} is replaced with the client’s name',
|
||||
'chat.tplChannel': 'Channel',
|
||||
'chat.tplBoth': 'Both channels',
|
||||
'chat.tplDelete': 'Delete',
|
||||
'chat.tplSaved': 'Template saved',
|
||||
'chat.tplSearch': 'Find a template',
|
||||
'chat.noteLabel': 'Note · team only',
|
||||
'chat.fromApp': 'from the app',
|
||||
'chat.undelivered': 'Not delivered: the client blocked the bot or never opened it',
|
||||
'chat.fileTooBig': 'The file is larger than 10 MB',
|
||||
'chat.enterHint': 'Enter to send · Shift+Enter for a new line',
|
||||
'chat.providerNotLinked': 'The provider hasn’t opened the work bot yet — you can only leave a note for now.',
|
||||
'chat.client': 'About the client',
|
||||
'chat.aboutProvider': 'About the provider',
|
||||
'chat.openCard': 'Client card',
|
||||
'chat.write': 'Message',
|
||||
'chat.back': 'All conversations',
|
||||
'chat.sys.took': '{name} took the conversation',
|
||||
'chat.sys.transferred': '{name} passed the conversation to {param}',
|
||||
'chat.sys.unassigned': '{name} removed the assignee',
|
||||
'chat.sys.closed': '{name} closed the conversation',
|
||||
'chat.sys.reopened': '{name} reopened the conversation',
|
||||
'chat.sys.moved': '{name} moved the conversation to {param}',
|
||||
'chat.ago.now': 'just now',
|
||||
'chat.ago.min': '{n} min',
|
||||
'chat.ago.h': '{n} h',
|
||||
'chat.ago.d': '{n} d',
|
||||
'chat.today': 'Today',
|
||||
'chat.yesterday': 'Yesterday',
|
||||
'chat.image': 'Photo',
|
||||
'chat.file': 'File',
|
||||
'chat.subUntil': 'until {date}',
|
||||
'chat.orders': 'Orders',
|
||||
'chat.phone': 'Phone',
|
||||
'chat.lang': 'Language',
|
||||
'chat.source': 'Source',
|
||||
'chat.linked': 'Work bot opened',
|
||||
'chat.notLinked': 'Work bot not opened',
|
||||
'chat.details': 'Details',
|
||||
'chat.more': 'More actions',
|
||||
'err.CONCIERGE_SUBSCRIBERS_ONLY': 'The concierge is for members only. Give the client a subscription first.',
|
||||
'err.ASSIGNEE_NO_ACCESS': 'This team member has no access to this channel.',
|
||||
'err.PROVIDER_NOT_LINKED': 'The provider hasn’t opened the work bot yet.',
|
||||
'err.BAD_FILE_TYPE': 'This file type isn’t supported. Use a photo, PDF, Word, Excel, text or ZIP.',
|
||||
'audit.bo.chats.send': 'replied in a chat',
|
||||
'audit.bo.chats.sendFile': 'sent a file in a chat',
|
||||
'audit.bo.chats.assign': 'assigned a chat',
|
||||
'audit.bo.chats.close': 'closed a conversation',
|
||||
'audit.bo.chats.reopen': 'reopened a conversation',
|
||||
'audit.bo.chats.move': 'moved a conversation to another channel',
|
||||
'audit.bo.chats.openFor': 'opened a chat with a client',
|
||||
'audit.bo.chats.templates.save': 'edited a reply template',
|
||||
'audit.bo.chats.templates.remove': 'deleted a reply template',
|
||||
};
|
||||
|
||||
const hy: Dict = {
|
||||
@@ -670,7 +872,7 @@ const hy: Dict = {
|
||||
'dash.attention': 'Պահանջում է ուշադրություն',
|
||||
'dash.allClear': 'Շտապ ոչինչ չկա․ ամեն ինչ արված է։',
|
||||
'att.unassigned': 'Պատվերներ առանց կատարողի',
|
||||
'att.unreadChats': 'Չկարդացված հաղորդագրություններ',
|
||||
'att.unreadChats': 'Պատասխանի սպասող զրույցներ',
|
||||
'att.webRequests': 'Հայտեր կայքից',
|
||||
'att.leads': 'Տեղափոխման հայտեր',
|
||||
'att.appRequests': 'Հայտեր հավելվածից',
|
||||
@@ -889,6 +1091,107 @@ const hy: Dict = {
|
||||
'team.disabled': 'Մուտքն անջատված է',
|
||||
'team.you': 'դուք',
|
||||
'team.saved': 'Դերերը պահպանված են',
|
||||
|
||||
// ───── чаты (этап 2) ─────
|
||||
'chat.title': 'Զրույցներ',
|
||||
'chat.all': 'Բոլորը',
|
||||
'channel.SUPPORT': 'Աջակցություն',
|
||||
'channel.CONCIERGE': 'Կոնսիերժ',
|
||||
'chat.f.waiting': 'Սպասում են պատասխանի',
|
||||
'chat.f.mine': 'Իմը',
|
||||
'chat.f.unassigned': 'Առանց պատասխանատուի',
|
||||
'chat.f.open': 'Բաց',
|
||||
'chat.f.closed': 'Փակ',
|
||||
'chat.f.all': 'Բոլորը',
|
||||
'chat.search': 'Անուն, @username, հեռախոս կամ տեքստ',
|
||||
'chat.emptyList': 'Այստեղ դատարկ է։ Նոր հաղորդագրությունները կհայտնվեն ինքնուրույն։',
|
||||
'chat.emptyFilter': 'Ոչինչ չգտանք․ փորձեք այլ ֆիլտր։',
|
||||
'chat.pick': 'Ընտրեք զրույցը',
|
||||
'chat.pickHint': 'Վերևում նրանք են, ովքեր ամենաերկար են սպասում։',
|
||||
'chat.waiting': 'սպասում է {t}',
|
||||
'chat.you': 'Դուք',
|
||||
'chat.provider': 'Կատարող',
|
||||
'chat.unassigned': 'Պատասխանատու չկա',
|
||||
'chat.take': 'Վերցնել ինձ',
|
||||
'chat.transfer': 'Փոխանցել',
|
||||
'chat.transferTo': 'Փոխանցել գործընկերոջը',
|
||||
'chat.unassign': 'Հանել պատասխանատուին',
|
||||
'chat.close': 'Փակել',
|
||||
'chat.reopen': 'Կրկին բացել',
|
||||
'chat.closedBanner': 'Զրույցը փակ է։ Կբացվի, երբ հաճախորդը գրի․ կարող եք պատասխանել նաև հիմա։',
|
||||
'chat.moveTo': 'Տեղափոխել «{channel}»',
|
||||
'chat.moved': 'Տեղափոխվեց «{channel}»',
|
||||
'chat.movedAway': 'Տեղափոխվեց «{channel}»։ Դուք մուտք չունեք այդ ալիքին։',
|
||||
'chat.other': 'Կա նաև զրույց «{channel}» ալիքում',
|
||||
'chat.otherOpen': 'Բացել',
|
||||
'chat.bot': 'Զրույցը վարում է ԱԲ կոնսիերժը։',
|
||||
'chat.botTake': 'Վերցնել',
|
||||
'chat.reply': 'Պատասխան',
|
||||
'chat.note': 'Նշում',
|
||||
'chat.replyPh': 'Պատասխան հաճախորդին՝ կգա Telegram-ում',
|
||||
'chat.notePh': 'Նշում թիմի համար՝ հաճախորդը չի տեսնի',
|
||||
'chat.send': 'Ուղարկել',
|
||||
'chat.saveNote': 'Պահել նշումը',
|
||||
'chat.attach': 'Կցել ֆայլ',
|
||||
'chat.templates': 'Ձևանմուշներ',
|
||||
'chat.tplManage': 'Կարգավորել ձևանմուշները',
|
||||
'chat.tplEmpty': 'Ձևանմուշներ դեռ չկան։',
|
||||
'chat.tplNew': 'Նոր ձևանմուշ',
|
||||
'chat.tplTitle': 'Անվանում',
|
||||
'chat.tplText': 'Տեքստ',
|
||||
'chat.tplTextHint': '{name}-ը կփոխարինվի հաճախորդի անունով',
|
||||
'chat.tplChannel': 'Ալիք',
|
||||
'chat.tplBoth': 'Երկու ալիքները',
|
||||
'chat.tplDelete': 'Ջնջել',
|
||||
'chat.tplSaved': 'Ձևանմուշը պահպանված է',
|
||||
'chat.tplSearch': 'Գտնել ձևանմուշ',
|
||||
'chat.noteLabel': 'Նշում · տեսանելի է միայն թիմին',
|
||||
'chat.fromApp': 'հավելվածից',
|
||||
'chat.undelivered': 'Չի առաքվել․ հաճախորդը արգելափակել է բոտը կամ երբեք չի բացել',
|
||||
'chat.fileTooBig': 'Ֆայլը 10 ՄԲ-ից մեծ է',
|
||||
'chat.enterHint': 'Enter՝ ուղարկել · Shift+Enter՝ նոր տող',
|
||||
'chat.providerNotLinked': 'Կատարողը դեռ չի բացել աշխատանքային բոտը․ առայժմ կարող եք թողնել միայն նշում։',
|
||||
'chat.client': 'Հաճախորդի մասին',
|
||||
'chat.aboutProvider': 'Կատարողի մասին',
|
||||
'chat.openCard': 'Հաճախորդի քարտ',
|
||||
'chat.write': 'Գրել',
|
||||
'chat.back': 'Բոլոր զրույցները',
|
||||
'chat.sys.took': '{name}-ը վերցրեց զրույցը',
|
||||
'chat.sys.transferred': '{name}-ը զրույցը փոխանցեց {param}-ին',
|
||||
'chat.sys.unassigned': '{name}-ը հանեց պատասխանատուին',
|
||||
'chat.sys.closed': '{name}-ը փակեց զրույցը',
|
||||
'chat.sys.reopened': '{name}-ը կրկին բացեց զրույցը',
|
||||
'chat.sys.moved': '{name}-ը զրույցը տեղափոխեց «{param}»',
|
||||
'chat.ago.now': 'հենց հիմա',
|
||||
'chat.ago.min': '{n} ր',
|
||||
'chat.ago.h': '{n} ժ',
|
||||
'chat.ago.d': '{n} օր',
|
||||
'chat.today': 'Այսօր',
|
||||
'chat.yesterday': 'Երեկ',
|
||||
'chat.image': 'Լուսանկար',
|
||||
'chat.file': 'Ֆայլ',
|
||||
'chat.subUntil': 'մինչև {date}',
|
||||
'chat.orders': 'Պատվերներ',
|
||||
'chat.phone': 'Հեռախոս',
|
||||
'chat.lang': 'Լեզու',
|
||||
'chat.source': 'Աղբյուր',
|
||||
'chat.linked': 'Աշխատանքային բոտը բացված է',
|
||||
'chat.notLinked': 'Աշխատանքային բոտը բացված չէ',
|
||||
'chat.details': 'Մանրամասներ',
|
||||
'chat.more': 'Այլ գործողություններ',
|
||||
'err.CONCIERGE_SUBSCRIBERS_ONLY': 'Կոնսիերժը միայն բաժանորդների համար է։ Նախ տրամադրեք բաժանորդագրություն։',
|
||||
'err.ASSIGNEE_NO_ACCESS': 'Այս աշխատակիցը մուտք չունի այս ալիքին։',
|
||||
'err.PROVIDER_NOT_LINKED': 'Կատարողը դեռ չի բացել աշխատանքային բոտը։',
|
||||
'err.BAD_FILE_TYPE': 'Այս ֆայլը չի համապատասխանում։ Կարելի է լուսանկար, PDF, Word, Excel, տեքստ կամ ZIP։',
|
||||
'audit.bo.chats.send': 'պատասխանեց զրույցում',
|
||||
'audit.bo.chats.sendFile': 'ֆայլ ուղարկեց զրույցում',
|
||||
'audit.bo.chats.assign': 'նշանակեց զրույցի պատասխանատու',
|
||||
'audit.bo.chats.close': 'փակեց զրույցը',
|
||||
'audit.bo.chats.reopen': 'կրկին բացեց զրույցը',
|
||||
'audit.bo.chats.move': 'զրույցը տեղափոխեց այլ ալիք',
|
||||
'audit.bo.chats.openFor': 'բացեց զրույց հաճախորդի հետ',
|
||||
'audit.bo.chats.templates.save': 'փոխեց պատասխանի ձևանմուշը',
|
||||
'audit.bo.chats.templates.remove': 'ջնջեց պատասխանի ձևանմուշը',
|
||||
};
|
||||
|
||||
const DICTS: Record<BoLocale, Dict> = { ru, en, hy };
|
||||
|
||||
Reference in new issue
Block a user