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:
@@ -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
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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("–")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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` : "–"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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. "Jetzt erstellen"
|
||||
legt eine Kopie auf dem Server ab, "Exportieren"/"Importieren"
|
||||
übertragen den aktuellen Gesamtstand als einzelne Datei.
|
||||
</span>
|
||||
{werte.letztesBackup && (
|
||||
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
|
||||
Letztes Backup: {datumZeit(werte.letztesBackup)}
|
||||
</span>
|
||||
)}
|
||||
<ActionButton onClick={() => void api.rest.dienstAufrufen(DIENST_DOMAIN, "backup_jetzt")}>
|
||||
Backup jetzt erstellen
|
||||
</ActionButton>
|
||||
<Feld label="Autom. Backup" last>
|
||||
<select
|
||||
className="dm-auswahl"
|
||||
value={werte.backupIntervall}
|
||||
onChange={(e) => aendern({ backupIntervall: e.target.value })}
|
||||
>
|
||||
<option value="aus">aus</option>
|
||||
<option value="taeglich">täglich</option>
|
||||
<option value="woechentlich">wöchentlich</option>
|
||||
<option value="monatlich">monatlich</option>
|
||||
<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>}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)} />
|
||||
</>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!} />}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()). */
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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. */}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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."
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 — "Gültig ab" 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 "Beitrag anpassen" — 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -49,6 +49,13 @@ export default defineConfig({
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": { target: "http://localhost:18123", changeOrigin: true, ws: true },
|
||||
// Fahrzeugfotos (/local/bilder/...) und die Statikdateien der
|
||||
// Integration (Markenlogo) liegen ebenfalls auf der HA-Instanz. Ohne
|
||||
// diese beiden Eintraege liefert der Dev-Server sie selbst - also gar
|
||||
// nicht - und jeder optische Abgleich gegen das Panel sieht nur
|
||||
// Platzhalter statt der echten Bilder.
|
||||
"/local": { target: "http://localhost:18123", changeOrigin: true },
|
||||
"/audi_dashboard_static": { target: "http://localhost:18123", changeOrigin: true },
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
||||
Reference in New Issue
Block a user