Phase 7b: Fahrten, Tanken und Statistik

Listen mit Jahr-Monat-Aufklappung, Detailseiten, manuelles Eintragen und
Beleg-Upload. Statistik rechnet ueber die portierten Regeln.

Zwei offene Audit-Befunde des alten Panels sind hier gleich mit erledigt:

1. Loeschen war ausschliesslich per Wischgeste erreichbar. Wer mit
   Sprachausgabe oder Schaltersteuerung bedient, wischt nicht - Fahrten und
   Tankvorgaenge waren fuer diese Nutzer gar nicht loeschbar. Jede Zeile hat
   jetzt zusaetzlich ein sichtbares Menue, ein richtiger Knopf mit Tastatur.
2. Leaflet kam per CDN und blieb auf einem Handy ohne eigenen Internetzugang
   still leer. Es liegt jetzt im Buendel (eigener Abschnitt, erst bei Bedarf
   geladen).

Bewusst NICHT uebernommen: fakeTrack() aus dem alten Panel, das eine
erfundene Linie zwischen zwei Punkten zeichnete, wenn keine Route vorlag.
Solange das Fahrzeug keine Positionen liefert, sagt die Seite das - statt
eine glaubwuerdig aussehende Erfindung zu zeigen.
This commit is contained in:
Paul Nothaft
2026-08-11 11:11:00 +02:00
parent 4dbf3cb173
commit 8b64a808ed
10 changed files with 1220 additions and 0 deletions
+239
View File
@@ -0,0 +1,239 @@
/**
* 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 `pyscript.audi_dashboard_beleg_ergebnis`.
* Genau dieses Muster hatte auch das alte Panel.
*/
import { useEffect, useRef, useState } from "react"
import { ActionButton, Pill, Tile } from "@audi-dash/ui"
import { ENTITAETEN } from "../api"
import { useDaten } from "../daten/DatenKontext"
import { datum, datumZeit, de, deOderStrich, eur } from "../format"
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
interface BelegErgebnis {
ok?: boolean
fehler?: string
meldung?: string
tank_id?: string
[weitere: string]: unknown
}
export function TankDetail({ id }: { id: string | undefined }) {
const { tankvorgaenge, api } = useDaten()
const tank = tankvorgaenge.find((t) => t.tank_id === id)
if (!tank) {
return (
<Leerzustand
titel="Tankvorgang nicht gefunden"
text="Dieser Eintrag existiert nicht mehr. Vielleicht wurde er gerade gelöscht."
/>
)
}
return (
<>
<Tile>
<span className="ads-eyebrow">{datumZeit(tank.ts)}</span>
<div className="dm-detail__kopf">
<div className="dm-detail__zahl">
{deOderStrich(tank.liters, 2)}
<span className="dm-detail__einheit">l</span>
</div>
<div className="dm-detail__marken">
{tank.status === "unvollständig" && <Pill>unvollständig</Pill>}
{tank.source === "beleg" && <Pill>aus Beleg</Pill>}
{tank.source === "manual" && <Pill>selbst eingetragen</Pill>}
{tank.source === "auto" && <Pill>automatisch erkannt</Pill>}
</div>
</div>
<Werteliste
kinder={
<>
<Wertzeile
label="Gesamtbetrag"
wert={tank.fuel_total_eur != null ? `${eur(tank.fuel_total_eur)}` : "—"}
/>
<Wertzeile
label="Preis je Liter"
wert={tank.price_per_l != null ? `${eur(tank.price_per_l)}` : "—"}
zusatz={
tank.list_price_per_l != null
? `Listenpreis ${eur(tank.list_price_per_l)}`
: undefined
}
/>
{tank.discount != null && tank.discount > 0 && (
<Wertzeile label="Ersparnis" wert={`${eur(tank.discount)}`} />
)}
<Wertzeile label="Tankstelle" wert={tank.station_name ?? "—"} zusatz={tank.station_address ?? undefined} />
<Wertzeile label="Kraftstoff" wert={tank.product_name ?? tank.fuel_type ?? "—"} />
<Wertzeile
label="Kilometerstand"
wert={tank.odometer_km != null ? `${de(tank.odometer_km)} km` : "—"}
/>
<Wertzeile
label="Seit der letzten Tankung"
wert={tank.distance_km != null ? `${de(tank.distance_km)} km` : "—"}
/>
</>
}
/>
</Tile>
<BelegBereich tankId={tank.tank_id} belegDatei={tank.receipt_file ?? null} belegNr={tank.receipt_no ?? null} zeitpunkt={tank.ts} api={api} />
</>
)
}
function BelegBereich({
tankId,
belegDatei,
belegNr,
zeitpunkt,
api,
}: {
tankId: string
belegDatei: string | null
belegNr: string | null
zeitpunkt: string
api: ReturnType<typeof useDaten>["api"]
}) {
const dateiwahl = useRef<HTMLInputElement | null>(null)
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: "Bitte den Beleg als PDF auswählen." })
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 (
<Tile>
<span className="ads-eyebrow">Beleg</span>
{belegDatei ? (
<Werteliste
kinder={
<>
<Wertzeile label="Hinterlegt" wert={belegDatei} />
<Wertzeile label="Beleg-Nummer" wert={belegNr ?? "—"} />
</>
}
/>
) : (
<p className="dm-fussnote">
Für diese Tankung liegt noch kein Beleg vor. Aus einem Shell-Beleg liest die App Menge,
Betrag, Tankstelle und Ersparnis selbst aus.
</p>
)}
<input
ref={dateiwahl}
type="file"
accept="application/pdf,.pdf"
hidden
onChange={(e) => {
const datei = e.target.files?.[0]
if (datei) void hochladen(datei)
e.target.value = ""
}}
/>
{meldung && (
<p
className={meldung.art === "fehler" ? "dm-fehler" : "dm-erfolg"}
role={meldung.art === "fehler" ? "alert" : "status"}
>
{meldung.text}
</p>
)}
<div style={{ marginTop: 14 }}>
<ActionButton onClick={() => dateiwahl.current?.click()} disabled={laeuft || wartet}>
{laeuft ? "Lade hoch …" : wartet ? "Beleg wird gelesen …" : belegDatei ? "Beleg ersetzen" : "Beleg hochladen"}
</ActionButton>
</div>
<p className="dm-fussnote">
Der Beleg muss vom {datum(zeitpunkt)} sein sonst weist der Server ihn ab, damit keine
fremde Tankung in diesen Eintrag gerät.
</p>
</Tile>
)
}
/** 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)
})
}