Phase 7b: Fahrten, Tanken und Statistik
Listen mit Jahr-Monat-Aufklappung, Detailseiten, manuelles Eintragen und Beleg-Upload. Statistik rechnet ueber die portierten Regeln. Zwei offene Audit-Befunde des alten Panels sind hier gleich mit erledigt: 1. Loeschen war ausschliesslich per Wischgeste erreichbar. Wer mit Sprachausgabe oder Schaltersteuerung bedient, wischt nicht - Fahrten und Tankvorgaenge waren fuer diese Nutzer gar nicht loeschbar. Jede Zeile hat jetzt zusaetzlich ein sichtbares Menue, ein richtiger Knopf mit Tastatur. 2. Leaflet kam per CDN und blieb auf einem Handy ohne eigenen Internetzugang still leer. Es liegt jetzt im Buendel (eigener Abschnitt, erst bei Bedarf geladen). Bewusst NICHT uebernommen: fakeTrack() aus dem alten Panel, das eine erfundene Linie zwischen zwei Punkten zeichnete, wenn keine Route vorlag. Solange das Fahrzeug keine Positionen liefert, sagt die Seite das - statt eine glaubwuerdig aussehende Erfindung zu zeigen.
This commit is contained in:
@@ -18,6 +18,20 @@ import { HassRest } from "./rest.ts";
|
||||
import { HassLive } from "./live.ts";
|
||||
import { Warteschlange } from "./warteschlange.ts";
|
||||
|
||||
/** Eingabefelder für Tankvorgänge. Die Namen sind die des Backends
|
||||
(belegverarbeitung.py), nicht die des Datensatzes - deshalb "liter" statt
|
||||
"liters" und "kosten" statt "fuel_total_eur". */
|
||||
export interface TankvorgangFelder {
|
||||
ts?: string;
|
||||
liter?: number | null;
|
||||
kosten?: number | null;
|
||||
km?: number | null;
|
||||
ersparnis?: number | null;
|
||||
station?: string | null;
|
||||
distanz?: number | null;
|
||||
kraftstoff?: string | null;
|
||||
}
|
||||
|
||||
/** Bündelt die drei Bausteine und bildet die Fachvorgänge ab, die das alte
|
||||
Dashboard über hass.callService erledigt hat. */
|
||||
export class DataMetricApi {
|
||||
@@ -78,6 +92,16 @@ export class DataMetricApi {
|
||||
);
|
||||
}
|
||||
|
||||
/** Legt eine Fahrt von Hand an — für Fahrten ohne automatische Erkennung. */
|
||||
fahrtAnlegen(tsStart: string, tsEnde: string, art: "privat" | "arbeitsweg"): Promise<unknown> {
|
||||
return this.warteschlange.einreihen(
|
||||
"pyscript",
|
||||
"audi_dashboard_fahrt_manuell_anlegen",
|
||||
{ ts_start: tsStart, ts_end: tsEnde, art },
|
||||
"Fahrt eintragen",
|
||||
);
|
||||
}
|
||||
|
||||
fahrtLoeschen(tripId: string): Promise<unknown> {
|
||||
return this.warteschlange.einreihen(
|
||||
"pyscript",
|
||||
@@ -87,6 +111,26 @@ export class DataMetricApi {
|
||||
);
|
||||
}
|
||||
|
||||
/** Felder eines Tankvorgangs, wie sie das Backend erwartet (deutsche
|
||||
Parameternamen — belegverarbeitung.py). */
|
||||
tankvorgangAnlegen(felder: TankvorgangFelder): Promise<unknown> {
|
||||
return this.warteschlange.einreihen(
|
||||
"pyscript",
|
||||
"audi_dashboard_tankvorgang_manuell",
|
||||
{ ...felder },
|
||||
"Tankvorgang eintragen",
|
||||
);
|
||||
}
|
||||
|
||||
tankvorgangAktualisieren(tankId: string, felder: TankvorgangFelder): Promise<unknown> {
|
||||
return this.warteschlange.einreihen(
|
||||
"pyscript",
|
||||
"audi_dashboard_tankvorgang_aktualisieren",
|
||||
{ tank_id: tankId, ...felder },
|
||||
"Tankvorgang ändern",
|
||||
);
|
||||
}
|
||||
|
||||
tankvorgangLoeschen(tankId: string): Promise<unknown> {
|
||||
return this.warteschlange.einreihen(
|
||||
"pyscript",
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Eine einzelne Fahrt. Vorlage: `vTrip(id)` im alten Panel — mit einem
|
||||
* bewussten Unterschied:
|
||||
*
|
||||
* Das alte Panel zeichnete über `fakeTrack()` eine **erfundene** Linie
|
||||
* zwischen zwei Punkten, wenn keine echte Route vorlag. Das ist hier nicht
|
||||
* übernommen. Solange das Fahrzeug keine Positionsdaten liefert (bis zum
|
||||
* FMM003 liefert es keine), zeigt die Seite ehrlich, dass keine Route bekannt
|
||||
* ist, statt eine glaubwürdig aussehende Erfindung zu zeigen.
|
||||
*/
|
||||
|
||||
import { Pill, Tile } from "@audi-dash/ui"
|
||||
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { datum, dauer, de, deOderStrich, uhrzeit } from "../format"
|
||||
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
|
||||
import { Karte } from "./Karte"
|
||||
|
||||
export function FahrtDetail({ id }: { id: string | undefined }) {
|
||||
const { fahrten } = useDaten()
|
||||
const fahrt = fahrten.find((f) => f.trip_id === id)
|
||||
|
||||
if (!fahrt) {
|
||||
return (
|
||||
<Leerzustand
|
||||
titel="Fahrt nicht gefunden"
|
||||
text="Dieser Eintrag existiert nicht mehr. Vielleicht wurde er gerade gelöscht."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const hatPositionen = fahrt.start_lat != null && fahrt.start_lon != null
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">
|
||||
{datum(fahrt.ts_start)} · {uhrzeit(fahrt.ts_start)} bis {uhrzeit(fahrt.ts_end)} Uhr
|
||||
</span>
|
||||
<div className="dm-detail__kopf">
|
||||
<div className="dm-detail__zahl">
|
||||
{deOderStrich(fahrt.distance_km, 1)}
|
||||
<span className="dm-detail__einheit">km</span>
|
||||
</div>
|
||||
<div className="dm-detail__marken">
|
||||
{fahrt.art === "arbeitsweg" ? <Pill variant="work">Arbeitsweg</Pill> : <Pill>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`
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
{hatPositionen ? (
|
||||
<Karte
|
||||
start={{ lat: fahrt.start_lat!, lon: fahrt.start_lon! }}
|
||||
{...(fahrt.end_lat != null && fahrt.end_lon != null
|
||||
? { ziel: { lat: fahrt.end_lat, lon: fahrt.end_lon } }
|
||||
: {})}
|
||||
/>
|
||||
) : (
|
||||
<Leerzustand
|
||||
titel="Keine Route aufgezeichnet"
|
||||
text="Das Fahrzeug liefert derzeit keine Positionsdaten. Sobald es das tut, erscheint hier die gefahrene Strecke auf der Karte."
|
||||
/>
|
||||
)}
|
||||
{(fahrt.start_address || fahrt.end_address) && (
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile label="Start" wert={fahrt.start_address ?? "—"} />
|
||||
<Wertzeile label="Ziel" wert={fahrt.end_address ?? "—"} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Tile>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Fahrtenliste, nach Jahr und Monat aufklappbar. Vorlage: `vTrips()` und
|
||||
* `vTripsFormular()` im alten Panel.
|
||||
*/
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import { Accordion, ActionButton, Feld, Pill, 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 type { SeitenName } from "../navigation"
|
||||
import { Leerzustand } from "./bausteine"
|
||||
import { Zeilenmenue } from "./Zeilenmenue"
|
||||
|
||||
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)
|
||||
|
||||
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) => (
|
||||
<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]}
|
||||
>
|
||||
{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">
|
||||
{datum(fahrt.ts_start)} · {uhrzeit(fahrt.ts_start)} Uhr ·{" "}
|
||||
{dauer(fahrt.duration_s)}
|
||||
</span>
|
||||
</span>
|
||||
{fahrt.art === "arbeitsweg" && <Pill variant="work">Arbeitsweg</Pill>}
|
||||
{fahrt.status === "offen" && <Pill>offen</Pill>}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Accordion>
|
||||
))}
|
||||
</Accordion>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Manuelles Eintragen — für Fahrten, die die automatische Erkennung verpasst hat. */
|
||||
function FahrtFormular({ beiFertig }: { beiFertig: () => void }) {
|
||||
const { api } = useDaten()
|
||||
const [start, setzeStart] = useState("")
|
||||
const [ende, setzeEnde] = useState("")
|
||||
const [art, setzeArt] = useState<"privat" | "arbeitsweg">("privat")
|
||||
const [fehler, setzeFehler] = useState<string | null>(null)
|
||||
const [laeuft, setzeLaeuft] = useState(false)
|
||||
|
||||
const speichern = async () => {
|
||||
setzeFehler(null)
|
||||
if (!start || !ende) {
|
||||
setzeFehler("Bitte Beginn und Ende angeben.")
|
||||
return
|
||||
}
|
||||
if (new Date(ende) <= new Date(start)) {
|
||||
setzeFehler("Das Ende muss nach dem Beginn liegen.")
|
||||
return
|
||||
}
|
||||
setzeLaeuft(true)
|
||||
try {
|
||||
await api.fahrtAnlegen(new Date(start).toISOString(), new Date(ende).toISOString(), art)
|
||||
beiFertig()
|
||||
} catch (ursache) {
|
||||
setzeFehler(ursache instanceof Error ? ursache.message : String(ursache))
|
||||
} finally {
|
||||
setzeLaeuft(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Fahrt eintragen</span>
|
||||
<Feld label="Beginn">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
type="datetime-local"
|
||||
value={start}
|
||||
onChange={(e) => setzeStart(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Ende">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
type="datetime-local"
|
||||
value={ende}
|
||||
onChange={(e) => setzeEnde(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Art" last>
|
||||
<select
|
||||
className="dm-auswahl"
|
||||
value={art}
|
||||
onChange={(e) => setzeArt(e.target.value as "privat" | "arbeitsweg")}
|
||||
>
|
||||
<option value="privat">Privat</option>
|
||||
<option value="arbeitsweg">Arbeitsweg</option>
|
||||
</select>
|
||||
</Feld>
|
||||
{fehler && (
|
||||
<p className="dm-fehler" role="alert">
|
||||
{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>
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
|
||||
{laeuft ? "Speichere …" : "Speichern"}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Tile>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Karte für Fahrtdetails.
|
||||
*
|
||||
* Leaflet kommt hier **aus dem Bündel**, nicht von einem CDN. Im alten Panel
|
||||
* wurde es zur Laufzeit nachgeladen, was auf einem Handy ohne eigenen
|
||||
* Internetzugang (nur über den VPN-Tunnel erreichbar) stillschweigend nichts
|
||||
* anzeigte — ein offener Befund des Audits. Die Kacheln selbst bleiben
|
||||
* zwangsläufig entfernt; fehlen sie, bleibt wenigstens die Karte samt Markern
|
||||
* bedienbar statt leer.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
|
||||
import "leaflet/dist/leaflet.css"
|
||||
|
||||
import { useTheme } from "../theme"
|
||||
|
||||
export interface Punkt {
|
||||
lat: number
|
||||
lon: number
|
||||
}
|
||||
|
||||
const KACHELN = {
|
||||
nacht: {
|
||||
url: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png",
|
||||
zusatz: "© OpenStreetMap, © CARTO",
|
||||
},
|
||||
tag: {
|
||||
url: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png",
|
||||
zusatz: "© OpenStreetMap, © CARTO",
|
||||
},
|
||||
} as const
|
||||
|
||||
export function Karte({ start, ziel }: { start: Punkt; ziel?: Punkt }) {
|
||||
const behaelter = useRef<HTMLDivElement | null>(null)
|
||||
const [theme] = useTheme()
|
||||
|
||||
useEffect(() => {
|
||||
const element = behaelter.current
|
||||
if (!element) return
|
||||
|
||||
let karte: import("leaflet").Map | null = null
|
||||
let abgebrochen = false
|
||||
|
||||
// Dynamisch geladen, damit Leaflet nicht im Startbündel jeder Seite liegt —
|
||||
// gebündelt wird es trotzdem, es kommt aus node_modules, nicht aus dem Netz.
|
||||
void import("leaflet").then((L) => {
|
||||
if (abgebrochen) return
|
||||
karte = L.map(element, { attributionControl: true, scrollWheelZoom: false })
|
||||
const kachel = KACHELN[theme]
|
||||
L.tileLayer(kachel.url, { attribution: kachel.zusatz, maxZoom: 19 }).addTo(karte)
|
||||
|
||||
const punkte: [number, number][] = [[start.lat, start.lon]]
|
||||
L.circleMarker([start.lat, start.lon], { radius: 6, color: "#F50537" })
|
||||
.bindTooltip("Start")
|
||||
.addTo(karte)
|
||||
|
||||
if (ziel) {
|
||||
punkte.push([ziel.lat, ziel.lon])
|
||||
L.circleMarker([ziel.lat, ziel.lon], { radius: 6, color: "#F50537", fill: true })
|
||||
.bindTooltip("Ziel")
|
||||
.addTo(karte)
|
||||
// Verbindungslinie NUR zwischen echten Punkten, kein erfundener Verlauf.
|
||||
L.polyline(punkte, { color: "#F50537", weight: 2, dashArray: "4 6" }).addTo(karte)
|
||||
}
|
||||
|
||||
if (punkte.length > 1) {
|
||||
karte.fitBounds(punkte, { padding: [24, 24] })
|
||||
} else {
|
||||
karte.setView(punkte[0]!, 14)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
abgebrochen = true
|
||||
karte?.remove()
|
||||
}
|
||||
}, [start, ziel, theme])
|
||||
|
||||
return <div ref={behaelter} className="dm-karte" role="img" aria-label="Karte der Fahrt" />
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Statistik. Vorlage: `vStat()` im alten Panel — die Rechenregeln stecken in
|
||||
* `daten/statistik.ts` und sind von dort unverändert übernommen.
|
||||
*/
|
||||
|
||||
import { Fig, ProgressBar, StatGrid, 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 { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
|
||||
|
||||
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."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const a = jahresauswertung(
|
||||
fahrten,
|
||||
tankvorgaenge,
|
||||
einstellungen.nachtVon,
|
||||
einstellungen.nachtBis,
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
<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}
|
||||
/>
|
||||
</Tile>
|
||||
|
||||
<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}
|
||||
/>
|
||||
<StatGrid
|
||||
items={ZEITRAEUME.map((z) => ({
|
||||
value: de(a.jeZeitraum[z].kosten),
|
||||
unit: "€",
|
||||
caption: ZEITRAUM_NAME[z],
|
||||
}))}
|
||||
columns={4}
|
||||
/>
|
||||
</Tile>
|
||||
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Langzeitverbrauch</span>
|
||||
<Fig value={deOderStrich(a.langzeit.wert, 1)} unit="l/100 km" size={40} />
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile label="Getankt insgesamt" wert={`${de(a.langzeit.liter, 1)} l`} />
|
||||
<Wertzeile
|
||||
label="Strecke bis zum letzten Tankstop"
|
||||
wert={`${de(a.langzeit.km)} km`}
|
||||
zusatz={a.langzeit.basis !== "–" ? a.langzeit.basis : undefined}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Letzter Tankstop"
|
||||
wert={a.langzeit.bis ? datum(a.langzeit.bis) : "—"}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{a.langzeit.unplausibel && (
|
||||
<p className="dm-fussnote dm-fussnote--warnung">
|
||||
Aus Litern und Strecke ergibt sich derzeit kein plausibler Wert. Sobald Tankvorgänge und
|
||||
Kilometerstände vollständig vorliegen, erscheint er hier.
|
||||
</p>
|
||||
)}
|
||||
<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}
|
||||
/>
|
||||
<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">
|
||||
Nacht = {einstellungen.nachtVon} bis {einstellungen.nachtBis} Uhr
|
||||
</p>
|
||||
</Tile>
|
||||
|
||||
<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}
|
||||
/>
|
||||
<ProgressBar
|
||||
segments={[
|
||||
{ value: a.arbeitKm, color: "var(--red)", label: `Arbeitsweg · ${de(a.arbeitKm)} km` },
|
||||
{ value: a.privatKm, color: "var(--fg3)", label: `Privat · ${de(a.privatKm)} km` },
|
||||
]}
|
||||
/>
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Ein einzelner Tankvorgang samt Beleg-Upload. Vorlage: `vFill(id)`.
|
||||
*
|
||||
* Der Beleg wird als PDF hochgeladen; das Ergebnis kommt **nicht** als
|
||||
* Rückgabewert des Dienstaufrufs zurück (pyscript liefert keine), sondern
|
||||
* asynchron über die Entität `pyscript.audi_dashboard_beleg_ergebnis`.
|
||||
* Genau dieses Muster hatte auch das alte Panel.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
import { ActionButton, Pill, Tile } from "@audi-dash/ui"
|
||||
|
||||
import { ENTITAETEN } from "../api"
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { datum, datumZeit, de, deOderStrich, eur } from "../format"
|
||||
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
|
||||
|
||||
interface BelegErgebnis {
|
||||
ok?: boolean
|
||||
fehler?: string
|
||||
meldung?: string
|
||||
tank_id?: string
|
||||
[weitere: string]: unknown
|
||||
}
|
||||
|
||||
export function TankDetail({ id }: { id: string | undefined }) {
|
||||
const { tankvorgaenge, api } = useDaten()
|
||||
const tank = tankvorgaenge.find((t) => t.tank_id === id)
|
||||
|
||||
if (!tank) {
|
||||
return (
|
||||
<Leerzustand
|
||||
titel="Tankvorgang nicht gefunden"
|
||||
text="Dieser Eintrag existiert nicht mehr. Vielleicht wurde er gerade gelöscht."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">{datumZeit(tank.ts)}</span>
|
||||
<div className="dm-detail__kopf">
|
||||
<div className="dm-detail__zahl">
|
||||
{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="Gesamtbetrag"
|
||||
wert={tank.fuel_total_eur != null ? `${eur(tank.fuel_total_eur)} €` : "—"}
|
||||
/>
|
||||
<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
|
||||
}
|
||||
/>
|
||||
{tank.discount != null && tank.discount > 0 && (
|
||||
<Wertzeile label="Ersparnis" 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` : "—"}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Tile>
|
||||
|
||||
<BelegBereich tankId={tank.tank_id} belegDatei={tank.receipt_file ?? null} belegNr={tank.receipt_no ?? null} zeitpunkt={tank.ts} api={api} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function BelegBereich({
|
||||
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)
|
||||
const [laeuft, setzeLaeuft] = useState(false)
|
||||
const [meldung, setzeMeldung] = useState<{ art: "ok" | "fehler"; text: string } | null>(null)
|
||||
|
||||
// Das Ergebnis kommt asynchron über eine eigene Entität, nicht als Antwort
|
||||
// auf den Dienstaufruf. Nach dem Hochladen wird sie deshalb kurz abgefragt.
|
||||
const [wartet, setzeWartet] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!wartet) return
|
||||
let abgebrochen = false
|
||||
const frist = Date.now() + 20_000
|
||||
|
||||
const abfragen = async () => {
|
||||
while (!abgebrochen && Date.now() < frist) {
|
||||
await new Promise((weiter) => setTimeout(weiter, 1200))
|
||||
try {
|
||||
const ergebnis = await api.rest.datenLesen<BelegErgebnis>(ENTITAETEN.belegErgebnis)
|
||||
if (abgebrochen) return
|
||||
if (ergebnis && ergebnis.tank_id === tankId) {
|
||||
setzeWartet(false)
|
||||
setzeMeldung(
|
||||
ergebnis.ok === false || ergebnis.fehler
|
||||
? { art: "fehler", text: ergebnis.fehler ?? "Der Beleg konnte nicht gelesen werden." }
|
||||
: { art: "ok", text: ergebnis.meldung ?? "Beleg übernommen." },
|
||||
)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Weiterversuchen — ein einzelner Fehlschlag heißt nichts.
|
||||
}
|
||||
}
|
||||
if (!abgebrochen) {
|
||||
setzeWartet(false)
|
||||
setzeMeldung({
|
||||
art: "fehler",
|
||||
text: "Der Server hat noch nicht geantwortet. Der Beleg wird trotzdem verarbeitet — die Seite aktualisiert sich, sobald er da ist.",
|
||||
})
|
||||
}
|
||||
}
|
||||
void abfragen()
|
||||
return () => {
|
||||
abgebrochen = true
|
||||
}
|
||||
}, [wartet, api, tankId])
|
||||
|
||||
const hochladen = async (datei: File) => {
|
||||
setzeMeldung(null)
|
||||
if (datei.type !== "application/pdf" && !datei.name.toLowerCase().endsWith(".pdf")) {
|
||||
setzeMeldung({ art: "fehler", text: "Bitte den Beleg als PDF auswählen." })
|
||||
return
|
||||
}
|
||||
setzeLaeuft(true)
|
||||
try {
|
||||
const base64 = await alsBase64(datei)
|
||||
await api.belegHochladen(base64, datei.name, tankId)
|
||||
setzeWartet(true)
|
||||
} catch (ursache) {
|
||||
setzeMeldung({
|
||||
art: "fehler",
|
||||
text: ursache instanceof Error ? ursache.message : String(ursache),
|
||||
})
|
||||
} finally {
|
||||
setzeLaeuft(false)
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
accept="application/pdf,.pdf"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const datei = e.target.files?.[0]
|
||||
if (datei) void hochladen(datei)
|
||||
e.target.value = ""
|
||||
}}
|
||||
/>
|
||||
|
||||
{meldung && (
|
||||
<p
|
||||
className={meldung.art === "fehler" ? "dm-fehler" : "dm-erfolg"}
|
||||
role={meldung.art === "fehler" ? "alert" : "status"}
|
||||
>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
/** Datei als Base64 ohne den `data:`-Vorspann — so erwartet es das Backend. */
|
||||
function alsBase64(datei: File): Promise<string> {
|
||||
return new Promise((aufloesen, ablehnen) => {
|
||||
const leser = new FileReader()
|
||||
leser.onload = () => {
|
||||
const ergebnis = String(leser.result)
|
||||
const komma = ergebnis.indexOf(",")
|
||||
aufloesen(komma >= 0 ? ergebnis.slice(komma + 1) : ergebnis)
|
||||
}
|
||||
leser.onerror = () => ablehnen(new Error("Die Datei konnte nicht gelesen werden."))
|
||||
leser.readAsDataURL(datei)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Tankvorgänge, nach Jahr und Monat aufklappbar. Vorlage: `vFuel()`.
|
||||
*
|
||||
* Der Durchschnittspreis ist immer mengengewichtet (Summe Kosten geteilt durch
|
||||
* Summe Liter) — eine kleine teure Tankung darf den Schnitt nicht so stark
|
||||
* heben wie eine große.
|
||||
*/
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import { Accordion, ActionButton, Feld, Pill, Tile } from "@audi-dash/ui"
|
||||
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { nachJahrUndMonat } from "../daten/statistik"
|
||||
import { datum, de, deOderStrich, eur, monatJahr, schnittpreis, summe } from "../format"
|
||||
import type { SeitenName } from "../navigation"
|
||||
import { Leerzustand } from "./bausteine"
|
||||
import { Zeilenmenue } from "./Zeilenmenue"
|
||||
|
||||
export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
|
||||
const { tankvorgaenge, api } = useDaten()
|
||||
const [formularOffen, setzeFormularOffen] = useState(false)
|
||||
|
||||
const gruppen = nachJahrUndMonat(tankvorgaenge, (t) => t.ts)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dm-abschnitt">
|
||||
<span className="ads-eyebrow">
|
||||
{tankvorgaenge.length === 0
|
||||
? "Keine Tankvorgänge"
|
||||
: `${de(tankvorgaenge.length)} Tankvorgang${tankvorgaenge.length === 1 ? "" : "̈e"}`}
|
||||
</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) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{gruppen.map((jahr) => (
|
||||
<Accordion
|
||||
key={jahr.jahr}
|
||||
title={String(jahr.jahr)}
|
||||
summary={`${eur(summe(jahr.eintraege, (t) => t.fuel_total_eur ?? 0))} €`}
|
||||
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={schnitt > 0 ? `${eur(schnitt)} €/l` : "—"}
|
||||
defaultOpen={monat === jahr.monate[0]}
|
||||
>
|
||||
{monat.eintraege.map((tank) => (
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Accordion>
|
||||
)
|
||||
})}
|
||||
</Accordion>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TankFormular({ beiFertig }: { beiFertig: () => void }) {
|
||||
const { api } = useDaten()
|
||||
const [zeitpunkt, setzeZeitpunkt] = useState("")
|
||||
const [liter, setzeLiter] = useState("")
|
||||
const [kosten, setzeKosten] = useState("")
|
||||
const [station, setzeStation] = useState("")
|
||||
const [km, setzeKm] = useState("")
|
||||
const [fehler, setzeFehler] = useState<string | null>(null)
|
||||
const [laeuft, setzeLaeuft] = useState(false)
|
||||
|
||||
const speichern = async () => {
|
||||
setzeFehler(null)
|
||||
const literZahl = Number(liter.replace(",", "."))
|
||||
const kostenZahl = Number(kosten.replace(",", "."))
|
||||
if (!Number.isFinite(literZahl) || literZahl <= 0) {
|
||||
setzeFehler("Bitte die getankte Menge in Litern angeben.")
|
||||
return
|
||||
}
|
||||
if (!Number.isFinite(kostenZahl) || kostenZahl <= 0) {
|
||||
setzeFehler("Bitte den Gesamtbetrag angeben.")
|
||||
return
|
||||
}
|
||||
setzeLaeuft(true)
|
||||
try {
|
||||
const kmZahl = Number(km.replace(",", "."))
|
||||
await api.tankvorgangAnlegen({
|
||||
ts: zeitpunkt ? new Date(zeitpunkt).toISOString() : new Date().toISOString(),
|
||||
liter: literZahl,
|
||||
kosten: kostenZahl,
|
||||
station: station || null,
|
||||
km: Number.isFinite(kmZahl) && km ? kmZahl : null,
|
||||
})
|
||||
beiFertig()
|
||||
} catch (ursache) {
|
||||
setzeFehler(ursache instanceof Error ? ursache.message : String(ursache))
|
||||
} finally {
|
||||
setzeLaeuft(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Tankung eintragen</span>
|
||||
<Feld label="Zeitpunkt">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
type="datetime-local"
|
||||
value={zeitpunkt}
|
||||
onChange={(e) => setzeZeitpunkt(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Menge" unit="l">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
inputMode="decimal"
|
||||
placeholder="0,00"
|
||||
value={liter}
|
||||
onChange={(e) => setzeLiter(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Gesamtbetrag" unit="€">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
inputMode="decimal"
|
||||
placeholder="0,00"
|
||||
value={kosten}
|
||||
onChange={(e) => setzeKosten(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Tankstelle">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
placeholder="optional"
|
||||
value={station}
|
||||
onChange={(e) => setzeStation(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Kilometerstand" unit="km" last>
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
inputMode="numeric"
|
||||
placeholder="vom Fahrzeug"
|
||||
value={km}
|
||||
onChange={(e) => setzeKm(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
{fehler && (
|
||||
<p className="dm-fehler" role="alert">
|
||||
{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>
|
||||
</Tile>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Löschweg für Listenzeilen — Wischen **und** ein sichtbares Menü.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { useState, type ReactNode } from "react"
|
||||
|
||||
import { Popup, PopupMenuItem, SwipeRow } from "@audi-dash/ui"
|
||||
|
||||
import { SymbolMehr } from "../symbole"
|
||||
|
||||
export function Zeilenmenue({
|
||||
beiLoeschen,
|
||||
loeschFrage,
|
||||
kinder,
|
||||
}: {
|
||||
beiLoeschen: () => void
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -5,9 +5,14 @@
|
||||
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import type { Route, SeitenName } from "../navigation"
|
||||
import { FahrtDetail } from "./FahrtDetail"
|
||||
import { Fahrten } from "./Fahrten"
|
||||
import { Fahrzeugstatus } from "./Fahrzeugstatus"
|
||||
import { MeinAudi } from "./MeinAudi"
|
||||
import { Platzhalter } from "./Platzhalter"
|
||||
import { Statistik } from "./Statistik"
|
||||
import { TankDetail } from "./TankDetail"
|
||||
import { Tanken } from "./Tanken"
|
||||
import { Uebersicht } from "./Uebersicht"
|
||||
|
||||
export interface SeitenProps {
|
||||
@@ -44,6 +49,16 @@ export function SeiteFuer(props: SeitenProps) {
|
||||
return <Fahrzeugstatus />
|
||||
case "audi":
|
||||
return <MeinAudi geheZu={geheZu} />
|
||||
case "trips":
|
||||
return <Fahrten geheZu={geheZu} />
|
||||
case "trip":
|
||||
return <FahrtDetail id={route.id} />
|
||||
case "fuel":
|
||||
return <Tanken geheZu={geheZu} />
|
||||
case "fill":
|
||||
return <TankDetail id={route.id} />
|
||||
case "stat":
|
||||
return <Statistik />
|
||||
default:
|
||||
return <Platzhalter route={route} geheZu={geheZu} />
|
||||
}
|
||||
|
||||
@@ -352,3 +352,138 @@
|
||||
gap: var(--sp-2);
|
||||
margin-top: var(--sp-3);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- Listenzeilen */
|
||||
|
||||
.dm-zeile {
|
||||
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;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--fg3);
|
||||
border-radius: var(--r-func);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dm-zeile__menue:hover {
|
||||
background: var(--shade);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.dm-listenzeile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
width: 100%;
|
||||
padding: 12px 2px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dm-listenzeile__haupt {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.dm-listenzeile__titel {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--sp-2);
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.dm-listenzeile__neben {
|
||||
font-size: 13px;
|
||||
color: var(--fg2);
|
||||
}
|
||||
|
||||
.dm-listenzeile__unter {
|
||||
font-size: 12px;
|
||||
font-weight: 300;
|
||||
color: var(--fg3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------- Detailseiten */
|
||||
|
||||
.dm-detail__kopf {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
margin: var(--sp-2) 0 var(--sp-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dm-detail__zahl {
|
||||
font-size: 40px;
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.dm-detail__einheit {
|
||||
font-size: 15px;
|
||||
color: var(--fg2);
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.dm-detail__marken {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dm-karte {
|
||||
height: 240px;
|
||||
width: 100%;
|
||||
border-radius: var(--r-func);
|
||||
overflow: hidden;
|
||||
margin-top: var(--sp-3);
|
||||
background: var(--tile-2);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Meldungen */
|
||||
|
||||
.dm-fehler {
|
||||
margin: var(--sp-3) 0 0;
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
line-height: 1.5;
|
||||
color: var(--bad);
|
||||
}
|
||||
|
||||
.dm-erfolg {
|
||||
margin: var(--sp-3) 0 0;
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
line-height: 1.5;
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.dm-fussnote--warnung {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user