since3-portal/modules/admin/queries/dashboard.ts
2026-09-29 16:37:48 +02:00

229 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import "server-only"
import Decimal from "decimal.js"
import { createClient } from "@/lib/supabase/server"
import { berlinToday } from "@/modules/shared/lib/info-format"
import { formatCurrency } from "@/modules/shared/lib/invoice-math"
import { PRODUCT_STATUS_LABELS, type ProductStatus } from "@/modules/shared/schemas/info"
const RECENT_UPLOAD_ROWS = 100
const MAX_UPLOAD_GROUPS = 8
const MAX_TASKS = 8
export type AttentionItem = {
key: string
kind: "overdue" | "outage" | "maintenance" | "check" | "stammdaten" | "terms"
title: string
detail: string
href: string
}
export type UploadGroup = {
key: string
clientName: string
uploaderName: string
folderName: string
count: number
latestAt: string
href: string
}
export type DashboardTask = {
id: string
name: string
clientName: string
since: string
status: "in_progress" | "planned"
href: string
}
export type DashboardData = {
openCount: number
openTotal: number
overdueCount: number
overdueTotal: number
paidThisMonthTotal: number
clientCount: number
clientsWithIssues: number
attention: AttentionItem[]
uploads: UploadGroup[]
tasks: DashboardTask[]
}
function daysBetween(fromIso: string, toIso: string) {
return Math.round((Date.parse(toIso) - Date.parse(fromIso)) / 86_400_000)
}
function sum(values: (number | string | null)[]) {
return values.reduce<Decimal>((total, value) => total.plus(new Decimal(value ?? 0)), new Decimal(0)).toNumber()
}
// Admins lesen per RLS (is_admin) alle Clients – daher reicht der normale Server-Client.
export async function getDashboardData(): Promise<DashboardData> {
const supabase = await createClient()
const today = berlinToday()
const currentMonth = today.slice(0, 7)
const [profilesRes, companiesRes, invoicesRes, productsRes, checksRes, tasksRes, documentsRes] = await Promise.all([
supabase.from("profiles").select("id, name, client_id, terms_accepted_at").eq("role", "client").order("name"),
supabase.from("clients").select("id, company_name"),
supabase
.from("invoices_overview")
.select("id, client_id, invoice_number, status, display_status, gross_total, due_date, paid_at")
.neq("status", "cancelled"),
supabase.from("client_products").select("id, client_id, name, status, status_message"),
supabase.from("product_check_summary").select("product_id, ok, error"),
supabase
.from("product_tasks")
.select("id, client_id, name, status, since")
.in("status", ["in_progress", "planned"])
.order("status")
.order("since")
.limit(MAX_TASKS),
supabase
.from("documents")
// !inner + Filter auf der Relation: nur Client-Uploads, bevor das Limit greift
.select("id, client_id, folder_id, created_at, folders(name), uploader:profiles!documents_uploaded_by_fkey!inner(id, name, role)")
.eq("uploader.role", "client")
.order("created_at", { ascending: false })
.limit(RECENT_UPLOAD_ROWS),
])
for (const result of [profilesRes, companiesRes, invoicesRes, productsRes, checksRes, tasksRes, documentsRes]) {
if (result.error) throw new Error("Dashboard konnte nicht geladen werden.")
}
const profiles = profilesRes.data!
const companyById = new Map(companiesRes.data!.map((row) => [row.id as string, row.company_name as string]))
const profileByClient = new Map<string, { id: string; name: string }>()
for (const profile of profiles) {
if (profile.client_id && !profileByClient.has(profile.client_id)) {
profileByClient.set(profile.client_id, { id: profile.id, name: profile.name })
}
}
const clientLabel = (clientId: string) =>
companyById.get(clientId) ?? profileByClient.get(clientId)?.name ?? "Unbekannter Client"
const clientBase = (clientId: string) => {
const profile = profileByClient.get(clientId)
return profile ? `/admin/clients/${profile.id}` : "/admin/clients"
}
const attention: AttentionItem[] = []
// Rechnungen
const invoices = invoicesRes.data!
const open = invoices.filter((invoice) => invoice.status === "pending")
const overdue = open
.filter((invoice) => invoice.display_status === "overdue")
.sort((a, b) => a.due_date.localeCompare(b.due_date))
const paidThisMonth = invoices.filter((invoice) => invoice.status === "paid" && invoice.paid_at?.startsWith(currentMonth))
for (const invoice of overdue) {
const days = daysBetween(invoice.due_date, today)
attention.push({
key: `invoice-${invoice.id}`,
kind: "overdue",
title: `${invoice.invoice_number} ist überfällig`,
detail: `${clientLabel(invoice.client_id)} · ${formatCurrency(Number(invoice.gross_total))} · seit ${days} ${days === 1 ? "Tag" : "Tagen"}`,
href: `${clientBase(invoice.client_id)}/billing`,
})
}
// Systeme
const lastCheckByProduct = new Map(checksRes.data!.map((row) => [row.product_id as string, row]))
const clientsWithIssues = new Set<string>()
for (const product of productsRes.data!) {
const status = product.status as ProductStatus
const lastCheck = lastCheckByProduct.get(product.id)
const failedCheck = lastCheck?.ok === false
if (status === "offline" || failedCheck) clientsWithIssues.add(product.client_id)
if (status === "online" && !failedCheck) continue
const kind: AttentionItem["kind"] = status === "offline" ? "outage" : status === "maintenance" ? "maintenance" : "check"
attention.push({
key: `product-${product.id}`,
kind,
title:
kind === "check"
? `${product.name}: letzte Prüfung fehlgeschlagen`
: `${product.name}: ${PRODUCT_STATUS_LABELS[status]}`,
detail: [clientLabel(product.client_id), kind === "check" ? lastCheck?.error : product.status_message]
.filter(Boolean)
.join(" · "),
href: clientBase(product.client_id),
})
}
// Onboarding
for (const profile of profiles) {
if (!profile.client_id) {
attention.push({
key: `stammdaten-${profile.id}`,
kind: "stammdaten",
title: `${profile.name}: Stammdaten fehlen`,
detail: "Ohne Stammdaten sind Rechnungen, Dokumente und Status nicht nutzbar.",
href: `/admin/clients/${profile.id}/data`,
})
}
if (!profile.terms_accepted_at) {
attention.push({
key: `terms-${profile.id}`,
kind: "terms",
title: `${profile.name}: AGB noch nicht akzeptiert`,
detail: "Wird beim nächsten Login des Clients abgefragt.",
href: `/admin/clients/${profile.id}`,
})
}
}
const priority: Record<AttentionItem["kind"], number> = { outage: 0, overdue: 1, check: 2, maintenance: 3, stammdaten: 4, terms: 5 }
attention.sort((a, b) => priority[a.kind] - priority[b.kind])
// Uploads von Clients, zusammengefasst pro Person + Ordner
const groups = new Map<string, UploadGroup>()
for (const doc of documentsRes.data!) {
const uploader = doc.uploader as unknown as { id: string; name: string; role: string } | null
if (!uploader || uploader.role !== "client") continue
const key = `${uploader.id}-${doc.folder_id}`
const existing = groups.get(key)
if (existing) {
existing.count++
continue
}
if (groups.size >= MAX_UPLOAD_GROUPS) continue
const folder = doc.folders as unknown as { name: string } | null
groups.set(key, {
key,
clientName: clientLabel(doc.client_id),
uploaderName: uploader.name,
folderName: folder?.name ?? "Ohne Ordner",
count: 1,
latestAt: doc.created_at,
href: `/admin/clients/${uploader.id}/storage${doc.folder_id ? `?folder=${doc.folder_id}` : ""}`,
})
}
return {
openCount: open.length,
openTotal: sum(open.map((invoice) => invoice.gross_total)),
overdueCount: overdue.length,
overdueTotal: sum(overdue.map((invoice) => invoice.gross_total)),
paidThisMonthTotal: sum(paidThisMonth.map((invoice) => invoice.gross_total)),
clientCount: profiles.length,
clientsWithIssues: clientsWithIssues.size,
attention,
uploads: [...groups.values()],
tasks: tasksRes.data!.map((task) => ({
id: task.id,
name: task.name,
clientName: clientLabel(task.client_id),
since: task.since,
status: task.status,
href: clientBase(task.client_id),
})),
}
}