v0.1.11 — PDF Report-Export mit Preset-Auswahl + Recharts-Tooltip-Fix

This commit is contained in:
Hendrik 2026-05-01 19:16:05 +02:00
parent 807911d026
commit fd118b40bf
8 changed files with 448 additions and 6 deletions

View file

@ -0,0 +1,107 @@
"use client";
import { useState } from "react";
import { FileDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
const SECTIONS = [
{ key: "summary", label: "Zusammenfassung" },
{ key: "daily", label: "Hits pro Tag (Chart)" },
{ key: "top", label: "Top Domains" },
{ key: "country", label: "Geografische Verteilung" },
{ key: "dead", label: "Tote Domains" },
{ key: "perDomain", label: "Detail pro Domain" },
{ key: "hits", label: "Letzte Aufrufe (bis 200)" },
] as const;
const PRESETS: Record<string, Record<string, boolean>> = {
basic: { summary: true, daily: true, top: true, country: true, dead: true, perDomain: false, hits: false },
detailed: { summary: true, daily: true, top: true, country: true, dead: true, perDomain: true, hits: true },
minimal: { summary: true, daily: false, top: true, country: false, dead: true, perDomain: false, hits: false },
};
export function ExportPdfButton() {
const [open, setOpen] = useState(false);
const [days, setDays] = useState(30);
const [title, setTitle] = useState("Domain-Redirect-Report");
const [sel, setSel] = useState<Record<string, boolean>>(PRESETS.basic);
function applyPreset(name: keyof typeof PRESETS) {
setSel(PRESETS[name]);
}
function build() {
const params = new URLSearchParams();
params.set("days", String(days));
params.set("title", title);
params.set("print", "1");
for (const s of SECTIONS) {
params.set(s.key, sel[s.key] ? "1" : "0");
}
return `/analytics/report?${params.toString()}`;
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm">
<FileDown className="mr-1 h-3 w-3" />
PDF Export
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>PDF-Report erstellen</DialogTitle>
<DialogDescription>Auswahl was rein soll, dann wird das Druck-Dialog geöffnet ( "Als PDF speichern").</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Vorlage</Label>
<div className="flex gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => applyPreset("minimal")}>Minimal</Button>
<Button type="button" variant="outline" size="sm" onClick={() => applyPreset("basic")}>Basic</Button>
<Button type="button" variant="outline" size="sm" onClick={() => applyPreset("detailed")}>Detailliert</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="title">Titel</Label>
<Input id="title" value={title} onChange={(e) => setTitle(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="days">Zeitraum (Tage)</Label>
<Input id="days" type="number" min={1} max={365} value={days} onChange={(e) => setDays(Math.max(1, Math.min(365, Number(e.target.value) || 30)))} />
</div>
</div>
<div className="space-y-1.5">
<Label>Inhalte</Label>
<div className="space-y-1">
{SECTIONS.map((s) => (
<label key={s.key} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={!!sel[s.key]}
onChange={(e) => setSel((cur) => ({ ...cur, [s.key]: e.target.checked }))}
/>
{s.label}
</label>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
<Button asChild>
<a href={build()} target="_blank" rel="noreferrer">Vorschau & PDF</a>
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -5,6 +5,7 @@ import { getDb } from "@/lib/db";
import { HitsLineChart } from "@/components/charts/HitsLineChart";
import { TopDomainsBarChart } from "@/components/charts/TopDomainsBarChart";
import { CountryPie } from "@/components/charts/CountryPie";
import { ExportPdfButton } from "./ExportPdfButton";
export const dynamic = "force-dynamic";
@ -47,7 +48,11 @@ export default function AnalyticsPage() {
return (
<div>
<PageHeader title="Analytics" description="Hit-Statistiken letzte 30 Tage" />
<PageHeader
title="Analytics"
description="Hit-Statistiken letzte 30 Tage"
actions={<ExportPdfButton />}
/>
<div className="space-y-4 p-8">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">

View file

@ -0,0 +1,232 @@
"use client";
import { useEffect } from "react";
import { Printer, ArrowLeft } from "lucide-react";
import Link from "next/link";
import { HitsLineChart } from "@/components/charts/HitsLineChart";
import { TopDomainsBarChart } from "@/components/charts/TopDomainsBarChart";
import { CountryPie } from "@/components/charts/CountryPie";
type Sections = {
summary: boolean;
daily: boolean;
top: boolean;
country: boolean;
perDomain: boolean;
dead: boolean;
hits: boolean;
title: string;
};
type ReportData = {
days: number;
sections: Sections;
totalDomains: number;
activeDomains: number;
totalHits: number;
uniqueIps: number;
daily: { day: string; hits: number }[];
top: { domain: string; hits: number }[];
country: { country: string; hits: number }[];
dead: { id: number; domain: string; target_url: string | null; created_at: number }[];
perDomain: { id: number; domain: string; target_url: string | null; redirect_code: number; status: string; hits_period: number; hits_total: number; last_hit: number | null }[];
recentHits: { ts: number; domain: string; country: string | null; path: string | null }[];
generatedAt: number;
};
export function ReportClient({ data, logo }: { data: ReportData; logo: React.ReactNode }) {
useEffect(() => {
const url = new URL(window.location.href);
if (url.searchParams.get("print") === "1") {
setTimeout(() => window.print(), 800);
}
}, []);
const s = data.sections;
return (
<div className="report-root">
<style>{`
@media print {
.no-print { display: none !important; }
.report-root { background: white !important; color: #000 !important; }
aside, [data-component="UpdateBanner"] { display: none !important; }
.report-section { page-break-inside: avoid; }
.report-page-break { page-break-after: always; }
html, body { background: white !important; }
}
@page { margin: 1.5cm; }
`}</style>
{/* Toolbar (only visible on screen) */}
<div className="no-print sticky top-0 z-10 flex items-center justify-between border-b border-zinc-800/70 bg-zinc-950/95 px-6 py-3 backdrop-blur">
<Link href="/analytics" className="flex items-center gap-2 text-sm text-muted-foreground hover:text-zinc-100">
<ArrowLeft className="h-4 w-4" /> Zurück
</Link>
<button onClick={() => window.print()} className="flex items-center gap-2 rounded-md bg-cyan-500 px-4 py-2 text-sm font-medium text-zinc-950 hover:bg-cyan-400">
<Printer className="h-4 w-4" /> Als PDF speichern
</button>
</div>
<div className="mx-auto max-w-4xl space-y-6 p-8">
{/* Cover */}
<header className="report-section flex items-center gap-4 border-b border-zinc-800/70 pb-6">
<div>{logo}</div>
<div className="flex-1">
<h1 className="text-3xl font-bold tracking-tight">{s.title}</h1>
<p className="text-sm text-muted-foreground">
Zeitraum: letzte {data.days} Tage Erstellt: {new Date(data.generatedAt).toLocaleString("de-DE")}
</p>
</div>
</header>
{s.summary && (
<section className="report-section space-y-3">
<h2 className="text-xl font-semibold">Zusammenfassung</h2>
<div className="grid grid-cols-4 gap-3">
<Stat label="Domains" value={data.totalDomains} sub={`${data.activeDomains} aktiv`} />
<Stat label="Hits gesamt" value={data.totalHits} sub={`über ${data.days} Tage`} />
<Stat label="Eindeutige Besucher" value={data.uniqueIps} sub="(IP-Hashes)" />
<Stat label="Ø Hits/Tag" value={Math.round(data.totalHits / data.days)} />
</div>
</section>
)}
{s.daily && (
<section className="report-section space-y-2">
<h2 className="text-xl font-semibold">Hits pro Tag</h2>
<div className="rounded-md border border-zinc-800/70 bg-zinc-900/30 p-4">
<HitsLineChart data={data.daily} />
</div>
</section>
)}
{s.top && data.top.length > 0 && (
<section className="report-section space-y-2">
<h2 className="text-xl font-semibold">Top Domains</h2>
<div className="rounded-md border border-zinc-800/70 bg-zinc-900/30 p-4">
<TopDomainsBarChart data={data.top.slice(0, 15)} />
</div>
<table className="w-full text-sm">
<thead className="text-xs uppercase tracking-wider text-muted-foreground">
<tr><th className="px-3 py-2 text-left">Domain</th><th className="px-3 py-2 text-right">Hits</th><th className="px-3 py-2 text-right">% gesamt</th></tr>
</thead>
<tbody className="divide-y divide-zinc-800/70">
{data.top.slice(0, 30).map((r) => (
<tr key={r.domain}>
<td className="px-3 py-1.5 font-mono text-xs">{r.domain}</td>
<td className="px-3 py-1.5 text-right tabular-nums">{r.hits.toLocaleString("de-DE")}</td>
<td className="px-3 py-1.5 text-right tabular-nums text-muted-foreground">{data.totalHits ? ((r.hits / data.totalHits) * 100).toFixed(1) : "0"}%</td>
</tr>
))}
</tbody>
</table>
</section>
)}
{s.country && data.country.length > 0 && (
<section className="report-section space-y-2">
<h2 className="text-xl font-semibold">Geografische Verteilung</h2>
<div className="grid grid-cols-2 gap-4">
<div className="rounded-md border border-zinc-800/70 bg-zinc-900/30 p-4">
<CountryPie data={data.country.slice(0, 8)} />
</div>
<table className="w-full text-sm">
<thead className="text-xs uppercase tracking-wider text-muted-foreground"><tr><th className="px-3 py-2 text-left">Land</th><th className="px-3 py-2 text-right">Hits</th></tr></thead>
<tbody className="divide-y divide-zinc-800/70">
{data.country.map((c) => (
<tr key={c.country}><td className="px-3 py-1.5 font-mono">{c.country}</td><td className="px-3 py-1.5 text-right tabular-nums">{c.hits.toLocaleString("de-DE")}</td></tr>
))}
</tbody>
</table>
</div>
</section>
)}
{s.dead && data.dead.length > 0 && (
<section className="report-section space-y-2">
<h2 className="text-xl font-semibold">Tote Domains</h2>
<p className="text-xs text-muted-foreground">Aktive Domains ohne Hits in den letzten 90 Tagen Kandidaten zum Kündigen.</p>
<table className="w-full text-sm">
<thead className="text-xs uppercase tracking-wider text-muted-foreground"><tr><th className="px-3 py-2 text-left">Domain</th><th className="px-3 py-2 text-left">Ziel</th><th className="px-3 py-2 text-left">Angelegt</th></tr></thead>
<tbody className="divide-y divide-zinc-800/70">
{data.dead.map((d) => (
<tr key={d.id}>
<td className="px-3 py-1.5 font-mono">{d.domain}</td>
<td className="px-3 py-1.5 font-mono text-xs text-muted-foreground">{d.target_url || "—"}</td>
<td className="px-3 py-1.5 text-xs text-muted-foreground">{new Date(d.created_at).toLocaleDateString("de-DE")}</td>
</tr>
))}
</tbody>
</table>
</section>
)}
{s.perDomain && data.perDomain.length > 0 && (
<section className="report-section space-y-2">
<h2 className="text-xl font-semibold">Detailbericht pro Domain</h2>
<table className="w-full text-sm">
<thead className="text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="px-3 py-2 text-left">Domain</th>
<th className="px-3 py-2 text-left">Status</th>
<th className="px-3 py-2 text-left">Code</th>
<th className="px-3 py-2 text-left">Ziel</th>
<th className="px-3 py-2 text-right">Hits ({data.days}d)</th>
<th className="px-3 py-2 text-right">Hits gesamt</th>
<th className="px-3 py-2 text-left">Letzter Hit</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800/70">
{data.perDomain.map((d) => (
<tr key={d.id}>
<td className="px-3 py-1.5 font-mono text-xs">{d.domain}</td>
<td className="px-3 py-1.5 text-xs">{d.status}</td>
<td className="px-3 py-1.5 text-xs">{d.redirect_code}</td>
<td className="px-3 py-1.5 font-mono text-[11px] text-muted-foreground">{d.target_url || "—"}</td>
<td className="px-3 py-1.5 text-right tabular-nums">{d.hits_period.toLocaleString("de-DE")}</td>
<td className="px-3 py-1.5 text-right tabular-nums">{d.hits_total.toLocaleString("de-DE")}</td>
<td className="px-3 py-1.5 text-xs text-muted-foreground">{d.last_hit ? new Date(d.last_hit).toLocaleString("de-DE") : "—"}</td>
</tr>
))}
</tbody>
</table>
</section>
)}
{s.hits && data.recentHits.length > 0 && (
<section className="report-section space-y-2">
<h2 className="text-xl font-semibold">Letzte Aufrufe</h2>
<p className="text-xs text-muted-foreground">Bis zu 200 jüngste Hits im Berichts-Zeitraum.</p>
<table className="w-full text-xs">
<thead className="text-[10px] uppercase tracking-wider text-muted-foreground"><tr><th className="px-2 py-1.5 text-left">Zeit</th><th className="px-2 py-1.5 text-left">Domain</th><th className="px-2 py-1.5 text-left">Land</th><th className="px-2 py-1.5 text-left">Pfad</th></tr></thead>
<tbody className="divide-y divide-zinc-800/40">
{data.recentHits.map((h, i) => (
<tr key={i}>
<td className="px-2 py-1 font-mono">{new Date(h.ts).toLocaleString("de-DE")}</td>
<td className="px-2 py-1 font-mono">{h.domain}</td>
<td className="px-2 py-1">{h.country || "—"}</td>
<td className="px-2 py-1 font-mono text-muted-foreground">{(h.path || "/").slice(0, 60)}</td>
</tr>
))}
</tbody>
</table>
</section>
)}
<footer className="border-t border-zinc-800/70 pt-3 text-xs text-muted-foreground">
Erstellt mit CoreX NexRedirect
</footer>
</div>
</div>
);
}
function Stat({ label, value, sub }: { label: string; value: number; sub?: string }) {
return (
<div className="rounded-md border border-zinc-800/70 bg-zinc-900/30 p-3">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">{label}</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{value.toLocaleString("de-DE")}</p>
{sub && <p className="text-[11px] text-muted-foreground">{sub}</p>}
</div>
);
}

View file

@ -0,0 +1,85 @@
import { notFound } from "next/navigation";
import { getDb } from "@/lib/db";
import { Logo } from "@/components/Logo";
import { ReportClient } from "./ReportClient";
export const dynamic = "force-dynamic";
type SP = { [k: string]: string | string[] | undefined };
function parseDays(sp: SP): number {
const v = sp.days;
const s = Array.isArray(v) ? v[0] : v;
const n = Number(s || 30);
return Number.isFinite(n) && n > 0 ? Math.min(365, n) : 30;
}
function flag(sp: SP, key: string, def = true): boolean {
const v = sp[key];
const s = Array.isArray(v) ? v[0] : v;
if (s === undefined) return def;
return s === "1" || s === "true";
}
export default async function ReportPage({ searchParams }: { searchParams: Promise<SP> }) {
const sp = await searchParams;
const days = parseDays(sp);
const since = Date.now() - days * 24 * 60 * 60 * 1000;
const db = getDb();
const sections = {
summary: flag(sp, "summary"),
daily: flag(sp, "daily"),
top: flag(sp, "top"),
country: flag(sp, "country"),
perDomain: flag(sp, "perDomain", false),
dead: flag(sp, "dead"),
hits: flag(sp, "hits", false),
title: typeof sp.title === "string" ? sp.title : "Domain-Redirect-Report",
};
const totalDomains = (db.prepare("SELECT COUNT(*) AS n FROM domains").get() as { n: number }).n;
const activeDomains = (db.prepare("SELECT COUNT(*) AS n FROM domains WHERE status='active'").get() as { n: number }).n;
const totalHits = (db.prepare("SELECT COUNT(*) AS n FROM hits WHERE ts > ?").get(since) as { n: number }).n;
const uniqueIps = (db.prepare("SELECT COUNT(DISTINCT ip_hash) AS n FROM hits WHERE ts > ?").get(since) as { n: number }).n;
const daily = db.prepare(`SELECT strftime('%Y-%m-%d', ts/1000, 'unixepoch') AS day, COUNT(*) AS hits FROM hits WHERE ts > ? GROUP BY day ORDER BY day`).all(since) as { day: string; hits: number }[];
const top = db.prepare(`SELECT d.domain, COUNT(h.id) AS hits FROM hits h JOIN domains d ON d.id = h.domain_id WHERE h.ts > ? GROUP BY d.domain ORDER BY hits DESC`).all(since) as { domain: string; hits: number }[];
const country = db.prepare(`SELECT COALESCE(country,'??') AS country, COUNT(*) AS hits FROM hits WHERE ts > ? GROUP BY country ORDER BY hits DESC`).all(since) as { country: string; hits: number }[];
const dead = db.prepare(`SELECT d.id, d.domain, d.target_url, d.created_at FROM domains d WHERE d.status='active' AND NOT EXISTS (SELECT 1 FROM hits h WHERE h.domain_id = d.id AND h.ts > ?) ORDER BY d.created_at`).all(Date.now() - 90*24*60*60*1000) as { id: number; domain: string; target_url: string | null; created_at: number }[];
const perDomain = sections.perDomain
? (db.prepare(`SELECT d.id, d.domain, d.target_url, d.redirect_code, d.status,
(SELECT COUNT(*) FROM hits h WHERE h.domain_id = d.id AND h.ts > ?) AS hits_period,
(SELECT COUNT(*) FROM hits h WHERE h.domain_id = d.id) AS hits_total,
(SELECT MAX(ts) FROM hits h WHERE h.domain_id = d.id) AS last_hit
FROM domains d ORDER BY hits_period DESC, d.domain`).all(since) as { id: number; domain: string; target_url: string | null; redirect_code: number; status: string; hits_period: number; hits_total: number; last_hit: number | null }[])
: [];
const recentHits = sections.hits
? (db.prepare(`SELECT h.ts, d.domain, h.country, h.path FROM hits h JOIN domains d ON d.id = h.domain_id WHERE h.ts > ? ORDER BY h.ts DESC LIMIT 200`).all(since) as { ts: number; domain: string; country: string | null; path: string | null }[])
: [];
if (!totalDomains) notFound();
return (
<ReportClient
data={{
days,
sections,
totalDomains,
activeDomains,
totalHits,
uniqueIps,
daily,
top,
country,
dead,
perDomain,
recentHits,
generatedAt: Date.now(),
}}
logo={<Logo size={48} />}
/>
);
}

View file

@ -11,8 +11,13 @@ export function CountryPie({ data }: { data: { country: string; hits: number }[]
<Pie data={data} dataKey="hits" nameKey="country" outerRadius={100} label={({ country }) => country}>
{data.map((_, i) => <Cell key={i} fill={COLORS[i % COLORS.length]} />)}
</Pie>
<Tooltip contentStyle={{ background: "#18181b", border: "1px solid #3f3f46", borderRadius: 8, fontSize: 12 }} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Tooltip
contentStyle={{ background: "#18181b", border: "1px solid #3f3f46", borderRadius: 8, fontSize: 12, color: "#e5e7eb" }}
itemStyle={{ color: "#e5e7eb" }}
labelStyle={{ color: "#a1a1aa" }}
formatter={(value: number, name: string) => [value.toLocaleString("de-DE"), name]}
/>
<Legend wrapperStyle={{ fontSize: 11, color: "#e5e7eb" }} />
</PieChart>
</ResponsiveContainer>
);

View file

@ -11,7 +11,11 @@ export function HitsLineChart({ data }: { data: { day: string; hits: number }[]
<CartesianGrid strokeDasharray="3 3" stroke="rgba(82,95,122,0.2)" />
<XAxis dataKey="day" tick={{ fontSize: 10, fill: "#a1a1aa" }} />
<YAxis tick={{ fontSize: 10, fill: "#a1a1aa" }} />
<Tooltip contentStyle={{ background: "#18181b", border: "1px solid #3f3f46", borderRadius: 8, fontSize: 12 }} />
<Tooltip
contentStyle={{ background: "#18181b", border: "1px solid #3f3f46", borderRadius: 8, fontSize: 12, color: "#e5e7eb" }}
itemStyle={{ color: "#e5e7eb" }}
labelStyle={{ color: "#a1a1aa" }}
/>
<Line type="monotone" dataKey="hits" stroke="#22d3ee" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>

View file

@ -11,7 +11,11 @@ export function TopDomainsBarChart({ data }: { data: { domain: string; hits: num
<CartesianGrid strokeDasharray="3 3" stroke="rgba(82,95,122,0.2)" />
<XAxis type="number" tick={{ fontSize: 10, fill: "#a1a1aa" }} />
<YAxis type="category" dataKey="domain" tick={{ fontSize: 10, fill: "#a1a1aa" }} width={120} />
<Tooltip contentStyle={{ background: "#18181b", border: "1px solid #3f3f46", borderRadius: 8, fontSize: 12 }} />
<Tooltip
contentStyle={{ background: "#18181b", border: "1px solid #3f3f46", borderRadius: 8, fontSize: 12, color: "#e5e7eb" }}
itemStyle={{ color: "#e5e7eb" }}
labelStyle={{ color: "#a1a1aa" }}
/>
<Bar dataKey="hits" fill="#34d399" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>

View file

@ -1,6 +1,6 @@
{
"name": "corex-nexredirect",
"version": "0.1.10",
"version": "0.1.11",
"license": "MIT",
"overrides": {
"postcss": "^8.5.13",