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
+44
View File
@@ -18,6 +18,20 @@ import { HassRest } from "./rest.ts";
import { HassLive } from "./live.ts";
import { Warteschlange } from "./warteschlange.ts";
/** Eingabefelder für Tankvorgänge. Die Namen sind die des Backends
(belegverarbeitung.py), nicht die des Datensatzes - deshalb "liter" statt
"liters" und "kosten" statt "fuel_total_eur". */
export interface TankvorgangFelder {
ts?: string;
liter?: number | null;
kosten?: number | null;
km?: number | null;
ersparnis?: number | null;
station?: string | null;
distanz?: number | null;
kraftstoff?: string | null;
}
/** Bündelt die drei Bausteine und bildet die Fachvorgänge ab, die das alte
Dashboard über hass.callService erledigt hat. */
export class DataMetricApi {
@@ -78,6 +92,16 @@ export class DataMetricApi {
);
}
/** Legt eine Fahrt von Hand an — für Fahrten ohne automatische Erkennung. */
fahrtAnlegen(tsStart: string, tsEnde: string, art: "privat" | "arbeitsweg"): Promise<unknown> {
return this.warteschlange.einreihen(
"pyscript",
"audi_dashboard_fahrt_manuell_anlegen",
{ ts_start: tsStart, ts_end: tsEnde, art },
"Fahrt eintragen",
);
}
fahrtLoeschen(tripId: string): Promise<unknown> {
return this.warteschlange.einreihen(
"pyscript",
@@ -87,6 +111,26 @@ export class DataMetricApi {
);
}
/** Felder eines Tankvorgangs, wie sie das Backend erwartet (deutsche
Parameternamen — belegverarbeitung.py). */
tankvorgangAnlegen(felder: TankvorgangFelder): Promise<unknown> {
return this.warteschlange.einreihen(
"pyscript",
"audi_dashboard_tankvorgang_manuell",
{ ...felder },
"Tankvorgang eintragen",
);
}
tankvorgangAktualisieren(tankId: string, felder: TankvorgangFelder): Promise<unknown> {
return this.warteschlange.einreihen(
"pyscript",
"audi_dashboard_tankvorgang_aktualisieren",
{ tank_id: tankId, ...felder },
"Tankvorgang ändern",
);
}
tankvorgangLoeschen(tankId: string): Promise<unknown> {
return this.warteschlange.einreihen(
"pyscript",
+111
View File
@@ -0,0 +1,111 @@
/**
* Eine einzelne Fahrt. Vorlage: `vTrip(id)` im alten Panel — mit einem
* bewussten Unterschied:
*
* Das alte Panel zeichnete über `fakeTrack()` eine **erfundene** Linie
* zwischen zwei Punkten, wenn keine echte Route vorlag. Das ist hier nicht
* übernommen. Solange das Fahrzeug keine Positionsdaten liefert (bis zum
* FMM003 liefert es keine), zeigt die Seite ehrlich, dass keine Route bekannt
* ist, statt eine glaubwürdig aussehende Erfindung zu zeigen.
*/
import { Pill, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { datum, dauer, de, deOderStrich, uhrzeit } from "../format"
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
import { Karte } from "./Karte"
export function FahrtDetail({ id }: { id: string | undefined }) {
const { fahrten } = useDaten()
const fahrt = fahrten.find((f) => f.trip_id === id)
if (!fahrt) {
return (
<Leerzustand
titel="Fahrt nicht gefunden"
text="Dieser Eintrag existiert nicht mehr. Vielleicht wurde er gerade gelöscht."
/>
)
}
const hatPositionen = fahrt.start_lat != null && fahrt.start_lon != null
return (
<>
<Tile>
<span className="ads-eyebrow">
{datum(fahrt.ts_start)} · {uhrzeit(fahrt.ts_start)} bis {uhrzeit(fahrt.ts_end)} Uhr
</span>
<div className="dm-detail__kopf">
<div className="dm-detail__zahl">
{deOderStrich(fahrt.distance_km, 1)}
<span className="dm-detail__einheit">km</span>
</div>
<div className="dm-detail__marken">
{fahrt.art === "arbeitsweg" ? <Pill variant="work">Arbeitsweg</Pill> : <Pill>Privat</Pill>}
{fahrt.status === "offen" && <Pill>noch offen</Pill>}
{fahrt.source === "manual" && <Pill>selbst eingetragen</Pill>}
</div>
</div>
<Werteliste
kinder={
<>
<Wertzeile label="Dauer" wert={dauer(fahrt.duration_s)} />
<Wertzeile
label="Kilometerstand Beginn"
wert={fahrt.odo_start != null ? `${de(fahrt.odo_start)} km` : "—"}
/>
<Wertzeile
label="Kilometerstand Ende"
wert={fahrt.odo_end != null ? `${de(fahrt.odo_end)} km` : "—"}
/>
<Wertzeile
label="Durchschnitt"
wert={
fahrt.distance_km != null && fahrt.duration_s > 0
? `${de((fahrt.distance_km / fahrt.duration_s) * 3600, 1)} km/h`
: "—"
}
/>
</>
}
/>
</Tile>
{fahrt.status === "offen" && (
<p className="dm-fussnote">
Diese Fahrt wartet noch auf den Kilometerstand. Das Fahrzeug meldet ihn oft erst bei der
nächsten Fahrt die Strecke wird dann automatisch ergänzt.
</p>
)}
<Tile>
<span className="ads-eyebrow">Strecke</span>
{hatPositionen ? (
<Karte
start={{ lat: fahrt.start_lat!, lon: fahrt.start_lon! }}
{...(fahrt.end_lat != null && fahrt.end_lon != null
? { ziel: { lat: fahrt.end_lat, lon: fahrt.end_lon } }
: {})}
/>
) : (
<Leerzustand
titel="Keine Route aufgezeichnet"
text="Das Fahrzeug liefert derzeit keine Positionsdaten. Sobald es das tut, erscheint hier die gefahrene Strecke auf der Karte."
/>
)}
{(fahrt.start_address || fahrt.end_address) && (
<Werteliste
kinder={
<>
<Wertzeile label="Start" wert={fahrt.start_address ?? "—"} />
<Wertzeile label="Ziel" wert={fahrt.end_address ?? "—"} />
</>
}
/>
)}
</Tile>
</>
)
}
+174
View File
@@ -0,0 +1,174 @@
/**
* Fahrtenliste, nach Jahr und Monat aufklappbar. Vorlage: `vTrips()` und
* `vTripsFormular()` im alten Panel.
*/
import { useState } from "react"
import { Accordion, ActionButton, Feld, Pill, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { nachJahrUndMonat } from "../daten/statistik"
import { datum, dauer, de, deOderStrich, monatJahr, summe, uhrzeit } from "../format"
import type { SeitenName } from "../navigation"
import { Leerzustand } from "./bausteine"
import { Zeilenmenue } from "./Zeilenmenue"
export function Fahrten({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
const { fahrten, api } = useDaten()
const [formularOffen, setzeFormularOffen] = useState(false)
const gruppen = nachJahrUndMonat(fahrten, (f) => f.ts_start)
return (
<>
<div className="dm-abschnitt">
<span className="ads-eyebrow">
{fahrten.length === 0
? "Keine Fahrten"
: `${de(fahrten.length)} Fahrt${fahrten.length === 1 ? "" : "en"}`}
</span>
<button
type="button"
className="dm-textknopf"
onClick={() => setzeFormularOffen((offen) => !offen)}
>
{formularOffen ? "Abbrechen" : "Fahrt eintragen"}
</button>
</div>
{formularOffen && <FahrtFormular beiFertig={() => setzeFormularOffen(false)} />}
{fahrten.length === 0 && !formularOffen && (
<Leerzustand
titel="Noch keine Fahrten"
text="Die App erkennt Fahrten automatisch, sobald sich das Fahrzeug meldet. Bis dahin kannst du Fahrten auch selbst eintragen."
aktion={{ beschriftung: "Fahrt eintragen", beiKlick: () => setzeFormularOffen(true) }}
/>
)}
{gruppen.map((jahr) => (
<Accordion
key={jahr.jahr}
title={String(jahr.jahr)}
summary={`${de(summe(jahr.eintraege, (f) => f.distance_km ?? 0))} km`}
defaultOpen={jahr === gruppen[0]}
>
{jahr.monate.map((monat) => (
<Accordion
key={monat.schluessel}
level={2}
title={monatJahr(new Date(monat.jahr, monat.monat, 1))}
summary={`${de(summe(monat.eintraege, (f) => f.distance_km ?? 0))} km`}
defaultOpen={monat === jahr.monate[0]}
>
{monat.eintraege.map((fahrt) => (
<Zeilenmenue
key={fahrt.trip_id}
loeschFrage={`Fahrt vom ${datum(fahrt.ts_start)} löschen?`}
beiLoeschen={() => void api.fahrtLoeschen(fahrt.trip_id)}
kinder={
<button
type="button"
className="dm-listenzeile"
onClick={() => geheZu("trip", fahrt.trip_id)}
>
<span className="dm-listenzeile__haupt">
<span className="dm-listenzeile__titel">
{deOderStrich(fahrt.distance_km, 1)} km
</span>
<span className="dm-listenzeile__unter">
{datum(fahrt.ts_start)} · {uhrzeit(fahrt.ts_start)} Uhr ·{" "}
{dauer(fahrt.duration_s)}
</span>
</span>
{fahrt.art === "arbeitsweg" && <Pill variant="work">Arbeitsweg</Pill>}
{fahrt.status === "offen" && <Pill>offen</Pill>}
</button>
}
/>
))}
</Accordion>
))}
</Accordion>
))}
</>
)
}
/** Manuelles Eintragen — für Fahrten, die die automatische Erkennung verpasst hat. */
function FahrtFormular({ beiFertig }: { beiFertig: () => void }) {
const { api } = useDaten()
const [start, setzeStart] = useState("")
const [ende, setzeEnde] = useState("")
const [art, setzeArt] = useState<"privat" | "arbeitsweg">("privat")
const [fehler, setzeFehler] = useState<string | null>(null)
const [laeuft, setzeLaeuft] = useState(false)
const speichern = async () => {
setzeFehler(null)
if (!start || !ende) {
setzeFehler("Bitte Beginn und Ende angeben.")
return
}
if (new Date(ende) <= new Date(start)) {
setzeFehler("Das Ende muss nach dem Beginn liegen.")
return
}
setzeLaeuft(true)
try {
await api.fahrtAnlegen(new Date(start).toISOString(), new Date(ende).toISOString(), art)
beiFertig()
} catch (ursache) {
setzeFehler(ursache instanceof Error ? ursache.message : String(ursache))
} finally {
setzeLaeuft(false)
}
}
return (
<Tile>
<span className="ads-eyebrow">Fahrt eintragen</span>
<Feld label="Beginn">
<input
className="dm-eingabe"
type="datetime-local"
value={start}
onChange={(e) => setzeStart(e.target.value)}
/>
</Feld>
<Feld label="Ende">
<input
className="dm-eingabe"
type="datetime-local"
value={ende}
onChange={(e) => setzeEnde(e.target.value)}
/>
</Feld>
<Feld label="Art" last>
<select
className="dm-auswahl"
value={art}
onChange={(e) => setzeArt(e.target.value as "privat" | "arbeitsweg")}
>
<option value="privat">Privat</option>
<option value="arbeitsweg">Arbeitsweg</option>
</select>
</Feld>
{fehler && (
<p className="dm-fehler" role="alert">
{fehler}
</p>
)}
<p className="dm-fussnote">
Die Strecke ergänzt die App selbst, sobald sie den Kilometerstand zu Beginn und Ende der
Fahrt kennt.
</p>
<div style={{ marginTop: 14 }}>
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
{laeuft ? "Speichere …" : "Speichern"}
</ActionButton>
</div>
</Tile>
)
}
+81
View File
@@ -0,0 +1,81 @@
/**
* Karte für Fahrtdetails.
*
* Leaflet kommt hier **aus dem Bündel**, nicht von einem CDN. Im alten Panel
* wurde es zur Laufzeit nachgeladen, was auf einem Handy ohne eigenen
* Internetzugang (nur über den VPN-Tunnel erreichbar) stillschweigend nichts
* anzeigte — ein offener Befund des Audits. Die Kacheln selbst bleiben
* zwangsläufig entfernt; fehlen sie, bleibt wenigstens die Karte samt Markern
* bedienbar statt leer.
*/
import { useEffect, useRef } from "react"
import "leaflet/dist/leaflet.css"
import { useTheme } from "../theme"
export interface Punkt {
lat: number
lon: number
}
const KACHELN = {
nacht: {
url: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png",
zusatz: "© OpenStreetMap, © CARTO",
},
tag: {
url: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png",
zusatz: "© OpenStreetMap, © CARTO",
},
} as const
export function Karte({ start, ziel }: { start: Punkt; ziel?: Punkt }) {
const behaelter = useRef<HTMLDivElement | null>(null)
const [theme] = useTheme()
useEffect(() => {
const element = behaelter.current
if (!element) return
let karte: import("leaflet").Map | null = null
let abgebrochen = false
// Dynamisch geladen, damit Leaflet nicht im Startbündel jeder Seite liegt —
// gebündelt wird es trotzdem, es kommt aus node_modules, nicht aus dem Netz.
void import("leaflet").then((L) => {
if (abgebrochen) return
karte = L.map(element, { attributionControl: true, scrollWheelZoom: false })
const kachel = KACHELN[theme]
L.tileLayer(kachel.url, { attribution: kachel.zusatz, maxZoom: 19 }).addTo(karte)
const punkte: [number, number][] = [[start.lat, start.lon]]
L.circleMarker([start.lat, start.lon], { radius: 6, color: "#F50537" })
.bindTooltip("Start")
.addTo(karte)
if (ziel) {
punkte.push([ziel.lat, ziel.lon])
L.circleMarker([ziel.lat, ziel.lon], { radius: 6, color: "#F50537", fill: true })
.bindTooltip("Ziel")
.addTo(karte)
// Verbindungslinie NUR zwischen echten Punkten, kein erfundener Verlauf.
L.polyline(punkte, { color: "#F50537", weight: 2, dashArray: "4 6" }).addTo(karte)
}
if (punkte.length > 1) {
karte.fitBounds(punkte, { padding: [24, 24] })
} else {
karte.setView(punkte[0]!, 14)
}
})
return () => {
abgebrochen = true
karte?.remove()
}
}, [start, ziel, theme])
return <div ref={behaelter} className="dm-karte" role="img" aria-label="Karte der Fahrt" />
}
+155
View File
@@ -0,0 +1,155 @@
/**
* Statistik. Vorlage: `vStat()` im alten Panel — die Rechenregeln stecken in
* `daten/statistik.ts` und sind von dort unverändert übernommen.
*/
import { Fig, ProgressBar, StatGrid, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { ZEITRAEUME, ZEITRAUM_NAME, jahresauswertung } from "../daten/statistik"
import { datum, de, deOderStrich, eur } from "../format"
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
export function Statistik() {
const { fahrten, tankvorgaenge, einstellungen } = useDaten()
if (!einstellungen) return null
if (fahrten.length === 0 && tankvorgaenge.length === 0) {
return (
<Leerzustand
titel="Noch nichts auszuwerten"
text="Sobald die ersten Fahrten und Tankungen erfasst sind, entstehen hier Auswertungen zu Strecke, Zeit, Verbrauch und Kosten."
/>
)
}
const a = jahresauswertung(
fahrten,
tankvorgaenge,
einstellungen.nachtVon,
einstellungen.nachtBis,
)
return (
<>
<Tile>
<span className="ads-eyebrow">Distanz und Fahrzeit {a.jahr}</span>
<Fig value={de(a.kmJahr)} unit="km" size={40} />
<StatGrid
items={ZEITRAEUME.map((z) => ({
value: de(a.jeZeitraum[z].km),
unit: "km",
caption: ZEITRAUM_NAME[z],
}))}
columns={4}
/>
<StatGrid
items={ZEITRAEUME.map((z) => ({
value: de(a.jeZeitraum[z].stunden, z === "jahr" || z === "monat" ? 0 : 1),
unit: "h",
caption: ZEITRAUM_NAME[z],
}))}
columns={4}
/>
</Tile>
<Tile>
<span className="ads-eyebrow">Getankte Menge und Kosten {a.jahr}</span>
<Fig value={de(a.literJahr)} unit="l" size={40} />
<StatGrid
items={ZEITRAEUME.map((z) => ({
value: de(a.jeZeitraum[z].liter),
unit: "l",
caption: ZEITRAUM_NAME[z],
}))}
columns={4}
/>
<StatGrid
items={ZEITRAEUME.map((z) => ({
value: de(a.jeZeitraum[z].kosten),
unit: "€",
caption: ZEITRAUM_NAME[z],
}))}
columns={4}
/>
</Tile>
<Tile>
<span className="ads-eyebrow">Langzeitverbrauch</span>
<Fig value={deOderStrich(a.langzeit.wert, 1)} unit="l/100 km" size={40} />
<Werteliste
kinder={
<>
<Wertzeile label="Getankt insgesamt" wert={`${de(a.langzeit.liter, 1)} l`} />
<Wertzeile
label="Strecke bis zum letzten Tankstop"
wert={`${de(a.langzeit.km)} km`}
zusatz={a.langzeit.basis !== "" ? a.langzeit.basis : undefined}
/>
<Wertzeile
label="Letzter Tankstop"
wert={a.langzeit.bis ? datum(a.langzeit.bis) : "—"}
/>
</>
}
/>
{a.langzeit.unplausibel && (
<p className="dm-fussnote dm-fussnote--warnung">
Aus Litern und Strecke ergibt sich derzeit kein plausibler Wert. Sobald Tankvorgänge und
Kilometerstände vollständig vorliegen, erscheint er hier.
</p>
)}
<p className="dm-fussnote">
Der Langzeitwert rechnet alle je getankten Liter gegen die Strecke bis zum letzten
Tankstop nicht Liter und Kilometer desselben Zeitraums, weil das unmögliche Werte
ergibt, sobald eine Fahrt fehlt.
</p>
</Tile>
<Tile>
<span className="ads-eyebrow">Tag und Nacht {a.jahr}</span>
<Fig
value={a.kmJahr > 0 ? de(Math.round((a.tagKm / a.kmJahr) * 100)) : "—"}
unit="% bei Tag"
size={40}
/>
<ProgressBar
segments={[
{ value: a.tagKm, color: "var(--fg)", label: `Tag · ${de(a.tagKm)} km` },
{ value: a.nachtKm, color: "var(--fg3)", label: `Nacht · ${de(a.nachtKm)} km` },
]}
/>
<p className="dm-fussnote">
Nacht = {einstellungen.nachtVon} bis {einstellungen.nachtBis} Uhr
</p>
</Tile>
<Tile>
<span className="ads-eyebrow">Art der Fahrten {a.jahr}</span>
<Fig
value={a.kmJahr > 0 ? de(Math.round((a.arbeitKm / a.kmJahr) * 100)) : "—"}
unit="% Arbeitsweg"
size={40}
/>
<ProgressBar
segments={[
{ value: a.arbeitKm, color: "var(--red)", label: `Arbeitsweg · ${de(a.arbeitKm)} km` },
{ value: a.privatKm, color: "var(--fg3)", label: `Privat · ${de(a.privatKm)} km` },
]}
/>
<Werteliste
kinder={
<>
<Wertzeile label="Fahrten Arbeitsweg" wert={de(a.arbeitFahrten)} />
<Wertzeile label="Fahrten privat" wert={de(a.privatFahrten)} />
<Wertzeile
label={`Kraftstoffkosten ${a.jahr}`}
wert={`${eur(a.kostenJahr)}`}
/>
</>
}
/>
</Tile>
</>
)
}
+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)
})
}
+212
View File
@@ -0,0 +1,212 @@
/**
* Tankvorgänge, nach Jahr und Monat aufklappbar. Vorlage: `vFuel()`.
*
* Der Durchschnittspreis ist immer mengengewichtet (Summe Kosten geteilt durch
* Summe Liter) — eine kleine teure Tankung darf den Schnitt nicht so stark
* heben wie eine große.
*/
import { useState } from "react"
import { Accordion, ActionButton, Feld, Pill, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { nachJahrUndMonat } from "../daten/statistik"
import { datum, de, deOderStrich, eur, monatJahr, schnittpreis, summe } from "../format"
import type { SeitenName } from "../navigation"
import { Leerzustand } from "./bausteine"
import { Zeilenmenue } from "./Zeilenmenue"
export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
const { tankvorgaenge, api } = useDaten()
const [formularOffen, setzeFormularOffen] = useState(false)
const gruppen = nachJahrUndMonat(tankvorgaenge, (t) => t.ts)
return (
<>
<div className="dm-abschnitt">
<span className="ads-eyebrow">
{tankvorgaenge.length === 0
? "Keine Tankvorgänge"
: `${de(tankvorgaenge.length)} Tankvorgang${tankvorgaenge.length === 1 ? "" : "̈e"}`}
</span>
<button
type="button"
className="dm-textknopf"
onClick={() => setzeFormularOffen((offen) => !offen)}
>
{formularOffen ? "Abbrechen" : "Tankung eintragen"}
</button>
</div>
{formularOffen && <TankFormular beiFertig={() => setzeFormularOffen(false)} />}
{tankvorgaenge.length === 0 && !formularOffen && (
<Leerzustand
titel="Noch keine Tankvorgänge"
text="Die App erkennt Tankungen automatisch am steigenden Füllstand und vervollständigt sie aus dem hochgeladenen Beleg. Du kannst sie auch selbst eintragen."
aktion={{ beschriftung: "Tankung eintragen", beiKlick: () => setzeFormularOffen(true) }}
/>
)}
{gruppen.map((jahr) => (
<Accordion
key={jahr.jahr}
title={String(jahr.jahr)}
summary={`${eur(summe(jahr.eintraege, (t) => t.fuel_total_eur ?? 0))}`}
defaultOpen={jahr === gruppen[0]}
>
{jahr.monate.map((monat) => {
const schnitt = schnittpreis(monat.eintraege)
return (
<Accordion
key={monat.schluessel}
level={2}
title={monatJahr(new Date(monat.jahr, monat.monat, 1))}
summary={schnitt > 0 ? `${eur(schnitt)} €/l` : "—"}
defaultOpen={monat === jahr.monate[0]}
>
{monat.eintraege.map((tank) => (
<Zeilenmenue
key={tank.tank_id}
loeschFrage={`Tankvorgang vom ${datum(tank.ts)} löschen?`}
beiLoeschen={() => void api.tankvorgangLoeschen(tank.tank_id)}
kinder={
<button
type="button"
className="dm-listenzeile"
onClick={() => geheZu("fill", tank.tank_id)}
>
<span className="dm-listenzeile__haupt">
<span className="dm-listenzeile__titel">
{deOderStrich(tank.liters, 2)} l
{tank.fuel_total_eur != null && (
<span className="dm-listenzeile__neben">
{eur(tank.fuel_total_eur)}
</span>
)}
</span>
<span className="dm-listenzeile__unter">
{datum(tank.ts)}
{tank.station_name ? ` · ${tank.station_name}` : ""}
{tank.price_per_l != null ? ` · ${eur(tank.price_per_l)} €/l` : ""}
</span>
</span>
{tank.status === "unvollständig" && <Pill>unvollständig</Pill>}
</button>
}
/>
))}
</Accordion>
)
})}
</Accordion>
))}
</>
)
}
function TankFormular({ beiFertig }: { beiFertig: () => void }) {
const { api } = useDaten()
const [zeitpunkt, setzeZeitpunkt] = useState("")
const [liter, setzeLiter] = useState("")
const [kosten, setzeKosten] = useState("")
const [station, setzeStation] = useState("")
const [km, setzeKm] = useState("")
const [fehler, setzeFehler] = useState<string | null>(null)
const [laeuft, setzeLaeuft] = useState(false)
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(",", "."))
await api.tankvorgangAnlegen({
ts: zeitpunkt ? new Date(zeitpunkt).toISOString() : new Date().toISOString(),
liter: literZahl,
kosten: kostenZahl,
station: station || null,
km: Number.isFinite(kmZahl) && km ? kmZahl : null,
})
beiFertig()
} catch (ursache) {
setzeFehler(ursache instanceof Error ? ursache.message : String(ursache))
} finally {
setzeLaeuft(false)
}
}
return (
<Tile>
<span className="ads-eyebrow">Tankung eintragen</span>
<Feld label="Zeitpunkt">
<input
className="dm-eingabe"
type="datetime-local"
value={zeitpunkt}
onChange={(e) => setzeZeitpunkt(e.target.value)}
/>
</Feld>
<Feld label="Menge" unit="l">
<input
className="dm-eingabe"
inputMode="decimal"
placeholder="0,00"
value={liter}
onChange={(e) => setzeLiter(e.target.value)}
/>
</Feld>
<Feld label="Gesamtbetrag" unit="€">
<input
className="dm-eingabe"
inputMode="decimal"
placeholder="0,00"
value={kosten}
onChange={(e) => setzeKosten(e.target.value)}
/>
</Feld>
<Feld label="Tankstelle">
<input
className="dm-eingabe"
placeholder="optional"
value={station}
onChange={(e) => setzeStation(e.target.value)}
/>
</Feld>
<Feld label="Kilometerstand" unit="km" last>
<input
className="dm-eingabe"
inputMode="numeric"
placeholder="vom Fahrzeug"
value={km}
onChange={(e) => setzeKm(e.target.value)}
/>
</Feld>
{fehler && (
<p className="dm-fehler" role="alert">
{fehler}
</p>
)}
<p className="dm-fussnote">
Den Preis je Liter rechnet die App selbst aus Betrag und Menge er wird nie aus einem Beleg
übernommen. Ohne Kilometerstand nimmt sie den des Fahrzeugs.
</p>
<div style={{ marginTop: 14 }}>
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
{laeuft ? "Speichere …" : "Speichern"}
</ActionButton>
</div>
</Tile>
)
}
+54
View File
@@ -0,0 +1,54 @@
/**
* Löschweg für Listenzeilen — Wischen **und** ein sichtbares Menü.
*
* Behebt einen offenen Befund des Design-Audits (AUDIT_2026-08-10.md §4.1):
* Im alten Panel war Löschen ausschließlich über eine Wischgeste erreichbar.
* Wer mit Sprachausgabe oder Schaltersteuerung bedient, führt keine
* Wischgesten aus und konnte Fahrten und Tankvorgänge deshalb gar nicht
* löschen. Das Menü ist immer da, ein richtiger Knopf, mit Tastatur erreichbar.
*/
import { useState, type ReactNode } from "react"
import { Popup, PopupMenuItem, SwipeRow } from "@audi-dash/ui"
import { SymbolMehr } from "../symbole"
export function Zeilenmenue({
beiLoeschen,
loeschFrage,
kinder,
}: {
beiLoeschen: () => void
loeschFrage: string
kinder: ReactNode
}) {
const [menueOffen, setzeMenueOffen] = useState(false)
const loeschen = () => {
setzeMenueOffen(false)
if (window.confirm(loeschFrage)) beiLoeschen()
}
return (
<div className="dm-zeile">
<SwipeRow onDelete={loeschen} className="dm-zeile__wisch">
{kinder}
</SwipeRow>
<button
type="button"
className="dm-zeile__menue"
aria-label="Weitere Aktionen"
onClick={() => setzeMenueOffen(true)}
>
<SymbolMehr groesse={18} />
</button>
<Popup open={menueOffen} onClose={() => setzeMenueOffen(false)} variant="menu" anchor="center">
<PopupMenuItem destructive onClick={loeschen}>
Löschen
</PopupMenuItem>
<PopupMenuItem onClick={() => setzeMenueOffen(false)}>Abbrechen</PopupMenuItem>
</Popup>
</div>
)
}
+15
View File
@@ -5,9 +5,14 @@
import { useDaten } from "../daten/DatenKontext"
import type { Route, SeitenName } from "../navigation"
import { FahrtDetail } from "./FahrtDetail"
import { Fahrten } from "./Fahrten"
import { Fahrzeugstatus } from "./Fahrzeugstatus"
import { MeinAudi } from "./MeinAudi"
import { Platzhalter } from "./Platzhalter"
import { Statistik } from "./Statistik"
import { TankDetail } from "./TankDetail"
import { Tanken } from "./Tanken"
import { Uebersicht } from "./Uebersicht"
export interface SeitenProps {
@@ -44,6 +49,16 @@ export function SeiteFuer(props: SeitenProps) {
return <Fahrzeugstatus />
case "audi":
return <MeinAudi geheZu={geheZu} />
case "trips":
return <Fahrten geheZu={geheZu} />
case "trip":
return <FahrtDetail id={route.id} />
case "fuel":
return <Tanken geheZu={geheZu} />
case "fill":
return <TankDetail id={route.id} />
case "stat":
return <Statistik />
default:
return <Platzhalter route={route} geheZu={geheZu} />
}
+135
View File
@@ -352,3 +352,138 @@
gap: var(--sp-2);
margin-top: var(--sp-3);
}
/* ------------------------------------------------------- Listenzeilen */
.dm-zeile {
display: flex;
align-items: stretch;
gap: 2px;
}
.dm-zeile__wisch {
flex: 1;
min-width: 0;
}
/* Immer sichtbarer Zugang zu denselben Aktionen wie die Wischgeste — ohne ihn
waere Loeschen fuer Sprachausgabe und Schaltersteuerung unerreichbar. */
.dm-zeile__menue {
flex: none;
width: 44px;
border: 0;
background: transparent;
color: var(--fg3);
border-radius: var(--r-func);
cursor: pointer;
}
.dm-zeile__menue:hover {
background: var(--shade);
color: var(--fg);
}
.dm-listenzeile {
display: flex;
align-items: center;
gap: var(--sp-3);
width: 100%;
padding: 12px 2px;
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.dm-listenzeile__haupt {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.dm-listenzeile__titel {
display: flex;
align-items: baseline;
gap: var(--sp-2);
font-size: 16px;
font-weight: 300;
}
.dm-listenzeile__neben {
font-size: 13px;
color: var(--fg2);
}
.dm-listenzeile__unter {
font-size: 12px;
font-weight: 300;
color: var(--fg3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* -------------------------------------------------------- Detailseiten */
.dm-detail__kopf {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--sp-3);
margin: var(--sp-2) 0 var(--sp-3);
flex-wrap: wrap;
}
.dm-detail__zahl {
font-size: 40px;
font-weight: 300;
line-height: 1;
letter-spacing: -0.02em;
}
.dm-detail__einheit {
font-size: 15px;
color: var(--fg2);
margin-left: 6px;
}
.dm-detail__marken {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.dm-karte {
height: 240px;
width: 100%;
border-radius: var(--r-func);
overflow: hidden;
margin-top: var(--sp-3);
background: var(--tile-2);
}
/* ------------------------------------------------------------ Meldungen */
.dm-fehler {
margin: var(--sp-3) 0 0;
font-size: 13px;
font-weight: 300;
line-height: 1.5;
color: var(--bad);
}
.dm-erfolg {
margin: var(--sp-3) 0 0;
font-size: 13px;
font-weight: 300;
line-height: 1.5;
color: var(--ok);
}
.dm-fussnote--warnung {
color: var(--warn);
}