feat(laufzeiten): anlegen, bearbeiten und zusammenfuehren (#043)
Laufzeiten entstanden bisher nur als Vorschlag aus dem Agenten-Report; bestaetigen oder verwerfen war alles. Anlegen von Hand gab es als Aktion ohne Oberflaeche, Aendern und Zusammenfuehren gar nicht - obwohl der Doppelfall zwangslaeufig auftritt, sobald das gemeldete Ablaufdatum von der Bestellung abweicht. Jetzt: Formular zum Anlegen auf der Rechnungsseite (Key vorgeschlagen, Ende aus dem Beginn vorbelegt), Bearbeiten von Key, Zeitraum und Notiz im Laufzeit-Detail, und Zusammenfuehren zweier Laufzeiten desselben Hosts. Die aufgeloeste gibt ihre Rechnungen ab, sofern das Ziel dort nichts hat; sonst bricht der Vorgang mit Erklaerung ab - zwei Einkaufsrechnungen auf einem Lizenzjahr waeren nicht mehr zuzuordnen. Dazu die Kundenansicht: Laufzeit und Host verlinkt, Marge und Rabatt je Zeile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -204,7 +204,7 @@ export default async function KundenDetailSeite({ params }: { params: Promise<{
|
||||
|
||||
<Card titel="Laufzeiten und Rechnungen" rahmen="info" tabelle>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped align-middle">
|
||||
<table className="table table-striped align-middle pl-karten-tabelle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Laufzeit</th>
|
||||
@@ -213,26 +213,46 @@ export default async function KundenDetailSeite({ params }: { params: Promise<{
|
||||
<th scope="col">Einkauf</th>
|
||||
<th scope="col">Kundenrechnung</th>
|
||||
<th scope="col">Verkauf</th>
|
||||
<th scope="col">Marge</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{laufzeiten.map((laufzeit) => (
|
||||
<tr key={laufzeit.id}>
|
||||
<td>
|
||||
{formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)}
|
||||
{/* Von hier aus werden die Rechnungen erfasst – ohne Link führte die
|
||||
Kundenansicht in eine Sackgasse (#043). */}
|
||||
<td className="pl-karten-kopf">
|
||||
<Link
|
||||
href={`/rechnungen/${laufzeit.id}`}
|
||||
className="fw-semibold text-decoration-none"
|
||||
>
|
||||
{formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)}
|
||||
</Link>
|
||||
{laufzeit.vorschlag ? (
|
||||
<span className="badge pl-bg-info ms-2">Vorschlag</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td>{laufzeit.hostname}</td>
|
||||
<td>
|
||||
<td data-label="Host">
|
||||
<Link href={`/hosts/${laufzeit.hostId}`} className="text-decoration-none">
|
||||
{laufzeit.hostname}
|
||||
</Link>
|
||||
</td>
|
||||
<td data-label="Proxmox-Rechnung">
|
||||
<CopyableCode wert={laufzeit.einkauf?.nummer ?? null} />
|
||||
<div>
|
||||
<EinkaufBadge bezahlt={laufzeit.einkauf ? laufzeit.einkauf.bezahlt : null} />
|
||||
</div>
|
||||
</td>
|
||||
<td>{formatEuro(laufzeit.einkauf?.nettoBetrag)}</td>
|
||||
<td>
|
||||
<td data-label="Einkauf">
|
||||
{formatEuro(laufzeit.einkauf?.nettoBetrag)}
|
||||
{laufzeit.einkauf && laufzeit.einkauf.rabatt > 0 ? (
|
||||
<div className="small text-body-secondary">
|
||||
{formatEuro(laufzeit.einkauf.listenpreis)} − {formatEuro(laufzeit.einkauf.rabatt)}{" "}
|
||||
Rabatt
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td data-label="Kundenrechnung">
|
||||
<CopyableCode wert={laufzeit.verkauf?.nummer ?? null} />
|
||||
<div>
|
||||
<VerkaufBadge
|
||||
@@ -241,12 +261,26 @@ export default async function KundenDetailSeite({ params }: { params: Promise<{
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>{formatEuro(laufzeit.verkauf?.nettoBetrag)}</td>
|
||||
<td data-label="Verkauf">{formatEuro(laufzeit.verkauf?.nettoBetrag)}</td>
|
||||
<td
|
||||
data-label="Marge"
|
||||
className={
|
||||
laufzeit.einkauf && laufzeit.verkauf
|
||||
? (laufzeit.verkauf.nettoBetrag ?? 0) - laufzeit.einkauf.nettoBetrag >= 0
|
||||
? "pl-text-success"
|
||||
: "pl-text-danger"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{laufzeit.einkauf && laufzeit.verkauf
|
||||
? formatEuro((laufzeit.verkauf.nettoBetrag ?? 0) - laufzeit.einkauf.nettoBetrag)
|
||||
: "–"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{laufzeiten.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center text-body-secondary py-4">
|
||||
<td colSpan={7} className="text-center text-body-secondary py-4">
|
||||
Noch keine Laufzeiten erfasst.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -11,6 +11,8 @@ import { formatDatum, formatEuro } from "@/lib/format";
|
||||
import { laufzeitBestaetigenAktion, laufzeitVerwerfenAktion } from "@/server/actions/rechnungen";
|
||||
import { einstellungen, faelligkeitsVorschlag } from "@/server/einstellungen";
|
||||
import { laufzeitDetail } from "@/server/queries/rechnungen";
|
||||
import { LaufzeitBearbeiten } from "@/components/rechnungen/LaufzeitBearbeiten";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
@@ -33,7 +35,22 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
||||
const marge = einkaufBetrag !== null && verkaufBetrag !== null ? verkaufBetrag - einkaufBetrag : null;
|
||||
|
||||
// Vorgaben aus den Einstellungen füllen leere Felder vor (#025).
|
||||
const [vorgaben, faelligVorschlag] = await Promise.all([einstellungen(), faelligkeitsVorschlag()]);
|
||||
const [vorgaben, faelligVorschlag, nachbarn] = await Promise.all([
|
||||
einstellungen(),
|
||||
faelligkeitsVorschlag(),
|
||||
prisma.laufzeit.findMany({
|
||||
where: { hostId: laufzeit.host.id, id: { not: laufzeit.id } },
|
||||
orderBy: { start: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
key: true,
|
||||
start: true,
|
||||
ende: true,
|
||||
proxmoxPositionId: true,
|
||||
kundenPositionId: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const verkaufVorschlag =
|
||||
verkaufBetrag ??
|
||||
(einkaufBetrag === null ? null : Math.round(einkaufBetrag * (1 + vorgaben.margeProzent / 100) * 100) / 100);
|
||||
@@ -127,6 +144,23 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
||||
{marge === null ? "–" : formatEuro(marge)}
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<hr />
|
||||
<LaufzeitBearbeiten
|
||||
laufzeitId={laufzeit.id}
|
||||
hostId={laufzeit.host.id}
|
||||
schluessel={laufzeit.key}
|
||||
start={laufzeit.start.toISOString().slice(0, 10)}
|
||||
ende={laufzeit.ende.toISOString().slice(0, 10)}
|
||||
notizen={laufzeit.notizen}
|
||||
nachbarn={nachbarn.map((nachbar) => ({
|
||||
id: nachbar.id,
|
||||
zeitraum: `${formatDatum(nachbar.start)} – ${formatDatum(nachbar.ende)}`,
|
||||
key: nachbar.key,
|
||||
hatEinkauf: nachbar.proxmoxPositionId !== null,
|
||||
hatVerkauf: nachbar.kundenPositionId !== null,
|
||||
}))}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{einkauf && einkauf.positionen.length > 1 ? (
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 { LaufzeitAnlegen } from "@/components/rechnungen/LaufzeitAnlegen";
|
||||
import {
|
||||
anzahlVorschlaege,
|
||||
laufzeitJahre,
|
||||
@@ -33,11 +34,16 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
||||
q: einzeln(roh.q),
|
||||
};
|
||||
|
||||
const [{ zeilen, summen }, jahre, kunden, vorschlaege] = await Promise.all([
|
||||
const [{ zeilen, summen }, jahre, kunden, vorschlaege, hostsFuerLaufzeit] = await Promise.all([
|
||||
rechnungsListe(filter),
|
||||
laufzeitJahre(),
|
||||
prisma.kunde.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||
anzahlVorschlaege(),
|
||||
prisma.host.findMany({
|
||||
where: { archiviertAm: null },
|
||||
orderBy: { hostname: "asc" },
|
||||
select: { id: true, hostname: true, subKey: true, kunde: { select: { name: true } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const heute = new Date();
|
||||
@@ -106,6 +112,19 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Laufzeiten entstehen sonst aus den Agenten-Reports; von Hand braucht man es für
|
||||
Hosts ohne gemeldetes Ablaufdatum oder für Nachträge (#043). */}
|
||||
<Card titel="Laufzeit von Hand anlegen" rahmen="keiner">
|
||||
<LaufzeitAnlegen
|
||||
hosts={hostsFuerLaufzeit.map((host) => ({
|
||||
id: host.id,
|
||||
hostname: host.hostname,
|
||||
kundeName: host.kunde?.name ?? null,
|
||||
subKey: host.subKey,
|
||||
}))}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
titel="Laufzeiten und Rechnungen"
|
||||
rahmen="primary"
|
||||
|
||||
Reference in New Issue
Block a user