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).
|
||||
`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)
|
||||
|
||||
@@ -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 ?? {}) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user