TANK_DISTANZ_SENSOR + Datensatz sichern/laden mit echtem CSV-Import (Panel)
- Neuer optionaler Sensor TANK_DISTANZ_SENSOR ersetzt die eigene Kilometerstand-Subtraktion fuer die Tankvorgang-Strecke, wo zugeordnet (Live-Erkennung, manuelle Erfassung, historischer Import) - "Fahrzeugprofil" und "Daten ausgeben" zu einer Kachel zusammengelegt: "Datensatz sichern" (4 Exporte wie bisher) / "Datensatz laden" (neu: echter CSV-Import fuer Fahrten/Tankvorgaenge/Wartungsplan) - Import gleicht per ID ab (Fahrten/Tankvorgaenge) bzw. Datum+Art (Wartungsplan, hat keine eigene ID) und aktualisiert nur die in der CSV enthaltenen Spalten - alles andere am Datensatz bleibt unangetastet - Nebenbei gefunden und behoben: belege.tankvorgang_aktualisieren() ueberschrieb bisher immer alle Felder, auch mit None, wenn irgendeins geaendert wurde - fuer den CSV-Import gefaehrlich, jetzt nur noch tatsaechlich uebergebene Felder - Ebenfalls gefunden und behoben: Panel las das Import-Ergebnis per HASS.states direkt nach dem Dienstaufruf - ein Wettlauf mit dem state_changed-Push. Nutzt jetzt denselben HASS.callWS(get_states)-Weg wie der bestehende historie_importieren-Ablauf. companion-app-Portierung von "Datensatz sichern/laden" steht noch aus. Version 2026.8.28.3, live im Testcontainer verifiziert (alle drei CSV-Datensatztypen: anlegen + aktualisieren per ID/Datum+Art getestet, Testdaten danach geloescht). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2622,11 +2622,12 @@ function vEinst() {
|
||||
<span class="label" style="margin-top:10px;color:var(--fg2);letter-spacing:0;
|
||||
text-transform:none;font-size:12.5px;line-height:1.6">
|
||||
Alles Fahrzeugspezifische liegt in einer einzigen Datei: Stammdaten, technische Daten,
|
||||
Ausstattung, Reifen, Versicherung, Steuer und Werkstatt. Für ein anderes Fahrzeug wird nur
|
||||
diese Datei getauscht — die App selbst bleibt unverändert.</span>
|
||||
<button class="aktion" data-profil="export">Profil sichern</button>
|
||||
<label class="aktion" style="display:block;text-align:center;cursor:pointer">
|
||||
Profil laden<input type="file" accept="application/json" data-profil="import" hidden></label>
|
||||
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.</span>
|
||||
<button class="aktion" data-datensatz="sichern">Datensatz sichern</button>
|
||||
<button class="aktion" data-datensatz="laden">Datensatz laden</button>
|
||||
</div>
|
||||
<div class="tile"><span class="label">Backup</span>
|
||||
<span class="label" style="margin-top:10px;color:var(--fg2);letter-spacing:0;
|
||||
@@ -2646,14 +2647,6 @@ function vEinst() {
|
||||
<label class="aktion" style="display:block;text-align:center;cursor:pointer">
|
||||
Backup importieren<input type="file" accept="application/json" data-backup="import" hidden></label>
|
||||
</div>
|
||||
<div class="tile"><span class="label">Daten ausgeben</span>
|
||||
<span style="display:block;margin-top:10px;color:var(--fg2);font-size:13.5px;line-height:1.6">
|
||||
Semikolon als Trennzeichen und deutsches Zahlenformat, damit die Dateien ohne Umweg in
|
||||
Tabellenprogrammen aufgehen.</span>
|
||||
<button class="aktion" data-csv="fahrten">Fahrten als CSV</button>
|
||||
<button class="aktion" data-csv="tanken">Tankvorgänge als CSV</button>
|
||||
<button class="aktion" data-csv="service">Wartungsplan als CSV</button>
|
||||
</div>
|
||||
<div class="tile"><span class="label">Version</span>
|
||||
<dl class="rows">
|
||||
<div class="row" style="border-bottom:0"><dt>Installiert</dt><dd>${esc(APP_VERSION || "unbekannt")}<small>Home-Assistant-Integration</small></dd></div>
|
||||
@@ -3904,6 +3897,132 @@ function vImportPopup() {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- Datensatz-Popup
|
||||
"Fahrzeugprofil"-Kachel in den Einstellungen: "Datensatz sichern" öffnet
|
||||
vier sofortige, rein clientseitige Exporte (Fahrzeugprofil als JSON,
|
||||
Fahrten/Tankvorgänge/Wartungsplan als CSV - identisch zum bisherigen
|
||||
Verhalten der jetzt zusammengelegten "Fahrzeugprofil"/"Daten
|
||||
ausgeben"-Kacheln). "Datensatz laden" spiegelt dieselben vier Dateien als
|
||||
Datei-Auswahl - Fahrzeugprofil läuft wie zuvor über den bestehenden
|
||||
profil_schreiben-Dienst, die drei CSV-Datensätze über den neuen
|
||||
csv_importieren-Dienst (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. */
|
||||
let datensatzPopup = null; // { modus: "sichern" | "laden" } | null
|
||||
let datensatzLaeuft = null; // welcher Datensatz-Import gerade läuft, oder null
|
||||
let datensatzErgebnis = null; // { art, ... } des zuletzt gelaufenen Imports, oder null
|
||||
let datensatzFehler = null;
|
||||
|
||||
const DATENSATZ_ARTEN = [
|
||||
["fahrzeugprofil", "Fahrzeugprofil"],
|
||||
["fahrten", "Fahrten"],
|
||||
["tanken", "Tankvorgänge"],
|
||||
["service", "Wartungsplan"],
|
||||
];
|
||||
|
||||
function datensatzExportieren(art) {
|
||||
if (art === "fahrzeugprofil") {
|
||||
const a = document.createElement("a");
|
||||
a.href = "data:application/json;charset=utf-8," + encodeURIComponent(JSON.stringify(PROFIL_ROH, null, 2));
|
||||
a.download = "fahrzeugprofil.json"; document.body.appendChild(a); a.click(); a.remove();
|
||||
} else if (art === "fahrten") {
|
||||
csvDatei("fahrten.csv", ["ID", "Start", "Ende", "km", "Art", "Status"],
|
||||
TRIPS.map((t) => [t.trip_id, t.ts_start, t.ts_end, t.distance_km != null ? de(t.distance_km) : "", t.art, t.status]));
|
||||
} else if (art === "tanken") {
|
||||
csvDatei("tankvorgaenge.csv", ["ID", "Zeitpunkt", "Station", "Liter", "€/l", "Kosten €"],
|
||||
FILLS.map((f) => [f.tank_id, f.ts, f.station_name || "", f.liters != null ? de(f.liters, 2) : "", f.liters ? de(f.fuel_total_eur / f.liters, 2) : "", f.fuel_total_eur != null ? eur(f.fuel_total_eur) : ""]));
|
||||
} else if (art === "service") {
|
||||
csvDatei("wartungsplan.csv", ["Datum", "km", "Art", "Werkstatt", "Kosten €"],
|
||||
CAR.service.buch.map((e2) => [e2.datum, de(e2.km), e2.art, e2.werkstatt, e2.kosten != null ? eur(e2.kosten) : ""]));
|
||||
}
|
||||
}
|
||||
|
||||
function datensatzErgebnisText(e) {
|
||||
if (e.art === "fahrzeugprofil") return "Fahrzeugprofil geladen.";
|
||||
const teile = [];
|
||||
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.";
|
||||
}
|
||||
|
||||
function datensatzImportieren(art, datei) {
|
||||
const leser = new FileReader();
|
||||
leser.onerror = () => {
|
||||
datensatzLaeuft = null;
|
||||
datensatzFehler = "Die Datei ließ sich nicht lesen.";
|
||||
render();
|
||||
};
|
||||
leser.onload = () => {
|
||||
if (art === "fahrzeugprofil") {
|
||||
try {
|
||||
const p = JSON.parse(leser.result);
|
||||
PROFIL_ROH = p; CONFIG = profilZuConfig(p);
|
||||
CAR = profilZuCar(p, { km: CAR.odo, tankprozent: CAR.tankPct, reichweite_km: CAR.reichweite, gesichert: CAR.gesichert });
|
||||
serviceRufen("profil_schreiben", { profil_json: JSON.stringify(p) });
|
||||
bild = 0;
|
||||
datensatzLaeuft = null; datensatzErgebnis = { art }; datensatzFehler = null;
|
||||
} catch (err) {
|
||||
datensatzLaeuft = null;
|
||||
datensatzFehler = "Die Datei ließ sich nicht lesen: " + err.message;
|
||||
}
|
||||
render();
|
||||
return;
|
||||
}
|
||||
HASS.callService(DOMAIN, "csv_importieren", { art, inhalt: leser.result })
|
||||
.then(async () => {
|
||||
// importStatusLesen() fragt den Zustand frisch per get_states ab,
|
||||
// statt aus dem lokal zwischengespeicherten HASS.states zu lesen -
|
||||
// dessen state_changed-Update kommt über eine eigene
|
||||
// Websocket-Nachricht rein und muss dem Rückgabewert dieses
|
||||
// Dienstaufrufs nicht unbedingt schon vorausgegangen sein (dieselbe
|
||||
// Begründung wie bei importAusloesen() oben, nur ohne dessen
|
||||
// Warteschleife - der CSV-Import läuft synchron im Backend, bis
|
||||
// hierhin ist der neue Stand schon veröffentlicht).
|
||||
const s = await importStatusLesen();
|
||||
datensatzLaeuft = null;
|
||||
datensatzErgebnis = { art, ...((s && s.attributes && s.attributes.daten) || {}) };
|
||||
datensatzFehler = null;
|
||||
render();
|
||||
datenAktualisieren();
|
||||
})
|
||||
.catch((err) => {
|
||||
datensatzLaeuft = null;
|
||||
datensatzFehler = "Der Import ist fehlgeschlagen: " + ((err && err.message) || "unbekannter Fehler");
|
||||
render();
|
||||
});
|
||||
};
|
||||
datensatzLaeuft = art;
|
||||
datensatzErgebnis = null; datensatzFehler = null;
|
||||
render();
|
||||
leser.readAsText(datei);
|
||||
}
|
||||
|
||||
function vDatensatzPopup() {
|
||||
if (!datensatzPopup) return "";
|
||||
const sichern = datensatzPopup.modus === "sichern";
|
||||
return `<div class="beleg-catcher" data-datensatzzu></div>
|
||||
<div class="beleg-popup" role="dialog" aria-modal="true" aria-label="${sichern ? "Datensatz sichern" : "Datensatz laden"}">
|
||||
<span class="label">${sichern ? "Datensatz sichern" : "Datensatz laden"}</span>
|
||||
<span class="label" style="margin-top:10px;color:var(--fg2);letter-spacing:0;
|
||||
text-transform:none;font-size:12.5px;line-height:1.6">
|
||||
${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."}
|
||||
</span>
|
||||
${DATENSATZ_ARTEN.map(([art, label]) => sichern
|
||||
? `<button class="aktion" style="margin-top:12px" data-datensatzsichern="${art}">${label}${art === "fahrzeugprofil" ? " sichern" : " als CSV"}</button>`
|
||||
: `<label class="aktion" style="display:block;text-align:center;cursor:pointer;margin-top:12px">
|
||||
${datensatzLaeuft === art ? "Lädt …" : label + (art === "fahrzeugprofil" ? " laden" : " (CSV) laden")}
|
||||
<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>` : ""}
|
||||
${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>`;
|
||||
}
|
||||
|
||||
function vBelegPopup() {
|
||||
if (!belegPopup) return "";
|
||||
// Am Telefon ist Einfügen der Hauptweg (PDF aus der Mail kopiert), am
|
||||
@@ -4090,7 +4209,7 @@ function render() {
|
||||
// faellt sonst bei jedem render(), z. B. beim Umschalten des Filters,
|
||||
// auf 0 zurueck).
|
||||
const setupKoerperScroll = setupOffen ? (ROOT.querySelector(".setup-koerper") || {}).scrollTop : 0;
|
||||
ROOT.getElementById("overlay").innerHTML = sheetMarkup() + vSetupPopup() + vBelegPopup() + vImportPopup();
|
||||
ROOT.getElementById("overlay").innerHTML = sheetMarkup() + vSetupPopup() + vBelegPopup() + vImportPopup() + vDatensatzPopup();
|
||||
if (setupOffen) {
|
||||
const koerper = ROOT.querySelector(".setup-koerper");
|
||||
if (koerper) koerper.scrollTop = setupKoerperScroll || 0;
|
||||
@@ -4824,6 +4943,18 @@ function ereignisseVerdrahten() {
|
||||
importPopup = null; render(); return;
|
||||
}
|
||||
if (e.target.closest("[data-import-start]")) { importAusloesen(); return; }
|
||||
const dsOeffnen = e.target.closest("[data-datensatz]");
|
||||
if (dsOeffnen) {
|
||||
datensatzPopup = { modus: dsOeffnen.dataset.datensatz };
|
||||
datensatzLaeuft = null; datensatzErgebnis = null; datensatzFehler = null;
|
||||
render(); return;
|
||||
}
|
||||
if (e.target.closest("[data-datensatzzu]")) {
|
||||
if (datensatzLaeuft) return;
|
||||
datensatzPopup = null; render(); return;
|
||||
}
|
||||
const dsExport = e.target.closest("[data-datensatzsichern]");
|
||||
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; }
|
||||
const vseldel = e.target.closest("[data-vselbstdel]");
|
||||
@@ -4833,13 +4964,6 @@ function ereignisseVerdrahten() {
|
||||
if (oel) { CAR.oel.modus = oel.dataset.oel; profilSpeichern(); render(); return; }
|
||||
const mode = e.target.closest("[data-mode]");
|
||||
if (mode) { themeSetzen(mode.dataset.mode); themeIcon(); render(); return; }
|
||||
const pr = e.target.closest("[data-profil]");
|
||||
if (pr && pr.dataset.profil === "export") {
|
||||
const a = document.createElement("a");
|
||||
a.href = "data:application/json;charset=utf-8," + encodeURIComponent(JSON.stringify(PROFIL_ROH, null, 2));
|
||||
a.download = "fahrzeugprofil.json"; document.body.appendChild(a); a.click(); a.remove();
|
||||
return;
|
||||
}
|
||||
const bk = e.target.closest("[data-backup]");
|
||||
if (bk && bk.dataset.backup === "jetzt") { serviceRufen("backup_jetzt", {}); return; }
|
||||
if (bk && bk.dataset.backup === "export") {
|
||||
@@ -4895,17 +5019,6 @@ function ereignisseVerdrahten() {
|
||||
}
|
||||
return;
|
||||
}
|
||||
const cs = e.target.closest("[data-csv]");
|
||||
if (cs) {
|
||||
const w = cs.dataset.csv;
|
||||
if (w === "fahrten") csvDatei("fahrten.csv", ["Start", "Ende", "km", "Art", "Status"],
|
||||
TRIPS.map((t) => [t.ts_start, t.ts_end, t.distance_km != null ? de(t.distance_km) : "", t.art, t.status]));
|
||||
else if (w === "tanken") csvDatei("tankvorgaenge.csv", ["Zeitpunkt", "Station", "Liter", "€/l", "Kosten €"],
|
||||
FILLS.map((f) => [f.ts, f.station_name || "", f.liters != null ? de(f.liters, 2) : "", f.liters ? de(f.fuel_total_eur / f.liters, 2) : "", f.fuel_total_eur != null ? eur(f.fuel_total_eur) : ""]));
|
||||
else csvDatei("servicebuch.csv", ["Datum", "km", "Art", "Werkstatt", "Kosten €"],
|
||||
CAR.service.buch.map((e2) => [e2.datum, de(e2.km), e2.art, e2.werkstatt, e2.kosten != null ? eur(e2.kosten) : ""]));
|
||||
return;
|
||||
}
|
||||
const satz = e.target.closest("[data-satz]");
|
||||
if (satz) { serviceRufen("reifen_wechseln", { satz: satz.dataset.satz.toLowerCase() }); CAR.reifen.aktiv = satz.dataset.satz; render(); return; }
|
||||
const rfArch = e.target.closest("[data-reifenarchivieren]");
|
||||
@@ -5007,18 +5120,9 @@ function ereignisseVerdrahten() {
|
||||
});
|
||||
|
||||
ROOT.addEventListener("change", (e) => {
|
||||
if (e.target.dataset.profil === "import") {
|
||||
if (e.target.dataset.datensatzladen) {
|
||||
const datei = e.target.files[0]; if (!datei) return;
|
||||
const leser = new FileReader();
|
||||
leser.onload = () => {
|
||||
try {
|
||||
const p = JSON.parse(leser.result);
|
||||
PROFIL_ROH = p; CONFIG = profilZuConfig(p); CAR = profilZuCar(p, { km: CAR.odo, tankprozent: CAR.tankPct, reichweite_km: CAR.reichweite, gesichert: CAR.gesichert });
|
||||
serviceRufen("profil_schreiben", { profil_json: JSON.stringify(p) });
|
||||
bild = 0; render();
|
||||
} catch (err) { hinweis("Die Datei ließ sich nicht lesen", err.message); }
|
||||
};
|
||||
leser.readAsText(datei);
|
||||
datensatzImportieren(e.target.dataset.datensatzladen, datei);
|
||||
return;
|
||||
}
|
||||
if (e.target.dataset.backup === "import") {
|
||||
|
||||
Reference in New Issue
Block a user