Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
158 lines
8.9 KiB
TypeScript
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,
|
|
};
|
|
}),
|
|
});
|