companion-app: Datensatz sichern/laden portiert (Item 9 Paritaet)

- "Daten ausgeben" zu "Fahrzeugprofil" umgebaut, gleiche zwei Knoepfe wie
  im Panel: Datensatz sichern/laden, popup mit vier Aktionen
- CSV-Spaltennamen jetzt byte-identisch zum Panel-Export (gemeinsamer
  Backend-Parser, csv_import.py) - vorher eigene, abweichende Kopfzeilen
  ohne Reimport-Moeglichkeit
- neue api.csvImportieren(), Fahrzeugprofil-Import ueber das bestehende
  api.profilSchreiben() (volles Profil, nicht die teilweise
  zusammenfuehrende profilSpeichern()-Bequemlichkeitsfunktion)
- Blinden Fleck geprueft: importStatusLesen()/zustandLesen() lesen per
  REST, nicht aus lokalem Cache - dieselbe Racebedingung wie im Panel kann
  hier strukturell nicht auftreten
- Toten dateiwahl-Scaffolding entfernt (nie verdrahtet)

Beim Bauen echten, vorbestehenden Fehler gefunden: companion-app und Panel
verwenden unterschiedliche Feldnamen fuer Wartungsplan-Eintraege
(betrieb/notiz vs. werkstatt/kosten) - als eigene Aufgabe geflaggt statt
hier mitgefixt.

