since3-portal/modules/shared/ui/views/info-panel.tsx
2026-09-29 22:56:37 +02:00

274 lines
15 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 { AlertTriangleIcon, CheckCircle2Icon, XCircleIcon } from "lucide-react"
import { cn } from "cn"
import { Badge } from "@/components/ui/badge"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { getClientId } from "@/modules/shared/queries/profile"
import { getInternalNotes, getOpenTasks, getProducts } from "@/modules/shared/queries/info"
import {
CATEGORY_LABELS,
DEFAULT_STATUS_MESSAGES,
PRODUCT_STATUS_LABELS,
TASK_STATUS_LABELS,
type Product,
type ProductStatus,
} from "@/modules/shared/schemas/info"
import { berlinToday, formatDateTime, formatTaskTiming, formatUptime } from "@/modules/shared/lib/info-format"
import { DeleteInfoDialog, ProductDialog, TaskDialog } from "@/modules/shared/ui/components/info-dialogs"
import { InternalNotes } from "@/modules/shared/ui/components/internal-notes"
import { AutoCheck, ProductCheckLine } from "@/modules/shared/ui/components/product-check"
const DOT_CLASSES: Record<ProductStatus, string> = {
online: "bg-signal-success",
maintenance: "bg-signal-warning",
offline: "bg-signal-error",
}
function statusBanner(products: Product[]) {
const offline = products.filter((product) => product.status === "offline")
const maintenance = products.filter((product) => product.status === "maintenance")
const names = (list: Product[]) => list.map((product) => product.name).join(", ")
if (offline.length > 0) {
return {
icon: XCircleIcon,
className: "border-signal-error/30 bg-signal-error/10 text-signal-error",
text: `Störung: ${names(offline)} ${offline.length === 1 ? "ist" : "sind"} aktuell nicht erreichbar`,
}
}
if (maintenance.length > 0) {
return {
icon: AlertTriangleIcon,
className: "border-signal-warning/30 bg-signal-warning/10 text-signal-warning",
text: `Wartungsarbeiten: ${names(maintenance)}`,
}
}
return {
icon: CheckCircle2Icon,
className: "border-signal-success/30 bg-signal-success/10 text-signal-success",
text: "Alle Systeme funktionieren normal",
}
}
// Gemessene Uptime hat Vorrang; der manuell eingetragene Wert ist nur Fallback für nicht prüfbare Produkte.
function displayedUptime(product: Product) {
return product.checks30d > 0 ? product.measuredUptime30d : product.uptime30d
}
function UptimeValue({ value }: { value: number | null }) {
if (value === null) return <span className="text-muted-foreground">–</span>
return (
<>
<span className="font-medium">{formatUptime(value)}</span>
<span className="text-muted-foreground"> · 30 Tage</span>
</>
)
}
function ProductLink({ value }: { value: string }) {
if (/^(https?:\/\/|www\.)/i.test(value)) {
const href = /^https?:\/\//i.test(value) ? value : `https://${value}`
return (
<a href={href} target="_blank" rel="noopener noreferrer" className="hover:underline underline-offset-4">
{value}
</a>
)
}
return <>{value}</>
}
function SectionHeader({ title, meta, action }: { title: React.ReactNode; meta?: React.ReactNode; action?: React.ReactNode }) {
return (
<div className="flex flex-wrap items-end justify-between gap-2">
<div className="flex flex-col gap-0.5">
<h2 className="text-lg font-semibold md:text-xl">{title}</h2>
{meta && <p className="text-small text-muted-foreground">{meta}</p>}
</div>
{action}
</div>
)
}
function EmptyState({ children }: { children: React.ReactNode }) {
return (
<p className="rounded-lg border border-border bg-surface/50 p-8 text-center text-muted-foreground">{children}</p>
)
}
const InfoPanel = async ({ profileId, isAdmin }: { profileId: string; isAdmin: boolean }) => {
const clientId = await getClientId(profileId)
const [products, tasks, notes] = clientId
? await Promise.all([
getProducts(clientId),
getOpenTasks(clientId),
isAdmin ? getInternalNotes(clientId) : Promise.resolve([]),
])
: [[], [], []]
const updateTimes = products.flatMap((product) =>
[product.updatedAt, product.lastCheck?.checkedAt].filter((value): value is string => Boolean(value))
)
const lastUpdated = updateTimes.length > 0
? updateTimes.reduce((latest, value) => (Date.parse(value) > Date.parse(latest) ? value : latest))
: null
const hasStaleChecks = products.some((product) => product.checkDue)
const banner = statusBanner(products)
const today = berlinToday()
return (
<div className="flex w-full flex-col gap-8 md:gap-10">
<div className="w-full flex flex-col justify-between gap-2">
<h1 className="text-2xl font-semibold md:text-3xl">Info</h1>
<p className="text-accent-400">Status eurer Systeme und laufende Arbeiten auf einen Blick.</p>
</div>
{!clientId ? (
<EmptyState>
{isAdmin
? "Für diesen Client sind noch keine Stammdaten hinterlegt. Bitte zuerst die Stammdaten ausfüllen."
: "Sobald eure Stammdaten hinterlegt sind, seht ihr hier den Status eurer Systeme."}
</EmptyState>
) : (
<>
<section className="flex flex-col gap-4">
<SectionHeader
title="Status"
meta={
lastUpdated || hasStaleChecks ? (
<span className="flex flex-wrap items-center gap-x-3">
{lastUpdated && <span>Zuletzt aktualisiert: {formatDateTime(lastUpdated)}</span>}
{hasStaleChecks && <AutoCheck profileId={profileId} />}
</span>
) : undefined
}
action={isAdmin ? <ProductDialog profileId={profileId} /> : undefined}
/>
{products.length === 0 ? (
<EmptyState>Noch keine Systeme hinterlegt.</EmptyState>
) : (
<>
<div role="status" className={cn("flex items-start gap-3 rounded-lg border px-4 py-3 font-medium sm:items-center", banner.className)}>
<banner.icon className="size-5 shrink-0" />
{banner.text}
</div>
<div className="overflow-x-auto rounded-lg border border-border">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Produkt</TableHead>
<TableHead>Status</TableHead>
<TableHead className="hidden text-right sm:table-cell">Uptime</TableHead>
{isAdmin && <TableHead className="w-20 sm:w-24" />}
</TableRow>
</TableHeader>
<TableBody>
{products.map((product) => (
<TableRow key={product.id}>
<TableCell>
<div className="flex items-center gap-3">
<span
className={cn("size-2.5 shrink-0 rounded-full", DOT_CLASSES[product.status])}
role="img"
aria-label={PRODUCT_STATUS_LABELS[product.status]}
title={PRODUCT_STATUS_LABELS[product.status]}
/>
<div className="flex min-w-0 flex-col">
<span className="font-medium">{product.name}</span>
<span className="text-small text-muted-foreground wrap-anywhere">
{product.url ? <ProductLink value={product.url} /> : CATEGORY_LABELS[product.category]}
</span>
<span className="text-small tabular-nums sm:hidden">
<UptimeValue value={displayedUptime(product)} />
</span>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex flex-col gap-0.5">
<span className="text-muted-foreground">
{product.statusMessage ?? DEFAULT_STATUS_MESSAGES[product.status]}
</span>
{product.checkable && (
<ProductCheckLine
profileId={profileId}
productId={product.id}
lastCheck={product.lastCheck}
/>
)}
</div>
</TableCell>
<TableCell
className="hidden text-right tabular-nums whitespace-nowrap sm:table-cell"
title={product.checks30d > 0 ? `Gemessen aus ${product.checks30d} Prüfungen` : undefined}
>
<UptimeValue value={displayedUptime(product)} />
</TableCell>
{isAdmin && (
<TableCell>
<div className="flex justify-end gap-1">
<ProductDialog profileId={profileId} product={product} />
<DeleteInfoDialog kind="product" profileId={profileId} id={product.id} label={product.name} />
</div>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
)}
</section>
<section className="flex flex-col gap-4">
<SectionHeader
title={
<span className="flex items-center gap-2">
Woran wir gerade arbeiten
<Badge>{tasks.length}</Badge>
</span>
}
action={isAdmin ? <TaskDialog profileId={profileId} /> : undefined}
/>
{tasks.length === 0 ? (
<EmptyState>Aktuell laufen keine Vorgänge.</EmptyState>
) : (
<ul className="flex flex-col divide-y divide-border rounded-lg border border-border">
{tasks.map((task) => (
<li key={task.id} className="flex items-start gap-3 px-4 py-3 sm:items-center sm:gap-4">
<Badge variant={task.status === "in_progress" ? "warning" : "neutral"} className="mt-0.5 w-20 shrink-0 sm:mt-0">
{TASK_STATUS_LABELS[task.status]}
</Badge>
<div className="flex min-w-0 flex-1 flex-col gap-0.5 sm:flex-row sm:items-center sm:gap-4">
<div className="flex min-w-0 flex-1 flex-col">
<span className="font-medium break-words">{task.name}</span>
{task.description && (
<span className="text-small text-muted-foreground break-words">{task.description}</span>
)}
</div>
<span className="text-small text-muted-foreground tabular-nums sm:shrink-0">
{formatTaskTiming(task.status, task.since, today)}
</span>
</div>
{isAdmin && (
<div className="flex shrink-0 gap-1">
<TaskDialog profileId={profileId} task={task} />
<DeleteInfoDialog kind="task" profileId={profileId} id={task.id} label={task.name} />
</div>
)}
</li>
))}
</ul>
)}
</section>
{isAdmin && <InternalNotes profileId={profileId} notes={notes} />}
</>
)}
</div>
)
}
export default InfoPanel