mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 20:51:17 +00:00
Merge pull request #7 from Since-3/feature/added-dashboard
added dashboard
This commit is contained in:
commit
89ab4a713a
14 changed files with 1496 additions and 11 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { createAdminClient } from "@/lib/supabase/admin";
|
import { createAdminClient } from "@/lib/supabase/admin";
|
||||||
import { Params } from "@/modules/shared/types/user";
|
import { Params } from "@/modules/shared/types/user";
|
||||||
|
import InfoPanel from "@/modules/shared/ui/views/info-panel";
|
||||||
|
|
||||||
|
|
||||||
const ClientDetailPage = async ({ params }: { params: Params }) => {
|
const ClientDetailPage = async ({ params }: { params: Params }) => {
|
||||||
|
|
@ -15,12 +16,7 @@ const ClientDetailPage = async ({ params }: { params: Params }) => {
|
||||||
|
|
||||||
if (!client) notFound();
|
if (!client) notFound();
|
||||||
|
|
||||||
return (
|
return <InfoPanel profileId={client.id} isAdmin />
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ClientDetailPage
|
export default ClientDetailPage
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
|
import { verifySession } from "@/lib/supabase/dal"
|
||||||
|
import InfoPanel from "@/modules/shared/ui/views/info-panel"
|
||||||
|
|
||||||
const ClientDashboard = () => {
|
const ClientDashboard = async () => {
|
||||||
return (
|
const session = await verifySession()
|
||||||
<div>page</div>
|
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