Paritaetsrunden 2 und 3, Designpruefung umgesetzt (2026.8.30.8)

Drei zusammenhaengende Runden, alle live bei 375x812 gegen audi_ha_test
geprueft und in beiden Codebasen angewandt.

Paritaetsrunde 2: der gemeinsame Rahmen war das eigentliche Problem -
Seitenrand, Kachelabstaende, Kopfleiste, Tableiste und vier Bausteine des
Design-Systems trugen noch den Stand vor der iOS-Entscheidung. Dazu sieben
Bildschirme neu aufgebaut. Zwei Panel-Fehler dabei mitbehoben: teaser()
zeigte unter "Letzte Fahrt" den aeltesten Datensatz, und "Daten bearbeiten"
war ein toter Knopf.

Paritaetsrunde 3: das Panel hat nie Audi Type gerendert. @font-face in einem
Shadow Root wird ignoriert - Schriftschnitte registriert der Browser pro
Dokument, nie pro Shadow Tree. Die drei Schnitte liegen jetzt in
audi-dashboard-schriften.css und werden ins Dokument gehaengt. Damit erledigt
sich eine ganze Reihe von "die Schrift sieht anders aus"-Eindruecken: die
beiden Anwendungen zeigten tatsaechlich verschiedene Schriften.
Ausserdem "Daten bearbeiten" im Panel gebaut und portiert, das Zeilenmenue
entfernt und die letzten neun Bildschirme angeglichen.

Designpruefung: die fuenf Punkte der Reihenfolge. Beim vierten hat das Messen
den Befund veraendert - gezaehlt waren die deklarierten Groessen, wirksam war
laengst eine saubere Sieben-Schritt-Skala mit 27 Ausreissern; die sind jetzt
auf den naechsten Schritt gezogen, keiner verschiebt sich um mehr als 1px.

