v1.16: SparkReceipt-Schnittstelle - Belege per Webhook importieren und als Kostenposition uebernehmen
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user