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