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
+2
View File
@@ -39,6 +39,8 @@ services:
N8N_TICKET_WEBHOOK: ${N8N_TICKET_WEBHOOK:-} N8N_TICKET_WEBHOOK: ${N8N_TICKET_WEBHOOK:-}
# Token, mit dem n8n die fertige KI-Antwort zurück an die API postet # Token, mit dem n8n die fertige KI-Antwort zurück an die API postet
KI_API_TOKEN: ${KI_API_TOKEN:-} KI_API_TOKEN: ${KI_API_TOKEN:-}
# Beleg-Import aus SparkReceipt (Shared Token für den Webhook)
SPARKRECEIPT_WEBHOOK_TOKEN: ${SPARKRECEIPT_WEBHOOK_TOKEN:-}
# PDF-Ablage im Dokumentenmodul (persistiertes Volume) # PDF-Ablage im Dokumentenmodul (persistiertes Volume)
UPLOAD_DIR: /app/uploads UPLOAD_DIR: /app/uploads
PORT: 3000 PORT: 3000
+30
View File
@@ -189,6 +189,36 @@ tar -czf mp-dist.tar.gz -C dist .
## 8. Verlauf / offene Punkte ## 8. Verlauf / offene Punkte
**Erledigt 04.10. (v1.16):** SparkReceipt-Schnittstelle für gescannte Belege.
SparkReceipt bietet Webhooks (Doku: sparkreceipt.com/integrations/webhooks)
– POST auf eine URL bei document created/updated/deleted, Standard-Verzögerung
10 min, Event-Log mit „Send now". Setup beim Nutzer: SparkReceipt →
Integrationen → Webhooks → URL
`https://mieterportal.hocks.eu/api/belege/webhook?token=<SPARKRECEIPT_WEBHOOK_TOKEN>`
(Token liegt in /opt/mieterportal/portal.env, Stand 04.10.:
0e8c671ca1c4df3668bac6b2b57602f49ad0f4db8366cc42 – bei Rotation Eintrag in
portal.env ersetzen + `docker compose up -d`). NEUE TABELLE `belege`
(quelle, externe_id, haendler, betrag, waehrung, datum, jahr, kategorie,
notiz, datei_url, zugeordnet, erfasst_am; UNIQUE(quelle, externe_id)).
API: POST /api/belege/webhook (KEINE Session – Token per ?token= ODER
x-webhook-token-Header; ohne gesetztes Env → 503; toleranten Feld-Aliasen –
Payload ggf. unter body.document verschachtelt, Aliase merchant/vendor/total/
amount/date/category/file_url u. a.; delete-Events werden ignoriert;
INSERT ... ON CONFLICT (quelle, externe_id) DO UPDATE → idempotent).
PATCH /api/belege/:id (adminOnly) setzt zugeordnet. Bootstrap liefert belege
nur an die Verwaltung. Frontend: Tabs auf /kosten („Kostenpositionen" /
„Spark-Receipt-Belege", Admin-only) – Info-Box mit der Webhook-URL, Filter
Alle/Nicht zugeordnet, Tabelle mit Übernehmen-Button (öffnet KostenDialog
vorbelegt mit Betrag/Jahr/Notiz „SparkReceipt · Händler (#ID)";
onGespeichert markiert den Beleg zugeordnet) plus Beleg-Link (datei_url).
KostenDialog hat neue Props vorbefuellung/onGespeichert/triggerLabel.
E2E verifiziert (simulierte SR-Payloads): 401 ohne Token, Create, Update
selbe externe_id ohne Duplikat, Jahr-Ableitung aus Datum, Mieter 403, Admin
zugeordnet, Testbeleg per SQL entfernt. OFFEN: echtes Feld-Mapping gegen
ein echtes SparkReceipt-Event prüfen (Event-Log in SR zeigt das Payload –
ggf. Aliase in belegFelder() nachpflegen); Beleg-PDF-Download aus SR
nachrüsten, falls die datei_url ein öffentlicher Link ist.
**Erledigt 04.10. (v1.15):** Struktur „1 Haus → mehrere Wohneinheiten" in der **Erledigt 04.10. (v1.15):** Struktur „1 Haus → mehrere Wohneinheiten" in der
Verwaltung verankert + Bugfix Haus-Umbenennung. BUGFIX (wichtig): der FK Verwaltung verankert + Bugfix Haus-Umbenennung. BUGFIX (wichtig): der FK
`costs_building_fkey` aus v1.14 hatte kein ON UPDATE CASCADE → jede `costs_building_fkey` aus v1.14 hatte kein ON UPDATE CASCADE → jede
+19
View File
@@ -164,6 +164,25 @@ BEGIN
ALTER TABLE costs ADD CONSTRAINT costs_building_fkey ALTER TABLE costs ADD CONSTRAINT costs_building_fkey
FOREIGN KEY (building) REFERENCES buildings(name) ON UPDATE CASCADE; FOREIGN KEY (building) REFERENCES buildings(name) ON UPDATE CASCADE;
END $$; END $$;
-- Gescannte Belege aus SparkReceipt (Cloud-Archiv), per Webhook importiert.
-- (quelle, externe_id) eindeutig → wiederholte Events aktualisieren statt zu duplizieren.
CREATE TABLE IF NOT EXISTS belege (
id TEXT PRIMARY KEY,
quelle TEXT NOT NULL DEFAULT 'sparkreceipt',
externe_id TEXT NOT NULL,
haendler TEXT NOT NULL DEFAULT '',
betrag NUMERIC(10,2),
waehrung TEXT NOT NULL DEFAULT 'EUR',
datum TEXT NOT NULL DEFAULT '',
jahr INT,
kategorie TEXT NOT NULL DEFAULT '',
notiz TEXT NOT NULL DEFAULT '',
datei_url TEXT NOT NULL DEFAULT '',
zugeordnet BOOLEAN NOT NULL DEFAULT FALSE,
erfasst_am TEXT NOT NULL,
UNIQUE (quelle, externe_id)
);
` `
async function init() { async function init() {
+81 -1
View File
@@ -21,6 +21,8 @@ const COOKIE_NAME = 'mp_session'
// KI-Antworten auf Schadensmeldungen über n8n (leer = deaktiviert) // KI-Antworten auf Schadensmeldungen über n8n (leer = deaktiviert)
const N8N_TICKET_WEBHOOK = process.env.N8N_TICKET_WEBHOOK || '' const N8N_TICKET_WEBHOOK = process.env.N8N_TICKET_WEBHOOK || ''
const KI_API_TOKEN = process.env.KI_API_TOKEN || '' const KI_API_TOKEN = process.env.KI_API_TOKEN || ''
// Beleg-Import aus SparkReceipt (leer = Webhook-Endpunkt deaktiviert)
const SPARKRECEIPT_WEBHOOK_TOKEN = process.env.SPARKRECEIPT_WEBHOOK_TOKEN || ''
// PDF-Ablage für das Dokumentenmodul (im Container per Volume persistiert) // PDF-Ablage für das Dokumentenmodul (im Container per Volume persistiert)
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads') const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads')
const UPLOAD_MAX_BYTES = 25 * 1024 * 1024 // 25 MB pro Datei const UPLOAD_MAX_BYTES = 25 * 1024 * 1024 // 25 MB pro Datei
@@ -182,6 +184,12 @@ const mapKosten = (k) => ({
kostenart: k.kostenart, betrag: Number(k.betrag), umlegbar: k.umlegbar, kostenart: k.kostenart, betrag: Number(k.betrag), umlegbar: k.umlegbar,
notiz: k.notiz, erfasstAm: k.erfasst_am, notiz: k.notiz, erfasstAm: k.erfasst_am,
}) })
const mapBeleg = (b) => ({
id: b.id, quelle: b.quelle, externeId: b.externe_id, haendler: b.haendler,
betrag: b.betrag === null ? null : Number(b.betrag), waehrung: b.waehrung,
datum: b.datum, jahr: b.jahr, kategorie: b.kategorie, notiz: b.notiz,
dateiUrl: b.datei_url, zugeordnet: b.zugeordnet, erfasstAm: b.erfasst_am,
})
const deutschesDatum = () => new Date().toLocaleDateString('de-DE') const deutschesDatum = () => new Date().toLocaleDateString('de-DE')
const deutschesDatumZeit = () => new Date().toLocaleString('de-DE') const deutschesDatumZeit = () => new Date().toLocaleString('de-DE')
@@ -285,7 +293,7 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
try { try {
const istAdmin = req.user.rolle === 'verwaltung' const istAdmin = req.user.rolle === 'verwaltung'
const einheit = req.user.einheit_id const einheit = req.user.einheit_id
const [units, transactions, documents, tickets, threads, messages, announcements, users, codes, meters, buildings, costs] = const [units, transactions, documents, tickets, threads, messages, announcements, users, codes, meters, buildings, costs, belege] =
await Promise.all([ await Promise.all([
pool.query('SELECT * FROM units ORDER BY id'), pool.query('SELECT * FROM units ORDER BY id'),
istAdmin istAdmin
@@ -319,6 +327,9 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
istAdmin istAdmin
? pool.query('SELECT * FROM costs ORDER BY jahr DESC, building, kostenart') ? pool.query('SELECT * FROM costs ORDER BY jahr DESC, building, kostenart')
: pool.query('SELECT * FROM costs WHERE unit_id = $1 ORDER BY jahr DESC, kostenart', [einheit]), : pool.query('SELECT * FROM costs WHERE unit_id = $1 ORDER BY jahr DESC, kostenart', [einheit]),
istAdmin
? pool.query('SELECT * FROM belege ORDER BY erfasst_am DESC')
: { rows: [] },
]) ])
// Threads mit ihren Nachrichten zusammenführen // Threads mit ihren Nachrichten zusammenführen
const nachrichtenProThread = {} const nachrichtenProThread = {}
@@ -341,6 +352,7 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
meters: meters.rows.map(mapMeter), meters: meters.rows.map(mapMeter),
buildings: buildings.rows.map(mapBuilding), buildings: buildings.rows.map(mapBuilding),
kosten: costs.rows.map(mapKosten), kosten: costs.rows.map(mapKosten),
belege: belege.rows.map(mapBeleg),
}) })
} catch (e) { next(e) } } catch (e) { next(e) }
}) })
@@ -712,6 +724,74 @@ app.delete('/api/costs/:id', auth, adminOnly, async (req, res, next) => {
} catch (e) { next(e) } } catch (e) { next(e) }
}) })
// ── Beleg-Import (SparkReceipt-Webhook) ──────────────────────
/** Tolerantes Extrahieren der Belegfelder: SparkReceipt schachtelt die
* Daten teils unter „document", teils flach – Aliase abdecken. */
function belegFelder(body) {
const d = (body && typeof body === 'object' && (body.document || body.receipt || body.data)) || body || {}
const nimm = (...keys) => {
for (const k of keys) {
if (d[k] !== undefined && d[k] !== null && d[k] !== '') return d[k]
}
return undefined
}
const betragRoh = nimm('total', 'amount', 'betrag', 'total_amount', 'sum', 'totalAmount')
const datumRoh = String(nimm('date', 'datum', 'purchase_date', 'created_at', 'document_date') ?? '')
const jahrRoh = nimm('year', 'jahr')
return {
externeId: String(nimm('id', 'document_id', 'documentId', 'receipt_id') ?? ''),
haendler: String(nimm('merchant', 'vendor', 'haendler', 'supplier', 'store_name', 'merchant_name', 'name') ?? ''),
betrag: betragRoh === undefined ? null : Number(betragRoh),
waehrung: String(nimm('currency', 'waehrung') ?? 'EUR'),
datum: datumRoh,
jahr: jahrRoh !== undefined ? Number(jahrRoh) : (datumRoh.match(/(19|20)\d{2}/) ? Number(datumRoh.match(/(19|20)\d{2}/)[0]) : null),
kategorie: String(nimm('category', 'kategorie') ?? ''),
notiz: String(nimm('notes', 'note', 'description', 'memo') ?? ''),
dateiUrl: String(nimm('file_url', 'fileUrl', 'image_url', 'receipt_url', 'document_url', 'url') ?? ''),
}
}
app.post('/api/belege/webhook', async (req, res) => {
try {
if (!SPARKRECEIPT_WEBHOOK_TOKEN) return res.status(503).json({ fehler: 'Beleg-Import ist nicht konfiguriert.' })
// SparkReceipt sendet nur eine URL (keine Custom-Headers) → Token per ?token=
const token = String(req.query.token || req.headers['x-webhook-token'] || '')
if (token !== SPARKRECEIPT_WEBHOOK_TOKEN) return res.status(401).json({ fehler: 'Ungültiger Token.' })
const event = String(req.body?.event || req.body?.event_type || req.body?.type || '')
if (/delete/i.test(event)) return res.json({ ok: true, ignoriert: event }) // Löschungen nicht importieren
const f = belegFelder(req.body)
if (!f.externeId) return res.status(400).json({ fehler: 'Keine Dokument-ID im Event.' })
if (f.betrag !== null && !Number.isFinite(f.betrag)) return res.status(400).json({ fehler: 'Betrag ungültig.' })
const { rows } = await pool.query(
`INSERT INTO belege (id, quelle, externe_id, haendler, betrag, waehrung, datum, jahr, kategorie, notiz, datei_url, erfasst_am)
VALUES ($1,'sparkreceipt',$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
ON CONFLICT (quelle, externe_id) DO UPDATE SET
haendler = EXCLUDED.haendler, betrag = EXCLUDED.betrag, waehrung = EXCLUDED.waehrung,
datum = EXCLUDED.datum, jahr = EXCLUDED.jahr, kategorie = EXCLUDED.kategorie,
notiz = EXCLUDED.notiz, datei_url = EXCLUDED.datei_url
RETURNING *`,
[`b-${Date.now()}`, f.externeId, f.haendler, f.betrag, f.waehrung, f.datum, f.jahr,
f.kategorie, f.notiz, f.dateiUrl, deutschesDatumZeit()],
)
res.json({ ok: true, beleg: mapBeleg(rows[0]), event: event || 'unknown' })
} catch (e) {
console.error('[belege] Webhook-Fehler:', e)
res.status(500).json({ fehler: 'Interner Serverfehler.' })
}
})
// Zugeordnet-Markierung für die Verwaltung (nach Übernahme als Kostenposition)
app.patch('/api/belege/:id', auth, adminOnly, async (req, res, next) => {
try {
const zugeordnet = Boolean((req.body || {}).zugeordnet)
const { rows } = await pool.query(
'UPDATE belege SET zugeordnet = $1 WHERE id = $2 RETURNING *', [zugeordnet, req.params.id],
)
if (!rows.length) return res.status(404).json({ fehler: 'Beleg nicht gefunden.' })
res.json({ beleg: mapBeleg(rows[0]) })
} catch (e) { next(e) }
})
// ── Bestand: Häuser & Wohnungen (Verwaltung) ──────────────── // ── Bestand: Häuser & Wohnungen (Verwaltung) ────────────────
function pruefeHaus(b) { function pruefeHaus(b) {
const fehler = [] const fehler = []
+165 -8
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react' 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 { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -28,7 +28,7 @@ import {
TableRow, TableRow,
} from '@/components/ui/table' } from '@/components/ui/table'
import { useStore, fmtEUR } from '../store/store' import { useStore, fmtEUR } from '../store/store'
import type { KostenItem } from '../types' import type { Beleg, KostenItem } from '../types'
const KOSTEN_ARTEN = [ const KOSTEN_ARTEN = [
'Wasser/Abwasser', 'Müllabfuhr', 'Straßenreinigung/Winterdienst', 'Gartenpflege', 'Wasser/Abwasser', 'Müllabfuhr', 'Straßenreinigung/Winterdienst', 'Gartenpflege',
@@ -40,17 +40,24 @@ const KOSTEN_ARTEN = [
/** Komma oder Punkt als Dezimaltrenner akzeptieren. */ /** Komma oder Punkt als Dezimaltrenner akzeptieren. */
const zahl = (s: string) => Number(s.replace(',', '.')) 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 { addKosten, updateKosten, units, buildings } = useStore()
const bearbeiten = Boolean(position) const bearbeiten = Boolean(position)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [kostenart, setKostenart] = useState(position?.kostenart ?? 'Wasser/Abwasser') const [kostenart, setKostenart] = useState(position?.kostenart ?? 'Wasser/Abwasser')
const [jahr, setJahr] = useState(String(position?.jahr ?? new Date().getFullYear())) const [jahr, setJahr] = useState(String(position?.jahr ?? vorbefuellung?.jahr ?? new Date().getFullYear()))
const [betrag, setBetrag] = useState(position ? String(position.betrag).replace('.', ',') : '') const [betrag, setBetrag] = useState(
position ? String(position.betrag).replace('.', ',') : (vorbefuellung?.betrag ?? ''),
)
const [einheit, setEinheit] = useState(position?.unitId ?? '__haus') // '__haus' = ganzes Haus const [einheit, setEinheit] = useState(position?.unitId ?? '__haus') // '__haus' = ganzes Haus
const [haus, setHaus] = useState(position?.building ?? buildings[0]?.name ?? '') const [haus, setHaus] = useState(position?.building ?? buildings[0]?.name ?? '')
const [umlegbar, setUmlegbar] = useState(position?.umlegbar ?? true) 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 [sende, setSende] = useState(false)
const [fehler, setFehler] = useState<string | null>(null) 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) const err = bearbeiten ? await updateKosten(position!.id, payload) : await addKosten(payload)
setSende(false) setSende(false)
if (err) return setFehler(err) if (err) return setFehler(err)
onGespeichert?.()
setOpen(false) setOpen(false)
} }
@@ -82,7 +90,7 @@ function KostenDialog({ position }: { position?: KostenItem }) {
</Button> </Button>
) : ( ) : (
<Button className="gap-2"> <Button className="gap-2">
<Plus className="h-4 w-4" /> Neue Kostenposition <Plus className="h-4 w-4" /> {triggerLabel ?? 'Neue Kostenposition'}
</Button> </Button>
)} )}
</DialogTrigger> </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() { export default function Kosten() {
const { kosten, role, units, buildings, deleteKosten } = useStore() const { kosten, role, units, buildings, deleteKosten } = useStore()
const [tab, setTab] = useState<'kosten' | 'belege'>('kosten')
const [jahrFilter, setJahrFilter] = useState('alle') const [jahrFilter, setJahrFilter] = useState('alle')
const [hausFilter, setHausFilter] = useState('alle') const [hausFilter, setHausFilter] = useState('alle')
const [artFilter, setArtFilter] = useState<'alle' | 'umlegbar' | 'nicht'>('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'} : 'Umlegbare und nicht umlegbare Kosten nach Haus und Wohnung – Grundlage für die Nebenkostenabrechnung'}
</p> </p>
</div> </div>
{role === 'admin' && <KostenDialog />} {role === 'admin' && tab === 'kosten' && <KostenDialog />}
</div> </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="grid gap-4 sm:grid-cols-3">
<div className="rounded-2xl border bg-white p-5 shadow-sm"> <div className="rounded-2xl border bg-white p-5 shadow-sm">
<div className="flex items-center gap-2 text-sm text-slate-500"> <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 umgelegt; nicht umlegbare Positionen trägt die Eigentümerseite. Erfasst von der
Verwaltung, Grundlage der jährlichen Nebenkostenabrechnung. Verwaltung, Grundlage der jährlichen Nebenkostenabrechnung.
</p> </p>
</>
)}
{tab === 'belege' && role === 'admin' && <BelegeListe />}
</div> </div>
) )
} }
+15
View File
@@ -33,6 +33,7 @@ import type {
ZaehlerArt, ZaehlerArt,
Building, Building,
KostenItem, KostenItem,
Beleg,
} from '../types' } from '../types'
import { apiGet, apiPost, apiPatch, apiDelete, ApiFehler } from '../lib/api' import { apiGet, apiPost, apiPatch, apiDelete, ApiFehler } from '../lib/api'
import { sha256, DEMO_PASSWORT } from '../lib/auth' import { sha256, DEMO_PASSWORT } from '../lib/auth'
@@ -53,6 +54,7 @@ interface Bootstrap {
meters: Meter[] meters: Meter[]
buildings: Building[] buildings: Building[]
kosten: KostenItem[] kosten: KostenItem[]
belege: Beleg[]
} }
const LEER: Bootstrap = { const LEER: Bootstrap = {
@@ -67,6 +69,7 @@ const LEER: Bootstrap = {
meters: [], meters: [],
buildings: [], buildings: [],
kosten: [], kosten: [],
belege: [],
} }
interface Store extends Bootstrap { 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 }>, k: Partial<{ building: string; unitId: string | null; jahr: number; kostenart: string; betrag: number; umlegbar: boolean; notiz: string }>,
) => Promise<string | null> ) => Promise<string | null>
deleteKosten: (id: 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) const StoreContext = createContext<Store | null>(null)
@@ -445,6 +450,16 @@ export function StoreProvider({ children }: { children: ReactNode }) {
return fehlerNachricht(e) 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]) }), [daten, bereit, me, role, meineEinheit, ladeAlles])
return <StoreContext.Provider value={value}>{children}</StoreContext.Provider> return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>
+17
View File
@@ -133,3 +133,20 @@ export interface KostenItem {
notiz: string notiz: string
erfasstAm: 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
}