feat(export): CSV- und PDF-Ausgaben fuer Hosts, Kunden und Rechnungen (#010)
PDFs entstehen serverseitig mit @react-pdf/renderer, mit einfarbigem Logo, wiederholtem Spaltenkopf und Summenzeile. Alle Exporte uebernehmen die Filter der jeweiligen Ansicht. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
import { PRODUKT_TEXT, STATUS_TEXT, statusAusDatenbank } from "@/lib/anzeige";
|
||||
import { csvAntwort, csvDatei } from "@/lib/csv";
|
||||
import { formatDatum } from "@/lib/format";
|
||||
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
|
||||
import { hostListe, type HostFilter } from "@/server/queries/hostliste";
|
||||
|
||||
type ExportHost = Awaited<ReturnType<typeof hostListe>>["hosts"][number];
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** CSV-Export der Host-Tabelle; übernimmt die Filter aus der Adresse. */
|
||||
@@ -20,13 +24,48 @@ export async function GET(request: Request) {
|
||||
};
|
||||
|
||||
const { gesamt } = await hostListe({ ...filter }, new Date());
|
||||
const alle = [];
|
||||
const alle: ExportHost[] = [];
|
||||
for (let seite = 1; alle.length < gesamt; seite++) {
|
||||
const teil = await hostListe({ ...filter, seite }, new Date());
|
||||
if (teil.hosts.length === 0) break;
|
||||
alle.push(...teil.hosts);
|
||||
}
|
||||
|
||||
const datum = new Date().toISOString().slice(0, 10);
|
||||
|
||||
if (new URL(request.url).searchParams.get("format") === "pdf") {
|
||||
return pdfAntwort(
|
||||
Bericht({
|
||||
titel: "Host-Liste",
|
||||
untertitel: `${alle.length} Hosts`,
|
||||
quer: true,
|
||||
children: Tabelle({
|
||||
spalten: [
|
||||
{ titel: "Host", breite: 18, wert: (h: ExportHost) => h.hostname },
|
||||
{ titel: "Kunde", breite: 18, wert: (h) => h.kunde?.name ?? "Nicht zugeordnet" },
|
||||
{ titel: "Produkt", breite: 15, wert: (h) => PRODUKT_TEXT[h.produkt] },
|
||||
{ titel: "Status", breite: 12, wert: (h) => STATUS_TEXT[statusAusDatenbank(h.subStatus)] },
|
||||
{ titel: "Key", breite: 15, wert: (h) => h.subKey ?? "–" },
|
||||
{
|
||||
titel: "Sockets",
|
||||
breite: 8,
|
||||
wert: (h) => `${h.subLizenzierteSockets ?? "–"} / ${h.sockets ?? "–"}`,
|
||||
rechts: true,
|
||||
},
|
||||
{
|
||||
titel: "Ablauf",
|
||||
breite: 14,
|
||||
wert: (h) => (h.subNextduedate ? formatDatum(h.subNextduedate) : "–"),
|
||||
rechts: true,
|
||||
},
|
||||
],
|
||||
zeilen: alle,
|
||||
}),
|
||||
}),
|
||||
`proxledger-hosts-${datum}.pdf`,
|
||||
);
|
||||
}
|
||||
|
||||
const inhalt = csvDatei(
|
||||
[
|
||||
"Hostname",
|
||||
@@ -64,6 +103,5 @@ export async function GET(request: Request) {
|
||||
]),
|
||||
);
|
||||
|
||||
const datum = new Date().toISOString().slice(0, 10);
|
||||
return csvAntwort(inhalt, `proxledger-hosts-${datum}.csv`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { PRODUKT_TEXT, STATUS_TEXT, statusAusDatenbank } from "@/lib/anzeige";
|
||||
import { formatDatum, formatEuro } from "@/lib/format";
|
||||
import { Abschnitt, Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
|
||||
import { kundeMitDetails, laufzeitenVonKunde, summen, summenProJahr } from "@/server/queries/kunden";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Kundenunterlage als PDF: Hosts, Laufzeiten und Summen eines Kunden. */
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const kunde = await kundeMitDetails(id);
|
||||
if (!kunde) return new Response("Nicht gefunden", { status: 404 });
|
||||
|
||||
const laufzeiten = await laufzeitenVonKunde(kunde.id);
|
||||
const gesamt = summen(laufzeiten);
|
||||
const proJahr = summenProJahr(laufzeiten);
|
||||
const datum = new Date().toISOString().slice(0, 10);
|
||||
|
||||
return pdfAntwort(
|
||||
Bericht({
|
||||
titel: kunde.name,
|
||||
untertitel: `Kundennummer ${kunde.kundennummer}${kunde.ansprechpartner ? ` · ${kunde.ansprechpartner}` : ""}`,
|
||||
children: [
|
||||
Abschnitt({ titel: `Hosts (${kunde.hosts.length})` }),
|
||||
Tabelle({
|
||||
spalten: [
|
||||
{ titel: "Host", breite: 28, wert: (h: (typeof kunde.hosts)[number]) => h.hostname },
|
||||
{ titel: "Produkt", breite: 22, wert: (h) => PRODUKT_TEXT[h.produkt] },
|
||||
{ titel: "Status", breite: 16, wert: (h) => STATUS_TEXT[statusAusDatenbank(h.subStatus)] },
|
||||
{ titel: "Key", breite: 20, wert: (h) => h.subKey ?? "–" },
|
||||
{
|
||||
titel: "Ablauf",
|
||||
breite: 14,
|
||||
wert: (h) => (h.subNextduedate ? formatDatum(h.subNextduedate) : "–"),
|
||||
rechts: true,
|
||||
},
|
||||
],
|
||||
zeilen: kunde.hosts,
|
||||
}),
|
||||
|
||||
Abschnitt({ titel: `Laufzeiten (${laufzeiten.length})` }),
|
||||
Tabelle({
|
||||
spalten: [
|
||||
{
|
||||
titel: "Zeitraum",
|
||||
breite: 26,
|
||||
wert: (l: (typeof laufzeiten)[number]) => `${formatDatum(l.start)} – ${formatDatum(l.ende)}`,
|
||||
},
|
||||
{ titel: "Host", breite: 18, wert: (l) => l.hostname },
|
||||
{ titel: "Proxmox-Rechnung", breite: 18, wert: (l) => l.einkauf?.nummer ?? "–" },
|
||||
{ titel: "Einkauf", breite: 12, wert: (l) => formatEuro(l.einkauf?.nettoBetrag), rechts: true },
|
||||
{ titel: "Kundenrechnung", breite: 18, wert: (l) => l.verkauf?.nummer ?? "–" },
|
||||
{ titel: "Verkauf", breite: 8, wert: (l) => formatEuro(l.verkauf?.nettoBetrag), rechts: true },
|
||||
],
|
||||
zeilen: laufzeiten,
|
||||
summe: ["Summe", "", "", formatEuro(gesamt.einkauf), "", formatEuro(gesamt.verkauf)],
|
||||
}),
|
||||
|
||||
Abschnitt({ titel: "Summen pro Jahr" }),
|
||||
Tabelle({
|
||||
spalten: [
|
||||
{ titel: "Jahr", breite: 25, wert: (j: (typeof proJahr)[number]) => j.jahr },
|
||||
{ titel: "Einkauf netto", breite: 25, wert: (j) => formatEuro(j.einkauf), rechts: true },
|
||||
{ titel: "Verkauf netto", breite: 25, wert: (j) => formatEuro(j.verkauf), rechts: true },
|
||||
{ titel: "Marge", breite: 25, wert: (j) => formatEuro(j.marge), rechts: true },
|
||||
],
|
||||
zeilen: proJahr,
|
||||
summe: ["Gesamt", formatEuro(gesamt.einkauf), formatEuro(gesamt.verkauf), formatEuro(gesamt.marge)],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
`proxledger-${kunde.kundennummer}-${datum}.pdf`,
|
||||
);
|
||||
}
|
||||
@@ -41,10 +41,16 @@ export default async function KundenDetailSeite({ params }: { params: Promise<{
|
||||
titel={kunde.name}
|
||||
brotkrumen={[{ titel: "Kunden", href: "/kunden" }, { titel: kunde.name }]}
|
||||
aktion={
|
||||
<Link href={`/kunden/${kunde.id}/bearbeiten`} className="btn btn-sm btn-outline-primary">
|
||||
<i className="bi bi-pencil me-1" aria-hidden="true" />
|
||||
Bearbeiten
|
||||
</Link>
|
||||
<span className="d-flex gap-2">
|
||||
<a href={`/kunden/${kunde.id}/export`} className="btn btn-sm btn-outline-secondary">
|
||||
<i className="bi bi-filetype-pdf me-1" aria-hidden="true" />
|
||||
Unterlage
|
||||
</a>
|
||||
<Link href={`/kunden/${kunde.id}/bearbeiten`} className="btn btn-sm btn-outline-primary">
|
||||
<i className="bi bi-pencil me-1" aria-hidden="true" />
|
||||
Bearbeiten
|
||||
</Link>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { csvAntwort, csvDatei } from "@/lib/csv";
|
||||
import { formatDatum, formatEuro } from "@/lib/format";
|
||||
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
|
||||
import {
|
||||
kundenListe,
|
||||
offeneRechnungspunkteProKunde,
|
||||
laufzeitenVonKunde,
|
||||
summen,
|
||||
} from "@/server/queries/kunden";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Kundenübersicht als CSV oder PDF. */
|
||||
export async function GET(request: Request) {
|
||||
const format = new URL(request.url).searchParams.get("format") ?? "csv";
|
||||
const [kunden, punkte] = await Promise.all([kundenListe(), offeneRechnungspunkteProKunde()]);
|
||||
|
||||
const zeilen = await Promise.all(
|
||||
kunden.map(async (kunde) => {
|
||||
const laufzeiten = await laufzeitenVonKunde(kunde.id);
|
||||
const betraege = summen(laufzeiten);
|
||||
return {
|
||||
...kunde,
|
||||
offeneRechnungspunkte: punkte.get(kunde.id)?.offen ?? 0,
|
||||
...betraege,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const datum = new Date().toISOString().slice(0, 10);
|
||||
|
||||
if (format === "pdf") {
|
||||
return pdfAntwort(
|
||||
Bericht({
|
||||
titel: "Kundenübersicht",
|
||||
untertitel: `${zeilen.length} Kunden`,
|
||||
quer: true,
|
||||
children: Tabelle({
|
||||
spalten: [
|
||||
{ titel: "Kunde", breite: 26, wert: (z: (typeof zeilen)[number]) => z.name },
|
||||
{ titel: "Nummer", breite: 10, wert: (z) => z.kundennummer },
|
||||
{ titel: "Hosts", breite: 7, wert: (z) => String(z.hosts), rechts: true },
|
||||
{ titel: "Aktiv", breite: 7, wert: (z) => String(z.aktiv), rechts: true },
|
||||
{
|
||||
titel: "Nächster Ablauf",
|
||||
breite: 13,
|
||||
wert: (z) => (z.naechsterAblauf ? formatDatum(z.naechsterAblauf) : "–"),
|
||||
},
|
||||
{ titel: "Warnungen", breite: 9, wert: (z) => String(z.offeneAlerts), rechts: true },
|
||||
{
|
||||
titel: "Offene Rechn.",
|
||||
breite: 10,
|
||||
wert: (z) => String(z.offeneRechnungspunkte),
|
||||
rechts: true,
|
||||
},
|
||||
{ titel: "Einkauf", breite: 9, wert: (z) => formatEuro(z.einkauf), rechts: true },
|
||||
{ titel: "Marge", breite: 9, wert: (z) => formatEuro(z.marge), rechts: true },
|
||||
],
|
||||
zeilen,
|
||||
summe: [
|
||||
"Summe",
|
||||
"",
|
||||
String(zeilen.reduce((s, z) => s + z.hosts, 0)),
|
||||
String(zeilen.reduce((s, z) => s + z.aktiv, 0)),
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
formatEuro(zeilen.reduce((s, z) => s + z.einkauf, 0)),
|
||||
formatEuro(zeilen.reduce((s, z) => s + z.marge, 0)),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
`proxledger-kunden-${datum}.pdf`,
|
||||
);
|
||||
}
|
||||
|
||||
const inhalt = csvDatei(
|
||||
[
|
||||
"Kunde",
|
||||
"Kundennummer",
|
||||
"Ansprechpartner",
|
||||
"Hosts",
|
||||
"Aktiv lizenziert",
|
||||
"Nächster Ablauf",
|
||||
"Offene Warnungen",
|
||||
"Offene Rechnungspunkte",
|
||||
"Einkauf netto",
|
||||
"Verkauf netto",
|
||||
"Marge netto",
|
||||
],
|
||||
zeilen.map((z) => [
|
||||
z.name,
|
||||
z.kundennummer,
|
||||
z.ansprechpartner,
|
||||
z.hosts,
|
||||
z.aktiv,
|
||||
z.naechsterAblauf,
|
||||
z.offeneAlerts,
|
||||
z.offeneRechnungspunkte,
|
||||
z.einkauf,
|
||||
z.verkauf,
|
||||
z.marge,
|
||||
]),
|
||||
);
|
||||
|
||||
return csvAntwort(inhalt, `proxledger-kunden-${datum}.csv`);
|
||||
}
|
||||
@@ -21,10 +21,21 @@ export default async function KundenSeite() {
|
||||
rahmen="primary"
|
||||
tabelle
|
||||
werkzeuge={
|
||||
<Link href="/kunden/neu" className="btn btn-sm btn-primary">
|
||||
<i className="bi bi-plus-lg me-1" aria-hidden="true" />
|
||||
Kunde anlegen
|
||||
</Link>
|
||||
<>
|
||||
{/* Downloads: bewusst kein <Link>, sonst würde Next.js die Datei vorab laden. */}
|
||||
<a href="/kunden/export" download className="btn btn-sm btn-outline-secondary">
|
||||
<i className="bi bi-filetype-csv me-1" aria-hidden="true" />
|
||||
CSV
|
||||
</a>
|
||||
<a href="/kunden/export?format=pdf" download className="btn btn-sm btn-outline-secondary">
|
||||
<i className="bi bi-filetype-pdf me-1" aria-hidden="true" />
|
||||
PDF
|
||||
</a>
|
||||
<Link href="/kunden/neu" className="btn btn-sm btn-primary">
|
||||
<i className="bi bi-plus-lg me-1" aria-hidden="true" />
|
||||
Kunde anlegen
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="table-responsive">
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { csvAntwort, csvDatei } from "@/lib/csv";
|
||||
import { formatDatum, formatEuro } from "@/lib/format";
|
||||
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
|
||||
import { rechnungsListe, type RechnungsFilter } from "@/server/queries/rechnungen";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Rechnungsliste als CSV (Buchhaltung) oder PDF, mit den Filtern der Ansicht. */
|
||||
export async function GET(request: Request) {
|
||||
const parameter = new URL(request.url).searchParams;
|
||||
const format = parameter.get("format") ?? "csv";
|
||||
const filter: RechnungsFilter = {
|
||||
status: (parameter.get("status") as RechnungsFilter["status"]) ?? "alle",
|
||||
kunde: parameter.get("kunde") ?? undefined,
|
||||
jahr: parameter.get("jahr") ?? undefined,
|
||||
q: parameter.get("q") ?? undefined,
|
||||
};
|
||||
|
||||
const { zeilen, summen } = await rechnungsListe(filter);
|
||||
const datum = new Date().toISOString().slice(0, 10);
|
||||
const zeitraum = filter.jahr ? `Jahr ${filter.jahr}` : "alle Jahre";
|
||||
|
||||
if (format === "pdf") {
|
||||
return pdfAntwort(
|
||||
Bericht({
|
||||
titel: "Rechnungsliste",
|
||||
untertitel: `${zeilen.length} Laufzeiten · ${zeitraum}`,
|
||||
quer: true,
|
||||
children: Tabelle({
|
||||
spalten: [
|
||||
{
|
||||
titel: "Kunde",
|
||||
breite: 18,
|
||||
wert: (z: (typeof zeilen)[number]) => z.kunde?.name ?? "Nicht zugeordnet",
|
||||
},
|
||||
{ titel: "Host", breite: 13, wert: (z) => z.hostname },
|
||||
{
|
||||
titel: "Laufzeit",
|
||||
breite: 16,
|
||||
wert: (z) => `${formatDatum(z.start)} – ${formatDatum(z.ende)}`,
|
||||
},
|
||||
{ titel: "Proxmox-Rechnung", breite: 13, wert: (z) => z.einkaufNummer ?? "–" },
|
||||
{ titel: "Einkauf", breite: 9, wert: (z) => formatEuro(z.einkaufBetrag), rechts: true },
|
||||
{ titel: "Bezahlt", breite: 7, wert: (z) => (z.einkaufBezahlt ? "ja" : "nein") },
|
||||
{ titel: "Kundenrechnung", breite: 12, wert: (z) => z.verkaufNummer ?? "–" },
|
||||
{ titel: "Verkauf", breite: 6, wert: (z) => formatEuro(z.verkaufBetrag), rechts: true },
|
||||
{
|
||||
titel: "Marge",
|
||||
breite: 6,
|
||||
wert: (z) => (z.marge === null ? "–" : formatEuro(z.marge)),
|
||||
rechts: true,
|
||||
},
|
||||
],
|
||||
zeilen,
|
||||
summe: [
|
||||
"Summe",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
formatEuro(summen.einkauf),
|
||||
"",
|
||||
"",
|
||||
formatEuro(summen.verkauf),
|
||||
formatEuro(summen.marge),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
`proxledger-rechnungen-${filter.jahr ?? "alle"}-${datum}.pdf`,
|
||||
);
|
||||
}
|
||||
|
||||
const inhalt = csvDatei(
|
||||
[
|
||||
"Kunde",
|
||||
"Host",
|
||||
"Key",
|
||||
"Laufzeit von",
|
||||
"Laufzeit bis",
|
||||
"Proxmox-Rechnung",
|
||||
"Einkauf netto",
|
||||
"Proxmox bezahlt",
|
||||
"Fällig am",
|
||||
"Kundenrechnung",
|
||||
"Verkauf netto",
|
||||
"Kundenrechnung erstellt",
|
||||
"Vom Kunden bezahlt",
|
||||
"Marge netto",
|
||||
],
|
||||
zeilen.map((z) => [
|
||||
z.kunde?.name ?? "Nicht zugeordnet",
|
||||
z.hostname,
|
||||
z.key,
|
||||
z.start,
|
||||
z.ende,
|
||||
z.einkaufNummer,
|
||||
z.einkaufBetrag,
|
||||
z.einkaufBezahlt === null ? "" : z.einkaufBezahlt ? "ja" : "nein",
|
||||
z.einkaufFaelligAm,
|
||||
z.verkaufNummer,
|
||||
z.verkaufBetrag,
|
||||
z.verkaufErstellt ? "ja" : "nein",
|
||||
z.verkaufBezahlt ? "ja" : "nein",
|
||||
z.marge,
|
||||
]),
|
||||
);
|
||||
|
||||
return csvAntwort(inhalt, `proxledger-rechnungen-${filter.jahr ?? "alle"}-${datum}.csv`);
|
||||
}
|
||||
@@ -111,24 +111,40 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
||||
rahmen="primary"
|
||||
tabelle
|
||||
werkzeuge={
|
||||
<div className="btn-group btn-group-sm" role="group" aria-label="Filter">
|
||||
{(
|
||||
[
|
||||
["offen", "Offen"],
|
||||
["erledigt", "Erledigt"],
|
||||
["alle", "Alle"],
|
||||
["vorschlaege", `Vorschläge${vorschlaege ? ` (${vorschlaege})` : ""}`],
|
||||
] as const
|
||||
).map(([wert, text]) => (
|
||||
<Link
|
||||
key={wert}
|
||||
href={adresse({ status: wert })}
|
||||
className={`btn ${filter.status === wert ? "btn-primary" : "btn-outline-primary"}`}
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<a
|
||||
href={adresse({}).replace("/rechnungen", "/rechnungen/export")}
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
>
|
||||
<i className="bi bi-filetype-csv me-1" aria-hidden="true" />
|
||||
CSV
|
||||
</a>
|
||||
<a
|
||||
href={adresse({ format: "pdf" }).replace("/rechnungen", "/rechnungen/export")}
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
>
|
||||
<i className="bi bi-filetype-pdf me-1" aria-hidden="true" />
|
||||
PDF
|
||||
</a>
|
||||
<div className="btn-group btn-group-sm" role="group" aria-label="Filter">
|
||||
{(
|
||||
[
|
||||
["offen", "Offen"],
|
||||
["erledigt", "Erledigt"],
|
||||
["alle", "Alle"],
|
||||
["vorschlaege", `Vorschläge${vorschlaege ? ` (${vorschlaege})` : ""}`],
|
||||
] as const
|
||||
).map(([wert, text]) => (
|
||||
<Link
|
||||
key={wert}
|
||||
href={adresse({ status: wert })}
|
||||
className={`btn ${filter.status === wert ? "btn-primary" : "btn-outline-primary"}`}
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form className="row g-2 p-3 border-bottom" role="search">
|
||||
|
||||
Reference in New Issue
Block a user