diff --git a/app/admin/clients/[id]/page.tsx b/app/admin/clients/[id]/page.tsx
index ccd6c24..88d190f 100644
--- a/app/admin/clients/[id]/page.tsx
+++ b/app/admin/clients/[id]/page.tsx
@@ -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 (
-
-
{client.name}
-
{client.email}
-
- )
+ return
}
export default ClientDetailPage
diff --git a/app/client/dashboard/page.tsx b/app/client/dashboard/page.tsx
index ec7110a..33dbb9b 100644
--- a/app/client/dashboard/page.tsx
+++ b/app/client/dashboard/page.tsx
@@ -1,8 +1,9 @@
+import { verifySession } from "@/lib/supabase/dal"
+import InfoPanel from "@/modules/shared/ui/views/info-panel"
-const ClientDashboard = () => {
- return (
- page
- )
+const ClientDashboard = async () => {
+ const session = await verifySession()
+ return
}
-export default ClientDashboard
\ No newline at end of file
+export default ClientDashboard
diff --git a/lib/supabase/migrations/20260930100000_info_section.sql b/lib/supabase/migrations/20260930100000_info_section.sql
new file mode 100644
index 0000000..c89a28f
--- /dev/null
+++ b/lib/supabase/migrations/20260930100000_info_section.sql
@@ -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);
diff --git a/lib/supabase/migrations/20260930120000_product_checks.sql b/lib/supabase/migrations/20260930120000_product_checks.sql
new file mode 100644
index 0000000..e1c4c73
--- /dev/null
+++ b/lib/supabase/migrations/20260930120000_product_checks.sql
@@ -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;
diff --git a/modules/shared/actions/info.ts b/modules/shared/actions/info.ts
new file mode 100644
index 0000000..9977901
--- /dev/null
+++ b/modules/shared/actions/info.ts
@@ -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 = {
+ 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
+): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 };
+}
diff --git a/modules/shared/actions/product-checks.ts b/modules/shared/actions/product-checks.ts
new file mode 100644
index 0000000..1e962d5
--- /dev/null
+++ b/modules/shared/actions/product-checks.ts
@@ -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 {
+ 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 };
+}
diff --git a/modules/shared/lib/info-format.ts b/modules/shared/lib/info-format.ts
new file mode 100644
index 0000000..f811b31
--- /dev/null
+++ b/modules/shared/lib/info-format.ts
@@ -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)} %`;
+}
diff --git a/modules/shared/lib/reachability.ts b/modules/shared/lib/reachability.ts
new file mode 100644
index 0000000..d3fb0b9
--- /dev/null
+++ b/modules/shared/lib/reachability.ts
@@ -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 {
+ // 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()
+ })
+}
diff --git a/modules/shared/queries/info.ts b/modules/shared/queries/info.ts
new file mode 100644
index 0000000..3c06602
--- /dev/null
+++ b/modules/shared/queries/info.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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,
+ }
+ })
+}
diff --git a/modules/shared/schemas/info.ts b/modules/shared/schemas/info.ts
new file mode 100644
index 0000000..ac35d2c
--- /dev/null
+++ b/modules/shared/schemas/info.ts
@@ -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 = {
+ website: "Webseite",
+ hosting: "Hosting",
+ email: "E-Mail",
+ seo: "SEO",
+ other: "Sonstiges",
+};
+
+export const PRODUCT_STATUS_LABELS: Record = {
+ online: "Operativ",
+ maintenance: "Wartung",
+ offline: "Ausfall",
+};
+
+export const DEFAULT_STATUS_MESSAGES: Record = {
+ online: "Erreichbar, keine Störungen",
+ maintenance: "Wartung geplant",
+ offline: "Nicht erreichbar",
+};
+
+export const TASK_STATUS_LABELS: Record = {
+ 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),
+});
diff --git a/modules/shared/ui/components/info-dialogs.tsx b/modules/shared/ui/components/info-dialogs.tsx
new file mode 100644
index 0000000..dd50f5b
--- /dev/null
+++ b/modules/shared/ui/components/info-dialogs.tsx
@@ -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;
+
+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;
+ 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 (
+
+ );
+}
+
+function FormDialog({
+ title,
+ trigger,
+ action,
+ hidden,
+ submitLabel = "Speichern",
+ children,
+}: {
+ title: string;
+ trigger: React.ReactElement;
+ action: FormAction;
+ hidden: Record;
+ 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 (
+
+ );
+}
+
+const editTrigger = (
+
+);
+
+function createTrigger(label: string) {
+ return (
+
+ );
+}
+
+export function ProductDialog({ profileId, product }: { profileId: string; product?: Product }) {
+ return (
+
+
+
+ );
+}
+
+function ProductFields({ product }: { product?: Product }) {
+ const [status, setStatus] = useState(product?.status ?? "online");
+
+ return (
+ <>
+
+
+ Produkt *
+
+
+
+ Kategorie
+
+
+
+
+ Link / Adresse
+
+
+
+
+ Status
+
+
+
+ Uptime 30 Tage (%)
+
+ Nur für nicht prüfbare Produkte. Bei Web-Adressen wird die Uptime gemessen.
+
+
+
+ Statusmeldung
+
+ Leer lassen für den Standardtext „{DEFAULT_STATUS_MESSAGES[status]}“.
+
+ >
+ );
+}
+
+export function TaskDialog({ profileId, task }: { profileId: string; task?: Task }) {
+ return (
+
+
+ Vorgang *
+
+
+
+ Beschreibung
+
+
+
+
+ Status
+
+
+
+ Seit / ab
+
+
+
+
+ „In Arbeit“ zeigt „seit …“, „Geplant“ zeigt „ab …“. „Erledigt“ blendet den Vorgang aus.
+
+
+ );
+}
+
+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(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 (
+
+ );
+}
diff --git a/modules/shared/ui/components/internal-notes.tsx b/modules/shared/ui/components/internal-notes.tsx
new file mode 100644
index 0000000..7c5bbf3
--- /dev/null
+++ b/modules/shared/ui/components/internal-notes.tsx
@@ -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 (
+
+ );
+}
+
+function NoteItem({ profileId, note }: { profileId: string; note: InternalNote }) {
+ const [editing, setEditing] = useState(false);
+
+ return (
+
+ {editing ? (
+ setEditing(false)} onCancel={() => setEditing(false)} />
+ ) : (
+
+
{note.body}
+
+
+
+
+
+ )}
+
+ Zuletzt bearbeitet von {note.editorName ?? "unbekannt"} · {formatDateTime(note.updatedAt)}
+
+
+ );
+}
+
+export function InternalNotes({ profileId, notes }: { profileId: string; notes: InternalNote[] }) {
+ const [formKey, setFormKey] = useState(0);
+
+ return (
+
+
+
Interne Notiz
+
+ Kunde sieht das nicht
+
+
+ setFormKey((key) => key + 1)} />
+ {notes.length > 0 && (
+
+ {notes.map((note) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/modules/shared/ui/components/product-check.tsx b/modules/shared/ui/components/product-check.tsx
new file mode 100644
index 0000000..f0ac62f
--- /dev/null
+++ b/modules/shared/ui/components/product-check.tsx
@@ -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(null);
+
+ function handleCheck() {
+ setError(null);
+ startTransition(async () => {
+ const response = await checkProduct(profileId, productId);
+ if ("error" in response) setError(response.error);
+ });
+ }
+
+ return (
+
+
+ {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")}
+
+
+
+ );
+}
+
+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 (
+
+ Erreichbarkeit wird geprüft...
+
+ );
+}
diff --git a/modules/shared/ui/views/info-panel.tsx b/modules/shared/ui/views/info-panel.tsx
new file mode 100644
index 0000000..36604a9
--- /dev/null
+++ b/modules/shared/ui/views/info-panel.tsx
@@ -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 = {
+ 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 –
+ return (
+ <>
+ {formatUptime(value)}
+ · 30 Tage
+ >
+ )
+}
+
+function ProductLink({ value }: { value: string }) {
+ if (/^(https?:\/\/|www\.)/i.test(value)) {
+ const href = /^https?:\/\//i.test(value) ? value : `https://${value}`
+ return (
+
+ {value}
+
+ )
+ }
+ return <>{value}>
+}
+
+function SectionHeader({ title, meta, action }: { title: React.ReactNode; meta?: React.ReactNode; action?: React.ReactNode }) {
+ return (
+
+
+
{title}
+ {meta &&
{meta}
}
+
+ {action}
+
+ )
+}
+
+function EmptyState({ children }: { children: React.ReactNode }) {
+ return (
+ {children}
+ )
+}
+
+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 (
+
+
+
Info
+
Status eurer Systeme und laufende Arbeiten auf einen Blick.
+
+
+ {!clientId ? (
+
+ {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."}
+
+ ) : (
+ <>
+
+
+ {lastUpdated && Zuletzt aktualisiert: {formatDateTime(lastUpdated)}}
+ {hasStaleChecks && }
+
+ ) : undefined
+ }
+ action={isAdmin ? : undefined}
+ />
+
+ {products.length === 0 ? (
+ Noch keine Systeme hinterlegt.
+ ) : (
+ <>
+
+
+ {banner.text}
+
+
+
+
+
+
+ Produkt
+ Status
+ Uptime
+ {isAdmin && }
+
+
+
+ {products.map((product) => (
+
+
+
+
+
+
{product.name}
+
+ {product.url ? : CATEGORY_LABELS[product.category]}
+
+
+
+
+
+
+
+ {product.statusMessage ?? DEFAULT_STATUS_MESSAGES[product.status]}
+
+ {product.checkable && (
+
+ )}
+
+
+ 0 ? `Gemessen aus ${product.checks30d} Prüfungen` : undefined}
+ >
+
+
+ {isAdmin && (
+
+
+
+ )}
+
+ ))}
+
+
+
+ >
+ )}
+
+
+
+
+ Woran wir gerade arbeiten
+ {tasks.length}
+
+ }
+ action={isAdmin ? : undefined}
+ />
+
+ {tasks.length === 0 ? (
+ Aktuell laufen keine Vorgänge.
+ ) : (
+
+ )}
+
+
+ {isAdmin &&
}
+ >
+ )}
+
+ )
+}
+
+export default InfoPanel