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); +}