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