mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 20:51:17 +00:00
added dashboard
This commit is contained in:
parent
29ea53a03d
commit
64b2c85f81
14 changed files with 1496 additions and 11 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
export default ClientDashboard
|
||||
|
|
|
|||
37
lib/supabase/migrations/20260930100000_info_section.sql
Normal file
37
lib/supabase/migrations/20260930100000_info_section.sql
Normal 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);
|
||||
51
lib/supabase/migrations/20260930120000_product_checks.sql
Normal file
51
lib/supabase/migrations/20260930120000_product_checks.sql
Normal 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;
|
||||
163
modules/shared/actions/info.ts
Normal file
163
modules/shared/actions/info.ts
Normal 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 };
|
||||
}
|
||||
108
modules/shared/actions/product-checks.ts
Normal file
108
modules/shared/actions/product-checks.ts
Normal 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 };
|
||||
}
|
||||
45
modules/shared/lib/info-format.ts
Normal file
45
modules/shared/lib/info-format.ts
Normal 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)} %`;
|
||||
}
|
||||
124
modules/shared/lib/reachability.ts
Normal file
124
modules/shared/lib/reachability.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
95
modules/shared/queries/info.ts
Normal file
95
modules/shared/queries/info.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
}
|
||||
111
modules/shared/schemas/info.ts
Normal file
111
modules/shared/schemas/info.ts
Normal 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),
|
||||
});
|
||||
302
modules/shared/ui/components/info-dialogs.tsx
Normal file
302
modules/shared/ui/components/info-dialogs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
modules/shared/ui/components/internal-notes.tsx
Normal file
105
modules/shared/ui/components/internal-notes.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
modules/shared/ui/components/product-check.tsx
Normal file
78
modules/shared/ui/components/product-check.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
269
modules/shared/ui/views/info-panel.tsx
Normal file
269
modules/shared/ui/views/info-panel.tsx
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue