feat(rechnungen): Laufzeiten bestaetigen, Rechnungen erfassen und Status setzen (#008)
Rechnungen mit Positionen: gleiche Nummer ergibt eine Sammelrechnung ueber mehrere Laufzeiten. PDF-Upload mit Inhaltspruefung und Ablage unter der Pruefsumme, Haekchen direkt in der Tabelle, Filter nach Status, Kunde und Jahr. 78 Tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { kundenrechnungSpeichern, proxmoxRechnungSpeichern } from "@/server/actions/rechnungen";
|
||||
import type { AktionsErgebnis } from "@/server/actions/kunden";
|
||||
|
||||
const LEER: AktionsErgebnis = {};
|
||||
|
||||
function isoDatum(wert: string | null | undefined) {
|
||||
return wert ? wert.slice(0, 10) : "";
|
||||
}
|
||||
|
||||
function Meldung({ ergebnis }: { ergebnis: AktionsErgebnis }) {
|
||||
if (ergebnis.fehler) return <div className="alert alert-danger py-2">{ergebnis.fehler}</div>;
|
||||
if (ergebnis.erfolg) return <div className="alert alert-success py-2">{ergebnis.erfolg}</div>;
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface ProxmoxWerte {
|
||||
nummer: string | null;
|
||||
datum: string | null;
|
||||
faelligAm: string | null;
|
||||
nettoBetrag: number | null;
|
||||
ustSatz: number;
|
||||
rabatt: number | null;
|
||||
zahlungsart: string | null;
|
||||
bezahlt: boolean;
|
||||
bezahltAm: string | null;
|
||||
anhangId: string | null;
|
||||
anhangName: string | null;
|
||||
}
|
||||
|
||||
/** Proxmox-Rechnung (Einkauf). Gleiche Rechnungsnummer heißt: Position an derselben Rechnung. */
|
||||
export function ProxmoxRechnungFormular({
|
||||
laufzeitId,
|
||||
hostId,
|
||||
werte,
|
||||
}: {
|
||||
laufzeitId: string;
|
||||
hostId: string;
|
||||
werte: ProxmoxWerte;
|
||||
}) {
|
||||
const [ergebnis, aktion, laeuft] = useActionState(proxmoxRechnungSpeichern, LEER);
|
||||
|
||||
return (
|
||||
<form action={aktion}>
|
||||
<input type="hidden" name="laufzeitId" value={laufzeitId} />
|
||||
<input type="hidden" name="hostId" value={hostId} />
|
||||
<Meldung ergebnis={ergebnis} />
|
||||
|
||||
<div className="row g-3">
|
||||
<div className="col-md-6">
|
||||
<label className="form-label" htmlFor="px-nummer">
|
||||
Rechnungsnummer *
|
||||
</label>
|
||||
<input
|
||||
id="px-nummer"
|
||||
name="nummer"
|
||||
className="form-control pl-mono"
|
||||
defaultValue={werte.nummer ?? ""}
|
||||
placeholder="PX-2026-14790"
|
||||
required
|
||||
/>
|
||||
<div className="form-text">
|
||||
Eine bereits vorhandene Nummer ergänzt die Sammelrechnung um diese Laufzeit.
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<label className="form-label" htmlFor="px-datum">
|
||||
Rechnungsdatum *
|
||||
</label>
|
||||
<input
|
||||
id="px-datum"
|
||||
name="datum"
|
||||
type="date"
|
||||
className="form-control"
|
||||
defaultValue={isoDatum(werte.datum)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<label className="form-label" htmlFor="px-faellig">
|
||||
Fällig am
|
||||
</label>
|
||||
<input
|
||||
id="px-faellig"
|
||||
name="faelligAm"
|
||||
type="date"
|
||||
className="form-control"
|
||||
defaultValue={isoDatum(werte.faelligAm)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-md-3">
|
||||
<label className="form-label" htmlFor="px-netto">
|
||||
Netto (€) *
|
||||
</label>
|
||||
<input
|
||||
id="px-netto"
|
||||
name="nettoBetrag"
|
||||
className="form-control"
|
||||
inputMode="decimal"
|
||||
defaultValue={werte.nettoBetrag ?? ""}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<label className="form-label" htmlFor="px-ust">
|
||||
USt (%)
|
||||
</label>
|
||||
<input
|
||||
id="px-ust"
|
||||
name="ustSatz"
|
||||
className="form-control"
|
||||
inputMode="decimal"
|
||||
defaultValue={werte.ustSatz}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<label className="form-label" htmlFor="px-rabatt">
|
||||
Rabatt (€)
|
||||
</label>
|
||||
<input
|
||||
id="px-rabatt"
|
||||
name="rabatt"
|
||||
className="form-control"
|
||||
inputMode="decimal"
|
||||
defaultValue={werte.rabatt ?? ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<label className="form-label" htmlFor="px-zahlungsart">
|
||||
Zahlungsart
|
||||
</label>
|
||||
<input
|
||||
id="px-zahlungsart"
|
||||
name="zahlungsart"
|
||||
className="form-control"
|
||||
defaultValue={werte.zahlungsart ?? ""}
|
||||
placeholder="Überweisung"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-md-4 d-flex align-items-end">
|
||||
<div className="form-check">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="px-bezahlt"
|
||||
name="bezahlt"
|
||||
defaultChecked={werte.bezahlt}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="px-bezahlt">
|
||||
bezahlt
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<label className="form-label" htmlFor="px-bezahltAm">
|
||||
Bezahlt am
|
||||
</label>
|
||||
<input
|
||||
id="px-bezahltAm"
|
||||
name="bezahltAm"
|
||||
type="date"
|
||||
className="form-control"
|
||||
defaultValue={isoDatum(werte.bezahltAm)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<label className="form-label" htmlFor="px-pdf">
|
||||
PDF (max. 10 MB)
|
||||
</label>
|
||||
<input id="px-pdf" name="pdf" type="file" accept="application/pdf" className="form-control" />
|
||||
{werte.anhangId ? (
|
||||
<div className="form-text">
|
||||
Hinterlegt:{" "}
|
||||
<a href={`/anhang/${werte.anhangId}`} target="_blank" rel="noreferrer">
|
||||
{werte.anhangName ?? "PDF ansehen"}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary mt-3 pl-touch-target" disabled={laeuft}>
|
||||
{laeuft ? "Speichert …" : "Proxmox-Rechnung speichern"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export interface KundenWerte {
|
||||
erstellt: boolean;
|
||||
nummer: string | null;
|
||||
datum: string | null;
|
||||
nettoBetrag: number | null;
|
||||
ustSatz: number;
|
||||
bezahlt: boolean;
|
||||
bezahltAm: string | null;
|
||||
anhangId: string | null;
|
||||
anhangName: string | null;
|
||||
}
|
||||
|
||||
/** Kundenrechnung (Verkauf). */
|
||||
export function KundenrechnungFormular({
|
||||
laufzeitId,
|
||||
hostId,
|
||||
werte,
|
||||
kundeVorhanden,
|
||||
}: {
|
||||
laufzeitId: string;
|
||||
hostId: string;
|
||||
werte: KundenWerte;
|
||||
kundeVorhanden: boolean;
|
||||
}) {
|
||||
const [ergebnis, aktion, laeuft] = useActionState(kundenrechnungSpeichern, LEER);
|
||||
|
||||
if (!kundeVorhanden) {
|
||||
return (
|
||||
<p className="mb-0 text-body-secondary">
|
||||
Dieser Host ist keinem Kunden zugewiesen. Ohne Kunde lässt sich keine Kundenrechnung erfassen.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={aktion}>
|
||||
<input type="hidden" name="laufzeitId" value={laufzeitId} />
|
||||
<input type="hidden" name="hostId" value={hostId} />
|
||||
<Meldung ergebnis={ergebnis} />
|
||||
|
||||
<div className="row g-3">
|
||||
<div className="col-md-4 d-flex align-items-end">
|
||||
<div className="form-check">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="kr-erstellt"
|
||||
name="erstellt"
|
||||
defaultChecked={werte.erstellt}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="kr-erstellt">
|
||||
Rechnung ist erstellt
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<label className="form-label" htmlFor="kr-nummer">
|
||||
Rechnungsnummer
|
||||
</label>
|
||||
<input
|
||||
id="kr-nummer"
|
||||
name="nummer"
|
||||
className="form-control pl-mono"
|
||||
defaultValue={werte.nummer ?? ""}
|
||||
placeholder="RE-2026-0412"
|
||||
/>
|
||||
<div className="form-text">Gleiche Nummer = eine Rechnung über mehrere Laufzeiten.</div>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<label className="form-label" htmlFor="kr-datum">
|
||||
Rechnungsdatum
|
||||
</label>
|
||||
<input
|
||||
id="kr-datum"
|
||||
name="datum"
|
||||
type="date"
|
||||
className="form-control"
|
||||
defaultValue={isoDatum(werte.datum)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-md-4">
|
||||
<label className="form-label" htmlFor="kr-netto">
|
||||
Netto (€)
|
||||
</label>
|
||||
<input
|
||||
id="kr-netto"
|
||||
name="nettoBetrag"
|
||||
className="form-control"
|
||||
inputMode="decimal"
|
||||
defaultValue={werte.nettoBetrag ?? ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<label className="form-label" htmlFor="kr-ust">
|
||||
USt (%)
|
||||
</label>
|
||||
<input
|
||||
id="kr-ust"
|
||||
name="ustSatz"
|
||||
className="form-control"
|
||||
inputMode="decimal"
|
||||
defaultValue={werte.ustSatz}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-3 d-flex align-items-end">
|
||||
<div className="form-check">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="kr-bezahlt"
|
||||
name="bezahlt"
|
||||
defaultChecked={werte.bezahlt}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="kr-bezahlt">
|
||||
vom Kunden bezahlt
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<label className="form-label" htmlFor="kr-bezahltAm">
|
||||
Bezahlt am
|
||||
</label>
|
||||
<input
|
||||
id="kr-bezahltAm"
|
||||
name="bezahltAm"
|
||||
type="date"
|
||||
className="form-control"
|
||||
defaultValue={isoDatum(werte.bezahltAm)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<label className="form-label" htmlFor="kr-pdf">
|
||||
PDF (max. 10 MB)
|
||||
</label>
|
||||
<input id="kr-pdf" name="pdf" type="file" accept="application/pdf" className="form-control" />
|
||||
{werte.anhangId ? (
|
||||
<div className="form-text">
|
||||
Hinterlegt:{" "}
|
||||
<a href={`/anhang/${werte.anhangId}`} target="_blank" rel="noreferrer">
|
||||
{werte.anhangName ?? "PDF ansehen"}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary mt-3 pl-touch-target" disabled={laeuft}>
|
||||
{laeuft ? "Speichert …" : "Kundenrechnung speichern"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { statusUmschalten } from "@/server/actions/rechnungen";
|
||||
|
||||
/**
|
||||
* Häkchen, das den Rechnungsstatus direkt in der Tabelle setzt.
|
||||
* Ohne erfasste Rechnung bleibt es deaktiviert – außer bei „Kundenrechnung erstellt“,
|
||||
* denn dort legt das Häkchen eine Rechnung ohne Nummer an, die später ergänzt wird.
|
||||
*/
|
||||
export function StatusHaken({
|
||||
laufzeitId,
|
||||
hostId,
|
||||
feld,
|
||||
wert,
|
||||
deaktiviert = false,
|
||||
titel,
|
||||
}: {
|
||||
laufzeitId: string;
|
||||
hostId: string;
|
||||
feld: "einkaufBezahlt" | "verkaufErstellt" | "verkaufBezahlt";
|
||||
wert: boolean;
|
||||
deaktiviert?: boolean;
|
||||
titel: string;
|
||||
}) {
|
||||
const [laeuft, starte] = useTransition();
|
||||
|
||||
function umschalten(neu: boolean) {
|
||||
const daten = new FormData();
|
||||
daten.set("laufzeitId", laufzeitId);
|
||||
daten.set("hostId", hostId);
|
||||
daten.set("feld", feld);
|
||||
daten.set("wert", String(neu));
|
||||
starte(async () => {
|
||||
await statusUmschalten(daten);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="d-inline-flex align-items-center justify-content-center">
|
||||
{laeuft ? (
|
||||
<span
|
||||
className="spinner-border spinner-border-sm text-body-secondary"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input pl-touch-target"
|
||||
checked={wert}
|
||||
disabled={deaktiviert}
|
||||
onChange={(e) => umschalten(e.target.checked)}
|
||||
aria-label={titel}
|
||||
title={deaktiviert ? "Erst eine Rechnung erfassen" : titel}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useState, useTransition } from "react";
|
||||
import { statusUmschalten } from "@/server/actions/rechnungen";
|
||||
import { Badge } from "./Badges";
|
||||
import type { Farbton } from "./SmallBox";
|
||||
|
||||
@@ -12,6 +13,12 @@ export interface Aufgabe {
|
||||
dringlichkeit: "gefahr" | "warnung" | "info";
|
||||
badgeText: string;
|
||||
href: string;
|
||||
/** Gesetzt, wenn das Abhaken direkt einen Rechnungsstatus setzt. */
|
||||
aktion?: {
|
||||
laufzeitId: string;
|
||||
hostId: string;
|
||||
feld: "einkaufBezahlt" | "verkaufErstellt";
|
||||
};
|
||||
}
|
||||
|
||||
const BADGE_FARBE: Record<Aufgabe["dringlichkeit"], Farbton> = {
|
||||
@@ -21,14 +28,30 @@ const BADGE_FARBE: Record<Aufgabe["dringlichkeit"], Farbton> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Aufgabenliste im AdminLTE-Todo-Stil. Im Prototyp merkt sich das Abhaken den Zustand nur lokal;
|
||||
* ab #008 setzt es den Rechnungsstatus in der Datenbank.
|
||||
* Aufgabenliste im AdminLTE-Todo-Stil. Aufgaben mit `aktion` setzen beim Abhaken den
|
||||
* Rechnungsstatus in der Datenbank; die übrigen sind Merkposten und verschwinden erst,
|
||||
* wenn die Ursache behoben ist (z. B. ein Host einem Kunden zugewiesen wurde).
|
||||
*/
|
||||
export function TodoList({ aufgaben }: { aufgaben: Aufgabe[] }) {
|
||||
const [erledigt, setErledigt] = useState<string[]>([]);
|
||||
const [oertlichErledigt, setOertlichErledigt] = useState<string[]>([]);
|
||||
const [laeuft, starte] = useTransition();
|
||||
|
||||
function umschalten(id: string) {
|
||||
setErledigt((alt) => (alt.includes(id) ? alt.filter((x) => x !== id) : [...alt, id]));
|
||||
function umschalten(aufgabe: Aufgabe) {
|
||||
if (!aufgabe.aktion) {
|
||||
setOertlichErledigt((alt) =>
|
||||
alt.includes(aufgabe.id) ? alt.filter((x) => x !== aufgabe.id) : [...alt, aufgabe.id],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const daten = new FormData();
|
||||
daten.set("laufzeitId", aufgabe.aktion.laufzeitId);
|
||||
daten.set("hostId", aufgabe.aktion.hostId);
|
||||
daten.set("feld", aufgabe.aktion.feld);
|
||||
daten.set("wert", "true");
|
||||
starte(async () => {
|
||||
await statusUmschalten(daten);
|
||||
});
|
||||
}
|
||||
|
||||
if (aufgaben.length === 0) {
|
||||
@@ -38,7 +61,7 @@ export function TodoList({ aufgaben }: { aufgaben: Aufgabe[] }) {
|
||||
return (
|
||||
<ul className="pl-todo-list">
|
||||
{aufgaben.map((aufgabe) => {
|
||||
const istErledigt = erledigt.includes(aufgabe.id);
|
||||
const istErledigt = oertlichErledigt.includes(aufgabe.id);
|
||||
return (
|
||||
<li
|
||||
key={aufgabe.id}
|
||||
@@ -48,8 +71,12 @@ export function TodoList({ aufgaben }: { aufgaben: Aufgabe[] }) {
|
||||
type="checkbox"
|
||||
className="form-check-input mt-1 pl-touch-target"
|
||||
checked={istErledigt}
|
||||
onChange={() => umschalten(aufgabe.id)}
|
||||
aria-label={`${aufgabe.titel} erledigt`}
|
||||
disabled={laeuft}
|
||||
onChange={() => umschalten(aufgabe)}
|
||||
aria-label={
|
||||
aufgabe.aktion ? `${aufgabe.titel} als erledigt eintragen` : `${aufgabe.titel} abhaken`
|
||||
}
|
||||
title={aufgabe.aktion ? "Setzt den Rechnungsstatus" : "Nur zur eigenen Übersicht"}
|
||||
/>
|
||||
<div className="flex-grow-1 pl-todo-text">
|
||||
<Link href={aufgabe.href} className="fw-semibold text-decoration-none">
|
||||
|
||||
Reference in New Issue
Block a user