Phase 7c: restliche Screens - alle 21 Seiten stehen

Fahrzeugdaten, Batterieverlauf, Service mit Werkstatt und Servicebuch,
Reifen, die ganze Versicherungs- und Steuergruppe, Einstellungen in voller
Tiefe und die Live-Ansicht.

Portiert dazu: die eigene Oelwechsel-Prognose (rechnet ab dem letzten
Wechsel im Servicebuch neu, weil der Langzeitschnitt dafuer zu traege ist),
Kalenderdateien fuer Termine und die CSV-Ausgabe im deutschen Format.

Die Live-Ansicht ist gebaut, aber ueber einen Schalter abgeschaltet: die
Werte dafuer liefert erst der FMM003. Ein Menuepunkt mit lauter Nullen waere
schlechter als keiner.

Zwei Stellen, an denen die App bewusst zurueckhaltend ist: ein unbekannter
Pruefpunkt erscheint als hohler Ring statt als geratenes Gruen, und der
Reifen-Kilometerstand wird nie zurueckgeschrieben, weil ihn der Server
fortschreibt.

49 Tests, darunter ein Rendertest ueber alle 21 Seiten in beiden Layouts
mit Beispieldaten in der Form, die das Backend wirklich liefert.
This commit is contained in:
Paul Nothaft
2026-08-11 11:17:47 +02:00
parent 8b64a808ed
commit 8e6e5bc2ee
16 changed files with 2258 additions and 54 deletions
+357
View File
@@ -0,0 +1,357 @@
/**
* Einstellungen. Vorlage: `vEinst()` im alten Panel — die umfangreichste
* Seite: Fahrzeugdaten, Darstellung, Fotos, SmartDeal, Pausenzeit, Sicherung,
* Datenausgabe und Zugang.
*/
import { useRef, useState } from "react"
import { ActionButton, Feld, Seg, Switch, Tile } from "@audi-dash/ui"
import { zugangVerwerfen } from "../api"
import { useDaten } from "../daten/DatenKontext"
import type { Einstellungen as EinstellungenWerte } from "../daten/profilAdapter"
import { datumZeit, de, isoTag } from "../format"
import { useTheme } from "../theme"
import { Wertzeile, Werteliste, bestaetigen } from "./bausteine"
import { Bild } from "./Bild"
import { BILDPLAETZE } from "./bilder"
import { csvHerunterladen } from "./csv"
import { zugangVergessen } from "./zugang"
export function Einstellungen({
tabBeschriftung,
setzeTabBeschriftung,
beiAbmeldung,
}: {
tabBeschriftung: boolean
setzeTabBeschriftung: (an: boolean) => void
beiAbmeldung: () => void
}) {
const { einstellungen, fahrzeug, fahrten, tankvorgaenge, rohprofil, api, profilSpeichern } =
useDaten()
const [theme, themeSetzen] = useTheme()
const [entwurf, setzeEntwurf] = useState<EinstellungenWerte | null>(null)
const [laeuft, setzeLaeuft] = useState(false)
const [gespeichert, setzeGespeichert] = useState(false)
const dateiwahl = useRef<HTMLInputElement | null>(null)
if (!einstellungen || !fahrzeug) return null
const werte = entwurf ?? einstellungen
const aendern = (teil: Partial<EinstellungenWerte>) =>
setzeEntwurf({ ...werte, ...teil })
const speichern = async () => {
if (!entwurf) return
setzeLaeuft(true)
setzeGespeichert(false)
try {
await profilSpeichern({ einstellungen: entwurf })
setzeEntwurf(null)
setzeGespeichert(true)
} finally {
setzeLaeuft(false)
}
}
const abmelden = async () => {
if (!bestaetigen("Zugang von diesem Gerät entfernen? Die Daten auf dem Server bleiben.")) return
await zugangVerwerfen()
zugangVergessen()
beiAbmeldung()
}
const profilExportieren = () => {
const inhalt = JSON.stringify(
{ profil: rohprofil, fahrten, tankvorgaenge, erzeugt: new Date().toISOString() },
null,
2,
)
const blob = new Blob([inhalt], { type: "application/json" })
const url = URL.createObjectURL(blob)
const verweis = document.createElement("a")
verweis.href = url
verweis.download = `datametric360-sicherung-${isoTag()}.json`
verweis.click()
URL.revokeObjectURL(url)
}
return (
<>
<Tile>
<span className="ads-eyebrow">Fahrzeug</span>
<Feld label="Modell">
<input
className="dm-eingabe"
value={werte.fahrzeugtitel}
onChange={(e) => aendern({ fahrzeugtitel: e.target.value })}
/>
</Feld>
<Feld label="Zusatz">
<input
className="dm-eingabe"
value={werte.fahrzeugzusatz}
onChange={(e) => aendern({ fahrzeugzusatz: e.target.value })}
/>
</Feld>
<Feld label="Kennzeichen">
<input
className="dm-eingabe"
value={werte.kennzeichen}
onChange={(e) => aendern({ kennzeichen: e.target.value })}
/>
</Feld>
<Feld label="Tankvolumen" unit="l">
<input
className="dm-eingabe"
inputMode="numeric"
value={werte.tankvolumen}
onChange={(e) => aendern({ tankvolumen: Number(e.target.value) || 0 })}
/>
</Feld>
<Feld label="WLAN des Fahrzeugs" last>
<input
className="dm-eingabe"
value={werte.wlanName}
onChange={(e) => aendern({ wlanName: e.target.value })}
/>
</Feld>
</Tile>
<Tile>
<span className="ads-eyebrow">Fahrten</span>
<Feld label="Pause zwischen zwei Fahrten" unit="Minuten">
<input
className="dm-eingabe"
inputMode="numeric"
value={werte.pauseMin}
onChange={(e) => aendern({ pauseMin: Number(e.target.value) || 0 })}
/>
</Feld>
<Feld label="Nacht beginnt">
<input
className="dm-eingabe"
type="time"
value={werte.nachtVon}
onChange={(e) => aendern({ nachtVon: e.target.value })}
/>
</Feld>
<Feld label="Nacht endet" last>
<input
className="dm-eingabe"
type="time"
value={werte.nachtBis}
onChange={(e) => aendern({ nachtBis: e.target.value })}
/>
</Feld>
<p className="dm-fussnote">
Setzt das Fahrzeug innerhalb der Pausenzeit die Fahrt fort, zählt das als eine Fahrt und
nicht als zwei etwa bei einem Tankstopp.
</p>
</Tile>
<Tile>
<span className="ads-eyebrow">Darstellung</span>
<Feld label="Erscheinungsbild">
<Seg
options={[
{ value: "nacht", label: "Nacht" },
{ value: "tag", label: "Tag" },
]}
value={theme}
onChange={(neu) => themeSetzen(neu as "nacht" | "tag")}
/>
</Feld>
<Feld label="Beschriftung der Tab-Leiste" last>
<Switch
checked={tabBeschriftung}
onChange={setzeTabBeschriftung}
aria-label="Beschriftung der Tab-Leiste"
/>
</Feld>
</Tile>
<Tile>
<span className="ads-eyebrow">Bild auf der Übersicht</span>
<Feld label="Verwendetes Bild" last>
<select
className="dm-auswahl"
value={werte.startbild}
onChange={(e) => aendern({ startbild: e.target.value })}
>
{BILDPLAETZE.map((platz) => (
<option key={platz.datei} value={platz.datei}>
{platz.name}
</option>
))}
<option value="seitenansicht-winter.webp">Seitenansicht Winter</option>
</select>
</Feld>
<div className="dm-galerie">
{BILDPLAETZE.map((platz) => (
<Bild
key={platz.datei}
datei={platz.datei}
beschreibung={platz.beschreibung}
name={platz.name}
groesse="mini"
/>
))}
</div>
<p className="dm-fussnote">
Fotos lädst du über die Verwaltung im Home Assistant hoch. Die Dateinamen sind fest
vergeben der Server nimmt nur diese an.
</p>
</Tile>
<Tile>
<span className="ads-eyebrow">Tankrabatt</span>
<Feld label={`${werte.markenname}-Rabatt aktiv`}>
<Switch
checked={fahrzeug.smartdeal.aktiv}
onChange={(an) =>
void profilSpeichern({
fahrzeug: {
...fahrzeug,
smartdeal: { ...fahrzeug.smartdeal, aktiv: an },
},
})
}
aria-label="Tankrabatt aktiv"
/>
</Feld>
<Feld label="Gültig bis" last>
<input
className="dm-eingabe"
type="date"
value={fahrzeug.smartdeal.laeuftAb ?? ""}
onChange={(e) =>
void profilSpeichern({
fahrzeug: {
...fahrzeug,
smartdeal: { ...fahrzeug.smartdeal, laeuftAb: e.target.value || null },
},
})
}
/>
</Feld>
<p className="dm-fussnote">
Das Datum gilt einschließlich am Stichtag selbst zählt der Rabatt noch, erst am Folgetag
schaltet er ab.
</p>
</Tile>
<Tile>
<span className="ads-eyebrow">Sicherung</span>
<Feld label="Automatische Sicherung">
<select
className="dm-auswahl"
value={werte.backupIntervall}
onChange={(e) => aendern({ backupIntervall: e.target.value })}
>
<option value="aus">aus</option>
<option value="taeglich">täglich</option>
<option value="woechentlich">wöchentlich</option>
<option value="monatlich">monatlich</option>
</select>
</Feld>
<Werteliste
kinder={
<Wertzeile
label="Zuletzt gesichert"
wert={werte.letztesBackup ? datumZeit(werte.letztesBackup) : "—"}
/>
}
/>
<div className="dm-knopfreihe">
<ActionButton
onClick={() =>
void api.rest.dienstAufrufen("pyscript", "audi_dashboard_backup_jetzt")
}
>
Jetzt sichern
</ActionButton>
<ActionButton onClick={profilExportieren}>Auf dieses Gerät laden</ActionButton>
</div>
</Tile>
<Tile>
<span className="ads-eyebrow">Daten ausgeben</span>
<p className="dm-fussnote">
Als CSV mit Semikolon und deutschem Zahlenformat so öffnet Excel die Datei ohne
Nachfragen.
</p>
<div className="dm-knopfreihe">
<ActionButton
onClick={() =>
csvHerunterladen(
"fahrten",
["Beginn", "Ende", "Strecke km", "Dauer s", "Art", "Status"],
fahrten.map((f) => [
f.ts_start,
f.ts_end,
f.distance_km ?? "",
f.duration_s,
f.art,
f.status,
]),
)
}
>
Fahrten
</ActionButton>
<ActionButton
onClick={() =>
csvHerunterladen(
"tankvorgaenge",
["Zeitpunkt", "Liter", "Betrag", "Preis je Liter", "Tankstelle", "Kilometerstand"],
tankvorgaenge.map((t) => [
t.ts,
t.liters ?? "",
t.fuel_total_eur ?? "",
t.price_per_l ?? "",
t.station_name ?? "",
t.odometer_km ?? "",
]),
)
}
>
Tankvorgänge
</ActionButton>
</div>
</Tile>
<Tile>
<span className="ads-eyebrow">Zugang</span>
<Werteliste
kinder={
<>
<Wertzeile label="Fahrten gespeichert" wert={de(fahrten.length)} />
<Wertzeile label="Tankvorgänge gespeichert" wert={de(tankvorgaenge.length)} />
</>
}
/>
<div style={{ marginTop: 14 }}>
<ActionButton variant="destructive" onClick={() => void abmelden()}>
Verbindung trennen
</ActionButton>
</div>
</Tile>
<input ref={dateiwahl} type="file" accept="application/json" hidden />
{gespeichert && <p className="dm-erfolg">Gespeichert.</p>}
{entwurf && (
<div className="dm-speicherleiste">
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
{laeuft ? "Speichere …" : "Änderungen speichern"}
</ActionButton>
<button type="button" className="dm-textknopf" onClick={() => setzeEntwurf(null)}>
Verwerfen
</button>
</div>
)}
</>
)
}