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 ( - ); 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 ( + + ); +} + +/* ───────────── список переездов ───────────── */ + +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')}

+
+ + +
+ +
+
+ {[...STATUSES, 'all' as const].map((s) => ( + + ))} +
+ + {list.isPending ? ( +
+ + +
+ ) : !list.data?.length ? ( +
{t('mv.empty')}
+ ) : ( + + )} + + ); +} + +/* ───────────── карточка переезда ───────────── */ + +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) => ( + + ))} +
+
+ +
+
+
+
+

{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) => ( +
  • + + +
  • + ))} +
+ 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')}

+