v1.23: Nebenkostenabrechnung als PDF aus Kosten- und Zaehlermodul
This commit is contained in:
+116
-2
@@ -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' && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user