v1.16: SparkReceipt-Schnittstelle - Belege per Webhook importieren und als Kostenposition uebernehmen

This commit is contained in:
2026-10-04 12:43:36 +02:00
parent 189ce10d65
commit 4eab719550
7 changed files with 329 additions and 9 deletions
+165 -8
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'
import { Calculator, Receipt, ReceiptText, PiggyBank, Plus, Pencil, Trash2, Coins } from 'lucide-react'
import { Calculator, Receipt, ReceiptText, PiggyBank, Plus, Pencil, Trash2, Coins, FileScan, Check, ExternalLink } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
@@ -28,7 +28,7 @@ import {
TableRow,
} from '@/components/ui/table'
import { useStore, fmtEUR } from '../store/store'
import type { KostenItem } from '../types'
import type { Beleg, KostenItem } from '../types'
const KOSTEN_ARTEN = [
'Wasser/Abwasser', 'Müllabfuhr', 'Straßenreinigung/Winterdienst', 'Gartenpflege',
@@ -40,17 +40,24 @@ const KOSTEN_ARTEN = [
/** Komma oder Punkt als Dezimaltrenner akzeptieren. */
const zahl = (s: string) => Number(s.replace(',', '.'))
function KostenDialog({ position }: { position?: KostenItem }) {
function KostenDialog({ position, vorbefuellung, onGespeichert, triggerLabel }: {
position?: KostenItem
vorbefuellung?: { betrag?: string; jahr?: string; notiz?: string }
onGespeichert?: () => void
triggerLabel?: string
}) {
const { addKosten, updateKosten, units, buildings } = useStore()
const bearbeiten = Boolean(position)
const [open, setOpen] = useState(false)
const [kostenart, setKostenart] = useState(position?.kostenart ?? 'Wasser/Abwasser')
const [jahr, setJahr] = useState(String(position?.jahr ?? new Date().getFullYear()))
const [betrag, setBetrag] = useState(position ? String(position.betrag).replace('.', ',') : '')
const [jahr, setJahr] = useState(String(position?.jahr ?? vorbefuellung?.jahr ?? new Date().getFullYear()))
const [betrag, setBetrag] = useState(
position ? String(position.betrag).replace('.', ',') : (vorbefuellung?.betrag ?? ''),
)
const [einheit, setEinheit] = useState(position?.unitId ?? '__haus') // '__haus' = ganzes Haus
const [haus, setHaus] = useState(position?.building ?? buildings[0]?.name ?? '')
const [umlegbar, setUmlegbar] = useState(position?.umlegbar ?? true)
const [notiz, setNotiz] = useState(position?.notiz ?? '')
const [notiz, setNotiz] = useState(position?.notiz ?? vorbefuellung?.notiz ?? '')
const [sende, setSende] = useState(false)
const [fehler, setFehler] = useState<string | null>(null)
@@ -70,6 +77,7 @@ function KostenDialog({ position }: { position?: KostenItem }) {
const err = bearbeiten ? await updateKosten(position!.id, payload) : await addKosten(payload)
setSende(false)
if (err) return setFehler(err)
onGespeichert?.()
setOpen(false)
}
@@ -82,7 +90,7 @@ function KostenDialog({ position }: { position?: KostenItem }) {
</Button>
) : (
<Button className="gap-2">
<Plus className="h-4 w-4" /> Neue Kostenposition
<Plus className="h-4 w-4" /> {triggerLabel ?? 'Neue Kostenposition'}
</Button>
)}
</DialogTrigger>
@@ -185,8 +193,133 @@ function KostenDialog({ position }: { position?: KostenItem }) {
)
}
/** Eingegangene SparkReceipt-Belege (nur Verwaltung). */
function BelegeListe() {
const { belege, setBelegZugeordnet } = useStore()
const [nurOffene, setNurOffene] = useState(false)
const zeilen = useMemo(
() => (nurOffene ? belege.filter((b) => !b.zugeordnet) : belege),
[belege, nurOffene],
)
const offen = belege.filter((b) => !b.zugeordnet).length
return (
<div className="space-y-4">
<div className="rounded-2xl border border-teal-200 bg-teal-50/60 p-4 text-sm text-slate-700">
<div className="flex items-center gap-2 font-semibold text-teal-800">
<FileScan className="h-4 w-4" /> Schnittstelle SparkReceipt
</div>
<p className="mt-1">
Gescannte Belege aus dem SparkReceipt-Cloudarchiv landen automatisch in dieser Liste.
In SparkReceipt unter <em>Integrationen → Webhooks</em> diese URL hinterlegen
(Token liegt in der Server-Konfiguration <code>portal.env</code>):
</p>
<code className="mt-2 block break-all rounded-lg bg-white px-3 py-2 text-xs text-slate-600 ring-1 ring-teal-100">
https://mieterportal.hocks.eu/api/belege/webhook?token=…
</code>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
onClick={() => setNurOffene(false)}
className={`rounded-full px-4 py-1.5 text-sm font-medium transition ${
!nurOffene ? 'bg-teal-600 text-white' : 'bg-white text-slate-600 ring-1 ring-slate-200 hover:ring-teal-400'
}`}
>
Alle ({belege.length})
</button>
<button
onClick={() => setNurOffene(true)}
className={`rounded-full px-4 py-1.5 text-sm font-medium transition ${
nurOffene ? 'bg-teal-600 text-white' : 'bg-white text-slate-600 ring-1 ring-slate-200 hover:ring-teal-400'
}`}
>
Nicht zugeordnet ({offen})
</button>
</div>
<div className="rounded-2xl border bg-white p-5 shadow-sm">
{zeilen.length === 0 ? (
<div className="py-10 text-center text-sm text-slate-500">
<FileScan className="mx-auto mb-3 h-8 w-8 text-slate-300" />
{nurOffene ? 'Alle Belege sind zugeordnet.' : 'Noch keine Belege eingegangen.'}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Händler</TableHead>
<TableHead>Datum</TableHead>
<TableHead>Kategorie (SparkReceipt)</TableHead>
<TableHead className="text-right">Betrag</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Aktion</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{zeilen.map((b: Beleg) => (
<TableRow key={b.id}>
<TableCell className="font-medium">
{b.haendler || '–'}
{b.notiz && <div className="text-xs font-normal text-slate-400">{b.notiz}</div>}
</TableCell>
<TableCell className="text-slate-500">
{b.datum || '–'}
{b.jahr && <span className="text-xs text-slate-400"> · {b.jahr}</span>}
</TableCell>
<TableCell className="text-slate-500">{b.kategorie || '–'}</TableCell>
<TableCell className="text-right font-semibold">
{b.betrag === null ? '–' : `${b.betrag.toLocaleString('de-DE', { minimumFractionDigits: 2 })} ${b.waehrung}`}
</TableCell>
<TableCell>
{b.zugeordnet ? (
<Badge variant="secondary" className="gap-1 bg-emerald-100 text-emerald-800">
<Check className="h-3 w-3" /> Zugeordnet
</Badge>
) : (
<Badge variant="secondary" className="bg-amber-100 text-amber-800">Offen</Badge>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
{!b.zugeordnet && (
<KostenDialog
triggerLabel="Übernehmen"
vorbefuellung={{
betrag: b.betrag === null ? '' : String(b.betrag).replace('.', ','),
jahr: b.jahr ? String(b.jahr) : String(new Date().getFullYear()),
notiz: `SparkReceipt ${b.haendler ? `· ${b.haendler} ` : ''}(#${b.externeId})`,
}}
onGespeichert={() => setBelegZugeordnet(b.id, true)}
/>
)}
{b.dateiUrl && (
<a
href={b.dateiUrl}
target="_blank"
rel="noreferrer"
title="Beleg in SparkReceipt öffnen"
className="inline-flex items-center gap-1 rounded-lg bg-slate-100 px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:bg-slate-200"
>
<ExternalLink className="h-3.5 w-3.5" /> Beleg
</a>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
)
}
export default function Kosten() {
const { kosten, role, units, buildings, deleteKosten } = useStore()
const [tab, setTab] = useState<'kosten' | 'belege'>('kosten')
const [jahrFilter, setJahrFilter] = useState('alle')
const [hausFilter, setHausFilter] = useState('alle')
const [artFilter, setArtFilter] = useState<'alle' | 'umlegbar' | 'nicht'>('alle')
@@ -230,9 +363,29 @@ export default function Kosten() {
: 'Umlegbare und nicht umlegbare Kosten nach Haus und Wohnung – Grundlage für die Nebenkostenabrechnung'}
</p>
</div>
{role === 'admin' && <KostenDialog />}
{role === 'admin' && tab === 'kosten' && <KostenDialog />}
</div>
{role === 'admin' && (
<div className="flex gap-2 border-b border-slate-200">
{([['kosten', 'Kostenpositionen'], ['belege', 'Spark-Receipt-Belege']] as const).map(([wert, label]) => (
<button
key={wert}
onClick={() => setTab(wert)}
className={`-mb-px border-b-2 px-4 py-2 text-sm font-medium transition ${
tab === wert
? 'border-teal-600 text-teal-700'
: 'border-transparent text-slate-500 hover:text-slate-800'
}`}
>
{label}
</button>
))}
</div>
)}
{tab === 'kosten' && (
<>
<div className="grid gap-4 sm:grid-cols-3">
<div className="rounded-2xl border bg-white p-5 shadow-sm">
<div className="flex items-center gap-2 text-sm text-slate-500">
@@ -370,6 +523,10 @@ export default function Kosten() {
umgelegt; nicht umlegbare Positionen trägt die Eigentümerseite. Erfasst von der
Verwaltung, Grundlage der jährlichen Nebenkostenabrechnung.
</p>
</>
)}
{tab === 'belege' && role === 'admin' && <BelegeListe />}
</div>
)
}
+15
View File
@@ -33,6 +33,7 @@ import type {
ZaehlerArt,
Building,
KostenItem,
Beleg,
} from '../types'
import { apiGet, apiPost, apiPatch, apiDelete, ApiFehler } from '../lib/api'
import { sha256, DEMO_PASSWORT } from '../lib/auth'
@@ -53,6 +54,7 @@ interface Bootstrap {
meters: Meter[]
buildings: Building[]
kosten: KostenItem[]
belege: Beleg[]
}
const LEER: Bootstrap = {
@@ -67,6 +69,7 @@ const LEER: Bootstrap = {
meters: [],
buildings: [],
kosten: [],
belege: [],
}
interface Store extends Bootstrap {
@@ -157,6 +160,8 @@ interface Store extends Bootstrap {
k: Partial<{ building: string; unitId: string | null; jahr: number; kostenart: string; betrag: number; umlegbar: boolean; notiz: string }>,
) => Promise<string | null>
deleteKosten: (id: string) => Promise<string | null>
// ── Belege (SparkReceipt-Import; Verwaltung ordnet zu) ──
setBelegZugeordnet: (id: string, zugeordnet: boolean) => Promise<string | null>
}
const StoreContext = createContext<Store | null>(null)
@@ -445,6 +450,16 @@ export function StoreProvider({ children }: { children: ReactNode }) {
return fehlerNachricht(e)
}
},
// ── Belege (SparkReceipt) ───────────────────────────────
async setBelegZugeordnet(id, zugeordnet) {
try {
const { beleg } = await apiPatch<{ beleg: Beleg }>(`/api/belege/${id}`, { zugeordnet })
setDaten((x) => ({ ...x, belege: x.belege.map((b) => (b.id === id ? beleg : b)) }))
return null
} catch (e) {
return fehlerNachricht(e)
}
},
}), [daten, bereit, me, role, meineEinheit, ladeAlles])
return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>
+17
View File
@@ -133,3 +133,20 @@ export interface KostenItem {
notiz: string
erfasstAm: string
}
/** Gescannter Beleg aus SparkReceipt (per Webhook importiert). */
export interface Beleg {
id: string
quelle: string
externeId: string // Dokument-ID in SparkReceipt
haendler: string
betrag: number | null
waehrung: string
datum: string
jahr: number | null
kategorie: string
notiz: string
dateiUrl: string
zugeordnet: boolean // bereits als Kostenposition übernommen
erfasstAm: string
}