Files
BarevClub/apps/api/src/routers/backoffice/dashboard.ts
T
2026-10-25 22:55:29 +04:00

158 lines
8.9 KiB
TypeScript

import { DAY, startOfYerevanDay } from '@barev/core';
import { unreadConciergeCount } from '@barev/services';
import { router, staffProcedure } from '../../trpc.ts';
/** День по Еревану (UTC+4, без перехода на летнее время) */
const dayKey = (d: Date) => new Date(d.getTime() + 4 * 3600_000).toISOString().slice(0, 10);
function name(u: { firstName: string | null; lastName: string | null; username: string | null } | null | undefined) {
if (!u) return '—';
return [u.firstName, u.lastName].filter(Boolean).join(' ') || (u.username ? `@${u.username}` : '—');
}
export const boDashboardRouter = router({
/** Что требует внимания — видят все роли */
attention: staffProcedure.query(async ({ ctx }) => {
const now = ctx.svc.now();
const [unassigned, unreadChats, webRequests, leads, appRequests, lowRatings, expiring] = await Promise.all([
ctx.db.order.count({ where: { providerId: null, companyId: null, status: { in: ['NEW', 'ENROLLED'] }, offers: { none: { status: 'OFFERED' } } } }),
unreadConciergeCount(ctx.svc),
ctx.db.webRequest.count({ where: { status: { in: ['NEW', 'LINKED'] } } }),
ctx.db.lead.count({ where: { status: 'NEW' } }),
ctx.db.clientRequest.count({ where: { status: 'NEW' } }),
ctx.db.rating.count({ where: { score: { lte: 3 }, createdAt: { gte: new Date(now.getTime() - 7 * DAY) } } }),
ctx.db.subscription.count({ where: { status: 'ACTIVE', endsAt: { gt: now, lte: new Date(now.getTime() + 7 * DAY) } } }),
]);
return { unassigned, unreadChats, webRequests, leads, appRequests, lowRatings, expiring };
}),
/** Полная сводка — деньги и команда видит только суперадмин */
overview: staffProcedure.query(async ({ ctx }) => {
const now = ctx.svc.now();
const today = startOfYerevanDay(now);
const from60 = new Date(today.getTime() - 59 * DAY);
const from30 = new Date(today.getTime() - 29 * DAY);
const [paidOrders, soldSubs, refunds, activeSubs, expiringSubs, newClients, leads30, giftClients, paidSubClients, cardUsages, providers, recentSubs, recentOrders, recentAudit] =
await Promise.all([
ctx.db.order.findMany({ where: { paymentStatus: 'PAID', paidAt: { gte: from60 } }, select: { paidAt: true, totalPrice: true } }),
ctx.db.subscription.findMany({ where: { amount: { not: null }, createdAt: { gte: from60 } }, select: { createdAt: true, amount: true } }),
ctx.db.subscription.findMany({ where: { refundedAt: { gte: from60 } }, select: { refundedAt: true, refundAmount: true } }),
ctx.db.subscription.findMany({ where: { status: 'ACTIVE', startsAt: { lte: now }, endsAt: { gt: now } }, select: { clientId: true, plan: true, source: true } }),
ctx.db.subscription.findMany({
where: { status: 'ACTIVE', endsAt: { gt: now, lte: new Date(now.getTime() + 7 * DAY) } },
orderBy: { endsAt: 'asc' },
take: 6,
include: { client: { include: { user: true } } },
}),
ctx.db.client.findMany({ where: { createdAt: { gte: from60 } }, select: { createdAt: true, source: true, utm: true, agency: { select: { name: true } } } }),
ctx.db.lead.count({ where: { createdAt: { gte: from30 } } }),
ctx.db.subscription.findMany({ where: { source: { in: ['MANUAL', 'AGENCY_GIFT'] } }, select: { clientId: true }, distinct: ['clientId'] }),
ctx.db.subscription.findMany({ where: { source: 'PAID' }, select: { clientId: true }, distinct: ['clientId'] }),
ctx.db.cardUsage.groupBy({ by: ['partnerId'], where: { createdAt: { gte: from30 }, partnerId: { not: null } }, _count: true, orderBy: { _count: { partnerId: 'desc' } }, take: 5 }),
ctx.db.provider.findMany({ where: { active: true, ratingCount: { gt: 0 } }, select: { id: true, name: true, ratingSum: true, ratingCount: true, role: true } }),
ctx.db.subscription.findMany({ orderBy: { createdAt: 'desc' }, take: 8, include: { client: { include: { user: true } } } }),
ctx.db.order.findMany({ where: { paymentStatus: 'PAID', paidAt: { not: null } }, orderBy: { paidAt: 'desc' }, take: 8, include: { client: { include: { user: true } }, service: { select: { code: true } } } }),
ctx.db.auditLog.findMany({ orderBy: { createdAt: 'desc' }, take: 8, include: { adminUser: { select: { name: true } } } }),
]);
// ── деньги по дням ──
const days: string[] = [];
for (let i = 59; i >= 0; i--) days.push(dayKey(new Date(today.getTime() - i * DAY + 12 * 3600_000)));
const byDay = new Map(days.map((d) => [d, { orders: 0, subs: 0 }]));
for (const o of paidOrders) byDay.get(dayKey(o.paidAt!))!.orders += o.totalPrice;
for (const s of soldSubs) {
const b = byDay.get(dayKey(s.createdAt));
if (b) b.subs += s.amount ?? 0;
}
for (const r of refunds) {
const b = byDay.get(dayKey(r.refundedAt!));
if (b) b.subs -= r.refundAmount ?? 0;
}
const series = days.map((d) => ({ day: d, ...byDay.get(d)! }));
const sum = (from: number, to: number) => series.slice(from, to).reduce((a, x) => ({ orders: a.orders + x.orders, subs: a.subs + x.subs }), { orders: 0, subs: 0 });
const money = {
today: sum(59, 60),
yesterday: sum(58, 59),
week: sum(53, 60),
prevWeek: sum(46, 53),
month: sum(30, 60),
prevMonth: sum(0, 30),
series: series.slice(30),
};
// ── подписки ──
const plus = new Set(activeSubs.filter((s) => s.plan === 'BAREV_PLUS').map((s) => s.clientId));
const elite = new Set(activeSubs.filter((s) => s.plan === 'ELITE').map((s) => s.clientId));
for (const id of elite) plus.delete(id);
const paidSet = new Set(paidSubClients.map((s) => s.clientId));
const giftConverted = giftClients.filter((g) => paidSet.has(g.clientId)).length;
// ── рост ──
const clients30 = newClients.filter((c) => c.createdAt >= from30);
const sources = new Map<string, number>();
for (const c of clients30) {
const utm = c.utm && typeof c.utm === 'object' ? ((c.utm as Record<string, unknown>).utm_source ?? (c.utm as Record<string, unknown>).source) : null;
const key = c.agency ? `agency:${c.agency.name}` : utm ? `utm:${String(utm)}` : c.source;
sources.set(key, (sources.get(key) ?? 0) + 1);
}
const payingClients30 = await ctx.db.client.count({
where: {
createdAt: { gte: from30 },
OR: [{ subscriptions: { some: { source: 'PAID' } } }, { orders: { some: { paymentStatus: 'PAID' } } }],
},
});
// ── команда ──
const partnerNames = new Map(
(await ctx.db.partner.findMany({ where: { id: { in: cardUsages.map((c) => c.partnerId!) } }, select: { id: true, name: true } })).map((p) => [p.id, p.name]),
);
const rated = providers.map((p) => ({ id: p.id, name: p.name, role: p.role, avg: p.ratingSum / p.ratingCount, count: p.ratingCount })).sort((a, b) => b.avg - a.avg);
// ── лента ──
const feed = [
...recentSubs.map((s) => ({
kind: 'subscription' as const,
at: s.createdAt,
clientId: s.clientId,
who: name(s.client.user),
plan: s.plan,
source: s.source,
amount: ctx.isSuper ? s.amount : null,
})),
...recentOrders.map((o) => ({ kind: 'payment' as const, at: o.paidAt!, clientId: o.clientId, who: name(o.client.user), service: o.service.code, number: o.number, amount: ctx.isSuper ? o.totalPrice : null })),
...recentAudit.map((a) => ({ kind: 'action' as const, at: a.createdAt, by: a.adminUser?.name ?? null, action: a.action, entityId: a.entityId })),
]
.sort((a, b) => b.at.getTime() - a.at.getTime())
.slice(0, 12);
return {
money: ctx.isSuper ? money : null,
subs: {
plus: plus.size,
elite: elite.size,
gifts: activeSubs.filter((s) => s.source !== 'PAID').length,
expiring: expiringSubs.map((s) => ({ clientId: s.clientId, name: name(s.client.user), plan: s.plan, endsAt: s.endsAt, source: s.source })),
giftClients: giftClients.length,
giftConverted,
},
growth: {
today: newClients.filter((c) => c.createdAt >= today).length,
week: newClients.filter((c) => c.createdAt >= new Date(today.getTime() - 6 * DAY)).length,
month: clients30.length,
prevMonth: newClients.length - clients30.length,
sources: [...sources.entries()].map(([key, count]) => ({ key, count })).sort((a, b) => b.count - a.count),
funnel: { leads: leads30, clients: clients30.length, paying: payingClients30 },
},
team: ctx.isSuper
? {
topProviders: rated.slice(0, 3),
bottomProviders: rated.length > 3 ? rated.slice(-2).reverse() : [],
topPartners: cardUsages.map((c) => ({ id: c.partnerId!, name: partnerNames.get(c.partnerId!) ?? '—', scans: c._count })),
}
: null,
feed,
};
}),
});