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>
338 lines
14 KiB
TypeScript
338 lines
14 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
}
|