edf3845375
The phone-app codebase (companion-app/) hadn't been touched since 2026-08-11 - every panel fix and feature since then (HIG audit rounds, receipt upload, trip editing, Inspektion forecast, ...) existed only in the HA panel. Found while auditing what's needed for a real iPhone build; user decision was to port everything now rather than ship a stale app. Six real, verified gaps (not blind copies of panel CSS/markup, which doesn't transfer to the @audi-dash/ui component set): - Battery voltage cutoff was still 13.2V, not the panel's 12.8V fix. - Dead wlan_name field (WLAN trip detection was fully removed from the backend 2026-08-12) - removed from the adapter and the settings screen instead of leaving a form field that silently does nothing. - No Inspektion forecast - added inspektionPrognose() alongside the existing oelwechselPrognose(), sharing a refactored core. - Arbeitsweg pill used the design-system's "work" variant, which is documented as recoloring to red - stopped passing it, same fix as the panel. - Trip creation only took Beginn/Ende/Art; extended with Startort/ Zielort/Kilometerstand/Distanz via a new shared FahrtFelder.tsx. - FahrtDetail.tsx was read-only - added an edit mode using the same shared fields, backed by a new DataMetricApi.fahrtAktualisieren() calling the backend service built earlier this session. Explicitly checked and found not applicable: price rounding (already 2 decimals here), pull-to-refresh CSS (no native gesture to fix), the panel's drag/paste receipt dialog (solves a desktop-browser problem this native app doesn't have - the plain file picker already gets iOS's native Files integration), and the purely cosmetic panel CSS fixes. npm install run at the repo root (node_modules was incomplete/stale), package-lock.json reflects the real dependency tree. Verified with npm run typecheck (clean), npm run test (95/95, up from 90 - added interaction tests for the new form and inspektionPrognose), and npm run build (succeeds). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
351 lines
11 KiB
TypeScript
351 lines
11 KiB
TypeScript
/**
|
|
* 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" last>
|
|
<input
|
|
className="dm-eingabe"
|
|
inputMode="numeric"
|
|
value={werte.tankvolumen}
|
|
onChange={(e) => aendern({ tankvolumen: Number(e.target.value) || 0 })}
|
|
/>
|
|
</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>
|
|
)}
|
|
</>
|
|
)
|
|
}
|