Files
audi-app/companion-app/src/screens/TankDetail.tsx
T
tobias bdbdb4a010 Tankstellenmarke als eigenes Feld, Belegfelder in der App richtig zugeordnet
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.
2026-09-04 17:00:22 +02:00

498 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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)
})
}