v1.23: Nebenkostenabrechnung als PDF aus Kosten- und Zaehlermodul

This commit is contained in:
2026-10-04 14:38:51 +02:00
parent 7026019ced
commit 9707b6700d
9 changed files with 1258 additions and 16 deletions
+116 -2
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'
import { Calculator, Receipt, ReceiptText, PiggyBank, Plus, Pencil, Trash2, Coins, FileScan, Check, ExternalLink, ScanSearch } from 'lucide-react'
import { Calculator, Receipt, ReceiptText, PiggyBank, Plus, Pencil, Trash2, Coins, FileScan, Check, ExternalLink, ScanSearch, FileDown, Landmark } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
@@ -193,6 +193,115 @@ function KostenDialog({ position, vorbefuellung, onGespeichert, triggerLabel }:
)
}
/** Nebenkostenabrechnung als PDF erzeugen (nur Verwaltung, v1.23). */
function AbrechnungDialog() {
const { units, erzeugeAbrechnung } = useStore()
const [open, setOpen] = useState(false)
const [einheit, setEinheit] = useState(units[0]?.id ?? '')
const [jahr, setJahr] = useState(String(new Date().getFullYear()))
const [sende, setSende] = useState(false)
const [fehler, setFehler] = useState<string | null>(null)
const [ergebnis, setErgebnis] = useState<{
dokumentId: string
zusammenfassung: { summe: number; vorauszahlung: number; differenz: number }
} | null>(null)
const submit = async () => {
if (!einheit || sende) return
setSende(true)
setFehler(null)
setErgebnis(null)
const r = await erzeugeAbrechnung(einheit, Number(jahr))
setSende(false)
if (r.fehler) return setFehler(r.fehler)
if (r.dokument && r.zusammenfassung) {
setErgebnis({ dokumentId: r.dokument.id, zusammenfassung: r.zusammenfassung })
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="gap-2">
<FileDown className="h-4 w-4" /> Abrechnung erzeugen
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Nebenkostenabrechnung als PDF</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-slate-500">
Erstellt die Abrechnung direkt aus dem Kosten- und Zählerstandsmodul:
umlegbare Kosten des Hauses werden nach Wohnflächenanteil verteilt,
die Nebenkostenvorauszahlung (Warm- − Kaltmiete × 12) wird abgezogen.
Das PDF landet im Dokumentenmodul der Wohnung.
</p>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label>Wohnung</Label>
<Select value={einheit} onValueChange={setEinheit}>
<SelectTrigger>
<SelectValue placeholder="Wohnung wählen" />
</SelectTrigger>
<SelectContent>
{units.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.building}, Whg. {u.number}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="ab-jahr">Abrechnungsjahr</Label>
<Input id="ab-jahr" inputMode="numeric" value={jahr} onChange={(e) => setJahr(e.target.value)} />
</div>
</div>
{fehler && <p className="text-sm font-medium text-red-600">{fehler}</p>}
{ergebnis && (
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm dark:border-emerald-900 dark:bg-emerald-950">
<div className="flex items-center gap-2 font-semibold text-emerald-800 dark:text-emerald-300">
<Check className="h-4 w-4" /> Abrechnung erzeugt
</div>
<div className="mt-2 grid grid-cols-3 gap-2 text-center">
<div>
<div className="text-xs text-slate-500">Kosten</div>
<div className="font-semibold">{fmtEUR.format(ergebnis.zusammenfassung.summe)}</div>
</div>
<div>
<div className="text-xs text-slate-500">Vorauszahlung</div>
<div className="font-semibold">{fmtEUR.format(ergebnis.zusammenfassung.vorauszahlung)}</div>
</div>
<div>
<div className="text-xs text-slate-500">
{ergebnis.zusammenfassung.differenz > 0 ? 'Nachzahlung' : 'Erstattung'}
</div>
<div className={`font-semibold ${ergebnis.zusammenfassung.differenz > 0 ? 'text-red-600' : 'text-emerald-600'}`}>
{fmtEUR.format(Math.abs(ergebnis.zusammenfassung.differenz))}
</div>
</div>
</div>
<a
href={`/api/documents/${ergebnis.dokumentId}/download`}
className="mt-3 inline-flex items-center gap-1.5 text-sm font-semibold text-emerald-700 underline-offset-2 hover:underline dark:text-emerald-300"
>
<Landmark className="h-4 w-4" /> PDF herunterladen
</a>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Schließen</Button>
<Button onClick={submit} disabled={!einheit || !jahr.trim() || sende} className="gap-2">
<FileDown className="h-4 w-4" /> {sende ? 'Erzeuge PDF …' : ergebnis ? 'Erneut erzeugen' : 'PDF erzeugen'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
/** Eingegangene SparkReceipt-Belege (nur Verwaltung). */
function BelegeListe() {
const { belege, setBelegZugeordnet, analysiereBeleg, units } = useStore()
@@ -414,7 +523,12 @@ export default function Kosten() {
: 'Umlegbare und nicht umlegbare Kosten nach Haus und Wohnung – Grundlage für die Nebenkostenabrechnung'}
</p>
</div>
{role === 'admin' && tab === 'kosten' && <KostenDialog />}
{role === 'admin' && tab === 'kosten' && (
<div className="flex flex-wrap gap-2">
<KostenDialog />
<AbrechnungDialog />
</div>
)}
</div>
{role === 'admin' && (
+23
View File
@@ -174,6 +174,12 @@ interface Store extends Bootstrap {
deleteKosten: (id: string) => Promise<string | null>
// ── Belege (SparkReceipt-Import; Verwaltung ordnet zu) ──
setBelegZugeordnet: (id: string, zugeordnet: boolean) => Promise<string | null>
// ── Nebenkostenabrechnung als PDF (v1.23) ─────────────────
erzeugeAbrechnung: (unitId: string, jahr: number) => Promise<{
fehler: string | null
dokument?: DocumentItem
zusammenfassung?: { summe: number; vorauszahlung: number; differenz: number }
}>
/** Beleg erneut per OCR analysieren und Zuordnung aktualisieren. */
analysiereBeleg: (id: string) => Promise<string | null>
}
@@ -509,6 +515,23 @@ export function StoreProvider({ children }: { children: ReactNode }) {
return fehlerNachricht(e)
}
},
// ── Nebenkostenabrechnung (v1.23) ─────────────────────────
async erzeugeAbrechnung(unitId, jahr) {
try {
const r = await apiPost<{ dokument: DocumentItem; zusammenfassung: { summe: number; vorauszahlung: number; differenz: number } }>(
'/api/abrechnungen', { unitId, jahr },
)
setDaten((d) => ({
...d,
documents: d.documents.some((x) => x.id === r.dokument.id)
? d.documents.map((x) => (x.id === r.dokument.id ? r.dokument : x))
: [...d.documents, r.dokument],
}))
return { fehler: null, dokument: r.dokument, zusammenfassung: r.zusammenfassung }
} catch (e) {
return { fehler: fehlerNachricht(e) }
}
},
}), [daten, bereit, me, role, meineEinheit, ladeAlles])
return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>