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:
1 parent
bbb25d3d0d
commit
dcba7c200c
19 files changed
+1422
-22
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user