From 64b2c85f81a4d64d11606d0b2ea5e93d78cde69e Mon Sep 17 00:00:00 2001 From: Rajpreet0 Date: Tue, 29 Sep 2026 15:20:50 +0200 Subject: [PATCH] added dashboard --- app/admin/clients/[id]/page.tsx | 8 +- app/client/dashboard/page.tsx | 11 +- .../20260930100000_info_section.sql | 37 +++ .../20260930120000_product_checks.sql | 51 +++ modules/shared/actions/info.ts | 163 ++++++++++ modules/shared/actions/product-checks.ts | 108 +++++++ modules/shared/lib/info-format.ts | 45 +++ modules/shared/lib/reachability.ts | 124 +++++++ modules/shared/queries/info.ts | 95 ++++++ modules/shared/schemas/info.ts | 111 +++++++ modules/shared/ui/components/info-dialogs.tsx | 302 ++++++++++++++++++ .../shared/ui/components/internal-notes.tsx | 105 ++++++ .../shared/ui/components/product-check.tsx | 78 +++++ modules/shared/ui/views/info-panel.tsx | 269 ++++++++++++++++ 14 files changed, 1496 insertions(+), 11 deletions(-) create mode 100644 lib/supabase/migrations/20260930100000_info_section.sql create mode 100644 lib/supabase/migrations/20260930120000_product_checks.sql create mode 100644 modules/shared/actions/info.ts create mode 100644 modules/shared/actions/product-checks.ts create mode 100644 modules/shared/lib/info-format.ts create mode 100644 modules/shared/lib/reachability.ts create mode 100644 modules/shared/queries/info.ts create mode 100644 modules/shared/schemas/info.ts create mode 100644 modules/shared/ui/components/info-dialogs.tsx create mode 100644 modules/shared/ui/components/internal-notes.tsx create mode 100644 modules/shared/ui/components/product-check.tsx create mode 100644 modules/shared/ui/views/info-panel.tsx 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 ( +
+ {Object.entries(hidden).map(([name, value]) => ( + + ))} + + {children} + {state && "error" in state &&

{state.error}

} +
+ + + +
+ ); +} + +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 ( + + + + + {title} + + + + + ); +} + +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 ( + + ); +} + +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 ( + + }> + + + + + Eintrag löschen + + Möchtest du {label} wirklich löschen? + Diese Aktion kann nicht rückgängig gemacht werden. + + + {error &&

{error}

} + + + + +
+
+ ); +} 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 ( +
+ + +