Zuletzt: die Standortvorschau zeichnete die falsche Nadel (das Panel wechselt
den Icon-Satz ab 34px, die App nahm immer den grossen), und der Kopfabstand
der App ist auf den sicheren Bereich reduziert - die 56px des Panels liegen
dort unter der Kopfleiste von Home Assistant, in der nativen Huelle steht
darueber nichts.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-30 18:31:46 +02:00
co-authored by Claude Opus 5
parent 25c0d073c4
commit 4a58021670
64 changed files with 4129 additions and 1431 deletions
+3
View File
@@ -80,6 +80,9 @@ function AngemeldeteApp({ beiAbmeldung }: { beiAbmeldung: () => void }) {
route={route}
geheZu={geheZu}
tabBeschriftung={tabBeschriftung}
statusStand={daten.statusStand}
// Ziehen zum Aktualisieren gibt es im Panel nur auf der Übersicht.
beiAktualisieren={route.name === "home" ? daten.jetztAktualisieren : undefined}
hinweis={
<Hinweisleiste
verbindung={daten.verbindung}
+129 -17
View File
@@ -7,15 +7,18 @@
* liegt bei 1000px.
*/
import type { ReactNode } from "react"
import { useEffect, useRef, useState } from "react"
import type { PointerEvent as ReactPointerEvent, ReactNode, RefObject } from "react"
import { TabBar } from "@audi-dash/ui"
import { standAlter } from "./format"
import { useBreiterBildschirm } from "./hooks/useBreiterBildschirm"
import { VierRinge } from "./screens/Typenschild"
import type { Hauptbereich, Route, SeitenName } from "./navigation"
import { TITEL, ZURUECK, bereichVon, istHauptbereich } from "./navigation"
import {
SymbolAktualisieren,
SymbolAudi,
SymbolFahrten,
SymbolStatistik,
@@ -31,10 +34,70 @@ export interface ShellProps {
tabBeschriftung: boolean
/** Wird über der Kopfzeile eingeblendet (Offline-Hinweis, Warteschlange). */
hinweis?: ReactNode | undefined
/** Zeitpunkt des letzten Datenstands — steht als „vor 3 Std." unter dem Titel. */
statusStand?: string | null | undefined
/** Neuabfrage beim Fahrzeug, ausgelöst durch Ziehen zum Aktualisieren. */
beiAktualisieren?: (() => Promise<void>) | undefined
/** Heißt `children`, weil JSX-Verschachtelung genau diesen Namen erwartet. */
children: ReactNode
}
/** Ziehen zum Aktualisieren, 1:1 nach der ptr-Logik des Panels. */
const PTR_SCHWELLE = 56
function useZiehenAktualisieren(
inhalt: RefObject<HTMLElement | null>,
beiAktualisieren: (() => Promise<void>) | undefined,
) {
const [zug, setzeZug] = useState(0)
const [laedt, setzeLaedt] = useState(false)
const start = useRef<number | null>(null)
if (!beiAktualisieren) {
return { zug: 0, laedt: false, handler: {} as Record<string, never> }
}
const beenden = () => {
if (start.current === null) return
start.current = null
if (zug >= PTR_SCHWELLE) {
setzeLaedt(true)
void beiAktualisieren().finally(() => {
setzeLaedt(false)
setzeZug(0)
})
} else {
setzeZug(0)
}
}
return {
zug,
laedt,
handler: {
onPointerDown: (e: ReactPointerEvent) => {
if (!e.isPrimary || laedt) return
if ((inhalt.current?.scrollTop ?? 0) > 0) return
start.current = e.clientY
setzeZug(0)
},
onPointerMove: (e: ReactPointerEvent) => {
if (start.current === null || !e.isPrimary) return
const dy = e.clientY - start.current
if (dy > 0 && (inhalt.current?.scrollTop ?? 0) <= 0) {
// Widerstand wie bei iOS: halbe Strecke, gedeckelt bei 90px.
setzeZug(Math.min(90, dy * 0.5))
} else {
start.current = null
setzeZug(0)
}
},
onPointerUp: beenden,
onPointerCancel: beenden,
},
}
}
const BEREICHE: { key: Hauptbereich; label: string; icon: ReactNode }[] = [
{ key: "home", label: "Übersicht", icon: <SymbolUebersicht /> },
{ key: "audi", label: "Mein Audi", icon: <SymbolAudi /> },
@@ -43,13 +106,33 @@ const BEREICHE: { key: Hauptbereich; label: string; icon: ReactNode }[] = [
{ key: "fuel", label: "Tanken", icon: <SymbolTanken /> },
]
export function Shell({ route, geheZu, tabBeschriftung, hinweis, children }: ShellProps) {
export function Shell({
route,
geheZu,
tabBeschriftung,
hinweis,
statusStand,
beiAktualisieren,
children,
}: ShellProps) {
const breit = useBreiterBildschirm()
const aktiverBereich = bereichVon(route.name)
const zurueckZiel = ZURUECK[route.name]
const unterseite = !istHauptbereich(route.name)
const inhaltRef = useRef<HTMLElement | null>(null)
const ptr = useZiehenAktualisieren(inhaltRef, breit ? undefined : beiAktualisieren)
// Der Text tickt mit, wie standAlterTicken() im Panel (Sekundentakt).
const [, neuZeichnen] = useState(0)
useEffect(() => {
if (route.name !== "home") return undefined
const uhr = window.setInterval(() => neuZeichnen((n) => n + 1), 1000)
return () => window.clearInterval(uhr)
}, [route.name])
const kopf = (
<header className="dm-kopf">
<header className={unterseite ? "dm-kopf dm-kopf--unterseite" : "dm-kopf"}>
{zurueckZiel && !breit ? (
<button
type="button"
@@ -60,27 +143,38 @@ export function Shell({ route, geheZu, tabBeschriftung, hinweis, children }: She
<SymbolZurueck groesse={22} />
</button>
) : null}
{/* Kein Eyebrow: das Panel blendet seinen (`.eyebrow{display:none}` in
audi-dashboard-ios.css) auf Telefonbreite vollständig aus - der feste
Text "DataMetric360" stand hier über jedem Bildschirm, im Panel steht
dort nichts. */}
<div className="dm-kopf__titel">
<span className="ads-eyebrow">DataMetric360</span>
<div className="ads-title">{TITEL[route.name]}</div>
{/* Datenstand nur auf der Übersicht, wie `#sync` im Panel. */}
{route.name === "home" && (
<div className="dm-stand">
<SymbolAktualisieren groesse={13} />
<span>{standAlter(statusStand)}</span>
</div>
)}
</div>
{/* `kopf` wird nur in der schmalen Spalte gerendert (breit hat seinen
eigenen Seitenleisten-Kopf, siehe unten) - dort sind die Audi-Ringe
oben rechts der einzige Zugang zu Einstellungen, das Zahnrad
entfällt, "damit oben rechts nur ein Element sitzt" (Design-Audit
2026-08-13, siehe .rings/.profilbtn in audi-dashboard-ios.css).
Companion-app zeigte hier bisher immer ein Zahnrad, unabhängig von
der Bildschirmbreite - echte Abweichung, nicht nur Kosmetik: es gab
keinen Rings-Zugang in Telefonbreite. */}
<button
type="button"
className="dm-kopf__aktion"
aria-label="Einstellungen"
aria-current={route.name === "einst" ? "page" : undefined}
onClick={() => geheZu("einst")}
>
<VierRinge hoehe={17} />
</button>
Nur auf den Tab-Ebenen: auf Unterseiten blendet das Panel die Ringe
aus (`#marke`.style.display in render()), dort steht links der
Zurück-Pfeil. */}
{!unterseite ? (
<button
type="button"
className="dm-kopf__aktion"
aria-label="Einstellungen"
onClick={() => geheZu("einst")}
>
<VierRinge hoehe={17} />
</button>
) : null}
</header>
)
@@ -143,7 +237,25 @@ export function Shell({ route, geheZu, tabBeschriftung, hinweis, children }: She
<div className="dm-spalte">
{hinweis}
{kopf}
<main className="dm-scroll dm-inhalt">{children}</main>
{/* Ziehen zum Aktualisieren wie im Panel (`.ptr`): das Band wächst
während der Geste aus der Höhe 0 heraus. */}
<div
className={ptr.laedt ? "dm-ptr dm-ptr--dreht" : "dm-ptr"}
style={{ height: ptr.laedt ? "56px" : `${ptr.zug}px` }}
aria-hidden={ptr.zug === 0 && !ptr.laedt}
>
<SymbolAktualisieren groesse={14} />
<span>
{ptr.laedt
? "Aktualisieren …"
: ptr.zug >= 56
? "Loslassen zum Aktualisieren"
: "Ziehen zum Aktualisieren"}
</span>
</div>
<main className="dm-scroll dm-inhalt" ref={inhaltRef} {...ptr.handler}>
{children}
</main>
<TabBar
items={BEREICHE}
activeKey={aktiverBereich}
+28
View File
@@ -39,6 +39,10 @@ export interface TankvorgangFelder {
station?: string | null;
distanz?: number | null;
kraftstoff?: string | null;
/** Aus einem vorab gelesenen Beleg (Upload ohne tank_id) - so übergibt sie
auch das Panel beim Speichern eines neuen Tankvorgangs mit Beleg. */
receipt_key?: string | null;
receipt_file?: string | null;
}
/** Eingabefelder für Fahrten - Namen wie beim Backend (fahrterkennung.py:
@@ -130,6 +134,30 @@ export class DataMetricApi {
);
}
/**
* Fahrzeugfoto hochladen bzw. ersetzen. Dieselben Dienste, die das Panel
* über `data-bildupload` anspricht (`bild_hochladen`/`bild_loeschen` in
* services.yaml). Die App konnte Fotos bisher gar nicht ändern — sie
* verwies stattdessen auf die Verwaltung in Home Assistant.
*/
bildHochladen(dateiname: string, datenBase64: string): Promise<unknown> {
return this.warteschlange.einreihen(
DIENST_DOMAIN,
"bild_hochladen",
{ dateiname, daten_base64: datenBase64 },
`Foto „${dateiname}" hochladen`,
)
}
bildLoeschen(dateiname: string): Promise<unknown> {
return this.warteschlange.einreihen(
DIENST_DOMAIN,
"bild_loeschen",
{ dateiname },
`Foto „${dateiname}" löschen`,
)
}
/** Legt eine Fahrt von Hand an — für Fahrten ohne automatische Erkennung. */
fahrtAnlegen(felder: FahrtFelder): Promise<unknown> {
return this.warteschlange.einreihen(
+70
View File
@@ -0,0 +1,70 @@
/**
* Adresse → Koordinaten (Vorwärts-Geokodierung über Nominatim), mit Cache im
* localStorage. Gegenstück zur Rückwärts-Auflösung in `Standort.tsx`.
*
* Gebraucht wird sie für die Tankstellennadel auf dem Einzelbeleg: der Beleg
* trägt nur eine Anschrift, und das Backend füllt `station_lat`/`station_lon`
* nie — ohne diesen Schritt bliebe die Karte dort dauerhaft ohne Nadel. Das
* Panel macht es seit jeher genau so (`adresseAufloesen()`); der App fehlte
* beides, Karte wie Auflösung.
*
* Der Cache liegt bewusst im localStorage und nicht nur im Speicher: eine
* Tankstellenadresse ändert ihre Lage nicht, und Nominatim bittet in seinen
* Nutzungsbedingungen ausdrücklich darum, Ergebnisse zu behalten. Auch ein
* erfolgloser Treffer wird als `null` vermerkt, damit eine Adresse, die
* Nominatim nicht kennt, nicht bei jedem Öffnen erneut abgefragt wird — ein
* Netzfehler dagegen nicht, der ist kein „gibt es nicht".
*/
export interface Koordinate {
lat: number
lon: number
}
const CACHE_SCHLUESSEL = "dm360.geocache"
function cacheLesen(): Record<string, Koordinate | null> {
try {
const roh = localStorage.getItem(CACHE_SCHLUESSEL)
return roh ? (JSON.parse(roh) as Record<string, Koordinate | null>) : {}
} catch {
return {}
}
}
function cacheSchreiben(suche: string, treffer: Koordinate | null): void {
try {
const cache = cacheLesen()
cache[suche] = treffer
localStorage.setItem(CACHE_SCHLUESSEL, JSON.stringify(cache))
} catch {
// Privatmodus oder volle Ablage — dann eben ohne Cache.
}
}
export async function adresseAufloesen(adresse: string | null | undefined): Promise<Koordinate | null> {
const suche = (adresse ?? "").trim()
if (!suche) return null
const cache = cacheLesen()
if (Object.prototype.hasOwnProperty.call(cache, suche)) return cache[suche] ?? null
let treffer: Koordinate | null = null
try {
const antwort = await fetch(
`https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=${encodeURIComponent(suche)}`,
{ headers: { "Accept-Language": "de" } },
)
const daten: unknown = await antwort.json()
if (Array.isArray(daten) && daten.length > 0) {
const erster = daten[0] as { lat?: string; lon?: string }
const lat = Number.parseFloat(erster.lat ?? "")
const lon = Number.parseFloat(erster.lon ?? "")
if (Number.isFinite(lat) && Number.isFinite(lon)) treffer = { lat, lon }
}
} catch {
// Netzfehler: nichts merken, damit der nächste Aufruf es erneut versucht.
return null
}
cacheSchreiben(suche, treffer)
return treffer
}
+2 -1
View File
@@ -176,7 +176,8 @@ describe("jahresauswertung", () => {
})
describe("nachJahrUndMonat", () => {
it("gruppiert absteigend, Neuestes zuerst", () => {
// Durchgehend absteigend, in beiden Anwendungen (Designprüfung 2026-08-30).
it("gruppiert durchgehend absteigend, Neuestes zuerst", () => {
const eintraege = [
{ id: "alt", ts: "2025-03-04T10:00:00" },
{ id: "neu", ts: "2026-08-01T10:00:00" },
+22 -1
View File
@@ -130,10 +130,25 @@ export interface Jahresauswertung {
privatKm: number
arbeitFahrten: number
privatFahrten: number
jeZeitraum: Record<Zeitraum, { km: number; stunden: number; liter: number; kosten: number }>
jeZeitraum: Record<
Zeitraum,
{ km: number; stunden: number; liter: number; kosten: number; verbrauch: number | null }
>
langzeit: Langzeitverbrauch
}
/**
* Nach Distanz gewichteter Mittelwert der genäherten Fahrtverbräuche —
* `schnittFahrtVerbrauch()` im Panel. Die Kachel „Langzeitverbrauch" zeigt
* damit einen Näherungswert je Zeitraum; hier fehlte er bisher.
*/
export function schnittFahrtVerbrauch(fahrten: readonly Fahrt[]): number | null {
const mit = fahrten.filter((f) => f.verbrauch_l_100km != null && (f.distance_km ?? 0) > 0)
const km = summe(mit, (f) => f.distance_km ?? 0)
if (km <= 0) return null
return summe(mit, (f) => (f.verbrauch_l_100km ?? 0) * (f.distance_km ?? 0)) / km
}
export function jahresauswertung(
fahrten: readonly Fahrt[],
tankvorgaenge: readonly Tankvorgang[],
@@ -150,6 +165,7 @@ export function jahresauswertung(
stunden: summe(f, (x) => x.duration_s ?? 0) / 3600,
liter: summe(t, (x) => x.liters ?? 0),
kosten: summe(t, (x) => x.fuel_total_eur ?? 0),
verbrauch: schnittFahrtVerbrauch(f),
}
}
@@ -205,6 +221,11 @@ export function nachJahrUndMonat<T>(
): JahresGruppe<T>[] {
const jahre = new Map<number, Map<string, MonatsGruppe<T>>>()
/* Durchgehend absteigend: Jahr, Monat und Zeile. Bis zur Designprüfung
2026-08-30 liefen Monate und Zeilen aufsteigend (der Panel-Stand von
damals) — in einer Liste, deren Jahre absteigen, sind das zwei
Richtungen gleichzeitig, und die neueste Fahrt landete unten außerhalb
des sichtbaren Bereichs. Das Panel ist mitgezogen (neuesteZuerst()). */
const sortiert = eintraege
.slice()
.sort((a, b) => new Date(zeitpunktVon(b)).getTime() - new Date(zeitpunktVon(a)).getTime())
+3 -3
View File
@@ -18,7 +18,7 @@ describe("Zahlen im deutschen Format", () => {
it("unterscheidet mit deOderStrich zwischen null und unbekannt", () => {
expect(deOderStrich(0)).toBe("0")
expect(deOderStrich(null)).toBe("")
expect(deOderStrich(null)).toBe("")
})
})
@@ -32,8 +32,8 @@ describe("dauer", () => {
})
it("zeigt einen Strich statt „0 min", () => {
expect(dauer(0)).toBe("")
expect(dauer(null)).toBe("")
expect(dauer(0)).toBe("")
expect(dauer(null)).toBe("")
})
})
+41 -8
View File
@@ -14,9 +14,14 @@ export function eur(wert: number | null | undefined): string {
return de(wert, 2)
}
/** Wie de(), aber „—" statt „0" wenn nichts bekannt ist. */
/* Fehlende Werte zeigt das Panel durchgängig als Halbgeviertstrich „–", nicht
als Geviertstrich „—" (30 Fundstellen gegen eine). Hier stand app-weit der
längere Strich — sichtbar breiter, sobald beide Anwendungen nebeneinander
liegen. */
/** Wie de(), aber „–" statt „0" wenn nichts bekannt ist. */
export function deOderStrich(wert: number | null | undefined, nachkomma = 0): string {
return wert === null || wert === undefined ? "" : de(wert, nachkomma)
return wert === null || wert === undefined ? "" : de(wert, nachkomma)
}
/** Summe über eine Liste. */
@@ -61,32 +66,32 @@ function alsDatum(wert: string | number | Date | null | undefined): Date | null
export function datum(wert: string | number | Date | null | undefined): string {
const d = alsDatum(wert)
return d ? DATUM_KURZ.format(d) : ""
return d ? DATUM_KURZ.format(d) : ""
}
export function datumLang(wert: string | number | Date | null | undefined): string {
const d = alsDatum(wert)
return d ? DATUM_LANG.format(d) : ""
return d ? DATUM_LANG.format(d) : ""
}
export function uhrzeit(wert: string | number | Date | null | undefined): string {
const d = alsDatum(wert)
return d ? UHRZEIT.format(d) : ""
return d ? UHRZEIT.format(d) : ""
}
export function datumZeit(wert: string | number | Date | null | undefined): string {
const d = alsDatum(wert)
return d ? `${DATUM_KURZ.format(d)}, ${UHRZEIT.format(d)} Uhr` : ""
return d ? `${DATUM_KURZ.format(d)}, ${UHRZEIT.format(d)} Uhr` : ""
}
export function monatJahr(wert: string | number | Date | null | undefined): string {
const d = alsDatum(wert)
return d ? MONAT_JAHR.format(d) : ""
return d ? MONAT_JAHR.format(d) : ""
}
/** Dauer in Sekunden als „2 h 14 min" bzw. „14 min". */
export function dauer(sekunden: number | null | undefined): string {
if (!sekunden || sekunden <= 0) return ""
if (!sekunden || sekunden <= 0) return ""
const minutenGesamt = Math.round(sekunden / 60)
const stunden = Math.floor(minutenGesamt / 60)
const minuten = minutenGesamt % 60
@@ -94,6 +99,23 @@ export function dauer(sekunden: number | null | undefined): string {
return `${stunden} h ${String(minuten).padStart(2, "0")} min`
}
/**
* Alter des letzten Datenstands als „vor 3 Std." — wortgleich mit
* `standAlterText()` im Panel, das diesen Text in der Kopfzeile der Übersicht
* neben dem Aktualisieren-Symbol zeigt.
*/
export function standAlter(wert: string | number | Date | null | undefined): string {
const d = alsDatum(wert)
if (!d) return "unbekannt"
const sekunden = Math.max(0, Math.floor((Date.now() - d.getTime()) / 1000))
if (sekunden < 60) return `vor ${sekunden} Sek.`
const minuten = Math.floor(sekunden / 60)
if (minuten < 60) return `vor ${minuten} Min.`
const stunden = Math.floor(minuten / 60)
if (stunden < 24) return `vor ${stunden} Std.`
return `vor ${Math.floor(stunden / 24)} Tage`
}
/** ISO-Tagesdatum (YYYY-MM-DD) für Vergleiche und Eingabefelder. */
export function isoTag(wert: string | number | Date | null | undefined = new Date()): string {
const d = alsDatum(wert)
@@ -101,3 +123,14 @@ export function isoTag(wert: string | number | Date | null | undefined = new Dat
const versatz = d.getTimezoneOffset() * 60_000
return new Date(d.getTime() - versatz).toISOString().slice(0, 10)
}
/**
* Datum und Uhrzeit mit „ · " getrennt — so setzt das Panel den Zeitstempel in
* der Batterie-Messwertliste (`dedat(...) + " · " + toLocaleTimeString(...)`).
* `datumZeit()` daneben bleibt die Komma-Schreibweise für die Stellen, die sie
* schon benutzen.
*/
export function datumUndZeit(wert: string | number | Date | null | undefined): string {
const d = alsDatum(wert)
return d ? `${DATUM_KURZ.format(d)} · ${UHRZEIT.format(d)} Uhr` : ""
}
+16 -10
View File
@@ -73,29 +73,35 @@ export const ZURUECK: Partial<Record<SeitenName, SeitenName>> = {
live: "home",
}
/** Überschrift je Seite. Leer bedeutet: die Seite bringt ihre eigene mit. */
/**
* Überschrift je Seite — wortgleich mit dem Kopf, den `render()` im Panel
* setzt (`head[1]`, audi-dashboard-app.js). Neun Titel wichen davon ab
* (Fahrzeugdaten/Batterie/Versicherung & Steuer/Beitrag/Notruf/Werkstatt/
* Wartungsplan/Fahrt/Tankvorgang); das waren keine Entscheidungen, sondern
* Drift. `live` hat keine Panel-Entsprechung (companion-app-eigener Screen).
*/
export const TITEL: Record<SeitenName, string> = {
home: "Übersicht",
audi: "Mein Audi",
trips: "Fahrten",
stat: "Statistik",
fuel: "Tanken",
ident: "Fahrzeugdaten",
battverlauf: "Batterie",
ident: "Identität und Technik",
battverlauf: "Batteriespannung",
battliste: "Messwerte",
service: "Service",
werkstatt: "Werkstatt",
sbuch: "Wartungsplan",
werkstatt: "Autohaus",
sbuch: "Eintrag",
reifen: "Reifen",
vers: "Versicherung & Steuer",
beitrag: "Beitrag",
vers: "Versicherung/Steuer",
beitrag: "Beitrag anpassen",
vertrag: "Vertrag",
vertragsdetails: "Vertragsdetails",
schutz: "Schutzbrief",
notruf: "Notruf",
notruf: "Rufnummern",
steuer: "Kfz-Steuer",
trip: "Fahrt",
fill: "Tankvorgang",
trip: "Einzelfahrt",
fill: "Einzelbeleg",
sicherheit: "Fahrzeugstatus",
tuerenklappen: "Türen und Klappen",
standort: "Standort",
+266 -49
View File
@@ -19,7 +19,7 @@ import { ENTITAETEN } from "../api"
import { useDaten } from "../daten/DatenKontext"
import { datum, de } from "../format"
import type { SeitenName } from "../navigation"
import { SymbolListe } from "../symbole"
import { SymbolBatterie, SymbolListe } from "../symbole"
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
interface Tageswert {
@@ -112,8 +112,57 @@ function ladezustand(spannung: number): number | null {
return 100
}
/** `bvSocLabel()` im Panel. */
function socLabel(soc: number): string {
return soc >= 90 ? "Sehr gut" : soc >= 70 ? "Gut" : soc >= 50 ? "Mittel" : soc >= 30 ? "Schwach" : "Kritisch"
}
/**
* `bvSohBewertung()` im Panel: Trend über die früheren gegen die späteren
* Messwerte, mit denselben Schwellen und demselben Wortlaut. Fehlte hier — die
* Bewertungskachel gab es gar nicht, stattdessen stand der Trend als nackte
* Wertzeile ohne Einordnung.
*/
const TAG_MS = 86_400_000
function sohBewertung(
punkte: { t: number; v: number }[],
): { status: "ok" | "warn" | "bad" | null; text: string; delta: number } {
if (punkte.length < 6)
return { status: null, text: "Noch nicht genug Messwerte für eine Trendbewertung.", delta: 0 }
const spanneTage = (punkte[punkte.length - 1]!.t - punkte[0]!.t) / TAG_MS
if (spanneTage < 60)
return {
status: null,
text: "Noch zu kurzer Zeitraum für eine Trendbewertung (mind. ~2 Monate nötig).",
delta: 0,
}
const fensterMs = Math.min(90, spanneTage / 3) * TAG_MS
const alt = punkte.filter((p) => p.t <= punkte[0]!.t + fensterMs)
const neu = punkte.filter((p) => p.t >= punkte[punkte.length - 1]!.t - fensterMs)
const mittel = (l: { v: number }[]) => l.reduce((s, p) => s + p.v, 0) / l.length
const delta = mittel(neu) - mittel(alt)
const status = delta >= -0.05 ? "ok" : delta >= -0.15 ? "warn" : "bad"
const text =
delta >= -0.05
? "Stabil - kein auffälliger Rückgang der Ruhespannung."
: delta >= -0.15
? "Leicht sinkend - im für alternde AGM-Batterien üblichen Rahmen."
: "Deutlicher Rückgang - kann auf eine alternde oder geschwächte Batterie hindeuten."
return { status, text, delta }
}
function statusFarbe(status: "ok" | "warn" | "bad" | null): string {
return status === "ok"
? "var(--ok)"
: status === "warn"
? "var(--warn)"
: status === "bad"
? "var(--bad)"
: "var(--fg3)"
}
export function Batterie({ geheZu }: { geheZu: (name: SeitenName) => void }) {
const { api, fahrzeug } = useDaten()
const { api } = useDaten()
const [verlauf, setzeVerlauf] = useState<Tageswert[] | null>(null)
const [fehler, setzeFehler] = useState(false)
@@ -153,60 +202,79 @@ export function Batterie({ geheZu }: { geheZu: (name: SeitenName) => void }) {
const letzter = messwerte[messwerte.length - 1]!
const soc = letzter.min != null ? ladezustand(letzter.min) : null
const stufen = farbstufenLesen()
const punkte = messwerte
.filter((t) => t.min != null && t.min_ts)
.map((t) => ({ t: new Date(t.min_ts!).getTime(), v: t.min! }))
const soh = sohBewertung(punkte)
// 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
/* Aufbau wie vBatterieverlauf(): eine Kachel „Verlauf" mit Diagramm und
Ruhespannungs-Skala, darunter eine Kachel „Bewertung" mit Ladezustand und
Gesundheitszustand. Hier stand bis 2026-08-30 eine einzelne Kachel
„Ruhespannung" mit großer Zahl und vier Wertzeilen — die Farbskala, die
Bewertungskachel und der Bewertungstext fehlten ganz, dafür gab es eine
Zeile „Aktuell gemeldet", die das Panel hier nicht führt. */
return (
<>
<Tile>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span className="ads-eyebrow">Ruhespannung</span>
<span className="ads-eyebrow">Verlauf</span>
<IconButton aria-label="Messwertliste" onClick={() => geheZu("battliste")}>
<SymbolListe groesse={18} />
</IconButton>
</div>
<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} />
<div className="dm-skalakopf">
<SymbolBatterie groesse={15} />
<span>Ruhespannung</span>
</div>
<div className="dm-skalabalken" />
<div className="dm-skalamarken">
<small>11,6 V · leer</small>
<small>12,2 V · 50 %</small>
<small>12,8 V · voll</small>
</div>
</Tile>
<Tile>
<span className="ads-eyebrow">Bewertung</span>
<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"
label="Ladezustand (SOC)"
wert={
fahrzeug?.batteriespannung != null
? `${de(fahrzeug.batteriespannung, 2)} V`
: "—"
letzter.min != null && soc != null ? (
<span className="dm-bewertung">
<span
className="ads-dot"
style={{ background: farbeFuerSpannung(letzter.min, stufen) }}
/>
{de(letzter.min, 1)} V · {de(soc)} % · {socLabel(soc)}
</span>
) : (
<span className="dm-offen">unbekannt</span>
)
}
/>
<Wertzeile
label="Gesundheitszustand (SOH-Trend)"
wert={
soh.status ? (
<span className="dm-bewertung">
<span className="ads-dot" style={{ background: statusFarbe(soh.status) }} />
{soh.delta >= 0 ? "+" : ""}
{de(soh.delta, 2)} V
</span>
) : (
<span className="dm-offen">-</span>
)
}
/>
</>
}
/>
<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>
<span className="dm-bewertungstext">{soh.text}</span>
</Tile>
</>
)
@@ -221,12 +289,35 @@ export function Batterie({ geheZu }: { geheZu: (name: SeitenName) => void }) {
320er-Fläche mit preserveAspectRatio="none" breitzuziehen: sonst würden die
neuen Punkte auf breiten Anzeigen zu Ellipsen verzerrt (im Panel war
dasselbe Problem an den auseinandergezogenen Achsenzahlen sichtbar). */
/** Hält einen Zoomausschnitt innerhalb des vorhandenen Zeitraums. */
function begrenzen(
bereich: [number, number],
voll: [number, number],
): [number, number] {
const spanne = Math.min(bereich[1] - bereich[0], voll[1] - voll[0])
let von = bereich[0]
if (von < voll[0]) von = voll[0]
if (von + spanne > voll[1]) von = voll[1] - spanne
return [von, von + spanne]
}
const BV_H = 170
const BV_ML = 40
const BV_MR = 10
const BV_MT = 10
const BV_MB = 12
function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
const hoehe = 120
const rand = 8
const hoehe = BV_H
const svgRef = useRef<SVGSVGElement | null>(null)
const [breite, setzeBreite] = useState(320)
const [gewaehlt, setzeGewaehlt] = useState<number | null>(null)
/* Zoomen und Verschieben wie im Panel (bvDomain/bvZeiger/bvGestStart):
zwei Finger spreizen den Zeitausschnitt, einer verschiebt ihn, der Knopf
stellt ihn zurück. Fehlte hier ganz — die Kurve war unveränderlich. */
const [ausschnitt, setzeAusschnitt] = useState<[number, number] | null>(null)
const zeiger = useRef(new Map<number, number>())
const gestStart = useRef<{ domain: [number, number]; mitte: number; spanne: number } | null>(null)
useEffect(() => {
const el = svgRef.current
@@ -247,16 +338,28 @@ function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
const groesster = 15
const spanne = groesster - kleinster
const x = (i: number) => rand + (i / Math.max(1, werte.length - 1)) * (breite - 2 * rand)
// Zeitachse: echte Zeitstempel statt gleichmäßiger Indexschritte, sonst
// ließe sich der Ausschnitt nicht sinnvoll zoomen.
const zeitVon = (t: Tageswert) => new Date(t.min_ts ?? t.datum).getTime()
const vollDomain: [number, number] =
werte.length >= 2
? [zeitVon(werte[0]!), zeitVon(werte[werte.length - 1]!)]
: [0, 1]
const domain = ausschnitt ?? vollDomain
const zeitSpanne = Math.max(1, domain[1] - domain[0])
const x = (i: number) =>
BV_ML +
((zeitVon(werte[i]!) - domain[0]) / zeitSpanne) * (breite - BV_ML - BV_MR)
// Anteil auf [0,1] geklemmt statt roh extrapoliert - ein Ausreißer außerhalb
// von kleinster/groesster zeichnet sich sonst weit außerhalb der Fläche.
const y = (v: number) => {
const anteil = Math.max(0, Math.min(1, (v - kleinster) / spanne))
return hoehe - rand - anteil * (hoehe - 2 * rand)
return hoehe - BV_MB - anteil * (hoehe - BV_MT - BV_MB)
}
const linie = werte
.map((t, i) => (t.min == null ? null : `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(t.min).toFixed(1)}`))
const punkteText = werte
.map((t, i) => (t.min == null ? null : `${x(i).toFixed(1)},${y(t.min).toFixed(1)}`))
.filter(Boolean)
.join(" ")
@@ -264,15 +367,84 @@ function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
const el = svgRef.current
if (!el || werte.length === 0) return
const kasten = el.getBoundingClientRect()
const relativ = ereignis.clientX - kasten.left - rand
const schritt = (breite - 2 * rand) / Math.max(1, werte.length - 1)
const index = Math.max(0, Math.min(werte.length - 1, Math.round(relativ / schritt)))
setzeGewaehlt((vorher) => (vorher === index ? null : index))
const anteil = (ereignis.clientX - kasten.left - BV_ML) / Math.max(1, breite - BV_ML - BV_MR)
const zeit = domain[0] + anteil * zeitSpanne
let bester = 0
let abstand = Infinity
werte.forEach((t, i) => {
const a = Math.abs(zeitVon(t) - zeit)
if (a < abstand) {
abstand = a
bester = i
}
})
setzeGewaehlt((vorher) => (vorher === bester ? null : bester))
}
const zeigerRunter = (e: React.PointerEvent<SVGSVGElement>) => {
zeiger.current.set(e.pointerId, e.clientX)
if (zeiger.current.size === 2) {
const [a, b] = [...zeiger.current.values()]
gestStart.current = {
domain: [domain[0], domain[1]],
mitte: (a! + b!) / 2,
spanne: Math.abs(a! - b!) || 1,
}
} else if (zeiger.current.size === 1) {
gestStart.current = { domain: [domain[0], domain[1]], mitte: e.clientX, spanne: 0 }
}
}
const zeigerBewegt = (e: React.PointerEvent<SVGSVGElement>) => {
if (!zeiger.current.has(e.pointerId) || !gestStart.current) return
zeiger.current.set(e.pointerId, e.clientX)
const nutzbar = Math.max(1, breite - BV_ML - BV_MR)
const start = gestStart.current
const startSpanne = start.domain[1] - start.domain[0]
if (zeiger.current.size >= 2) {
const [a, b] = [...zeiger.current.values()]
const faktor = start.spanne / Math.max(1, Math.abs(a! - b!))
const neueSpanne = Math.max(
3600_000,
Math.min(vollDomain[1] - vollDomain[0], startSpanne * faktor),
)
const mitteZeit = start.domain[0] + (startSpanne * (start.mitte - BV_ML)) / nutzbar
setzeAusschnitt(begrenzen([mitteZeit - neueSpanne / 2, mitteZeit + neueSpanne / 2], vollDomain))
} else {
const verschub = ((start.mitte - e.clientX) / nutzbar) * startSpanne
setzeAusschnitt(
begrenzen([start.domain[0] + verschub, start.domain[1] + verschub], vollDomain),
)
}
}
const zeigerHoch = (e: React.PointerEvent<SVGSVGElement>) => {
zeiger.current.delete(e.pointerId)
if (zeiger.current.size === 0) gestStart.current = null
}
const aktiv = gewaehlt != null ? werte[gewaehlt] : undefined
const zeitpunkt = aktiv?.min_ts ? new Date(aktiv.min_ts) : null
const stufen = farbstufenLesen()
const kurz = (wert?: string) =>
wert
? new Date(wert).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "2-digit",
})
: ""
/* Gitterlinien mit Achsenbeschriftung und die weiche Flächenfüllung unter der
Linie fehlten hier ganz — das Panel zeichnet beide (bvZeichnen()), samt
dem Zeitraum unter der Fläche. Ohne sie stand die Kurve ohne jeden
Maßstab im Raum. */
const flaeche =
punkteText.length > 0 && werte.length >= 2
? `M${x(0).toFixed(1)},${(hoehe - BV_MB).toFixed(1)} L${punkteText.split(" ").join(" L")} L${x(
werte.length - 1,
).toFixed(1)},${(hoehe - BV_MB).toFixed(1)} Z`
: ""
return (
<>
@@ -283,8 +455,45 @@ function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
role="img"
aria-label={`Spannungsverlauf über ${werte.length} Messungen`}
onClick={punktWaehlen}
onPointerDown={zeigerRunter}
onPointerMove={zeigerBewegt}
onPointerUp={zeigerHoch}
onPointerCancel={zeigerHoch}
>
<path d={linie} fill="none" stroke="var(--fg)" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
<defs>
<linearGradient id="dmBvGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="var(--fg)" stopOpacity="0.16" />
<stop offset="1" stopColor="var(--fg)" stopOpacity="0" />
</linearGradient>
</defs>
{[0, 1, 2, 3].map((i) => {
const v = kleinster + (spanne * i) / 3
const yy = y(v)
return (
<g key={i}>
<line
x1={BV_ML}
y1={yy}
x2={breite - BV_MR}
y2={yy}
stroke="var(--line)"
strokeWidth="0.5"
/>
<text x={BV_ML - 6} y={yy + 3} fontSize="11" fill="var(--fg3)" textAnchor="end">
{de(v, 1)}
</text>
</g>
)
})}
{flaeche && <path d={flaeche} fill="url(#dmBvGrad)" stroke="none" />}
<polyline
points={punkteText}
fill="none"
stroke="var(--fg)"
strokeWidth="1.5"
strokeLinejoin="round"
strokeLinecap="round"
/>
{werte.map((t, i) =>
t.min == null ? null : (
<circle
@@ -298,8 +507,16 @@ function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
),
)}
</svg>
<div className="dm-diagramm__fuss">
<span className="ads-eyebrow">
{kurz(new Date(domain[0]).toISOString())} {kurz(new Date(domain[1]).toISOString())}
</span>
<button type="button" className="ads-action dm-zoomzurueck" onClick={() => setzeAusschnitt(null)}>
Zoom zurücksetzen
</button>
</div>
{aktiv && (
<p className="dm-fussnote">
<div className="dm-diagramm__tooltip">
{[
zeitpunkt
? `${datum(aktiv.datum)} · ${zeitpunkt.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr`
@@ -310,7 +527,7 @@ function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
]
.filter(Boolean)
.join(" · ")}
</p>
</div>
)}
</>
)
+3 -3
View File
@@ -19,7 +19,7 @@ import { Pill, Tile } from "@audi-dash/ui"
import { ENTITAETEN } from "../api"
import { useDaten } from "../daten/DatenKontext"
import { datum, datumZeit, de } from "../format"
import { datum, datumUndZeit, de } from "../format"
import { Leerzustand } from "./bausteine"
import { AGM_RUHE_MAX_V } from "./Batterie"
import { Zeilenmenue } from "./Zeilenmenue"
@@ -86,11 +86,11 @@ export function BatterieListe() {
<span className="dm-listenzeile__titel">
{de(t.min!, 2)} V
<span className="dm-listenzeile__neben">
{t.min_temp_c != null ? `${de(t.min_temp_c, 1)} °C` : ""}
{t.min_temp_c != null ? `${de(t.min_temp_c, 1)} °C` : ""}
</span>
</span>
<span className="dm-listenzeile__unter">
{t.min_ts ? datumZeit(t.min_ts) : datum(t.datum)}
{t.min_ts ? datumUndZeit(t.min_ts) : datum(t.datum)}
</span>
</span>
{t.min! > AGM_RUHE_MAX_V && <Pill>Generatorspannung</Pill>}
+10 -4
View File
@@ -16,21 +16,27 @@ export function Bild({
beschreibung,
name,
groesse,
dateiZeigen,
}: {
datei: string | null | undefined
beschreibung: string
name: string
groesse?: "default" | "mini" | "round"
/** Zeigt den Dateinamen statt der Handlungsaufforderung - so macht es das
Panel auf der Einstellungen-Seite (dateiZeigen in bildMitPlatzhalter()). */
dateiZeigen?: boolean
}) {
const url = bildUrl(datei)
// Der Dateiname hilft nur, wenn er lesbar ist — in der 78px breiten
// Miniaturansicht liefe er über den Rand hinaus.
const mitDateiname = groesse !== "mini" && groesse !== "round"
// Unter der Bezeichnung steht im Panel die Handlungsaufforderung, nicht der
// Dateiname (`.platzhalter-aktion` zeigt "Foto hinzufügen";
// `.platzhalter-datei` ist dort ausdrücklich ausgeblendet). In der 78px
// breiten Miniaturansicht bleibt kein Platz dafür.
const mitAktion = groesse !== "mini" && groesse !== "round"
return (
<ImagePlaceholder
{...(url ? { src: url } : {})}
{...(groesse ? { size: groesse } : {})}
{...(mitDateiname && datei ? { hint: datei } : {})}
{...(mitAktion ? { hint: dateiZeigen && datei ? datei : "Foto hinzufügen" } : {})}
alt={beschreibung}
label={name}
/>
+202 -190
View File
@@ -14,11 +14,10 @@ import { eigeneVersion } from "../daten/appVersion"
import { useDaten } from "../daten/DatenKontext"
import { OtaFehler, buendelAnwenden, buendelPasst, otaMoeglich } from "../daten/ota"
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, markenlogoUrl } from "./bilder"
import { datumZeit, isoTag } from "../format"
import { useBoden, 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"
@@ -49,6 +48,13 @@ export function Einstellungen({
neuLaden,
} = useDaten()
const [theme, themeSetzen] = useTheme()
const [boden, setzeBoden] = useBoden()
// "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)
@@ -161,6 +167,13 @@ export function Einstellungen({
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 =
werte.startbild === "seitenansicht.webp" && fahrzeug.reifen.aktiv === "Winter"
? { datei: SEITE_WINTER.datei, beschreibung: SEITE_WINTER.beschreibung }
: (BILDPLAETZE.find((b) => b.datei === werte.startbild) ?? BILDPLAETZE[0]!)
const aendern = (teil: Partial<EinstellungenWerte>) =>
setzeEntwurf({ ...werte, ...teil })
@@ -170,8 +183,12 @@ export function Einstellungen({
setzeLaeuft(true)
setzeGespeichert(false)
try {
await profilSpeichern({ einstellungen: entwurf })
await profilSpeichern({
einstellungen: entwurf,
...(erstzulassung !== null ? { fahrzeug: { ...fahrzeug, erstzulassung } } : {}),
})
setzeEntwurf(null)
setzeErstzulassung(null)
setzeGespeichert(true)
} finally {
setzeLaeuft(false)
@@ -243,99 +260,97 @@ export function Einstellungen({
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</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>
{/* Panel-Wortlaut: "Fahrten zusammenfassen" / "Pause bis" - war hier
als "Fahrten"/"Pause zwischen zwei Fahrten" eigenständig
formuliert, brach dadurch in der 16px-Label-Spalte (Parität-Audit
2026-08-30, Feld-Label-Korrektur) Wort für Wort um. */}
<span className="ads-eyebrow">Fahrten zusammenfassen</span>
<Feld label="Pause bis" 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>
<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>
<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>
<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 der Tab-Leiste"
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>
<Feld label="Fahrbahn unter dem Fahrzeug" last>
<Switch checked={boden} onChange={setzeBoden} aria-label="Fahrbahn unter dem Fahrzeug" />
</Feld>
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
Angedeutete Fahrbahn mit Schatten, nur bei Außenansichten
</span>
</Tile>
<Tile>
<span className="ads-eyebrow">Bild auf der Übersicht</span>
<Feld label="Verwendetes Bild" last>
<span className="ads-eyebrow">Bild der Übersicht</span>
<Feld label="Ansicht" last>
<select
className="dm-auswahl"
value={werte.startbild}
@@ -346,28 +361,26 @@ export function Einstellungen({
{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"
/>
))}
<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>
<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>
<span className="ads-eyebrow">Kraftstoff-Rabatt</span>
<Feld
label={
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
@@ -379,7 +392,9 @@ export function Einstellungen({
e.currentTarget.style.display = "none"
}}
/>
{werte.markenname}-Rabatt aktiv
{/* Das Panel nennt den Schalter "SmartDeal aktiv" - fest, nicht
aus dem Markennamen gebaut. */}
SmartDeal aktiv
</span>
}
last={!fahrzeug.smartdeal.aktiv}
@@ -394,7 +409,7 @@ export function Einstellungen({
},
})
}
aria-label="Tankrabatt aktiv"
aria-label="SmartDeal aktiv"
/>
</Feld>
{fahrzeug.smartdeal.aktiv ? (
@@ -414,31 +429,31 @@ export function Einstellungen({
}
/>
</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>
</>
) : (
<p className="dm-fussnote">Ersparnis und Bildmarke werden ausgeblendet.</p>
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
Ersparnis und Bildmarke werden ausgeblendet
</span>
)}
</Tile>
{/* Nachträglicher Import aus dem HA-Verlauf. Steht bewusst vor
„Sicherung": beides holt Daten von außen in die App, und der Import
ist der Schritt, den man einmal nach der Einrichtung braucht. */}
{/* 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. */}
<Tile>
<span className="ads-eyebrow">Vergangene Daten</span>
<p className="dm-fussnote">
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.
</p>
<div style={{ marginTop: 14 }}>
<ActionButton onClick={() => setzeImportOffen(true)}>
Daten importieren aus Home Assistant
</ActionButton>
</div>
<span className="ads-eyebrow">Fahrten zusammenfassen</span>
<Feld label="Pause bis" unit="Minuten" last>
<input
className="dm-eingabe"
inputMode="numeric"
value={werte.pauseMin}
onChange={(e) => aendern({ pauseMin: Number(e.target.value) || 0 })}
/>
</Feld>
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
Steigst du kürzer als diese Zeit aus etwa zum Tanken , bleibt es eine
zusammenhängende Fahrt statt zweier
</span>
</Tile>
<HistorienImport
@@ -462,43 +477,60 @@ export function Einstellungen({
/>
<Tile>
<span className="ads-eyebrow">Sicherung</span>
<Feld label="Automatische Sicherung">
<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. &quot;Jetzt erstellen&quot;
legt eine Kopie auf dem Server ab, &quot;Exportieren&quot;/&quot;Importieren&quot;
ü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>
<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(DIENST_DOMAIN, "backup_jetzt")
}
>
Jetzt sichern
</ActionButton>
<ActionButton onClick={profilExportieren}>Auf dieses Gerät laden</ActionButton>
<ActionButton
onClick={() => backupDateiwahl.current?.click()}
disabled={backupImportLaeuft}
>
{backupImportLaeuft ? "Lade …" : "Von diesem Gerät einspielen"}
</ActionButton>
</div>
<ActionButton onClick={profilExportieren}>Backup exportieren</ActionButton>
<ActionButton
onClick={() => backupDateiwahl.current?.click()}
disabled={backupImportLaeuft}
>
{backupImportLaeuft ? "Lade …" : "Backup importieren"}
</ActionButton>
<input
ref={backupDateiwahl}
type="file"
@@ -513,22 +545,6 @@ export function Einstellungen({
{backupImportFehler && <p className="dm-fehler">{backupImportFehler}</p>}
</Tile>
<Tile>
<span className="ads-eyebrow">Fahrzeugprofil</span>
<p className="dm-fussnote">
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>
{otaMoeglich() && (
<Tile>
@@ -544,7 +560,7 @@ export function Einstellungen({
/>
}
/>
<p className="dm-fussnote">
<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>
@@ -556,7 +572,7 @@ export function Einstellungen({
{otaFehler && <p className="dm-fehler">{otaFehler}</p>}
</>
) : (
<p className="dm-fussnote">
<p className="dm-erklaerung">
{otaBuendel
? `Diese App ist aktuell (${eigeneVersion() ?? "unbekannte Version"}).`
: "Der Server hat gerade kein Update-Bündel hinterlegt."}
@@ -590,20 +606,20 @@ export function Einstellungen({
{updateInstallierenLaeuft ? (
<div className="dm-ladezeile">
<span className="dm-spinner" aria-hidden="true" />
<span className="dm-fussnote" style={{ margin: 0 }}>
<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-fussnote" style={{ margin: 0 }}>
<span className="dm-erklaerung" style={{ marginTop: 0 }}>
Home Assistant startet neu
</span>
</div>
) : integrationUpdate?.installiert ? (
<>
<p className="dm-fussnote">
<p className="dm-erklaerung">
Version {integrationUpdate.version} installiert - die neue Fassung wird erst nach
einem Neustart geladen.
</p>
@@ -618,7 +634,7 @@ export function Einstellungen({
<Werteliste
kinder={<Wertzeile label="Verfügbare Version" wert={integrationUpdate.version ?? "?"} />}
/>
<p className="dm-fussnote">
<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.
@@ -632,13 +648,13 @@ export function Einstellungen({
) : updatePruefenLaeuft ? (
<div className="dm-ladezeile">
<span className="dm-spinner" aria-hidden="true" />
<span className="dm-fussnote" style={{ margin: 0 }}>
<span className="dm-erklaerung" style={{ marginTop: 0 }}>
Prüfe
</span>
</div>
) : (
<>
<p className="dm-fussnote">
<p className="dm-erklaerung">
{integrationUpdate
? `Integration ist aktuell (${integrationUpdate.geprueft_am ? datumZeit(integrationUpdate.geprueft_am) : ""}).`
: "Noch nicht geprüft."}
@@ -658,21 +674,17 @@ export function Einstellungen({
!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>
<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>
<ActionButton variant="destructive" onClick={() => void abmelden()}>
Verbindung trennen
</ActionButton>
</Tile>
{gespeichert && <p className="dm-erfolg">Gespeichert.</p>}
+58 -65
View File
@@ -11,12 +11,13 @@
import { useState } from "react"
import { ActionButton, Feld, Pill, Tile } from "@audi-dash/ui"
import { ActionButton, Feld, Fig, Pill, Tile } from "@audi-dash/ui"
import type { Fahrt } from "../api"
import { useDaten } from "../daten/DatenKontext"
import { datum, dauer, de, deOderStrich, uhrzeit } from "../format"
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
import type { SeitenName } from "../navigation"
import { Leerzustand, Wertzeile, Werteliste, bestaetigen } from "./bausteine"
import { FahrtFelderFormular, fahrtFelderAusFahrt, fahrtFelderAuswerten } from "./FahrtFelder"
import { Karte } from "./Karte"
@@ -28,8 +29,8 @@ function alsDatetimeLocal(iso: string): string {
return new Date(d.getTime() - versatz).toISOString().slice(0, 16)
}
export function FahrtDetail({ id }: { id: string | undefined }) {
const { fahrten } = useDaten()
export function FahrtDetail({ id, geheZu }: { id: string | undefined; geheZu: (name: SeitenName) => void }) {
const { fahrten, api } = useDaten()
const fahrt = fahrten.find((f) => f.trip_id === id)
const [bearbeitenOffen, setzeBearbeitenOffen] = useState(false)
@@ -42,73 +43,31 @@ export function FahrtDetail({ id }: { id: string | undefined }) {
)
}
const loeschen = () => {
if (!fahrt || !bestaetigen("Fahrt löschen?")) return
void api.fahrtLoeschen(fahrt.trip_id)
geheZu("trips")
}
if (bearbeitenOffen) {
return <FahrtBearbeiten fahrt={fahrt} beiFertig={() => setzeBearbeitenOffen(false)} />
}
const hatPositionen = fahrt.start_lat != null && fahrt.start_lon != null
/* Aufbau 1:1 wie vTrip(): Karte, dann die randlose Distanz-Kachel mit der
52px-Zahl und der Art-Pille darunter, dann eine einzige Wertezeilen-Kachel
(Start/Ziel/Start- und Endkilometer/Dauer/Verbrauch) mit „Werte
bearbeiten", zuletzt „Fahrt löschen".
Vorher stand hier eine eigene Kopfzeile mit Datumsbereich und
Bearbeiten-Textknopf, die Werte in anderer Reihenfolge und unter anderen
Namen („Kilometerstand Beginn/Ende"), eine Zeile „Durchschnitt", die es im
Panel nicht gibt, zwei zusätzliche Erklärabsätze und Start/Ziel erst ganz
unten unter der Karte. */
return (
<>
<Tile>
<div className="dm-abschnitt" style={{ marginBottom: 0 }}>
<span className="ads-eyebrow">
{datum(fahrt.ts_start)} · {uhrzeit(fahrt.ts_start)} bis {uhrzeit(fahrt.ts_end)} Uhr
</span>
<button type="button" className="dm-textknopf" onClick={() => setzeBearbeitenOffen(true)}>
Bearbeiten
</button>
</div>
<div className="dm-detail__kopf">
<div className="dm-detail__zahl dm-detail__zahl--gross">
{deOderStrich(fahrt.distance_km, 1)}
<span className="dm-detail__einheit">km</span>
</div>
<div className="dm-detail__marken">
<Pill>{fahrt.art === "arbeitsweg" ? "Arbeitsweg" : "Privat"}</Pill>
{fahrt.status === "offen" && <Pill>noch offen</Pill>}
{fahrt.source === "manual" && <Pill>selbst eingetragen</Pill>}
</div>
</div>
<Werteliste
kinder={
<>
<Wertzeile label="Dauer" wert={dauer(fahrt.duration_s)} />
<Wertzeile
label="Kilometerstand Beginn"
wert={fahrt.odo_start != null ? `${de(fahrt.odo_start)} km` : "—"}
/>
<Wertzeile
label="Kilometerstand Ende"
wert={fahrt.odo_end != null ? `${de(fahrt.odo_end)} km` : "—"}
/>
<Wertzeile
label="Durchschnitt"
wert={
fahrt.distance_km != null && fahrt.duration_s > 0
? `${de((fahrt.distance_km / fahrt.duration_s) * 3600, 1)} km/h`
: "—"
}
/>
<Wertzeile
label="Verbrauch"
wert={fahrt.verbrauch_l_100km != null ? `${de(fahrt.verbrauch_l_100km, 1)} l/100 km` : "liegt nicht vor"}
zusatz="Näherung aus Tankfüllstand"
/>
</>
}
/>
</Tile>
{fahrt.status === "offen" && (
<p className="dm-fussnote">
Diese Fahrt wartet noch auf den Kilometerstand. Das Fahrzeug meldet ihn oft erst bei der
nächsten Fahrt die Strecke wird dann automatisch ergänzt.
</p>
)}
<Tile>
<span className="ads-eyebrow">Strecke</span>
<Tile className="dm-kartenkachel">
{hatPositionen ? (
<Karte
start={{ lat: fahrt.start_lat!, lon: fahrt.start_lon! }}
@@ -123,6 +82,19 @@ export function FahrtDetail({ id }: { id: string | undefined }) {
text="Das Fahrzeug liefert derzeit keine Positionsdaten. Sobald es das tut, erscheint hier die gefahrene Strecke auf der Karte."
/>
)}
</Tile>
<Tile variant="flat" className="dm-distanzkachel">
<span className="ads-eyebrow">Distanz</span>
<div style={{ marginTop: "10px" }}>
<Fig value={deOderStrich(fahrt.distance_km)} unit="km" size={52} />
</div>
<div style={{ marginTop: "15px" }}>
<Pill>{fahrt.art === "arbeitsweg" ? "Arbeitsweg" : "Privat"}</Pill>
</div>
</Tile>
<Tile>
<Werteliste
kinder={
<>
@@ -136,16 +108,37 @@ export function FahrtDetail({ id }: { id: string | undefined }) {
wert={`${datum(fahrt.ts_end)} · ${uhrzeit(fahrt.ts_end)} Uhr`}
zusatz={fahrt.end_address ?? "unbekannt"}
/>
<Wertzeile
label="Startkilometer"
wert={fahrt.odo_start != null ? `${de(fahrt.odo_start)} km` : "noch offen"}
/>
<Wertzeile
label="Endkilometer"
wert={fahrt.odo_end != null ? `${de(fahrt.odo_end)} km` : "noch offen"}
/>
<Wertzeile label="Dauer" wert={dauer(fahrt.duration_s)} />
<Wertzeile
label="Verbrauch"
wert={
fahrt.verbrauch_l_100km != null
? `${de(fahrt.verbrauch_l_100km, 1)} l/100 km`
: "liegt nicht vor"
}
zusatz="Näherung aus Tankfüllstand"
/>
</>
}
/>
<ActionButton onClick={() => setzeBearbeitenOffen(true)}>Werte bearbeiten</ActionButton>
</Tile>
<ActionButton variant="destructive" onClick={() => void loeschen()}>
Fahrt löschen
</ActionButton>
</>
)
}
/** Bearbeiten-Formular für eine bestehende Fahrt - dieselbe Feldgruppe wie
beim Neuanlegen (FahrtFelder.tsx), vorbelegt mit den aktuellen Werten. */
function FahrtBearbeiten({ fahrt, beiFertig }: { fahrt: Fahrt; beiFertig: () => void }) {
const { api } = useDaten()
const [start, setzeStart] = useState(alsDatetimeLocal(fahrt.ts_start))
+88 -74
View File
@@ -5,13 +5,13 @@
import { useState } from "react"
import { Accordion, ActionButton, Feld, Pill, Tile } from "@audi-dash/ui"
import { Accordion, ActionButton, Feld, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { nachJahrUndMonat } from "../daten/statistik"
import { datum, dauer, de, deOderStrich, monatJahr, summe, uhrzeit } from "../format"
import { datum, de, summe } from "../format"
import type { SeitenName } from "../navigation"
import { Leerzustand } from "./bausteine"
import { Blattzeile, Leerzustand } from "./bausteine"
import { FahrtFelderFormular, LEERE_FAHRT_FELDER, fahrtFelderAuswerten } from "./FahrtFelder"
import type { FahrtFelderEingabe } from "./FahrtFelder"
import { Zeilenmenue } from "./Zeilenmenue"
@@ -28,88 +28,104 @@ function strecke(fahrt: { start_address?: string | null; end_address?: string |
return `${a}${b}`
}
/** artText() im Panel: erster Buchstabe groß, sonst unverändert. */
function artText(art: string | null | undefined): string {
return art ? art.charAt(0).toUpperCase() + art.slice(1) : ""
}
export function Fahrten({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
const { fahrten, api } = useDaten()
const [formularOffen, setzeFormularOffen] = useState(false)
const gruppen = nachJahrUndMonat(fahrten, (f) => f.ts_start)
/* Aufbau 1:1 wie vTrips(): eine einzige Kachel um alle Jahre, die
Fahrtenzahl steht in der Jahreszeile (nicht als eigene Überschrift über
der Liste), die Monatszeile nennt nur den Monat, und das Formular steht
unter der Liste statt darüber. Die Zeilen selbst sind Blattzeilen mit der
Strecke als Hauptangabe und "Datum · Art" darunter — hier standen bisher
die Kilometer vorn und alles andere in einer langen Fußzeile, dazu zwei
Pillen (Arbeitsweg/offen), die es im Panel nicht gibt: die Art steht dort
im Zusatz der Beschriftung, der Status im Zusatz des Werts. */
return (
<>
<div className="dm-abschnitt">
<span className="ads-eyebrow">
{fahrten.length === 0
? "Keine Fahrten"
: `${de(fahrten.length)} Fahrt${fahrten.length === 1 ? "" : "en"}`}
</span>
<button
type="button"
className="dm-textknopf"
onClick={() => setzeFormularOffen((offen) => !offen)}
>
{formularOffen ? "Abbrechen" : "Fahrt eintragen"}
</button>
</div>
{formularOffen && <FahrtFormular beiFertig={() => setzeFormularOffen(false)} />}
{fahrten.length === 0 && !formularOffen && (
<Leerzustand
titel="Noch keine Fahrten"
text="Die App erkennt Fahrten automatisch, sobald sich das Fahrzeug meldet. Bis dahin kannst du Fahrten auch selbst eintragen."
aktion={{ beschriftung: "Fahrt eintragen", beiKlick: () => setzeFormularOffen(true) }}
/>
)}
{gruppen.map((jahr) => (
<Accordion
key={jahr.jahr}
title={String(jahr.jahr)}
summary={`${de(summe(jahr.eintraege, (f) => f.distance_km ?? 0))} km`}
defaultOpen={jahr === gruppen[0]}
>
{jahr.monate.map((monat) => (
{gruppen.length === 0 ? (
<Tile>
<Leerzustand
titel="Noch keine Fahrten"
text="Fahrten werden erkannt, sobald der Motor gestartet wird. Bis dahin lässt sich jede Fahrt von Hand erfassen."
/>
</Tile>
) : (
<Tile>
{gruppen.map((jahr) => (
<Accordion
key={monat.schluessel}
level={2}
title={monatJahr(new Date(monat.jahr, monat.monat, 1))}
summary={`${de(summe(monat.eintraege, (f) => f.distance_km ?? 0))} km`}
defaultOpen={monat === jahr.monate[0]}
key={jahr.jahr}
title={String(jahr.jahr)}
summary={
<>
{de(summe(jahr.eintraege, (f) => f.distance_km ?? 0))} km
<br />
{jahr.eintraege.length} Fahrt{jahr.eintraege.length === 1 ? "" : "en"}
</>
}
defaultOpen={jahr === gruppen[0]}
>
{monat.eintraege.map((fahrt) => (
<Zeilenmenue
key={fahrt.trip_id}
loeschFrage={`Fahrt vom ${datum(fahrt.ts_start)} löschen?`}
beiLoeschen={() => void api.fahrtLoeschen(fahrt.trip_id)}
kinder={
<button
type="button"
className="dm-listenzeile"
onClick={() => geheZu("trip", fahrt.trip_id)}
>
<span className="dm-listenzeile__haupt">
<span className="dm-listenzeile__titel">
{deOderStrich(fahrt.distance_km, 1)} km
</span>
<span className="dm-listenzeile__unter">
{strecke(fahrt)} · {datum(fahrt.ts_start)} · {uhrzeit(fahrt.ts_start)} Uhr ·{" "}
{dauer(fahrt.duration_s)}
</span>
</span>
{fahrt.art === "arbeitsweg" && <Pill>Arbeitsweg</Pill>}
{fahrt.status === "offen" && <Pill>offen</Pill>}
</button>
}
/>
{jahr.monate.map((monat) => (
<Accordion
key={monat.schluessel}
level={2}
title={monatName(monat.jahr, monat.monat)}
summary={`${de(summe(monat.eintraege, (f) => f.distance_km ?? 0))} km`}
defaultOpen={monat === jahr.monate[0]}
>
{monat.eintraege.map((fahrt, i) => (
<Zeilenmenue
key={fahrt.trip_id}
loeschFrage={`Fahrt vom ${datum(fahrt.ts_start)} löschen?`}
beiLoeschen={() => void api.fahrtLoeschen(fahrt.trip_id)}
kinder={
<Blattzeile
label={strecke(fahrt)}
labelZusatz={`${datum(fahrt.ts_start)} · ${artText(fahrt.art)}`}
wert={
fahrt.distance_km != null ? `${de(fahrt.distance_km)} km` : "offen"
}
wertZusatz={
fahrt.verbrauch_l_100km != null
? `${de(fahrt.verbrauch_l_100km, 1)} l/100 km`
: fahrt.status
}
beiKlick={() => geheZu("trip", fahrt.trip_id)}
letzte={i === monat.eintraege.length - 1}
/>
}
/>
))}
</Accordion>
))}
</Accordion>
))}
</Accordion>
))}
</Tile>
)}
{formularOffen && <FahrtFormular beiFertig={() => setzeFormularOffen(false)} />}
<ActionButton
variant={formularOffen ? "default" : "primary"}
onClick={() => setzeFormularOffen((offen) => !offen)}
>
{formularOffen ? "Abbrechen" : "Fahrt erfassen"}
</ActionButton>
</>
)
}
/** Monatsname allein, wie im Panel (`toLocaleDateString({month:"long"})`). */
function monatName(jahr: number, monat: number): string {
return new Date(jahr, monat, 1).toLocaleDateString("de-DE", { month: "long" })
}
/** Manuelles Eintragen — für Fahrten, die die automatische Erkennung verpasst hat.
Dieselben Felder wie beim Bearbeiten einer bestehenden Fahrt (siehe
FahrtFelderFormular in FahrtDetail.tsx) - beide teilen sich deshalb die
@@ -149,7 +165,7 @@ function FahrtFormular({ beiFertig }: { beiFertig: () => void }) {
return (
<Tile>
<span className="ads-eyebrow">Fahrt eintragen</span>
<span className="ads-eyebrow">Neue Fahrt</span>
<Feld label="Beginn">
<input
className="dm-eingabe"
@@ -172,12 +188,10 @@ function FahrtFormular({ beiFertig }: { beiFertig: () => void }) {
{fehler}
</p>
)}
<p className="dm-fussnote">
Die Strecke ergänzt die App selbst, sobald sie den Kilometerstand zu Beginn und Ende der
Fahrt kennt.
</p>
{/* Ohne Erklärabsatz: das Panel führt im Neuanlage-Formular nur die
Felder und die Knöpfe. */}
<div style={{ marginTop: 14 }}>
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
<ActionButton variant="primary" onClick={() => void speichern()} disabled={laeuft}>
{laeuft ? "Speichere …" : "Speichern"}
</ActionButton>
</div>
+245 -52
View File
@@ -1,13 +1,20 @@
/**
* Fahrzeugdaten: Identität, technisches Datenblatt, Ausstattung.
* Vorlage: `vIdent()` im alten Panel. Rein anzeigend — die Inhalte pflegt
* man im Profil, nicht hier.
* Vorlage: `vIdent()`/`vIdentBearbeiten()` im alten Panel.
*
* „Daten bearbeiten" öffnet dieselbe Bearbeitungsansicht wie dort: die Werte
* des Datenblatts und die Ausstattungslisten, jeweils mit „×" zum Entfernen
* und „+ Position hinzufügen". Die Kopfdaten (Modell, Kennzeichen, Ausführung,
* Erstzulassung, FIN) bleiben bewusst außen vor — die pflegt „Fahrzeug
* einrichten" in den Einstellungen.
*/
import { Accordion, Tile } from "@audi-dash/ui"
import { useState } from "react"
import { ActionButton, Feld, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { datum, de } from "../format"
import { datum } from "../format"
import { Wertzeile, Werteliste } from "./bausteine"
interface TechnikGruppe {
@@ -23,6 +30,7 @@ interface AusstattungGruppe {
export function Fahrzeugdaten() {
const { fahrzeug, einstellungen } = useDaten()
const [bearbeiten, setzeBearbeiten] = useState(false)
if (!fahrzeug || !einstellungen) return null
const technik = Array.isArray(fahrzeug.technik)
@@ -32,72 +40,257 @@ export function Fahrzeugdaten() {
? (fahrzeug.ausstattung as AusstattungGruppe[])
: []
if (bearbeiten) {
return (
<DatenBearbeiten
technik={technik}
ausstattung={ausstattung}
beiFertig={() => setzeBearbeiten(false)}
/>
)
}
/* Aufbau 1:1 wie vIdent(): eine Kachel je Technik- und je
Ausstattungsgruppe, alle Angaben unmittelbar sichtbar. Bis 2026-08-30
steckten sie hier in zugeklappten Aufklapp-Elementen — auf dem Bildschirm
blieben von über 3 000 Zeichen Datenblatt nur die Gruppennamen übrig.
Auch die Kopfkachel wich ab: sie hieß „Identität" statt „Fahrzeug", zeigte
„Hauptuntersuchung fällig" und „Kilometerstand", die das Panel hier nicht
führt, und ließ „Motor und Leistung" sowie „Zulassung" weg. */
return (
<>
<Tile>
<span className="ads-eyebrow">Identität</span>
<span className="ads-eyebrow">Fahrzeug</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="Modell" wert={einstellungen.fahrzeugtitel || ""} />
<Wertzeile
label="Ausführung"
wert={einstellungen.ausfuehrung || "noch offen"}
/>
<Wertzeile label="Fahrgestellnummer" wert={fahrzeug.fin || ""} />
<Wertzeile label="Motor und Leistung" wert={fahrzeug.details || ""} />
<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` : "—"}
wert={fahrzeug.erstzulassung ? datum(fahrzeug.erstzulassung) : ""}
/>
<Wertzeile label="Kennzeichen" wert={einstellungen.kennzeichen || ""} />
<Wertzeile label="Zulassung" wert="Deutschland" />
</>
}
/>
</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} />
))}
{technik.map((gruppe, i) => (
<Tile key={gruppe.gruppe ?? i}>
<span className="ads-eyebrow">{gruppe.gruppe ?? "Weitere Angaben"}</span>
<Werteliste
kinder={(gruppe.posten ?? []).map(([bezeichnung, wert]) => (
<Wertzeile
key={bezeichnung}
label={bezeichnung}
wert={wert ? wert : <span className="dm-offen">noch offen</span>}
/>
</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>
))}
{ausstattung.map((gruppe, i) => (
<Tile key={gruppe.gruppe ?? i}>
<span className="ads-eyebrow">{gruppe.gruppe ?? "Weitere Ausstattung"}</span>
<Werteliste
kinder={(gruppe.sonder ?? []).map((posten) => (
<Wertzeile key={posten} label={posten} wert="" />
))}
/>
</Tile>
)}
))}
<ActionButton onClick={() => setzeBearbeiten(true)}>Daten bearbeiten</ActionButton>
</>
)
}
/** Bearbeitungsansicht, Zuschnitt wie `vIdentBearbeiten()` im Panel. */
function DatenBearbeiten({
technik,
ausstattung,
beiFertig,
}: {
technik: TechnikGruppe[]
ausstattung: AusstattungGruppe[]
beiFertig: () => void
}) {
const { fahrzeug, profilSpeichern } = useDaten()
const [entwurfTechnik, setzeEntwurfTechnik] = useState<TechnikGruppe[]>(() =>
technik.map((g) => ({ ...g, posten: (g.posten ?? []).map((p) => [p[0], p[1]] as [string, string]) })),
)
const [entwurfAusstattung, setzeEntwurfAusstattung] = useState<AusstattungGruppe[]>(() =>
ausstattung.map((g) => ({ ...g, sonder: [...(g.sonder ?? [])] })),
)
const [laeuft, setzeLaeuft] = useState(false)
if (!fahrzeug) return null
/* Das Panel speichert jedes Feld sofort; hier folgt der Bildschirm dem
Muster der übrigen Formulare dieser App — Entwurf plus einem Knopf, der
schreibt und zurückführt (Paritätsregel: gleiches Verhalten, eigenes
Idiom). Bei rund 50 Feldern spart das ebenso viele Schreibvorgänge auf
das gesamte Profil. */
const speichern = async () => {
setzeLaeuft(true)
try {
await profilSpeichern({
fahrzeug: {
...fahrzeug,
technik: entwurfTechnik as unknown as typeof fahrzeug.technik,
ausstattung: entwurfAusstattung as unknown as typeof fahrzeug.ausstattung,
},
})
beiFertig()
} finally {
setzeLaeuft(false)
}
}
return (
<>
<Tile>
<span className="ads-eyebrow">Daten bearbeiten</span>
<span className="ads-eyebrow" style={{ marginTop: "10px" }}>
Hier stehen das technische Datenblatt und die Ausstattung. Modell, Kennzeichen,
Ausführung, Erstzulassung und Fahrgestellnummer pflegst du unter Einstellungen Fahrzeug
einrichten.
</span>
</Tile>
{entwurfTechnik.map((gruppe, gi) => (
<Tile key={gruppe.gruppe ?? gi}>
<span className="ads-eyebrow">{gruppe.gruppe ?? "Weitere Angaben"}</span>
{(gruppe.posten ?? []).map((posten, i) => (
<Feld key={i} label={posten[0]} last={i === (gruppe.posten?.length ?? 0) - 1}>
<span className="dm-selbst-zeile">
<input
className="dm-eingabe"
placeholder="noch offen"
value={posten[1]}
onChange={(e) => {
const wert = e.target.value
setzeEntwurfTechnik((alt) =>
alt.map((g, gj) =>
gj !== gi
? g
: {
...g,
posten: (g.posten ?? []).map((p, pj) =>
pj === i ? ([p[0], wert] as [string, string]) : p,
),
},
),
)
}}
/>
<button
type="button"
className="dm-zeile-loeschen"
aria-label={`${posten[0]} entfernen`}
onClick={() =>
setzeEntwurfTechnik((alt) =>
alt.map((g, gj) =>
gj !== gi
? g
: { ...g, posten: (g.posten ?? []).filter((_, pj) => pj !== i) },
),
)
}
>
×
</button>
</span>
</Feld>
))}
{(gruppe.posten?.length ?? 0) === 0 && (
<span className="ads-eyebrow" style={{ marginTop: "8px" }}>
Keine Angaben
</span>
)}
<ActionButton
onClick={() =>
setzeEntwurfTechnik((alt) =>
alt.map((g, gj) =>
gj !== gi
? g
: { ...g, posten: [...(g.posten ?? []), ["Neue Angabe", ""] as [string, string]] },
),
)
}
>
+ Position hinzufügen
</ActionButton>
</Tile>
))}
{entwurfAusstattung.map((gruppe, gi) => (
<Tile key={gruppe.gruppe ?? gi}>
<span className="ads-eyebrow">{gruppe.gruppe ?? "Weitere Ausstattung"}</span>
{(gruppe.sonder ?? []).map((posten, i) => (
<Feld key={i} label={`Position ${i + 1}`} last={i === (gruppe.sonder?.length ?? 0) - 1}>
<span className="dm-selbst-zeile">
<input
className="dm-eingabe"
value={posten}
onChange={(e) => {
const wert = e.target.value
setzeEntwurfAusstattung((alt) =>
alt.map((g, gj) =>
gj !== gi
? g
: { ...g, sonder: (g.sonder ?? []).map((s, sj) => (sj === i ? wert : s)) },
),
)
}}
/>
<button
type="button"
className="dm-zeile-loeschen"
aria-label={`Position ${i + 1} entfernen`}
onClick={() =>
setzeEntwurfAusstattung((alt) =>
alt.map((g, gj) =>
gj !== gi
? g
: { ...g, sonder: (g.sonder ?? []).filter((_, sj) => sj !== i) },
),
)
}
>
×
</button>
</span>
</Feld>
))}
{(gruppe.sonder?.length ?? 0) === 0 && (
<span className="ads-eyebrow" style={{ marginTop: "8px" }}>
Keine Ausstattung hinterlegt
</span>
)}
<ActionButton
onClick={() =>
setzeEntwurfAusstattung((alt) =>
alt.map((g, gj) => (gj !== gi ? g : { ...g, sonder: [...(g.sonder ?? []), ""] })),
)
}
>
+ Position hinzufügen
</ActionButton>
</Tile>
))}
<ActionButton variant="primary" onClick={() => void speichern()} disabled={laeuft}>
{laeuft ? "Speichere …" : "Fertig"}
</ActionButton>
</>
)
}
+1 -1
View File
@@ -69,7 +69,7 @@ export function LiveFahrt() {
<>
<Wertzeile
label="Bisher gefahren"
wert={live.streckeKm != null ? `${de(live.streckeKm, 1)} km` : ""}
wert={live.streckeKm != null ? `${de(live.streckeKm, 1)} km` : ""}
/>
<Wertzeile label="Unterwegs seit" wert={dauer(live.dauerS)} />
</>
+70 -27
View File
@@ -18,7 +18,7 @@ import { useState } from "react"
import { Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { naechsterService } from "../daten/service"
import { inspektionPrognose, oelwechselPrognose } from "../daten/service"
import { datum, de, deOderStrich, eur } from "../format"
import type { SeitenName } from "../navigation"
import { Wertzeile, Werteliste } from "./bausteine"
@@ -34,7 +34,7 @@ interface AusstattungGruppe {
}
export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
const { fahrzeug, einstellungen, fahrten } = useDaten()
const { fahrzeug, einstellungen } = useDaten()
const [bildIndex, setzeBildIndex] = useState(0)
if (!fahrzeug || !einstellungen) return null
@@ -48,13 +48,46 @@ export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) =
const positionen = ausstattung.reduce((n, g) => n + (g.sonder?.length ?? 0), 0)
const buch = (fahrzeug.service["buch"] ?? []) as ServicebuchEintrag[]
// Vereinfacht gegenüber vAudi() im Panel, das hier die volle
// Drei-Termine-Liste (Ölwechsel/Inspektion/Hauptuntersuchung) zeigt: nur
// der zeitlich nächste Termin (dieselbe Funktion, die schon die
// Übersicht-Kachel speist). Die volle Aufschlüsselung bleibt der
// Service-Seite selbst vorbehalten — bewusste Vereinfachung, nicht
// vergessen, siehe AGENTS.md.
const service = naechsterService(fahrzeug, buch, fahrten)
/* Alle drei Termine, wie vAudi() im Panel. Bis 2026-08-30 stand hier nur
der zeitlich nächste (die Vereinfachung aus Kapitel 2 des ersten Audits) —
Inspektion und Hauptuntersuchung fehlten damit ganz.
Der Wert ist bewusst die reale Fahrzeugmeldung (Datum plus „in X km"),
nicht die App-Prognose; fehlt sie, bleibt ein Strich. Die eigene Prognose
steht klein unter dem Namen (prognoseZeile() dort). */
const oelProg = oelwechselPrognose(fahrzeug, buch)
const inspProg = inspektionPrognose(fahrzeug, buch)
const termine: { art: string; prognose: string | null; haupt: string; neben: string | null }[] = [
{
art: "Ölwechsel",
prognose: oelProg
? `${de(oelProg.restKm)} km, voraussichtlich am ${datum(oelProg.datum)}`
: null,
haupt: fahrzeug.oelwechselFaelligTs ? datum(fahrzeug.oelwechselFaelligTs) : "",
neben:
fahrzeug.oelwechselFaelligKm != null
? `in ${de(Math.abs(fahrzeug.oelwechselFaelligKm))} km`
: null,
},
{
art: "Inspektion",
prognose: inspProg ? `voraussichtlich am ${datum(inspProg.datum)}` : null,
haupt: fahrzeug.inspektionFaelligTs ? datum(fahrzeug.inspektionFaelligTs) : "",
neben:
fahrzeug.inspektionFaelligKm != null
? `in ${de(Math.abs(fahrzeug.inspektionFaelligKm))} km`
: null,
},
{
// Die Hauptuntersuchung meldet das Fahrzeug nie — hier zählt allein das
// im Profil hinterlegte Datum.
art: "Hauptuntersuchung",
prognose: null,
haupt: fahrzeug.hu ? datum(fahrzeug.hu) : "",
neben: null,
},
]
const schild = schildErkennen(einstellungen.fahrzeugtitel)
const ausfuehrung = einstellungen.ausfuehrung.trim()
@@ -76,7 +109,7 @@ export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) =
onClick={() => setzeBildIndex((i) => (i + 1) % BILDPLAETZE.length)}
aria-label="Nächstes Bild"
>
<Bild datei={platz.datei} beschreibung={platz.beschreibung} name={platz.name} />
<Bild datei={platz.datei} beschreibung={platz.beschreibung} name={platz.beschreibung} />
</button>
<div className="dm-galerie-punkte" aria-hidden="true">
{BILDPLAETZE.map((b, i) => (
@@ -86,7 +119,7 @@ export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) =
<Tile variant="flat" className="dm-szene__text">
<div className="dm-szene__marke">
<Typenschild modell={einstellungen.fahrzeugtitel} hoehe={26} />
<Typenschild modell={einstellungen.fahrzeugtitel} hoehe={18} />
<div className="dm-szene__zusatz">
{schild ? (
zusatz && <span>{zusatz}</span>
@@ -107,9 +140,11 @@ export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) =
<Werteliste
kinder={
<>
<Wertzeile label="Fahrgestellnummer" wert={fahrzeug.fin || ""} />
<Wertzeile label="Details" wert={fahrzeug.details || ""} />
<Wertzeile label="Erstzulassung" wert={fahrzeug.erstzulassung || "—"} />
<Wertzeile label="Fahrgestellnummer" wert={fahrzeug.fin || ""} />
<Wertzeile label="Details" wert={fahrzeug.details || ""} />
{/* Leerer Wert statt Strich, wie vAudi(): das Panel gibt hier
den Rohwert aus. */}
<Wertzeile label="Erstzulassung" wert={fahrzeug.erstzulassung} />
<Wertzeile label="Ausführung" wert={ausfuehrung || "noch offen"} />
<Wertzeile label="Ausstattung" wert={`${positionen} Positionen`} />
</>
@@ -132,11 +167,10 @@ export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) =
/>
<button type="button" className="dm-wertzeile dm-wertzeile--knopf" onClick={() => geheZu("battverlauf")}>
<dt>Batteriespannung</dt>
{/* Ohne Chevron: das Panel gibt der Zeile nur den
Zeiger-Cursor, kein eigenes Pfeilsymbol. */}
<dd>
{fahrzeug.batteriespannung != null ? `${de(fahrzeug.batteriespannung, 1)} V` : "unbekannt"}
<span className="dm-wertzeile__chevron" aria-hidden="true">
</span>
</dd>
</button>
</>
@@ -148,15 +182,24 @@ export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) =
<span className="ads-eyebrow">Service</span>
<Werteliste
kinder={
service ? (
termine.map((t) => (
<Wertzeile
label={service.art}
wert={service.restKm !== null ? `${de(service.restKm)} km` : datum(service.datum)}
zusatz={service.restKm !== null ? `voraussichtlich am ${datum(service.datum)}` : undefined}
key={t.art}
label={
<>
{t.art}
{t.prognose && (
<>
<br />
<span className="dm-beitragsposten__sf">{t.prognose}</span>
</>
)}
</>
}
wert={t.haupt}
{...(t.neben ? { zusatz: t.neben } : {})}
/>
) : (
<Wertzeile label="Nächster Termin" wert="kein Eintrag im Wartungsplan" />
)
))
}
/>
</Tile>
@@ -168,12 +211,12 @@ export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) =
<>
<Wertzeile
label="Versicherung"
wert={fahrzeug.versicherung["beitrag"] ? `${eur(fahrzeug.versicherung["beitrag"] as number)}` : ""}
wert={fahrzeug.versicherung["beitrag"] ? `${eur(fahrzeug.versicherung["beitrag"] as number)}` : ""}
zusatz="im Jahr"
/>
<Wertzeile
label="Kfz-Steuer"
wert={fahrzeug.steuer["betrag"] ? `${eur(fahrzeug.steuer["betrag"] as number)}` : ""}
wert={fahrzeug.steuer["betrag"] ? `${eur(fahrzeug.steuer["betrag"] as number)}` : ""}
zusatz={fahrzeug.steuer["zeitraum"] === "jährlich" ? "im Jahr" : (fahrzeug.steuer["zeitraum"] as string)}
/>
<Wertzeile
@@ -192,7 +235,7 @@ export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) =
<>
<Wertzeile
label={`montiert: ${fahrzeug.reifen.aktiv}räder`}
wert={(aktiverSatz["mass"] as string) || ""}
wert={(aktiverSatz["mass"] as string) || ""}
/>
<Wertzeile label="Laufleistung" wert={deOderStrich(aktiverSatz.km) + " km"} />
<Wertzeile
+4 -4
View File
@@ -491,15 +491,15 @@ function ArchivZeile({ eintrag }: { eintrag: ReifenArchivEintrag }) {
<Werteliste
kinder={
<>
<Wertzeile label="Größe" wert={eintrag.mass || ""} />
<Wertzeile label="Größe" wert={eintrag.mass || ""} />
<Wertzeile
label="Marke und Modell"
wert={[eintrag.marke, eintrag.modell].filter(Boolean).join(" ") || ""}
wert={[eintrag.marke, eintrag.modell].filter(Boolean).join(" ") || ""}
/>
<Wertzeile label="DOT" wert={eintrag.dot || ""} />
<Wertzeile label="DOT" wert={eintrag.dot || ""} />
<Wertzeile
label="Luftdruck"
wert={`${eintrag.druck_vorne || ""} / ${eintrag.druck_hinten || ""} bar`}
wert={`${eintrag.druck_vorne || ""} / ${eintrag.druck_hinten || ""} bar`}
zusatz="vorne / hinten"
/>
{Boolean(eintrag.kommentar) && <Wertzeile label="Notiz" wert={eintrag.kommentar!} />}
+264 -160
View File
@@ -10,7 +10,7 @@
import { useRef, useState } from "react"
import { ActionButton, Feld, Tile } from "@audi-dash/ui"
import { ActionButton, Feld, Fig, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import {
@@ -24,7 +24,12 @@ import {
import { datum, de, eur, isoTag } from "../format"
import type { SeitenName } from "../navigation"
import { SymbolInspektion, SymbolOelwechsel } from "../symbole"
import { Leerzustand, NaviKachel, Wertzeile, Werteliste, bestaetigen } from "./bausteine"
import {
Blattzeile,
Leerzustand,
Zahnradknopf,
bestaetigen,
} from "./bausteine"
import { kalenderDateiLaden } from "./kalender"
import { vcardParsen } from "./vcard"
@@ -172,40 +177,141 @@ export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
)}
</div>
{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 Eintrag der jeweiligen Art im Wartungsplan neu
und nutzt die seither tatsächlich gefahrene Strecke. Sie ersetzt die Meldung des
Fahrzeugs nicht, sondern ergänzt sie.
</p>
{/* Kein Kalenderknopf und keine Erklärung in dieser Kachel: das Panel
führt beides in "Termin vereinbaren" darunter. */}
</Tile>
<div className="dm-raster">
<NaviKachel
titel="Werkstatt"
wert={autohaus.name || "Noch nicht hinterlegt"}
beiKlick={() => geheZu("werkstatt")}
/>
<NaviKachel
titel="Wartungsplan"
wert={`${de(buch.length)} Eintrag${buch.length === 1 ? "" : "e"}`}
beiKlick={() => geheZu("sbuch")}
/>
</div>
<TerminVereinbaren />
{/* Statt zweier Navigationskacheln zeigt das Panel Autohaus und
Wartungsplan hier vollständig — mit Anschrift, Rufnummer, E-Mail und
Kartenlink bzw. der Liste der Einträge. */}
<Tile className="dm-kachel--zahnrad">
<span className="ads-eyebrow">Autohaus</span>
<Zahnradknopf beschriftung="Angaben ändern" beiKlick={() => geheZu("werkstatt")} />
<div className="dm-autohaus__name">{autohaus.name ?? ""}</div>
<span className="ads-eyebrow" style={{ marginTop: "8px" }}>
{autohaus.adresse ?? ""}
</span>
{autohaus.tel && (
<a
className="ads-action dm-waehlknopf"
href={`tel:${autohaus.tel.replace(/\s/g, "")}`}
style={{ marginTop: "16px" }}
>
{autohaus.tel}
</a>
)}
{autohaus.mail && (
<a className="ads-action dm-waehlknopf" href={`mailto:${autohaus.mail}`}>
E-Mail schreiben
</a>
)}
<a
className="ads-action dm-waehlknopf"
target="_blank"
rel="noopener"
href={`https://maps.google.com/?q=${encodeURIComponent(autohaus.adresse ?? "")}`}
>
In Karten öffnen
</a>
</Tile>
<Tile>
<span className="ads-eyebrow">Wartungsplan</span>
{buch.length > 0 ? (
buch.map((eintrag, i) => (
<Blattzeile
key={`${eintrag.datum}-${i}`}
label={eintrag.art ?? ""}
labelZusatz={eintrag.werkstatt ?? ""}
wert={eintrag.datum ?? ""}
wertZusatz={`${de(eintrag.km ?? 0)} km${
eintrag.kosten != null ? ` · ${eur(eintrag.kosten)}` : ""
}`}
beiKlick={() => geheZu("sbuch", String(i))}
letzte={i === buch.length - 1}
/>
))
) : (
<Leerzustand
titel="Noch keine Einträge"
text="Aus dem Wartungsplan berechnet die App die Fälligkeit von Inspektion, Ölwechsel und Hauptuntersuchung. Der erste Eintrag ist die Grundlage für jede Prognose."
/>
)}
<ActionButton variant="primary" onClick={() => geheZu("sbuch", "neu")}>
Eintrag hinzufügen
</ActionButton>
</Tile>
</>
)
}
/** `Termin vereinbaren` aus vService(): Art wählen, Datum setzen, ICS laden. */
function TerminVereinbaren() {
const { fahrzeug, profilSpeichern } = useDaten()
const [art, setzeArt] = useState("Ölwechsel")
const vereinbart = (fahrzeug?.service["vereinbart"] ?? {}) as Record<string, string>
const [datumWert, setzeDatumWert] = useState(() => vereinbart[art] ?? "")
if (!fahrzeug) return null
const merken = async (neuesDatum: string) => {
setzeDatumWert(neuesDatum)
await profilSpeichern({
fahrzeug: {
...fahrzeug,
service: { ...fahrzeug.service, vereinbart: { ...vereinbart, [art]: neuesDatum } },
},
})
}
const verwerfen = async () => {
setzeDatumWert("")
const rest = { ...vereinbart }
delete rest[art]
await profilSpeichern({
fahrzeug: { ...fahrzeug, service: { ...fahrzeug.service, vereinbart: rest } },
})
}
return (
<Tile>
<span className="ads-eyebrow">Termin vereinbaren</span>
<Feld label="Wofür">
<select
className="dm-auswahl"
value={art}
onChange={(e) => {
setzeArt(e.target.value)
setzeDatumWert(vereinbart[e.target.value] ?? "")
}}
>
{["Ölwechsel", "Inspektion", "Hauptuntersuchung"].map((x) => (
<option key={x} value={x}>
{x}
</option>
))}
</select>
</Feld>
<Feld label="Werkstatttermin am" last>
<input
className="dm-eingabe"
type="date"
value={datumWert}
onChange={(e) => void merken(e.target.value)}
/>
</Feld>
<ActionButton
onClick={() => kalenderDateiLaden(art, datumWert || isoTag(), `${art} in der Werkstatt`)}
>
In den Kalender übernehmen
</ActionButton>
{vereinbart[art] && <ActionButton onClick={() => void verwerfen()}>Termin verwerfen</ActionButton>}
</Tile>
)
}
export function Werkstatt() {
const { fahrzeug, profilSpeichern } = useDaten()
const autohausRoh = (fahrzeug?.service["autohaus"] ?? {}) as Autohaus
@@ -268,7 +374,7 @@ export function Werkstatt() {
return (
<Tile>
<span className="ads-eyebrow">Werkstatt</span>
<span className="ads-eyebrow">Angaben zum Autohaus</span>
{feld("name", "Name")}
{feld("adresse", "Adresse")}
{feld("tel", "Telefon")}
@@ -296,14 +402,40 @@ export function Werkstatt() {
)
}
export function Servicebuch() {
/** Arten wie SERVICE_ARTEN im Panel. */
const SERVICE_ARTEN = ["Ölwechsel", "Inspektion", "Hauptuntersuchung", "Reparatur", "Sonstiges"]
/**
* Ein einzelner Wartungsplan-Eintrag. Vorlage: `vSbuch()` — dort ist diese
* Route immer die Detail-/Bearbeitungsseite genau eines Eintrags; die Liste
* steht auf der Service-Seite. Hier war es bis 2026-08-30 umgekehrt: die Route
* zeigte die ganze Liste samt Anlegen-Formular, und die Service-Seite nur eine
* Navigationskachel mit der Anzahl.
*
* `id === "neu"` legt einen leeren Entwurf an — im Panel entspricht das dem
* Weg über „Eintrag hinzufügen", der dort einen Entwurf erzeugt und sofort auf
* diese Seite springt.
*/
export function Servicebuch({
id,
geheZu,
}: {
id?: string | undefined
geheZu: (name: SeitenName, id?: string) => void
}) {
const { fahrzeug, profilSpeichern } = useDaten()
const [neuOffen, setzeNeuOffen] = useState(false)
const [entwurf, setzeEntwurf] = useState<ServicebuchEintrag>({ datum: isoTag(), art: "Ölwechsel" })
const istNeu = id === "neu"
const index = istNeu ? -1 : Number(id ?? -1)
const buchRoh = (fahrzeug?.service["buch"] ?? []) as ServicebuchEintrag[]
const [entwurf, setzeEntwurf] = useState<ServicebuchEintrag>(() =>
istNeu
? { datum: isoTag(), art: "Ölwechsel", km: fahrzeug?.odo ?? 0, werkstatt: "" }
: { ...(buchRoh[index] ?? {}) },
)
const [laeuft, setzeLaeuft] = useState(false)
if (!fahrzeug) return null
const buch = (fahrzeug.service["buch"] ?? []) as ServicebuchEintrag[]
if (!istNeu && !buchRoh[index]) return <Tile>Eintrag nicht gefunden</Tile>
const schreiben = async (neuesBuch: ServicebuchEintrag[]) => {
setzeLaeuft(true)
@@ -316,140 +448,112 @@ export function Servicebuch() {
}
}
const hinzufuegen = async () => {
await schreiben(
[...buch, entwurf].sort((a, b) => String(b.datum ?? "").localeCompare(String(a.datum ?? ""))),
)
setzeNeuOffen(false)
setzeEntwurf({ datum: isoTag(), art: "Ölwechsel" })
/* Das Panel speichert jedes Feld sofort; diese App folgt ihrem eigenen
Muster aus Entwurf plus Speichern-Knopf (Paritätsregel: gleiches
Verhalten, eigenes Idiom). */
const speichern = async () => {
await schreiben(istNeu ? [...buchRoh, entwurf] : buchRoh.map((e, i) => (i === index ? entwurf : e)))
geheZu("service")
}
const entfernen = async (index: number) => {
const loeschen = async () => {
if (!bestaetigen("Diesen Wartungsplan-Eintrag löschen?")) return
await schreiben(buch.filter((_, i) => i !== index))
await schreiben(buchRoh.filter((_, i) => i !== index))
geheZu("service")
}
const setze = (feld: keyof ServicebuchEintrag, wert: unknown) =>
setzeEntwurf((alt) => ({ ...alt, [feld]: wert }))
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>
<Tile>
<span className="ads-eyebrow">{entwurf.art ?? "Eintrag"}</span>
<div style={{ marginTop: "var(--sp-3)" }}>
<Fig value={de(entwurf.km ?? 0)} unit="km" size={40} />
</div>
<span className="ads-eyebrow" style={{ marginTop: "var(--sp-3)" }}>
{entwurf.datum ?? ""} · {entwurf.werkstatt ?? ""}
</span>
</Tile>
{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="Werkstatt">
<input
className="dm-eingabe"
value={entwurf.werkstatt ?? ""}
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, werkstatt: e.target.value }))}
/>
</Feld>
<Feld label="Kosten" unit="€">
<input
className="dm-eingabe"
inputMode="decimal"
value={entwurf.kosten ?? ""}
onChange={(e) =>
setzeEntwurf((alt) => ({
...alt,
kosten: e.target.value === "" ? null : Number(e.target.value),
}))
}
/>
</Feld>
<Feld label="Durchgeführte Arbeiten" last>
<input
className="dm-eingabe"
value={entwurf.arbeiten ?? ""}
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, arbeiten: e.target.value }))}
/>
</Feld>
<div style={{ marginTop: 14 }}>
<ActionButton onClick={() => void hinzufuegen()} disabled={laeuft}>
Speichern
</ActionButton>
</div>
</Tile>
)}
{buch.length === 0 && !neuOffen && (
<Leerzustand
titel="Wartungsplan 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="Werkstatt" wert={eintrag.werkstatt || "—"} />
<Wertzeile label="Kosten" wert={eintrag.kosten != null ? eur(eintrag.kosten) : "noch offen"} />
{eintrag.arbeiten && <Wertzeile label="Durchgeführte Arbeiten" wert={eintrag.arbeiten} />}
</>
}
<Tile>
<span className="ads-eyebrow">Eintrag</span>
<Feld label="Datum">
<input
className="dm-eingabe"
type="date"
value={entwurf.datum ?? ""}
onChange={(e) => setze("datum", e.target.value)}
/>
</Tile>
))}
</Feld>
<Feld label="Kilometerstand" unit="km">
<input
className="dm-eingabe"
inputMode="numeric"
value={entwurf.km ?? ""}
onChange={(e) => setze("km", Number(e.target.value))}
/>
</Feld>
<Feld label="Art">
<select
className="dm-auswahl"
value={entwurf.art ?? "Ölwechsel"}
onChange={(e) => setze("art", e.target.value)}
>
{!SERVICE_ARTEN.includes(entwurf.art ?? "") && entwurf.art && (
<option value={entwurf.art}>{entwurf.art}</option>
)}
{SERVICE_ARTEN.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</Feld>
<Feld label="Werkstatt">
<input
className="dm-eingabe"
value={entwurf.werkstatt ?? ""}
onChange={(e) => setze("werkstatt", e.target.value)}
/>
</Feld>
<Feld label="Kosten" unit="€" last>
<input
className="dm-eingabe"
inputMode="decimal"
placeholder="noch offen"
value={entwurf.kosten ?? ""}
onChange={(e) => setze("kosten", e.target.value === "" ? null : Number(e.target.value))}
/>
</Feld>
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
Kosten fließen in die Jahresübersicht ein
</span>
</Tile>
<Tile>
<span className="ads-eyebrow">Durchgeführte Arbeiten</span>
<textarea
className="dm-notiz"
rows={7}
placeholder="Was wurde gemacht, welche Teile wurden getauscht, was ist aufgefallen"
value={entwurf.arbeiten ?? ""}
onChange={(e) => setze("arbeiten", e.target.value)}
/>
<span className="ads-eyebrow" style={{ marginTop: "var(--sp-3)" }}>
Beim Verkauf ist diese Liste mehr wert als die Rechnung sie zeigt, dass gepflegt wurde
</span>
</Tile>
<ActionButton variant="primary" onClick={() => void speichern()} disabled={laeuft}>
Speichern
</ActionButton>
{!istNeu && (
<ActionButton variant="destructive" onClick={() => void loeschen()} disabled={laeuft}>
Eintrag löschen
</ActionButton>
)}
</>
)
}
+15 -1
View File
@@ -46,6 +46,13 @@ const CI = {
// sonst praktisch unsichtbar.
poiCarL:
'M9 17.5C9 9.52 15.52 3 23.5 3S38 9.52 38 17.5c0 2.04-.92 4.78-2.23 7.67a90 90 0 0 1-4.84 8.98 170 170 0 0 1-6.98 10.59l-.03.04-.01.01-.41-.29-.4.3-.02-.02-.03-.04a66 66 0 0 1-.56-.8 166 166 0 0 1-6.42-9.8 90 90 0 0 1-4.84-8.97C9.92 22.27 9 19.54 9 17.5m14.5 27-.4.3.4.55.4-.56zm0-.86a132 132 0 0 0 1.77-2.56 170 170 0 0 0 4.8-7.45 89 89 0 0 0 4.79-8.87c1.3-2.89 2.14-5.45 2.14-7.26C37 10.08 30.92 4 23.5 4S10 10.08 10 17.5c0 1.8.83 4.37 2.14 7.26a89 89 0 0 0 4.79 8.87 170 170 0 0 0 6.57 10.01M17.96 7.98A63.3 63.3 0 0 1 29.05 8l.2.02.13.16c.6.7 1.1 1.36 1.46 1.94l1.31 2.08L34 14.31v7.19c0 .83-.67 1.5-1.5 1.5h-2a1.5 1.5 0 0 1-1.5-1.5V21H18v.5c0 .83-.67 1.5-1.5 1.5h-2a1.5 1.5 0 0 1-1.5-1.5v-7.19l1.85-2.11 1.3-2.08c.37-.58.86-1.24 1.47-1.96l.13-.16.2-.02ZM15.48 13 14 14.69V15h3v1h-3v5.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5V20h13v1.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5V16h-3v-1h3v-.31L31.52 13zm15.36-1L30 10.65a16 16 0 0 0-1.25-1.67 62.4 62.4 0 0 0-10.5-.02 17 17 0 0 0-1.25 1.7L16.16 12zM19.3 15h8.42l1.4 1.4-.16.31c-.34.74-.65 1.32-.92 1.72-.14.2-.28.38-.42.5a.92.92 0 0 1-.61.27h-7a.92.92 0 0 1-.6-.27 2.4 2.4 0 0 1-.43-.5c-.27-.4-.58-.98-.92-1.72l-.15-.32zm.42 1-.6.6c.26.57.5.99.69 1.27.11.17.2.27.26.32l.01.01h6.86v-.01c.07-.05.16-.15.27-.32.2-.28.43-.7.7-1.27l-.6-.6h-7.6Z',
// poi-car-s: dieselbe Nadel im 24er-Raster. Das Panel schaltet in
// fahrzeugMarkerSVG() ab 34px auf die grosse Zeichnung um - unter 34px ist
// die kleine gemeint, sie ist fuer diese Groesse gezeichnet.
poiCar:
'M3.8 8.6C3.8 4.33 7.32.8 11.5.8s7.7 3.53 7.7 7.8c0 1.11-.5 2.58-1.17 4.1a48 48 0 0 1-2.5 4.7 90 90 0 0 1-3.54 5.47l-.06.09-.02.02V23l-.41-.29-.4.29h-.01l-.02-.03-.06-.09a70 70 0 0 1-1.05-1.53 90 90 0 0 1-2.49-3.93 48 48 0 0 1-2.5-4.72C4.29 11.18 3.8 9.71 3.8 8.6m7.7 14.1-.4.29.4.57.4-.57zm0-.87a88 88 0 0 0 3.17-4.94 47 47 0 0 0 2.45-4.6A10.3 10.3 0 0 0 18.2 8.6c0-3.73-3.08-6.8-6.7-6.8a6.82 6.82 0 0 0-6.7 6.8c0 .89.4 2.18 1.08 3.69.67 1.48 1.55 3.1 2.45 4.6a89 89 0 0 0 3.17 4.94M8.77 4h5.47L17 7.35V11a1 1 0 0 1-1 1h-1.5a1 1 0 0 1-1-1h-4a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7.32zm.46 1L7 7.68V8h2v1H7v2h1.5v-1h6v1H16V9h-2V8h2v-.29L13.76 5z',
poiCarSil:
'M3.8 8.6C3.8 4.33 7.32.8 11.5.8s7.7 3.53 7.7 7.8c0 1.11-.5 2.58-1.17 4.1a48 48 0 0 1-2.5 4.7 90 90 0 0 1-3.54 5.47l-.06.09-.02.02V23l-.41-.29-.4.29h-.01l-.02-.03-.06-.09a70 70 0 0 1-1.05-1.53 90 90 0 0 1-2.49-3.93 48 48 0 0 1-2.5-4.72C4.29 11.18 3.8 9.71 3.8 8.6',
poiCarLSil:
'M9 17.5C9 9.52 15.52 3 23.5 3S38 9.52 38 17.5c0 2.04-.92 4.78-2.23 7.67a90 90 0 0 1-4.84 8.98 170 170 0 0 1-6.98 10.59l-.03.04-.01.01-.41-.29-.4.3-.02-.02-.03-.04a66 66 0 0 1-.56-.8 166 166 0 0 1-6.42-9.8 90 90 0 0 1-4.84-8.97C9.92 22.27 9 19.54 9 17.5',
} as const
@@ -55,7 +62,14 @@ function ciIcon(d: string, px: number): string {
}
function fahrzeugMarkerHtml(px = 38): string {
return `<div class="dm-fahrzeug-pin"><svg viewBox="0 0 48 48" width="${px}" height="${px}" fill="none" aria-hidden="true"><path fill="#182634" d="${CI.poiCarLSil}"/><path fill="currentColor" d="${CI.poiCarL}"/></svg></div>`
// Groessenwechsel wie fahrzeugMarkerSVG() im Panel: ab 34px die grosse
// Zeichnung (48er-Raster), darunter die kleine (24er). Die Vorschaukarte der
// Uebersicht zeichnet mit 32px und bekam bisher faelschlich die grosse.
const gross = px >= 34
const box = gross ? 48 : 24
const sil = gross ? CI.poiCarLSil : CI.poiCarSil
const icon = gross ? CI.poiCarL : CI.poiCar
return `<div class="dm-fahrzeug-pin"><svg viewBox="0 0 ${box} ${box}" width="${px}" height="${px}" fill="none" aria-hidden="true"><path fill="#182634" d="${sil}"/><path fill="currentColor" d="${icon}"/></svg></div>`
}
/* Haversine-Näherung, wie im Panel (distanzMeter()/distanzText()). */
+150 -78
View File
@@ -1,25 +1,95 @@
/**
* Statistik. Vorlage: `vStat()` im alten Panel die Rechenregeln stecken in
* `daten/statistik.ts` und sind von dort unverändert übernommen.
*
* Aufbau wie im Panel: fünf Kacheln, deren Kopf aus Beschriftung, großer Zahl
* (34px) und einem rechtsbündigen Nebenwert besteht; die Auswertung darunter
* ist eingeklappt und öffnet sich beim Antippen des Kopfes (`statTile()` mit
* `.tile-h`/`.tile-b`). Bis 2026-08-30 stand hier alles dauerhaft offen, ohne
* Nebenwert und ohne die Zwischenüberschriften über den Vierergruppen.
*/
import { Fig, ProgressBar, StatGrid, Tile } from "@audi-dash/ui"
import { useState, type ReactNode } from "react"
import { Fig, ProgressBar, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { ZEITRAEUME, ZEITRAUM_NAME, jahresauswertung } from "../daten/statistik"
import { datum, de, deOderStrich, eur } from "../format"
import type { Zeitraum } from "../daten/statistik"
import { datum, de, deOderStrich } from "../format"
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
/** `statTile()` im Panel: Kopf mit Zahl und Nebenwert, aufklappbarer Rumpf. */
function StatKachel({
titel,
wert,
einheit,
neben,
children,
}: {
titel: string
wert: string
einheit: string
neben: ReactNode
/** Heißt `children`, weil JSX-Verschachtelung genau diesen Namen erwartet. */
children: ReactNode
}) {
const [offen, setzeOffen] = useState(false)
return (
<Tile>
<button
type="button"
className="dm-statkopf"
aria-expanded={offen}
onClick={() => setzeOffen((o) => !o)}
>
{/* Derselbe drehende Pfeil wie in den Aufklapp-Zeilen: ohne ihn sahen
die fuenf Kacheln aus wie feste Anzeigen (Designpruefung 2026-08-30). */}
<span className="dm-statkopf__kopfzeile">
<span className="ads-eyebrow">{titel}</span>
<span className="ads-mark" aria-hidden="true" />
</span>
<span className="dm-statkopf__zeile">
<Fig value={wert} unit={einheit} size={34} />
<span className="dm-wert dm-statkopf__neben">{neben}</span>
</span>
</button>
{offen && <div className="dm-statrumpf">{children}</div>}
</Tile>
)
}
/** `quad()` im Panel: Zwischenüberschrift plus vier Werte nebeneinander. */
function Vierergruppe({ titel, werte }: { titel: string; werte: [string, string][] }) {
return (
<>
<span className="ads-eyebrow" style={{ marginTop: "20px" }}>
{titel}
</span>
<div className="dm-quad">
{werte.map(([zahl, beschriftung]) => (
<div key={beschriftung}>
<div className="dm-quad__n">{zahl}</div>
<div className="dm-quad__l">{beschriftung}</div>
</div>
))}
</div>
</>
)
}
export function Statistik() {
const { fahrten, tankvorgaenge, einstellungen } = useDaten()
if (!einstellungen) return null
if (fahrten.length === 0 && tankvorgaenge.length === 0) {
return (
<Leerzustand
titel="Noch nichts auszuwerten"
text="Sobald die ersten Fahrten und Tankungen erfasst sind, entstehen hier Auswertungen zu Strecke, Zeit, Verbrauch und Kosten."
/>
<Tile>
<Leerzustand
titel="Noch nichts auszuwerten"
text="Sobald die ersten Fahrten und Tankungen erfasst sind, entstehen hier Auswertungen zu Strecke, Zeit, Verbrauch und Kosten."
/>
</Tile>
)
}
@@ -30,53 +100,57 @@ export function Statistik() {
einstellungen.nachtBis,
)
// Die Einheit steht im Panel in der Beschriftung der Vierergruppe
// ("Jahr h", "Monat €"), nicht am Wert.
const jeZeitraum = (
auswahl: (z: Zeitraum) => string,
einheit = "",
): [string, string][] =>
ZEITRAEUME.map((z) => [auswahl(z), `${ZEITRAUM_NAME[z]}${einheit}`])
return (
<div className="dm-raster">
<Tile>
<span className="ads-eyebrow">Distanz und Fahrzeit {a.jahr}</span>
<Fig value={de(a.kmJahr)} unit="km" size={40} />
<StatGrid
items={ZEITRAEUME.map((z) => ({
value: de(a.jeZeitraum[z].km),
unit: "km",
caption: ZEITRAUM_NAME[z],
}))}
columns={4}
<>
<StatKachel
titel={`Distanz und Fahrzeit ${a.jahr}`}
wert={de(a.kmJahr)}
einheit="km"
neben={`${de(a.stundenJahr)} h`}
>
<Vierergruppe
titel="Gefahrene Distanz"
werte={jeZeitraum((z) => de(a.jeZeitraum[z].km))}
/>
<StatGrid
items={ZEITRAEUME.map((z) => ({
value: de(a.jeZeitraum[z].stunden, z === "jahr" || z === "monat" ? 0 : 1),
unit: "h",
caption: ZEITRAUM_NAME[z],
}))}
columns={4}
<Vierergruppe
titel="Fahrzeit"
werte={jeZeitraum(
(z) => de(a.jeZeitraum[z].stunden, z === "jahr" || z === "monat" ? 0 : 1),
" h",
)}
/>
</Tile>
</StatKachel>
<Tile>
<span className="ads-eyebrow">Getankte Menge und Kosten {a.jahr}</span>
<Fig value={de(a.literJahr)} unit="l" size={40} />
<StatGrid
items={ZEITRAEUME.map((z) => ({
value: de(a.jeZeitraum[z].liter),
unit: "l",
caption: ZEITRAUM_NAME[z],
}))}
columns={4}
<StatKachel
titel={`Getankte Menge und Kosten ${a.jahr}`}
wert={de(a.literJahr)}
einheit="l"
neben={`${de(a.kostenJahr)}`}
>
<Vierergruppe
titel="Getankte Liter"
werte={jeZeitraum((z) => de(a.jeZeitraum[z].liter))}
/>
<StatGrid
items={ZEITRAEUME.map((z) => ({
value: de(a.jeZeitraum[z].kosten),
unit: "€",
caption: ZEITRAUM_NAME[z],
}))}
columns={4}
<Vierergruppe
titel="Kraftstoffkosten"
werte={jeZeitraum((z) => de(a.jeZeitraum[z].kosten), " €")}
/>
</Tile>
</StatKachel>
<Tile className="dm-raster__breit">
<span className="ads-eyebrow">Langzeitverbrauch</span>
<Fig value={deOderStrich(a.langzeit.wert, 1)} unit="l/100 km" size={40} />
<StatKachel
titel="Langzeitverbrauch"
wert={deOderStrich(a.langzeit.wert, 1)}
einheit="l/100 km"
neben={a.langzeit.bis ? `bis ${datum(a.langzeit.bis)}` : ""}
>
<Werteliste
kinder={
<>
@@ -88,49 +162,51 @@ export function Statistik() {
/>
<Wertzeile
label="Letzter Tankstop"
wert={a.langzeit.bis ? datum(a.langzeit.bis) : ""}
wert={a.langzeit.bis ? datum(a.langzeit.bis) : ""}
/>
</>
}
/>
{a.langzeit.unplausibel && (
<p className="dm-fussnote dm-fussnote--warnung">
<span className="ads-eyebrow dm-warnton" style={{ marginTop: "16px" }}>
Aus Litern und Strecke ergibt sich derzeit kein plausibler Wert. Sobald Tankvorgänge und
Kilometerstände vollständig vorliegen, erscheint er hier.
</p>
</span>
)}
<p className="dm-fussnote">
Der Langzeitwert rechnet alle je getankten Liter gegen die Strecke bis zum letzten
Tankstop nicht Liter und Kilometer desselben Zeitraums, weil das unmögliche Werte
ergibt, sobald eine Fahrt fehlt.
</p>
</Tile>
<Tile>
<span className="ads-eyebrow">Tag und Nacht {a.jahr}</span>
<Fig
value={a.kmJahr > 0 ? de(Math.round((a.tagKm / a.kmJahr) * 100)) : "—"}
unit="% bei Tag"
size={40}
<Vierergruppe
titel="Näherung je Fahrt, je Zeitraum"
werte={jeZeitraum((z) => deOderStrich(a.jeZeitraum[z].verbrauch, 1))}
/>
<span className="ads-eyebrow" style={{ marginTop: "16px" }}>
Der Langzeitwert rechnet alle je getankten Liter gegen die Strecke bis zum letzten
Tankstop. Die Werte je Zeitraum sind eine Näherung aus der Tankfüllstand-Differenz der
einzelnen Fahrten, keine Meldung des Fahrzeugs.
</span>
</StatKachel>
<StatKachel
titel="Tag und Nacht"
wert={a.kmJahr > 0 ? de(Math.round((a.tagKm / a.kmJahr) * 100)) : ""}
einheit="% bei Tag"
neben={`${de(a.tagKm)} km`}
>
<ProgressBar
segments={[
{ value: a.tagKm, color: "var(--fg)", label: `Tag · ${de(a.tagKm)} km` },
{ value: a.nachtKm, color: "var(--fg3)", label: `Nacht · ${de(a.nachtKm)} km` },
]}
/>
<p className="dm-fussnote">
<span className="ads-eyebrow" style={{ marginTop: "16px" }}>
Nacht = {einstellungen.nachtVon} bis {einstellungen.nachtBis} Uhr
</p>
</Tile>
</span>
</StatKachel>
<Tile>
<span className="ads-eyebrow">Art der Fahrten {a.jahr}</span>
<Fig
value={a.kmJahr > 0 ? de(Math.round((a.arbeitKm / a.kmJahr) * 100)) : "—"}
unit="% Arbeitsweg"
size={40}
/>
<StatKachel
titel="Art der Fahrten"
wert={a.kmJahr > 0 ? de(Math.round((a.arbeitKm / a.kmJahr) * 100)) : ""}
einheit="% Arbeitsweg"
neben={`${de(a.arbeitKm)} km`}
>
<ProgressBar
segments={[
{ value: a.arbeitKm, color: "var(--fg)", label: `Arbeitsweg · ${de(a.arbeitKm)} km` },
@@ -142,14 +218,10 @@ export function Statistik() {
<>
<Wertzeile label="Fahrten Arbeitsweg" wert={de(a.arbeitFahrten)} />
<Wertzeile label="Fahrten privat" wert={de(a.privatFahrten)} />
<Wertzeile
label={`Kraftstoffkosten ${a.jahr}`}
wert={`${eur(a.kostenJahr)}`}
/>
</>
}
/>
</Tile>
</div>
</StatKachel>
</>
)
}
+98 -77
View File
@@ -9,13 +9,16 @@
import { useEffect, useRef, useState } from "react"
import { ActionButton, Feld, Pill, Tile } from "@audi-dash/ui"
import { ActionButton, Feld, Fig, Tile } from "@audi-dash/ui"
import type { Tankvorgang } from "../api"
import { ENTITAETEN } from "../api"
import { useDaten } from "../daten/DatenKontext"
import { datum, datumZeit, de, deOderStrich, eur } from "../format"
import { KRAFTSTOFFSORTEN, Leerzustand, Wertzeile, Werteliste } from "./bausteine"
import { datum, de, deOderStrich, eur, uhrzeit } from "../format"
import type { SeitenName } from "../navigation"
import { KRAFTSTOFFSORTEN, Leerzustand, Wertzeile, Werteliste, bestaetigen } from "./bausteine"
import { markenlogoUrl } from "./bilder"
import { TankstellenKarte } from "./TankstellenKarte"
interface BelegErgebnis {
ok?: boolean
@@ -25,8 +28,8 @@ interface BelegErgebnis {
[weitere: string]: unknown
}
export function TankDetail({ id }: { id: string | undefined }) {
const { tankvorgaenge, api } = useDaten()
export function TankDetail({ id, geheZu }: { id: string | undefined; geheZu: (name: SeitenName) => void }) {
const { tankvorgaenge, fahrzeug, api } = useDaten()
const tank = tankvorgaenge.find((t) => t.tank_id === id)
const [bearbeitenOffen, setzeBearbeitenOffen] = useState(false)
@@ -39,66 +42,100 @@ export function TankDetail({ id }: { id: string | undefined }) {
)
}
const loeschen = () => {
if (!tank || !bestaetigen("Tankvorgang löschen?")) return
void api.tankvorgangLoeschen(tank.tank_id)
geheZu("fuel")
}
if (bearbeitenOffen) {
return <TankBearbeiten tank={tank} beiFertig={() => setzeBearbeitenOffen(false)} />
}
/* Aufbau 1:1 wie vFill(): Karte, randlose Getankt"-Kachel mit der
52px-Zahl, dann eine einzige Wertezeilen-Kachel in der Reihenfolge des
Panels (Zeitpunkt/Kilometerstand/Gefahrene Distanz/Kraftstoff/Getankte
Liter/Kosten/Preis je Liter/SmartDeal-Ersparnis/Tankstelle) mit den drei
Knöpfen darunter.
Vorher: eigene Kopfzeile mit Zeitstempel und Bearbeiten-Textknopf, Pillen
für Status und Quelle (die das Panel hier nicht führt), andere Reihenfolge
und Bezeichnungen (Gesamtbetrag", „Seit der letzten Tankung",
Listenpreis") und der Beleg in einer eigenen Kachel. */
const preisJeLiter =
tank.liters && tank.fuel_total_eur != null ? tank.fuel_total_eur / tank.liters : null
return (
<>
<Tile className="dm-kartenkachel">
<TankstellenKarte adresse={tank.station_address} />
</Tile>
<Tile variant="flat" className="dm-distanzkachel">
<span className="ads-eyebrow">Getankt</span>
<div style={{ marginTop: "10px" }}>
<Fig value={deOderStrich(tank.liters, 2)} unit="l" size={52} />
</div>
</Tile>
<Tile>
<div className="dm-abschnitt" style={{ marginBottom: 0 }}>
<span className="ads-eyebrow">{datumZeit(tank.ts)}</span>
<button type="button" className="dm-textknopf" onClick={() => setzeBearbeitenOffen(true)}>
Bearbeiten
</button>
</div>
<div className="dm-detail__kopf">
<div className="dm-detail__zahl dm-detail__zahl--gross">
{deOderStrich(tank.liters, 2)}
<span className="dm-detail__einheit">l</span>
</div>
<div className="dm-detail__marken">
{tank.status === "unvollständig" && <Pill>unvollständig</Pill>}
{tank.source === "beleg" && <Pill>aus Beleg</Pill>}
{tank.source === "manual" && <Pill>selbst eingetragen</Pill>}
{tank.source === "auto" && <Pill>automatisch erkannt</Pill>}
</div>
</div>
<Werteliste
kinder={
<>
<Wertzeile label="Zeitpunkt" wert={datum(tank.ts)} zusatz={`${uhrzeit(tank.ts)} Uhr`} />
<Wertzeile
label="Gesamtbetrag"
wert={tank.fuel_total_eur != null ? `${eur(tank.fuel_total_eur)} ` : ""}
label="Kilometerstand"
wert={tank.odometer_km != null ? `${de(tank.odometer_km)} km` : "unbekannt"}
/>
<Wertzeile
label="Gefahrene Distanz"
wert={tank.distance_km != null ? `${de(tank.distance_km)} km` : "unbekannt"}
zusatz="seit der letzten Tankung"
/>
<Wertzeile label="Kraftstoff" wert={tank.fuel_type ?? KRAFTSTOFFSORTEN[0]} />
<Wertzeile label="Getankte Liter" wert={deOderStrich(tank.liters, 2) + " l"} />
<Wertzeile
label="Kosten"
wert={tank.fuel_total_eur != null ? `${eur(tank.fuel_total_eur)}` : ""}
zusatz="real, nach Rabatt"
/>
<Wertzeile
label="Preis je Liter"
wert={tank.price_per_l != null ? `${eur(tank.price_per_l)}` : ""}
zusatz={
tank.list_price_per_l != null
? `Listenpreis ${eur(tank.list_price_per_l)}`
: undefined
}
wert={preisJeLiter != null ? `${de(preisJeLiter, 2)}` : ""}
zusatz="Kosten ÷ Liter"
/>
{tank.discount != null && tank.discount > 0 && (
<Wertzeile label="Ersparnis" wert={`${eur(tank.discount)}`} />
{fahrzeug?.smartdeal.aktiv && tank.discount != null && (
<Wertzeile
label={
<span className="dm-marke">
<img
src={markenlogoUrl()}
alt=""
onError={(e) => {
e.currentTarget.style.display = "none"
}}
/>
SmartDeal-Ersparnis
</span>
}
wert={`${eur(tank.discount)}`}
/>
)}
<Wertzeile label="Tankstelle" wert={tank.station_name ?? "—"} zusatz={tank.station_address ?? undefined} />
<Wertzeile label="Kraftstoff" wert={tank.product_name ?? tank.fuel_type ?? "—"} />
<Wertzeile
label="Kilometerstand"
wert={tank.odometer_km != null ? `${de(tank.odometer_km)} km` : "—"}
/>
<Wertzeile
label="Seit der letzten Tankung"
wert={tank.distance_km != null ? `${de(tank.distance_km)} km` : "—"}
label="Tankstelle"
wert={tank.station_name ?? "unbekannt"}
zusatz={tank.station_address ?? ""}
/>
</>
}
/>
<ActionButton onClick={() => setzeBearbeitenOffen(true)}>Werte bearbeiten</ActionButton>
<BelegKnopf tankId={tank.tank_id} belegDatei={tank.receipt_file ?? null} api={api} />
</Tile>
<BelegBereich tankId={tank.tank_id} belegDatei={tank.receipt_file ?? null} belegNr={tank.receipt_no ?? null} zeitpunkt={tank.ts} api={api} />
<ActionButton variant="destructive" onClick={() => void loeschen()}>
Tankvorgang löschen
</ActionButton>
</>
)
}
@@ -266,17 +303,17 @@ function TankBearbeiten({ tank, beiFertig }: { tank: Tankvorgang; beiFertig: ()
)
}
function BelegBereich({
/* Im Panel ist der Beleg kein eigener Abschnitt, sondern ein Knopf in der
Wertezeilen-Kachel ("Beleg ersetzen"/"Beleg nachtragen"). Die frühere
eigene Kachel mit Dateiname und Beleg-Nummer hatte dort keine
Entsprechung. */
function BelegKnopf({
tankId,
belegDatei,
belegNr,
zeitpunkt,
api,
}: {
tankId: string
belegDatei: string | null
belegNr: string | null
zeitpunkt: string
api: ReturnType<typeof useDaten>["api"]
}) {
const dateiwahl = useRef<HTMLInputElement | null>(null)
@@ -346,24 +383,7 @@ function BelegBereich({
}
return (
<Tile>
<span className="ads-eyebrow">Beleg</span>
{belegDatei ? (
<Werteliste
kinder={
<>
<Wertzeile label="Hinterlegt" wert={belegDatei} />
<Wertzeile label="Beleg-Nummer" wert={belegNr ?? "—"} />
</>
}
/>
) : (
<p className="dm-fussnote">
Für diese Tankung liegt noch kein Beleg vor. Aus einem Shell-Beleg liest die App Menge,
Betrag, Tankstelle und Ersparnis selbst aus.
</p>
)}
<>
<input
ref={dateiwahl}
type="file"
@@ -375,7 +395,6 @@ function BelegBereich({
e.target.value = ""
}}
/>
{meldung && (
<p
className={meldung.art === "fehler" ? "dm-fehler" : "dm-erfolg"}
@@ -384,17 +403,19 @@ function BelegBereich({
{meldung.text}
</p>
)}
<div style={{ marginTop: 14 }}>
<ActionButton onClick={() => dateiwahl.current?.click()} disabled={laeuft || wartet}>
{laeuft ? "Lade hoch …" : wartet ? "Beleg wird gelesen …" : belegDatei ? "Beleg ersetzen" : "Beleg hochladen"}
</ActionButton>
</div>
<p className="dm-fussnote">
Der Beleg muss vom {datum(zeitpunkt)} sein sonst weist der Server ihn ab, damit keine
fremde Tankung in diesen Eintrag gerät.
</p>
</Tile>
<ActionButton onClick={() => dateiwahl.current?.click()} disabled={laeuft || wartet}>
{laeuft
? "Lade hoch …"
: wartet
? "Beleg wird gelesen …"
: belegDatei
? "Beleg ersetzen"
: "Beleg nachtragen"}
</ActionButton>
{/* Der Datumshinweis darunter ist entfallen - im Panel steht an dieser
Stelle nur im Fehlerfall eine Meldung (belegFehler), kein
dauerhafter Erklärtext. */}
</>
)
}
+176 -85
View File
@@ -6,15 +6,16 @@
* heben wie eine große.
*/
import { useState } from "react"
import { useRef, useState } from "react"
import { Accordion, ActionButton, Feld, Pill, Tile } from "@audi-dash/ui"
import { ENTITAETEN } from "../api"
import { useDaten } from "../daten/DatenKontext"
import { nachJahrUndMonat } from "../daten/statistik"
import { datum, de, deOderStrich, eur, monatJahr, schnittpreis, summe } from "../format"
import { datum, de, eur, schnittpreis, summe } from "../format"
import type { SeitenName } from "../navigation"
import { KRAFTSTOFFSORTEN, Leerzustand } from "./bausteine"
import { Blattzeile, KRAFTSTOFFSORTEN, Leerzustand } from "./bausteine"
import { markenlogoUrl } from "./bilder"
import { Zeilenmenue } from "./Zeilenmenue"
@@ -27,33 +28,17 @@ export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
return (
<>
<div className="dm-abschnitt">
<span className="ads-eyebrow">
{tankvorgaenge.length === 0
? "Keine Tankvorgänge"
: tankvorgaenge.length === 1
? "1 Tankvorgang"
: `${de(tankvorgaenge.length)} Tankvorgänge`}
</span>
<button
type="button"
className="dm-textknopf"
onClick={() => setzeFormularOffen((offen) => !offen)}
>
{formularOffen ? "Abbrechen" : "Tankung eintragen"}
</button>
</div>
{formularOffen && <TankFormular beiFertig={() => setzeFormularOffen(false)} />}
{tankvorgaenge.length === 0 && !formularOffen && (
<Leerzustand
titel="Noch keine Tankvorgänge"
text="Die App erkennt Tankungen automatisch am steigenden Füllstand und vervollständigt sie aus dem hochgeladenen Beleg. Du kannst sie auch selbst eintragen."
aktion={{ beschriftung: "Tankung eintragen", beiKlick: () => setzeFormularOffen(true) }}
/>
)}
{/* Aufbau wie vFuel(): eine Kachel um alle Jahre, kein Zähler über der
Liste, das Formular darunter. */}
{gruppen.length === 0 ? (
<Tile>
<Leerzustand
titel="Noch keine Tankvorgänge"
text="Ein Tankvorgang wird automatisch angelegt, sobald der Tankstand des Fahrzeugs steigt. Den Beleg kann man danach hochladen oder den Vorgang gleich hier von Hand erfassen."
/>
</Tile>
) : (
<Tile>
{gruppen.map((jahr) => {
const ersparnisJahr = summe(jahr.eintraege, (t) => t.discount ?? 0)
return (
@@ -69,7 +54,7 @@ export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
{de(summe(jahr.eintraege, (t) => t.liters ?? 0), 1)} l · Ø{" "}
{de(schnittpreis(jahr.eintraege), 2)} /l
</span>
{smartdealAktiv && ersparnisJahr > 0 && (
{smartdealAktiv && (
<Pill>
<img
src={markenlogoUrl()}
@@ -86,64 +71,88 @@ export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
}
defaultOpen={jahr === gruppen[0]}
>
{jahr.monate.map((monat) => {
const schnitt = schnittpreis(monat.eintraege)
return (
<Accordion
key={monat.schluessel}
level={2}
title={monatJahr(new Date(monat.jahr, monat.monat, 1))}
summary={
<span style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 3 }}>
<span style={{ color: "var(--fg)" }}>
{eur(summe(monat.eintraege, (t) => t.fuel_total_eur ?? 0))}
</span>
<span>{schnitt > 0 ? `Ø ${eur(schnitt)} €/l` : "—"}</span>
{jahr.monate.map((monat) => (
<Accordion
key={monat.schluessel}
level={2}
title={monatName(monat.jahr, monat.monat)}
summary={
<span style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 3 }}>
<span style={{ color: "var(--fg)" }}>
{eur(summe(monat.eintraege, (t) => t.fuel_total_eur ?? 0))}
</span>
}
defaultOpen={monat === jahr.monate[0]}
>
{monat.eintraege.map((tank) => (
<span>Ø {de(schnittpreis(monat.eintraege), 2)} /l</span>
</span>
}
defaultOpen={monat === jahr.monate[0]}
>
{/* Zeile wie im Panel: links das Datum mit der Tankstelle
darunter, rechts die Menge mit dem Preis je Liter. Vorher
standen hier Menge und Betrag vorn, alles andere in einer
Fußzeile, dazu eine Pille unvollständig", die es im Panel
nicht gibt. Der Preis je Liter wird wie dort aus Betrag und
Menge gerechnet, nicht aus dem gespeicherten Feld. */}
{monat.eintraege.map((tank, i) => {
const preis =
tank.liters && tank.fuel_total_eur != null
? tank.fuel_total_eur / tank.liters
: null
return (
<Zeilenmenue
key={tank.tank_id}
loeschFrage={`Tankvorgang vom ${datum(tank.ts)} löschen?`}
beiLoeschen={() => void api.tankvorgangLoeschen(tank.tank_id)}
kinder={
<button
type="button"
className="dm-listenzeile"
onClick={() => geheZu("fill", tank.tank_id)}
>
<span className="dm-listenzeile__haupt">
<span className="dm-listenzeile__titel">
{deOderStrich(tank.liters, 2)} l
{tank.fuel_total_eur != null && (
<span className="dm-listenzeile__neben">
{eur(tank.fuel_total_eur)}
</span>
)}
</span>
<span className="dm-listenzeile__unter">
{datum(tank.ts)}
{tank.station_name ? ` · ${tank.station_name}` : ""}
{tank.price_per_l != null ? ` · ${eur(tank.price_per_l)} €/l` : ""}
</span>
</span>
{tank.status === "unvollständig" && <Pill>unvollständig</Pill>}
</button>
<Blattzeile
label={datum(tank.ts)}
labelZusatz={tank.station_name ?? ""}
wert={tank.liters != null ? `${de(tank.liters, 2)} l` : ""}
wertZusatz={preis != null ? `${de(preis, 2)} €/l` : ""}
beiKlick={() => geheZu("fill", tank.tank_id)}
letzte={i === monat.eintraege.length - 1}
/>
}
/>
))}
</Accordion>
)
})}
)
})}
</Accordion>
))}
</Accordion>
)
})}
</Tile>
)}
{formularOffen && <TankFormular beiFertig={() => setzeFormularOffen(false)} />}
<ActionButton
variant={formularOffen ? "default" : "primary"}
onClick={() => setzeFormularOffen((offen) => !offen)}
>
{formularOffen ? "Abbrechen" : "Tankvorgang erfassen"}
</ActionButton>
</>
)
}
/** Felder, die belege.py nach dem Lesen eines Belegs veröffentlicht. */
interface BelegEntwurf {
ts?: string
liter?: number | null
kosten?: number | null
km?: number | null
ersparnis?: number | null
station?: string | null
kraftstoff?: string | null
receipt_key?: string | null
receipt_file?: string | null
}
/** Monatsname allein, wie im Panel. */
function monatName(jahr: number, monat: number): string {
return new Date(jahr, monat, 1).toLocaleDateString("de-DE", { month: "long" })
}
function TankFormular({ beiFertig }: { beiFertig: () => void }) {
const { api, fahrzeug } = useDaten()
const [zeitpunkt, setzeZeitpunkt] = useState("")
@@ -155,10 +164,74 @@ function TankFormular({ beiFertig }: { beiFertig: () => void }) {
const [ersparnis, setzeErsparnis] = useState("")
const [fehler, setzeFehler] = useState<string | null>(null)
const [laeuft, setzeLaeuft] = useState(false)
/* Vorab gelesener Beleg. Ein Upload OHNE tank_id legt im Backend nichts an,
sondern liest den Beleg nur aus und veröffentlicht die Felder über
sensor.audi_dashboard_beleg_ergebnis (belege.py) - genau dieses Muster
benutzt das Panel im Neuanlage-Formular (data-beleg="neu" plus
belegEntwurf). Der App fehlte der Weg ganz: sie konnte einen Beleg nur an
einem bereits bestehenden Tankvorgang nachtragen. */
const [beleg, setzeBeleg] = useState<BelegEntwurf | null>(null)
const [belegLaeuft, setzeBelegLaeuft] = useState(false)
const belegwahl = useRef<HTMLInputElement | null>(null)
const smartdealAktiv = fahrzeug?.smartdeal.aktiv === true
const speichern = async () => {
const belegLesen = async (datei: File) => {
setzeFehler(null)
if (datei.type !== "application/pdf" && !datei.name.toLowerCase().endsWith(".pdf")) {
setzeFehler("Bitte den Beleg als PDF auswählen.")
return
}
setzeBelegLaeuft(true)
try {
const base64 = await new Promise<string>((fertig, schief) => {
const leser = new FileReader()
leser.onload = () => {
const roh = String(leser.result)
const komma = roh.indexOf(",")
fertig(komma >= 0 ? roh.slice(komma + 1) : roh)
}
leser.onerror = () => schief(new Error("Die Datei konnte nicht gelesen werden."))
leser.readAsDataURL(datei)
})
await api.belegHochladen(base64, datei.name)
// Das Ergebnis kommt asynchron über eine eigene Entität, nicht als
// Antwort auf den Dienstaufruf - hier kurz darauf warten.
const frist = Date.now() + 20_000
while (Date.now() < frist) {
await new Promise((weiter) => setTimeout(weiter, 1200))
try {
const e = await api.rest.datenLesen<BelegEntwurf & { fehler?: string; tank_id?: string | null }>(
ENTITAETEN.belegErgebnis,
)
if (e && e.tank_id == null) {
if (e.fehler) {
setzeFehler(e.fehler)
} else {
setzeBeleg(e)
if (e.liter != null) setzeLiter(String(e.liter))
if (e.kosten != null) setzeKosten(String(e.kosten))
if (e.station) setzeStation(e.station)
if (e.km != null) setzeKm(String(e.km))
if (e.ersparnis != null) setzeErsparnis(String(e.ersparnis))
if (e.kraftstoff) setzeKraftstoff(e.kraftstoff)
if (e.ts) setzeZeitpunkt(e.ts.slice(0, 16))
}
return
}
} catch {
// einzelner Fehlschlag heißt nichts - weiterversuchen
}
}
setzeFehler("Der Server hat den Beleg noch nicht zurückgemeldet.")
} catch (ursache) {
setzeFehler(ursache instanceof Error ? ursache.message : String(ursache))
} finally {
setzeBelegLaeuft(false)
}
}
const speichern = async (mitBeleg: boolean) => {
setzeFehler(null)
const literZahl = Number(liter.replace(",", "."))
const kostenZahl = Number(kosten.replace(",", "."))
@@ -184,6 +257,9 @@ function TankFormular({ beiFertig }: { beiFertig: () => void }) {
...(smartdealAktiv
? { ersparnis: Number.isFinite(ersparnisZahl) && ersparnis ? ersparnisZahl : null }
: {}),
...(mitBeleg && beleg
? { receipt_key: beleg.receipt_key ?? null, receipt_file: beleg.receipt_file ?? null }
: {}),
})
beiFertig()
} catch (ursache) {
@@ -195,7 +271,7 @@ function TankFormular({ beiFertig }: { beiFertig: () => void }) {
return (
<Tile>
<span className="ads-eyebrow">Tankung eintragen</span>
<span className="ads-eyebrow">Neuer Tankvorgang</span>
<Feld label="Zeitpunkt">
<input
className="dm-eingabe"
@@ -268,15 +344,30 @@ function TankFormular({ beiFertig }: { beiFertig: () => void }) {
{fehler}
</p>
)}
<p className="dm-fussnote">
Den Preis je Liter rechnet die App selbst aus Betrag und Menge er wird nie aus einem Beleg
übernommen. Ohne Kilometerstand nimmt sie den des Fahrzeugs.
</p>
<div style={{ marginTop: 14 }}>
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
{laeuft ? "Speichere …" : "Speichern"}
</ActionButton>
</div>
{/* Ohne Erklärabsatz: das Panel führt im Neuanlage-Formular nur die
Felder und die Knöpfe. */}
{/* Drei Knöpfe wie in vFuelFormular(): erst den Beleg lesen lassen, dann
mit oder ohne ihn speichern. */}
<input
ref={belegwahl}
type="file"
accept="application/pdf,.pdf"
hidden
onChange={(e) => {
const datei = e.target.files?.[0]
if (datei) void belegLesen(datei)
e.target.value = ""
}}
/>
<ActionButton onClick={() => belegwahl.current?.click()} disabled={belegLaeuft || laeuft}>
{belegLaeuft ? "Beleg wird gelesen …" : beleg ? "Beleg ersetzen" : "Beleg hochladen"}
</ActionButton>
<ActionButton variant="primary" onClick={() => void speichern(true)} disabled={laeuft}>
{laeuft ? "Speichere …" : "Speichern"}
</ActionButton>
<ActionButton onClick={() => void speichern(false)} disabled={laeuft}>
Ohne Beleg speichern
</ActionButton>
</Tile>
)
}
@@ -0,0 +1,46 @@
/**
* Karte im Einzelbeleg: eine Nadel auf der Tankstelle.
*
* Die Koordinaten stehen nicht im Datensatz der Beleg trägt nur eine
* Anschrift, und das Backend füllt `station_lat`/`station_lon` nie. Sie werden
* deshalb aus der Adresse aufgelöst (`adresseAufloesen()`), genau wie im
* Panel. Ohne Adresse oder ohne Treffer bleibt der ehrliche Leerzustand
* stehen, statt irgendeinen Kartenausschnitt zu zeigen.
*/
import { useEffect, useState } from "react"
import { adresseAufloesen } from "../daten/geokodierung"
import type { Koordinate } from "../daten/geokodierung"
import { Leerzustand } from "./bausteine"
import { Karte } from "./Karte"
export function TankstellenKarte({ adresse }: { adresse: string | null | undefined }) {
const [position, setzePosition] = useState<Koordinate | null>(null)
const [geprueft, setzeGeprueft] = useState(false)
useEffect(() => {
let abgebrochen = false
setzeGeprueft(false)
void adresseAufloesen(adresse).then((treffer) => {
if (abgebrochen) return
setzePosition(treffer)
setzeGeprueft(true)
})
return () => {
abgebrochen = true
}
}, [adresse])
if (position) return <Karte start={position} />
// Solange die Auflösung läuft: eine leere Kartenfläche, wie im Panel (die
// Karte steht dort schon, die Nadel kommt nach). Bewusst nicht die
// Ladeanzeige der Seite - die meldet „der Bildschirm ist noch nicht da".
if (!geprueft) return <div className="dm-kartenplatz" />
return (
<Leerzustand
titel="Keine Position zur Tankstelle"
text="Zu dieser Anschrift ließ sich kein Kartenpunkt finden. Sobald der Beleg eine auflösbare Adresse trägt, erscheint die Tankstelle hier."
/>
)
}
+118 -104
View File
@@ -6,15 +6,15 @@
* Tankung. Ziehen zum Aktualisieren stößt eine Neuabfrage beim Fahrzeug an.
*/
import { useState } from "react"
import { Fig, ProgressBar, StatusRow, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { naechsterService } from "../daten/service"
import { datum, datumZeit, dauer, de, deOderStrich, eur } from "../format"
import { datum, de, deOderStrich, eur } from "../format"
import type { SeitenName } from "../navigation"
import { Leerzustand, NaviKachel } from "./bausteine"
import { useBoden } from "../theme"
import { BILDPLAETZE, bildInfo } from "./bilder"
import { Blattzeile, Leerzustand } from "./bausteine"
import { Bild } from "./Bild"
import type { ServicebuchEintrag } from "./Service"
import { StandortVorschau } from "./Standort"
@@ -30,13 +30,29 @@ const SERVICE_SYMBOL: Partial<Record<string, typeof SymbolOelwechsel>> = {
"Inspektion": SymbolInspektion,
}
/* Innenraumaufnahmen bekommen keinen Bodenschatten — `OHNE_SZENE` im Panel. */
const OHNE_SZENE = ["Cockpit", "Sitze", "Scheinwerfer"]
export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
const { fahrzeug, einstellungen, fahrten, tankvorgaenge, jetztAktualisieren, statusStand } =
useDaten()
const [aktualisiert, setzeAktualisiert] = useState(false)
const { fahrzeug, einstellungen, fahrten, tankvorgaenge } = useDaten()
const [boden] = useBoden()
if (!fahrzeug || !einstellungen) return null
// Bodenschatten unter dem Fahrzeug, wie szeneKlasse() im Panel: abschaltbar,
// nicht bei Innenraumaufnahmen, blaustichig bei montierten Winterrädern.
const winterraeder = fahrzeug.reifen.aktiv === "Winter"
const platz = BILDPLAETZE.find((p) => p.datei === einstellungen.startbild) ?? BILDPLAETZE[0]!
// Bei Winterrädern zeigt die Seitenansicht das Winterfoto, wie bildInfo()
// im Panel — das fehlte hier ganz.
const startbild = bildInfo(platz, winterraeder)
const bodenKlasse =
!boden || OHNE_SZENE.includes(platz.name)
? ""
: winterraeder
? " dm-szene--winter"
: " dm-szene--strasse"
// Der Zusatz stammt aus dem Modellnamen selbst (alles nach dem Typenschild)
// plus der eigens gepflegten Ausführung — so macht es auch das alte Panel.
const schild = schildErkennen(einstellungen.fahrzeugtitel)
@@ -74,26 +90,28 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
? "Nicht sicher abgestellt"
: "Status unbekannt"
const aktualisieren = async () => {
setzeAktualisiert(true)
try {
await jetztAktualisieren()
} finally {
setzeAktualisiert(false)
}
}
// Die Neuabfrage beim Fahrzeug hängt jetzt am Ziehen zum Aktualisieren im
// Rahmen (Shell.tsx), wie im Panel — der frühere Knopf am Seitenende hatte
// dort keine Entsprechung.
return (
<>
<div className="dm-szene">
{/* Randloses Fahrzeugbild, danach eine eigene randlose Kachel mit
Typenschild, Kennzeichen und Statuszeile dieselbe Aufteilung wie
vHome() im Panel (`.szene.bleed` gefolgt von einer `.tile.flat`).
Der Status stand hier bisher als eigenes Element unter der Szene. */}
<div className={`dm-szene${bodenKlasse}`}>
<Bild
datei={einstellungen.startbild}
beschreibung={einstellungen.fahrzeugtitel}
name="Fahrzeugbild"
datei={startbild.datei}
beschreibung={startbild.beschreibung}
name={startbild.beschreibung}
/>
</div>
<Tile variant="flat" className="dm-kopfblock">
<div className="dm-szene__text">
<div className="dm-szene__marke">
<Typenschild modell={einstellungen.fahrzeugtitel} hoehe={26} />
<Typenschild modell={einstellungen.fahrzeugtitel} hoehe={18} />
{/* Neben dem Schild steht nur, was im Namen darüber hinausgeht
das Modell selbst zeigt bereits das Schild. Ohne erkanntes
Schild tritt der volle Name an dessen Stelle. Deckungsgleich
@@ -117,14 +135,15 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
{einstellungen.kennzeichen || "Kennzeichen nicht hinterlegt"}
</span>
</div>
</div>
<StatusRow
status={sicherheitsstatus}
title={sicherheitstext}
{...(statusStand ? { subtitle: `Stand ${datumZeit(statusStand)}` } : {})}
onClick={() => geheZu("sicherheit")}
/>
{/* Ohne Zeitstempel-Unterzeile: das Panel zeigt den Stand der Daten in
der Kopfzeile ("vor 3 Std."), nicht unter dem Status. */}
<StatusRow
status={sicherheitsstatus}
title={sicherheitstext}
chevron
onClick={() => geheZu("sicherheit")}
/>
</Tile>
{/* Reihenfolge deckungsgleich mit vHome() im Panel (".tankzeile", eigens
als myAudi-App-Konvention kommentiert, die einzige Kachel im ganzen
@@ -136,11 +155,11 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
am 2026-08-16 aus dem Panel entfernt (Nutzerwunsch) und war hier nie
nachgezogen worden. Panel: "tile flat" - keine Kartenfläche, war
hier fälschlich eine normale (graue) Tile (Parität-Audit). */}
<Tile variant="flat">
<Tile variant="flat" className="dm-tankkachel">
<div className="dm-tankzeile">
<Fig value={deOderStrich(fahrzeug.reichweite)} unit="km" size={60} />
<span className="dm-tankzeile__pct">
{fahrzeug.tankBekannt ? `${de(fahrzeug.tankPct)} %` : ""}
{fahrzeug.tankBekannt ? `${de(fahrzeug.tankPct)} %` : ""}
</span>
</div>
<ProgressBar percent={Math.max(0, Math.min(100, fahrzeug.tankPct))} />
@@ -159,7 +178,7 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
"differentiate controls from content": nur der obere Teil ist der
Knopf zur Service-Seite, die Kilometerstand-Zeile bleibt reine
Anzeige. Deckungsgleich mit vHome() im Panel. */}
<Tile>
<Tile className="dm-servicekachel">
{/* Ohne Termin ist dieser Teil im Panel bewusst kein Knopf (kein
data-go, kein Chevron) - es gibt nichts, wohin er führen könnte.
"differentiate controls from content" gilt auch für die
@@ -167,9 +186,13 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
{service ? (
<button type="button" className="dm-serviceblock" onClick={() => geheZu("service")}>
<span className="ads-eyebrow">Nächster Service:</span>
<span className="dm-serviceblock__chevron" aria-hidden="true">
</span>
{/* Gezeichneter Pfeil wie .go im Panel, kein Schriftzeichen: das
">" war ein 15px-Glyph der Textschrift und damit erkennbar
anders geformt und gewichtet als der Chevron ueberall sonst
(Designpruefung 2026-08-30). */}
<svg className="dm-serviceblock__chevron" viewBox="0 0 6 10" aria-hidden="true">
<polyline points="1,1 5,5 1,9" />
</svg>
{(() => {
const Symbol = SERVICE_SYMBOL[service.art]
const fig = (
@@ -188,89 +211,80 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
fig
)
})()}
<span className="dm-fussnote">
{/* Im Panel ist auch diese Nebenzeile ein `.label` (13px, --fg3),
nicht die kleinere Fußnote (11,5px/300), die hier stand. */}
<span className="ads-eyebrow" style={{ marginTop: "8px" }}>
{service.restKm !== null ? `voraussichtlich am ${datum(service.datum)}` : service.art}
</span>
</button>
) : (
<div className="dm-serviceblock">
<span className="ads-eyebrow">Nächster Service:</span>
<span className="dm-fussnote">kein Eintrag im Wartungsplan</span>
{/* Panel: `<div class="wert">`, 16px in --fg — nicht die Fußnote. */}
<div className="dm-wert">kein Eintrag im Wartungsplan</div>
</div>
)}
<div className="dm-servicezeile">
<span className="dm-servicezeile__k">Kilometerstand</span>
<Fig value={fahrzeug.odoBekannt ? de(fahrzeug.odo) : ""} unit="km" size={20} />
<span className="ads-eyebrow">Kilometerstand</span>
<Fig value={fahrzeug.odoBekannt ? de(fahrzeug.odo) : ""} unit="km" size={20} />
</div>
</Tile>
{/* Zuletzt": eine Kachel mit zwei Blattzeilen, wie teaser() im Panel
links die Bezeichnung mit Strecke bzw. Tankstelle darunter, rechts
der Wert mit Status bzw. Betrag. Vorher waren das zwei
NaviKacheln nebeneinander ohne Überschrift, ohne Strecken-/
Tankstellenangabe und mit Letzte Tankung" statt Letzter
Tankvorgang". Fehlen beide Datensätze, lässt das Panel die Kachel
ganz weg; die Leerzustände hier bleiben als eigene Konvention der
App erhalten (siehe Kapitel 1 des Paritätsaudits). */}
{letzteFahrt || letzteTankung ? (
<Tile className="dm-teaser">
<span className="ads-eyebrow">Zuletzt</span>
{letzteFahrt && (
<Blattzeile
label="Letzte Fahrt"
labelZusatz={`${letzteFahrt.start_address ?? ""}${letzteFahrt.end_address ?? ""}`}
wert={
letzteFahrt.distance_km != null
? `${de(letzteFahrt.distance_km)} km`
: "offen"
}
wertZusatz={letzteFahrt.status}
beiKlick={() => geheZu("trip", letzteFahrt.trip_id)}
letzte={!letzteTankung}
/>
)}
{letzteTankung && (
<Blattzeile
label="Letzter Tankvorgang"
labelZusatz={letzteTankung.station_name ?? ""}
wert={letzteTankung.liters != null ? `${de(letzteTankung.liters, 2)} l` : ""}
wertZusatz={
letzteTankung.fuel_total_eur != null
? `${eur(letzteTankung.fuel_total_eur)}`
: ""
}
beiKlick={() => geheZu("fill", letzteTankung.tank_id)}
letzte
/>
)}
</Tile>
) : (
<Tile>
<span className="ads-eyebrow">Zuletzt</span>
<Leerzustand
titel="Noch keine Fahrt erfasst"
text="Sobald du losfährst, erscheint hier die letzte Fahrt."
/>
</Tile>
)}
{/* Die Standortkarte steht seit der Designprüfung 2026-08-30 hinter
Zuletzt wie im Panel. Die 190px hohe Karte davor drückte die zwei
kurzen, häufig gelesenen Zeilen auf dem Telefon unter den ersten
Bildschirm. */}
<StandortVorschau geheZu={() => geheZu("standort")} />
<div className="dm-raster">
{letzteFahrt ? (
<NaviKachel
titel="Letzte Fahrt"
wert={
<>
<div className="dm-navikachel__zahl">
{deOderStrich(letzteFahrt.distance_km, 1)} km
</div>
<small>
{datum(letzteFahrt.ts_start)} · {dauer(letzteFahrt.duration_s)}
</small>
</>
}
beiKlick={() => geheZu("trip", letzteFahrt.trip_id)}
/>
) : (
<Tile>
<span className="ads-eyebrow">Letzte Fahrt</span>
<Leerzustand
titel="Noch keine Fahrt erfasst"
text="Sobald du losfährst, erscheint hier die letzte Fahrt."
/>
</Tile>
)}
{letzteTankung ? (
<NaviKachel
titel="Letzte Tankung"
wert={
<>
<div className="dm-navikachel__zahl">
{deOderStrich(letzteTankung.liters, 2)} l
</div>
<small>
{datum(letzteTankung.ts)} ·{" "}
{letzteTankung.fuel_total_eur != null
? `${eur(letzteTankung.fuel_total_eur)}`
: "Betrag offen"}
</small>
</>
}
beiKlick={() => geheZu("fill", letzteTankung.tank_id)}
/>
) : (
<Tile>
<span className="ads-eyebrow">Letzte Tankung</span>
<Leerzustand
titel="Noch keine Tankung erfasst"
text="Tankvorgänge erkennt die App automatisch am steigenden Füllstand — oder du trägst sie selbst ein."
/>
</Tile>
)}
</div>
<div className="dm-aktionen">
<button
type="button"
className="dm-textknopf"
onClick={() => void aktualisieren()}
disabled={aktualisiert}
>
{aktualisiert ? "Frage das Fahrzeug …" : "Jetzt beim Fahrzeug nachfragen"}
</button>
</div>
</>
)
}
+318 -162
View File
@@ -9,78 +9,159 @@
import { useState } from "react"
import { ActionButton, Feld, Tile } from "@audi-dash/ui"
import { ActionButton, Feld, Fig, ProgressBar, Switch, 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"
import { Leerzustand, Wertzeile, Werteliste, Zahnradknopf } from "./bausteine"
interface Beitragsposten {
bez?: string
betrag?: number
sf?: string
sfAlt?: string
satz?: string
}
/* Aufbau 1:1 wie vVers(): vier Kacheln mit je einem Zahnrad zum Bearbeiten
Notruf mit beiden Rufnummern als Wählknöpfen, der Jahresbeitrag mit großer
Zahl, Aufteilung und Balken, der Vertrag mit allen Zeilen und dem
Details-Knopf, zuletzt die Kfz-Steuer. Bis 2026-08-30 stand hier eine
einzelne Zusammenfassungskachel plus fünf Navigationskacheln: die
Beitragsaufteilung, die Selbstbeteiligungen, die Schadenfreiheitsklassen und
die Inlandsnummer fehlten dadurch ganz. */
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
const teile = (v["teile"] ?? []) as Beitragsposten[]
const beitrag = (v["beitrag"] as number) ?? 0
const selbst = (v["selbst"] ?? []) as [string, string][]
const service = (v["service"] as string) ?? ""
const notfall = (v["notfall"] as string) ?? ""
const balkenfarben = ["var(--fg)", "var(--fg2)", "var(--fg3)"]
return (
<>
<Tile>
<span className="ads-eyebrow">Versicherung</span>
<Tile className="dm-kachel--zahnrad">
<span className="ads-eyebrow">Versicherung - Schaden Notruf 24/7</span>
<Zahnradknopf beschriftung="Rufnummern ändern" beiKlick={() => geheZu("notruf")} />
<a className="ads-action dm-waehlknopf" href={`tel:${service.replace(/\s/g, "")}`}>
Inland · {service}
</a>
<a className="ads-action dm-waehlknopf" href={`tel:${notfall.replace(/\s/g, "")}`}>
Ausland · {notfall}
</a>
</Tile>
<Tile className="dm-kachel--zahnrad">
<span className="ads-eyebrow">
Jahresbeitrag ab {v["gueltigAb"] ? datum(v["gueltigAb"] as string) : ""}
</span>
<Zahnradknopf beschriftung="Beitrag anpassen" beiKlick={() => geheZu("beitrag")} />
<div style={{ marginTop: "var(--sp-3)" }}>
<Fig value={eur(beitrag)} unit="€" size={46} />
</div>
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
{(v["zahlweise"] as string) ?? ""} · enthält {eur((v["steueranteil"] as number) ?? 0)}
Versicherungsteuer ({(v["steuersatz"] as string) ?? ""})
</span>
<Werteliste
kinder={teile.map((t, i) => (
<Wertzeile
key={`${t.bez ?? i}`}
label={
<>
{t.bez}
{t.sf && (
<>
<br />
<span className="dm-beitragsposten__sf">
{t.sf} · Beitragssatz {t.satz}
</span>
</>
)}
</>
}
wert={`${eur(t.betrag ?? 0)}`}
zusatz={`${beitrag ? de(((t.betrag ?? 0) / beitrag) * 100, 0) : 0} %`}
/>
))}
/>
<div style={{ marginTop: "18px" }}>
<ProgressBar
showLegend={false}
segments={teile.map((t, i) => ({
value: t.betrag || 0.01,
color: balkenfarben[i] ?? "var(--fg3)",
}))}
/>
</div>
</Tile>
<Tile className="dm-kachel--zahnrad">
<span className="ads-eyebrow">Vertrag</span>
<Zahnradknopf beschriftung="Vertrag bearbeiten" beiKlick={() => geheZu("vertrag")} />
<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="Gesellschaft" wert={(v["gesellschaft"] as string) || ""} />
<Wertzeile label="Umfang" wert={(v["vertrag"] as string) || ""} />
<Wertzeile label="Vertragsnummer" wert={(v["nummer"] as string) || ""} />
{selbst.map(([bezeichnung, wert], i) => (
<Wertzeile
key={`${bezeichnung}-${i}`}
label={`Selbstbeteiligung ${bezeichnung}`}
wert={wert}
/>
))}
<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) : "—"}
label="Schadenfreiheitsklasse"
wert={teile.map((t) => t.sf).filter(Boolean).join(" / ")}
zusatz={`zuvor ${teile.map((t) => t.sfAlt).filter(Boolean).join(" / ")}`}
/>
</>
}
/>
<ActionButton onClick={() => geheZu("vertragsdetails")}>Details</ActionButton>
</Tile>
<div className="dm-raster">
<NaviKachel titel="Beitrag" wert="Einzelposten bearbeiten" beiKlick={() => geheZu("beitrag")} />
<NaviKachel
titel="Vertrag"
wert="Gesellschaft, Nummer, Selbstbeteiligung"
beiKlick={() => geheZu("vertrag")}
/>
<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>
<SteuerKachel beiBearbeiten={() => geheZu("steuer")} />
</>
)
}
/** `vSteuer()` — die Kfz-Steuer-Kachel am Ende der Versicherungsseite. */
function SteuerKachel({ beiBearbeiten }: { beiBearbeiten: () => void }) {
const { fahrzeug } = useDaten()
if (!fahrzeug) return null
const t = fahrzeug.steuer
const zeitraum = (t["zeitraum"] as string) ?? ""
return (
<Tile className="dm-kachel--zahnrad dm-steuerkachel">
<span className="ads-eyebrow">Kfz-Steuer</span>
<Zahnradknopf beschriftung="Kfz-Steuer bearbeiten" beiKlick={beiBearbeiten} />
<Werteliste
kinder={
<>
<Wertzeile
label="Betrag"
wert={`${eur((t["betrag"] as number) ?? 0)}`}
zusatz={zeitraum === "jährlich" ? "im Jahr" : zeitraum}
/>
<Wertzeile
label="Nächste Fälligkeit"
wert={t["faellig"] ? datum(t["faellig"] as string) : "unbekannt"}
/>
</>
}
/>
</Tile>
)
}
export function Beitrag() {
const { fahrzeug, profilSpeichern } = useDaten()
const [laeuft, setzeLaeuft] = useState(false)
@@ -91,6 +172,7 @@ export function Beitrag() {
const teile = (v["teile"] ?? []) as Beitragsposten[]
const [entwurf, setzeEntwurf] = useState<Beitragsposten[]>(() => teile.map((t) => ({ ...t })))
const [gueltigAb, setzeGueltigAb] = useState((v["gueltigAb"] as string) ?? "")
const speichern = async () => {
setzeLaeuft(true)
@@ -100,7 +182,7 @@ export function Beitrag() {
await profilSpeichern({
fahrzeug: {
...fahrzeug,
versicherung: { ...v, teile: entwurf, beitrag: summeBetrag },
versicherung: { ...v, teile: entwurf, beitrag: summeBetrag, gueltigAb },
},
})
setzeGespeichert(true)
@@ -109,50 +191,64 @@ export function Beitrag() {
}
}
if (entwurf.length === 0) {
return (
<Leerzustand
titel="Keine Beitragsposten"
text="Sobald im Profil Einzelposten hinterlegt sind, kannst du sie hier bearbeiten."
/>
)
}
const setzeFeld = (index: number, feld: "sf" | "betrag", wert: string) =>
setzeEntwurf((alt) => {
const kopie = alt.map((p) => ({ ...p }))
while (kopie.length <= index) kopie.push({})
kopie[index]![feld] =
feld === "betrag" ? (Number(wert.replace(",", ".")) as never) : (wert as never)
return kopie
})
/* Felder und Beschriftungen wortgleich mit vBeitrag(): davor fehlten hier
Gültig ab" und beide Schadenfreiheitsklassen, die Kostenfelder hießen nur
Haftpflicht"/„Vollkasko …" statt Kosten ", und statt der Erklärung stand
eine Summenzeile, die es im Panel nicht gibt (die Summe rechnet sich beim
Speichern ohnehin neu). */
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))}`}
/>
}
/>
<span className="ads-eyebrow">Beitrag anpassen</span>
<Feld label="Gültig ab">
<input
className="dm-eingabe"
type="date"
value={gueltigAb}
onChange={(e) => setzeGueltigAb(e.target.value)}
/>
</Feld>
<Feld label="Schadenfreiheitsklasse Haftpflicht">
<input
className="dm-eingabe"
value={entwurf[0]?.sf ?? ""}
onChange={(e) => setzeFeld(0, "sf", e.target.value)}
/>
</Feld>
<Feld label="Schadenfreiheitsklasse Vollkasko">
<input
className="dm-eingabe"
value={entwurf[1]?.sf ?? ""}
onChange={(e) => setzeFeld(1, "sf", e.target.value)}
/>
</Feld>
{(["Kosten Haftpflicht", "Kosten Vollkasko", "Kosten Schutzbrief"] as const).map(
(beschriftung, index) => (
<Feld key={beschriftung} label={beschriftung} unit="€" last={index === 2}>
<input
className="dm-eingabe"
inputMode="decimal"
value={entwurf[index]?.betrag ?? 0}
onChange={(e) => setzeFeld(index, "betrag", e.target.value)}
/>
</Feld>
),
)}
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
Der Jahresbeitrag oben ist die Summe dieser drei Posten und rechnet sich beim Speichern
automatisch neu &quot;Gültig ab&quot; steuert den Titel der Jahresbeitrag-Kachel
</span>
{gespeichert && <p className="dm-erfolg">Gespeichert.</p>}
<div style={{ marginTop: 14 }}>
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
<ActionButton variant="primary" onClick={() => void speichern()} disabled={laeuft}>
{laeuft ? "Speichere …" : "Speichern"}
</ActionButton>
</div>
@@ -169,6 +265,10 @@ export function Vertrag() {
const [selbst, setzeSelbst] = useState<string[][]>(() =>
((v["selbst"] as (string | number)[][]) ?? []).map(([b, w]) => [String(b ?? ""), String(w ?? "")]),
)
const [sfAlt, setzeSfAlt] = useState<string[]>(() => {
const teile = (v["teile"] ?? []) as Beitragsposten[]
return [teile[0]?.sfAlt ?? "", teile[1]?.sfAlt ?? ""]
})
const [laeuft, setzeLaeuft] = useState(false)
const [gespeichert, setzeGespeichert] = useState(false)
@@ -178,10 +278,13 @@ export function Vertrag() {
setzeLaeuft(true)
setzeGespeichert(false)
try {
const teile = ((v["teile"] ?? []) as Beitragsposten[]).map((t, i) =>
i < 2 ? { ...t, sfAlt: sfAlt[i] ?? "" } : t,
)
await profilSpeichern({
fahrzeug: {
...fahrzeug,
versicherung: { ...v, gesellschaft, vertrag: umfang, nummer, selbst },
versicherung: { ...v, gesellschaft, vertrag: umfang, nummer, selbst, teile },
},
})
setzeGespeichert(true)
@@ -245,51 +348,97 @@ export function Vertrag() {
</span>
</Feld>
))}
{selbst.length === 0 && <p className="dm-fussnote">Keine Selbstbeteiligung hinterlegt.</p>}
<div style={{ marginTop: 14 }}>
<ActionButton onClick={() => setzeSelbst((alt) => [...alt, ["", ""]])}>
+ Position hinzufügen
</ActionButton>
</div>
{selbst.length === 0 && (
<span className="ads-eyebrow" style={{ marginTop: "8px" }}>
Keine Selbstbeteiligung hinterlegt
</span>
)}
<ActionButton onClick={() => setzeSelbst((alt) => [...alt, ["", ""]])}>
+ Position hinzufügen
</ActionButton>
</Tile>
{/* Die vorherige Schadenfreiheitsklasse fehlte hier ganz im Panel ist
das eine eigene Kachel (vVertragBearbeiten()), die aktuelle Klasse
bleibt bewusst dem Beitrag-Formular vorbehalten. */}
<Tile>
<span className="ads-eyebrow">Schadenfreiheitsklasse · zuvor</span>
<Feld label="Haftpflicht">
<input
className="dm-eingabe"
value={sfAlt[0] ?? ""}
onChange={(e) => {
const wert = e.target.value
setzeSfAlt((alt) => [wert, alt[1] ?? ""])
}}
/>
</Feld>
<Feld label="Vollkasko" last>
<input
className="dm-eingabe"
value={sfAlt[1] ?? ""}
onChange={(e) => {
const wert = e.target.value
setzeSfAlt((alt) => [alt[0] ?? "", wert])
}}
/>
</Feld>
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
Die aktuelle Schadenfreiheitsklasse änderst du unter &quot;Beitrag anpassen&quot; hier
steht nur der vorherige Wert für den Vergleich.
</span>
</Tile>
{gespeichert && <p className="dm-erfolg">Gespeichert.</p>}
<div style={{ marginTop: 14 }}>
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
{laeuft ? "Speichere …" : "Speichern"}
</ActionButton>
</div>
<ActionButton variant="primary" onClick={() => void speichern()} disabled={laeuft}>
{laeuft ? "Speichere …" : "Speichern"}
</ActionButton>
</>
)
}
/**
* `versicherung.leistungen` ist eine Liste von Gruppen mit je einer
* Überschrift und einer Liste von Klartext-Posten nicht, wie hier bis
* 2026-08-30 angenommen, eine Liste von `{bez, wert}`-Paaren. Aus dem falschen
* Typ entstanden auf dieser Seite zwölf Zeilen aus lauter Strichen: `l.bez`
* und `l.wert` waren an jedem Eintrag undefiniert. Derselbe Fehlertyp wie im
* Schutzbrief (Kapitel 4 des vorigen Audits), nur eine Ebene weiter oben.
*/
interface Leistungsgruppe {
gruppe?: string
posten?: string[]
}
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 leistungen = (v["leistungen"] ?? []) as Leistungsgruppe[]
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 ?? "—"} />
))}
/>
)}
{/* Der Schutzbrief steht im Panel oben, als Kachel-Knopf mit Chevron,
nicht als Navigationskachel unter den Leistungen. */}
<Tile variant="button" chevron onClick={() => geheZu("schutz")}>
<span className="ads-eyebrow">Schutzbrief Mobilität</span>
<div className="dm-wert">Panne, Unfall, Diebstahl, Fahrerausfall</div>
<span className="ads-eyebrow" style={{ marginTop: "10px" }}>
Nummern und Leistungen für den Ernstfall
</span>
</Tile>
<NaviKachel
titel="Schutzbrief"
wert="Bedingungen im Detail"
beiKlick={() => geheZu("schutz")}
/>
{leistungen.map((gruppe, gi) => (
<Tile key={`${gruppe.gruppe}-${gi}`}>
<span className="ads-eyebrow">{gruppe.gruppe}</span>
<Werteliste
kinder={(gruppe.posten ?? []).map((posten, i) => (
<Wertzeile key={`${posten}-${i}`} label={posten} wert="" />
))}
/>
</Tile>
))}
</>
)
}
@@ -347,7 +496,9 @@ export function Schutzbrief() {
<Wertzeile key={x.bez ?? i} label={x.bez ?? ""} wert={x.grenze ?? ""} zusatz={x.zusatz} />
))}
/>
<p className="dm-fussnote">Mobilität wird binnen 60 Minuten organisiert.</p>
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
Mobilität wird binnen 60 Minuten organisiert
</span>
</Tile>
)}
@@ -359,9 +510,15 @@ export function Schutzbrief() {
<li key={a}>{a}</li>
))}
</ul>
{Boolean(s["gilt"]) && <p className="dm-fussnote">Gilt in {s["gilt"] as string}</p>}
{Boolean(s["gilt"]) && (
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
Gilt in {s["gilt"] as string}
</span>
)}
{Boolean(s["versichert"]) && (
<p className="dm-fussnote">Versichert: {s["versichert"] as string}</p>
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
Versichert: {s["versichert"] as string}
</span>
)}
</Tile>
)}
@@ -371,7 +528,21 @@ export function Schutzbrief() {
<span className="ads-eyebrow">{g.titel}</span>
<Werteliste
kinder={(g.posten ?? []).map(([bez, wert, zusatz], i) => (
<Wertzeile key={bez ?? i} label={bez} wert={wert} zusatz={zusatz} />
<Wertzeile
key={bez ?? i}
label={
<>
{bez}
{zusatz && (
<>
<br />
<span className="dm-beitragsposten__sf">{zusatz}</span>
</>
)}
</>
}
wert={wert}
/>
))}
/>
</Tile>
@@ -396,8 +567,12 @@ export function Schutzbrief() {
<li key={x}>{x}</li>
))}
</ul>
{Boolean(s["rang"]) && <p className="dm-fussnote">{s["rang"] as string}</p>}
{Boolean(s["stand"]) && <p className="dm-fussnote">{s["stand"] as string}</p>}
{Boolean(s["rang"]) && <span className="dm-erklaerung">{s["rang"] as string}</span>}
{Boolean(s["stand"]) && (
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
{s["stand"] as string}
</span>
)}
</Tile>
)}
</>
@@ -428,9 +603,15 @@ export function Notruf() {
}
return (
/* Beschriftungen wie vNotrufBearbeiten(): Rufnummern ändern" mit
Inland"/„Ausland". Hier hießen sie Nummern im Notfall" mit
Service"/„Notfall" dieselben zwei Felder, aber anders benannt als in
der Kachel, aus der man kommt. Die beiden Wählknöpfe entfallen: das
Panel bietet das Wählen auf der Versicherungsseite selbst an, nicht im
Bearbeiten-Formular. */
<Tile>
<span className="ads-eyebrow">Nummern im Notfall</span>
<Feld label="Service">
<span className="ads-eyebrow">Rufnummern ändern</span>
<Feld label="Inland">
<input
className="dm-eingabe"
type="tel"
@@ -438,7 +619,7 @@ export function Notruf() {
onChange={(e) => setzeService(e.target.value)}
/>
</Feld>
<Feld label="Notfall" last>
<Feld label="Ausland" last>
<input
className="dm-eingabe"
type="tel"
@@ -446,23 +627,9 @@ export function Notruf() {
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}>
<ActionButton variant="primary" onClick={() => void speichern()} disabled={laeuft}>
{laeuft ? "Speichere …" : "Speichern"}
</ActionButton>
</div>
@@ -504,14 +671,23 @@ export function Steuer() {
}
/>
</Feld>
{/* Auswahlliste statt freiem Text, und Nächste Fälligkeit" statt
Fällig am" wie vSteuerBearbeiten(). Die Zeilen Finanzamt,
Grundlage und Monatlich gerechnet" sowie die Fußnote hatten im
Panel keine Entsprechung und sind entfallen; die dazu gespeicherten
Profilwerte bleiben unangetastet. Lastschrift ist dort ein Schalter,
keine Anzeigezeile. */}
<Feld label="Zeitraum">
<input
className="dm-eingabe"
value={(entwurf["zeitraum"] as string) ?? ""}
<select
className="dm-auswahl"
value={(entwurf["zeitraum"] as string) ?? "jährlich"}
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, zeitraum: e.target.value }))}
/>
>
<option value="jährlich">jährlich</option>
<option value="halbjährlich">halbjährlich</option>
</select>
</Feld>
<Feld label="Fällig am">
<Feld label="Nächste Fälligkeit">
<input
className="dm-eingabe"
type="date"
@@ -519,39 +695,19 @@ export function Steuer() {
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 label="Lastschrift erteilt" last>
<Switch
checked={entwurf["lastschrift"] === true}
onChange={(an: boolean) => setzeEntwurf((alt) => ({ ...alt, lastschrift: an }))}
aria-label="Lastschrift erteilt"
/>
</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}>
<ActionButton variant="primary" 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>
)
}
+14 -32
View File
@@ -1,18 +1,19 @@
/**
* Löschweg für Listenzeilen Wischen **und** ein sichtbares Menü.
* Löschweg für Listenzeilen: nach links wischen, dann Löschen" antippen
* genau wie `swipeZeile()` im Panel.
*
* Behebt einen offenen Befund des Design-Audits (AUDIT_2026-08-10.md §4.1):
* Im alten Panel war Löschen ausschließlich über eine Wischgeste erreichbar.
* Wer mit Sprachausgabe oder Schaltersteuerung bedient, führt keine
* Wischgesten aus und konnte Fahrten und Tankvorgänge deshalb gar nicht
* löschen. Das Menü ist immer da, ein richtiger Knopf, mit Tastatur erreichbar.
* Bis 2026-08-30 saß rechts neben jeder Zeile zusätzlich ein dauerhaft
* sichtbarer ···"-Knopf mit Löschmenü. Er stammte aus einem Befund des
* Design-Audits (AUDIT_2026-08-10.md §4.1: Wischgesten sind mit Sprachausgabe
* und Schaltersteuerung unerreichbar). Der Eigentümer hat entschieden, dass
* Barrierefreiheit für diese beiden Anwendungen nicht gebraucht wird und der
* Knopf entfallen soll er hatte im Panel keine Entsprechung und schob jede
* Listenzeile um 44px zusammen.
*/
import { useState, type ReactNode } from "react"
import type { ReactNode } from "react"
import { Popup, PopupMenuItem, SwipeRow } from "@audi-dash/ui"
import { SymbolMehr } from "../symbole"
import { SwipeRow } from "@audi-dash/ui"
export function Zeilenmenue({
beiLoeschen,
@@ -23,32 +24,13 @@ export function Zeilenmenue({
loeschFrage: string
kinder: ReactNode
}) {
const [menueOffen, setzeMenueOffen] = useState(false)
const loeschen = () => {
setzeMenueOffen(false)
if (window.confirm(loeschFrage)) beiLoeschen()
}
return (
<div className="dm-zeile">
<SwipeRow onDelete={loeschen} className="dm-zeile__wisch">
{kinder}
</SwipeRow>
<button
type="button"
className="dm-zeile__menue"
aria-label="Weitere Aktionen"
onClick={() => setzeMenueOffen(true)}
>
<SymbolMehr groesse={18} />
</button>
<Popup open={menueOffen} onClose={() => setzeMenueOffen(false)} variant="menu" anchor="center">
<PopupMenuItem destructive onClick={loeschen}>
Löschen
</PopupMenuItem>
<PopupMenuItem onClick={() => setzeMenueOffen(false)}>Abbrechen</PopupMenuItem>
</Popup>
</div>
<SwipeRow onDelete={loeschen} deleteLabel="Löschen">
{kinder}
</SwipeRow>
)
}
+150 -1
View File
@@ -3,11 +3,14 @@
* gebraucht wird, aber zu speziell für das Design-System ist.
*/
import type { ReactNode } from "react"
import { useRef, useState, type ReactNode } from "react"
import { ActionButton, Tile } from "@audi-dash/ui"
import type { SicherheitsPunkt } from "../api"
import { useDaten } from "../daten/DatenKontext"
import { SymbolZahnradVoll } from "../symbole"
import { Bild } from "./Bild"
/** Kraftstoffsorten zur Auswahl - dieselbe feste Liste wie `KRAFTSTOFFSORTEN`
im Panel, geteilt zwischen Tanken.tsx (Neuanlegen) und TankDetail.tsx
@@ -69,6 +72,152 @@ export function NaviKachel({
)
}
/**
* Fahrzeugfoto mit Antipp-Menü zum Ersetzen und Löschen `data-bildklick`
* plus `.bildmenu` im Panel. Die App konnte Fotos bisher gar nicht ändern und
* verwies auf die Verwaltung in Home Assistant; die Dienste dafür
* (`bild_hochladen`/`bild_loeschen`) gibt es längst.
*/
export function BildMitMenue({
datei,
beschreibung,
name,
groesse,
menueLage,
dateiZeigen,
}: {
datei: string
beschreibung: string
name: string
groesse?: "default" | "mini" | "round"
/** "ansicht" = unter dem Bild zentriert, "rad" = links oben (wie im Panel). */
menueLage?: "ansicht" | "rad"
dateiZeigen?: boolean
}) {
const { api } = useDaten()
const [offen, setzeOffen] = useState(false)
const datei_ = useRef<HTMLInputElement | null>(null)
const hochladen = async (gewaehlt: File) => {
const base64 = await new Promise<string>((fertig, fehler) => {
const leser = new FileReader()
leser.onload = () => fertig(String(leser.result).split(",")[1] ?? "")
leser.onerror = () => fehler(leser.error)
leser.readAsDataURL(gewaehlt)
})
await api.bildHochladen(datei, base64)
setzeOffen(false)
}
return (
<div className="dm-bildklick">
<button type="button" className="dm-bildklick__flaeche" onClick={() => setzeOffen(true)}>
<Bild
datei={datei}
beschreibung={beschreibung}
name={name}
{...(groesse ? { groesse } : {})}
{...(dateiZeigen ? { dateiZeigen: true } : {})}
/>
</button>
<input
ref={datei_}
type="file"
accept="image/*"
hidden
onChange={(e) => {
const f = e.target.files?.[0]
if (f) void hochladen(f)
e.target.value = ""
}}
/>
{offen && (
<>
<div className="dm-bildmenu-fang" onClick={() => setzeOffen(false)} />
<div className={menueLage === "rad" ? "dm-bildmenu dm-bildmenu--rad" : "dm-bildmenu"}>
<button type="button" onClick={() => datei_.current?.click()}>
Foto ersetzen
</button>
<button
type="button"
className="dm-bildmenu__loeschen"
onClick={() => {
setzeOffen(false)
if (bestaetigen("Foto löschen?")) void api.bildLoeschen(datei)
}}
>
Löschen
</button>
</div>
</>
)}
</div>
)
}
/**
* Zahnrad in der rechten oberen Ecke einer Kachel `.zahnrad` im Panel. Führt
* zur Bearbeitung dessen, was die Kachel zeigt. Die Kachel selbst muss dafür
* `position: relative` tragen (`.ads-tile` tut das über `.dm-kachel--zahnrad`).
*/
export function Zahnradknopf({ beschriftung, beiKlick }: { beschriftung: string; beiKlick: () => void }) {
return (
<button type="button" className="dm-zahnrad" aria-label={beschriftung} onClick={beiKlick}>
<SymbolZahnradVoll groesse={17} />
</button>
)
}
/**
* Listenzeile im Zuschnitt von `.leaf` im Panel: links Beschriftung mit
* kleinerem Zusatz darunter, rechts der Wert mit eigenem Zusatz, danach der
* Chevron. Genau diese Aufteilung benutzt das Panel für die Zuletzt-Kachel der
* Übersicht, die Fahrten- und die Tankliste sowie die Batterie-Messwerte.
*/
export function Blattzeile({
label,
labelZusatz,
wert,
wertZusatz,
beiKlick,
letzte,
}: {
label: ReactNode
labelZusatz?: ReactNode
wert: ReactNode
wertZusatz?: ReactNode
/** Ohne Klickziel wird die Zeile ein reines <div>, wie im Panel. */
beiKlick?: () => void
/** Unterste Zeile einer Kachel — dort entfällt die Trennlinie. */
letzte?: boolean
}) {
const klassen = letzte ? "dm-blatt dm-blatt--letzte" : "dm-blatt"
const inhalt = (
<>
<span className="dm-blatt__k">
{label}
{labelZusatz !== undefined && <small>{labelZusatz}</small>}
</span>
<span className="dm-blatt__v">
{wert}
{wertZusatz !== undefined && <small>{wertZusatz}</small>}
</span>
{beiKlick && (
<svg className="dm-blatt__chevron" viewBox="0 0 6 10" aria-hidden="true">
<polyline points="1,1 5,5 1,9" />
</svg>
)}
</>
)
return beiKlick ? (
<button type="button" className={klassen} onClick={beiKlick}>
{inhalt}
</button>
) : (
<div className={klassen}>{inhalt}</div>
)
}
/**
* Ein Wert mit Beschriftung. " statt eines erfundenen Werts, wenn nichts
* bekannt ist die Regel zieht sich durch die ganze App.
+30 -4
View File
@@ -9,21 +9,47 @@ import { zugangLesenSynchron } from "./zugang"
export interface Bildplatz {
name: string
/**
* Angezeigte Bezeichnung des Fotos wortgleich mit `label` in BILDER im
* Panel. Sie steht als Alternativtext am Bild und als Beschriftung im
* Platzhalter; hier standen vorher eigene, längere Formulierungen
* (Auto von der Seite" statt „Auto Seite").
*/
beschreibung: string
datei: string
}
/** Die sieben Plätze der Fotogalerie unter „Mein Audi". */
export const BILDPLAETZE: Bildplatz[] = [
{ name: "Seitenansicht", beschreibung: "Auto von der Seite", datei: "seitenansicht.webp" },
{ name: "Front schräg", beschreibung: "Auto von vorn schräg", datei: "front-schraeg.webp" },
{ name: "Frontansicht", beschreibung: "Auto von vorn", datei: "frontansicht.webp" },
{ name: "Heckansicht", beschreibung: "Auto von hinten", datei: "heckansicht.webp" },
{ name: "Seitenansicht", beschreibung: "Auto Seite", datei: "seitenansicht.webp" },
{ name: "Front schräg", beschreibung: "Auto Front schräg", datei: "front-schraeg.webp" },
{ name: "Frontansicht", beschreibung: "Auto Front", datei: "frontansicht.webp" },
{ name: "Heckansicht", beschreibung: "Auto Heck", datei: "heckansicht.webp" },
{ name: "Cockpit", beschreibung: "Innenraum Cockpit", datei: "cockpit.webp" },
{ name: "Scheinwerfer", beschreibung: "Scheinwerfer", datei: "scheinwerfer.webp" },
{ name: "Sitze", beschreibung: "Innenraum Sitze", datei: "sitze.webp" },
]
/**
* Bei montierten Winterrädern zeigt die Seitenansicht das Winterfoto
* `SEITE_WINTER`/`bildInfo()` im Panel. Fehlte hier bisher ganz.
*/
export const SEITE_WINTER = {
beschreibung: "Auto Seite mit Winterrädern",
datei: "seitenansicht-winter.webp",
} as const
/** Datei und Bezeichnung eines Galerieplatzes, Winterräder berücksichtigt. */
export function bildInfo(
platz: Bildplatz,
winterraeder: boolean,
): { datei: string; beschreibung: string } {
if (platz.name === "Seitenansicht" && winterraeder) {
return { datei: SEITE_WINTER.datei, beschreibung: SEITE_WINTER.beschreibung }
}
return { datei: platz.datei, beschreibung: platz.beschreibung }
}
/** Zusätzlich erlaubt, aber außerhalb der Galerie geführt. */
export const WEITERE_BILDER = [
"seitenansicht-winter.webp",
+3 -3
View File
@@ -81,7 +81,7 @@ export function SeiteFuer(props: SeitenProps) {
case "werkstatt":
return <Werkstatt />
case "sbuch":
return <Servicebuch />
return <Servicebuch id={route.id} geheZu={geheZu} />
case "reifen":
return <Reifen />
case "vers":
@@ -101,11 +101,11 @@ export function SeiteFuer(props: SeitenProps) {
case "trips":
return <Fahrten geheZu={geheZu} />
case "trip":
return <FahrtDetail id={route.id} />
return <FahrtDetail id={route.id} geheZu={geheZu} />
case "fuel":
return <Tanken geheZu={geheZu} />
case "fill":
return <TankDetail id={route.id} />
return <TankDetail id={route.id} geheZu={geheZu} />
case "stat":
return <Statistik />
case "live":
+7 -5
View File
@@ -126,7 +126,9 @@ describe("Inhalte kommen wirklich aus den Daten", () => {
it("gruppiert Fahrten nach Jahr", async () => {
zeige("trips")
await waitFor(() => expect(screen.getByText("2026")).toBeTruthy())
expect(screen.getByText("42,5 km")).toBeTruthy()
// Ganze Kilometer in der Liste, wie vTrips() im Panel (`de(t.distance_km)`
// ohne Nachkommastelle) - die Nachkommastelle steht auf der Einzelfahrt.
expect(screen.getByText("43 km")).toBeTruthy()
})
it("zeigt bei einer Fahrt ohne Position keine erfundene Route", async () => {
@@ -143,8 +145,8 @@ describe("Inhalte kommen wirklich aus den Daten", () => {
it("bietet beim Eintragen einer Fahrt alle Felder, nicht nur Start/Ende/Art", async () => {
zeige("trips")
await waitFor(() => expect(screen.getByText("Fahrt eintragen")).toBeTruthy())
fireEvent.click(screen.getByText("Fahrt eintragen"))
await waitFor(() => expect(screen.getByText("Fahrt erfassen")).toBeTruthy())
fireEvent.click(screen.getByText("Fahrt erfassen"))
expect(screen.getByText("Startort")).toBeTruthy()
expect(screen.getByText("Zielort")).toBeTruthy()
expect(screen.getByText("Startkilometer")).toBeTruthy()
@@ -154,8 +156,8 @@ describe("Inhalte kommen wirklich aus den Daten", () => {
it("lässt eine bestehende Fahrt bearbeiten, vorbelegt mit ihren Werten", async () => {
zeige("trip", "t1")
await waitFor(() => expect(screen.getByText("Bearbeiten")).toBeTruthy())
fireEvent.click(screen.getByText("Bearbeiten"))
await waitFor(() => expect(screen.getByText("Werte bearbeiten")).toBeTruthy())
fireEvent.click(screen.getByText("Werte bearbeiten"))
expect(screen.getByText("Fahrt bearbeiten")).toBeTruthy()
// t1 ist "arbeitsweg" (beispieldaten.ts) - das Auswahlfeld muss das übernehmen,
// nicht immer bei "privat" neu anfangen.
+3
View File
@@ -78,6 +78,9 @@
text-transform: none;
font-weight: 400;
color: var(--fg3);
/* .label im Panel setzt line-height:1.3; ohne das stehen mehrzeilige
Beschriftungen hier enger als dort. */
line-height: 1.3;
}
/* .quad .l im Panel (Statistik-Kacheln): 11px, keine Großschreibung, normale
+16 -7
View File
@@ -27,11 +27,20 @@ body {
/* Wurzel der App: setzt Schrift, Farben und Zahlendarstellung (.ads-root aus
der Bibliothek) und spannt sich über die volle Höhe. */
/* `-webkit-font-smoothing: antialiased` und `letter-spacing: 0` setzt das
Panel auf `.phone` (audi-dashboard-ios.css). Ohne die Graustufen-Glättung
rendert dieselbe Schrift in derselben Größe rund 5 % breiter live
gemessen: Fahrt ohne Ortsangabe" 173px hier gegen 165,4px im Panel. Das
betraf jede Zeichenkette der App und war der Grund, warum Listenzeilen hier
umbrachen, die im Panel einzeilig stehen. */
.dm-wurzel {
height: 100%;
display: flex;
overflow: hidden;
font-weight: 400;
letter-spacing: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Tastaturbedienung: im alten Panel als Audit-Befund nachgerüstet, hier von
@@ -41,20 +50,20 @@ body {
outline-offset: 2px;
}
/* Ohne Bildlaufleiste, wie `main#view` im Panel (`scrollbar-width:none` plus
::-webkit-scrollbar mit Breite 0). Die 8px breite Leiste hier nahm der
Inhaltsfläche auf jedem Bildschirm 8px Breite weg - live gemessen 335px
Kachelbreite statt der 343px des Panels. */
.dm-scroll {
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.dm-scroll::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.dm-scroll::-webkit-scrollbar-thumb {
background: var(--line-strong);
border-radius: var(--r-pill);
width: 0;
height: 0;
}
/* Formularfelder: mindestens 16px, sonst zoomt iOS Safari beim Fokus die
+130 -11
View File
@@ -13,34 +13,116 @@
/* ------------------------------------------------------------- Kopfzeile */
/* 1:1 die .topbar des Panels (audi-dashboard-ios.css:
`padding:56px 20px 4px 16px; gap:4px`, keine Trennlinie). Die 56px oben sind
dort die fest einkompilierte Statusleisten-Reserve - das Panel läuft auf dem
Telefon als PWA und kennt env() gar nicht. Diese App läuft zusätzlich in
einer nativen Hülle, wo die echte Reserve aus dem Gerät kommt; max() liefert
deshalb im Browser exakt die 56px des Panels und auf dem Gerät den realen
Sicherheitsabstand. */
.dm-kopf {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: calc(env(safe-area-inset-top, 0px) + var(--sp-4)) var(--sp-5) var(--sp-3);
border-bottom: 1px solid var(--line);
gap: var(--sp-1);
/* Nur der sichere Bereich, kein Aufschlag: das Panel setzt hier zwar
ebenfalls 56px (.topbar in audi-dashboard-ios.css), die liegen dort
aber unter der eigenen Kopfleiste von Home Assistant. In der nativen
Huelle steht darueber nichts ausser der Statusleiste, dieselbe Zahl
ergab deshalb ein sichtbar leeres Band ueber der Ueberschrift.
env(safe-area-inset-top) haelt die Statusleiste weiterhin frei -
ohne Einzug (Browser, iPad, geraetelose Vorschau) beginnt der Titel
jetzt direkt oben. */
padding: env(safe-area-inset-top, 0px) 20px 4px 16px;
background: var(--canvas);
}
/* Unterseite: kompakter, zentrierter Titel wie in einer iOS-Navigationsleiste
(`.phone:has(.back.on) .topbar/.head/.title` im Panel). */
.dm-kopf--unterseite {
padding: env(safe-area-inset-top, 0px) 16px 6px;
gap: var(--sp-2);
}
.dm-kopf__titel {
flex: 1;
min-width: 0;
}
.dm-kopf--unterseite .dm-kopf__titel {
text-align: center;
}
.dm-kopf--unterseite .ads-title {
font-size: 17px;
}
/* Datenstand unter dem Titel der Übersicht (`.sync` im Panel). */
.dm-stand {
display: flex;
align-items: center;
gap: 6px;
margin-top: 3px;
font-size: 12.5px;
color: var(--fg3);
}
/* Ziehen zum Aktualisieren (`.ptr` im Panel): ein Band der Höhe 0, das
während der Geste wächst. */
.dm-ptr {
height: 0;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--fg3);
font-size: 12.5px;
transition: height .2s;
flex: 0 0 auto;
}
.dm-ptr--dreht svg {
animation: dm-drehen 1s linear infinite;
}
@keyframes dm-drehen {
to {
transform: rotate(360deg);
}
}
/* Maße und Farbe wie im Panel: `.back{width:34px;height:34px;margin-left:-8px}`
mit `.back svg{width:22px;height:22px;color:var(--fg)}`, und
`.rings{min-width:44px;min-height:44px;margin-left:8px;margin-right:-10px;
padding:0 10px}`. Der Zurück-Pfeil war hier 44px breit und in --fg2 statt
--fg - sichtbar heller und weiter eingerückt als im Panel. */
.dm-kopf__zurueck,
.dm-kopf__aktion {
flex: none;
width: 44px;
height: 44px;
display: grid;
place-items: center;
border: 0;
border-radius: var(--r-pill);
background: transparent;
color: var(--fg2);
cursor: pointer;
}
.dm-kopf__zurueck {
width: 34px;
height: 34px;
margin-left: -8px;
color: var(--fg);
}
.dm-kopf__aktion {
min-width: 44px;
height: 44px;
margin-left: var(--sp-2);
margin-right: -10px;
padding: 0 10px;
color: var(--fg2);
}
.dm-kopf__zurueck:hover,
.dm-kopf__aktion:hover {
background: var(--shade);
@@ -54,12 +136,37 @@
/* ---------------------------------------------------------- Inhaltsfläche */
/* Seitenrand und Kachelabstand kommen 1:1 vom Panel (`main#view{--seitenrand:16px;
padding:0 var(--seitenrand) 30px}` in audi-dashboard-ios.css). Bis 2026-08-30
standen hier --sp-4/--sp-5 aus dem Design-System: 22px Seitenrand statt 16px
(jede Kachel 20px schmaler als im Panel), 16px Innenabstand oben, den das Panel
gar nicht hat, und zusätzlich zum margin-bottom der Kachel noch ein
flex-gap - live gemessen ergab das 24px zwischen zwei Kacheln, wo das Panel
14px zeigt. Das war app-weit der größte Formatunterschied überhaupt. Der
Abstand kommt jetzt wie im Panel allein aus dem margin-bottom der Kachel,
deshalb kein gap mehr. */
.dm-inhalt {
--seitenrand: 16px;
flex: 1;
padding: var(--sp-4) var(--sp-5) calc(env(safe-area-inset-bottom, 0px) + var(--sp-5));
padding: 0 var(--seitenrand) 30px;
display: flex;
flex-direction: column;
gap: var(--sp-3);
}
/* Kachelmaße wie im Panel (`.tile{padding:16px 18px;margin-bottom:14px}`,
audi-dashboard-ios.css). @audi-dash/ui leitet beides aus --sp-5/--sp-3 ab
(22px rundum, 12px Abstand) - als markenneutraler Kit richtig, hier aber
sichtbar anders als das Panel. Gleiches Muster wie die Schrift-Auflagen in
audi-schrift.css: design-system bleibt unangetastet, companion-app legt
lokal die gemessenen Panel-Werte darüber. */
.ads-tile {
padding: 16px 18px;
margin-bottom: 14px;
}
/* .tile.flat setzt im Panel nur das Padding zurück, der Abstand bleibt. */
.ads-tile--flat {
padding: 0;
}
/* Inhalte dürfen nie gestaucht werden die Fläche scrollt stattdessen.
@@ -188,11 +295,22 @@
/* ------------------------------------------------------------ Tab-Leiste */
/* Die Menüleiste des Panels liegt als durchscheinende, weichgezeichnete
Fläche über dem Inhalt (`.tabbar` in audi-dashboard-ios.css:
rgba(12,16,20,.72) bzw. rgba(242,242,247,.78) plus
backdrop-filter:saturate(180%) blur(24px), dazu 26px Polster unten für den
Home-Indikator). Hier war sie deckend und ohne dieses Polster. */
.dm-tableiste {
flex: none;
padding-bottom: env(safe-area-inset-bottom, 0px);
padding-bottom: max(env(safe-area-inset-bottom, 0px), 26px);
border-top: 1px solid var(--line);
background: var(--canvas);
background: rgba(12, 16, 20, .72);
-webkit-backdrop-filter: saturate(180%) blur(24px);
backdrop-filter: saturate(180%) blur(24px);
}
[data-theme="tag"] .dm-tableiste {
background: rgba(242, 242, 247, .78);
}
.dm-seitenleiste__marke {
@@ -205,6 +323,7 @@
.dm-szene__marke {
display: flex;
align-items: center;
gap: var(--sp-3);
/* `.badge` im Panel: 10px zwischen Typenschild und Zusatz. */
gap: 10px;
flex-wrap: wrap;
}
+571 -63
View File
@@ -101,17 +101,20 @@ input[type="date"].dm-eingabe {
background: var(--tile);
}
/* `.notiz` im Panel: --ios-fill statt --tile-2, 12px Radius, 14px Abstand
nach oben, Zeilenhöhe 1,55 und normaler Schnitt. */
.dm-notiz {
font: inherit;
font-weight: 300;
font-size: 16px;
color: var(--fg);
background: var(--tile-2);
background: var(--ios-fill);
border: 0;
border-radius: var(--r-func);
border-radius: 12px;
padding: 10px 12px;
margin-top: 14px;
width: 100%;
min-height: 88px;
line-height: 1.55;
resize: vertical;
outline: none;
}
@@ -187,7 +190,7 @@ input[type="date"].dm-eingabe {
}
.dm-einrichtung__hinweis {
font-size: 11.5px;
font-size: 12.5px;
font-weight: 300;
line-height: 1.5;
color: var(--fg3);
@@ -196,27 +199,27 @@ input[type="date"].dm-eingabe {
/* ------------------------------------------------- Leer- und Ladezustände */
/* `.leer` im Panel: linksbündig und knapp gesetzt (gap 8px, padding 4px 0 2px),
Titel 16px/400 in --fg, Text 13,5px in --fg2. Hier war der Leerzustand
zentriert, mit 44px Polster und dünnerem Schnitt - deutlich mehr Fläche als
im Panel. */
.dm-leer {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: var(--sp-2);
padding: 44px 24px;
padding: 4px 0 2px;
color: var(--fg2);
}
.dm-leer__titel {
font-size: 16px;
font-weight: 300;
font-weight: 400;
color: var(--fg);
}
.dm-leer__text {
font-size: 13.5px;
font-weight: 300;
font-size: 13px;
line-height: 1.55;
max-width: 34ch;
margin: 0;
text-wrap: pretty;
}
@@ -228,7 +231,7 @@ input[type="date"].dm-eingabe {
gap: var(--sp-2);
padding: 44px 24px;
color: var(--fg2);
font-size: 13.5px;
font-size: 13px;
font-weight: 300;
}
@@ -238,10 +241,59 @@ input[type="date"].dm-eingabe {
Fahrzeugbild und Name/Kennzeichen sitzen direkt auf dem Seitenhintergrund,
nicht in einer grauen Karte. War hier bis 2026-08-30 fälschlich als
Kachel mit Hintergrund gebaut (Parität-Audit). */
/* Randloses Fahrzeugbild wie im Panel (`.szene.bleed{margin:-10px -20px 0;
padding:0 0 10px}` plus `.szene.bleed .carbox{padding:0 8px}` und der
Verlauf nach unten in die Fläche). Hier war das Bild bisher auf die
Inhaltsbreite eingerückt und in eine abgerundete Kachel geschnitten -
im Panel läuft es über die volle Bildschirmbreite und blendet nach unten
aus. */
.dm-szene {
position: relative;
border-radius: var(--r-tile);
overflow: hidden;
margin: -10px calc(-1 * var(--seitenrand)) 0;
padding: 0 8px 10px;
}
/* Die drei oberen Kacheln der Übersicht tragen im Panel eigene Abstände
(20/22/22 statt der 14 aller anderen). */
.dm-kopfblock {
margin-bottom: 20px;
}
.dm-tankkachel,
.dm-servicekachel {
margin-bottom: 22px;
}
.dm-szene::before {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 42%;
background: linear-gradient(to bottom, transparent, var(--canvas) 78%);
pointer-events: none;
z-index: 1;
}
/* Bodenschatten unter dem Fahrzeug (`.szene.strasse::after` /
`.szene.winter::after` im Panel, abschaltbar über den Schalter Boden" in
den Einstellungen). */
.dm-szene--strasse::after,
.dm-szene--winter::after {
content: "";
position: absolute;
left: 8%;
right: 8%;
bottom: 6px;
height: 26px;
border-radius: 50%;
background: radial-gradient(ellipse at center, var(--shade), transparent 70%);
pointer-events: none;
}
.dm-szene--winter::after {
background: radial-gradient(ellipse at center, rgba(150, 190, 255, .16), transparent 70%);
}
/* Panel: .carbox ~120px, .szene ~130px hoch - ein schmales, breites Band,
@@ -253,6 +305,35 @@ input[type="date"].dm-eingabe {
aspect-ratio: 16 / 8;
}
/* Ohne Foto bleibt die Fläche flach und läuft nicht randlos: sonst stünden
172px leerer Platzhalter vor der Reichweite, der wichtigsten Zahl der
Seite (`.bildbox.bild-fehlt.carfix.klein{height:120px}` und
`.szene.bleed:has(.bild-fehlt)` im Panel). */
/* 72px statt 120px: ohne hinterlegtes Foto stand hier eine leere Flaeche
direkt ueber der Reichweite, der wichtigsten Zahl der Seite. Der
Platzhalter bleibt sichtbar - er ist der einzige Hinweis, dass es diesen
Bildplatz gibt - nur eben schmal (Designpruefung 2026-08-30). */
.dm-szene .ads-bildbox:has(.ads-platzhalter) {
aspect-ratio: auto;
height: 72px;
}
.dm-galerie-wechsel .ads-bildbox:has(.ads-platzhalter) {
aspect-ratio: auto;
height: 132px;
}
.dm-szene:has(.ads-platzhalter) {
margin-left: 0;
margin-right: 0;
padding-left: 0;
padding-right: 0;
}
.dm-szene:has(.ads-platzhalter)::before {
display: none;
}
.dm-szene .ads-bild,
.dm-szene img {
width: 100%;
@@ -265,8 +346,12 @@ input[type="date"].dm-eingabe {
(Nutzerbericht "nicht linksbündig zum Rest der Seite", live gemessen:
38px hier gegen 22px bei "Reichweite" - im Panel beide bei 16px). Nur
oben/unten bleibt Abstand (zum Bild bzw. zur nächsten Kachel). */
.dm-szene__text {
padding: var(--sp-4) 0 var(--sp-5);
/* Ohne eigenes Polster: im Panel sitzen Typenschild, Kennzeichen und
Statuszeile direkt in einer randlosen `.tile.flat` (padding 0), das
Kennzeichen mit 10px Abstand zum Schild. Die 16/22px hier machten den Block
36px höher als im Panel (dort 89px, hier gemessen 125px). */
.dm-szene__text > .ads-eyebrow {
margin-top: 10px;
}
/* Übersicht: "Nächster Service" als tippbare Kennzahl innerhalb der Kachel,
@@ -298,11 +383,14 @@ button.dm-serviceblock:active {
}
.dm-serviceblock__chevron {
position: absolute;
top: 2px;
top: 3px;
right: 0;
font-size: 15px;
line-height: 1;
width: 6px;
height: 10px;
color: var(--fg3);
fill: none;
stroke: currentColor;
stroke-width: 1.4;
}
/* Serviceart-Icon links der Kennzahl statt des Wortes "bis zum/zur ..." -
deckungsgleich mit .fig-zeile im Panel (audi-dashboard.css). */
@@ -324,20 +412,311 @@ button.dm-serviceblock:active {
color: var(--fg2);
flex: none;
}
/* `.zahnrad` im Panel: rechts oben in der Kachel, 44px Trefferfläche,
17px Symbol. */
.dm-kachel--zahnrad {
position: relative;
}
.dm-kachel--zahnrad > .ads-eyebrow:first-child {
padding-right: 44px;
}
.dm-zahnrad {
position: absolute;
top: 11px;
right: 11px;
width: 44px;
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 50%;
background: none;
color: var(--fg3);
cursor: pointer;
padding: 0;
}
.dm-zahnrad:hover,
.dm-zahnrad:active {
background: var(--tile-2);
color: var(--fg);
}
.dm-zahnrad:active {
transform: scale(.92);
}
/* Rufnummern der Notruf-Kachel: sehen aus wie Aktionsknöpfe, sind aber Links
(`<a class="aktion" href="tel:…">` im Panel). */
.dm-waehlknopf {
display: block;
text-decoration: none;
text-align: center;
}
/* `.tile` der Kfz-Steuer steht im Panel mit 26px Abstand nach der
Vertragskachel. */
.dm-steuerkachel {
margin-top: 26px;
}
.dm-diagramm__fuss {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10px;
}
/* Der Zoom-Knopf ist im Panel eine schmale Variante von `.aktion`
(`padding:8px 16px;font-size:11px;width:auto;margin-top:0`). */
.dm-zoomzurueck {
width: auto;
min-height: 0;
margin-top: 0;
padding: 8px 16px;
font-size: 11px;
}
.dm-diagramm__tooltip {
margin-top: var(--sp-3);
font-size: 13px;
color: var(--fg2);
}
/* Ruhespannungs-Skala auf der Batterieseite (Panel: .bv-skala-kopf plus der
Farbverlauf rot gelb grün und die drei Eckwerte darunter). */
.dm-skalakopf {
display: flex;
align-items: center;
gap: 6px;
margin-top: 16px;
font-size: 13px;
letter-spacing: .01em;
color: var(--fg3);
}
.dm-skalabalken {
margin-top: 8px;
height: 8px;
border-radius: var(--r-pill);
background: linear-gradient(to right, var(--bad), var(--warn), var(--ok));
}
.dm-skalamarken {
display: flex;
justify-content: space-between;
margin-top: 6px;
}
.dm-skalamarken small {
font-size: 12.5px;
color: var(--fg3);
}
.dm-bewertung {
display: inline-flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
}
.dm-bewertungstext {
display: block;
margin-top: 10px;
color: var(--fg2);
font-size: 13px;
line-height: 1.6;
}
/* Antipp-Menü am Fahrzeugfoto (`.bildmenu` im Panel, iOS-Auflage: kein
Rahmen, durchscheinend mit Weichzeichner). */
.dm-bildklick {
position: relative;
}
.dm-bildklick__flaeche {
display: block;
width: 100%;
padding: 0;
border: 0;
background: none;
cursor: pointer;
}
.dm-bildmenu-fang {
position: fixed;
inset: 0;
z-index: 1000;
}
.dm-bildmenu {
position: absolute;
z-index: 1001;
bottom: var(--sp-5);
left: 50%;
transform: translateX(-50%);
min-width: 160px;
display: flex;
flex-direction: column;
border: 0;
border-radius: 14px;
padding: var(--sp-2);
background: rgba(40, 44, 52, .92);
-webkit-backdrop-filter: saturate(180%) blur(22px);
backdrop-filter: saturate(180%) blur(22px);
box-shadow: 0 12px 40px rgba(0, 0, 0, .5);
}
[data-theme="tag"] .dm-bildmenu {
background: rgba(250, 250, 252, .92);
}
.dm-bildmenu--rad {
bottom: auto;
top: 90px;
left: 22px;
transform: none;
}
.dm-bildmenu button {
display: block;
width: 100%;
text-align: left;
background: none;
border: 0;
border-bottom: 1px solid var(--line);
color: var(--fg);
font-family: inherit;
font-size: 16px;
padding: 13px 16px;
min-height: 46px;
border-radius: 10px;
cursor: pointer;
}
.dm-bildmenu button:last-child {
border-bottom: 0;
}
.dm-bildmenu button:active {
background: var(--shade);
}
.dm-bildmenu__loeschen {
color: var(--bad);
}
/* Name des Autohauses: 15px in --fg, wie im Panel. */
.dm-autohaus__name {
font-size: 15px;
margin-top: var(--sp-3);
}
/* Fehlender Wert im technischen Datenblatt: noch offen" in --fg3, wie im
Panel (`<dd style="color:var(--fg3)">`). */
.dm-offen {
color: var(--fg3);
}
/* Zweitzeile unter der Bezeichnung eines Beitragspostens (SF + Beitragssatz). */
.dm-beitragsposten__sf {
color: var(--fg3);
font-size: 11px;
}
/* ------------------------------------------------------------ Statistik */
/* `.tile-h` im Panel: der ganze Kachelkopf ist der Schalter zum Aufklappen,
ohne eigenes Aussehen. */
.dm-statkopf {
display: block;
width: 100%;
padding: 0;
border: 0;
background: none;
font-family: inherit;
text-align: left;
color: inherit;
cursor: pointer;
}
.dm-statkopf__kopfzeile {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
}
.dm-statkopf__kopfzeile .ads-eyebrow {
flex: 1;
}
.dm-statkopf[aria-expanded="true"] .ads-mark {
transform: rotate(45deg);
}
.dm-statkopf__zeile {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 14px;
margin-top: var(--sp-3);
}
.dm-statkopf__neben {
padding-bottom: 5px;
}
/* `.quad` im Panel: vier Werte nebeneinander, Zahl im Wide-Schnitt. */
.dm-quad {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--sp-3);
margin-top: var(--sp-3);
}
.dm-quad__n {
display: block;
font-family: var(--font-wide);
font-size: 16px;
font-weight: 300;
color: var(--fg);
line-height: 1.2;
}
.dm-quad__l {
display: block;
margin-top: 4px;
font-size: 11px;
letter-spacing: normal;
color: var(--fg3);
line-height: 1.3;
}
.dm-warnton {
color: var(--warn);
}
/* `.wert` im Panel: der normale Fließtext einer Kachel. */
.dm-wert {
font-size: 14px;
letter-spacing: normal;
color: var(--fg2);
margin-top: var(--sp-2);
}
.dm-servicezeile {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--sp-4);
margin-top: var(--sp-3);
margin-top: 14px;
padding-top: 13px;
border-top: 1px solid var(--line);
}
.dm-servicezeile__k {
font-size: 13px;
font-weight: 300;
color: var(--fg2);
}
/* Service-Seite, "Anstehende Termine": drei sichtbar getrennte Bloecke statt
einer Wertzeilen-Liste - deckungsgleich mit .termingruppe im Panel
@@ -362,12 +741,12 @@ button.dm-serviceblock:active {
flex: none;
}
.dm-tgkopf span {
font-size: 16.5px;
font-size: 16px;
color: var(--fg);
font-weight: 400;
}
.dm-tgvorgabe {
font-size: 11.5px;
font-size: 12.5px;
color: var(--fg2);
margin-top: 8px;
}
@@ -388,7 +767,7 @@ button.dm-serviceblock:active {
margin-top: 8px;
}
.dm-tgprognose {
font-size: 12px;
font-size: 12.5px;
color: var(--fg2);
margin-top: 6px;
}
@@ -568,7 +947,7 @@ button.dm-serviceblock:active {
.dm-fussnote {
margin: var(--sp-3) 0 0;
font-size: 11.5px;
font-size: 12.5px;
font-weight: 300;
line-height: 1.5;
color: var(--fg3);
@@ -619,7 +998,7 @@ button.dm-serviceblock:active {
.dm-sicherheitszeile:last-child { border-bottom: 0; }
button.dm-sicherheitszeile { cursor: pointer; }
button.dm-sicherheitszeile:active { background: var(--tile-2); }
.dm-sicherheitszeile__text { flex: 1; font-size: 14.5px; }
.dm-sicherheitszeile__text { flex: 1; font-size: 14px; }
.dm-sicherheitszeile__chev { margin-left: auto; flex: none; color: var(--fg3); }
/* Sammelzeile über den vier Sicherheitszeilen - deckungsgleich mit dem
@@ -648,34 +1027,101 @@ button.dm-sicherheitszeile:active { background: var(--tile-2); }
/* ------------------------------------------------------- Listenzeilen */
.dm-zeile {
/* `.dm-zeile`/`.dm-zeile__wisch`/`.dm-zeile__menue` sind mit dem ···"-Knopf
entfallen (siehe Zeilenmenue.tsx) - die Zeile ist jetzt direkt die
Wischzeile, wie im Panel. */
/* Zuschnitt von `.leaf` im Panel (audi-dashboard.css + iOS-Auflage, live
gemessen): 12px oben/unten, 40px links (Platz der Wischspalte), min-height
56px, Trennlinie unten, Beschriftung 16px in --fg mit 12,5px-Zusatz in
--fg3, Wert 15px in --fg2 mit demselben Zusatz. */
.dm-blatt {
display: flex;
align-items: stretch;
gap: 2px;
}
.dm-zeile__wisch {
flex: 1;
min-width: 0;
}
/* Immer sichtbarer Zugang zu denselben Aktionen wie die Wischgeste ohne ihn
waere Loeschen fuer Sprachausgabe und Schaltersteuerung unerreichbar. */
.dm-zeile__menue {
flex: none;
width: 44px;
align-items: center;
justify-content: space-between;
gap: var(--sp-4);
width: 100%;
padding: 12px 0 12px 40px;
min-height: 56px;
border: 0;
background: transparent;
color: var(--fg3);
border-radius: var(--r-func);
cursor: pointer;
border-bottom: 1px solid var(--line);
background: none;
font-family: inherit;
text-align: left;
color: inherit;
}
.dm-zeile__menue:hover {
background: var(--shade);
.dm-blatt--letzte {
border-bottom: 0;
}
button.dm-blatt {
cursor: pointer;
transition: background .15s;
}
button.dm-blatt:active {
background: var(--tile-2);
}
/* `flex: 0 1 auto` wie `.leaf .k` im Panel die Beschriftung nimmt ihre
natürliche Breite und schrumpft erst, wenn es sonst nicht passt. Mit
`1 1 auto` (dem Wert, den das Panel nur für die Zuletzt-Kachel setzt)
brach Fahrt ohne Ortsangabe" hier auf zwei Zeilen um, obwohl es im Panel
einzeilig steht. */
.dm-blatt__k {
flex: 0 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
font-size: 16px;
line-height: 1.35;
color: var(--fg);
}
.dm-blatt__v {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 3px;
text-align: right;
font-size: 15px;
color: var(--fg2);
}
.dm-blatt__k small,
.dm-blatt__v small {
font-size: 12.5px;
color: var(--fg3);
}
.dm-blatt__chevron {
width: 6px;
height: 10px;
flex: 0 0 auto;
fill: none;
stroke: var(--fg3);
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Die Zuletzt-Kachel der Übersicht: ohne die linke Wischspalte, und beide
Werte auf einer gemeinsamen linken Kante (`.teaser .leaf .v{min-width:72px}`
im Panel, samt der dort ausführlich begründeten flex-Verteilung). */
.dm-teaser .dm-blatt {
padding-left: 0;
}
.dm-teaser .dm-blatt__k {
flex: 1 1 auto;
}
.dm-teaser .dm-blatt__v {
min-width: 72px;
}
.dm-listenzeile {
display: flex;
align-items: center;
@@ -718,7 +1164,7 @@ button.dm-listenzeile {
}
.dm-listenzeile__unter {
font-size: 12px;
font-size: 12.5px;
font-weight: 300;
color: var(--fg3);
/* Umbrechen statt abschneiden: Datum, Uhrzeit und Dauer sind alle drei
@@ -843,7 +1289,7 @@ button.dm-listenzeile {
list-style: none;
margin: var(--sp-2) 0 0;
padding: 0;
font-size: 13.5px;
font-size: 13px;
font-weight: 300;
line-height: 1.7;
}
@@ -868,11 +1314,14 @@ button.dm-listenzeile {
color: var(--fg2);
}
/* 170px hoch wie BV_H im Panel 120px ließen die Kurve sichtbar flacher
wirken als dort. */
.dm-diagramm {
width: 100%;
height: 120px;
margin: var(--sp-3) 0;
height: 170px;
margin: 14px 0 0;
display: block;
touch-action: none;
}
.dm-knopfreihe {
@@ -945,7 +1394,7 @@ button.dm-listenzeile {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-size: 12.5px;
letter-spacing: normal;
color: var(--fg2);
background: none;
@@ -992,7 +1441,7 @@ button.dm-listenzeile {
padding: 10px 16px;
border: 1px solid var(--line-strong);
border-radius: var(--r-pill);
font-size: 13.5px;
font-size: 13px;
font-weight: 300;
color: var(--fg);
text-decoration: none;
@@ -1053,7 +1502,8 @@ button.dm-listenzeile {
position: relative;
border: none;
padding: 0;
margin: 0 0 var(--sp-3);
/* 14px wie jede andere Kachel im Panel (.tile). */
margin: 0 0 14px;
background: none;
cursor: pointer;
border-radius: var(--r-tile);
@@ -1062,7 +1512,13 @@ button.dm-listenzeile {
.dm-standort-vorschau {
width: 100%;
height: 190px;
background: var(--tile);
/* Im Panel deklariert .standort-vorschau zwar var(--tile), gewinnt aber
nicht: Leaflet setzt der Karte selbst die Klasse .leaflet-container und
sein Blatt liegt dort hinter den App-Blaettern. Sichtbar sind also
tatsaechlich Leaflets eigene #ddd - waehrend die Kacheln laden und in
Luecken. Hier steht derselbe Wert ausdruecklich, damit die Reihenfolge
der gebuendelten Blaetter nichts daran aendert. */
background: #ddd;
}
.dm-standort-leer {
position: absolute;
@@ -1132,7 +1588,7 @@ button.dm-listenzeile {
min-height: 400px;
border-radius: var(--r-tile);
overflow: hidden;
margin: 0 calc(-1 * var(--sp-5));
margin: 0 calc(-1 * var(--seitenrand));
}
.dm-standort-karte {
width: 100%;
@@ -1236,7 +1692,7 @@ button.dm-listenzeile {
margin-top: 4px;
}
.dm-standortmenu__adresse {
font-size: 13.5px;
font-size: 13px;
color: var(--fg2);
margin-top: 20px;
}
@@ -1245,7 +1701,7 @@ button.dm-listenzeile {
align-items: center;
gap: 9px;
margin-top: 8px;
font-size: 13.5px;
font-size: 13px;
color: var(--fg2);
}
.dm-standortmenu__status svg {
@@ -1264,7 +1720,7 @@ button.dm-listenzeile {
align-items: center;
gap: 10px;
margin-top: 8px;
font-size: 13.5px;
font-size: 13px;
color: var(--fg2);
}
.dm-standortmenu__werte svg {
@@ -1304,3 +1760,55 @@ button.dm-listenzeile {
opacity: 0.4;
pointer-events: none;
}
/* Erläuternder Absatz unter einer Kachelüberschrift. Das Panel setzt ihn als
`.label` mit einer Inline-Auflage (`color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6`) - unter anderem bei
Fahrzeugprofil, Backup und Integration-Update.
Bewusst eine eigene Klasse statt einer Änderung an `.dm-fussnote`: die wird
an 35 Stellen in 13 Dateien für ganz unterschiedliche Kleintexte benutzt
(im Paritätsaudit zweimal als offener Punkt notiert). Wer die Panel-Optik
braucht, nimmt diese Klasse; alles andere bleibt unangetastet. */
.dm-erklaerung {
display: block;
margin: 10px 0 0;
font-size: 12.5px;
line-height: 1.6;
letter-spacing: 0;
text-transform: none;
color: var(--fg2);
}
/* Karte auf der Einzelfahrt/dem Einzelbeleg: das Panel setzt sie als eigene
Kachel ohne Innenabstand (`.mapbox`, 210px hoch, abgerundet). */
.dm-kartenkachel {
padding: 0;
overflow: hidden;
}
/* Die Distanz-Kachel der Einzelfahrt trägt im Panel eigene Abstände
(`style="margin:18px 0 14px"`). */
.dm-distanzkachel {
margin: 18px 0 14px;
}
/* `.marke` im Panel: Bildmarke und Text nebeneinander, z. B. vor
SmartDeal-Ersparnis" auf dem Einzelbeleg. */
.dm-marke {
display: inline-flex;
align-items: center;
gap: 6px;
}
.dm-marke img {
height: 13px;
width: auto;
}
/* Leere Kartenfläche, solange die Tankstellenadresse noch aufgelöst wird.
Maße wie `.mapbox` im Panel. */
.dm-kartenplatz {
height: 210px;
background: var(--tile-2);
}
+51 -14
View File
@@ -165,6 +165,39 @@ export function SymbolZahnrad({ groesse }: SymbolProps) {
)
}
/* Das Zahnrad der Kachel-Ecken (`.zahnrad` im Panel) ein gefülltes Symbol
aus dem Audi-CI-Satz, verbatim übernommen. Nicht zu verwechseln mit
SymbolZahnrad oben, dem Kontur-Symbol der Seitenleiste. */
export function SymbolZahnradVoll({ groesse }: SymbolProps) {
return (
<RahmenFlaeche
groesse={groesse}
kinder={
<path
fill="currentColor"
d="M9.4 2h4.2v2.84c.37.13.75.27 1.11.44L16.7 3.3l3 3.01-1.98 1.99c.17.36.31.74.44 1.11H21v4.3h-2.84c-.13.37-.27.75-.44 1.11l2 2-3.03 2.89-1.98-1.98c-.36.17-.74.31-1.11.44V21H9.4v-2.84a9 9 0 0 1-1.11-.44L6.3 19.7l-3-3.01 1.98-1.99a9 9 0 0 1-.44-1.11H2V9.4h2.84c.13-.37.27-.75.44-1.11L3.3 6.3l3.01-3L8.3 5.28c.36-.17.74-.31 1.11-.44V2Zm1 1v2.56l-.34.11c-.61.2-1.16.4-1.6.66l-.34.2L6.3 4.71 4.7 6.3l1.83 1.82-.2.34c-.27.44-.45.99-.66 1.6l-.11.34H3v2.2h2.56l.11.34c.2.61.4 1.16.66 1.6l.2.34-1.82 1.82 1.59 1.6 1.82-1.83.34.2c.44.27.99.45 1.6.66l.34.11V20h2.2v-2.56l.34-.11a8.3 8.3 0 0 0 1.6-.66l.34-.2 1.83 1.83 1.57-1.5-1.81-1.82.2-.34c.27-.44.45-.99.66-1.6l.11-.34H20v-2.3h-2.56l-.11-.34a8.2 8.2 0 0 0-.66-1.6l-.2-.34 1.82-1.82-1.59-1.6-1.82 1.83-.34-.2a8.2 8.2 0 0 0-1.6-.66l-.34-.11V3zm-2.9 8.5c0-2.18 1.82-4 4-4s4 1.82 4 4-1.82 4-4 4-4-1.82-4-4m4-3c-1.62 0-3 1.38-3 3s1.38 3 3 3 3-1.38 3-3-1.38-3-3-3"
/>
}
/>
)
}
/* battery-12v-s aus dem Audi-CI-Satz, verbatim wie im Panel (CI.battery12vS)
steht vor der Ruhespannungs-Skala auf der Batterieseite. */
export function SymbolBatterie({ groesse }: SymbolProps) {
return (
<RahmenFlaeche
groesse={groesse}
kinder={
<path
fill="currentColor"
d="M5 5h4v2h6V5h4v2h3v12H2V7h3zm1 2h2V6H6zM3 8v10h18V8zm15-1V6h-2v1zm-1 6v2h-1v-2h-2v-1h2v-2h1v2h2v1zm-8 0H5v-1h4z"
/>
}
/>
)
}
export function SymbolSonne({ groesse }: SymbolProps) {
return (
<Rahmen
@@ -188,20 +221,8 @@ export function SymbolMond({ groesse }: SymbolProps) {
)
}
export function SymbolMehr({ groesse }: SymbolProps) {
return (
<Rahmen
groesse={groesse}
kinder={
<g>
<circle cx="5" cy="12" r="1.4" />
<circle cx="12" cy="12" r="1.4" />
<circle cx="19" cy="12" r="1.4" />
</g>
}
/>
)
}
/* SymbolMehr (die drei Punkte) ist mit dem ···"-Knopf der Listenzeilen
entfallen - es gab keine zweite Verwendung. */
/**
* oil-change-s/inspection-s aus dem Audi-CI-Icon-Satz (vom Nutzer als SVG
@@ -262,6 +283,22 @@ export function SymbolEdit({ groesse }: SymbolProps) {
)
}
/* Datenstand/Aktualisieren die beiden Pfeile aus der Kopfzeile des Panels
(`#sync`), verbatim übernommen. Fläche statt Kontur, wie dort. */
export function SymbolAktualisieren({ groesse }: SymbolProps) {
return (
<RahmenFlaeche
groesse={groesse}
kinder={
<path
fill="currentColor"
d="m18.65 4.95 2.56 2.55-2.56 2.55-.7-.7L19.29 8H7.2A5.24 5.24 0 0 0 2 13.38V14H1v-.6A6.24 6.24 0 0 1 7.2 7h12.1l-1.34-1.35.7-.7ZM23 11.59A6.24 6.24 0 0 1 16.8 18H4.7l1.35 1.35-.7.7-2.56-2.55 2.56-2.55.7.7L4.71 17H16.8a5.24 5.24 0 0 0 5.2-5.38V11h1v.6Z"
/>
}
/>
)
}
export function SymbolWarnung({ groesse }: SymbolProps) {
return (
<Rahmen
+33 -1
View File
@@ -50,12 +50,44 @@ export function useTheme(): [Theme, (theme: Theme) => void] {
// Balken über einer dunklen App steht.
useEffect(() => {
const marke = document.querySelector('meta[name="theme-color"]')
if (marke) marke.setAttribute("content", theme === "nacht" ? "#161b23" : "#ffffff")
// Werte des Panels (--canvas in audi-dashboard-ios.css). Hier standen noch
// #161b23/#ffffff aus dem Basis-Stylesheet, das im Panel nie greift —
// die Systemleiste hatte damit einen anderen Ton als die App darunter.
if (marke) marke.setAttribute("content", theme === "nacht" ? "#0C1014" : "#F2F2F7")
}, [theme])
return [theme, setzen]
}
/**
* Bodenschatten unter dem Fahrzeugbild im Panel der Schalter Boden" in den
* Einstellungen (`bodenAn`). Dort lebt er nur im Speicher und ist nach einem
* Neuladen wieder an; hier folgt er dem Muster der übrigen Ansichtsschalter
* und bleibt erhalten, Standard wie im Panel: an.
*/
const BODEN_SCHLUESSEL = "dm360.boden"
export function useBoden(): [boolean, (an: boolean) => void] {
const [an, setzeAn] = useState<boolean>(() => {
try {
return localStorage.getItem(BODEN_SCHLUESSEL) !== "aus"
} catch {
return true
}
})
const setzen = useCallback((neu: boolean) => {
setzeAn(neu)
try {
localStorage.setItem(BODEN_SCHLUESSEL, neu ? "an" : "aus")
} catch {
// Privatmodus
}
}, [])
return [an, setzen]
}
/** Sichtbarkeit der Tab-Beschriftungen, ebenfalls dauerhaft gespeichert. */
const BESCHRIFTUNG_SCHLUESSEL = "dm360.tabbeschriftung"