521 lines
15 KiB
TypeScript
521 lines
15 KiB
TypeScript
import type {
|
|
AdminDashboardVM,
|
|
AdminDashboardWidgetId,
|
|
AdminDashboardWidgetInput,
|
|
} from "@hivegrid/view-models";
|
|
import { buildAdminDashboardVM } from "@hivegrid/view-models";
|
|
import type {
|
|
AdminPermissionName,
|
|
AdminRoleKey,
|
|
} from "@hivegrid/db/admin-permissions";
|
|
import {
|
|
adminSalesDealReadWhere,
|
|
adminSalesTaskReadWhere,
|
|
} from "@hivegrid/db/admin-sales-access-scope";
|
|
import { runWithRlsContext } from "@hivegrid/db/rls-context.server";
|
|
|
|
import { adminPath } from "~/utils/app/CanonicalUrlUtils";
|
|
import { db } from "~/utils/db.server";
|
|
import { adminGetAllTenantsIdsAndNames } from "~/utils/db/tenants.db.server";
|
|
import { getMrr } from "~/utils/services/.server/subscriptionService";
|
|
import { getOperatorHealthSnapshot } from "./operator-health.server";
|
|
|
|
type DashboardSource =
|
|
| "salesOwn"
|
|
| "salesTeam"
|
|
| "support"
|
|
| "engineering"
|
|
| "executive";
|
|
|
|
type DashboardSourceLoader = (input: {
|
|
readonly adminUserId: string;
|
|
readonly now: Date;
|
|
readonly locale: string;
|
|
}) => Promise<readonly AdminDashboardWidgetInput[]>;
|
|
|
|
export type AdminDashboardDataSources = Readonly<
|
|
Record<DashboardSource, DashboardSourceLoader>
|
|
>;
|
|
|
|
const ROLE_WIDGETS = {
|
|
super_admin: ["super_platform"],
|
|
sales: [
|
|
"sales_own_pipeline",
|
|
"sales_today",
|
|
"sales_appointments",
|
|
"sales_followups",
|
|
],
|
|
sales_manager: [
|
|
"sales_team_pipeline",
|
|
"sales_unassigned_leads",
|
|
"sales_team_activity",
|
|
],
|
|
support: ["support_inbox", "support_customer_events"],
|
|
engineering: [
|
|
"engineering_health",
|
|
"engineering_queues",
|
|
"engineering_errors",
|
|
"engineering_deploy",
|
|
],
|
|
executive: [
|
|
"executive_forecast",
|
|
"executive_revenue",
|
|
"executive_mrr",
|
|
"executive_arr",
|
|
"executive_churn",
|
|
],
|
|
viewer: ["viewer_reports"],
|
|
} as const satisfies Record<AdminRoleKey, readonly AdminDashboardWidgetId[]>;
|
|
|
|
const WIDGET_PERMISSION = {
|
|
sales_own_pipeline: "admin_sales.crm.read",
|
|
sales_today: "admin_sales.crm.read",
|
|
sales_appointments: "admin_sales.booking.manage",
|
|
sales_followups: "admin_sales.crm.read",
|
|
sales_team_pipeline: "admin_sales.crm.read_all",
|
|
sales_unassigned_leads: "admin_sales.crm.read_all",
|
|
sales_team_activity: "admin_sales.reporting.read_all",
|
|
support_inbox: "admin_identity.impersonation.start",
|
|
support_customer_events: "admin_identity.impersonation.start",
|
|
engineering_health: "admin.metrics.view",
|
|
engineering_queues: "admin.metrics.view",
|
|
engineering_errors: "admin.events.view",
|
|
engineering_deploy: "admin.metrics.view",
|
|
executive_forecast: "admin_sales.reporting.read_all",
|
|
executive_revenue: "admin_sales.reporting.read_all",
|
|
executive_mrr: "admin_sales.reporting.read_all",
|
|
executive_arr: "admin_sales.reporting.read_all",
|
|
executive_churn: "admin_sales.reporting.read_all",
|
|
viewer_reports: "admin_sales.reporting.read",
|
|
super_platform: "admin_identity.roles.manage",
|
|
} as const satisfies Record<AdminDashboardWidgetId, AdminPermissionName>;
|
|
|
|
const WIDGET_SOURCE = {
|
|
sales_own_pipeline: "salesOwn",
|
|
sales_today: "salesOwn",
|
|
sales_appointments: "salesOwn",
|
|
sales_followups: "salesOwn",
|
|
sales_team_pipeline: "salesTeam",
|
|
sales_unassigned_leads: "salesTeam",
|
|
sales_team_activity: "salesTeam",
|
|
support_inbox: "support",
|
|
support_customer_events: "support",
|
|
engineering_health: "engineering",
|
|
engineering_queues: "engineering",
|
|
engineering_errors: "engineering",
|
|
engineering_deploy: "engineering",
|
|
executive_forecast: "executive",
|
|
executive_revenue: "executive",
|
|
executive_mrr: "executive",
|
|
executive_arr: "executive",
|
|
executive_churn: "executive",
|
|
viewer_reports: null,
|
|
super_platform: null,
|
|
} as const satisfies Record<
|
|
AdminDashboardWidgetId,
|
|
DashboardSource | null
|
|
>;
|
|
|
|
export function selectAdminDashboardWidgetIds(input: {
|
|
readonly roleKeys: readonly AdminRoleKey[];
|
|
readonly permissions: readonly AdminPermissionName[];
|
|
}): readonly AdminDashboardWidgetId[] {
|
|
const permissions = new Set(input.permissions);
|
|
const selected = new Set<AdminDashboardWidgetId>();
|
|
for (const roleKey of input.roleKeys) {
|
|
for (const widgetId of ROLE_WIDGETS[roleKey]) {
|
|
if (permissions.has(WIDGET_PERMISSION[widgetId])) {
|
|
selected.add(widgetId);
|
|
}
|
|
}
|
|
}
|
|
return [...selected];
|
|
}
|
|
|
|
export async function loadPersonalizedAdminDashboard(
|
|
input: {
|
|
readonly adminUserId: string;
|
|
readonly roleKeys: readonly AdminRoleKey[];
|
|
readonly permissions: readonly AdminPermissionName[];
|
|
readonly now?: Date;
|
|
readonly locale?: string;
|
|
},
|
|
sources: AdminDashboardDataSources = DEFAULT_SOURCES
|
|
): Promise<AdminDashboardVM> {
|
|
const selectedIds = selectAdminDashboardWidgetIds(input);
|
|
const selectedSet = new Set(selectedIds);
|
|
const sourceNames = new Set(
|
|
selectedIds
|
|
.map((widgetId) => WIDGET_SOURCE[widgetId])
|
|
.filter((source): source is DashboardSource => source !== null)
|
|
);
|
|
const now = input.now ?? new Date();
|
|
const locale = input.locale ?? "de";
|
|
const loaded = await Promise.all(
|
|
[...sourceNames].map((sourceName) =>
|
|
sources[sourceName]({ adminUserId: input.adminUserId, now, locale })
|
|
)
|
|
);
|
|
const loadedById = new Map(
|
|
loaded.flat().map((widget) => [widget.id, widget])
|
|
);
|
|
const widgets = selectedIds.flatMap((widgetId) => {
|
|
const loadedWidget = loadedById.get(widgetId);
|
|
if (loadedWidget) return [loadedWidget];
|
|
if (widgetId === "viewer_reports") {
|
|
return [
|
|
widget(
|
|
widgetId,
|
|
"admin.dashboard.personalized.readOnly",
|
|
adminPath("dashboard", { mode: "host-aware" })
|
|
),
|
|
];
|
|
}
|
|
if (widgetId === "super_platform") {
|
|
return [
|
|
widget(
|
|
widgetId,
|
|
"admin.dashboard.personalized.fullAccess",
|
|
adminPath("accounts", { mode: "host-aware" }),
|
|
"positive"
|
|
),
|
|
];
|
|
}
|
|
return [];
|
|
});
|
|
|
|
return buildAdminDashboardVM({
|
|
roles: input.roleKeys,
|
|
widgets: widgets.filter((entry) => selectedSet.has(entry.id)),
|
|
});
|
|
}
|
|
|
|
const DEFAULT_SOURCES: AdminDashboardDataSources = {
|
|
salesOwn: loadSalesOwnWidgets,
|
|
salesTeam: loadSalesTeamWidgets,
|
|
support: loadSupportWidgets,
|
|
engineering: loadEngineeringWidgets,
|
|
executive: loadExecutiveWidgets,
|
|
};
|
|
|
|
async function loadSalesOwnWidgets({
|
|
adminUserId,
|
|
now,
|
|
locale,
|
|
}: Parameters<DashboardSourceLoader>[0]) {
|
|
const endOfToday = new Date(now);
|
|
endOfToday.setHours(24, 0, 0, 0);
|
|
const nextThirtyDays = new Date(now);
|
|
nextThirtyDays.setDate(nextThirtyDays.getDate() + 30);
|
|
const dealScope = adminSalesDealReadWhere({
|
|
adminUserId,
|
|
canReadAll: false,
|
|
canWriteAll: false,
|
|
canReadAllCommunication: false,
|
|
});
|
|
const taskScope = adminSalesTaskReadWhere({
|
|
adminUserId,
|
|
canReadAll: false,
|
|
canWriteAll: false,
|
|
canReadAllCommunication: false,
|
|
});
|
|
const [pipeline, today, appointments, followups] = await Promise.all([
|
|
db.adminDeal.aggregate({
|
|
where: { ...dealScope, archivedAt: null, status: "open" },
|
|
_sum: { amountCents: true },
|
|
}),
|
|
db.adminTask.count({
|
|
where: {
|
|
...taskScope,
|
|
archivedAt: null,
|
|
status: "open",
|
|
dueAt: { lt: endOfToday },
|
|
},
|
|
}),
|
|
db.adminBooking.count({
|
|
where: {
|
|
adminUserId,
|
|
status: { in: ["reserved", "confirmed"] },
|
|
startsAt: { gte: now, lt: nextThirtyDays },
|
|
},
|
|
}),
|
|
db.adminTask.count({
|
|
where: {
|
|
...taskScope,
|
|
archivedAt: null,
|
|
status: "open",
|
|
reminderAt: { not: null },
|
|
},
|
|
}),
|
|
]);
|
|
|
|
return [
|
|
widget(
|
|
"sales_own_pipeline",
|
|
formatEuroCents(pipeline._sum.amountCents ?? 0, locale),
|
|
adminPath("sales/deals", { mode: "host-aware" })
|
|
),
|
|
widget(
|
|
"sales_today",
|
|
String(today),
|
|
adminPath("sales/tasks/today", { mode: "host-aware" }),
|
|
today > 0 ? "warning" : "positive"
|
|
),
|
|
widget(
|
|
"sales_appointments",
|
|
String(appointments),
|
|
adminPath("sales/bookings", { mode: "host-aware" })
|
|
),
|
|
widget(
|
|
"sales_followups",
|
|
String(followups),
|
|
adminPath("sales/tasks/today", { mode: "host-aware" })
|
|
),
|
|
];
|
|
}
|
|
|
|
async function loadSalesTeamWidgets({
|
|
now,
|
|
locale,
|
|
}: Parameters<DashboardSourceLoader>[0]) {
|
|
const activityStart = new Date(now);
|
|
activityStart.setDate(activityStart.getDate() - 7);
|
|
const [pipeline, unassignedLeads, activity] = await Promise.all([
|
|
db.adminDeal.aggregate({
|
|
where: { archivedAt: null, status: "open" },
|
|
_sum: { amountCents: true },
|
|
}),
|
|
db.adminLead.count({
|
|
where: {
|
|
archivedAt: null,
|
|
assignedToAdminUserId: null,
|
|
status: { in: ["new", "contacted", "qualified"] },
|
|
},
|
|
}),
|
|
db.adminActivity.count({
|
|
where: { occurredAt: { gte: activityStart } },
|
|
}),
|
|
]);
|
|
|
|
return [
|
|
widget(
|
|
"sales_team_pipeline",
|
|
formatEuroCents(pipeline._sum.amountCents ?? 0, locale),
|
|
adminPath("sales/deals", { mode: "host-aware" })
|
|
),
|
|
widget(
|
|
"sales_unassigned_leads",
|
|
String(unassignedLeads),
|
|
adminPath("leads", { mode: "host-aware" }),
|
|
unassignedLeads > 0 ? "warning" : "positive"
|
|
),
|
|
widget(
|
|
"sales_team_activity",
|
|
String(activity),
|
|
adminPath("sales/tasks/today", { mode: "host-aware" })
|
|
),
|
|
];
|
|
}
|
|
|
|
async function loadSupportWidgets({
|
|
now,
|
|
}: Parameters<DashboardSourceLoader>[0]) {
|
|
const eventStart = new Date(now);
|
|
eventStart.setDate(eventStart.getDate() - 1);
|
|
const [openTickets, customerEvents] = await Promise.all([
|
|
db.supportTicket.count({
|
|
where: { status: { in: ["open", "answered"] } },
|
|
}),
|
|
db.supportTicket.count({ where: { updatedAt: { gte: eventStart } } }),
|
|
]);
|
|
return [
|
|
widget(
|
|
"support_inbox",
|
|
String(openTickets),
|
|
adminPath("support", { mode: "host-aware" }),
|
|
openTickets > 0 ? "warning" : "positive"
|
|
),
|
|
widget(
|
|
"support_customer_events",
|
|
String(customerEvents),
|
|
adminPath("audit", { mode: "host-aware" })
|
|
),
|
|
];
|
|
}
|
|
|
|
async function loadEngineeringWidgets() {
|
|
try {
|
|
const snapshot = await getOperatorHealthSnapshot();
|
|
const issueCount = snapshot.subsystems.filter(
|
|
(subsystem) => subsystem.status !== "ok"
|
|
).length;
|
|
return [
|
|
widget(
|
|
"engineering_health",
|
|
issueCount === 0 ? "OK" : String(issueCount),
|
|
adminPath("system", { mode: "host-aware" }),
|
|
issueCount === 0 ? "positive" : "warning"
|
|
),
|
|
widget(
|
|
"engineering_queues",
|
|
String(snapshot.signals.failedJobsTotal),
|
|
adminPath("system/jobs", { mode: "host-aware" }),
|
|
snapshot.signals.failedJobsTotal > 0 ? "critical" : "positive"
|
|
),
|
|
widget(
|
|
"engineering_errors",
|
|
String(
|
|
snapshot.signals.webhookFailures +
|
|
snapshot.signals.billingErrors
|
|
),
|
|
adminPath("events", { mode: "host-aware" }),
|
|
snapshot.signals.webhookFailures + snapshot.signals.billingErrors > 0
|
|
? "critical"
|
|
: "positive"
|
|
),
|
|
widget(
|
|
"engineering_deploy",
|
|
snapshot.deploy.release,
|
|
adminPath("system/deploys", { mode: "host-aware" })
|
|
),
|
|
];
|
|
} catch {
|
|
return [
|
|
widget(
|
|
"engineering_health",
|
|
"admin.dashboard.personalized.unavailable",
|
|
adminPath("system", { mode: "host-aware" }),
|
|
"critical"
|
|
),
|
|
widget(
|
|
"engineering_queues",
|
|
"admin.dashboard.personalized.unavailable",
|
|
adminPath("system/jobs", { mode: "host-aware" }),
|
|
"critical"
|
|
),
|
|
widget(
|
|
"engineering_errors",
|
|
"admin.dashboard.personalized.unavailable",
|
|
adminPath("events", { mode: "host-aware" }),
|
|
"critical"
|
|
),
|
|
widget(
|
|
"engineering_deploy",
|
|
"admin.dashboard.personalized.unavailable",
|
|
adminPath("system/deploys", { mode: "host-aware" }),
|
|
"critical"
|
|
),
|
|
];
|
|
}
|
|
}
|
|
|
|
async function loadExecutiveWidgets({
|
|
now,
|
|
locale,
|
|
}: Parameters<DashboardSourceLoader>[0]) {
|
|
const yearStart = new Date(now.getFullYear(), 0, 1);
|
|
const tenants = await adminGetAllTenantsIdsAndNames();
|
|
const tenantIds = tenants.map((tenant) => tenant.id);
|
|
const [forecast, revenue, mrr, subscriptions] = await Promise.all([
|
|
db.adminDeal.aggregate({
|
|
where: { archivedAt: null, status: "open" },
|
|
_sum: { amountCents: true },
|
|
}),
|
|
db.adminDeal.aggregate({
|
|
where: { archivedAt: null, status: "won", wonAt: { gte: yearStart } },
|
|
_sum: { amountCents: true },
|
|
}),
|
|
getMrr("eur", tenantIds),
|
|
loadTenantSubscriptions(tenantIds),
|
|
]);
|
|
const churned = subscriptions.filter(
|
|
(subscription) =>
|
|
subscription?.cancelledAt !== null ||
|
|
subscription?.status === "cancelled"
|
|
).length;
|
|
const churnRate =
|
|
subscriptions.length === 0
|
|
? 0
|
|
: Math.round((churned / subscriptions.length) * 10_000) / 100;
|
|
|
|
return [
|
|
widget(
|
|
"executive_forecast",
|
|
formatEuroCents(forecast._sum.amountCents ?? 0, locale),
|
|
adminPath("sales/deals", { mode: "host-aware" })
|
|
),
|
|
widget(
|
|
"executive_revenue",
|
|
formatEuroCents(revenue._sum.amountCents ?? 0, locale),
|
|
adminPath("sales/deals", { mode: "host-aware" })
|
|
),
|
|
widget(
|
|
"executive_mrr",
|
|
formatEuroUnits(mrr.total, locale),
|
|
adminPath("dashboard", { mode: "host-aware" })
|
|
),
|
|
widget(
|
|
"executive_arr",
|
|
formatEuroUnits(mrr.total * 12, locale),
|
|
adminPath("dashboard", { mode: "host-aware" })
|
|
),
|
|
widget(
|
|
"executive_churn",
|
|
`${new Intl.NumberFormat(locale, {
|
|
maximumFractionDigits: 2,
|
|
}).format(churnRate)} %`,
|
|
adminPath("dashboard", { mode: "host-aware" }),
|
|
churnRate > 5 ? "warning" : "neutral"
|
|
),
|
|
];
|
|
}
|
|
|
|
async function loadTenantSubscriptions(tenantIds: readonly string[]) {
|
|
const subscriptions: Array<{
|
|
readonly status: string;
|
|
readonly cancelledAt: Date | null;
|
|
} | null> = [];
|
|
for (let index = 0; index < tenantIds.length; index += 10) {
|
|
const batch = tenantIds.slice(index, index + 10);
|
|
subscriptions.push(
|
|
...(await Promise.all(
|
|
batch.map((tenantId) =>
|
|
runWithRlsContext({ tenantId }, () =>
|
|
db.tenantSubscription.findUnique({
|
|
where: { tenantId },
|
|
select: { status: true, cancelledAt: true },
|
|
})
|
|
)
|
|
)
|
|
))
|
|
);
|
|
}
|
|
return subscriptions.filter(
|
|
(
|
|
subscription
|
|
): subscription is { readonly status: string; readonly cancelledAt: Date | null } =>
|
|
subscription !== null
|
|
);
|
|
}
|
|
|
|
function widget(
|
|
id: AdminDashboardWidgetId,
|
|
value: string,
|
|
href: string,
|
|
tone: AdminDashboardWidgetInput["tone"] = "neutral"
|
|
): AdminDashboardWidgetInput {
|
|
return { id, value, href, tone };
|
|
}
|
|
|
|
function formatEuroCents(value: number, locale: string) {
|
|
return formatEuroUnits(value / 100, locale);
|
|
}
|
|
|
|
function formatEuroUnits(value: number, locale: string) {
|
|
return new Intl.NumberFormat(locale, {
|
|
style: "currency",
|
|
currency: "EUR",
|
|
maximumFractionDigits: 0,
|
|
}).format(value);
|
|
}
|