mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 19:51:17 +00:00
204 lines
9.7 KiB
TypeScript
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
|