since3-portal/modules/admin/ui/views/dashboard-view.tsx
2026-09-29 22:56:37 +02:00

204 lines
9.7 KiB
TypeScript

import Link from "next/link"
import {
AlertTriangleIcon,
ArrowRightIcon,
CheckCircle2Icon,
ClipboardListIcon,
FileWarningIcon,
ReceiptIcon,
ScaleIcon,
ServerCrashIcon,
UploadIcon,
WrenchIcon,
type LucideIcon,
} from "lucide-react"
import { cn } from "cn"
import { Badge } from "@/components/ui/badge"
import { getDashboardData, type AttentionItem } from "@/modules/admin/queries/dashboard"
import { formatCurrency } from "@/modules/shared/lib/invoice-math"
import { berlinToday, formatDateTime, formatTaskTiming } from "@/modules/shared/lib/info-format"
import { TASK_STATUS_LABELS } from "@/modules/shared/schemas/info"
const ATTENTION_STYLES: Record<AttentionItem["kind"], { icon: LucideIcon; className: string }> = {
outage: { icon: ServerCrashIcon, className: "text-signal-error bg-signal-error/10" },
overdue: { icon: ReceiptIcon, className: "text-signal-error bg-signal-error/10" },
check: { icon: AlertTriangleIcon, className: "text-signal-warning bg-signal-warning/10" },
maintenance: { icon: WrenchIcon, className: "text-signal-warning bg-signal-warning/10" },
stammdaten: { icon: FileWarningIcon, className: "text-ink-600 bg-ink-100 dark:text-ink-300 dark:bg-ink-800" },
terms: { icon: ScaleIcon, className: "text-ink-600 bg-ink-100 dark:text-ink-300 dark:bg-ink-800" },
}
function StatCard({
label,
value,
detail,
tone = "default",
href,
}: {
label: string
value: string
detail?: string
tone?: "default" | "danger" | "success"
href?: string
}) {
const content = (
<>
<span className="text-small text-muted-foreground">{label}</span>
<span
className={cn(
"text-xl font-semibold tabular-nums break-words sm:text-2xl",
tone === "danger" && "text-signal-error",
tone === "success" && "text-signal-success"
)}
>
{value}
</span>
{detail && <span className="text-small text-muted-foreground">{detail}</span>}
</>
)
const className = "flex min-w-0 flex-col gap-1 rounded-lg border border-border p-4"
return href ? (
<Link href={href} className={cn(className, "transition-colors hover:bg-surface")}>{content}</Link>
) : (
<div className={className}>{content}</div>
)
}
function Section({ title, count, children }: { title: string; count?: number; children: React.ReactNode }) {
return (
<section className="flex flex-col gap-3">
<h2 className="flex items-center gap-2 text-lg font-semibold md:text-xl">
{title}
{count !== undefined && count > 0 && <Badge>{count}</Badge>}
</h2>
{children}
</section>
)
}
function EmptyHint({ icon: Icon = CheckCircle2Icon, children }: { icon?: LucideIcon; children: React.ReactNode }) {
return (
<p className="flex items-center gap-2 rounded-lg border border-border bg-surface/50 p-4 text-muted-foreground">
<Icon className="size-4 shrink-0" /> {children}
</p>
)
}
function LinkRow({ href, children }: { href: string; children: React.ReactNode }) {
return (
<li>
<Link href={href} className="group flex items-center gap-3 px-4 py-3 transition-colors hover:bg-surface">
{children}
<ArrowRightIcon className="size-4 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
</Link>
</li>
)
}
const DashboardView = async ({ name }: { name: string }) => {
const data = await getDashboardData()
const today = berlinToday()
return (
<div className="flex w-full flex-col gap-8 md:gap-10">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-semibold md:text-3xl">Hallo {name.split(" ")[0]}</h1>
<p className="text-accent-400">Alles Wichtige über alle Clients auf einen Blick.</p>
</div>
<div className="grid gap-3 min-[480px]:grid-cols-2 xl:grid-cols-4">
<StatCard label="Offene Rechnungen" value={formatCurrency(data.openTotal)} detail={`${data.openCount} ${data.openCount === 1 ? "Rechnung" : "Rechnungen"}`} />
<StatCard
label="Davon überfällig"
value={formatCurrency(data.overdueTotal)}
detail={`${data.overdueCount} ${data.overdueCount === 1 ? "Rechnung" : "Rechnungen"}`}
tone={data.overdueCount > 0 ? "danger" : "default"}
/>
<StatCard label="Bezahlt diesen Monat" value={formatCurrency(data.paidThisMonthTotal)} tone={data.paidThisMonthTotal > 0 ? "success" : "default"} />
<StatCard
label="Clients"
value={String(data.clientCount)}
detail={data.clientsWithIssues > 0 ? `${data.clientsWithIssues} mit Störung` : "Keine Störungen"}
tone={data.clientsWithIssues > 0 ? "danger" : "default"}
href="/admin/clients"
/>
</div>
<Section title="Braucht Aufmerksamkeit" count={data.attention.length}>
{data.attention.length === 0 ? (
<EmptyHint>Alles im grünen Bereich.</EmptyHint>
) : (
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-lg border border-border">
{data.attention.map((item) => {
const style = ATTENTION_STYLES[item.kind]
return (
<LinkRow key={item.key} href={item.href}>
<span className={cn("flex size-8 shrink-0 items-center justify-center rounded-md", style.className)}>
<style.icon className="size-4" />
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate font-medium">{item.title}</span>
{item.detail && <span className="truncate text-small text-muted-foreground">{item.detail}</span>}
</span>
</LinkRow>
)
})}
</ul>
)}
</Section>
<div className="grid gap-8 md:gap-10 xl:grid-cols-2">
<Section title="Neueste Uploads von Clients">
{data.uploads.length === 0 ? (
<EmptyHint icon={UploadIcon}>Noch keine Uploads von Clients.</EmptyHint>
) : (
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-lg border border-border">
{data.uploads.map((upload) => (
<LinkRow key={upload.key} href={upload.href}>
<UploadIcon className="size-4 shrink-0 text-muted-foreground" />
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate">
<span className="font-medium">{upload.clientName}</span> hat {upload.count}{" "}
{upload.count === 1 ? "Datei" : "Dateien"} in „{upload.folderName}“ hochgeladen
</span>
<span className="truncate text-small text-muted-foreground">
{upload.uploaderName} · zuletzt {formatDateTime(upload.latestAt)}
</span>
</span>
</LinkRow>
))}
</ul>
)}
</Section>
<Section title="Laufende Vorgänge">
{data.tasks.length === 0 ? (
<EmptyHint icon={ClipboardListIcon}>Keine offenen Vorgänge.</EmptyHint>
) : (
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-lg border border-border">
{data.tasks.map((task) => (
<LinkRow key={task.id} href={task.href}>
<Badge variant={task.status === "in_progress" ? "warning" : "neutral"} className="w-20 shrink-0">
{TASK_STATUS_LABELS[task.status]}
</Badge>
<span className="flex min-w-0 flex-1 flex-col sm:flex-row sm:items-center sm:gap-3">
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate font-medium">{task.name}</span>
<span className="truncate text-small text-muted-foreground">{task.clientName}</span>
</span>
<span className="text-small text-muted-foreground sm:shrink-0">
{formatTaskTiming(task.status, task.since, today)}
</span>
</span>
</LinkRow>
))}
</ul>
)}
</Section>
</div>
</div>
)
}
export default DashboardView