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