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:
@@ -4096,6 +4096,57 @@ bumped `2026.8.28.1` → `.2` (initial popup) → `.3` (the race-condition fix -
|
|||||||
specifically to see this fix, since the panel JS is cache-busted by manifest version, not by content hash).
|
specifically to see this fix, since the panel JS is cache-busted by manifest version, not by content hash).
|
||||||
`audi_ha_test` restarted at each step, confirmed clean via log every time.
|
`audi_ha_test` restarted at each step, confirmed clean via log every time.
|
||||||
|
|
||||||
|
## AD. Item 9 ported to companion-app (2026.8.28)
|
||||||
|
|
||||||
|
companion-app's starting layout differed from the panel's: no standalone "Fahrzeugprofil" tile existed at
|
||||||
|
all (its "Sicherung" tile already bundled profile export together with the server-side backup trigger -
|
||||||
|
`profilExportieren()` there actually downloads the combined `{profil, fahrten, tankvorgaenge}` bundle, the
|
||||||
|
companion-app equivalent of the panel's separate, untouched "Backup" tile, not of "Fahrzeugprofil" - so
|
||||||
|
"Sicherung" was correctly left alone). "Daten ausgeben" only exported Fahrten/Tankvorgänge (no Wartungsplan,
|
||||||
|
no CSV import at all, no ID columns). Ported by turning "Daten ausgeben" into the same "Fahrzeugprofil" tile
|
||||||
|
+ "Datensatz sichern"/"laden" popup the panel now has, reaching the same end state as the panel through a
|
||||||
|
different starting layout - exactly what the parity rule asks for.
|
||||||
|
|
||||||
|
**The CSV column names had to be made byte-identical to the panel's** (`"ID"`, `"Start"`, `"Ende"`, `"km"`,
|
||||||
|
etc.), not merely similar - the backend CSV parser (`csv_import.py`) is shared by both frontends and looks
|
||||||
|
columns up by exact name. companion-app's own prior CSV export used different header text entirely
|
||||||
|
("Beginn"/"Strecke km"/"Dauer s") - harmless before, since nothing ever read it back, but would have broken
|
||||||
|
cross-frontend round-tripping (export from the panel, import into companion-app, or vice versa) had it been
|
||||||
|
left as-is under a shared backend parser.
|
||||||
|
|
||||||
|
**Found a second real, pre-existing bug while doing this**: companion-app's `ServicebuchEintrag` type
|
||||||
|
(`Service.tsx`) declares `betrieb`/`notiz`, but the actual backend list the panel writes to
|
||||||
|
(`profil["service"]["buch"]`) uses `werkstatt`/`kosten`/`arbeiten` - no translation layer exists anywhere
|
||||||
|
(`profilAdapter.ts` passes `service` through as an untyped `Record<string, unknown>`), so a Wartungsplan
|
||||||
|
entry created in one codebase genuinely doesn't display correctly in the other (shows "Betrieb: —", no
|
||||||
|
cost). Out of scope for this feature to fix properly - `DatensatzPopup.tsx`'s Wartungsplan CSV export reads
|
||||||
|
the real field names via a documented `as Record<string, unknown>` cast (with an inline comment explaining
|
||||||
|
why) rather than trusting the local, apparently-wrong type. Flagged as a separate background task
|
||||||
|
(`task_7105e44c`) for a dedicated fix - decide the canonical field names (almost certainly the panel's, the
|
||||||
|
older and more established convention), fix `ServicebuchEintrag` and `Service.tsx`'s read/write code, and
|
||||||
|
simplify the CSV export's workaround cast once the type itself is correct.
|
||||||
|
|
||||||
|
New `src/screens/DatensatzPopup.tsx` (own file, mirrors `HistorienImport.tsx`'s shape/idiom: `Popup
|
||||||
|
variant="form"`, own open/close/error/result state, an `aktiv` ref guarding against a state update after
|
||||||
|
unmount). New `api.csvImportieren(art, inhalt)` (`api/index.ts`) - not queued, same reasoning as
|
||||||
|
`historieImportieren()` (a deliberate one-off action, not a form field that should tolerate being offline).
|
||||||
|
Fahrzeugprofil import reuses the existing `api.profilSchreiben(profil)` directly (NOT the `DatenKontext`
|
||||||
|
convenience `profilSpeichern()`, which only merges partial `einstellungen`/`fahrzeug` fields - a loaded file
|
||||||
|
needs to replace the whole profile, matching the panel's `profil_schreiben` semantics exactly), followed by
|
||||||
|
`neuLaden()` to refresh derived state. `importStatusLesen()`/`zustandLesen()` fetch over REST, not a locally
|
||||||
|
cached reactive object - so companion-app was never exposed to the race-condition class of bug the panel
|
||||||
|
needed a dedicated fix for (see section AC) - confirmed structurally, not just assumed.
|
||||||
|
|
||||||
|
Also removed: a dead `dateiwahl` file-input ref in `Einstellungen.tsx` (declared, rendered, never wired to
|
||||||
|
an `onChange` or `.click()` anywhere) - pre-existing, unrelated scaffolding, but sitting directly next to
|
||||||
|
the new, functionally-equivalent file-input mechanism this change adds made it actively confusing to leave,
|
||||||
|
so removed rather than merely noted.
|
||||||
|
|
||||||
|
Verified: `tsc --noEmit` clean, full suite green at 146/146 (including the "every page renders with sample
|
||||||
|
data" smoke test, which covers `Einstellungen.tsx`), `vite build` succeeds. **Not live-verified** - no
|
||||||
|
running companion-app session with backend auth this round, the same structural limitation noted repeatedly
|
||||||
|
elsewhere in this file for companion-app-only changes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Working conventions (observed — keep them)
|
## Working conventions (observed — keep them)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export * from "./ablageNativ.ts";
|
|||||||
import { DIENST_DOMAIN, ENTITAETEN } from "./types.ts";
|
import { DIENST_DOMAIN, ENTITAETEN } from "./types.ts";
|
||||||
import type {
|
import type {
|
||||||
AppVersionAngabe,
|
AppVersionAngabe,
|
||||||
|
CsvImportErgebnis,
|
||||||
Fahrt,
|
Fahrt,
|
||||||
Fahrzeugstatus,
|
Fahrzeugstatus,
|
||||||
ImportErgebnis,
|
ImportErgebnis,
|
||||||
@@ -304,4 +305,22 @@ export class DataMetricApi {
|
|||||||
);
|
);
|
||||||
return { zustand: zustand.state, daten: zustand.attributes?.daten ?? {} };
|
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 ?? {}) };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,6 +163,17 @@ export interface ImportErgebnis {
|
|||||||
meldung?: string;
|
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
|
/** Das OTA-Bündel, das die Integration mit ausliefert (siehe
|
||||||
custom_components/audi_dashboard/koordinator.py `_buendel_lesen()` und
|
custom_components/audi_dashboard/koordinator.py `_buendel_lesen()` und
|
||||||
companion-app/scripts/ota-paket.ps1). `null`, solange keine
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,11 +4,12 @@
|
|||||||
* Datenausgabe und Zugang.
|
* 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 { ActionButton, Feld, Seg, Switch, Tile } from "@audi-dash/ui"
|
||||||
|
|
||||||
import { DIENST_DOMAIN, zugangLesen, zugangVerwerfen } from "../api"
|
import { DIENST_DOMAIN, zugangLesen, zugangVerwerfen } from "../api"
|
||||||
|
import type { Profil } from "../api"
|
||||||
import { eigeneVersion } from "../daten/appVersion"
|
import { eigeneVersion } from "../daten/appVersion"
|
||||||
import { useDaten } from "../daten/DatenKontext"
|
import { useDaten } from "../daten/DatenKontext"
|
||||||
import { OtaFehler, buendelAnwenden, buendelPasst, otaMoeglich } from "../daten/ota"
|
import { OtaFehler, buendelAnwenden, buendelPasst, otaMoeglich } from "../daten/ota"
|
||||||
@@ -18,8 +19,9 @@ import { useTheme } from "../theme"
|
|||||||
import { Wertzeile, Werteliste, bestaetigen } from "./bausteine"
|
import { Wertzeile, Werteliste, bestaetigen } from "./bausteine"
|
||||||
import { Bild } from "./Bild"
|
import { Bild } from "./Bild"
|
||||||
import { BILDPLAETZE } from "./bilder"
|
import { BILDPLAETZE } from "./bilder"
|
||||||
import { csvHerunterladen } from "./csv"
|
import { DatensatzPopup } from "./DatensatzPopup"
|
||||||
import { HistorienImport } from "./HistorienImport"
|
import { HistorienImport } from "./HistorienImport"
|
||||||
|
import type { ServicebuchEintrag } from "./Service"
|
||||||
import { zugangVergessen } from "./zugang"
|
import { zugangVergessen } from "./zugang"
|
||||||
|
|
||||||
export function Einstellungen({
|
export function Einstellungen({
|
||||||
@@ -56,7 +58,7 @@ export function Einstellungen({
|
|||||||
const [updateInstallierenLaeuft, setzeUpdateInstallierenLaeuft] = useState(false)
|
const [updateInstallierenLaeuft, setzeUpdateInstallierenLaeuft] = useState(false)
|
||||||
const [updateNeustartLaeuft, setzeUpdateNeustartLaeuft] = useState(false)
|
const [updateNeustartLaeuft, setzeUpdateNeustartLaeuft] = useState(false)
|
||||||
const [updateAktionFehler, setzeUpdateAktionFehler] = useState<string | null>(null)
|
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())
|
const otaUpdateVerfuegbar = otaMoeglich() && buendelPasst(otaBuendel, eigeneVersion())
|
||||||
|
|
||||||
@@ -179,6 +181,14 @@ export function Einstellungen({
|
|||||||
beiAbmeldung()
|
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 profilExportieren = () => {
|
||||||
const inhalt = JSON.stringify(
|
const inhalt = JSON.stringify(
|
||||||
{ profil: rohprofil, fahrten, tankvorgaenge, erzeugt: new Date().toISOString() },
|
{ profil: rohprofil, fahrten, tankvorgaenge, erzeugt: new Date().toISOString() },
|
||||||
@@ -376,6 +386,19 @@ export function Einstellungen({
|
|||||||
beiFertig={() => void jetztAktualisieren()}
|
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>
|
<Tile>
|
||||||
<span className="ads-eyebrow">Sicherung</span>
|
<span className="ads-eyebrow">Sicherung</span>
|
||||||
<Feld label="Automatische Sicherung">
|
<Feld label="Automatische Sicherung">
|
||||||
@@ -411,48 +434,19 @@ export function Einstellungen({
|
|||||||
</Tile>
|
</Tile>
|
||||||
|
|
||||||
<Tile>
|
<Tile>
|
||||||
<span className="ads-eyebrow">Daten ausgeben</span>
|
<span className="ads-eyebrow">Fahrzeugprofil</span>
|
||||||
<p className="dm-fussnote">
|
<p className="dm-fussnote">
|
||||||
Als CSV mit Semikolon und deutschem Zahlenformat — so öffnet Excel die Datei ohne
|
Alles Fahrzeugspezifische liegt in einer einzigen Datei: Stammdaten, technische Daten,
|
||||||
Nachfragen.
|
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>
|
</p>
|
||||||
<div className="dm-knopfreihe">
|
<div className="dm-knopfreihe">
|
||||||
<ActionButton
|
<ActionButton onClick={() => setzeDatensatzPopup("sichern")}>
|
||||||
onClick={() =>
|
Datensatz sichern
|
||||||
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>
|
</ActionButton>
|
||||||
|
<ActionButton onClick={() => setzeDatensatzPopup("laden")}>Datensatz laden</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
</Tile>
|
</Tile>
|
||||||
|
|
||||||
@@ -588,8 +582,6 @@ export function Einstellungen({
|
|||||||
</div>
|
</div>
|
||||||
</Tile>
|
</Tile>
|
||||||
|
|
||||||
<input ref={dateiwahl} type="file" accept="application/json" hidden />
|
|
||||||
|
|
||||||
{gespeichert && <p className="dm-erfolg">Gespeichert.</p>}
|
{gespeichert && <p className="dm-erfolg">Gespeichert.</p>}
|
||||||
{entwurf && (
|
{entwurf && (
|
||||||
<div className="dm-speicherleiste">
|
<div className="dm-speicherleiste">
|
||||||
|
|||||||
Reference in New Issue
Block a user