tsc --noEmit sauber, 146/146 Tests, vite build erfolgreich. Nicht live
getestet (kein laufender companion-app-Dev-Server mit Backend-Zugang in
dieser Sitzung).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 10:31:10 +02:00
parent bfb7dec1e9
commit 2be6b99667
5 changed files with 368 additions and 43 deletions
+19
View File
@@ -16,6 +16,7 @@ export * from "./ablageNativ.ts";
import { DIENST_DOMAIN, ENTITAETEN } from "./types.ts";
import type {
AppVersionAngabe,
CsvImportErgebnis,
Fahrt,
Fahrzeugstatus,
ImportErgebnis,
@@ -304,4 +305,22 @@ export class DataMetricApi {
);
return { zustand: zustand.state, daten: zustand.attributes?.daten ?? {} };
}
/* --------------------------------------------------- CSV-Import
Wie historieImportieren() bewusst NICHT über die Warteschlange: eine
bewusste, einmalige Aktion, deren Ergebnis der eigentliche Zweck des
Aufrufs ist, kein Formularfeld, das man auch offline abschicken würde.
Anders als dort aber synchron im Backend (csv_import.py) - der
Dienstaufruf kehrt erst zurück, wenn sensor.audi_dashboard_import_status
den neuen Stand schon trägt, keine Wartezeit nötig. */
async csvImportieren(
art: "fahrten" | "tanken" | "service",
inhalt: string,
): Promise<CsvImportErgebnis> {
await this.rest.dienstAufrufen(DIENST_DOMAIN, "csv_importieren", { art, inhalt });
const zustand = await this.rest.zustandLesen<{ daten: CsvImportErgebnis }>(
ENTITAETEN.importStatus,
);
return { art, ...(zustand.attributes?.daten ?? {}) };
}
}
+11
View File
@@ -163,6 +163,17 @@ export interface ImportErgebnis {
meldung?: string;
}
/** Ergebnis eines CSV-Imports (custom_components/audi_dashboard/csv_import.py) -
dieselbe Entität wie ImportErgebnis (sensor.audi_dashboard_import_status),
aber eine andere Kennzahlform: Zeilenabgleich statt Fahrten/Tankvorgänge/
Batterie getrennt. */
export interface CsvImportErgebnis {
art?: string;
angelegt?: number;
aktualisiert?: number;
uebersprungen?: number;
}
/** Das OTA-Bündel, das die Integration mit ausliefert (siehe
custom_components/audi_dashboard/koordinator.py `_buendel_lesen()` und
companion-app/scripts/ota-paket.ps1). `null`, solange keine
@@ -0,0 +1,252 @@
/**
* "Datensatz sichern"/"Datensatz laden" — Einstellungen/Fahrzeugprofil.
* Vorlage: `vDatensatzPopup()` im Panel (audi-dashboard-app.js).
*
* "Sichern" löst vier sofortige, rein clientseitige Exporte aus
* (Fahrzeugprofil als JSON, Fahrten/Tankvorgänge/Wartungsplan als CSV).
* "Laden" spiegelt dieselben vier Dateien als Dateiauswahl - Fahrzeugprofil
* über den bestehenden profil_schreiben-Dienst (api.profilSchreiben()),
* die drei CSV-Datensätze über den neuen csv_importieren-Dienst
* (api.csvImportieren(), custom_components/audi_dashboard/csv_import.py):
* eine Zeile mit bekannter ID aktualisiert nur ihre eigenen Spalten, alles
* andere am Datensatz bleibt unangetastet - siehe dessen Moduldocstring für
* die Begründung.
*
* Die CSV-Spaltennamen sind absichtlich BYTE-IDENTISCH zum Panel-Export
* ("ID","Start","Ende","km","Art","Status" usw.) - derselbe Backend-Parser
* bedient beide Oberflächen, eine hier exportierte Datei muss sich also auch
* im Panel importieren lassen und umgekehrt. Vorher hatte diese Seite eigene,
* abweichende Spaltennamen ("Beginn"/"Strecke km"/"Dauer s") - die gab es
* nur zum Lesen in einer Tabelle, nie zum Reimport, ein Rundlauf wäre also
* gescheitert.
*/
import { useEffect, useRef, useState } from "react"
import { ActionButton, Popup } from "@audi-dash/ui"
import type { CsvImportErgebnis, DataMetricApi, Fahrt, Profil, Tankvorgang } from "../api"
import { de, eur } from "../format"
import { csvHerunterladen } from "./csv"
import type { ServicebuchEintrag } from "./Service"
type Art = "fahrzeugprofil" | "fahrten" | "tanken" | "service"
const ARTEN: [Art, string][] = [
["fahrzeugprofil", "Fahrzeugprofil"],
["fahrten", "Fahrten"],
["tanken", "Tankvorgänge"],
["service", "Wartungsplan"],
]
function exportieren(
art: Art,
rohprofil: Profil | null,
fahrten: readonly Fahrt[],
tankvorgaenge: readonly Tankvorgang[],
wartungsplan: readonly ServicebuchEintrag[],
) {
if (art === "fahrzeugprofil") {
const blob = new Blob([JSON.stringify(rohprofil, null, 2)], { type: "application/json" })
const url = URL.createObjectURL(blob)
const verweis = document.createElement("a")
verweis.href = url
verweis.download = "fahrzeugprofil.json"
verweis.click()
URL.revokeObjectURL(url)
return
}
if (art === "fahrten") {
csvHerunterladen(
"fahrten",
["ID", "Start", "Ende", "km", "Art", "Status"],
fahrten.map((f) => [f.trip_id, f.ts_start, f.ts_end, f.distance_km ?? "", f.art, f.status]),
)
return
}
if (art === "tanken") {
csvHerunterladen(
"tankvorgaenge",
["ID", "Zeitpunkt", "Station", "Liter", "€/l", "Kosten €"],
tankvorgaenge.map((t) => [
t.tank_id,
t.ts,
t.station_name ?? "",
t.liters ?? "",
t.liters && t.fuel_total_eur != null ? de(t.fuel_total_eur / t.liters, 2) : "",
t.fuel_total_eur != null ? eur(t.fuel_total_eur) : "",
]),
)
return
}
// ACHTUNG: liest werkstatt/kosten, nicht ServicebuchEintrag.betrieb/notiz -
// das ist absichtlich. Der tatsächlich gespeicherte Datensatz
// (profil["service"]["buch"], vom Backend/Panel geschrieben) trägt
// werkstatt/kosten/arbeiten; ServicebuchEintrag hier im Frontend nennt
// sie betrieb/notiz und wird nirgends dazwischen übersetzt (Service.tsx
// liest fahrzeug.service["buch"] ungemappt durch) - ein
// Schema-Auseinanderlaufen zwischen den beiden Oberflächen, das beim Bau
// dieses CSV-Exports auffiel, aber ein eigenes Thema ist (siehe AGENTS.md).
// Der Export muss die echten Feldnamen lesen, sonst zeigt jede exportierte
// Zeile eine leere Werkstatt/Kosten-Spalte.
csvHerunterladen(
"wartungsplan",
["Datum", "km", "Art", "Werkstatt", "Kosten €"],
wartungsplan.map((e) => {
const roh = e as Record<string, unknown>
const werkstatt = typeof roh.werkstatt === "string" ? roh.werkstatt : ""
const kosten = typeof roh.kosten === "number" ? roh.kosten : null
return [e.datum ?? "", e.km != null ? de(e.km) : "", e.art ?? "", werkstatt, kosten != null ? eur(kosten) : ""]
}),
)
}
function ergebnisText(art: Art, e: CsvImportErgebnis): string {
if (art === "fahrzeugprofil") return "Fahrzeugprofil geladen."
const teile: string[] = []
if (e.angelegt) teile.push(`${de(e.angelegt)} neu angelegt`)
if (e.aktualisiert) teile.push(`${de(e.aktualisiert)} aktualisiert`)
if (e.uebersprungen) teile.push(`${de(e.uebersprungen)} übersprungen`)
return teile.length ? teile.join(" · ") : "Keine gültigen Zeilen in der Datei gefunden."
}
export function DatensatzPopup({
offen,
modus,
beiSchliessen,
api,
rohprofil,
fahrten,
tankvorgaenge,
wartungsplan,
beiProfilGeladen,
beiFertig,
}: {
offen: boolean
modus: "sichern" | "laden"
beiSchliessen: () => void
api: DataMetricApi
rohprofil: Profil | null
fahrten: readonly Fahrt[]
tankvorgaenge: readonly Tankvorgang[]
wartungsplan: readonly ServicebuchEintrag[]
/** Nach einem geladenen Fahrzeugprofil: den neuen Stand übernehmen. */
beiProfilGeladen: (profil: Profil) => Promise<void>
/** Nach einem erfolgreichen CSV-Import: Fahrten-/Tankvorgangslisten neu laden. */
beiFertig: () => void
}) {
const [laeuft, setzeLaeuft] = useState<Art | null>(null)
const [ergebnis, setzeErgebnis] = useState<{ art: Art; text: string } | null>(null)
const [fehler, setzeFehler] = useState<string | null>(null)
const eingaben = useRef<Partial<Record<Art, HTMLInputElement | null>>>({})
const aktiv = useRef(true)
useEffect(() => {
aktiv.current = true
return () => {
aktiv.current = false
}
}, [])
useEffect(() => {
if (offen) {
setzeErgebnis(null)
setzeFehler(null)
setzeLaeuft(null)
}
}, [offen])
const schliessen = () => {
if (laeuft) return
beiSchliessen()
}
const dateiGewaehlt = async (art: Art, datei: File) => {
setzeLaeuft(art)
setzeErgebnis(null)
setzeFehler(null)
let inhalt: string
try {
inhalt = await datei.text()
} catch {
if (!aktiv.current) return
setzeLaeuft(null)
setzeFehler("Die Datei ließ sich nicht lesen.")
return
}
try {
if (art === "fahrzeugprofil") {
const profil = JSON.parse(inhalt) as Profil
await beiProfilGeladen(profil)
if (!aktiv.current) return
setzeErgebnis({ art, text: ergebnisText(art, {}) })
} else {
const e = await api.csvImportieren(art, inhalt)
if (!aktiv.current) return
setzeErgebnis({ art, text: ergebnisText(art, e) })
beiFertig()
}
} catch (err) {
if (!aktiv.current) return
setzeFehler(
art === "fahrzeugprofil"
? `Die Datei ließ sich nicht lesen: ${err instanceof Error ? err.message : "unbekannter Fehler"}`
: `Der Import ist fehlgeschlagen: ${err instanceof Error ? err.message : "unbekannter Fehler"}`,
)
} finally {
if (aktiv.current) setzeLaeuft(null)
}
}
return (
<Popup open={offen} onClose={schliessen} variant="form" anchor="center">
<span className="ads-eyebrow">{modus === "sichern" ? "Datensatz sichern" : "Datensatz laden"}</span>
<p className="dm-fussnote">
{modus === "sichern"
? "Semikolon als Trennzeichen und deutsches Zahlenformat, damit die Dateien ohne Umweg in Tabellenprogrammen aufgehen."
: "Eine zuvor gesicherte, ggf. bearbeitete Datei wieder einspielen — eine Zeile mit bekannter ID aktualisiert nur ihre eigenen Spalten, alles andere bleibt unangetastet."}
</p>
{ARTEN.map(([art, label]) =>
modus === "sichern" ? (
<div key={art} style={{ marginTop: 12 }}>
<ActionButton
onClick={() => exportieren(art, rohprofil, fahrten, tankvorgaenge, wartungsplan)}
>
{label}
{art === "fahrzeugprofil" ? " sichern" : " als CSV"}
</ActionButton>
</div>
) : (
<div key={art} style={{ marginTop: 12 }}>
<ActionButton
onClick={() => eingaben.current[art]?.click()}
disabled={laeuft !== null}
>
{laeuft === art ? "Lädt …" : `${label}${art === "fahrzeugprofil" ? " laden" : " (CSV) laden"}`}
</ActionButton>
<input
ref={(el) => {
eingaben.current[art] = el
}}
type="file"
accept={art === "fahrzeugprofil" ? "application/json" : "text/csv"}
hidden
onChange={(e) => {
const datei = e.target.files?.[0]
e.target.value = ""
if (datei) void dateiGewaehlt(art, datei)
}}
/>
</div>
),
)}
{ergebnis && <p className="dm-importergebnis">{ergebnis.text}</p>}
{fehler && <p className="dm-fussnote dm-fussnote--warnung">{fehler}</p>}
<div style={{ marginTop: 16 }}>
<ActionButton onClick={beiSchliessen} disabled={laeuft !== null}>
Fertig
</ActionButton>
</div>
</Popup>
)
}
+35 -43
View File
@@ -4,11 +4,12 @@
* Datenausgabe und Zugang.
*/
import { useEffect, useRef, useState } from "react"
import { useEffect, useState } from "react"
import { ActionButton, Feld, Seg, Switch, Tile } from "@audi-dash/ui"
import { DIENST_DOMAIN, zugangLesen, zugangVerwerfen } from "../api"
import type { Profil } from "../api"
import { eigeneVersion } from "../daten/appVersion"
import { useDaten } from "../daten/DatenKontext"
import { OtaFehler, buendelAnwenden, buendelPasst, otaMoeglich } from "../daten/ota"
@@ -18,8 +19,9 @@ import { useTheme } from "../theme"
import { Wertzeile, Werteliste, bestaetigen } from "./bausteine"
import { Bild } from "./Bild"
import { BILDPLAETZE } from "./bilder"
import { csvHerunterladen } from "./csv"
import { DatensatzPopup } from "./DatensatzPopup"
import { HistorienImport } from "./HistorienImport"
import type { ServicebuchEintrag } from "./Service"
import { zugangVergessen } from "./zugang"
export function Einstellungen({
@@ -56,7 +58,7 @@ export function Einstellungen({
const [updateInstallierenLaeuft, setzeUpdateInstallierenLaeuft] = useState(false)
const [updateNeustartLaeuft, setzeUpdateNeustartLaeuft] = useState(false)
const [updateAktionFehler, setzeUpdateAktionFehler] = useState<string | null>(null)
const dateiwahl = useRef<HTMLInputElement | null>(null)
const [datensatzPopup, setzeDatensatzPopup] = useState<"sichern" | "laden" | null>(null)
const otaUpdateVerfuegbar = otaMoeglich() && buendelPasst(otaBuendel, eigeneVersion())
@@ -179,6 +181,14 @@ export function Einstellungen({
beiAbmeldung()
}
// Anders als profilSpeichern() (Kontext) - das nur einstellungen/fahrzeug
// teilweise zusammenführt - schreibt eine geladene Datei das GANZE Profil,
// deckungsgleich mit profil_schreiben im Panel.
const profilGeladen = async (profil: Profil) => {
await api.profilSchreiben(profil)
await neuLaden()
}
const profilExportieren = () => {
const inhalt = JSON.stringify(
{ profil: rohprofil, fahrten, tankvorgaenge, erzeugt: new Date().toISOString() },
@@ -376,6 +386,19 @@ export function Einstellungen({
beiFertig={() => void jetztAktualisieren()}
/>
<DatensatzPopup
offen={datensatzPopup !== null}
modus={datensatzPopup ?? "sichern"}
beiSchliessen={() => setzeDatensatzPopup(null)}
api={api}
rohprofil={rohprofil}
fahrten={fahrten}
tankvorgaenge={tankvorgaenge}
wartungsplan={(fahrzeug.service["buch"] ?? []) as ServicebuchEintrag[]}
beiProfilGeladen={profilGeladen}
beiFertig={() => void jetztAktualisieren()}
/>
<Tile>
<span className="ads-eyebrow">Sicherung</span>
<Feld label="Automatische Sicherung">
@@ -411,48 +434,19 @@ export function Einstellungen({
</Tile>
<Tile>
<span className="ads-eyebrow">Daten ausgeben</span>
<span className="ads-eyebrow">Fahrzeugprofil</span>
<p className="dm-fussnote">
Als CSV mit Semikolon und deutschem Zahlenformat so öffnet Excel die Datei ohne
Nachfragen.
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={() =>
csvHerunterladen(
"fahrten",
["Beginn", "Ende", "Strecke km", "Dauer s", "Art", "Status"],
fahrten.map((f) => [
f.ts_start,
f.ts_end,
f.distance_km ?? "",
f.duration_s,
f.art,
f.status,
]),
)
}
>
Fahrten
</ActionButton>
<ActionButton
onClick={() =>
csvHerunterladen(
"tankvorgaenge",
["Zeitpunkt", "Liter", "Betrag", "Preis je Liter", "Tankstelle", "Kilometerstand"],
tankvorgaenge.map((t) => [
t.ts,
t.liters ?? "",
t.fuel_total_eur ?? "",
t.price_per_l ?? "",
t.station_name ?? "",
t.odometer_km ?? "",
]),
)
}
>
Tankvorgänge
<ActionButton onClick={() => setzeDatensatzPopup("sichern")}>
Datensatz sichern
</ActionButton>
<ActionButton onClick={() => setzeDatensatzPopup("laden")}>Datensatz laden</ActionButton>
</div>
</Tile>
@@ -588,8 +582,6 @@ export function Einstellungen({
</div>
</Tile>
<input ref={dateiwahl} type="file" accept="application/json" hidden />
{gespeichert && <p className="dm-erfolg">Gespeichert.</p>}
{entwurf && (
<div className="dm-speicherleiste">