v1.17+v1.18: Beleg-Auto-Zuordnung (Zaehlernummer/Adresse) + eigener OCR-Container (Tesseract)
This commit is contained in:
+56
-5
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Calculator, Receipt, ReceiptText, PiggyBank, Plus, Pencil, Trash2, Coins, FileScan, Check, ExternalLink } from 'lucide-react'
|
||||
import { Calculator, Receipt, ReceiptText, PiggyBank, Plus, Pencil, Trash2, Coins, FileScan, Check, ExternalLink, ScanSearch } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -42,7 +42,7 @@ const zahl = (s: string) => Number(s.replace(',', '.'))
|
||||
|
||||
function KostenDialog({ position, vorbefuellung, onGespeichert, triggerLabel }: {
|
||||
position?: KostenItem
|
||||
vorbefuellung?: { betrag?: string; jahr?: string; notiz?: string }
|
||||
vorbefuellung?: { betrag?: string; jahr?: string; notiz?: string; unitId?: string | null; building?: string }
|
||||
onGespeichert?: () => void
|
||||
triggerLabel?: string
|
||||
}) {
|
||||
@@ -54,8 +54,8 @@ function KostenDialog({ position, vorbefuellung, onGespeichert, triggerLabel }:
|
||||
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 [einheit, setEinheit] = useState(position?.unitId ?? vorbefuellung?.unitId ?? '__haus') // '__haus' = ganzes Haus
|
||||
const [haus, setHaus] = useState(position?.building ?? vorbefuellung?.building ?? buildings[0]?.name ?? '')
|
||||
const [umlegbar, setUmlegbar] = useState(position?.umlegbar ?? true)
|
||||
const [notiz, setNotiz] = useState(position?.notiz ?? vorbefuellung?.notiz ?? '')
|
||||
const [sende, setSende] = useState(false)
|
||||
@@ -195,8 +195,9 @@ function KostenDialog({ position, vorbefuellung, onGespeichert, triggerLabel }:
|
||||
|
||||
/** Eingegangene SparkReceipt-Belege (nur Verwaltung). */
|
||||
function BelegeListe() {
|
||||
const { belege, setBelegZugeordnet } = useStore()
|
||||
const { belege, setBelegZugeordnet, analysiereBeleg, units } = useStore()
|
||||
const [nurOffene, setNurOffene] = useState(false)
|
||||
const [analysiere, setAnalysiere] = useState<string | null>(null)
|
||||
|
||||
const zeilen = useMemo(
|
||||
() => (nurOffene ? belege.filter((b) => !b.zugeordnet) : belege),
|
||||
@@ -204,6 +205,24 @@ function BelegeListe() {
|
||||
)
|
||||
const offen = belege.filter((b) => !b.zugeordnet).length
|
||||
|
||||
const zuordnungLabel = (b: Beleg) => {
|
||||
const whg = b.unitId ? (() => {
|
||||
const u = units.find((x) => x.id === b.unitId)
|
||||
return u ? `Whg. ${u.number}` : b.unitId
|
||||
})() : null
|
||||
const ziel = whg ? `${b.gebaeude ?? ''}, ${whg}` : (b.gebaeude ?? null)
|
||||
const weg = b.zuordnung === 'zaehlernummer' ? 'Zählernummer'
|
||||
: b.zuordnung === 'adresse' ? 'Adresse'
|
||||
: b.zuordnung === 'manuell' ? 'Manuell' : null
|
||||
return { ziel, weg }
|
||||
}
|
||||
|
||||
const analysieren = async (id: string) => {
|
||||
setAnalysiere(id)
|
||||
await analysiereBeleg(id)
|
||||
setAnalysiere(null)
|
||||
}
|
||||
|
||||
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">
|
||||
@@ -212,6 +231,9 @@ function BelegeListe() {
|
||||
</div>
|
||||
<p className="mt-1">
|
||||
Gescannte Belege aus dem SparkReceipt-Cloudarchiv landen automatisch in dieser Liste.
|
||||
Die Zuordnung zur Wohneinheit erfolgt primär über die <strong>Stromzählernummer</strong>,
|
||||
sekundär über die <strong>Adresse</strong> – zuerst im übertragenen Text, sonst per OCR-Analyse
|
||||
des Belegs (Button „Analysieren").
|
||||
In SparkReceipt unter <em>Integrationen → Webhooks</em> diese URL hinterlegen
|
||||
(Token liegt in der Server-Konfiguration <code>portal.env</code>):
|
||||
</p>
|
||||
@@ -252,6 +274,7 @@ function BelegeListe() {
|
||||
<TableHead>Händler</TableHead>
|
||||
<TableHead>Datum</TableHead>
|
||||
<TableHead>Kategorie (SparkReceipt)</TableHead>
|
||||
<TableHead>Zuordnung</TableHead>
|
||||
<TableHead className="text-right">Betrag</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Aktion</TableHead>
|
||||
@@ -269,6 +292,19 @@ function BelegeListe() {
|
||||
{b.jahr && <span className="text-xs text-slate-400"> · {b.jahr}</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-500">{b.kategorie || '–'}</TableCell>
|
||||
<TableCell className="text-slate-500">
|
||||
{(() => {
|
||||
const { ziel, weg } = zuordnungLabel(b)
|
||||
return ziel ? (
|
||||
<>
|
||||
<div className="text-sm font-medium text-slate-700">{ziel}</div>
|
||||
{weg && <div className="text-xs text-slate-400">erkannt per {weg}</div>}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-slate-400">nicht zugeordnet</span>
|
||||
)
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-semibold">
|
||||
{b.betrag === null ? '–' : `${b.betrag.toLocaleString('de-DE', { minimumFractionDigits: 2 })} ${b.waehrung}`}
|
||||
</TableCell>
|
||||
@@ -283,6 +319,19 @@ function BelegeListe() {
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{b.dateiUrl && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
disabled={analysiere === b.id}
|
||||
onClick={() => analysieren(b.id)}
|
||||
title="Beleg per OCR analysieren und Zuordnung aktualisieren"
|
||||
>
|
||||
<ScanSearch className="h-3.5 w-3.5" />
|
||||
{analysiere === b.id ? 'Analysiere …' : 'Analysieren'}
|
||||
</Button>
|
||||
)}
|
||||
{!b.zugeordnet && (
|
||||
<KostenDialog
|
||||
triggerLabel="Übernehmen"
|
||||
@@ -290,6 +339,8 @@ function BelegeListe() {
|
||||
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})`,
|
||||
unitId: b.unitId,
|
||||
building: b.gebaeude ?? undefined,
|
||||
}}
|
||||
onGespeichert={() => setBelegZugeordnet(b.id, true)}
|
||||
/>
|
||||
|
||||
@@ -162,6 +162,8 @@ interface Store extends Bootstrap {
|
||||
deleteKosten: (id: string) => Promise<string | null>
|
||||
// ── Belege (SparkReceipt-Import; Verwaltung ordnet zu) ──
|
||||
setBelegZugeordnet: (id: string, zugeordnet: boolean) => Promise<string | null>
|
||||
/** Beleg erneut per OCR analysieren und Zuordnung aktualisieren. */
|
||||
analysiereBeleg: (id: string) => Promise<string | null>
|
||||
}
|
||||
|
||||
const StoreContext = createContext<Store | null>(null)
|
||||
@@ -460,6 +462,15 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
||||
return fehlerNachricht(e)
|
||||
}
|
||||
},
|
||||
async analysiereBeleg(id) {
|
||||
try {
|
||||
const { beleg } = await apiPost<{ beleg: Beleg }>(`/api/belege/${id}/analysieren`)
|
||||
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>
|
||||
|
||||
@@ -149,4 +149,7 @@ export interface Beleg {
|
||||
dateiUrl: string
|
||||
zugeordnet: boolean // bereits als Kostenposition übernommen
|
||||
erfasstAm: string
|
||||
unitId: string | null // Auto-/Manuell-Zuordnung zur Wohneinheit
|
||||
gebaeude: string | null // zugeordnetes Haus
|
||||
zuordnung: 'zaehlernummer' | 'adresse' | 'manuell' | null // Zuordnungsweg
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user