diff --git a/apps/api/src/routers/backoffice/index.ts b/apps/api/src/routers/backoffice/index.ts index 104b05b..c9fa6a5 100644 --- a/apps/api/src/routers/backoffice/index.ts +++ b/apps/api/src/routers/backoffice/index.ts @@ -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, diff --git a/apps/api/src/routers/backoffice/moves.ts b/apps/api/src/routers/backoffice/moves.ts new file mode 100644 index 0000000..e6a7c4d --- /dev/null +++ b/apps/api/src/routers/backoffice/moves.ts @@ -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 }); + }), +}); diff --git a/apps/backoffice/src/App.tsx b/apps/backoffice/src/App.tsx index 608ba30..358e639 100644 --- a/apps/backoffice/src/App.tsx +++ b/apps/backoffice/src/App.tsx @@ -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 = ; else if ((path === '/chats' || threadId) && canChat) page = ; else if (path === '/orders' && canOrders) page = ; + else if ((path === '/orders' || path === '/moves') && canMoves) page = ; + else if (moveId && canMoves) page = ; + else if (path === '/requests' && canMoves) page = ; else if (orderId && canOrders) page = ; else if (path === '/providers' && canOrders) page = ; else if (providerId && canOrders) page = ; @@ -118,7 +125,8 @@ function Shell({ me }: { me: Me }) { else if (current?.stage) page = ; else page = ; - 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 ( diff --git a/apps/backoffice/src/i18n.tsx b/apps/backoffice/src/i18n.tsx index 5e1ba40..2f579fd 100644 --- a/apps/backoffice/src/i18n.tsx +++ b/apps/backoffice/src/i18n.tsx @@ -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': 'Զրոյից', diff --git a/apps/backoffice/src/main.tsx b/apps/backoffice/src/main.tsx index 39b485d..1913d24 100644 --- a/apps/backoffice/src/main.tsx +++ b/apps/backoffice/src/main.tsx @@ -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'; diff --git a/apps/backoffice/src/moves.css b/apps/backoffice/src/moves.css new file mode 100644 index 0000000..5190539 --- /dev/null +++ b/apps/backoffice/src/moves.css @@ -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); + } +} diff --git a/apps/backoffice/src/pages/ClientCard.tsx b/apps/backoffice/src/pages/ClientCard.tsx index afebc2e..d322ad1 100644 --- a/apps/backoffice/src/pages/ClientCard.tsx +++ b/apps/backoffice/src/pages/ClientCard.tsx @@ -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 ( - open.mutate({ clientId })} disabled={open.isPending}> - + open.mutate({ clientId })} disabled={open.isPending}> + {t('chat.write')} ); diff --git a/apps/backoffice/src/pages/Dashboard.tsx b/apps/backoffice/src/pages/Dashboard.tsx index 0b78c66..b3836dc 100644 --- a/apps/backoffice/src/pages/Dashboard.tsx +++ b/apps/backoffice/src/pages/Dashboard.tsx @@ -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)' }, ]; diff --git a/apps/backoffice/src/pages/Moves.tsx b/apps/backoffice/src/pages/Moves.tsx new file mode 100644 index 0000000..5f7a78c --- /dev/null +++ b/apps/backoffice/src/pages/Moves.tsx @@ -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 ( + + {tabs.map((x) => ( + + {t(`ord.tab.${x.id}`)} + {x.n > 0 && {x.n}} + + ))} + + ); +} + +/* ───────────── список переездов ───────────── */ + +const STATUSES = ['NEW', 'IN_WORK', 'DONE', 'CANCELLED'] as const; +type Status = (typeof STATUSES)[number]; +export const MOVE_DOT: Record = { 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 ( + <> + + {t('ord.tab.moves')} + + + + + + + setSearch(e.target.value)} /> + + + + {[...STATUSES, 'all' as const].map((s) => ( + navigate(s === 'NEW' ? '/moves' : `/moves?status=${s}`, { replace: true })}> + {s !== 'all' && } + {s === 'all' ? t('mv.all') : shared(`admin.leads.status_${s}`)} + {s !== 'all' && counts.data && {counts.data[s]}} + + ))} + + + {list.isPending ? ( + + + + + ) : !list.data?.length ? ( + {t('mv.empty')} + ) : ( + + {list.data.map((m) => ( + + + + + №{m.number} {m.name} + {m.inApp && {t('mv.inApp')}} + + + {[m.contact, m.agency, m.locale.toUpperCase()].filter(Boolean).join(' · ')} + + + + {t('mv.when')} + {whenText(shared, date, m)} + + + {m.package ? shared(`landing.quiz.package_${m.package}`) : t('mv.unfinished')} + {m.stepsTotal > 0 ? ( + <> + + + + + {t('mv.steps', { done: m.stepsDone, total: m.stepsTotal })} + {m.current ? ` · ${t('mv.now', { step: stepName(m.current) })}` : ''} + + > + ) : ( + {t('mv.noPlan')} + )} + + + + {shared(`admin.leads.status_${m.status}`)} + + {m.docsTotal > 0 && {t('mv.docs', { done: m.docsDone, total: m.docsTotal })}} + {date(m.createdAt)} + + + + ))} + + )} + > + ); +} + +/* ───────────── карточка переезда ───────────── */ + +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(null); + const canChat = me.roles.includes('SUPERADMIN') || me.roles.includes('SUPPORT') || me.roles.includes('CONCIERGE'); + + const back = ( + + + {t('mv.back')} + + ); + if (lead.isError) + return ( + <> + {back} + {errText(t, lead.error)} + > + ); + if (!lead.data) + return ( + + + + + ); + 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)[locale] || (x.name as Record).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} + + + + №{d.number} · {t('mv.created')} {dateTime(d.createdAt)} + + {d.name} + + + {STATUSES.map((s) => ( + d.status !== s && update.mutate({ id, status: s })}> + {shared(`admin.leads.status_${s}`)} + + ))} + + + + + + + + {t('mv.plan')} + {steps.length > 0 && {t('mv.steps', { done, total: steps.length })}} + + {d.client ? t('mv.planHintApp') : t('mv.planHintNoApp')} + {steps.length > 0 && ( + + + + )} + + {d.steps.map((s) => ( + + ))} + + addStep.mutate({ id, ...v })} /> + + + + + {t('mv.docsTitle')} + + {d.docs.filter((x) => x.checked).length}/{d.docs.length} + + + {t('mv.docsHint')} + + {d.docs.map((x) => ( + + + updateDoc.mutate({ id: x.id, checked: e.target.checked })} /> + {nameOf('DOC', x)} + + confirm(t('mv.removeConfirm', { name: nameOf('DOC', x) })) && removeDoc.mutate({ id: x.id })}> + + + + ))} + + addDoc.mutate({ id, ...v })} /> + + {t('mv.listHint')} + + + + + + + {t('mv.contact')} + + {d.contact} + + {t('mv.lang')}: {d.locale.toUpperCase()} + {d.agency ? ` · ${t('mv.agency')}: ${d.agency}` : ''} + + + + {d.client && canChat && } + {tg && ( + + + Telegram + + )} + {tel && ( + + + {t('mv.call')} + + )} + {d.client && ( + + {t('mv.openClient')} + + )} + + + + + {t('mv.answers')} + + {t('mv.when')} + {whenText(shared, date, d)} + {t('mv.package')} + {d.package ? shared(`landing.quiz.package_${d.package}`) : {t('mv.unfinished')}} + {wants.length > 0 && ( + <> + {t('mv.wants')} + + + {wants.map((w) => ( + {w} + ))} + + + > + )} + + + + + {t('mv.note')} + setNote(e.target.value)} /> + {note !== null && note !== (d.adminNote ?? '') && ( + update.mutate({ id, adminNote: note }, { onSuccess: () => setNote(null) })}> + {t('mv.save')} + + )} + + + + > + ); +} + +/** Шаг плана: статус, название, комментарий для клиента */ +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(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 ( + + + {step.status === 'DONE' && } + + + + {title === null ? ( + setTitle(name)}> + {name} + + ) : ( + 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); + }} + /> + )} + save.mutate({ id: step.id, status: e.target.value as StepStatus })}> + {STEP_STATUSES.map((s) => ( + + {shared(`client.move.status.${s}`)} + + ))} + + confirm(t('mv.removeConfirm', { name })) && remove.mutate({ id: step.id })}> + + + + + setNote(e.target.value)} /> + {note !== (step.note ?? '') && ( + save.mutate({ id: step.id, note })}> + {t('mv.save')} + + )} + + + + ); +} + +/** Добавить пункт: из общего списка или свой */ +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 ( + + setValue(e.target.value)} aria-label={placeholder}> + {placeholder} + {options.map((o) => ( + + {o.name} + + ))} + {t('mv.custom')} + + {value === '__custom' && setTitle(e.target.value)} />} + {value && ( + { + onAdd(value === '__custom' ? { title: title.trim() } : { code: value }); + setValue(''); + setTitle(''); + }} + > + + {t('mv.add')} + + )} + + ); +} + +/* ───────────── заявки ───────────── */ + +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(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 ( + <> + + {t('ord.tab.requests')} + + navigate('/requests', { replace: true })}> + {t('rq.open')} + + navigate('/requests?closed=1', { replace: true })}> + {t('rq.closed')} + + + + + + + + + {t('rq.site')} + {site.data?.length ?? ''} + + {t('rq.siteHint')} + {site.isPending ? ( + + ) : !site.data?.length ? ( + {open ? t('rq.empty') : t('rq.emptyClosed')} + ) : ( + + {site.data.map((r) => ( + + + + №{r.number} {shared(`services.${r.service.code}.name`)} + + {shared(`admin.webRequests.status_${r.status}`)} + + {paramsSummary(sharedT, r.service.code, r.params as Record)} + + {t('rq.wish')}: {dateTime(r.preferredAt)} · {t('rq.price')}: {amd(r.totalPrice)} + + + {shared(`districts.${r.district.code}`)}, {r.addressText} + + + + {r.name || person(r.client?.user)} · {r.phone} + {!r.client && · {t('rq.noApp')}} + + + {r.note && {r.note}} + updateSite.mutate({ id: r.id, adminNote })} /> + + {r.order ? ( + + {t('rq.order', { n: r.order.number })} + + ) : ( + r.status !== 'CANCELLED' && + r.client && ( + setOrderFor(r.client!.id)}> + + {t('rq.newOrder')} + + ) + )} + {r.client && ( + + {t('mv.openClient')} + + )} + {r.status === 'CANCELLED' ? ( + updateSite.mutate({ id: r.id, status: 'REOPEN' })}> + {t('rq.reopen')} + + ) : ( + r.status !== 'ORDERED' && ( + confirm(shared('admin.webRequests.cancelConfirm', { number: r.number })) && updateSite.mutate({ id: r.id, status: 'CANCELLED' })}> + {t('rq.cancel')} + + ) + )} + + {dateTime(r.createdAt)} + + ))} + + )} + + + + + {t('rq.app')} + {app.data?.length ?? ''} + + {t('rq.appHint')} + {app.isPending ? ( + + ) : !app.data?.length ? ( + {open ? t('rq.empty') : t('rq.emptyClosed')} + ) : ( + + {app.data.map((r) => ( + + + + №{r.number}{' '} + {r.kind === 'SUBSCRIPTION' ? shared('admin.requests.subscription', { plan: planName(r.plan) }) : t('rq.callback', { service: r.serviceCode ? shared(`services.${r.serviceCode}.name`) : '—' })} + + {shared(`admin.requests.status_${r.status}`)} + + + {person(r.client.user)} + {r.client.user.phone && ( + <> + {' · '} + {r.client.user.phone} + > + )} + · {r.client.user.locale.toUpperCase()} + + {r.comment && {r.comment}} + updateApp.mutate({ id: r.id, adminNote })} /> + + {r.status === 'NEW' && r.kind === 'SUBSCRIPTION' && ( + confirm(shared('admin.requests.activateConfirm', { plan: planName(r.plan) })) && activate.mutate({ id: r.id, days: 30 })}> + {t('rq.activate')} + + )} + {r.status === 'NEW' && ( + + )} + {r.status === 'NEW' ? ( + <> + updateApp.mutate({ id: r.id, status: 'DONE' })}> + + {t('rq.done')} + + updateApp.mutate({ id: r.id, status: 'CANCELLED' })}> + {t('rq.cancel')} + + > + ) : ( + updateApp.mutate({ id: r.id, status: 'NEW' })}> + {t('rq.reopen')} + + )} + + {dateTime(r.createdAt)} + + ))} + + )} + + + {orderFor && 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 ( + + setV(e.target.value)} /> + {v !== (value ?? '') && ( + onSave(v)}> + {t('mv.save')} + + )} + + ); +} diff --git a/apps/backoffice/src/pages/Orders.tsx b/apps/backoffice/src/pages/Orders.tsx index 7af5e83..a9a86ee 100644 --- a/apps/backoffice/src/pages/Orders.tsx +++ b/apps/backoffice/src/pages/Orders.tsx @@ -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 }) { + + {!!c?.needsDispatcher && group !== 'new' && ( set({ group: 'new' })}> diff --git a/apps/bot-client/src/index.ts b/apps/bot-client/src/index.ts index b74fb83..cae5b1d 100644 --- a/apps/bot-client/src/index.ts +++ b/apps/bot-client/src/index.ts @@ -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(); +/** Низкая оценка: следующий текст — комментарий к ней (chatId → заказ и оценка) */ +const awaitingRatingComment = new Map(); async function localeOf(from: Context['from']): Promise { 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 { diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 33f08e4..dd96446 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -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) { // назначил диспетчер компании — сотрудник узнаёт сразу diff --git a/packages/i18n/locales/en.json b/packages/i18n/locales/en.json index ae65e6f..29f0342 100644 --- a/packages/i18n/locales/en.json +++ b/packages/i18n/locales/en.json @@ -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": { diff --git a/packages/i18n/locales/es.json b/packages/i18n/locales/es.json index f31f94f..51dbcab 100644 --- a/packages/i18n/locales/es.json +++ b/packages/i18n/locales/es.json @@ -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": { diff --git a/packages/i18n/locales/hy.json b/packages/i18n/locales/hy.json index 048d20e..63e42d3 100644 --- a/packages/i18n/locales/hy.json +++ b/packages/i18n/locales/hy.json @@ -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": { diff --git a/packages/i18n/locales/ru.json b/packages/i18n/locales/ru.json index 24cda26..bf4cafb 100644 --- a/packages/i18n/locales/ru.json +++ b/packages/i18n/locales/ru.json @@ -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": { diff --git a/packages/i18n/locales/zh.json b/packages/i18n/locales/zh.json index f13126f..2823f72 100644 --- a/packages/i18n/locales/zh.json +++ b/packages/i18n/locales/zh.json @@ -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": { diff --git a/packages/services/src/leads.ts b/packages/services/src/leads.ts index 4549698..3d2ff4a 100644 --- a/packages/services/src/leads.ts +++ b/packages/services/src/leads.ts @@ -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); diff --git a/packages/services/src/relocation.ts b/packages/services/src/relocation.ts index 96fb4d5..c03e234 100644 --- a/packages/services/src/relocation.ts +++ b/packages/services/src/relocation.ts @@ -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;
{d.client ? t('mv.planHintApp') : t('mv.planHintNoApp')}
{t('mv.docsHint')}
+ {t('mv.listHint')} +
{t('rq.siteHint')}
{t('rq.appHint')}