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(); for (const c of clients30) { const utm = c.utm && typeof c.utm === 'object' ? ((c.utm as Record).utm_source ?? (c.utm as Record).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, }; }), });