diff --git a/app/(app)/analytics/ExportPdfButton.tsx b/app/(app)/analytics/ExportPdfButton.tsx new file mode 100644 index 0000000..3d5d599 --- /dev/null +++ b/app/(app)/analytics/ExportPdfButton.tsx @@ -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> = { + 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>(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 ( + + + + + + + PDF-Report erstellen + Auswahl was rein soll, dann wird das Druck-Dialog geöffnet (→ "Als PDF speichern"). + + +
+
+ +
+ + + +
+
+ +
+
+ + setTitle(e.target.value)} /> +
+
+ + setDays(Math.max(1, Math.min(365, Number(e.target.value) || 30)))} /> +
+
+ +
+ +
+ {SECTIONS.map((s) => ( + + ))} +
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/app/(app)/analytics/page.tsx b/app/(app)/analytics/page.tsx index f698d96..615a381 100644 --- a/app/(app)/analytics/page.tsx +++ b/app/(app)/analytics/page.tsx @@ -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 (
- + } + />
diff --git a/app/(app)/analytics/report/ReportClient.tsx b/app/(app)/analytics/report/ReportClient.tsx new file mode 100644 index 0000000..a18ba9a --- /dev/null +++ b/app/(app)/analytics/report/ReportClient.tsx @@ -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 ( +
+ + + {/* Toolbar (only visible on screen) */} +
+ + Zurück + + +
+ +
+ {/* Cover */} +
+
{logo}
+
+

{s.title}

+

+ Zeitraum: letzte {data.days} Tage • Erstellt: {new Date(data.generatedAt).toLocaleString("de-DE")} +

+
+
+ + {s.summary && ( +
+

Zusammenfassung

+
+ + + + +
+
+ )} + + {s.daily && ( +
+

Hits pro Tag

+
+ +
+
+ )} + + {s.top && data.top.length > 0 && ( +
+

Top Domains

+
+ +
+ + + + + + {data.top.slice(0, 30).map((r) => ( + + + + + + ))} + +
DomainHits% gesamt
{r.domain}{r.hits.toLocaleString("de-DE")}{data.totalHits ? ((r.hits / data.totalHits) * 100).toFixed(1) : "0"}%
+
+ )} + + {s.country && data.country.length > 0 && ( +
+

Geografische Verteilung

+
+
+ +
+ + + + {data.country.map((c) => ( + + ))} + +
LandHits
{c.country}{c.hits.toLocaleString("de-DE")}
+
+
+ )} + + {s.dead && data.dead.length > 0 && ( +
+

Tote Domains

+

Aktive Domains ohne Hits in den letzten 90 Tagen — Kandidaten zum Kündigen.

+ + + + {data.dead.map((d) => ( + + + + + + ))} + +
DomainZielAngelegt
{d.domain}{d.target_url || "—"}{new Date(d.created_at).toLocaleDateString("de-DE")}
+
+ )} + + {s.perDomain && data.perDomain.length > 0 && ( +
+

Detailbericht pro Domain

+ + + + + + + + + + + + + + {data.perDomain.map((d) => ( + + + + + + + + + + ))} + +
DomainStatusCodeZielHits ({data.days}d)Hits gesamtLetzter Hit
{d.domain}{d.status}{d.redirect_code}{d.target_url || "—"}{d.hits_period.toLocaleString("de-DE")}{d.hits_total.toLocaleString("de-DE")}{d.last_hit ? new Date(d.last_hit).toLocaleString("de-DE") : "—"}
+
+ )} + + {s.hits && data.recentHits.length > 0 && ( +
+

Letzte Aufrufe

+

Bis zu 200 jüngste Hits im Berichts-Zeitraum.

+ + + + {data.recentHits.map((h, i) => ( + + + + + + + ))} + +
ZeitDomainLandPfad
{new Date(h.ts).toLocaleString("de-DE")}{h.domain}{h.country || "—"}{(h.path || "/").slice(0, 60)}
+
+ )} + +
+ Erstellt mit CoreX NexRedirect +
+
+
+ ); +} + +function Stat({ label, value, sub }: { label: string; value: number; sub?: string }) { + return ( +
+

{label}

+

{value.toLocaleString("de-DE")}

+ {sub &&

{sub}

} +
+ ); +} diff --git a/app/(app)/analytics/report/page.tsx b/app/(app)/analytics/report/page.tsx new file mode 100644 index 0000000..41d7a25 --- /dev/null +++ b/app/(app)/analytics/report/page.tsx @@ -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 }) { + 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 ( + } + /> + ); +} diff --git a/components/charts/CountryPie.tsx b/components/charts/CountryPie.tsx index 37e03b1..2562ca8 100644 --- a/components/charts/CountryPie.tsx +++ b/components/charts/CountryPie.tsx @@ -11,8 +11,13 @@ export function CountryPie({ data }: { data: { country: string; hits: number }[] country}> {data.map((_, i) => )} - - + [value.toLocaleString("de-DE"), name]} + /> + ); diff --git a/components/charts/HitsLineChart.tsx b/components/charts/HitsLineChart.tsx index 06ab7e5..1f3c292 100644 --- a/components/charts/HitsLineChart.tsx +++ b/components/charts/HitsLineChart.tsx @@ -11,7 +11,11 @@ export function HitsLineChart({ data }: { data: { day: string; hits: number }[] - + diff --git a/components/charts/TopDomainsBarChart.tsx b/components/charts/TopDomainsBarChart.tsx index 8a84378..61407bf 100644 --- a/components/charts/TopDomainsBarChart.tsx +++ b/components/charts/TopDomainsBarChart.tsx @@ -11,7 +11,11 @@ export function TopDomainsBarChart({ data }: { data: { domain: string; hits: num - + diff --git a/package.json b/package.json index 4ccce5f..dc309d7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "corex-nexredirect", - "version": "0.1.10", + "version": "0.1.11", "license": "MIT", "overrides": { "postcss": "^8.5.13",