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,96 @@
|
||||
/**
|
||||
* Eigene Ölwechsel-Prognose. Portiert aus `oelwechselPrognose()` im alten
|
||||
* Panel.
|
||||
*
|
||||
* Die Rechnung beginnt bewusst neu ab dem letzten tatsächlich durchgeführten
|
||||
* Ölwechsel — der allgemeine Langzeitdurchschnitt seit dem allerersten
|
||||
* Servicebucheintrag wäre dafür zu träge. Aus km und Tagen seit dem letzten
|
||||
* Wechsel ergibt sich die aktuelle Fahrleistung pro Tag; hochgerechnet auf die
|
||||
* verbleibenden km bis zum eingestellten Intervall ergibt das ein Datum,
|
||||
* gedeckelt auf spätestens das Zeitlimit des Intervalls.
|
||||
*/
|
||||
|
||||
import type { Fahrzeug } from "./profilAdapter"
|
||||
|
||||
const TAG_MS = 86_400_000
|
||||
|
||||
export interface Oelprognose {
|
||||
/** ISO-Tagesdatum. */
|
||||
datum: string
|
||||
restKm: number
|
||||
kmProTag: number
|
||||
/** Die Zeitgrenze hat gegriffen, nicht die Kilometergrenze. */
|
||||
durchZeitlimit: boolean
|
||||
}
|
||||
|
||||
interface Buchhaltung {
|
||||
datum?: string
|
||||
km?: number
|
||||
art?: string
|
||||
}
|
||||
|
||||
function monatePlus(isoDatum: string, monate: number): string {
|
||||
const d = new Date(isoDatum)
|
||||
if (Number.isNaN(d.getTime())) return isoDatum
|
||||
d.setMonth(d.getMonth() + monate)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/** Der jüngste Servicebucheintrag, dessen Art auf einen Ölwechsel hindeutet. */
|
||||
export function letzterOelwechsel(buch: readonly Buchhaltung[]): Buchhaltung | null {
|
||||
const treffer = buch
|
||||
.filter((e) => typeof e.art === "string" && /öl|oel/i.test(e.art) && e.datum && e.km != null)
|
||||
.sort((a, b) => String(b.datum).localeCompare(String(a.datum)))
|
||||
return treffer[0] ?? null
|
||||
}
|
||||
|
||||
export function oelwechselPrognose(
|
||||
fahrzeug: Fahrzeug,
|
||||
buch: readonly Buchhaltung[],
|
||||
jetzt = new Date(),
|
||||
): Oelprognose | null {
|
||||
if (!fahrzeug.odoBekannt) return null
|
||||
const letzter = letzterOelwechsel(buch)
|
||||
if (!letzter?.datum || letzter.km == null) return null
|
||||
|
||||
const oel = fahrzeug.oel
|
||||
const intervallKm =
|
||||
oel.modus === "hersteller"
|
||||
? (oel.herstellerKm ?? 0)
|
||||
: Math.min(oel.km ?? Infinity, oel.herstellerKm ?? Infinity)
|
||||
const intervallMonate =
|
||||
oel.modus === "hersteller"
|
||||
? (oel.herstellerMonate ?? 0)
|
||||
: Math.min(oel.monate ?? Infinity, oel.herstellerMonate ?? Infinity)
|
||||
|
||||
if (!Number.isFinite(intervallKm) || intervallKm <= 0) return null
|
||||
|
||||
const zielKm = letzter.km + intervallKm
|
||||
const restKm = Math.max(0, zielKm - fahrzeug.odo)
|
||||
const kappDatum = monatePlus(letzter.datum, Number.isFinite(intervallMonate) ? intervallMonate : 24)
|
||||
|
||||
if (restKm <= 0) {
|
||||
return { datum: jetzt.toISOString().slice(0, 10), restKm: 0, kmProTag: 0, durchZeitlimit: false }
|
||||
}
|
||||
|
||||
const tageSeit = (jetzt.getTime() - new Date(letzter.datum).getTime()) / TAG_MS
|
||||
const kmSeit = fahrzeug.odo - letzter.km
|
||||
|
||||
// Ohne belastbare Fahrleistung (direkt nach dem Wechsel) gibt es keine
|
||||
// seriöse Kilometerprognose — dann gilt das Zeitlimit.
|
||||
if (tageSeit <= 0 || kmSeit <= 0) {
|
||||
return { datum: kappDatum, restKm, kmProTag: 0, durchZeitlimit: true }
|
||||
}
|
||||
|
||||
const kmProTag = kmSeit / tageSeit
|
||||
const tageBisZiel = restKm / kmProTag
|
||||
const ausKm = new Date(jetzt.getTime() + tageBisZiel * TAG_MS).toISOString().slice(0, 10)
|
||||
|
||||
const durchZeitlimit = kappDatum < ausKm
|
||||
return {
|
||||
datum: durchZeitlimit ? kappDatum : ausKm,
|
||||
restKm,
|
||||
kmProTag,
|
||||
durchZeitlimit,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Schalter für Funktionen, die gebaut, aber noch nicht nutzbar sind.
|
||||
*
|
||||
* Der Sinn: Die Oberfläche dafür existiert und ist geprüft, sie zeigt aber
|
||||
* nichts an, solange keine echten Daten fließen. Ein sichtbarer Menüpunkt mit
|
||||
* lauter Nullen wäre schlechter als gar keiner.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Live-Ansicht der laufenden Fahrt (Position, Geschwindigkeit, Strecke).
|
||||
*
|
||||
* Braucht den FMM003 am CAN-Bus, der die Werte per MQTT nach Home Assistant
|
||||
* schickt — siehe COMPANION_APP_ARCHITECTURE.md §2b und Phase 13 des
|
||||
* Umsetzungsplans. **Erst umlegen, wenn die Zuordnungstabelle aus einer echten
|
||||
* MQTT-Nachricht gebaut ist**, nicht vorher.
|
||||
*/
|
||||
export const LIVE_VERFUEGBAR = false
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Batteriespannung im Verlauf. Vorlage: `vBatterieverlauf()`.
|
||||
*
|
||||
* Das Diagramm ist von Hand als SVG gezeichnet — wie im alten Panel und aus
|
||||
* demselben Grund: eine Diagrammbibliothek wäre um ein Vielfaches größer als
|
||||
* die ganze Seite.
|
||||
*
|
||||
* Wichtig für die Einordnung: Der Ladezustand wird aus der **Tagesminimum**-
|
||||
* Spannung geschätzt (Ruhespannung kurz nach dem Start, bevor die Lichtmaschine
|
||||
* anhebt); Werte über 13,2 V gelten als „während der Fahrt gemessen" und
|
||||
* taugen nicht als Ruhewert.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { Tile } from "@audi-dash/ui"
|
||||
|
||||
import { ENTITAETEN } from "../api"
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { datum, de } from "../format"
|
||||
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
|
||||
|
||||
interface Tageswert {
|
||||
datum: string
|
||||
min: number | null
|
||||
max: number | null
|
||||
min_ts?: string
|
||||
max_ts?: string
|
||||
}
|
||||
|
||||
/** Ab hier gilt eine Messung als nicht mehr in Ruhe aufgenommen. */
|
||||
const AGM_RUHE_MAX_V = 13.2
|
||||
|
||||
/** Spannung → Ladezustand für eine AGM-Batterie in Ruhe. */
|
||||
const LADEKURVE: [number, number][] = [
|
||||
[12.8, 100],
|
||||
[12.6, 90],
|
||||
[12.4, 75],
|
||||
[12.2, 50],
|
||||
[12.0, 25],
|
||||
[11.8, 10],
|
||||
]
|
||||
|
||||
function ladezustand(spannung: number): number | null {
|
||||
if (spannung > AGM_RUHE_MAX_V) return null
|
||||
const ersterTreffer = LADEKURVE.find(([v]) => spannung >= v)
|
||||
if (ersterTreffer) return ersterTreffer[1]
|
||||
return 0
|
||||
}
|
||||
|
||||
export function Batterie() {
|
||||
const { api, fahrzeug } = useDaten()
|
||||
const [verlauf, setzeVerlauf] = useState<Tageswert[] | null>(null)
|
||||
const [fehler, setzeFehler] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
void api.rest
|
||||
.datenLesen<Tageswert[]>(ENTITAETEN.batterieverlauf)
|
||||
.then((daten) => setzeVerlauf(Array.isArray(daten) ? daten : []))
|
||||
.catch(() => setzeFehler(true))
|
||||
}, [api])
|
||||
|
||||
if (fehler) {
|
||||
return (
|
||||
<Leerzustand
|
||||
titel="Verlauf nicht abrufbar"
|
||||
text="Der Server hat den Batterieverlauf nicht geliefert. Beim nächsten Aufruf klappt es vielleicht wieder."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!verlauf) return <div className="dm-laden">Verlauf wird geladen …</div>
|
||||
|
||||
const messwerte = verlauf.filter((t) => t.min != null)
|
||||
|
||||
if (messwerte.length === 0) {
|
||||
return (
|
||||
<Leerzustand
|
||||
titel="Keine Messwerte"
|
||||
text="Für dieses Fahrzeug liefert die Anbindung derzeit keinen Spannungssensor. Sobald einer verfügbar ist, zeichnet die App den Verlauf hier auf."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const letzter = messwerte[messwerte.length - 1]!
|
||||
const soc = letzter.min != null ? ladezustand(letzter.min) : null
|
||||
|
||||
// Trend als Vergleich früher gegen später — keine echte Kapazitätsmessung.
|
||||
const haelfte = Math.floor(messwerte.length / 2)
|
||||
const mittel = (liste: Tageswert[]) =>
|
||||
liste.length > 0 ? liste.reduce((s, t) => s + (t.min ?? 0), 0) / liste.length : null
|
||||
const frueher = mittel(messwerte.slice(0, haelfte))
|
||||
const spaeter = mittel(messwerte.slice(haelfte))
|
||||
const trend = frueher != null && spaeter != null ? spaeter - frueher : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Ruhespannung</span>
|
||||
<div className="dm-detail__kopf">
|
||||
<div className="dm-detail__zahl">
|
||||
{letzter.min != null ? de(letzter.min, 2) : "—"}
|
||||
<span className="dm-detail__einheit">V</span>
|
||||
</div>
|
||||
</div>
|
||||
<Verlaufsdiagramm werte={messwerte} />
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile label="Letzte Messung" wert={datum(letzter.datum)} />
|
||||
<Wertzeile
|
||||
label="Geschätzter Ladezustand"
|
||||
wert={soc != null ? `${de(soc)} %` : "—"}
|
||||
zusatz={soc == null ? "Messung nicht in Ruhe aufgenommen" : undefined}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Trend über den Zeitraum"
|
||||
wert={trend != null ? `${trend >= 0 ? "+" : ""}${de(trend, 2)} V` : "—"}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Aktuell gemeldet"
|
||||
wert={
|
||||
fahrzeug?.batteriespannung != null
|
||||
? `${de(fahrzeug.batteriespannung, 2)} V`
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<p className="dm-fussnote">
|
||||
Der Ladezustand wird aus dem Tagesminimum geschätzt — das ist die Spannung kurz nach dem
|
||||
Start, bevor die Lichtmaschine sie anhebt. Der Trend vergleicht die frühere mit der
|
||||
späteren Hälfte des Zeitraums; er ist ein Hinweis, keine Kapazitätsmessung.
|
||||
</p>
|
||||
</Tile>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Von Hand gezeichnete Verlaufskurve aus Tagesminimum und -maximum. */
|
||||
function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
|
||||
const breite = 320
|
||||
const hoehe = 120
|
||||
const rand = 4
|
||||
|
||||
const alleWerte = werte.flatMap((t) => [t.min, t.max].filter((v): v is number => v != null))
|
||||
const kleinster = Math.min(...alleWerte) - 0.1
|
||||
const groesster = Math.max(...alleWerte) + 0.1
|
||||
const spanne = Math.max(0.2, groesster - kleinster)
|
||||
|
||||
const x = (i: number) =>
|
||||
rand + (i / Math.max(1, werte.length - 1)) * (breite - 2 * rand)
|
||||
const y = (v: number) => hoehe - rand - ((v - kleinster) / spanne) * (hoehe - 2 * rand)
|
||||
|
||||
const linie = (auswahl: (t: Tageswert) => number | null) =>
|
||||
werte
|
||||
.map((t, i) => {
|
||||
const v = auswahl(t)
|
||||
return v == null ? null : `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(v).toFixed(1)}`
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="dm-diagramm"
|
||||
viewBox={`0 0 ${breite} ${hoehe}`}
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-label={`Spannungsverlauf über ${werte.length} Tage`}
|
||||
>
|
||||
<path d={linie((t) => t.max)} fill="none" stroke="var(--fg3)" strokeWidth="1" />
|
||||
<path d={linie((t) => t.min)} fill="none" stroke="var(--red)" strokeWidth="1.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Fahrzeugdaten: Identität, technisches Datenblatt, Ausstattung.
|
||||
* Vorlage: `vIdent()` im alten Panel. Rein anzeigend — die Inhalte pflegt
|
||||
* man im Profil, nicht hier.
|
||||
*/
|
||||
|
||||
import { Accordion, Tile } from "@audi-dash/ui"
|
||||
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { datum, de } from "../format"
|
||||
import { Wertzeile, Werteliste } from "./bausteine"
|
||||
|
||||
interface TechnikGruppe {
|
||||
gruppe?: string
|
||||
posten?: [string, string][]
|
||||
}
|
||||
|
||||
interface AusstattungGruppe {
|
||||
gruppe?: string
|
||||
sonder?: string[]
|
||||
serie?: string[]
|
||||
}
|
||||
|
||||
export function Fahrzeugdaten() {
|
||||
const { fahrzeug, einstellungen } = useDaten()
|
||||
if (!fahrzeug || !einstellungen) return null
|
||||
|
||||
const technik = Array.isArray(fahrzeug.technik)
|
||||
? (fahrzeug.technik as TechnikGruppe[])
|
||||
: (Object.values(fahrzeug.technik) as TechnikGruppe[])
|
||||
const ausstattung = Array.isArray(fahrzeug.ausstattung)
|
||||
? (fahrzeug.ausstattung as AusstattungGruppe[])
|
||||
: []
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Identität</span>
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile label="Modell" wert={einstellungen.fahrzeugtitel || "—"} />
|
||||
<Wertzeile label="Ausführung" wert={einstellungen.ausfuehrung || fahrzeug.details || "—"} />
|
||||
<Wertzeile label="Fahrgestellnummer" wert={fahrzeug.fin || "—"} />
|
||||
<Wertzeile label="Kennzeichen" wert={einstellungen.kennzeichen || "—"} />
|
||||
<Wertzeile
|
||||
label="Erstzulassung"
|
||||
wert={fahrzeug.erstzulassung ? datum(fahrzeug.erstzulassung) : "—"}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Hauptuntersuchung fällig"
|
||||
wert={fahrzeug.hu ? datum(fahrzeug.hu) : "—"}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Kilometerstand"
|
||||
wert={fahrzeug.odoBekannt ? `${de(fahrzeug.odo)} km` : "—"}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Tile>
|
||||
|
||||
{technik.length > 0 && (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Technische Daten</span>
|
||||
{technik.map((gruppe, i) => (
|
||||
<Accordion key={gruppe.gruppe ?? i} title={gruppe.gruppe ?? "Weitere Angaben"}>
|
||||
<Werteliste
|
||||
kinder={(gruppe.posten ?? []).map(([bezeichnung, wert]) => (
|
||||
<Wertzeile key={bezeichnung} label={bezeichnung} wert={wert} />
|
||||
))}
|
||||
/>
|
||||
</Accordion>
|
||||
))}
|
||||
</Tile>
|
||||
)}
|
||||
|
||||
{ausstattung.length > 0 && (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Ausstattung</span>
|
||||
{ausstattung.map((gruppe, i) => (
|
||||
<Accordion
|
||||
key={gruppe.gruppe ?? i}
|
||||
title={gruppe.gruppe ?? "Weitere Ausstattung"}
|
||||
summary={`${(gruppe.sonder?.length ?? 0) + (gruppe.serie?.length ?? 0)}`}
|
||||
>
|
||||
<ul className="dm-liste">
|
||||
{(gruppe.sonder ?? []).map((posten) => (
|
||||
<li key={posten}>{posten}</li>
|
||||
))}
|
||||
{(gruppe.serie ?? []).map((posten) => (
|
||||
<li key={posten} className="dm-liste__serie">
|
||||
{posten}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Accordion>
|
||||
))}
|
||||
</Tile>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* „Fahrt läuft gerade" — Live-Ansicht mit Karte, Geschwindigkeit, bisheriger
|
||||
* Strecke und Dauer.
|
||||
*
|
||||
* Steht bereit, ist aber **abgeschaltet**: Die Daten dafür (Zündung, Position,
|
||||
* Geschwindigkeit) liefert erst der FMM003, und der ist noch nicht verbaut
|
||||
* (COMPANION_APP_ARCHITECTURE.md §2b, Phase 13 des Umsetzungsplans). Der
|
||||
* Schalter liegt in `funktionen.ts` — umlegen, sobald echte Werte fließen,
|
||||
* nicht vorher: bis dahin gäbe es nichts anzuzeigen außer Nullen.
|
||||
*/
|
||||
|
||||
import { Fig, Tile } from "@audi-dash/ui"
|
||||
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { dauer, de, deOderStrich } from "../format"
|
||||
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
|
||||
import { Karte } from "./Karte"
|
||||
|
||||
export interface LiveZustand {
|
||||
faehrt: boolean
|
||||
geschwindigkeit?: number | null
|
||||
position?: { lat: number; lon: number } | null
|
||||
streckeKm?: number | null
|
||||
dauerS?: number | null
|
||||
}
|
||||
|
||||
/** Liest die Live-Werte aus dem Fahrzeugstatus. Solange der FMM003 fehlt,
|
||||
liefert das Backend diese Felder nicht — dann ist alles null. */
|
||||
export function liveZustandLesen(status: Record<string, unknown>): LiveZustand {
|
||||
const lat = typeof status["lat"] === "number" ? status["lat"] : null
|
||||
const lon = typeof status["lon"] === "number" ? status["lon"] : null
|
||||
return {
|
||||
faehrt: status["zuendung"] === true || status["zuendung"] === 1,
|
||||
geschwindigkeit: typeof status["geschwindigkeit"] === "number" ? status["geschwindigkeit"] : null,
|
||||
position: lat != null && lon != null ? { lat, lon } : null,
|
||||
streckeKm: typeof status["fahrt_strecke_km"] === "number" ? status["fahrt_strecke_km"] : null,
|
||||
dauerS: typeof status["fahrt_dauer_s"] === "number" ? status["fahrt_dauer_s"] : null,
|
||||
}
|
||||
}
|
||||
|
||||
export function LiveFahrt() {
|
||||
const { fahrzeug } = useDaten()
|
||||
if (!fahrzeug) return null
|
||||
|
||||
const live = liveZustandLesen(fahrzeug as unknown as Record<string, unknown>)
|
||||
|
||||
if (!live.faehrt) {
|
||||
return (
|
||||
<Leerzustand
|
||||
titel="Das Fahrzeug steht"
|
||||
text="Sobald die Zündung an ist, siehst du hier die laufende Fahrt mit Position, Geschwindigkeit, Strecke und Dauer."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Geschwindigkeit</span>
|
||||
<Fig value={deOderStrich(live.geschwindigkeit)} unit="km/h" size={56} />
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile
|
||||
label="Bisher gefahren"
|
||||
wert={live.streckeKm != null ? `${de(live.streckeKm, 1)} km` : "—"}
|
||||
/>
|
||||
<Wertzeile label="Unterwegs seit" wert={dauer(live.dauerS)} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Tile>
|
||||
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Position</span>
|
||||
{live.position ? (
|
||||
<Karte start={live.position} />
|
||||
) : (
|
||||
<Leerzustand
|
||||
titel="Keine Position"
|
||||
text="Das Fahrzeug meldet gerade keine Koordinaten."
|
||||
/>
|
||||
)}
|
||||
</Tile>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* Vorläufiger Inhalt aller Seiten — wird in Phase 7 des Umsetzungsplans Seite
|
||||
* für Seite durch die echten Ansichten ersetzt. Bis dahin belegt er die
|
||||
* Navigation, damit sich der Rahmen bedienen lässt.
|
||||
*/
|
||||
|
||||
import { Tile } from "@audi-dash/ui"
|
||||
|
||||
import type { Route, SeitenName } from "../navigation"
|
||||
import { TITEL } from "../navigation"
|
||||
|
||||
const UNTERSEITEN: Partial<Record<SeitenName, SeitenName[]>> = {
|
||||
home: ["sicherheit", "einst"],
|
||||
audi: ["ident", "battverlauf", "service", "reifen", "vers"],
|
||||
service: ["werkstatt", "sbuch"],
|
||||
vers: ["beitrag", "vertragsdetails", "steuer", "notruf"],
|
||||
vertragsdetails: ["schutz"],
|
||||
trips: ["trip"],
|
||||
fuel: ["fill"],
|
||||
}
|
||||
|
||||
export function Platzhalter({
|
||||
route,
|
||||
geheZu,
|
||||
}: {
|
||||
route: Route
|
||||
geheZu: (name: SeitenName, id?: string) => void
|
||||
}) {
|
||||
const weiter = UNTERSEITEN[route.name] ?? []
|
||||
return (
|
||||
<>
|
||||
<Tile>
|
||||
<div style={{ fontWeight: 300, fontSize: 18 }}>{TITEL[route.name]}</div>
|
||||
<p style={{ color: "var(--fg2)", fontSize: 14, fontWeight: 300, margin: "8px 0 0" }}>
|
||||
Diese Seite wird noch gebaut.
|
||||
</p>
|
||||
</Tile>
|
||||
{weiter.length > 0 && (
|
||||
<div className="dm-raster">
|
||||
{weiter.map((ziel) => (
|
||||
<Tile key={ziel} variant="button" chevron onClick={() => geheZu(ziel)}>
|
||||
<span className="ads-eyebrow">weiter</span>
|
||||
<div style={{ fontWeight: 300, fontSize: 16, marginTop: 4 }}>{TITEL[ziel]}</div>
|
||||
</Tile>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Reifensätze. Vorlage: `vReifen()` im alten Panel.
|
||||
*
|
||||
* Der Kilometerstand je Satz kommt vom Backend (`reifenzaehler.py`) und wird
|
||||
* hier nie zurückgeschrieben — sonst überschriebe ein im Browser gehaltener
|
||||
* älterer Stand eine zwischenzeitliche Fortschreibung. Der Wechsel läuft
|
||||
* deshalb auch über einen eigenen Dienst statt über das Profil.
|
||||
*/
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import { ActionButton, Feld, Seg, Tile } from "@audi-dash/ui"
|
||||
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import type { Reifensatz } from "../daten/profilAdapter"
|
||||
import { datum, de, deOderStrich } from "../format"
|
||||
import { Wertzeile, Werteliste, bestaetigen } from "./bausteine"
|
||||
import { Bild } from "./Bild"
|
||||
import { kalenderDateiLaden } from "./kalender"
|
||||
|
||||
export function Reifen() {
|
||||
const { fahrzeug, api, profilSpeichern } = useDaten()
|
||||
const [laeuft, setzeLaeuft] = useState(false)
|
||||
const [gezeigt, setzeGezeigt] = useState<"Sommer" | "Winter" | null>(null)
|
||||
|
||||
if (!fahrzeug) return null
|
||||
|
||||
const aktiv = fahrzeug.reifen.aktiv
|
||||
const anzeige = gezeigt ?? aktiv
|
||||
const satz: Reifensatz = anzeige === "Winter" ? fahrzeug.reifen.winter : fahrzeug.reifen.sommer
|
||||
const wechsel = fahrzeug.reifen.wechsel as { datum?: string; vereinbart?: boolean }
|
||||
|
||||
const wechseln = async () => {
|
||||
const ziel = aktiv === "Sommer" ? "winter" : "sommer"
|
||||
if (!bestaetigen(`Auf ${ziel === "winter" ? "Winter" : "Sommer"}reifen umstellen?`)) return
|
||||
setzeLaeuft(true)
|
||||
try {
|
||||
// Eigener Dienst: das Backend rechnet zuerst den alten Satz ab und
|
||||
// schaltet erst dann um, damit keine Kilometer auf den neuen Satz
|
||||
// rutschen, die noch auf dem alten gefahren wurden.
|
||||
await api.rest.dienstAufrufen("pyscript", "audi_dashboard_reifen_wechseln", { satz: ziel })
|
||||
} finally {
|
||||
setzeLaeuft(false)
|
||||
}
|
||||
}
|
||||
|
||||
const wechseldatumSetzen = async (neuesDatum: string) => {
|
||||
await profilSpeichern({
|
||||
fahrzeug: {
|
||||
...fahrzeug,
|
||||
reifen: { ...fahrzeug.reifen, wechsel: { ...wechsel, datum: neuesDatum } },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Montiert</span>
|
||||
<div className="dm-detail__kopf">
|
||||
<div className="dm-detail__zahl">{aktiv}</div>
|
||||
<ActionButton onClick={() => void wechseln()} disabled={laeuft}>
|
||||
{laeuft ? "Stelle um …" : `Auf ${aktiv === "Sommer" ? "Winter" : "Sommer"} wechseln`}
|
||||
</ActionButton>
|
||||
</div>
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile
|
||||
label="Anzugsmoment der Radschrauben"
|
||||
wert={
|
||||
fahrzeug.reifen.anzugsmoment != null
|
||||
? `${de(fahrzeug.reifen.anzugsmoment)} Nm`
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Nächster Wechseltermin"
|
||||
wert={wechsel.datum ? datum(wechsel.datum) : "—"}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Feld label="Wechseltermin" last>
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
type="date"
|
||||
value={wechsel.datum ?? ""}
|
||||
onChange={(e) => void wechseldatumSetzen(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
{wechsel.datum && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<ActionButton
|
||||
onClick={() =>
|
||||
kalenderDateiLaden(
|
||||
"Reifenwechsel",
|
||||
wechsel.datum!,
|
||||
`Wechsel auf ${aktiv === "Sommer" ? "Winter" : "Sommer"}reifen`,
|
||||
)
|
||||
}
|
||||
>
|
||||
In den Kalender eintragen
|
||||
</ActionButton>
|
||||
</div>
|
||||
)}
|
||||
</Tile>
|
||||
|
||||
<Seg
|
||||
options={[
|
||||
{ value: "Sommer", label: "Sommer" },
|
||||
{ value: "Winter", label: "Winter" },
|
||||
]}
|
||||
value={anzeige}
|
||||
onChange={(wert) => setzeGezeigt(wert as "Sommer" | "Winter")}
|
||||
/>
|
||||
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">
|
||||
{anzeige}reifen {anzeige === aktiv ? "· montiert" : "· eingelagert"}
|
||||
</span>
|
||||
<Bild
|
||||
datei={anzeige === "Winter" ? "rad-winter.webp" : "rad-sommer.webp"}
|
||||
beschreibung={`${anzeige}rad`}
|
||||
name={`${anzeige}rad`}
|
||||
/>
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile label="Gefahrene Strecke" wert={`${deOderStrich(satz["km"] as number)} km`} />
|
||||
<Wertzeile label="Größe" wert={(satz["mass"] as string) || "—"} />
|
||||
<Wertzeile
|
||||
label="Marke und Modell"
|
||||
wert={
|
||||
[satz["marke"], satz["modell"]].filter(Boolean).join(" ") || "—"
|
||||
}
|
||||
/>
|
||||
<Wertzeile label="DOT" wert={(satz["dot"] as string) || "—"} />
|
||||
<Wertzeile
|
||||
label="Luftdruck"
|
||||
wert={`${(satz["druck_vorne"] as string) || "—"} / ${(satz["druck_hinten"] as string) || "—"} bar`}
|
||||
zusatz="vorne / hinten"
|
||||
/>
|
||||
{Boolean(satz["kommentar"]) && (
|
||||
<Wertzeile label="Notiz" wert={satz["kommentar"] as string} />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<p className="dm-fussnote">
|
||||
Die gefahrene Strecke schreibt der Server bei jeder Änderung des Kilometerstands fort und
|
||||
rechnet sie immer dem gerade montierten Satz zu.
|
||||
</p>
|
||||
</Tile>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* Service, Werkstatt und Servicebuch. Vorlagen: `vService()`, `vWerkstatt()`,
|
||||
* `vSbuch()`.
|
||||
*
|
||||
* Die vom Fahrzeug gemeldete Fälligkeit und die aus dem Servicebuch
|
||||
* gerechnete Prognose stehen bewusst nebeneinander statt sich zu ersetzen:
|
||||
* Die Logik im Werkscomputer ist nicht bekannt und kann von den selbst
|
||||
* eingestellten Intervallen abweichen.
|
||||
*/
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import { ActionButton, Feld, Tile } from "@audi-dash/ui"
|
||||
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { oelwechselPrognose } from "../daten/service"
|
||||
import { datum, de, isoTag } from "../format"
|
||||
import type { SeitenName } from "../navigation"
|
||||
import { Leerzustand, NaviKachel, Wertzeile, Werteliste, bestaetigen } from "./bausteine"
|
||||
import { kalenderDateiLaden } from "./kalender"
|
||||
|
||||
export interface ServicebuchEintrag {
|
||||
datum?: string
|
||||
km?: number
|
||||
art?: string
|
||||
betrieb?: string
|
||||
notiz?: string
|
||||
[weitere: string]: unknown
|
||||
}
|
||||
|
||||
interface Autohaus {
|
||||
name?: string
|
||||
adresse?: string
|
||||
tel?: string
|
||||
mail?: string
|
||||
}
|
||||
|
||||
export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
|
||||
const { fahrzeug } = useDaten()
|
||||
if (!fahrzeug) return null
|
||||
|
||||
const buch = (fahrzeug.service["buch"] ?? []) as ServicebuchEintrag[]
|
||||
const autohaus = (fahrzeug.service["autohaus"] ?? {}) as Autohaus
|
||||
const prognose = oelwechselPrognose(fahrzeug, buch)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Nächster Ölwechsel</span>
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile
|
||||
label="Vom Fahrzeug gemeldet"
|
||||
wert={fahrzeug.oelwechselFaelligTs ? datum(fahrzeug.oelwechselFaelligTs) : "—"}
|
||||
zusatz={
|
||||
fahrzeug.oelwechselFaelligKm != null
|
||||
? `in ${de(Math.abs(fahrzeug.oelwechselFaelligKm))} km`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Eigene Prognose"
|
||||
wert={prognose ? datum(prognose.datum) : "—"}
|
||||
zusatz={
|
||||
prognose
|
||||
? `${de(prognose.restKm)} km offen · ${de(prognose.kmProTag, 1)} km/Tag`
|
||||
: "Noch kein Ölwechsel im Servicebuch"
|
||||
}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Nächste Inspektion"
|
||||
wert={fahrzeug.inspektionFaelligTs ? datum(fahrzeug.inspektionFaelligTs) : "—"}
|
||||
zusatz={
|
||||
fahrzeug.inspektionFaelligKm != null
|
||||
? `in ${de(Math.abs(fahrzeug.inspektionFaelligKm))} km`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Hauptuntersuchung"
|
||||
wert={fahrzeug.hu ? datum(fahrzeug.hu) : "—"}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{prognose && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<ActionButton
|
||||
onClick={() =>
|
||||
kalenderDateiLaden("Ölwechsel", prognose.datum, "Ölwechsel laut eigener Prognose")
|
||||
}
|
||||
>
|
||||
In den Kalender eintragen
|
||||
</ActionButton>
|
||||
</div>
|
||||
)}
|
||||
<p className="dm-fussnote">
|
||||
Die eigene Prognose rechnet ab dem letzten Ölwechsel im Servicebuch neu und nutzt die
|
||||
seither tatsächlich gefahrene Strecke. Sie ersetzt die Meldung des Fahrzeugs nicht,
|
||||
sondern ergänzt sie.
|
||||
</p>
|
||||
</Tile>
|
||||
|
||||
<div className="dm-raster">
|
||||
<NaviKachel
|
||||
titel="Werkstatt"
|
||||
wert={autohaus.name || "Noch nicht hinterlegt"}
|
||||
beiKlick={() => geheZu("werkstatt")}
|
||||
/>
|
||||
<NaviKachel
|
||||
titel="Servicebuch"
|
||||
wert={`${de(buch.length)} Eintrag${buch.length === 1 ? "" : "e"}`}
|
||||
beiKlick={() => geheZu("sbuch")}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function Werkstatt() {
|
||||
const { fahrzeug, profilSpeichern } = useDaten()
|
||||
const autohausRoh = (fahrzeug?.service["autohaus"] ?? {}) as Autohaus
|
||||
const [entwurf, setzeEntwurf] = useState<Autohaus>({ ...autohausRoh })
|
||||
const [laeuft, setzeLaeuft] = useState(false)
|
||||
const [gespeichert, setzeGespeichert] = useState(false)
|
||||
|
||||
if (!fahrzeug) return null
|
||||
|
||||
const speichern = async () => {
|
||||
setzeLaeuft(true)
|
||||
setzeGespeichert(false)
|
||||
try {
|
||||
const neu = { ...fahrzeug, service: { ...fahrzeug.service, autohaus: entwurf } }
|
||||
await profilSpeichern({ fahrzeug: neu })
|
||||
setzeGespeichert(true)
|
||||
} finally {
|
||||
setzeLaeuft(false)
|
||||
}
|
||||
}
|
||||
|
||||
const feld = (schluessel: keyof Autohaus, beschriftung: string, letztes = false) => (
|
||||
<Feld label={beschriftung} last={letztes}>
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
value={entwurf[schluessel] ?? ""}
|
||||
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, [schluessel]: e.target.value }))}
|
||||
/>
|
||||
</Feld>
|
||||
)
|
||||
|
||||
return (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Werkstatt</span>
|
||||
{feld("name", "Name")}
|
||||
{feld("adresse", "Adresse")}
|
||||
{feld("tel", "Telefon")}
|
||||
{feld("mail", "E-Mail", true)}
|
||||
{gespeichert && <p className="dm-erfolg">Gespeichert.</p>}
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
|
||||
{laeuft ? "Speichere …" : "Speichern"}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Tile>
|
||||
)
|
||||
}
|
||||
|
||||
export function Servicebuch() {
|
||||
const { fahrzeug, profilSpeichern } = useDaten()
|
||||
const [neuOffen, setzeNeuOffen] = useState(false)
|
||||
const [entwurf, setzeEntwurf] = useState<ServicebuchEintrag>({ datum: isoTag(), art: "Ölwechsel" })
|
||||
const [laeuft, setzeLaeuft] = useState(false)
|
||||
|
||||
if (!fahrzeug) return null
|
||||
const buch = (fahrzeug.service["buch"] ?? []) as ServicebuchEintrag[]
|
||||
|
||||
const schreiben = async (neuesBuch: ServicebuchEintrag[]) => {
|
||||
setzeLaeuft(true)
|
||||
try {
|
||||
await profilSpeichern({
|
||||
fahrzeug: { ...fahrzeug, service: { ...fahrzeug.service, buch: neuesBuch } },
|
||||
})
|
||||
} finally {
|
||||
setzeLaeuft(false)
|
||||
}
|
||||
}
|
||||
|
||||
const hinzufuegen = async () => {
|
||||
await schreiben(
|
||||
[...buch, entwurf].sort((a, b) => String(b.datum ?? "").localeCompare(String(a.datum ?? ""))),
|
||||
)
|
||||
setzeNeuOffen(false)
|
||||
setzeEntwurf({ datum: isoTag(), art: "Ölwechsel" })
|
||||
}
|
||||
|
||||
const entfernen = async (index: number) => {
|
||||
if (!bestaetigen("Diesen Servicebuch-Eintrag löschen?")) return
|
||||
await schreiben(buch.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dm-abschnitt">
|
||||
<span className="ads-eyebrow">{de(buch.length)} Einträge</span>
|
||||
<button
|
||||
type="button"
|
||||
className="dm-textknopf"
|
||||
onClick={() => setzeNeuOffen((offen) => !offen)}
|
||||
>
|
||||
{neuOffen ? "Abbrechen" : "Eintrag hinzufügen"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{neuOffen && (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Neuer Eintrag</span>
|
||||
<Feld label="Datum">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
type="date"
|
||||
value={entwurf.datum ?? ""}
|
||||
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, datum: e.target.value }))}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Art">
|
||||
<select
|
||||
className="dm-auswahl"
|
||||
value={entwurf.art ?? "Ölwechsel"}
|
||||
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, art: e.target.value }))}
|
||||
>
|
||||
<option>Ölwechsel</option>
|
||||
<option>Inspektion</option>
|
||||
<option>Hauptuntersuchung</option>
|
||||
<option>Reparatur</option>
|
||||
<option>Sonstiges</option>
|
||||
</select>
|
||||
</Feld>
|
||||
<Feld label="Kilometerstand" unit="km">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
inputMode="numeric"
|
||||
value={entwurf.km ?? ""}
|
||||
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, km: Number(e.target.value) }))}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Betrieb">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
value={entwurf.betrieb ?? ""}
|
||||
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, betrieb: e.target.value }))}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Notiz" last>
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
value={entwurf.notiz ?? ""}
|
||||
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, notiz: e.target.value }))}
|
||||
/>
|
||||
</Feld>
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<ActionButton onClick={() => void hinzufuegen()} disabled={laeuft}>
|
||||
Speichern
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Tile>
|
||||
)}
|
||||
|
||||
{buch.length === 0 && !neuOffen && (
|
||||
<Leerzustand
|
||||
titel="Servicebuch ist leer"
|
||||
text="Trag hier ein, was am Fahrzeug gemacht wurde. Aus den Ölwechseln entsteht die eigene Wartungsprognose."
|
||||
aktion={{ beschriftung: "Eintrag hinzufügen", beiKlick: () => setzeNeuOffen(true) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{buch.map((eintrag, index) => (
|
||||
<Tile key={`${eintrag.datum}-${index}`}>
|
||||
<div className="dm-detail__kopf">
|
||||
<div>
|
||||
<span className="ads-eyebrow">{eintrag.datum ? datum(eintrag.datum) : "ohne Datum"}</span>
|
||||
<div style={{ fontSize: 17, fontWeight: 300, marginTop: 4 }}>
|
||||
{eintrag.art ?? "Eintrag"}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="dm-textknopf"
|
||||
onClick={() => void entfernen(index)}
|
||||
disabled={laeuft}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile
|
||||
label="Kilometerstand"
|
||||
wert={eintrag.km != null ? `${de(eintrag.km)} km` : "—"}
|
||||
/>
|
||||
<Wertzeile label="Betrieb" wert={eintrag.betrieb || "—"} />
|
||||
{eintrag.notiz && <Wertzeile label="Notiz" wert={eintrag.notiz} />}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Tile>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* Versicherung, Vertragsdetails, Schutzbrief, Notruf und Kfz-Steuer.
|
||||
* Vorlagen: `vVers()`, `vBeitrag()`, `vVertragsdetails()`, `vSchutz()`,
|
||||
* `vNotrufBearbeiten()`, `vSteuer()`.
|
||||
*
|
||||
* Alle Formulare hier schreiben über `profilSpeichern()` das **ganze** Profil
|
||||
* zurück — das Backend kennt keinen Dienst für einzelne Felder.
|
||||
*/
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import { ActionButton, Feld, Tile } from "@audi-dash/ui"
|
||||
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { datum, de, eur } from "../format"
|
||||
import type { SeitenName } from "../navigation"
|
||||
import { Leerzustand, NaviKachel, Wertzeile, Werteliste } from "./bausteine"
|
||||
|
||||
interface Beitragsposten {
|
||||
bez?: string
|
||||
betrag?: number
|
||||
sf?: string
|
||||
satz?: string
|
||||
}
|
||||
|
||||
export function Versicherung({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
|
||||
const { fahrzeug } = useDaten()
|
||||
if (!fahrzeug) return null
|
||||
|
||||
const v = fahrzeug.versicherung
|
||||
const steuer = fahrzeug.steuer
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Versicherung</span>
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile label="Gesellschaft" wert={(v["gesellschaft"] as string) || "—"} />
|
||||
<Wertzeile label="Vertrag" wert={(v["vertrag"] as string) || "—"} />
|
||||
<Wertzeile label="Vertragsnummer" wert={(v["nummer"] as string) || "—"} />
|
||||
<Wertzeile
|
||||
label="Beitrag"
|
||||
wert={v["beitrag"] ? `${eur(v["beitrag"] as number)} €` : "—"}
|
||||
zusatz={(v["zahlweise"] as string) || undefined}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Gültig ab"
|
||||
wert={v["gueltigAb"] ? datum(v["gueltigAb"] as string) : "—"}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Tile>
|
||||
|
||||
<div className="dm-raster">
|
||||
<NaviKachel titel="Beitrag" wert="Einzelposten bearbeiten" beiKlick={() => geheZu("beitrag")} />
|
||||
<NaviKachel
|
||||
titel="Vertragsdetails"
|
||||
wert="Leistungen und Schutzbrief"
|
||||
beiKlick={() => geheZu("vertragsdetails")}
|
||||
/>
|
||||
<NaviKachel
|
||||
titel="Notruf"
|
||||
wert={(v["notfall"] as string) || "Nummern hinterlegen"}
|
||||
beiKlick={() => geheZu("notruf")}
|
||||
/>
|
||||
<NaviKachel
|
||||
titel="Kfz-Steuer"
|
||||
wert={steuer["betrag"] ? `${eur(steuer["betrag"] as number)} €` : "Noch nicht hinterlegt"}
|
||||
beiKlick={() => geheZu("steuer")}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function Beitrag() {
|
||||
const { fahrzeug, profilSpeichern } = useDaten()
|
||||
const [laeuft, setzeLaeuft] = useState(false)
|
||||
const [gespeichert, setzeGespeichert] = useState(false)
|
||||
|
||||
if (!fahrzeug) return null
|
||||
const v = fahrzeug.versicherung
|
||||
const teile = (v["teile"] ?? []) as Beitragsposten[]
|
||||
|
||||
const [entwurf, setzeEntwurf] = useState<Beitragsposten[]>(() => teile.map((t) => ({ ...t })))
|
||||
|
||||
const speichern = async () => {
|
||||
setzeLaeuft(true)
|
||||
setzeGespeichert(false)
|
||||
try {
|
||||
const summeBetrag = entwurf.reduce((s, t) => s + (Number(t.betrag) || 0), 0)
|
||||
await profilSpeichern({
|
||||
fahrzeug: {
|
||||
...fahrzeug,
|
||||
versicherung: { ...v, teile: entwurf, beitrag: summeBetrag },
|
||||
},
|
||||
})
|
||||
setzeGespeichert(true)
|
||||
} finally {
|
||||
setzeLaeuft(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (entwurf.length === 0) {
|
||||
return (
|
||||
<Leerzustand
|
||||
titel="Keine Beitragsposten"
|
||||
text="Sobald im Profil Einzelposten hinterlegt sind, kannst du sie hier bearbeiten."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Beitragsposten</span>
|
||||
{entwurf.map((posten, index) => (
|
||||
<Feld
|
||||
key={`${posten.bez}-${index}`}
|
||||
label={posten.bez ?? `Posten ${index + 1}`}
|
||||
unit="€"
|
||||
last={index === entwurf.length - 1}
|
||||
>
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
inputMode="decimal"
|
||||
value={posten.betrag ?? ""}
|
||||
onChange={(e) =>
|
||||
setzeEntwurf((alt) =>
|
||||
alt.map((p, i) =>
|
||||
i === index ? { ...p, betrag: Number(e.target.value.replace(",", ".")) } : p,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Feld>
|
||||
))}
|
||||
<Werteliste
|
||||
kinder={
|
||||
<Wertzeile
|
||||
label="Summe"
|
||||
wert={`${eur(entwurf.reduce((s, t) => s + (Number(t.betrag) || 0), 0))} €`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{gespeichert && <p className="dm-erfolg">Gespeichert.</p>}
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
|
||||
{laeuft ? "Speichere …" : "Speichern"}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Tile>
|
||||
)
|
||||
}
|
||||
|
||||
export function Vertragsdetails({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
|
||||
const { fahrzeug } = useDaten()
|
||||
if (!fahrzeug) return null
|
||||
|
||||
const v = fahrzeug.versicherung
|
||||
const leistungen = (v["leistungen"] ?? []) as { bez?: string; wert?: string }[]
|
||||
const selbst = (v["selbst"] ?? []) as (string | number)[][]
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Leistungen</span>
|
||||
{leistungen.length === 0 ? (
|
||||
<p className="dm-fussnote">Im Profil sind noch keine Leistungen hinterlegt.</p>
|
||||
) : (
|
||||
<Werteliste
|
||||
kinder={leistungen.map((l, i) => (
|
||||
<Wertzeile key={`${l.bez}-${i}`} label={l.bez ?? "—"} wert={l.wert ?? "—"} />
|
||||
))}
|
||||
/>
|
||||
)}
|
||||
</Tile>
|
||||
|
||||
{selbst.length > 0 && (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Selbstbeteiligung</span>
|
||||
<Werteliste
|
||||
kinder={selbst.map((zeile, i) => (
|
||||
<Wertzeile key={i} label={String(zeile[0] ?? "")} wert={String(zeile[1] ?? "")} />
|
||||
))}
|
||||
/>
|
||||
</Tile>
|
||||
)}
|
||||
|
||||
<NaviKachel
|
||||
titel="Schutzbrief"
|
||||
wert="Bedingungen im Detail"
|
||||
beiKlick={() => geheZu("schutz")}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function Schutzbrief() {
|
||||
const { fahrzeug } = useDaten()
|
||||
if (!fahrzeug) return null
|
||||
|
||||
const s = (fahrzeug.versicherung["schutzbrief"] ?? {}) as Record<string, unknown>
|
||||
const listen: [string, string[]][] = [
|
||||
["Auslöser", (s["ausloeser"] ?? []) as string[]],
|
||||
["Entweder", (s["entweder"] ?? []) as string[]],
|
||||
["Leistungsgruppen", (s["gruppen"] ?? []) as string[]],
|
||||
["Nicht versichert", (s["nicht"] ?? []) as string[]],
|
||||
["Pflichten", (s["pflichten"] ?? []) as string[]],
|
||||
]
|
||||
const hatInhalt = listen.some(([, eintraege]) => eintraege.length > 0)
|
||||
|
||||
if (!hatInhalt) {
|
||||
return (
|
||||
<Leerzustand
|
||||
titel="Keine Schutzbrief-Angaben"
|
||||
text="Im Profil sind zum Schutzbrief noch keine Bedingungen hinterlegt."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Schutzbrief</span>
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile label="Stand" wert={(s["stand"] as string) || "—"} />
|
||||
<Wertzeile label="Gilt" wert={(s["gilt"] as string) || "—"} />
|
||||
<Wertzeile label="Versichert" wert={(s["versichert"] as string) || "—"} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Tile>
|
||||
{listen
|
||||
.filter(([, eintraege]) => eintraege.length > 0)
|
||||
.map(([titel, eintraege]) => (
|
||||
<Tile key={titel}>
|
||||
<span className="ads-eyebrow">{titel}</span>
|
||||
<ul className="dm-liste">
|
||||
{eintraege.map((eintrag) => (
|
||||
<li key={eintrag}>{eintrag}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Tile>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function Notruf() {
|
||||
const { fahrzeug, profilSpeichern } = useDaten()
|
||||
const v = fahrzeug?.versicherung ?? {}
|
||||
const [service, setzeService] = useState((v["service"] as string) ?? "")
|
||||
const [notfall, setzeNotfall] = useState((v["notfall"] as string) ?? "")
|
||||
const [laeuft, setzeLaeuft] = useState(false)
|
||||
const [gespeichert, setzeGespeichert] = useState(false)
|
||||
|
||||
if (!fahrzeug) return null
|
||||
|
||||
const speichern = async () => {
|
||||
setzeLaeuft(true)
|
||||
setzeGespeichert(false)
|
||||
try {
|
||||
await profilSpeichern({
|
||||
fahrzeug: { ...fahrzeug, versicherung: { ...v, service, notfall } },
|
||||
})
|
||||
setzeGespeichert(true)
|
||||
} finally {
|
||||
setzeLaeuft(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Nummern im Notfall</span>
|
||||
<Feld label="Service">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
type="tel"
|
||||
value={service}
|
||||
onChange={(e) => setzeService(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Notfall" last>
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
type="tel"
|
||||
value={notfall}
|
||||
onChange={(e) => setzeNotfall(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
{(service || notfall) && (
|
||||
<div className="dm-waehlen">
|
||||
{service && (
|
||||
<a className="dm-waehlen__knopf" href={`tel:${service.replace(/\s/g, "")}`}>
|
||||
Service anrufen
|
||||
</a>
|
||||
)}
|
||||
{notfall && (
|
||||
<a className="dm-waehlen__knopf" href={`tel:${notfall.replace(/\s/g, "")}`}>
|
||||
Notfall anrufen
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{gespeichert && <p className="dm-erfolg">Gespeichert.</p>}
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
|
||||
{laeuft ? "Speichere …" : "Speichern"}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Tile>
|
||||
)
|
||||
}
|
||||
|
||||
export function Steuer() {
|
||||
const { fahrzeug, profilSpeichern } = useDaten()
|
||||
const [entwurf, setzeEntwurf] = useState(() => ({ ...(fahrzeug?.steuer ?? {}) }))
|
||||
const [laeuft, setzeLaeuft] = useState(false)
|
||||
const [gespeichert, setzeGespeichert] = useState(false)
|
||||
|
||||
if (!fahrzeug) return null
|
||||
|
||||
const speichern = async () => {
|
||||
setzeLaeuft(true)
|
||||
setzeGespeichert(false)
|
||||
try {
|
||||
await profilSpeichern({
|
||||
fahrzeug: { ...fahrzeug, steuer: entwurf as typeof fahrzeug.steuer },
|
||||
})
|
||||
setzeGespeichert(true)
|
||||
} finally {
|
||||
setzeLaeuft(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Kfz-Steuer</span>
|
||||
<Feld label="Betrag" unit="€">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
inputMode="decimal"
|
||||
value={(entwurf["betrag"] as number) ?? ""}
|
||||
onChange={(e) =>
|
||||
setzeEntwurf((alt) => ({ ...alt, betrag: Number(e.target.value.replace(",", ".")) }))
|
||||
}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Zeitraum">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
value={(entwurf["zeitraum"] as string) ?? ""}
|
||||
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, zeitraum: e.target.value }))}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Fällig am">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
type="date"
|
||||
value={(entwurf["faellig"] as string) ?? ""}
|
||||
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, faellig: e.target.value }))}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Finanzamt" last>
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
value={(entwurf["finanzamt"] as string) ?? ""}
|
||||
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, finanzamt: e.target.value }))}
|
||||
/>
|
||||
</Feld>
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile label="Grundlage" wert={(entwurf["grundlage"] as string) || "—"} />
|
||||
<Wertzeile
|
||||
label="Lastschrift"
|
||||
wert={entwurf["lastschrift"] ? "eingerichtet" : "nicht eingerichtet"}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Monatlich gerechnet"
|
||||
wert={
|
||||
entwurf["betrag"] ? `${eur((entwurf["betrag"] as number) / 12)} €` : "—"
|
||||
}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{gespeichert && <p className="dm-erfolg">Gespeichert.</p>}
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
|
||||
{laeuft ? "Speichere …" : "Speichern"}
|
||||
</ActionButton>
|
||||
</div>
|
||||
<p className="dm-fussnote">
|
||||
{de(1)} Fahrzeug, {(entwurf["zeitraum"] as string) || "jährlich"} zu zahlen.
|
||||
</p>
|
||||
</Tile>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* CSV-Ausgabe. Portiert aus dem alten Panel: Semikolon als Trennzeichen,
|
||||
* deutsches Zahlenformat und ein BOM am Anfang — nur damit öffnet Excel die
|
||||
* Datei ohne Rückfragen richtig.
|
||||
*/
|
||||
|
||||
function feld(wert: unknown): string {
|
||||
if (wert === null || wert === undefined) return ""
|
||||
if (typeof wert === "number") return String(wert).replace(".", ",")
|
||||
const text = String(wert)
|
||||
return /[";\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
|
||||
}
|
||||
|
||||
export function alsCsv(kopf: readonly string[], zeilen: readonly unknown[][]): string {
|
||||
const inhalt = [kopf.join(";"), ...zeilen.map((z) => z.map(feld).join(";"))].join("\r\n")
|
||||
return `${inhalt}`
|
||||
}
|
||||
|
||||
export function csvHerunterladen(
|
||||
name: string,
|
||||
kopf: readonly string[],
|
||||
zeilen: readonly unknown[][],
|
||||
): void {
|
||||
const blob = new Blob([alsCsv(kopf, zeilen)], { type: "text/csv;charset=utf-8" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const verweis = document.createElement("a")
|
||||
verweis.href = url
|
||||
verweis.download = `${name}.csv`
|
||||
document.body.appendChild(verweis)
|
||||
verweis.click()
|
||||
verweis.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Termine als Kalenderdatei. Portiert aus `ics()` im alten Panel.
|
||||
*
|
||||
* Erzeugt eine .ics-Datei im Browser, ohne Server — sie landet als Download
|
||||
* bzw. auf dem iPhone direkt im Kalender. Mit Erinnerung zwei Tage vorher.
|
||||
*/
|
||||
|
||||
function zeilenUmbruch(text: string): string {
|
||||
return text.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n")
|
||||
}
|
||||
|
||||
function alsIcsDatum(isoTag: string): string {
|
||||
return isoTag.replace(/-/g, "")
|
||||
}
|
||||
|
||||
export function kalenderDatei(titel: string, isoTag: string, beschreibung = ""): string {
|
||||
const stempel = new Date().toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"
|
||||
const start = alsIcsDatum(isoTag)
|
||||
const ende = alsIcsDatum(
|
||||
new Date(new Date(isoTag).getTime() + 86_400_000).toISOString().slice(0, 10),
|
||||
)
|
||||
|
||||
return [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//DataMetric360//DE",
|
||||
"CALSCALE:GREGORIAN",
|
||||
"BEGIN:VEVENT",
|
||||
`UID:dm360-${start}-${Math.abs(hash(titel))}@datametric360`,
|
||||
`DTSTAMP:${stempel}`,
|
||||
`DTSTART;VALUE=DATE:${start}`,
|
||||
`DTEND;VALUE=DATE:${ende}`,
|
||||
`SUMMARY:${zeilenUmbruch(titel)}`,
|
||||
beschreibung ? `DESCRIPTION:${zeilenUmbruch(beschreibung)}` : "",
|
||||
"BEGIN:VALARM",
|
||||
"TRIGGER:-P2D",
|
||||
"ACTION:DISPLAY",
|
||||
`DESCRIPTION:${zeilenUmbruch(titel)}`,
|
||||
"END:VALARM",
|
||||
"END:VEVENT",
|
||||
"END:VCALENDAR",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\r\n")
|
||||
}
|
||||
|
||||
function hash(text: string): number {
|
||||
let wert = 0
|
||||
for (let i = 0; i < text.length; i++) wert = (wert * 31 + text.charCodeAt(i)) | 0
|
||||
return wert
|
||||
}
|
||||
|
||||
export function kalenderDateiLaden(titel: string, isoTag: string, beschreibung = ""): void {
|
||||
const inhalt = kalenderDatei(titel, isoTag, beschreibung)
|
||||
const blob = new Blob([inhalt], { type: "text/calendar;charset=utf-8" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const verweis = document.createElement("a")
|
||||
verweis.href = url
|
||||
verweis.download = `${titel.replace(/[^\wäöüÄÖÜß-]+/g, "-").toLowerCase()}.ics`
|
||||
document.body.appendChild(verweis)
|
||||
verweis.click()
|
||||
verweis.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -5,15 +5,28 @@
|
||||
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import type { Route, SeitenName } from "../navigation"
|
||||
import { Batterie } from "./Batterie"
|
||||
import { Einstellungen } from "./Einstellungen"
|
||||
import { FahrtDetail } from "./FahrtDetail"
|
||||
import { Fahrten } from "./Fahrten"
|
||||
import { Fahrzeugdaten } from "./Fahrzeugdaten"
|
||||
import { Fahrzeugstatus } from "./Fahrzeugstatus"
|
||||
import { LiveFahrt } from "./LiveFahrt"
|
||||
import { MeinAudi } from "./MeinAudi"
|
||||
import { Platzhalter } from "./Platzhalter"
|
||||
import { Reifen } from "./Reifen"
|
||||
import { Service, Servicebuch, Werkstatt } from "./Service"
|
||||
import { Statistik } from "./Statistik"
|
||||
import { TankDetail } from "./TankDetail"
|
||||
import { Tanken } from "./Tanken"
|
||||
import { Uebersicht } from "./Uebersicht"
|
||||
import {
|
||||
Beitrag,
|
||||
Notruf,
|
||||
Schutzbrief,
|
||||
Steuer,
|
||||
Versicherung,
|
||||
Vertragsdetails,
|
||||
} from "./Versicherung"
|
||||
|
||||
export interface SeitenProps {
|
||||
route: Route
|
||||
@@ -40,7 +53,7 @@ export function SeiteFuer(props: SeitenProps) {
|
||||
return <div className="dm-laden">Daten werden geladen …</div>
|
||||
}
|
||||
|
||||
const { route, geheZu } = props
|
||||
const { route, geheZu, tabBeschriftung, setzeTabBeschriftung, beiAbmeldung } = props
|
||||
|
||||
switch (route.name) {
|
||||
case "home":
|
||||
@@ -49,6 +62,30 @@ export function SeiteFuer(props: SeitenProps) {
|
||||
return <Fahrzeugstatus />
|
||||
case "audi":
|
||||
return <MeinAudi geheZu={geheZu} />
|
||||
case "ident":
|
||||
return <Fahrzeugdaten />
|
||||
case "battverlauf":
|
||||
return <Batterie />
|
||||
case "service":
|
||||
return <Service geheZu={geheZu} />
|
||||
case "werkstatt":
|
||||
return <Werkstatt />
|
||||
case "sbuch":
|
||||
return <Servicebuch />
|
||||
case "reifen":
|
||||
return <Reifen />
|
||||
case "vers":
|
||||
return <Versicherung geheZu={geheZu} />
|
||||
case "beitrag":
|
||||
return <Beitrag />
|
||||
case "vertragsdetails":
|
||||
return <Vertragsdetails geheZu={geheZu} />
|
||||
case "schutz":
|
||||
return <Schutzbrief />
|
||||
case "notruf":
|
||||
return <Notruf />
|
||||
case "steuer":
|
||||
return <Steuer />
|
||||
case "trips":
|
||||
return <Fahrten geheZu={geheZu} />
|
||||
case "trip":
|
||||
@@ -59,7 +96,15 @@ export function SeiteFuer(props: SeitenProps) {
|
||||
return <TankDetail id={route.id} />
|
||||
case "stat":
|
||||
return <Statistik />
|
||||
default:
|
||||
return <Platzhalter route={route} geheZu={geheZu} />
|
||||
case "live":
|
||||
return <LiveFahrt />
|
||||
case "einst":
|
||||
return (
|
||||
<Einstellungen
|
||||
tabBeschriftung={tabBeschriftung}
|
||||
setzeTabBeschriftung={setzeTabBeschriftung}
|
||||
beiAbmeldung={beiAbmeldung}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Abnahme für Phase 7 des Umsetzungsplans: Jede Seite muss mit echten
|
||||
* Beispieldaten rendern — in beiden Layouts — und die Leerzustände müssen
|
||||
* greifen, statt dass eine leere Liste die Seite zerlegt.
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react"
|
||||
import { beforeEach, describe, expect, it } from "vitest"
|
||||
|
||||
import { DatenAnbieter } from "../daten/DatenKontext"
|
||||
import { Shell } from "../Shell"
|
||||
import type { SeitenName } from "../navigation"
|
||||
import { TITEL } from "../navigation"
|
||||
import { SeiteFuer } from "./register"
|
||||
import { beispielApi } from "../tests/beispieldaten"
|
||||
import { bildschirmbreiteSetzen } from "../tests/aufbau"
|
||||
|
||||
const ALLE_SEITEN = Object.keys(TITEL) as SeitenName[]
|
||||
|
||||
function zeige(name: SeitenName, id?: string) {
|
||||
const api = beispielApi()
|
||||
return render(
|
||||
<div className="ads-root" data-theme="nacht">
|
||||
<DatenAnbieter api={api}>
|
||||
<Shell
|
||||
route={id === undefined ? { name } : { name, id }}
|
||||
geheZu={() => {}}
|
||||
tabBeschriftung
|
||||
>
|
||||
<SeiteFuer
|
||||
route={id === undefined ? { name } : { name, id }}
|
||||
geheZu={() => {}}
|
||||
tabBeschriftung
|
||||
setzeTabBeschriftung={() => {}}
|
||||
beiAbmeldung={() => {}}
|
||||
/>
|
||||
</Shell>
|
||||
</DatenAnbieter>
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
|
||||
describe("Alle Seiten rendern mit Beispieldaten", () => {
|
||||
beforeEach(() => {
|
||||
bildschirmbreiteSetzen(390)
|
||||
})
|
||||
|
||||
for (const name of ALLE_SEITEN) {
|
||||
it(`${name} (${TITEL[name]})`, async () => {
|
||||
const id = name === "trip" ? "t1" : name === "fill" ? "f1" : undefined
|
||||
const { container } = zeige(name, id)
|
||||
// Nach der Erstladung darf kein Ladehinweis mehr stehen.
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(".dm-laden")).toBeNull()
|
||||
})
|
||||
expect(container.textContent?.length ?? 0).toBeGreaterThan(20)
|
||||
})
|
||||
}
|
||||
|
||||
it("rendert jede Seite auch im Flächenlayout", async () => {
|
||||
bildschirmbreiteSetzen(1280)
|
||||
for (const name of ALLE_SEITEN) {
|
||||
const id = name === "trip" ? "t1" : name === "fill" ? "f1" : undefined
|
||||
const { container, unmount } = zeige(name, id)
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(".dm-laden")).toBeNull()
|
||||
})
|
||||
expect(container.querySelector(".dm-seitenleiste")).not.toBeNull()
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("Inhalte kommen wirklich aus den Daten", () => {
|
||||
beforeEach(() => {
|
||||
bildschirmbreiteSetzen(390)
|
||||
})
|
||||
|
||||
it("zeigt Kilometerstand und Reichweite auf der Übersicht", async () => {
|
||||
zeige("home")
|
||||
await waitFor(() => expect(screen.getByText("48.250")).toBeTruthy())
|
||||
expect(screen.getByText("385")).toBeTruthy()
|
||||
})
|
||||
|
||||
it("zeigt einen unbekannten Prüfpunkt als unbekannt, nicht als sicher", async () => {
|
||||
const { container } = zeige("sicherheit")
|
||||
await waitFor(() => expect(container.querySelector(".dm-pruefliste")).not.toBeNull())
|
||||
expect(container.querySelectorAll(".dm-punkt--unbekannt").length).toBe(1)
|
||||
expect(screen.getByText("unbekannt")).toBeTruthy()
|
||||
})
|
||||
|
||||
it("gruppiert Fahrten nach Jahr", async () => {
|
||||
zeige("trips")
|
||||
await waitFor(() => expect(screen.getByText("2026")).toBeTruthy())
|
||||
expect(screen.getByText("42,5 km")).toBeTruthy()
|
||||
})
|
||||
|
||||
it("zeigt bei einer Fahrt ohne Position keine erfundene Route", async () => {
|
||||
const { container } = zeige("trip", "t1")
|
||||
await waitFor(() => expect(screen.getByText("Keine Route aufgezeichnet")).toBeTruthy())
|
||||
expect(container.querySelector(".dm-karte")).toBeNull()
|
||||
})
|
||||
|
||||
it("rechnet den Tankvorgang mit mengengewichtetem Preis", async () => {
|
||||
zeige("fill", "f1")
|
||||
await waitFor(() => expect(screen.getByText("48,70")).toBeTruthy())
|
||||
expect(screen.getByText("1,83 €")).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -487,3 +487,84 @@
|
||||
.dm-fussnote--warnung {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- Listen, Text */
|
||||
|
||||
.dm-liste {
|
||||
list-style: none;
|
||||
margin: var(--sp-2) 0 0;
|
||||
padding: 0;
|
||||
font-size: 13.5px;
|
||||
font-weight: 300;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.dm-liste li {
|
||||
padding-left: 14px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dm-liste li::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.72em;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--fg3);
|
||||
}
|
||||
|
||||
.dm-liste__serie {
|
||||
color: var(--fg2);
|
||||
}
|
||||
|
||||
.dm-diagramm {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
margin: var(--sp-3) 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dm-knopfreihe {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
|
||||
.dm-waehlen {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
margin-top: var(--sp-3);
|
||||
}
|
||||
|
||||
.dm-waehlen__knopf {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-pill);
|
||||
font-size: 13.5px;
|
||||
font-weight: 300;
|
||||
color: var(--fg);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.dm-waehlen__knopf:hover {
|
||||
background: var(--shade);
|
||||
}
|
||||
|
||||
/* Bleibt beim Scrollen unten stehen, damit ungespeicherte Aenderungen nicht
|
||||
aus dem Blick geraten. */
|
||||
.dm-speicherleiste {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) 0;
|
||||
background: var(--canvas);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Beispieldaten für Rendertests. Aufgebaut wie das, was das Backend
|
||||
* tatsächlich liefert (an der laufenden Testinstanz abgelesen), nicht wie es
|
||||
* laut Spezifikation aussehen sollte.
|
||||
*/
|
||||
|
||||
import type { DataMetricApi, Fahrt, Fahrzeugstatus, Profil, Tankvorgang } from "../api"
|
||||
|
||||
export const beispielStatus: Fahrzeugstatus = {
|
||||
km: 48250,
|
||||
tankprozent: 62,
|
||||
reichweite_km: 385,
|
||||
batteriespannung: null,
|
||||
gesichert: true,
|
||||
sicherheitscheck: [
|
||||
{ label: "Tür vorne links", ok: true },
|
||||
{ label: "Tür vorne rechts", ok: true },
|
||||
{ label: "Fenster vorne links", ok: false },
|
||||
{ label: "Motorhaube", ok: null },
|
||||
],
|
||||
oelwechsel_faellig_ts: "2027-03-14T00:00:00+00:00",
|
||||
oelwechsel_faellig_km: 11750,
|
||||
inspektion_faellig_ts: "2027-03-14T00:00:00+00:00",
|
||||
inspektion_faellig_km: 11750,
|
||||
}
|
||||
|
||||
export const beispielProfil: Profil = {
|
||||
fahrzeug: {
|
||||
fin: "WUAZZZF48PA902804",
|
||||
titel: "RS 4 Avant",
|
||||
zusatz: "competition",
|
||||
untertitel: "quattro · 331 kW",
|
||||
details: "2.9 TFSI quattro competition",
|
||||
kennzeichen: "M AB 1234",
|
||||
erstzulassung: "2023-04-01",
|
||||
hauptuntersuchung_faellig: "2027-04-01",
|
||||
tankvolumen_liter: 58,
|
||||
wlan_name: "Audi_MMI",
|
||||
ausfuehrung: "competition",
|
||||
},
|
||||
einstellungen: {
|
||||
nacht_von: "22:00",
|
||||
nacht_bis: "06:00",
|
||||
fahrten_pausenzeit_min: 15,
|
||||
uebersichtsbild: "seitenansicht.webp",
|
||||
kraftstoffanbieter: "Shell",
|
||||
smartdeal: { aktiv: true, laeuft_ab: null },
|
||||
oelwechsel_intervall: {
|
||||
modus: "hersteller",
|
||||
km: 15000,
|
||||
monate: 12,
|
||||
hersteller_km: 30000,
|
||||
hersteller_monate: 24,
|
||||
},
|
||||
backup_intervall: "taeglich",
|
||||
letztes_backup: "2026-08-10T04:00:00",
|
||||
},
|
||||
versicherung: {
|
||||
gesellschaft: "Beispiel Versicherung",
|
||||
vertrag: "Vollkasko",
|
||||
nummer: "V-123456",
|
||||
service: "0800 111111",
|
||||
notfall: "0800 222222",
|
||||
beitrag: 840,
|
||||
zahlweise: "jährlich",
|
||||
gueltigAb: "2026-01-01",
|
||||
teile: [
|
||||
{ bez: "Haftpflicht", betrag: 320 },
|
||||
{ bez: "Vollkasko", betrag: 520 },
|
||||
],
|
||||
selbst: [["Vollkasko", "500 €"]],
|
||||
leistungen: [{ bez: "Werkstattbindung", wert: "nein" }],
|
||||
schutzbrief: {
|
||||
stand: "2026-01",
|
||||
gilt: "Europa",
|
||||
versichert: "Fahrzeug und Insassen",
|
||||
ausloeser: ["Panne", "Unfall"],
|
||||
entweder: [],
|
||||
gruppen: ["Abschleppen"],
|
||||
nicht: [],
|
||||
pflichten: ["Unverzüglich melden"],
|
||||
},
|
||||
},
|
||||
steuer: {
|
||||
betrag: 320,
|
||||
zeitraum: "jährlich",
|
||||
faellig: "2027-04-01",
|
||||
grundlage: "Hubraum und CO₂-Ausstoß",
|
||||
lastschrift: true,
|
||||
finanzamt: "München",
|
||||
},
|
||||
reifen: {
|
||||
aktiv: "sommer",
|
||||
anzugsmoment_nm: 140,
|
||||
wechsel: { datum: "2026-10-15", vereinbart: false },
|
||||
referenz_odo_km: 48250,
|
||||
saetze: {
|
||||
sommer: { km: 12000, mass: "275/30 R20", marke: "Michelin", druck_vorne: "2,5", druck_hinten: "2,4" },
|
||||
winter: { km: 8000, mass: "265/35 R19", marke: "Continental", druck_vorne: "2,6", druck_hinten: "2,5" },
|
||||
},
|
||||
},
|
||||
service: {
|
||||
vereinbart: {},
|
||||
autohaus: { name: "Audi Zentrum", adresse: "Musterweg 1", tel: "089 1234", mail: "a@b.de" },
|
||||
buch: [
|
||||
{ datum: "2026-02-10", km: 40000, art: "Ölwechsel", betrieb: "Audi Zentrum" },
|
||||
{ datum: "2025-03-05", km: 25000, art: "Inspektion", betrieb: "Audi Zentrum" },
|
||||
],
|
||||
},
|
||||
technik: [
|
||||
{ gruppe: "Motor", posten: [["Bauart", "V6 Biturbo"], ["Leistung", "331 kW"]] },
|
||||
],
|
||||
ausstattung: [{ gruppe: "RS-Paket", sonder: ["RS competition"], serie: ["Klimaautomatik"] }],
|
||||
}
|
||||
|
||||
export const beispielFahrten: Fahrt[] = [
|
||||
{
|
||||
trip_id: "t1",
|
||||
ts_start: "2026-08-10T08:00:00+02:00",
|
||||
ts_end: "2026-08-10T08:45:00+02:00",
|
||||
duration_s: 2700,
|
||||
distance_km: 42.5,
|
||||
km_quelle: "odometer",
|
||||
odo_start: 48200,
|
||||
odo_end: 48242.5,
|
||||
art: "arbeitsweg",
|
||||
source: "ha",
|
||||
status: "vollständig",
|
||||
},
|
||||
{
|
||||
trip_id: "t2",
|
||||
ts_start: "2026-08-09T23:10:00+02:00",
|
||||
ts_end: "2026-08-09T23:40:00+02:00",
|
||||
duration_s: 1800,
|
||||
distance_km: 18.2,
|
||||
km_quelle: "odometer",
|
||||
odo_start: 48180,
|
||||
odo_end: 48198.2,
|
||||
art: "privat",
|
||||
source: "manual",
|
||||
status: "offen",
|
||||
},
|
||||
]
|
||||
|
||||
export const beispielTankvorgaenge: Tankvorgang[] = [
|
||||
{
|
||||
tank_id: "f1",
|
||||
ts: "2026-08-08T17:20:00+02:00",
|
||||
liters: 48.7,
|
||||
fuel_total_eur: 89.12,
|
||||
price_per_l: 1.83,
|
||||
station_name: "Shell München Ost",
|
||||
station_address: "Musterstraße 5",
|
||||
product_name: "V-Power",
|
||||
odometer_km: 48100,
|
||||
distance_km: 520,
|
||||
receipt_file: "beleg-2026-08-08.pdf",
|
||||
receipt_no: "4711",
|
||||
discount: 3.4,
|
||||
source: "beleg",
|
||||
status: "vollständig",
|
||||
},
|
||||
]
|
||||
|
||||
/** Datenschicht-Ersatz für Tests: liefert Beispieldaten, schreibt ins Leere. */
|
||||
export function beispielApi(): DataMetricApi {
|
||||
const horcher = { verbindung: [] as ((z: string) => void)[] }
|
||||
return {
|
||||
rest: {
|
||||
zustandLesen: async () => ({
|
||||
entity_id: "pyscript.audi_dashboard_fahrzeugstatus",
|
||||
state: "aktuell",
|
||||
attributes: { daten: beispielStatus },
|
||||
last_changed: "2026-08-11T09:00:00+00:00",
|
||||
last_updated: "2026-08-11T09:00:00+00:00",
|
||||
}),
|
||||
datenLesen: async () => [],
|
||||
dienstAufrufen: async () => null,
|
||||
verbindungPruefen: async () => ({ message: "API running." }),
|
||||
zugangSetzen: () => {},
|
||||
},
|
||||
live: {
|
||||
aufVerbindung: (h: (z: string) => void) => {
|
||||
horcher.verbindung.push(h)
|
||||
h("verbunden")
|
||||
return () => {}
|
||||
},
|
||||
aufZustand: () => () => {},
|
||||
starten: async () => {},
|
||||
beenden: () => {},
|
||||
},
|
||||
warteschlange: {
|
||||
laden: async () => {},
|
||||
aufAenderung: (h: (a: unknown[]) => void) => {
|
||||
h([])
|
||||
return () => {}
|
||||
},
|
||||
anzahl: 0,
|
||||
},
|
||||
profilLesen: async () => beispielProfil,
|
||||
fahrtenLesen: async () => beispielFahrten,
|
||||
tankvorgaengeLesen: async () => beispielTankvorgaenge,
|
||||
fahrzeugstatusLesen: async () => beispielStatus,
|
||||
profilSchreiben: async () => null,
|
||||
belegHochladen: async () => null,
|
||||
fahrtAnlegen: async () => null,
|
||||
fahrtLoeschen: async () => null,
|
||||
tankvorgangAnlegen: async () => null,
|
||||
tankvorgangAktualisieren: async () => null,
|
||||
tankvorgangLoeschen: async () => null,
|
||||
jetztAktualisieren: async () => null,
|
||||
} as unknown as DataMetricApi
|
||||
}
|
||||
Reference in New Issue
Block a user