From 8b64a808ed9309edeeaebd4b3b7aa530dcd0503e Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 11 Aug 2026 11:11:00 +0200 Subject: [PATCH] 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. --- companion-app/src/api/index.ts | 44 ++++ companion-app/src/screens/FahrtDetail.tsx | 111 ++++++++++ companion-app/src/screens/Fahrten.tsx | 174 ++++++++++++++++ companion-app/src/screens/Karte.tsx | 81 ++++++++ companion-app/src/screens/Statistik.tsx | 155 ++++++++++++++ companion-app/src/screens/TankDetail.tsx | 239 ++++++++++++++++++++++ companion-app/src/screens/Tanken.tsx | 212 +++++++++++++++++++ companion-app/src/screens/Zeilenmenue.tsx | 54 +++++ companion-app/src/screens/register.tsx | 15 ++ companion-app/src/stile/screens.css | 135 ++++++++++++ 10 files changed, 1220 insertions(+) create mode 100644 companion-app/src/screens/FahrtDetail.tsx create mode 100644 companion-app/src/screens/Fahrten.tsx create mode 100644 companion-app/src/screens/Karte.tsx create mode 100644 companion-app/src/screens/Statistik.tsx create mode 100644 companion-app/src/screens/TankDetail.tsx create mode 100644 companion-app/src/screens/Tanken.tsx create mode 100644 companion-app/src/screens/Zeilenmenue.tsx diff --git a/companion-app/src/api/index.ts b/companion-app/src/api/index.ts index b11b69a..42ee522 100644 --- a/companion-app/src/api/index.ts +++ b/companion-app/src/api/index.ts @@ -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 { + return this.warteschlange.einreihen( + "pyscript", + "audi_dashboard_fahrt_manuell_anlegen", + { ts_start: tsStart, ts_end: tsEnde, art }, + "Fahrt eintragen", + ); + } + fahrtLoeschen(tripId: string): Promise { 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 { + return this.warteschlange.einreihen( + "pyscript", + "audi_dashboard_tankvorgang_manuell", + { ...felder }, + "Tankvorgang eintragen", + ); + } + + tankvorgangAktualisieren(tankId: string, felder: TankvorgangFelder): Promise { + return this.warteschlange.einreihen( + "pyscript", + "audi_dashboard_tankvorgang_aktualisieren", + { tank_id: tankId, ...felder }, + "Tankvorgang ändern", + ); + } + tankvorgangLoeschen(tankId: string): Promise { return this.warteschlange.einreihen( "pyscript", diff --git a/companion-app/src/screens/FahrtDetail.tsx b/companion-app/src/screens/FahrtDetail.tsx new file mode 100644 index 0000000..a6a389d --- /dev/null +++ b/companion-app/src/screens/FahrtDetail.tsx @@ -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 ( + + ) + } + + const hatPositionen = fahrt.start_lat != null && fahrt.start_lon != null + + return ( + <> + + + {datum(fahrt.ts_start)} · {uhrzeit(fahrt.ts_start)} bis {uhrzeit(fahrt.ts_end)} Uhr + +
+
+ {deOderStrich(fahrt.distance_km, 1)} + km +
+
+ {fahrt.art === "arbeitsweg" ? Arbeitsweg : Privat} + {fahrt.status === "offen" && noch offen} + {fahrt.source === "manual" && selbst eingetragen} +
+
+ + + + + 0 + ? `${de((fahrt.distance_km / fahrt.duration_s) * 3600, 1)} km/h` + : "—" + } + /> + + } + /> +
+ + {fahrt.status === "offen" && ( +

+ 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. +

+ )} + + + Strecke + {hatPositionen ? ( + + ) : ( + + )} + {(fahrt.start_address || fahrt.end_address) && ( + + + + + } + /> + )} + + + ) +} diff --git a/companion-app/src/screens/Fahrten.tsx b/companion-app/src/screens/Fahrten.tsx new file mode 100644 index 0000000..4eb0f99 --- /dev/null +++ b/companion-app/src/screens/Fahrten.tsx @@ -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 ( + <> +
+ + {fahrten.length === 0 + ? "Keine Fahrten" + : `${de(fahrten.length)} Fahrt${fahrten.length === 1 ? "" : "en"}`} + + +
+ + {formularOffen && setzeFormularOffen(false)} />} + + {fahrten.length === 0 && !formularOffen && ( + setzeFormularOffen(true) }} + /> + )} + + {gruppen.map((jahr) => ( + f.distance_km ?? 0))} km`} + defaultOpen={jahr === gruppen[0]} + > + {jahr.monate.map((monat) => ( + f.distance_km ?? 0))} km`} + defaultOpen={monat === jahr.monate[0]} + > + {monat.eintraege.map((fahrt) => ( + void api.fahrtLoeschen(fahrt.trip_id)} + kinder={ + + } + /> + ))} + + ))} + + ))} + + ) +} + +/** 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(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 ( + + Fahrt eintragen + + setzeStart(e.target.value)} + /> + + + setzeEnde(e.target.value)} + /> + + + + + {fehler && ( +

+ {fehler} +

+ )} +

+ Die Strecke ergänzt die App selbst, sobald sie den Kilometerstand zu Beginn und Ende der + Fahrt kennt. +

+
+ void speichern()} disabled={laeuft}> + {laeuft ? "Speichere …" : "Speichern"} + +
+
+ ) +} diff --git a/companion-app/src/screens/Karte.tsx b/companion-app/src/screens/Karte.tsx new file mode 100644 index 0000000..8e0901f --- /dev/null +++ b/companion-app/src/screens/Karte.tsx @@ -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(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
+} diff --git a/companion-app/src/screens/Statistik.tsx b/companion-app/src/screens/Statistik.tsx new file mode 100644 index 0000000..c485e20 --- /dev/null +++ b/companion-app/src/screens/Statistik.tsx @@ -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 ( + + ) + } + + const a = jahresauswertung( + fahrten, + tankvorgaenge, + einstellungen.nachtVon, + einstellungen.nachtBis, + ) + + return ( + <> + + Distanz und Fahrzeit {a.jahr} + + ({ + value: de(a.jeZeitraum[z].km), + unit: "km", + caption: ZEITRAUM_NAME[z], + }))} + columns={4} + /> + ({ + value: de(a.jeZeitraum[z].stunden, z === "jahr" || z === "monat" ? 0 : 1), + unit: "h", + caption: ZEITRAUM_NAME[z], + }))} + columns={4} + /> + + + + Getankte Menge und Kosten {a.jahr} + + ({ + value: de(a.jeZeitraum[z].liter), + unit: "l", + caption: ZEITRAUM_NAME[z], + }))} + columns={4} + /> + ({ + value: de(a.jeZeitraum[z].kosten), + unit: "€", + caption: ZEITRAUM_NAME[z], + }))} + columns={4} + /> + + + + Langzeitverbrauch + + + + + + + } + /> + {a.langzeit.unplausibel && ( +

+ Aus Litern und Strecke ergibt sich derzeit kein plausibler Wert. Sobald Tankvorgänge und + Kilometerstände vollständig vorliegen, erscheint er hier. +

+ )} +

+ 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. +

+
+ + + Tag und Nacht {a.jahr} + 0 ? de(Math.round((a.tagKm / a.kmJahr) * 100)) : "—"} + unit="% bei Tag" + size={40} + /> + +

+ Nacht = {einstellungen.nachtVon} bis {einstellungen.nachtBis} Uhr +

+
+ + + Art der Fahrten {a.jahr} + 0 ? de(Math.round((a.arbeitKm / a.kmJahr) * 100)) : "—"} + unit="% Arbeitsweg" + size={40} + /> + + + + + + + } + /> + + + ) +} diff --git a/companion-app/src/screens/TankDetail.tsx b/companion-app/src/screens/TankDetail.tsx new file mode 100644 index 0000000..e795fca --- /dev/null +++ b/companion-app/src/screens/TankDetail.tsx @@ -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 ( + + ) + } + + return ( + <> + + {datumZeit(tank.ts)} +
+
+ {deOderStrich(tank.liters, 2)} + l +
+
+ {tank.status === "unvollständig" && unvollständig} + {tank.source === "beleg" && aus Beleg} + {tank.source === "manual" && selbst eingetragen} + {tank.source === "auto" && automatisch erkannt} +
+
+ + + + {tank.discount != null && tank.discount > 0 && ( + + )} + + + + + + } + /> +
+ + + + ) +} + +function BelegBereich({ + tankId, + belegDatei, + belegNr, + zeitpunkt, + api, +}: { + tankId: string + belegDatei: string | null + belegNr: string | null + zeitpunkt: string + api: ReturnType["api"] +}) { + const dateiwahl = useRef(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(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 ( + + Beleg + {belegDatei ? ( + + + + + } + /> + ) : ( +

+ Für diese Tankung liegt noch kein Beleg vor. Aus einem Shell-Beleg liest die App Menge, + Betrag, Tankstelle und Ersparnis selbst aus. +

+ )} + + { + const datei = e.target.files?.[0] + if (datei) void hochladen(datei) + e.target.value = "" + }} + /> + + {meldung && ( +

+ {meldung.text} +

+ )} + +
+ dateiwahl.current?.click()} disabled={laeuft || wartet}> + {laeuft ? "Lade hoch …" : wartet ? "Beleg wird gelesen …" : belegDatei ? "Beleg ersetzen" : "Beleg hochladen"} + +
+

+ Der Beleg muss vom {datum(zeitpunkt)} sein — sonst weist der Server ihn ab, damit keine + fremde Tankung in diesen Eintrag gerät. +

+
+ ) +} + +/** Datei als Base64 ohne den `data:`-Vorspann — so erwartet es das Backend. */ +function alsBase64(datei: File): Promise { + 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) + }) +} diff --git a/companion-app/src/screens/Tanken.tsx b/companion-app/src/screens/Tanken.tsx new file mode 100644 index 0000000..a22efbf --- /dev/null +++ b/companion-app/src/screens/Tanken.tsx @@ -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 ( + <> +
+ + {tankvorgaenge.length === 0 + ? "Keine Tankvorgänge" + : `${de(tankvorgaenge.length)} Tankvorgang${tankvorgaenge.length === 1 ? "" : "̈e"}`} + + +
+ + {formularOffen && setzeFormularOffen(false)} />} + + {tankvorgaenge.length === 0 && !formularOffen && ( + setzeFormularOffen(true) }} + /> + )} + + {gruppen.map((jahr) => ( + t.fuel_total_eur ?? 0))} €`} + defaultOpen={jahr === gruppen[0]} + > + {jahr.monate.map((monat) => { + const schnitt = schnittpreis(monat.eintraege) + return ( + 0 ? `${eur(schnitt)} €/l` : "—"} + defaultOpen={monat === jahr.monate[0]} + > + {monat.eintraege.map((tank) => ( + void api.tankvorgangLoeschen(tank.tank_id)} + kinder={ + + } + /> + ))} + + ) + })} + + ))} + + ) +} + +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(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 ( + + Tankung eintragen + + setzeZeitpunkt(e.target.value)} + /> + + + setzeLiter(e.target.value)} + /> + + + setzeKosten(e.target.value)} + /> + + + setzeStation(e.target.value)} + /> + + + setzeKm(e.target.value)} + /> + + {fehler && ( +

+ {fehler} +

+ )} +

+ 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. +

+
+ void speichern()} disabled={laeuft}> + {laeuft ? "Speichere …" : "Speichern"} + +
+
+ ) +} diff --git a/companion-app/src/screens/Zeilenmenue.tsx b/companion-app/src/screens/Zeilenmenue.tsx new file mode 100644 index 0000000..1a9b38d --- /dev/null +++ b/companion-app/src/screens/Zeilenmenue.tsx @@ -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 ( +
+ + {kinder} + + + setzeMenueOffen(false)} variant="menu" anchor="center"> + + Löschen + + setzeMenueOffen(false)}>Abbrechen + +
+ ) +} diff --git a/companion-app/src/screens/register.tsx b/companion-app/src/screens/register.tsx index 4f71172..ca9b03c 100644 --- a/companion-app/src/screens/register.tsx +++ b/companion-app/src/screens/register.tsx @@ -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 case "audi": return + case "trips": + return + case "trip": + return + case "fuel": + return + case "fill": + return + case "stat": + return default: return } diff --git a/companion-app/src/stile/screens.css b/companion-app/src/stile/screens.css index 03d5cc7..611dc58 100644 --- a/companion-app/src/stile/screens.css +++ b/companion-app/src/stile/screens.css @@ -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); +}