mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 20:51:17 +00:00
229 lines
8.9 KiB
TypeScript
229 lines
8.9 KiB
TypeScript
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),
|
||
})),
|
||
}
|
||
}
|