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:
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user