bdbdb4a010
Die Marke steht auf jedem Beleg, war aber nirgends gespeichert: der Parser erkennt sie seit dem 16.08.2026, faltete sie aber nur in station_name. Fuer jeden Beleg, den eine aeltere Fassung eingelesen hat, blieb dort der Betreibername stehen und die Marke war danach nicht mehr zu holen. - shell_beleg_parser: station_brand als eigenes Feld aus beiden Parserwegen, dazu marke_aus_text()/marke_aus_pdf() - sie lesen nur den Belegkopf und haengen nicht am vollstaendigen Einlesen. - belege.marken_nachtragen(): traegt die Marke einmal beim Start aus der abgelegten Belegdatei nach. Nur wo das Feld gar nicht vorkommt und die Datei liegt; ein einmal eingetragenes Feld wird nie ueberschrieben. - Panel und App: die gespeicherte Marke geht vor, geraten wird nur, wo keine da ist. Ein Teil, der fuer sich genommen eine Kopfmarke ist, taugt zudem nie als Ort. Dazu Punkt 5 der Meldung vom 04.09.2026: ein eingelesener Beleg fuellte nur Datum und Uhrzeit. Der Parser las ihn immer vollstaendig - die App fragte liter/kosten/station/ersparnis/kraftstoff ab, veroeffentlicht werden aber die Namen des Belegs (liters/fuel_total_eur/station_name/discount/fuel_type). Uebereingestimmt hat allein ts. Die Zuordnung steht jetzt als belegFormularwerte() in daten/belegentwurf.ts und wird gegen die echte Nutzlast dieses Belegs geprueft. tsc sauber, 258 Tests (die drei neuen Marken-Tests gegen den alten Stand als scheiternd nachgewiesen), vite build sauber, Panel als Modul geparst, Parser-Suite 11 Tests gegen elf echte Belege. Panel und App liefern fuer elf Faelle byteweise dasselbe. Live in audi_ha_test auf 2026.9.4.21 ohne Traceback: Uebersicht "Shell, Nuernberger Str., Ansbach", Einzelbeleg zweizeilig mit Marke, und der echte geteilte Beleg fuellt durchs Backend das ganze Formular.
498 lines
17 KiB
TypeScript
498 lines
17 KiB
TypeScript
/**
|
||
* Ein einzelner Tankvorgang samt Beleg-Upload. Vorlage: `vFill(id)`.
|
||
*
|
||
* Der Beleg wird als PDF hochgeladen; das Ergebnis kommt **nicht** als
|
||
* Rückgabewert des Dienstaufrufs zurück (pyscript liefert keine), sondern
|
||
* asynchron über die Entität `sensor.audi_dashboard_beleg_ergebnis`.
|
||
* Genau dieses Muster hatte auch das alte Panel.
|
||
*/
|
||
|
||
import { useEffect, useState } from "react"
|
||
|
||
import { ActionButton, Feld, Fig, Tile } from "@audi-dash/ui"
|
||
|
||
import type { Tankvorgang } from "../api"
|
||
import { ENTITAETEN } from "../api"
|
||
import { useDaten } from "../daten/DatenKontext"
|
||
import { kartendienstUrl, tankstelleZeilen } from "../daten/tankstelle"
|
||
import { datum, de, deOderStrich, eur, uhrzeit } from "../format"
|
||
import type { SeitenName } from "../navigation"
|
||
import { KRAFTSTOFFSORTEN, Leerzustand, Wertzeile, Werteliste } from "./bausteine"
|
||
import { LOESCH_HINWEIS, LOESCH_TEXT, bestaetigen } from "./bestaetigung"
|
||
import { markenlogoUrl } from "./bilder"
|
||
import { BelegPopup } from "./BelegPopup"
|
||
import { TankstellenKarte } from "./TankstellenKarte"
|
||
|
||
interface BelegErgebnis {
|
||
ok?: boolean
|
||
fehler?: string
|
||
meldung?: string
|
||
tank_id?: string
|
||
[weitere: string]: unknown
|
||
}
|
||
|
||
export function TankDetail({ id, geheZu }: { id: string | undefined; geheZu: (name: SeitenName) => void }) {
|
||
const { tankvorgaenge, fahrzeug, api } = useDaten()
|
||
const tank = tankvorgaenge.find((t) => t.tank_id === id)
|
||
const [bearbeitenOffen, setzeBearbeitenOffen] = useState(false)
|
||
|
||
if (!tank) {
|
||
return (
|
||
<Leerzustand
|
||
titel="Tankvorgang nicht gefunden"
|
||
text="Dieser Eintrag existiert nicht mehr. Vielleicht wurde er gerade gelöscht."
|
||
/>
|
||
)
|
||
}
|
||
|
||
const loeschen = async () => {
|
||
if (!tank) return
|
||
if (!(await bestaetigen(LOESCH_TEXT.fill, { text: LOESCH_HINWEIS.fill }))) return
|
||
void api.tankvorgangLoeschen(tank.tank_id)
|
||
geheZu("fuel")
|
||
}
|
||
|
||
if (bearbeitenOffen) {
|
||
return <TankBearbeiten tank={tank} beiFertig={() => setzeBearbeitenOffen(false)} />
|
||
}
|
||
|
||
/* Aufbau 1:1 wie vFill(): Karte, randlose „Getankt"-Kachel mit der
|
||
52px-Zahl, dann eine einzige Wertezeilen-Kachel in der Reihenfolge des
|
||
Panels (Zeitpunkt/Kilometerstand/Gefahrene Distanz/Kraftstoff/Getankte
|
||
Liter/Kosten/Preis je Liter/SmartDeal-Ersparnis/Tankstelle) mit den drei
|
||
Knöpfen darunter.
|
||
|
||
Vorher: eigene Kopfzeile mit Zeitstempel und Bearbeiten-Textknopf, Pillen
|
||
für Status und Quelle (die das Panel hier nicht führt), andere Reihenfolge
|
||
und Bezeichnungen („Gesamtbetrag", „Seit der letzten Tankung",
|
||
„Listenpreis") und der Beleg in einer eigenen Kachel. */
|
||
const preisJeLiter =
|
||
tank.liters && tank.fuel_total_eur != null ? tank.fuel_total_eur / tank.liters : null
|
||
|
||
return (
|
||
<>
|
||
<Tile className="dm-kartenkachel">
|
||
<TankstellenKarte
|
||
name={tank.station_name}
|
||
adresse={tank.station_address}
|
||
marke={tank.station_brand}
|
||
/>
|
||
</Tile>
|
||
|
||
<Tile variant="flat" className="dm-distanzkachel">
|
||
<span className="ads-eyebrow">Getankt</span>
|
||
<div style={{ marginTop: "10px" }}>
|
||
<Fig value={deOderStrich(tank.liters, 2)} unit="l" size={52} />
|
||
</div>
|
||
</Tile>
|
||
|
||
<Tile>
|
||
<Werteliste
|
||
kinder={
|
||
<>
|
||
<Wertzeile label="Zeitpunkt" wert={datum(tank.ts)} zusatz={`${uhrzeit(tank.ts)} Uhr`} />
|
||
<Wertzeile
|
||
label="Kilometerstand"
|
||
wert={tank.odometer_km != null ? `${de(tank.odometer_km)} km` : "unbekannt"}
|
||
/>
|
||
<Wertzeile
|
||
label="Gefahrene Distanz"
|
||
wert={tank.distance_km != null ? `${de(tank.distance_km)} km` : "unbekannt"}
|
||
zusatz="seit der letzten Tankung"
|
||
/>
|
||
<Wertzeile label="Kraftstoff" wert={tank.fuel_type ?? KRAFTSTOFFSORTEN[0]} />
|
||
<Wertzeile label="Getankte Liter" wert={deOderStrich(tank.liters, 2) + " l"} />
|
||
<Wertzeile
|
||
label="Kosten"
|
||
wert={tank.fuel_total_eur != null ? `${eur(tank.fuel_total_eur)} €` : "–"}
|
||
zusatz="real, nach Rabatt"
|
||
/>
|
||
<Wertzeile
|
||
label="Preis je Liter"
|
||
wert={preisJeLiter != null ? `${de(preisJeLiter, 2)} €` : "–"}
|
||
zusatz="Kosten ÷ Liter"
|
||
/>
|
||
{fahrzeug?.smartdeal.aktiv && tank.discount != null && (
|
||
<Wertzeile
|
||
label={
|
||
<span className="dm-marke">
|
||
<img
|
||
src={markenlogoUrl()}
|
||
alt=""
|
||
onError={(e) => {
|
||
e.currentTarget.style.display = "none"
|
||
}}
|
||
/>
|
||
SmartDeal-Ersparnis
|
||
</span>
|
||
}
|
||
wert={`${eur(tank.discount)} €`}
|
||
/>
|
||
)}
|
||
<Tankstellenzeile
|
||
name={tank.station_name}
|
||
adresse={tank.station_address}
|
||
marke={tank.station_brand}
|
||
/>
|
||
</>
|
||
}
|
||
/>
|
||
<ActionButton onClick={() => setzeBearbeitenOffen(true)}>Werte bearbeiten</ActionButton>
|
||
<BelegKnopf tankId={tank.tank_id} belegDatei={tank.receipt_file ?? null} api={api} />
|
||
</Tile>
|
||
|
||
<ActionButton variant="destructive" onClick={() => void loeschen()}>
|
||
Tankvorgang löschen
|
||
</ActionButton>
|
||
</>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Tankstelle des Belegs: eigene Zeile über die volle Kachelbreite statt der
|
||
* engen Wertespalte, zweizeilig (Tankstelle und Straße / Ort) und antippbar -
|
||
* der Tipp führt in den Kartendienst des Geräts. Deckungsgleich mit
|
||
* `.tankstellenzeile` im Panel; die Aufteilung stammt vom Eigentümer
|
||
* (04.09.2026).
|
||
*
|
||
* Ohne Ziel bleibt es eine gewöhnliche Zeile: ein Verweis, der nirgendwohin
|
||
* führt, wäre schlechter als keiner.
|
||
*/
|
||
function Tankstellenzeile({
|
||
name,
|
||
adresse,
|
||
marke,
|
||
}: {
|
||
name: string | null | undefined
|
||
adresse: string | null | undefined
|
||
marke?: string | null | undefined
|
||
}) {
|
||
const { oben, unten } = tankstelleZeilen(name, adresse, marke)
|
||
const url = kartendienstUrl(name, adresse, null, "zeigen", marke)
|
||
const inhalt = (
|
||
<>
|
||
<span className="dm-tankstellenzeile__ort">
|
||
{oben}
|
||
{unten && <small>{unten}</small>}
|
||
</span>
|
||
{url && (
|
||
<svg className="dm-blatt__chevron" viewBox="0 0 6 10" aria-hidden="true">
|
||
<polyline points="1,1 5,5 1,9" />
|
||
</svg>
|
||
)}
|
||
</>
|
||
)
|
||
return (
|
||
<div className="dm-tankstellenzeile">
|
||
<dt>Tankstelle</dt>
|
||
<dd>
|
||
{url ? (
|
||
<a
|
||
className="dm-tankstellenzeile__inhalt"
|
||
href={url}
|
||
target="_blank"
|
||
rel="noopener"
|
||
aria-label={`${oben} in Karten öffnen`}
|
||
>
|
||
{inhalt}
|
||
</a>
|
||
) : (
|
||
<span className="dm-tankstellenzeile__inhalt">{inhalt}</span>
|
||
)}
|
||
</dd>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** "YYYY-MM-DDTHH:mm" in Ortszeit für ein <input type="datetime-local">. */
|
||
function alsDatetimeLocal(iso: string): string {
|
||
const d = new Date(iso)
|
||
if (Number.isNaN(d.getTime())) return ""
|
||
const versatz = d.getTimezoneOffset() * 60_000
|
||
return new Date(d.getTime() - versatz).toISOString().slice(0, 16)
|
||
}
|
||
|
||
/** Bearbeiten-Formular für einen bestehenden Tankvorgang - dieselben Felder
|
||
wie tankFelder() im Panel (Zeitpunkt, Kilometerstand, Distanz, Kraftstoff,
|
||
Liter, Kosten, SmartDeal-Ersparnis, Tankstelle). Fehlte hier bisher
|
||
vollständig - der einzige Weg, einen Wert zu korrigieren, war ein neuer
|
||
Beleg-Upload. */
|
||
function TankBearbeiten({ tank, beiFertig }: { tank: Tankvorgang; beiFertig: () => void }) {
|
||
const { api, fahrzeug } = useDaten()
|
||
const [zeitpunkt, setzeZeitpunkt] = useState(alsDatetimeLocal(tank.ts))
|
||
const [km, setzeKm] = useState(tank.odometer_km != null ? String(tank.odometer_km) : "")
|
||
const [distanz, setzeDistanz] = useState(tank.distance_km != null ? String(tank.distance_km) : "")
|
||
const [kraftstoff, setzeKraftstoff] = useState(tank.fuel_type ?? KRAFTSTOFFSORTEN[0])
|
||
const [liter, setzeLiter] = useState(tank.liters != null ? String(tank.liters) : "")
|
||
const [kosten, setzeKosten] = useState(tank.fuel_total_eur != null ? String(tank.fuel_total_eur) : "")
|
||
const [ersparnis, setzeErsparnis] = useState(tank.discount != null ? String(tank.discount) : "")
|
||
const [station, setzeStation] = useState(tank.station_name ?? "")
|
||
const [fehler, setzeFehler] = useState<string | null>(null)
|
||
const [laeuft, setzeLaeuft] = useState(false)
|
||
|
||
const smartdealAktiv = fahrzeug?.smartdeal.aktiv === true
|
||
|
||
const speichern = async () => {
|
||
setzeFehler(null)
|
||
const literZahl = Number(liter.replace(",", "."))
|
||
const kostenZahl = Number(kosten.replace(",", "."))
|
||
if (!Number.isFinite(literZahl) || literZahl <= 0) {
|
||
setzeFehler("Bitte die getankte Menge in Litern angeben.")
|
||
return
|
||
}
|
||
if (!Number.isFinite(kostenZahl) || kostenZahl <= 0) {
|
||
setzeFehler("Bitte den Gesamtbetrag angeben.")
|
||
return
|
||
}
|
||
setzeLaeuft(true)
|
||
try {
|
||
const kmZahl = Number(km.replace(",", "."))
|
||
const distanzZahl = Number(distanz.replace(",", "."))
|
||
const ersparnisZahl = Number(ersparnis.replace(",", "."))
|
||
await api.tankvorgangAktualisieren(tank.tank_id, {
|
||
ts: zeitpunkt ? new Date(zeitpunkt).toISOString() : tank.ts,
|
||
liter: literZahl,
|
||
kosten: kostenZahl,
|
||
kraftstoff,
|
||
station: station || null,
|
||
km: Number.isFinite(kmZahl) && km ? kmZahl : null,
|
||
distanz: Number.isFinite(distanzZahl) && distanz ? distanzZahl : null,
|
||
...(smartdealAktiv
|
||
? { ersparnis: Number.isFinite(ersparnisZahl) && ersparnis ? ersparnisZahl : null }
|
||
: {}),
|
||
})
|
||
beiFertig()
|
||
} catch (ursache) {
|
||
setzeFehler(ursache instanceof Error ? ursache.message : String(ursache))
|
||
} finally {
|
||
setzeLaeuft(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Tile>
|
||
<div className="dm-abschnitt" style={{ marginBottom: 0 }}>
|
||
<span className="ads-eyebrow">Tankvorgang bearbeiten</span>
|
||
<button type="button" className="dm-textknopf" onClick={beiFertig} disabled={laeuft}>
|
||
Abbrechen
|
||
</button>
|
||
</div>
|
||
<Feld label="Zeitpunkt">
|
||
<input
|
||
className="dm-eingabe"
|
||
type="datetime-local"
|
||
value={zeitpunkt}
|
||
onChange={(e) => setzeZeitpunkt(e.target.value)}
|
||
/>
|
||
</Feld>
|
||
<Feld label="Kilometerstand" unit="km">
|
||
<input
|
||
className="dm-eingabe"
|
||
inputMode="numeric"
|
||
placeholder="wird ergänzt"
|
||
value={km}
|
||
onChange={(e) => setzeKm(e.target.value)}
|
||
/>
|
||
</Feld>
|
||
<Feld label="Gefahrene Distanz" unit="km">
|
||
<input
|
||
className="dm-eingabe"
|
||
inputMode="decimal"
|
||
placeholder="aus km-Stand"
|
||
value={distanz}
|
||
onChange={(e) => setzeDistanz(e.target.value)}
|
||
/>
|
||
</Feld>
|
||
<Feld label="Kraftstoff">
|
||
<select
|
||
className="dm-auswahl"
|
||
value={kraftstoff}
|
||
onChange={(e) => setzeKraftstoff(e.target.value)}
|
||
>
|
||
{KRAFTSTOFFSORTEN.map((sorte) => (
|
||
<option key={sorte} value={sorte}>
|
||
{sorte}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</Feld>
|
||
<Feld label="Getankte Liter" unit="l">
|
||
<input
|
||
className="dm-eingabe"
|
||
inputMode="decimal"
|
||
placeholder="0,00"
|
||
value={liter}
|
||
onChange={(e) => setzeLiter(e.target.value)}
|
||
/>
|
||
</Feld>
|
||
<Feld label="Kosten (real, nach Rabatt)" unit="€">
|
||
<input
|
||
className="dm-eingabe"
|
||
inputMode="decimal"
|
||
placeholder="0,00"
|
||
value={kosten}
|
||
onChange={(e) => setzeKosten(e.target.value)}
|
||
/>
|
||
</Feld>
|
||
{smartdealAktiv && (
|
||
<Feld label="SmartDeal-Ersparnis" unit="€">
|
||
<input
|
||
className="dm-eingabe"
|
||
inputMode="decimal"
|
||
placeholder="0,00"
|
||
value={ersparnis}
|
||
onChange={(e) => setzeErsparnis(e.target.value)}
|
||
/>
|
||
</Feld>
|
||
)}
|
||
<Feld label="Tankstelle" last>
|
||
<input
|
||
className="dm-eingabe"
|
||
placeholder="Name"
|
||
value={station}
|
||
onChange={(e) => setzeStation(e.target.value)}
|
||
/>
|
||
</Feld>
|
||
{fehler && (
|
||
<p className="dm-fehler" role="alert">
|
||
{fehler}
|
||
</p>
|
||
)}
|
||
<div style={{ marginTop: 14 }}>
|
||
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
|
||
{laeuft ? "Speichere …" : "Speichern"}
|
||
</ActionButton>
|
||
</div>
|
||
</Tile>
|
||
)
|
||
}
|
||
|
||
/* Im Panel ist der Beleg kein eigener Abschnitt, sondern ein Knopf in der
|
||
Wertezeilen-Kachel ("Beleg ersetzen"/"Beleg nachtragen"). Die frühere
|
||
eigene Kachel mit Dateiname und Beleg-Nummer hatte dort keine
|
||
Entsprechung. */
|
||
function BelegKnopf({
|
||
tankId,
|
||
belegDatei,
|
||
api,
|
||
}: {
|
||
tankId: string
|
||
belegDatei: string | null
|
||
api: ReturnType<typeof useDaten>["api"]
|
||
}) {
|
||
// Wie in Tanken.tsx: erst das Beleg-Fenster, dann der gewaehlte Weg. Am
|
||
// Telefon ist das Einfuegen aus der Zwischenablage der Hauptweg.
|
||
const [belegFensterOffen, setzeBelegFensterOffen] = useState(false)
|
||
const [laeuft, setzeLaeuft] = useState(false)
|
||
const [meldung, setzeMeldung] = useState<{ art: "ok" | "fehler"; text: string } | null>(null)
|
||
|
||
// Das Ergebnis kommt asynchron über eine eigene Entität, nicht als Antwort
|
||
// auf den Dienstaufruf. Nach dem Hochladen wird sie deshalb kurz abgefragt.
|
||
const [wartet, setzeWartet] = useState(false)
|
||
useEffect(() => {
|
||
if (!wartet) return
|
||
let abgebrochen = false
|
||
const frist = Date.now() + 20_000
|
||
|
||
const abfragen = async () => {
|
||
while (!abgebrochen && Date.now() < frist) {
|
||
await new Promise((weiter) => setTimeout(weiter, 1200))
|
||
try {
|
||
const ergebnis = await api.rest.datenLesen<BelegErgebnis>(ENTITAETEN.belegErgebnis)
|
||
if (abgebrochen) return
|
||
if (ergebnis && ergebnis.tank_id === tankId) {
|
||
setzeWartet(false)
|
||
setzeMeldung(
|
||
ergebnis.ok === false || ergebnis.fehler
|
||
? { art: "fehler", text: ergebnis.fehler ?? "Der Beleg konnte nicht gelesen werden." }
|
||
: { art: "ok", text: ergebnis.meldung ?? "Beleg übernommen." },
|
||
)
|
||
return
|
||
}
|
||
} catch {
|
||
// Weiterversuchen — ein einzelner Fehlschlag heißt nichts.
|
||
}
|
||
}
|
||
if (!abgebrochen) {
|
||
setzeWartet(false)
|
||
setzeMeldung({
|
||
art: "fehler",
|
||
text: "Der Server hat noch nicht geantwortet. Der Beleg wird trotzdem verarbeitet — die Seite aktualisiert sich, sobald er da ist.",
|
||
})
|
||
}
|
||
}
|
||
void abfragen()
|
||
return () => {
|
||
abgebrochen = true
|
||
}
|
||
}, [wartet, api, tankId])
|
||
|
||
const hochladen = async (datei: File) => {
|
||
setzeMeldung(null)
|
||
if (datei.type !== "application/pdf" && !datei.name.toLowerCase().endsWith(".pdf")) {
|
||
setzeMeldung({ art: "fehler", text: "Nur PDF-Belege werden unterstützt" })
|
||
return
|
||
}
|
||
setzeLaeuft(true)
|
||
try {
|
||
const base64 = await alsBase64(datei)
|
||
await api.belegHochladen(base64, datei.name, tankId)
|
||
setzeWartet(true)
|
||
} catch (ursache) {
|
||
setzeMeldung({
|
||
art: "fehler",
|
||
text: ursache instanceof Error ? ursache.message : String(ursache),
|
||
})
|
||
} finally {
|
||
setzeLaeuft(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<BelegPopup
|
||
offen={belegFensterOffen}
|
||
beiAbbruch={() => setzeBelegFensterOffen(false)}
|
||
beiDatei={(datei) => {
|
||
setzeBelegFensterOffen(false)
|
||
void hochladen(datei)
|
||
}}
|
||
/>
|
||
{meldung && (
|
||
<p
|
||
className={meldung.art === "fehler" ? "dm-fehler" : "dm-erfolg"}
|
||
role={meldung.art === "fehler" ? "alert" : "status"}
|
||
>
|
||
{meldung.text}
|
||
</p>
|
||
)}
|
||
<ActionButton onClick={() => setzeBelegFensterOffen(true)} disabled={laeuft || wartet}>
|
||
{laeuft
|
||
? "Lade hoch …"
|
||
: wartet
|
||
? "Beleg wird gelesen …"
|
||
: belegDatei
|
||
? "Beleg ersetzen"
|
||
: "Beleg nachtragen"}
|
||
</ActionButton>
|
||
{/* Der Datumshinweis darunter ist entfallen - im Panel steht an dieser
|
||
Stelle nur im Fehlerfall eine Meldung (belegFehler), kein
|
||
dauerhafter Erklärtext. */}
|
||
</>
|
||
)
|
||
}
|
||
|
||
/** Datei als Base64 ohne den `data:`-Vorspann — so erwartet es das Backend. */
|
||
function alsBase64(datei: File): Promise<string> {
|
||
return new Promise((aufloesen, ablehnen) => {
|
||
const leser = new FileReader()
|
||
leser.onload = () => {
|
||
const ergebnis = String(leser.result)
|
||
const komma = ergebnis.indexOf(",")
|
||
aufloesen(komma >= 0 ? ergebnis.slice(komma + 1) : ergebnis)
|
||
}
|
||
leser.onerror = () => ablehnen(new Error("Die Datei konnte nicht gelesen werden."))
|
||
leser.readAsDataURL(datei)
|
||
})
|
||
}
|