Vollständiger Panel-companion-app-Paritätsaudit (11 Kapitel)
Systematischer Bildvergleich der companion-app gegen das HA-Panel (companion-app/AGENTS.md Kapitel 1-11): Farbtoken/Radien aus dem falschen Stylesheet gelesen (iOS-Overlay statt Basis-CSS, betraf fast jede Kachel/Farbe/Radius app-weit), vier app-weite @audi-dash/ui-Bugs (Switch/Seg/Feld rot statt neutral bzw. falsche Feldbreite), Reifen-Seite strukturell neu gebaut (beide Radsätze gleichzeitig statt Umschalter, editierbare Felder, Anzugsmoment-/km-Korrektur), Standort-Feature komplett neu (fehlte bisher ganz), sowie diverse Struktur-/Typografie-/Datenlücken in MeinAudi/Service/Versicherung/Sicherheit/Fahrten/Tanken/Statistik/Batterie/Einstellungen. Manifest auf 2026.8.30.2 angehoben, OTA-Bündel neu gebaut und in audi_ha_test verifiziert (sauberer Neustart, keine Tracebacks). Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
@@ -43,6 +43,28 @@ ARCHIV="$AUSGABE/DataMetric360.xcarchive"
|
||||
|
||||
cd "$APP"
|
||||
|
||||
# Ohne diese Pruefung baut/signiert das Skript zuverlaessig - aber nur den
|
||||
# Stand, der gerade lokal ausgecheckt ist. Ein vergessenes `git pull` waere
|
||||
# damit ein stiller Veraltungsfehler wie schon mehrfach an anderer Stelle in
|
||||
# diesem Projekt (Cache-Busting, OTA-Buendel-Abgleich, VERSIONIERUNG.md) - hier
|
||||
# stattdessen ein lauter Abbruch statt eines optisch/inhaltlich alten Builds.
|
||||
echo "==> Git-Stand pruefen"
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "FEHLER: Uncommittete Aenderungen im Arbeitsverzeichnis." >&2
|
||||
git status --short >&2
|
||||
echo "Erst committen oder stashen, dann erneut versuchen." >&2
|
||||
exit 1
|
||||
fi
|
||||
git fetch origin
|
||||
LOKAL="$(git rev-parse HEAD)"
|
||||
FERN="$(git rev-parse origin/main)"
|
||||
if [ "$LOKAL" != "$FERN" ]; then
|
||||
echo "FEHLER: Lokaler Stand ($LOKAL) weicht von origin/main ($FERN) ab." >&2
|
||||
echo "Erst 'git pull' ausfuehren - sonst signiert dieses Skript einen veralteten Stand." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " HEAD == origin/main ($LOKAL), aktuell."
|
||||
|
||||
echo "==> Webbuendel bauen"
|
||||
npm run build
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ describe("Shell", () => {
|
||||
expect(bereichVon("trip")).toBe("trips")
|
||||
})
|
||||
|
||||
it("erreicht die Einstellungen über das Zahnrad, nicht über die Tab-Leiste", () => {
|
||||
it("erreicht die Einstellungen über die Ringe, nicht über die Tab-Leiste", () => {
|
||||
const geheZu = rahmen("home")
|
||||
fireEvent.click(screen.getByLabelText("Einstellungen"))
|
||||
expect(geheZu).toHaveBeenCalledWith("einst")
|
||||
|
||||
@@ -64,6 +64,14 @@ export function Shell({ route, geheZu, tabBeschriftung, hinweis, children }: She
|
||||
<span className="ads-eyebrow">DataMetric360</span>
|
||||
<div className="ads-title">{TITEL[route.name]}</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"
|
||||
@@ -71,7 +79,7 @@ export function Shell({ route, geheZu, tabBeschriftung, hinweis, children }: She
|
||||
aria-current={route.name === "einst" ? "page" : undefined}
|
||||
onClick={() => geheZu("einst")}
|
||||
>
|
||||
<SymbolZahnrad groesse={22} />
|
||||
<VierRinge hoehe={17} />
|
||||
</button>
|
||||
</header>
|
||||
)
|
||||
|
||||
@@ -210,6 +210,20 @@ export class DataMetricApi {
|
||||
);
|
||||
}
|
||||
|
||||
/** Korrigiert nur den Kilometerzähler von `satz` - bewusst kein
|
||||
profilSpeichern() (siehe reifenzaehler.py): ein eigener Dienst, der
|
||||
referenz_odo_km unangetastet lässt, damit die nächste automatische
|
||||
Fortschreibung ab dem neuen Stand weiterzählt statt ihn zu
|
||||
überschreiben. Deckungsgleich mit data-kmspeichern im Panel. */
|
||||
reifenKmSetzen(satz: "sommer" | "winter", km: number): Promise<unknown> {
|
||||
return this.warteschlange.einreihen(
|
||||
DIENST_DOMAIN,
|
||||
"reifen_km_setzen",
|
||||
{ satz, km },
|
||||
"Kilometerstand korrigieren",
|
||||
);
|
||||
}
|
||||
|
||||
reifenArchivAktualisieren(id: string, felder: ReifenArchivFelder): Promise<unknown> {
|
||||
return this.warteschlange.einreihen(
|
||||
DIENST_DOMAIN,
|
||||
|
||||
@@ -47,6 +47,13 @@ export interface DatenWert {
|
||||
/** Ob diese App noch dem Stand entspricht, den das Backend ausliefert.
|
||||
Siehe appVersion.ts und VERSIONIERUNG.md. */
|
||||
versionsstand: Versionsstand
|
||||
/** Die vom Backend gemeldete Integrationsversion (`APP_VERSION`,
|
||||
`sensor.audi_dashboard_app_version`'s `app`-Feld), oder null vor der
|
||||
ersten Antwort. Panel-Gegenstück: die "Version"-Kachel in vEinst()
|
||||
("Installiert: ..."), die hier bislang fehlte, weil nur das
|
||||
Vergleichsergebnis (`versionsstand`), nie der Rohwert selbst,
|
||||
weitergereicht wurde. */
|
||||
serverVersion: string | null
|
||||
/** Das OTA-Bündel, das die Integration mitliefert (siehe daten/ota.ts),
|
||||
oder null — kein Fehler, sondern der Zustand ohne Bündel oder ohne
|
||||
Verbindung. */
|
||||
@@ -226,6 +233,7 @@ export function DatenAnbieter({
|
||||
warteschlange,
|
||||
statusStand,
|
||||
versionsstand: versionVergleichen(eigeneVersion(), serverVersion).stand,
|
||||
serverVersion,
|
||||
otaBuendel,
|
||||
integrationUpdate,
|
||||
einstellungen,
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface Fahrzeug {
|
||||
odo: number
|
||||
odoBekannt: boolean
|
||||
tankPct: number
|
||||
tankBekannt: boolean
|
||||
reichweite: number | null
|
||||
batteriespannung: number | null
|
||||
gesichert: boolean | null
|
||||
@@ -164,6 +165,7 @@ export function profilZuFahrzeug(profil: Profil, status: Fahrzeugstatus): Fahrze
|
||||
odo: status.km ?? 0,
|
||||
odoBekannt: status.km !== null && status.km !== undefined,
|
||||
tankPct: status.tankprozent ?? 0,
|
||||
tankBekannt: status.tankprozent !== null && status.tankprozent !== undefined,
|
||||
reichweite: status.reichweite_km ?? null,
|
||||
batteriespannung: status.batteriespannung ?? null,
|
||||
gesichert: status.gesichert ?? null,
|
||||
|
||||
@@ -21,6 +21,7 @@ function fahrzeug(odo: number, modus = "hersteller"): Fahrzeug {
|
||||
odo,
|
||||
odoBekannt: true,
|
||||
tankPct: 0,
|
||||
tankBekannt: true,
|
||||
reichweite: null,
|
||||
batteriespannung: null,
|
||||
gesichert: null,
|
||||
|
||||
@@ -35,6 +35,7 @@ export type SeitenName =
|
||||
// Sonstige
|
||||
| "sicherheit"
|
||||
| "tuerenklappen"
|
||||
| "standort"
|
||||
| "live"
|
||||
| "einst"
|
||||
|
||||
@@ -68,6 +69,7 @@ export const ZURUECK: Partial<Record<SeitenName, SeitenName>> = {
|
||||
einst: "home",
|
||||
sicherheit: "home",
|
||||
tuerenklappen: "sicherheit",
|
||||
standort: "home",
|
||||
live: "home",
|
||||
}
|
||||
|
||||
@@ -96,6 +98,7 @@ export const TITEL: Record<SeitenName, string> = {
|
||||
fill: "Tankvorgang",
|
||||
sicherheit: "Fahrzeugstatus",
|
||||
tuerenklappen: "Türen und Klappen",
|
||||
standort: "Standort",
|
||||
live: "Fahrt läuft",
|
||||
einst: "Einstellungen",
|
||||
}
|
||||
|
||||
@@ -57,6 +57,48 @@ const LADEKURVE: [number, number][] = [
|
||||
[12.2, 50], [12.4, 60], [12.5, 70], [12.6, 80], [12.7, 90], [12.8, 100],
|
||||
]
|
||||
|
||||
/** Deckungsgleich mit bvHexZuRgb()/bvMischen()/bvFarbe()/bvFarbstufen() im
|
||||
Panel: der einzelne Messpunkt ist nach Ruhespannung eingefärbt (rot →
|
||||
gelb → grün), nicht - wie hier bis 2026-08-30 - durchgehend `var(--red)`.
|
||||
Rot als durchgehende Diagrammfarbe verstößt gegen die eigene Projektregel
|
||||
"Rot ist Akzent, nie Fläche" (siehe die Statistik-/TabBar-Funde weiter
|
||||
oben in dieser Datei) und war hier schlicht nie an die Panel-Logik
|
||||
angeglichen worden. */
|
||||
function hexZuRgb(hex: string): [number, number, number] {
|
||||
const h = hex.trim().replace("#", "")
|
||||
const r = parseInt(h.slice(0, 2), 16)
|
||||
const g = parseInt(h.slice(2, 4), 16)
|
||||
const b = parseInt(h.slice(4, 6), 16)
|
||||
return [Number.isFinite(r) ? r : 0, Number.isFinite(g) ? g : 0, Number.isFinite(b) ? b : 0]
|
||||
}
|
||||
function mischen(a: [number, number, number], b: [number, number, number], t: number): [number, number, number] {
|
||||
return [
|
||||
Math.round(a[0] + (b[0] - a[0]) * t),
|
||||
Math.round(a[1] + (b[1] - a[1]) * t),
|
||||
Math.round(a[2] + (b[2] - a[2]) * t),
|
||||
]
|
||||
}
|
||||
function farbstufenLesen() {
|
||||
const stil = typeof getComputedStyle === "function" ? getComputedStyle(document.documentElement) : null
|
||||
const wert = (name: string, ersatz: string) => {
|
||||
const v = stil?.getPropertyValue(name).trim()
|
||||
return v ? v : ersatz
|
||||
}
|
||||
return {
|
||||
bad: hexZuRgb(wert("--bad", "#FF453A")),
|
||||
warn: hexZuRgb(wert("--warn", "#FFD60A")),
|
||||
ok: hexZuRgb(wert("--ok", "#30D158")),
|
||||
}
|
||||
}
|
||||
function farbeFuerSpannung(v: number, stufen: ReturnType<typeof farbstufenLesen>): string {
|
||||
let rgb: [number, number, number]
|
||||
if (v <= 11.6) rgb = stufen.bad
|
||||
else if (v <= 12.2) rgb = mischen(stufen.bad, stufen.warn, (v - 11.6) / (12.2 - 11.6))
|
||||
else if (v <= 12.8) rgb = mischen(stufen.warn, stufen.ok, (v - 12.2) / (12.8 - 12.2))
|
||||
else rgb = stufen.ok
|
||||
return `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`
|
||||
}
|
||||
|
||||
function ladezustand(spannung: number): number | null {
|
||||
if (spannung > AGM_RUHE_MAX_V) return null
|
||||
const t = LADEKURVE
|
||||
@@ -230,6 +272,7 @@ function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
|
||||
|
||||
const aktiv = gewaehlt != null ? werte[gewaehlt] : undefined
|
||||
const zeitpunkt = aktiv?.min_ts ? new Date(aktiv.min_ts) : null
|
||||
const stufen = farbstufenLesen()
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -241,7 +284,7 @@ function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
|
||||
aria-label={`Spannungsverlauf über ${werte.length} Messungen`}
|
||||
onClick={punktWaehlen}
|
||||
>
|
||||
<path d={linie} fill="none" stroke="var(--red)" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<path d={linie} fill="none" stroke="var(--fg)" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
|
||||
{werte.map((t, i) =>
|
||||
t.min == null ? null : (
|
||||
<circle
|
||||
@@ -249,7 +292,7 @@ function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
|
||||
cx={x(i)}
|
||||
cy={y(t.min)}
|
||||
r={gewaehlt === i ? 4.5 : 2.4}
|
||||
fill="var(--red)"
|
||||
fill={farbeFuerSpannung(t.min, stufen)}
|
||||
{...(gewaehlt === i ? { stroke: "var(--fg)", strokeWidth: 1 } : {})}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Datenausgabe und Zugang.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
import { ActionButton, Feld, Seg, Switch, Tile } from "@audi-dash/ui"
|
||||
|
||||
@@ -18,7 +18,7 @@ import { datumZeit, de, isoTag } from "../format"
|
||||
import { useTheme } from "../theme"
|
||||
import { Wertzeile, Werteliste, bestaetigen } from "./bausteine"
|
||||
import { Bild } from "./Bild"
|
||||
import { BILDPLAETZE } from "./bilder"
|
||||
import { BILDPLAETZE, markenlogoUrl } from "./bilder"
|
||||
import { DatensatzPopup } from "./DatensatzPopup"
|
||||
import { HistorienImport } from "./HistorienImport"
|
||||
import type { ServicebuchEintrag } from "./Service"
|
||||
@@ -41,6 +41,7 @@ export function Einstellungen({
|
||||
rohprofil,
|
||||
otaBuendel,
|
||||
integrationUpdate,
|
||||
serverVersion,
|
||||
verbindung,
|
||||
api,
|
||||
profilSpeichern,
|
||||
@@ -59,6 +60,9 @@ export function Einstellungen({
|
||||
const [updateNeustartLaeuft, setzeUpdateNeustartLaeuft] = useState(false)
|
||||
const [updateAktionFehler, setzeUpdateAktionFehler] = useState<string | null>(null)
|
||||
const [datensatzPopup, setzeDatensatzPopup] = useState<"sichern" | "laden" | null>(null)
|
||||
const [backupImportLaeuft, setzeBackupImportLaeuft] = useState(false)
|
||||
const [backupImportFehler, setzeBackupImportFehler] = useState<string | null>(null)
|
||||
const backupDateiwahl = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const otaUpdateVerfuegbar = otaMoeglich() && buendelPasst(otaBuendel, eigeneVersion())
|
||||
|
||||
@@ -189,6 +193,39 @@ export function Einstellungen({
|
||||
await neuLaden()
|
||||
}
|
||||
|
||||
// Gegenstück zu profilExportieren() - dieselbe Paketform ({profil, fahrten,
|
||||
// tankvorgaenge}) über den bestehenden backup_wiederherstellen-Dienst,
|
||||
// deckungsgleich mit dem data-backup="import"-Handler im Panel. Fehlte hier
|
||||
// bisher komplett - "Auf dieses Gerät laden" hatte kein Gegenstück, ein
|
||||
// exportiertes Backup ließ sich nie wieder einspielen.
|
||||
const backupImportieren = async (datei: File) => {
|
||||
setzeBackupImportFehler(null)
|
||||
setzeBackupImportLaeuft(true)
|
||||
try {
|
||||
const text = await datei.text()
|
||||
const paket = JSON.parse(text) as {
|
||||
profil?: unknown
|
||||
fahrten?: unknown
|
||||
tankvorgaenge?: unknown
|
||||
entitaeten?: unknown
|
||||
}
|
||||
if (!paket.profil) throw new Error("Keine gültige Backup-Datei (Profil fehlt).")
|
||||
await api.rest.dienstAufrufen(DIENST_DOMAIN, "backup_wiederherstellen", {
|
||||
profil_json: JSON.stringify(paket.profil),
|
||||
fahrten_json: JSON.stringify(paket.fahrten ?? []),
|
||||
tankvorgaenge_json: JSON.stringify(paket.tankvorgaenge ?? []),
|
||||
...(paket.entitaeten ? { entitaeten_json: JSON.stringify(paket.entitaeten) } : {}),
|
||||
})
|
||||
await neuLaden()
|
||||
} catch (ursache) {
|
||||
setzeBackupImportFehler(
|
||||
ursache instanceof Error ? ursache.message : "Die Datei ließ sich nicht lesen.",
|
||||
)
|
||||
} finally {
|
||||
setzeBackupImportLaeuft(false)
|
||||
}
|
||||
}
|
||||
|
||||
const profilExportieren = () => {
|
||||
const inhalt = JSON.stringify(
|
||||
{ profil: rohprofil, fahrten, tankvorgaenge, erzeugt: new Date().toISOString() },
|
||||
@@ -240,8 +277,12 @@ export function Einstellungen({
|
||||
</Tile>
|
||||
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Fahrten</span>
|
||||
<Feld label="Pause zwischen zwei Fahrten" unit="Minuten">
|
||||
{/* 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"
|
||||
@@ -327,7 +368,22 @@ export function Einstellungen({
|
||||
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Tankrabatt</span>
|
||||
<Feld label={`${werte.markenname}-Rabatt aktiv`}>
|
||||
<Feld
|
||||
label={
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<img
|
||||
src={markenlogoUrl()}
|
||||
alt={werte.markenname}
|
||||
style={{ height: 14, width: "auto" }}
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = "none"
|
||||
}}
|
||||
/>
|
||||
{werte.markenname}-Rabatt aktiv
|
||||
</span>
|
||||
}
|
||||
last={!fahrzeug.smartdeal.aktiv}
|
||||
>
|
||||
<Switch
|
||||
checked={fahrzeug.smartdeal.aktiv}
|
||||
onChange={(an) =>
|
||||
@@ -341,25 +397,31 @@ export function Einstellungen({
|
||||
aria-label="Tankrabatt aktiv"
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Gültig bis" last>
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
type="date"
|
||||
value={fahrzeug.smartdeal.laeuftAb ?? ""}
|
||||
onChange={(e) =>
|
||||
void profilSpeichern({
|
||||
fahrzeug: {
|
||||
...fahrzeug,
|
||||
smartdeal: { ...fahrzeug.smartdeal, laeuftAb: e.target.value || null },
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Feld>
|
||||
<p className="dm-fussnote">
|
||||
Das Datum gilt einschließlich — am Stichtag selbst zählt der Rabatt noch, erst am Folgetag
|
||||
schaltet er ab.
|
||||
</p>
|
||||
{fahrzeug.smartdeal.aktiv ? (
|
||||
<>
|
||||
<Feld label="Gültig bis" last>
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
type="date"
|
||||
value={fahrzeug.smartdeal.laeuftAb ?? ""}
|
||||
onChange={(e) =>
|
||||
void profilSpeichern({
|
||||
fahrzeug: {
|
||||
...fahrzeug,
|
||||
smartdeal: { ...fahrzeug.smartdeal, laeuftAb: e.target.value || null },
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Feld>
|
||||
<p className="dm-fussnote">
|
||||
Das Datum gilt einschließlich — am Stichtag selbst zählt der Rabatt noch, erst am
|
||||
Folgetag schaltet er ab.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="dm-fussnote">Ersparnis und Bildmarke werden ausgeblendet.</p>
|
||||
)}
|
||||
</Tile>
|
||||
|
||||
{/* Nachträglicher Import aus dem HA-Verlauf. Steht bewusst vor
|
||||
@@ -430,7 +492,25 @@ export function Einstellungen({
|
||||
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>
|
||||
<input
|
||||
ref={backupDateiwahl}
|
||||
type="file"
|
||||
accept="application/json"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const datei = e.target.files?.[0]
|
||||
if (datei) void backupImportieren(datei)
|
||||
e.target.value = ""
|
||||
}}
|
||||
/>
|
||||
{backupImportFehler && <p className="dm-fehler">{backupImportFehler}</p>}
|
||||
</Tile>
|
||||
|
||||
<Tile>
|
||||
@@ -485,6 +565,19 @@ export function Einstellungen({
|
||||
</Tile>
|
||||
)}
|
||||
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Version</span>
|
||||
<Werteliste
|
||||
kinder={
|
||||
<Wertzeile
|
||||
label="Installiert"
|
||||
wert={serverVersion ?? "unbekannt"}
|
||||
zusatz="Home-Assistant-Integration"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Tile>
|
||||
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Integration-Update</span>
|
||||
{/* HIG "Loading": Netzwerk zu Gitea plus Entpacken dauert unbestimmt
|
||||
|
||||
@@ -60,7 +60,7 @@ export function FahrtDetail({ id }: { id: string | undefined }) {
|
||||
</button>
|
||||
</div>
|
||||
<div className="dm-detail__kopf">
|
||||
<div className="dm-detail__zahl">
|
||||
<div className="dm-detail__zahl dm-detail__zahl--gross">
|
||||
{deOderStrich(fahrt.distance_km, 1)}
|
||||
<span className="dm-detail__einheit">km</span>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,18 @@ import { FahrtFelderFormular, LEERE_FAHRT_FELDER, fahrtFelderAuswerten } from ".
|
||||
import type { FahrtFelderEingabe } from "./FahrtFelder"
|
||||
import { Zeilenmenue } from "./Zeilenmenue"
|
||||
|
||||
/** Vorlage: strecke() im Panel - "Start → Ziel", nur eine Seite, oder
|
||||
"Fahrt ohne Ortsangabe". Trug bisher in der Liste hier gar nichts bei
|
||||
(nur auf der Einzelfahrt-Seite sichtbar) - im Panel ist das die
|
||||
führende Information jeder Fahrtzeile. */
|
||||
function strecke(fahrt: { start_address?: string | null; end_address?: string | null }): string | null {
|
||||
const a = fahrt.start_address, b = fahrt.end_address
|
||||
if (!a && !b) return "Fahrt ohne Ortsangabe"
|
||||
if (!b) return a ?? null
|
||||
if (!a) return b
|
||||
return `${a} → ${b}`
|
||||
}
|
||||
|
||||
export function Fahrten({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
|
||||
const { fahrten, api } = useDaten()
|
||||
const [formularOffen, setzeFormularOffen] = useState(false)
|
||||
@@ -80,7 +92,7 @@ export function Fahrten({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
|
||||
{deOderStrich(fahrt.distance_km, 1)} km
|
||||
</span>
|
||||
<span className="dm-listenzeile__unter">
|
||||
{datum(fahrt.ts_start)} · {uhrzeit(fahrt.ts_start)} Uhr ·{" "}
|
||||
{strecke(fahrt)} · {datum(fahrt.ts_start)} · {uhrzeit(fahrt.ts_start)} Uhr ·{" "}
|
||||
{dauer(fahrt.duration_s)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -11,15 +11,15 @@
|
||||
* Motorhaube und dem Kofferraum einzeln.
|
||||
*/
|
||||
|
||||
import { StatusRow, Tile } from "@audi-dash/ui"
|
||||
import { Tile } from "@audi-dash/ui"
|
||||
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { datumZeit } from "../format"
|
||||
import type { SeitenName } from "../navigation"
|
||||
import { gruppenErgebnis, Leerzustand, StatusKreis } from "./bausteine"
|
||||
import { Bild } from "./Bild"
|
||||
|
||||
export function Fahrzeugstatus({ geheZu }: { geheZu: (name: SeitenName) => void }) {
|
||||
const { fahrzeug, statusStand } = useDaten()
|
||||
const { fahrzeug } = useDaten()
|
||||
if (!fahrzeug) return null
|
||||
|
||||
const punkte = fahrzeug.sicherheitscheck
|
||||
@@ -36,20 +36,29 @@ export function Fahrzeugstatus({ geheZu }: { geheZu: (name: SeitenName) => void
|
||||
const tuerenKlappen = gruppenErgebnis(punkte.filter((p) => p.gruppe === "tueren_klappen"))
|
||||
const fensterDach = gruppenErgebnis(punkte.filter((p) => p.gruppe === "fenster_dach"))
|
||||
const licht = gruppenErgebnis(punkte.filter((p) => p.gruppe === "licht"))
|
||||
const gesichert = fahrzeug.gesichert
|
||||
const sammelText =
|
||||
gesichert === null
|
||||
? "Zustand nicht vollständig bekannt"
|
||||
: gesichert
|
||||
? "Fahrzeug ist sicher abgestellt"
|
||||
: "Bitte prüfen"
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusRow
|
||||
status={fahrzeug.gesichert === true ? "ok" : fahrzeug.gesichert === false ? "bad" : "warn"}
|
||||
title={
|
||||
fahrzeug.gesichert === true
|
||||
? "Fahrzeug ist sicher abgestellt"
|
||||
: fahrzeug.gesichert === false
|
||||
? "Bitte prüfen"
|
||||
: "Zustand nicht vollständig bekannt"
|
||||
}
|
||||
{...(statusStand ? { subtitle: `Stand ${datumZeit(statusStand)}` } : {})}
|
||||
/>
|
||||
{/* Draufsicht, wie im Panel (.szene.bleed, "carfix klein" = 16:8) - war
|
||||
hier komplett unportiert (Parität-Audit 2026-08-30). */}
|
||||
<div className="dm-szene">
|
||||
<Bild datei="draufsicht.webp" beschreibung="Draufsicht" name="Draufsicht" />
|
||||
</div>
|
||||
|
||||
{/* Deckungsgleich mit der obersten Kachel in vSicherheit(): dasselbe
|
||||
Kreis-Häkchen-Symbol wie die vier Zeilen unten (nicht der generische
|
||||
StatusRow-Punkt), keine Zeitangabe - das Panel zeigt dort keine. */}
|
||||
<Tile className="dm-sicherheitssammel">
|
||||
<StatusKreis ok={gesichert} />
|
||||
<span>{sammelText}</span>
|
||||
</Tile>
|
||||
|
||||
<Tile>
|
||||
<div className="dm-sicherheitsliste">
|
||||
|
||||
@@ -53,20 +53,28 @@ export function Karte({
|
||||
L.tileLayer(KACHEL_URL, { attribution: KACHEL_ZUSATZ, maxZoom: 19 }).addTo(karte)
|
||||
|
||||
const punkte: [number, number][] = [[start.lat, start.lon]]
|
||||
L.circleMarker([start.lat, start.lon], { radius: 6, color: "#F50537" })
|
||||
// Deckungsgleich mit initMap() im Panel: Start schwarz mit weißem Ring,
|
||||
// Ziel rot - erkennbar unterschiedlich, nicht beide in derselben Farbe.
|
||||
L.circleMarker([start.lat, start.lon], { radius: 6, color: "#fff", weight: 2, fillColor: "#000", fillOpacity: 1 })
|
||||
.bindTooltip("Start")
|
||||
.addTo(karte)
|
||||
|
||||
if (ziel) {
|
||||
punkte.push([ziel.lat, ziel.lon])
|
||||
L.circleMarker([ziel.lat, ziel.lon], { radius: 6, color: "#F50537", fill: true })
|
||||
L.circleMarker([ziel.lat, ziel.lon], { radius: 6, color: "#F50537", weight: 2, fillColor: "#F50537", fillOpacity: 1 })
|
||||
.bindTooltip("Ziel")
|
||||
.addTo(karte)
|
||||
// Echte Route, wenn vorhanden - sonst eine Verbindungslinie NUR zwischen
|
||||
// den beiden echten Punkten, kein erfundener Verlauf dazwischen.
|
||||
const linie = route && route.length >= 2 ? route : punkte
|
||||
L.polyline(linie, { color: "#F50537", weight: 2, dashArray: route && route.length >= 2 ? undefined : "4 6" }).addTo(karte)
|
||||
if (route && route.length >= 2) punkte.push(...route)
|
||||
L.polyline(linie, { color: "#F50537", weight: 2.5, opacity: 0.9, dashArray: route && route.length >= 2 ? undefined : "4 6" }).addTo(karte)
|
||||
if (route && route.length >= 2) {
|
||||
// Kleine Zwischenpunkte entlang der echten Strecke, wie im Panel.
|
||||
route.slice(1, -1).forEach((punkt) => {
|
||||
L.circleMarker(punkt, { radius: 2.5, color: "#F50537", weight: 0, fillColor: "#F50537", fillOpacity: 0.85 }).addTo(karte!)
|
||||
})
|
||||
punkte.push(...route)
|
||||
}
|
||||
}
|
||||
|
||||
if (punkte.length > 1) {
|
||||
|
||||
@@ -1,72 +1,209 @@
|
||||
/**
|
||||
* „Mein Audi" — Sammelseite mit Fotogalerie und Wegen zu den Unterseiten.
|
||||
* Vorlage: `vAudi()` im alten Panel.
|
||||
* „Mein Audi" — Sammelseite mit Fahrzeugbild, Kennzeichen und den Wegen zu
|
||||
* den Unterseiten, jede mit einer echten Datenvorschau. Vorlage: `vAudi()`
|
||||
* im alten Panel.
|
||||
*
|
||||
* War bis 2026-08-30 eine reine Foto-Rasteransicht plus fünf generische
|
||||
* "Los"-Kacheln mit Platzhaltertext ("Termine und Wartungsplan") statt
|
||||
* echter Werte — eine ganz andere, ärmere Seite als das Panel-Vorbild
|
||||
* (Parität-Audit). Jetzt strukturell nachgebaut: ein einzelnes, antippbares
|
||||
* Wechselbild (wie `carImg("galerie")`) statt eines Rasters aller Plätze
|
||||
* (das Raster gehört zu Einstellungen → Bild der Übersicht, nicht hierher),
|
||||
* eine randlose Marke/Kennzeichen-Zeile, und vier Kacheln mit echter
|
||||
* Werteliste + Chevron statt einer Zeile Fließtext.
|
||||
*/
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import { Tile } from "@audi-dash/ui"
|
||||
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { de, deOderStrich } from "../format"
|
||||
import { naechsterService } from "../daten/service"
|
||||
import { datum, de, deOderStrich, eur } from "../format"
|
||||
import type { SeitenName } from "../navigation"
|
||||
import { NaviKachel } from "./bausteine"
|
||||
import { Wertzeile, Werteliste } from "./bausteine"
|
||||
import { Bild } from "./Bild"
|
||||
import { BILDPLAETZE } from "./bilder"
|
||||
import type { ServicebuchEintrag } from "./Service"
|
||||
import { Typenschild, schildErkennen } from "./Typenschild"
|
||||
|
||||
interface AusstattungGruppe {
|
||||
gruppe?: string
|
||||
sonder?: string[]
|
||||
serie?: string[]
|
||||
}
|
||||
|
||||
export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
|
||||
const { fahrzeug, einstellungen } = useDaten()
|
||||
const { fahrzeug, einstellungen, fahrten } = useDaten()
|
||||
const [bildIndex, setzeBildIndex] = useState(0)
|
||||
if (!fahrzeug || !einstellungen) return null
|
||||
|
||||
const aktiverSatz =
|
||||
fahrzeug.reifen.aktiv === "Winter" ? fahrzeug.reifen.winter : fahrzeug.reifen.sommer
|
||||
const platz = BILDPLAETZE[bildIndex % BILDPLAETZE.length]!
|
||||
|
||||
const ausstattung = Array.isArray(fahrzeug.ausstattung)
|
||||
? (fahrzeug.ausstattung as AusstattungGruppe[])
|
||||
: []
|
||||
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)
|
||||
|
||||
const schild = schildErkennen(einstellungen.fahrzeugtitel)
|
||||
const ausfuehrung = einstellungen.ausfuehrung.trim()
|
||||
const zusatz = [
|
||||
schild?.zusatz,
|
||||
ausfuehrung && !schild?.zusatz.toLowerCase().includes(ausfuehrung.toLowerCase())
|
||||
? ausfuehrung
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.trim()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Fotogalerie</span>
|
||||
<div className="dm-galerie">
|
||||
{BILDPLAETZE.map((platz) => (
|
||||
<Bild
|
||||
key={platz.datei}
|
||||
datei={platz.datei}
|
||||
beschreibung={platz.beschreibung}
|
||||
name={platz.name}
|
||||
groesse="mini"
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="dm-galerie-wechsel"
|
||||
onClick={() => setzeBildIndex((i) => (i + 1) % BILDPLAETZE.length)}
|
||||
aria-label="Nächstes Bild"
|
||||
>
|
||||
<Bild datei={platz.datei} beschreibung={platz.beschreibung} name={platz.name} />
|
||||
</button>
|
||||
<div className="dm-galerie-punkte" aria-hidden="true">
|
||||
{BILDPLAETZE.map((b, i) => (
|
||||
<span key={b.datei} className={i === bildIndex ? "an" : ""} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Tile variant="flat" className="dm-szene__text">
|
||||
<div className="dm-szene__marke">
|
||||
<Typenschild modell={einstellungen.fahrzeugtitel} hoehe={26} />
|
||||
<div className="dm-szene__zusatz">
|
||||
{schild ? (
|
||||
zusatz && <span>{zusatz}</span>
|
||||
) : (
|
||||
<>
|
||||
{einstellungen.fahrzeugtitel} {zusatz && <span>{zusatz}</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="dm-fussnote">
|
||||
Fotos verwaltest du in den Einstellungen. Fehlende Bilder zeigen ihren erwarteten
|
||||
Dateinamen, damit klar ist, was wohin gehört.
|
||||
</p>
|
||||
<span className="ads-eyebrow">
|
||||
{einstellungen.kennzeichen || "Kennzeichen nicht hinterlegt"}
|
||||
</span>
|
||||
</Tile>
|
||||
|
||||
<div className="dm-raster">
|
||||
<NaviKachel
|
||||
titel="Fahrzeugdaten"
|
||||
wert={fahrzeug.fin || "Technik und Ausstattung"}
|
||||
beiKlick={() => geheZu("ident")}
|
||||
/>
|
||||
<NaviKachel
|
||||
titel="Batterie"
|
||||
wert={
|
||||
fahrzeug.batteriespannung != null
|
||||
? `${de(fahrzeug.batteriespannung, 2)} V`
|
||||
: "Kein Spannungssensor"
|
||||
<Tile variant="button" chevron onClick={() => geheZu("ident")}>
|
||||
<span className="ads-eyebrow">Fahrzeug</span>
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile label="Fahrgestellnummer" wert={fahrzeug.fin || "—"} />
|
||||
<Wertzeile label="Details" wert={fahrzeug.details || "—"} />
|
||||
<Wertzeile label="Erstzulassung" wert={fahrzeug.erstzulassung || "—"} />
|
||||
<Wertzeile label="Ausführung" wert={ausfuehrung || "noch offen"} />
|
||||
<Wertzeile label="Ausstattung" wert={`${positionen} Positionen`} />
|
||||
</>
|
||||
}
|
||||
beiKlick={() => geheZu("battverlauf")}
|
||||
/>
|
||||
<NaviKachel titel="Service" wert="Termine und Wartungsplan" beiKlick={() => geheZu("service")} />
|
||||
<NaviKachel
|
||||
titel="Reifen"
|
||||
wert={`${fahrzeug.reifen.aktiv} · ${deOderStrich(aktiverSatz.km)} km`}
|
||||
beiKlick={() => geheZu("reifen")}
|
||||
</Tile>
|
||||
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Zustand</span>
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile
|
||||
label="Kilometerstand"
|
||||
wert={fahrzeug.odoBekannt ? `${de(fahrzeug.odo)} km` : "unbekannt"}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Tankfüllung"
|
||||
wert={`${de(fahrzeug.tankPct)} % / ${de((einstellungen.tankvolumen * fahrzeug.tankPct) / 100, 1)} l`}
|
||||
/>
|
||||
<button type="button" className="dm-wertzeile dm-wertzeile--knopf" onClick={() => geheZu("battverlauf")}>
|
||||
<dt>Batteriespannung</dt>
|
||||
<dd>
|
||||
{fahrzeug.batteriespannung != null ? `${de(fahrzeug.batteriespannung, 1)} V` : "unbekannt"}
|
||||
<span className="dm-wertzeile__chevron" aria-hidden="true">
|
||||
›
|
||||
</span>
|
||||
</dd>
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<NaviKachel
|
||||
titel="Versicherung & Steuer"
|
||||
wert="Beitrag, Vertrag, Kfz-Steuer"
|
||||
beiKlick={() => geheZu("vers")}
|
||||
</Tile>
|
||||
|
||||
<Tile variant="button" chevron onClick={() => geheZu("service")}>
|
||||
<span className="ads-eyebrow">Service</span>
|
||||
<Werteliste
|
||||
kinder={
|
||||
service ? (
|
||||
<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}
|
||||
/>
|
||||
) : (
|
||||
<Wertzeile label="Nächster Termin" wert="kein Eintrag im Wartungsplan" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Tile>
|
||||
|
||||
<Tile variant="button" chevron onClick={() => geheZu("vers")}>
|
||||
<span className="ads-eyebrow">Versicherung/Steuer</span>
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile
|
||||
label="Versicherung"
|
||||
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)} €` : "—"}
|
||||
zusatz={fahrzeug.steuer["zeitraum"] === "jährlich" ? "im Jahr" : (fahrzeug.steuer["zeitraum"] as string)}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Summe"
|
||||
wert={`${eur(((fahrzeug.versicherung["beitrag"] as number) || 0) + ((fahrzeug.steuer["betrag"] as number) || 0))} €`}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Tile>
|
||||
|
||||
<Tile variant="button" chevron onClick={() => geheZu("reifen")}>
|
||||
<span className="ads-eyebrow">Reifen</span>
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile
|
||||
label={`montiert: ${fahrzeug.reifen.aktiv}räder`}
|
||||
wert={(aktiverSatz["mass"] as string) || "—"}
|
||||
/>
|
||||
<Wertzeile label="Laufleistung" wert={deOderStrich(aktiverSatz.km) + " km"} />
|
||||
<Wertzeile
|
||||
label="Wechseltermin"
|
||||
wert={datum(fahrzeug.reifen.wechsel["datum"] as string)}
|
||||
zusatz={fahrzeug.reifen.wechsel["vereinbart"] ? "vereinbart" : "–"}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Tile>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,170 +9,322 @@
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import { Accordion, ActionButton, Feld, IconButton, Seg, Tile } from "@audi-dash/ui"
|
||||
import { Accordion, ActionButton, Feld, IconButton, Switch, Tile } from "@audi-dash/ui"
|
||||
|
||||
import { DIENST_DOMAIN } from "../api"
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import type { Reifensatz, ReifenArchivEintrag } from "../daten/profilAdapter"
|
||||
import { datum, de, deOderStrich } from "../format"
|
||||
import { SymbolPaket } from "../symbole"
|
||||
import { SymbolEdit, SymbolPaket } from "../symbole"
|
||||
import { Wertzeile, Werteliste, bestaetigen } from "./bausteine"
|
||||
import { Bild } from "./Bild"
|
||||
import { kalenderDateiLaden } from "./kalender"
|
||||
|
||||
type SatzSchluessel = "sommer" | "winter"
|
||||
|
||||
export function Reifen() {
|
||||
const { fahrzeug, api, profilSpeichern } = useDaten()
|
||||
const [laeuft, setzeLaeuft] = useState(false)
|
||||
const [gezeigt, setzeGezeigt] = useState<"Sommer" | "Winter" | null>(null)
|
||||
const [nmOffen, setzeNmOffen] = useState(false)
|
||||
const [nmEntwurf, setzeNmEntwurf] = useState("")
|
||||
const [kmOffen, setzeKmOffen] = useState<SatzSchluessel | null>(null)
|
||||
const [kmEntwurf, setzeKmEntwurf] = useState("")
|
||||
const [wechselDatum, setzeWechselDatum] = useState<string | null>(null)
|
||||
const [wechselVereinbart, setzeWechselVereinbart] = useState<boolean | null>(null)
|
||||
|
||||
if (!fahrzeug) return null
|
||||
|
||||
const aktiv = fahrzeug.reifen.aktiv
|
||||
const anzeige = gezeigt ?? aktiv
|
||||
const satz: Reifensatz = anzeige === "Winter" ? fahrzeug.reifen.winter : fahrzeug.reifen.sommer
|
||||
const wechsel = fahrzeug.reifen.wechsel as { datum?: string; vereinbart?: boolean }
|
||||
// Lokaler Entwurf, solange der Nutzer nichts geändert hat - Panel-
|
||||
// Verhalten: Datum/Häkchen speichern erst beim Klick auf "In den Kalender
|
||||
// übernehmen" (data-ics="reifen" liest #rfDat/#rfOk erst dort aus).
|
||||
const wechselDatumAnzeige = wechselDatum ?? wechsel.datum ?? ""
|
||||
const wechselVereinbartAnzeige = wechselVereinbart ?? wechsel.vereinbart ?? false
|
||||
|
||||
const wechseln = async () => {
|
||||
const ziel = aktiv === "Sommer" ? "winter" : "sommer"
|
||||
if (!bestaetigen(`Auf ${ziel === "winter" ? "Winter" : "Sommer"}reifen umstellen?`)) return
|
||||
// Deckungsgleich mit data-nmspeichern im Panel: normales Profilfeld, kein
|
||||
// eigener Dienst (anders als der Kilometerstand unten).
|
||||
const anzugsmomentSpeichern = async () => {
|
||||
const wert = parseInt(nmEntwurf, 10)
|
||||
if (!Number.isNaN(wert) && wert > 0) {
|
||||
await profilSpeichern({ fahrzeug: { ...fahrzeug, reifen: { ...fahrzeug.reifen, anzugsmoment: wert } } })
|
||||
}
|
||||
setzeNmOffen(false)
|
||||
}
|
||||
|
||||
const kalenderUebernehmen = async () => {
|
||||
await profilSpeichern({
|
||||
fahrzeug: {
|
||||
...fahrzeug,
|
||||
reifen: { ...fahrzeug.reifen, wechsel: { ...wechsel, datum: wechselDatumAnzeige, vereinbart: wechselVereinbartAnzeige } },
|
||||
},
|
||||
})
|
||||
const ziel = aktiv === "Sommer" ? "Winter" : "Sommer"
|
||||
kalenderDateiLaden(
|
||||
"Reifenwechsel",
|
||||
wechselDatumAnzeige,
|
||||
`Wechsel auf ${ziel}reifen`,
|
||||
)
|
||||
}
|
||||
|
||||
const kmSpeichern = async (satzSchluessel: SatzSchluessel) => {
|
||||
const wert = parseFloat(kmEntwurf.replace(",", "."))
|
||||
if (!Number.isNaN(wert) && wert >= 0) {
|
||||
setzeLaeuft(true)
|
||||
try {
|
||||
await api.reifenKmSetzen(satzSchluessel, Math.round(wert * 10) / 10)
|
||||
} finally {
|
||||
setzeLaeuft(false)
|
||||
}
|
||||
}
|
||||
setzeKmOffen(null)
|
||||
}
|
||||
|
||||
// Deckungsgleich mit data-satz im Panel: direktes Umstellen ohne
|
||||
// Rückfrage, das Backend rechnet den bisherigen Satz zuerst ab.
|
||||
const montierenAuf = async (ziel: "Sommer" | "Winter") => {
|
||||
setzeLaeuft(true)
|
||||
try {
|
||||
// Eigener Dienst: das Backend rechnet zuerst den alten Satz ab und
|
||||
// schaltet erst dann um, damit keine Kilometer auf den neuen Satz
|
||||
// rutschen, die noch auf dem alten gefahren wurden.
|
||||
await api.rest.dienstAufrufen(DIENST_DOMAIN, "reifen_wechseln", { satz: ziel })
|
||||
await api.rest.dienstAufrufen(DIENST_DOMAIN, "reifen_wechseln", { satz: ziel.toLowerCase() })
|
||||
} finally {
|
||||
setzeLaeuft(false)
|
||||
}
|
||||
}
|
||||
|
||||
const archivieren = async () => {
|
||||
const archivieren = async (satzSchluessel: SatzSchluessel) => {
|
||||
if (!bestaetigen("Neue Räder anlegen?")) return
|
||||
setzeLaeuft(true)
|
||||
try {
|
||||
// Eigener Dienst wie reifen_wechseln: archiviert den bisherigen Stand
|
||||
// und setzt den laufenden Satz zurück (km 0, Marke/Modell/DOT/Kommentar
|
||||
// leer - siehe reifen.py archivieren()).
|
||||
await api.reifenArchivieren(anzeige === "Winter" ? "winter" : "sommer")
|
||||
await api.reifenArchivieren(satzSchluessel)
|
||||
} finally {
|
||||
setzeLaeuft(false)
|
||||
}
|
||||
}
|
||||
|
||||
const wechseldatumSetzen = async (neuesDatum: string) => {
|
||||
// Deckungsgleich mit data-rf im Panel: jedes Feld speichert sofort beim
|
||||
// Verlassen (kein separater Entwurfs-/Speichern-Schritt wie bei den
|
||||
// Archiv-Sätzen unten - dort schützt das einen womöglich seither
|
||||
// fortgeschriebenen km-Stand, hier gibt es diesen Konflikt nicht).
|
||||
const feldSpeichern = async (satzSchluessel: SatzSchluessel, feld: string, wert: string) => {
|
||||
const bisher = satzSchluessel === "winter" ? fahrzeug.reifen.winter : fahrzeug.reifen.sommer
|
||||
await profilSpeichern({
|
||||
fahrzeug: {
|
||||
...fahrzeug,
|
||||
reifen: { ...fahrzeug.reifen, wechsel: { ...wechsel, datum: neuesDatum } },
|
||||
},
|
||||
fahrzeug: { ...fahrzeug, reifen: { ...fahrzeug.reifen, [satzSchluessel]: { ...bisher, [feld]: wert } } },
|
||||
})
|
||||
}
|
||||
|
||||
const satzTile = (satzSchluessel: SatzSchluessel) => {
|
||||
const satz: Reifensatz = satzSchluessel === "winter" ? fahrzeug.reifen.winter : fahrzeug.reifen.sommer
|
||||
const satzLabel = satzSchluessel === "winter" ? "Winterräder" : "Sommerräder"
|
||||
const radLabel = satzSchluessel === "winter" ? "Winterrad" : "Sommerrad"
|
||||
const istAktiv = aktiv.toLowerCase() === satzSchluessel
|
||||
|
||||
return (
|
||||
<Tile key={satzSchluessel}>
|
||||
<div className="dm-radkopf-row">
|
||||
<div className="dm-radkopf">
|
||||
<span className="dm-tile-headline">{satzLabel}</span>
|
||||
<div className="dm-reifenzeile">
|
||||
<IconButton
|
||||
aria-label={`${satzLabel} Kilometerstand ändern`}
|
||||
onClick={() => {
|
||||
setzeKmEntwurf(String((satz["km"] as number) ?? ""))
|
||||
setzeKmOffen((o) => (o === satzSchluessel ? null : satzSchluessel))
|
||||
}}
|
||||
>
|
||||
<SymbolEdit groesse={16} />
|
||||
</IconButton>
|
||||
{satz["km"] != null ? de(satz["km"] as number) : "–"}
|
||||
<span className="ads-fig__unit">km</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dm-radfoto">
|
||||
<Bild
|
||||
datei={satzSchluessel === "winter" ? "rad-winter.webp" : "rad-sommer.webp"}
|
||||
beschreibung={radLabel}
|
||||
name={radLabel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{kmOffen === satzSchluessel && (
|
||||
<div className="dm-bearbeiten-block">
|
||||
<p className="dm-fussnote">
|
||||
Korrigiert nur den bisher gefahrenen Stand - weitere Fahrten mit diesem Satz zählen ab
|
||||
hier weiter dazu.
|
||||
</p>
|
||||
<Feld label="Neuer Wert" unit="km" last>
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
type="number"
|
||||
step={1}
|
||||
min={0}
|
||||
value={kmEntwurf}
|
||||
onChange={(e) => setzeKmEntwurf(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
<div className="dm-knopfreihe">
|
||||
<ActionButton onClick={() => void kmSpeichern(satzSchluessel)} disabled={laeuft}>
|
||||
{laeuft ? "Speichere …" : "Speichern"}
|
||||
</ActionButton>
|
||||
<ActionButton onClick={() => setzeKmOffen(null)}>Abbrechen</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="dm-montiert-reihe">
|
||||
<button
|
||||
type="button"
|
||||
className={`dm-montiert${istAktiv ? " dm-montiert--an" : ""}`}
|
||||
onClick={() => void montierenAuf(satzSchluessel === "winter" ? "Winter" : "Sommer")}
|
||||
disabled={laeuft}
|
||||
>
|
||||
Montiert
|
||||
</button>
|
||||
<IconButton
|
||||
className="dm-archivbtn"
|
||||
aria-label="Neue Räder anlegen"
|
||||
onClick={() => void archivieren(satzSchluessel)}
|
||||
disabled={laeuft}
|
||||
>
|
||||
<SymbolPaket groesse={18} />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Feld label="Hersteller">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
defaultValue={(satz["marke"] as string) ?? ""}
|
||||
placeholder="noch offen"
|
||||
onBlur={(e) => void feldSpeichern(satzSchluessel, "marke", e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Modell">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
defaultValue={(satz["modell"] as string) ?? ""}
|
||||
placeholder="noch offen"
|
||||
onBlur={(e) => void feldSpeichern(satzSchluessel, "modell", e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Größe">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
defaultValue={(satz["mass"] as string) ?? ""}
|
||||
placeholder="noch offen"
|
||||
onBlur={(e) => void feldSpeichern(satzSchluessel, "mass", e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="DOT">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
defaultValue={(satz["dot"] as string) ?? ""}
|
||||
placeholder="noch offen"
|
||||
onBlur={(e) => void feldSpeichern(satzSchluessel, "dot", e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Solldruck vorn / hinten" unit="bar">
|
||||
<input
|
||||
className="dm-eingabe druckfeld"
|
||||
defaultValue={(satz["druck_vorne"] as string) ?? ""}
|
||||
placeholder="–"
|
||||
onBlur={(e) => void feldSpeichern(satzSchluessel, "druck_vorne", e.target.value)}
|
||||
/>
|
||||
<span>/</span>
|
||||
<input
|
||||
className="dm-eingabe druckfeld"
|
||||
defaultValue={(satz["druck_hinten"] as string) ?? ""}
|
||||
placeholder="–"
|
||||
onBlur={(e) => void feldSpeichern(satzSchluessel, "druck_hinten", e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
{/* Volle Breite statt Label+Eingabe in einer Zeile (wie Versicherung.tsx'
|
||||
Vertragsnotiz) - Parität zum Panel (.feld--breit), sonst reißt ein
|
||||
längerer Kommentar wie "Eingelagert beim Autohaus" ab. */}
|
||||
<div className="ads-feld" style={{ display: "block", borderBottom: 0 }}>
|
||||
<label style={{ display: "block", marginBottom: 8 }}>Kommentar</label>
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
style={{ width: "100%", textAlign: "left" }}
|
||||
defaultValue={(satz["kommentar"] as string) ?? ""}
|
||||
placeholder="noch offen"
|
||||
onBlur={(e) => void feldSpeichern(satzSchluessel, "kommentar", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tile>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Montiert</span>
|
||||
<span className="ads-eyebrow">Anzugsmoment der Radschrauben</span>
|
||||
<div className="dm-detail__kopf">
|
||||
<div className="dm-detail__zahl">{aktiv}</div>
|
||||
<ActionButton onClick={() => void wechseln()} disabled={laeuft}>
|
||||
{laeuft ? "Stelle um …" : `Auf ${aktiv === "Sommer" ? "Winter" : "Sommer"} wechseln`}
|
||||
</ActionButton>
|
||||
<div className="ads-fig" style={{ fontSize: 34, marginTop: 12 }}>
|
||||
{fahrzeug.reifen.anzugsmoment != null ? de(fahrzeug.reifen.anzugsmoment) : "–"}
|
||||
<span className="ads-fig__unit">Nm</span>
|
||||
</div>
|
||||
<IconButton
|
||||
aria-label="Anzugsmoment ändern"
|
||||
onClick={() => {
|
||||
setzeNmEntwurf(String(fahrzeug.reifen.anzugsmoment ?? ""))
|
||||
setzeNmOffen((o) => !o)
|
||||
}}
|
||||
>
|
||||
<SymbolEdit groesse={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile
|
||||
label="Anzugsmoment der Radschrauben"
|
||||
wert={
|
||||
fahrzeug.reifen.anzugsmoment != null
|
||||
? `${de(fahrzeug.reifen.anzugsmoment)} Nm`
|
||||
: "—"
|
||||
}
|
||||
{nmOffen && (
|
||||
<div className="dm-bearbeiten-block">
|
||||
<Feld label="Neuer Wert" unit="Nm" last>
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
type="number"
|
||||
step={5}
|
||||
min={0}
|
||||
value={nmEntwurf}
|
||||
onChange={(e) => setzeNmEntwurf(e.target.value)}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Nächster Wechseltermin"
|
||||
wert={wechsel.datum ? datum(wechsel.datum) : "—"}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Feld label="Wechseltermin" last>
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
type="date"
|
||||
value={wechsel.datum ?? ""}
|
||||
onChange={(e) => void wechseldatumSetzen(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
{wechsel.datum && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<ActionButton
|
||||
onClick={() =>
|
||||
kalenderDateiLaden(
|
||||
"Reifenwechsel",
|
||||
wechsel.datum!,
|
||||
`Wechsel auf ${aktiv === "Sommer" ? "Winter" : "Sommer"}reifen`,
|
||||
)
|
||||
}
|
||||
>
|
||||
In den Kalender eintragen
|
||||
</ActionButton>
|
||||
</Feld>
|
||||
<div className="dm-knopfreihe">
|
||||
<ActionButton onClick={() => void anzugsmomentSpeichern()}>Speichern</ActionButton>
|
||||
<ActionButton onClick={() => setzeNmOffen(false)}>Abbrechen</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Tile>
|
||||
|
||||
<Seg
|
||||
options={[
|
||||
{ value: "Sommer", label: "Sommer" },
|
||||
{ value: "Winter", label: "Winter" },
|
||||
]}
|
||||
value={anzeige}
|
||||
onChange={(wert) => setzeGezeigt(wert as "Sommer" | "Winter")}
|
||||
/>
|
||||
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">
|
||||
{anzeige}reifen {anzeige === aktiv ? "· montiert" : "· eingelagert"}
|
||||
</span>
|
||||
<Bild
|
||||
datei={anzeige === "Winter" ? "rad-winter.webp" : "rad-sommer.webp"}
|
||||
beschreibung={`${anzeige}rad`}
|
||||
name={`${anzeige}rad`}
|
||||
/>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: -6 }}>
|
||||
<IconButton aria-label="Neue Räder anlegen" onClick={() => void archivieren()} disabled={laeuft}>
|
||||
<SymbolPaket groesse={18} />
|
||||
</IconButton>
|
||||
<span className="ads-eyebrow">Wechseltermin</span>
|
||||
<Feld label="Geplant am">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
type="date"
|
||||
value={wechselDatumAnzeige}
|
||||
onChange={(e) => setzeWechselDatum(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Termin vereinbart" last>
|
||||
<Switch
|
||||
checked={wechselVereinbartAnzeige}
|
||||
onChange={(checked) => setzeWechselVereinbart(checked)}
|
||||
aria-label="Termin vereinbart"
|
||||
/>
|
||||
</Feld>
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<ActionButton onClick={() => void kalenderUebernehmen()} disabled={!wechselDatumAnzeige}>
|
||||
In den Kalender übernehmen
|
||||
</ActionButton>
|
||||
</div>
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile label="Gefahrene Strecke" wert={`${deOderStrich(satz["km"] as number)} km`} />
|
||||
<Wertzeile label="Größe" wert={(satz["mass"] as string) || "—"} />
|
||||
<Wertzeile
|
||||
label="Marke und Modell"
|
||||
wert={
|
||||
[satz["marke"], satz["modell"]].filter(Boolean).join(" ") || "—"
|
||||
}
|
||||
/>
|
||||
<Wertzeile label="DOT" wert={(satz["dot"] as string) || "—"} />
|
||||
<Wertzeile
|
||||
label="Luftdruck"
|
||||
wert={`${(satz["druck_vorne"] as string) || "—"} / ${(satz["druck_hinten"] as string) || "—"} bar`}
|
||||
zusatz="vorne / hinten"
|
||||
/>
|
||||
{Boolean(satz["kommentar"]) && (
|
||||
<Wertzeile label="Notiz" wert={satz["kommentar"] as string} />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<p className="dm-fussnote">
|
||||
Die gefahrene Strecke schreibt der Server bei jeder Änderung des Kilometerstands fort und
|
||||
rechnet sie immer dem gerade montierten Satz zu.
|
||||
</p>
|
||||
</Tile>
|
||||
|
||||
{satzTile("sommer")}
|
||||
{satzTile("winter")}
|
||||
|
||||
{fahrzeug.reifen.archiv.length > 0 && (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Archiv</span>
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
/**
|
||||
* Standort — Live-GPS-Position des Fahrzeugs. Vorschau-Kachel auf der
|
||||
* Übersicht (`StandortVorschau`, eingebettet dort) plus Vollbild-Karte mit
|
||||
* eigenem Marker, Abstand, Adresse, Fahrzustand und Route/Teilen-Aktionen.
|
||||
* Vorlage: `vStandort()`/`standortKachel()` im alten Panel — bis 2026-08-29
|
||||
* fehlte diese ganze Funktion hier komplett (Parität-Audit).
|
||||
*
|
||||
* Leaflet kommt wie bei `Karte.tsx` aus dem Bündel, nicht vom CDN.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"
|
||||
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { de } from "../format"
|
||||
import type { Fahrzeug } from "../daten/profilAdapter"
|
||||
import { SymbolTanken } from "../symbole"
|
||||
|
||||
import "leaflet/dist/leaflet.css"
|
||||
|
||||
const KACHEL_URL = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
const KACHEL_ZUSATZ = "© OpenStreetMap contributors"
|
||||
const SAT_KACHEL_URL =
|
||||
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
|
||||
const SAT_ZUSATZ = "© Esri"
|
||||
|
||||
// Audi-CI-Icon-Satz (vom Nutzer als SVG geliefert, verbatim übernommen wie die
|
||||
// übrigen CI-Icons in diesem Projekt — siehe symbole.tsx SymbolOelwechsel/-
|
||||
// Inspektion) — hier lokal, weil sie nur für die Kartensteuerung/den
|
||||
// Fahrzeugmarker gebraucht werden, nicht als allgemeine UI-Symbole.
|
||||
const CI = {
|
||||
mapLayer:
|
||||
'M11.7 19.4-8.97-5.66.54-.85 8.96 5.67c.17.1.37.1.54 0l8.96-5.67.54.85-8.97 5.66a1.5 1.5 0 0 1-1.6 0m.53-3.84c.17.1.37.1.54 0l8.3-5.25-8.3-5.23a.5.5 0 0 0-.54 0l-8.3 5.23zm-.53.84-9.64-6.09 9.64-6.08a1.5 1.5 0 0 1 1.6 0l9.64 6.08-9.64 6.1c-.49.3-1.11.3-1.6 0Z',
|
||||
carSearch:
|
||||
'M1 7.8a6.16 6.16 0 0 0 6.1 6.1h.4A5.7 5.7 0 0 0 13 7.91V7.9a6.1 6.1 0 0 0-5.91-6A5.93 5.93 0 0 0 1 7.8m-1 0C0 3.92 3.23.8 7.11.9a7.1 7.1 0 0 1 6.89 7c.06 2.33-1 4.44-2.73 5.71L14.8 19H20v1.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5v-7.8l-1.9-1.9-.04-.06a55 55 0 0 0-2.84-4.71c-1.42-.1-2.49-.17-3.2-.2l.04-1c.77.04 1.93.1 3.48.22l.24.02.14.2a55 55 0 0 1 2.99 4.93L24 12.3v8.2c0 .83-.67 1.5-1.5 1.5h-2a1.5 1.5 0 0 1-1.5-1.5V20h-3.55l1.67 2.54-.84.54L14.26 20 5 19.96v.54c0 .83-.67 1.5-1.5 1.5h-2A1.5 1.5 0 0 1 0 20.5v-7.08h1v7.08c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5v-1.54l9.6.02-3.17-4.83c-.87.45-1.85.72-2.92.75H7.1c-3.88 0-7-3.13-7.1-7.09zm5.47-2.75c1.86-.12 3.37-.2 4.52-.24l.03 1c-1.08.03-2.5.1-4.24.22L4.51 8c-.27.43-.6 1-1 1.7l-.87-.48c.4-.72.74-1.3 1.03-1.76q.6-.96 1.41-2.19l.14-.2z',
|
||||
gps: 'M12.5 3.51V6.5h-1V3.51a8.46 8.46 0 0 0-7.99 7.99H6.5v1H3.51a8.46 8.46 0 0 0 7.99 7.99V17.5h1v2.99a8.46 8.46 0 0 0 7.99-7.99H17.5v-1h2.99a8.46 8.46 0 0 0-7.99-7.99m0-1a9.46 9.46 0 0 1 8.99 8.99h2.01v1h-2.01a9.46 9.46 0 0 1-8.99 8.99v2.01h-1v-2.01a9.46 9.46 0 0 1-8.99-8.99H.5v-1h2.01a9.46 9.46 0 0 1 8.99-8.99V.5h1z',
|
||||
selectAll:
|
||||
'M2.8 2H5v1H3v2H2V2.8a.76.76 0 0 1 .43-.71.9.9 0 0 1 .35-.09zv.5zM9 3H7V2h2zm4 0h-2V2h2zm4 0h-2V2h2zm4 0h-2V2h2.2v.5V2h.02a1 1 0 0 1 .1.01c.07.01.16.03.25.08a.76.76 0 0 1 .43.71V5h-1zM6 6h12v12H6zm1 1v10h10V7zM3 7v2H2V7zm19 0v2h-1V7zM3 11v2H2v-2zm19 0v2h-1v-2zM3 15v2H2v-2zm19 0v2h-1v-2zM3 21v-2H2v2.2a.76.76 0 0 0 .43.71.9.9 0 0 0 .35.09h.01l.01-.5v.5H5v-1zm19-2v2.2a.76.76 0 0 1-.43.71.9.9 0 0 1-.35.09h-.01l-.01-.49V22H19v-1h2v-2zM9 22H7v-1h2zm4 0h-2v-1h2zm4 0h-2v-1h2z',
|
||||
planRoute:
|
||||
'm18.3 7-3.65-3.65.7-.7 4.86 4.85-4.86 4.85-.7-.7L18.29 8H9.5C8.67 8 8 8.67 8 9.5V21H7V9.5A2.5 2.5 0 0 1 9.5 7z',
|
||||
upload:
|
||||
'M13 20h-1V9.7l-3.65 3.65-.7-.7 4.85-4.86 4.85 4.86-.7.7L13 9.71zM6 5V4h13v1z',
|
||||
parking:
|
||||
'M0 2.5A2.5 2.5 0 0 1 2.5 0H23v20.5a2.5 2.5 0 0 1-2.5 2.5H0zM2.5 1C1.67 1 1 1.67 1 2.5V22h19.5c.83 0 1.5-.67 1.5-1.5V1zm11.59 6.35C13.6 7 13.02 7 12.5 7H8v6h4.5c.52 0 1.11-.01 1.59-.35.44-.3.91-1 .91-2.65 0-1.66-.47-2.34-.91-2.65M8 14v3H7V6h5.53c.5 0 1.38 0 2.13.53C15.47 7.09 16 8.16 16 10s-.53 2.9-1.34 3.47a3.6 3.6 0 0 1-2.13.53z',
|
||||
// poi-car-l: Umriss (Ring) + Vollton-Silhouette dahinter, siehe
|
||||
// fahrzeugMarkerSVG() im Panel — reine Konturformen sind auf Kartenkacheln
|
||||
// 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',
|
||||
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
|
||||
|
||||
function ciIcon(d: string, px: number): string {
|
||||
return `<svg viewBox="0 0 24 24" width="${px}" height="${px}" fill="none" aria-hidden="true"><path fill="currentColor" d="${d}"/></svg>`
|
||||
}
|
||||
|
||||
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>`
|
||||
}
|
||||
|
||||
/* Haversine-Näherung, wie im Panel (distanzMeter()/distanzText()). */
|
||||
function distanzMeter(a: { lat: number; lon: number }, b: { lat: number; lon: number }): number {
|
||||
const R = 6371000
|
||||
const rad = Math.PI / 180
|
||||
const dLat = (b.lat - a.lat) * rad
|
||||
const dLon = (b.lon - a.lon) * rad
|
||||
const s =
|
||||
Math.sin(dLat / 2) ** 2 + Math.cos(a.lat * rad) * Math.cos(b.lat * rad) * Math.sin(dLon / 2) ** 2
|
||||
return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s))
|
||||
}
|
||||
function distanzText(m: number | null): string {
|
||||
return m == null ? "" : m < 1000 ? `${Math.round(m)} m` : `${de(m / 1000, 1)} km`
|
||||
}
|
||||
function dauerSeitText(ts: number | null): string {
|
||||
if (ts == null) return "unbekannt"
|
||||
const sekunden = Math.max(0, Math.floor((Date.now() - ts) / 1000))
|
||||
const tage = Math.floor(sekunden / 86400)
|
||||
const stunden = Math.floor((sekunden % 86400) / 3600)
|
||||
const minuten = Math.floor((sekunden % 3600) / 60)
|
||||
if (tage > 0) return `${tage} Tg. ${stunden} Std. ${minuten} Min.`
|
||||
if (stunden > 0) return `${stunden} Std. ${minuten} Min.`
|
||||
return `${minuten} Min.`
|
||||
}
|
||||
|
||||
/* Fahrzustand aus der Zündung + letzter beendeter Fahrt, deckungsgleich mit
|
||||
standortZustand() im Panel. */
|
||||
function standortZustand(
|
||||
fahrzeug: Fahrzeug,
|
||||
fahrten: readonly { ts_end: string }[],
|
||||
pauseMin: number,
|
||||
): { faehrt: boolean; unbekannt: boolean; seitTs: number | null; geparkt: boolean } {
|
||||
let neuesteTs: number | null = null
|
||||
for (const t of fahrten) {
|
||||
const ts = new Date(t.ts_end).getTime()
|
||||
if (!Number.isNaN(ts) && (neuesteTs === null || ts > neuesteTs)) neuesteTs = ts
|
||||
}
|
||||
if (fahrzeug.zuendung === true) return { faehrt: true, unbekannt: false, seitTs: null, geparkt: false }
|
||||
const minuten = neuesteTs != null ? (Date.now() - neuesteTs) / 60000 : null
|
||||
return {
|
||||
faehrt: false,
|
||||
unbekannt: fahrzeug.zuendung == null,
|
||||
seitTs: neuesteTs,
|
||||
geparkt: minuten != null && minuten >= pauseMin,
|
||||
}
|
||||
}
|
||||
|
||||
/* Reverse-Geocoding über Nominatim (OpenStreetMap), wie standortAdresseAufloesen()
|
||||
im Panel — Cache-Schlüssel auf 3 Nachkommastellen (~110 m) gerundet, damit
|
||||
erneutes Öffnen an derselben Stelle keinen zweiten Abruf auslöst. Modulweiter
|
||||
Cache (nicht in localStorage wie die Tankstellen-Adresssuche im Panel — die
|
||||
Fahrzeugposition ändert sich laufend, ein dauerhafter Cache brächte nichts). */
|
||||
let adressCacheKey: string | null = null
|
||||
let adressCacheWert: string | null = null
|
||||
async function standortAdresseAufloesen(lat: number, lon: number): Promise<string | null> {
|
||||
const key = `${lat.toFixed(3)},${lon.toFixed(3)}`
|
||||
if (adressCacheKey === key) return adressCacheWert
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${lat}&lon=${lon}&zoom=18&addressdetails=1`,
|
||||
{ headers: { "Accept-Language": "de" } },
|
||||
)
|
||||
const daten = (await res.json()) as { address?: Record<string, string>; display_name?: string }
|
||||
const a = daten.address ?? {}
|
||||
const strasse = [a["road"], a["house_number"]].filter(Boolean).join(" ")
|
||||
const ort = [a["postcode"], a["city"] ?? a["town"] ?? a["village"]].filter(Boolean).join(" ")
|
||||
adressCacheWert = [strasse, ort].filter(Boolean).join(", ") || daten.display_name || null
|
||||
} catch {
|
||||
// Bewusst NICHT den Schlüssel setzen — ein Netzfehler soll beim nächsten
|
||||
// Öffnen erneut versucht werden, nicht dauerhaft als "erledigt" gelten.
|
||||
return null
|
||||
}
|
||||
adressCacheKey = key
|
||||
return adressCacheWert
|
||||
}
|
||||
|
||||
interface EigenePosition {
|
||||
lat: number
|
||||
lon: number
|
||||
}
|
||||
|
||||
function eigenePositionErfassen(
|
||||
setzePosition: (p: EigenePosition | null) => void,
|
||||
setzeFehler: (f: string | null) => void,
|
||||
): void {
|
||||
if (!navigator.geolocation) {
|
||||
setzeFehler("GPS offline")
|
||||
return
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
setzeFehler(null)
|
||||
setzePosition({ lat: pos.coords.latitude, lon: pos.coords.longitude })
|
||||
},
|
||||
(err) => {
|
||||
// Diese Meldung betrifft den Standort DIESES Geräts, nicht des Fahrzeugs
|
||||
// — "GPS-Freigabe fehlt" nur beim genau dafür zuständigen Fehlercode,
|
||||
// sonst "GPS offline" (deckungsgleich mit dem Panel).
|
||||
setzeFehler(err.code === err.PERMISSION_DENIED ? "GPS-Freigabe fehlt" : "GPS offline")
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 30000 },
|
||||
)
|
||||
}
|
||||
|
||||
/** Kleine, nicht interaktive Kartenvorschau auf der Übersicht — Vorlage:
|
||||
standortKachel()/initVorschauMap() im Panel. */
|
||||
export function StandortVorschau({ geheZu }: { geheZu: () => void }) {
|
||||
const { fahrzeug } = useDaten()
|
||||
const behaelter = useRef<HTMLDivElement | null>(null)
|
||||
const bekannt = fahrzeug?.standortLat != null && fahrzeug?.standortLon != null
|
||||
|
||||
useEffect(() => {
|
||||
const element = behaelter.current
|
||||
if (!element || !bekannt || !fahrzeug) return
|
||||
let karte: import("leaflet").Map | null = null
|
||||
let abgebrochen = false
|
||||
void import("leaflet").then((L) => {
|
||||
if (abgebrochen || !element) return
|
||||
karte = L.map(element, {
|
||||
attributionControl: false,
|
||||
zoomControl: false,
|
||||
dragging: false,
|
||||
scrollWheelZoom: false,
|
||||
doubleClickZoom: false,
|
||||
boxZoom: false,
|
||||
keyboard: false,
|
||||
touchZoom: false,
|
||||
})
|
||||
L.tileLayer(KACHEL_URL, { attribution: KACHEL_ZUSATZ, maxZoom: 19 }).addTo(karte)
|
||||
karte.setView([fahrzeug.standortLat!, fahrzeug.standortLon!], 14)
|
||||
const icon = L.divIcon({
|
||||
className: "dm-fahrzeug-pin-wrap",
|
||||
html: fahrzeugMarkerHtml(32),
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [15, 31],
|
||||
})
|
||||
L.marker([fahrzeug.standortLat!, fahrzeug.standortLon!], { icon }).addTo(karte)
|
||||
setTimeout(() => karte?.invalidateSize(), 60)
|
||||
})
|
||||
return () => {
|
||||
abgebrochen = true
|
||||
karte?.remove()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bekannt, fahrzeug?.standortLat, fahrzeug?.standortLon])
|
||||
|
||||
return (
|
||||
<button type="button" className="dm-standort-kachelbtn" onClick={geheZu} aria-label="Standort in Vollbild öffnen">
|
||||
<div className="dm-standort-vorschau" ref={behaelter} />
|
||||
{!bekannt && <div className="dm-standort-leer">Kein GPS-Signal vom Fahrzeug</div>}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** Vollbild-Standortansicht — Vorlage: vStandort() im Panel. */
|
||||
export function Standort() {
|
||||
const { fahrzeug, einstellungen, fahrten } = useDaten()
|
||||
const behaelter = useRef<HTMLDivElement | null>(null)
|
||||
const kartenRef = useRef<import("leaflet").Map | null>(null)
|
||||
const fahrzeugMarkerRef = useRef<import("leaflet").Marker | null>(null)
|
||||
const userMarkerRef = useRef<import("leaflet").Marker | null>(null)
|
||||
const kachelRef = useRef<import("leaflet").TileLayer | null>(null)
|
||||
const leafletRef = useRef<typeof import("leaflet") | null>(null)
|
||||
|
||||
const [satellit, setzeSatellit] = useState(false)
|
||||
const [eigenePosition, setzeEigenePosition] = useState<EigenePosition | null>(null)
|
||||
const [positionsFehler, setzePositionsFehler] = useState<string | null>(null)
|
||||
const [adresse, setzeAdresse] = useState<string | null>(null)
|
||||
const [blattOffen, setzeBlattOffen] = useState(false)
|
||||
const blattRef = useRef<HTMLDivElement | null>(null)
|
||||
// Zug am Griff, deckungsgleich mit standortMenuVerdrahten() im Panel: das
|
||||
// Blatt folgt der Fingerbewegung direkt (Ref statt State - kein Re-Render
|
||||
// pro Pixel), erst pointerup entscheidet über den endgültigen Zustand.
|
||||
const zugRef = useRef<{ y0: number; offenBeimStart: boolean } | null>(null)
|
||||
|
||||
const blattHub = () => {
|
||||
const el = blattRef.current
|
||||
if (!el) return 0
|
||||
const peek = parseFloat(getComputedStyle(el).getPropertyValue("--dm-standort-peek")) || 96
|
||||
return Math.max(0, el.offsetHeight - peek)
|
||||
}
|
||||
const griffPointerDown = (e: ReactPointerEvent) => {
|
||||
zugRef.current = { y0: e.clientY, offenBeimStart: blattOffen }
|
||||
const el = blattRef.current
|
||||
if (el) el.style.transition = "none"
|
||||
}
|
||||
const griffPointerMove = (e: ReactPointerEvent) => {
|
||||
const zug = zugRef.current
|
||||
const el = blattRef.current
|
||||
if (!zug || !el) return
|
||||
const dy = e.clientY - zug.y0
|
||||
const hub = blattHub()
|
||||
const basis = zug.offenBeimStart ? 0 : hub
|
||||
const versatz = Math.max(0, Math.min(hub, basis + dy))
|
||||
el.style.transform = `translateY(${versatz}px)`
|
||||
}
|
||||
const griffGesteBeenden = () => {
|
||||
const el = blattRef.current
|
||||
if (el) {
|
||||
el.style.transition = ""
|
||||
el.style.transform = ""
|
||||
}
|
||||
zugRef.current = null
|
||||
}
|
||||
const griffPointerUp = (e: ReactPointerEvent) => {
|
||||
const zug = zugRef.current
|
||||
if (!zug) return
|
||||
const dy = e.clientY - zug.y0
|
||||
griffGesteBeenden()
|
||||
if (Math.abs(dy) > 40) setzeBlattOffen(dy < 0)
|
||||
else setzeBlattOffen((o) => !o)
|
||||
}
|
||||
|
||||
const bekannt = fahrzeug?.standortLat != null && fahrzeug?.standortLon != null
|
||||
const fahrzeugPos = bekannt ? { lat: fahrzeug!.standortLat!, lon: fahrzeug!.standortLon! } : null
|
||||
|
||||
// Karte einmalig aufbauen.
|
||||
useEffect(() => {
|
||||
const element = behaelter.current
|
||||
if (!element) return
|
||||
let abgebrochen = false
|
||||
void import("leaflet").then((L) => {
|
||||
if (abgebrochen || !element) return
|
||||
leafletRef.current = L
|
||||
const karte = L.map(element, { attributionControl: false, zoomControl: false })
|
||||
kartenRef.current = karte
|
||||
kachelRef.current = L.tileLayer(KACHEL_URL, { attribution: KACHEL_ZUSATZ, maxZoom: 19 }).addTo(karte)
|
||||
if (fahrzeugPos) karte.setView([fahrzeugPos.lat, fahrzeugPos.lon], 15)
|
||||
else karte.setView([51.16, 10.45], 5)
|
||||
setTimeout(() => karte.invalidateSize(), 60)
|
||||
eigenePositionErfassen(setzeEigenePosition, setzePositionsFehler)
|
||||
})
|
||||
return () => {
|
||||
abgebrochen = true
|
||||
kartenRef.current?.remove()
|
||||
kartenRef.current = null
|
||||
}
|
||||
// Nur beim ersten Aufbau — Positions-/Kachel-Updates laufen über die
|
||||
// Effekte unten, damit die Karte nicht bei jedem Datenpush neu entsteht.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Kachel-Ebene wechseln (Straße/Satellit).
|
||||
useEffect(() => {
|
||||
const L = leafletRef.current
|
||||
const karte = kartenRef.current
|
||||
if (!L || !karte) return
|
||||
if (kachelRef.current) karte.removeLayer(kachelRef.current)
|
||||
kachelRef.current = satellit
|
||||
? L.tileLayer(SAT_KACHEL_URL, { attribution: SAT_ZUSATZ, maxZoom: 19 }).addTo(karte)
|
||||
: L.tileLayer(KACHEL_URL, { attribution: KACHEL_ZUSATZ, maxZoom: 19 }).addTo(karte)
|
||||
}, [satellit])
|
||||
|
||||
// Fahrzeugmarker zeichnen/aktualisieren.
|
||||
useEffect(() => {
|
||||
const L = leafletRef.current
|
||||
const karte = kartenRef.current
|
||||
if (!L || !karte) return
|
||||
if (fahrzeugMarkerRef.current) {
|
||||
karte.removeLayer(fahrzeugMarkerRef.current)
|
||||
fahrzeugMarkerRef.current = null
|
||||
}
|
||||
if (fahrzeugPos) {
|
||||
const icon = L.divIcon({
|
||||
className: "dm-fahrzeug-pin-wrap",
|
||||
html: fahrzeugMarkerHtml(38),
|
||||
iconSize: [38, 38],
|
||||
iconAnchor: [19, 36],
|
||||
})
|
||||
const marker = L.marker([fahrzeugPos.lat, fahrzeugPos.lon], { icon }).addTo(karte)
|
||||
marker.on("click", () => setzeBlattOffen(true))
|
||||
fahrzeugMarkerRef.current = marker
|
||||
}
|
||||
}, [fahrzeugPos?.lat, fahrzeugPos?.lon])
|
||||
|
||||
// Eigene Position zeichnen/aktualisieren.
|
||||
useEffect(() => {
|
||||
const L = leafletRef.current
|
||||
const karte = kartenRef.current
|
||||
if (!L || !karte) return
|
||||
if (userMarkerRef.current) {
|
||||
karte.removeLayer(userMarkerRef.current)
|
||||
userMarkerRef.current = null
|
||||
}
|
||||
if (eigenePosition) {
|
||||
const icon = L.divIcon({
|
||||
className: "dm-user-pin-wrap",
|
||||
html: `<div class="dm-user-pin"></div>`,
|
||||
iconSize: [16, 16],
|
||||
iconAnchor: [8, 8],
|
||||
})
|
||||
userMarkerRef.current = L.marker([eigenePosition.lat, eigenePosition.lon], {
|
||||
icon,
|
||||
zIndexOffset: -100,
|
||||
}).addTo(karte)
|
||||
}
|
||||
}, [eigenePosition?.lat, eigenePosition?.lon])
|
||||
|
||||
// Anfangsausschnitt: beide Punkte zeigen, sobald beide bekannt sind.
|
||||
useEffect(() => {
|
||||
const karte = kartenRef.current
|
||||
if (!karte) return
|
||||
if (fahrzeugPos && eigenePosition) {
|
||||
karte.fitBounds(
|
||||
[
|
||||
[fahrzeugPos.lat, fahrzeugPos.lon],
|
||||
[eigenePosition.lat, eigenePosition.lon],
|
||||
],
|
||||
{ padding: [64, 64], maxZoom: 16 },
|
||||
)
|
||||
}
|
||||
// Nur beim ersten Mal, an dem beide Punkte vorliegen — ein späterer Push
|
||||
// (Fahrzeug bewegt sich) soll den Nutzer nicht aus einer manuellen
|
||||
// Kartenposition herausreißen.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [!!fahrzeugPos, !!eigenePosition])
|
||||
|
||||
// Adresse nachladen, sobald/wann immer sich die Fahrzeugposition ändert.
|
||||
useEffect(() => {
|
||||
if (!fahrzeugPos) {
|
||||
setzeAdresse(null)
|
||||
return
|
||||
}
|
||||
let abgebrochen = false
|
||||
void standortAdresseAufloesen(fahrzeugPos.lat, fahrzeugPos.lon).then((a) => {
|
||||
if (!abgebrochen && a) setzeAdresse(a)
|
||||
})
|
||||
return () => {
|
||||
abgebrochen = true
|
||||
}
|
||||
}, [fahrzeugPos?.lat, fahrzeugPos?.lon])
|
||||
|
||||
if (!fahrzeug || !einstellungen) return null
|
||||
|
||||
const distanz = eigenePosition && fahrzeugPos ? distanzMeter(eigenePosition, fahrzeugPos) : null
|
||||
const distanzAnzeige = !bekannt
|
||||
? ""
|
||||
: eigenePosition
|
||||
? `${distanzText(distanz)} entfernt`
|
||||
: (positionsFehler ?? "Abstand wird ermittelt …")
|
||||
const koordinaten = fahrzeugPos ? `${de(fahrzeugPos.lat, 4)}, ${de(fahrzeugPos.lon, 4)}` : ""
|
||||
const adresseAnzeige = !bekannt ? "Kein GPS-Signal vom Fahrzeug" : (adresse ?? koordinaten)
|
||||
const zustand = standortZustand(fahrzeug, fahrten, einstellungen.pauseMin)
|
||||
const kartenlink = fahrzeugPos
|
||||
? `https://www.google.com/maps/dir/?api=1&destination=${fahrzeugPos.lat},${fahrzeugPos.lon}`
|
||||
: null
|
||||
|
||||
const teilen = () => {
|
||||
if (!kartenlink) return
|
||||
const titel = `${einstellungen.fahrzeugtitel} · Standort`
|
||||
if (navigator.share) {
|
||||
void navigator.share({ title: titel, url: kartenlink }).catch(() => {
|
||||
/* vom Nutzer abgebrochen */
|
||||
})
|
||||
return
|
||||
}
|
||||
void navigator.clipboard.writeText(kartenlink).catch(() => {
|
||||
/* Zwischenablage nicht verfügbar — der Link steht in der Adressleiste des neuen Tabs */
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dm-standort-vollbild">
|
||||
<div className="dm-standort-karte" ref={behaelter} />
|
||||
<div className="dm-kartensteuerung">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setzeSatellit((s) => !s)}
|
||||
aria-pressed={satellit}
|
||||
aria-label={satellit ? "Satellitenansicht, zur Straßenkarte wechseln" : "Straßenkarte, zur Satellitenansicht wechseln"}
|
||||
dangerouslySetInnerHTML={{ __html: ciIcon(CI.mapLayer, 22) }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fahrzeugPos && kartenRef.current?.flyTo([fahrzeugPos.lat, fahrzeugPos.lon], Math.max(kartenRef.current.getZoom(), 15))}
|
||||
aria-label="Fahrzeug auf der Karte suchen"
|
||||
dangerouslySetInnerHTML={{ __html: ciIcon(CI.carSearch, 22) }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!eigenePosition) {
|
||||
eigenePositionErfassen(setzeEigenePosition, setzePositionsFehler)
|
||||
return
|
||||
}
|
||||
kartenRef.current?.flyTo([eigenePosition.lat, eigenePosition.lon], Math.max(kartenRef.current.getZoom(), 15))
|
||||
}}
|
||||
aria-label="Auf eigenen Standort zentrieren"
|
||||
dangerouslySetInnerHTML={{ __html: ciIcon(CI.gps, 22) }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const karte = kartenRef.current
|
||||
if (!karte) return
|
||||
if (fahrzeugPos && eigenePosition) {
|
||||
karte.fitBounds([[fahrzeugPos.lat, fahrzeugPos.lon], [eigenePosition.lat, eigenePosition.lon]], {
|
||||
padding: [64, 64],
|
||||
maxZoom: 16,
|
||||
})
|
||||
} else if (fahrzeugPos) karte.flyTo([fahrzeugPos.lat, fahrzeugPos.lon], 15)
|
||||
else if (eigenePosition) karte.flyTo([eigenePosition.lat, eigenePosition.lon], 15)
|
||||
}}
|
||||
aria-label="Fahrzeug und eigenen Standort gemeinsam zeigen"
|
||||
dangerouslySetInnerHTML={{ __html: ciIcon(CI.selectAll, 22) }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={blattRef}
|
||||
className={blattOffen ? "dm-standortmenu dm-standortmenu--offen" : "dm-standortmenu"}
|
||||
>
|
||||
<div
|
||||
className="dm-standortmenu__griffzone"
|
||||
onPointerDown={griffPointerDown}
|
||||
onPointerMove={griffPointerMove}
|
||||
onPointerUp={griffPointerUp}
|
||||
onPointerCancel={griffGesteBeenden}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="dm-standortmenu__griff"
|
||||
// Tippen/Klicken wird schon von den Pointer-Handlern der
|
||||
// Griffzone erledigt (dy < 40px zählt dort als Tipp) - ein
|
||||
// zusätzliches onClick würde bei Maus/Touch doppelt umschalten.
|
||||
// Nur für Tastaturbedienung (Enter/Leertaste), die keinen
|
||||
// Pointer-Event auslöst.
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
setzeBlattOffen((o) => !o)
|
||||
}
|
||||
}}
|
||||
aria-expanded={blattOffen}
|
||||
aria-label={blattOffen ? "Fahrzeugdetails einklappen" : "Fahrzeugdetails aufklappen"}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="dm-standortmenu__schliessen"
|
||||
onClick={() => setzeBlattOffen(false)}
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
|
||||
<path d="M5 5l10 10M15 5 5 15" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="dm-standortmenu__kopf">
|
||||
<div className="dm-standortmenu__name">{einstellungen.fahrzeugtitel}</div>
|
||||
<div className="dm-standortmenu__distanz">{distanzAnzeige}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dm-standortmenu__adresse">{adresseAnzeige}</div>
|
||||
<div className="dm-standortmenu__status">
|
||||
{zustand.faehrt ? (
|
||||
<>
|
||||
<span className="dm-standortmenu__punkt" />
|
||||
<span>Fahrzeug fährt</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span dangerouslySetInnerHTML={{ __html: ciIcon(CI.parking, 16) }} />
|
||||
<span>
|
||||
{zustand.unbekannt
|
||||
? "Fahrzustand unbekannt"
|
||||
: zustand.seitTs != null
|
||||
? `Geparkt seit ${dauerSeitText(zustand.seitTs)}`
|
||||
: "Fahrzeug steht"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="dm-standortmenu__werte">
|
||||
<SymbolTanken groesse={16} />
|
||||
<span>{de(fahrzeug.tankPct)} %</span>
|
||||
<span>{fahrzeug.reichweite != null ? `${de(fahrzeug.reichweite)} km` : "Reichweite unbekannt"}</span>
|
||||
</div>
|
||||
<div className="dm-standortmenu__aktionen">
|
||||
<a
|
||||
className="dm-standort-pille dm-standort-pille--primaer"
|
||||
href={kartenlink ?? undefined}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
aria-disabled={!kartenlink}
|
||||
style={kartenlink ? undefined : { pointerEvents: "none", opacity: 0.4 }}
|
||||
>
|
||||
<span dangerouslySetInnerHTML={{ __html: ciIcon(CI.planRoute, 15) }} />
|
||||
Route
|
||||
</a>
|
||||
<button type="button" className="dm-standort-pille" onClick={teilen} disabled={!bekannt}>
|
||||
<span dangerouslySetInnerHTML={{ __html: ciIcon(CI.upload, 15) }} />
|
||||
Teilen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,12 +9,13 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
import { ActionButton, Pill, Tile } from "@audi-dash/ui"
|
||||
import { ActionButton, Feld, Pill, 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 { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
|
||||
import { KRAFTSTOFFSORTEN, Leerzustand, Wertzeile, Werteliste } from "./bausteine"
|
||||
|
||||
interface BelegErgebnis {
|
||||
ok?: boolean
|
||||
@@ -27,6 +28,7 @@ interface BelegErgebnis {
|
||||
export function TankDetail({ id }: { id: string | undefined }) {
|
||||
const { tankvorgaenge, api } = useDaten()
|
||||
const tank = tankvorgaenge.find((t) => t.tank_id === id)
|
||||
const [bearbeitenOffen, setzeBearbeitenOffen] = useState(false)
|
||||
|
||||
if (!tank) {
|
||||
return (
|
||||
@@ -37,12 +39,21 @@ export function TankDetail({ id }: { id: string | undefined }) {
|
||||
)
|
||||
}
|
||||
|
||||
if (bearbeitenOffen) {
|
||||
return <TankBearbeiten tank={tank} beiFertig={() => setzeBearbeitenOffen(false)} />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">{datumZeit(tank.ts)}</span>
|
||||
<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">
|
||||
<div className="dm-detail__zahl dm-detail__zahl--gross">
|
||||
{deOderStrich(tank.liters, 2)}
|
||||
<span className="dm-detail__einheit">l</span>
|
||||
</div>
|
||||
@@ -92,6 +103,169 @@ export function TankDetail({ id }: { id: string | undefined }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** "YYYY-MM-DDTHH:mm" in Ortszeit für ein <input type="datetime-local">. */
|
||||
function alsDatetimeLocal(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return ""
|
||||
const versatz = d.getTimezoneOffset() * 60_000
|
||||
return new Date(d.getTime() - versatz).toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
/** Bearbeiten-Formular für einen bestehenden Tankvorgang - dieselben Felder
|
||||
wie tankFelder() im Panel (Zeitpunkt, Kilometerstand, Distanz, Kraftstoff,
|
||||
Liter, Kosten, SmartDeal-Ersparnis, Tankstelle). Fehlte hier bisher
|
||||
vollständig - der einzige Weg, einen Wert zu korrigieren, war ein neuer
|
||||
Beleg-Upload. */
|
||||
function TankBearbeiten({ tank, beiFertig }: { tank: Tankvorgang; beiFertig: () => void }) {
|
||||
const { api, fahrzeug } = useDaten()
|
||||
const [zeitpunkt, setzeZeitpunkt] = useState(alsDatetimeLocal(tank.ts))
|
||||
const [km, setzeKm] = useState(tank.odometer_km != null ? String(tank.odometer_km) : "")
|
||||
const [distanz, setzeDistanz] = useState(tank.distance_km != null ? String(tank.distance_km) : "")
|
||||
const [kraftstoff, setzeKraftstoff] = useState(tank.fuel_type ?? KRAFTSTOFFSORTEN[0])
|
||||
const [liter, setzeLiter] = useState(tank.liters != null ? String(tank.liters) : "")
|
||||
const [kosten, setzeKosten] = useState(tank.fuel_total_eur != null ? String(tank.fuel_total_eur) : "")
|
||||
const [ersparnis, setzeErsparnis] = useState(tank.discount != null ? String(tank.discount) : "")
|
||||
const [station, setzeStation] = useState(tank.station_name ?? "")
|
||||
const [fehler, setzeFehler] = useState<string | null>(null)
|
||||
const [laeuft, setzeLaeuft] = useState(false)
|
||||
|
||||
const smartdealAktiv = fahrzeug?.smartdeal.aktiv === true
|
||||
|
||||
const speichern = async () => {
|
||||
setzeFehler(null)
|
||||
const literZahl = Number(liter.replace(",", "."))
|
||||
const kostenZahl = Number(kosten.replace(",", "."))
|
||||
if (!Number.isFinite(literZahl) || literZahl <= 0) {
|
||||
setzeFehler("Bitte die getankte Menge in Litern angeben.")
|
||||
return
|
||||
}
|
||||
if (!Number.isFinite(kostenZahl) || kostenZahl <= 0) {
|
||||
setzeFehler("Bitte den Gesamtbetrag angeben.")
|
||||
return
|
||||
}
|
||||
setzeLaeuft(true)
|
||||
try {
|
||||
const kmZahl = Number(km.replace(",", "."))
|
||||
const distanzZahl = Number(distanz.replace(",", "."))
|
||||
const ersparnisZahl = Number(ersparnis.replace(",", "."))
|
||||
await api.tankvorgangAktualisieren(tank.tank_id, {
|
||||
ts: zeitpunkt ? new Date(zeitpunkt).toISOString() : tank.ts,
|
||||
liter: literZahl,
|
||||
kosten: kostenZahl,
|
||||
kraftstoff,
|
||||
station: station || null,
|
||||
km: Number.isFinite(kmZahl) && km ? kmZahl : null,
|
||||
distanz: Number.isFinite(distanzZahl) && distanz ? distanzZahl : null,
|
||||
...(smartdealAktiv
|
||||
? { ersparnis: Number.isFinite(ersparnisZahl) && ersparnis ? ersparnisZahl : null }
|
||||
: {}),
|
||||
})
|
||||
beiFertig()
|
||||
} catch (ursache) {
|
||||
setzeFehler(ursache instanceof Error ? ursache.message : String(ursache))
|
||||
} finally {
|
||||
setzeLaeuft(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tile>
|
||||
<div className="dm-abschnitt" style={{ marginBottom: 0 }}>
|
||||
<span className="ads-eyebrow">Tankvorgang bearbeiten</span>
|
||||
<button type="button" className="dm-textknopf" onClick={beiFertig} disabled={laeuft}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
<Feld label="Zeitpunkt">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
type="datetime-local"
|
||||
value={zeitpunkt}
|
||||
onChange={(e) => setzeZeitpunkt(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Kilometerstand" unit="km">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
inputMode="numeric"
|
||||
placeholder="wird ergänzt"
|
||||
value={km}
|
||||
onChange={(e) => setzeKm(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Gefahrene Distanz" unit="km">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
inputMode="decimal"
|
||||
placeholder="aus km-Stand"
|
||||
value={distanz}
|
||||
onChange={(e) => setzeDistanz(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Kraftstoff">
|
||||
<select
|
||||
className="dm-auswahl"
|
||||
value={kraftstoff}
|
||||
onChange={(e) => setzeKraftstoff(e.target.value)}
|
||||
>
|
||||
{KRAFTSTOFFSORTEN.map((sorte) => (
|
||||
<option key={sorte} value={sorte}>
|
||||
{sorte}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Feld>
|
||||
<Feld label="Getankte Liter" unit="l">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
inputMode="decimal"
|
||||
placeholder="0,00"
|
||||
value={liter}
|
||||
onChange={(e) => setzeLiter(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Kosten (real, nach Rabatt)" unit="€">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
inputMode="decimal"
|
||||
placeholder="0,00"
|
||||
value={kosten}
|
||||
onChange={(e) => setzeKosten(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
{smartdealAktiv && (
|
||||
<Feld label="SmartDeal-Ersparnis" unit="€">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
inputMode="decimal"
|
||||
placeholder="0,00"
|
||||
value={ersparnis}
|
||||
onChange={(e) => setzeErsparnis(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
)}
|
||||
<Feld label="Tankstelle" last>
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
placeholder="Name"
|
||||
value={station}
|
||||
onChange={(e) => setzeStation(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
{fehler && (
|
||||
<p className="dm-fehler" role="alert">
|
||||
{fehler}
|
||||
</p>
|
||||
)}
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
|
||||
{laeuft ? "Speichere …" : "Speichern"}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Tile>
|
||||
)
|
||||
}
|
||||
|
||||
function BelegBereich({
|
||||
tankId,
|
||||
belegDatei,
|
||||
|
||||
@@ -14,14 +14,16 @@ import { useDaten } from "../daten/DatenKontext"
|
||||
import { nachJahrUndMonat } from "../daten/statistik"
|
||||
import { datum, de, deOderStrich, eur, monatJahr, schnittpreis, summe } from "../format"
|
||||
import type { SeitenName } from "../navigation"
|
||||
import { Leerzustand } from "./bausteine"
|
||||
import { KRAFTSTOFFSORTEN, Leerzustand } from "./bausteine"
|
||||
import { markenlogoUrl } from "./bilder"
|
||||
import { Zeilenmenue } from "./Zeilenmenue"
|
||||
|
||||
export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
|
||||
const { tankvorgaenge, api } = useDaten()
|
||||
const { tankvorgaenge, fahrzeug, einstellungen, api } = useDaten()
|
||||
const [formularOffen, setzeFormularOffen] = useState(false)
|
||||
|
||||
const gruppen = nachJahrUndMonat(tankvorgaenge, (t) => t.ts)
|
||||
const smartdealAktiv = fahrzeug?.smartdeal.aktiv === true
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -29,7 +31,9 @@ export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
|
||||
<span className="ads-eyebrow">
|
||||
{tankvorgaenge.length === 0
|
||||
? "Keine Tankvorgänge"
|
||||
: `${de(tankvorgaenge.length)} Tankvorgang${tankvorgaenge.length === 1 ? "" : "̈e"}`}
|
||||
: tankvorgaenge.length === 1
|
||||
? "1 Tankvorgang"
|
||||
: `${de(tankvorgaenge.length)} Tankvorgänge`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -50,11 +54,36 @@ export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
|
||||
/>
|
||||
)}
|
||||
|
||||
{gruppen.map((jahr) => (
|
||||
{gruppen.map((jahr) => {
|
||||
const ersparnisJahr = summe(jahr.eintraege, (t) => t.discount ?? 0)
|
||||
return (
|
||||
<Accordion
|
||||
key={jahr.jahr}
|
||||
title={String(jahr.jahr)}
|
||||
summary={`${eur(summe(jahr.eintraege, (t) => t.fuel_total_eur ?? 0))} €`}
|
||||
summary={
|
||||
<span style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 5 }}>
|
||||
<span style={{ fontSize: 17, color: "var(--fg)" }}>
|
||||
{eur(summe(jahr.eintraege, (t) => t.fuel_total_eur ?? 0))} €
|
||||
</span>
|
||||
<span>
|
||||
{de(summe(jahr.eintraege, (t) => t.liters ?? 0), 1)} l · Ø{" "}
|
||||
{de(schnittpreis(jahr.eintraege), 2)} €/l
|
||||
</span>
|
||||
{smartdealAktiv && ersparnisJahr > 0 && (
|
||||
<Pill>
|
||||
<img
|
||||
src={markenlogoUrl()}
|
||||
alt={einstellungen?.markenname ?? "Logo"}
|
||||
style={{ height: 14, verticalAlign: "middle", marginRight: 6 }}
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = "none"
|
||||
}}
|
||||
/>
|
||||
−{eur(ersparnisJahr)} €
|
||||
</Pill>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
defaultOpen={jahr === gruppen[0]}
|
||||
>
|
||||
{jahr.monate.map((monat) => {
|
||||
@@ -64,7 +93,14 @@ export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
|
||||
key={monat.schluessel}
|
||||
level={2}
|
||||
title={monatJahr(new Date(monat.jahr, monat.monat, 1))}
|
||||
summary={schnitt > 0 ? `${eur(schnitt)} €/l` : "—"}
|
||||
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>
|
||||
</span>
|
||||
}
|
||||
defaultOpen={monat === jahr.monate[0]}
|
||||
>
|
||||
{monat.eintraege.map((tank) => (
|
||||
@@ -102,21 +138,26 @@ export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
|
||||
)
|
||||
})}
|
||||
</Accordion>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TankFormular({ beiFertig }: { beiFertig: () => void }) {
|
||||
const { api } = useDaten()
|
||||
const { api, fahrzeug } = useDaten()
|
||||
const [zeitpunkt, setzeZeitpunkt] = useState("")
|
||||
const [liter, setzeLiter] = useState("")
|
||||
const [kosten, setzeKosten] = useState("")
|
||||
const [kraftstoff, setzeKraftstoff] = useState<string>(KRAFTSTOFFSORTEN[0])
|
||||
const [station, setzeStation] = useState("")
|
||||
const [km, setzeKm] = useState("")
|
||||
const [ersparnis, setzeErsparnis] = useState("")
|
||||
const [fehler, setzeFehler] = useState<string | null>(null)
|
||||
const [laeuft, setzeLaeuft] = useState(false)
|
||||
|
||||
const smartdealAktiv = fahrzeug?.smartdeal.aktiv === true
|
||||
|
||||
const speichern = async () => {
|
||||
setzeFehler(null)
|
||||
const literZahl = Number(liter.replace(",", "."))
|
||||
@@ -132,12 +173,17 @@ function TankFormular({ beiFertig }: { beiFertig: () => void }) {
|
||||
setzeLaeuft(true)
|
||||
try {
|
||||
const kmZahl = Number(km.replace(",", "."))
|
||||
const ersparnisZahl = Number(ersparnis.replace(",", "."))
|
||||
await api.tankvorgangAnlegen({
|
||||
ts: zeitpunkt ? new Date(zeitpunkt).toISOString() : new Date().toISOString(),
|
||||
liter: literZahl,
|
||||
kosten: kostenZahl,
|
||||
kraftstoff,
|
||||
station: station || null,
|
||||
km: Number.isFinite(kmZahl) && km ? kmZahl : null,
|
||||
...(smartdealAktiv
|
||||
? { ersparnis: Number.isFinite(ersparnisZahl) && ersparnis ? ersparnisZahl : null }
|
||||
: {}),
|
||||
})
|
||||
beiFertig()
|
||||
} catch (ursache) {
|
||||
@@ -158,6 +204,19 @@ function TankFormular({ beiFertig }: { beiFertig: () => void }) {
|
||||
onChange={(e) => setzeZeitpunkt(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Kraftstoff">
|
||||
<select
|
||||
className="dm-auswahl"
|
||||
value={kraftstoff}
|
||||
onChange={(e) => setzeKraftstoff(e.target.value)}
|
||||
>
|
||||
{KRAFTSTOFFSORTEN.map((sorte) => (
|
||||
<option key={sorte} value={sorte}>
|
||||
{sorte}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Feld>
|
||||
<Feld label="Menge" unit="l">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
@@ -176,6 +235,17 @@ function TankFormular({ beiFertig }: { beiFertig: () => void }) {
|
||||
onChange={(e) => setzeKosten(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
{smartdealAktiv && (
|
||||
<Feld label="SmartDeal-Ersparnis" unit="€">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
inputMode="decimal"
|
||||
placeholder="0,00"
|
||||
value={ersparnis}
|
||||
onChange={(e) => setzeErsparnis(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
)}
|
||||
<Feld label="Tankstelle">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* Detailseite zu "Türen und Klappen geschlossen" - jede Tür einzeln,
|
||||
* Motorhaube, Kofferraum. Einzige der vier Sammelzeilen aus Fahrzeugstatus.tsx
|
||||
* mit eigener Detailseite. Vorlage: die frühere flache Liste in
|
||||
* Fahrzeugstatus.tsx (vor der Aufteilung in vier Sammelzeilen).
|
||||
* mit eigener Detailseite. Vorlage: vTuerenKlappen() im Panel - deckungsgleiche
|
||||
* dt/dd-Zeile (Label links in var(--fg), Punkt+Zustand rechts in var(--fg2)),
|
||||
* nicht die Kreis-Häkchen aus Fahrzeugstatus.tsx: die Detailliste verwendet
|
||||
* bewusst den einfachen Punkt wie jede andere Wertzeile im Panel.
|
||||
*/
|
||||
|
||||
import { Tile } from "@audi-dash/ui"
|
||||
@@ -27,26 +29,20 @@ export function TuerenKlappen() {
|
||||
return (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Türen und Klappen</span>
|
||||
<ul className="dm-pruefliste">
|
||||
<div style={{ marginTop: 10 }}>
|
||||
{punkte.map((punkt) => (
|
||||
<li key={punkt.label} className="dm-pruefliste__zeile">
|
||||
<span
|
||||
className={
|
||||
punkt.ok === true
|
||||
? "dm-punkt dm-punkt--ok"
|
||||
: punkt.ok === false
|
||||
? "dm-punkt dm-punkt--offen"
|
||||
: "dm-punkt dm-punkt--unbekannt"
|
||||
}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="dm-pruefliste__label">{punkt.label}</span>
|
||||
<span className="dm-pruefliste__wert">
|
||||
{punkt.ok === true ? "zu" : punkt.ok === false ? "offen" : "unbekannt"}
|
||||
</span>
|
||||
</li>
|
||||
<div className="dm-wertzeile" key={punkt.label}>
|
||||
<dt>{punkt.label}</dt>
|
||||
<dd style={{ display: "flex", alignItems: "center", justifyContent: "flex-end", gap: 8 }}>
|
||||
<span
|
||||
className={`ads-dot ads-dot--${punkt.ok === null ? "warn" : punkt.ok ? "ok" : "bad"}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{punkt.ok === null ? "unbekannt" : punkt.ok ? "zu" : "offen"}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Tile>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { SeitenName } from "../navigation"
|
||||
import { Leerzustand, NaviKachel } from "./bausteine"
|
||||
import { Bild } from "./Bild"
|
||||
import type { ServicebuchEintrag } from "./Service"
|
||||
import { StandortVorschau } from "./Standort"
|
||||
import { SymbolInspektion, SymbolOelwechsel } from "../symbole"
|
||||
import { Typenschild, schildErkennen } from "./Typenschild"
|
||||
|
||||
@@ -64,12 +65,14 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
|
||||
|
||||
const sicherheitsstatus =
|
||||
fahrzeug.gesichert === true ? "ok" : fahrzeug.gesichert === false ? "bad" : "warn"
|
||||
// Wortlaut deckungsgleich mit vHome() im Panel (g === null ? "Status unbekannt" :
|
||||
// g ? "Sicher abgestellt" : "Nicht sicher abgestellt").
|
||||
const sicherheitstext =
|
||||
fahrzeug.gesichert === true
|
||||
? "Sicher abgestellt"
|
||||
: fahrzeug.gesichert === false
|
||||
? "Nicht vollständig verschlossen"
|
||||
: "Zustand unbekannt"
|
||||
? "Nicht sicher abgestellt"
|
||||
: "Status unbekannt"
|
||||
|
||||
const aktualisieren = async () => {
|
||||
setzeAktualisiert(true)
|
||||
@@ -93,14 +96,19 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
|
||||
<Typenschild modell={einstellungen.fahrzeugtitel} hoehe={26} />
|
||||
{/* 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. */}
|
||||
<div className="ads-title dm-szene__zusatz">
|
||||
Schild tritt der volle Name an dessen Stelle. Deckungsgleich
|
||||
mit ".badge .zusatz" im Panel: 13px, var(--fg2), nicht kursiv -
|
||||
der Umschließer trug bis 2026-08-30 fälschlich die Klasse
|
||||
"ads-title" (22px/fett wirkend), und .ads-sport (kursiv, für
|
||||
andere Stellen gedacht) passte hier ebenfalls nicht
|
||||
(Parität-Audit). */}
|
||||
<div className="dm-szene__zusatz">
|
||||
{schild ? (
|
||||
zusatz && <span className="ads-sport">{zusatz}</span>
|
||||
zusatz && <span>{zusatz}</span>
|
||||
) : (
|
||||
<>
|
||||
{einstellungen.fahrzeugtitel}{" "}
|
||||
{zusatz && <span className="ads-sport">{zusatz}</span>}
|
||||
{zusatz && <span>{zusatz}</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -118,17 +126,27 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
|
||||
onClick={() => geheZu("sicherheit")}
|
||||
/>
|
||||
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Reichweite</span>
|
||||
<Fig value={deOderStrich(fahrzeug.reichweite)} unit="km" size={40} />
|
||||
<ProgressBar percent={Math.max(0, Math.min(100, fahrzeug.tankPct))} />
|
||||
<div className="dm-tanktext">
|
||||
<span>{de(fahrzeug.tankPct)} % im Tank</span>
|
||||
<span>
|
||||
rund {de((einstellungen.tankvolumen * fahrzeug.tankPct) / 100, 1)} von{" "}
|
||||
{de(einstellungen.tankvolumen)} l
|
||||
{/* Reihenfolge deckungsgleich mit vHome() im Panel (".tankzeile", eigens
|
||||
als myAudi-App-Konvention kommentiert, die einzige Kachel im ganzen
|
||||
Panel mit der Beschriftung UNTEN statt oben): Zahl und Prozent in
|
||||
einer Zeile (unten ausgerichtet), dann der Balken, erst dann das
|
||||
Label "Reichweite". 60px-Hauptzahl (nicht 40 - das ist die Größe
|
||||
des kleineren "Nächster Service"-Werts), reine Prozentzahl ohne
|
||||
Zusatztext. Die frühere Liter-Schätzzeile ("rund X von Y l") wurde
|
||||
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">
|
||||
<div className="dm-tankzeile">
|
||||
<Fig value={deOderStrich(fahrzeug.reichweite)} unit="km" size={60} />
|
||||
<span className="dm-tankzeile__pct">
|
||||
{fahrzeug.tankBekannt ? `${de(fahrzeug.tankPct)} %` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar percent={Math.max(0, Math.min(100, fahrzeug.tankPct))} />
|
||||
<span className="ads-eyebrow" style={{ marginTop: "var(--sp-3)" }}>
|
||||
Reichweite
|
||||
</span>
|
||||
</Tile>
|
||||
|
||||
{/* Kilometerstand und Service standen bis 2026-08-17 als zwei gleich
|
||||
@@ -142,49 +160,52 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
|
||||
Knopf zur Service-Seite, die Kilometerstand-Zeile bleibt reine
|
||||
Anzeige. Deckungsgleich mit vHome() im Panel. */}
|
||||
<Tile>
|
||||
<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>
|
||||
{service ? (
|
||||
<>
|
||||
{(() => {
|
||||
const Symbol = SERVICE_SYMBOL[service.art]
|
||||
const fig = (
|
||||
<Fig
|
||||
value={service.restKm !== null ? de(service.restKm) : datum(service.datum)}
|
||||
{...(service.restKm !== null ? { unit: "km" } : {})}
|
||||
size={40}
|
||||
/>
|
||||
)
|
||||
return Symbol ? (
|
||||
<div className="dm-servicefig">
|
||||
<Symbol groesse={31} />
|
||||
{fig}
|
||||
</div>
|
||||
) : (
|
||||
fig
|
||||
)
|
||||
})()}
|
||||
<span className="dm-fussnote">
|
||||
{service.restKm !== null ? `voraussichtlich am ${datum(service.datum)}` : service.art}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
{/* 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
|
||||
Abwesenheit von Inhalt. */}
|
||||
{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>
|
||||
{(() => {
|
||||
const Symbol = SERVICE_SYMBOL[service.art]
|
||||
const fig = (
|
||||
<Fig
|
||||
value={service.restKm !== null ? de(service.restKm) : datum(service.datum)}
|
||||
{...(service.restKm !== null ? { unit: "km" } : {})}
|
||||
size={40}
|
||||
/>
|
||||
)
|
||||
return Symbol ? (
|
||||
<div className="dm-servicefig">
|
||||
<Symbol groesse={31} />
|
||||
{fig}
|
||||
</div>
|
||||
) : (
|
||||
fig
|
||||
)
|
||||
})()}
|
||||
<span className="dm-fussnote">
|
||||
{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>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="dm-servicezeile">
|
||||
<span className="dm-servicezeile__k">Kilometerstand</span>
|
||||
<Fig value={fahrzeug.odoBekannt ? de(fahrzeug.odo) : "—"} unit="km" size={20} />
|
||||
</div>
|
||||
</Tile>
|
||||
|
||||
<StandortVorschau geheZu={() => geheZu("standort")} />
|
||||
|
||||
<div className="dm-raster">
|
||||
{letzteFahrt ? (
|
||||
<NaviKachel
|
||||
|
||||
@@ -294,19 +294,35 @@ export function Vertragsdetails({ geheZu }: { geheZu: (name: SeitenName, id?: st
|
||||
)
|
||||
}
|
||||
|
||||
interface SchutzbriefEntweder {
|
||||
bez?: string
|
||||
grenze?: string
|
||||
zusatz?: string
|
||||
}
|
||||
interface SchutzbriefGruppe {
|
||||
titel?: string
|
||||
posten?: [string, string, string?][]
|
||||
}
|
||||
|
||||
export function Schutzbrief() {
|
||||
const { fahrzeug } = useDaten()
|
||||
if (!fahrzeug) return null
|
||||
|
||||
const s = (fahrzeug.versicherung["schutzbrief"] ?? {}) as Record<string, unknown>
|
||||
const listen: [string, string[]][] = [
|
||||
["Auslöser", (s["ausloeser"] ?? []) as string[]],
|
||||
["Entweder", (s["entweder"] ?? []) as string[]],
|
||||
["Leistungsgruppen", (s["gruppen"] ?? []) as string[]],
|
||||
["Nicht versichert", (s["nicht"] ?? []) as string[]],
|
||||
["Pflichten", (s["pflichten"] ?? []) as string[]],
|
||||
]
|
||||
const hatInhalt = listen.some(([, eintraege]) => eintraege.length > 0)
|
||||
const ausloeser = (s["ausloeser"] ?? []) as string[]
|
||||
const entweder = (s["entweder"] ?? []) as SchutzbriefEntweder[]
|
||||
const gruppen = (s["gruppen"] ?? []) as SchutzbriefGruppe[]
|
||||
// [Bezeichnung, Begründung] - dieselbe Zwei-Spalten-Form wie im Panel
|
||||
// (sb.nicht.map(([bez, begr]) => ...)), nicht ein Array aus Strings.
|
||||
const nicht = (s["nicht"] ?? []) as [string, string][]
|
||||
const pflichten = (s["pflichten"] ?? []) as string[]
|
||||
|
||||
const hatInhalt =
|
||||
ausloeser.length > 0 ||
|
||||
entweder.length > 0 ||
|
||||
gruppen.length > 0 ||
|
||||
nicht.length > 0 ||
|
||||
pflichten.length > 0
|
||||
|
||||
if (!hatInhalt) {
|
||||
return (
|
||||
@@ -319,30 +335,71 @@ export function Schutzbrief() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Schutzbrief</span>
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile label="Stand" wert={(s["stand"] as string) || "—"} />
|
||||
<Wertzeile label="Gilt" wert={(s["gilt"] as string) || "—"} />
|
||||
<Wertzeile label="Versichert" wert={(s["versichert"] as string) || "—"} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Tile>
|
||||
{listen
|
||||
.filter(([, eintraege]) => eintraege.length > 0)
|
||||
.map(([titel, eintraege]) => (
|
||||
<Tile key={titel}>
|
||||
<span className="ads-eyebrow">{titel}</span>
|
||||
<ul className="dm-liste">
|
||||
{eintraege.map((eintrag) => (
|
||||
<li key={eintrag}>{eintrag}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Tile>
|
||||
))}
|
||||
{entweder.length > 0 && (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Die Entscheidung am Schadenort</span>
|
||||
<p className="dm-fussnote">
|
||||
Diese drei Leistungen schließen einander aus. Wer eine nimmt, verliert den Anspruch auf
|
||||
die anderen beiden.
|
||||
</p>
|
||||
<Werteliste
|
||||
kinder={entweder.map((x, i) => (
|
||||
<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>
|
||||
</Tile>
|
||||
)}
|
||||
|
||||
{ausloeser.length > 0 && (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Wann der Schutzbrief greift</span>
|
||||
<ul className="dm-liste">
|
||||
{ausloeser.map((a) => (
|
||||
<li key={a}>{a}</li>
|
||||
))}
|
||||
</ul>
|
||||
{Boolean(s["gilt"]) && <p className="dm-fussnote">Gilt in {s["gilt"] as string}</p>}
|
||||
{Boolean(s["versichert"]) && (
|
||||
<p className="dm-fussnote">Versichert: {s["versichert"] as string}</p>
|
||||
)}
|
||||
</Tile>
|
||||
)}
|
||||
|
||||
{gruppen.map((g, gi) => (
|
||||
<Tile key={g.titel ?? gi}>
|
||||
<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} />
|
||||
))}
|
||||
/>
|
||||
</Tile>
|
||||
))}
|
||||
|
||||
{nicht.length > 0 && (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Nicht versichert</span>
|
||||
<Werteliste
|
||||
kinder={nicht.map(([bez, begruendung], i) => (
|
||||
<Wertzeile key={bez ?? i} label={bez} wert="" zusatz={begruendung} />
|
||||
))}
|
||||
/>
|
||||
</Tile>
|
||||
)}
|
||||
|
||||
{pflichten.length > 0 && (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Deine Pflichten</span>
|
||||
<ul className="dm-liste">
|
||||
{pflichten.map((x) => (
|
||||
<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>}
|
||||
</Tile>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,11 @@ import { ActionButton, Tile } from "@audi-dash/ui"
|
||||
|
||||
import type { SicherheitsPunkt } from "../api"
|
||||
|
||||
/** Kraftstoffsorten zur Auswahl - dieselbe feste Liste wie `KRAFTSTOFFSORTEN`
|
||||
im Panel, geteilt zwischen Tanken.tsx (Neuanlegen) und TankDetail.tsx
|
||||
(Bearbeiten). */
|
||||
export const KRAFTSTOFFSORTEN = ["V-Power Racing", "Super Plus", "Super"] as const
|
||||
|
||||
/**
|
||||
* Leerzustand. Aus dem Design-Auftrag: freundlich formuliert, mit einem
|
||||
* Hinweis, was als Nächstes passiert — nie nur „keine Daten".
|
||||
|
||||
@@ -29,6 +29,7 @@ export const WEITERE_BILDER = [
|
||||
"seitenansicht-winter.webp",
|
||||
"rad-sommer.webp",
|
||||
"rad-winter.webp",
|
||||
"draufsicht.webp",
|
||||
] as const
|
||||
|
||||
export const ERLAUBTE_DATEINAMEN: readonly string[] = [
|
||||
@@ -47,3 +48,17 @@ export function bildUrl(dateiname: string | null | undefined): string | undefine
|
||||
if (!basis) return undefined
|
||||
return `${basis}/local/bilder/${dateiname}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Feste Datei des Kraftstoff-Marken-Logos für die SmartDeal-Anzeige -
|
||||
* Bestandteil des Integrations-Bündels
|
||||
* (`custom_components/audi_dashboard/frontend/bilder/shell-logo.svg`), unter
|
||||
* dem eigenen Statik-Pfad der Integration ausgeliefert (`/audi_dashboard_static/`)
|
||||
* - anders als die Fahrzeugfotos NICHT unter `/local/bilder/`. Deckungsgleich
|
||||
* mit `MARKENLOGO_DATEI`/`STATIK` im Panel.
|
||||
*/
|
||||
export function markenlogoUrl(): string | undefined {
|
||||
const basis = zugangLesenSynchron()?.basisUrl
|
||||
if (!basis) return undefined
|
||||
return `${basis}/audi_dashboard_static/bilder/shell-logo.svg`
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { LiveFahrt } from "./LiveFahrt"
|
||||
import { MeinAudi } from "./MeinAudi"
|
||||
import { Reifen } from "./Reifen"
|
||||
import { Service, Servicebuch, Werkstatt } from "./Service"
|
||||
import { Standort } from "./Standort"
|
||||
import { Statistik } from "./Statistik"
|
||||
import { TankDetail } from "./TankDetail"
|
||||
import { Tanken } from "./Tanken"
|
||||
@@ -65,6 +66,8 @@ export function SeiteFuer(props: SeitenProps) {
|
||||
return <Fahrzeugstatus geheZu={geheZu} />
|
||||
case "tuerenklappen":
|
||||
return <TuerenKlappen />
|
||||
case "standort":
|
||||
return <Standort />
|
||||
case "audi":
|
||||
return <MeinAudi geheZu={geheZu} />
|
||||
case "ident":
|
||||
|
||||
@@ -118,7 +118,7 @@ describe("Inhalte kommen wirklich aus den Daten", () => {
|
||||
|
||||
it("Türen und Klappen: zeigt nur die Punkte dieser Gruppe", async () => {
|
||||
const { container } = zeige("tuerenklappen")
|
||||
await waitFor(() => expect(container.querySelector(".dm-pruefliste")).not.toBeNull())
|
||||
await waitFor(() => expect(container.querySelector(".dm-wertzeile")).not.toBeNull())
|
||||
expect(screen.getByText("Motorhaube")).toBeTruthy()
|
||||
expect(screen.queryByText("Standlicht")).toBeNull()
|
||||
})
|
||||
|
||||
@@ -61,3 +61,31 @@
|
||||
font-family: "Audi Type Extended", var(--font-stack);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* iOS-Auflage vom 2026-08-13 ("iOS-Look wurde offiziell", AGENTS.md Abschnitt
|
||||
C): das Panel ersetzte die alte, großgeschriebene Audi-CI-Eyebrow projektweit
|
||||
durch einen normalen, kleinen grauen Kurztext (".label" in
|
||||
audi-dashboard-ios.css, live gemessen: 13px, text-transform:none,
|
||||
letter-spacing:.01em - keine Großschreibung, kaum Laufweite). design-system
|
||||
selbst bleibt bei der ursprünglichen, markenneutralen Eyebrow-Optik (dient
|
||||
auch Claude Design als eigenständiger Kit) - companion-app überschreibt hier
|
||||
lokal, damit sie exakt wie das Panel aussieht (Owner-Entscheidung
|
||||
2026-08-29: "die iOS App soll optisch möglichst identisch zur vorhandenen
|
||||
HA App sein"). */
|
||||
.ads-eyebrow {
|
||||
font-size: 13px;
|
||||
letter-spacing: .01em;
|
||||
text-transform: none;
|
||||
font-weight: 400;
|
||||
color: var(--fg3);
|
||||
}
|
||||
|
||||
/* .quad .l im Panel (Statistik-Kacheln): 11px, keine Großschreibung, normale
|
||||
Laufweite - @audi-dash/ui's neutrales .ads-quad__l (9px, uppercase,
|
||||
letter-spacing .12em) ist derselbe Vor-iOS-Eyebrow-Stil wie .ads-eyebrow
|
||||
oben, hier lokal auf den live gemessenen Panel-Wert korrigiert. */
|
||||
.ads-quad__l {
|
||||
font-size: 11px;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
@@ -47,9 +47,10 @@
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.dm-kopf__aktion[aria-current="page"] {
|
||||
color: var(--red);
|
||||
}
|
||||
/* Kein rotes Aktiv-Signal hier: Rot bleibt Akzent/destruktiv, keine
|
||||
Navigationsfarbe (dieselbe Regel wie beim Panel-Zurückpfeil, den primären
|
||||
Aktionsknöpfen und der Arbeitsweg-Pille - siehe AGENTS.md 2026-08-13/17).
|
||||
Der Ringe-Knopf selbst hat im Panel auch kein eigenes Aktiv-Aussehen. */
|
||||
|
||||
/* ---------------------------------------------------------- Inhaltsfläche */
|
||||
|
||||
|
||||
@@ -234,11 +234,23 @@ input[type="date"].dm-eingabe {
|
||||
|
||||
/* ------------------------------------------------------------ Übersicht */
|
||||
|
||||
/* Deckungsgleich mit .szene.bleed im Panel: randlos, keine Kartenfläche -
|
||||
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). */
|
||||
.dm-szene {
|
||||
position: relative;
|
||||
border-radius: var(--r-tile);
|
||||
overflow: hidden;
|
||||
background: var(--tile);
|
||||
}
|
||||
|
||||
/* Panel: .carbox ~120px, .szene ~130px hoch - ein schmales, breites Band,
|
||||
nicht das 4:3-Seitenverhältnis, das ImagePlaceholder sonst für Galerien
|
||||
verwendet (dort korrekt, hier war es das nie - "carfix klein" im Panel ist
|
||||
16:8). Scoped auf die Übersicht-Szene, damit andere ImagePlaceholder-
|
||||
Stellen (Mein Audi, Reifen, ...) ihre eigene, richtige Größe behalten. */
|
||||
.dm-szene .ads-bildbox {
|
||||
aspect-ratio: 16 / 8;
|
||||
}
|
||||
|
||||
.dm-szene .ads-bild,
|
||||
@@ -247,8 +259,14 @@ input[type="date"].dm-eingabe {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Kein linkes/rechtes Innenpolster: das Panel stellt Kennzeichen/Marke in
|
||||
eine randlose "tile flat" (padding:0) - links/rechts eingerückt zu haben
|
||||
ließ diesen Text nicht mit "Reichweite" & Co. auf einer Kante stehen
|
||||
(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) var(--sp-4) var(--sp-5);
|
||||
padding: var(--sp-4) 0 var(--sp-5);
|
||||
}
|
||||
|
||||
/* Übersicht: "Nächster Service" als tippbare Kennzahl innerhalb der Kachel,
|
||||
@@ -267,9 +285,15 @@ input[type="date"].dm-eingabe {
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
}
|
||||
/* Nur wenn es wirklich ein Knopf ist (ein Termin vorhanden, siehe
|
||||
Uebersicht.tsx) - der "kein Eintrag"-Zustand ist ein reines <div>, ohne
|
||||
Zeigehand/Press-Feedback, deckungsgleich mit der nicht-klickbaren
|
||||
.tile-Variante im Panel. */
|
||||
button.dm-serviceblock {
|
||||
cursor: pointer;
|
||||
}
|
||||
.dm-serviceblock:active {
|
||||
button.dm-serviceblock:active {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.dm-serviceblock__chevron {
|
||||
@@ -282,10 +306,19 @@ input[type="date"].dm-eingabe {
|
||||
}
|
||||
/* Serviceart-Icon links der Kennzahl statt des Wortes "bis zum/zur ..." -
|
||||
deckungsgleich mit .fig-zeile im Panel (audi-dashboard.css). */
|
||||
/* margin-top deckungsgleich mit .fig-zeile im Panel - fehlte hier, "Nächster
|
||||
Service:" und die Kennzahl standen ohne Abstand aufeinander (Parität-
|
||||
Audit 2026-08-30). Der zweite Selektor deckt den Fall ohne Serviceart-
|
||||
Icon ab (Hauptuntersuchung), wo das Panel dasselbe margin-top direkt auf
|
||||
.fig setzt statt auf eine Wrapper-Zeile. */
|
||||
.dm-servicefig {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.dm-serviceblock > .ads-fig {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.dm-servicefig svg {
|
||||
color: var(--fg2);
|
||||
@@ -339,6 +372,9 @@ input[type="date"].dm-eingabe {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.dm-tgwert {
|
||||
/* Fehlte hier ganz - fiel auf die schmale Grundschrift zurück statt auf
|
||||
Audi Type Wide wie .tg-wert im Panel (Parität-Audit 2026-08-29). */
|
||||
font-family: var(--font-wide);
|
||||
font-weight: 300;
|
||||
font-size: 22px;
|
||||
color: var(--fg);
|
||||
@@ -357,14 +393,19 @@ input[type="date"].dm-eingabe {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.dm-tanktext {
|
||||
/* Reichweiten-Kachel auf der Übersicht - deckungsgleich mit .tankzeile/.pct
|
||||
im Panel (audi-dashboard.css). */
|
||||
.dm-tankzeile {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
margin-top: var(--sp-2);
|
||||
font-size: 12.5px;
|
||||
font-weight: 300;
|
||||
gap: 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.dm-tankzeile__pct {
|
||||
font-size: 15px;
|
||||
color: var(--fg2);
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.dm-navikachel__wert {
|
||||
@@ -374,16 +415,27 @@ input[type="date"].dm-eingabe {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
/* "Letzte Fahrt"/"Letzte Tankung" auf der Übersicht - deckungsgleich mit
|
||||
.leaf .v/.leaf .v small im Panel (15px/400/var(--fg2), Zusatzzeile
|
||||
12.5px/var(--fg3), KEINE Wide-Schrift/Hero-Zahl). War hier 20px/300 ohne
|
||||
Farbangabe - eine erfundene "große Zahl"-Behandlung, die dieser Kachel im
|
||||
Panel nie zustand (Parität-Audit 2026-08-30). Nur hier verwendet (die
|
||||
anderen NaviKachel-Aufrufe in MeinAudi.tsx/Service.tsx/Versicherung.tsx
|
||||
nutzen .dm-navikachel__wert direkt, unangetastet). */
|
||||
.dm-navikachel__zahl {
|
||||
font-size: 20px;
|
||||
font-weight: 300;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
color: var(--fg2);
|
||||
}
|
||||
.dm-navikachel__wert .dm-navikachel__zahl + small {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.dm-navikachel__wert small,
|
||||
.dm-wertzeile small {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 11.5px;
|
||||
font-size: 12.5px;
|
||||
color: var(--fg3);
|
||||
}
|
||||
|
||||
@@ -420,12 +472,17 @@ input[type="date"].dm-eingabe {
|
||||
margin: var(--sp-3) 0 0;
|
||||
}
|
||||
|
||||
/* Live am Panel gemessen (.row/dt/dd, audi-dashboard.css): beide Spalten
|
||||
16px/400, dt (Feldname) in var(--fg) - NICHT grau, das ist der Wert (dd,
|
||||
var(--fg2)) - genau umgekehrt zu dem, was hier vorher stand (13px/grau
|
||||
Label, 14px/hell Wert). Betrifft jede Wertzeile app-weit (Parität-Audit
|
||||
2026-08-30). */
|
||||
.dm-wertzeile {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-4);
|
||||
padding: 10px 0;
|
||||
padding: 11px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
@@ -434,15 +491,16 @@ input[type="date"].dm-eingabe {
|
||||
}
|
||||
|
||||
.dm-wertzeile dt {
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
color: var(--fg2);
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.dm-wertzeile dd {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: var(--fg2);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@@ -454,6 +512,60 @@ input[type="date"].dm-eingabe {
|
||||
padding: var(--sp-3) 2px 0;
|
||||
}
|
||||
|
||||
/* Wechselbild "Mein Audi" - deckungsgleich mit carImg("galerie")/.dots im
|
||||
Panel: ein antippbares Bild, das zum nächsten Galerieplatz weiterschaltet,
|
||||
plus Punktanzeige darunter. */
|
||||
.dm-galerie-wechsel {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* 16:9, wie carImg("galerie") im Panel ("carfix", nicht "carfix klein" -
|
||||
das 16:8-Maß gilt nur für die Übersicht-Szene). */
|
||||
.dm-galerie-wechsel .ads-bildbox {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
.dm-galerie-punkte {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.dm-galerie-punkte span {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--line-strong);
|
||||
}
|
||||
.dm-galerie-punkte span.an {
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
/* Klickbare Wertzeile (Batteriespannung → Verlauf) - deckungsgleich mit
|
||||
`.row[data-go]` im Panel: derselbe Zeilenaufbau wie .dm-wertzeile, nur als
|
||||
<button> statt <div>, mit Chevron in der Wertespalte. */
|
||||
.dm-wertzeile--knopf {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
text-align: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dm-wertzeile--knopf dd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.dm-wertzeile__chevron {
|
||||
color: var(--fg3);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dm-fussnote {
|
||||
margin: var(--sp-3) 0 0;
|
||||
font-size: 11.5px;
|
||||
@@ -465,60 +577,12 @@ input[type="date"].dm-eingabe {
|
||||
|
||||
/* ------------------------------------------------------ Fahrzeugstatus */
|
||||
|
||||
.dm-pruefliste {
|
||||
list-style: none;
|
||||
margin: var(--sp-3) 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dm-pruefliste__zeile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.dm-pruefliste__zeile:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.dm-pruefliste__label {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.dm-pruefliste__wert {
|
||||
font-size: 12.5px;
|
||||
font-weight: 300;
|
||||
color: var(--fg2);
|
||||
}
|
||||
|
||||
.dm-punkt {
|
||||
flex: none;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.dm-punkt--ok {
|
||||
background: var(--ok);
|
||||
}
|
||||
|
||||
.dm-punkt--offen {
|
||||
background: var(--bad);
|
||||
}
|
||||
|
||||
/* Unbekannt bewusst als hohler Ring: sichtbar anders als grün und als rot,
|
||||
damit "keine Meldung" nie wie eine Aussage aussieht. */
|
||||
.dm-punkt--unbekannt {
|
||||
border: 1.5px solid var(--fg3);
|
||||
}
|
||||
|
||||
/* Kreis-Häkchen für die vier Sammelzeilen der Sicherheit-Seite - Zustand ist
|
||||
so schon am Symbol erkennbar, nicht nur an der Farbe (siehe .dm-punkt
|
||||
oben, das bleibt für die Detailliste unverändert). */
|
||||
so schon am Symbol erkennbar, nicht nur an der Farbe. Die Detailliste
|
||||
"Türen und Klappen" (TuerenKlappen.tsx) verwendet stattdessen die
|
||||
gewöhnliche .dm-wertzeile-Zeile mit dem einfachen @audi-dash/ui .ads-dot -
|
||||
deckungsgleich mit dem Panel, dessen vTuerenKlappen() ebenfalls den
|
||||
schlichten Punkt statt des Kreis-Häkchens zeigt. */
|
||||
.dm-statuskreis {
|
||||
flex: none;
|
||||
width: 22px;
|
||||
@@ -558,6 +622,21 @@ button.dm-sicherheitszeile:active { background: var(--tile-2); }
|
||||
.dm-sicherheitszeile__text { flex: 1; font-size: 14.5px; }
|
||||
.dm-sicherheitszeile__chev { margin-left: auto; flex: none; color: var(--fg3); }
|
||||
|
||||
/* Sammelzeile über den vier Sicherheitszeilen - deckungsgleich mit dem
|
||||
obersten Kasten in vSicherheit() (style="display:flex;align-items:center;
|
||||
gap:12px;padding:16px 20px"), dasselbe Kreis-Häkchen-Symbol wie die Zeilen
|
||||
darunter, keine Zeitangabe. */
|
||||
.ads-tile.dm-sicherheitssammel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
.dm-sicherheitssammel span {
|
||||
font-size: 15px;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Galerie */
|
||||
|
||||
.dm-galerie {
|
||||
@@ -666,6 +745,14 @@ button.dm-listenzeile {
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* Einzelfahrt/Einzelbeleg: das Panel zeichnet die Distanz- bzw. Liter-Zahl in
|
||||
vTrip()/dem Tankvorgang-Detail eigens bei 52px (style="font-size:52px"),
|
||||
größer als die übrigen Detailseiten mit ihrer gemeinsamen 40px-.fig - kein
|
||||
Versehen, sondern die Hauptzahl der jeweiligen Seite. */
|
||||
.dm-detail__zahl--gross {
|
||||
font-size: 52px;
|
||||
}
|
||||
|
||||
.dm-detail__einheit {
|
||||
font-size: 15px;
|
||||
color: var(--fg2);
|
||||
@@ -795,6 +882,103 @@ button.dm-listenzeile {
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
|
||||
/* Ausklappbares Korrekturfeld (Anzugsmoment/Reifenkilometer) - deckungsgleich
|
||||
mit dem entsprechenden Block im Panel (margin-top:14-18px, Trennlinie
|
||||
darüber). */
|
||||
.dm-bearbeiten-block {
|
||||
margin-top: var(--sp-4);
|
||||
padding-top: var(--sp-4);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* Reifen-Kachel: Kopfzeile aus Überschrift+km links, Radfoto rechts, beide
|
||||
oben bündig - deckungsgleich mit .radkopf-row/.radkopf/.tile-headline im
|
||||
Panel. */
|
||||
.dm-radkopf-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
.dm-radkopf {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.dm-tile-headline {
|
||||
font-size: 20px;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.2;
|
||||
color: var(--fg);
|
||||
display: block;
|
||||
}
|
||||
.dm-reifenzeile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.dm-reifenzeile .ads-icon-button {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--fg3);
|
||||
}
|
||||
.dm-reifenzeile .ads-icon-button svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.dm-radfoto .ads-bildbox {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
/* Kompakte Pille (Montiert) statt voller Breite - deckungsgleich mit
|
||||
.montiert/.montiert.on im Panel. */
|
||||
.dm-montiert-reihe {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
.dm-montiert {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
letter-spacing: normal;
|
||||
color: var(--fg2);
|
||||
background: none;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-pill);
|
||||
padding: 7px 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dm-montiert::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
bottom: -8px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
.dm-montiert:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
.dm-montiert--an {
|
||||
background: var(--fg);
|
||||
border-color: var(--fg);
|
||||
color: var(--canvas);
|
||||
}
|
||||
.dm-archivbtn {
|
||||
background: var(--tile-2);
|
||||
color: var(--fg2);
|
||||
}
|
||||
.dm-archivbtn:hover,
|
||||
.dm-archivbtn:active {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.dm-waehlen {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -836,14 +1020,18 @@ button.dm-listenzeile {
|
||||
}
|
||||
|
||||
/* Der Zusatz neben dem Typenschild darf umbrechen — abgeschnittener Text mit
|
||||
Auslassungspunkten sieht nach Fehler aus, ein Umbruch nicht. */
|
||||
Auslassungspunkten sieht nach Fehler aus, ein Umbruch nicht. Größe/Farbe
|
||||
deckungsgleich mit ".badge .zusatz" im Panel (13px, var(--fg2), nicht
|
||||
kursiv) - war hier 19px, geerbt von der (falschen) "ads-title"-Klasse am
|
||||
Umschließer (Parität-Audit 2026-08-30). */
|
||||
.dm-szene__zusatz {
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
font-size: 19px;
|
||||
line-height: 1.2;
|
||||
font-size: 13px;
|
||||
line-height: normal;
|
||||
color: var(--fg2);
|
||||
}
|
||||
|
||||
/* Die Galerie richtet sich nach der festen Kachelbreite der Bibliothek (78px),
|
||||
@@ -854,3 +1042,265 @@ button.dm-listenzeile {
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- Standort
|
||||
Live-GPS-Position des Fahrzeugs — deckungsgleich mit .standort-kachel/
|
||||
.standort-vollbild/.standortmenu im Panel (audi-dashboard.css). War bis
|
||||
2026-08-29 komplett unportiert (Parität-Audit). */
|
||||
.dm-standort-kachelbtn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0 0 var(--sp-3);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
border-radius: var(--r-tile);
|
||||
overflow: hidden;
|
||||
}
|
||||
.dm-standort-vorschau {
|
||||
width: 100%;
|
||||
height: 190px;
|
||||
background: var(--tile);
|
||||
}
|
||||
.dm-standort-leer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
color: var(--fg3);
|
||||
text-align: center;
|
||||
padding: 0 30px;
|
||||
background: var(--tile);
|
||||
}
|
||||
|
||||
.dm-fahrzeug-pin-wrap,
|
||||
.dm-user-pin-wrap {
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
.dm-fahrzeug-pin {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.4));
|
||||
}
|
||||
.dm-user-pin {
|
||||
position: relative;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #0a84ff;
|
||||
border: 3px solid #fff;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.dm-user-pin::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -14px;
|
||||
border-radius: 50%;
|
||||
background: rgba(10, 132, 255, 0.18);
|
||||
animation: dm-user-pulse 2.4s ease-out infinite;
|
||||
}
|
||||
@keyframes dm-user-pulse {
|
||||
0% {
|
||||
transform: scale(0.4);
|
||||
opacity: 0.9;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Füllt die verfügbare Höhe der Seite, statt bei 640px zu kappen und darunter
|
||||
leeren, mit dem geschlossenen Blatt verwechselbaren Seitenhintergrund
|
||||
stehen zu lassen (Nutzerbericht "Standort Slide Up ist leer" - der Inhalt
|
||||
fehlte nicht, er saß nur unterhalb der Kappungsgrenze in leerem Weiß).
|
||||
`.dm-inhalt > *{flex:none}` (rahmen.css) gilt für jede andere Seite zu
|
||||
Recht - hier gezielt aufgehoben, die einzige Seite ohne weiteren Inhalt
|
||||
unter einer einzelnen großen Fläche. */
|
||||
.dm-inhalt > .dm-standort-vollbild {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.dm-standort-vollbild {
|
||||
position: relative;
|
||||
min-height: 400px;
|
||||
border-radius: var(--r-tile);
|
||||
overflow: hidden;
|
||||
margin: 0 calc(-1 * var(--sp-5));
|
||||
}
|
||||
.dm-standort-karte {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dm-kartensteuerung {
|
||||
position: absolute;
|
||||
z-index: 400;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.dm-kartensteuerung button {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(16, 20, 26, 0.84);
|
||||
color: #fff;
|
||||
border: none;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.32);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
backdrop-filter: blur(10px);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
[data-theme="tag"] .dm-kartensteuerung button {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: #101418;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.dm-kartensteuerung button:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
.dm-standortmenu {
|
||||
position: absolute;
|
||||
z-index: 410;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
--dm-standort-peek: 96px;
|
||||
background: var(--canvas);
|
||||
border-radius: var(--r-tile) var(--r-tile) 0 0;
|
||||
box-shadow: 0 -6px 24px rgba(0, 0, 0, 0.35);
|
||||
padding: 0 20px max(18px, env(safe-area-inset-bottom));
|
||||
transform: translateY(calc(100% - var(--dm-standort-peek)));
|
||||
transition: transform 0.28s cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||
}
|
||||
.dm-standortmenu--offen {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.dm-standortmenu__griffzone {
|
||||
padding: 10px 0 14px;
|
||||
position: relative;
|
||||
}
|
||||
.dm-standortmenu__griff {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
border-radius: 2px;
|
||||
background: var(--line-strong);
|
||||
margin: 0 auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dm-standortmenu__schliessen {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--tile);
|
||||
border: none;
|
||||
color: var(--fg2);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.dm-standortmenu__kopf {
|
||||
margin-top: 8px;
|
||||
padding-right: 44px;
|
||||
}
|
||||
.dm-standortmenu__name {
|
||||
font-size: 17px;
|
||||
color: var(--fg);
|
||||
}
|
||||
.dm-standortmenu__distanz {
|
||||
font-size: 12.5px;
|
||||
color: var(--fg3);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.dm-standortmenu__adresse {
|
||||
font-size: 13.5px;
|
||||
color: var(--fg2);
|
||||
margin-top: 20px;
|
||||
}
|
||||
.dm-standortmenu__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin-top: 8px;
|
||||
font-size: 13.5px;
|
||||
color: var(--fg2);
|
||||
}
|
||||
.dm-standortmenu__status svg {
|
||||
color: var(--fg3);
|
||||
flex: none;
|
||||
}
|
||||
.dm-standortmenu__punkt {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--ok);
|
||||
flex: none;
|
||||
}
|
||||
.dm-standortmenu__werte {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
font-size: 13.5px;
|
||||
color: var(--fg2);
|
||||
}
|
||||
.dm-standortmenu__werte svg {
|
||||
color: var(--fg3);
|
||||
flex: none;
|
||||
}
|
||||
.dm-standortmenu__aktionen {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.dm-standort-pille {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
height: 40px;
|
||||
padding: 0 20px;
|
||||
border-radius: var(--r-pill);
|
||||
border: 1px solid var(--line-strong);
|
||||
background: none;
|
||||
color: var(--fg);
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dm-standort-pille--primaer {
|
||||
background: var(--fg);
|
||||
border-color: var(--fg);
|
||||
color: var(--canvas);
|
||||
}
|
||||
.dm-standort-pille:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
.dm-standort-pille:disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -54,11 +54,18 @@ export function SymbolUebersicht({ groesse }: SymbolProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// Wagen von vorn - dasselbe Symbol wie in der myAudi-App (Audi-Symbolsatz),
|
||||
// dasselbe Pfad wie ICONS.audi im Panel. Fläche statt Kontur (voll), wie dort
|
||||
// (siehe ICONS_VOLL). War hier bis 2026-08-29 ein grobes Platzhalter-Viereck -
|
||||
// nie durch das echte Symbol ersetzt worden (gefunden beim Parität-Audit).
|
||||
export function SymbolAudi({ groesse }: SymbolProps) {
|
||||
return (
|
||||
<Rahmen
|
||||
groesse={groesse}
|
||||
kinder={<polygon points="21.6,5 16.5,19 2.4,19 7.5,5" />}
|
||||
voll
|
||||
kinder={
|
||||
<path d="M5.46 4.05a80 80 0 0 1 13.08 0l.24.02.14.2A55 55 0 0 1 21.9 9.2l2.1 2.1v8.2c0 .83-.67 1.5-1.5 1.5h-2a1.5 1.5 0 0 1-1.5-1.5V19l-14-.04v.54c0 .83-.67 1.5-1.5 1.5h-2A1.5 1.5 0 0 1 0 19.5v-8.2l2.1-2.1a55 55 0 0 1 2.98-4.93l.14-.2zM2.7 10 1 11.7v.3h4v1H1v6.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5v-1.55L20 18v1.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5V13h-4v-1h4v-.3L21.3 10zm17.94-1a45 45 0 0 0-2.43-3.98 78 78 0 0 0-12.44 0L4.51 7c-.3.5-.7 1.16-1.16 2.01h17.3ZM7.3 12h9.42l1.36 1.36-.1.3c-.31 1-.6 1.78-.88 2.31-.13.27-.27.5-.41.67-.13.15-.36.36-.68.36H8a.9.9 0 0 1-.68-.36c-.14-.17-.28-.4-.41-.67a17 17 0 0 1-.89-2.32l-.09-.29zm.42 1-.64.64c.28.84.52 1.47.73 1.89a2.4 2.4 0 0 0 .3.47h7.8l.02-.01c.07-.08.16-.23.28-.46.21-.42.45-1.05.73-1.9L16.3 13H7.71Z" />
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -238,6 +245,23 @@ export function SymbolInspektion({ groesse }: SymbolProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// edit-s aus dem Audi-CI-Icon-Satz (vom Nutzer als SVG geliefert, verbatim
|
||||
// übernommen wie oil-change-s/inspection-s oben) - für den km-Korrektur-
|
||||
// Stift bei Anzugsmoment/Reifenkilometern (Vorlage: CI.editS im Panel).
|
||||
export function SymbolEdit({ groesse }: SymbolProps) {
|
||||
return (
|
||||
<RahmenFlaeche
|
||||
groesse={groesse}
|
||||
kinder={
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m14.14 1.06 2.4 1.1.04.02c.23.16.42.39.52.65.1.27.13.6-.06.91l-.91 1.94 2.94 1.38-2.82 5.95-.9-.42 2.38-5.05-2.02-.95L9.5 19.8 5 23.57v-5.88l7.56-16.14.02-.03c.16-.23.39-.42.65-.52a1 1 0 0 1 .9.06Zm1.08 4.2.94-2 .02-.03v-.04a.4.4 0 0 0-.13-.16l-2.4-1.1-.02-.01h-.04a.4.4 0 0 0-.16.13L12.53 4l2.7 1.27Zm-3.12-.37L6.16 17.56l2.7 1.27L14.8 6.16 12.1 4.9ZM6 18.6v2.84l2.18-1.81L6 18.59Z"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SymbolWarnung({ groesse }: SymbolProps) {
|
||||
return (
|
||||
<Rahmen
|
||||
|
||||
@@ -37,7 +37,20 @@ export default defineConfig({
|
||||
base: "./",
|
||||
plugins: [react()],
|
||||
define: { __APP_VERSION__: JSON.stringify(appVersion) },
|
||||
server: { port: 5173 },
|
||||
// Nur fuer `vite dev` (server.* wirkt nie in `vite build`, also risikolos
|
||||
// fuer die echte App). Der Browser darf `/api/...` gegen den eigenen
|
||||
// Vite-Ursprung aufrufen - das ist same-origin, keine Praeflight-Anfrage
|
||||
// noetig - und Vite selbst (ein Node-Prozess, keine Browser-CORS-Regeln)
|
||||
// reicht das serverseitig an die HA-Testinstanz weiter. Sonst blockt HA
|
||||
// die Anfrage direkt (siehe AGENTS.md: "CORS ist ein echter Zwang fuer
|
||||
// diese App" - `cors_allowed_origins` wirkt seit HA 2026.8 nicht). Nur
|
||||
// gegen `audi_ha_test` (:18123) - die echte Instanz wird nie beruehrt.
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": { target: "http://localhost:18123", changeOrigin: true, ws: true },
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
target: "es2022",
|
||||
|
||||
Reference in New Issue
Block a user