Files
ProxLedger/src/app/(dashboard)/rechnungen/page.tsx
T
florian.wildeandClaude Opus 5 335fc47496 fix(ui): Zugang zur Laufzeit auf dem Telefon (#039)
Im Reiter Rechnungen der Host-Detailansicht war die Laufzeit nie verlinkt,
und in der Rechnungsliste ist diese Spalte erst ab 992 px sichtbar. Auf dem
Telefon fuehrte damit kein Weg zu den Rechnungsformularen.

Die Laufzeit ist jetzt ein Link und zugleich Ueberschrift der Karte; in der
Liste bleibt sie auf dem Telefon sichtbar und entfaellt nur zwischen 768 und
992 px. Dafuer braucht es eine eigene Klasse: Bootstraps d-*-Hilfsklassen
setzen display mit !important und ueberschreiben das Kartenlayout.

Nebenbefund: Auch dieser Reiter rechnete den Einkauf ohne Rabatt - dritte
Stelle nach #037 und #038.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 17:08:54 +02:00

338 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Link from "next/link";
import { ContentHeader } from "@/components/layout/ContentHeader";
import { StatusHaken } from "@/components/rechnungen/StatusHaken";
import { Badge } from "@/components/ui/Badges";
import { Callout } from "@/components/ui/Callout";
import { Card } from "@/components/ui/Card";
import { CopyableCode } from "@/components/ui/CopyableCode";
import { InfoBox } from "@/components/ui/SmallBox";
import { prisma } from "@/lib/db";
import { formatDatum, formatEuro, tageBis } from "@/lib/format";
import {
anzahlVorschlaege,
laufzeitJahre,
rechnungsListe,
type RechnungsFilter,
} from "@/server/queries/rechnungen";
export const metadata = { title: "Rechnungen" };
type SuchParameter = Promise<Record<string, string | string[] | undefined>>;
function einzeln(wert: string | string[] | undefined): string | undefined {
const text = Array.isArray(wert) ? wert[0] : wert;
return text && text.length > 0 ? text : undefined;
}
export default async function RechnungenSeite({ searchParams }: { searchParams: SuchParameter }) {
const roh = await searchParams;
const filter: RechnungsFilter = {
status: (einzeln(roh.status) as RechnungsFilter["status"]) ?? "offen",
kunde: einzeln(roh.kunde),
jahr: einzeln(roh.jahr),
q: einzeln(roh.q),
};
const [{ zeilen, summen }, jahre, kunden, vorschlaege] = await Promise.all([
rechnungsListe(filter),
laufzeitJahre(),
prisma.kunde.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
anzahlVorschlaege(),
]);
const heute = new Date();
function adresse(aenderung: Record<string, string | undefined>) {
const params = new URLSearchParams();
for (const [schluessel, wert] of Object.entries({ ...filter, ...aenderung })) {
if (wert) params.set(schluessel, String(wert));
}
const text = params.toString();
return text ? `/rechnungen?${text}` : "/rechnungen";
}
return (
<>
<ContentHeader titel="Rechnungen" brotkrumen={[{ titel: "Rechnungen" }]} />
<div className="app-content">
<div className="container-fluid">
{vorschlaege > 0 && filter.status !== "vorschlaege" ? (
<Callout
titel={`${vorschlaege} vorgeschlagene Laufzeit${vorschlaege === 1 ? "" : "en"}`}
farbe="info"
aktion={
<Link href={adresse({ status: "vorschlaege" })} className="btn btn-primary pl-touch-target">
Ansehen
</Link>
}
>
Die Agents haben neue Ablaufdaten gemeldet. Bestätige die Laufzeit und trage die Rechnungsdaten
ein.
</Callout>
) : null}
<p className="small text-body-secondary mb-2">
Summen beziehen sich auf die aktuelle Auswahl ({zeilen.length} Laufzeiten).
</p>
<div className="row">
<div className="col-6 col-lg-3">
<InfoBox
text="Einkauf netto"
wert={formatEuro(summen.einkauf)}
icon="bi-cart"
farbe="neutral"
/>
</div>
<div className="col-6 col-lg-3">
<InfoBox
text="Verkauf netto"
wert={formatEuro(summen.verkauf)}
icon="bi-receipt"
farbe="primary"
/>
</div>
<div className="col-6 col-lg-3">
<InfoBox
text="Marge"
wert={formatEuro(summen.marge)}
icon="bi-graph-up-arrow"
farbe="success"
/>
</div>
<div className="col-6 col-lg-3">
<InfoBox text="Laufzeiten" wert={String(zeilen.length)} icon="bi-calendar-range" farbe="info" />
</div>
</div>
<Card
titel="Laufzeiten und Rechnungen"
rahmen="primary"
tabelle
werkzeuge={
<>
<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">
<input type="hidden" name="status" value={filter.status} />
<div className="col-md-4">
<input
name="q"
type="search"
defaultValue={filter.q ?? ""}
className="form-control"
placeholder="Rechnungsnummer, Key oder Host …"
aria-label="Suche"
/>
</div>
<div className="col-6 col-md-3">
<select
name="kunde"
defaultValue={filter.kunde ?? ""}
className="form-select"
aria-label="Kunde"
>
<option value="">Alle Kunden</option>
{kunden.map((kunde) => (
<option key={kunde.id} value={kunde.id}>
{kunde.name}
</option>
))}
</select>
</div>
<div className="col-6 col-md-2">
<select
name="jahr"
defaultValue={filter.jahr ?? ""}
className="form-select"
aria-label="Jahr"
>
<option value="">Alle Jahre</option>
{jahre.map((jahr) => (
<option key={jahr} value={jahr}>
{jahr}
</option>
))}
</select>
</div>
<div className="col-md-2">
<button type="submit" className="btn btn-primary w-100">
Filtern
</button>
</div>
</form>
<div className="table-responsive">
<table className="table table-striped align-middle pl-karten-tabelle">
<thead>
<tr>
<th scope="col">Kunde</th>
<th scope="col" className="d-none d-md-table-cell">
Host
</th>
<th scope="col" className="pl-spalte-laufzeit">
Laufzeit
</th>
<th scope="col">Proxmox-Rechnung</th>
<th scope="col" className="d-none d-xl-table-cell">
Netto
</th>
<th scope="col" className="text-center">
Bezahlt
</th>
<th scope="col">Kundenrechnung</th>
<th scope="col" className="d-none d-xl-table-cell">
Netto
</th>
<th scope="col" className="text-center">
Erstellt
</th>
<th scope="col" className="text-center">
Bezahlt
</th>
<th scope="col" className="d-none d-xl-table-cell">
Marge
</th>
</tr>
</thead>
<tbody>
{zeilen.map((zeile) => {
const faelligIn = tageBis(zeile.einkaufFaelligAm, heute);
return (
<tr key={zeile.id}>
{/* Auf dem Telefon steht der Host als Überschrift der Karte, sonst als Spalte. */}
<td className="d-md-none pl-karten-kopf">
<Link href={`/hosts/${zeile.hostId}`} className="fw-semibold text-decoration-none">
{zeile.hostname}
</Link>
</td>
<td data-label="Kunde">
{zeile.kunde ? (
<Link href={`/kunden/${zeile.kunde.id}`} className="text-decoration-none">
{zeile.kunde.name}
</Link>
) : (
<span className="badge pl-bg-neutral">Nicht zugeordnet</span>
)}
</td>
<td className="d-none d-md-table-cell">
<Link href={`/hosts/${zeile.hostId}`} className="text-decoration-none">
{zeile.hostname}
</Link>
</td>
<td className="pl-spalte-laufzeit" data-label="Laufzeit">
<Link href={`/rechnungen/${zeile.id}`} className="text-decoration-none">
{formatDatum(zeile.start)} – {formatDatum(zeile.ende)}
</Link>
{zeile.vorschlag ? <Badge farbe="info">Vorschlag</Badge> : null}
</td>
<td data-label="Proxmox-Rechnung">
<CopyableCode wert={zeile.einkaufNummer} />
{zeile.einkaufBezahlt === false && faelligIn !== null && faelligIn <= 7 ? (
<div className="small pl-text-danger">
fällig {formatDatum(zeile.einkaufFaelligAm)}
</div>
) : null}
</td>
<td className="d-none d-xl-table-cell" data-label="Einkauf">
{formatEuro(zeile.einkaufBetrag)}
</td>
<td className="text-center" data-label="Einkauf bezahlt">
<StatusHaken
laufzeitId={zeile.id}
hostId={zeile.hostId}
feld="einkaufBezahlt"
wert={zeile.einkaufBezahlt === true}
deaktiviert={zeile.einkaufBezahlt === null}
titel="Proxmox-Rechnung bezahlt"
/>
</td>
<td data-label="Kundenrechnung">
<CopyableCode wert={zeile.verkaufNummer} />
</td>
<td className="d-none d-xl-table-cell" data-label="Verkauf">
{formatEuro(zeile.verkaufBetrag)}
</td>
<td className="text-center" data-label="Rechnung erstellt">
<StatusHaken
laufzeitId={zeile.id}
hostId={zeile.hostId}
feld="verkaufErstellt"
wert={zeile.verkaufErstellt}
titel="Kundenrechnung erstellt"
/>
</td>
<td className="text-center" data-label="Vom Kunden bezahlt">
<StatusHaken
laufzeitId={zeile.id}
hostId={zeile.hostId}
feld="verkaufBezahlt"
wert={zeile.verkaufBezahlt}
deaktiviert={!zeile.verkaufErstellt}
titel="Kundenrechnung vom Kunden bezahlt"
/>
</td>
<td
data-label="Marge"
className={`d-none d-xl-table-cell ${(zeile.marge ?? 0) >= 0 ? "pl-text-success" : "pl-text-danger"}`}
>
{zeile.marge === null ? "–" : formatEuro(zeile.marge)}
</td>
</tr>
);
})}
{zeilen.length === 0 ? (
<tr>
<td colSpan={12} className="text-center text-body-secondary py-4">
Nichts gefunden. <Link href="/rechnungen?status=alle">Alle anzeigen</Link>
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</Card>
<p className="small text-body-secondary">
Die Häkchen wirken sofort. Für Rechnungsnummer, Beträge und PDF die Laufzeit öffnen.
</p>
</div>
</div>
</>
);
}