Paritaetsrunden 2 und 3, Designpruefung umgesetzt (2026.8.30.8)
Drei zusammenhaengende Runden, alle live bei 375x812 gegen audi_ha_test geprueft und in beiden Codebasen angewandt. Paritaetsrunde 2: der gemeinsame Rahmen war das eigentliche Problem - Seitenrand, Kachelabstaende, Kopfleiste, Tableiste und vier Bausteine des Design-Systems trugen noch den Stand vor der iOS-Entscheidung. Dazu sieben Bildschirme neu aufgebaut. Zwei Panel-Fehler dabei mitbehoben: teaser() zeigte unter "Letzte Fahrt" den aeltesten Datensatz, und "Daten bearbeiten" war ein toter Knopf. Paritaetsrunde 3: das Panel hat nie Audi Type gerendert. @font-face in einem Shadow Root wird ignoriert - Schriftschnitte registriert der Browser pro Dokument, nie pro Shadow Tree. Die drei Schnitte liegen jetzt in audi-dashboard-schriften.css und werden ins Dokument gehaengt. Damit erledigt sich eine ganze Reihe von "die Schrift sieht anders aus"-Eindruecken: die beiden Anwendungen zeigten tatsaechlich verschiedene Schriften. Ausserdem "Daten bearbeiten" im Panel gebaut und portiert, das Zeilenmenue entfernt und die letzten neun Bildschirme angeglichen. Designpruefung: die fuenf Punkte der Reihenfolge. Beim vierten hat das Messen den Befund veraendert - gezaehlt waren die deklarierten Groessen, wirksam war laengst eine saubere Sieben-Schritt-Skala mit 27 Ausreissern; die sind jetzt auf den naechsten Schritt gezogen, keiner verschiebt sich um mehr als 1px. Zuletzt: die Standortvorschau zeichnete die falsche Nadel (das Panel wechselt den Icon-Satz ab 34px, die App nahm immer den grossen), und der Kopfabstand der App ist auf den sicheren Bereich reduziert - die 56px des Panels liegen dort unter der Kopfleiste von Home Assistant, in der nativen Huelle steht darueber nichts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
# AGENTS.md — Project state, review findings, open items, and working rules
|
||||
|
||||
**Last updated: 2026-08-30** (a full 11-chapter panel↔companion-app visual/functional parity audit,
|
||||
**Last updated: 2026-08-30** (design-audit follow-through - the five priority items applied to
|
||||
both codebases, plus the location preview's wrong pin; manifest `2026.8.30.8`, see section AN.
|
||||
Before that: third parity round — the panel had never actually rendered Audi
|
||||
Type (@font-face inside a shadow root is ignored), "Daten bearbeiten" now works in both, and the
|
||||
last nine screens are aligned; manifest `2026.8.30.5`, see section AM. Before that: second parity round — the shared chrome turned out to be the real
|
||||
problem, plus seven screens rebuilt and two panel-side defects fixed; manifest `2026.8.30.3`, see
|
||||
section AL. Before that: a full 11-chapter panel↔companion-app visual/functional parity audit,
|
||||
one continuous session — see "Chapter 1" through "Chapter 11 (Abschluss)" further down for the
|
||||
complete record; companion-app manifest bumped to `2026.8.30.2`, OTA bundle rebuilt and redeployed;
|
||||
2026-08-29: iOS signing prepared end to end — device build verified, signature
|
||||
@@ -5624,7 +5630,194 @@ Rebuilt to match each field's real shape and the panel's own grouping: `entweder
|
||||
|
||||
Verified: `npm run typecheck` clean, `npm test` 147/147. Live-verified through the dev proxy against `audi_ha_test`'s real profile data — confirmed **zero** `[object Object]` anywhere on the rendered page (checked via `document.body.innerText.includes('[object Object]')`, not just eyeballing) and real, correctly formatted insurance terms showing throughout ("Weiterkommen: Bahn 1. Klasse bis 1 200 km…", "Am Schadenort: Pannenhilfe vor Ort bis 100 €…").
|
||||
|
||||
**Not yet done in this chapter** (context budget — flagged, not silently skipped): `vVers()`'s top-level structure (separate Notruf/Jahresbeitrag-with-breakdown-bar/Vertrag tiles with edit gears) vs. companion's single combined summary tile + `NaviKachel` grid was not rebuilt to match 1:1, unlike the `MeinAudi.tsx` rebuild in Chapter 2 — `Beitrag()`/`Vertrag()`/`Notruf()`/`Steuer()` sub-screens were not individually re-verified against their panel counterparts (`vBeitrag()`/`vVertragBearbeiten()`/`vNotrufBearbeiten()`/`vSteuer()`) this round. Worth a dedicated pass later.
|
||||
**Not yet done in this chapter** (context budget — flagged, not silently skipped): `vVers()`'s top-level structure (separate Notruf/Jahresbeitrag-with-breakdown-bar/Vertrag tiles with edit gears) vs. companion's single combined summary tile + `NaviKachel` grid was not rebuilt to match 1:1, unlike the `MeinAudi.tsx` rebuild in Chapter 2 — `Beitrag()`/`Vertrag()`/`Notruf()`/`Steuer()` sub-screens were not individually re-verified against their panel counterparts (`vBeitrag()`/`vVertragBearbeiten()`/`vNotrufBearbeiten()`/`vSteuer()`) this round. Worth a dedicated pass later. **Done 2026-08-30 in section AL below.**
|
||||
|
||||
## AL. Second parity round: the *global* chrome was the real problem, plus seven screens rebuilt (2026.8.30.3)
|
||||
|
||||
Owner, after the first audit shipped: **"Die HA App in der Smartphone Ansicht ist die Vorgabe. Die iOS App soll identisch zu dieser Ansicht sein... Alle Abweichungen der iOS App zur HA App sind in der iOS App anzuwenden. Fehlende Features sind einzubauen. Copy exactly."** Two explicit decisions taken during the round (`AskUserQuestion`): **(1)** where the panel is demonstrably wrong, fix the *panel* rather than copy the defect; **(2)** where companion-app has *more* than the panel, remove it.
|
||||
|
||||
**Method note that changed the findings, worth keeping:** `innerText` on a freshly-rendered panel view is read *too early* — placeholder markup for a missing photo only appears once the `<img>` `onerror` fires. Two screens (Sicherheit, Reifen) looked like they had extra content in companion-app until the panel was re-dumped with a ~900 ms delay, at which point both matched exactly. **Always wait after navigating the panel before comparing text.**
|
||||
|
||||
### The biggest find: the app-wide chrome, not any single screen
|
||||
|
||||
Every screen was wrong at once because the shared frame was. All measured live, panel vs. companion-app, at 375×812:
|
||||
|
||||
| | Panel | companion-app (before) |
|
||||
|---|---|---|
|
||||
| Content side padding | 16px (`main#view{--seitenrand:16px}`) | 22px (`--sp-5`) — every tile 20px narrower |
|
||||
| Tile padding | `16px 18px` | `22px` |
|
||||
| Tile-to-tile gap | 14px | **24px** (flex `gap` *plus* the tile's own margin) |
|
||||
| Scrollbar | hidden | 8px wide, stealing content width on every screen |
|
||||
| `--line` (day mode) | `rgba(60,60,67,.16)` | `.13` — every separator too faint |
|
||||
| Missing-value dash | `–` (en dash, 30×) | `—` (em dash, 56×) |
|
||||
|
||||
Header: the panel's `.eyebrow` is `display:none` on phone width — companion-app printed a fixed **"DataMetric360"** above every single screen title, an element the panel does not have at all. The panel also **hides the rings on sub-pages** (back arrow instead) and renders sub-page titles at **17px centered** (iOS navigation-bar style) against 22px left-aligned here; it has no border under the header; and **nine route titles** were plain drift (`Fahrzeugdaten`→`Identität und Technik`, `Batterie`→`Batteriespannung`, `Versicherung & Steuer`→`Versicherung/Steuer`, `Beitrag`→`Beitrag anpassen`, `Notruf`→`Rufnummern`, `Werkstatt`→`Autohaus`, `Wartungsplan`→`Eintrag`, `Fahrt`→`Einzelfahrt`, `Tankvorgang`→`Einzelbeleg`).
|
||||
|
||||
Tab bar: labels were **9px UPPERCASE with .08em tracking** (the pre-2026-08-13 style again), icons 21px instead of 26px, no 63×39 icon pill, no translucent blur, no 26px home-indicator padding.
|
||||
|
||||
`@audi-dash/ui` carried three more app-wide instances of the same pre-iOS styling, each fixed against live-measured panel values: **`ActionButton`** (11px uppercase outline with a red hover → 16px normal text on a filled 14px-radius surface, plus a new `primary` variant for `.aktion.primaer`), **`Pill`** (10px uppercase outline → 12px normal on `--ios-fill`), and **`Accordion`** (chevron on the *wrong side* — right instead of left —, 6×10 instead of square 8×8 so it read as a tick rather than an arrow, and it turned **red** when expanded, the same "red as a navigation state" violation fixed twice before elsewhere). Also `Fig`'s unit lost its `max(11px, …)` floor, so the 20px odometer figure rendered its "km" at **6.4px**; `ProgressBar` was 4px on `--line` instead of 6px on `--ios-fill`; `StatusRow` had no padding/min-height and a 14px title instead of 16px; `ImagePlaceholder` was a dashed outline with a 10.5px tracked caption instead of the panel's filled `--ios-fill` box with a 13px caption; and `Tile`'s content span lacked `min-width:0`, so the Fahrgestellnummer pushed its row out of the tile.
|
||||
|
||||
### Features that were missing entirely
|
||||
|
||||
- **Pull-to-refresh** (`.ptr` + the gesture) and the **"vor 3 Std." data-age line** in the header — companion-app had neither, and instead a "Jetzt beim Fahrzeug nachfragen" button the panel does not have. Built in `Shell.tsx` (`useZiehenAktualisieren`, `standAlter()` in `format.ts`), the button removed.
|
||||
- **Full-bleed vehicle image** with the fade-out gradient, the **ground shadow** (`strasse`/`winter`) and its **"Boden" switch** (`useBoden` in `theme.ts`), and the **winter side-view swap** (`bildInfo()` in `bilder.ts`). The image labels also differed ("Auto von der Seite" vs. the panel's "Auto Seite").
|
||||
- **`Termin vereinbaren`**, the full **Autohaus** tile (address, phone, e-mail, maps links) and the **Wartungsplan list** with "Eintrag hinzufügen" on the Service screen — companion-app had two `NaviKachel`s instead, and the `sbuch` route was a whole list screen where the panel's is a **single-entry editor**. Route and screen rebuilt accordingly.
|
||||
- The **Statistik tiles are collapsible in the panel** (`statTile()` → `.tile-h`/`.tile-b`) with a right-aligned secondary value in the head; companion-app rendered everything permanently expanded, without the head value and without the `quad()` sub-headings. Rebuilt, and `schnittFahrtVerbrauch()` (the missing "Näherung je Fahrt, je Zeitraum" row) added to `statistik.ts`.
|
||||
- **Batterie**: the panel's chart has grid lines, Y-axis labels, an area fill, a date-range line, and pinch-zoom/pan with a reset button — none of it existed here; and the whole **"Bewertung" tile** (SOC with colour dot and label, SOH trend with its verdict text) was missing, replaced by four plain value rows.
|
||||
- **`Vertragsdetails` rendered twelve em-dashes.** `versicherung.leistungen` is `{gruppe, posten[]}[]`, not `{bez, wert}[]` — the same mis-typed-data-shape defect as the `Schutzbrief()` `[object Object]` bug from chapter 4, one level up. Also on the Versicherung group: the whole `vVers()` structure (Notruf tile with *both* numbers as dial buttons, Jahresbeitrag with its 46px figure, per-part breakdown and segmented bar, Vertrag with Selbstbeteiligung rows and the SF "zuvor" line) was rebuilt; `Beitrag` gained "Gültig ab" and both SF fields; `Vertrag` gained the "Schadenfreiheitsklasse · zuvor" tile; `Notruf` was relabelled Inland/Ausland; `Steuer` got the Zeitraum dropdown, "Nächste Fälligkeit" and the Lastschrift switch.
|
||||
- **`Fahrzeugdaten` hid over 3 000 characters of data sheet inside collapsed accordions** — the panel shows every technical group and every equipment group as its own flat tile. Rebuilt; the head tile also had the wrong name ("Identität" vs "Fahrzeug") and the wrong field set.
|
||||
- **Fahrten/Tanken list rows** used a different anatomy (km/litres first, everything else in one long footer line, plus Arbeitsweg/offen/unvollständig pills the panel has no equivalent for). Rebuilt on a new shared `Blattzeile` (`.leaf` in the panel), the count header and top "eintragen" button removed (the panel puts the count in the year row and the form *below* the list), month labels shortened to the month name, and **`nachJahrUndMonat()` was sorting months and rows descending** where the panel keeps the backend's ascending order — both lists were reversed and opened the wrong month by default.
|
||||
|
||||
### Panel-side changes (both decided with the owner, not taken unilaterally)
|
||||
|
||||
1. **`teaser()` showed the OLDEST trip and fill, not the newest** — `TRIPS[0]`/`FILLS[0]` on backend lists that arrive ascending, under headings that read "Letzte Fahrt"/"Letzter Tankvorgang". Found only because companion-app sorted correctly and the two disagreed. Fixed in the panel (sort by timestamp, take the newest); companion-app already behaved correctly and was left alone.
|
||||
2. **`vIdent()`'s "Daten bearbeiten" button is dead** — no `data-go`, no handler, does nothing when tapped. Deliberately **not** copied into companion-app; reported instead, since a dead control is not a feature worth reproducing.
|
||||
|
||||
### Deliberately not removed, despite decision (2) — needs an owner call
|
||||
|
||||
`Zeilenmenue`'s always-visible "···" button on trip/fill rows has no panel counterpart (the panel is swipe-only), but it exists to close a recorded accessibility finding (`AUDIT_2026-08-10.md` §4.1: swipe-only deletion is unreachable with VoiceOver or switch control). Removing it would re-open that defect, so it stays until the owner decides between "remove for exact parity" and "port it into the panel instead".
|
||||
|
||||
### Still open after this round
|
||||
|
||||
Einstellungen (section names, order and content differ substantially in both directions), MeinAudi, Standort, Schutzbrief, FahrtDetail, TankDetail, Werkstatt, BatterieListe, and the receipt-upload buttons the panel offers inside the *new*-fill form (`Beleg hochladen` / `Ohne Beleg speichern`) — companion-app only offers receipt upload on an existing fill's detail page.
|
||||
|
||||
Verified: `npm run typecheck` clean and `npm test` 147/147 after every step, `npm run build` clean, `design-system` rebuilt after each of its CSS changes, `node --check` on the panel bundle, and **live side-by-side at a real 375×812 viewport for every screen touched** — Übersicht, Statistik, Service, Versicherung and Vertragsdetails now produce **byte-identical `innerText`** to the panel, and Übersicht additionally matches element for element (tile heights 130/89/106/169/190, gaps 0/20/22/22/14). Manifest bumped to `2026.8.30.3`, `npm run ota` rebuilt (`bundle.zip` 253,799 bytes, sha256 `8d22031998d6cd18b4d2b3072f16ea87bd3600a107cc6a3c831ba20d3fd8c7ef`), integration redeployed to `audi_ha_test` (`docker cp …/. `, trailing dot per section AK) and confirmed via a clean single-warning setup log.
|
||||
|
||||
**Dev-proxy note:** `vite.config.ts`'s dev proxy now also forwards `/local` and `/audi_dashboard_static` to `:18123`. Without it the dev server answered image requests itself, so every optical comparison saw placeholders instead of the real photos and the Shell logo. `server.*` still never applies to `vite build`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## AM. Third parity round: the panel had never rendered Audi Type, plus the last nine screens (2026.8.30.5)
|
||||
|
||||
Owner's three instructions after round two: **(1)** accessibility is not needed in either app — remove the "···" row menu; **(2)** "Daten bearbeiten" is required, so make it work *in the panel* first and then port it; **(3)** carry on with the open list.
|
||||
|
||||
### The find of the round: the panel was showing Helvetica, not Audi Type
|
||||
|
||||
Chasing a single wrapped list row ("Fahrt ohne Ortsangabe" broke onto two lines in the app, one in the panel) turned into the largest defect found so far. Same declared CSS on both sides, same 16px/400, and — verified by hash — **byte-identical woff2 files**. Yet the same string measured **173px in the app and 165.4px in the panel**.
|
||||
|
||||
165.4px is exactly Helvetica. Probing inside the panel's shadow root settled it: `"Audi Type"`, `"Times New Roman"`, `serif` and a deliberately invented family name all measured **145.7px** — the panel resolved every one of them to the same fallback, i.e. **"Audi Type" was not available to the panel at all**, and its text had always come out in Helvetica (the second entry of its own font stack). `document.fonts` listed no Audi face either.
|
||||
|
||||
Cause: the panel appends *both* stylesheets to its shadow root, and the @font-face rules sat at the top of `audi-dashboard.css`. **@font-face inside a shadow root is ignored — font faces are registered per document, never per shadow tree.** The mirror image of this project's own Leaflet bug (that stylesheet was in `document.head` and never reached the shadow root); same root cause, opposite direction. Fixed by splitting the three faces into `audi-dashboard-schriften.css` and linking *that one file* into `document.head` (`schriftenLaden()`); it contains nothing but @font-face, so the rest of the Home Assistant interface is untouched. Verified live afterwards: the panel now reports both Audi faces as loaded and measures the reference string at 173px — identical to the app.
|
||||
|
||||
This also retires a long tail of "the fonts look slightly different" impressions: until now the two apps genuinely rendered different typefaces.
|
||||
|
||||
### "Daten bearbeiten" (panel first, then ported)
|
||||
|
||||
The button in `vIdent()` had no `data-go` and no handler — dead. Now implemented in the panel (`vIdentBearbeiten()`, `identBearbeitenOffen`, reset in `go()`/`zurueck()`): every value of the technical data sheet becomes an input, every equipment entry an editable row, both with "×" and "+ Position hinzufügen", saved through `profilSpeichern()` per field — the same idiom as the Selbstbeteiligung editor. Deliberately **not** included: Modell, Kennzeichen, Ausführung, Erstzulassung and FIN, which "Fahrzeug einrichten" in Einstellungen already owns; two editors for one field drift apart. Verified live in the container (add → 8 rows → delete → 7 rows, persisted). Then ported to `Fahrzeugdaten.tsx` with the app's own draft-plus-"Fertig" idiom (≈50 fields would otherwise mean ≈50 whole-profile writes).
|
||||
|
||||
### Row menu removed
|
||||
|
||||
`Zeilenmenue` is now just the swipe row, as in the panel. `SymbolMehr` and the `.dm-zeile*` classes went with it (orphans of this change). `SwipeRow`'s delete button also still carried the pre-iOS styling (11px uppercase, tracked) and lacked the panel's `visibility:hidden`-until-swiped trick — both fixed. Rows now use the full width, exactly like the panel.
|
||||
|
||||
### The remaining nine screens
|
||||
|
||||
- **Einstellungen** rebuilt to `vEinst()`'s sections, order and wording: "Fahrzeug einrichten" is collapsible again and carries the identity fields plus the history import; "Darstellung" gained the missing **"Fahrbahn unter dem Fahrzeug"** switch (the setting existed since round two, the control did not); "Bild der Übersicht" shows the chosen view large with **tap-to-upload/replace/delete** — the app previously could not change photos at all and pointed at Home Assistant, although `bild_hochladen`/`bild_loeschen` have existed all along (new `api.bildHochladen`/`api.bildLoeschen` and a shared `BildMitMenue`). "Tankrabatt"→"Kraftstoff-Rabatt", "Sicherung"→"Backup" with the panel's texts and button labels, "Vergangene Daten" folded into "Fahrzeug einrichten", Fahrzeugprofil moved above Backup. Removed: "Nacht beginnt/endet", the two count rows under "Zugang", and several explanation paragraphs the panel does not have.
|
||||
- **Mein Audi**: the Service tile shows **all three terms** again (the deliberate Chapter-2 simplification is gone), image captions use the panel's labels ("Auto Seite"), and the Batteriespannung row lost its chevron (the panel marks it only by cursor).
|
||||
- **FahrtDetail** and **TankDetail** rebuilt to `vTrip()`/`vFill()`: map first, then the flat 52px figure tile, then one value-row tile in the panel's order with "Werte bearbeiten"/"Beleg ersetzen" inside it and the destructive action last. Both had their own header row, their own ordering and labels, and extra pills the panel does not carry.
|
||||
- **New-fill form** gained the panel's three buttons (**"Beleg hochladen"**, "Speichern", "Ohne Beleg speichern"). A receipt upload *without* `tank_id` only parses and publishes the fields (`belege.py`), which the form now waits for and pre-fills — the app previously could only attach a receipt to an already-existing fill.
|
||||
- **Einzelbeleg map**: the record has no station coordinates and the backend never fills them; the panel geocodes the receipt address through Nominatim with a localStorage cache. Ported as `daten/geokodierung.ts` plus `TankstellenKarte`.
|
||||
- **Schutzbrief**: the per-row note belongs under the *label*, not under the value (`dt` = bez + small zusatz, `dd` = grenze) — it was the other way round; explanation paragraphs aligned.
|
||||
- **Werkstatt** tile renamed to "Angaben zum Autohaus"; **Messwertliste** timestamps use "·" instead of a comma (new `datumUndZeit()`); **Standort** already matched exactly.
|
||||
- New `.dm-erklaerung` class for the panel's 12.5px explanation paragraphs. This finally resolves the `.dm-fussnote` ambiguity flagged twice: rather than change a class used 35 times across 13 files, the panel-shaped role got its own class.
|
||||
|
||||
### Deliberately kept, with reasons
|
||||
|
||||
- **"Zugang" → "Verbindung trennen"** and the **App-Update (OTA)** tile have no panel counterpart *because they cannot*: the panel runs inside Home Assistant and knows neither a server address nor a bundle. Same category as the onboarding screen and `LiveFahrt`.
|
||||
- **`Tankvolumen` and `Zusatz`** are in the data model but editable nowhere in the panel, so their editors were removed here per the owner's rule. They are now only changeable in the profile file — say the word and they get an editor in the panel instead.
|
||||
|
||||
Verified: `npm run typecheck` clean and `npm test` 147/147 after every step, `npm run build` clean, `node --check` on the panel bundle, live comparison at 375×812 for every screen touched. Manifest `2026.8.30.5`, `npm run ota` rebuilt (`bundle.zip` 255,695 bytes, sha256 `43fdc62a88cc212d98a808239c650e1d6a3cb33c243a576ef36014c0b8948943`), integration redeployed to `audi_ha_test`, clean restart with no traceback, and the font fix confirmed live in the panel.
|
||||
|
||||
**Tooling note worth keeping:** do not use `perl -pi -e 's|...|...|'` on these files. A pattern containing an escaped `|` terminated early, the substitution then matched every line, and `MeinAudi.tsx` came back with the replacement prepended to all 250 lines. It was fully reversible (the injection is deterministic), but the safe path is a small Node script that reads the file, checks the anchor exists, replaces once, and preserves CRLF — the source files are CRLF and a naive newline pattern will silently not match.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## AN. Design audit follow-through: the five priority items, and what measuring first changed (2026.8.30.6/.7)
|
||||
|
||||
Working through the priority list of the design audit published at
|
||||
https://claude.ai/code/artifact/cb5bcae6-5ca5-43c1-a575-cdff96eb2a54. Every item was applied to
|
||||
**both** codebases (parity rule) and verified live at 375x812 against `audi_ha_test`.
|
||||
|
||||
**1. Statistik tiles had no expand affordance** - the five collapsible tiles looked like fixed
|
||||
readouts. The rotating chevron the accordion rows already use now sits in the tile head
|
||||
(`.stat-kopfzeile` + `.mark` in the panel, `dm-statkopf__kopfzeile` + `ads-mark` in the app).
|
||||
|
||||
**2. Lists ran newest-last inside a newest-first frame.** Years descended while months and rows
|
||||
ascended, so the newest trip sat at the bottom, off screen. Panel got `neuesteZuerst()` for
|
||||
`vTrips()`/`vFuel()`; the app's `nachJahrUndMonat()` went fully descending. `teaser()` in the panel
|
||||
was picking `TRIPS[0]`/`FILLS[0]` - the *oldest* record - under headings reading "Letzte Fahrt";
|
||||
it now sorts by timestamp.
|
||||
|
||||
**3. `--fg3` on light ground was 2.92:1** against the page and 3.26:1 against a tile - too little
|
||||
for a glance in sunlight. Darkened to `#6B6B70` (4.75:1 / 5.30:1) in `audi-dashboard-ios.css` and
|
||||
`design-system`'s day theme; the night value stays `#8E8E93` (already 5.86:1). Verified live in
|
||||
both: day `#6B6B70`, night unchanged.
|
||||
|
||||
**4. The type scale - and here measuring first changed the fix entirely.** The audit counted
|
||||
*declared* sizes and found fourteen distinct values below 18px, which reads as a badly fragmented
|
||||
scale. Measuring the **effective** `font-size` of every text element across eleven panel views told
|
||||
a different story: a clean seven-step backbone (11 / 12.5 / 13 / 14 / 15 / 16 / 17) already carried
|
||||
**96%** of 621 elements, with 27 stragglers sitting one notch beside a real step. Most of the
|
||||
declared values never reached the screen at all, because the always-loaded iOS overlay overrides
|
||||
them - the same trap recorded twice before in this file.
|
||||
|
||||
So the fix is narrow, not a restructure: snap the stragglers onto the nearest step
|
||||
(11.5 -> 12.5, 12 -> 12.5, 13.5 -> 13, 14.5 -> 14, 16.5 -> 16), applied as one mechanical
|
||||
substitution across panel CSS, panel inline styles, companion-app CSS and `design-system` at once,
|
||||
so the two sides cannot drift apart on it. Nothing moves more than 1px. Also fixed: `<small>` in the
|
||||
accordion head had no rule of its own and inherited the UA's `smaller` (0.833x) - 10.4px, below the
|
||||
11px floor this project took from Apple's guidance; it now has an explicit 11px.
|
||||
|
||||
Deliberately untouched: `font-size: max(11px, .32em)` on the unit beside the large figures. Its 12.8
|
||||
and 14.72px readings are that rule scaling with a 40px and a 46px figure, which is the point of it.
|
||||
|
||||
Re-measured afterwards across the same eleven views: **11 / 12.5 / 13 / 14 / 15 / 16 / 17**, nothing
|
||||
else, plus the two intentional relative values. Fourteen values down to seven.
|
||||
|
||||
**5. The Ubersicht carried its two heaviest tiles in front of its lightest.** Without a stored photo
|
||||
the placeholder still occupied 120px directly above the range - the most important number on the
|
||||
page - and "Zuletzt", two short and frequently read lines, sat below a 190px map and therefore below
|
||||
the fold on a phone. Placeholder narrowed to 72px (kept, not removed: it is the only sign that this
|
||||
image slot exists), and "Zuletzt" moved in front of the location card in both codebases.
|
||||
|
||||
**Found while verifying, fixed too:** the app drew the Ubersicht service chevron as the text
|
||||
character "›" at 15px, where the panel draws `.go`, a 6x10 SVG polyline at stroke-width 1.4 -
|
||||
visibly a different shape and weight from every other chevron in the app. Now the same SVG.
|
||||
|
||||
**Also fixed, from the owner's report "ubersicht/karte: hier stimmt ios noch nicht zum panel":** the
|
||||
location preview drew the wrong pin. `fahrzeugMarkerSVG()` in the panel switches icon sets by size -
|
||||
at or above 34px the 48-grid drawing, below it the 24-grid one, which is drawn for small sizes. The
|
||||
app always used the 48-grid one, so the 32px preview showed a drawing meant to be seen at 38px+. The
|
||||
app now mirrors the same switch, and the preview's map background is pinned to Leaflet's own #ddd,
|
||||
which is what the panel actually shows (its own `var(--tile)` declaration loses to
|
||||
`.leaflet-container`). Verified byte-identical afterwards: same marker markup (1087 characters, same
|
||||
hash), same `translate3d(172px, 95px, 0)`, same drop-shadow, same colour.
|
||||
|
||||
Verified: `node --check` on the panel bundle, `design-system` rebuilt, companion-app `tsc --noEmit`
|
||||
clean and 147/147 green after every step, `npm run build` clean. Live at 375x812 the Ubersicht of
|
||||
both now yields the same text and the same size distribution (11x1, 12.5x3, 12.8x1, 13x8, 15x3,
|
||||
16x3) and the same tile order. Manifest `2026.8.30.6`, then `2026.8.30.7` once the chevron fix
|
||||
changed the app after the first bundle - two different bundles must never share one version number.
|
||||
`npm run ota` rebuilt (`bundle.zip` 256.124 bytes, sha256
|
||||
`556b8e7b43277366921e927ce56f8f748e0b7e5efaf021040fcfd99f0027e8c2`), redeployed to `audi_ha_test`,
|
||||
clean restart.
|
||||
|
||||
**Header padding: the one place where copying the panel's number was wrong (2026.8.30.8).** The
|
||||
owner asked why the iOS app shows a large gap above the headline. Measured: 105px, made of a 49px
|
||||
"Diese App ist älter als der Server" band - the message itself, not a layout fault - and 56px of
|
||||
header padding. That 56px is a faithful copy of the panel (`.topbar{padding:56px 20px 4px 16px}` in
|
||||
the iOS overlay, confirmed live). It is also the one case found so far where copying the panel
|
||||
exactly produces a *different* result: in the panel those 56px sit below Home Assistant's own header
|
||||
bar, while the native shell has nothing above them but the status bar. Same number, different
|
||||
picture. On the owner's instruction the added padding is gone; `.dm-kopf`/`.dm-kopf--unterseite` now
|
||||
carry `env(safe-area-inset-top, 0px)` alone, so the status bar stays clear on the device while the
|
||||
headline starts at the top everywhere without an inset. Deliberate, disclosed divergence from the
|
||||
panel - do not "fix" it back by matching the number again.
|
||||
|
||||
**Committed and pushed** at the end of this session, after rebasing onto `25c0d07` (an `.ipa` refresh
|
||||
pushed from elsewhere, no overlap).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -80,6 +80,9 @@ function AngemeldeteApp({ beiAbmeldung }: { beiAbmeldung: () => void }) {
|
||||
route={route}
|
||||
geheZu={geheZu}
|
||||
tabBeschriftung={tabBeschriftung}
|
||||
statusStand={daten.statusStand}
|
||||
// Ziehen zum Aktualisieren gibt es im Panel nur auf der Übersicht.
|
||||
beiAktualisieren={route.name === "home" ? daten.jetztAktualisieren : undefined}
|
||||
hinweis={
|
||||
<Hinweisleiste
|
||||
verbindung={daten.verbindung}
|
||||
|
||||
+129
-17
@@ -7,15 +7,18 @@
|
||||
* liegt bei 1000px.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import type { PointerEvent as ReactPointerEvent, ReactNode, RefObject } from "react"
|
||||
|
||||
import { TabBar } from "@audi-dash/ui"
|
||||
|
||||
import { standAlter } from "./format"
|
||||
import { useBreiterBildschirm } from "./hooks/useBreiterBildschirm"
|
||||
import { VierRinge } from "./screens/Typenschild"
|
||||
import type { Hauptbereich, Route, SeitenName } from "./navigation"
|
||||
import { TITEL, ZURUECK, bereichVon, istHauptbereich } from "./navigation"
|
||||
import {
|
||||
SymbolAktualisieren,
|
||||
SymbolAudi,
|
||||
SymbolFahrten,
|
||||
SymbolStatistik,
|
||||
@@ -31,10 +34,70 @@ export interface ShellProps {
|
||||
tabBeschriftung: boolean
|
||||
/** Wird über der Kopfzeile eingeblendet (Offline-Hinweis, Warteschlange). */
|
||||
hinweis?: ReactNode | undefined
|
||||
/** Zeitpunkt des letzten Datenstands — steht als „vor 3 Std." unter dem Titel. */
|
||||
statusStand?: string | null | undefined
|
||||
/** Neuabfrage beim Fahrzeug, ausgelöst durch Ziehen zum Aktualisieren. */
|
||||
beiAktualisieren?: (() => Promise<void>) | undefined
|
||||
/** Heißt `children`, weil JSX-Verschachtelung genau diesen Namen erwartet. */
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/** Ziehen zum Aktualisieren, 1:1 nach der ptr-Logik des Panels. */
|
||||
const PTR_SCHWELLE = 56
|
||||
|
||||
function useZiehenAktualisieren(
|
||||
inhalt: RefObject<HTMLElement | null>,
|
||||
beiAktualisieren: (() => Promise<void>) | undefined,
|
||||
) {
|
||||
const [zug, setzeZug] = useState(0)
|
||||
const [laedt, setzeLaedt] = useState(false)
|
||||
const start = useRef<number | null>(null)
|
||||
|
||||
if (!beiAktualisieren) {
|
||||
return { zug: 0, laedt: false, handler: {} as Record<string, never> }
|
||||
}
|
||||
|
||||
const beenden = () => {
|
||||
if (start.current === null) return
|
||||
start.current = null
|
||||
if (zug >= PTR_SCHWELLE) {
|
||||
setzeLaedt(true)
|
||||
void beiAktualisieren().finally(() => {
|
||||
setzeLaedt(false)
|
||||
setzeZug(0)
|
||||
})
|
||||
} else {
|
||||
setzeZug(0)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
zug,
|
||||
laedt,
|
||||
handler: {
|
||||
onPointerDown: (e: ReactPointerEvent) => {
|
||||
if (!e.isPrimary || laedt) return
|
||||
if ((inhalt.current?.scrollTop ?? 0) > 0) return
|
||||
start.current = e.clientY
|
||||
setzeZug(0)
|
||||
},
|
||||
onPointerMove: (e: ReactPointerEvent) => {
|
||||
if (start.current === null || !e.isPrimary) return
|
||||
const dy = e.clientY - start.current
|
||||
if (dy > 0 && (inhalt.current?.scrollTop ?? 0) <= 0) {
|
||||
// Widerstand wie bei iOS: halbe Strecke, gedeckelt bei 90px.
|
||||
setzeZug(Math.min(90, dy * 0.5))
|
||||
} else {
|
||||
start.current = null
|
||||
setzeZug(0)
|
||||
}
|
||||
},
|
||||
onPointerUp: beenden,
|
||||
onPointerCancel: beenden,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const BEREICHE: { key: Hauptbereich; label: string; icon: ReactNode }[] = [
|
||||
{ key: "home", label: "Übersicht", icon: <SymbolUebersicht /> },
|
||||
{ key: "audi", label: "Mein Audi", icon: <SymbolAudi /> },
|
||||
@@ -43,13 +106,33 @@ const BEREICHE: { key: Hauptbereich; label: string; icon: ReactNode }[] = [
|
||||
{ key: "fuel", label: "Tanken", icon: <SymbolTanken /> },
|
||||
]
|
||||
|
||||
export function Shell({ route, geheZu, tabBeschriftung, hinweis, children }: ShellProps) {
|
||||
export function Shell({
|
||||
route,
|
||||
geheZu,
|
||||
tabBeschriftung,
|
||||
hinweis,
|
||||
statusStand,
|
||||
beiAktualisieren,
|
||||
children,
|
||||
}: ShellProps) {
|
||||
const breit = useBreiterBildschirm()
|
||||
const aktiverBereich = bereichVon(route.name)
|
||||
const zurueckZiel = ZURUECK[route.name]
|
||||
|
||||
const unterseite = !istHauptbereich(route.name)
|
||||
const inhaltRef = useRef<HTMLElement | null>(null)
|
||||
const ptr = useZiehenAktualisieren(inhaltRef, breit ? undefined : beiAktualisieren)
|
||||
|
||||
// Der Text tickt mit, wie standAlterTicken() im Panel (Sekundentakt).
|
||||
const [, neuZeichnen] = useState(0)
|
||||
useEffect(() => {
|
||||
if (route.name !== "home") return undefined
|
||||
const uhr = window.setInterval(() => neuZeichnen((n) => n + 1), 1000)
|
||||
return () => window.clearInterval(uhr)
|
||||
}, [route.name])
|
||||
|
||||
const kopf = (
|
||||
<header className="dm-kopf">
|
||||
<header className={unterseite ? "dm-kopf dm-kopf--unterseite" : "dm-kopf"}>
|
||||
{zurueckZiel && !breit ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -60,27 +143,38 @@ export function Shell({ route, geheZu, tabBeschriftung, hinweis, children }: She
|
||||
<SymbolZurueck groesse={22} />
|
||||
</button>
|
||||
) : null}
|
||||
{/* Kein Eyebrow: das Panel blendet seinen (`.eyebrow{display:none}` in
|
||||
audi-dashboard-ios.css) auf Telefonbreite vollständig aus - der feste
|
||||
Text "DataMetric360" stand hier über jedem Bildschirm, im Panel steht
|
||||
dort nichts. */}
|
||||
<div className="dm-kopf__titel">
|
||||
<span className="ads-eyebrow">DataMetric360</span>
|
||||
<div className="ads-title">{TITEL[route.name]}</div>
|
||||
{/* Datenstand nur auf der Übersicht, wie `#sync` im Panel. */}
|
||||
{route.name === "home" && (
|
||||
<div className="dm-stand">
|
||||
<SymbolAktualisieren groesse={13} />
|
||||
<span>{standAlter(statusStand)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* `kopf` wird nur in der schmalen Spalte gerendert (breit hat seinen
|
||||
eigenen Seitenleisten-Kopf, siehe unten) - dort sind die Audi-Ringe
|
||||
oben rechts der einzige Zugang zu Einstellungen, das Zahnrad
|
||||
entfällt, "damit oben rechts nur ein Element sitzt" (Design-Audit
|
||||
2026-08-13, siehe .rings/.profilbtn in audi-dashboard-ios.css).
|
||||
Companion-app zeigte hier bisher immer ein Zahnrad, unabhängig von
|
||||
der Bildschirmbreite - echte Abweichung, nicht nur Kosmetik: es gab
|
||||
keinen Rings-Zugang in Telefonbreite. */}
|
||||
<button
|
||||
type="button"
|
||||
className="dm-kopf__aktion"
|
||||
aria-label="Einstellungen"
|
||||
aria-current={route.name === "einst" ? "page" : undefined}
|
||||
onClick={() => geheZu("einst")}
|
||||
>
|
||||
<VierRinge hoehe={17} />
|
||||
</button>
|
||||
Nur auf den Tab-Ebenen: auf Unterseiten blendet das Panel die Ringe
|
||||
aus (`#marke`.style.display in render()), dort steht links der
|
||||
Zurück-Pfeil. */}
|
||||
{!unterseite ? (
|
||||
<button
|
||||
type="button"
|
||||
className="dm-kopf__aktion"
|
||||
aria-label="Einstellungen"
|
||||
onClick={() => geheZu("einst")}
|
||||
>
|
||||
<VierRinge hoehe={17} />
|
||||
</button>
|
||||
) : null}
|
||||
</header>
|
||||
)
|
||||
|
||||
@@ -143,7 +237,25 @@ export function Shell({ route, geheZu, tabBeschriftung, hinweis, children }: She
|
||||
<div className="dm-spalte">
|
||||
{hinweis}
|
||||
{kopf}
|
||||
<main className="dm-scroll dm-inhalt">{children}</main>
|
||||
{/* Ziehen zum Aktualisieren wie im Panel (`.ptr`): das Band wächst
|
||||
während der Geste aus der Höhe 0 heraus. */}
|
||||
<div
|
||||
className={ptr.laedt ? "dm-ptr dm-ptr--dreht" : "dm-ptr"}
|
||||
style={{ height: ptr.laedt ? "56px" : `${ptr.zug}px` }}
|
||||
aria-hidden={ptr.zug === 0 && !ptr.laedt}
|
||||
>
|
||||
<SymbolAktualisieren groesse={14} />
|
||||
<span>
|
||||
{ptr.laedt
|
||||
? "Aktualisieren …"
|
||||
: ptr.zug >= 56
|
||||
? "Loslassen zum Aktualisieren"
|
||||
: "Ziehen zum Aktualisieren"}
|
||||
</span>
|
||||
</div>
|
||||
<main className="dm-scroll dm-inhalt" ref={inhaltRef} {...ptr.handler}>
|
||||
{children}
|
||||
</main>
|
||||
<TabBar
|
||||
items={BEREICHE}
|
||||
activeKey={aktiverBereich}
|
||||
|
||||
@@ -39,6 +39,10 @@ export interface TankvorgangFelder {
|
||||
station?: string | null;
|
||||
distanz?: number | null;
|
||||
kraftstoff?: string | null;
|
||||
/** Aus einem vorab gelesenen Beleg (Upload ohne tank_id) - so übergibt sie
|
||||
auch das Panel beim Speichern eines neuen Tankvorgangs mit Beleg. */
|
||||
receipt_key?: string | null;
|
||||
receipt_file?: string | null;
|
||||
}
|
||||
|
||||
/** Eingabefelder für Fahrten - Namen wie beim Backend (fahrterkennung.py:
|
||||
@@ -130,6 +134,30 @@ export class DataMetricApi {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fahrzeugfoto hochladen bzw. ersetzen. Dieselben Dienste, die das Panel
|
||||
* über `data-bildupload` anspricht (`bild_hochladen`/`bild_loeschen` in
|
||||
* services.yaml). Die App konnte Fotos bisher gar nicht ändern — sie
|
||||
* verwies stattdessen auf die Verwaltung in Home Assistant.
|
||||
*/
|
||||
bildHochladen(dateiname: string, datenBase64: string): Promise<unknown> {
|
||||
return this.warteschlange.einreihen(
|
||||
DIENST_DOMAIN,
|
||||
"bild_hochladen",
|
||||
{ dateiname, daten_base64: datenBase64 },
|
||||
`Foto „${dateiname}" hochladen`,
|
||||
)
|
||||
}
|
||||
|
||||
bildLoeschen(dateiname: string): Promise<unknown> {
|
||||
return this.warteschlange.einreihen(
|
||||
DIENST_DOMAIN,
|
||||
"bild_loeschen",
|
||||
{ dateiname },
|
||||
`Foto „${dateiname}" löschen`,
|
||||
)
|
||||
}
|
||||
|
||||
/** Legt eine Fahrt von Hand an — für Fahrten ohne automatische Erkennung. */
|
||||
fahrtAnlegen(felder: FahrtFelder): Promise<unknown> {
|
||||
return this.warteschlange.einreihen(
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Adresse → Koordinaten (Vorwärts-Geokodierung über Nominatim), mit Cache im
|
||||
* localStorage. Gegenstück zur Rückwärts-Auflösung in `Standort.tsx`.
|
||||
*
|
||||
* Gebraucht wird sie für die Tankstellennadel auf dem Einzelbeleg: der Beleg
|
||||
* trägt nur eine Anschrift, und das Backend füllt `station_lat`/`station_lon`
|
||||
* nie — ohne diesen Schritt bliebe die Karte dort dauerhaft ohne Nadel. Das
|
||||
* Panel macht es seit jeher genau so (`adresseAufloesen()`); der App fehlte
|
||||
* beides, Karte wie Auflösung.
|
||||
*
|
||||
* Der Cache liegt bewusst im localStorage und nicht nur im Speicher: eine
|
||||
* Tankstellenadresse ändert ihre Lage nicht, und Nominatim bittet in seinen
|
||||
* Nutzungsbedingungen ausdrücklich darum, Ergebnisse zu behalten. Auch ein
|
||||
* erfolgloser Treffer wird als `null` vermerkt, damit eine Adresse, die
|
||||
* Nominatim nicht kennt, nicht bei jedem Öffnen erneut abgefragt wird — ein
|
||||
* Netzfehler dagegen nicht, der ist kein „gibt es nicht".
|
||||
*/
|
||||
|
||||
export interface Koordinate {
|
||||
lat: number
|
||||
lon: number
|
||||
}
|
||||
|
||||
const CACHE_SCHLUESSEL = "dm360.geocache"
|
||||
|
||||
function cacheLesen(): Record<string, Koordinate | null> {
|
||||
try {
|
||||
const roh = localStorage.getItem(CACHE_SCHLUESSEL)
|
||||
return roh ? (JSON.parse(roh) as Record<string, Koordinate | null>) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function cacheSchreiben(suche: string, treffer: Koordinate | null): void {
|
||||
try {
|
||||
const cache = cacheLesen()
|
||||
cache[suche] = treffer
|
||||
localStorage.setItem(CACHE_SCHLUESSEL, JSON.stringify(cache))
|
||||
} catch {
|
||||
// Privatmodus oder volle Ablage — dann eben ohne Cache.
|
||||
}
|
||||
}
|
||||
|
||||
export async function adresseAufloesen(adresse: string | null | undefined): Promise<Koordinate | null> {
|
||||
const suche = (adresse ?? "").trim()
|
||||
if (!suche) return null
|
||||
const cache = cacheLesen()
|
||||
if (Object.prototype.hasOwnProperty.call(cache, suche)) return cache[suche] ?? null
|
||||
|
||||
let treffer: Koordinate | null = null
|
||||
try {
|
||||
const antwort = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=${encodeURIComponent(suche)}`,
|
||||
{ headers: { "Accept-Language": "de" } },
|
||||
)
|
||||
const daten: unknown = await antwort.json()
|
||||
if (Array.isArray(daten) && daten.length > 0) {
|
||||
const erster = daten[0] as { lat?: string; lon?: string }
|
||||
const lat = Number.parseFloat(erster.lat ?? "")
|
||||
const lon = Number.parseFloat(erster.lon ?? "")
|
||||
if (Number.isFinite(lat) && Number.isFinite(lon)) treffer = { lat, lon }
|
||||
}
|
||||
} catch {
|
||||
// Netzfehler: nichts merken, damit der nächste Aufruf es erneut versucht.
|
||||
return null
|
||||
}
|
||||
cacheSchreiben(suche, treffer)
|
||||
return treffer
|
||||
}
|
||||
@@ -176,7 +176,8 @@ describe("jahresauswertung", () => {
|
||||
})
|
||||
|
||||
describe("nachJahrUndMonat", () => {
|
||||
it("gruppiert absteigend, Neuestes zuerst", () => {
|
||||
// Durchgehend absteigend, in beiden Anwendungen (Designprüfung 2026-08-30).
|
||||
it("gruppiert durchgehend absteigend, Neuestes zuerst", () => {
|
||||
const eintraege = [
|
||||
{ id: "alt", ts: "2025-03-04T10:00:00" },
|
||||
{ id: "neu", ts: "2026-08-01T10:00:00" },
|
||||
|
||||
@@ -130,10 +130,25 @@ export interface Jahresauswertung {
|
||||
privatKm: number
|
||||
arbeitFahrten: number
|
||||
privatFahrten: number
|
||||
jeZeitraum: Record<Zeitraum, { km: number; stunden: number; liter: number; kosten: number }>
|
||||
jeZeitraum: Record<
|
||||
Zeitraum,
|
||||
{ km: number; stunden: number; liter: number; kosten: number; verbrauch: number | null }
|
||||
>
|
||||
langzeit: Langzeitverbrauch
|
||||
}
|
||||
|
||||
/**
|
||||
* Nach Distanz gewichteter Mittelwert der genäherten Fahrtverbräuche —
|
||||
* `schnittFahrtVerbrauch()` im Panel. Die Kachel „Langzeitverbrauch" zeigt
|
||||
* damit einen Näherungswert je Zeitraum; hier fehlte er bisher.
|
||||
*/
|
||||
export function schnittFahrtVerbrauch(fahrten: readonly Fahrt[]): number | null {
|
||||
const mit = fahrten.filter((f) => f.verbrauch_l_100km != null && (f.distance_km ?? 0) > 0)
|
||||
const km = summe(mit, (f) => f.distance_km ?? 0)
|
||||
if (km <= 0) return null
|
||||
return summe(mit, (f) => (f.verbrauch_l_100km ?? 0) * (f.distance_km ?? 0)) / km
|
||||
}
|
||||
|
||||
export function jahresauswertung(
|
||||
fahrten: readonly Fahrt[],
|
||||
tankvorgaenge: readonly Tankvorgang[],
|
||||
@@ -150,6 +165,7 @@ export function jahresauswertung(
|
||||
stunden: summe(f, (x) => x.duration_s ?? 0) / 3600,
|
||||
liter: summe(t, (x) => x.liters ?? 0),
|
||||
kosten: summe(t, (x) => x.fuel_total_eur ?? 0),
|
||||
verbrauch: schnittFahrtVerbrauch(f),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,6 +221,11 @@ export function nachJahrUndMonat<T>(
|
||||
): JahresGruppe<T>[] {
|
||||
const jahre = new Map<number, Map<string, MonatsGruppe<T>>>()
|
||||
|
||||
/* Durchgehend absteigend: Jahr, Monat und Zeile. Bis zur Designprüfung
|
||||
2026-08-30 liefen Monate und Zeilen aufsteigend (der Panel-Stand von
|
||||
damals) — in einer Liste, deren Jahre absteigen, sind das zwei
|
||||
Richtungen gleichzeitig, und die neueste Fahrt landete unten außerhalb
|
||||
des sichtbaren Bereichs. Das Panel ist mitgezogen (neuesteZuerst()). */
|
||||
const sortiert = eintraege
|
||||
.slice()
|
||||
.sort((a, b) => new Date(zeitpunktVon(b)).getTime() - new Date(zeitpunktVon(a)).getTime())
|
||||
|
||||
@@ -18,7 +18,7 @@ describe("Zahlen im deutschen Format", () => {
|
||||
|
||||
it("unterscheidet mit deOderStrich zwischen null und unbekannt", () => {
|
||||
expect(deOderStrich(0)).toBe("0")
|
||||
expect(deOderStrich(null)).toBe("—")
|
||||
expect(deOderStrich(null)).toBe("–")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -32,8 +32,8 @@ describe("dauer", () => {
|
||||
})
|
||||
|
||||
it("zeigt einen Strich statt „0 min", () => {
|
||||
expect(dauer(0)).toBe("—")
|
||||
expect(dauer(null)).toBe("—")
|
||||
expect(dauer(0)).toBe("–")
|
||||
expect(dauer(null)).toBe("–")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -14,9 +14,14 @@ export function eur(wert: number | null | undefined): string {
|
||||
return de(wert, 2)
|
||||
}
|
||||
|
||||
/** Wie de(), aber „—" statt „0" wenn nichts bekannt ist. */
|
||||
/* Fehlende Werte zeigt das Panel durchgängig als Halbgeviertstrich „–", nicht
|
||||
als Geviertstrich „—" (30 Fundstellen gegen eine). Hier stand app-weit der
|
||||
längere Strich — sichtbar breiter, sobald beide Anwendungen nebeneinander
|
||||
liegen. */
|
||||
|
||||
/** Wie de(), aber „–" statt „0" wenn nichts bekannt ist. */
|
||||
export function deOderStrich(wert: number | null | undefined, nachkomma = 0): string {
|
||||
return wert === null || wert === undefined ? "—" : de(wert, nachkomma)
|
||||
return wert === null || wert === undefined ? "–" : de(wert, nachkomma)
|
||||
}
|
||||
|
||||
/** Summe über eine Liste. */
|
||||
@@ -61,32 +66,32 @@ function alsDatum(wert: string | number | Date | null | undefined): Date | null
|
||||
|
||||
export function datum(wert: string | number | Date | null | undefined): string {
|
||||
const d = alsDatum(wert)
|
||||
return d ? DATUM_KURZ.format(d) : "—"
|
||||
return d ? DATUM_KURZ.format(d) : "–"
|
||||
}
|
||||
|
||||
export function datumLang(wert: string | number | Date | null | undefined): string {
|
||||
const d = alsDatum(wert)
|
||||
return d ? DATUM_LANG.format(d) : "—"
|
||||
return d ? DATUM_LANG.format(d) : "–"
|
||||
}
|
||||
|
||||
export function uhrzeit(wert: string | number | Date | null | undefined): string {
|
||||
const d = alsDatum(wert)
|
||||
return d ? UHRZEIT.format(d) : "—"
|
||||
return d ? UHRZEIT.format(d) : "–"
|
||||
}
|
||||
|
||||
export function datumZeit(wert: string | number | Date | null | undefined): string {
|
||||
const d = alsDatum(wert)
|
||||
return d ? `${DATUM_KURZ.format(d)}, ${UHRZEIT.format(d)} Uhr` : "—"
|
||||
return d ? `${DATUM_KURZ.format(d)}, ${UHRZEIT.format(d)} Uhr` : "–"
|
||||
}
|
||||
|
||||
export function monatJahr(wert: string | number | Date | null | undefined): string {
|
||||
const d = alsDatum(wert)
|
||||
return d ? MONAT_JAHR.format(d) : "—"
|
||||
return d ? MONAT_JAHR.format(d) : "–"
|
||||
}
|
||||
|
||||
/** Dauer in Sekunden als „2 h 14 min" bzw. „14 min". */
|
||||
export function dauer(sekunden: number | null | undefined): string {
|
||||
if (!sekunden || sekunden <= 0) return "—"
|
||||
if (!sekunden || sekunden <= 0) return "–"
|
||||
const minutenGesamt = Math.round(sekunden / 60)
|
||||
const stunden = Math.floor(minutenGesamt / 60)
|
||||
const minuten = minutenGesamt % 60
|
||||
@@ -94,6 +99,23 @@ export function dauer(sekunden: number | null | undefined): string {
|
||||
return `${stunden} h ${String(minuten).padStart(2, "0")} min`
|
||||
}
|
||||
|
||||
/**
|
||||
* Alter des letzten Datenstands als „vor 3 Std." — wortgleich mit
|
||||
* `standAlterText()` im Panel, das diesen Text in der Kopfzeile der Übersicht
|
||||
* neben dem Aktualisieren-Symbol zeigt.
|
||||
*/
|
||||
export function standAlter(wert: string | number | Date | null | undefined): string {
|
||||
const d = alsDatum(wert)
|
||||
if (!d) return "unbekannt"
|
||||
const sekunden = Math.max(0, Math.floor((Date.now() - d.getTime()) / 1000))
|
||||
if (sekunden < 60) return `vor ${sekunden} Sek.`
|
||||
const minuten = Math.floor(sekunden / 60)
|
||||
if (minuten < 60) return `vor ${minuten} Min.`
|
||||
const stunden = Math.floor(minuten / 60)
|
||||
if (stunden < 24) return `vor ${stunden} Std.`
|
||||
return `vor ${Math.floor(stunden / 24)} Tage`
|
||||
}
|
||||
|
||||
/** ISO-Tagesdatum (YYYY-MM-DD) für Vergleiche und Eingabefelder. */
|
||||
export function isoTag(wert: string | number | Date | null | undefined = new Date()): string {
|
||||
const d = alsDatum(wert)
|
||||
@@ -101,3 +123,14 @@ export function isoTag(wert: string | number | Date | null | undefined = new Dat
|
||||
const versatz = d.getTimezoneOffset() * 60_000
|
||||
return new Date(d.getTime() - versatz).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/**
|
||||
* Datum und Uhrzeit mit „ · " getrennt — so setzt das Panel den Zeitstempel in
|
||||
* der Batterie-Messwertliste (`dedat(...) + " · " + toLocaleTimeString(...)`).
|
||||
* `datumZeit()` daneben bleibt die Komma-Schreibweise für die Stellen, die sie
|
||||
* schon benutzen.
|
||||
*/
|
||||
export function datumUndZeit(wert: string | number | Date | null | undefined): string {
|
||||
const d = alsDatum(wert)
|
||||
return d ? `${DATUM_KURZ.format(d)} · ${UHRZEIT.format(d)} Uhr` : "–"
|
||||
}
|
||||
|
||||
@@ -73,29 +73,35 @@ export const ZURUECK: Partial<Record<SeitenName, SeitenName>> = {
|
||||
live: "home",
|
||||
}
|
||||
|
||||
/** Überschrift je Seite. Leer bedeutet: die Seite bringt ihre eigene mit. */
|
||||
/**
|
||||
* Überschrift je Seite — wortgleich mit dem Kopf, den `render()` im Panel
|
||||
* setzt (`head[1]`, audi-dashboard-app.js). Neun Titel wichen davon ab
|
||||
* (Fahrzeugdaten/Batterie/Versicherung & Steuer/Beitrag/Notruf/Werkstatt/
|
||||
* Wartungsplan/Fahrt/Tankvorgang); das waren keine Entscheidungen, sondern
|
||||
* Drift. `live` hat keine Panel-Entsprechung (companion-app-eigener Screen).
|
||||
*/
|
||||
export const TITEL: Record<SeitenName, string> = {
|
||||
home: "Übersicht",
|
||||
audi: "Mein Audi",
|
||||
trips: "Fahrten",
|
||||
stat: "Statistik",
|
||||
fuel: "Tanken",
|
||||
ident: "Fahrzeugdaten",
|
||||
battverlauf: "Batterie",
|
||||
ident: "Identität und Technik",
|
||||
battverlauf: "Batteriespannung",
|
||||
battliste: "Messwerte",
|
||||
service: "Service",
|
||||
werkstatt: "Werkstatt",
|
||||
sbuch: "Wartungsplan",
|
||||
werkstatt: "Autohaus",
|
||||
sbuch: "Eintrag",
|
||||
reifen: "Reifen",
|
||||
vers: "Versicherung & Steuer",
|
||||
beitrag: "Beitrag",
|
||||
vers: "Versicherung/Steuer",
|
||||
beitrag: "Beitrag anpassen",
|
||||
vertrag: "Vertrag",
|
||||
vertragsdetails: "Vertragsdetails",
|
||||
schutz: "Schutzbrief",
|
||||
notruf: "Notruf",
|
||||
notruf: "Rufnummern",
|
||||
steuer: "Kfz-Steuer",
|
||||
trip: "Fahrt",
|
||||
fill: "Tankvorgang",
|
||||
trip: "Einzelfahrt",
|
||||
fill: "Einzelbeleg",
|
||||
sicherheit: "Fahrzeugstatus",
|
||||
tuerenklappen: "Türen und Klappen",
|
||||
standort: "Standort",
|
||||
|
||||
@@ -19,7 +19,7 @@ import { ENTITAETEN } from "../api"
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { datum, de } from "../format"
|
||||
import type { SeitenName } from "../navigation"
|
||||
import { SymbolListe } from "../symbole"
|
||||
import { SymbolBatterie, SymbolListe } from "../symbole"
|
||||
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
|
||||
|
||||
interface Tageswert {
|
||||
@@ -112,8 +112,57 @@ function ladezustand(spannung: number): number | null {
|
||||
return 100
|
||||
}
|
||||
|
||||
/** `bvSocLabel()` im Panel. */
|
||||
function socLabel(soc: number): string {
|
||||
return soc >= 90 ? "Sehr gut" : soc >= 70 ? "Gut" : soc >= 50 ? "Mittel" : soc >= 30 ? "Schwach" : "Kritisch"
|
||||
}
|
||||
|
||||
/**
|
||||
* `bvSohBewertung()` im Panel: Trend über die früheren gegen die späteren
|
||||
* Messwerte, mit denselben Schwellen und demselben Wortlaut. Fehlte hier — die
|
||||
* Bewertungskachel gab es gar nicht, stattdessen stand der Trend als nackte
|
||||
* Wertzeile ohne Einordnung.
|
||||
*/
|
||||
const TAG_MS = 86_400_000
|
||||
function sohBewertung(
|
||||
punkte: { t: number; v: number }[],
|
||||
): { status: "ok" | "warn" | "bad" | null; text: string; delta: number } {
|
||||
if (punkte.length < 6)
|
||||
return { status: null, text: "Noch nicht genug Messwerte für eine Trendbewertung.", delta: 0 }
|
||||
const spanneTage = (punkte[punkte.length - 1]!.t - punkte[0]!.t) / TAG_MS
|
||||
if (spanneTage < 60)
|
||||
return {
|
||||
status: null,
|
||||
text: "Noch zu kurzer Zeitraum für eine Trendbewertung (mind. ~2 Monate nötig).",
|
||||
delta: 0,
|
||||
}
|
||||
const fensterMs = Math.min(90, spanneTage / 3) * TAG_MS
|
||||
const alt = punkte.filter((p) => p.t <= punkte[0]!.t + fensterMs)
|
||||
const neu = punkte.filter((p) => p.t >= punkte[punkte.length - 1]!.t - fensterMs)
|
||||
const mittel = (l: { v: number }[]) => l.reduce((s, p) => s + p.v, 0) / l.length
|
||||
const delta = mittel(neu) - mittel(alt)
|
||||
const status = delta >= -0.05 ? "ok" : delta >= -0.15 ? "warn" : "bad"
|
||||
const text =
|
||||
delta >= -0.05
|
||||
? "Stabil - kein auffälliger Rückgang der Ruhespannung."
|
||||
: delta >= -0.15
|
||||
? "Leicht sinkend - im für alternde AGM-Batterien üblichen Rahmen."
|
||||
: "Deutlicher Rückgang - kann auf eine alternde oder geschwächte Batterie hindeuten."
|
||||
return { status, text, delta }
|
||||
}
|
||||
|
||||
function statusFarbe(status: "ok" | "warn" | "bad" | null): string {
|
||||
return status === "ok"
|
||||
? "var(--ok)"
|
||||
: status === "warn"
|
||||
? "var(--warn)"
|
||||
: status === "bad"
|
||||
? "var(--bad)"
|
||||
: "var(--fg3)"
|
||||
}
|
||||
|
||||
export function Batterie({ geheZu }: { geheZu: (name: SeitenName) => void }) {
|
||||
const { api, fahrzeug } = useDaten()
|
||||
const { api } = useDaten()
|
||||
const [verlauf, setzeVerlauf] = useState<Tageswert[] | null>(null)
|
||||
const [fehler, setzeFehler] = useState(false)
|
||||
|
||||
@@ -153,60 +202,79 @@ export function Batterie({ geheZu }: { geheZu: (name: SeitenName) => void }) {
|
||||
|
||||
const letzter = messwerte[messwerte.length - 1]!
|
||||
const soc = letzter.min != null ? ladezustand(letzter.min) : null
|
||||
const stufen = farbstufenLesen()
|
||||
const punkte = messwerte
|
||||
.filter((t) => t.min != null && t.min_ts)
|
||||
.map((t) => ({ t: new Date(t.min_ts!).getTime(), v: t.min! }))
|
||||
const soh = sohBewertung(punkte)
|
||||
|
||||
// Trend als Vergleich früher gegen später — keine echte Kapazitätsmessung.
|
||||
const haelfte = Math.floor(messwerte.length / 2)
|
||||
const mittel = (liste: Tageswert[]) =>
|
||||
liste.length > 0 ? liste.reduce((s, t) => s + (t.min ?? 0), 0) / liste.length : null
|
||||
const frueher = mittel(messwerte.slice(0, haelfte))
|
||||
const spaeter = mittel(messwerte.slice(haelfte))
|
||||
const trend = frueher != null && spaeter != null ? spaeter - frueher : null
|
||||
|
||||
/* Aufbau wie vBatterieverlauf(): eine Kachel „Verlauf" mit Diagramm und
|
||||
Ruhespannungs-Skala, darunter eine Kachel „Bewertung" mit Ladezustand und
|
||||
Gesundheitszustand. Hier stand bis 2026-08-30 eine einzelne Kachel
|
||||
„Ruhespannung" mit großer Zahl und vier Wertzeilen — die Farbskala, die
|
||||
Bewertungskachel und der Bewertungstext fehlten ganz, dafür gab es eine
|
||||
Zeile „Aktuell gemeldet", die das Panel hier nicht führt. */
|
||||
return (
|
||||
<>
|
||||
<Tile>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span className="ads-eyebrow">Ruhespannung</span>
|
||||
<span className="ads-eyebrow">Verlauf</span>
|
||||
<IconButton aria-label="Messwertliste" onClick={() => geheZu("battliste")}>
|
||||
<SymbolListe groesse={18} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="dm-detail__kopf">
|
||||
<div className="dm-detail__zahl">
|
||||
{letzter.min != null ? de(letzter.min, 2) : "—"}
|
||||
<span className="dm-detail__einheit">V</span>
|
||||
</div>
|
||||
</div>
|
||||
<Verlaufsdiagramm werte={messwerte} />
|
||||
<div className="dm-skalakopf">
|
||||
<SymbolBatterie groesse={15} />
|
||||
<span>Ruhespannung</span>
|
||||
</div>
|
||||
<div className="dm-skalabalken" />
|
||||
<div className="dm-skalamarken">
|
||||
<small>11,6 V · leer</small>
|
||||
<small>12,2 V · 50 %</small>
|
||||
<small>12,8 V · voll</small>
|
||||
</div>
|
||||
</Tile>
|
||||
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Bewertung</span>
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile label="Letzte Messung" wert={datum(letzter.datum)} />
|
||||
<Wertzeile
|
||||
label="Geschätzter Ladezustand"
|
||||
wert={soc != null ? `${de(soc)} %` : "—"}
|
||||
zusatz={soc == null ? "Messung nicht in Ruhe aufgenommen" : undefined}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Trend über den Zeitraum"
|
||||
wert={trend != null ? `${trend >= 0 ? "+" : ""}${de(trend, 2)} V` : "—"}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Aktuell gemeldet"
|
||||
label="Ladezustand (SOC)"
|
||||
wert={
|
||||
fahrzeug?.batteriespannung != null
|
||||
? `${de(fahrzeug.batteriespannung, 2)} V`
|
||||
: "—"
|
||||
letzter.min != null && soc != null ? (
|
||||
<span className="dm-bewertung">
|
||||
<span
|
||||
className="ads-dot"
|
||||
style={{ background: farbeFuerSpannung(letzter.min, stufen) }}
|
||||
/>
|
||||
{de(letzter.min, 1)} V · {de(soc)} % · {socLabel(soc)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="dm-offen">unbekannt</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Gesundheitszustand (SOH-Trend)"
|
||||
wert={
|
||||
soh.status ? (
|
||||
<span className="dm-bewertung">
|
||||
<span className="ads-dot" style={{ background: statusFarbe(soh.status) }} />
|
||||
{soh.delta >= 0 ? "+" : ""}
|
||||
{de(soh.delta, 2)} V
|
||||
</span>
|
||||
) : (
|
||||
<span className="dm-offen">-</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<p className="dm-fussnote">
|
||||
Der Ladezustand wird aus dem Tagesminimum geschätzt — das ist die Spannung kurz nach dem
|
||||
Start, bevor die Lichtmaschine sie anhebt. Der Trend vergleicht die frühere mit der
|
||||
späteren Hälfte des Zeitraums; er ist ein Hinweis, keine Kapazitätsmessung.
|
||||
</p>
|
||||
<span className="dm-bewertungstext">{soh.text}</span>
|
||||
</Tile>
|
||||
</>
|
||||
)
|
||||
@@ -221,12 +289,35 @@ export function Batterie({ geheZu }: { geheZu: (name: SeitenName) => void }) {
|
||||
320er-Fläche mit preserveAspectRatio="none" breitzuziehen: sonst würden die
|
||||
neuen Punkte auf breiten Anzeigen zu Ellipsen verzerrt (im Panel war
|
||||
dasselbe Problem an den auseinandergezogenen Achsenzahlen sichtbar). */
|
||||
/** Hält einen Zoomausschnitt innerhalb des vorhandenen Zeitraums. */
|
||||
function begrenzen(
|
||||
bereich: [number, number],
|
||||
voll: [number, number],
|
||||
): [number, number] {
|
||||
const spanne = Math.min(bereich[1] - bereich[0], voll[1] - voll[0])
|
||||
let von = bereich[0]
|
||||
if (von < voll[0]) von = voll[0]
|
||||
if (von + spanne > voll[1]) von = voll[1] - spanne
|
||||
return [von, von + spanne]
|
||||
}
|
||||
|
||||
const BV_H = 170
|
||||
const BV_ML = 40
|
||||
const BV_MR = 10
|
||||
const BV_MT = 10
|
||||
const BV_MB = 12
|
||||
|
||||
function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
|
||||
const hoehe = 120
|
||||
const rand = 8
|
||||
const hoehe = BV_H
|
||||
const svgRef = useRef<SVGSVGElement | null>(null)
|
||||
const [breite, setzeBreite] = useState(320)
|
||||
const [gewaehlt, setzeGewaehlt] = useState<number | null>(null)
|
||||
/* Zoomen und Verschieben wie im Panel (bvDomain/bvZeiger/bvGestStart):
|
||||
zwei Finger spreizen den Zeitausschnitt, einer verschiebt ihn, der Knopf
|
||||
stellt ihn zurück. Fehlte hier ganz — die Kurve war unveränderlich. */
|
||||
const [ausschnitt, setzeAusschnitt] = useState<[number, number] | null>(null)
|
||||
const zeiger = useRef(new Map<number, number>())
|
||||
const gestStart = useRef<{ domain: [number, number]; mitte: number; spanne: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = svgRef.current
|
||||
@@ -247,16 +338,28 @@ function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
|
||||
const groesster = 15
|
||||
const spanne = groesster - kleinster
|
||||
|
||||
const x = (i: number) => rand + (i / Math.max(1, werte.length - 1)) * (breite - 2 * rand)
|
||||
// Zeitachse: echte Zeitstempel statt gleichmäßiger Indexschritte, sonst
|
||||
// ließe sich der Ausschnitt nicht sinnvoll zoomen.
|
||||
const zeitVon = (t: Tageswert) => new Date(t.min_ts ?? t.datum).getTime()
|
||||
const vollDomain: [number, number] =
|
||||
werte.length >= 2
|
||||
? [zeitVon(werte[0]!), zeitVon(werte[werte.length - 1]!)]
|
||||
: [0, 1]
|
||||
const domain = ausschnitt ?? vollDomain
|
||||
const zeitSpanne = Math.max(1, domain[1] - domain[0])
|
||||
|
||||
const x = (i: number) =>
|
||||
BV_ML +
|
||||
((zeitVon(werte[i]!) - domain[0]) / zeitSpanne) * (breite - BV_ML - BV_MR)
|
||||
// Anteil auf [0,1] geklemmt statt roh extrapoliert - ein Ausreißer außerhalb
|
||||
// von kleinster/groesster zeichnet sich sonst weit außerhalb der Fläche.
|
||||
const y = (v: number) => {
|
||||
const anteil = Math.max(0, Math.min(1, (v - kleinster) / spanne))
|
||||
return hoehe - rand - anteil * (hoehe - 2 * rand)
|
||||
return hoehe - BV_MB - anteil * (hoehe - BV_MT - BV_MB)
|
||||
}
|
||||
|
||||
const linie = werte
|
||||
.map((t, i) => (t.min == null ? null : `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(t.min).toFixed(1)}`))
|
||||
const punkteText = werte
|
||||
.map((t, i) => (t.min == null ? null : `${x(i).toFixed(1)},${y(t.min).toFixed(1)}`))
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
|
||||
@@ -264,15 +367,84 @@ function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
|
||||
const el = svgRef.current
|
||||
if (!el || werte.length === 0) return
|
||||
const kasten = el.getBoundingClientRect()
|
||||
const relativ = ereignis.clientX - kasten.left - rand
|
||||
const schritt = (breite - 2 * rand) / Math.max(1, werte.length - 1)
|
||||
const index = Math.max(0, Math.min(werte.length - 1, Math.round(relativ / schritt)))
|
||||
setzeGewaehlt((vorher) => (vorher === index ? null : index))
|
||||
const anteil = (ereignis.clientX - kasten.left - BV_ML) / Math.max(1, breite - BV_ML - BV_MR)
|
||||
const zeit = domain[0] + anteil * zeitSpanne
|
||||
let bester = 0
|
||||
let abstand = Infinity
|
||||
werte.forEach((t, i) => {
|
||||
const a = Math.abs(zeitVon(t) - zeit)
|
||||
if (a < abstand) {
|
||||
abstand = a
|
||||
bester = i
|
||||
}
|
||||
})
|
||||
setzeGewaehlt((vorher) => (vorher === bester ? null : bester))
|
||||
}
|
||||
|
||||
const zeigerRunter = (e: React.PointerEvent<SVGSVGElement>) => {
|
||||
zeiger.current.set(e.pointerId, e.clientX)
|
||||
if (zeiger.current.size === 2) {
|
||||
const [a, b] = [...zeiger.current.values()]
|
||||
gestStart.current = {
|
||||
domain: [domain[0], domain[1]],
|
||||
mitte: (a! + b!) / 2,
|
||||
spanne: Math.abs(a! - b!) || 1,
|
||||
}
|
||||
} else if (zeiger.current.size === 1) {
|
||||
gestStart.current = { domain: [domain[0], domain[1]], mitte: e.clientX, spanne: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
const zeigerBewegt = (e: React.PointerEvent<SVGSVGElement>) => {
|
||||
if (!zeiger.current.has(e.pointerId) || !gestStart.current) return
|
||||
zeiger.current.set(e.pointerId, e.clientX)
|
||||
const nutzbar = Math.max(1, breite - BV_ML - BV_MR)
|
||||
const start = gestStart.current
|
||||
const startSpanne = start.domain[1] - start.domain[0]
|
||||
if (zeiger.current.size >= 2) {
|
||||
const [a, b] = [...zeiger.current.values()]
|
||||
const faktor = start.spanne / Math.max(1, Math.abs(a! - b!))
|
||||
const neueSpanne = Math.max(
|
||||
3600_000,
|
||||
Math.min(vollDomain[1] - vollDomain[0], startSpanne * faktor),
|
||||
)
|
||||
const mitteZeit = start.domain[0] + (startSpanne * (start.mitte - BV_ML)) / nutzbar
|
||||
setzeAusschnitt(begrenzen([mitteZeit - neueSpanne / 2, mitteZeit + neueSpanne / 2], vollDomain))
|
||||
} else {
|
||||
const verschub = ((start.mitte - e.clientX) / nutzbar) * startSpanne
|
||||
setzeAusschnitt(
|
||||
begrenzen([start.domain[0] + verschub, start.domain[1] + verschub], vollDomain),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const zeigerHoch = (e: React.PointerEvent<SVGSVGElement>) => {
|
||||
zeiger.current.delete(e.pointerId)
|
||||
if (zeiger.current.size === 0) gestStart.current = null
|
||||
}
|
||||
|
||||
const aktiv = gewaehlt != null ? werte[gewaehlt] : undefined
|
||||
const zeitpunkt = aktiv?.min_ts ? new Date(aktiv.min_ts) : null
|
||||
const stufen = farbstufenLesen()
|
||||
const kurz = (wert?: string) =>
|
||||
wert
|
||||
? new Date(wert).toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "2-digit",
|
||||
})
|
||||
: ""
|
||||
|
||||
/* Gitterlinien mit Achsenbeschriftung und die weiche Flächenfüllung unter der
|
||||
Linie fehlten hier ganz — das Panel zeichnet beide (bvZeichnen()), samt
|
||||
dem Zeitraum unter der Fläche. Ohne sie stand die Kurve ohne jeden
|
||||
Maßstab im Raum. */
|
||||
const flaeche =
|
||||
punkteText.length > 0 && werte.length >= 2
|
||||
? `M${x(0).toFixed(1)},${(hoehe - BV_MB).toFixed(1)} L${punkteText.split(" ").join(" L")} L${x(
|
||||
werte.length - 1,
|
||||
).toFixed(1)},${(hoehe - BV_MB).toFixed(1)} Z`
|
||||
: ""
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -283,8 +455,45 @@ function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
|
||||
role="img"
|
||||
aria-label={`Spannungsverlauf über ${werte.length} Messungen`}
|
||||
onClick={punktWaehlen}
|
||||
onPointerDown={zeigerRunter}
|
||||
onPointerMove={zeigerBewegt}
|
||||
onPointerUp={zeigerHoch}
|
||||
onPointerCancel={zeigerHoch}
|
||||
>
|
||||
<path d={linie} fill="none" stroke="var(--fg)" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
|
||||
<defs>
|
||||
<linearGradient id="dmBvGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor="var(--fg)" stopOpacity="0.16" />
|
||||
<stop offset="1" stopColor="var(--fg)" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{[0, 1, 2, 3].map((i) => {
|
||||
const v = kleinster + (spanne * i) / 3
|
||||
const yy = y(v)
|
||||
return (
|
||||
<g key={i}>
|
||||
<line
|
||||
x1={BV_ML}
|
||||
y1={yy}
|
||||
x2={breite - BV_MR}
|
||||
y2={yy}
|
||||
stroke="var(--line)"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<text x={BV_ML - 6} y={yy + 3} fontSize="11" fill="var(--fg3)" textAnchor="end">
|
||||
{de(v, 1)}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
{flaeche && <path d={flaeche} fill="url(#dmBvGrad)" stroke="none" />}
|
||||
<polyline
|
||||
points={punkteText}
|
||||
fill="none"
|
||||
stroke="var(--fg)"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{werte.map((t, i) =>
|
||||
t.min == null ? null : (
|
||||
<circle
|
||||
@@ -298,8 +507,16 @@ function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
|
||||
),
|
||||
)}
|
||||
</svg>
|
||||
<div className="dm-diagramm__fuss">
|
||||
<span className="ads-eyebrow">
|
||||
{kurz(new Date(domain[0]).toISOString())} – {kurz(new Date(domain[1]).toISOString())}
|
||||
</span>
|
||||
<button type="button" className="ads-action dm-zoomzurueck" onClick={() => setzeAusschnitt(null)}>
|
||||
Zoom zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
{aktiv && (
|
||||
<p className="dm-fussnote">
|
||||
<div className="dm-diagramm__tooltip">
|
||||
{[
|
||||
zeitpunkt
|
||||
? `${datum(aktiv.datum)} · ${zeitpunkt.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr`
|
||||
@@ -310,7 +527,7 @@ function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ import { Pill, Tile } from "@audi-dash/ui"
|
||||
|
||||
import { ENTITAETEN } from "../api"
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { datum, datumZeit, de } from "../format"
|
||||
import { datum, datumUndZeit, de } from "../format"
|
||||
import { Leerzustand } from "./bausteine"
|
||||
import { AGM_RUHE_MAX_V } from "./Batterie"
|
||||
import { Zeilenmenue } from "./Zeilenmenue"
|
||||
@@ -86,11 +86,11 @@ export function BatterieListe() {
|
||||
<span className="dm-listenzeile__titel">
|
||||
{de(t.min!, 2)} V
|
||||
<span className="dm-listenzeile__neben">
|
||||
{t.min_temp_c != null ? `${de(t.min_temp_c, 1)} °C` : "—"}
|
||||
{t.min_temp_c != null ? `${de(t.min_temp_c, 1)} °C` : "–"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="dm-listenzeile__unter">
|
||||
{t.min_ts ? datumZeit(t.min_ts) : datum(t.datum)}
|
||||
{t.min_ts ? datumUndZeit(t.min_ts) : datum(t.datum)}
|
||||
</span>
|
||||
</span>
|
||||
{t.min! > AGM_RUHE_MAX_V && <Pill>Generatorspannung</Pill>}
|
||||
|
||||
@@ -16,21 +16,27 @@ export function Bild({
|
||||
beschreibung,
|
||||
name,
|
||||
groesse,
|
||||
dateiZeigen,
|
||||
}: {
|
||||
datei: string | null | undefined
|
||||
beschreibung: string
|
||||
name: string
|
||||
groesse?: "default" | "mini" | "round"
|
||||
/** Zeigt den Dateinamen statt der Handlungsaufforderung - so macht es das
|
||||
Panel auf der Einstellungen-Seite (dateiZeigen in bildMitPlatzhalter()). */
|
||||
dateiZeigen?: boolean
|
||||
}) {
|
||||
const url = bildUrl(datei)
|
||||
// Der Dateiname hilft nur, wenn er lesbar ist — in der 78px breiten
|
||||
// Miniaturansicht liefe er über den Rand hinaus.
|
||||
const mitDateiname = groesse !== "mini" && groesse !== "round"
|
||||
// Unter der Bezeichnung steht im Panel die Handlungsaufforderung, nicht der
|
||||
// Dateiname (`.platzhalter-aktion` zeigt "Foto hinzufügen";
|
||||
// `.platzhalter-datei` ist dort ausdrücklich ausgeblendet). In der 78px
|
||||
// breiten Miniaturansicht bleibt kein Platz dafür.
|
||||
const mitAktion = groesse !== "mini" && groesse !== "round"
|
||||
return (
|
||||
<ImagePlaceholder
|
||||
{...(url ? { src: url } : {})}
|
||||
{...(groesse ? { size: groesse } : {})}
|
||||
{...(mitDateiname && datei ? { hint: datei } : {})}
|
||||
{...(mitAktion ? { hint: dateiZeigen && datei ? datei : "Foto hinzufügen" } : {})}
|
||||
alt={beschreibung}
|
||||
label={name}
|
||||
/>
|
||||
|
||||
@@ -14,11 +14,10 @@ import { eigeneVersion } from "../daten/appVersion"
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { OtaFehler, buendelAnwenden, buendelPasst, otaMoeglich } from "../daten/ota"
|
||||
import type { Einstellungen as EinstellungenWerte } from "../daten/profilAdapter"
|
||||
import { datumZeit, de, isoTag } from "../format"
|
||||
import { useTheme } from "../theme"
|
||||
import { Wertzeile, Werteliste, bestaetigen } from "./bausteine"
|
||||
import { Bild } from "./Bild"
|
||||
import { BILDPLAETZE, markenlogoUrl } from "./bilder"
|
||||
import { datumZeit, isoTag } from "../format"
|
||||
import { useBoden, useTheme } from "../theme"
|
||||
import { BildMitMenue, Wertzeile, Werteliste, bestaetigen } from "./bausteine"
|
||||
import { BILDPLAETZE, SEITE_WINTER, markenlogoUrl } from "./bilder"
|
||||
import { DatensatzPopup } from "./DatensatzPopup"
|
||||
import { HistorienImport } from "./HistorienImport"
|
||||
import type { ServicebuchEintrag } from "./Service"
|
||||
@@ -49,6 +48,13 @@ export function Einstellungen({
|
||||
neuLaden,
|
||||
} = useDaten()
|
||||
const [theme, themeSetzen] = useTheme()
|
||||
const [boden, setzeBoden] = useBoden()
|
||||
// "Fahrzeug einrichten" ist im Panel zugeklappt und oeffnet sich ueber den
|
||||
// Knopf darunter (einrichtenOffen in vEinst()).
|
||||
const [einrichtenOffen, setzeEinrichtenOffen] = useState(false)
|
||||
// Erstzulassung liegt am Fahrzeug, nicht an den Einstellungen - deshalb ein
|
||||
// eigener Entwurf, der beim Speichern mitgeschrieben wird.
|
||||
const [erstzulassung, setzeErstzulassung] = useState<string | null>(null)
|
||||
const [entwurf, setzeEntwurf] = useState<EinstellungenWerte | null>(null)
|
||||
const [laeuft, setzeLaeuft] = useState(false)
|
||||
const [gespeichert, setzeGespeichert] = useState(false)
|
||||
@@ -161,6 +167,13 @@ export function Einstellungen({
|
||||
|
||||
if (!einstellungen || !fahrzeug) return null
|
||||
const werte = entwurf ?? einstellungen
|
||||
const erstzulassungWert = erstzulassung ?? fahrzeug.erstzulassung
|
||||
// Bei Winterraedern zeigt die Seitenansicht das Winterfoto - wie bildInfo()
|
||||
// im Panel, das hier dieselbe Vorschau liefert.
|
||||
const gewaehltesBild =
|
||||
werte.startbild === "seitenansicht.webp" && fahrzeug.reifen.aktiv === "Winter"
|
||||
? { datei: SEITE_WINTER.datei, beschreibung: SEITE_WINTER.beschreibung }
|
||||
: (BILDPLAETZE.find((b) => b.datei === werte.startbild) ?? BILDPLAETZE[0]!)
|
||||
|
||||
const aendern = (teil: Partial<EinstellungenWerte>) =>
|
||||
setzeEntwurf({ ...werte, ...teil })
|
||||
@@ -170,8 +183,12 @@ export function Einstellungen({
|
||||
setzeLaeuft(true)
|
||||
setzeGespeichert(false)
|
||||
try {
|
||||
await profilSpeichern({ einstellungen: entwurf })
|
||||
await profilSpeichern({
|
||||
einstellungen: entwurf,
|
||||
...(erstzulassung !== null ? { fahrzeug: { ...fahrzeug, erstzulassung } } : {}),
|
||||
})
|
||||
setzeEntwurf(null)
|
||||
setzeErstzulassung(null)
|
||||
setzeGespeichert(true)
|
||||
} finally {
|
||||
setzeLaeuft(false)
|
||||
@@ -243,99 +260,97 @@ export function Einstellungen({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Aufbau, Reihenfolge und Wortlaut wie vEinst(): „Fahrzeug einrichten"
|
||||
(aufklappbar), Darstellung, Bild der Übersicht, Kraftstoff-Rabatt,
|
||||
Fahrten zusammenfassen, Fahrzeugprofil, Backup, Version,
|
||||
Integration-Update. Vorher standen die Abschnitte in anderer
|
||||
Reihenfolge und unter eigenen Namen („Tankrabatt", „Sicherung",
|
||||
„Vergangene Daten", „Bild auf der Übersicht"). */}
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Fahrzeug</span>
|
||||
<Feld label="Modell">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
value={werte.fahrzeugtitel}
|
||||
onChange={(e) => aendern({ fahrzeugtitel: e.target.value })}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Zusatz">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
value={werte.fahrzeugzusatz}
|
||||
onChange={(e) => aendern({ fahrzeugzusatz: e.target.value })}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Kennzeichen">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
value={werte.kennzeichen}
|
||||
onChange={(e) => aendern({ kennzeichen: e.target.value })}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Tankvolumen" unit="l" last>
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
inputMode="numeric"
|
||||
value={werte.tankvolumen}
|
||||
onChange={(e) => aendern({ tankvolumen: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</Feld>
|
||||
</Tile>
|
||||
|
||||
<Tile>
|
||||
{/* Panel-Wortlaut: "Fahrten zusammenfassen" / "Pause bis" - war hier
|
||||
als "Fahrten"/"Pause zwischen zwei Fahrten" eigenständig
|
||||
formuliert, brach dadurch in der 16px-Label-Spalte (Parität-Audit
|
||||
2026-08-30, Feld-Label-Korrektur) Wort für Wort um. */}
|
||||
<span className="ads-eyebrow">Fahrten zusammenfassen</span>
|
||||
<Feld label="Pause bis" unit="Minuten">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
inputMode="numeric"
|
||||
value={werte.pauseMin}
|
||||
onChange={(e) => aendern({ pauseMin: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Nacht beginnt">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
type="time"
|
||||
value={werte.nachtVon}
|
||||
onChange={(e) => aendern({ nachtVon: e.target.value })}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Nacht endet" last>
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
type="time"
|
||||
value={werte.nachtBis}
|
||||
onChange={(e) => aendern({ nachtBis: e.target.value })}
|
||||
/>
|
||||
</Feld>
|
||||
<p className="dm-fussnote">
|
||||
Setzt das Fahrzeug innerhalb der Pausenzeit die Fahrt fort, zählt das als eine Fahrt und
|
||||
nicht als zwei — etwa bei einem Tankstopp.
|
||||
</p>
|
||||
<span className="ads-eyebrow">Fahrzeug einrichten</span>
|
||||
{einrichtenOffen && (
|
||||
<>
|
||||
<Feld label="Modell">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
value={werte.fahrzeugtitel}
|
||||
onChange={(e) => aendern({ fahrzeugtitel: e.target.value })}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Kennzeichen">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
value={werte.kennzeichen}
|
||||
onChange={(e) => aendern({ kennzeichen: e.target.value })}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Erstzulassung">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
placeholder="MM/JJJJ"
|
||||
value={erstzulassungWert}
|
||||
onChange={(e) => setzeErstzulassung(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Ausführung" last>
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
value={werte.ausfuehrung}
|
||||
onChange={(e) => aendern({ ausfuehrung: e.target.value })}
|
||||
/>
|
||||
</Feld>
|
||||
{/* „Setup — Sensoren zuordnen" bleibt dem Panel vorbehalten: die
|
||||
Zuordnung von Home-Assistant-Entitäten setzt eine angemeldete
|
||||
HA-Sitzung voraus, die diese App nicht hat. */}
|
||||
<ActionButton onClick={() => setzeImportOffen(true)}>
|
||||
Daten importieren aus Home Assistant
|
||||
</ActionButton>
|
||||
<span className="ads-eyebrow" style={{ marginTop: "10px" }}>
|
||||
Legt Fahrten, Tankvorgänge und Spannungswerte für einen vergangenen Zeitraum
|
||||
nachträglich an — aus dem Verlauf, den Home Assistant zu den zugeordneten Sensoren
|
||||
bereits aufgezeichnet hat.
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<ActionButton
|
||||
variant={einrichtenOffen ? "primary" : "default"}
|
||||
onClick={() => setzeEinrichtenOffen((offen) => !offen)}
|
||||
>
|
||||
{einrichtenOffen ? "Fertig" : "Einrichten"}
|
||||
</ActionButton>
|
||||
</Tile>
|
||||
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Darstellung</span>
|
||||
<Feld label="Erscheinungsbild">
|
||||
<Seg
|
||||
options={[
|
||||
{ value: "nacht", label: "Nacht" },
|
||||
{ value: "tag", label: "Tag" },
|
||||
]}
|
||||
value={theme}
|
||||
onChange={(neu) => themeSetzen(neu as "nacht" | "tag")}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Beschriftung der Tab-Leiste" last>
|
||||
<Seg
|
||||
options={[
|
||||
{ value: "nacht", label: "Nacht" },
|
||||
{ value: "tag", label: "Tag" },
|
||||
]}
|
||||
value={theme}
|
||||
onChange={(neu) => themeSetzen(neu as "nacht" | "tag")}
|
||||
/>
|
||||
<Feld label="Beschriftung in der Menüleiste" last>
|
||||
<Switch
|
||||
checked={tabBeschriftung}
|
||||
onChange={setzeTabBeschriftung}
|
||||
aria-label="Beschriftung der Tab-Leiste"
|
||||
aria-label="Beschriftung in der Menüleiste"
|
||||
/>
|
||||
</Feld>
|
||||
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
|
||||
Aus zeigt nur die Symbole und gibt etwas Höhe frei
|
||||
</span>
|
||||
<Feld label="Fahrbahn unter dem Fahrzeug" last>
|
||||
<Switch checked={boden} onChange={setzeBoden} aria-label="Fahrbahn unter dem Fahrzeug" />
|
||||
</Feld>
|
||||
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
|
||||
Angedeutete Fahrbahn mit Schatten, nur bei Außenansichten
|
||||
</span>
|
||||
</Tile>
|
||||
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Bild auf der Übersicht</span>
|
||||
<Feld label="Verwendetes Bild" last>
|
||||
<span className="ads-eyebrow">Bild der Übersicht</span>
|
||||
<Feld label="Ansicht" last>
|
||||
<select
|
||||
className="dm-auswahl"
|
||||
value={werte.startbild}
|
||||
@@ -346,28 +361,26 @@ export function Einstellungen({
|
||||
{platz.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="seitenansicht-winter.webp">Seitenansicht Winter</option>
|
||||
</select>
|
||||
</Feld>
|
||||
<div className="dm-galerie">
|
||||
{BILDPLAETZE.map((platz) => (
|
||||
<Bild
|
||||
key={platz.datei}
|
||||
datei={platz.datei}
|
||||
beschreibung={platz.beschreibung}
|
||||
name={platz.name}
|
||||
groesse="mini"
|
||||
/>
|
||||
))}
|
||||
<span className="ads-eyebrow" style={{ marginTop: "10px" }}>
|
||||
Auf das Bild tippen, um für diese Ansicht ein eigenes Foto hochzuladen oder zu ersetzen.
|
||||
</span>
|
||||
{/* Vorher stand hier ein Raster aller sieben Miniaturen und der Hinweis,
|
||||
Fotos seien nur über Home Assistant zu pflegen — das Panel zeigt
|
||||
genau die gewählte Ansicht groß und lädt direkt hoch. */}
|
||||
<div style={{ marginTop: "var(--sp-3)" }}>
|
||||
<BildMitMenue
|
||||
datei={gewaehltesBild.datei}
|
||||
beschreibung={gewaehltesBild.beschreibung}
|
||||
name={gewaehltesBild.beschreibung}
|
||||
dateiZeigen
|
||||
/>
|
||||
</div>
|
||||
<p className="dm-fussnote">
|
||||
Fotos lädst du über die Verwaltung im Home Assistant hoch. Die Dateinamen sind fest
|
||||
vergeben — der Server nimmt nur diese an.
|
||||
</p>
|
||||
</Tile>
|
||||
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Tankrabatt</span>
|
||||
<span className="ads-eyebrow">Kraftstoff-Rabatt</span>
|
||||
<Feld
|
||||
label={
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
@@ -379,7 +392,9 @@ export function Einstellungen({
|
||||
e.currentTarget.style.display = "none"
|
||||
}}
|
||||
/>
|
||||
{werte.markenname}-Rabatt aktiv
|
||||
{/* Das Panel nennt den Schalter "SmartDeal aktiv" - fest, nicht
|
||||
aus dem Markennamen gebaut. */}
|
||||
SmartDeal aktiv
|
||||
</span>
|
||||
}
|
||||
last={!fahrzeug.smartdeal.aktiv}
|
||||
@@ -394,7 +409,7 @@ export function Einstellungen({
|
||||
},
|
||||
})
|
||||
}
|
||||
aria-label="Tankrabatt aktiv"
|
||||
aria-label="SmartDeal aktiv"
|
||||
/>
|
||||
</Feld>
|
||||
{fahrzeug.smartdeal.aktiv ? (
|
||||
@@ -414,31 +429,31 @@ export function Einstellungen({
|
||||
}
|
||||
/>
|
||||
</Feld>
|
||||
<p className="dm-fussnote">
|
||||
Das Datum gilt einschließlich — am Stichtag selbst zählt der Rabatt noch, erst am
|
||||
Folgetag schaltet er ab.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="dm-fussnote">Ersparnis und Bildmarke werden ausgeblendet.</p>
|
||||
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
|
||||
Ersparnis und Bildmarke werden ausgeblendet
|
||||
</span>
|
||||
)}
|
||||
</Tile>
|
||||
|
||||
{/* Nachträglicher Import aus dem HA-Verlauf. Steht bewusst vor
|
||||
„Sicherung": beides holt Daten von außen in die App, und der Import
|
||||
ist der Schritt, den man einmal nach der Einrichtung braucht. */}
|
||||
{/* „Nacht beginnt"/„Nacht endet" sind entfallen: das Panel fuehrt sie
|
||||
in den Einstellungen nicht, die Nachtgrenze steht dort nur als
|
||||
Erlaeuterung auf der Statistik-Seite. */}
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Vergangene Daten</span>
|
||||
<p className="dm-fussnote">
|
||||
Legt Fahrten, Tankvorgänge und Spannungswerte für einen vergangenen Zeitraum
|
||||
nachträglich an — aus dem Verlauf, den Home Assistant zu den zugeordneten Sensoren
|
||||
bereits aufgezeichnet hat.
|
||||
</p>
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<ActionButton onClick={() => setzeImportOffen(true)}>
|
||||
Daten importieren aus Home Assistant
|
||||
</ActionButton>
|
||||
</div>
|
||||
<span className="ads-eyebrow">Fahrten zusammenfassen</span>
|
||||
<Feld label="Pause bis" unit="Minuten" last>
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
inputMode="numeric"
|
||||
value={werte.pauseMin}
|
||||
onChange={(e) => aendern({ pauseMin: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</Feld>
|
||||
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
|
||||
Steigst du kürzer als diese Zeit aus — etwa zum Tanken —, bleibt es eine
|
||||
zusammenhängende Fahrt statt zweier
|
||||
</span>
|
||||
</Tile>
|
||||
|
||||
<HistorienImport
|
||||
@@ -462,43 +477,60 @@ export function Einstellungen({
|
||||
/>
|
||||
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Sicherung</span>
|
||||
<Feld label="Automatische Sicherung">
|
||||
<span className="ads-eyebrow">Fahrzeugprofil</span>
|
||||
<p className="dm-erklaerung">
|
||||
Alles Fahrzeugspezifische liegt in einer einzigen Datei: Stammdaten, technische Daten,
|
||||
Ausstattung, Reifen, Versicherung, Steuer und Werkstatt. Fahrten, Tankvorgänge und der
|
||||
Wartungsplan lassen sich einzeln als CSV sichern und bearbeitet wieder einspielen — eine
|
||||
Zeile mit bekannter ID aktualisiert nur ihre eigenen Spalten, alles andere bleibt
|
||||
unangetastet.
|
||||
</p>
|
||||
<div className="dm-knopfreihe">
|
||||
<ActionButton onClick={() => setzeDatensatzPopup("sichern")}>
|
||||
Datensatz sichern
|
||||
</ActionButton>
|
||||
<ActionButton onClick={() => setzeDatensatzPopup("laden")}>Datensatz laden</ActionButton>
|
||||
</div>
|
||||
</Tile>
|
||||
|
||||
{/* Wortlaut, Reihenfolge und Knopfbeschriftungen wie im Panel: die
|
||||
Kachel hiess hier „Sicherung", der Erlaeuterungstext fehlte, und die
|
||||
Knoepfe waren eigenstaendig formuliert („Jetzt sichern", „Auf dieses
|
||||
Geraet laden", „Von diesem Geraet einspielen"). */}
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Backup</span>
|
||||
<span className="ads-eyebrow" style={{ marginTop: "10px" }}>
|
||||
Sichert Fahrzeugprofil, Fahrten und Tankvorgänge gemeinsam. "Jetzt erstellen"
|
||||
legt eine Kopie auf dem Server ab, "Exportieren"/"Importieren"
|
||||
übertragen den aktuellen Gesamtstand als einzelne Datei.
|
||||
</span>
|
||||
{werte.letztesBackup && (
|
||||
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
|
||||
Letztes Backup: {datumZeit(werte.letztesBackup)}
|
||||
</span>
|
||||
)}
|
||||
<ActionButton onClick={() => void api.rest.dienstAufrufen(DIENST_DOMAIN, "backup_jetzt")}>
|
||||
Backup jetzt erstellen
|
||||
</ActionButton>
|
||||
<Feld label="Autom. Backup" last>
|
||||
<select
|
||||
className="dm-auswahl"
|
||||
value={werte.backupIntervall}
|
||||
onChange={(e) => aendern({ backupIntervall: e.target.value })}
|
||||
>
|
||||
<option value="aus">aus</option>
|
||||
<option value="taeglich">täglich</option>
|
||||
<option value="woechentlich">wöchentlich</option>
|
||||
<option value="monatlich">monatlich</option>
|
||||
<option value="aus">Aus</option>
|
||||
<option value="taeglich">Täglich</option>
|
||||
<option value="woechentlich">Wöchentlich</option>
|
||||
<option value="monatlich">Monatlich</option>
|
||||
</select>
|
||||
</Feld>
|
||||
<Werteliste
|
||||
kinder={
|
||||
<Wertzeile
|
||||
label="Zuletzt gesichert"
|
||||
wert={werte.letztesBackup ? datumZeit(werte.letztesBackup) : "—"}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<div className="dm-knopfreihe">
|
||||
<ActionButton
|
||||
onClick={() =>
|
||||
void api.rest.dienstAufrufen(DIENST_DOMAIN, "backup_jetzt")
|
||||
}
|
||||
>
|
||||
Jetzt sichern
|
||||
</ActionButton>
|
||||
<ActionButton onClick={profilExportieren}>Auf dieses Gerät laden</ActionButton>
|
||||
<ActionButton
|
||||
onClick={() => backupDateiwahl.current?.click()}
|
||||
disabled={backupImportLaeuft}
|
||||
>
|
||||
{backupImportLaeuft ? "Lade …" : "Von diesem Gerät einspielen"}
|
||||
</ActionButton>
|
||||
</div>
|
||||
<ActionButton onClick={profilExportieren}>Backup exportieren</ActionButton>
|
||||
<ActionButton
|
||||
onClick={() => backupDateiwahl.current?.click()}
|
||||
disabled={backupImportLaeuft}
|
||||
>
|
||||
{backupImportLaeuft ? "Lade …" : "Backup importieren"}
|
||||
</ActionButton>
|
||||
<input
|
||||
ref={backupDateiwahl}
|
||||
type="file"
|
||||
@@ -513,22 +545,6 @@ export function Einstellungen({
|
||||
{backupImportFehler && <p className="dm-fehler">{backupImportFehler}</p>}
|
||||
</Tile>
|
||||
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Fahrzeugprofil</span>
|
||||
<p className="dm-fussnote">
|
||||
Alles Fahrzeugspezifische liegt in einer einzigen Datei: Stammdaten, technische Daten,
|
||||
Ausstattung, Reifen, Versicherung, Steuer und Werkstatt. Fahrten, Tankvorgänge und der
|
||||
Wartungsplan lassen sich einzeln als CSV sichern und bearbeitet wieder einspielen — eine
|
||||
Zeile mit bekannter ID aktualisiert nur ihre eigenen Spalten, alles andere bleibt
|
||||
unangetastet.
|
||||
</p>
|
||||
<div className="dm-knopfreihe">
|
||||
<ActionButton onClick={() => setzeDatensatzPopup("sichern")}>
|
||||
Datensatz sichern
|
||||
</ActionButton>
|
||||
<ActionButton onClick={() => setzeDatensatzPopup("laden")}>Datensatz laden</ActionButton>
|
||||
</div>
|
||||
</Tile>
|
||||
|
||||
{otaMoeglich() && (
|
||||
<Tile>
|
||||
@@ -544,7 +560,7 @@ export function Einstellungen({
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<p className="dm-fussnote">
|
||||
<p className="dm-erklaerung">
|
||||
Lädt die Oberfläche neu und startet die App neu. Fahrzeugprofil, Fahrten und
|
||||
Tankvorgänge sind davon nicht betroffen — die liegen in Home Assistant.
|
||||
</p>
|
||||
@@ -556,7 +572,7 @@ export function Einstellungen({
|
||||
{otaFehler && <p className="dm-fehler">{otaFehler}</p>}
|
||||
</>
|
||||
) : (
|
||||
<p className="dm-fussnote">
|
||||
<p className="dm-erklaerung">
|
||||
{otaBuendel
|
||||
? `Diese App ist aktuell (${eigeneVersion() ?? "unbekannte Version"}).`
|
||||
: "Der Server hat gerade kein Update-Bündel hinterlegt."}
|
||||
@@ -590,20 +606,20 @@ export function Einstellungen({
|
||||
{updateInstallierenLaeuft ? (
|
||||
<div className="dm-ladezeile">
|
||||
<span className="dm-spinner" aria-hidden="true" />
|
||||
<span className="dm-fussnote" style={{ margin: 0 }}>
|
||||
<span className="dm-erklaerung" style={{ marginTop: 0 }}>
|
||||
Installiere …
|
||||
</span>
|
||||
</div>
|
||||
) : updateNeustartLaeuft ? (
|
||||
<div className="dm-ladezeile">
|
||||
<span className="dm-spinner" aria-hidden="true" />
|
||||
<span className="dm-fussnote" style={{ margin: 0 }}>
|
||||
<span className="dm-erklaerung" style={{ marginTop: 0 }}>
|
||||
Home Assistant startet neu …
|
||||
</span>
|
||||
</div>
|
||||
) : integrationUpdate?.installiert ? (
|
||||
<>
|
||||
<p className="dm-fussnote">
|
||||
<p className="dm-erklaerung">
|
||||
Version {integrationUpdate.version} installiert - die neue Fassung wird erst nach
|
||||
einem Neustart geladen.
|
||||
</p>
|
||||
@@ -618,7 +634,7 @@ export function Einstellungen({
|
||||
<Werteliste
|
||||
kinder={<Wertzeile label="Verfügbare Version" wert={integrationUpdate.version ?? "?"} />}
|
||||
/>
|
||||
<p className="dm-fussnote">
|
||||
<p className="dm-erklaerung">
|
||||
Lädt die neueste Fassung von Gitea und ersetzt den Integrationsordner. Home Assistant
|
||||
muss danach neu gestartet werden. Fahrzeugprofil, Fahrten und Tankvorgänge sind davon
|
||||
nicht betroffen.
|
||||
@@ -632,13 +648,13 @@ export function Einstellungen({
|
||||
) : updatePruefenLaeuft ? (
|
||||
<div className="dm-ladezeile">
|
||||
<span className="dm-spinner" aria-hidden="true" />
|
||||
<span className="dm-fussnote" style={{ margin: 0 }}>
|
||||
<span className="dm-erklaerung" style={{ marginTop: 0 }}>
|
||||
Prüfe …
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="dm-fussnote">
|
||||
<p className="dm-erklaerung">
|
||||
{integrationUpdate
|
||||
? `Integration ist aktuell (${integrationUpdate.geprueft_am ? datumZeit(integrationUpdate.geprueft_am) : ""}).`
|
||||
: "Noch nicht geprüft."}
|
||||
@@ -658,21 +674,17 @@ export function Einstellungen({
|
||||
!updateNeustartLaeuft && <p className="dm-fehler">{integrationUpdate.fehler}</p>}
|
||||
</Tile>
|
||||
|
||||
{/* Ohne Entsprechung im Panel - und zwar notwendigerweise: das Panel
|
||||
laeuft innerhalb von Home Assistant und kennt keine Serveradresse und
|
||||
kein Token, die man wechseln koennte. Bleibt deshalb bestehen (wie
|
||||
der Willkommensbildschirm). Die beiden reinen Zaehlzeilen darueber
|
||||
(„Fahrten gespeichert"/„Tankvorgaenge gespeichert") sind entfallen -
|
||||
die waren Zusatz ohne Panel-Vorbild. */}
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Zugang</span>
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile label="Fahrten gespeichert" wert={de(fahrten.length)} />
|
||||
<Wertzeile label="Tankvorgänge gespeichert" wert={de(tankvorgaenge.length)} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<ActionButton variant="destructive" onClick={() => void abmelden()}>
|
||||
Verbindung trennen
|
||||
</ActionButton>
|
||||
</div>
|
||||
<ActionButton variant="destructive" onClick={() => void abmelden()}>
|
||||
Verbindung trennen
|
||||
</ActionButton>
|
||||
</Tile>
|
||||
|
||||
{gespeichert && <p className="dm-erfolg">Gespeichert.</p>}
|
||||
|
||||
@@ -11,12 +11,13 @@
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import { ActionButton, Feld, Pill, Tile } from "@audi-dash/ui"
|
||||
import { ActionButton, Feld, Fig, Pill, Tile } from "@audi-dash/ui"
|
||||
|
||||
import type { Fahrt } from "../api"
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { datum, dauer, de, deOderStrich, uhrzeit } from "../format"
|
||||
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
|
||||
import type { SeitenName } from "../navigation"
|
||||
import { Leerzustand, Wertzeile, Werteliste, bestaetigen } from "./bausteine"
|
||||
import { FahrtFelderFormular, fahrtFelderAusFahrt, fahrtFelderAuswerten } from "./FahrtFelder"
|
||||
import { Karte } from "./Karte"
|
||||
|
||||
@@ -28,8 +29,8 @@ function alsDatetimeLocal(iso: string): string {
|
||||
return new Date(d.getTime() - versatz).toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
export function FahrtDetail({ id }: { id: string | undefined }) {
|
||||
const { fahrten } = useDaten()
|
||||
export function FahrtDetail({ id, geheZu }: { id: string | undefined; geheZu: (name: SeitenName) => void }) {
|
||||
const { fahrten, api } = useDaten()
|
||||
const fahrt = fahrten.find((f) => f.trip_id === id)
|
||||
const [bearbeitenOffen, setzeBearbeitenOffen] = useState(false)
|
||||
|
||||
@@ -42,73 +43,31 @@ export function FahrtDetail({ id }: { id: string | undefined }) {
|
||||
)
|
||||
}
|
||||
|
||||
const loeschen = () => {
|
||||
if (!fahrt || !bestaetigen("Fahrt löschen?")) return
|
||||
void api.fahrtLoeschen(fahrt.trip_id)
|
||||
geheZu("trips")
|
||||
}
|
||||
|
||||
if (bearbeitenOffen) {
|
||||
return <FahrtBearbeiten fahrt={fahrt} beiFertig={() => setzeBearbeitenOffen(false)} />
|
||||
}
|
||||
|
||||
const hatPositionen = fahrt.start_lat != null && fahrt.start_lon != null
|
||||
|
||||
/* Aufbau 1:1 wie vTrip(): Karte, dann die randlose Distanz-Kachel mit der
|
||||
52px-Zahl und der Art-Pille darunter, dann eine einzige Wertezeilen-Kachel
|
||||
(Start/Ziel/Start- und Endkilometer/Dauer/Verbrauch) mit „Werte
|
||||
bearbeiten", zuletzt „Fahrt löschen".
|
||||
|
||||
Vorher stand hier eine eigene Kopfzeile mit Datumsbereich und
|
||||
Bearbeiten-Textknopf, die Werte in anderer Reihenfolge und unter anderen
|
||||
Namen („Kilometerstand Beginn/Ende"), eine Zeile „Durchschnitt", die es im
|
||||
Panel nicht gibt, zwei zusätzliche Erklärabsätze und Start/Ziel erst ganz
|
||||
unten unter der Karte. */
|
||||
return (
|
||||
<>
|
||||
<Tile>
|
||||
<div className="dm-abschnitt" style={{ marginBottom: 0 }}>
|
||||
<span className="ads-eyebrow">
|
||||
{datum(fahrt.ts_start)} · {uhrzeit(fahrt.ts_start)} bis {uhrzeit(fahrt.ts_end)} Uhr
|
||||
</span>
|
||||
<button type="button" className="dm-textknopf" onClick={() => setzeBearbeitenOffen(true)}>
|
||||
Bearbeiten
|
||||
</button>
|
||||
</div>
|
||||
<div className="dm-detail__kopf">
|
||||
<div className="dm-detail__zahl dm-detail__zahl--gross">
|
||||
{deOderStrich(fahrt.distance_km, 1)}
|
||||
<span className="dm-detail__einheit">km</span>
|
||||
</div>
|
||||
<div className="dm-detail__marken">
|
||||
<Pill>{fahrt.art === "arbeitsweg" ? "Arbeitsweg" : "Privat"}</Pill>
|
||||
{fahrt.status === "offen" && <Pill>noch offen</Pill>}
|
||||
{fahrt.source === "manual" && <Pill>selbst eingetragen</Pill>}
|
||||
</div>
|
||||
</div>
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile label="Dauer" wert={dauer(fahrt.duration_s)} />
|
||||
<Wertzeile
|
||||
label="Kilometerstand Beginn"
|
||||
wert={fahrt.odo_start != null ? `${de(fahrt.odo_start)} km` : "—"}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Kilometerstand Ende"
|
||||
wert={fahrt.odo_end != null ? `${de(fahrt.odo_end)} km` : "—"}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Durchschnitt"
|
||||
wert={
|
||||
fahrt.distance_km != null && fahrt.duration_s > 0
|
||||
? `${de((fahrt.distance_km / fahrt.duration_s) * 3600, 1)} km/h`
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Verbrauch"
|
||||
wert={fahrt.verbrauch_l_100km != null ? `${de(fahrt.verbrauch_l_100km, 1)} l/100 km` : "liegt nicht vor"}
|
||||
zusatz="Näherung aus Tankfüllstand"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Tile>
|
||||
|
||||
{fahrt.status === "offen" && (
|
||||
<p className="dm-fussnote">
|
||||
Diese Fahrt wartet noch auf den Kilometerstand. Das Fahrzeug meldet ihn oft erst bei der
|
||||
nächsten Fahrt — die Strecke wird dann automatisch ergänzt.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Strecke</span>
|
||||
<Tile className="dm-kartenkachel">
|
||||
{hatPositionen ? (
|
||||
<Karte
|
||||
start={{ lat: fahrt.start_lat!, lon: fahrt.start_lon! }}
|
||||
@@ -123,6 +82,19 @@ export function FahrtDetail({ id }: { id: string | undefined }) {
|
||||
text="Das Fahrzeug liefert derzeit keine Positionsdaten. Sobald es das tut, erscheint hier die gefahrene Strecke auf der Karte."
|
||||
/>
|
||||
)}
|
||||
</Tile>
|
||||
|
||||
<Tile variant="flat" className="dm-distanzkachel">
|
||||
<span className="ads-eyebrow">Distanz</span>
|
||||
<div style={{ marginTop: "10px" }}>
|
||||
<Fig value={deOderStrich(fahrt.distance_km)} unit="km" size={52} />
|
||||
</div>
|
||||
<div style={{ marginTop: "15px" }}>
|
||||
<Pill>{fahrt.art === "arbeitsweg" ? "Arbeitsweg" : "Privat"}</Pill>
|
||||
</div>
|
||||
</Tile>
|
||||
|
||||
<Tile>
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
@@ -136,16 +108,37 @@ export function FahrtDetail({ id }: { id: string | undefined }) {
|
||||
wert={`${datum(fahrt.ts_end)} · ${uhrzeit(fahrt.ts_end)} Uhr`}
|
||||
zusatz={fahrt.end_address ?? "unbekannt"}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Startkilometer"
|
||||
wert={fahrt.odo_start != null ? `${de(fahrt.odo_start)} km` : "noch offen"}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Endkilometer"
|
||||
wert={fahrt.odo_end != null ? `${de(fahrt.odo_end)} km` : "noch offen"}
|
||||
/>
|
||||
<Wertzeile label="Dauer" wert={dauer(fahrt.duration_s)} />
|
||||
<Wertzeile
|
||||
label="Verbrauch"
|
||||
wert={
|
||||
fahrt.verbrauch_l_100km != null
|
||||
? `${de(fahrt.verbrauch_l_100km, 1)} l/100 km`
|
||||
: "liegt nicht vor"
|
||||
}
|
||||
zusatz="Näherung aus Tankfüllstand"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<ActionButton onClick={() => setzeBearbeitenOffen(true)}>Werte bearbeiten</ActionButton>
|
||||
</Tile>
|
||||
|
||||
<ActionButton variant="destructive" onClick={() => void loeschen()}>
|
||||
Fahrt löschen
|
||||
</ActionButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Bearbeiten-Formular für eine bestehende Fahrt - dieselbe Feldgruppe wie
|
||||
beim Neuanlegen (FahrtFelder.tsx), vorbelegt mit den aktuellen Werten. */
|
||||
function FahrtBearbeiten({ fahrt, beiFertig }: { fahrt: Fahrt; beiFertig: () => void }) {
|
||||
const { api } = useDaten()
|
||||
const [start, setzeStart] = useState(alsDatetimeLocal(fahrt.ts_start))
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import { Accordion, ActionButton, Feld, Pill, Tile } from "@audi-dash/ui"
|
||||
import { Accordion, ActionButton, Feld, Tile } from "@audi-dash/ui"
|
||||
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { nachJahrUndMonat } from "../daten/statistik"
|
||||
import { datum, dauer, de, deOderStrich, monatJahr, summe, uhrzeit } from "../format"
|
||||
import { datum, de, summe } from "../format"
|
||||
import type { SeitenName } from "../navigation"
|
||||
import { Leerzustand } from "./bausteine"
|
||||
import { Blattzeile, Leerzustand } from "./bausteine"
|
||||
import { FahrtFelderFormular, LEERE_FAHRT_FELDER, fahrtFelderAuswerten } from "./FahrtFelder"
|
||||
import type { FahrtFelderEingabe } from "./FahrtFelder"
|
||||
import { Zeilenmenue } from "./Zeilenmenue"
|
||||
@@ -28,88 +28,104 @@ function strecke(fahrt: { start_address?: string | null; end_address?: string |
|
||||
return `${a} → ${b}`
|
||||
}
|
||||
|
||||
/** artText() im Panel: erster Buchstabe groß, sonst unverändert. */
|
||||
function artText(art: string | null | undefined): string {
|
||||
return art ? art.charAt(0).toUpperCase() + art.slice(1) : ""
|
||||
}
|
||||
|
||||
export function Fahrten({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
|
||||
const { fahrten, api } = useDaten()
|
||||
const [formularOffen, setzeFormularOffen] = useState(false)
|
||||
|
||||
const gruppen = nachJahrUndMonat(fahrten, (f) => f.ts_start)
|
||||
|
||||
/* Aufbau 1:1 wie vTrips(): eine einzige Kachel um alle Jahre, die
|
||||
Fahrtenzahl steht in der Jahreszeile (nicht als eigene Überschrift über
|
||||
der Liste), die Monatszeile nennt nur den Monat, und das Formular steht
|
||||
unter der Liste statt darüber. Die Zeilen selbst sind Blattzeilen mit der
|
||||
Strecke als Hauptangabe und "Datum · Art" darunter — hier standen bisher
|
||||
die Kilometer vorn und alles andere in einer langen Fußzeile, dazu zwei
|
||||
Pillen (Arbeitsweg/offen), die es im Panel nicht gibt: die Art steht dort
|
||||
im Zusatz der Beschriftung, der Status im Zusatz des Werts. */
|
||||
return (
|
||||
<>
|
||||
<div className="dm-abschnitt">
|
||||
<span className="ads-eyebrow">
|
||||
{fahrten.length === 0
|
||||
? "Keine Fahrten"
|
||||
: `${de(fahrten.length)} Fahrt${fahrten.length === 1 ? "" : "en"}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="dm-textknopf"
|
||||
onClick={() => setzeFormularOffen((offen) => !offen)}
|
||||
>
|
||||
{formularOffen ? "Abbrechen" : "Fahrt eintragen"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{formularOffen && <FahrtFormular beiFertig={() => setzeFormularOffen(false)} />}
|
||||
|
||||
{fahrten.length === 0 && !formularOffen && (
|
||||
<Leerzustand
|
||||
titel="Noch keine Fahrten"
|
||||
text="Die App erkennt Fahrten automatisch, sobald sich das Fahrzeug meldet. Bis dahin kannst du Fahrten auch selbst eintragen."
|
||||
aktion={{ beschriftung: "Fahrt eintragen", beiKlick: () => setzeFormularOffen(true) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{gruppen.map((jahr) => (
|
||||
<Accordion
|
||||
key={jahr.jahr}
|
||||
title={String(jahr.jahr)}
|
||||
summary={`${de(summe(jahr.eintraege, (f) => f.distance_km ?? 0))} km`}
|
||||
defaultOpen={jahr === gruppen[0]}
|
||||
>
|
||||
{jahr.monate.map((monat) => (
|
||||
{gruppen.length === 0 ? (
|
||||
<Tile>
|
||||
<Leerzustand
|
||||
titel="Noch keine Fahrten"
|
||||
text="Fahrten werden erkannt, sobald der Motor gestartet wird. Bis dahin lässt sich jede Fahrt von Hand erfassen."
|
||||
/>
|
||||
</Tile>
|
||||
) : (
|
||||
<Tile>
|
||||
{gruppen.map((jahr) => (
|
||||
<Accordion
|
||||
key={monat.schluessel}
|
||||
level={2}
|
||||
title={monatJahr(new Date(monat.jahr, monat.monat, 1))}
|
||||
summary={`${de(summe(monat.eintraege, (f) => f.distance_km ?? 0))} km`}
|
||||
defaultOpen={monat === jahr.monate[0]}
|
||||
key={jahr.jahr}
|
||||
title={String(jahr.jahr)}
|
||||
summary={
|
||||
<>
|
||||
{de(summe(jahr.eintraege, (f) => f.distance_km ?? 0))} km
|
||||
<br />
|
||||
{jahr.eintraege.length} Fahrt{jahr.eintraege.length === 1 ? "" : "en"}
|
||||
</>
|
||||
}
|
||||
defaultOpen={jahr === gruppen[0]}
|
||||
>
|
||||
{monat.eintraege.map((fahrt) => (
|
||||
<Zeilenmenue
|
||||
key={fahrt.trip_id}
|
||||
loeschFrage={`Fahrt vom ${datum(fahrt.ts_start)} löschen?`}
|
||||
beiLoeschen={() => void api.fahrtLoeschen(fahrt.trip_id)}
|
||||
kinder={
|
||||
<button
|
||||
type="button"
|
||||
className="dm-listenzeile"
|
||||
onClick={() => geheZu("trip", fahrt.trip_id)}
|
||||
>
|
||||
<span className="dm-listenzeile__haupt">
|
||||
<span className="dm-listenzeile__titel">
|
||||
{deOderStrich(fahrt.distance_km, 1)} km
|
||||
</span>
|
||||
<span className="dm-listenzeile__unter">
|
||||
{strecke(fahrt)} · {datum(fahrt.ts_start)} · {uhrzeit(fahrt.ts_start)} Uhr ·{" "}
|
||||
{dauer(fahrt.duration_s)}
|
||||
</span>
|
||||
</span>
|
||||
{fahrt.art === "arbeitsweg" && <Pill>Arbeitsweg</Pill>}
|
||||
{fahrt.status === "offen" && <Pill>offen</Pill>}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
{jahr.monate.map((monat) => (
|
||||
<Accordion
|
||||
key={monat.schluessel}
|
||||
level={2}
|
||||
title={monatName(monat.jahr, monat.monat)}
|
||||
summary={`${de(summe(monat.eintraege, (f) => f.distance_km ?? 0))} km`}
|
||||
defaultOpen={monat === jahr.monate[0]}
|
||||
>
|
||||
{monat.eintraege.map((fahrt, i) => (
|
||||
<Zeilenmenue
|
||||
key={fahrt.trip_id}
|
||||
loeschFrage={`Fahrt vom ${datum(fahrt.ts_start)} löschen?`}
|
||||
beiLoeschen={() => void api.fahrtLoeschen(fahrt.trip_id)}
|
||||
kinder={
|
||||
<Blattzeile
|
||||
label={strecke(fahrt)}
|
||||
labelZusatz={`${datum(fahrt.ts_start)} · ${artText(fahrt.art)}`}
|
||||
wert={
|
||||
fahrt.distance_km != null ? `${de(fahrt.distance_km)} km` : "offen"
|
||||
}
|
||||
wertZusatz={
|
||||
fahrt.verbrauch_l_100km != null
|
||||
? `${de(fahrt.verbrauch_l_100km, 1)} l/100 km`
|
||||
: fahrt.status
|
||||
}
|
||||
beiKlick={() => geheZu("trip", fahrt.trip_id)}
|
||||
letzte={i === monat.eintraege.length - 1}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Accordion>
|
||||
))}
|
||||
</Accordion>
|
||||
))}
|
||||
</Accordion>
|
||||
))}
|
||||
</Tile>
|
||||
)}
|
||||
|
||||
{formularOffen && <FahrtFormular beiFertig={() => setzeFormularOffen(false)} />}
|
||||
|
||||
<ActionButton
|
||||
variant={formularOffen ? "default" : "primary"}
|
||||
onClick={() => setzeFormularOffen((offen) => !offen)}
|
||||
>
|
||||
{formularOffen ? "Abbrechen" : "Fahrt erfassen"}
|
||||
</ActionButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Monatsname allein, wie im Panel (`toLocaleDateString({month:"long"})`). */
|
||||
function monatName(jahr: number, monat: number): string {
|
||||
return new Date(jahr, monat, 1).toLocaleDateString("de-DE", { month: "long" })
|
||||
}
|
||||
|
||||
/** Manuelles Eintragen — für Fahrten, die die automatische Erkennung verpasst hat.
|
||||
Dieselben Felder wie beim Bearbeiten einer bestehenden Fahrt (siehe
|
||||
FahrtFelderFormular in FahrtDetail.tsx) - beide teilen sich deshalb die
|
||||
@@ -149,7 +165,7 @@ function FahrtFormular({ beiFertig }: { beiFertig: () => void }) {
|
||||
|
||||
return (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Fahrt eintragen</span>
|
||||
<span className="ads-eyebrow">Neue Fahrt</span>
|
||||
<Feld label="Beginn">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
@@ -172,12 +188,10 @@ function FahrtFormular({ beiFertig }: { beiFertig: () => void }) {
|
||||
{fehler}
|
||||
</p>
|
||||
)}
|
||||
<p className="dm-fussnote">
|
||||
Die Strecke ergänzt die App selbst, sobald sie den Kilometerstand zu Beginn und Ende der
|
||||
Fahrt kennt.
|
||||
</p>
|
||||
{/* Ohne Erklärabsatz: das Panel führt im Neuanlage-Formular nur die
|
||||
Felder und die Knöpfe. */}
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
|
||||
<ActionButton variant="primary" onClick={() => void speichern()} disabled={laeuft}>
|
||||
{laeuft ? "Speichere …" : "Speichern"}
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
/**
|
||||
* Fahrzeugdaten: Identität, technisches Datenblatt, Ausstattung.
|
||||
* Vorlage: `vIdent()` im alten Panel. Rein anzeigend — die Inhalte pflegt
|
||||
* man im Profil, nicht hier.
|
||||
* Vorlage: `vIdent()`/`vIdentBearbeiten()` im alten Panel.
|
||||
*
|
||||
* „Daten bearbeiten" öffnet dieselbe Bearbeitungsansicht wie dort: die Werte
|
||||
* des Datenblatts und die Ausstattungslisten, jeweils mit „×" zum Entfernen
|
||||
* und „+ Position hinzufügen". Die Kopfdaten (Modell, Kennzeichen, Ausführung,
|
||||
* Erstzulassung, FIN) bleiben bewusst außen vor — die pflegt „Fahrzeug
|
||||
* einrichten" in den Einstellungen.
|
||||
*/
|
||||
|
||||
import { Accordion, Tile } from "@audi-dash/ui"
|
||||
import { useState } from "react"
|
||||
|
||||
import { ActionButton, Feld, Tile } from "@audi-dash/ui"
|
||||
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { datum, de } from "../format"
|
||||
import { datum } from "../format"
|
||||
import { Wertzeile, Werteliste } from "./bausteine"
|
||||
|
||||
interface TechnikGruppe {
|
||||
@@ -23,6 +30,7 @@ interface AusstattungGruppe {
|
||||
|
||||
export function Fahrzeugdaten() {
|
||||
const { fahrzeug, einstellungen } = useDaten()
|
||||
const [bearbeiten, setzeBearbeiten] = useState(false)
|
||||
if (!fahrzeug || !einstellungen) return null
|
||||
|
||||
const technik = Array.isArray(fahrzeug.technik)
|
||||
@@ -32,72 +40,257 @@ export function Fahrzeugdaten() {
|
||||
? (fahrzeug.ausstattung as AusstattungGruppe[])
|
||||
: []
|
||||
|
||||
if (bearbeiten) {
|
||||
return (
|
||||
<DatenBearbeiten
|
||||
technik={technik}
|
||||
ausstattung={ausstattung}
|
||||
beiFertig={() => setzeBearbeiten(false)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/* Aufbau 1:1 wie vIdent(): eine Kachel je Technik- und je
|
||||
Ausstattungsgruppe, alle Angaben unmittelbar sichtbar. Bis 2026-08-30
|
||||
steckten sie hier in zugeklappten Aufklapp-Elementen — auf dem Bildschirm
|
||||
blieben von über 3 000 Zeichen Datenblatt nur die Gruppennamen übrig.
|
||||
Auch die Kopfkachel wich ab: sie hieß „Identität" statt „Fahrzeug", zeigte
|
||||
„Hauptuntersuchung fällig" und „Kilometerstand", die das Panel hier nicht
|
||||
führt, und ließ „Motor und Leistung" sowie „Zulassung" weg. */
|
||||
return (
|
||||
<>
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Identität</span>
|
||||
<span className="ads-eyebrow">Fahrzeug</span>
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile label="Modell" wert={einstellungen.fahrzeugtitel || "—"} />
|
||||
<Wertzeile label="Ausführung" wert={einstellungen.ausfuehrung || fahrzeug.details || "—"} />
|
||||
<Wertzeile label="Fahrgestellnummer" wert={fahrzeug.fin || "—"} />
|
||||
<Wertzeile label="Kennzeichen" wert={einstellungen.kennzeichen || "—"} />
|
||||
<Wertzeile label="Modell" wert={einstellungen.fahrzeugtitel || "–"} />
|
||||
<Wertzeile
|
||||
label="Ausführung"
|
||||
wert={einstellungen.ausfuehrung || "noch offen"}
|
||||
/>
|
||||
<Wertzeile label="Fahrgestellnummer" wert={fahrzeug.fin || "–"} />
|
||||
<Wertzeile label="Motor und Leistung" wert={fahrzeug.details || "–"} />
|
||||
<Wertzeile
|
||||
label="Erstzulassung"
|
||||
wert={fahrzeug.erstzulassung ? datum(fahrzeug.erstzulassung) : "—"}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Hauptuntersuchung fällig"
|
||||
wert={fahrzeug.hu ? datum(fahrzeug.hu) : "—"}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Kilometerstand"
|
||||
wert={fahrzeug.odoBekannt ? `${de(fahrzeug.odo)} km` : "—"}
|
||||
wert={fahrzeug.erstzulassung ? datum(fahrzeug.erstzulassung) : "–"}
|
||||
/>
|
||||
<Wertzeile label="Kennzeichen" wert={einstellungen.kennzeichen || "–"} />
|
||||
<Wertzeile label="Zulassung" wert="Deutschland" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Tile>
|
||||
|
||||
{technik.length > 0 && (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Technische Daten</span>
|
||||
{technik.map((gruppe, i) => (
|
||||
<Accordion key={gruppe.gruppe ?? i} title={gruppe.gruppe ?? "Weitere Angaben"}>
|
||||
<Werteliste
|
||||
kinder={(gruppe.posten ?? []).map(([bezeichnung, wert]) => (
|
||||
<Wertzeile key={bezeichnung} label={bezeichnung} wert={wert} />
|
||||
))}
|
||||
{technik.map((gruppe, i) => (
|
||||
<Tile key={gruppe.gruppe ?? i}>
|
||||
<span className="ads-eyebrow">{gruppe.gruppe ?? "Weitere Angaben"}</span>
|
||||
<Werteliste
|
||||
kinder={(gruppe.posten ?? []).map(([bezeichnung, wert]) => (
|
||||
<Wertzeile
|
||||
key={bezeichnung}
|
||||
label={bezeichnung}
|
||||
wert={wert ? wert : <span className="dm-offen">noch offen</span>}
|
||||
/>
|
||||
</Accordion>
|
||||
))}
|
||||
))}
|
||||
/>
|
||||
</Tile>
|
||||
)}
|
||||
))}
|
||||
|
||||
{ausstattung.length > 0 && (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Ausstattung</span>
|
||||
{ausstattung.map((gruppe, i) => (
|
||||
<Accordion
|
||||
key={gruppe.gruppe ?? i}
|
||||
title={gruppe.gruppe ?? "Weitere Ausstattung"}
|
||||
summary={`${(gruppe.sonder?.length ?? 0) + (gruppe.serie?.length ?? 0)}`}
|
||||
>
|
||||
<ul className="dm-liste">
|
||||
{(gruppe.sonder ?? []).map((posten) => (
|
||||
<li key={posten}>{posten}</li>
|
||||
))}
|
||||
{(gruppe.serie ?? []).map((posten) => (
|
||||
<li key={posten} className="dm-liste__serie">
|
||||
{posten}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Accordion>
|
||||
))}
|
||||
{ausstattung.map((gruppe, i) => (
|
||||
<Tile key={gruppe.gruppe ?? i}>
|
||||
<span className="ads-eyebrow">{gruppe.gruppe ?? "Weitere Ausstattung"}</span>
|
||||
<Werteliste
|
||||
kinder={(gruppe.sonder ?? []).map((posten) => (
|
||||
<Wertzeile key={posten} label={posten} wert="" />
|
||||
))}
|
||||
/>
|
||||
</Tile>
|
||||
)}
|
||||
))}
|
||||
|
||||
<ActionButton onClick={() => setzeBearbeiten(true)}>Daten bearbeiten</ActionButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Bearbeitungsansicht, Zuschnitt wie `vIdentBearbeiten()` im Panel. */
|
||||
function DatenBearbeiten({
|
||||
technik,
|
||||
ausstattung,
|
||||
beiFertig,
|
||||
}: {
|
||||
technik: TechnikGruppe[]
|
||||
ausstattung: AusstattungGruppe[]
|
||||
beiFertig: () => void
|
||||
}) {
|
||||
const { fahrzeug, profilSpeichern } = useDaten()
|
||||
const [entwurfTechnik, setzeEntwurfTechnik] = useState<TechnikGruppe[]>(() =>
|
||||
technik.map((g) => ({ ...g, posten: (g.posten ?? []).map((p) => [p[0], p[1]] as [string, string]) })),
|
||||
)
|
||||
const [entwurfAusstattung, setzeEntwurfAusstattung] = useState<AusstattungGruppe[]>(() =>
|
||||
ausstattung.map((g) => ({ ...g, sonder: [...(g.sonder ?? [])] })),
|
||||
)
|
||||
const [laeuft, setzeLaeuft] = useState(false)
|
||||
|
||||
if (!fahrzeug) return null
|
||||
|
||||
/* Das Panel speichert jedes Feld sofort; hier folgt der Bildschirm dem
|
||||
Muster der übrigen Formulare dieser App — Entwurf plus einem Knopf, der
|
||||
schreibt und zurückführt (Paritätsregel: gleiches Verhalten, eigenes
|
||||
Idiom). Bei rund 50 Feldern spart das ebenso viele Schreibvorgänge auf
|
||||
das gesamte Profil. */
|
||||
const speichern = async () => {
|
||||
setzeLaeuft(true)
|
||||
try {
|
||||
await profilSpeichern({
|
||||
fahrzeug: {
|
||||
...fahrzeug,
|
||||
technik: entwurfTechnik as unknown as typeof fahrzeug.technik,
|
||||
ausstattung: entwurfAusstattung as unknown as typeof fahrzeug.ausstattung,
|
||||
},
|
||||
})
|
||||
beiFertig()
|
||||
} finally {
|
||||
setzeLaeuft(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Daten bearbeiten</span>
|
||||
<span className="ads-eyebrow" style={{ marginTop: "10px" }}>
|
||||
Hier stehen das technische Datenblatt und die Ausstattung. Modell, Kennzeichen,
|
||||
Ausführung, Erstzulassung und Fahrgestellnummer pflegst du unter Einstellungen → Fahrzeug
|
||||
einrichten.
|
||||
</span>
|
||||
</Tile>
|
||||
|
||||
{entwurfTechnik.map((gruppe, gi) => (
|
||||
<Tile key={gruppe.gruppe ?? gi}>
|
||||
<span className="ads-eyebrow">{gruppe.gruppe ?? "Weitere Angaben"}</span>
|
||||
{(gruppe.posten ?? []).map((posten, i) => (
|
||||
<Feld key={i} label={posten[0]} last={i === (gruppe.posten?.length ?? 0) - 1}>
|
||||
<span className="dm-selbst-zeile">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
placeholder="noch offen"
|
||||
value={posten[1]}
|
||||
onChange={(e) => {
|
||||
const wert = e.target.value
|
||||
setzeEntwurfTechnik((alt) =>
|
||||
alt.map((g, gj) =>
|
||||
gj !== gi
|
||||
? g
|
||||
: {
|
||||
...g,
|
||||
posten: (g.posten ?? []).map((p, pj) =>
|
||||
pj === i ? ([p[0], wert] as [string, string]) : p,
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="dm-zeile-loeschen"
|
||||
aria-label={`${posten[0]} entfernen`}
|
||||
onClick={() =>
|
||||
setzeEntwurfTechnik((alt) =>
|
||||
alt.map((g, gj) =>
|
||||
gj !== gi
|
||||
? g
|
||||
: { ...g, posten: (g.posten ?? []).filter((_, pj) => pj !== i) },
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</Feld>
|
||||
))}
|
||||
{(gruppe.posten?.length ?? 0) === 0 && (
|
||||
<span className="ads-eyebrow" style={{ marginTop: "8px" }}>
|
||||
Keine Angaben
|
||||
</span>
|
||||
)}
|
||||
<ActionButton
|
||||
onClick={() =>
|
||||
setzeEntwurfTechnik((alt) =>
|
||||
alt.map((g, gj) =>
|
||||
gj !== gi
|
||||
? g
|
||||
: { ...g, posten: [...(g.posten ?? []), ["Neue Angabe", ""] as [string, string]] },
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
+ Position hinzufügen
|
||||
</ActionButton>
|
||||
</Tile>
|
||||
))}
|
||||
|
||||
{entwurfAusstattung.map((gruppe, gi) => (
|
||||
<Tile key={gruppe.gruppe ?? gi}>
|
||||
<span className="ads-eyebrow">{gruppe.gruppe ?? "Weitere Ausstattung"}</span>
|
||||
{(gruppe.sonder ?? []).map((posten, i) => (
|
||||
<Feld key={i} label={`Position ${i + 1}`} last={i === (gruppe.sonder?.length ?? 0) - 1}>
|
||||
<span className="dm-selbst-zeile">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
value={posten}
|
||||
onChange={(e) => {
|
||||
const wert = e.target.value
|
||||
setzeEntwurfAusstattung((alt) =>
|
||||
alt.map((g, gj) =>
|
||||
gj !== gi
|
||||
? g
|
||||
: { ...g, sonder: (g.sonder ?? []).map((s, sj) => (sj === i ? wert : s)) },
|
||||
),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="dm-zeile-loeschen"
|
||||
aria-label={`Position ${i + 1} entfernen`}
|
||||
onClick={() =>
|
||||
setzeEntwurfAusstattung((alt) =>
|
||||
alt.map((g, gj) =>
|
||||
gj !== gi
|
||||
? g
|
||||
: { ...g, sonder: (g.sonder ?? []).filter((_, sj) => sj !== i) },
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</Feld>
|
||||
))}
|
||||
{(gruppe.sonder?.length ?? 0) === 0 && (
|
||||
<span className="ads-eyebrow" style={{ marginTop: "8px" }}>
|
||||
Keine Ausstattung hinterlegt
|
||||
</span>
|
||||
)}
|
||||
<ActionButton
|
||||
onClick={() =>
|
||||
setzeEntwurfAusstattung((alt) =>
|
||||
alt.map((g, gj) => (gj !== gi ? g : { ...g, sonder: [...(g.sonder ?? []), ""] })),
|
||||
)
|
||||
}
|
||||
>
|
||||
+ Position hinzufügen
|
||||
</ActionButton>
|
||||
</Tile>
|
||||
))}
|
||||
|
||||
<ActionButton variant="primary" onClick={() => void speichern()} disabled={laeuft}>
|
||||
{laeuft ? "Speichere …" : "Fertig"}
|
||||
</ActionButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ export function LiveFahrt() {
|
||||
<>
|
||||
<Wertzeile
|
||||
label="Bisher gefahren"
|
||||
wert={live.streckeKm != null ? `${de(live.streckeKm, 1)} km` : "—"}
|
||||
wert={live.streckeKm != null ? `${de(live.streckeKm, 1)} km` : "–"}
|
||||
/>
|
||||
<Wertzeile label="Unterwegs seit" wert={dauer(live.dauerS)} />
|
||||
</>
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useState } from "react"
|
||||
import { Tile } from "@audi-dash/ui"
|
||||
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { naechsterService } from "../daten/service"
|
||||
import { inspektionPrognose, oelwechselPrognose } from "../daten/service"
|
||||
import { datum, de, deOderStrich, eur } from "../format"
|
||||
import type { SeitenName } from "../navigation"
|
||||
import { Wertzeile, Werteliste } from "./bausteine"
|
||||
@@ -34,7 +34,7 @@ interface AusstattungGruppe {
|
||||
}
|
||||
|
||||
export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
|
||||
const { fahrzeug, einstellungen, fahrten } = useDaten()
|
||||
const { fahrzeug, einstellungen } = useDaten()
|
||||
const [bildIndex, setzeBildIndex] = useState(0)
|
||||
if (!fahrzeug || !einstellungen) return null
|
||||
|
||||
@@ -48,13 +48,46 @@ export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) =
|
||||
const positionen = ausstattung.reduce((n, g) => n + (g.sonder?.length ?? 0), 0)
|
||||
|
||||
const buch = (fahrzeug.service["buch"] ?? []) as ServicebuchEintrag[]
|
||||
// Vereinfacht gegenüber vAudi() im Panel, das hier die volle
|
||||
// Drei-Termine-Liste (Ölwechsel/Inspektion/Hauptuntersuchung) zeigt: nur
|
||||
// der zeitlich nächste Termin (dieselbe Funktion, die schon die
|
||||
// Übersicht-Kachel speist). Die volle Aufschlüsselung bleibt der
|
||||
// Service-Seite selbst vorbehalten — bewusste Vereinfachung, nicht
|
||||
// vergessen, siehe AGENTS.md.
|
||||
const service = naechsterService(fahrzeug, buch, fahrten)
|
||||
|
||||
/* Alle drei Termine, wie vAudi() im Panel. Bis 2026-08-30 stand hier nur
|
||||
der zeitlich nächste (die Vereinfachung aus Kapitel 2 des ersten Audits) —
|
||||
Inspektion und Hauptuntersuchung fehlten damit ganz.
|
||||
|
||||
Der Wert ist bewusst die reale Fahrzeugmeldung (Datum plus „in X km"),
|
||||
nicht die App-Prognose; fehlt sie, bleibt ein Strich. Die eigene Prognose
|
||||
steht klein unter dem Namen (prognoseZeile() dort). */
|
||||
const oelProg = oelwechselPrognose(fahrzeug, buch)
|
||||
const inspProg = inspektionPrognose(fahrzeug, buch)
|
||||
const termine: { art: string; prognose: string | null; haupt: string; neben: string | null }[] = [
|
||||
{
|
||||
art: "Ölwechsel",
|
||||
prognose: oelProg
|
||||
? `${de(oelProg.restKm)} km, voraussichtlich am ${datum(oelProg.datum)}`
|
||||
: null,
|
||||
haupt: fahrzeug.oelwechselFaelligTs ? datum(fahrzeug.oelwechselFaelligTs) : "–",
|
||||
neben:
|
||||
fahrzeug.oelwechselFaelligKm != null
|
||||
? `in ${de(Math.abs(fahrzeug.oelwechselFaelligKm))} km`
|
||||
: null,
|
||||
},
|
||||
{
|
||||
art: "Inspektion",
|
||||
prognose: inspProg ? `voraussichtlich am ${datum(inspProg.datum)}` : null,
|
||||
haupt: fahrzeug.inspektionFaelligTs ? datum(fahrzeug.inspektionFaelligTs) : "–",
|
||||
neben:
|
||||
fahrzeug.inspektionFaelligKm != null
|
||||
? `in ${de(Math.abs(fahrzeug.inspektionFaelligKm))} km`
|
||||
: null,
|
||||
},
|
||||
{
|
||||
// Die Hauptuntersuchung meldet das Fahrzeug nie — hier zählt allein das
|
||||
// im Profil hinterlegte Datum.
|
||||
art: "Hauptuntersuchung",
|
||||
prognose: null,
|
||||
haupt: fahrzeug.hu ? datum(fahrzeug.hu) : "–",
|
||||
neben: null,
|
||||
},
|
||||
]
|
||||
|
||||
const schild = schildErkennen(einstellungen.fahrzeugtitel)
|
||||
const ausfuehrung = einstellungen.ausfuehrung.trim()
|
||||
@@ -76,7 +109,7 @@ export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) =
|
||||
onClick={() => setzeBildIndex((i) => (i + 1) % BILDPLAETZE.length)}
|
||||
aria-label="Nächstes Bild"
|
||||
>
|
||||
<Bild datei={platz.datei} beschreibung={platz.beschreibung} name={platz.name} />
|
||||
<Bild datei={platz.datei} beschreibung={platz.beschreibung} name={platz.beschreibung} />
|
||||
</button>
|
||||
<div className="dm-galerie-punkte" aria-hidden="true">
|
||||
{BILDPLAETZE.map((b, i) => (
|
||||
@@ -86,7 +119,7 @@ export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) =
|
||||
|
||||
<Tile variant="flat" className="dm-szene__text">
|
||||
<div className="dm-szene__marke">
|
||||
<Typenschild modell={einstellungen.fahrzeugtitel} hoehe={26} />
|
||||
<Typenschild modell={einstellungen.fahrzeugtitel} hoehe={18} />
|
||||
<div className="dm-szene__zusatz">
|
||||
{schild ? (
|
||||
zusatz && <span>{zusatz}</span>
|
||||
@@ -107,9 +140,11 @@ export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) =
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile label="Fahrgestellnummer" wert={fahrzeug.fin || "—"} />
|
||||
<Wertzeile label="Details" wert={fahrzeug.details || "—"} />
|
||||
<Wertzeile label="Erstzulassung" wert={fahrzeug.erstzulassung || "—"} />
|
||||
<Wertzeile label="Fahrgestellnummer" wert={fahrzeug.fin || "–"} />
|
||||
<Wertzeile label="Details" wert={fahrzeug.details || "–"} />
|
||||
{/* Leerer Wert statt Strich, wie vAudi(): das Panel gibt hier
|
||||
den Rohwert aus. */}
|
||||
<Wertzeile label="Erstzulassung" wert={fahrzeug.erstzulassung} />
|
||||
<Wertzeile label="Ausführung" wert={ausfuehrung || "noch offen"} />
|
||||
<Wertzeile label="Ausstattung" wert={`${positionen} Positionen`} />
|
||||
</>
|
||||
@@ -132,11 +167,10 @@ export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) =
|
||||
/>
|
||||
<button type="button" className="dm-wertzeile dm-wertzeile--knopf" onClick={() => geheZu("battverlauf")}>
|
||||
<dt>Batteriespannung</dt>
|
||||
{/* Ohne Chevron: das Panel gibt der Zeile nur den
|
||||
Zeiger-Cursor, kein eigenes Pfeilsymbol. */}
|
||||
<dd>
|
||||
{fahrzeug.batteriespannung != null ? `${de(fahrzeug.batteriespannung, 1)} V` : "unbekannt"}
|
||||
<span className="dm-wertzeile__chevron" aria-hidden="true">
|
||||
›
|
||||
</span>
|
||||
</dd>
|
||||
</button>
|
||||
</>
|
||||
@@ -148,15 +182,24 @@ export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) =
|
||||
<span className="ads-eyebrow">Service</span>
|
||||
<Werteliste
|
||||
kinder={
|
||||
service ? (
|
||||
termine.map((t) => (
|
||||
<Wertzeile
|
||||
label={service.art}
|
||||
wert={service.restKm !== null ? `${de(service.restKm)} km` : datum(service.datum)}
|
||||
zusatz={service.restKm !== null ? `voraussichtlich am ${datum(service.datum)}` : undefined}
|
||||
key={t.art}
|
||||
label={
|
||||
<>
|
||||
{t.art}
|
||||
{t.prognose && (
|
||||
<>
|
||||
<br />
|
||||
<span className="dm-beitragsposten__sf">{t.prognose}</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
wert={t.haupt}
|
||||
{...(t.neben ? { zusatz: t.neben } : {})}
|
||||
/>
|
||||
) : (
|
||||
<Wertzeile label="Nächster Termin" wert="kein Eintrag im Wartungsplan" />
|
||||
)
|
||||
))
|
||||
}
|
||||
/>
|
||||
</Tile>
|
||||
@@ -168,12 +211,12 @@ export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) =
|
||||
<>
|
||||
<Wertzeile
|
||||
label="Versicherung"
|
||||
wert={fahrzeug.versicherung["beitrag"] ? `${eur(fahrzeug.versicherung["beitrag"] as number)} €` : "—"}
|
||||
wert={fahrzeug.versicherung["beitrag"] ? `${eur(fahrzeug.versicherung["beitrag"] as number)} €` : "–"}
|
||||
zusatz="im Jahr"
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Kfz-Steuer"
|
||||
wert={fahrzeug.steuer["betrag"] ? `${eur(fahrzeug.steuer["betrag"] as number)} €` : "—"}
|
||||
wert={fahrzeug.steuer["betrag"] ? `${eur(fahrzeug.steuer["betrag"] as number)} €` : "–"}
|
||||
zusatz={fahrzeug.steuer["zeitraum"] === "jährlich" ? "im Jahr" : (fahrzeug.steuer["zeitraum"] as string)}
|
||||
/>
|
||||
<Wertzeile
|
||||
@@ -192,7 +235,7 @@ export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) =
|
||||
<>
|
||||
<Wertzeile
|
||||
label={`montiert: ${fahrzeug.reifen.aktiv}räder`}
|
||||
wert={(aktiverSatz["mass"] as string) || "—"}
|
||||
wert={(aktiverSatz["mass"] as string) || "–"}
|
||||
/>
|
||||
<Wertzeile label="Laufleistung" wert={deOderStrich(aktiverSatz.km) + " km"} />
|
||||
<Wertzeile
|
||||
|
||||
@@ -491,15 +491,15 @@ function ArchivZeile({ eintrag }: { eintrag: ReifenArchivEintrag }) {
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile label="Größe" wert={eintrag.mass || "—"} />
|
||||
<Wertzeile label="Größe" wert={eintrag.mass || "–"} />
|
||||
<Wertzeile
|
||||
label="Marke und Modell"
|
||||
wert={[eintrag.marke, eintrag.modell].filter(Boolean).join(" ") || "—"}
|
||||
wert={[eintrag.marke, eintrag.modell].filter(Boolean).join(" ") || "–"}
|
||||
/>
|
||||
<Wertzeile label="DOT" wert={eintrag.dot || "—"} />
|
||||
<Wertzeile label="DOT" wert={eintrag.dot || "–"} />
|
||||
<Wertzeile
|
||||
label="Luftdruck"
|
||||
wert={`${eintrag.druck_vorne || "—"} / ${eintrag.druck_hinten || "—"} bar`}
|
||||
wert={`${eintrag.druck_vorne || "–"} / ${eintrag.druck_hinten || "–"} bar`}
|
||||
zusatz="vorne / hinten"
|
||||
/>
|
||||
{Boolean(eintrag.kommentar) && <Wertzeile label="Notiz" wert={eintrag.kommentar!} />}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
import { useRef, useState } from "react"
|
||||
|
||||
import { ActionButton, Feld, Tile } from "@audi-dash/ui"
|
||||
import { ActionButton, Feld, Fig, Tile } from "@audi-dash/ui"
|
||||
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import {
|
||||
@@ -24,7 +24,12 @@ import {
|
||||
import { datum, de, eur, isoTag } from "../format"
|
||||
import type { SeitenName } from "../navigation"
|
||||
import { SymbolInspektion, SymbolOelwechsel } from "../symbole"
|
||||
import { Leerzustand, NaviKachel, Wertzeile, Werteliste, bestaetigen } from "./bausteine"
|
||||
import {
|
||||
Blattzeile,
|
||||
Leerzustand,
|
||||
Zahnradknopf,
|
||||
bestaetigen,
|
||||
} from "./bausteine"
|
||||
import { kalenderDateiLaden } from "./kalender"
|
||||
import { vcardParsen } from "./vcard"
|
||||
|
||||
@@ -172,40 +177,141 @@ export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{prognose && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<ActionButton
|
||||
onClick={() =>
|
||||
kalenderDateiLaden("Ölwechsel", prognose.datum, "Ölwechsel laut eigener Prognose")
|
||||
}
|
||||
>
|
||||
In den Kalender eintragen
|
||||
</ActionButton>
|
||||
</div>
|
||||
)}
|
||||
<p className="dm-fussnote">
|
||||
Die eigene Prognose rechnet ab dem letzten Eintrag der jeweiligen Art im Wartungsplan neu
|
||||
und nutzt die seither tatsächlich gefahrene Strecke. Sie ersetzt die Meldung des
|
||||
Fahrzeugs nicht, sondern ergänzt sie.
|
||||
</p>
|
||||
{/* Kein Kalenderknopf und keine Erklärung in dieser Kachel: das Panel
|
||||
führt beides in "Termin vereinbaren" darunter. */}
|
||||
</Tile>
|
||||
|
||||
<div className="dm-raster">
|
||||
<NaviKachel
|
||||
titel="Werkstatt"
|
||||
wert={autohaus.name || "Noch nicht hinterlegt"}
|
||||
beiKlick={() => geheZu("werkstatt")}
|
||||
/>
|
||||
<NaviKachel
|
||||
titel="Wartungsplan"
|
||||
wert={`${de(buch.length)} Eintrag${buch.length === 1 ? "" : "e"}`}
|
||||
beiKlick={() => geheZu("sbuch")}
|
||||
/>
|
||||
</div>
|
||||
<TerminVereinbaren />
|
||||
|
||||
{/* Statt zweier Navigationskacheln zeigt das Panel Autohaus und
|
||||
Wartungsplan hier vollständig — mit Anschrift, Rufnummer, E-Mail und
|
||||
Kartenlink bzw. der Liste der Einträge. */}
|
||||
<Tile className="dm-kachel--zahnrad">
|
||||
<span className="ads-eyebrow">Autohaus</span>
|
||||
<Zahnradknopf beschriftung="Angaben ändern" beiKlick={() => geheZu("werkstatt")} />
|
||||
<div className="dm-autohaus__name">{autohaus.name ?? ""}</div>
|
||||
<span className="ads-eyebrow" style={{ marginTop: "8px" }}>
|
||||
{autohaus.adresse ?? ""}
|
||||
</span>
|
||||
{autohaus.tel && (
|
||||
<a
|
||||
className="ads-action dm-waehlknopf"
|
||||
href={`tel:${autohaus.tel.replace(/\s/g, "")}`}
|
||||
style={{ marginTop: "16px" }}
|
||||
>
|
||||
{autohaus.tel}
|
||||
</a>
|
||||
)}
|
||||
{autohaus.mail && (
|
||||
<a className="ads-action dm-waehlknopf" href={`mailto:${autohaus.mail}`}>
|
||||
E-Mail schreiben
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
className="ads-action dm-waehlknopf"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
href={`https://maps.google.com/?q=${encodeURIComponent(autohaus.adresse ?? "")}`}
|
||||
>
|
||||
In Karten öffnen
|
||||
</a>
|
||||
</Tile>
|
||||
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Wartungsplan</span>
|
||||
{buch.length > 0 ? (
|
||||
buch.map((eintrag, i) => (
|
||||
<Blattzeile
|
||||
key={`${eintrag.datum}-${i}`}
|
||||
label={eintrag.art ?? ""}
|
||||
labelZusatz={eintrag.werkstatt ?? ""}
|
||||
wert={eintrag.datum ?? ""}
|
||||
wertZusatz={`${de(eintrag.km ?? 0)} km${
|
||||
eintrag.kosten != null ? ` · ${eur(eintrag.kosten)} €` : ""
|
||||
}`}
|
||||
beiKlick={() => geheZu("sbuch", String(i))}
|
||||
letzte={i === buch.length - 1}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Leerzustand
|
||||
titel="Noch keine Einträge"
|
||||
text="Aus dem Wartungsplan berechnet die App die Fälligkeit von Inspektion, Ölwechsel und Hauptuntersuchung. Der erste Eintrag ist die Grundlage für jede Prognose."
|
||||
/>
|
||||
)}
|
||||
<ActionButton variant="primary" onClick={() => geheZu("sbuch", "neu")}>
|
||||
Eintrag hinzufügen
|
||||
</ActionButton>
|
||||
</Tile>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** `Termin vereinbaren` aus vService(): Art wählen, Datum setzen, ICS laden. */
|
||||
function TerminVereinbaren() {
|
||||
const { fahrzeug, profilSpeichern } = useDaten()
|
||||
const [art, setzeArt] = useState("Ölwechsel")
|
||||
const vereinbart = (fahrzeug?.service["vereinbart"] ?? {}) as Record<string, string>
|
||||
const [datumWert, setzeDatumWert] = useState(() => vereinbart[art] ?? "")
|
||||
|
||||
if (!fahrzeug) return null
|
||||
|
||||
const merken = async (neuesDatum: string) => {
|
||||
setzeDatumWert(neuesDatum)
|
||||
await profilSpeichern({
|
||||
fahrzeug: {
|
||||
...fahrzeug,
|
||||
service: { ...fahrzeug.service, vereinbart: { ...vereinbart, [art]: neuesDatum } },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const verwerfen = async () => {
|
||||
setzeDatumWert("")
|
||||
const rest = { ...vereinbart }
|
||||
delete rest[art]
|
||||
await profilSpeichern({
|
||||
fahrzeug: { ...fahrzeug, service: { ...fahrzeug.service, vereinbart: rest } },
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Termin vereinbaren</span>
|
||||
<Feld label="Wofür">
|
||||
<select
|
||||
className="dm-auswahl"
|
||||
value={art}
|
||||
onChange={(e) => {
|
||||
setzeArt(e.target.value)
|
||||
setzeDatumWert(vereinbart[e.target.value] ?? "")
|
||||
}}
|
||||
>
|
||||
{["Ölwechsel", "Inspektion", "Hauptuntersuchung"].map((x) => (
|
||||
<option key={x} value={x}>
|
||||
{x}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Feld>
|
||||
<Feld label="Werkstatttermin am" last>
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
type="date"
|
||||
value={datumWert}
|
||||
onChange={(e) => void merken(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
<ActionButton
|
||||
onClick={() => kalenderDateiLaden(art, datumWert || isoTag(), `${art} in der Werkstatt`)}
|
||||
>
|
||||
In den Kalender übernehmen
|
||||
</ActionButton>
|
||||
{vereinbart[art] && <ActionButton onClick={() => void verwerfen()}>Termin verwerfen</ActionButton>}
|
||||
</Tile>
|
||||
)
|
||||
}
|
||||
|
||||
export function Werkstatt() {
|
||||
const { fahrzeug, profilSpeichern } = useDaten()
|
||||
const autohausRoh = (fahrzeug?.service["autohaus"] ?? {}) as Autohaus
|
||||
@@ -268,7 +374,7 @@ export function Werkstatt() {
|
||||
|
||||
return (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Werkstatt</span>
|
||||
<span className="ads-eyebrow">Angaben zum Autohaus</span>
|
||||
{feld("name", "Name")}
|
||||
{feld("adresse", "Adresse")}
|
||||
{feld("tel", "Telefon")}
|
||||
@@ -296,14 +402,40 @@ export function Werkstatt() {
|
||||
)
|
||||
}
|
||||
|
||||
export function Servicebuch() {
|
||||
/** Arten wie SERVICE_ARTEN im Panel. */
|
||||
const SERVICE_ARTEN = ["Ölwechsel", "Inspektion", "Hauptuntersuchung", "Reparatur", "Sonstiges"]
|
||||
|
||||
/**
|
||||
* Ein einzelner Wartungsplan-Eintrag. Vorlage: `vSbuch()` — dort ist diese
|
||||
* Route immer die Detail-/Bearbeitungsseite genau eines Eintrags; die Liste
|
||||
* steht auf der Service-Seite. Hier war es bis 2026-08-30 umgekehrt: die Route
|
||||
* zeigte die ganze Liste samt Anlegen-Formular, und die Service-Seite nur eine
|
||||
* Navigationskachel mit der Anzahl.
|
||||
*
|
||||
* `id === "neu"` legt einen leeren Entwurf an — im Panel entspricht das dem
|
||||
* Weg über „Eintrag hinzufügen", der dort einen Entwurf erzeugt und sofort auf
|
||||
* diese Seite springt.
|
||||
*/
|
||||
export function Servicebuch({
|
||||
id,
|
||||
geheZu,
|
||||
}: {
|
||||
id?: string | undefined
|
||||
geheZu: (name: SeitenName, id?: string) => void
|
||||
}) {
|
||||
const { fahrzeug, profilSpeichern } = useDaten()
|
||||
const [neuOffen, setzeNeuOffen] = useState(false)
|
||||
const [entwurf, setzeEntwurf] = useState<ServicebuchEintrag>({ datum: isoTag(), art: "Ölwechsel" })
|
||||
const istNeu = id === "neu"
|
||||
const index = istNeu ? -1 : Number(id ?? -1)
|
||||
const buchRoh = (fahrzeug?.service["buch"] ?? []) as ServicebuchEintrag[]
|
||||
const [entwurf, setzeEntwurf] = useState<ServicebuchEintrag>(() =>
|
||||
istNeu
|
||||
? { datum: isoTag(), art: "Ölwechsel", km: fahrzeug?.odo ?? 0, werkstatt: "" }
|
||||
: { ...(buchRoh[index] ?? {}) },
|
||||
)
|
||||
const [laeuft, setzeLaeuft] = useState(false)
|
||||
|
||||
if (!fahrzeug) return null
|
||||
const buch = (fahrzeug.service["buch"] ?? []) as ServicebuchEintrag[]
|
||||
if (!istNeu && !buchRoh[index]) return <Tile>Eintrag nicht gefunden</Tile>
|
||||
|
||||
const schreiben = async (neuesBuch: ServicebuchEintrag[]) => {
|
||||
setzeLaeuft(true)
|
||||
@@ -316,140 +448,112 @@ export function Servicebuch() {
|
||||
}
|
||||
}
|
||||
|
||||
const hinzufuegen = async () => {
|
||||
await schreiben(
|
||||
[...buch, entwurf].sort((a, b) => String(b.datum ?? "").localeCompare(String(a.datum ?? ""))),
|
||||
)
|
||||
setzeNeuOffen(false)
|
||||
setzeEntwurf({ datum: isoTag(), art: "Ölwechsel" })
|
||||
/* Das Panel speichert jedes Feld sofort; diese App folgt ihrem eigenen
|
||||
Muster aus Entwurf plus Speichern-Knopf (Paritätsregel: gleiches
|
||||
Verhalten, eigenes Idiom). */
|
||||
const speichern = async () => {
|
||||
await schreiben(istNeu ? [...buchRoh, entwurf] : buchRoh.map((e, i) => (i === index ? entwurf : e)))
|
||||
geheZu("service")
|
||||
}
|
||||
|
||||
const entfernen = async (index: number) => {
|
||||
const loeschen = async () => {
|
||||
if (!bestaetigen("Diesen Wartungsplan-Eintrag löschen?")) return
|
||||
await schreiben(buch.filter((_, i) => i !== index))
|
||||
await schreiben(buchRoh.filter((_, i) => i !== index))
|
||||
geheZu("service")
|
||||
}
|
||||
|
||||
const setze = (feld: keyof ServicebuchEintrag, wert: unknown) =>
|
||||
setzeEntwurf((alt) => ({ ...alt, [feld]: wert }))
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dm-abschnitt">
|
||||
<span className="ads-eyebrow">{de(buch.length)} Einträge</span>
|
||||
<button
|
||||
type="button"
|
||||
className="dm-textknopf"
|
||||
onClick={() => setzeNeuOffen((offen) => !offen)}
|
||||
>
|
||||
{neuOffen ? "Abbrechen" : "Eintrag hinzufügen"}
|
||||
</button>
|
||||
</div>
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">{entwurf.art ?? "Eintrag"}</span>
|
||||
<div style={{ marginTop: "var(--sp-3)" }}>
|
||||
<Fig value={de(entwurf.km ?? 0)} unit="km" size={40} />
|
||||
</div>
|
||||
<span className="ads-eyebrow" style={{ marginTop: "var(--sp-3)" }}>
|
||||
{entwurf.datum ?? ""} · {entwurf.werkstatt ?? ""}
|
||||
</span>
|
||||
</Tile>
|
||||
|
||||
{neuOffen && (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Neuer Eintrag</span>
|
||||
<Feld label="Datum">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
type="date"
|
||||
value={entwurf.datum ?? ""}
|
||||
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, datum: e.target.value }))}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Art">
|
||||
<select
|
||||
className="dm-auswahl"
|
||||
value={entwurf.art ?? "Ölwechsel"}
|
||||
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, art: e.target.value }))}
|
||||
>
|
||||
<option>Ölwechsel</option>
|
||||
<option>Inspektion</option>
|
||||
<option>Hauptuntersuchung</option>
|
||||
<option>Reparatur</option>
|
||||
<option>Sonstiges</option>
|
||||
</select>
|
||||
</Feld>
|
||||
<Feld label="Kilometerstand" unit="km">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
inputMode="numeric"
|
||||
value={entwurf.km ?? ""}
|
||||
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, km: Number(e.target.value) }))}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Werkstatt">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
value={entwurf.werkstatt ?? ""}
|
||||
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, werkstatt: e.target.value }))}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Kosten" unit="€">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
inputMode="decimal"
|
||||
value={entwurf.kosten ?? ""}
|
||||
onChange={(e) =>
|
||||
setzeEntwurf((alt) => ({
|
||||
...alt,
|
||||
kosten: e.target.value === "" ? null : Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Durchgeführte Arbeiten" last>
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
value={entwurf.arbeiten ?? ""}
|
||||
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, arbeiten: e.target.value }))}
|
||||
/>
|
||||
</Feld>
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<ActionButton onClick={() => void hinzufuegen()} disabled={laeuft}>
|
||||
Speichern
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Tile>
|
||||
)}
|
||||
|
||||
{buch.length === 0 && !neuOffen && (
|
||||
<Leerzustand
|
||||
titel="Wartungsplan ist leer"
|
||||
text="Trag hier ein, was am Fahrzeug gemacht wurde. Aus den Ölwechseln entsteht die eigene Wartungsprognose."
|
||||
aktion={{ beschriftung: "Eintrag hinzufügen", beiKlick: () => setzeNeuOffen(true) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{buch.map((eintrag, index) => (
|
||||
<Tile key={`${eintrag.datum}-${index}`}>
|
||||
<div className="dm-detail__kopf">
|
||||
<div>
|
||||
<span className="ads-eyebrow">{eintrag.datum ? datum(eintrag.datum) : "ohne Datum"}</span>
|
||||
<div style={{ fontSize: 17, fontWeight: 300, marginTop: 4 }}>
|
||||
{eintrag.art ?? "Eintrag"}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="dm-textknopf"
|
||||
onClick={() => void entfernen(index)}
|
||||
disabled={laeuft}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile
|
||||
label="Kilometerstand"
|
||||
wert={eintrag.km != null ? `${de(eintrag.km)} km` : "—"}
|
||||
/>
|
||||
<Wertzeile label="Werkstatt" wert={eintrag.werkstatt || "—"} />
|
||||
<Wertzeile label="Kosten" wert={eintrag.kosten != null ? eur(eintrag.kosten) : "noch offen"} />
|
||||
{eintrag.arbeiten && <Wertzeile label="Durchgeführte Arbeiten" wert={eintrag.arbeiten} />}
|
||||
</>
|
||||
}
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Eintrag</span>
|
||||
<Feld label="Datum">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
type="date"
|
||||
value={entwurf.datum ?? ""}
|
||||
onChange={(e) => setze("datum", e.target.value)}
|
||||
/>
|
||||
</Tile>
|
||||
))}
|
||||
</Feld>
|
||||
<Feld label="Kilometerstand" unit="km">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
inputMode="numeric"
|
||||
value={entwurf.km ?? ""}
|
||||
onChange={(e) => setze("km", Number(e.target.value))}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Art">
|
||||
<select
|
||||
className="dm-auswahl"
|
||||
value={entwurf.art ?? "Ölwechsel"}
|
||||
onChange={(e) => setze("art", e.target.value)}
|
||||
>
|
||||
{!SERVICE_ARTEN.includes(entwurf.art ?? "") && entwurf.art && (
|
||||
<option value={entwurf.art}>{entwurf.art}</option>
|
||||
)}
|
||||
{SERVICE_ARTEN.map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{a}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Feld>
|
||||
<Feld label="Werkstatt">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
value={entwurf.werkstatt ?? ""}
|
||||
onChange={(e) => setze("werkstatt", e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Kosten" unit="€" last>
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
inputMode="decimal"
|
||||
placeholder="noch offen"
|
||||
value={entwurf.kosten ?? ""}
|
||||
onChange={(e) => setze("kosten", e.target.value === "" ? null : Number(e.target.value))}
|
||||
/>
|
||||
</Feld>
|
||||
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
|
||||
Kosten fließen in die Jahresübersicht ein
|
||||
</span>
|
||||
</Tile>
|
||||
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Durchgeführte Arbeiten</span>
|
||||
<textarea
|
||||
className="dm-notiz"
|
||||
rows={7}
|
||||
placeholder="Was wurde gemacht, welche Teile wurden getauscht, was ist aufgefallen"
|
||||
value={entwurf.arbeiten ?? ""}
|
||||
onChange={(e) => setze("arbeiten", e.target.value)}
|
||||
/>
|
||||
<span className="ads-eyebrow" style={{ marginTop: "var(--sp-3)" }}>
|
||||
Beim Verkauf ist diese Liste mehr wert als die Rechnung — sie zeigt, dass gepflegt wurde
|
||||
</span>
|
||||
</Tile>
|
||||
|
||||
<ActionButton variant="primary" onClick={() => void speichern()} disabled={laeuft}>
|
||||
Speichern
|
||||
</ActionButton>
|
||||
{!istNeu && (
|
||||
<ActionButton variant="destructive" onClick={() => void loeschen()} disabled={laeuft}>
|
||||
Eintrag löschen
|
||||
</ActionButton>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,6 +46,13 @@ const CI = {
|
||||
// sonst praktisch unsichtbar.
|
||||
poiCarL:
|
||||
'M9 17.5C9 9.52 15.52 3 23.5 3S38 9.52 38 17.5c0 2.04-.92 4.78-2.23 7.67a90 90 0 0 1-4.84 8.98 170 170 0 0 1-6.98 10.59l-.03.04-.01.01-.41-.29-.4.3-.02-.02-.03-.04a66 66 0 0 1-.56-.8 166 166 0 0 1-6.42-9.8 90 90 0 0 1-4.84-8.97C9.92 22.27 9 19.54 9 17.5m14.5 27-.4.3.4.55.4-.56zm0-.86a132 132 0 0 0 1.77-2.56 170 170 0 0 0 4.8-7.45 89 89 0 0 0 4.79-8.87c1.3-2.89 2.14-5.45 2.14-7.26C37 10.08 30.92 4 23.5 4S10 10.08 10 17.5c0 1.8.83 4.37 2.14 7.26a89 89 0 0 0 4.79 8.87 170 170 0 0 0 6.57 10.01M17.96 7.98A63.3 63.3 0 0 1 29.05 8l.2.02.13.16c.6.7 1.1 1.36 1.46 1.94l1.31 2.08L34 14.31v7.19c0 .83-.67 1.5-1.5 1.5h-2a1.5 1.5 0 0 1-1.5-1.5V21H18v.5c0 .83-.67 1.5-1.5 1.5h-2a1.5 1.5 0 0 1-1.5-1.5v-7.19l1.85-2.11 1.3-2.08c.37-.58.86-1.24 1.47-1.96l.13-.16.2-.02ZM15.48 13 14 14.69V15h3v1h-3v5.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5V20h13v1.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5V16h-3v-1h3v-.31L31.52 13zm15.36-1L30 10.65a16 16 0 0 0-1.25-1.67 62.4 62.4 0 0 0-10.5-.02 17 17 0 0 0-1.25 1.7L16.16 12zM19.3 15h8.42l1.4 1.4-.16.31c-.34.74-.65 1.32-.92 1.72-.14.2-.28.38-.42.5a.92.92 0 0 1-.61.27h-7a.92.92 0 0 1-.6-.27 2.4 2.4 0 0 1-.43-.5c-.27-.4-.58-.98-.92-1.72l-.15-.32zm.42 1-.6.6c.26.57.5.99.69 1.27.11.17.2.27.26.32l.01.01h6.86v-.01c.07-.05.16-.15.27-.32.2-.28.43-.7.7-1.27l-.6-.6h-7.6Z',
|
||||
// poi-car-s: dieselbe Nadel im 24er-Raster. Das Panel schaltet in
|
||||
// fahrzeugMarkerSVG() ab 34px auf die grosse Zeichnung um - unter 34px ist
|
||||
// die kleine gemeint, sie ist fuer diese Groesse gezeichnet.
|
||||
poiCar:
|
||||
'M3.8 8.6C3.8 4.33 7.32.8 11.5.8s7.7 3.53 7.7 7.8c0 1.11-.5 2.58-1.17 4.1a48 48 0 0 1-2.5 4.7 90 90 0 0 1-3.54 5.47l-.06.09-.02.02V23l-.41-.29-.4.29h-.01l-.02-.03-.06-.09a70 70 0 0 1-1.05-1.53 90 90 0 0 1-2.49-3.93 48 48 0 0 1-2.5-4.72C4.29 11.18 3.8 9.71 3.8 8.6m7.7 14.1-.4.29.4.57.4-.57zm0-.87a88 88 0 0 0 3.17-4.94 47 47 0 0 0 2.45-4.6A10.3 10.3 0 0 0 18.2 8.6c0-3.73-3.08-6.8-6.7-6.8a6.82 6.82 0 0 0-6.7 6.8c0 .89.4 2.18 1.08 3.69.67 1.48 1.55 3.1 2.45 4.6a89 89 0 0 0 3.17 4.94M8.77 4h5.47L17 7.35V11a1 1 0 0 1-1 1h-1.5a1 1 0 0 1-1-1h-4a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7.32zm.46 1L7 7.68V8h2v1H7v2h1.5v-1h6v1H16V9h-2V8h2v-.29L13.76 5z',
|
||||
poiCarSil:
|
||||
'M3.8 8.6C3.8 4.33 7.32.8 11.5.8s7.7 3.53 7.7 7.8c0 1.11-.5 2.58-1.17 4.1a48 48 0 0 1-2.5 4.7 90 90 0 0 1-3.54 5.47l-.06.09-.02.02V23l-.41-.29-.4.29h-.01l-.02-.03-.06-.09a70 70 0 0 1-1.05-1.53 90 90 0 0 1-2.49-3.93 48 48 0 0 1-2.5-4.72C4.29 11.18 3.8 9.71 3.8 8.6',
|
||||
poiCarLSil:
|
||||
'M9 17.5C9 9.52 15.52 3 23.5 3S38 9.52 38 17.5c0 2.04-.92 4.78-2.23 7.67a90 90 0 0 1-4.84 8.98 170 170 0 0 1-6.98 10.59l-.03.04-.01.01-.41-.29-.4.3-.02-.02-.03-.04a66 66 0 0 1-.56-.8 166 166 0 0 1-6.42-9.8 90 90 0 0 1-4.84-8.97C9.92 22.27 9 19.54 9 17.5',
|
||||
} as const
|
||||
@@ -55,7 +62,14 @@ function ciIcon(d: string, px: number): string {
|
||||
}
|
||||
|
||||
function fahrzeugMarkerHtml(px = 38): string {
|
||||
return `<div class="dm-fahrzeug-pin"><svg viewBox="0 0 48 48" width="${px}" height="${px}" fill="none" aria-hidden="true"><path fill="#182634" d="${CI.poiCarLSil}"/><path fill="currentColor" d="${CI.poiCarL}"/></svg></div>`
|
||||
// Groessenwechsel wie fahrzeugMarkerSVG() im Panel: ab 34px die grosse
|
||||
// Zeichnung (48er-Raster), darunter die kleine (24er). Die Vorschaukarte der
|
||||
// Uebersicht zeichnet mit 32px und bekam bisher faelschlich die grosse.
|
||||
const gross = px >= 34
|
||||
const box = gross ? 48 : 24
|
||||
const sil = gross ? CI.poiCarLSil : CI.poiCarSil
|
||||
const icon = gross ? CI.poiCarL : CI.poiCar
|
||||
return `<div class="dm-fahrzeug-pin"><svg viewBox="0 0 ${box} ${box}" width="${px}" height="${px}" fill="none" aria-hidden="true"><path fill="#182634" d="${sil}"/><path fill="currentColor" d="${icon}"/></svg></div>`
|
||||
}
|
||||
|
||||
/* Haversine-Näherung, wie im Panel (distanzMeter()/distanzText()). */
|
||||
|
||||
@@ -1,25 +1,95 @@
|
||||
/**
|
||||
* Statistik. Vorlage: `vStat()` im alten Panel — die Rechenregeln stecken in
|
||||
* `daten/statistik.ts` und sind von dort unverändert übernommen.
|
||||
*
|
||||
* Aufbau wie im Panel: fünf Kacheln, deren Kopf aus Beschriftung, großer Zahl
|
||||
* (34px) und einem rechtsbündigen Nebenwert besteht; die Auswertung darunter
|
||||
* ist eingeklappt und öffnet sich beim Antippen des Kopfes (`statTile()` mit
|
||||
* `.tile-h`/`.tile-b`). Bis 2026-08-30 stand hier alles dauerhaft offen, ohne
|
||||
* Nebenwert und ohne die Zwischenüberschriften über den Vierergruppen.
|
||||
*/
|
||||
|
||||
import { Fig, ProgressBar, StatGrid, Tile } from "@audi-dash/ui"
|
||||
import { useState, type ReactNode } from "react"
|
||||
|
||||
import { Fig, ProgressBar, Tile } from "@audi-dash/ui"
|
||||
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { ZEITRAEUME, ZEITRAUM_NAME, jahresauswertung } from "../daten/statistik"
|
||||
import { datum, de, deOderStrich, eur } from "../format"
|
||||
import type { Zeitraum } from "../daten/statistik"
|
||||
import { datum, de, deOderStrich } from "../format"
|
||||
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
|
||||
|
||||
/** `statTile()` im Panel: Kopf mit Zahl und Nebenwert, aufklappbarer Rumpf. */
|
||||
function StatKachel({
|
||||
titel,
|
||||
wert,
|
||||
einheit,
|
||||
neben,
|
||||
children,
|
||||
}: {
|
||||
titel: string
|
||||
wert: string
|
||||
einheit: string
|
||||
neben: ReactNode
|
||||
/** Heißt `children`, weil JSX-Verschachtelung genau diesen Namen erwartet. */
|
||||
children: ReactNode
|
||||
}) {
|
||||
const [offen, setzeOffen] = useState(false)
|
||||
return (
|
||||
<Tile>
|
||||
<button
|
||||
type="button"
|
||||
className="dm-statkopf"
|
||||
aria-expanded={offen}
|
||||
onClick={() => setzeOffen((o) => !o)}
|
||||
>
|
||||
{/* Derselbe drehende Pfeil wie in den Aufklapp-Zeilen: ohne ihn sahen
|
||||
die fuenf Kacheln aus wie feste Anzeigen (Designpruefung 2026-08-30). */}
|
||||
<span className="dm-statkopf__kopfzeile">
|
||||
<span className="ads-eyebrow">{titel}</span>
|
||||
<span className="ads-mark" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="dm-statkopf__zeile">
|
||||
<Fig value={wert} unit={einheit} size={34} />
|
||||
<span className="dm-wert dm-statkopf__neben">{neben}</span>
|
||||
</span>
|
||||
</button>
|
||||
{offen && <div className="dm-statrumpf">{children}</div>}
|
||||
</Tile>
|
||||
)
|
||||
}
|
||||
|
||||
/** `quad()` im Panel: Zwischenüberschrift plus vier Werte nebeneinander. */
|
||||
function Vierergruppe({ titel, werte }: { titel: string; werte: [string, string][] }) {
|
||||
return (
|
||||
<>
|
||||
<span className="ads-eyebrow" style={{ marginTop: "20px" }}>
|
||||
{titel}
|
||||
</span>
|
||||
<div className="dm-quad">
|
||||
{werte.map(([zahl, beschriftung]) => (
|
||||
<div key={beschriftung}>
|
||||
<div className="dm-quad__n">{zahl}</div>
|
||||
<div className="dm-quad__l">{beschriftung}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function Statistik() {
|
||||
const { fahrten, tankvorgaenge, einstellungen } = useDaten()
|
||||
if (!einstellungen) return null
|
||||
|
||||
if (fahrten.length === 0 && tankvorgaenge.length === 0) {
|
||||
return (
|
||||
<Leerzustand
|
||||
titel="Noch nichts auszuwerten"
|
||||
text="Sobald die ersten Fahrten und Tankungen erfasst sind, entstehen hier Auswertungen zu Strecke, Zeit, Verbrauch und Kosten."
|
||||
/>
|
||||
<Tile>
|
||||
<Leerzustand
|
||||
titel="Noch nichts auszuwerten"
|
||||
text="Sobald die ersten Fahrten und Tankungen erfasst sind, entstehen hier Auswertungen zu Strecke, Zeit, Verbrauch und Kosten."
|
||||
/>
|
||||
</Tile>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,53 +100,57 @@ export function Statistik() {
|
||||
einstellungen.nachtBis,
|
||||
)
|
||||
|
||||
// Die Einheit steht im Panel in der Beschriftung der Vierergruppe
|
||||
// ("Jahr h", "Monat €"), nicht am Wert.
|
||||
const jeZeitraum = (
|
||||
auswahl: (z: Zeitraum) => string,
|
||||
einheit = "",
|
||||
): [string, string][] =>
|
||||
ZEITRAEUME.map((z) => [auswahl(z), `${ZEITRAUM_NAME[z]}${einheit}`])
|
||||
|
||||
return (
|
||||
<div className="dm-raster">
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Distanz und Fahrzeit {a.jahr}</span>
|
||||
<Fig value={de(a.kmJahr)} unit="km" size={40} />
|
||||
<StatGrid
|
||||
items={ZEITRAEUME.map((z) => ({
|
||||
value: de(a.jeZeitraum[z].km),
|
||||
unit: "km",
|
||||
caption: ZEITRAUM_NAME[z],
|
||||
}))}
|
||||
columns={4}
|
||||
<>
|
||||
<StatKachel
|
||||
titel={`Distanz und Fahrzeit ${a.jahr}`}
|
||||
wert={de(a.kmJahr)}
|
||||
einheit="km"
|
||||
neben={`${de(a.stundenJahr)} h`}
|
||||
>
|
||||
<Vierergruppe
|
||||
titel="Gefahrene Distanz"
|
||||
werte={jeZeitraum((z) => de(a.jeZeitraum[z].km))}
|
||||
/>
|
||||
<StatGrid
|
||||
items={ZEITRAEUME.map((z) => ({
|
||||
value: de(a.jeZeitraum[z].stunden, z === "jahr" || z === "monat" ? 0 : 1),
|
||||
unit: "h",
|
||||
caption: ZEITRAUM_NAME[z],
|
||||
}))}
|
||||
columns={4}
|
||||
<Vierergruppe
|
||||
titel="Fahrzeit"
|
||||
werte={jeZeitraum(
|
||||
(z) => de(a.jeZeitraum[z].stunden, z === "jahr" || z === "monat" ? 0 : 1),
|
||||
" h",
|
||||
)}
|
||||
/>
|
||||
</Tile>
|
||||
</StatKachel>
|
||||
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Getankte Menge und Kosten {a.jahr}</span>
|
||||
<Fig value={de(a.literJahr)} unit="l" size={40} />
|
||||
<StatGrid
|
||||
items={ZEITRAEUME.map((z) => ({
|
||||
value: de(a.jeZeitraum[z].liter),
|
||||
unit: "l",
|
||||
caption: ZEITRAUM_NAME[z],
|
||||
}))}
|
||||
columns={4}
|
||||
<StatKachel
|
||||
titel={`Getankte Menge und Kosten ${a.jahr}`}
|
||||
wert={de(a.literJahr)}
|
||||
einheit="l"
|
||||
neben={`${de(a.kostenJahr)} €`}
|
||||
>
|
||||
<Vierergruppe
|
||||
titel="Getankte Liter"
|
||||
werte={jeZeitraum((z) => de(a.jeZeitraum[z].liter))}
|
||||
/>
|
||||
<StatGrid
|
||||
items={ZEITRAEUME.map((z) => ({
|
||||
value: de(a.jeZeitraum[z].kosten),
|
||||
unit: "€",
|
||||
caption: ZEITRAUM_NAME[z],
|
||||
}))}
|
||||
columns={4}
|
||||
<Vierergruppe
|
||||
titel="Kraftstoffkosten"
|
||||
werte={jeZeitraum((z) => de(a.jeZeitraum[z].kosten), " €")}
|
||||
/>
|
||||
</Tile>
|
||||
</StatKachel>
|
||||
|
||||
<Tile className="dm-raster__breit">
|
||||
<span className="ads-eyebrow">Langzeitverbrauch</span>
|
||||
<Fig value={deOderStrich(a.langzeit.wert, 1)} unit="l/100 km" size={40} />
|
||||
<StatKachel
|
||||
titel="Langzeitverbrauch"
|
||||
wert={deOderStrich(a.langzeit.wert, 1)}
|
||||
einheit="l/100 km"
|
||||
neben={a.langzeit.bis ? `bis ${datum(a.langzeit.bis)}` : "–"}
|
||||
>
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
@@ -88,49 +162,51 @@ export function Statistik() {
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Letzter Tankstop"
|
||||
wert={a.langzeit.bis ? datum(a.langzeit.bis) : "—"}
|
||||
wert={a.langzeit.bis ? datum(a.langzeit.bis) : "–"}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{a.langzeit.unplausibel && (
|
||||
<p className="dm-fussnote dm-fussnote--warnung">
|
||||
<span className="ads-eyebrow dm-warnton" style={{ marginTop: "16px" }}>
|
||||
Aus Litern und Strecke ergibt sich derzeit kein plausibler Wert. Sobald Tankvorgänge und
|
||||
Kilometerstände vollständig vorliegen, erscheint er hier.
|
||||
</p>
|
||||
</span>
|
||||
)}
|
||||
<p className="dm-fussnote">
|
||||
Der Langzeitwert rechnet alle je getankten Liter gegen die Strecke bis zum letzten
|
||||
Tankstop — nicht Liter und Kilometer desselben Zeitraums, weil das unmögliche Werte
|
||||
ergibt, sobald eine Fahrt fehlt.
|
||||
</p>
|
||||
</Tile>
|
||||
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Tag und Nacht {a.jahr}</span>
|
||||
<Fig
|
||||
value={a.kmJahr > 0 ? de(Math.round((a.tagKm / a.kmJahr) * 100)) : "—"}
|
||||
unit="% bei Tag"
|
||||
size={40}
|
||||
<Vierergruppe
|
||||
titel="Näherung je Fahrt, je Zeitraum"
|
||||
werte={jeZeitraum((z) => deOderStrich(a.jeZeitraum[z].verbrauch, 1))}
|
||||
/>
|
||||
<span className="ads-eyebrow" style={{ marginTop: "16px" }}>
|
||||
Der Langzeitwert rechnet alle je getankten Liter gegen die Strecke bis zum letzten
|
||||
Tankstop. Die Werte je Zeitraum sind eine Näherung aus der Tankfüllstand-Differenz der
|
||||
einzelnen Fahrten, keine Meldung des Fahrzeugs.
|
||||
</span>
|
||||
</StatKachel>
|
||||
|
||||
<StatKachel
|
||||
titel="Tag und Nacht"
|
||||
wert={a.kmJahr > 0 ? de(Math.round((a.tagKm / a.kmJahr) * 100)) : "–"}
|
||||
einheit="% bei Tag"
|
||||
neben={`${de(a.tagKm)} km`}
|
||||
>
|
||||
<ProgressBar
|
||||
segments={[
|
||||
{ value: a.tagKm, color: "var(--fg)", label: `Tag · ${de(a.tagKm)} km` },
|
||||
{ value: a.nachtKm, color: "var(--fg3)", label: `Nacht · ${de(a.nachtKm)} km` },
|
||||
]}
|
||||
/>
|
||||
<p className="dm-fussnote">
|
||||
<span className="ads-eyebrow" style={{ marginTop: "16px" }}>
|
||||
Nacht = {einstellungen.nachtVon} bis {einstellungen.nachtBis} Uhr
|
||||
</p>
|
||||
</Tile>
|
||||
</span>
|
||||
</StatKachel>
|
||||
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Art der Fahrten {a.jahr}</span>
|
||||
<Fig
|
||||
value={a.kmJahr > 0 ? de(Math.round((a.arbeitKm / a.kmJahr) * 100)) : "—"}
|
||||
unit="% Arbeitsweg"
|
||||
size={40}
|
||||
/>
|
||||
<StatKachel
|
||||
titel="Art der Fahrten"
|
||||
wert={a.kmJahr > 0 ? de(Math.round((a.arbeitKm / a.kmJahr) * 100)) : "–"}
|
||||
einheit="% Arbeitsweg"
|
||||
neben={`${de(a.arbeitKm)} km`}
|
||||
>
|
||||
<ProgressBar
|
||||
segments={[
|
||||
{ value: a.arbeitKm, color: "var(--fg)", label: `Arbeitsweg · ${de(a.arbeitKm)} km` },
|
||||
@@ -142,14 +218,10 @@ export function Statistik() {
|
||||
<>
|
||||
<Wertzeile label="Fahrten Arbeitsweg" wert={de(a.arbeitFahrten)} />
|
||||
<Wertzeile label="Fahrten privat" wert={de(a.privatFahrten)} />
|
||||
<Wertzeile
|
||||
label={`Kraftstoffkosten ${a.jahr}`}
|
||||
wert={`${eur(a.kostenJahr)} €`}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Tile>
|
||||
</div>
|
||||
</StatKachel>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,13 +9,16 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
import { ActionButton, Feld, Pill, Tile } from "@audi-dash/ui"
|
||||
import { ActionButton, Feld, Fig, Tile } from "@audi-dash/ui"
|
||||
|
||||
import type { Tankvorgang } from "../api"
|
||||
import { ENTITAETEN } from "../api"
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { datum, datumZeit, de, deOderStrich, eur } from "../format"
|
||||
import { KRAFTSTOFFSORTEN, Leerzustand, Wertzeile, Werteliste } from "./bausteine"
|
||||
import { datum, de, deOderStrich, eur, uhrzeit } from "../format"
|
||||
import type { SeitenName } from "../navigation"
|
||||
import { KRAFTSTOFFSORTEN, Leerzustand, Wertzeile, Werteliste, bestaetigen } from "./bausteine"
|
||||
import { markenlogoUrl } from "./bilder"
|
||||
import { TankstellenKarte } from "./TankstellenKarte"
|
||||
|
||||
interface BelegErgebnis {
|
||||
ok?: boolean
|
||||
@@ -25,8 +28,8 @@ interface BelegErgebnis {
|
||||
[weitere: string]: unknown
|
||||
}
|
||||
|
||||
export function TankDetail({ id }: { id: string | undefined }) {
|
||||
const { tankvorgaenge, api } = useDaten()
|
||||
export function TankDetail({ id, geheZu }: { id: string | undefined; geheZu: (name: SeitenName) => void }) {
|
||||
const { tankvorgaenge, fahrzeug, api } = useDaten()
|
||||
const tank = tankvorgaenge.find((t) => t.tank_id === id)
|
||||
const [bearbeitenOffen, setzeBearbeitenOffen] = useState(false)
|
||||
|
||||
@@ -39,66 +42,100 @@ export function TankDetail({ id }: { id: string | undefined }) {
|
||||
)
|
||||
}
|
||||
|
||||
const loeschen = () => {
|
||||
if (!tank || !bestaetigen("Tankvorgang löschen?")) return
|
||||
void api.tankvorgangLoeschen(tank.tank_id)
|
||||
geheZu("fuel")
|
||||
}
|
||||
|
||||
if (bearbeitenOffen) {
|
||||
return <TankBearbeiten tank={tank} beiFertig={() => setzeBearbeitenOffen(false)} />
|
||||
}
|
||||
|
||||
/* Aufbau 1:1 wie vFill(): Karte, randlose „Getankt"-Kachel mit der
|
||||
52px-Zahl, dann eine einzige Wertezeilen-Kachel in der Reihenfolge des
|
||||
Panels (Zeitpunkt/Kilometerstand/Gefahrene Distanz/Kraftstoff/Getankte
|
||||
Liter/Kosten/Preis je Liter/SmartDeal-Ersparnis/Tankstelle) mit den drei
|
||||
Knöpfen darunter.
|
||||
|
||||
Vorher: eigene Kopfzeile mit Zeitstempel und Bearbeiten-Textknopf, Pillen
|
||||
für Status und Quelle (die das Panel hier nicht führt), andere Reihenfolge
|
||||
und Bezeichnungen („Gesamtbetrag", „Seit der letzten Tankung",
|
||||
„Listenpreis") und der Beleg in einer eigenen Kachel. */
|
||||
const preisJeLiter =
|
||||
tank.liters && tank.fuel_total_eur != null ? tank.fuel_total_eur / tank.liters : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tile className="dm-kartenkachel">
|
||||
<TankstellenKarte adresse={tank.station_address} />
|
||||
</Tile>
|
||||
|
||||
<Tile variant="flat" className="dm-distanzkachel">
|
||||
<span className="ads-eyebrow">Getankt</span>
|
||||
<div style={{ marginTop: "10px" }}>
|
||||
<Fig value={deOderStrich(tank.liters, 2)} unit="l" size={52} />
|
||||
</div>
|
||||
</Tile>
|
||||
|
||||
<Tile>
|
||||
<div className="dm-abschnitt" style={{ marginBottom: 0 }}>
|
||||
<span className="ads-eyebrow">{datumZeit(tank.ts)}</span>
|
||||
<button type="button" className="dm-textknopf" onClick={() => setzeBearbeitenOffen(true)}>
|
||||
Bearbeiten
|
||||
</button>
|
||||
</div>
|
||||
<div className="dm-detail__kopf">
|
||||
<div className="dm-detail__zahl dm-detail__zahl--gross">
|
||||
{deOderStrich(tank.liters, 2)}
|
||||
<span className="dm-detail__einheit">l</span>
|
||||
</div>
|
||||
<div className="dm-detail__marken">
|
||||
{tank.status === "unvollständig" && <Pill>unvollständig</Pill>}
|
||||
{tank.source === "beleg" && <Pill>aus Beleg</Pill>}
|
||||
{tank.source === "manual" && <Pill>selbst eingetragen</Pill>}
|
||||
{tank.source === "auto" && <Pill>automatisch erkannt</Pill>}
|
||||
</div>
|
||||
</div>
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile label="Zeitpunkt" wert={datum(tank.ts)} zusatz={`${uhrzeit(tank.ts)} Uhr`} />
|
||||
<Wertzeile
|
||||
label="Gesamtbetrag"
|
||||
wert={tank.fuel_total_eur != null ? `${eur(tank.fuel_total_eur)} €` : "—"}
|
||||
label="Kilometerstand"
|
||||
wert={tank.odometer_km != null ? `${de(tank.odometer_km)} km` : "unbekannt"}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Gefahrene Distanz"
|
||||
wert={tank.distance_km != null ? `${de(tank.distance_km)} km` : "unbekannt"}
|
||||
zusatz="seit der letzten Tankung"
|
||||
/>
|
||||
<Wertzeile label="Kraftstoff" wert={tank.fuel_type ?? KRAFTSTOFFSORTEN[0]} />
|
||||
<Wertzeile label="Getankte Liter" wert={deOderStrich(tank.liters, 2) + " l"} />
|
||||
<Wertzeile
|
||||
label="Kosten"
|
||||
wert={tank.fuel_total_eur != null ? `${eur(tank.fuel_total_eur)} €` : "–"}
|
||||
zusatz="real, nach Rabatt"
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Preis je Liter"
|
||||
wert={tank.price_per_l != null ? `${eur(tank.price_per_l)} €` : "—"}
|
||||
zusatz={
|
||||
tank.list_price_per_l != null
|
||||
? `Listenpreis ${eur(tank.list_price_per_l)} €`
|
||||
: undefined
|
||||
}
|
||||
wert={preisJeLiter != null ? `${de(preisJeLiter, 2)} €` : "–"}
|
||||
zusatz="Kosten ÷ Liter"
|
||||
/>
|
||||
{tank.discount != null && tank.discount > 0 && (
|
||||
<Wertzeile label="Ersparnis" wert={`${eur(tank.discount)} €`} />
|
||||
{fahrzeug?.smartdeal.aktiv && tank.discount != null && (
|
||||
<Wertzeile
|
||||
label={
|
||||
<span className="dm-marke">
|
||||
<img
|
||||
src={markenlogoUrl()}
|
||||
alt=""
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = "none"
|
||||
}}
|
||||
/>
|
||||
SmartDeal-Ersparnis
|
||||
</span>
|
||||
}
|
||||
wert={`${eur(tank.discount)} €`}
|
||||
/>
|
||||
)}
|
||||
<Wertzeile label="Tankstelle" wert={tank.station_name ?? "—"} zusatz={tank.station_address ?? undefined} />
|
||||
<Wertzeile label="Kraftstoff" wert={tank.product_name ?? tank.fuel_type ?? "—"} />
|
||||
<Wertzeile
|
||||
label="Kilometerstand"
|
||||
wert={tank.odometer_km != null ? `${de(tank.odometer_km)} km` : "—"}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Seit der letzten Tankung"
|
||||
wert={tank.distance_km != null ? `${de(tank.distance_km)} km` : "—"}
|
||||
label="Tankstelle"
|
||||
wert={tank.station_name ?? "unbekannt"}
|
||||
zusatz={tank.station_address ?? ""}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<ActionButton onClick={() => setzeBearbeitenOffen(true)}>Werte bearbeiten</ActionButton>
|
||||
<BelegKnopf tankId={tank.tank_id} belegDatei={tank.receipt_file ?? null} api={api} />
|
||||
</Tile>
|
||||
|
||||
<BelegBereich tankId={tank.tank_id} belegDatei={tank.receipt_file ?? null} belegNr={tank.receipt_no ?? null} zeitpunkt={tank.ts} api={api} />
|
||||
<ActionButton variant="destructive" onClick={() => void loeschen()}>
|
||||
Tankvorgang löschen
|
||||
</ActionButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -266,17 +303,17 @@ function TankBearbeiten({ tank, beiFertig }: { tank: Tankvorgang; beiFertig: ()
|
||||
)
|
||||
}
|
||||
|
||||
function BelegBereich({
|
||||
/* Im Panel ist der Beleg kein eigener Abschnitt, sondern ein Knopf in der
|
||||
Wertezeilen-Kachel ("Beleg ersetzen"/"Beleg nachtragen"). Die frühere
|
||||
eigene Kachel mit Dateiname und Beleg-Nummer hatte dort keine
|
||||
Entsprechung. */
|
||||
function BelegKnopf({
|
||||
tankId,
|
||||
belegDatei,
|
||||
belegNr,
|
||||
zeitpunkt,
|
||||
api,
|
||||
}: {
|
||||
tankId: string
|
||||
belegDatei: string | null
|
||||
belegNr: string | null
|
||||
zeitpunkt: string
|
||||
api: ReturnType<typeof useDaten>["api"]
|
||||
}) {
|
||||
const dateiwahl = useRef<HTMLInputElement | null>(null)
|
||||
@@ -346,24 +383,7 @@ function BelegBereich({
|
||||
}
|
||||
|
||||
return (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Beleg</span>
|
||||
{belegDatei ? (
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile label="Hinterlegt" wert={belegDatei} />
|
||||
<Wertzeile label="Beleg-Nummer" wert={belegNr ?? "—"} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<p className="dm-fussnote">
|
||||
Für diese Tankung liegt noch kein Beleg vor. Aus einem Shell-Beleg liest die App Menge,
|
||||
Betrag, Tankstelle und Ersparnis selbst aus.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<>
|
||||
<input
|
||||
ref={dateiwahl}
|
||||
type="file"
|
||||
@@ -375,7 +395,6 @@ function BelegBereich({
|
||||
e.target.value = ""
|
||||
}}
|
||||
/>
|
||||
|
||||
{meldung && (
|
||||
<p
|
||||
className={meldung.art === "fehler" ? "dm-fehler" : "dm-erfolg"}
|
||||
@@ -384,17 +403,19 @@ function BelegBereich({
|
||||
{meldung.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<ActionButton onClick={() => dateiwahl.current?.click()} disabled={laeuft || wartet}>
|
||||
{laeuft ? "Lade hoch …" : wartet ? "Beleg wird gelesen …" : belegDatei ? "Beleg ersetzen" : "Beleg hochladen"}
|
||||
</ActionButton>
|
||||
</div>
|
||||
<p className="dm-fussnote">
|
||||
Der Beleg muss vom {datum(zeitpunkt)} sein — sonst weist der Server ihn ab, damit keine
|
||||
fremde Tankung in diesen Eintrag gerät.
|
||||
</p>
|
||||
</Tile>
|
||||
<ActionButton onClick={() => dateiwahl.current?.click()} disabled={laeuft || wartet}>
|
||||
{laeuft
|
||||
? "Lade hoch …"
|
||||
: wartet
|
||||
? "Beleg wird gelesen …"
|
||||
: belegDatei
|
||||
? "Beleg ersetzen"
|
||||
: "Beleg nachtragen"}
|
||||
</ActionButton>
|
||||
{/* Der Datumshinweis darunter ist entfallen - im Panel steht an dieser
|
||||
Stelle nur im Fehlerfall eine Meldung (belegFehler), kein
|
||||
dauerhafter Erklärtext. */}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,15 +6,16 @@
|
||||
* heben wie eine große.
|
||||
*/
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRef, useState } from "react"
|
||||
|
||||
import { Accordion, ActionButton, Feld, Pill, Tile } from "@audi-dash/ui"
|
||||
|
||||
import { ENTITAETEN } from "../api"
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { nachJahrUndMonat } from "../daten/statistik"
|
||||
import { datum, de, deOderStrich, eur, monatJahr, schnittpreis, summe } from "../format"
|
||||
import { datum, de, eur, schnittpreis, summe } from "../format"
|
||||
import type { SeitenName } from "../navigation"
|
||||
import { KRAFTSTOFFSORTEN, Leerzustand } from "./bausteine"
|
||||
import { Blattzeile, KRAFTSTOFFSORTEN, Leerzustand } from "./bausteine"
|
||||
import { markenlogoUrl } from "./bilder"
|
||||
import { Zeilenmenue } from "./Zeilenmenue"
|
||||
|
||||
@@ -27,33 +28,17 @@ export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dm-abschnitt">
|
||||
<span className="ads-eyebrow">
|
||||
{tankvorgaenge.length === 0
|
||||
? "Keine Tankvorgänge"
|
||||
: tankvorgaenge.length === 1
|
||||
? "1 Tankvorgang"
|
||||
: `${de(tankvorgaenge.length)} Tankvorgänge`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="dm-textknopf"
|
||||
onClick={() => setzeFormularOffen((offen) => !offen)}
|
||||
>
|
||||
{formularOffen ? "Abbrechen" : "Tankung eintragen"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{formularOffen && <TankFormular beiFertig={() => setzeFormularOffen(false)} />}
|
||||
|
||||
{tankvorgaenge.length === 0 && !formularOffen && (
|
||||
<Leerzustand
|
||||
titel="Noch keine Tankvorgänge"
|
||||
text="Die App erkennt Tankungen automatisch am steigenden Füllstand und vervollständigt sie aus dem hochgeladenen Beleg. Du kannst sie auch selbst eintragen."
|
||||
aktion={{ beschriftung: "Tankung eintragen", beiKlick: () => setzeFormularOffen(true) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Aufbau wie vFuel(): eine Kachel um alle Jahre, kein Zähler über der
|
||||
Liste, das Formular darunter. */}
|
||||
{gruppen.length === 0 ? (
|
||||
<Tile>
|
||||
<Leerzustand
|
||||
titel="Noch keine Tankvorgänge"
|
||||
text="Ein Tankvorgang wird automatisch angelegt, sobald der Tankstand des Fahrzeugs steigt. Den Beleg kann man danach hochladen – oder den Vorgang gleich hier von Hand erfassen."
|
||||
/>
|
||||
</Tile>
|
||||
) : (
|
||||
<Tile>
|
||||
{gruppen.map((jahr) => {
|
||||
const ersparnisJahr = summe(jahr.eintraege, (t) => t.discount ?? 0)
|
||||
return (
|
||||
@@ -69,7 +54,7 @@ export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
|
||||
{de(summe(jahr.eintraege, (t) => t.liters ?? 0), 1)} l · Ø{" "}
|
||||
{de(schnittpreis(jahr.eintraege), 2)} €/l
|
||||
</span>
|
||||
{smartdealAktiv && ersparnisJahr > 0 && (
|
||||
{smartdealAktiv && (
|
||||
<Pill>
|
||||
<img
|
||||
src={markenlogoUrl()}
|
||||
@@ -86,64 +71,88 @@ export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
|
||||
}
|
||||
defaultOpen={jahr === gruppen[0]}
|
||||
>
|
||||
{jahr.monate.map((monat) => {
|
||||
const schnitt = schnittpreis(monat.eintraege)
|
||||
return (
|
||||
<Accordion
|
||||
key={monat.schluessel}
|
||||
level={2}
|
||||
title={monatJahr(new Date(monat.jahr, monat.monat, 1))}
|
||||
summary={
|
||||
<span style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 3 }}>
|
||||
<span style={{ color: "var(--fg)" }}>
|
||||
{eur(summe(monat.eintraege, (t) => t.fuel_total_eur ?? 0))} €
|
||||
</span>
|
||||
<span>{schnitt > 0 ? `Ø ${eur(schnitt)} €/l` : "—"}</span>
|
||||
{jahr.monate.map((monat) => (
|
||||
<Accordion
|
||||
key={monat.schluessel}
|
||||
level={2}
|
||||
title={monatName(monat.jahr, monat.monat)}
|
||||
summary={
|
||||
<span style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 3 }}>
|
||||
<span style={{ color: "var(--fg)" }}>
|
||||
{eur(summe(monat.eintraege, (t) => t.fuel_total_eur ?? 0))} €
|
||||
</span>
|
||||
}
|
||||
defaultOpen={monat === jahr.monate[0]}
|
||||
>
|
||||
{monat.eintraege.map((tank) => (
|
||||
<span>Ø {de(schnittpreis(monat.eintraege), 2)} €/l</span>
|
||||
</span>
|
||||
}
|
||||
defaultOpen={monat === jahr.monate[0]}
|
||||
>
|
||||
{/* Zeile wie im Panel: links das Datum mit der Tankstelle
|
||||
darunter, rechts die Menge mit dem Preis je Liter. Vorher
|
||||
standen hier Menge und Betrag vorn, alles andere in einer
|
||||
Fußzeile, dazu eine Pille „unvollständig", die es im Panel
|
||||
nicht gibt. Der Preis je Liter wird wie dort aus Betrag und
|
||||
Menge gerechnet, nicht aus dem gespeicherten Feld. */}
|
||||
{monat.eintraege.map((tank, i) => {
|
||||
const preis =
|
||||
tank.liters && tank.fuel_total_eur != null
|
||||
? tank.fuel_total_eur / tank.liters
|
||||
: null
|
||||
return (
|
||||
<Zeilenmenue
|
||||
key={tank.tank_id}
|
||||
loeschFrage={`Tankvorgang vom ${datum(tank.ts)} löschen?`}
|
||||
beiLoeschen={() => void api.tankvorgangLoeschen(tank.tank_id)}
|
||||
kinder={
|
||||
<button
|
||||
type="button"
|
||||
className="dm-listenzeile"
|
||||
onClick={() => geheZu("fill", tank.tank_id)}
|
||||
>
|
||||
<span className="dm-listenzeile__haupt">
|
||||
<span className="dm-listenzeile__titel">
|
||||
{deOderStrich(tank.liters, 2)} l
|
||||
{tank.fuel_total_eur != null && (
|
||||
<span className="dm-listenzeile__neben">
|
||||
{eur(tank.fuel_total_eur)} €
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="dm-listenzeile__unter">
|
||||
{datum(tank.ts)}
|
||||
{tank.station_name ? ` · ${tank.station_name}` : ""}
|
||||
{tank.price_per_l != null ? ` · ${eur(tank.price_per_l)} €/l` : ""}
|
||||
</span>
|
||||
</span>
|
||||
{tank.status === "unvollständig" && <Pill>unvollständig</Pill>}
|
||||
</button>
|
||||
<Blattzeile
|
||||
label={datum(tank.ts)}
|
||||
labelZusatz={tank.station_name ?? ""}
|
||||
wert={tank.liters != null ? `${de(tank.liters, 2)} l` : ""}
|
||||
wertZusatz={preis != null ? `${de(preis, 2)} €/l` : ""}
|
||||
beiKlick={() => geheZu("fill", tank.tank_id)}
|
||||
letzte={i === monat.eintraege.length - 1}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Accordion>
|
||||
)
|
||||
})}
|
||||
)
|
||||
})}
|
||||
</Accordion>
|
||||
))}
|
||||
</Accordion>
|
||||
)
|
||||
})}
|
||||
</Tile>
|
||||
)}
|
||||
|
||||
{formularOffen && <TankFormular beiFertig={() => setzeFormularOffen(false)} />}
|
||||
|
||||
<ActionButton
|
||||
variant={formularOffen ? "default" : "primary"}
|
||||
onClick={() => setzeFormularOffen((offen) => !offen)}
|
||||
>
|
||||
{formularOffen ? "Abbrechen" : "Tankvorgang erfassen"}
|
||||
</ActionButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Felder, die belege.py nach dem Lesen eines Belegs veröffentlicht. */
|
||||
interface BelegEntwurf {
|
||||
ts?: string
|
||||
liter?: number | null
|
||||
kosten?: number | null
|
||||
km?: number | null
|
||||
ersparnis?: number | null
|
||||
station?: string | null
|
||||
kraftstoff?: string | null
|
||||
receipt_key?: string | null
|
||||
receipt_file?: string | null
|
||||
}
|
||||
|
||||
/** Monatsname allein, wie im Panel. */
|
||||
function monatName(jahr: number, monat: number): string {
|
||||
return new Date(jahr, monat, 1).toLocaleDateString("de-DE", { month: "long" })
|
||||
}
|
||||
|
||||
function TankFormular({ beiFertig }: { beiFertig: () => void }) {
|
||||
const { api, fahrzeug } = useDaten()
|
||||
const [zeitpunkt, setzeZeitpunkt] = useState("")
|
||||
@@ -155,10 +164,74 @@ function TankFormular({ beiFertig }: { beiFertig: () => void }) {
|
||||
const [ersparnis, setzeErsparnis] = useState("")
|
||||
const [fehler, setzeFehler] = useState<string | null>(null)
|
||||
const [laeuft, setzeLaeuft] = useState(false)
|
||||
/* Vorab gelesener Beleg. Ein Upload OHNE tank_id legt im Backend nichts an,
|
||||
sondern liest den Beleg nur aus und veröffentlicht die Felder über
|
||||
sensor.audi_dashboard_beleg_ergebnis (belege.py) - genau dieses Muster
|
||||
benutzt das Panel im Neuanlage-Formular (data-beleg="neu" plus
|
||||
belegEntwurf). Der App fehlte der Weg ganz: sie konnte einen Beleg nur an
|
||||
einem bereits bestehenden Tankvorgang nachtragen. */
|
||||
const [beleg, setzeBeleg] = useState<BelegEntwurf | null>(null)
|
||||
const [belegLaeuft, setzeBelegLaeuft] = useState(false)
|
||||
const belegwahl = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const smartdealAktiv = fahrzeug?.smartdeal.aktiv === true
|
||||
|
||||
const speichern = async () => {
|
||||
const belegLesen = async (datei: File) => {
|
||||
setzeFehler(null)
|
||||
if (datei.type !== "application/pdf" && !datei.name.toLowerCase().endsWith(".pdf")) {
|
||||
setzeFehler("Bitte den Beleg als PDF auswählen.")
|
||||
return
|
||||
}
|
||||
setzeBelegLaeuft(true)
|
||||
try {
|
||||
const base64 = await new Promise<string>((fertig, schief) => {
|
||||
const leser = new FileReader()
|
||||
leser.onload = () => {
|
||||
const roh = String(leser.result)
|
||||
const komma = roh.indexOf(",")
|
||||
fertig(komma >= 0 ? roh.slice(komma + 1) : roh)
|
||||
}
|
||||
leser.onerror = () => schief(new Error("Die Datei konnte nicht gelesen werden."))
|
||||
leser.readAsDataURL(datei)
|
||||
})
|
||||
await api.belegHochladen(base64, datei.name)
|
||||
// Das Ergebnis kommt asynchron über eine eigene Entität, nicht als
|
||||
// Antwort auf den Dienstaufruf - hier kurz darauf warten.
|
||||
const frist = Date.now() + 20_000
|
||||
while (Date.now() < frist) {
|
||||
await new Promise((weiter) => setTimeout(weiter, 1200))
|
||||
try {
|
||||
const e = await api.rest.datenLesen<BelegEntwurf & { fehler?: string; tank_id?: string | null }>(
|
||||
ENTITAETEN.belegErgebnis,
|
||||
)
|
||||
if (e && e.tank_id == null) {
|
||||
if (e.fehler) {
|
||||
setzeFehler(e.fehler)
|
||||
} else {
|
||||
setzeBeleg(e)
|
||||
if (e.liter != null) setzeLiter(String(e.liter))
|
||||
if (e.kosten != null) setzeKosten(String(e.kosten))
|
||||
if (e.station) setzeStation(e.station)
|
||||
if (e.km != null) setzeKm(String(e.km))
|
||||
if (e.ersparnis != null) setzeErsparnis(String(e.ersparnis))
|
||||
if (e.kraftstoff) setzeKraftstoff(e.kraftstoff)
|
||||
if (e.ts) setzeZeitpunkt(e.ts.slice(0, 16))
|
||||
}
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// einzelner Fehlschlag heißt nichts - weiterversuchen
|
||||
}
|
||||
}
|
||||
setzeFehler("Der Server hat den Beleg noch nicht zurückgemeldet.")
|
||||
} catch (ursache) {
|
||||
setzeFehler(ursache instanceof Error ? ursache.message : String(ursache))
|
||||
} finally {
|
||||
setzeBelegLaeuft(false)
|
||||
}
|
||||
}
|
||||
|
||||
const speichern = async (mitBeleg: boolean) => {
|
||||
setzeFehler(null)
|
||||
const literZahl = Number(liter.replace(",", "."))
|
||||
const kostenZahl = Number(kosten.replace(",", "."))
|
||||
@@ -184,6 +257,9 @@ function TankFormular({ beiFertig }: { beiFertig: () => void }) {
|
||||
...(smartdealAktiv
|
||||
? { ersparnis: Number.isFinite(ersparnisZahl) && ersparnis ? ersparnisZahl : null }
|
||||
: {}),
|
||||
...(mitBeleg && beleg
|
||||
? { receipt_key: beleg.receipt_key ?? null, receipt_file: beleg.receipt_file ?? null }
|
||||
: {}),
|
||||
})
|
||||
beiFertig()
|
||||
} catch (ursache) {
|
||||
@@ -195,7 +271,7 @@ function TankFormular({ beiFertig }: { beiFertig: () => void }) {
|
||||
|
||||
return (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Tankung eintragen</span>
|
||||
<span className="ads-eyebrow">Neuer Tankvorgang</span>
|
||||
<Feld label="Zeitpunkt">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
@@ -268,15 +344,30 @@ function TankFormular({ beiFertig }: { beiFertig: () => void }) {
|
||||
{fehler}
|
||||
</p>
|
||||
)}
|
||||
<p className="dm-fussnote">
|
||||
Den Preis je Liter rechnet die App selbst aus Betrag und Menge — er wird nie aus einem Beleg
|
||||
übernommen. Ohne Kilometerstand nimmt sie den des Fahrzeugs.
|
||||
</p>
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
|
||||
{laeuft ? "Speichere …" : "Speichern"}
|
||||
</ActionButton>
|
||||
</div>
|
||||
{/* Ohne Erklärabsatz: das Panel führt im Neuanlage-Formular nur die
|
||||
Felder und die Knöpfe. */}
|
||||
{/* Drei Knöpfe wie in vFuelFormular(): erst den Beleg lesen lassen, dann
|
||||
mit oder ohne ihn speichern. */}
|
||||
<input
|
||||
ref={belegwahl}
|
||||
type="file"
|
||||
accept="application/pdf,.pdf"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const datei = e.target.files?.[0]
|
||||
if (datei) void belegLesen(datei)
|
||||
e.target.value = ""
|
||||
}}
|
||||
/>
|
||||
<ActionButton onClick={() => belegwahl.current?.click()} disabled={belegLaeuft || laeuft}>
|
||||
{belegLaeuft ? "Beleg wird gelesen …" : beleg ? "Beleg ersetzen" : "Beleg hochladen"}
|
||||
</ActionButton>
|
||||
<ActionButton variant="primary" onClick={() => void speichern(true)} disabled={laeuft}>
|
||||
{laeuft ? "Speichere …" : "Speichern"}
|
||||
</ActionButton>
|
||||
<ActionButton onClick={() => void speichern(false)} disabled={laeuft}>
|
||||
Ohne Beleg speichern
|
||||
</ActionButton>
|
||||
</Tile>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Karte im Einzelbeleg: eine Nadel auf der Tankstelle.
|
||||
*
|
||||
* Die Koordinaten stehen nicht im Datensatz — der Beleg trägt nur eine
|
||||
* Anschrift, und das Backend füllt `station_lat`/`station_lon` nie. Sie werden
|
||||
* deshalb aus der Adresse aufgelöst (`adresseAufloesen()`), genau wie im
|
||||
* Panel. Ohne Adresse oder ohne Treffer bleibt der ehrliche Leerzustand
|
||||
* stehen, statt irgendeinen Kartenausschnitt zu zeigen.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { adresseAufloesen } from "../daten/geokodierung"
|
||||
import type { Koordinate } from "../daten/geokodierung"
|
||||
import { Leerzustand } from "./bausteine"
|
||||
import { Karte } from "./Karte"
|
||||
|
||||
export function TankstellenKarte({ adresse }: { adresse: string | null | undefined }) {
|
||||
const [position, setzePosition] = useState<Koordinate | null>(null)
|
||||
const [geprueft, setzeGeprueft] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let abgebrochen = false
|
||||
setzeGeprueft(false)
|
||||
void adresseAufloesen(adresse).then((treffer) => {
|
||||
if (abgebrochen) return
|
||||
setzePosition(treffer)
|
||||
setzeGeprueft(true)
|
||||
})
|
||||
return () => {
|
||||
abgebrochen = true
|
||||
}
|
||||
}, [adresse])
|
||||
|
||||
if (position) return <Karte start={position} />
|
||||
// Solange die Auflösung läuft: eine leere Kartenfläche, wie im Panel (die
|
||||
// Karte steht dort schon, die Nadel kommt nach). Bewusst nicht die
|
||||
// Ladeanzeige der Seite - die meldet „der Bildschirm ist noch nicht da".
|
||||
if (!geprueft) return <div className="dm-kartenplatz" />
|
||||
return (
|
||||
<Leerzustand
|
||||
titel="Keine Position zur Tankstelle"
|
||||
text="Zu dieser Anschrift ließ sich kein Kartenpunkt finden. Sobald der Beleg eine auflösbare Adresse trägt, erscheint die Tankstelle hier."
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -6,15 +6,15 @@
|
||||
* Tankung. Ziehen zum Aktualisieren stößt eine Neuabfrage beim Fahrzeug an.
|
||||
*/
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import { Fig, ProgressBar, StatusRow, Tile } from "@audi-dash/ui"
|
||||
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { naechsterService } from "../daten/service"
|
||||
import { datum, datumZeit, dauer, de, deOderStrich, eur } from "../format"
|
||||
import { datum, de, deOderStrich, eur } from "../format"
|
||||
import type { SeitenName } from "../navigation"
|
||||
import { Leerzustand, NaviKachel } from "./bausteine"
|
||||
import { useBoden } from "../theme"
|
||||
import { BILDPLAETZE, bildInfo } from "./bilder"
|
||||
import { Blattzeile, Leerzustand } from "./bausteine"
|
||||
import { Bild } from "./Bild"
|
||||
import type { ServicebuchEintrag } from "./Service"
|
||||
import { StandortVorschau } from "./Standort"
|
||||
@@ -30,13 +30,29 @@ const SERVICE_SYMBOL: Partial<Record<string, typeof SymbolOelwechsel>> = {
|
||||
"Inspektion": SymbolInspektion,
|
||||
}
|
||||
|
||||
/* Innenraumaufnahmen bekommen keinen Bodenschatten — `OHNE_SZENE` im Panel. */
|
||||
const OHNE_SZENE = ["Cockpit", "Sitze", "Scheinwerfer"]
|
||||
|
||||
export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
|
||||
const { fahrzeug, einstellungen, fahrten, tankvorgaenge, jetztAktualisieren, statusStand } =
|
||||
useDaten()
|
||||
const [aktualisiert, setzeAktualisiert] = useState(false)
|
||||
const { fahrzeug, einstellungen, fahrten, tankvorgaenge } = useDaten()
|
||||
const [boden] = useBoden()
|
||||
|
||||
if (!fahrzeug || !einstellungen) return null
|
||||
|
||||
// Bodenschatten unter dem Fahrzeug, wie szeneKlasse() im Panel: abschaltbar,
|
||||
// nicht bei Innenraumaufnahmen, blaustichig bei montierten Winterrädern.
|
||||
const winterraeder = fahrzeug.reifen.aktiv === "Winter"
|
||||
const platz = BILDPLAETZE.find((p) => p.datei === einstellungen.startbild) ?? BILDPLAETZE[0]!
|
||||
// Bei Winterrädern zeigt die Seitenansicht das Winterfoto, wie bildInfo()
|
||||
// im Panel — das fehlte hier ganz.
|
||||
const startbild = bildInfo(platz, winterraeder)
|
||||
const bodenKlasse =
|
||||
!boden || OHNE_SZENE.includes(platz.name)
|
||||
? ""
|
||||
: winterraeder
|
||||
? " dm-szene--winter"
|
||||
: " dm-szene--strasse"
|
||||
|
||||
// Der Zusatz stammt aus dem Modellnamen selbst (alles nach dem Typenschild)
|
||||
// plus der eigens gepflegten Ausführung — so macht es auch das alte Panel.
|
||||
const schild = schildErkennen(einstellungen.fahrzeugtitel)
|
||||
@@ -74,26 +90,28 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
|
||||
? "Nicht sicher abgestellt"
|
||||
: "Status unbekannt"
|
||||
|
||||
const aktualisieren = async () => {
|
||||
setzeAktualisiert(true)
|
||||
try {
|
||||
await jetztAktualisieren()
|
||||
} finally {
|
||||
setzeAktualisiert(false)
|
||||
}
|
||||
}
|
||||
// Die Neuabfrage beim Fahrzeug hängt jetzt am Ziehen zum Aktualisieren im
|
||||
// Rahmen (Shell.tsx), wie im Panel — der frühere Knopf am Seitenende hatte
|
||||
// dort keine Entsprechung.
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dm-szene">
|
||||
{/* Randloses Fahrzeugbild, danach eine eigene randlose Kachel mit
|
||||
Typenschild, Kennzeichen und Statuszeile — dieselbe Aufteilung wie
|
||||
vHome() im Panel (`.szene.bleed` gefolgt von einer `.tile.flat`).
|
||||
Der Status stand hier bisher als eigenes Element unter der Szene. */}
|
||||
<div className={`dm-szene${bodenKlasse}`}>
|
||||
<Bild
|
||||
datei={einstellungen.startbild}
|
||||
beschreibung={einstellungen.fahrzeugtitel}
|
||||
name="Fahrzeugbild"
|
||||
datei={startbild.datei}
|
||||
beschreibung={startbild.beschreibung}
|
||||
name={startbild.beschreibung}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tile variant="flat" className="dm-kopfblock">
|
||||
<div className="dm-szene__text">
|
||||
<div className="dm-szene__marke">
|
||||
<Typenschild modell={einstellungen.fahrzeugtitel} hoehe={26} />
|
||||
<Typenschild modell={einstellungen.fahrzeugtitel} hoehe={18} />
|
||||
{/* Neben dem Schild steht nur, was im Namen darüber hinausgeht —
|
||||
das Modell selbst zeigt bereits das Schild. Ohne erkanntes
|
||||
Schild tritt der volle Name an dessen Stelle. Deckungsgleich
|
||||
@@ -117,14 +135,15 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
|
||||
{einstellungen.kennzeichen || "Kennzeichen nicht hinterlegt"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StatusRow
|
||||
status={sicherheitsstatus}
|
||||
title={sicherheitstext}
|
||||
{...(statusStand ? { subtitle: `Stand ${datumZeit(statusStand)}` } : {})}
|
||||
onClick={() => geheZu("sicherheit")}
|
||||
/>
|
||||
{/* Ohne Zeitstempel-Unterzeile: das Panel zeigt den Stand der Daten in
|
||||
der Kopfzeile ("vor 3 Std."), nicht unter dem Status. */}
|
||||
<StatusRow
|
||||
status={sicherheitsstatus}
|
||||
title={sicherheitstext}
|
||||
chevron
|
||||
onClick={() => geheZu("sicherheit")}
|
||||
/>
|
||||
</Tile>
|
||||
|
||||
{/* Reihenfolge deckungsgleich mit vHome() im Panel (".tankzeile", eigens
|
||||
als myAudi-App-Konvention kommentiert, die einzige Kachel im ganzen
|
||||
@@ -136,11 +155,11 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
|
||||
am 2026-08-16 aus dem Panel entfernt (Nutzerwunsch) und war hier nie
|
||||
nachgezogen worden. Panel: "tile flat" - keine Kartenfläche, war
|
||||
hier fälschlich eine normale (graue) Tile (Parität-Audit). */}
|
||||
<Tile variant="flat">
|
||||
<Tile variant="flat" className="dm-tankkachel">
|
||||
<div className="dm-tankzeile">
|
||||
<Fig value={deOderStrich(fahrzeug.reichweite)} unit="km" size={60} />
|
||||
<span className="dm-tankzeile__pct">
|
||||
{fahrzeug.tankBekannt ? `${de(fahrzeug.tankPct)} %` : "—"}
|
||||
{fahrzeug.tankBekannt ? `${de(fahrzeug.tankPct)} %` : "–"}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar percent={Math.max(0, Math.min(100, fahrzeug.tankPct))} />
|
||||
@@ -159,7 +178,7 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
|
||||
"differentiate controls from content": nur der obere Teil ist der
|
||||
Knopf zur Service-Seite, die Kilometerstand-Zeile bleibt reine
|
||||
Anzeige. Deckungsgleich mit vHome() im Panel. */}
|
||||
<Tile>
|
||||
<Tile className="dm-servicekachel">
|
||||
{/* Ohne Termin ist dieser Teil im Panel bewusst kein Knopf (kein
|
||||
data-go, kein Chevron) - es gibt nichts, wohin er führen könnte.
|
||||
"differentiate controls from content" gilt auch für die
|
||||
@@ -167,9 +186,13 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
|
||||
{service ? (
|
||||
<button type="button" className="dm-serviceblock" onClick={() => geheZu("service")}>
|
||||
<span className="ads-eyebrow">Nächster Service:</span>
|
||||
<span className="dm-serviceblock__chevron" aria-hidden="true">
|
||||
›
|
||||
</span>
|
||||
{/* Gezeichneter Pfeil wie .go im Panel, kein Schriftzeichen: das
|
||||
">" war ein 15px-Glyph der Textschrift und damit erkennbar
|
||||
anders geformt und gewichtet als der Chevron ueberall sonst
|
||||
(Designpruefung 2026-08-30). */}
|
||||
<svg className="dm-serviceblock__chevron" viewBox="0 0 6 10" aria-hidden="true">
|
||||
<polyline points="1,1 5,5 1,9" />
|
||||
</svg>
|
||||
{(() => {
|
||||
const Symbol = SERVICE_SYMBOL[service.art]
|
||||
const fig = (
|
||||
@@ -188,89 +211,80 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
|
||||
fig
|
||||
)
|
||||
})()}
|
||||
<span className="dm-fussnote">
|
||||
{/* Im Panel ist auch diese Nebenzeile ein `.label` (13px, --fg3),
|
||||
nicht die kleinere Fußnote (11,5px/300), die hier stand. */}
|
||||
<span className="ads-eyebrow" style={{ marginTop: "8px" }}>
|
||||
{service.restKm !== null ? `voraussichtlich am ${datum(service.datum)}` : service.art}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="dm-serviceblock">
|
||||
<span className="ads-eyebrow">Nächster Service:</span>
|
||||
<span className="dm-fussnote">kein Eintrag im Wartungsplan</span>
|
||||
{/* Panel: `<div class="wert">`, 16px in --fg — nicht die Fußnote. */}
|
||||
<div className="dm-wert">kein Eintrag im Wartungsplan</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="dm-servicezeile">
|
||||
<span className="dm-servicezeile__k">Kilometerstand</span>
|
||||
<Fig value={fahrzeug.odoBekannt ? de(fahrzeug.odo) : "—"} unit="km" size={20} />
|
||||
<span className="ads-eyebrow">Kilometerstand</span>
|
||||
<Fig value={fahrzeug.odoBekannt ? de(fahrzeug.odo) : "–"} unit="km" size={20} />
|
||||
</div>
|
||||
</Tile>
|
||||
|
||||
{/* „Zuletzt": eine Kachel mit zwei Blattzeilen, wie teaser() im Panel —
|
||||
links die Bezeichnung mit Strecke bzw. Tankstelle darunter, rechts
|
||||
der Wert mit Status bzw. Betrag. Vorher waren das zwei
|
||||
NaviKacheln nebeneinander ohne Überschrift, ohne Strecken-/
|
||||
Tankstellenangabe und mit „Letzte Tankung" statt „Letzter
|
||||
Tankvorgang". Fehlen beide Datensätze, lässt das Panel die Kachel
|
||||
ganz weg; die Leerzustände hier bleiben als eigene Konvention der
|
||||
App erhalten (siehe Kapitel 1 des Paritätsaudits). */}
|
||||
{letzteFahrt || letzteTankung ? (
|
||||
<Tile className="dm-teaser">
|
||||
<span className="ads-eyebrow">Zuletzt</span>
|
||||
{letzteFahrt && (
|
||||
<Blattzeile
|
||||
label="Letzte Fahrt"
|
||||
labelZusatz={`${letzteFahrt.start_address ?? ""} → ${letzteFahrt.end_address ?? ""}`}
|
||||
wert={
|
||||
letzteFahrt.distance_km != null
|
||||
? `${de(letzteFahrt.distance_km)} km`
|
||||
: "offen"
|
||||
}
|
||||
wertZusatz={letzteFahrt.status}
|
||||
beiKlick={() => geheZu("trip", letzteFahrt.trip_id)}
|
||||
letzte={!letzteTankung}
|
||||
/>
|
||||
)}
|
||||
{letzteTankung && (
|
||||
<Blattzeile
|
||||
label="Letzter Tankvorgang"
|
||||
labelZusatz={letzteTankung.station_name ?? ""}
|
||||
wert={letzteTankung.liters != null ? `${de(letzteTankung.liters, 2)} l` : ""}
|
||||
wertZusatz={
|
||||
letzteTankung.fuel_total_eur != null
|
||||
? `${eur(letzteTankung.fuel_total_eur)} €`
|
||||
: ""
|
||||
}
|
||||
beiKlick={() => geheZu("fill", letzteTankung.tank_id)}
|
||||
letzte
|
||||
/>
|
||||
)}
|
||||
</Tile>
|
||||
) : (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Zuletzt</span>
|
||||
<Leerzustand
|
||||
titel="Noch keine Fahrt erfasst"
|
||||
text="Sobald du losfährst, erscheint hier die letzte Fahrt."
|
||||
/>
|
||||
</Tile>
|
||||
)}
|
||||
|
||||
{/* Die Standortkarte steht seit der Designprüfung 2026-08-30 hinter
|
||||
„Zuletzt“ — wie im Panel. Die 190px hohe Karte davor drückte die zwei
|
||||
kurzen, häufig gelesenen Zeilen auf dem Telefon unter den ersten
|
||||
Bildschirm. */}
|
||||
<StandortVorschau geheZu={() => geheZu("standort")} />
|
||||
|
||||
<div className="dm-raster">
|
||||
{letzteFahrt ? (
|
||||
<NaviKachel
|
||||
titel="Letzte Fahrt"
|
||||
wert={
|
||||
<>
|
||||
<div className="dm-navikachel__zahl">
|
||||
{deOderStrich(letzteFahrt.distance_km, 1)} km
|
||||
</div>
|
||||
<small>
|
||||
{datum(letzteFahrt.ts_start)} · {dauer(letzteFahrt.duration_s)}
|
||||
</small>
|
||||
</>
|
||||
}
|
||||
beiKlick={() => geheZu("trip", letzteFahrt.trip_id)}
|
||||
/>
|
||||
) : (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Letzte Fahrt</span>
|
||||
<Leerzustand
|
||||
titel="Noch keine Fahrt erfasst"
|
||||
text="Sobald du losfährst, erscheint hier die letzte Fahrt."
|
||||
/>
|
||||
</Tile>
|
||||
)}
|
||||
|
||||
{letzteTankung ? (
|
||||
<NaviKachel
|
||||
titel="Letzte Tankung"
|
||||
wert={
|
||||
<>
|
||||
<div className="dm-navikachel__zahl">
|
||||
{deOderStrich(letzteTankung.liters, 2)} l
|
||||
</div>
|
||||
<small>
|
||||
{datum(letzteTankung.ts)} ·{" "}
|
||||
{letzteTankung.fuel_total_eur != null
|
||||
? `${eur(letzteTankung.fuel_total_eur)} €`
|
||||
: "Betrag offen"}
|
||||
</small>
|
||||
</>
|
||||
}
|
||||
beiKlick={() => geheZu("fill", letzteTankung.tank_id)}
|
||||
/>
|
||||
) : (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Letzte Tankung</span>
|
||||
<Leerzustand
|
||||
titel="Noch keine Tankung erfasst"
|
||||
text="Tankvorgänge erkennt die App automatisch am steigenden Füllstand — oder du trägst sie selbst ein."
|
||||
/>
|
||||
</Tile>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="dm-aktionen">
|
||||
<button
|
||||
type="button"
|
||||
className="dm-textknopf"
|
||||
onClick={() => void aktualisieren()}
|
||||
disabled={aktualisiert}
|
||||
>
|
||||
{aktualisiert ? "Frage das Fahrzeug …" : "Jetzt beim Fahrzeug nachfragen"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,78 +9,159 @@
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import { ActionButton, Feld, Tile } from "@audi-dash/ui"
|
||||
import { ActionButton, Feld, Fig, ProgressBar, Switch, Tile } from "@audi-dash/ui"
|
||||
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { datum, de, eur } from "../format"
|
||||
import type { SeitenName } from "../navigation"
|
||||
import { Leerzustand, NaviKachel, Wertzeile, Werteliste } from "./bausteine"
|
||||
import { Leerzustand, Wertzeile, Werteliste, Zahnradknopf } from "./bausteine"
|
||||
|
||||
interface Beitragsposten {
|
||||
bez?: string
|
||||
betrag?: number
|
||||
sf?: string
|
||||
sfAlt?: string
|
||||
satz?: string
|
||||
}
|
||||
|
||||
/* Aufbau 1:1 wie vVers(): vier Kacheln mit je einem Zahnrad zum Bearbeiten —
|
||||
Notruf mit beiden Rufnummern als Wählknöpfen, der Jahresbeitrag mit großer
|
||||
Zahl, Aufteilung und Balken, der Vertrag mit allen Zeilen und dem
|
||||
Details-Knopf, zuletzt die Kfz-Steuer. Bis 2026-08-30 stand hier eine
|
||||
einzelne Zusammenfassungskachel plus fünf Navigationskacheln: die
|
||||
Beitragsaufteilung, die Selbstbeteiligungen, die Schadenfreiheitsklassen und
|
||||
die Inlandsnummer fehlten dadurch ganz. */
|
||||
export function Versicherung({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
|
||||
const { fahrzeug } = useDaten()
|
||||
if (!fahrzeug) return null
|
||||
|
||||
const v = fahrzeug.versicherung
|
||||
const steuer = fahrzeug.steuer
|
||||
const teile = (v["teile"] ?? []) as Beitragsposten[]
|
||||
const beitrag = (v["beitrag"] as number) ?? 0
|
||||
const selbst = (v["selbst"] ?? []) as [string, string][]
|
||||
const service = (v["service"] as string) ?? ""
|
||||
const notfall = (v["notfall"] as string) ?? ""
|
||||
const balkenfarben = ["var(--fg)", "var(--fg2)", "var(--fg3)"]
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Versicherung</span>
|
||||
<Tile className="dm-kachel--zahnrad">
|
||||
<span className="ads-eyebrow">Versicherung - Schaden Notruf 24/7</span>
|
||||
<Zahnradknopf beschriftung="Rufnummern ändern" beiKlick={() => geheZu("notruf")} />
|
||||
<a className="ads-action dm-waehlknopf" href={`tel:${service.replace(/\s/g, "")}`}>
|
||||
Inland · {service}
|
||||
</a>
|
||||
<a className="ads-action dm-waehlknopf" href={`tel:${notfall.replace(/\s/g, "")}`}>
|
||||
Ausland · {notfall}
|
||||
</a>
|
||||
</Tile>
|
||||
|
||||
<Tile className="dm-kachel--zahnrad">
|
||||
<span className="ads-eyebrow">
|
||||
Jahresbeitrag ab {v["gueltigAb"] ? datum(v["gueltigAb"] as string) : "–"}
|
||||
</span>
|
||||
<Zahnradknopf beschriftung="Beitrag anpassen" beiKlick={() => geheZu("beitrag")} />
|
||||
<div style={{ marginTop: "var(--sp-3)" }}>
|
||||
<Fig value={eur(beitrag)} unit="€" size={46} />
|
||||
</div>
|
||||
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
|
||||
{(v["zahlweise"] as string) ?? ""} · enthält {eur((v["steueranteil"] as number) ?? 0)} €
|
||||
Versicherungsteuer ({(v["steuersatz"] as string) ?? ""})
|
||||
</span>
|
||||
<Werteliste
|
||||
kinder={teile.map((t, i) => (
|
||||
<Wertzeile
|
||||
key={`${t.bez ?? i}`}
|
||||
label={
|
||||
<>
|
||||
{t.bez}
|
||||
{t.sf && (
|
||||
<>
|
||||
<br />
|
||||
<span className="dm-beitragsposten__sf">
|
||||
{t.sf} · Beitragssatz {t.satz}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
wert={`${eur(t.betrag ?? 0)} €`}
|
||||
zusatz={`${beitrag ? de(((t.betrag ?? 0) / beitrag) * 100, 0) : 0} %`}
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
<div style={{ marginTop: "18px" }}>
|
||||
<ProgressBar
|
||||
showLegend={false}
|
||||
segments={teile.map((t, i) => ({
|
||||
value: t.betrag || 0.01,
|
||||
color: balkenfarben[i] ?? "var(--fg3)",
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</Tile>
|
||||
|
||||
<Tile className="dm-kachel--zahnrad">
|
||||
<span className="ads-eyebrow">Vertrag</span>
|
||||
<Zahnradknopf beschriftung="Vertrag bearbeiten" beiKlick={() => geheZu("vertrag")} />
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile label="Gesellschaft" wert={(v["gesellschaft"] as string) || "—"} />
|
||||
<Wertzeile label="Vertrag" wert={(v["vertrag"] as string) || "—"} />
|
||||
<Wertzeile label="Vertragsnummer" wert={(v["nummer"] as string) || "—"} />
|
||||
<Wertzeile label="Gesellschaft" wert={(v["gesellschaft"] as string) || "–"} />
|
||||
<Wertzeile label="Umfang" wert={(v["vertrag"] as string) || "–"} />
|
||||
<Wertzeile label="Vertragsnummer" wert={(v["nummer"] as string) || "–"} />
|
||||
{selbst.map(([bezeichnung, wert], i) => (
|
||||
<Wertzeile
|
||||
key={`${bezeichnung}-${i}`}
|
||||
label={`Selbstbeteiligung ${bezeichnung}`}
|
||||
wert={wert}
|
||||
/>
|
||||
))}
|
||||
<Wertzeile
|
||||
label="Beitrag"
|
||||
wert={v["beitrag"] ? `${eur(v["beitrag"] as number)} €` : "—"}
|
||||
zusatz={(v["zahlweise"] as string) || undefined}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Gültig ab"
|
||||
wert={v["gueltigAb"] ? datum(v["gueltigAb"] as string) : "—"}
|
||||
label="Schadenfreiheitsklasse"
|
||||
wert={teile.map((t) => t.sf).filter(Boolean).join(" / ")}
|
||||
zusatz={`zuvor ${teile.map((t) => t.sfAlt).filter(Boolean).join(" / ")}`}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<ActionButton onClick={() => geheZu("vertragsdetails")}>Details</ActionButton>
|
||||
</Tile>
|
||||
|
||||
<div className="dm-raster">
|
||||
<NaviKachel titel="Beitrag" wert="Einzelposten bearbeiten" beiKlick={() => geheZu("beitrag")} />
|
||||
<NaviKachel
|
||||
titel="Vertrag"
|
||||
wert="Gesellschaft, Nummer, Selbstbeteiligung"
|
||||
beiKlick={() => geheZu("vertrag")}
|
||||
/>
|
||||
<NaviKachel
|
||||
titel="Vertragsdetails"
|
||||
wert="Leistungen und Schutzbrief"
|
||||
beiKlick={() => geheZu("vertragsdetails")}
|
||||
/>
|
||||
<NaviKachel
|
||||
titel="Notruf"
|
||||
wert={(v["notfall"] as string) || "Nummern hinterlegen"}
|
||||
beiKlick={() => geheZu("notruf")}
|
||||
/>
|
||||
<NaviKachel
|
||||
titel="Kfz-Steuer"
|
||||
wert={steuer["betrag"] ? `${eur(steuer["betrag"] as number)} €` : "Noch nicht hinterlegt"}
|
||||
beiKlick={() => geheZu("steuer")}
|
||||
/>
|
||||
</div>
|
||||
<SteuerKachel beiBearbeiten={() => geheZu("steuer")} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** `vSteuer()` — die Kfz-Steuer-Kachel am Ende der Versicherungsseite. */
|
||||
function SteuerKachel({ beiBearbeiten }: { beiBearbeiten: () => void }) {
|
||||
const { fahrzeug } = useDaten()
|
||||
if (!fahrzeug) return null
|
||||
const t = fahrzeug.steuer
|
||||
const zeitraum = (t["zeitraum"] as string) ?? ""
|
||||
return (
|
||||
<Tile className="dm-kachel--zahnrad dm-steuerkachel">
|
||||
<span className="ads-eyebrow">Kfz-Steuer</span>
|
||||
<Zahnradknopf beschriftung="Kfz-Steuer bearbeiten" beiKlick={beiBearbeiten} />
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile
|
||||
label="Betrag"
|
||||
wert={`${eur((t["betrag"] as number) ?? 0)} €`}
|
||||
zusatz={zeitraum === "jährlich" ? "im Jahr" : zeitraum}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Nächste Fälligkeit"
|
||||
wert={t["faellig"] ? datum(t["faellig"] as string) : "unbekannt"}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Tile>
|
||||
)
|
||||
}
|
||||
|
||||
export function Beitrag() {
|
||||
const { fahrzeug, profilSpeichern } = useDaten()
|
||||
const [laeuft, setzeLaeuft] = useState(false)
|
||||
@@ -91,6 +172,7 @@ export function Beitrag() {
|
||||
const teile = (v["teile"] ?? []) as Beitragsposten[]
|
||||
|
||||
const [entwurf, setzeEntwurf] = useState<Beitragsposten[]>(() => teile.map((t) => ({ ...t })))
|
||||
const [gueltigAb, setzeGueltigAb] = useState((v["gueltigAb"] as string) ?? "")
|
||||
|
||||
const speichern = async () => {
|
||||
setzeLaeuft(true)
|
||||
@@ -100,7 +182,7 @@ export function Beitrag() {
|
||||
await profilSpeichern({
|
||||
fahrzeug: {
|
||||
...fahrzeug,
|
||||
versicherung: { ...v, teile: entwurf, beitrag: summeBetrag },
|
||||
versicherung: { ...v, teile: entwurf, beitrag: summeBetrag, gueltigAb },
|
||||
},
|
||||
})
|
||||
setzeGespeichert(true)
|
||||
@@ -109,50 +191,64 @@ export function Beitrag() {
|
||||
}
|
||||
}
|
||||
|
||||
if (entwurf.length === 0) {
|
||||
return (
|
||||
<Leerzustand
|
||||
titel="Keine Beitragsposten"
|
||||
text="Sobald im Profil Einzelposten hinterlegt sind, kannst du sie hier bearbeiten."
|
||||
/>
|
||||
)
|
||||
}
|
||||
const setzeFeld = (index: number, feld: "sf" | "betrag", wert: string) =>
|
||||
setzeEntwurf((alt) => {
|
||||
const kopie = alt.map((p) => ({ ...p }))
|
||||
while (kopie.length <= index) kopie.push({})
|
||||
kopie[index]![feld] =
|
||||
feld === "betrag" ? (Number(wert.replace(",", ".")) as never) : (wert as never)
|
||||
return kopie
|
||||
})
|
||||
|
||||
/* Felder und Beschriftungen wortgleich mit vBeitrag(): davor fehlten hier
|
||||
„Gültig ab" und beide Schadenfreiheitsklassen, die Kostenfelder hießen nur
|
||||
„Haftpflicht"/„Vollkasko …" statt „Kosten …", und statt der Erklärung stand
|
||||
eine Summenzeile, die es im Panel nicht gibt (die Summe rechnet sich beim
|
||||
Speichern ohnehin neu). */
|
||||
return (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Beitragsposten</span>
|
||||
{entwurf.map((posten, index) => (
|
||||
<Feld
|
||||
key={`${posten.bez}-${index}`}
|
||||
label={posten.bez ?? `Posten ${index + 1}`}
|
||||
unit="€"
|
||||
last={index === entwurf.length - 1}
|
||||
>
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
inputMode="decimal"
|
||||
value={posten.betrag ?? ""}
|
||||
onChange={(e) =>
|
||||
setzeEntwurf((alt) =>
|
||||
alt.map((p, i) =>
|
||||
i === index ? { ...p, betrag: Number(e.target.value.replace(",", ".")) } : p,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Feld>
|
||||
))}
|
||||
<Werteliste
|
||||
kinder={
|
||||
<Wertzeile
|
||||
label="Summe"
|
||||
wert={`${eur(entwurf.reduce((s, t) => s + (Number(t.betrag) || 0), 0))} €`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<span className="ads-eyebrow">Beitrag anpassen</span>
|
||||
<Feld label="Gültig ab">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
type="date"
|
||||
value={gueltigAb}
|
||||
onChange={(e) => setzeGueltigAb(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Schadenfreiheitsklasse Haftpflicht">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
value={entwurf[0]?.sf ?? ""}
|
||||
onChange={(e) => setzeFeld(0, "sf", e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Schadenfreiheitsklasse Vollkasko">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
value={entwurf[1]?.sf ?? ""}
|
||||
onChange={(e) => setzeFeld(1, "sf", e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
{(["Kosten Haftpflicht", "Kosten Vollkasko", "Kosten Schutzbrief"] as const).map(
|
||||
(beschriftung, index) => (
|
||||
<Feld key={beschriftung} label={beschriftung} unit="€" last={index === 2}>
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
inputMode="decimal"
|
||||
value={entwurf[index]?.betrag ?? 0}
|
||||
onChange={(e) => setzeFeld(index, "betrag", e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
),
|
||||
)}
|
||||
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
|
||||
Der Jahresbeitrag oben ist die Summe dieser drei Posten und rechnet sich beim Speichern
|
||||
automatisch neu — "Gültig ab" steuert den Titel der Jahresbeitrag-Kachel
|
||||
</span>
|
||||
{gespeichert && <p className="dm-erfolg">Gespeichert.</p>}
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
|
||||
<ActionButton variant="primary" onClick={() => void speichern()} disabled={laeuft}>
|
||||
{laeuft ? "Speichere …" : "Speichern"}
|
||||
</ActionButton>
|
||||
</div>
|
||||
@@ -169,6 +265,10 @@ export function Vertrag() {
|
||||
const [selbst, setzeSelbst] = useState<string[][]>(() =>
|
||||
((v["selbst"] as (string | number)[][]) ?? []).map(([b, w]) => [String(b ?? ""), String(w ?? "")]),
|
||||
)
|
||||
const [sfAlt, setzeSfAlt] = useState<string[]>(() => {
|
||||
const teile = (v["teile"] ?? []) as Beitragsposten[]
|
||||
return [teile[0]?.sfAlt ?? "", teile[1]?.sfAlt ?? ""]
|
||||
})
|
||||
const [laeuft, setzeLaeuft] = useState(false)
|
||||
const [gespeichert, setzeGespeichert] = useState(false)
|
||||
|
||||
@@ -178,10 +278,13 @@ export function Vertrag() {
|
||||
setzeLaeuft(true)
|
||||
setzeGespeichert(false)
|
||||
try {
|
||||
const teile = ((v["teile"] ?? []) as Beitragsposten[]).map((t, i) =>
|
||||
i < 2 ? { ...t, sfAlt: sfAlt[i] ?? "" } : t,
|
||||
)
|
||||
await profilSpeichern({
|
||||
fahrzeug: {
|
||||
...fahrzeug,
|
||||
versicherung: { ...v, gesellschaft, vertrag: umfang, nummer, selbst },
|
||||
versicherung: { ...v, gesellschaft, vertrag: umfang, nummer, selbst, teile },
|
||||
},
|
||||
})
|
||||
setzeGespeichert(true)
|
||||
@@ -245,51 +348,97 @@ export function Vertrag() {
|
||||
</span>
|
||||
</Feld>
|
||||
))}
|
||||
{selbst.length === 0 && <p className="dm-fussnote">Keine Selbstbeteiligung hinterlegt.</p>}
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<ActionButton onClick={() => setzeSelbst((alt) => [...alt, ["", ""]])}>
|
||||
+ Position hinzufügen
|
||||
</ActionButton>
|
||||
</div>
|
||||
{selbst.length === 0 && (
|
||||
<span className="ads-eyebrow" style={{ marginTop: "8px" }}>
|
||||
Keine Selbstbeteiligung hinterlegt
|
||||
</span>
|
||||
)}
|
||||
<ActionButton onClick={() => setzeSelbst((alt) => [...alt, ["", ""]])}>
|
||||
+ Position hinzufügen
|
||||
</ActionButton>
|
||||
</Tile>
|
||||
|
||||
{/* Die vorherige Schadenfreiheitsklasse fehlte hier ganz — im Panel ist
|
||||
das eine eigene Kachel (vVertragBearbeiten()), die aktuelle Klasse
|
||||
bleibt bewusst dem Beitrag-Formular vorbehalten. */}
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Schadenfreiheitsklasse · zuvor</span>
|
||||
<Feld label="Haftpflicht">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
value={sfAlt[0] ?? ""}
|
||||
onChange={(e) => {
|
||||
const wert = e.target.value
|
||||
setzeSfAlt((alt) => [wert, alt[1] ?? ""])
|
||||
}}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Vollkasko" last>
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
value={sfAlt[1] ?? ""}
|
||||
onChange={(e) => {
|
||||
const wert = e.target.value
|
||||
setzeSfAlt((alt) => [alt[0] ?? "", wert])
|
||||
}}
|
||||
/>
|
||||
</Feld>
|
||||
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
|
||||
Die aktuelle Schadenfreiheitsklasse änderst du unter "Beitrag anpassen" — hier
|
||||
steht nur der vorherige Wert für den Vergleich.
|
||||
</span>
|
||||
</Tile>
|
||||
|
||||
{gespeichert && <p className="dm-erfolg">Gespeichert.</p>}
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
|
||||
{laeuft ? "Speichere …" : "Speichern"}
|
||||
</ActionButton>
|
||||
</div>
|
||||
<ActionButton variant="primary" onClick={() => void speichern()} disabled={laeuft}>
|
||||
{laeuft ? "Speichere …" : "Speichern"}
|
||||
</ActionButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* `versicherung.leistungen` ist eine Liste von Gruppen mit je einer
|
||||
* Überschrift und einer Liste von Klartext-Posten — nicht, wie hier bis
|
||||
* 2026-08-30 angenommen, eine Liste von `{bez, wert}`-Paaren. Aus dem falschen
|
||||
* Typ entstanden auf dieser Seite zwölf Zeilen aus lauter Strichen: `l.bez`
|
||||
* und `l.wert` waren an jedem Eintrag undefiniert. Derselbe Fehlertyp wie im
|
||||
* Schutzbrief (Kapitel 4 des vorigen Audits), nur eine Ebene weiter oben.
|
||||
*/
|
||||
interface Leistungsgruppe {
|
||||
gruppe?: string
|
||||
posten?: string[]
|
||||
}
|
||||
|
||||
export function Vertragsdetails({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
|
||||
const { fahrzeug } = useDaten()
|
||||
if (!fahrzeug) return null
|
||||
|
||||
const v = fahrzeug.versicherung
|
||||
const leistungen = (v["leistungen"] ?? []) as { bez?: string; wert?: string }[]
|
||||
const leistungen = (v["leistungen"] ?? []) as Leistungsgruppe[]
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Leistungen</span>
|
||||
{leistungen.length === 0 ? (
|
||||
<p className="dm-fussnote">Im Profil sind noch keine Leistungen hinterlegt.</p>
|
||||
) : (
|
||||
<Werteliste
|
||||
kinder={leistungen.map((l, i) => (
|
||||
<Wertzeile key={`${l.bez}-${i}`} label={l.bez ?? "—"} wert={l.wert ?? "—"} />
|
||||
))}
|
||||
/>
|
||||
)}
|
||||
{/* Der Schutzbrief steht im Panel oben, als Kachel-Knopf mit Chevron,
|
||||
nicht als Navigationskachel unter den Leistungen. */}
|
||||
<Tile variant="button" chevron onClick={() => geheZu("schutz")}>
|
||||
<span className="ads-eyebrow">Schutzbrief Mobilität</span>
|
||||
<div className="dm-wert">Panne, Unfall, Diebstahl, Fahrerausfall</div>
|
||||
<span className="ads-eyebrow" style={{ marginTop: "10px" }}>
|
||||
Nummern und Leistungen für den Ernstfall
|
||||
</span>
|
||||
</Tile>
|
||||
|
||||
<NaviKachel
|
||||
titel="Schutzbrief"
|
||||
wert="Bedingungen im Detail"
|
||||
beiKlick={() => geheZu("schutz")}
|
||||
/>
|
||||
{leistungen.map((gruppe, gi) => (
|
||||
<Tile key={`${gruppe.gruppe}-${gi}`}>
|
||||
<span className="ads-eyebrow">{gruppe.gruppe}</span>
|
||||
<Werteliste
|
||||
kinder={(gruppe.posten ?? []).map((posten, i) => (
|
||||
<Wertzeile key={`${posten}-${i}`} label={posten} wert="" />
|
||||
))}
|
||||
/>
|
||||
</Tile>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -347,7 +496,9 @@ export function Schutzbrief() {
|
||||
<Wertzeile key={x.bez ?? i} label={x.bez ?? ""} wert={x.grenze ?? ""} zusatz={x.zusatz} />
|
||||
))}
|
||||
/>
|
||||
<p className="dm-fussnote">Mobilität wird binnen 60 Minuten organisiert.</p>
|
||||
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
|
||||
Mobilität wird binnen 60 Minuten organisiert
|
||||
</span>
|
||||
</Tile>
|
||||
)}
|
||||
|
||||
@@ -359,9 +510,15 @@ export function Schutzbrief() {
|
||||
<li key={a}>{a}</li>
|
||||
))}
|
||||
</ul>
|
||||
{Boolean(s["gilt"]) && <p className="dm-fussnote">Gilt in {s["gilt"] as string}</p>}
|
||||
{Boolean(s["gilt"]) && (
|
||||
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
|
||||
Gilt in {s["gilt"] as string}
|
||||
</span>
|
||||
)}
|
||||
{Boolean(s["versichert"]) && (
|
||||
<p className="dm-fussnote">Versichert: {s["versichert"] as string}</p>
|
||||
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
|
||||
Versichert: {s["versichert"] as string}
|
||||
</span>
|
||||
)}
|
||||
</Tile>
|
||||
)}
|
||||
@@ -371,7 +528,21 @@ export function Schutzbrief() {
|
||||
<span className="ads-eyebrow">{g.titel}</span>
|
||||
<Werteliste
|
||||
kinder={(g.posten ?? []).map(([bez, wert, zusatz], i) => (
|
||||
<Wertzeile key={bez ?? i} label={bez} wert={wert} zusatz={zusatz} />
|
||||
<Wertzeile
|
||||
key={bez ?? i}
|
||||
label={
|
||||
<>
|
||||
{bez}
|
||||
{zusatz && (
|
||||
<>
|
||||
<br />
|
||||
<span className="dm-beitragsposten__sf">{zusatz}</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
wert={wert}
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
</Tile>
|
||||
@@ -396,8 +567,12 @@ export function Schutzbrief() {
|
||||
<li key={x}>{x}</li>
|
||||
))}
|
||||
</ul>
|
||||
{Boolean(s["rang"]) && <p className="dm-fussnote">{s["rang"] as string}</p>}
|
||||
{Boolean(s["stand"]) && <p className="dm-fussnote">{s["stand"] as string}</p>}
|
||||
{Boolean(s["rang"]) && <span className="dm-erklaerung">{s["rang"] as string}</span>}
|
||||
{Boolean(s["stand"]) && (
|
||||
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
|
||||
{s["stand"] as string}
|
||||
</span>
|
||||
)}
|
||||
</Tile>
|
||||
)}
|
||||
</>
|
||||
@@ -428,9 +603,15 @@ export function Notruf() {
|
||||
}
|
||||
|
||||
return (
|
||||
/* Beschriftungen wie vNotrufBearbeiten(): „Rufnummern ändern" mit
|
||||
„Inland"/„Ausland". Hier hießen sie „Nummern im Notfall" mit
|
||||
„Service"/„Notfall" — dieselben zwei Felder, aber anders benannt als in
|
||||
der Kachel, aus der man kommt. Die beiden Wählknöpfe entfallen: das
|
||||
Panel bietet das Wählen auf der Versicherungsseite selbst an, nicht im
|
||||
Bearbeiten-Formular. */
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Nummern im Notfall</span>
|
||||
<Feld label="Service">
|
||||
<span className="ads-eyebrow">Rufnummern ändern</span>
|
||||
<Feld label="Inland">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
type="tel"
|
||||
@@ -438,7 +619,7 @@ export function Notruf() {
|
||||
onChange={(e) => setzeService(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Notfall" last>
|
||||
<Feld label="Ausland" last>
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
type="tel"
|
||||
@@ -446,23 +627,9 @@ export function Notruf() {
|
||||
onChange={(e) => setzeNotfall(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
{(service || notfall) && (
|
||||
<div className="dm-waehlen">
|
||||
{service && (
|
||||
<a className="dm-waehlen__knopf" href={`tel:${service.replace(/\s/g, "")}`}>
|
||||
Service anrufen
|
||||
</a>
|
||||
)}
|
||||
{notfall && (
|
||||
<a className="dm-waehlen__knopf" href={`tel:${notfall.replace(/\s/g, "")}`}>
|
||||
Notfall anrufen
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{gespeichert && <p className="dm-erfolg">Gespeichert.</p>}
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
|
||||
<ActionButton variant="primary" onClick={() => void speichern()} disabled={laeuft}>
|
||||
{laeuft ? "Speichere …" : "Speichern"}
|
||||
</ActionButton>
|
||||
</div>
|
||||
@@ -504,14 +671,23 @@ export function Steuer() {
|
||||
}
|
||||
/>
|
||||
</Feld>
|
||||
{/* Auswahlliste statt freiem Text, und „Nächste Fälligkeit" statt
|
||||
„Fällig am" — wie vSteuerBearbeiten(). Die Zeilen Finanzamt,
|
||||
Grundlage und „Monatlich gerechnet" sowie die Fußnote hatten im
|
||||
Panel keine Entsprechung und sind entfallen; die dazu gespeicherten
|
||||
Profilwerte bleiben unangetastet. Lastschrift ist dort ein Schalter,
|
||||
keine Anzeigezeile. */}
|
||||
<Feld label="Zeitraum">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
value={(entwurf["zeitraum"] as string) ?? ""}
|
||||
<select
|
||||
className="dm-auswahl"
|
||||
value={(entwurf["zeitraum"] as string) ?? "jährlich"}
|
||||
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, zeitraum: e.target.value }))}
|
||||
/>
|
||||
>
|
||||
<option value="jährlich">jährlich</option>
|
||||
<option value="halbjährlich">halbjährlich</option>
|
||||
</select>
|
||||
</Feld>
|
||||
<Feld label="Fällig am">
|
||||
<Feld label="Nächste Fälligkeit">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
type="date"
|
||||
@@ -519,39 +695,19 @@ export function Steuer() {
|
||||
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, faellig: e.target.value }))}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Finanzamt" last>
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
value={(entwurf["finanzamt"] as string) ?? ""}
|
||||
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, finanzamt: e.target.value }))}
|
||||
<Feld label="Lastschrift erteilt" last>
|
||||
<Switch
|
||||
checked={entwurf["lastschrift"] === true}
|
||||
onChange={(an: boolean) => setzeEntwurf((alt) => ({ ...alt, lastschrift: an }))}
|
||||
aria-label="Lastschrift erteilt"
|
||||
/>
|
||||
</Feld>
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile label="Grundlage" wert={(entwurf["grundlage"] as string) || "—"} />
|
||||
<Wertzeile
|
||||
label="Lastschrift"
|
||||
wert={entwurf["lastschrift"] ? "eingerichtet" : "nicht eingerichtet"}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Monatlich gerechnet"
|
||||
wert={
|
||||
entwurf["betrag"] ? `${eur((entwurf["betrag"] as number) / 12)} €` : "—"
|
||||
}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{gespeichert && <p className="dm-erfolg">Gespeichert.</p>}
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
|
||||
<ActionButton variant="primary" onClick={() => void speichern()} disabled={laeuft}>
|
||||
{laeuft ? "Speichere …" : "Speichern"}
|
||||
</ActionButton>
|
||||
</div>
|
||||
<p className="dm-fussnote">
|
||||
{de(1)} Fahrzeug, {(entwurf["zeitraum"] as string) || "jährlich"} zu zahlen.
|
||||
</p>
|
||||
</Tile>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
/**
|
||||
* Löschweg für Listenzeilen — Wischen **und** ein sichtbares Menü.
|
||||
* Löschweg für Listenzeilen: nach links wischen, dann „Löschen" antippen —
|
||||
* genau wie `swipeZeile()` im Panel.
|
||||
*
|
||||
* Behebt einen offenen Befund des Design-Audits (AUDIT_2026-08-10.md §4.1):
|
||||
* Im alten Panel war Löschen ausschließlich über eine Wischgeste erreichbar.
|
||||
* Wer mit Sprachausgabe oder Schaltersteuerung bedient, führt keine
|
||||
* Wischgesten aus und konnte Fahrten und Tankvorgänge deshalb gar nicht
|
||||
* löschen. Das Menü ist immer da, ein richtiger Knopf, mit Tastatur erreichbar.
|
||||
* Bis 2026-08-30 saß rechts neben jeder Zeile zusätzlich ein dauerhaft
|
||||
* sichtbarer „···"-Knopf mit Löschmenü. Er stammte aus einem Befund des
|
||||
* Design-Audits (AUDIT_2026-08-10.md §4.1: Wischgesten sind mit Sprachausgabe
|
||||
* und Schaltersteuerung unerreichbar). Der Eigentümer hat entschieden, dass
|
||||
* Barrierefreiheit für diese beiden Anwendungen nicht gebraucht wird und der
|
||||
* Knopf entfallen soll — er hatte im Panel keine Entsprechung und schob jede
|
||||
* Listenzeile um 44px zusammen.
|
||||
*/
|
||||
|
||||
import { useState, type ReactNode } from "react"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import { Popup, PopupMenuItem, SwipeRow } from "@audi-dash/ui"
|
||||
|
||||
import { SymbolMehr } from "../symbole"
|
||||
import { SwipeRow } from "@audi-dash/ui"
|
||||
|
||||
export function Zeilenmenue({
|
||||
beiLoeschen,
|
||||
@@ -23,32 +24,13 @@ export function Zeilenmenue({
|
||||
loeschFrage: string
|
||||
kinder: ReactNode
|
||||
}) {
|
||||
const [menueOffen, setzeMenueOffen] = useState(false)
|
||||
|
||||
const loeschen = () => {
|
||||
setzeMenueOffen(false)
|
||||
if (window.confirm(loeschFrage)) beiLoeschen()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dm-zeile">
|
||||
<SwipeRow onDelete={loeschen} className="dm-zeile__wisch">
|
||||
{kinder}
|
||||
</SwipeRow>
|
||||
<button
|
||||
type="button"
|
||||
className="dm-zeile__menue"
|
||||
aria-label="Weitere Aktionen"
|
||||
onClick={() => setzeMenueOffen(true)}
|
||||
>
|
||||
<SymbolMehr groesse={18} />
|
||||
</button>
|
||||
<Popup open={menueOffen} onClose={() => setzeMenueOffen(false)} variant="menu" anchor="center">
|
||||
<PopupMenuItem destructive onClick={loeschen}>
|
||||
Löschen
|
||||
</PopupMenuItem>
|
||||
<PopupMenuItem onClick={() => setzeMenueOffen(false)}>Abbrechen</PopupMenuItem>
|
||||
</Popup>
|
||||
</div>
|
||||
<SwipeRow onDelete={loeschen} deleteLabel="Löschen">
|
||||
{kinder}
|
||||
</SwipeRow>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
* gebraucht wird, aber zu speziell für das Design-System ist.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import { useRef, useState, type ReactNode } from "react"
|
||||
|
||||
import { ActionButton, Tile } from "@audi-dash/ui"
|
||||
|
||||
import type { SicherheitsPunkt } from "../api"
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { SymbolZahnradVoll } from "../symbole"
|
||||
import { Bild } from "./Bild"
|
||||
|
||||
/** Kraftstoffsorten zur Auswahl - dieselbe feste Liste wie `KRAFTSTOFFSORTEN`
|
||||
im Panel, geteilt zwischen Tanken.tsx (Neuanlegen) und TankDetail.tsx
|
||||
@@ -69,6 +72,152 @@ export function NaviKachel({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fahrzeugfoto mit Antipp-Menü zum Ersetzen und Löschen — `data-bildklick`
|
||||
* plus `.bildmenu` im Panel. Die App konnte Fotos bisher gar nicht ändern und
|
||||
* verwies auf die Verwaltung in Home Assistant; die Dienste dafür
|
||||
* (`bild_hochladen`/`bild_loeschen`) gibt es längst.
|
||||
*/
|
||||
export function BildMitMenue({
|
||||
datei,
|
||||
beschreibung,
|
||||
name,
|
||||
groesse,
|
||||
menueLage,
|
||||
dateiZeigen,
|
||||
}: {
|
||||
datei: string
|
||||
beschreibung: string
|
||||
name: string
|
||||
groesse?: "default" | "mini" | "round"
|
||||
/** "ansicht" = unter dem Bild zentriert, "rad" = links oben (wie im Panel). */
|
||||
menueLage?: "ansicht" | "rad"
|
||||
dateiZeigen?: boolean
|
||||
}) {
|
||||
const { api } = useDaten()
|
||||
const [offen, setzeOffen] = useState(false)
|
||||
const datei_ = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const hochladen = async (gewaehlt: File) => {
|
||||
const base64 = await new Promise<string>((fertig, fehler) => {
|
||||
const leser = new FileReader()
|
||||
leser.onload = () => fertig(String(leser.result).split(",")[1] ?? "")
|
||||
leser.onerror = () => fehler(leser.error)
|
||||
leser.readAsDataURL(gewaehlt)
|
||||
})
|
||||
await api.bildHochladen(datei, base64)
|
||||
setzeOffen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dm-bildklick">
|
||||
<button type="button" className="dm-bildklick__flaeche" onClick={() => setzeOffen(true)}>
|
||||
<Bild
|
||||
datei={datei}
|
||||
beschreibung={beschreibung}
|
||||
name={name}
|
||||
{...(groesse ? { groesse } : {})}
|
||||
{...(dateiZeigen ? { dateiZeigen: true } : {})}
|
||||
/>
|
||||
</button>
|
||||
<input
|
||||
ref={datei_}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (f) void hochladen(f)
|
||||
e.target.value = ""
|
||||
}}
|
||||
/>
|
||||
{offen && (
|
||||
<>
|
||||
<div className="dm-bildmenu-fang" onClick={() => setzeOffen(false)} />
|
||||
<div className={menueLage === "rad" ? "dm-bildmenu dm-bildmenu--rad" : "dm-bildmenu"}>
|
||||
<button type="button" onClick={() => datei_.current?.click()}>
|
||||
Foto ersetzen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="dm-bildmenu__loeschen"
|
||||
onClick={() => {
|
||||
setzeOffen(false)
|
||||
if (bestaetigen("Foto löschen?")) void api.bildLoeschen(datei)
|
||||
}}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Zahnrad in der rechten oberen Ecke einer Kachel — `.zahnrad` im Panel. Führt
|
||||
* zur Bearbeitung dessen, was die Kachel zeigt. Die Kachel selbst muss dafür
|
||||
* `position: relative` tragen (`.ads-tile` tut das über `.dm-kachel--zahnrad`).
|
||||
*/
|
||||
export function Zahnradknopf({ beschriftung, beiKlick }: { beschriftung: string; beiKlick: () => void }) {
|
||||
return (
|
||||
<button type="button" className="dm-zahnrad" aria-label={beschriftung} onClick={beiKlick}>
|
||||
<SymbolZahnradVoll groesse={17} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Listenzeile im Zuschnitt von `.leaf` im Panel: links Beschriftung mit
|
||||
* kleinerem Zusatz darunter, rechts der Wert mit eigenem Zusatz, danach der
|
||||
* Chevron. Genau diese Aufteilung benutzt das Panel für die Zuletzt-Kachel der
|
||||
* Übersicht, die Fahrten- und die Tankliste sowie die Batterie-Messwerte.
|
||||
*/
|
||||
export function Blattzeile({
|
||||
label,
|
||||
labelZusatz,
|
||||
wert,
|
||||
wertZusatz,
|
||||
beiKlick,
|
||||
letzte,
|
||||
}: {
|
||||
label: ReactNode
|
||||
labelZusatz?: ReactNode
|
||||
wert: ReactNode
|
||||
wertZusatz?: ReactNode
|
||||
/** Ohne Klickziel wird die Zeile ein reines <div>, wie im Panel. */
|
||||
beiKlick?: () => void
|
||||
/** Unterste Zeile einer Kachel — dort entfällt die Trennlinie. */
|
||||
letzte?: boolean
|
||||
}) {
|
||||
const klassen = letzte ? "dm-blatt dm-blatt--letzte" : "dm-blatt"
|
||||
const inhalt = (
|
||||
<>
|
||||
<span className="dm-blatt__k">
|
||||
{label}
|
||||
{labelZusatz !== undefined && <small>{labelZusatz}</small>}
|
||||
</span>
|
||||
<span className="dm-blatt__v">
|
||||
{wert}
|
||||
{wertZusatz !== undefined && <small>{wertZusatz}</small>}
|
||||
</span>
|
||||
{beiKlick && (
|
||||
<svg className="dm-blatt__chevron" viewBox="0 0 6 10" aria-hidden="true">
|
||||
<polyline points="1,1 5,5 1,9" />
|
||||
</svg>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
return beiKlick ? (
|
||||
<button type="button" className={klassen} onClick={beiKlick}>
|
||||
{inhalt}
|
||||
</button>
|
||||
) : (
|
||||
<div className={klassen}>{inhalt}</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Wert mit Beschriftung. „—" statt eines erfundenen Werts, wenn nichts
|
||||
* bekannt ist — die Regel zieht sich durch die ganze App.
|
||||
|
||||
@@ -9,21 +9,47 @@ import { zugangLesenSynchron } from "./zugang"
|
||||
|
||||
export interface Bildplatz {
|
||||
name: string
|
||||
/**
|
||||
* Angezeigte Bezeichnung des Fotos — wortgleich mit `label` in BILDER im
|
||||
* Panel. Sie steht als Alternativtext am Bild und als Beschriftung im
|
||||
* Platzhalter; hier standen vorher eigene, längere Formulierungen
|
||||
* („Auto von der Seite" statt „Auto Seite").
|
||||
*/
|
||||
beschreibung: string
|
||||
datei: string
|
||||
}
|
||||
|
||||
/** Die sieben Plätze der Fotogalerie unter „Mein Audi". */
|
||||
export const BILDPLAETZE: Bildplatz[] = [
|
||||
{ name: "Seitenansicht", beschreibung: "Auto von der Seite", datei: "seitenansicht.webp" },
|
||||
{ name: "Front schräg", beschreibung: "Auto von vorn schräg", datei: "front-schraeg.webp" },
|
||||
{ name: "Frontansicht", beschreibung: "Auto von vorn", datei: "frontansicht.webp" },
|
||||
{ name: "Heckansicht", beschreibung: "Auto von hinten", datei: "heckansicht.webp" },
|
||||
{ name: "Seitenansicht", beschreibung: "Auto Seite", datei: "seitenansicht.webp" },
|
||||
{ name: "Front schräg", beschreibung: "Auto Front schräg", datei: "front-schraeg.webp" },
|
||||
{ name: "Frontansicht", beschreibung: "Auto Front", datei: "frontansicht.webp" },
|
||||
{ name: "Heckansicht", beschreibung: "Auto Heck", datei: "heckansicht.webp" },
|
||||
{ name: "Cockpit", beschreibung: "Innenraum Cockpit", datei: "cockpit.webp" },
|
||||
{ name: "Scheinwerfer", beschreibung: "Scheinwerfer", datei: "scheinwerfer.webp" },
|
||||
{ name: "Sitze", beschreibung: "Innenraum Sitze", datei: "sitze.webp" },
|
||||
]
|
||||
|
||||
/**
|
||||
* Bei montierten Winterrädern zeigt die Seitenansicht das Winterfoto —
|
||||
* `SEITE_WINTER`/`bildInfo()` im Panel. Fehlte hier bisher ganz.
|
||||
*/
|
||||
export const SEITE_WINTER = {
|
||||
beschreibung: "Auto Seite mit Winterrädern",
|
||||
datei: "seitenansicht-winter.webp",
|
||||
} as const
|
||||
|
||||
/** Datei und Bezeichnung eines Galerieplatzes, Winterräder berücksichtigt. */
|
||||
export function bildInfo(
|
||||
platz: Bildplatz,
|
||||
winterraeder: boolean,
|
||||
): { datei: string; beschreibung: string } {
|
||||
if (platz.name === "Seitenansicht" && winterraeder) {
|
||||
return { datei: SEITE_WINTER.datei, beschreibung: SEITE_WINTER.beschreibung }
|
||||
}
|
||||
return { datei: platz.datei, beschreibung: platz.beschreibung }
|
||||
}
|
||||
|
||||
/** Zusätzlich erlaubt, aber außerhalb der Galerie geführt. */
|
||||
export const WEITERE_BILDER = [
|
||||
"seitenansicht-winter.webp",
|
||||
|
||||
@@ -81,7 +81,7 @@ export function SeiteFuer(props: SeitenProps) {
|
||||
case "werkstatt":
|
||||
return <Werkstatt />
|
||||
case "sbuch":
|
||||
return <Servicebuch />
|
||||
return <Servicebuch id={route.id} geheZu={geheZu} />
|
||||
case "reifen":
|
||||
return <Reifen />
|
||||
case "vers":
|
||||
@@ -101,11 +101,11 @@ export function SeiteFuer(props: SeitenProps) {
|
||||
case "trips":
|
||||
return <Fahrten geheZu={geheZu} />
|
||||
case "trip":
|
||||
return <FahrtDetail id={route.id} />
|
||||
return <FahrtDetail id={route.id} geheZu={geheZu} />
|
||||
case "fuel":
|
||||
return <Tanken geheZu={geheZu} />
|
||||
case "fill":
|
||||
return <TankDetail id={route.id} />
|
||||
return <TankDetail id={route.id} geheZu={geheZu} />
|
||||
case "stat":
|
||||
return <Statistik />
|
||||
case "live":
|
||||
|
||||
@@ -126,7 +126,9 @@ describe("Inhalte kommen wirklich aus den Daten", () => {
|
||||
it("gruppiert Fahrten nach Jahr", async () => {
|
||||
zeige("trips")
|
||||
await waitFor(() => expect(screen.getByText("2026")).toBeTruthy())
|
||||
expect(screen.getByText("42,5 km")).toBeTruthy()
|
||||
// Ganze Kilometer in der Liste, wie vTrips() im Panel (`de(t.distance_km)`
|
||||
// ohne Nachkommastelle) - die Nachkommastelle steht auf der Einzelfahrt.
|
||||
expect(screen.getByText("43 km")).toBeTruthy()
|
||||
})
|
||||
|
||||
it("zeigt bei einer Fahrt ohne Position keine erfundene Route", async () => {
|
||||
@@ -143,8 +145,8 @@ describe("Inhalte kommen wirklich aus den Daten", () => {
|
||||
|
||||
it("bietet beim Eintragen einer Fahrt alle Felder, nicht nur Start/Ende/Art", async () => {
|
||||
zeige("trips")
|
||||
await waitFor(() => expect(screen.getByText("Fahrt eintragen")).toBeTruthy())
|
||||
fireEvent.click(screen.getByText("Fahrt eintragen"))
|
||||
await waitFor(() => expect(screen.getByText("Fahrt erfassen")).toBeTruthy())
|
||||
fireEvent.click(screen.getByText("Fahrt erfassen"))
|
||||
expect(screen.getByText("Startort")).toBeTruthy()
|
||||
expect(screen.getByText("Zielort")).toBeTruthy()
|
||||
expect(screen.getByText("Startkilometer")).toBeTruthy()
|
||||
@@ -154,8 +156,8 @@ describe("Inhalte kommen wirklich aus den Daten", () => {
|
||||
|
||||
it("lässt eine bestehende Fahrt bearbeiten, vorbelegt mit ihren Werten", async () => {
|
||||
zeige("trip", "t1")
|
||||
await waitFor(() => expect(screen.getByText("Bearbeiten")).toBeTruthy())
|
||||
fireEvent.click(screen.getByText("Bearbeiten"))
|
||||
await waitFor(() => expect(screen.getByText("Werte bearbeiten")).toBeTruthy())
|
||||
fireEvent.click(screen.getByText("Werte bearbeiten"))
|
||||
expect(screen.getByText("Fahrt bearbeiten")).toBeTruthy()
|
||||
// t1 ist "arbeitsweg" (beispieldaten.ts) - das Auswahlfeld muss das übernehmen,
|
||||
// nicht immer bei "privat" neu anfangen.
|
||||
|
||||
@@ -78,6 +78,9 @@
|
||||
text-transform: none;
|
||||
font-weight: 400;
|
||||
color: var(--fg3);
|
||||
/* .label im Panel setzt line-height:1.3; ohne das stehen mehrzeilige
|
||||
Beschriftungen hier enger als dort. */
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* .quad .l im Panel (Statistik-Kacheln): 11px, keine Großschreibung, normale
|
||||
|
||||
@@ -27,11 +27,20 @@ body {
|
||||
|
||||
/* Wurzel der App: setzt Schrift, Farben und Zahlendarstellung (.ads-root aus
|
||||
der Bibliothek) und spannt sich über die volle Höhe. */
|
||||
/* `-webkit-font-smoothing: antialiased` und `letter-spacing: 0` setzt das
|
||||
Panel auf `.phone` (audi-dashboard-ios.css). Ohne die Graustufen-Glättung
|
||||
rendert dieselbe Schrift in derselben Größe rund 5 % breiter — live
|
||||
gemessen: „Fahrt ohne Ortsangabe" 173px hier gegen 165,4px im Panel. Das
|
||||
betraf jede Zeichenkette der App und war der Grund, warum Listenzeilen hier
|
||||
umbrachen, die im Panel einzeilig stehen. */
|
||||
.dm-wurzel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Tastaturbedienung: im alten Panel als Audit-Befund nachgerüstet, hier von
|
||||
@@ -41,20 +50,20 @@ body {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Ohne Bildlaufleiste, wie `main#view` im Panel (`scrollbar-width:none` plus
|
||||
::-webkit-scrollbar mit Breite 0). Die 8px breite Leiste hier nahm der
|
||||
Inhaltsfläche auf jedem Bildschirm 8px Breite weg - live gemessen 335px
|
||||
Kachelbreite statt der 343px des Panels. */
|
||||
.dm-scroll {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.dm-scroll::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.dm-scroll::-webkit-scrollbar-thumb {
|
||||
background: var(--line-strong);
|
||||
border-radius: var(--r-pill);
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* Formularfelder: mindestens 16px, sonst zoomt iOS Safari beim Fokus die
|
||||
|
||||
@@ -13,34 +13,116 @@
|
||||
|
||||
/* ------------------------------------------------------------- Kopfzeile */
|
||||
|
||||
/* 1:1 die .topbar des Panels (audi-dashboard-ios.css:
|
||||
`padding:56px 20px 4px 16px; gap:4px`, keine Trennlinie). Die 56px oben sind
|
||||
dort die fest einkompilierte Statusleisten-Reserve - das Panel läuft auf dem
|
||||
Telefon als PWA und kennt env() gar nicht. Diese App läuft zusätzlich in
|
||||
einer nativen Hülle, wo die echte Reserve aus dem Gerät kommt; max() liefert
|
||||
deshalb im Browser exakt die 56px des Panels und auf dem Gerät den realen
|
||||
Sicherheitsabstand. */
|
||||
.dm-kopf {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: calc(env(safe-area-inset-top, 0px) + var(--sp-4)) var(--sp-5) var(--sp-3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
gap: var(--sp-1);
|
||||
/* Nur der sichere Bereich, kein Aufschlag: das Panel setzt hier zwar
|
||||
ebenfalls 56px (.topbar in audi-dashboard-ios.css), die liegen dort
|
||||
aber unter der eigenen Kopfleiste von Home Assistant. In der nativen
|
||||
Huelle steht darueber nichts ausser der Statusleiste, dieselbe Zahl
|
||||
ergab deshalb ein sichtbar leeres Band ueber der Ueberschrift.
|
||||
env(safe-area-inset-top) haelt die Statusleiste weiterhin frei -
|
||||
ohne Einzug (Browser, iPad, geraetelose Vorschau) beginnt der Titel
|
||||
jetzt direkt oben. */
|
||||
padding: env(safe-area-inset-top, 0px) 20px 4px 16px;
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
/* Unterseite: kompakter, zentrierter Titel wie in einer iOS-Navigationsleiste
|
||||
(`.phone:has(.back.on) .topbar/.head/.title` im Panel). */
|
||||
.dm-kopf--unterseite {
|
||||
padding: env(safe-area-inset-top, 0px) 16px 6px;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.dm-kopf__titel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dm-kopf--unterseite .dm-kopf__titel {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dm-kopf--unterseite .ads-title {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
/* Datenstand unter dem Titel der Übersicht (`.sync` im Panel). */
|
||||
.dm-stand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 3px;
|
||||
font-size: 12.5px;
|
||||
color: var(--fg3);
|
||||
}
|
||||
|
||||
/* Ziehen zum Aktualisieren (`.ptr` im Panel): ein Band der Höhe 0, das
|
||||
während der Geste wächst. */
|
||||
.dm-ptr {
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: var(--fg3);
|
||||
font-size: 12.5px;
|
||||
transition: height .2s;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.dm-ptr--dreht svg {
|
||||
animation: dm-drehen 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes dm-drehen {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Maße und Farbe wie im Panel: `.back{width:34px;height:34px;margin-left:-8px}`
|
||||
mit `.back svg{width:22px;height:22px;color:var(--fg)}`, und
|
||||
`.rings{min-width:44px;min-height:44px;margin-left:8px;margin-right:-10px;
|
||||
padding:0 10px}`. Der Zurück-Pfeil war hier 44px breit und in --fg2 statt
|
||||
--fg - sichtbar heller und weiter eingerückt als im Panel. */
|
||||
.dm-kopf__zurueck,
|
||||
.dm-kopf__aktion {
|
||||
flex: none;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: var(--r-pill);
|
||||
background: transparent;
|
||||
color: var(--fg2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dm-kopf__zurueck {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
margin-left: -8px;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.dm-kopf__aktion {
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
margin-left: var(--sp-2);
|
||||
margin-right: -10px;
|
||||
padding: 0 10px;
|
||||
color: var(--fg2);
|
||||
}
|
||||
|
||||
.dm-kopf__zurueck:hover,
|
||||
.dm-kopf__aktion:hover {
|
||||
background: var(--shade);
|
||||
@@ -54,12 +136,37 @@
|
||||
|
||||
/* ---------------------------------------------------------- Inhaltsfläche */
|
||||
|
||||
/* Seitenrand und Kachelabstand kommen 1:1 vom Panel (`main#view{--seitenrand:16px;
|
||||
padding:0 var(--seitenrand) 30px}` in audi-dashboard-ios.css). Bis 2026-08-30
|
||||
standen hier --sp-4/--sp-5 aus dem Design-System: 22px Seitenrand statt 16px
|
||||
(jede Kachel 20px schmaler als im Panel), 16px Innenabstand oben, den das Panel
|
||||
gar nicht hat, und zusätzlich zum margin-bottom der Kachel noch ein
|
||||
flex-gap - live gemessen ergab das 24px zwischen zwei Kacheln, wo das Panel
|
||||
14px zeigt. Das war app-weit der größte Formatunterschied überhaupt. Der
|
||||
Abstand kommt jetzt wie im Panel allein aus dem margin-bottom der Kachel,
|
||||
deshalb kein gap mehr. */
|
||||
.dm-inhalt {
|
||||
--seitenrand: 16px;
|
||||
flex: 1;
|
||||
padding: var(--sp-4) var(--sp-5) calc(env(safe-area-inset-bottom, 0px) + var(--sp-5));
|
||||
padding: 0 var(--seitenrand) 30px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
/* Kachelmaße wie im Panel (`.tile{padding:16px 18px;margin-bottom:14px}`,
|
||||
audi-dashboard-ios.css). @audi-dash/ui leitet beides aus --sp-5/--sp-3 ab
|
||||
(22px rundum, 12px Abstand) - als markenneutraler Kit richtig, hier aber
|
||||
sichtbar anders als das Panel. Gleiches Muster wie die Schrift-Auflagen in
|
||||
audi-schrift.css: design-system bleibt unangetastet, companion-app legt
|
||||
lokal die gemessenen Panel-Werte darüber. */
|
||||
.ads-tile {
|
||||
padding: 16px 18px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
/* .tile.flat setzt im Panel nur das Padding zurück, der Abstand bleibt. */
|
||||
.ads-tile--flat {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Inhalte dürfen nie gestaucht werden — die Fläche scrollt stattdessen.
|
||||
@@ -188,11 +295,22 @@
|
||||
|
||||
/* ------------------------------------------------------------ Tab-Leiste */
|
||||
|
||||
/* Die Menüleiste des Panels liegt als durchscheinende, weichgezeichnete
|
||||
Fläche über dem Inhalt (`.tabbar` in audi-dashboard-ios.css:
|
||||
rgba(12,16,20,.72) bzw. rgba(242,242,247,.78) plus
|
||||
backdrop-filter:saturate(180%) blur(24px), dazu 26px Polster unten für den
|
||||
Home-Indikator). Hier war sie deckend und ohne dieses Polster. */
|
||||
.dm-tableiste {
|
||||
flex: none;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
padding-bottom: max(env(safe-area-inset-bottom, 0px), 26px);
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--canvas);
|
||||
background: rgba(12, 16, 20, .72);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(24px);
|
||||
backdrop-filter: saturate(180%) blur(24px);
|
||||
}
|
||||
|
||||
[data-theme="tag"] .dm-tableiste {
|
||||
background: rgba(242, 242, 247, .78);
|
||||
}
|
||||
|
||||
.dm-seitenleiste__marke {
|
||||
@@ -205,6 +323,7 @@
|
||||
.dm-szene__marke {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
/* `.badge` im Panel: 10px zwischen Typenschild und Zusatz. */
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -101,17 +101,20 @@ input[type="date"].dm-eingabe {
|
||||
background: var(--tile);
|
||||
}
|
||||
|
||||
/* `.notiz` im Panel: --ios-fill statt --tile-2, 12px Radius, 14px Abstand
|
||||
nach oben, Zeilenhöhe 1,55 und normaler Schnitt. */
|
||||
.dm-notiz {
|
||||
font: inherit;
|
||||
font-weight: 300;
|
||||
font-size: 16px;
|
||||
color: var(--fg);
|
||||
background: var(--tile-2);
|
||||
background: var(--ios-fill);
|
||||
border: 0;
|
||||
border-radius: var(--r-func);
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
margin-top: 14px;
|
||||
width: 100%;
|
||||
min-height: 88px;
|
||||
line-height: 1.55;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
}
|
||||
@@ -187,7 +190,7 @@ input[type="date"].dm-eingabe {
|
||||
}
|
||||
|
||||
.dm-einrichtung__hinweis {
|
||||
font-size: 11.5px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 300;
|
||||
line-height: 1.5;
|
||||
color: var(--fg3);
|
||||
@@ -196,27 +199,27 @@ input[type="date"].dm-eingabe {
|
||||
|
||||
/* ------------------------------------------------- Leer- und Ladezustände */
|
||||
|
||||
/* `.leer` im Panel: linksbündig und knapp gesetzt (gap 8px, padding 4px 0 2px),
|
||||
Titel 16px/400 in --fg, Text 13,5px in --fg2. Hier war der Leerzustand
|
||||
zentriert, mit 44px Polster und dünnerem Schnitt - deutlich mehr Fläche als
|
||||
im Panel. */
|
||||
.dm-leer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: var(--sp-2);
|
||||
padding: 44px 24px;
|
||||
padding: 4px 0 2px;
|
||||
color: var(--fg2);
|
||||
}
|
||||
|
||||
.dm-leer__titel {
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
font-weight: 400;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.dm-leer__text {
|
||||
font-size: 13.5px;
|
||||
font-weight: 300;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
max-width: 34ch;
|
||||
margin: 0;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
@@ -228,7 +231,7 @@ input[type="date"].dm-eingabe {
|
||||
gap: var(--sp-2);
|
||||
padding: 44px 24px;
|
||||
color: var(--fg2);
|
||||
font-size: 13.5px;
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
@@ -238,10 +241,59 @@ input[type="date"].dm-eingabe {
|
||||
Fahrzeugbild und Name/Kennzeichen sitzen direkt auf dem Seitenhintergrund,
|
||||
nicht in einer grauen Karte. War hier bis 2026-08-30 fälschlich als
|
||||
Kachel mit Hintergrund gebaut (Parität-Audit). */
|
||||
/* Randloses Fahrzeugbild wie im Panel (`.szene.bleed{margin:-10px -20px 0;
|
||||
padding:0 0 10px}` plus `.szene.bleed .carbox{padding:0 8px}` und der
|
||||
Verlauf nach unten in die Fläche). Hier war das Bild bisher auf die
|
||||
Inhaltsbreite eingerückt und in eine abgerundete Kachel geschnitten -
|
||||
im Panel läuft es über die volle Bildschirmbreite und blendet nach unten
|
||||
aus. */
|
||||
.dm-szene {
|
||||
position: relative;
|
||||
border-radius: var(--r-tile);
|
||||
overflow: hidden;
|
||||
margin: -10px calc(-1 * var(--seitenrand)) 0;
|
||||
padding: 0 8px 10px;
|
||||
}
|
||||
|
||||
/* Die drei oberen Kacheln der Übersicht tragen im Panel eigene Abstände
|
||||
(20/22/22 statt der 14 aller anderen). */
|
||||
.dm-kopfblock {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.dm-tankkachel,
|
||||
.dm-servicekachel {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.dm-szene::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 42%;
|
||||
background: linear-gradient(to bottom, transparent, var(--canvas) 78%);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Bodenschatten unter dem Fahrzeug (`.szene.strasse::after` /
|
||||
`.szene.winter::after` im Panel, abschaltbar über den Schalter „Boden" in
|
||||
den Einstellungen). */
|
||||
.dm-szene--strasse::after,
|
||||
.dm-szene--winter::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 8%;
|
||||
right: 8%;
|
||||
bottom: 6px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(ellipse at center, var(--shade), transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dm-szene--winter::after {
|
||||
background: radial-gradient(ellipse at center, rgba(150, 190, 255, .16), transparent 70%);
|
||||
}
|
||||
|
||||
/* Panel: .carbox ~120px, .szene ~130px hoch - ein schmales, breites Band,
|
||||
@@ -253,6 +305,35 @@ input[type="date"].dm-eingabe {
|
||||
aspect-ratio: 16 / 8;
|
||||
}
|
||||
|
||||
/* Ohne Foto bleibt die Fläche flach und läuft nicht randlos: sonst stünden
|
||||
172px leerer Platzhalter vor der Reichweite, der wichtigsten Zahl der
|
||||
Seite (`.bildbox.bild-fehlt.carfix.klein{height:120px}` und
|
||||
`.szene.bleed:has(.bild-fehlt)` im Panel). */
|
||||
/* 72px statt 120px: ohne hinterlegtes Foto stand hier eine leere Flaeche
|
||||
direkt ueber der Reichweite, der wichtigsten Zahl der Seite. Der
|
||||
Platzhalter bleibt sichtbar - er ist der einzige Hinweis, dass es diesen
|
||||
Bildplatz gibt - nur eben schmal (Designpruefung 2026-08-30). */
|
||||
.dm-szene .ads-bildbox:has(.ads-platzhalter) {
|
||||
aspect-ratio: auto;
|
||||
height: 72px;
|
||||
}
|
||||
|
||||
.dm-galerie-wechsel .ads-bildbox:has(.ads-platzhalter) {
|
||||
aspect-ratio: auto;
|
||||
height: 132px;
|
||||
}
|
||||
|
||||
.dm-szene:has(.ads-platzhalter) {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.dm-szene:has(.ads-platzhalter)::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dm-szene .ads-bild,
|
||||
.dm-szene img {
|
||||
width: 100%;
|
||||
@@ -265,8 +346,12 @@ input[type="date"].dm-eingabe {
|
||||
(Nutzerbericht "nicht linksbündig zum Rest der Seite", live gemessen:
|
||||
38px hier gegen 22px bei "Reichweite" - im Panel beide bei 16px). Nur
|
||||
oben/unten bleibt Abstand (zum Bild bzw. zur nächsten Kachel). */
|
||||
.dm-szene__text {
|
||||
padding: var(--sp-4) 0 var(--sp-5);
|
||||
/* Ohne eigenes Polster: im Panel sitzen Typenschild, Kennzeichen und
|
||||
Statuszeile direkt in einer randlosen `.tile.flat` (padding 0), das
|
||||
Kennzeichen mit 10px Abstand zum Schild. Die 16/22px hier machten den Block
|
||||
36px höher als im Panel (dort 89px, hier gemessen 125px). */
|
||||
.dm-szene__text > .ads-eyebrow {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* Übersicht: "Nächster Service" als tippbare Kennzahl innerhalb der Kachel,
|
||||
@@ -298,11 +383,14 @@ button.dm-serviceblock:active {
|
||||
}
|
||||
.dm-serviceblock__chevron {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
top: 3px;
|
||||
right: 0;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
width: 6px;
|
||||
height: 10px;
|
||||
color: var(--fg3);
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.4;
|
||||
}
|
||||
/* Serviceart-Icon links der Kennzahl statt des Wortes "bis zum/zur ..." -
|
||||
deckungsgleich mit .fig-zeile im Panel (audi-dashboard.css). */
|
||||
@@ -324,20 +412,311 @@ button.dm-serviceblock:active {
|
||||
color: var(--fg2);
|
||||
flex: none;
|
||||
}
|
||||
/* `.zahnrad` im Panel: rechts oben in der Kachel, 44px Trefferfläche,
|
||||
17px Symbol. */
|
||||
.dm-kachel--zahnrad {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dm-kachel--zahnrad > .ads-eyebrow:first-child {
|
||||
padding-right: 44px;
|
||||
}
|
||||
|
||||
.dm-zahnrad {
|
||||
position: absolute;
|
||||
top: 11px;
|
||||
right: 11px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: none;
|
||||
color: var(--fg3);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dm-zahnrad:hover,
|
||||
.dm-zahnrad:active {
|
||||
background: var(--tile-2);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.dm-zahnrad:active {
|
||||
transform: scale(.92);
|
||||
}
|
||||
|
||||
/* Rufnummern der Notruf-Kachel: sehen aus wie Aktionsknöpfe, sind aber Links
|
||||
(`<a class="aktion" href="tel:…">` im Panel). */
|
||||
.dm-waehlknopf {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* `.tile` der Kfz-Steuer steht im Panel mit 26px Abstand nach der
|
||||
Vertragskachel. */
|
||||
.dm-steuerkachel {
|
||||
margin-top: 26px;
|
||||
}
|
||||
|
||||
.dm-diagramm__fuss {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* Der Zoom-Knopf ist im Panel eine schmale Variante von `.aktion`
|
||||
(`padding:8px 16px;font-size:11px;width:auto;margin-top:0`). */
|
||||
.dm-zoomzurueck {
|
||||
width: auto;
|
||||
min-height: 0;
|
||||
margin-top: 0;
|
||||
padding: 8px 16px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dm-diagramm__tooltip {
|
||||
margin-top: var(--sp-3);
|
||||
font-size: 13px;
|
||||
color: var(--fg2);
|
||||
}
|
||||
|
||||
/* Ruhespannungs-Skala auf der Batterieseite (Panel: .bv-skala-kopf plus der
|
||||
Farbverlauf rot → gelb → grün und die drei Eckwerte darunter). */
|
||||
.dm-skalakopf {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 16px;
|
||||
font-size: 13px;
|
||||
letter-spacing: .01em;
|
||||
color: var(--fg3);
|
||||
}
|
||||
|
||||
.dm-skalabalken {
|
||||
margin-top: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--r-pill);
|
||||
background: linear-gradient(to right, var(--bad), var(--warn), var(--ok));
|
||||
}
|
||||
|
||||
.dm-skalamarken {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.dm-skalamarken small {
|
||||
font-size: 12.5px;
|
||||
color: var(--fg3);
|
||||
}
|
||||
|
||||
.dm-bewertung {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.dm-bewertungstext {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
color: var(--fg2);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Antipp-Menü am Fahrzeugfoto (`.bildmenu` im Panel, iOS-Auflage: kein
|
||||
Rahmen, durchscheinend mit Weichzeichner). */
|
||||
.dm-bildklick {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dm-bildklick__flaeche {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dm-bildmenu-fang {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.dm-bildmenu {
|
||||
position: absolute;
|
||||
z-index: 1001;
|
||||
bottom: var(--sp-5);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
min-width: 160px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
padding: var(--sp-2);
|
||||
background: rgba(40, 44, 52, .92);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(22px);
|
||||
backdrop-filter: saturate(180%) blur(22px);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, .5);
|
||||
}
|
||||
|
||||
[data-theme="tag"] .dm-bildmenu {
|
||||
background: rgba(250, 250, 252, .92);
|
||||
}
|
||||
|
||||
.dm-bildmenu--rad {
|
||||
bottom: auto;
|
||||
top: 90px;
|
||||
left: 22px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.dm-bildmenu button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: var(--fg);
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
padding: 13px 16px;
|
||||
min-height: 46px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dm-bildmenu button:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.dm-bildmenu button:active {
|
||||
background: var(--shade);
|
||||
}
|
||||
|
||||
.dm-bildmenu__loeschen {
|
||||
color: var(--bad);
|
||||
}
|
||||
|
||||
/* Name des Autohauses: 15px in --fg, wie im Panel. */
|
||||
.dm-autohaus__name {
|
||||
font-size: 15px;
|
||||
margin-top: var(--sp-3);
|
||||
}
|
||||
|
||||
/* Fehlender Wert im technischen Datenblatt: „noch offen" in --fg3, wie im
|
||||
Panel (`<dd style="color:var(--fg3)">`). */
|
||||
.dm-offen {
|
||||
color: var(--fg3);
|
||||
}
|
||||
|
||||
/* Zweitzeile unter der Bezeichnung eines Beitragspostens (SF + Beitragssatz). */
|
||||
.dm-beitragsposten__sf {
|
||||
color: var(--fg3);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Statistik */
|
||||
|
||||
/* `.tile-h` im Panel: der ganze Kachelkopf ist der Schalter zum Aufklappen,
|
||||
ohne eigenes Aussehen. */
|
||||
.dm-statkopf {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dm-statkopf__kopfzeile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.dm-statkopf__kopfzeile .ads-eyebrow {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dm-statkopf[aria-expanded="true"] .ads-mark {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.dm-statkopf__zeile {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
margin-top: var(--sp-3);
|
||||
}
|
||||
|
||||
.dm-statkopf__neben {
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
/* `.quad` im Panel: vier Werte nebeneinander, Zahl im Wide-Schnitt. */
|
||||
.dm-quad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--sp-3);
|
||||
margin-top: var(--sp-3);
|
||||
}
|
||||
|
||||
.dm-quad__n {
|
||||
display: block;
|
||||
font-family: var(--font-wide);
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
color: var(--fg);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.dm-quad__l {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
letter-spacing: normal;
|
||||
color: var(--fg3);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.dm-warnton {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
/* `.wert` im Panel: der normale Fließtext einer Kachel. */
|
||||
.dm-wert {
|
||||
font-size: 14px;
|
||||
letter-spacing: normal;
|
||||
color: var(--fg2);
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
|
||||
.dm-servicezeile {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-4);
|
||||
margin-top: var(--sp-3);
|
||||
margin-top: 14px;
|
||||
padding-top: 13px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.dm-servicezeile__k {
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
color: var(--fg2);
|
||||
}
|
||||
|
||||
/* Service-Seite, "Anstehende Termine": drei sichtbar getrennte Bloecke statt
|
||||
einer Wertzeilen-Liste - deckungsgleich mit .termingruppe im Panel
|
||||
@@ -362,12 +741,12 @@ button.dm-serviceblock:active {
|
||||
flex: none;
|
||||
}
|
||||
.dm-tgkopf span {
|
||||
font-size: 16.5px;
|
||||
font-size: 16px;
|
||||
color: var(--fg);
|
||||
font-weight: 400;
|
||||
}
|
||||
.dm-tgvorgabe {
|
||||
font-size: 11.5px;
|
||||
font-size: 12.5px;
|
||||
color: var(--fg2);
|
||||
margin-top: 8px;
|
||||
}
|
||||
@@ -388,7 +767,7 @@ button.dm-serviceblock:active {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.dm-tgprognose {
|
||||
font-size: 12px;
|
||||
font-size: 12.5px;
|
||||
color: var(--fg2);
|
||||
margin-top: 6px;
|
||||
}
|
||||
@@ -568,7 +947,7 @@ button.dm-serviceblock:active {
|
||||
|
||||
.dm-fussnote {
|
||||
margin: var(--sp-3) 0 0;
|
||||
font-size: 11.5px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 300;
|
||||
line-height: 1.5;
|
||||
color: var(--fg3);
|
||||
@@ -619,7 +998,7 @@ button.dm-serviceblock:active {
|
||||
.dm-sicherheitszeile:last-child { border-bottom: 0; }
|
||||
button.dm-sicherheitszeile { cursor: pointer; }
|
||||
button.dm-sicherheitszeile:active { background: var(--tile-2); }
|
||||
.dm-sicherheitszeile__text { flex: 1; font-size: 14.5px; }
|
||||
.dm-sicherheitszeile__text { flex: 1; font-size: 14px; }
|
||||
.dm-sicherheitszeile__chev { margin-left: auto; flex: none; color: var(--fg3); }
|
||||
|
||||
/* Sammelzeile über den vier Sicherheitszeilen - deckungsgleich mit dem
|
||||
@@ -648,34 +1027,101 @@ button.dm-sicherheitszeile:active { background: var(--tile-2); }
|
||||
|
||||
/* ------------------------------------------------------- Listenzeilen */
|
||||
|
||||
.dm-zeile {
|
||||
/* `.dm-zeile`/`.dm-zeile__wisch`/`.dm-zeile__menue` sind mit dem „···"-Knopf
|
||||
entfallen (siehe Zeilenmenue.tsx) - die Zeile ist jetzt direkt die
|
||||
Wischzeile, wie im Panel. */
|
||||
|
||||
/* Zuschnitt von `.leaf` im Panel (audi-dashboard.css + iOS-Auflage, live
|
||||
gemessen): 12px oben/unten, 40px links (Platz der Wischspalte), min-height
|
||||
56px, Trennlinie unten, Beschriftung 16px in --fg mit 12,5px-Zusatz in
|
||||
--fg3, Wert 15px in --fg2 mit demselben Zusatz. */
|
||||
.dm-blatt {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.dm-zeile__wisch {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Immer sichtbarer Zugang zu denselben Aktionen wie die Wischgeste — ohne ihn
|
||||
waere Loeschen fuer Sprachausgabe und Schaltersteuerung unerreichbar. */
|
||||
.dm-zeile__menue {
|
||||
flex: none;
|
||||
width: 44px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-4);
|
||||
width: 100%;
|
||||
padding: 12px 0 12px 40px;
|
||||
min-height: 56px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--fg3);
|
||||
border-radius: var(--r-func);
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.dm-zeile__menue:hover {
|
||||
background: var(--shade);
|
||||
.dm-blatt--letzte {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
button.dm-blatt {
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
}
|
||||
|
||||
button.dm-blatt:active {
|
||||
background: var(--tile-2);
|
||||
}
|
||||
|
||||
/* `flex: 0 1 auto` wie `.leaf .k` im Panel — die Beschriftung nimmt ihre
|
||||
natürliche Breite und schrumpft erst, wenn es sonst nicht passt. Mit
|
||||
`1 1 auto` (dem Wert, den das Panel nur für die Zuletzt-Kachel setzt)
|
||||
brach „Fahrt ohne Ortsangabe" hier auf zwei Zeilen um, obwohl es im Panel
|
||||
einzeilig steht. */
|
||||
.dm-blatt__k {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
font-size: 16px;
|
||||
line-height: 1.35;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.dm-blatt__v {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
text-align: right;
|
||||
font-size: 15px;
|
||||
color: var(--fg2);
|
||||
}
|
||||
|
||||
.dm-blatt__k small,
|
||||
.dm-blatt__v small {
|
||||
font-size: 12.5px;
|
||||
color: var(--fg3);
|
||||
}
|
||||
|
||||
.dm-blatt__chevron {
|
||||
width: 6px;
|
||||
height: 10px;
|
||||
flex: 0 0 auto;
|
||||
fill: none;
|
||||
stroke: var(--fg3);
|
||||
stroke-width: 1.6;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* Die Zuletzt-Kachel der Übersicht: ohne die linke Wischspalte, und beide
|
||||
Werte auf einer gemeinsamen linken Kante (`.teaser .leaf .v{min-width:72px}`
|
||||
im Panel, samt der dort ausführlich begründeten flex-Verteilung). */
|
||||
.dm-teaser .dm-blatt {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.dm-teaser .dm-blatt__k {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.dm-teaser .dm-blatt__v {
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
.dm-listenzeile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -718,7 +1164,7 @@ button.dm-listenzeile {
|
||||
}
|
||||
|
||||
.dm-listenzeile__unter {
|
||||
font-size: 12px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 300;
|
||||
color: var(--fg3);
|
||||
/* Umbrechen statt abschneiden: Datum, Uhrzeit und Dauer sind alle drei
|
||||
@@ -843,7 +1289,7 @@ button.dm-listenzeile {
|
||||
list-style: none;
|
||||
margin: var(--sp-2) 0 0;
|
||||
padding: 0;
|
||||
font-size: 13.5px;
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
line-height: 1.7;
|
||||
}
|
||||
@@ -868,11 +1314,14 @@ button.dm-listenzeile {
|
||||
color: var(--fg2);
|
||||
}
|
||||
|
||||
/* 170px hoch wie BV_H im Panel — 120px ließen die Kurve sichtbar flacher
|
||||
wirken als dort. */
|
||||
.dm-diagramm {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
margin: var(--sp-3) 0;
|
||||
height: 170px;
|
||||
margin: 14px 0 0;
|
||||
display: block;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.dm-knopfreihe {
|
||||
@@ -945,7 +1394,7 @@ button.dm-listenzeile {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-size: 12.5px;
|
||||
letter-spacing: normal;
|
||||
color: var(--fg2);
|
||||
background: none;
|
||||
@@ -992,7 +1441,7 @@ button.dm-listenzeile {
|
||||
padding: 10px 16px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-pill);
|
||||
font-size: 13.5px;
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
color: var(--fg);
|
||||
text-decoration: none;
|
||||
@@ -1053,7 +1502,8 @@ button.dm-listenzeile {
|
||||
position: relative;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0 0 var(--sp-3);
|
||||
/* 14px wie jede andere Kachel im Panel (.tile). */
|
||||
margin: 0 0 14px;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
border-radius: var(--r-tile);
|
||||
@@ -1062,7 +1512,13 @@ button.dm-listenzeile {
|
||||
.dm-standort-vorschau {
|
||||
width: 100%;
|
||||
height: 190px;
|
||||
background: var(--tile);
|
||||
/* Im Panel deklariert .standort-vorschau zwar var(--tile), gewinnt aber
|
||||
nicht: Leaflet setzt der Karte selbst die Klasse .leaflet-container und
|
||||
sein Blatt liegt dort hinter den App-Blaettern. Sichtbar sind also
|
||||
tatsaechlich Leaflets eigene #ddd - waehrend die Kacheln laden und in
|
||||
Luecken. Hier steht derselbe Wert ausdruecklich, damit die Reihenfolge
|
||||
der gebuendelten Blaetter nichts daran aendert. */
|
||||
background: #ddd;
|
||||
}
|
||||
.dm-standort-leer {
|
||||
position: absolute;
|
||||
@@ -1132,7 +1588,7 @@ button.dm-listenzeile {
|
||||
min-height: 400px;
|
||||
border-radius: var(--r-tile);
|
||||
overflow: hidden;
|
||||
margin: 0 calc(-1 * var(--sp-5));
|
||||
margin: 0 calc(-1 * var(--seitenrand));
|
||||
}
|
||||
.dm-standort-karte {
|
||||
width: 100%;
|
||||
@@ -1236,7 +1692,7 @@ button.dm-listenzeile {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.dm-standortmenu__adresse {
|
||||
font-size: 13.5px;
|
||||
font-size: 13px;
|
||||
color: var(--fg2);
|
||||
margin-top: 20px;
|
||||
}
|
||||
@@ -1245,7 +1701,7 @@ button.dm-listenzeile {
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin-top: 8px;
|
||||
font-size: 13.5px;
|
||||
font-size: 13px;
|
||||
color: var(--fg2);
|
||||
}
|
||||
.dm-standortmenu__status svg {
|
||||
@@ -1264,7 +1720,7 @@ button.dm-listenzeile {
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
font-size: 13.5px;
|
||||
font-size: 13px;
|
||||
color: var(--fg2);
|
||||
}
|
||||
.dm-standortmenu__werte svg {
|
||||
@@ -1304,3 +1760,55 @@ button.dm-listenzeile {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Erläuternder Absatz unter einer Kachelüberschrift. Das Panel setzt ihn als
|
||||
`.label` mit einer Inline-Auflage (`color:var(--fg2);letter-spacing:0;
|
||||
text-transform:none;font-size:12.5px;line-height:1.6`) - unter anderem bei
|
||||
Fahrzeugprofil, Backup und Integration-Update.
|
||||
|
||||
Bewusst eine eigene Klasse statt einer Änderung an `.dm-fussnote`: die wird
|
||||
an 35 Stellen in 13 Dateien für ganz unterschiedliche Kleintexte benutzt
|
||||
(im Paritätsaudit zweimal als offener Punkt notiert). Wer die Panel-Optik
|
||||
braucht, nimmt diese Klasse; alles andere bleibt unangetastet. */
|
||||
.dm-erklaerung {
|
||||
display: block;
|
||||
margin: 10px 0 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
color: var(--fg2);
|
||||
}
|
||||
|
||||
/* Karte auf der Einzelfahrt/dem Einzelbeleg: das Panel setzt sie als eigene
|
||||
Kachel ohne Innenabstand (`.mapbox`, 210px hoch, abgerundet). */
|
||||
.dm-kartenkachel {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Die Distanz-Kachel der Einzelfahrt trägt im Panel eigene Abstände
|
||||
(`style="margin:18px 0 14px"`). */
|
||||
.dm-distanzkachel {
|
||||
margin: 18px 0 14px;
|
||||
}
|
||||
|
||||
/* `.marke` im Panel: Bildmarke und Text nebeneinander, z. B. vor
|
||||
„SmartDeal-Ersparnis" auf dem Einzelbeleg. */
|
||||
.dm-marke {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dm-marke img {
|
||||
height: 13px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* Leere Kartenfläche, solange die Tankstellenadresse noch aufgelöst wird.
|
||||
Maße wie `.mapbox` im Panel. */
|
||||
.dm-kartenplatz {
|
||||
height: 210px;
|
||||
background: var(--tile-2);
|
||||
}
|
||||
|
||||
@@ -165,6 +165,39 @@ export function SymbolZahnrad({ groesse }: SymbolProps) {
|
||||
)
|
||||
}
|
||||
|
||||
/* Das Zahnrad der Kachel-Ecken (`.zahnrad` im Panel) — ein gefülltes Symbol
|
||||
aus dem Audi-CI-Satz, verbatim übernommen. Nicht zu verwechseln mit
|
||||
SymbolZahnrad oben, dem Kontur-Symbol der Seitenleiste. */
|
||||
export function SymbolZahnradVoll({ groesse }: SymbolProps) {
|
||||
return (
|
||||
<RahmenFlaeche
|
||||
groesse={groesse}
|
||||
kinder={
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M9.4 2h4.2v2.84c.37.13.75.27 1.11.44L16.7 3.3l3 3.01-1.98 1.99c.17.36.31.74.44 1.11H21v4.3h-2.84c-.13.37-.27.75-.44 1.11l2 2-3.03 2.89-1.98-1.98c-.36.17-.74.31-1.11.44V21H9.4v-2.84a9 9 0 0 1-1.11-.44L6.3 19.7l-3-3.01 1.98-1.99a9 9 0 0 1-.44-1.11H2V9.4h2.84c.13-.37.27-.75.44-1.11L3.3 6.3l3.01-3L8.3 5.28c.36-.17.74-.31 1.11-.44V2Zm1 1v2.56l-.34.11c-.61.2-1.16.4-1.6.66l-.34.2L6.3 4.71 4.7 6.3l1.83 1.82-.2.34c-.27.44-.45.99-.66 1.6l-.11.34H3v2.2h2.56l.11.34c.2.61.4 1.16.66 1.6l.2.34-1.82 1.82 1.59 1.6 1.82-1.83.34.2c.44.27.99.45 1.6.66l.34.11V20h2.2v-2.56l.34-.11a8.3 8.3 0 0 0 1.6-.66l.34-.2 1.83 1.83 1.57-1.5-1.81-1.82.2-.34c.27-.44.45-.99.66-1.6l.11-.34H20v-2.3h-2.56l-.11-.34a8.2 8.2 0 0 0-.66-1.6l-.2-.34 1.82-1.82-1.59-1.6-1.82 1.83-.34-.2a8.2 8.2 0 0 0-1.6-.66l-.34-.11V3zm-2.9 8.5c0-2.18 1.82-4 4-4s4 1.82 4 4-1.82 4-4 4-4-1.82-4-4m4-3c-1.62 0-3 1.38-3 3s1.38 3 3 3 3-1.38 3-3-1.38-3-3-3"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/* battery-12v-s aus dem Audi-CI-Satz, verbatim wie im Panel (CI.battery12vS) —
|
||||
steht vor der Ruhespannungs-Skala auf der Batterieseite. */
|
||||
export function SymbolBatterie({ groesse }: SymbolProps) {
|
||||
return (
|
||||
<RahmenFlaeche
|
||||
groesse={groesse}
|
||||
kinder={
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5 5h4v2h6V5h4v2h3v12H2V7h3zm1 2h2V6H6zM3 8v10h18V8zm15-1V6h-2v1zm-1 6v2h-1v-2h-2v-1h2v-2h1v2h2v1zm-8 0H5v-1h4z"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SymbolSonne({ groesse }: SymbolProps) {
|
||||
return (
|
||||
<Rahmen
|
||||
@@ -188,20 +221,8 @@ export function SymbolMond({ groesse }: SymbolProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function SymbolMehr({ groesse }: SymbolProps) {
|
||||
return (
|
||||
<Rahmen
|
||||
groesse={groesse}
|
||||
kinder={
|
||||
<g>
|
||||
<circle cx="5" cy="12" r="1.4" />
|
||||
<circle cx="12" cy="12" r="1.4" />
|
||||
<circle cx="19" cy="12" r="1.4" />
|
||||
</g>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/* SymbolMehr (die drei Punkte) ist mit dem „···"-Knopf der Listenzeilen
|
||||
entfallen - es gab keine zweite Verwendung. */
|
||||
|
||||
/**
|
||||
* oil-change-s/inspection-s aus dem Audi-CI-Icon-Satz (vom Nutzer als SVG
|
||||
@@ -262,6 +283,22 @@ export function SymbolEdit({ groesse }: SymbolProps) {
|
||||
)
|
||||
}
|
||||
|
||||
/* Datenstand/Aktualisieren — die beiden Pfeile aus der Kopfzeile des Panels
|
||||
(`#sync`), verbatim übernommen. Fläche statt Kontur, wie dort. */
|
||||
export function SymbolAktualisieren({ groesse }: SymbolProps) {
|
||||
return (
|
||||
<RahmenFlaeche
|
||||
groesse={groesse}
|
||||
kinder={
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m18.65 4.95 2.56 2.55-2.56 2.55-.7-.7L19.29 8H7.2A5.24 5.24 0 0 0 2 13.38V14H1v-.6A6.24 6.24 0 0 1 7.2 7h12.1l-1.34-1.35.7-.7ZM23 11.59A6.24 6.24 0 0 1 16.8 18H4.7l1.35 1.35-.7.7-2.56-2.55 2.56-2.55.7.7L4.71 17H16.8a5.24 5.24 0 0 0 5.2-5.38V11h1v.6Z"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SymbolWarnung({ groesse }: SymbolProps) {
|
||||
return (
|
||||
<Rahmen
|
||||
|
||||
@@ -50,12 +50,44 @@ export function useTheme(): [Theme, (theme: Theme) => void] {
|
||||
// Balken über einer dunklen App steht.
|
||||
useEffect(() => {
|
||||
const marke = document.querySelector('meta[name="theme-color"]')
|
||||
if (marke) marke.setAttribute("content", theme === "nacht" ? "#161b23" : "#ffffff")
|
||||
// Werte des Panels (--canvas in audi-dashboard-ios.css). Hier standen noch
|
||||
// #161b23/#ffffff aus dem Basis-Stylesheet, das im Panel nie greift —
|
||||
// die Systemleiste hatte damit einen anderen Ton als die App darunter.
|
||||
if (marke) marke.setAttribute("content", theme === "nacht" ? "#0C1014" : "#F2F2F7")
|
||||
}, [theme])
|
||||
|
||||
return [theme, setzen]
|
||||
}
|
||||
|
||||
/**
|
||||
* Bodenschatten unter dem Fahrzeugbild — im Panel der Schalter „Boden" in den
|
||||
* Einstellungen (`bodenAn`). Dort lebt er nur im Speicher und ist nach einem
|
||||
* Neuladen wieder an; hier folgt er dem Muster der übrigen Ansichtsschalter
|
||||
* und bleibt erhalten, Standard wie im Panel: an.
|
||||
*/
|
||||
const BODEN_SCHLUESSEL = "dm360.boden"
|
||||
|
||||
export function useBoden(): [boolean, (an: boolean) => void] {
|
||||
const [an, setzeAn] = useState<boolean>(() => {
|
||||
try {
|
||||
return localStorage.getItem(BODEN_SCHLUESSEL) !== "aus"
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
const setzen = useCallback((neu: boolean) => {
|
||||
setzeAn(neu)
|
||||
try {
|
||||
localStorage.setItem(BODEN_SCHLUESSEL, neu ? "an" : "aus")
|
||||
} catch {
|
||||
// Privatmodus
|
||||
}
|
||||
}, [])
|
||||
|
||||
return [an, setzen]
|
||||
}
|
||||
|
||||
/** Sichtbarkeit der Tab-Beschriftungen, ebenfalls dauerhaft gespeichert. */
|
||||
const BESCHRIFTUNG_SCHLUESSEL = "dm360.tabbeschriftung"
|
||||
|
||||
|
||||
@@ -49,6 +49,13 @@ export default defineConfig({
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": { target: "http://localhost:18123", changeOrigin: true, ws: true },
|
||||
// Fahrzeugfotos (/local/bilder/...) und die Statikdateien der
|
||||
// Integration (Markenlogo) liegen ebenfalls auf der HA-Instanz. Ohne
|
||||
// diese beiden Eintraege liefert der Dev-Server sie selbst - also gar
|
||||
// nicht - und jeder optische Abgleich gegen das Panel sieht nur
|
||||
// Platzhalter statt der echten Bilder.
|
||||
"/local": { target: "http://localhost:18123", changeOrigin: true },
|
||||
"/audi_dashboard_static": { target: "http://localhost:18123", changeOrigin: true },
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"version":"2026.8.30.2","sha256":"88922cb0e3a7603b6e4e2fe7daee85513ec9e16c4ae7b2e0229f514bde58915f","bytes":248241,"gebaut":"2026-08-30T04:29:47Z"}
|
||||
{"version":"2026.8.30.8","sha256":"2754d426c7145d000fd2194d4dd85db096c2fcae008eb518584f189becfb0538","bytes":256098,"gebaut":"2026-08-30T16:30:51Z"}
|
||||
Binary file not shown.
@@ -71,6 +71,33 @@ const CSS_VERSION = (() => {
|
||||
Integrationsordner würde sie das nächste Update mitlöschen. */
|
||||
const STATIK = new URL(".", import.meta.url).href.replace(/\/$/, "");
|
||||
|
||||
/* Schriftschnitte MUESSEN im Dokument registriert werden, nicht im Shadow
|
||||
Root: @font-face-Regeln innerhalb eines Schattenbaums ignoriert der Browser
|
||||
grundsaetzlich, Schriftfamilien sind dokumentweit. Weil audi-dashboard.css
|
||||
(mit den eingebetteten Schnitten am Anfang) hier immer nur an ROOT gehaengt
|
||||
wurde, ist das Panel seit jeher auf Helvetica zurueckgefallen, obwohl
|
||||
"Audi Type" ganz vorn in jedem font-family-Stapel steht - beim
|
||||
Paritaetsabgleich 2026-08-30 dadurch aufgefallen, dass dieselbe
|
||||
Zeichenkette im Panel exakt so breit rendert wie in Helvetica, waehrend die
|
||||
companion-app mit derselben, bytegleichen Schriftdatei ~5 % schmaler setzt.
|
||||
Die Schnitte liegen deshalb jetzt in audi-dashboard-schriften.css, und nur
|
||||
diese Datei geht in document.head - sie enthaelt ausschliesslich
|
||||
@font-face und beeinflusst die uebrige HA-Oberflaeche nicht.
|
||||
|
||||
Der Umkehrfall ist in diesem Projekt schon einmal passiert: das
|
||||
Leaflet-Stylesheet lag in document.head und erreichte den Shadow Root nicht
|
||||
(siehe AGENTS.md, "fragmentierte Leaflet-Darstellung"). Beide Faelle haben
|
||||
dieselbe Ursache - Stylesheets wirken je Baum, Schriften nur im Dokument. */
|
||||
const SCHRIFTEN_ID = "audi-dashboard-schriften";
|
||||
function schriftenLaden() {
|
||||
if (document.getElementById(SCHRIFTEN_ID)) return;
|
||||
const link = document.createElement("link");
|
||||
link.id = SCHRIFTEN_ID;
|
||||
link.rel = "stylesheet";
|
||||
link.href = `${STATIK}/audi-dashboard-schriften.css?v=${CSS_VERSION}`;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- Namen des Backends
|
||||
Die eine Stelle, an der die Entity-IDs und Dienstnamen stehen. Sie sind
|
||||
ein Vertrag mit custom_components/audi_dashboard/const.py; die
|
||||
@@ -1190,7 +1217,18 @@ function badge() {
|
||||
|
||||
function teaser() {
|
||||
if (!TRIPS.length && !FILLS.length) return "";
|
||||
const t = TRIPS[0], f = FILLS[0];
|
||||
/* Bis 2026-08-30 stand hier TRIPS[0]/FILLS[0]. Beide Listen kommen vom
|
||||
Backend aufsteigend sortiert (vTrips()/vFuel() geben sie unveraendert in
|
||||
dieser Reihenfolge aus, aelteste Zeile zuerst) - Index 0 war damit der
|
||||
AELTESTE Datensatz, obwohl die Kachel "Letzte Fahrt"/"Letzter
|
||||
Tankvorgang" heisst. Beim Paritaetsabgleich mit der companion-app
|
||||
aufgefallen, die hier von Anfang an nach Zeitstempel sortiert hat.
|
||||
Jetzt beide gleich: der zeitlich juengste Datensatz. */
|
||||
const juengster = (liste, feld) =>
|
||||
liste.length
|
||||
? liste.slice().sort((a, b) => new Date(b[feld]) - new Date(a[feld]))[0]
|
||||
: null;
|
||||
const t = juengster(TRIPS, "ts_start"), f = juengster(FILLS, "ts");
|
||||
return `<div class="tile teaser"><span class="label">Zuletzt</span>
|
||||
${t ? `<button class="leaf" style="padding-left:0" data-go="trip:${t.trip_id}">
|
||||
<span class="k">Letzte Fahrt<small>${esc(t.start_address || "")} → ${esc(t.end_address || "")}</small></span>
|
||||
@@ -1429,7 +1467,10 @@ function vHome() {
|
||||
/* C2/C5/D1: Reihenfolge der myAudi-App - randloses Fahrzeugbild, Name und
|
||||
Status, große Reichweite mit farbigem Balken, dann Aktionskacheln. Die
|
||||
Kacheln führen zu Ansichten statt zu Fernsteuerung, die das Panel bewusst
|
||||
nicht kann. */
|
||||
nicht kann.
|
||||
"Zuletzt" steht seit der Designpruefung 2026-08-30 vor der Standortkarte:
|
||||
zwei kurze, haeufig gelesene Textzeilen lagen bisher unter einer 190px
|
||||
hohen Karte und damit auf dem Telefon unterhalb des ersten Bildschirms. */
|
||||
return `
|
||||
<div class="szene bleed ${szeneKlasse(startIndex())}"><div class="carbox">${carImg("fest")}</div></div>
|
||||
<div class="tile flat" style="margin-bottom:20px">
|
||||
@@ -1506,8 +1547,8 @@ function vHome() {
|
||||
${kmZeile}
|
||||
</div>`;
|
||||
})()}
|
||||
${standortKachel()}
|
||||
${teaser()}`;
|
||||
${teaser()}
|
||||
${standortKachel()}`;
|
||||
}
|
||||
|
||||
/* Kreis-Häkchen (ok) / Kreis-Kreuz (nicht ok) / Kreis-Fragezeichen (unbekannt)
|
||||
@@ -1875,7 +1916,7 @@ function vBatterieverlauf() {
|
||||
? `<span class="dot" style="background:${punktFarbe(soh.status)}"></span>${(soh.delta >= 0 ? "+" : "") + de(soh.delta, 2)} V`
|
||||
: `<span style="color:var(--fg3)">-</span>`}</dd></div>
|
||||
</dl>
|
||||
<span style="display:block;margin-top:10px;color:var(--fg2);font-size:13.5px;line-height:1.6">${soh.text}</span>
|
||||
<span style="display:block;margin-top:10px;color:var(--fg2);font-size:13px;line-height:1.6">${soh.text}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -2134,7 +2175,20 @@ function vService() {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* "Daten bearbeiten" auf der Seite Identität und Technik. Der Knopf war bis
|
||||
2026-08-30 ohne Funktion (kein data-go, kein Handler) - beim
|
||||
Paritätsabgleich aufgefallen und auf Wunsch des Eigentümers jetzt umgesetzt.
|
||||
|
||||
Bearbeitet werden genau die Angaben, für die es sonst nirgends einen Editor
|
||||
gibt: die Werte des technischen Datenblatts (CAR.technik) und die
|
||||
Ausstattungslisten (CAR.ausstattung). Die Kopfdaten darüber - Modell,
|
||||
Kennzeichen, Ausführung, Erstzulassung, FIN - bleiben bewusst außen vor:
|
||||
die pflegt "Fahrzeug einrichten" in den Einstellungen, und zwei Eingabe-
|
||||
stellen für dasselbe Feld laufen erfahrungsgemäß auseinander. */
|
||||
let identBearbeitenOffen = false;
|
||||
|
||||
function vIdent() {
|
||||
if (identBearbeitenOffen) return vIdentBearbeiten();
|
||||
return `
|
||||
<div class="tile"><span class="label">Fahrzeug</span>
|
||||
<dl class="rows" style="margin-top:10px">
|
||||
@@ -2157,7 +2211,51 @@ function vIdent() {
|
||||
${g.sonder.map((x, i) => `<div class="row" ${i === g.sonder.length - 1 ? 'style="border-bottom:0"' : ""}>
|
||||
<dt style="color:var(--fg)">${esc(x)}</dt><dd></dd></div>`).join("")}
|
||||
</dl></div>`).join("")}
|
||||
<button class="aktion">Daten bearbeiten</button>`;
|
||||
<button class="aktion" data-identbearbeiten>Daten bearbeiten</button>`;
|
||||
}
|
||||
|
||||
/* Bearbeitungsansicht: je Technikgruppe eine Kachel mit einem Eingabefeld je
|
||||
Posten, je Ausstattungsgruppe eine Kachel mit einer Zeile je Position -
|
||||
beide mit "×" zum Entfernen und "+ Position hinzufügen", genau im Zuschnitt
|
||||
der Selbstbeteiligung in vVertragBearbeiten(). Gespeichert wird wie überall
|
||||
sonst sofort je Feld über profilSpeichern(). */
|
||||
function vIdentBearbeiten() {
|
||||
const technik = CAR.technik || [];
|
||||
const ausstattung = CAR.ausstattung || [];
|
||||
return `
|
||||
<div class="tile"><span class="label">Daten bearbeiten</span>
|
||||
<span class="label" style="margin-top:10px">Hier stehen das technische Datenblatt und die
|
||||
Ausstattung. Modell, Kennzeichen, Ausführung, Erstzulassung und Fahrgestellnummer pflegst du
|
||||
unter Einstellungen → Fahrzeug einrichten.</span>
|
||||
</div>
|
||||
${technik.map((g, gi) => `<div class="tile"><span class="label">${esc(g.gruppe)}</span>
|
||||
${(g.posten || []).map((pp, i) => `
|
||||
<div class="feld" ${i === g.posten.length - 1 ? 'style="border-bottom:0"' : ""}>
|
||||
<label for="tk${gi}-${i}">${esc(pp[0])}</label>
|
||||
<span style="display:inline-flex;align-items:center;gap:8px">
|
||||
<input id="tk${gi}-${i}" value="${esc(pp[1] || "")}" placeholder="noch offen"
|
||||
data-technik="${gi}|${i}">
|
||||
<button class="zeile-loeschen" data-technikdel="${gi}|${i}"
|
||||
aria-label="${esc(pp[0])} entfernen">×</button>
|
||||
</span>
|
||||
</div>`).join("")}
|
||||
${!(g.posten || []).length ? `<span class="label" style="margin-top:8px">Keine Angaben</span>` : ""}
|
||||
<button class="aktion" data-technikadd="${gi}">+ Position hinzufügen</button>
|
||||
</div>`).join("")}
|
||||
${ausstattung.map((g, gi) => `<div class="tile"><span class="label">${esc(g.gruppe)}</span>
|
||||
${(g.sonder || []).map((x, i) => `
|
||||
<div class="feld" ${i === g.sonder.length - 1 ? 'style="border-bottom:0"' : ""}>
|
||||
<label for="as${gi}-${i}">Position ${i + 1}</label>
|
||||
<span style="display:inline-flex;align-items:center;gap:8px">
|
||||
<input id="as${gi}-${i}" value="${esc(x)}" data-ausstattung="${gi}|${i}">
|
||||
<button class="zeile-loeschen" data-ausstattungdel="${gi}|${i}"
|
||||
aria-label="Position ${i + 1} entfernen">×</button>
|
||||
</span>
|
||||
</div>`).join("")}
|
||||
${!(g.sonder || []).length ? `<span class="label" style="margin-top:8px">Keine Ausstattung hinterlegt</span>` : ""}
|
||||
<button class="aktion" data-ausstattungadd="${gi}">+ Position hinzufügen</button>
|
||||
</div>`).join("")}
|
||||
<button class="aktion primaer" data-identbearbeiten>Fertig</button>`;
|
||||
}
|
||||
|
||||
function monatePlus(datum, monate) { const d = dat(datum); d.setMonth(d.getMonth() + monate); return d; }
|
||||
@@ -2686,7 +2784,7 @@ function vEinst() {
|
||||
if (laeuft === "installieren") return spinner("Installiere …");
|
||||
if (laeuft === "neustart") return spinner("Home Assistant startet neu …");
|
||||
if (u && u.installiert) {
|
||||
return `<span style="display:block;margin-top:8px;color:var(--fg2);font-size:13.5px;line-height:1.6">
|
||||
return `<span style="display:block;margin-top:8px;color:var(--fg2);font-size:13px;line-height:1.6">
|
||||
Version ${esc(u.version)} installiert - die neue Fassung wird erst nach einem Neustart
|
||||
geladen.</span>
|
||||
<button class="aktion" style="margin-top:14px" data-update="neustart">Installation abschließen - Jetzt neu starten</button>`;
|
||||
@@ -2694,13 +2792,13 @@ function vEinst() {
|
||||
if (laeuft === "pruefen") return spinner("Prüfe …");
|
||||
if (u && u.verfuegbar) {
|
||||
return `<dl class="rows"><div class="row" style="border-bottom:0"><dt>Verfügbare Version</dt><dd>${esc(u.version)}</dd></div></dl>
|
||||
<span style="display:block;margin-top:8px;color:var(--fg2);font-size:13.5px;line-height:1.6">
|
||||
<span style="display:block;margin-top:8px;color:var(--fg2);font-size:13px;line-height:1.6">
|
||||
Lädt die neueste Fassung von Gitea und ersetzt den Integrationsordner. Home Assistant
|
||||
muss danach neu gestartet werden. Fahrzeugprofil, Fahrten, Tankvorgänge und eigene
|
||||
Fotos bleiben dabei unangetastet - die liegen außerhalb des Integrationsordners.</span>
|
||||
<button class="aktion" style="margin-top:14px" data-update="installieren">Update installieren</button>`;
|
||||
}
|
||||
return `<span style="display:block;margin-top:8px;color:var(--fg2);font-size:13.5px;line-height:1.6">
|
||||
return `<span style="display:block;margin-top:8px;color:var(--fg2);font-size:13px;line-height:1.6">
|
||||
${u ? `Integration ist aktuell${u.geprueft_am ? ` (geprüft ${dedat(new Date(u.geprueft_am))})` : ""}.` : "Noch nicht geprüft."}</span>
|
||||
<button class="aktion" style="margin-top:14px" data-update="pruefen">Auf Update prüfen</button>`;
|
||||
})()}
|
||||
@@ -3171,6 +3269,15 @@ function vTripsFormular() {
|
||||
<button class="aktion primaer" data-fahrtspeichern>Speichern</button></div>` : ""}
|
||||
<button class="aktion ${fahrtOffen ? "" : "primaer"}" data-fahrt>${fahrtOffen ? "Abbrechen" : "Fahrt erfassen"}</button>`;
|
||||
}
|
||||
/* Jahre laufen absteigend, Monate und Zeilen liefen bis zur Designpruefung
|
||||
2026-08-30 aufsteigend - in derselben Liste zwei Richtungen, und die
|
||||
neueste Fahrt stand ganz unten, ausserhalb des sichtbaren Bereichs.
|
||||
neuesteZuerst() dreht Zeilen und Monatsreihenfolge um; der zuerst
|
||||
aufgeklappte Monat (mi === 0) ist damit automatisch der neueste. */
|
||||
function neuesteZuerst(liste, feld) {
|
||||
return liste.slice().sort((a, b) => new Date(b[feld]) - new Date(a[feld]));
|
||||
}
|
||||
|
||||
function vTrips() {
|
||||
const jahre = [...new Set(TRIPS.map((t) => new Date(t.ts_start).getFullYear()))].sort((a, b) => b - a);
|
||||
/* D5: Leerzustand nennt die Ursache - die Fahrtenerkennung greift erst, wenn
|
||||
@@ -3183,7 +3290,7 @@ function vTrips() {
|
||||
${vTripsFormular()}`;
|
||||
let h = `<div class="tile">`;
|
||||
jahre.forEach((j, ji) => {
|
||||
const tj = TRIPS.filter((t) => new Date(t.ts_start).getFullYear() === j);
|
||||
const tj = neuesteZuerst(TRIPS.filter((t) => new Date(t.ts_start).getFullYear() === j), "ts_start");
|
||||
h += `<button class="acc-h" aria-expanded="${ji === 0}" data-acc="y${j}"><span class="mark"></span>
|
||||
<span class="k">${j}</span><span class="v">${de(sum(tj, (t) => t.distance_km || 0))} km<br>${tj.length} Fahrten</span></button>
|
||||
<div class="acc-b ${ji === 0 ? "on" : ""}" id="y${j}">`;
|
||||
@@ -3244,9 +3351,13 @@ function vTrip(id) {
|
||||
|
||||
function quad(t, v) { return `<span class="label" style="margin-top:20px">${t}</span>
|
||||
<div class="quad">${v.map((x) => `<div><div class="n">${x[0]}</div><div class="l">${x[1]}</div></div>`).join("")}</div>`; }
|
||||
/* Der Kachelkopf traegt seit der Designpruefung 2026-08-30 denselben
|
||||
drehenden Pfeil wie die Aufklapp-Zeilen in Fahrten und Tanken. Vorher sah
|
||||
die Statistik aus wie fuenf feste Anzeigen - dass sich dahinter die
|
||||
gesamte Auswertung oeffnet, war an nichts zu erkennen. */
|
||||
function statTile(id, label, fig, unit, extra, body) {
|
||||
return `<div class="tile"><button class="tile-h" aria-expanded="false" data-acc="${id}">
|
||||
<span class="label">${label}</span>
|
||||
<span class="stat-kopfzeile"><span class="label">${label}</span><span class="mark"></span></span>
|
||||
<div style="display:flex;align-items:flex-end;justify-content:space-between;gap:14px;margin-top:12px">
|
||||
<div class="fig" style="font-size:34px">${fig}<span class="unit">${unit}</span></div>
|
||||
<span class="wert" style="padding-bottom:5px">${extra}</span></div>
|
||||
@@ -3639,7 +3750,7 @@ function vFuel() {
|
||||
${vFuelFormular()}`;
|
||||
let h = `<div class="tile">`;
|
||||
jahre.forEach((j, ji) => {
|
||||
const fj = FILLS.filter((f) => new Date(f.ts).getFullYear() === j);
|
||||
const fj = neuesteZuerst(FILLS.filter((f) => new Date(f.ts).getFullYear() === j), "ts");
|
||||
const ersparnisJahr = sum(fj, (f) => f.discount || 0);
|
||||
/* D2: Auf der Jahreszeile stehen nur die Kosten, groß. Preis und Menge
|
||||
folgen als Zweitzeile, die Ersparnis als neutrale Pille - vorher
|
||||
@@ -3649,7 +3760,7 @@ function vFuel() {
|
||||
<span class="v" style="display:flex;flex-direction:column;align-items:flex-end;gap:5px">
|
||||
<span style="font-size:17px;color:var(--fg)">${eur(sum(fj, (f) => f.fuel_total_eur || 0))} €</span>
|
||||
<span>${de(sum(fj, (f) => f.liters || 0), 1)} l · Ø ${de(schnittpreis(fj), 2)} €/l</span>
|
||||
${CAR.smartdeal.aktiv ? `<span class="marke"><span class="pill" style="font-size:11.5px;padding:4px 10px">${markenlogo()}<span style="margin-left:6px">−${eur(ersparnisJahr)} €</span></span></span>` : ""}
|
||||
${CAR.smartdeal.aktiv ? `<span class="marke"><span class="pill" style="font-size:12.5px;padding:4px 10px">${markenlogo()}<span style="margin-left:6px">−${eur(ersparnisJahr)} €</span></span></span>` : ""}
|
||||
</span></button>
|
||||
<div class="acc-b ${ji === 0 ? "on" : ""}" id="fy${j}">`;
|
||||
const monate = [...new Set(fj.map((f) => new Date(f.ts).toLocaleDateString("de-DE", { month: "long" })))];
|
||||
@@ -4062,7 +4173,7 @@ function vDatensatzPopup() {
|
||||
<input type="file" accept="${art === "fahrzeugprofil" ? "application/json" : "text/csv"}"
|
||||
data-datensatzladen="${art}" hidden ${datensatzLaeuft ? "disabled" : ""}></label>`
|
||||
).join("")}
|
||||
${datensatzErgebnis ? `<div style="margin-top:14px;font-size:13.5px;line-height:1.6;color:var(--fg)">${esc(datensatzErgebnisText(datensatzErgebnis))}</div>` : ""}
|
||||
${datensatzErgebnis ? `<div style="margin-top:14px;font-size:13px;line-height:1.6;color:var(--fg)">${esc(datensatzErgebnisText(datensatzErgebnis))}</div>` : ""}
|
||||
${datensatzFehler ? `<span class="label" style="margin-top:10px;color:var(--red)">${esc(datensatzFehler)}</span>` : ""}
|
||||
<button class="aktion primaer" style="margin-top:16px" data-datensatzzu>Fertig</button>
|
||||
</div>`;
|
||||
@@ -4423,6 +4534,9 @@ const go = (name, id) => {
|
||||
// Ein offenes Bearbeiten-Formular gehört zu genau der Fahrt, auf der es
|
||||
// geöffnet wurde - sonst begrüßt die nächste Fahrt einen mit Eingabefeldern.
|
||||
fahrtBearbeitenOffen = false;
|
||||
// Gleiches gilt für "Daten bearbeiten" auf Identität und Technik: wer die
|
||||
// Seite verlässt, soll sie beim nächsten Mal wieder lesend vorfinden.
|
||||
if (name !== "ident") identBearbeitenOffen = false;
|
||||
/* B3: echter Verlauf statt statischer ZURUECK-Tabelle. Ein Tabwechsel setzt
|
||||
den Stapel zurück, weil Tabs Wurzeln sind, keine Ebenen. */
|
||||
if (istTab(name)) routeStack = [];
|
||||
@@ -4433,6 +4547,7 @@ const go = (name, id) => {
|
||||
function zurueck() {
|
||||
sheet = null;
|
||||
fahrtBearbeitenOffen = false;
|
||||
identBearbeitenOffen = false;
|
||||
const ziel = routeStack.pop() || { name: ZURUECK[route.name] || "home" };
|
||||
navRichtung = "raus";
|
||||
route = ziel; render();
|
||||
@@ -5107,6 +5222,31 @@ function ereignisseVerdrahten() {
|
||||
if (dsExport) { datensatzExportieren(dsExport.dataset.datensatzsichern); return; }
|
||||
const del = e.target.closest("[data-loeschen]");
|
||||
if (del) { delete CAR.service.vereinbart[del.dataset.loeschen]; profilSpeichern(); render(); return; }
|
||||
/* Identität und Technik: Bearbeitungsansicht ein/aus, Positionen der
|
||||
Technik- und Ausstattungsgruppen anlegen/entfernen. */
|
||||
if (e.target.closest("[data-identbearbeiten]")) { identBearbeitenOffen = !identBearbeitenOffen; render(); return; }
|
||||
const tkdel = e.target.closest("[data-technikdel]");
|
||||
if (tkdel) {
|
||||
const [gi, i] = tkdel.dataset.technikdel.split("|").map(Number);
|
||||
CAR.technik[gi].posten.splice(i, 1); profilSpeichern(); render(); return;
|
||||
}
|
||||
const tkadd = e.target.closest("[data-technikadd]");
|
||||
if (tkadd) {
|
||||
const g = CAR.technik[+tkadd.dataset.technikadd];
|
||||
(g.posten = g.posten || []).push(["Neue Angabe", ""]);
|
||||
profilSpeichern(); render(); return;
|
||||
}
|
||||
const asdel = e.target.closest("[data-ausstattungdel]");
|
||||
if (asdel) {
|
||||
const [gi, i] = asdel.dataset.ausstattungdel.split("|").map(Number);
|
||||
CAR.ausstattung[gi].sonder.splice(i, 1); profilSpeichern(); render(); return;
|
||||
}
|
||||
const asadd = e.target.closest("[data-ausstattungadd]");
|
||||
if (asadd) {
|
||||
const g = CAR.ausstattung[+asadd.dataset.ausstattungadd];
|
||||
(g.sonder = g.sonder || []).push("");
|
||||
profilSpeichern(); render(); return;
|
||||
}
|
||||
const vseldel = e.target.closest("[data-vselbstdel]");
|
||||
if (vseldel) { CAR.versicherung.selbst.splice(+vseldel.dataset.vselbstdel, 1); profilSpeichern(); render(); return; }
|
||||
if (e.target.closest("[data-vselbstadd]")) { CAR.versicherung.selbst.push(["", ""]); profilSpeichern(); render(); return; }
|
||||
@@ -5409,6 +5549,21 @@ function ereignisseVerdrahten() {
|
||||
if (vnotruf) { CAR.versicherung[vnotruf] = e.target.value; profilSpeichern(); render(); return; }
|
||||
const vvertrag = e.target.dataset.vvertrag;
|
||||
if (vvertrag) { CAR.versicherung[vvertrag] = e.target.value; profilSpeichern(); render(); return; }
|
||||
/* Kein render() beim Tippen: der Cursor säße sonst nach jedem Zeichen
|
||||
wieder am Feldanfang - dieselbe Begründung wie beim Setup-Suchfeld und
|
||||
beim Tankpreisfeld weiter oben. */
|
||||
const technik = e.target.dataset.technik;
|
||||
if (technik) {
|
||||
const [gi, i] = technik.split("|").map(Number);
|
||||
CAR.technik[gi].posten[i][1] = e.target.value;
|
||||
profilSpeichern(); return;
|
||||
}
|
||||
const ausstattung = e.target.dataset.ausstattung;
|
||||
if (ausstattung) {
|
||||
const [gi, i] = ausstattung.split("|").map(Number);
|
||||
CAR.ausstattung[gi].sonder[i] = e.target.value;
|
||||
profilSpeichern(); return;
|
||||
}
|
||||
const vselbst = e.target.dataset.vselbst;
|
||||
if (vselbst) {
|
||||
const [i, feld] = vselbst.split("|");
|
||||
@@ -5719,6 +5874,8 @@ class AudiDashboardPanel extends HTMLElement {
|
||||
Reload. Vorher stand die App nach jedem Neuladen wieder auf Nacht. */
|
||||
HOST.dataset.theme = HOST.dataset.theme || themeGewaehlt();
|
||||
|
||||
schriftenLaden();
|
||||
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = `${STATIK}/audi-dashboard.css?v=${CSS_VERSION}`;
|
||||
|
||||
@@ -38,7 +38,13 @@
|
||||
--tile-2:#EFEFF4;
|
||||
--line:rgba(60,60,67,.13);
|
||||
--line-strong:rgba(60,60,67,.24);
|
||||
--fg:#000000; --fg2:#3C3C43; --fg3:#8E8E93;
|
||||
/* --fg3 traegt jede Kachelueberschrift, jede Einheit und jeden Zusatz.
|
||||
Auf hellem Grund lag #8E8E93 bei 2,92:1 gegen die Seitenflaeche und
|
||||
3,26:1 gegen die Kachel - fuer einen kurzen Blick im Auto bei
|
||||
Sonnenlicht zu wenig (Designpruefung 2026-08-30). #6B6B70 bringt beide
|
||||
auf 4,75:1 bzw. 5,30:1, ohne den ruhigen Gesamteindruck zu aendern.
|
||||
Der Nachtwert bleibt #8E8E93 - dort sind es ohnehin 5,86:1. */
|
||||
--fg:#000000; --fg2:#3C3C43; --fg3:#6B6B70;
|
||||
--ok:#34C759; --warn:#FF9F0A; --bad:#FF3B30;
|
||||
--ios-sep:rgba(60,60,67,.16);
|
||||
--ios-fill:rgba(120,120,128,.16);
|
||||
@@ -102,7 +108,7 @@ html,body{height:100%}
|
||||
.phone:has(.back.on) .topbar{padding:54px 16px 6px;gap:8px}
|
||||
.phone:has(.back.on) .head{align-items:center}
|
||||
.phone:has(.back.on) .title{font-size:17px;font-weight:400;letter-spacing:-.01em;text-align:center}
|
||||
.phone:has(.back.on) .sync{margin-top:2px;font-size:11.5px}
|
||||
.phone:has(.back.on) .sync{margin-top:2px;font-size:12.5px}
|
||||
|
||||
main#view{--seitenrand:16px;padding:0 var(--seitenrand) 30px;scroll-behavior:smooth}
|
||||
|
||||
@@ -195,7 +201,7 @@ main#view{--seitenrand:16px;padding:0 var(--seitenrand) 30px;scroll-behavior:smo
|
||||
.switch input:checked+.track{background:var(--ok)}
|
||||
.switch input:checked+.track::before{transform:translateX(20px)}
|
||||
|
||||
.pill{font-size:12px;letter-spacing:.01em;text-transform:none;padding:5px 11px;
|
||||
.pill{font-size:12.5px;letter-spacing:.01em;text-transform:none;padding:5px 11px;
|
||||
border:0;background:var(--ios-fill);color:var(--fg2)}
|
||||
|
||||
.status{padding:14px 0 2px}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "audi_dashboard",
|
||||
"name": "Audi Dashboard",
|
||||
"version": "2026.8.30.2",
|
||||
"version": "2026.8.30.8",
|
||||
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
|
||||
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
|
||||
"codeowners": ["@paul"],
|
||||
|
||||
@@ -1,41 +1,64 @@
|
||||
/* Maße live am Panel gemessen (.acc-h in audi-dashboard.css + iOS-Auflage):
|
||||
13px Polster, 56px Mindesthöhe, 12px Abstand, Titel 16px, Wert 12,5px und
|
||||
einzeilig. */
|
||||
.ads-acc-h {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
gap: var(--sp-3);
|
||||
width: 100%;
|
||||
min-height: 56px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
padding: 12px 0;
|
||||
padding: 13px 0;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--line);
|
||||
transition: background .15s;
|
||||
}
|
||||
|
||||
.ads-acc-h:active {
|
||||
background: var(--tile-2);
|
||||
}
|
||||
|
||||
.ads-acc-h__k {
|
||||
flex: 1;
|
||||
font-size: 15px;
|
||||
font-size: 16px;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
/* Ohne eigene Regel erbt <small> den UA-Wert "smaller" (0,833x) - im
|
||||
Akkordeonkopf sind das 10,4px und damit unter der 11px-Untergrenze, die
|
||||
dieses Projekt aus den Apple-Richtlinien uebernommen hat. */
|
||||
.ads-acc-h__v small { font-size: 11px; }
|
||||
|
||||
.ads-acc-h__v {
|
||||
font-size: 11.5px;
|
||||
flex: 0 0 auto;
|
||||
font-size: 12.5px;
|
||||
color: var(--fg2);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Quadratisch, nicht 6x10: die Technik aus zwei Rahmenkanten plus 45°-Drehung
|
||||
ergibt nur bei gleich langen Kanten einen sauberen Pfeil — sonst sieht sie
|
||||
aus wie ein Häkchen. Genau dieser Fehler wurde im Panel am 2026-08-13
|
||||
behoben und war hier stehen geblieben. */
|
||||
.ads-mark {
|
||||
width: 6px;
|
||||
height: 10px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
flex: 0 0 auto;
|
||||
border-right: 1.4px solid var(--fg3);
|
||||
border-bottom: 1.4px solid var(--fg3);
|
||||
transform: rotate(-45deg) scaleY(1);
|
||||
transform: rotate(-45deg);
|
||||
transition: transform .15s, border-color .15s;
|
||||
}
|
||||
|
||||
/* Ein Chevron, eine Farbe — nur die Drehung zeigt den Zustand. Rot bleibt
|
||||
Akzent/destruktiv, nie Navigationsfarbe (dieselbe Regel wie bei der
|
||||
Menüleiste und dem Zurück-Pfeil). */
|
||||
.ads-acc-h[aria-expanded="true"] .ads-mark {
|
||||
border-color: var(--red);
|
||||
transform: rotate(45deg) scaleY(1);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.ads-acc-b {
|
||||
|
||||
@@ -39,10 +39,12 @@ export function Accordion({
|
||||
|
||||
return (
|
||||
<div className={classes.join(" ")}>
|
||||
{/* Der Pfeil steht links vor dem Titel, wie `.acc-h` im Panel
|
||||
(`<span class="mark"></span><span class="k">…`). */}
|
||||
<button type="button" className="ads-acc-h" onClick={toggle} aria-expanded={isOpen}>
|
||||
<span className="ads-mark" aria-hidden />
|
||||
<span className="ads-acc-h__k">{title}</span>
|
||||
{summary !== undefined && <span className="ads-acc-h__v">{summary}</span>}
|
||||
<span className="ads-mark" aria-hidden />
|
||||
</button>
|
||||
<div className={isOpen ? "ads-acc-b ads-acc-b--on" : "ads-acc-b"}>{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@ button.ads-leaf {
|
||||
}
|
||||
|
||||
.ads-leaf__k {
|
||||
font-size: 13.5px;
|
||||
font-size: 13px;
|
||||
color: var(--fg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
/* Werte 1:1 aus der iOS-Auflage des Panels (`.aktion` in
|
||||
audi-dashboard-ios.css): gefüllte Fläche ohne Rahmen, 16px normale Schrift,
|
||||
keine Großschreibung, 14px Radius. Hier stand noch die Optik von vor der
|
||||
iOS-Entscheidung vom 2026-08-13 — 11px, gesperrt, GROSSGESCHRIEBEN, nur
|
||||
Umriss, roter Rahmen beim Überfahren. Das betraf jeden Knopf der App
|
||||
("SPEICHERN", "+ POSITION HINZUFÜGEN", ...). */
|
||||
.ads-action {
|
||||
display: block;
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
letter-spacing: .14em;
|
||||
text-transform: uppercase;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
color: var(--fg);
|
||||
background: none;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-pill);
|
||||
padding: 14px;
|
||||
background: var(--ios-fill);
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
padding: 15px;
|
||||
min-height: 48px;
|
||||
margin-top: 18px;
|
||||
cursor: pointer;
|
||||
transition: background .15s, filter .15s, transform .1s, opacity .15s;
|
||||
}
|
||||
|
||||
/* `.aktion + .aktion` im Panel: zwei Knöpfe untereinander rücken enger. */
|
||||
.ads-action + .ads-action {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.ads-action:hover:not(:disabled) {
|
||||
border-color: var(--red);
|
||||
background: var(--tile-2);
|
||||
}
|
||||
|
||||
.ads-action:active:not(:disabled) {
|
||||
transform: scale(.985);
|
||||
}
|
||||
|
||||
.ads-action:disabled {
|
||||
@@ -22,13 +41,25 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Hauptaktion: graue Fläche, kein Rot — Rot bleibt destruktiven Aktionen
|
||||
vorbehalten (Nutzerentscheidung 2026-08-16). */
|
||||
.ads-action--primary {
|
||||
background: var(--fg);
|
||||
color: var(--canvas);
|
||||
}
|
||||
|
||||
.ads-action--primary:hover:not(:disabled) {
|
||||
background: var(--fg);
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.ads-action--destructive {
|
||||
background: var(--ios-fill);
|
||||
color: var(--bad);
|
||||
border-color: var(--bad);
|
||||
}
|
||||
|
||||
.ads-action--destructive:hover:not(:disabled) {
|
||||
background: var(--bad);
|
||||
color: var(--fg);
|
||||
border-color: var(--bad);
|
||||
color: #fff;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import * as React from "react";
|
||||
export interface ActionButtonProps {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
variant?: "default" | "destructive";
|
||||
/** "primary" is the source app's `.aktion.primaer` - a filled main action. */
|
||||
variant?: "default" | "primary" | "destructive";
|
||||
disabled?: boolean;
|
||||
type?: "button" | "submit";
|
||||
className?: string;
|
||||
@@ -18,6 +19,7 @@ export function ActionButton({
|
||||
className,
|
||||
}: ActionButtonProps) {
|
||||
const classes = ["ads-action"];
|
||||
if (variant === "primary") classes.push("ads-action--primary");
|
||||
if (variant === "destructive") classes.push("ads-action--destructive");
|
||||
if (className) classes.push(className);
|
||||
return (
|
||||
|
||||
@@ -11,7 +11,10 @@
|
||||
}
|
||||
|
||||
.ads-fig__unit {
|
||||
font-size: .32em;
|
||||
/* Relativ zur Zahl, aber nie unter der Lesbarkeitsgrenze — wie `.fig .unit`
|
||||
im Panel. Ohne die Untergrenze rutschte die Einheit an der 20px-Zahl des
|
||||
Kilometerstands auf 6,4px (live gemessen), im Panel sind es dort 11px. */
|
||||
font-size: max(11px, .32em);
|
||||
font-weight: 400;
|
||||
letter-spacing: normal;
|
||||
color: var(--fg2);
|
||||
|
||||
@@ -24,6 +24,12 @@
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Der Platzhalter im Panel ist eine gefüllte Fläche ohne Rahmen
|
||||
(`.bildbox.bild-fehlt .platzhalter{border:0;background:var(--ios-fill);
|
||||
border-radius:var(--r-tile)}` in der iOS-Auflage), mit 13px-Bezeichnung in
|
||||
--fg2 und 12px-Zusatz in --fg3. Hier stand noch ein gestrichelter Rahmen mit
|
||||
sehr kleiner, gesperrter Beschriftung und einem Zusatz in Schreibmaschinen-
|
||||
schrift. */
|
||||
.ads-platzhalter {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -31,12 +37,13 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border: 1px dashed var(--line-strong);
|
||||
gap: 7px;
|
||||
background: var(--ios-fill);
|
||||
border: 0;
|
||||
border-radius: inherit;
|
||||
color: var(--fg3);
|
||||
text-align: center;
|
||||
padding: var(--sp-2);
|
||||
padding: var(--sp-3);
|
||||
}
|
||||
|
||||
.ads-platzhalter__icon svg {
|
||||
@@ -46,13 +53,12 @@
|
||||
}
|
||||
|
||||
.ads-platzhalter__label {
|
||||
font-size: 10.5px;
|
||||
letter-spacing: .02em;
|
||||
color: var(--fg3);
|
||||
font-size: 13px;
|
||||
letter-spacing: normal;
|
||||
color: var(--fg2);
|
||||
}
|
||||
|
||||
.ads-platzhalter__hint {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 9px;
|
||||
font-size: 12.5px;
|
||||
color: var(--fg3);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ const DEFAULT_ICON = (
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M4 5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5Zm2 0v14h12V5H6Zm3 3a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm-1 8 3-3 2 2 3-4 3 5H8Z"
|
||||
d="m23.35 1.35-22 22-.7-.7 22-22zM1 3h17.5v1H2v16H1zm21 11.85V4h1v17H6v-1h16v-3.77l-3.24-3.08-4.57 4.48-2.85-2.84.7-.7 2.15 2.14 4.56-4.47 3.25 3.1ZM4 8.5a2.5 2.5 0 1 1 5 0 2.5 2.5 0 0 1-5 0M6.5 7a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
/* `.pill` in der iOS-Auflage des Panels: gefüllte Fläche ohne Rahmen, 12px
|
||||
normale Schrift, keine Großschreibung. Hier stand noch die Optik von vor der
|
||||
iOS-Entscheidung (10px, gesperrt, GROSSGESCHRIEBEN, nur Umriss). */
|
||||
.ads-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 10px;
|
||||
letter-spacing: .14em;
|
||||
text-transform: uppercase;
|
||||
font-size: 12.5px;
|
||||
letter-spacing: .01em;
|
||||
text-transform: none;
|
||||
color: var(--fg2);
|
||||
border: 1px solid var(--line-strong);
|
||||
background: var(--ios-fill);
|
||||
border: 0;
|
||||
border-radius: var(--r-pill);
|
||||
padding: 6px 12px;
|
||||
padding: 5px 11px;
|
||||
}
|
||||
|
||||
.ads-pill--work {
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
border: none;
|
||||
color: var(--fg);
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
font-size: 13px;
|
||||
padding: 10px 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
/* Live am Panel gemessen: die iOS-Auflage hebt den Balken von 4px auf 6px und
|
||||
färbt die Spur mit --ios-fill statt --line (`.balken{height:6px;
|
||||
background:var(--ios-fill)}` in audi-dashboard-ios.css). Hier stand noch der
|
||||
Wert aus dem Basis-Stylesheet, das im Panel nie zum Zug kommt. */
|
||||
.ads-balken {
|
||||
height: 4px;
|
||||
height: 6px;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--line);
|
||||
background: var(--ios-fill);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
/* Maße 1:1 vom Panel (.status in audi-dashboard.css/-ios.css: min-height 44px,
|
||||
padding 14px 0 2px, Titel 16px, Punkt 10px). Sie waren hier auf 0/14px/11px
|
||||
geblieben - die Zeile stand damit sichtbar enger und kleiner als im Panel. */
|
||||
.ads-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
padding: 14px 0 2px;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
cursor: default;
|
||||
color: inherit;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
|
||||
button.ads-status:active {
|
||||
opacity: .55;
|
||||
}
|
||||
|
||||
.ads-status__go {
|
||||
width: 6px;
|
||||
height: 10px;
|
||||
margin-left: auto;
|
||||
flex: 0 0 auto;
|
||||
color: var(--fg3);
|
||||
}
|
||||
|
||||
button.ads-status {
|
||||
@@ -16,8 +34,8 @@ button.ads-status {
|
||||
}
|
||||
|
||||
.ads-dot {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
@@ -32,7 +50,7 @@ button.ads-status {
|
||||
}
|
||||
|
||||
.ads-status__t {
|
||||
font-size: 14px;
|
||||
font-size: 16px;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,13 @@ export interface StatusRowProps {
|
||||
status: "ok" | "warn" | "bad";
|
||||
title: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
/** Trailing chevron, marking the row as a link to a detail view. */
|
||||
chevron?: boolean;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StatusRow({ status, title, subtitle, onClick, className }: StatusRowProps) {
|
||||
export function StatusRow({ status, title, subtitle, chevron, onClick, className }: StatusRowProps) {
|
||||
const classes = ["ads-status"];
|
||||
if (className) classes.push(className);
|
||||
const Tag: React.ElementType = onClick ? "button" : "div";
|
||||
@@ -23,6 +25,18 @@ export function StatusRow({ status, title, subtitle, onClick, className }: Statu
|
||||
<span className="ads-status__t">{title}</span>
|
||||
{subtitle !== undefined && <span className="ads-status__s">{subtitle}</span>}
|
||||
</span>
|
||||
{chevron && (
|
||||
<svg className="ads-status__go" viewBox="0 0 6 10" aria-hidden>
|
||||
<polyline
|
||||
points="1,1 5,5 1,9"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 13px in normaler Schreibweise wie `.swipe-loeschen` im Panel — hier stand
|
||||
noch 11px GROSSGESCHRIEBEN mit Sperrung (derselbe Vor-iOS-Stil wie bei
|
||||
Knöpfen und Pillen).
|
||||
|
||||
`visibility:hidden`, bis wirklich gewischt wird: Zeilenhöhen sind gebrochen
|
||||
(z. B. 66,28px), dadurch fällt die Kante des Inhalts nicht aufs
|
||||
Gerätepixelraster und der rote Knopf blitzt sonst als dünne Linie an den
|
||||
Zeilenrändern durch — im Panel als "roter Teilrahmen um jede Listenzeile"
|
||||
gemeldet und dort genau so behoben. */
|
||||
.ads-swipe-delete {
|
||||
position: absolute;
|
||||
inset: 0 0 0 auto;
|
||||
@@ -11,10 +20,19 @@
|
||||
color: #fff;
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
font-size: 13px;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
cursor: pointer;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.ads-swipe-wrap:has(.ads-swiped) .ads-swipe-delete {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.ads-swipe-delete:active {
|
||||
filter: brightness(.88);
|
||||
}
|
||||
|
||||
.ads-swipe-content {
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* Maße 1:1 vom Panel (.tab in audi-dashboard-ios.css: padding 7px 2px 6px,
|
||||
gap 3px). Sie standen hier noch auf den Werten vor der iOS-Auflage von
|
||||
2026-08-13 - dieselbe Ursache wie beim roten Aktiv-Rand, der 2026-08-30
|
||||
ersetzt wurde. */
|
||||
.ads-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 10px 0 8px;
|
||||
gap: 3px;
|
||||
padding: 7px 2px 6px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--fg3);
|
||||
@@ -24,26 +28,57 @@
|
||||
ab (--shade ist .05/.04, hier .14/.07), deshalb hier direkt benannt, wie im
|
||||
Original. */
|
||||
.ads-tab--on {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
border-radius: 14px;
|
||||
color: var(--fg);
|
||||
}
|
||||
[data-theme="tag"] .ads-tab--on {
|
||||
|
||||
.ads-tabbar:not(.ads-tabbar--ohne) .ads-tab--on {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
border-radius: 14px;
|
||||
}
|
||||
[data-theme="tag"] .ads-tabbar:not(.ads-tabbar--ohne) .ads-tab--on {
|
||||
background: rgba(0, 0, 0, 0.07);
|
||||
}
|
||||
|
||||
.ads-tab__icon svg {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
/* Die Hülle um das Symbol ist die .tabpille des Panels: feste 63x39, damit
|
||||
ihre Größe unabhängig vom Symbol ist. Ohne sie saß die Beschriftung im
|
||||
Vergleich zum Panel deutlich zu dicht unter dem Symbol. Im reinen
|
||||
Symbolmodus trägt sie selbst die Pille, sonst der ganze Knopf (oben). */
|
||||
.ads-tab__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 63px;
|
||||
height: 39px;
|
||||
max-width: 100%;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.ads-tabbar--ohne .ads-tab__icon svg {
|
||||
.ads-tabbar--ohne .ads-tab--on .ads-tab__icon {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
[data-theme="tag"] .ads-tabbar--ohne .ads-tab--on .ads-tab__icon {
|
||||
background: rgba(0, 0, 0, 0.07);
|
||||
}
|
||||
|
||||
/* .tab svg im Panel ist in beiden Modi 26px - nur das Polster wechselt
|
||||
(.tabbar.ohne .tab{padding:11px 2px 10px}). Die 21px hier ließen die
|
||||
Symbole der Menüleiste sichtbar kleiner wirken als im Panel. */
|
||||
.ads-tab__icon svg {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.ads-tab__label {
|
||||
font-size: 9px;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
.ads-tabbar--ohne .ads-tab {
|
||||
padding: 11px 2px 10px;
|
||||
}
|
||||
|
||||
/* Live am Panel gemessen (.tab span): 11px, keine Großschreibung, .01em
|
||||
Laufweite. Die 9px/uppercase/.08em hier waren derselbe Vor-iOS-Stil, den
|
||||
.ads-eyebrow noch trägt - dort bewusst behalten und lokal überschrieben,
|
||||
weil es eine allgemeine Textklasse des Kits ist; die Beschriftung der
|
||||
Menüleiste ist dagegen ein Bauteil-Detail und folgt dem Panel direkt. */
|
||||
.ads-tab__label {
|
||||
font-size: 11px;
|
||||
letter-spacing: .01em;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
@@ -26,8 +26,13 @@ button.ads-tile--button:hover {
|
||||
background: var(--tile-2);
|
||||
}
|
||||
|
||||
/* min-width:0 hebt die automatische Mindestbreite eines Flex-Kindes auf.
|
||||
Ohne sie wuchs der Inhalt an einer langen, unbrechbaren Zeichenkette (der
|
||||
Fahrgestellnummer) über das Innenmaß der Kachel hinaus und lief rechts aus
|
||||
dem Bild — im Panel bleibt die Zeile innerhalb der Kachel. */
|
||||
.ads-tile__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ads-tile__go {
|
||||
|
||||
@@ -51,11 +51,19 @@
|
||||
--canvas: #F2F2F7;
|
||||
--tile: #FFFFFF;
|
||||
--tile-2: #EFEFF4;
|
||||
--line: rgba(60, 60, 67, .13);
|
||||
/* Live gemessen: --ios-sep im Tagmodus des Panels ist .16, nicht .13 (im
|
||||
Nachtmodus stimmen beide überein). Betraf jede Trennlinie im Tagmodus. */
|
||||
--line: rgba(60, 60, 67, .16);
|
||||
--line-strong: rgba(60, 60, 67, .24);
|
||||
--fg: #000000;
|
||||
--fg2: #3C3C43;
|
||||
--fg3: #8E8E93;
|
||||
/* --fg3 traegt jede Kachelueberschrift, jede Einheit und jeden Zusatz.
|
||||
Auf hellem Grund lag #8E8E93 bei 2,92:1 gegen die Seitenflaeche und
|
||||
3,26:1 gegen die Kachel - fuer einen kurzen Blick im Auto bei
|
||||
Sonnenlicht zu wenig (Designpruefung 2026-08-30). #6B6B70 bringt beide
|
||||
auf 4,75:1 bzw. 5,30:1, ohne den ruhigen Gesamteindruck zu aendern.
|
||||
Der Nachtwert bleibt #8E8E93 - dort sind es ohnehin 5,86:1. */
|
||||
--fg3: #6B6B70;
|
||||
--ok: #34C759;
|
||||
--warn: #FF9F0A;
|
||||
--bad: #FF3B30;
|
||||
|
||||
Reference in New Issue
Block a user