Relocations and requests in the new admin; star rating in the client bot

Orders section gets tabs: Orders / Relocations / Requests (concierge sees the
last two). Relocation list with statuses, plan progress and current step;
relocation card with per-client steps (status, client comment, rename, add,
remove), documents, quiz answers, team note, write/call. Requests: website
cleaning requests and app requests (membership, call back) with notes,
create order, turn on membership. Dashboard links point here instead of the
old admin.

Fix: leads with an unanswered 'already moved?' (NULL) were skipped by
'moved not true' filters — plan sync and linking to the app client.

Client bot: after an order is done, asks for 1-5 stars; low rating asks what
went wrong, the reply is saved as the rating comment and goes to the team chat.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-21 04:17:21 +04:00
1 parent bbb25d3d0d
commit dcba7c200c
19 files changed
+1422 -22

No files matched your search

+3
View File
@@ -8,6 +8,7 @@ import { boProvidersRouter } from './providers.ts';
import { boCrmRouter } from './crm.ts';
import { boPartnersRouter } from './partners.ts';
import { boProductsRouter } from './products.ts';
import { boMovesRouter, boRequestsRouter } from './moves.ts';
import { boBroadcastsRouter, boJournalRouter, boSettingsRouter } from './admin5.ts';
/** API новой админки (apps/backoffice) */
@@ -19,6 +20,8 @@ export const backofficeRouter = router({
segments: boSegmentsRouter,
chats: boChatsRouter,
orders: boOrdersRouter,
moves: boMovesRouter,
requests: boRequestsRouter,
providers: boProvidersRouter,
crm: boCrmRouter,
partners: boPartnersRouter,
+136
View File
@@ -0,0 +1,136 @@
import {
addLeadDoc,
addLeadStep,
grantSubscription,
listClientRequests,
listLeads,
listRelocationItems,
listWebRequests,
relocationNames,
removeLeadDoc,
removeLeadStep,
updateClientRequest,
updateLeadByAdmin,
updateLeadDoc,
updateLeadStep,
updateWebRequest,
} from '@barev/services';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { rolesProcedure, router } from '../../trpc.ts';
/** Переезды и заявки ведут поддержка и консьерж */
const opsProcedure = rolesProcedure('SUPPORT', 'CONCIERGE');
const LEAD_STATUSES = ['NEW', 'IN_WORK', 'DONE', 'CANCELLED'] as const;
const STEP_STATUSES = ['TODO', 'IN_PROGRESS', 'DONE', 'SKIPPED'] as const;
const leadInclude = {
steps: { orderBy: { sortOrder: 'asc' } },
docs: { orderBy: { sortOrder: 'asc' } },
client: { select: { id: true, cardCode: true, user: { select: { firstName: true, lastName: true, username: true, phone: true } } } },
} as const;
/** Переезды: заявки из квиза на сайте и из приложения, у каждой — план по шагам и документы клиента */
export const boMovesRouter = router({
counts: opsProcedure.query(async ({ ctx }) => {
const rows = await ctx.db.lead.groupBy({ by: ['status'], where: { OR: [{ moved: null }, { moved: false }] }, _count: true });
const out = { NEW: 0, IN_WORK: 0, DONE: 0, CANCELLED: 0 };
for (const r of rows) out[r.status] = r._count;
return out;
}),
list: opsProcedure.input(z.object({ status: z.enum(LEAD_STATUSES).optional(), search: z.string().max(100).optional() })).query(async ({ ctx, input }) => {
const leads = await listLeads(ctx.svc, { status: input.status });
const q = input.search?.trim().toLowerCase().replace(/^#/, '');
return leads
.filter((l) => !l.moved)
.filter((l) => !q || String(l.number) === q || l.name.toLowerCase().includes(q) || l.contact.toLowerCase().includes(q) || (l.agency ?? '').toLowerCase().includes(q))
.map((l) => {
const steps = l.steps.filter((s) => s.status !== 'SKIPPED');
return {
id: l.id,
number: l.number,
name: l.name,
contact: l.contact,
contactType: l.contactType,
locale: l.locale,
agency: l.agency,
status: l.status,
package: l.package,
moveWhen: l.moveWhen,
moveDate: l.moveDate,
submitted: !!l.submittedAt,
createdAt: l.createdAt,
inApp: !!l.client,
stepsDone: steps.filter((s) => s.status === 'DONE').length,
stepsTotal: steps.length,
current: steps.find((s) => s.status === 'IN_PROGRESS') ?? steps.find((s) => s.status === 'TODO') ?? null,
docsDone: l.docs.filter((d) => d.checked).length,
docsTotal: l.docs.length,
adminNote: l.adminNote,
};
});
}),
get: opsProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
const lead = await ctx.db.lead.findUnique({ where: { id: input.id }, omit: { key: true }, include: leadInclude });
if (!lead) throw new TRPCError({ code: 'NOT_FOUND' });
return lead;
}),
/** общий список шагов и документов: названия на 5 языках */
names: opsProcedure.query(({ ctx }) => relocationNames(ctx.db)),
templates: opsProcedure.query(({ ctx }) => listRelocationItems(ctx.svc)),
update: opsProcedure
.input(z.object({ id: z.string(), status: z.enum(LEAD_STATUSES).optional(), adminNote: z.string().max(1000).optional() }))
.mutation(({ ctx, input }) => updateLeadByAdmin(ctx.svc, input)),
/** шаг плана: статус (клиенту уходит сообщение в бота), комментарий для клиента, своё название */
updateStep: opsProcedure
.input(z.object({ id: z.string(), status: z.enum(STEP_STATUSES).optional(), note: z.string().max(500).optional(), title: z.string().max(120).optional() }))
.mutation(({ ctx, input }) => updateLeadStep(ctx.svc, { stepId: input.id, status: input.status, note: input.note, title: input.title })),
addStep: opsProcedure
.input(z.object({ id: z.string(), code: z.string().max(40).optional(), title: z.string().max(120).optional() }))
.mutation(({ ctx, input }) => addLeadStep(ctx.svc, { leadId: input.id, code: input.code, title: input.title })),
removeStep: opsProcedure.input(z.object({ id: z.string() })).mutation(({ ctx, input }) => removeLeadStep(ctx.svc, input.id)),
addDoc: opsProcedure
.input(z.object({ id: z.string(), code: z.string().max(40).optional(), title: z.string().max(120).optional() }))
.mutation(({ ctx, input }) => addLeadDoc(ctx.svc, { leadId: input.id, code: input.code, title: input.title })),
updateDoc: opsProcedure
.input(z.object({ id: z.string(), title: z.string().max(120).optional(), checked: z.boolean().optional() }))
.mutation(({ ctx, input }) => updateLeadDoc(ctx.svc, { docId: input.id, title: input.title, checked: input.checked })),
removeDoc: opsProcedure.input(z.object({ id: z.string() })).mutation(({ ctx, input }) => removeLeadDoc(ctx.svc, input.id)),
});
/** Заявки: с сайта (уборка без регистрации) и из приложения (подписка, «перезвоните» по услуге) */
export const boRequestsRouter = router({
counts: opsProcedure.query(async ({ ctx }) => {
const [site, app] = await Promise.all([
ctx.db.webRequest.count({ where: { status: { in: ['NEW', 'LINKED'] } } }),
ctx.db.clientRequest.count({ where: { status: 'NEW' } }),
]);
return { site, app };
}),
site: opsProcedure.input(z.object({ open: z.boolean() })).query(async ({ ctx, input }) => {
const rows = await listWebRequests(ctx.svc, {});
return rows.filter((r) => (input.open ? r.status === 'NEW' || r.status === 'LINKED' : r.status === 'ORDERED' || r.status === 'CANCELLED'));
}),
app: opsProcedure.input(z.object({ open: z.boolean() })).query(async ({ ctx, input }) => {
const rows = await listClientRequests(ctx.svc, {});
return rows.filter((r) => (input.open ? r.status === 'NEW' : r.status !== 'NEW'));
}),
updateSite: opsProcedure
.input(z.object({ id: z.string(), status: z.enum(['CANCELLED', 'REOPEN']).optional(), adminNote: z.string().max(1000).optional() }))
.mutation(({ ctx, input }) => updateWebRequest(ctx.svc, input)),
updateApp: opsProcedure
.input(z.object({ id: z.string(), status: z.enum(['NEW', 'DONE', 'CANCELLED']).optional(), adminNote: z.string().max(1000).optional() }))
.mutation(({ ctx, input }) => updateClientRequest(ctx.svc, input)),
/** включить подписку по заявке из приложения: создаёт подписку и закрывает заявку */
activate: opsProcedure.input(z.object({ id: z.string(), days: z.number().int().min(1).max(366) })).mutation(async ({ ctx, input }) => {
const r = await ctx.db.clientRequest.findUniqueOrThrow({ where: { id: input.id } });
if (r.kind !== 'SUBSCRIPTION' || !r.plan) throw new TRPCError({ code: 'BAD_REQUEST' });
return grantSubscription(ctx.svc, { clientId: r.clientId, plan: r.plan, days: input.days, requestId: r.id });
}),
});
+11 -3
View File
@@ -21,6 +21,7 @@ import { AgencyCard, Agencies } from './pages/Agencies.tsx';
import { VenueCard, Venues } from './pages/Venues.tsx';
import { Products, ServiceEditor } from './pages/Products.tsx';
import { Broadcasts, Journal, Settings } from './pages/Admin5.tsx';
import { MoveCard, Moves, Requests } from './pages/Moves.tsx';
export type Role = 'SUPERADMIN' | 'SUPPORT' | 'CONCIERGE' | 'SALES';
export interface Me {
@@ -54,7 +55,7 @@ const NAV: NavItem[] = [
{ path: '/', key: 'nav.dashboard', icon: 'dashboard', roles: 'all' },
{ path: '/clients', key: 'nav.clients', icon: 'clients', roles: ['SUPPORT', 'CONCIERGE'] },
{ path: '/chats', key: 'nav.chats', icon: 'chats', roles: ['SUPPORT', 'CONCIERGE'], count: 'chats' },
{ path: '/orders', key: 'nav.orders', icon: 'orders', roles: ['SUPPORT'], count: 'orders' },
{ path: '/orders', key: 'nav.orders', icon: 'orders', roles: ['SUPPORT', 'CONCIERGE'], count: 'orders' },
{ path: '/providers', key: 'nav.providers', icon: 'providers', roles: ['SUPPORT'], group: 'work' },
{ path: '/agencies', key: 'nav.agencies', icon: 'agencies', roles: [], group: 'partners' },
{ path: '/venues', key: 'nav.venues', icon: 'venues', roles: [], group: 'partners' },
@@ -87,7 +88,10 @@ function Shell({ me }: { me: Me }) {
const canChat = items.some((n) => n.path === '/chats');
const orderId = match('/orders/:id', path)?.id;
const providerId = match('/providers/:id', path)?.id;
const canOrders = items.some((n) => n.path === '/orders');
// заказы и исполнители — поддержка; переезды и заявки — ещё и консьерж
const canOrders = isSuper || me.roles.includes('SUPPORT');
const canMoves = items.some((n) => n.path === '/orders');
const moveId = match('/moves/:id', path)?.id;
const leadId = match('/crm/:id', path)?.id;
const agencyId = match('/agencies/:id', path)?.id;
const venueId = match('/venues/:id', path)?.id;
@@ -98,6 +102,9 @@ function Shell({ me }: { me: Me }) {
if (path === '/') page = <Dashboard me={me} />;
else if ((path === '/chats' || threadId) && canChat) page = <Chats me={me} threadId={threadId} />;
else if (path === '/orders' && canOrders) page = <Orders me={me} />;
else if ((path === '/orders' || path === '/moves') && canMoves) page = <Moves me={me} />;
else if (moveId && canMoves) page = <MoveCard id={moveId} me={me} />;
else if (path === '/requests' && canMoves) page = <Requests me={me} />;
else if (orderId && canOrders) page = <OrderCard id={orderId} me={me} />;
else if (path === '/providers' && canOrders) page = <Providers me={me} />;
else if (providerId && canOrders) page = <ProviderCard id={providerId} me={me} />;
@@ -118,7 +125,8 @@ function Shell({ me }: { me: Me }) {
else if (current?.stage) page = <Soon title={t(current.key)} stage={current.stage} />;
else page = <Dashboard me={me} />;
const isActive = (n: NavItem) => (n.path === '/' ? path === '/' : path === n.path || path.startsWith(`${n.path}/`));
const isActive = (n: NavItem) =>
n.path === '/' ? path === '/' : path === n.path || path.startsWith(`${n.path}/`) || (n.path === '/orders' && (path.startsWith('/moves') || path === '/requests'));
const tabItems = items.slice(0, 4);
return (
+216
View File
@@ -386,6 +386,78 @@ const ru = {
'chat.notLinked': 'Рабочий бот не открыт',
'chat.details': 'Подробнее',
'chat.more': 'Ещё действия',
'ord.tab.orders': 'Заказы',
'ord.tab.moves': 'Переезды',
'ord.tab.requests': 'Заявки',
'mv.search': 'Имя, контакт, агентство или №',
'mv.empty': 'Переездов с таким статусом нет.',
'mv.all': 'Все',
'mv.steps': 'Шаги {done} из {total}',
'mv.docs': 'Документы {done}/{total}',
'mv.now': 'Сейчас: {step}',
'mv.noPlan': 'План ещё не собран',
'mv.back': 'Все переезды',
'mv.plan': 'План переезда',
'mv.planHintApp': 'Клиент видит шаги в приложении. Когда меняете статус шага, ему приходит сообщение в бот.',
'mv.planHintNoApp': 'Клиент ещё не открыл приложение — план пока видите только вы.',
'mv.docsTitle': 'Документы клиента',
'mv.docsHint': 'Клиент отмечает их в приложении, вы — здесь.',
'mv.answers': 'Ответы в квизе',
'mv.when': 'Когда переезд',
'mv.package': 'Пакет',
'mv.wants': 'Что нужно',
'mv.agency': 'Агентство',
'mv.lang': 'Язык',
'mv.created': 'Заявка',
'mv.unfinished': 'не дошёл до конца квиза',
'mv.note': 'Заметка команды',
'mv.notePh': 'Для себя: созвонились, что обещали, кто ведёт…',
'mv.save': 'Сохранить',
'mv.status': 'Статус переезда',
'mv.contact': 'Контакт',
'mv.write': 'Написать',
'mv.call': 'Позвонить',
'mv.openClient': 'Карточка клиента',
'mv.inApp': 'в приложении',
'mv.add': 'Добавить',
'mv.addStep': 'Шаг из списка…',
'mv.addDoc': 'Документ из списка…',
'mv.custom': 'Свой пункт…',
'mv.customPh': 'Название для этого клиента',
'mv.stepNote': 'Комментарий для клиента — он увидит его в приложении',
'mv.remove': 'Убрать у клиента',
'mv.removeConfirm': 'Убрать «{name}» у этого клиента?',
'mv.rename': 'Переименовать',
'mv.listHint': 'Общий список шагов и документов настраивается в «Продуктах».',
'rq.site': 'С сайта',
'rq.app': 'Из приложения',
'rq.open': 'Открытые',
'rq.closed': 'Закрытые',
'rq.siteHint': 'Уборка, заказанная на barevclub.com без регистрации. Позвоните, подтвердите время и создайте заказ.',
'rq.appHint': 'Клиенты попросили подписку или звонок по услуге без онлайн-записи.',
'rq.empty': 'Открытых заявок нет.',
'rq.emptyClosed': 'Закрытых заявок пока нет.',
'rq.callback': 'Перезвонить: {service}',
'rq.newOrder': 'Создать заказ',
'rq.wish': 'Желаемое время',
'rq.price': 'Цена на сайте',
'rq.noApp': 'не открыл бота',
'rq.done': 'Связались',
'rq.cancel': 'Отменить',
'rq.reopen': 'Вернуть',
'rq.activate': 'Включить на 30 дней',
'rq.activated': 'Подписка включена',
'rq.order': 'Заказ №{n}',
'audit.bo.moves.update': 'изменил(а) переезд',
'audit.bo.moves.updateStep': 'изменил(а) шаг переезда',
'audit.bo.moves.addStep': 'добавил(а) шаг переезда',
'audit.bo.moves.removeStep': 'убрал(а) шаг переезда',
'audit.bo.moves.addDoc': 'добавил(а) документ переезда',
'audit.bo.moves.updateDoc': 'изменил(а) документ переезда',
'audit.bo.moves.removeDoc': 'убрал(а) документ переезда',
'audit.bo.requests.updateSite': 'изменил(а) заявку с сайта',
'audit.bo.requests.updateApp': 'изменил(а) заявку из приложения',
'audit.bo.requests.activate': 'включил(а) подписку по заявке',
'pr.newService': 'Новая услуга',
'pr.newServiceHint': 'С нуля — с фиксированной ценой и вашими вопросами клиенту, или копией готовой услуги.',
'pr.fromScratch': 'С нуля',
@@ -1482,6 +1554,78 @@ const en: Dict = {
'chat.notLinked': 'Work bot not opened',
'chat.details': 'Details',
'chat.more': 'More actions',
'ord.tab.orders': 'Orders',
'ord.tab.moves': 'Relocations',
'ord.tab.requests': 'Requests',
'mv.search': 'Name, contact, agency or #',
'mv.empty': 'No relocations with this status.',
'mv.all': 'All',
'mv.steps': 'Steps {done} of {total}',
'mv.docs': 'Documents {done}/{total}',
'mv.now': 'Now: {step}',
'mv.noPlan': 'No plan yet',
'mv.back': 'All relocations',
'mv.plan': 'Relocation plan',
'mv.planHintApp': 'The client sees the steps in the app and gets a bot message when a step status changes.',
'mv.planHintNoApp': 'The client hasn’t opened the app yet — only you see the plan.',
'mv.docsTitle': 'Client documents',
'mv.docsHint': 'The client ticks them in the app, you — here.',
'mv.answers': 'Quiz answers',
'mv.when': 'Moving date',
'mv.package': 'Package',
'mv.wants': 'Needs',
'mv.agency': 'Agency',
'mv.lang': 'Language',
'mv.created': 'Request',
'mv.unfinished': 'didn’t finish the quiz',
'mv.note': 'Team note',
'mv.notePh': 'For the team: called, what was promised, who leads…',
'mv.save': 'Save',
'mv.status': 'Relocation status',
'mv.contact': 'Contact',
'mv.write': 'Message',
'mv.call': 'Call',
'mv.openClient': 'Client card',
'mv.inApp': 'in the app',
'mv.add': 'Add',
'mv.addStep': 'Step from the list…',
'mv.addDoc': 'Document from the list…',
'mv.custom': 'Custom item…',
'mv.customPh': 'Name for this client',
'mv.stepNote': 'Comment for the client — shown in the app',
'mv.remove': 'Remove for this client',
'mv.removeConfirm': 'Remove “{name}” for this client?',
'mv.rename': 'Rename',
'mv.listHint': 'The shared list of steps and documents is set up in Products.',
'rq.site': 'From the website',
'rq.app': 'From the app',
'rq.open': 'Open',
'rq.closed': 'Closed',
'rq.siteHint': 'Cleaning booked on barevclub.com without sign-up. Call, confirm the time and create an order.',
'rq.appHint': 'Clients asked for a membership or a call about a service without online booking.',
'rq.empty': 'No open requests.',
'rq.emptyClosed': 'No closed requests yet.',
'rq.callback': 'Call back: {service}',
'rq.newOrder': 'Create order',
'rq.wish': 'Preferred time',
'rq.price': 'Website price',
'rq.noApp': 'hasn’t opened the bot',
'rq.done': 'Contacted',
'rq.cancel': 'Cancel',
'rq.reopen': 'Reopen',
'rq.activate': 'Turn on for 30 days',
'rq.activated': 'Membership turned on',
'rq.order': 'Order #{n}',
'audit.bo.moves.update': 'updated a relocation',
'audit.bo.moves.updateStep': 'updated a relocation step',
'audit.bo.moves.addStep': 'added a relocation step',
'audit.bo.moves.removeStep': 'removed a relocation step',
'audit.bo.moves.addDoc': 'added a relocation document',
'audit.bo.moves.updateDoc': 'updated a relocation document',
'audit.bo.moves.removeDoc': 'removed a relocation document',
'audit.bo.requests.updateSite': 'updated a website request',
'audit.bo.requests.updateApp': 'updated an app request',
'audit.bo.requests.activate': 'turned on a membership from a request',
'pr.newService': 'New service',
'pr.newServiceHint': 'From scratch with a fixed price and your own questions, or as a copy of an existing service.',
'pr.fromScratch': 'From scratch',
@@ -2575,6 +2719,78 @@ const hy: Dict = {
'chat.notLinked': 'Աշխատանքային բոտը բացված չէ',
'chat.details': 'Մանրամասներ',
'chat.more': 'Այլ գործողություններ',
'ord.tab.orders': 'Պատվերներ',
'ord.tab.moves': 'Տեղափոխություններ',
'ord.tab.requests': 'Հայտեր',
'mv.search': 'Անուն, կոնտակտ, գործակալություն կամ №',
'mv.empty': 'Այս կարգավիճակով տեղափոխություններ չկան։',
'mv.all': 'Բոլորը',
'mv.steps': 'Քայլեր՝ {done}/{total}',
'mv.docs': 'Փաստաթղթեր {done}/{total}',
'mv.now': 'Հիմա՝ {step}',
'mv.noPlan': 'Պլանը դեռ չկա',
'mv.back': 'Բոլոր տեղափոխությունները',
'mv.plan': 'Տեղափոխության պլան',
'mv.planHintApp': 'Հաճախորդը տեսնում է քայլերը հավելվածում և ստանում է հաղորդագրություն։',
'mv.planHintNoApp': 'Հաճախորդը դեռ չի բացել հավելվածը։',
'mv.docsTitle': 'Հաճախորդի փաստաթղթեր',
'mv.docsHint': 'Հաճախորդը նշում է հավելվածում, դուք՝ այստեղ։',
'mv.answers': 'Պատասխաններ',
'mv.when': 'Երբ',
'mv.package': 'Փաթեթ',
'mv.wants': 'Ինչ է պետք',
'mv.agency': 'Գործակալություն',
'mv.lang': 'Լեզու',
'mv.created': 'Հայտ',
'mv.unfinished': 'չի ավարտել',
'mv.note': 'Թիմի նշում',
'mv.notePh': 'Թիմի համար…',
'mv.save': 'Պահպանել',
'mv.status': 'Կարգավիճակ',
'mv.contact': 'Կոնտակտ',
'mv.write': 'Գրել',
'mv.call': 'Զանգել',
'mv.openClient': 'Հաճախորդի քարտ',
'mv.inApp': 'հավելվածում',
'mv.add': 'Ավելացնել',
'mv.addStep': 'Քայլ ցուցակից…',
'mv.addDoc': 'Փաստաթուղթ ցուցակից…',
'mv.custom': 'Սեփական…',
'mv.customPh': 'Անուն այս հաճախորդի համար',
'mv.stepNote': 'Մեկնաբանություն հաճախորդի համար',
'mv.remove': 'Հեռացնել',
'mv.removeConfirm': 'Հեռացնե՞լ «{name}»։',
'mv.rename': 'Վերանվանել',
'mv.listHint': 'Ընդհանուր ցուցակը՝ «Ապրանքներ»-ում։',
'rq.site': 'Կայքից',
'rq.app': 'Հավելվածից',
'rq.open': 'Բաց',
'rq.closed': 'Փակ',
'rq.siteHint': 'Մաքրում՝ պատվիրված կայքում։ Զանգեք և ստեղծեք պատվեր։',
'rq.appHint': 'Հաճախորդները խնդրել են բաժանորդագրություն կամ զանգ։',
'rq.empty': 'Բաց հայտեր չկան։',
'rq.emptyClosed': 'Փակ հայտեր չկան։',
'rq.callback': 'Հետ զանգել՝ {service}',
'rq.newOrder': 'Ստեղծել պատվեր',
'rq.wish': 'Ցանկալի ժամ',
'rq.price': 'Գինը կայքում',
'rq.noApp': 'բոտը չի բացել',
'rq.done': 'Կապ հաստատվեց',
'rq.cancel': 'Չեղարկել',
'rq.reopen': 'Վերաբացել',
'rq.activate': 'Միացնել 30 օրով',
'rq.activated': 'Բաժանորդագրությունը միացված է',
'rq.order': 'Պատվեր №{n}',
'audit.bo.moves.update': 'փոխեց տեղափոխությունը',
'audit.bo.moves.updateStep': 'փոխեց քայլը',
'audit.bo.moves.addStep': 'ավելացրեց քայլ',
'audit.bo.moves.removeStep': 'հեռացրեց քայլը',
'audit.bo.moves.addDoc': 'ավելացրեց փաստաթուղթ',
'audit.bo.moves.updateDoc': 'փոխեց փաստաթուղթը',
'audit.bo.moves.removeDoc': 'հեռացրեց փաստաթուղթը',
'audit.bo.requests.updateSite': 'փոխեց կայքի հայտը',
'audit.bo.requests.updateApp': 'փոխեց հավելվածի հայտը',
'audit.bo.requests.activate': 'միացրեց բաժանորդագրություն',
'pr.newService': 'Նոր ծառայություն',
'pr.newServiceHint': 'Զրոյից՝ ֆիքսված գնով, կամ պատրաստի ծառայության պատճենով։',
'pr.fromScratch': 'Զրոյից',
+1
View File
@@ -4,6 +4,7 @@ import './chats.css';
import './orders.css';
import './crm.css';
import './admin5.css';
import './moves.css';
import { ApiProvider, loadTextOverrides, ToastProvider } from '@barev/web';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
+316
View File
@@ -0,0 +1,316 @@
/* Раздел «Заказы»: вкладки Заказы · Переезды · Заявки */
.bo-subtabs {
display: flex;
gap: 4px;
margin: -4px 0 16px;
border-bottom: 1px solid var(--bc-line);
overflow-x: auto;
scrollbar-width: none;
}
.bo-subtabs a {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 14px 12px;
margin-bottom: -1px;
border-bottom: 2px solid transparent;
color: var(--bc-ink-2);
font-weight: 600;
text-decoration: none;
white-space: nowrap;
}
.bo-subtabs a[aria-current='page'] {
color: var(--bc-ink);
border-bottom-color: var(--bc-plum);
}
.bo-subtabs__n {
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 10px;
background: var(--bc-accent);
color: #fff;
font-size: 12px;
display: inline-grid;
place-items: center;
}
/* ───── список переездов ───── */
.bo-mvrows {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.bo-mvrow {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(0, 0.8fr) minmax(0, 1.3fr) auto;
align-items: center;
gap: 16px;
padding: 14px 18px;
border-radius: var(--bo-r-row);
background: var(--bo-panel);
color: inherit;
text-decoration: none;
}
.bo-mvrow:hover {
box-shadow: 0 6px 20px rgba(30, 27, 26, 0.06);
}
.bo-mvrow__main,
.bo-mvrow__col {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.bo-mvrow__title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bo-mvrow__sub {
font-size: 13px;
color: var(--bc-ink-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bo-mvrow__status {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 3px;
}
.bo-mvbar {
display: block;
height: 5px;
border-radius: 5px;
background: var(--bc-line);
overflow: hidden;
}
.bo-mvbar span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--bc-home);
}
.bo-mvbar--big {
height: 8px;
margin: 4px 0 14px;
}
/* ───── карточка: шаги плана ───── */
.bo-mvsteps {
list-style: none;
margin: 0 0 12px;
padding: 0;
}
.bo-mvstep {
position: relative;
display: flex;
gap: 12px;
padding-bottom: 14px;
}
.bo-mvstep::before {
content: '';
position: absolute;
left: 10px;
top: 24px;
bottom: 0;
width: 2px;
background: var(--bc-line);
}
.bo-mvstep:last-child::before {
display: none;
}
.bo-mvstep[data-status='DONE']::before {
background: var(--bc-home);
}
.bo-mvstep__dot {
flex: none;
width: 22px;
height: 22px;
margin-top: 7px;
border-radius: 50%;
border: 2px solid var(--bc-line);
background: var(--bo-panel);
display: grid;
place-items: center;
color: var(--bc-home-ink);
position: relative;
z-index: 1;
}
.bo-mvstep[data-status='DONE'] .bo-mvstep__dot {
background: var(--bc-home);
border-color: var(--bc-home);
}
.bo-mvstep[data-status='IN_PROGRESS'] .bo-mvstep__dot {
border: 6px solid var(--bc-accent);
}
.bo-mvstep[data-status='SKIPPED'] {
opacity: 0.5;
}
.bo-mvstep__body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.bo-mvstep__top {
display: flex;
align-items: center;
gap: 8px;
}
.bo-mvstep__top .bo-input {
flex: 1;
}
.bo-mvstep__top .bo-select {
width: auto;
flex: none;
}
.bo-mvstep__name {
flex: 1;
min-width: 0;
text-align: left;
border: 0;
background: none;
padding: 6px 0;
font: inherit;
font-weight: 600;
color: inherit;
cursor: text;
}
.bo-mvstep__name:hover {
text-decoration: underline dotted;
}
.bo-mvstep__note {
display: flex;
gap: 8px;
}
.bo-mvstep__note .bo-input {
min-height: 38px;
font-size: 14px;
}
.bo-mvstep[data-status='DONE'] .bo-mvstep__note .bo-input:placeholder-shown {
display: none;
}
/* документы */
.bo-mvdocs {
list-style: none;
margin: 0 0 12px;
padding: 0;
}
.bo-mvdocs li {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
border-top: 1px solid var(--bc-line);
}
.bo-mvdocs li:first-child {
border-top: 0;
}
.bo-mvdocs label {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
}
.bo-mvdocs input[type='checkbox'] {
width: 18px;
height: 18px;
accent-color: var(--bc-plum);
}
.bo-mvdocs li[data-checked] span {
color: var(--bc-ink-3);
text-decoration: line-through;
}
.bo-mvadd {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.bo-mvadd .bo-select {
width: auto;
min-width: 220px;
}
.bo-mvadd .bo-input {
flex: 1;
min-width: 200px;
}
.bo-mvwants {
margin: 0;
padding-left: 18px;
}
/* ───── заявки ───── */
.bo-rqgrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.bo-rqlist {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.bo-rq {
display: flex;
flex-direction: column;
gap: 5px;
padding: 14px;
border-radius: 18px;
background: var(--bo-soft);
}
.bo-rq__top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
}
.bo-rq__who {
font-size: 14px;
}
.bo-rq__note {
padding: 8px 10px;
border-radius: 12px;
background: var(--bo-panel);
}
.bo-rq__notefield {
display: flex;
gap: 6px;
}
.bo-rq__notefield .bo-input {
min-height: 36px;
font-size: 13px;
background: var(--bo-panel);
}
.bo-rq__actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
@media (max-width: 1100px) {
.bo-mvrow {
grid-template-columns: minmax(0, 1fr) auto;
}
.bo-mvrow__col {
display: none;
}
.bo-rqgrid {
grid-template-columns: minmax(0, 1fr);
}
}
+3 -3
View File
@@ -334,14 +334,14 @@ export function ClientCard({ id, me }: { id: string; me: Me }) {
}
/** Открыть переписку с клиентом (или начать её) — в канале, куда он сейчас пишет */
function WriteButton({ clientId }: { clientId: string }) {
export function WriteButton({ clientId, small }: { clientId: string; small?: boolean }) {
const { t } = useT();
const trpc = useTRPC();
const toast = useToast();
const open = useMutation(trpc.bo.chats.openFor.mutationOptions({ onSuccess: (r) => navigate(`/chats/${r.id}`), onError: (e) => toast(errText(t, e), 'error') }));
return (
<button type="button" className="bo-btn bo-btn--accent" onClick={() => open.mutate({ clientId })} disabled={open.isPending}>
<I name="chats" size={16} />
<button type="button" className={`bo-btn bo-btn--accent${small ? ' bo-btn--sm' : ''}`} onClick={() => open.mutate({ clientId })} disabled={open.isPending}>
<I name="chats" size={small ? 14 : 16} />
{t('chat.write')}
</button>
);
+4 -4
View File
@@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query';
import { useTRPC } from '@barev/web';
import type { Me } from '../App.tsx';
import { useT, type BoKey } from '../i18n.tsx';
import { I, OLD_ADMIN_URL, PlanBadge, Skeleton, sourceKeyLabel } from '../components/kit.tsx';
import { I, PlanBadge, Skeleton, sourceKeyLabel } from '../components/kit.tsx';
type Pair = { orders: number; subs: number };
const total = (p: Pair) => p.orders + p.subs;
@@ -57,9 +57,9 @@ type Att = { unassigned: number; unreadChats: number; webRequests: number; leads
const ATT: { key: keyof Att; href: string; urgent?: boolean; fill: string; ink: string }[] = [
{ key: 'unassigned', href: '#/orders?group=new', urgent: true, fill: 'var(--bc-accent)', ink: '#fff' },
{ key: 'unreadChats', href: '#/chats', urgent: true, fill: 'var(--bc-accent)', ink: '#fff' },
{ key: 'webRequests', href: `${OLD_ADMIN_URL}#/requests`, fill: 'var(--bc-blue)', ink: 'var(--bc-blue-ink)' },
{ key: 'leads', href: `${OLD_ADMIN_URL}#/leads`, fill: 'var(--bc-docs)', ink: 'var(--bc-docs-ink)' },
{ key: 'appRequests', href: `${OLD_ADMIN_URL}#/app-requests`, fill: 'var(--bc-learn)', ink: 'var(--bc-learn-ink)' },
{ key: 'webRequests', href: '#/requests', fill: 'var(--bc-blue)', ink: 'var(--bc-blue-ink)' },
{ key: 'leads', href: '#/moves', fill: 'var(--bc-docs)', ink: 'var(--bc-docs-ink)' },
{ key: 'appRequests', href: '#/requests', fill: 'var(--bc-learn)', ink: 'var(--bc-learn-ink)' },
{ key: 'lowRatings', href: '#/providers', fill: 'var(--bc-pink)', ink: 'var(--bc-plum)' },
{ key: 'expiring', href: '#/clients?expiring=7', fill: 'var(--bc-perks)', ink: 'var(--bc-perks-ink)' },
];
+662
View File
@@ -0,0 +1,662 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { formatAmd, type Locale } from '@barev/i18n';
import { navigate, paramsSummary, useLocation, useToast, useTRPC } from '@barev/web';
import { useEffect, useState } from 'react';
import type { Me } from '../App.tsx';
import { errText, useT } from '../i18n.tsx';
import { I, Skeleton } from '../components/kit.tsx';
import { WriteButton } from './ClientCard.tsx';
import { NewOrderDialog } from './NewOrder.tsx';
/* ───────────── вкладки раздела «Заказы» ───────────── */
/** Заказы · Переезды · Заявки — один раздел; поддержка видит всё, консьерж — переезды и заявки */
export function OrdersTabs({ me, current }: { me: Me; current: 'orders' | 'moves' | 'requests' }) {
const { t } = useT();
const trpc = useTRPC();
const canOrders = me.roles.includes('SUPERADMIN') || me.roles.includes('SUPPORT');
const moves = useQuery({ ...trpc.bo.moves.counts.queryOptions(), refetchInterval: 30_000 });
const reqs = useQuery({ ...trpc.bo.requests.counts.queryOptions(), refetchInterval: 30_000 });
const tabs = [
...(canOrders ? [{ id: 'orders' as const, href: '#/orders', n: 0 }] : []),
{ id: 'moves' as const, href: '#/moves', n: moves.data?.NEW ?? 0 },
{ id: 'requests' as const, href: '#/requests', n: (reqs.data?.site ?? 0) + (reqs.data?.app ?? 0) },
];
return (
<nav className="bo-subtabs" aria-label={t('nav.orders')}>
{tabs.map((x) => (
<a key={x.id} href={x.href} aria-current={current === x.id ? 'page' : undefined}>
{t(`ord.tab.${x.id}`)}
{x.n > 0 && <span className="bo-subtabs__n">{x.n}</span>}
</a>
))}
</nav>
);
}
/* ───────────── список переездов ───────────── */
const STATUSES = ['NEW', 'IN_WORK', 'DONE', 'CANCELLED'] as const;
type Status = (typeof STATUSES)[number];
export const MOVE_DOT: Record<Status, string> = { NEW: 'var(--bc-accent)', IN_WORK: 'var(--bc-blue-ink)', DONE: 'var(--bc-ok)', CANCELLED: 'var(--bc-ink-3)' };
function whenText(shared: (k: string) => string, date: (d: Date) => string, m: { moveWhen: string | null; moveDate: Date | null }) {
if (m.moveWhen === 'date' && m.moveDate) return date(m.moveDate);
return m.moveWhen ? shared(`landing.quiz.when_${m.moveWhen}`) : '—';
}
export function Moves({ me }: { me: Me }) {
const { t, shared, date, locale } = useT();
const trpc = useTRPC();
const { query } = useLocation();
const status = (query.get('status') as Status | 'all' | null) ?? 'NEW';
const [search, setSearch] = useState('');
const [q, setQ] = useState('');
useEffect(() => {
const id = setTimeout(() => setQ(search.trim()), 300);
return () => clearTimeout(id);
}, [search]);
const counts = useQuery({ ...trpc.bo.moves.counts.queryOptions(), refetchInterval: 30_000 });
const list = useQuery({ ...trpc.bo.moves.list.queryOptions({ status: status === 'all' ? undefined : status, search: q || undefined }), refetchInterval: 30_000, placeholderData: keepPreviousData });
const names = useQuery(trpc.bo.moves.names.queryOptions());
const stepName = (s: { code: string; title: string | null }) => s.title || names.data?.[`STEP:${s.code}`]?.name[locale] || names.data?.[`STEP:${s.code}`]?.name.ru || s.code;
return (
<>
<div className="bo-head">
<h1 className="bo-title">{t('ord.tab.moves')}</h1>
</div>
<OrdersTabs me={me} current="moves" />
<div className="bo-toolbar">
<label className="bo-search">
<I name="search" size={18} />
<input className="bo-input" type="search" value={search} placeholder={t('mv.search')} aria-label={t('mv.search')} onChange={(e) => setSearch(e.target.value)} />
</label>
</div>
<div className="bo-ogroups" role="tablist">
{[...STATUSES, 'all' as const].map((s) => (
<button key={s} type="button" role="tab" aria-selected={status === s} className="bo-ogroup" onClick={() => navigate(s === 'NEW' ? '/moves' : `/moves?status=${s}`, { replace: true })}>
{s !== 'all' && <span className="bo-ogroup__dot" style={{ background: MOVE_DOT[s] }} />}
{s === 'all' ? t('mv.all') : shared(`admin.leads.status_${s}`)}
{s !== 'all' && counts.data && <span className="bo-ogroup__n">{counts.data[s]}</span>}
</button>
))}
</div>
{list.isPending ? (
<div className="bo-col">
<Skeleton h={72} />
<Skeleton h={72} />
</div>
) : !list.data?.length ? (
<div className="bo-panel bo-empty">{t('mv.empty')}</div>
) : (
<ul className="bo-mvrows">
{list.data.map((m) => (
<li key={m.id}>
<a href={`#/moves/${m.id}`} className="bo-mvrow">
<span className="bo-mvrow__main">
<span className="bo-mvrow__title">
<span className="bo-num bo-faint">№{m.number}</span> {m.name}
{m.inApp && <span className="bo-chip bo-chip--ok">{t('mv.inApp')}</span>}
</span>
<span className="bo-mvrow__sub">
{[m.contact, m.agency, m.locale.toUpperCase()].filter(Boolean).join(' · ')}
</span>
</span>
<span className="bo-mvrow__col">
<span className="bo-faint bo-small">{t('mv.when')}</span>
<span>{whenText(shared, date, m)}</span>
</span>
<span className="bo-mvrow__col">
<span className="bo-faint bo-small">{m.package ? shared(`landing.quiz.package_${m.package}`) : t('mv.unfinished')}</span>
{m.stepsTotal > 0 ? (
<>
<span className="bo-mvbar" aria-hidden="true">
<span style={{ width: `${(m.stepsDone / m.stepsTotal) * 100}%` }} />
</span>
<span className="bo-small">
{t('mv.steps', { done: m.stepsDone, total: m.stepsTotal })}
{m.current ? ` · ${t('mv.now', { step: stepName(m.current) })}` : ''}
</span>
</>
) : (
<span className="bo-faint">{t('mv.noPlan')}</span>
)}
</span>
<span className="bo-mvrow__status">
<span className="bo-status" style={{ ['--dot' as string]: MOVE_DOT[m.status] }}>
{shared(`admin.leads.status_${m.status}`)}
</span>
{m.docsTotal > 0 && <span className="bo-small bo-faint">{t('mv.docs', { done: m.docsDone, total: m.docsTotal })}</span>}
<span className="bo-small bo-faint">{date(m.createdAt)}</span>
</span>
</a>
</li>
))}
</ul>
)}
</>
);
}
/* ───────────── карточка переезда ───────────── */
const STEP_STATUSES = ['TODO', 'IN_PROGRESS', 'DONE', 'SKIPPED'] as const;
type StepStatus = (typeof STEP_STATUSES)[number];
const YES_NO = ['flat', 'socialCard', 'bankCard', 'registration', 'residence'] as const;
interface Options {
airport?: 'none' | 'standard' | 'premium';
stay?: 'none' | 'flat' | 'hotel';
stayBudgetFrom?: number;
stayBudgetTo?: number;
flat?: boolean;
socialCard?: boolean;
bankCard?: boolean;
registration?: boolean;
residence?: boolean;
}
export function MoveCard({ id, me }: { id: string; me: Me }) {
const { t, shared, date, dateTime, locale } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const lead = useQuery(trpc.bo.moves.get.queryOptions({ id }));
const names = useQuery(trpc.bo.moves.names.queryOptions());
const templates = useQuery(trpc.bo.moves.templates.queryOptions());
const refresh = () => {
void qc.invalidateQueries({ queryKey: trpc.bo.moves.pathKey() });
};
const opts = { onSuccess: () => (refresh(), toast(t('prv.saved'))), onError: (e: unknown) => toast(errText(t, e), 'error') };
const update = useMutation(trpc.bo.moves.update.mutationOptions(opts));
const addStep = useMutation(trpc.bo.moves.addStep.mutationOptions(opts));
const addDoc = useMutation(trpc.bo.moves.addDoc.mutationOptions(opts));
const updateDoc = useMutation(trpc.bo.moves.updateDoc.mutationOptions(opts));
const removeDoc = useMutation(trpc.bo.moves.removeDoc.mutationOptions(opts));
const [note, setNote] = useState<string | null>(null);
const canChat = me.roles.includes('SUPERADMIN') || me.roles.includes('SUPPORT') || me.roles.includes('CONCIERGE');
const back = (
<a className="bo-back" href="#/moves">
<I name="back" size={16} />
{t('mv.back')}
</a>
);
if (lead.isError)
return (
<>
{back}
<div className="bo-panel bo-empty">{errText(t, lead.error)}</div>
</>
);
if (!lead.data)
return (
<div className="bo-col">
<Skeleton h={90} />
<Skeleton h={300} />
</div>
);
const d = lead.data;
const nameOf = (kind: 'STEP' | 'DOC', it: { code: string; title: string | null }) => {
const n = names.data?.[`${kind}:${it.code}`]?.name;
return it.title || n?.[locale] || n?.ru || it.code;
};
const free = (kind: 'STEP' | 'DOC', taken: { code: string }[]) =>
(templates.data ?? [])
.filter((x) => x.kind === kind && x.active && !taken.some((y) => y.code === x.code))
.map((x) => ({ code: x.code, name: (x.name as Record<string, string>)[locale] || (x.name as Record<string, string>).ru || x.code }));
const o = (d.options ?? {}) as Options;
const wants: string[] = [];
if (d.package === 'CUSTOM') {
if (o.airport && o.airport !== 'none') wants.push(`${shared('landing.quiz.airport')}: ${shared(`landing.quiz.airport_${o.airport}`)}`);
if (o.stay && o.stay !== 'none') {
const budget = o.stayBudgetFrom || o.stayBudgetTo ? ` (${shared('admin.leads.budget', { from: o.stayBudgetFrom ? formatAmd(locale as Locale, o.stayBudgetFrom) : '…', to: o.stayBudgetTo ? formatAmd(locale as Locale, o.stayBudgetTo) : '…' })})` : '';
wants.push(`${shared('landing.quiz.stay')}: ${shared(`landing.quiz.stay_${o.stay}`)}${budget}`);
}
for (const k of YES_NO) if (o[k]) wants.push(shared(`landing.quiz.${k}`));
}
const tel = d.contactType === 'PHONE' ? d.contact.replace(/[^\d+]/g, '') : null;
const tg = d.contactType === 'TELEGRAM' ? d.contact.replace(/^@/, '') : null;
const steps = d.steps.filter((s) => s.status !== 'SKIPPED');
const done = steps.filter((s) => s.status === 'DONE').length;
const noteValue = note ?? d.adminNote ?? '';
return (
<>
{back}
<header className="bo-ohead">
<div>
<div className="bo-ohead__kicker">
№{d.number} · {t('mv.created')} {dateTime(d.createdAt)}
</div>
<h1 className="bo-title">{d.name}</h1>
</div>
<div className="bo-pills" role="group" aria-label={t('mv.status')}>
{STATUSES.map((s) => (
<button key={s} type="button" className="bo-pill" aria-pressed={d.status === s} onClick={() => d.status !== s && update.mutate({ id, status: s })}>
{shared(`admin.leads.status_${s}`)}
</button>
))}
</div>
</header>
<div className="bo-client-grid">
<div className="bo-col">
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h">{t('mv.plan')}</h2>
{steps.length > 0 && <span className="bo-faint">{t('mv.steps', { done, total: steps.length })}</span>}
</div>
<p className="bo-hint">{d.client ? t('mv.planHintApp') : t('mv.planHintNoApp')}</p>
{steps.length > 0 && (
<div className="bo-mvbar bo-mvbar--big" aria-hidden="true">
<span style={{ width: `${(done / steps.length) * 100}%` }} />
</div>
)}
<ol className="bo-mvsteps">
{d.steps.map((s) => (
<StepRow key={s.id} step={s} name={nameOf('STEP', s)} onSaved={refresh} />
))}
</ol>
<AddItem placeholder={t('mv.addStep')} options={free('STEP', d.steps)} onAdd={(v) => addStep.mutate({ id, ...v })} />
</section>
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h">{t('mv.docsTitle')}</h2>
<span className="bo-faint">
{d.docs.filter((x) => x.checked).length}/{d.docs.length}
</span>
</div>
<p className="bo-hint">{t('mv.docsHint')}</p>
<ul className="bo-mvdocs">
{d.docs.map((x) => (
<li key={x.id} data-checked={x.checked || undefined}>
<label>
<input type="checkbox" checked={x.checked} onChange={(e) => updateDoc.mutate({ id: x.id, checked: e.target.checked })} />
<span>{nameOf('DOC', x)}</span>
</label>
<button type="button" className="bo-iconbtn" title={t('mv.remove')} aria-label={t('mv.remove')} onClick={() => confirm(t('mv.removeConfirm', { name: nameOf('DOC', x) })) && removeDoc.mutate({ id: x.id })}>
<I name="close" size={14} />
</button>
</li>
))}
</ul>
<AddItem placeholder={t('mv.addDoc')} options={free('DOC', d.docs)} onAdd={(v) => addDoc.mutate({ id, ...v })} />
<p className="bo-hint" style={{ marginTop: 10 }}>
{t('mv.listHint')}
</p>
</section>
</div>
<div className="bo-col">
<section className="bo-panel">
<h2 className="bo-h">{t('mv.contact')}</h2>
<div className="bo-contact" style={{ marginTop: 8 }}>
<b>{d.contact}</b>
<div className="bo-faint bo-small">
{t('mv.lang')}: {d.locale.toUpperCase()}
{d.agency ? ` · ${t('mv.agency')}: ${d.agency}` : ''}
</div>
</div>
<div className="bo-row" style={{ flexWrap: 'wrap', gap: 8, marginTop: 12 }}>
{d.client && canChat && <WriteButton clientId={d.client.id} />}
{tg && (
<a className="bo-btn bo-btn--soft" href={`https://t.me/${tg}`} target="_blank" rel="noopener">
<I name="telegram" size={16} />
Telegram
</a>
)}
{tel && (
<a className="bo-btn bo-btn--soft" href={`tel:${tel}`}>
<I name="phone" size={16} />
{t('mv.call')}
</a>
)}
{d.client && (
<a className="bo-btn bo-btn--ghost" href={`#/clients/${d.client.id}`}>
{t('mv.openClient')}
</a>
)}
</div>
</section>
<section className="bo-panel">
<h2 className="bo-h">{t('mv.answers')}</h2>
<dl className="bo-kv" style={{ marginTop: 8 }}>
<dt>{t('mv.when')}</dt>
<dd>{whenText(shared, date, d)}</dd>
<dt>{t('mv.package')}</dt>
<dd>{d.package ? shared(`landing.quiz.package_${d.package}`) : <span className="bo-faint">{t('mv.unfinished')}</span>}</dd>
{wants.length > 0 && (
<>
<dt>{t('mv.wants')}</dt>
<dd>
<ul className="bo-mvwants">
{wants.map((w) => (
<li key={w}>{w}</li>
))}
</ul>
</dd>
</>
)}
</dl>
</section>
<section className="bo-panel">
<h2 className="bo-h">{t('mv.note')}</h2>
<textarea className="bo-textarea" style={{ marginTop: 8 }} rows={4} maxLength={1000} placeholder={t('mv.notePh')} value={noteValue} onChange={(e) => setNote(e.target.value)} />
{note !== null && note !== (d.adminNote ?? '') && (
<button type="button" className="bo-btn bo-btn--sm bo-btn--accent" style={{ marginTop: 8 }} onClick={() => update.mutate({ id, adminNote: note }, { onSuccess: () => setNote(null) })}>
{t('mv.save')}
</button>
)}
</section>
</div>
</div>
</>
);
}
/** Шаг плана: статус, название, комментарий для клиента */
function StepRow({ step, name, onSaved }: { step: { id: string; status: StepStatus; note: string | null }; name: string; onSaved: () => void }) {
const { t, shared } = useT();
const trpc = useTRPC();
const toast = useToast();
const [note, setNote] = useState(step.note ?? '');
const [title, setTitle] = useState<string | null>(null);
useEffect(() => setNote(step.note ?? ''), [step.note]);
const opts = { onSuccess: () => (onSaved(), toast(t('prv.saved'))), onError: (e: unknown) => toast(errText(t, e), 'error') };
const save = useMutation(trpc.bo.moves.updateStep.mutationOptions(opts));
const remove = useMutation(trpc.bo.moves.removeStep.mutationOptions(opts));
return (
<li className="bo-mvstep" data-status={step.status}>
<span className="bo-mvstep__dot" aria-hidden="true">
{step.status === 'DONE' && <I name="check" size={12} stroke={3} />}
</span>
<div className="bo-mvstep__body">
<div className="bo-mvstep__top">
{title === null ? (
<button type="button" className="bo-mvstep__name" title={t('mv.rename')} onClick={() => setTitle(name)}>
{name}
</button>
) : (
<input
className="bo-input"
autoFocus
value={title}
maxLength={120}
onChange={(e) => setTitle(e.target.value)}
onBlur={() => {
if (title.trim() && title.trim() !== name) save.mutate({ id: step.id, title: title.trim() });
setTitle(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
if (e.key === 'Escape') setTitle(null);
}}
/>
)}
<select className="bo-select" value={step.status} aria-label={name} onChange={(e) => save.mutate({ id: step.id, status: e.target.value as StepStatus })}>
{STEP_STATUSES.map((s) => (
<option key={s} value={s}>
{shared(`client.move.status.${s}`)}
</option>
))}
</select>
<button type="button" className="bo-iconbtn" title={t('mv.remove')} aria-label={t('mv.remove')} onClick={() => confirm(t('mv.removeConfirm', { name })) && remove.mutate({ id: step.id })}>
<I name="close" size={14} />
</button>
</div>
<div className="bo-mvstep__note">
<input className="bo-input" value={note} maxLength={500} placeholder={t('mv.stepNote')} onChange={(e) => setNote(e.target.value)} />
{note !== (step.note ?? '') && (
<button type="button" className="bo-btn bo-btn--sm" onClick={() => save.mutate({ id: step.id, note })}>
{t('mv.save')}
</button>
)}
</div>
</div>
</li>
);
}
/** Добавить пункт: из общего списка или свой */
function AddItem({ placeholder, options, onAdd }: { placeholder: string; options: { code: string; name: string }[]; onAdd: (v: { code?: string; title?: string }) => void }) {
const { t } = useT();
const [value, setValue] = useState('');
const [title, setTitle] = useState('');
return (
<div className="bo-mvadd">
<select className="bo-select" value={value} onChange={(e) => setValue(e.target.value)} aria-label={placeholder}>
<option value="">{placeholder}</option>
{options.map((o) => (
<option key={o.code} value={o.code}>
{o.name}
</option>
))}
<option value="__custom">{t('mv.custom')}</option>
</select>
{value === '__custom' && <input className="bo-input" value={title} maxLength={120} placeholder={t('mv.customPh')} onChange={(e) => setTitle(e.target.value)} />}
{value && (
<button
type="button"
className="bo-btn bo-btn--sm bo-btn--accent"
disabled={value === '__custom' && !title.trim()}
onClick={() => {
onAdd(value === '__custom' ? { title: title.trim() } : { code: value });
setValue('');
setTitle('');
}}
>
<I name="plus" size={14} />
{t('mv.add')}
</button>
)}
</div>
);
}
/* ───────────── заявки ───────────── */
export function Requests({ me }: { me: Me }) {
const { t, shared, sharedT, dateTime, amd } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const { query } = useLocation();
const open = query.get('closed') !== '1';
const site = useQuery({ ...trpc.bo.requests.site.queryOptions({ open }), refetchInterval: 30_000 });
const app = useQuery({ ...trpc.bo.requests.app.queryOptions({ open }), refetchInterval: 30_000 });
const [orderFor, setOrderFor] = useState<string | null>(null);
const opts = (msg = t('prv.saved')) => ({
onSuccess: () => {
void qc.invalidateQueries({ queryKey: trpc.bo.requests.pathKey() });
toast(msg);
},
onError: (e: unknown) => toast(errText(t, e), 'error'),
});
const updateSite = useMutation(trpc.bo.requests.updateSite.mutationOptions(opts()));
const updateApp = useMutation(trpc.bo.requests.updateApp.mutationOptions(opts()));
const activate = useMutation(trpc.bo.requests.activate.mutationOptions(opts(t('rq.activated'))));
const planName = (p: string | null) => (p === 'ELITE' ? 'Barev Elite' : 'Barev+');
const person = (u: { firstName: string | null; lastName: string | null; username: string | null } | undefined) => [u?.firstName, u?.lastName].filter(Boolean).join(' ') || (u?.username ? `@${u.username}` : '—');
return (
<>
<div className="bo-head">
<h1 className="bo-title">{t('ord.tab.requests')}</h1>
<div className="bo-pills" role="group">
<button type="button" className="bo-pill" aria-pressed={open} onClick={() => navigate('/requests', { replace: true })}>
{t('rq.open')}
</button>
<button type="button" className="bo-pill" aria-pressed={!open} onClick={() => navigate('/requests?closed=1', { replace: true })}>
{t('rq.closed')}
</button>
</div>
</div>
<OrdersTabs me={me} current="requests" />
<div className="bo-client-grid bo-rqgrid">
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h">{t('rq.site')}</h2>
<span className="bo-faint">{site.data?.length ?? ''}</span>
</div>
<p className="bo-hint">{t('rq.siteHint')}</p>
{site.isPending ? (
<Skeleton h={80} />
) : !site.data?.length ? (
<div className="bo-empty">{open ? t('rq.empty') : t('rq.emptyClosed')}</div>
) : (
<ul className="bo-rqlist">
{site.data.map((r) => (
<li key={r.id} className="bo-rq">
<div className="bo-rq__top">
<b>
<span className="bo-num bo-faint">№{r.number}</span> {shared(`services.${r.service.code}.name`)}
</b>
<span className="bo-chip">{shared(`admin.webRequests.status_${r.status}`)}</span>
</div>
<div className="bo-small">{paramsSummary(sharedT, r.service.code, r.params as Record<string, unknown>)}</div>
<div className="bo-small">
{t('rq.wish')}: <b>{dateTime(r.preferredAt)}</b> · {t('rq.price')}: <b className="bo-num">{amd(r.totalPrice)}</b>
</div>
<div className="bo-small bo-faint">
{shared(`districts.${r.district.code}`)}, {r.addressText}
</div>
<div className="bo-rq__who">
<span>
{r.name || person(r.client?.user)} · <a href={`tel:${r.phone.replace(/[^\d+]/g, '')}`}>{r.phone}</a>
{!r.client && <span className="bo-faint"> · {t('rq.noApp')}</span>}
</span>
</div>
{r.note && <div className="bo-small bo-rq__note">{r.note}</div>}
<NoteLine value={r.adminNote} onSave={(adminNote) => updateSite.mutate({ id: r.id, adminNote })} />
<div className="bo-rq__actions">
{r.order ? (
<a className="bo-btn bo-btn--sm bo-btn--soft" href={`#/orders/${r.order.id}`}>
{t('rq.order', { n: r.order.number })}
</a>
) : (
r.status !== 'CANCELLED' &&
r.client && (
<button type="button" className="bo-btn bo-btn--sm bo-btn--accent" onClick={() => setOrderFor(r.client!.id)}>
<I name="plus" size={14} />
{t('rq.newOrder')}
</button>
)
)}
{r.client && (
<a className="bo-btn bo-btn--sm bo-btn--ghost" href={`#/clients/${r.client.id}`}>
{t('mv.openClient')}
</a>
)}
{r.status === 'CANCELLED' ? (
<button type="button" className="bo-btn bo-btn--sm bo-btn--ghost" onClick={() => updateSite.mutate({ id: r.id, status: 'REOPEN' })}>
{t('rq.reopen')}
</button>
) : (
r.status !== 'ORDERED' && (
<button type="button" className="bo-btn bo-btn--sm bo-btn--ghost" onClick={() => confirm(shared('admin.webRequests.cancelConfirm', { number: r.number })) && updateSite.mutate({ id: r.id, status: 'CANCELLED' })}>
{t('rq.cancel')}
</button>
)
)}
</div>
<div className="bo-faint bo-small">{dateTime(r.createdAt)}</div>
</li>
))}
</ul>
)}
</section>
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h">{t('rq.app')}</h2>
<span className="bo-faint">{app.data?.length ?? ''}</span>
</div>
<p className="bo-hint">{t('rq.appHint')}</p>
{app.isPending ? (
<Skeleton h={80} />
) : !app.data?.length ? (
<div className="bo-empty">{open ? t('rq.empty') : t('rq.emptyClosed')}</div>
) : (
<ul className="bo-rqlist">
{app.data.map((r) => (
<li key={r.id} className="bo-rq">
<div className="bo-rq__top">
<b>
<span className="bo-num bo-faint">№{r.number}</span>{' '}
{r.kind === 'SUBSCRIPTION' ? shared('admin.requests.subscription', { plan: planName(r.plan) }) : t('rq.callback', { service: r.serviceCode ? shared(`services.${r.serviceCode}.name`) : '—' })}
</b>
<span className="bo-chip">{shared(`admin.requests.status_${r.status}`)}</span>
</div>
<div className="bo-rq__who">
<a href={`#/clients/${r.clientId}`}>{person(r.client.user)}</a>
{r.client.user.phone && (
<>
{' · '}
<a href={`tel:${r.client.user.phone}`}>{r.client.user.phone}</a>
</>
)}
<span className="bo-faint"> · {r.client.user.locale.toUpperCase()}</span>
</div>
{r.comment && <div className="bo-small bo-rq__note">{r.comment}</div>}
<NoteLine value={r.adminNote} onSave={(adminNote) => updateApp.mutate({ id: r.id, adminNote })} />
<div className="bo-rq__actions">
{r.status === 'NEW' && r.kind === 'SUBSCRIPTION' && (
<button type="button" className="bo-btn bo-btn--sm bo-btn--accent" onClick={() => confirm(shared('admin.requests.activateConfirm', { plan: planName(r.plan) })) && activate.mutate({ id: r.id, days: 30 })}>
{t('rq.activate')}
</button>
)}
{r.status === 'NEW' && (
<WriteButton clientId={r.clientId} small />
)}
{r.status === 'NEW' ? (
<>
<button type="button" className="bo-btn bo-btn--sm bo-btn--soft" onClick={() => updateApp.mutate({ id: r.id, status: 'DONE' })}>
<I name="check" size={14} />
{t('rq.done')}
</button>
<button type="button" className="bo-btn bo-btn--sm bo-btn--ghost" onClick={() => updateApp.mutate({ id: r.id, status: 'CANCELLED' })}>
{t('rq.cancel')}
</button>
</>
) : (
<button type="button" className="bo-btn bo-btn--sm bo-btn--ghost" onClick={() => updateApp.mutate({ id: r.id, status: 'NEW' })}>
{t('rq.reopen')}
</button>
)}
</div>
<div className="bo-faint bo-small">{dateTime(r.createdAt)}</div>
</li>
))}
</ul>
)}
</section>
</div>
{orderFor && <NewOrderDialog clientId={orderFor} onClose={() => setOrderFor(null)} />}
</>
);
}
/** Заметка команды одной строкой: сохраняется по кнопке */
function NoteLine({ value, onSave }: { value: string | null; onSave: (v: string) => void }) {
const { t } = useT();
const [v, setV] = useState(value ?? '');
useEffect(() => setV(value ?? ''), [value]);
return (
<div className="bo-rq__notefield">
<input className="bo-input" value={v} maxLength={1000} placeholder={t('mv.notePh')} aria-label={t('mv.note')} onChange={(e) => setV(e.target.value)} />
{v !== (value ?? '') && (
<button type="button" className="bo-btn bo-btn--sm" onClick={() => onSave(v)}>
{t('mv.save')}
</button>
)}
</div>
);
}
+3
View File
@@ -6,6 +6,7 @@ import type { Me } from '../App.tsx';
import { errText, useT } from '../i18n.tsx';
import { I, Skeleton } from '../components/kit.tsx';
import { NewOrderDialog } from './NewOrder.tsx';
import { OrdersTabs } from './Moves.tsx';
type Group = 'active' | 'new' | 'pending' | 'assigned' | 'in_progress' | 'done' | 'cancelled' | 'all';
type Period = 'all' | 'today' | 'tomorrow' | 'week' | 'past';
@@ -85,6 +86,8 @@ export function Orders({ me }: { me: Me }) {
</div>
</div>
<OrdersTabs me={me} current="orders" />
{!!c?.needsDispatcher && group !== 'new' && (
<button type="button" className="bo-alert" onClick={() => set({ group: 'new' })}>
<I name="warn" size={18} />
+30
View File
@@ -12,6 +12,7 @@ import {
DomainError,
getClientByTelegramId,
giveConsent,
rateOrder,
linkWebRequest,
setClientChannel,
setLocale,
@@ -40,6 +41,8 @@ const bot = new Bot(token || 'disabled:token');
/** Ожидаем ввод кода агентства от этих чатов */
const awaitingCode = new Set<number>();
/** Низкая оценка: следующий текст — комментарий к ней (chatId → заказ и оценка) */
const awaitingRatingComment = new Map<number, { orderId: string; score: number }>();
async function localeOf(from: Context['from']): Promise<Locale> {
if (!from) return 'ru';
@@ -193,11 +196,38 @@ bot.on('message:document', async (c) => {
await toTeam(c, c.message.caption ?? '', { tgFileId: d.file_id, tgBot: 'client', fileName: d.file_name ?? 'file', mime: d.mime_type ?? 'application/octet-stream', size: d.file_size ?? null });
});
/** Оценка заказа кнопкой со звёздами (сообщение приходит, когда заказ выполнен) */
bot.callbackQuery(/^rate:(\w+):([1-5])$/, async (c) => {
await c.answerCallbackQuery();
const client = await getClientByTelegramId(db, BigInt(c.from.id));
if (!client) return;
const t = createT(client.user.locale as Locale);
const score = Number(c.match[2]);
try {
await rateOrder(ctx, { orderId: c.match[1]!, clientId: client.id, score });
} catch (e) {
if (e instanceof DomainError) return void (await c.reply(t(`errors.${e.code}`)));
throw e;
}
await c.editMessageText(t('bot.client.rated', { stars: '★'.repeat(score) + '☆'.repeat(5 - score) }));
if (score <= 3) {
awaitingRatingComment.set(c.chat!.id, { orderId: c.match[1]!, score });
await c.reply(t('bot.client.ratedLow'));
}
});
/** ONB-6: ручной ввод кода агентства; любой другой текст — в чат команды */
bot.on('message:text', async (c) => {
const locale = await localeOf(c.from);
const t = createT(locale);
const client = await getClientByTelegramId(db, BigInt(c.from.id));
// комментарий к низкой оценке: сохраняем в оценку и передаём команде в чат
const pending = awaitingRatingComment.get(c.chat.id);
if (pending && client) {
awaitingRatingComment.delete(c.chat.id);
await rateOrder(ctx, { orderId: pending.orderId, clientId: client.id, score: pending.score, comment: c.message.text.slice(0, 1000) }).catch(() => undefined);
return toTeam(c, c.message.text);
}
if (!awaitingCode.has(c.chat.id)) return toTeam(c, c.message.text);
if (!client) return;
try {
+10
View File
@@ -2,6 +2,7 @@
* Воркер: превращает доменные события в сообщения Telegram на языке получателя (MSG-2, MSG-3)
* и раз в минуту запускает фоновые задачи — напоминания и истечение предложений.
*/
import { getFlow, type ServiceFlow } from '@barev/core';
import { getDb } from '@barev/db';
import { createT, formatAmd, formatDateTime, type Locale } from '@barev/i18n';
import { autoAssign, startTextOverrides, chatRecipients, dueReminders, dueSalesTasks, EVENTS_QUEUE, expireOffers, retryAutoAssign, type AutoAssignResult, type Ctx, type DomainEvent } from '@barev/services';
@@ -184,6 +185,15 @@ async function handle(event: DomainEvent) {
if (!o) return;
// клиент — о каждом переходе (STS)
await toClient(o, `notify.status.${o.service.flow}.${event.to}`);
// заказ выполнен — просим оценить (если ещё не оценил в приложении)
if (getFlow(o.service.flow as ServiceFlow).done.includes(event.to) && !(await db.rating.findUnique({ where: { orderId: o.id } }))) {
const t = createT(o.client.user.locale as Locale);
await clientBot.send({
chatId: o.client.user.telegramId,
text: t('notify.rateAsk'),
buttons: [[1, 2, 3, 4, 5].map((n) => ({ text: `${n} ★`, callback_data: `rate:${o.id}:${n}` }))],
});
}
if (event.to === 'CANCELLED' && o.provider?.user) await toProvider(o, o.provider.user, 'notify.pro.cancelled');
if (event.to === 'ASSIGNED' && event.from === 'NEW' && o.provider?.user && o.companyId) {
// назначил диспетчер компании — сотрудник узнаёт сразу
+5 -2
View File
@@ -878,7 +878,9 @@
"channelSupport": "You are now writing to support. Just send a message, photo or file.",
"channelConcierge": "You are now writing to your personal concierge. Just send a message, photo or file.",
"cmdSupport": "Write to support",
"cmdConcierge": "Write to the concierge (Barev+)"
"cmdConcierge": "Write to the concierge (Barev+)",
"rated": "Thanks for your rating! {{stars}}",
"ratedLow": "Sorry it didn’t go well. Tell us here what happened — the team will sort it out."
},
"pro": {
"notLinked": "This bot is for Barev Club specialists. Ask the operator for an invite link.",
@@ -1019,7 +1021,8 @@
"concierge": "Barev Club concierge",
"subscriptionActivated": "{{plan}} is on until {{date}}. Discounts are already in service prices.",
"operator": "Barev Club operator",
"support": "Barev Club support"
"support": "Barev Club support",
"rateAsk": "How did it go? Please rate — it takes a second:"
},
"admin": {
"nav": {
+5 -2
View File
@@ -878,7 +878,9 @@
"channelSupport": "Ahora escribes a soporte. Envía un mensaje, una foto o un archivo.",
"channelConcierge": "Ahora escribes a tu conserje personal. Envía un mensaje, una foto o un archivo.",
"cmdSupport": "Escribir a soporte",
"cmdConcierge": "Escribir al conserje (Barev+)"
"cmdConcierge": "Escribir al conserje (Barev+)",
"rated": "¡Gracias por tu valoración! {{stars}}",
"ratedLow": "Lamentamos que no fuera bien. Cuéntanos aquí qué pasó y el equipo lo resolverá."
},
"pro": {
"notLinked": "Este bot es para los especialistas de Barev Club. Pide al operador un enlace de invitación.",
@@ -1019,7 +1021,8 @@
"concierge": "Conserje de Barev Club",
"subscriptionActivated": "{{plan}} activo hasta el {{date}}. Los descuentos ya están en los precios.",
"operator": "Operador de Barev Club",
"support": "Soporte de Barev Club"
"support": "Soporte de Barev Club",
"rateAsk": "¿Qué tal fue? Valóralo, por favor, es un segundo:"
},
"admin": {
"nav": {
+5 -2
View File
@@ -878,7 +878,9 @@
"channelSupport": "Այժմ գրում եք աջակցությանը։ Պարզապես ուղարկեք հաղորդագրություն, լուսանկար կամ ֆայլ։",
"channelConcierge": "Այժմ գրում եք ձեր անձնական կոնսիերժին։ Պարզապես ուղարկեք հաղորդագրություն, լուսանկար կամ ֆայլ։",
"cmdSupport": "Գրել աջակցությանը",
"cmdConcierge": "Գրել կոնսիերժին (Barev+)"
"cmdConcierge": "Գրել կոնսիերժին (Barev+)",
"rated": "Շնորհակալություն գնահատականի համար։ {{stars}}",
"ratedLow": "Ափսոս, որ ամեն ինչ լավ չանցավ։ Գրիր այստեղ, թե ինչ է պատահել, թիմը կզբաղվի։"
},
"pro": {
"notLinked": "Այս բոտը Barev Club-ի կատարողների համար է։ Խնդրեք օպերատորից հրավերի հղում։",
@@ -1019,7 +1021,8 @@
"concierge": "Barev Club կոնսիերժ",
"subscriptionActivated": "{{plan}}-ը միացված է մինչև {{date}}։ Զեղչերն արդեն ծառայությունների գներում են։",
"operator": "Barev Club օպերատոր",
"support": "Barev Club աջակցություն"
"support": "Barev Club աջակցություն",
"rateAsk": "Ինչպե՞ս անցավ։ Խնդրում ենք գնահատել՝ մեկ վայրկյան է։"
},
"admin": {
"nav": {
+5 -2
View File
@@ -878,7 +878,9 @@
"channelSupport": "Теперь вы пишете в поддержку. Просто отправьте сообщение, фото или файл.",
"channelConcierge": "Теперь вы пишете личному консьержу. Просто отправьте сообщение, фото или файл.",
"cmdSupport": "Написать в поддержку",
"cmdConcierge": "Написать консьержу (Barev+)"
"cmdConcierge": "Написать консьержу (Barev+)",
"rated": "Спасибо за оценку! {{stars}}",
"ratedLow": "Жаль, что не всё прошло хорошо. Напиши здесь, что случилось, — команда разберётся."
},
"pro": {
"notLinked": "Этот бот — для исполнителей Barev Club. Попроси у оператора ссылку-приглашение.",
@@ -1019,7 +1021,8 @@
"concierge": "Консьерж Barev Club",
"subscriptionActivated": "{{plan}} включён до {{date}}. Скидки уже в ценах услуг.",
"operator": "Оператор Barev Club",
"support": "Поддержка Barev Club"
"support": "Поддержка Barev Club",
"rateAsk": "Как всё прошло? Оцени, пожалуйста, — это займёт секунду:"
},
"admin": {
"nav": {
+5 -2
View File
@@ -878,7 +878,9 @@
"channelSupport": "现在你在给客服写信。直接发送消息、照片或文件即可。",
"channelConcierge": "现在你在给专属礼宾写信。直接发送消息、照片或文件即可。",
"cmdSupport": "联系客服",
"cmdConcierge": "联系礼宾(Barev+)"
"cmdConcierge": "联系礼宾(Barev+)",
"rated": "感谢您的评价!{{stars}}",
"ratedLow": "很抱歉服务不够好。请在这里告诉我们发生了什么,团队会处理。"
},
"pro": {
"notLinked": "此机器人供 Barev Club 服务人员使用。请向客服索取邀请链接。",
@@ -1019,7 +1021,8 @@
"concierge": "Barev Club 礼宾",
"subscriptionActivated": "{{plan}} 已开通,有效期至 {{date}}。服务价格已含折扣。",
"operator": "Barev Club 客服",
"support": "Barev Club 客服支持"
"support": "Barev Club 客服支持",
"rateAsk": "服务体验如何?请花一秒钟评价一下:"
},
"admin": {
"nav": {
+1 -1
View File
@@ -101,7 +101,7 @@ export async function updateLead(ctx: Ctx, input: UpdateLeadInput) {
/** Для админки: свежие сверху */
export async function listLeads(ctx: Ctx, input: { status?: LeadStatus }) {
// заявки, отправленные до появления шаблонов, получают план и документы при первом показе
const bare = await ctx.db.lead.findMany({ where: { submittedAt: { not: null }, moved: { not: true }, package: { not: null }, docs: { none: {} } } });
const bare = await ctx.db.lead.findMany({ where: { submittedAt: { not: null }, OR: [{ moved: null }, { moved: false }], package: { not: null }, docs: { none: {} } } });
for (const l of bare) {
await syncLeadSteps(ctx.db, l);
await ensureLeadDocs(ctx.db, l.id);
+1 -1
View File
@@ -72,7 +72,7 @@ export async function clientRelocation(ctx: Ctx, clientId: string) {
const username = client.user.username;
if (!username) return null;
const site = await ctx.db.lead.findFirst({
where: { clientId: null, contactType: 'TELEGRAM', contact: { equals: `@${username}`, mode: 'insensitive' }, moved: { not: true } },
where: { clientId: null, contactType: 'TELEGRAM', contact: { equals: `@${username}`, mode: 'insensitive' }, OR: [{ moved: null }, { moved: false }] },
orderBy: { createdAt: 'desc' },
});
if (!site) return null;