90efcc5b00
Die App blieb nach einem Neustart von Home Assistant auf dem alten Stand stehen, mit einer 502-Fehlerzeile, die erst ein Beenden der App wegbekam. Zwei Ursachen: 502/503/504 galten als harter Ladefehler statt als "Server faehrt gerade hoch", und nach dem Wiederverbinden wurde genau einmal nachgeladen - schlug das fehl, kam nie wieder ein Versuch. Beides behoben, vier Regressionstests dazu. OTA: das Buendel ist von HEAD ueber die Platte bis zu den ausgelieferten Bytes nachweislich deckungsgleich. Die Fassung haengt jetzt trotzdem als ?v= an der Adresse (der statische Pfad kommt ohne Cache-Control, aber mit ETag), und ein Pruefsummenfehler wird erklaert statt durchgereicht. Statistik: der Gesamtwert steht jetzt als grosse Zahl in der Kopfzeile, darunter bleiben vier Spalten - fuenf passen auf dem Telefon nachweislich nicht in eine Zeile. Fahrzeugfoto ohne Beschnitt und ohne Bodenschatten, damit auch ohne den Schalter dafuer. Setup filtert zusaetzlich nach dem Signalnamen aus dem Katalog. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
765 lines
30 KiB
TypeScript
765 lines
30 KiB
TypeScript
/**
|
||
* Einstellungen. Vorlage: `vEinst()` im alten Panel — die umfangreichste
|
||
* Seite: Fahrzeugdaten, Darstellung, Fotos, SmartDeal, Pausenzeit, Sicherung,
|
||
* Datenausgabe und Zugang.
|
||
*/
|
||
|
||
import { useEffect, useRef, useState } from "react"
|
||
|
||
import { ActionButton, Feld, Seg, Sheet, Switch, Tile } from "@audi-dash/ui"
|
||
|
||
import { DIENST_DOMAIN, zugangLesen, zugangVerwerfen } from "../api"
|
||
import type { Profil } from "../api"
|
||
import { eigeneVersion } from "../daten/appVersion"
|
||
import { Datumsfeld } from "./Datumsfeld"
|
||
import { useDaten } from "../daten/DatenKontext"
|
||
import { OtaFehler, buendelAnwenden, buendelPasst, otaMoeglich } from "../daten/ota"
|
||
import type { Einstellungen as EinstellungenWerte } from "../daten/profilAdapter"
|
||
import { datum, datumZeit, isoTag } from "../format"
|
||
import { useTheme } from "../theme"
|
||
import { BildMitMenue, Wertzeile, Werteliste, bestaetigen } from "./bausteine"
|
||
import { BILDPLAETZE, SEITE_WINTER, markenlogoUrl } from "./bilder"
|
||
import { DatensatzPopup } from "./DatensatzPopup"
|
||
import { HistorienImport } from "./HistorienImport"
|
||
import type { ServicebuchEintrag } from "./Service"
|
||
import { zugangVergessen } from "./zugang"
|
||
|
||
export function Einstellungen({
|
||
tabBeschriftung,
|
||
setzeTabBeschriftung,
|
||
beiAbmeldung,
|
||
}: {
|
||
tabBeschriftung: boolean
|
||
setzeTabBeschriftung: (an: boolean) => void
|
||
beiAbmeldung: () => void
|
||
}) {
|
||
const {
|
||
einstellungen,
|
||
fahrzeug,
|
||
fahrten,
|
||
tankvorgaenge,
|
||
rohprofil,
|
||
otaBuendel,
|
||
integrationUpdate,
|
||
serverVersion,
|
||
verbindung,
|
||
api,
|
||
profilSpeichern,
|
||
jetztAktualisieren,
|
||
neuLaden,
|
||
} = useDaten()
|
||
const [theme, themeSetzen] = useTheme()
|
||
// "Fahrzeug einrichten" ist im Panel zugeklappt und oeffnet sich ueber den
|
||
// Knopf darunter (einrichtenOffen in vEinst()).
|
||
const [einrichtenOffen, setzeEinrichtenOffen] = useState(false)
|
||
// Erstzulassung liegt am Fahrzeug, nicht an den Einstellungen - deshalb ein
|
||
// eigener Entwurf, der beim Speichern mitgeschrieben wird.
|
||
const [erstzulassung, setzeErstzulassung] = useState<string | null>(null)
|
||
const [entwurf, setzeEntwurf] = useState<EinstellungenWerte | null>(null)
|
||
const [laeuft, setzeLaeuft] = useState(false)
|
||
const [gespeichert, setzeGespeichert] = useState(false)
|
||
const [importOffen, setzeImportOffen] = useState(false)
|
||
/* Abfrage "bis wann gilt der Rabatt", die das Einschalten des SmartDeals
|
||
ausloest (sdpopup im Panel). */
|
||
const [sdAbfrage, setzeSdAbfrage] = useState(false)
|
||
const [sdEntwurf, setzeSdEntwurf] = useState("")
|
||
const [otaLaeuft, setzeOtaLaeuft] = useState(false)
|
||
const [otaFehler, setzeOtaFehler] = useState<string | null>(null)
|
||
const [updatePruefenLaeuft, setzeUpdatePruefenLaeuft] = useState(false)
|
||
const [updateInstallierenLaeuft, setzeUpdateInstallierenLaeuft] = useState(false)
|
||
const [updateNeustartLaeuft, setzeUpdateNeustartLaeuft] = useState(false)
|
||
const [updateAktionFehler, setzeUpdateAktionFehler] = useState<string | null>(null)
|
||
const [datensatzPopup, setzeDatensatzPopup] = useState<"sichern" | "laden" | null>(null)
|
||
const [backupImportLaeuft, setzeBackupImportLaeuft] = useState(false)
|
||
const [backupImportFehler, setzeBackupImportFehler] = useState<string | null>(null)
|
||
const backupDateiwahl = useRef<HTMLInputElement | null>(null)
|
||
|
||
const otaUpdateVerfuegbar = otaMoeglich() && buendelPasst(otaBuendel, eigeneVersion())
|
||
|
||
const otaAusloesen = async () => {
|
||
if (!otaBuendel) return
|
||
setzeOtaLaeuft(true)
|
||
setzeOtaFehler(null)
|
||
try {
|
||
const zugang = await zugangLesen()
|
||
if (!zugang) throw new OtaFehler("Kein Zugang eingerichtet.")
|
||
// Kehrt im Erfolgsfall nicht zurück - set() lädt die App neu, siehe
|
||
// daten/ota.ts. Der catch-Block unten fängt sowohl echte Fehlschläge
|
||
// als auch den defensiven letzten throw in buendelAnwenden() ab.
|
||
await buendelAnwenden(otaBuendel, zugang.basisUrl)
|
||
} catch (fehler) {
|
||
setzeOtaFehler(
|
||
fehler instanceof Error ? fehler.message : "Das Update konnte nicht installiert werden.",
|
||
)
|
||
setzeOtaLaeuft(false)
|
||
}
|
||
}
|
||
|
||
// Selbst-Update der Integration (aktualisierung.py) - Ersatz für
|
||
// install.ps1, das Windows Smart App Control blockiert. Beide Aktionen
|
||
// laufen sofort (keine Warteschlange, siehe api/index.ts) und laden
|
||
// danach über neuLaden() den neuen Stand von
|
||
// sensor.audi_dashboard_app_version - der Fehlertext bei fehlendem Token
|
||
// kommt von dort mit, eine eigene "ist ein Token eingetragen?"-Prüfung
|
||
// gibt es bewusst nicht. updateAktionFehler fängt zusätzlich echte
|
||
// Verbindungsfehler ab (dienste.py behandelt AktualisierungsFehler selbst
|
||
// und liefert 200 zurück - hier landet nur, was gar nicht erst bei Home
|
||
// Assistant ankam), sonst bliebe ein solcher Fehlschlag ganz ohne
|
||
// Rückmeldung.
|
||
const updatePruefenAusloesen = async () => {
|
||
setzeUpdatePruefenLaeuft(true)
|
||
setzeUpdateAktionFehler(null)
|
||
try {
|
||
await api.updatePruefen()
|
||
await neuLaden()
|
||
} catch (fehler) {
|
||
setzeUpdateAktionFehler(
|
||
fehler instanceof Error ? fehler.message : "Die Prüfung konnte nicht ausgeführt werden.",
|
||
)
|
||
} finally {
|
||
setzeUpdatePruefenLaeuft(false)
|
||
}
|
||
}
|
||
|
||
const updateInstallierenAusloesen = async () => {
|
||
setzeUpdateInstallierenLaeuft(true)
|
||
setzeUpdateAktionFehler(null)
|
||
try {
|
||
await api.updateInstallieren()
|
||
await neuLaden()
|
||
} catch (fehler) {
|
||
setzeUpdateAktionFehler(
|
||
fehler instanceof Error ? fehler.message : "Das Update konnte nicht installiert werden.",
|
||
)
|
||
} finally {
|
||
setzeUpdateInstallierenLaeuft(false)
|
||
}
|
||
}
|
||
|
||
// Nach einer erfolgreichen Installation zeigt die Kachel direkt den
|
||
// nächsten nötigen Schritt statt den Nutzer selbst suchen zu lassen - ein
|
||
// reiner Reload reicht nicht, siehe AGENTS.md Abschnitt J. Kein try/finally
|
||
// um setzeUpdateNeustartLaeuft: bricht die Verbindung normalerweise durch
|
||
// den Neustart selbst ab, bevor der Aufruf überhaupt zurückkehrt.
|
||
const updateNeustartAusloesen = async () => {
|
||
setzeUpdateNeustartLaeuft(true)
|
||
setzeUpdateAktionFehler(null)
|
||
try {
|
||
await api.homeAssistantNeuStarten()
|
||
} catch (fehler) {
|
||
setzeUpdateAktionFehler(
|
||
fehler instanceof Error
|
||
? fehler.message
|
||
: "Home Assistant konnte nicht neu gestartet werden.",
|
||
)
|
||
setzeUpdateNeustartLaeuft(false)
|
||
}
|
||
}
|
||
|
||
// Ergänzt das absichtliche Fehlen des try/finally oben: die Verbindung
|
||
// trennt sich für die Dauer des HA-Neustarts, DatenKontext meldet das über
|
||
// `verbindung`/`api.live.aufVerbindung()` und lädt bei "verbunden" bereits
|
||
// integrationUpdate & Co. neu (siehe DatenKontext.tsx) - aber
|
||
// updateNeustartLaeuft ist reiner Bildschirm-Zustand hier und blieb davon
|
||
// unberührt. Ohne diesen Effekt stand "Home Assistant startet neu …" nach
|
||
// einem erfolgreichen Neustart weiter da, bis die Seite von Hand neu
|
||
// geladen wurde.
|
||
useEffect(() => {
|
||
if (verbindung === "verbunden" && updateNeustartLaeuft) setzeUpdateNeustartLaeuft(false)
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [verbindung])
|
||
|
||
if (!einstellungen || !fahrzeug) return null
|
||
const werte = entwurf ?? einstellungen
|
||
const erstzulassungWert = erstzulassung ?? fahrzeug.erstzulassung
|
||
// Bei Winterraedern zeigt die Seitenansicht das Winterfoto - wie bildInfo()
|
||
// im Panel, das hier dieselbe Vorschau liefert.
|
||
const gewaehltesBild =
|
||
/* Verglichen wird der NAME der Ansicht, nicht der Dateiname.
|
||
|
||
Das Panel speichert in `einstellungen.uebersichtsbild` seit jeher den
|
||
Namen ("Seitenansicht"), die App verglich hier gegen den Dateinamen
|
||
("seitenansicht.webp") - der Vergleich konnte also nie zutreffen, die
|
||
Auswahl fiel immer auf den ersten Eintrag zurueck, und was die App
|
||
schrieb, erkannte das Panel seinerseits nicht wieder. Nachgesehen im
|
||
echten Profil der Testinstanz (2026-08-31): dort steht
|
||
'Seitenansicht'. Kanonisch ist der Name - die aeltere, bereits
|
||
gespeicherte Form, dieselbe Entscheidung wie beim Wartungsplan. Die
|
||
Namen beider Kataloge stimmen ueberein. */
|
||
werte.startbild === "Seitenansicht" && fahrzeug.reifen.aktiv === "Winter"
|
||
? { datei: SEITE_WINTER.datei, beschreibung: SEITE_WINTER.beschreibung }
|
||
: (BILDPLAETZE.find((b) => b.name === werte.startbild) ?? BILDPLAETZE[0]!)
|
||
|
||
const aendern = (teil: Partial<EinstellungenWerte>) =>
|
||
setzeEntwurf({ ...werte, ...teil })
|
||
|
||
const speichern = async () => {
|
||
if (!entwurf) return
|
||
setzeLaeuft(true)
|
||
setzeGespeichert(false)
|
||
try {
|
||
await profilSpeichern({
|
||
einstellungen: entwurf,
|
||
...(erstzulassung !== null ? { fahrzeug: { ...fahrzeug, erstzulassung } } : {}),
|
||
})
|
||
setzeEntwurf(null)
|
||
setzeErstzulassung(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()
|
||
}
|
||
|
||
// Anders als profilSpeichern() (Kontext) - das nur einstellungen/fahrzeug
|
||
// teilweise zusammenführt - schreibt eine geladene Datei das GANZE Profil,
|
||
// deckungsgleich mit profil_schreiben im Panel.
|
||
const profilGeladen = async (profil: Profil) => {
|
||
await api.profilSchreiben(profil)
|
||
await neuLaden()
|
||
}
|
||
|
||
// Gegenstück zu profilExportieren() - dieselbe Paketform ({profil, fahrten,
|
||
// tankvorgaenge}) über den bestehenden backup_wiederherstellen-Dienst,
|
||
// deckungsgleich mit dem data-backup="import"-Handler im Panel. Fehlte hier
|
||
// bisher komplett - "Auf dieses Gerät laden" hatte kein Gegenstück, ein
|
||
// exportiertes Backup ließ sich nie wieder einspielen.
|
||
const backupImportieren = async (datei: File) => {
|
||
setzeBackupImportFehler(null)
|
||
setzeBackupImportLaeuft(true)
|
||
try {
|
||
const text = await datei.text()
|
||
const paket = JSON.parse(text) as {
|
||
profil?: unknown
|
||
fahrten?: unknown
|
||
tankvorgaenge?: unknown
|
||
entitaeten?: unknown
|
||
}
|
||
if (!paket.profil) throw new Error("Keine gültige Backup-Datei (Profil fehlt).")
|
||
await api.rest.dienstAufrufen(DIENST_DOMAIN, "backup_wiederherstellen", {
|
||
profil_json: JSON.stringify(paket.profil),
|
||
fahrten_json: JSON.stringify(paket.fahrten ?? []),
|
||
tankvorgaenge_json: JSON.stringify(paket.tankvorgaenge ?? []),
|
||
...(paket.entitaeten ? { entitaeten_json: JSON.stringify(paket.entitaeten) } : {}),
|
||
})
|
||
await neuLaden()
|
||
} catch (ursache) {
|
||
setzeBackupImportFehler(
|
||
ursache instanceof Error ? ursache.message : "Die Datei ließ sich nicht lesen.",
|
||
)
|
||
} finally {
|
||
setzeBackupImportLaeuft(false)
|
||
}
|
||
}
|
||
|
||
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 (
|
||
<>
|
||
{/* Aufbau, Reihenfolge und Wortlaut wie vEinst(): „Fahrzeug einrichten"
|
||
(aufklappbar), Darstellung, Bild der Übersicht, Kraftstoff-Rabatt,
|
||
Fahrten zusammenfassen, Fahrzeugprofil, Backup, Version,
|
||
Integration-Update. Vorher standen die Abschnitte in anderer
|
||
Reihenfolge und unter eigenen Namen („Tankrabatt", „Sicherung",
|
||
„Vergangene Daten", „Bild auf der Übersicht"). */}
|
||
<Tile>
|
||
<span className="ads-eyebrow">Fahrzeug einrichten</span>
|
||
{einrichtenOffen && (
|
||
<>
|
||
<Feld label="Modell">
|
||
<input
|
||
className="dm-eingabe"
|
||
value={werte.fahrzeugtitel}
|
||
onChange={(e) => aendern({ fahrzeugtitel: e.target.value })}
|
||
/>
|
||
</Feld>
|
||
<Feld label="Kennzeichen">
|
||
<input
|
||
className="dm-eingabe"
|
||
value={werte.kennzeichen}
|
||
onChange={(e) => aendern({ kennzeichen: e.target.value })}
|
||
/>
|
||
</Feld>
|
||
<Feld label="Erstzulassung">
|
||
<input
|
||
className="dm-eingabe"
|
||
placeholder="MM/JJJJ"
|
||
value={erstzulassungWert}
|
||
onChange={(e) => setzeErstzulassung(e.target.value)}
|
||
/>
|
||
</Feld>
|
||
<Feld label="Ausführung" last>
|
||
<input
|
||
className="dm-eingabe"
|
||
value={werte.ausfuehrung}
|
||
onChange={(e) => aendern({ ausfuehrung: e.target.value })}
|
||
/>
|
||
</Feld>
|
||
{/* „Setup — Sensoren zuordnen" bleibt dem Panel vorbehalten: die
|
||
Zuordnung von Home-Assistant-Entitäten setzt eine angemeldete
|
||
HA-Sitzung voraus, die diese App nicht hat. */}
|
||
<ActionButton onClick={() => setzeImportOffen(true)}>
|
||
Daten importieren aus Home Assistant
|
||
</ActionButton>
|
||
<span className="ads-eyebrow" style={{ marginTop: "10px" }}>
|
||
Legt Fahrten, Tankvorgänge und Spannungswerte für einen vergangenen Zeitraum
|
||
nachträglich an — aus dem Verlauf, den Home Assistant zu den zugeordneten Sensoren
|
||
bereits aufgezeichnet hat.
|
||
</span>
|
||
</>
|
||
)}
|
||
<ActionButton
|
||
variant={einrichtenOffen ? "primary" : "default"}
|
||
onClick={() => setzeEinrichtenOffen((offen) => !offen)}
|
||
>
|
||
{einrichtenOffen ? "Fertig" : "Einrichten"}
|
||
</ActionButton>
|
||
</Tile>
|
||
|
||
<Tile>
|
||
<span className="ads-eyebrow">Darstellung</span>
|
||
<Seg
|
||
options={[
|
||
{ value: "nacht", label: "Nacht" },
|
||
{ value: "tag", label: "Tag" },
|
||
]}
|
||
value={theme}
|
||
onChange={(neu) => themeSetzen(neu as "nacht" | "tag")}
|
||
/>
|
||
<Feld label="Beschriftung in der Menüleiste" last>
|
||
<Switch
|
||
checked={tabBeschriftung}
|
||
onChange={setzeTabBeschriftung}
|
||
aria-label="Beschriftung in der Menüleiste"
|
||
/>
|
||
</Feld>
|
||
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
|
||
Aus zeigt nur die Symbole und gibt etwas Höhe frei
|
||
</span>
|
||
</Tile>
|
||
|
||
<Tile>
|
||
<span className="ads-eyebrow">Bild der Übersicht</span>
|
||
{/* Sofort gespeichert, nicht ueber die Speicherleiste am Seitenende.
|
||
Das Umschalten der Ansicht ist keine Aenderung, die man sammelt
|
||
und spaeter bestaetigt - man will das andere Foto sehen. Das
|
||
Panel macht es genauso (data-startbild ruft direkt
|
||
profilSpeichern()); in der App tauchte stattdessen unten die
|
||
Leiste "Aenderungen speichern" auf (gemeldet 2026-08-31). */}
|
||
<Feld label="Ansicht" last>
|
||
<select
|
||
className="dm-auswahl"
|
||
value={werte.startbild}
|
||
onChange={(e) => {
|
||
const startbild = e.target.value
|
||
void profilSpeichern({ einstellungen: { ...werte, startbild } })
|
||
}}
|
||
>
|
||
{BILDPLAETZE.map((platz) => (
|
||
<option key={platz.datei} value={platz.name}>
|
||
{platz.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</Feld>
|
||
<span className="ads-eyebrow" style={{ marginTop: "10px" }}>
|
||
Auf das Bild tippen, um für diese Ansicht ein eigenes Foto hochzuladen oder zu ersetzen.
|
||
</span>
|
||
{/* Vorher stand hier ein Raster aller sieben Miniaturen und der Hinweis,
|
||
Fotos seien nur über Home Assistant zu pflegen — das Panel zeigt
|
||
genau die gewählte Ansicht groß und lädt direkt hoch. */}
|
||
<div style={{ marginTop: "var(--sp-3)" }}>
|
||
<BildMitMenue
|
||
datei={gewaehltesBild.datei}
|
||
beschreibung={gewaehltesBild.beschreibung}
|
||
name={gewaehltesBild.beschreibung}
|
||
dateiZeigen
|
||
/>
|
||
</div>
|
||
</Tile>
|
||
|
||
<Tile>
|
||
<span className="ads-eyebrow">Kraftstoff-Rabatt</span>
|
||
<Feld
|
||
label={
|
||
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||
<img
|
||
src={markenlogoUrl()}
|
||
alt={werte.markenname}
|
||
className="dm-markenlogo"
|
||
onError={(e) => {
|
||
e.currentTarget.style.display = "none"
|
||
}}
|
||
/>
|
||
{/* Das Panel nennt den Schalter "SmartDeal aktiv" - fest, nicht
|
||
aus dem Markennamen gebaut. */}
|
||
SmartDeal aktiv
|
||
</span>
|
||
}
|
||
last={!fahrzeug.smartdeal.aktiv}
|
||
>
|
||
<Switch
|
||
checked={fahrzeug.smartdeal.aktiv}
|
||
onChange={(an) => {
|
||
/* Einschalten fragt zuerst, bis wann der Rabatt gilt - ohne
|
||
dieses Datum kann die App ihn nie von selbst auslaufen
|
||
lassen, und genau daran haengt smartdeal.aktiv im
|
||
Profiladapter. Ausschalten braucht keine Rueckfrage.
|
||
Gleiches Verhalten wie data-sd/sdpopup im Panel. */
|
||
if (an) {
|
||
setzeSdEntwurf(fahrzeug.smartdeal.laeuftAb ?? "")
|
||
setzeSdAbfrage(true)
|
||
return
|
||
}
|
||
void profilSpeichern({
|
||
fahrzeug: {
|
||
...fahrzeug,
|
||
smartdeal: { ...fahrzeug.smartdeal, aktiv: false },
|
||
},
|
||
})
|
||
}}
|
||
aria-label="SmartDeal aktiv"
|
||
/>
|
||
</Feld>
|
||
{fahrzeug.smartdeal.aktiv ? (
|
||
<Feld label="Gültig bis" last>
|
||
{/* Reine Anzeige, wie im Panel: geaendert wird das Datum ueber den
|
||
Schalter, der die Abfrage oeffnet - so gibt es nur einen Weg
|
||
dorthin und keine zwei Eingabestellen fuer denselben Wert. */}
|
||
<span>
|
||
{fahrzeug.smartdeal.laeuftAb ? datum(fahrzeug.smartdeal.laeuftAb) : "–"}
|
||
</span>
|
||
</Feld>
|
||
) : (
|
||
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
|
||
Ersparnis und Bildmarke werden ausgeblendet
|
||
</span>
|
||
)}
|
||
</Tile>
|
||
|
||
{/* Blatt statt schwebender Karte: eine Eingabe, die der Nutzer selbst
|
||
angestoßen hat, kommt nach Apples Vorgabe von der unteren Kante -
|
||
in Daumenreichweite und mit sichtbarem Weg hinaus. Die alte Karte
|
||
saß mittig und war obendrein durchscheinend. */}
|
||
<Sheet
|
||
open={sdAbfrage}
|
||
onClose={() => setzeSdAbfrage(false)}
|
||
title="SmartDeal"
|
||
description="Bis wann gilt der Rabatt?"
|
||
>
|
||
<Feld label="Gültig bis" last>
|
||
<Datumsfeld wert={sdEntwurf} beiAenderung={setzeSdEntwurf} />
|
||
</Feld>
|
||
<div className="dm-sd-knoepfe">
|
||
<ActionButton
|
||
variant="primary"
|
||
disabled={!sdEntwurf}
|
||
onClick={() => {
|
||
if (!sdEntwurf) return
|
||
setzeSdAbfrage(false)
|
||
void profilSpeichern({
|
||
fahrzeug: {
|
||
...fahrzeug,
|
||
smartdeal: { aktiv: true, laeuftAb: sdEntwurf },
|
||
},
|
||
})
|
||
}}
|
||
>
|
||
Bestätigen
|
||
</ActionButton>
|
||
<ActionButton onClick={() => setzeSdAbfrage(false)}>Abbrechen</ActionButton>
|
||
</div>
|
||
</Sheet>
|
||
|
||
{/* „Nacht beginnt"/„Nacht endet" sind entfallen: das Panel fuehrt sie
|
||
in den Einstellungen nicht, die Nachtgrenze steht dort nur als
|
||
Erlaeuterung auf der Statistik-Seite. */}
|
||
{/* Die Kachel „Fahrten zusammenfassen" ist am 2026-08-31 entfallen:
|
||
der FMM003 wendet die Pausenregel selbst an (Ignition OFF Timeout
|
||
= 900 s), eine zweite Wartezeit in der App haette daraus eine
|
||
halbe Stunde gemacht. */}
|
||
|
||
<HistorienImport
|
||
offen={importOffen}
|
||
beiSchliessen={() => setzeImportOffen(false)}
|
||
api={api}
|
||
beiFertig={() => void jetztAktualisieren()}
|
||
/>
|
||
|
||
<DatensatzPopup
|
||
offen={datensatzPopup !== null}
|
||
modus={datensatzPopup ?? "sichern"}
|
||
beiSchliessen={() => setzeDatensatzPopup(null)}
|
||
api={api}
|
||
rohprofil={rohprofil}
|
||
fahrten={fahrten}
|
||
tankvorgaenge={tankvorgaenge}
|
||
wartungsplan={(fahrzeug.service["buch"] ?? []) as ServicebuchEintrag[]}
|
||
beiProfilGeladen={profilGeladen}
|
||
beiFertig={() => void jetztAktualisieren()}
|
||
/>
|
||
|
||
<Tile>
|
||
<span className="ads-eyebrow">Fahrzeugprofil</span>
|
||
<p className="dm-erklaerung">
|
||
Alles Fahrzeugspezifische liegt in einer einzigen Datei: Stammdaten, technische Daten,
|
||
Ausstattung, Reifen, Versicherung, Steuer und Werkstatt. Fahrten, Tankvorgänge und der
|
||
Wartungsplan lassen sich einzeln als CSV sichern und bearbeitet wieder einspielen — eine
|
||
Zeile mit bekannter ID aktualisiert nur ihre eigenen Spalten, alles andere bleibt
|
||
unangetastet.
|
||
</p>
|
||
<div className="dm-knopfreihe">
|
||
<ActionButton onClick={() => setzeDatensatzPopup("sichern")}>
|
||
Datensatz sichern
|
||
</ActionButton>
|
||
<ActionButton onClick={() => setzeDatensatzPopup("laden")}>Datensatz laden</ActionButton>
|
||
</div>
|
||
</Tile>
|
||
|
||
{/* Wortlaut, Reihenfolge und Knopfbeschriftungen wie im Panel: die
|
||
Kachel hiess hier „Sicherung", der Erlaeuterungstext fehlte, und die
|
||
Knoepfe waren eigenstaendig formuliert („Jetzt sichern", „Auf dieses
|
||
Geraet laden", „Von diesem Geraet einspielen"). */}
|
||
<Tile>
|
||
<span className="ads-eyebrow">Backup</span>
|
||
<span className="ads-eyebrow" style={{ marginTop: "10px" }}>
|
||
Sichert Fahrzeugprofil, Fahrten und Tankvorgänge gemeinsam. "Jetzt erstellen"
|
||
legt eine Kopie auf dem Server ab, "Exportieren"/"Importieren"
|
||
übertragen den aktuellen Gesamtstand als einzelne Datei.
|
||
</span>
|
||
{werte.letztesBackup && (
|
||
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
|
||
Letztes Backup: {datumZeit(werte.letztesBackup)}
|
||
</span>
|
||
)}
|
||
<ActionButton onClick={() => void api.rest.dienstAufrufen(DIENST_DOMAIN, "backup_jetzt")}>
|
||
Backup jetzt erstellen
|
||
</ActionButton>
|
||
<Feld label="Autom. Backup" last>
|
||
<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>
|
||
<ActionButton onClick={profilExportieren}>Backup exportieren</ActionButton>
|
||
<ActionButton
|
||
onClick={() => backupDateiwahl.current?.click()}
|
||
disabled={backupImportLaeuft}
|
||
>
|
||
{backupImportLaeuft ? "Lade …" : "Backup importieren"}
|
||
</ActionButton>
|
||
<input
|
||
ref={backupDateiwahl}
|
||
type="file"
|
||
accept="application/json"
|
||
hidden
|
||
onChange={(e) => {
|
||
const datei = e.target.files?.[0]
|
||
if (datei) void backupImportieren(datei)
|
||
e.target.value = ""
|
||
}}
|
||
/>
|
||
{backupImportFehler && <p className="dm-fehler">{backupImportFehler}</p>}
|
||
</Tile>
|
||
|
||
|
||
{otaMoeglich() && (
|
||
<Tile>
|
||
<span className="ads-eyebrow">App-Update</span>
|
||
{otaUpdateVerfuegbar && otaBuendel ? (
|
||
<>
|
||
<Werteliste
|
||
kinder={
|
||
<Wertzeile
|
||
label="Verfügbare Version"
|
||
wert={otaBuendel.version}
|
||
zusatz={`${Math.round(otaBuendel.bytes / 1024)} KB`}
|
||
/>
|
||
}
|
||
/>
|
||
<p className="dm-erklaerung">
|
||
Lädt die Oberfläche neu und startet die App neu. Fahrzeugprofil, Fahrten und
|
||
Tankvorgänge sind davon nicht betroffen — die liegen in Home Assistant.
|
||
</p>
|
||
<div className="dm-knopfreihe">
|
||
<ActionButton onClick={() => void otaAusloesen()} disabled={otaLaeuft}>
|
||
{otaLaeuft ? "Lade Update …" : "Update installieren"}
|
||
</ActionButton>
|
||
</div>
|
||
{otaFehler && <p className="dm-fehler">{otaFehler}</p>}
|
||
</>
|
||
) : (
|
||
<p className="dm-erklaerung">
|
||
{otaBuendel
|
||
? `Diese App ist aktuell (${eigeneVersion() ?? "unbekannte Version"}).`
|
||
: "Der Server hat gerade kein Update-Bündel hinterlegt."}
|
||
</p>
|
||
)}
|
||
</Tile>
|
||
)}
|
||
|
||
<Tile>
|
||
<span className="ads-eyebrow">Version</span>
|
||
<Werteliste
|
||
kinder={
|
||
<Wertzeile
|
||
label="Installiert"
|
||
wert={serverVersion ?? "unbekannt"}
|
||
zusatz="Home-Assistant-Integration"
|
||
/>
|
||
}
|
||
/>
|
||
</Tile>
|
||
|
||
<Tile>
|
||
<span className="ads-eyebrow">Integration-Update</span>
|
||
{/* HIG "Loading": Netzwerk zu Gitea plus Entpacken dauert unbestimmt
|
||
lang, ohne verlässliche Fortschrittsangabe - ein laufender
|
||
Indikator statt eines nur deaktivierten Knopfes verhindert, dass
|
||
das wie ein Hänger aussieht. Nach Erfolg führt der Knopf direkt
|
||
zum nächsten nötigen Schritt (Neustart) statt den Nutzer selbst
|
||
suchen zu lassen - ein reiner Reload reicht nicht (AGENTS.md
|
||
Abschnitt J). */}
|
||
{updateInstallierenLaeuft ? (
|
||
<div className="dm-ladezeile">
|
||
<span className="dm-spinner" aria-hidden="true" />
|
||
<span className="dm-erklaerung" style={{ marginTop: 0 }}>
|
||
Installiere …
|
||
</span>
|
||
</div>
|
||
) : updateNeustartLaeuft ? (
|
||
<div className="dm-ladezeile">
|
||
<span className="dm-spinner" aria-hidden="true" />
|
||
<span className="dm-erklaerung" style={{ marginTop: 0 }}>
|
||
Home Assistant startet neu …
|
||
</span>
|
||
</div>
|
||
) : integrationUpdate?.installiert ? (
|
||
<>
|
||
<p className="dm-erklaerung">
|
||
Version {integrationUpdate.version} installiert - die neue Fassung wird erst nach
|
||
einem Neustart geladen.
|
||
</p>
|
||
<div className="dm-knopfreihe">
|
||
<ActionButton onClick={() => void updateNeustartAusloesen()}>
|
||
Installation abschließen - Jetzt neu starten
|
||
</ActionButton>
|
||
</div>
|
||
</>
|
||
) : integrationUpdate?.verfuegbar ? (
|
||
<>
|
||
{/* Zeitstempel und ein zweiter Prüfknopf gehören hierher, nicht nur
|
||
in den Zweig „ist aktuell": die angezeigte Version ist das
|
||
gespeicherte Ergebnis der letzten Prüfung, nicht der Live-Stand.
|
||
Ohne beides sah eine Wochen alte Zahl aus wie eine Tatsache, und
|
||
es gab keinen Weg, sie aufzufrischen, solange überhaupt ein
|
||
Update angeboten wurde. */}
|
||
<Werteliste
|
||
kinder={
|
||
<Wertzeile
|
||
label="Verfügbare Version"
|
||
wert={integrationUpdate.version ?? "?"}
|
||
zusatz={
|
||
integrationUpdate.geprueft_am
|
||
? `geprüft ${datumZeit(integrationUpdate.geprueft_am)}`
|
||
: undefined
|
||
}
|
||
/>
|
||
}
|
||
/>
|
||
<p className="dm-erklaerung">
|
||
Lädt die neueste Fassung von Gitea und ersetzt den Integrationsordner. Home Assistant
|
||
muss danach neu gestartet werden. Fahrzeugprofil, Fahrten und Tankvorgänge sind davon
|
||
nicht betroffen.
|
||
</p>
|
||
<div className="dm-knopfreihe">
|
||
<ActionButton variant="primary" onClick={() => void updateInstallierenAusloesen()}>
|
||
Update installieren
|
||
</ActionButton>
|
||
<ActionButton onClick={() => void updatePruefenAusloesen()}>
|
||
Erneut prüfen
|
||
</ActionButton>
|
||
</div>
|
||
</>
|
||
) : updatePruefenLaeuft ? (
|
||
<div className="dm-ladezeile">
|
||
<span className="dm-spinner" aria-hidden="true" />
|
||
<span className="dm-erklaerung" style={{ marginTop: 0 }}>
|
||
Prüfe …
|
||
</span>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<p className="dm-erklaerung">
|
||
{integrationUpdate
|
||
? `Integration ist aktuell (${integrationUpdate.geprueft_am ? datumZeit(integrationUpdate.geprueft_am) : ""}).`
|
||
: "Noch nicht geprüft."}
|
||
</p>
|
||
<div className="dm-knopfreihe">
|
||
<ActionButton onClick={() => void updatePruefenAusloesen()}>
|
||
Auf Update prüfen
|
||
</ActionButton>
|
||
</div>
|
||
</>
|
||
)}
|
||
{updateAktionFehler && <p className="dm-fehler">{updateAktionFehler}</p>}
|
||
{!updateAktionFehler &&
|
||
integrationUpdate?.fehler &&
|
||
!updatePruefenLaeuft &&
|
||
!updateInstallierenLaeuft &&
|
||
!updateNeustartLaeuft && <p className="dm-fehler">{integrationUpdate.fehler}</p>}
|
||
</Tile>
|
||
|
||
{/* Ohne Entsprechung im Panel - und zwar notwendigerweise: das Panel
|
||
laeuft innerhalb von Home Assistant und kennt keine Serveradresse und
|
||
kein Token, die man wechseln koennte. Bleibt deshalb bestehen (wie
|
||
der Willkommensbildschirm). Die beiden reinen Zaehlzeilen darueber
|
||
(„Fahrten gespeichert"/„Tankvorgaenge gespeichert") sind entfallen -
|
||
die waren Zusatz ohne Panel-Vorbild. */}
|
||
<Tile>
|
||
<span className="ads-eyebrow">Zugang</span>
|
||
<ActionButton variant="destructive" onClick={() => void abmelden()}>
|
||
Verbindung trennen
|
||
</ActionButton>
|
||
</Tile>
|
||
|
||
{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>
|
||
)}
|
||
</>
|
||
)
|
||
}
|