companion-app: catch up six days of panel drift before the native build
The phone-app codebase (companion-app/) hadn't been touched since 2026-08-11 - every panel fix and feature since then (HIG audit rounds, receipt upload, trip editing, Inspektion forecast, ...) existed only in the HA panel. Found while auditing what's needed for a real iPhone build; user decision was to port everything now rather than ship a stale app. Six real, verified gaps (not blind copies of panel CSS/markup, which doesn't transfer to the @audi-dash/ui component set): - Battery voltage cutoff was still 13.2V, not the panel's 12.8V fix. - Dead wlan_name field (WLAN trip detection was fully removed from the backend 2026-08-12) - removed from the adapter and the settings screen instead of leaving a form field that silently does nothing. - No Inspektion forecast - added inspektionPrognose() alongside the existing oelwechselPrognose(), sharing a refactored core. - Arbeitsweg pill used the design-system's "work" variant, which is documented as recoloring to red - stopped passing it, same fix as the panel. - Trip creation only took Beginn/Ende/Art; extended with Startort/ Zielort/Kilometerstand/Distanz via a new shared FahrtFelder.tsx. - FahrtDetail.tsx was read-only - added an edit mode using the same shared fields, backed by a new DataMetricApi.fahrtAktualisieren() calling the backend service built earlier this session. Explicitly checked and found not applicable: price rounding (already 2 decimals here), pull-to-refresh CSS (no native gesture to fix), the panel's drag/paste receipt dialog (solves a desktop-browser problem this native app doesn't have - the plain file picker already gets iOS's native Files integration), and the purely cosmetic panel CSS fixes. npm install run at the repo root (node_modules was incomplete/stale), package-lock.json reflects the real dependency tree. Verified with npm run typecheck (clean), npm run test (95/95, up from 90 - added interaction tests for the new form and inspektionPrognose), and npm run build (succeeds). Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -33,6 +33,21 @@ export interface TankvorgangFelder {
|
||||
kraftstoff?: string | null;
|
||||
}
|
||||
|
||||
/** Eingabefelder für Fahrten - Namen wie beim Backend (fahrterkennung.py:
|
||||
audi_dashboard_fahrt_manuell_anlegen/audi_dashboard_fahrt_aktualisieren).
|
||||
Alles außer den Zeitpunkten ist optional: leer bleibt es liegen, bis das
|
||||
Kilometerstand-Screening (§7.2) oder eine spätere Bearbeitung es füllt. */
|
||||
export interface FahrtFelder {
|
||||
ts_start?: string;
|
||||
ts_end?: string;
|
||||
art?: "privat" | "arbeitsweg";
|
||||
start_ort?: string | null;
|
||||
ziel_ort?: string | null;
|
||||
odo_start?: number | null;
|
||||
odo_end?: number | null;
|
||||
distanz?: number | null;
|
||||
}
|
||||
|
||||
/** Bündelt die drei Bausteine und bildet die Fachvorgänge ab, die das alte
|
||||
Dashboard über hass.callService erledigt hat. */
|
||||
export class DataMetricApi {
|
||||
@@ -94,15 +109,26 @@ 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> {
|
||||
fahrtAnlegen(felder: FahrtFelder): Promise<unknown> {
|
||||
return this.warteschlange.einreihen(
|
||||
"pyscript",
|
||||
"audi_dashboard_fahrt_manuell_anlegen",
|
||||
{ ts_start: tsStart, ts_end: tsEnde, art },
|
||||
{ ...felder },
|
||||
"Fahrt eintragen",
|
||||
);
|
||||
}
|
||||
|
||||
/** Bearbeitet eine bestehende Fahrt, gleich ob automatisch erkannt oder von
|
||||
Hand angelegt. */
|
||||
fahrtAktualisieren(tripId: string, felder: FahrtFelder): Promise<unknown> {
|
||||
return this.warteschlange.einreihen(
|
||||
"pyscript",
|
||||
"audi_dashboard_fahrt_aktualisieren",
|
||||
{ trip_id: tripId, ...felder },
|
||||
"Fahrt ändern",
|
||||
);
|
||||
}
|
||||
|
||||
fahrtLoeschen(tripId: string): Promise<unknown> {
|
||||
return this.warteschlange.einreihen(
|
||||
"pyscript",
|
||||
|
||||
@@ -19,7 +19,6 @@ function beispielprofil(aenderung: Partial<Profil> = {}): Profil {
|
||||
untertitel: "quattro · 331 kW",
|
||||
kennzeichen: "M AB 1234",
|
||||
tankvolumen_liter: 58,
|
||||
wlan_name: "Audi_MMI",
|
||||
},
|
||||
einstellungen: {
|
||||
uebersichtsbild: "seitenansicht.webp",
|
||||
|
||||
@@ -21,7 +21,6 @@ export interface Einstellungen {
|
||||
nachtBis: string
|
||||
pauseMin: number
|
||||
markenname: string
|
||||
wlanName: string
|
||||
backupIntervall: string
|
||||
letztesBackup: string | null
|
||||
ausfuehrung: string
|
||||
@@ -114,7 +113,6 @@ export function profilZuEinstellungen(profil: Profil): Einstellungen {
|
||||
nachtBis: text(einst, "nacht_bis", "06:00"),
|
||||
pauseMin: zahl(einst, "fahrten_pausenzeit_min") ?? 15,
|
||||
markenname: text(einst, "kraftstoffanbieter", "Shell"),
|
||||
wlanName: text(fahrzeug, "wlan_name"),
|
||||
backupIntervall: text(einst, "backup_intervall", "aus"),
|
||||
letztesBackup: text(einst, "letztes_backup") || null,
|
||||
ausfuehrung: text(fahrzeug, "ausfuehrung"),
|
||||
@@ -199,7 +197,6 @@ export function zusammenfuehren(
|
||||
f["zusatz"] = einstellungen.fahrzeugzusatz
|
||||
f["untertitel"] = einstellungen.fahrzeuguntertitel
|
||||
f["kennzeichen"] = einstellungen.kennzeichen
|
||||
f["wlan_name"] = einstellungen.wlanName
|
||||
f["ausfuehrung"] = einstellungen.ausfuehrung
|
||||
f["tankvolumen_liter"] = einstellungen.tankvolumen
|
||||
f["fin"] = fahrzeug.fin
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { letzterOelwechsel, oelwechselPrognose } from "./service"
|
||||
import { inspektionPrognose, letzterOelwechsel, oelwechselPrognose } from "./service"
|
||||
import type { Fahrzeug } from "./profilAdapter"
|
||||
|
||||
function fahrzeug(odo: number, modus = "hersteller"): Fahrzeug {
|
||||
@@ -92,3 +92,23 @@ describe("oelwechselPrognose", () => {
|
||||
expect(oelwechselPrognose(fahrzeug(46000), [], jetzt)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("inspektionPrognose", () => {
|
||||
const jetzt = new Date("2026-08-11T12:00:00")
|
||||
|
||||
it("rechnet mit dem festen Intervall 30.000 km / 24 Monate, nicht dem Ölwechsel-Intervall", () => {
|
||||
// Letzte Inspektion 05.03.2025 bei 25000 km -> Ziel 55000. Bei 46000 km
|
||||
// sind das 9000 km offen, unabhängig vom oel-Feld des Fahrzeugs (15000/12).
|
||||
const p = inspektionPrognose(fahrzeug(46000), BUCH, jetzt)
|
||||
expect(p).not.toBeNull()
|
||||
expect(p!.restKm).toBe(9000)
|
||||
})
|
||||
|
||||
it("ignoriert den Ölwechsel-Eintrag bei der Suche nach der letzten Inspektion", () => {
|
||||
expect(inspektionPrognose(fahrzeug(46000), [BUCH[0]!], jetzt)).toBeNull()
|
||||
})
|
||||
|
||||
it("liefert nichts, wenn nie eine Inspektion eingetragen wurde", () => {
|
||||
expect(inspektionPrognose(fahrzeug(46000), [], jetzt)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/**
|
||||
* Eigene Ölwechsel-Prognose. Portiert aus `oelwechselPrognose()` im alten
|
||||
* Panel.
|
||||
* Eigene Service-Prognosen (Ölwechsel, Inspektion). Portiert aus
|
||||
* `oelwechselPrognose()` im alten Panel; die Inspektion kam am 2026-08-17
|
||||
* hinzu (§ AGENTS.md, "Inspektion: voraussichtliches Datum ergänzen").
|
||||
*
|
||||
* Die Rechnung beginnt bewusst neu ab dem letzten tatsächlich durchgeführten
|
||||
* Ölwechsel — der allgemeine Langzeitdurchschnitt seit dem allerersten
|
||||
* Servicebucheintrag wäre dafür zu träge. Aus km und Tagen seit dem letzten
|
||||
* Wechsel ergibt sich die aktuelle Fahrleistung pro Tag; hochgerechnet auf die
|
||||
* verbleibenden km bis zum eingestellten Intervall ergibt das ein Datum,
|
||||
* gedeckelt auf spätestens das Zeitlimit des Intervalls.
|
||||
* Eintrag dieser Art — der allgemeine Langzeitdurchschnitt seit dem
|
||||
* allerersten Servicebucheintrag wäre dafür zu träge. Aus km und Tagen seit
|
||||
* dem letzten Eintrag ergibt sich die aktuelle Fahrleistung pro Tag;
|
||||
* hochgerechnet auf die verbleibenden km bis zum Intervall ergibt das ein
|
||||
* Datum, gedeckelt auf spätestens das Zeitlimit des Intervalls.
|
||||
*/
|
||||
|
||||
import type { Fahrzeug } from "./profilAdapter"
|
||||
@@ -36,33 +37,31 @@ function monatePlus(isoDatum: string, monate: number): string {
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/** Der jüngste Servicebucheintrag, dessen Art auf einen Ölwechsel hindeutet. */
|
||||
export function letzterOelwechsel(buch: readonly Buchhaltung[]): Buchhaltung | null {
|
||||
/** Der jüngste Servicebucheintrag, dessen Art auf das gesuchte Stichwort
|
||||
passt (z. B. "öl"/"oel" oder "inspektion"). */
|
||||
function letzterEintrag(buch: readonly Buchhaltung[], stichwort: RegExp): Buchhaltung | null {
|
||||
const treffer = buch
|
||||
.filter((e) => typeof e.art === "string" && /öl|oel/i.test(e.art) && e.datum && e.km != null)
|
||||
.filter((e) => typeof e.art === "string" && stichwort.test(e.art) && e.datum && e.km != null)
|
||||
.sort((a, b) => String(b.datum).localeCompare(String(a.datum)))
|
||||
return treffer[0] ?? null
|
||||
}
|
||||
|
||||
export function oelwechselPrognose(
|
||||
/** Der jüngste Servicebucheintrag, dessen Art auf einen Ölwechsel hindeutet. */
|
||||
export function letzterOelwechsel(buch: readonly Buchhaltung[]): Buchhaltung | null {
|
||||
return letzterEintrag(buch, /öl|oel/i)
|
||||
}
|
||||
|
||||
/** Gemeinsamer Kern für Ölwechsel- und Inspektionsprognose - beide rechnen
|
||||
identisch, nur mit anderem Intervall und anderer Suche im Servicebuch. */
|
||||
function servicePrognose(
|
||||
fahrzeug: Fahrzeug,
|
||||
buch: readonly Buchhaltung[],
|
||||
jetzt = new Date(),
|
||||
letzter: Buchhaltung | null,
|
||||
intervallKm: number,
|
||||
intervallMonate: number,
|
||||
jetzt: Date,
|
||||
): Oelprognose | null {
|
||||
if (!fahrzeug.odoBekannt) return null
|
||||
const letzter = letzterOelwechsel(buch)
|
||||
if (!letzter?.datum || letzter.km == null) return null
|
||||
|
||||
const oel = fahrzeug.oel
|
||||
const intervallKm =
|
||||
oel.modus === "hersteller"
|
||||
? (oel.herstellerKm ?? 0)
|
||||
: Math.min(oel.km ?? Infinity, oel.herstellerKm ?? Infinity)
|
||||
const intervallMonate =
|
||||
oel.modus === "hersteller"
|
||||
? (oel.herstellerMonate ?? 0)
|
||||
: Math.min(oel.monate ?? Infinity, oel.herstellerMonate ?? Infinity)
|
||||
|
||||
if (!Number.isFinite(intervallKm) || intervallKm <= 0) return null
|
||||
|
||||
const zielKm = letzter.km + intervallKm
|
||||
@@ -76,7 +75,7 @@ export function oelwechselPrognose(
|
||||
const tageSeit = (jetzt.getTime() - new Date(letzter.datum).getTime()) / TAG_MS
|
||||
const kmSeit = fahrzeug.odo - letzter.km
|
||||
|
||||
// Ohne belastbare Fahrleistung (direkt nach dem Wechsel) gibt es keine
|
||||
// Ohne belastbare Fahrleistung (direkt nach dem Eintrag) gibt es keine
|
||||
// seriöse Kilometerprognose — dann gilt das Zeitlimit.
|
||||
if (tageSeit <= 0 || kmSeit <= 0) {
|
||||
return { datum: kappDatum, restKm, kmProTag: 0, durchZeitlimit: true }
|
||||
@@ -94,3 +93,30 @@ export function oelwechselPrognose(
|
||||
durchZeitlimit,
|
||||
}
|
||||
}
|
||||
|
||||
export function oelwechselPrognose(
|
||||
fahrzeug: Fahrzeug,
|
||||
buch: readonly Buchhaltung[],
|
||||
jetzt = new Date(),
|
||||
): Oelprognose | null {
|
||||
const oel = fahrzeug.oel
|
||||
const intervallKm =
|
||||
oel.modus === "hersteller"
|
||||
? (oel.herstellerKm ?? 0)
|
||||
: Math.min(oel.km ?? Infinity, oel.herstellerKm ?? Infinity)
|
||||
const intervallMonate =
|
||||
oel.modus === "hersteller"
|
||||
? (oel.herstellerMonate ?? 0)
|
||||
: Math.min(oel.monate ?? Infinity, oel.herstellerMonate ?? Infinity)
|
||||
return servicePrognose(fahrzeug, letzterOelwechsel(buch), intervallKm, intervallMonate, jetzt)
|
||||
}
|
||||
|
||||
/** Inspektion: feste 30.000 km / 24 Monate, wie auch das Fahrzeug selbst und
|
||||
das alte Panel rechnen - kein einstellbares Intervall wie beim Ölwechsel. */
|
||||
export function inspektionPrognose(
|
||||
fahrzeug: Fahrzeug,
|
||||
buch: readonly Buchhaltung[],
|
||||
jetzt = new Date(),
|
||||
): Oelprognose | null {
|
||||
return servicePrognose(fahrzeug, letzterEintrag(buch, /inspektion/i), 30000, 24, jetzt)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*
|
||||
* Wichtig für die Einordnung: Der Ladezustand wird aus der **Tagesminimum**-
|
||||
* Spannung geschätzt (Ruhespannung kurz nach dem Start, bevor die Lichtmaschine
|
||||
* anhebt); Werte über 13,2 V gelten als „während der Fahrt gemessen" und
|
||||
* anhebt); Werte über 12,8 V gelten als „während der Fahrt gemessen" und
|
||||
* taugen nicht als Ruhewert.
|
||||
*/
|
||||
|
||||
@@ -28,8 +28,9 @@ interface Tageswert {
|
||||
max_ts?: string
|
||||
}
|
||||
|
||||
/** Ab hier gilt eine Messung als nicht mehr in Ruhe aufgenommen. */
|
||||
const AGM_RUHE_MAX_V = 13.2
|
||||
/** Ab hier gilt eine Messung als nicht mehr in Ruhe aufgenommen - alles
|
||||
darüber ist schon Lichtmaschinenspannung, nicht mehr Ruhespannung. */
|
||||
const AGM_RUHE_MAX_V = 12.8
|
||||
|
||||
/** Spannung → Ladezustand für eine AGM-Batterie in Ruhe. */
|
||||
const LADEKURVE: [number, number][] = [
|
||||
|
||||
@@ -102,7 +102,7 @@ export function Einstellungen({
|
||||
onChange={(e) => aendern({ kennzeichen: e.target.value })}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Tankvolumen" unit="l">
|
||||
<Feld label="Tankvolumen" unit="l" last>
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
inputMode="numeric"
|
||||
@@ -110,13 +110,6 @@ export function Einstellungen({
|
||||
onChange={(e) => aendern({ tankvolumen: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="WLAN des Fahrzeugs" last>
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
value={werte.wlanName}
|
||||
onChange={(e) => aendern({ wlanName: e.target.value })}
|
||||
/>
|
||||
</Feld>
|
||||
</Tile>
|
||||
|
||||
<Tile>
|
||||
|
||||
@@ -9,16 +9,29 @@
|
||||
* ist, statt eine glaubwürdig aussehende Erfindung zu zeigen.
|
||||
*/
|
||||
|
||||
import { Pill, Tile } from "@audi-dash/ui"
|
||||
import { useState } from "react"
|
||||
|
||||
import { ActionButton, Feld, Pill, Tile } from "@audi-dash/ui"
|
||||
|
||||
import type { Fahrt } from "../api"
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { datum, dauer, de, deOderStrich, uhrzeit } from "../format"
|
||||
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
|
||||
import { FahrtFelderFormular, fahrtFelderAusFahrt, fahrtFelderAuswerten } from "./FahrtFelder"
|
||||
import { Karte } from "./Karte"
|
||||
|
||||
/** "YYYY-MM-DDTHH:mm" in Ortszeit für ein <input type="datetime-local">. */
|
||||
function alsDatetimeLocal(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return ""
|
||||
const versatz = d.getTimezoneOffset() * 60_000
|
||||
return new Date(d.getTime() - versatz).toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
export function FahrtDetail({ id }: { id: string | undefined }) {
|
||||
const { fahrten } = useDaten()
|
||||
const fahrt = fahrten.find((f) => f.trip_id === id)
|
||||
const [bearbeitenOffen, setzeBearbeitenOffen] = useState(false)
|
||||
|
||||
if (!fahrt) {
|
||||
return (
|
||||
@@ -29,21 +42,30 @@ export function FahrtDetail({ id }: { id: string | undefined }) {
|
||||
)
|
||||
}
|
||||
|
||||
if (bearbeitenOffen) {
|
||||
return <FahrtBearbeiten fahrt={fahrt} beiFertig={() => setzeBearbeitenOffen(false)} />
|
||||
}
|
||||
|
||||
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-abschnitt" style={{ marginBottom: 0 }}>
|
||||
<span className="ads-eyebrow">
|
||||
{datum(fahrt.ts_start)} · {uhrzeit(fahrt.ts_start)} bis {uhrzeit(fahrt.ts_end)} Uhr
|
||||
</span>
|
||||
<button type="button" className="dm-textknopf" onClick={() => setzeBearbeitenOffen(true)}>
|
||||
Bearbeiten
|
||||
</button>
|
||||
</div>
|
||||
<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>}
|
||||
<Pill>{fahrt.art === "arbeitsweg" ? "Arbeitsweg" : "Privat"}</Pill>
|
||||
{fahrt.status === "offen" && <Pill>noch offen</Pill>}
|
||||
{fahrt.source === "manual" && <Pill>selbst eingetragen</Pill>}
|
||||
</div>
|
||||
@@ -109,3 +131,77 @@ export function FahrtDetail({ id }: { id: string | undefined }) {
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Bearbeiten-Formular für eine bestehende Fahrt - dieselbe Feldgruppe wie
|
||||
beim Neuanlegen (FahrtFelder.tsx), vorbelegt mit den aktuellen Werten. */
|
||||
function FahrtBearbeiten({ fahrt, beiFertig }: { fahrt: Fahrt; beiFertig: () => void }) {
|
||||
const { api } = useDaten()
|
||||
const [start, setzeStart] = useState(alsDatetimeLocal(fahrt.ts_start))
|
||||
const [ende, setzeEnde] = useState(alsDatetimeLocal(fahrt.ts_end))
|
||||
const [felder, setzeFelder] = useState(fahrtFelderAusFahrt(fahrt))
|
||||
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.fahrtAktualisieren(fahrt.trip_id, {
|
||||
ts_start: new Date(start).toISOString(),
|
||||
ts_end: new Date(ende).toISOString(),
|
||||
...fahrtFelderAuswerten(felder),
|
||||
})
|
||||
beiFertig()
|
||||
} catch (ursache) {
|
||||
setzeFehler(ursache instanceof Error ? ursache.message : String(ursache))
|
||||
} finally {
|
||||
setzeLaeuft(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tile>
|
||||
<div className="dm-abschnitt" style={{ marginBottom: 0 }}>
|
||||
<span className="ads-eyebrow">Fahrt bearbeiten</span>
|
||||
<button type="button" className="dm-textknopf" onClick={beiFertig} disabled={laeuft}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
<FahrtFelderFormular werte={felder} setzeWerte={setzeFelder} />
|
||||
{fehler && (
|
||||
<p className="dm-fehler" role="alert">
|
||||
{fehler}
|
||||
</p>
|
||||
)}
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
|
||||
{laeuft ? "Speichere …" : "Speichern"}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Tile>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Die Felder einer Fahrt jenseits von Zeitpunkt und Dauer - geteilt zwischen
|
||||
* dem Neuanlegen (Fahrten.tsx) und dem Bearbeiten (FahrtDetail.tsx), damit
|
||||
* beide Formulare nicht auseinanderlaufen. Vorlage: `fahrtFelder()` im
|
||||
* Panel (audi-dashboard-app.js).
|
||||
*/
|
||||
|
||||
import { Feld } from "@audi-dash/ui"
|
||||
|
||||
import type { FahrtFelder as FahrtFelderApi } from "../api"
|
||||
|
||||
export interface FahrtFelderEingabe {
|
||||
art: "privat" | "arbeitsweg"
|
||||
startOrt: string
|
||||
zielOrt: string
|
||||
odoStart: string
|
||||
odoEnde: string
|
||||
distanz: string
|
||||
}
|
||||
|
||||
export const LEERE_FAHRT_FELDER: FahrtFelderEingabe = {
|
||||
art: "privat",
|
||||
startOrt: "",
|
||||
zielOrt: "",
|
||||
odoStart: "",
|
||||
odoEnde: "",
|
||||
distanz: "",
|
||||
}
|
||||
|
||||
/** Vorbelegung aus einer bestehenden Fahrt fürs Bearbeiten-Formular. */
|
||||
export function fahrtFelderAusFahrt(fahrt: {
|
||||
art: "privat" | "arbeitsweg"
|
||||
start_address?: string | null
|
||||
end_address?: string | null
|
||||
odo_start?: number | null
|
||||
odo_end?: number | null
|
||||
distance_km?: number | null
|
||||
}): FahrtFelderEingabe {
|
||||
return {
|
||||
art: fahrt.art,
|
||||
startOrt: fahrt.start_address ?? "",
|
||||
zielOrt: fahrt.end_address ?? "",
|
||||
odoStart: fahrt.odo_start != null ? String(fahrt.odo_start) : "",
|
||||
odoEnde: fahrt.odo_end != null ? String(fahrt.odo_end) : "",
|
||||
distanz: fahrt.distance_km != null ? String(fahrt.distance_km) : "",
|
||||
}
|
||||
}
|
||||
|
||||
/** Wandelt die Formulareingabe in die Felder um, die das Backend erwartet -
|
||||
leere Felder werden als "nicht angegeben" (undefined) übertragen, nicht
|
||||
als 0, sonst würde ein leeres Kilometerfeld einen echten Wert überschreiben. */
|
||||
export function fahrtFelderAuswerten(
|
||||
eingabe: FahrtFelderEingabe,
|
||||
): Pick<FahrtFelderApi, "art" | "start_ort" | "ziel_ort" | "odo_start" | "odo_end" | "distanz"> {
|
||||
const zahl = (wert: string): number | null => {
|
||||
if (wert.trim() === "") return null
|
||||
const n = Number(wert.replace(",", "."))
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
return {
|
||||
art: eingabe.art,
|
||||
start_ort: eingabe.startOrt.trim() || null,
|
||||
ziel_ort: eingabe.zielOrt.trim() || null,
|
||||
odo_start: zahl(eingabe.odoStart),
|
||||
odo_end: zahl(eingabe.odoEnde),
|
||||
distanz: zahl(eingabe.distanz),
|
||||
}
|
||||
}
|
||||
|
||||
export function FahrtFelderFormular({
|
||||
werte,
|
||||
setzeWerte,
|
||||
}: {
|
||||
werte: FahrtFelderEingabe
|
||||
setzeWerte: (aendern: (alt: FahrtFelderEingabe) => FahrtFelderEingabe) => void
|
||||
}) {
|
||||
const feld = <K extends keyof FahrtFelderEingabe>(schluessel: K) => ({
|
||||
value: werte[schluessel],
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setzeWerte((alt) => ({ ...alt, [schluessel]: e.target.value })),
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Feld label="Art">
|
||||
<select
|
||||
className="dm-auswahl"
|
||||
value={werte.art}
|
||||
onChange={(e) =>
|
||||
setzeWerte((alt) => ({ ...alt, art: e.target.value as "privat" | "arbeitsweg" }))
|
||||
}
|
||||
>
|
||||
<option value="privat">Privat</option>
|
||||
<option value="arbeitsweg">Arbeitsweg</option>
|
||||
</select>
|
||||
</Feld>
|
||||
<Feld label="Startort">
|
||||
<input className="dm-eingabe" placeholder="unbekannt" {...feld("startOrt")} />
|
||||
</Feld>
|
||||
<Feld label="Zielort">
|
||||
<input className="dm-eingabe" placeholder="unbekannt" {...feld("zielOrt")} />
|
||||
</Feld>
|
||||
<Feld label="Startkilometer" unit="km">
|
||||
<input className="dm-eingabe" inputMode="numeric" placeholder="wird ergänzt" {...feld("odoStart")} />
|
||||
</Feld>
|
||||
<Feld label="Endkilometer" unit="km">
|
||||
<input className="dm-eingabe" inputMode="numeric" placeholder="wird ergänzt" {...feld("odoEnde")} />
|
||||
</Feld>
|
||||
<Feld label="Gefahrene Distanz" unit="km" last>
|
||||
<input className="dm-eingabe" inputMode="decimal" placeholder="aus km-Stand" {...feld("distanz")} />
|
||||
</Feld>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,8 @@ 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 { FahrtFelderFormular, LEERE_FAHRT_FELDER, fahrtFelderAuswerten } from "./FahrtFelder"
|
||||
import type { FahrtFelderEingabe } from "./FahrtFelder"
|
||||
import { Zeilenmenue } from "./Zeilenmenue"
|
||||
|
||||
export function Fahrten({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
|
||||
@@ -82,7 +84,7 @@ export function Fahrten({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
|
||||
{dauer(fahrt.duration_s)}
|
||||
</span>
|
||||
</span>
|
||||
{fahrt.art === "arbeitsweg" && <Pill variant="work">Arbeitsweg</Pill>}
|
||||
{fahrt.art === "arbeitsweg" && <Pill>Arbeitsweg</Pill>}
|
||||
{fahrt.status === "offen" && <Pill>offen</Pill>}
|
||||
</button>
|
||||
}
|
||||
@@ -96,12 +98,15 @@ export function Fahrten({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
|
||||
)
|
||||
}
|
||||
|
||||
/** Manuelles Eintragen — für Fahrten, die die automatische Erkennung verpasst hat. */
|
||||
/** Manuelles Eintragen — für Fahrten, die die automatische Erkennung verpasst hat.
|
||||
Dieselben Felder wie beim Bearbeiten einer bestehenden Fahrt (siehe
|
||||
FahrtFelderFormular in FahrtDetail.tsx) - beide teilen sich deshalb die
|
||||
Feldgruppe. */
|
||||
function FahrtFormular({ beiFertig }: { beiFertig: () => void }) {
|
||||
const { api } = useDaten()
|
||||
const [start, setzeStart] = useState("")
|
||||
const [ende, setzeEnde] = useState("")
|
||||
const [art, setzeArt] = useState<"privat" | "arbeitsweg">("privat")
|
||||
const [felder, setzeFelder] = useState<FahrtFelderEingabe>(LEERE_FAHRT_FELDER)
|
||||
const [fehler, setzeFehler] = useState<string | null>(null)
|
||||
const [laeuft, setzeLaeuft] = useState(false)
|
||||
|
||||
@@ -117,7 +122,11 @@ function FahrtFormular({ beiFertig }: { beiFertig: () => void }) {
|
||||
}
|
||||
setzeLaeuft(true)
|
||||
try {
|
||||
await api.fahrtAnlegen(new Date(start).toISOString(), new Date(ende).toISOString(), art)
|
||||
await api.fahrtAnlegen({
|
||||
ts_start: new Date(start).toISOString(),
|
||||
ts_end: new Date(ende).toISOString(),
|
||||
...fahrtFelderAuswerten(felder),
|
||||
})
|
||||
beiFertig()
|
||||
} catch (ursache) {
|
||||
setzeFehler(ursache instanceof Error ? ursache.message : String(ursache))
|
||||
@@ -145,16 +154,7 @@ function FahrtFormular({ beiFertig }: { beiFertig: () => void }) {
|
||||
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>
|
||||
<FahrtFelderFormular werte={felder} setzeWerte={setzeFelder} />
|
||||
{fehler && (
|
||||
<p className="dm-fehler" role="alert">
|
||||
{fehler}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useState } from "react"
|
||||
import { ActionButton, Feld, Tile } from "@audi-dash/ui"
|
||||
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { oelwechselPrognose } from "../daten/service"
|
||||
import { inspektionPrognose, oelwechselPrognose } from "../daten/service"
|
||||
import { datum, de, isoTag } from "../format"
|
||||
import type { SeitenName } from "../navigation"
|
||||
import { Leerzustand, NaviKachel, Wertzeile, Werteliste, bestaetigen } from "./bausteine"
|
||||
@@ -42,6 +42,7 @@ export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
|
||||
const buch = (fahrzeug.service["buch"] ?? []) as ServicebuchEintrag[]
|
||||
const autohaus = (fahrzeug.service["autohaus"] ?? {}) as Autohaus
|
||||
const prognose = oelwechselPrognose(fahrzeug, buch)
|
||||
const inspektionsprognose = inspektionPrognose(fahrzeug, buch)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -77,6 +78,12 @@ export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{inspektionsprognose && (
|
||||
<Wertzeile
|
||||
label="Eigene Prognose (Inspektion)"
|
||||
wert={datum(inspektionsprognose.datum)}
|
||||
/>
|
||||
)}
|
||||
<Wertzeile
|
||||
label="Hauptuntersuchung"
|
||||
wert={fahrzeug.hu ? datum(fahrzeug.hu) : "—"}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* greifen, statt dass eine leere Liste die Seite zerlegt.
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react"
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import { beforeEach, describe, expect, it } from "vitest"
|
||||
|
||||
import { DatenAnbieter } from "../daten/DatenKontext"
|
||||
@@ -106,4 +106,26 @@ describe("Inhalte kommen wirklich aus den Daten", () => {
|
||||
await waitFor(() => expect(screen.getByText("48,70")).toBeTruthy())
|
||||
expect(screen.getByText("1,83 €")).toBeTruthy()
|
||||
})
|
||||
|
||||
it("bietet beim Eintragen einer Fahrt alle Felder, nicht nur Start/Ende/Art", async () => {
|
||||
zeige("trips")
|
||||
await waitFor(() => expect(screen.getByText("Fahrt eintragen")).toBeTruthy())
|
||||
fireEvent.click(screen.getByText("Fahrt eintragen"))
|
||||
expect(screen.getByText("Startort")).toBeTruthy()
|
||||
expect(screen.getByText("Zielort")).toBeTruthy()
|
||||
expect(screen.getByText("Startkilometer")).toBeTruthy()
|
||||
expect(screen.getByText("Endkilometer")).toBeTruthy()
|
||||
expect(screen.getByText("Gefahrene Distanz")).toBeTruthy()
|
||||
})
|
||||
|
||||
it("lässt eine bestehende Fahrt bearbeiten, vorbelegt mit ihren Werten", async () => {
|
||||
zeige("trip", "t1")
|
||||
await waitFor(() => expect(screen.getByText("Bearbeiten")).toBeTruthy())
|
||||
fireEvent.click(screen.getByText("Bearbeiten"))
|
||||
expect(screen.getByText("Fahrt bearbeiten")).toBeTruthy()
|
||||
// t1 ist "arbeitsweg" (beispieldaten.ts) - das Auswahlfeld muss das übernehmen,
|
||||
// nicht immer bei "privat" neu anfangen.
|
||||
const artAuswahl = screen.getByDisplayValue("Arbeitsweg") as HTMLSelectElement
|
||||
expect(artAuswahl.value).toBe("arbeitsweg")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,7 +35,6 @@ export const beispielProfil: Profil = {
|
||||
erstzulassung: "2023-04-01",
|
||||
hauptuntersuchung_faellig: "2027-04-01",
|
||||
tankvolumen_liter: 58,
|
||||
wlan_name: "Audi_MMI",
|
||||
ausfuehrung: "competition",
|
||||
},
|
||||
einstellungen: {
|
||||
|
||||
Reference in New Issue
Block a user