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

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

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

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

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

No files matched your search

+3
View File
@@ -8,6 +8,7 @@ import { boProvidersRouter } from './providers.ts';
import { boCrmRouter } from './crm.ts';
import { boPartnersRouter } from './partners.ts';
import { boProductsRouter } from './products.ts';
import { boMovesRouter, boRequestsRouter } from './moves.ts';
import { boBroadcastsRouter, boJournalRouter, boSettingsRouter } from './admin5.ts';
/** API новой админки (apps/backoffice) */
@@ -19,6 +20,8 @@ export const backofficeRouter = router({
segments: boSegmentsRouter,
chats: boChatsRouter,
orders: boOrdersRouter,
moves: boMovesRouter,
requests: boRequestsRouter,
providers: boProvidersRouter,
crm: boCrmRouter,
partners: boPartnersRouter,
+136
View File
@@ -0,0 +1,136 @@
import {
addLeadDoc,
addLeadStep,
grantSubscription,
listClientRequests,
listLeads,
listRelocationItems,
listWebRequests,
relocationNames,
removeLeadDoc,
removeLeadStep,
updateClientRequest,
updateLeadByAdmin,
updateLeadDoc,
updateLeadStep,
updateWebRequest,
} from '@barev/services';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { rolesProcedure, router } from '../../trpc.ts';
/** Переезды и заявки ведут поддержка и консьерж */
const opsProcedure = rolesProcedure('SUPPORT', 'CONCIERGE');
const LEAD_STATUSES = ['NEW', 'IN_WORK', 'DONE', 'CANCELLED'] as const;
const STEP_STATUSES = ['TODO', 'IN_PROGRESS', 'DONE', 'SKIPPED'] as const;
const leadInclude = {
steps: { orderBy: { sortOrder: 'asc' } },
docs: { orderBy: { sortOrder: 'asc' } },
client: { select: { id: true, cardCode: true, user: { select: { firstName: true, lastName: true, username: true, phone: true } } } },
} as const;
/** Переезды: заявки из квиза на сайте и из приложения, у каждой — план по шагам и документы клиента */
export const boMovesRouter = router({
counts: opsProcedure.query(async ({ ctx }) => {
const rows = await ctx.db.lead.groupBy({ by: ['status'], where: { OR: [{ moved: null }, { moved: false }] }, _count: true });
const out = { NEW: 0, IN_WORK: 0, DONE: 0, CANCELLED: 0 };
for (const r of rows) out[r.status] = r._count;
return out;
}),
list: opsProcedure.input(z.object({ status: z.enum(LEAD_STATUSES).optional(), search: z.string().max(100).optional() })).query(async ({ ctx, input }) => {
const leads = await listLeads(ctx.svc, { status: input.status });
const q = input.search?.trim().toLowerCase().replace(/^#/, '');
return leads
.filter((l) => !l.moved)
.filter((l) => !q || String(l.number) === q || l.name.toLowerCase().includes(q) || l.contact.toLowerCase().includes(q) || (l.agency ?? '').toLowerCase().includes(q))
.map((l) => {
const steps = l.steps.filter((s) => s.status !== 'SKIPPED');
return {
id: l.id,
number: l.number,
name: l.name,
contact: l.contact,
contactType: l.contactType,
locale: l.locale,
agency: l.agency,
status: l.status,
package: l.package,
moveWhen: l.moveWhen,
moveDate: l.moveDate,
submitted: !!l.submittedAt,
createdAt: l.createdAt,
inApp: !!l.client,
stepsDone: steps.filter((s) => s.status === 'DONE').length,
stepsTotal: steps.length,
current: steps.find((s) => s.status === 'IN_PROGRESS') ?? steps.find((s) => s.status === 'TODO') ?? null,
docsDone: l.docs.filter((d) => d.checked).length,
docsTotal: l.docs.length,
adminNote: l.adminNote,
};
});
}),
get: opsProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
const lead = await ctx.db.lead.findUnique({ where: { id: input.id }, omit: { key: true }, include: leadInclude });
if (!lead) throw new TRPCError({ code: 'NOT_FOUND' });
return lead;
}),
/** общий список шагов и документов: названия на 5 языках */
names: opsProcedure.query(({ ctx }) => relocationNames(ctx.db)),
templates: opsProcedure.query(({ ctx }) => listRelocationItems(ctx.svc)),
update: opsProcedure
.input(z.object({ id: z.string(), status: z.enum(LEAD_STATUSES).optional(), adminNote: z.string().max(1000).optional() }))
.mutation(({ ctx, input }) => updateLeadByAdmin(ctx.svc, input)),
/** шаг плана: статус (клиенту уходит сообщение в бота), комментарий для клиента, своё название */
updateStep: opsProcedure
.input(z.object({ id: z.string(), status: z.enum(STEP_STATUSES).optional(), note: z.string().max(500).optional(), title: z.string().max(120).optional() }))
.mutation(({ ctx, input }) => updateLeadStep(ctx.svc, { stepId: input.id, status: input.status, note: input.note, title: input.title })),
addStep: opsProcedure
.input(z.object({ id: z.string(), code: z.string().max(40).optional(), title: z.string().max(120).optional() }))
.mutation(({ ctx, input }) => addLeadStep(ctx.svc, { leadId: input.id, code: input.code, title: input.title })),
removeStep: opsProcedure.input(z.object({ id: z.string() })).mutation(({ ctx, input }) => removeLeadStep(ctx.svc, input.id)),
addDoc: opsProcedure
.input(z.object({ id: z.string(), code: z.string().max(40).optional(), title: z.string().max(120).optional() }))
.mutation(({ ctx, input }) => addLeadDoc(ctx.svc, { leadId: input.id, code: input.code, title: input.title })),
updateDoc: opsProcedure
.input(z.object({ id: z.string(), title: z.string().max(120).optional(), checked: z.boolean().optional() }))
.mutation(({ ctx, input }) => updateLeadDoc(ctx.svc, { docId: input.id, title: input.title, checked: input.checked })),
removeDoc: opsProcedure.input(z.object({ id: z.string() })).mutation(({ ctx, input }) => removeLeadDoc(ctx.svc, input.id)),
});
/** Заявки: с сайта (уборка без регистрации) и из приложения (подписка, «перезвоните» по услуге) */
export const boRequestsRouter = router({
counts: opsProcedure.query(async ({ ctx }) => {
const [site, app] = await Promise.all([
ctx.db.webRequest.count({ where: { status: { in: ['NEW', 'LINKED'] } } }),
ctx.db.clientRequest.count({ where: { status: 'NEW' } }),
]);
return { site, app };
}),
site: opsProcedure.input(z.object({ open: z.boolean() })).query(async ({ ctx, input }) => {
const rows = await listWebRequests(ctx.svc, {});
return rows.filter((r) => (input.open ? r.status === 'NEW' || r.status === 'LINKED' : r.status === 'ORDERED' || r.status === 'CANCELLED'));
}),
app: opsProcedure.input(z.object({ open: z.boolean() })).query(async ({ ctx, input }) => {
const rows = await listClientRequests(ctx.svc, {});
return rows.filter((r) => (input.open ? r.status === 'NEW' : r.status !== 'NEW'));
}),
updateSite: opsProcedure
.input(z.object({ id: z.string(), status: z.enum(['CANCELLED', 'REOPEN']).optional(), adminNote: z.string().max(1000).optional() }))
.mutation(({ ctx, input }) => updateWebRequest(ctx.svc, input)),
updateApp: opsProcedure
.input(z.object({ id: z.string(), status: z.enum(['NEW', 'DONE', 'CANCELLED']).optional(), adminNote: z.string().max(1000).optional() }))
.mutation(({ ctx, input }) => updateClientRequest(ctx.svc, input)),
/** включить подписку по заявке из приложения: создаёт подписку и закрывает заявку */
activate: opsProcedure.input(z.object({ id: z.string(), days: z.number().int().min(1).max(366) })).mutation(async ({ ctx, input }) => {
const r = await ctx.db.clientRequest.findUniqueOrThrow({ where: { id: input.id } });
if (r.kind !== 'SUBSCRIPTION' || !r.plan) throw new TRPCError({ code: 'BAD_REQUEST' });
return grantSubscription(ctx.svc, { clientId: r.clientId, plan: r.plan, days: input.days, requestId: r.id });
}),
});