added dashboard

This commit is contained in:
Rajpreet0 2026-09-29 15:20:50 +02:00
parent 29ea53a03d
commit 64b2c85f81
14 changed files with 1496 additions and 11 deletions

View file

@ -1,6 +1,7 @@
import { notFound } from "next/navigation";
import { createAdminClient } from "@/lib/supabase/admin";
import { Params } from "@/modules/shared/types/user";
import InfoPanel from "@/modules/shared/ui/views/info-panel";
const ClientDetailPage = async ({ params }: { params: Params }) => {
@ -15,12 +16,7 @@ const ClientDetailPage = async ({ params }: { params: Params }) => {
if (!client) notFound();
return (
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-semibold">{client.name}</h1>
<p className="text-muted-foreground">{client.email}</p>
</div>
)
return <InfoPanel profileId={client.id} isAdmin />
}
export default ClientDetailPage

View file

@ -1,8 +1,9 @@
import { verifySession } from "@/lib/supabase/dal"
import InfoPanel from "@/modules/shared/ui/views/info-panel"
const ClientDashboard = () => {
return (
<div>page</div>
)
const ClientDashboard = async () => {
const session = await verifySession()
return <InfoPanel profileId={session.userId} isAdmin={false} />
}
export default ClientDashboard

View file

@ -0,0 +1,37 @@
-- Info-Sektion: Produktstatus, laufende Vorgänge, interne Notizen
-- Hosting als eigene Kategorie (neben Webseite, E-Mail, SEO)
alter type public.product_category add value if not exists 'hosting';
-- Freitext zum Status, z. B. "Erreichbar, keine Störungen" oder "Wartung geplant am 12.10."
alter table public.client_products
add column if not exists status_message text;
-- Vorgänge müssen nicht zwingend an ein Produkt gebunden sein
alter table public.product_tasks
alter column product_id drop not null;
-- "zuletzt bearbeitet von ..." für interne Notizen
alter table public.internal_notes
add column if not exists updated_by uuid references public.profiles(id) on delete set null default auth.uid(),
add column if not exists updated_at timestamptz not null default now();
update public.internal_notes
set updated_by = created_by, updated_at = created_at
where updated_by is null;
create or replace function public.set_updated_by()
returns trigger language plpgsql set search_path = '' as $$
begin
new.updated_by = auth.uid();
return new;
end $$;
create trigger set_updated_at before update on public.internal_notes
for each row execute function public.set_updated_at();
create trigger set_updated_by before update on public.internal_notes
for each row execute function public.set_updated_by();
create index if not exists internal_notes_client_updated_idx
on public.internal_notes (client_id, updated_at desc);

View file

@ -0,0 +1,51 @@
-- Erreichbarkeitsprüfungen für Produkte (manuell per Button + beim Seitenaufruf)
create table public.product_checks (
id bigint generated always as identity primary key,
product_id uuid not null references public.client_products(id) on delete cascade,
client_id uuid not null references public.clients(id) on delete cascade,
checked_at timestamptz not null default now(),
ok boolean not null,
status_code int,
response_ms int,
error text
);
create index on public.product_checks (product_id, checked_at desc);
-- Lesen: Admin alles, Client nur eigene. Schreiben bewusst ohne Policy:
-- Ergebnisse schreibt nur der Server (Service Role), damit Clients ihre Uptime nicht fälschen können.
alter table public.product_checks enable row level security;
create policy admin_select on public.product_checks for select to authenticated
using ((select public.is_admin()));
create policy client_select on public.product_checks for select to authenticated
using (client_id = (select public.current_client_id()));
-- Letzte Prüfung + Uptime der letzten 30 Tage je Produkt
create view public.product_check_summary with (security_invoker = true) as
select
p.id as product_id,
last.checked_at,
last.ok,
last.status_code,
last.response_ms,
last.error,
stats.checks_30d,
stats.uptime_30d
from public.client_products p
left join lateral (
select c.checked_at, c.ok, c.status_code, c.response_ms, c.error
from public.product_checks c
where c.product_id = p.id
order by c.checked_at desc
limit 1
) last on true
left join lateral (
select
count(*)::int as checks_30d,
round(100.0 * count(*) filter (where c.ok) / nullif(count(*), 0), 2) as uptime_30d
from public.product_checks c
where c.product_id = p.id
and c.checked_at > now() - interval '30 days'
) stats on true;

View file

@ -0,0 +1,163 @@
"use server";
import * as z from "zod";
import { revalidatePath } from "next/cache";
import { createClient } from "@/lib/supabase/server";
import { verifySession } from "@/lib/supabase/dal";
import { getClientId } from "@/modules/shared/queries/profile";
import { NoteSchema, ProductSchema, TaskSchema } from "@/modules/shared/schemas/info";
export type InfoActionState = { error: string } | { success: true } | undefined;
const IdSchema = z.string().uuid();
type Table = "client_products" | "product_tasks" | "internal_notes";
const DELETE_TABLES: Record<string, Table | undefined> = {
product: "client_products",
task: "product_tasks",
note: "internal_notes",
};
function firstError(error: z.ZodError) {
return error.issues[0]?.message ?? "Bitte alle Pflichtfelder ausfüllen.";
}
function revalidateInfo(profileId: string) {
revalidatePath("/client/dashboard");
revalidatePath(`/admin/clients/${profileId}`);
}
// Admin-Check + Client auflösen. Alle Schreibzugriffe laufen danach zusätzlich über RLS (admin_all).
async function resolveAdminTarget(profileId: string): Promise<{ error: string } | { clientId: string }> {
const session = await verifySession();
if (!session.isAdmin) return { error: "Keine Berechtigung." };
if (!IdSchema.safeParse(profileId).success) return { error: "Ungültiger Client." };
const clientId = await getClientId(profileId);
if (!clientId) return { error: "Client hat noch keine Stammdaten hinterlegt." };
return { clientId };
}
async function upsertRow(
table: Table,
clientId: string,
id: string | null,
values: Record<string, unknown>
): Promise<boolean> {
const supabase = await createClient();
if (!id) {
const { error } = await supabase.from(table).insert({ ...values, client_id: clientId });
return !error;
}
if (!IdSchema.safeParse(id).success) return false;
const { data, error } = await supabase
.from(table)
.update(values)
.eq("id", id)
.eq("client_id", clientId)
.select("id");
return !error && Boolean(data?.length);
}
function formString(formData: FormData, key: string) {
return String(formData.get(key) ?? "");
}
function formId(formData: FormData) {
const id = formString(formData, "id");
return id === "" ? null : id;
}
export async function saveProduct(_prev: InfoActionState, formData: FormData): Promise<InfoActionState> {
const profileId = formString(formData, "profileId");
const target = await resolveAdminTarget(profileId);
if ("error" in target) return target;
const parsed = ProductSchema.safeParse({
name: formString(formData, "name"),
category: formString(formData, "category"),
url: formString(formData, "url"),
status: formString(formData, "status"),
statusMessage: formString(formData, "statusMessage"),
uptime30d: formString(formData, "uptime30d"),
});
if (!parsed.success) return { error: firstError(parsed.error) };
const ok = await upsertRow("client_products", target.clientId, formId(formData), {
name: parsed.data.name,
category: parsed.data.category,
url: parsed.data.url,
status: parsed.data.status,
status_message: parsed.data.statusMessage,
uptime_30d: parsed.data.uptime30d,
});
if (!ok) return { error: "Produkt konnte nicht gespeichert werden." };
revalidateInfo(profileId);
return { success: true };
}
export async function saveTask(_prev: InfoActionState, formData: FormData): Promise<InfoActionState> {
const profileId = formString(formData, "profileId");
const target = await resolveAdminTarget(profileId);
if ("error" in target) return target;
const parsed = TaskSchema.safeParse({
name: formString(formData, "name"),
description: formString(formData, "description"),
status: formString(formData, "status"),
since: formString(formData, "since"),
});
if (!parsed.success) return { error: firstError(parsed.error) };
const ok = await upsertRow("product_tasks", target.clientId, formId(formData), parsed.data);
if (!ok) return { error: "Vorgang konnte nicht gespeichert werden." };
revalidateInfo(profileId);
return { success: true };
}
export async function saveNote(_prev: InfoActionState, formData: FormData): Promise<InfoActionState> {
const profileId = formString(formData, "profileId");
const target = await resolveAdminTarget(profileId);
if ("error" in target) return target;
const parsed = NoteSchema.safeParse({ body: formString(formData, "body") });
if (!parsed.success) return { error: firstError(parsed.error) };
const ok = await upsertRow("internal_notes", target.clientId, formId(formData), parsed.data);
if (!ok) return { error: "Notiz konnte nicht gespeichert werden." };
revalidateInfo(profileId);
return { success: true };
}
export async function deleteInfoEntry(
kind: "product" | "task" | "note",
profileId: string,
id: string
): Promise<InfoActionState> {
const target = await resolveAdminTarget(profileId);
if ("error" in target) return target;
const table = Object.hasOwn(DELETE_TABLES, kind) ? DELETE_TABLES[kind] : undefined;
if (!table || !IdSchema.safeParse(id).success) return { error: "Ungültiger Eintrag." };
const supabase = await createClient();
const { data, error } = await supabase
.from(table)
.delete()
.eq("id", id)
.eq("client_id", target.clientId)
.select("id");
if (error || !data?.length) return { error: "Eintrag konnte nicht gelöscht werden." };
revalidateInfo(profileId);
return { success: true };
}

View file

@ -0,0 +1,108 @@
"use server";
import * as z from "zod";
import { revalidatePath } from "next/cache";
import { createClient } from "@/lib/supabase/server";
import { createAdminClient } from "@/lib/supabase/admin";
import { verifySession } from "@/lib/supabase/dal";
import { getClientId } from "@/modules/shared/queries/profile";
import { checkUrl, toCheckableUrl, type CheckResult } from "@/modules/shared/lib/reachability";
import { AUTO_CHECK_STALE_AFTER_MS } from "@/modules/shared/schemas/info";
const MANUAL_MIN_INTERVAL_MS = 30 * 1000;
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
const IdSchema = z.string().uuid();
export type CheckActionResult = { error: string } | { result: CheckResult };
type CheckableProduct = { id: string; url: URL; lastCheckedAt: string | null };
// Lesen über RLS (Admin: alle, Client: nur eigene) – damit ist gleichzeitig geprüft, ob der Aufrufer das Produkt sehen darf.
async function loadCheckableProducts(profileId: string, productId?: string) {
const session = await verifySession();
if (!IdSchema.safeParse(profileId).success) return null;
if (!session.isAdmin && session.userId !== profileId) return null;
const clientId = await getClientId(profileId);
if (!clientId) return null;
const supabase = await createClient();
let query = supabase.from("client_products").select("id, url").eq("client_id", clientId);
if (productId) query = query.eq("id", productId);
const { data: products, error } = await query;
if (error || !products?.length) return null;
const { data: summaries } = await supabase
.from("product_check_summary")
.select("product_id, checked_at")
.in("product_id", products.map((product) => product.id));
const lastChecked = new Map((summaries ?? []).map((row) => [row.product_id as string, row.checked_at as string | null]));
const checkable: CheckableProduct[] = [];
for (const product of products) {
const url = toCheckableUrl(product.url);
if (url) checkable.push({ id: product.id, url, lastCheckedAt: lastChecked.get(product.id) ?? null });
}
return { clientId, products: checkable };
}
async function runAndStore(clientId: string, products: CheckableProduct[]) {
const results = await Promise.all(products.map(async (product) => ({ product, result: await checkUrl(product.url) })));
// Service Role, weil es bewusst keine Insert-Policy gibt (Clients sollen Ergebnisse nicht selbst schreiben können).
const admin = createAdminClient();
await admin.from("product_checks").insert(
results.map(({ product, result }) => ({
product_id: product.id,
client_id: clientId,
ok: result.ok,
status_code: result.statusCode,
response_ms: result.responseMs,
error: result.error,
}))
);
await admin
.from("product_checks")
.delete()
.in("product_id", products.map((product) => product.id))
.lt("checked_at", new Date(Date.now() - RETENTION_MS).toISOString());
return results;
}
function revalidateInfo(profileId: string) {
revalidatePath("/client/dashboard");
revalidatePath(`/admin/clients/${profileId}`);
}
function olderThan(timestamp: string | null, ms: number) {
return timestamp === null || Date.now() - Date.parse(timestamp) > ms;
}
export async function checkProduct(profileId: string, productId: string): Promise<CheckActionResult> {
if (!IdSchema.safeParse(productId).success) return { error: "Ungültiges Produkt." };
const loaded = await loadCheckableProducts(profileId, productId);
const product = loaded?.products[0];
if (!loaded || !product) return { error: "Dieses Produkt kann nicht automatisch geprüft werden." };
if (!olderThan(product.lastCheckedAt, MANUAL_MIN_INTERVAL_MS)) {
return { error: "Wurde gerade erst geprüft. Bitte kurz warten." };
}
const [{ result }] = await runAndStore(loaded.clientId, [product]);
revalidateInfo(profileId);
return { result };
}
export async function checkStaleProducts(profileId: string): Promise<{ checked: number }> {
const loaded = await loadCheckableProducts(profileId);
const stale = loaded?.products.filter((product) => olderThan(product.lastCheckedAt, AUTO_CHECK_STALE_AFTER_MS)) ?? [];
if (!loaded || stale.length === 0) return { checked: 0 };
await runAndStore(loaded.clientId, stale);
revalidateInfo(profileId);
return { checked: stale.length };
}

View file

@ -0,0 +1,45 @@
import type { TaskStatus } from "@/modules/shared/schemas/info";
const TIME_ZONE = "Europe/Berlin";
const DAY_MS = 24 * 60 * 60 * 1000;
// Server läuft in UTC; "heute" soll aber der Kalendertag in Deutschland sein.
export function berlinToday() {
return new Intl.DateTimeFormat("en-CA", { timeZone: TIME_ZONE }).format(new Date());
}
function daysBetween(fromIso: string, toIso: string) {
return Math.round((Date.parse(toIso) - Date.parse(fromIso)) / DAY_MS);
}
const shortDate = new Intl.DateTimeFormat("de-DE", { day: "numeric", month: "short", timeZone: "UTC" });
export function formatTaskTiming(status: TaskStatus, since: string, today = berlinToday()) {
const days = daysBetween(since, today);
if (status === "planned" || days < 0) {
return `ab ${shortDate.format(new Date(`${since}T00:00:00Z`))}`;
}
if (days === 0) return "seit heute";
if (days === 1) return "seit gestern";
return `seit ${days} Tagen`;
}
const dateTime = new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZone: TIME_ZONE,
});
export function formatDateTime(timestamp: string) {
return `${dateTime.format(new Date(timestamp))} Uhr`;
}
const percent = new Intl.NumberFormat("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
export function formatUptime(value: number) {
return `${percent.format(value)} %`;
}

View file

@ -0,0 +1,124 @@
import "server-only"
import dns from "node:dns"
import http from "node:http"
import https from "node:https"
import net from "node:net"
export type CheckResult = {
ok: boolean
statusCode: number | null
responseMs: number | null
error: string | null
}
const TIMEOUT_MS = 8000
// Nur öffentliche Ziele: schützt davor, über den Check interne Dienste (Metadata-API, localhost, LAN) anzusprechen.
const blocked = new net.BlockList()
for (const [address, prefix] of [
["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16],
["172.16.0.0", 12], ["192.0.0.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], ["224.0.0.0", 4], ["240.0.0.0", 4],
] as const) {
blocked.addSubnet(address, prefix, "ipv4")
}
for (const [address, prefix] of [["::", 128], ["::1", 128], ["fc00::", 7], ["fe80::", 10], ["ff00::", 8]] as const) {
blocked.addSubnet(address, prefix, "ipv6")
}
function isBlockedAddress(address: string, family: number) {
const mappedV4 = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address)?.[1]
if (mappedV4) return blocked.check(mappedV4, "ipv4")
return blocked.check(address, family === 6 ? "ipv6" : "ipv4")
}
// Wird von http(s).request für jede Verbindung genutzt, also auch gegen DNS-Rebinding zwischen Prüfung und Connect.
const safeLookup: net.LookupFunction = (hostname, options, callback) => {
dns.lookup(hostname, { ...options, all: true }, (error, addresses) => {
if (error) return callback(error, "", 4)
const list = addresses as dns.LookupAddress[]
const forbidden = list.find((entry) => isBlockedAddress(entry.address, entry.family))
if (list.length === 0 || forbidden) {
return callback(Object.assign(new Error("Ziel nicht erlaubt"), { code: "EBLOCKED" }), "", 4)
}
if (options.all) return (callback as unknown as (err: null, addresses: dns.LookupAddress[]) => void)(null, list)
callback(null, list[0].address, list[0].family)
})
}
const DOMAIN_PATTERN = /^(https?:\/\/)?[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}(:\d{1,5})?(\/\S*)?$/i
// "www.firma.de" oder "https://firma.de/shop" → prüfbar; "Server", "info@firma.de" → nicht.
export function toCheckableUrl(value: string | null): URL | null {
if (!value) return null
const trimmed = value.trim()
if (trimmed.includes("@") || !DOMAIN_PATTERN.test(trimmed)) return null
try {
const url = new URL(/^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`)
return url.protocol === "http:" || url.protocol === "https:" ? url : null
} catch {
return null
}
}
function describeError(error: NodeJS.ErrnoException) {
switch (error.code) {
case "EBLOCKED": return "Ziel nicht erlaubt"
case "ENOTFOUND": case "EAI_AGAIN": return "Domain nicht gefunden"
case "ECONNREFUSED": return "Verbindung abgelehnt"
case "ECONNRESET": return "Verbindung abgebrochen"
case "ETIMEDOUT": return "Zeitüberschreitung"
default:
return error.code?.startsWith("ERR_TLS") || error.code?.includes("CERT")
? "SSL-Zertifikat ungültig"
: "Nicht erreichbar"
}
}
export function checkUrl(url: URL): Promise<CheckResult> {
// Bei IP-Literalen ruft Node den lookup-Hook nicht auf, daher hier separat prüfen.
const host = url.hostname.replace(/^\[|\]$/g, "")
const ipFamily = net.isIP(host)
if (ipFamily !== 0 && isBlockedAddress(host, ipFamily)) {
return Promise.resolve({ ok: false, statusCode: null, responseMs: null, error: "Ziel nicht erlaubt" })
}
const client = url.protocol === "http:" ? http : https
const started = performance.now()
return new Promise((resolve) => {
let settled = false
const finish = (result: CheckResult) => {
if (settled) return
settled = true
resolve(result)
}
const request = client.request(
url,
{
method: "GET",
lookup: safeLookup,
timeout: TIMEOUT_MS,
headers: { "User-Agent": "Since3-Statuscheck/1.0", Accept: "text/html,*/*" },
},
(response) => {
const statusCode = response.statusCode ?? 0
response.destroy()
finish({
ok: statusCode > 0 && statusCode < 400,
statusCode,
responseMs: Math.round(performance.now() - started),
error: statusCode >= 400 ? `HTTP ${statusCode}` : null,
})
}
)
request.on("timeout", () => {
request.destroy(Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }))
})
request.on("error", (error: NodeJS.ErrnoException) => {
finish({ ok: false, statusCode: null, responseMs: null, error: describeError(error) })
})
request.end()
})
}

View file

@ -0,0 +1,95 @@
import "server-only"
import { createClient } from "@/lib/supabase/server"
import { AUTO_CHECK_STALE_AFTER_MS, type InternalNote, type Product, type Task } from "@/modules/shared/schemas/info"
import { toCheckableUrl } from "@/modules/shared/lib/reachability"
export async function getProducts(clientId: string): Promise<Product[]> {
const supabase = await createClient()
const { data, error } = await supabase
.from("client_products")
.select("id, name, category, url, status, status_message, uptime_30d, updated_at")
.eq("client_id", clientId)
.order("created_at")
if (error) throw new Error("Produkte konnten nicht geladen werden.")
if (data.length === 0) return []
const { data: summaries, error: summaryError } = await supabase
.from("product_check_summary")
.select("product_id, checked_at, ok, response_ms, error, checks_30d, uptime_30d")
.in("product_id", data.map((row) => row.id))
if (summaryError) throw new Error("Prüfergebnisse konnten nicht geladen werden.")
const summaryById = new Map(summaries.map((row) => [row.product_id as string, row]))
const now = Date.now()
return data.map((row) => {
const summary = summaryById.get(row.id)
const checkable = toCheckableUrl(row.url) !== null
return {
id: row.id,
name: row.name,
category: row.category,
url: row.url,
status: row.status,
statusMessage: row.status_message,
uptime30d: row.uptime_30d === null ? null : Number(row.uptime_30d),
updatedAt: row.updated_at,
checkable,
checkDue: checkable && (!summary?.checked_at || now - Date.parse(summary.checked_at) > AUTO_CHECK_STALE_AFTER_MS),
measuredUptime30d: summary?.uptime_30d == null ? null : Number(summary.uptime_30d),
checks30d: summary?.checks_30d ?? 0,
lastCheck: summary?.checked_at
? {
checkedAt: summary.checked_at,
ok: summary.ok,
responseMs: summary.response_ms,
error: summary.error,
}
: null,
}
})
}
export async function getOpenTasks(clientId: string): Promise<Task[]> {
const supabase = await createClient()
const { data, error } = await supabase
.from("product_tasks")
.select("id, name, description, status, since")
.eq("client_id", clientId)
.in("status", ["in_progress", "planned"])
.order("status")
.order("since")
if (error) throw new Error("Vorgänge konnten nicht geladen werden.")
return data.map((row) => ({
id: row.id,
name: row.name,
description: row.description,
status: row.status,
since: row.since,
}))
}
export async function getInternalNotes(clientId: string): Promise<InternalNote[]> {
const supabase = await createClient()
const { data, error } = await supabase
.from("internal_notes")
.select("id, body, updated_at, editor:profiles!internal_notes_updated_by_fkey(name)")
.eq("client_id", clientId)
.order("updated_at", { ascending: false })
if (error) throw new Error("Interne Notizen konnten nicht geladen werden.")
return data.map((row) => {
const editor = row.editor as { name: string } | { name: string }[] | null
return {
id: row.id,
body: row.body,
updatedAt: row.updated_at,
editorName: (Array.isArray(editor) ? editor[0]?.name : editor?.name) ?? null,
}
})
}

View file

@ -0,0 +1,111 @@
import * as z from "zod";
export const PRODUCT_CATEGORIES = ["website", "hosting", "email", "seo", "other"] as const;
export type ProductCategory = (typeof PRODUCT_CATEGORIES)[number];
export const PRODUCT_STATUSES = ["online", "maintenance", "offline"] as const;
export type ProductStatus = (typeof PRODUCT_STATUSES)[number];
// Beim Seitenaufruf werden nur Produkte geprüft, deren letzte Prüfung älter ist.
export const AUTO_CHECK_STALE_AFTER_MS = 5 * 60 * 1000;
export const TASK_STATUSES =["in_progress", "planned", "done"] as const;
export type TaskStatus = (typeof TASK_STATUSES)[number];
export const CATEGORY_LABELS: Record<ProductCategory, string> = {
website: "Webseite",
hosting: "Hosting",
email: "E-Mail",
seo: "SEO",
other: "Sonstiges",
};
export const PRODUCT_STATUS_LABELS: Record<ProductStatus, string> = {
online: "Operativ",
maintenance: "Wartung",
offline: "Ausfall",
};
export const DEFAULT_STATUS_MESSAGES: Record<ProductStatus, string> = {
online: "Erreichbar, keine Störungen",
maintenance: "Wartung geplant",
offline: "Nicht erreichbar",
};
export const TASK_STATUS_LABELS: Record<TaskStatus, string> = {
in_progress: "In Arbeit",
planned: "Geplant",
done: "Erledigt",
};
export type Product = {
id: string;
name: string;
category: ProductCategory;
url: string | null;
status: ProductStatus;
statusMessage: string | null;
uptime30d: number | null;
updatedAt: string;
checkable: boolean;
checkDue: boolean;
measuredUptime30d: number | null;
checks30d: number;
lastCheck: {
checkedAt: string;
ok: boolean;
responseMs: number | null;
error: string | null;
} | null;
};
export type Task = {
id: string;
name: string;
description: string | null;
status: TaskStatus;
since: string;
};
export type InternalNote = {
id: string;
body: string;
updatedAt: string;
editorName: string | null;
};
const optionalText = z
.string()
.trim()
.transform((value) => (value === "" ? null : value));
export const ProductSchema = z.object({
name: z.string().trim().min(1, { error: "Name ist erforderlich." }),
category: z.enum(PRODUCT_CATEGORIES),
url: optionalText,
status: z.enum(PRODUCT_STATUSES),
statusMessage: optionalText,
uptime30d: z
.string()
.trim()
.transform((value, ctx) => {
if (value === "") return null;
const number = Number(value.replace(",", "."));
if (!Number.isFinite(number) || number < 0 || number > 100) {
ctx.addIssue({ code: "custom", message: "Uptime muss zwischen 0 und 100 liegen." });
return z.NEVER;
}
return Math.round(number * 100) / 100;
}),
});
export const TaskSchema = z.object({
name: z.string().trim().min(1, { error: "Vorgang ist erforderlich." }),
description: optionalText,
status: z.enum(TASK_STATUSES),
since: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, { error: "Bitte ein Datum wählen." }),
});
export const NoteSchema = z.object({
body: z.string().trim().min(1, { error: "Notiz darf nicht leer sein." }).max(5000),
});

View file

@ -0,0 +1,302 @@
"use client";
import { useActionState, useState } from "react";
import { PencilIcon, PlusIcon, Trash2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { deleteInfoEntry, saveProduct, saveTask, type InfoActionState } from "@/modules/shared/actions/info";
import {
CATEGORY_LABELS,
DEFAULT_STATUS_MESSAGES,
PRODUCT_CATEGORIES,
PRODUCT_STATUSES,
PRODUCT_STATUS_LABELS,
TASK_STATUSES,
TASK_STATUS_LABELS,
type Product,
type Task,
} from "@/modules/shared/schemas/info";
import { toIsoDate } from "@/modules/shared/lib/invoice-math";
type FormAction = (prev: InfoActionState, formData: FormData) => Promise<InfoActionState>;
export const selectClassName =
"flex h-10 w-full rounded-md border border-input bg-background px-3 text-body outline-none transition-colors disabled:cursor-not-allowed disabled:opacity-50";
function ActionForm({
action,
hidden,
submitLabel,
onDone,
children,
}: {
action: FormAction;
hidden: Record<string, string>;
submitLabel: string;
onDone: () => void;
children: React.ReactNode;
}) {
async function run(prev: InfoActionState, formData: FormData) {
const result = await action(prev, formData);
if (result && "success" in result) onDone();
return result;
}
const [state, formAction, pending] = useActionState(run, undefined);
return (
<form action={formAction}>
{Object.entries(hidden).map(([name, value]) => (
<input key={name} type="hidden" name={name} value={value} />
))}
<FieldGroup>
{children}
{state && "error" in state && <p className="text-sm text-destructive">{state.error}</p>}
</FieldGroup>
<DialogFooter className="mt-6">
<Button type="submit" disabled={pending}>
{pending ? "Wird gespeichert..." : submitLabel}
</Button>
</DialogFooter>
</form>
);
}
function FormDialog({
title,
trigger,
action,
hidden,
submitLabel = "Speichern",
children,
}: {
title: string;
trigger: React.ReactElement;
action: FormAction;
hidden: Record<string, string>;
submitLabel?: string;
children: React.ReactNode;
}) {
const [open, setOpen] = useState(false);
const [instanceKey, setInstanceKey] = useState(0);
function handleOpenChange(nextOpen: boolean) {
setOpen(nextOpen);
if (!nextOpen) setInstanceKey((key) => key + 1);
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger render={trigger} />
<DialogContent className="sm:max-w-lg">
<DialogHeader className="mb-4">
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<ActionForm
key={instanceKey}
action={action}
hidden={hidden}
submitLabel={submitLabel}
onDone={() => handleOpenChange(false)}
>
{children}
</ActionForm>
</DialogContent>
</Dialog>
);
}
const editTrigger = (
<Button variant="ghost" size="icon-sm" aria-label="Bearbeiten">
<PencilIcon className="size-4" />
</Button>
);
function createTrigger(label: string) {
return (
<Button variant="outline" size="sm">
<PlusIcon className="size-4" /> {label}
</Button>
);
}
export function ProductDialog({ profileId, product }: { profileId: string; product?: Product }) {
return (
<FormDialog
title={product ? "Produkt bearbeiten" : "Neues Produkt"}
trigger={product ? editTrigger : createTrigger("Erstellen")}
action={saveProduct}
hidden={{ profileId, id: product?.id ?? "" }}
submitLabel={product ? "Speichern" : "Erstellen"}
>
<ProductFields product={product} />
</FormDialog>
);
}
function ProductFields({ product }: { product?: Product }) {
const [status, setStatus] = useState<Product["status"]>(product?.status ?? "online");
return (
<>
<div className="grid gap-4 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="product-name">Produkt <span className="text-destructive">*</span></FieldLabel>
<Input id="product-name" name="name" defaultValue={product?.name ?? ""} placeholder="z. B. Webseite" required />
</Field>
<Field>
<FieldLabel htmlFor="product-category">Kategorie</FieldLabel>
<select id="product-category" name="category" defaultValue={product?.category ?? "website"} className={selectClassName}>
{PRODUCT_CATEGORIES.map((category) => (
<option key={category} value={category}>{CATEGORY_LABELS[category]}</option>
))}
</select>
</Field>
</div>
<Field>
<FieldLabel htmlFor="product-url">Link / Adresse</FieldLabel>
<Input id="product-url" name="url" defaultValue={product?.url ?? ""} placeholder="www.mustermann-gmbh.de, Server, info@mustermann.de" />
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="product-status">Status</FieldLabel>
<select
id="product-status"
name="status"
value={status}
onChange={(event) => setStatus(event.target.value as Product["status"])}
className={selectClassName}
>
{PRODUCT_STATUSES.map((value) => (
<option key={value} value={value}>{PRODUCT_STATUS_LABELS[value]}</option>
))}
</select>
</Field>
<Field>
<FieldLabel htmlFor="product-uptime">Uptime 30 Tage (%)</FieldLabel>
<Input
id="product-uptime"
name="uptime30d"
inputMode="decimal"
defaultValue={product?.uptime30d?.toLocaleString("de-DE") ?? ""}
placeholder="99,98"
/>
<FieldDescription>Nur für nicht prüfbare Produkte. Bei Web-Adressen wird die Uptime gemessen.</FieldDescription>
</Field>
</div>
<Field>
<FieldLabel htmlFor="product-message">Statusmeldung</FieldLabel>
<Input
id="product-message"
name="statusMessage"
defaultValue={product?.statusMessage ?? ""}
placeholder={DEFAULT_STATUS_MESSAGES[status]}
/>
<FieldDescription>Leer lassen für den Standardtext „{DEFAULT_STATUS_MESSAGES[status]}“.</FieldDescription>
</Field>
</>
);
}
export function TaskDialog({ profileId, task }: { profileId: string; task?: Task }) {
return (
<FormDialog
title={task ? "Vorgang bearbeiten" : "Neuer Vorgang"}
trigger={task ? editTrigger : createTrigger("Hinzufügen")}
action={saveTask}
hidden={{ profileId, id: task?.id ?? "" }}
submitLabel={task ? "Speichern" : "Hinzufügen"}
>
<Field>
<FieldLabel htmlFor="task-name">Vorgang <span className="text-destructive">*</span></FieldLabel>
<Input id="task-name" name="name" defaultValue={task?.name ?? ""} placeholder="z. B. Suchmaschinenoptimierung Startseite" required />
</Field>
<Field>
<FieldLabel htmlFor="task-description">Beschreibung</FieldLabel>
<Input id="task-description" name="description" defaultValue={task?.description ?? ""} placeholder="z. B. Meta-Texte & Ladezeit" />
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="task-status">Status</FieldLabel>
<select id="task-status" name="status" defaultValue={task?.status ?? "in_progress"} className={selectClassName}>
{TASK_STATUSES.map((value) => (
<option key={value} value={value}>{TASK_STATUS_LABELS[value]}</option>
))}
</select>
</Field>
<Field>
<FieldLabel htmlFor="task-since">Seit / ab</FieldLabel>
<Input id="task-since" name="since" type="date" defaultValue={task?.since ?? toIsoDate(new Date())} required />
</Field>
</div>
<FieldDescription>
„In Arbeit“ zeigt „seit …“, „Geplant“ zeigt „ab …“. „Erledigt“ blendet den Vorgang aus.
</FieldDescription>
</FormDialog>
);
}
export function DeleteInfoDialog({
kind,
profileId,
id,
label,
}: {
kind: "product" | "task" | "note";
profileId: string;
id: string;
label: string;
}) {
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleDelete() {
setPending(true);
setError(null);
const result = await deleteInfoEntry(kind, profileId, id);
setPending(false);
if (result && "error" in result) {
setError(result.error);
return;
}
setOpen(false);
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button variant="ghost" size="icon-sm" aria-label="Löschen" />}>
<Trash2Icon className="size-4" />
</DialogTrigger>
<DialogContent>
<DialogHeader className="mb-4">
<DialogTitle>Eintrag löschen</DialogTitle>
<DialogDescription>
Möchtest du <span className="font-medium text-foreground">{label}</span> wirklich löschen?
Diese Aktion kann nicht rückgängig gemacht werden.
</DialogDescription>
</DialogHeader>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={pending}>
Abbrechen
</Button>
<Button type="button" variant="destructive" onClick={handleDelete} disabled={pending}>
{pending ? "Wird gelöscht..." : "Löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,105 @@
"use client";
import { useActionState, useState } from "react";
import { EyeOffIcon, PencilIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { saveNote, type InfoActionState } from "@/modules/shared/actions/info";
import type { InternalNote } from "@/modules/shared/schemas/info";
import { formatDateTime } from "@/modules/shared/lib/info-format";
import { DeleteInfoDialog } from "./info-dialogs";
function NoteForm({
profileId,
note,
onDone,
onCancel,
}: {
profileId: string;
note?: InternalNote;
onDone: () => void;
onCancel?: () => void;
}) {
async function run(prev: InfoActionState, formData: FormData) {
const result = await saveNote(prev, formData);
if (result && "success" in result) onDone();
return result;
}
const [state, formAction, pending] = useActionState(run, undefined);
return (
<form action={formAction} className="flex flex-col gap-2">
<input type="hidden" name="profileId" value={profileId} />
<input type="hidden" name="id" value={note?.id ?? ""} />
<Textarea
name="body"
defaultValue={note?.body ?? ""}
placeholder="Interne Notiz zum Kunden..."
aria-label={note ? "Notiz bearbeiten" : "Neue interne Notiz"}
className="min-h-20"
required
autoFocus={Boolean(note)}
/>
{state && "error" in state && <p className="text-sm text-destructive">{state.error}</p>}
<div className="flex justify-end gap-2">
{onCancel && (
<Button type="button" variant="outline" size="sm" onClick={onCancel} disabled={pending}>
Abbrechen
</Button>
)}
<Button type="submit" size="sm" disabled={pending}>
{pending ? "Wird gespeichert..." : note ? "Speichern" : "Notiz hinzufügen"}
</Button>
</div>
</form>
);
}
function NoteItem({ profileId, note }: { profileId: string; note: InternalNote }) {
const [editing, setEditing] = useState(false);
return (
<li className="flex flex-col gap-2 border-t border-border py-4 first:border-t-0">
{editing ? (
<NoteForm profileId={profileId} note={note} onDone={() => setEditing(false)} onCancel={() => setEditing(false)} />
) : (
<div className="flex items-start justify-between gap-4">
<p className="whitespace-pre-wrap break-words">{note.body}</p>
<div className="flex shrink-0 gap-1">
<Button variant="ghost" size="icon-sm" aria-label="Notiz bearbeiten" onClick={() => setEditing(true)}>
<PencilIcon className="size-4" />
</Button>
<DeleteInfoDialog kind="note" profileId={profileId} id={note.id} label="diese Notiz" />
</div>
</div>
)}
<p className="text-small text-muted-foreground">
Zuletzt bearbeitet von {note.editorName ?? "unbekannt"} · {formatDateTime(note.updatedAt)}
</p>
</li>
);
}
export function InternalNotes({ profileId, notes }: { profileId: string; notes: InternalNote[] }) {
const [formKey, setFormKey] = useState(0);
return (
<section className="flex flex-col gap-4 rounded-lg border border-dashed border-border bg-surface/50 p-4 md:p-6">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-xl font-semibold">Interne Notiz</h2>
<span className="inline-flex items-center gap-1.5 rounded-full bg-ink-100 px-2.5 py-0.5 text-label text-ink-700 dark:bg-ink-800 dark:text-ink-300">
<EyeOffIcon className="size-3.5" /> Kunde sieht das nicht
</span>
</div>
<NoteForm key={formKey} profileId={profileId} onDone={() => setFormKey((key) => key + 1)} />
{notes.length > 0 && (
<ul className="flex flex-col">
{notes.map((note) => (
<NoteItem key={note.id} profileId={profileId} note={note} />
))}
</ul>
)}
</section>
);
}

View file

@ -0,0 +1,78 @@
"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import { LoaderCircleIcon, RefreshCwIcon } from "lucide-react";
import { cn } from "cn";
import { Button } from "@/components/ui/button";
import { checkProduct, checkStaleProducts } from "@/modules/shared/actions/product-checks";
import { formatDateTime } from "@/modules/shared/lib/info-format";
import type { Product } from "@/modules/shared/schemas/info";
export function ProductCheckLine({
profileId,
productId,
lastCheck,
}: {
profileId: string;
productId: string;
lastCheck: Product["lastCheck"];
}) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
function handleCheck() {
setError(null);
startTransition(async () => {
const response = await checkProduct(profileId, productId);
if ("error" in response) setError(response.error);
});
}
return (
<div className="flex items-center gap-1 text-small">
<span aria-live="polite" className={cn(lastCheck && !lastCheck.ok ? "text-signal-error" : "text-muted-foreground")}>
{pending
? "Wird geprüft..."
: error ??
(lastCheck
? `${lastCheck.ok ? "Erreichbar" : lastCheck.error ?? "Nicht erreichbar"}${
lastCheck.ok && lastCheck.responseMs !== null ? ` · ${lastCheck.responseMs} ms` : ""
} · geprüft ${formatDateTime(lastCheck.checkedAt)}`
: "Noch nicht geprüft")}
</span>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-6"
onClick={handleCheck}
disabled={pending}
aria-label="Erreichbarkeit jetzt prüfen"
title="Erreichbarkeit jetzt prüfen"
>
<RefreshCwIcon className={cn("size-3.5", pending && "animate-spin")} />
</Button>
</div>
);
}
export function AutoCheck({ profileId }: { profileId: string }) {
const [running, setRunning] = useState(true);
const started = useRef(false);
useEffect(() => {
if (started.current) return;
started.current = true;
checkStaleProducts(profileId)
.catch(() => undefined)
.finally(() => setRunning(false));
}, [profileId]);
if (!running) return null;
return (
<span className="inline-flex items-center gap-1.5 text-small text-muted-foreground" aria-live="polite">
<LoaderCircleIcon className="size-3.5 animate-spin" /> Erreichbarkeit wird geprüft...
</span>
);
}

View file

@ -0,0 +1,269 @@
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-xl font-semibold">{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 flex-col gap-10 w-full">
<div className="w-full flex flex-col justify-between gap-2">
<h1 className="text-3xl font-semibold">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-center gap-3 rounded-lg border px-4 py-3 font-medium", 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="text-right">Uptime</TableHead>
{isAdmin && <TableHead className="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 flex-col">
<span className="font-medium">{product.name}</span>
<span className="text-small text-muted-foreground">
{product.url ? <ProductLink value={product.url} /> : CATEGORY_LABELS[product.category]}
</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="text-right tabular-nums whitespace-nowrap"
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-center gap-4 px-4 py-3">
<Badge variant={task.status === "in_progress" ? "warning" : "neutral"} className="w-20">
{TASK_STATUS_LABELS[task.status]}
</Badge>
<div className="flex min-w-0 flex-1 flex-col">
<span className="font-medium">{task.name}</span>
{task.description && (
<span className="text-small text-muted-foreground">{task.description}</span>
)}
</div>
<span className="shrink-0 text-small text-muted-foreground tabular-nums">
{formatTaskTiming(task.status, task.since, today)}
</span>
{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