feat: mabea-doku Zwei-Apps-Support + Ressourcenbuchung Opt-in
CI / backend-tests (push) Failing after 3m31s
CI / frontend-build (push) Successful in 23s

Backend/Frontend-Seite: /app-download/info und /app-download/qr.png
bekommen app-Query-Parameter (scan/doku), Download-Seite /app?app=...
mit Umschalter für die beiden eigenständigen Flutter-Apps.

ASSET-012 Nutzer-Feedback: Ressourcenbuchung war in jeder Akte
sichtbar, unabhängig vom Objekttyp - jetzt Opt-in je Objekttyp
(Objekttyp.ressourcenbuchung_aktiv, Migration 0054, Default false),
Admin-Checkbox in ObjekttypSection.tsx, AktePage.tsx zeigt das Panel
nur noch wenn aktiviert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-10 21:49:34 +02:00
1 parent 3d560738de
commit ce99aa6fbb
15 files changed
+231 -29

No files matched your search

+7
View File
@@ -478,6 +478,13 @@ Details zu ASSET-001 … ASSET-010. ASSET-010 ergänzt am 2026-09-09 aus Open-So
(kein Kalender-Widget, wie in der Kachel als MVP-Ausreichend markiert) in
der Akte JEDES Objekts (nicht nur Fahrzeuge — Material-Sets/Geräte können
genauso gebucht werden). `backend/tests/test_ressourcenbuchung.py`.
**Bugfix 2026-09-10 (Nutzer-Feedback):** "in jeder Akte" wurde als
aufdringlich empfunden — meiste Objekttypen (Verbrauchsmaterial-Rucksäcke
etc.) brauchen keinen Buchungskalender. Auf Opt-in je Objekttyp
umgestellt: neues Feld `Objekttyp.ressourcenbuchung_aktiv` (Migration
0054, Default `false`), Admin-Checkbox "Ressourcenbuchung in der Akte
anzeigen" in `ObjekttypSection.tsx`, `AkteRead.ressourcenbuchung_aktiv`
steuert die bedingte Anzeige des Panels in `AktePage.tsx`.
---
+35 -2
View File
@@ -51,7 +51,8 @@ Build-Host, kein Dauerbetrieb der App dort (App läuft auf Endgeräten).
Store-Veröffentlichung (Sideload/interner Test-Build reicht für die
Sondierung).
- **DoD:** ✅ **Prototyp läuft, iterativ über den ursprünglichen Scope hinaus
gewachsen (2026-09-10, v0.1.0 → v0.7.0)** — Ergebnis noch nicht formal
gewachsen (2026-09-10, mabea-scan v0.1.0 → v0.8.0, mabea-doku v0.1.0 →
v0.2.0 als eigenständige zweite App)** — Ergebnis noch nicht formal
bewertet (keine Grundsatzentscheidung Ergänzung-vs-Ablösung-PWA getroffen),
aber der Prototyp selbst ist weit über "ein Screen QR-Scan" hinausgewachsen:
- **v0.1.0** — Basis-Scope: Login-Screen (JWT), QR-Scan-Screen gegen
@@ -90,13 +91,45 @@ Build-Host, kein Dauerbetrieb der App dort (App läuft auf Endgeräten).
— ein normaler Frontend-Deploy mit `rsync --delete` hätte den Ordner sonst
gelöscht, real passiert und behoben). Download-Link im Portal nur für
Administrator sichtbar (`AdminPage.tsx`).
- **v0.7.0 (2026-09-10):** mehr Scan-Funktionen — Akte-Kurzinfo nach
- **Zwei-Apps-Entscheidung (2026-09-10):** Nutzer-Vorgabe — statt einer
gemeinsamen App ZWEI eigenständige: **mabea-scan** (dieser Prototyp,
FLUT-001, Inventur/Kontroll-fokussiert) und **mabea-doku** (neu,
FLUT-003, Fristen-Übersicht + perspektivisch Erfassen). Getrennte
Android-`applicationId`s (`de.mabea.mabea_scan_prototyp` /
`de.perlbach.mabea.mabea_doku`), beide parallel installierbar, getrennte
`version*.json`/APK-Dateien unter `/downloads/`. Backend-Endpunkte
`/app-download/info` und `/app-download/qr.png` bekamen einen `app`-
Query-Parameter (`scan` Default / `doku`), Portal-Seite `/app?app=scan`
bzw. `/app?app=doku` mit Umschalter.
- **mabea-doku v0.1.0/v0.2.0 (2026-09-10, FLUT-003, neues Projekt unter
`/mnt/projekte/flutter/mabea_doku`):** Login (gleiches Muster wie
mabea-scan: `flutter_secure_storage`, optionale Zugangsdaten-Speicherung
per Checkbox, Auto-Relogin bei 401) + Fristen-Übersicht-Screen, der
`/dashboard/prueftermine`, `/dashboard/ablaufdaten` und
`/dashboard/faellige-wartungen` zusammenführt und nach Dringlichkeit
sortiert (fehlgeschlagen/überfällig zuerst). In-App-Update-Banner (v0.2.0,
identisches Muster wie mabea-scan) gegen `/downloads/version-doku.json`.
**Phase 1 = reine Lesesicht.** Erfassen (Kontrolle/Mangel/Wartung direkt
aus mabea-doku eintragen, vom Nutzer für eine Ausbaustufe gewünscht) ist
bewusst NICHT Teil dieses ersten Wurfs — eigener, größerer Schritt
(Formulare, Validierung, ggf. Offline-Queue analog PWA), noch nicht
begonnen.
- **v0.7.0/v0.8.0 (2026-09-10, mabea-scan):** mehr Scan-Funktionen — Akte-Kurzinfo nach
Objekt-Scan (Einsatzbereitschafts-Ampel + Gründe, letzte Kontrolle,
Anzahl offener Mängel, via `GET /akte/objekt/{id}`, Ladefehler nicht
fatal — Basis-Objekt bleibt sichtbar); Taschenlampe-Toggle
(`MobileScannerController.toggleTorch()`); Geräte-Exemplar-Code als
Fallback, wenn kein Objekt-Code passt (`GET /objektpositionen/code/
{code}` — Geräte-Etiketten waren bis dahin gar nicht scanbar).
**v0.8.0:** "Kontrolle / Inventur"-Button im Objekt-Ergebnis — startet
eine Kontrolle (`POST /objekte/{id}/kontrollen`, mit
Übernahme-Dialog bei 409/bereits aktiver Kontrolle) und öffnet einen
Erfassungs-Screen mit Ist-Mengen-Eingabe je Beladungsposition (einzeln
speicherbar über `PUT /kontrollen/{id}/positionen/{material_id}`,
Abschließen über `POST /kontrollen/{id}/abschliessen` mit Warnung bei
unvollständiger Erfassung) — behebt die Lücke, dass nach dem Scan bis
dahin nur Lesezugriff möglich war, obwohl mabea-scan explizit für die
Inventur gedacht ist.
- **Feldtest-Vorbereitung 2026-09-10:** öffentliche Download-Seite
`/app` (KEIN Login nötig — Henne-Ei, die App IST der Login-Client)
zeigt QR-Code + Download-Button + Installationsanleitung für Helfer.
@@ -0,0 +1,25 @@
"""ASSET-012: Ressourcenbuchung Opt-in je Objekttyp statt global erzwungen.
Revision ID: 0054_ressourcenbuchung_optin
Revises: 0053_systemeinstellung
Create Date: 2026-09-10
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0054_ressourcenbuchung_optin"
down_revision: Union[str, None] = "0053_systemeinstellung"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"ALTER TABLE objekttyp ADD COLUMN ressourcenbuchung_aktiv BOOLEAN NOT NULL DEFAULT false"
)
def downgrade() -> None:
op.execute("ALTER TABLE objekttyp DROP COLUMN ressourcenbuchung_aktiv")
+21 -10
View File
@@ -1,5 +1,6 @@
import json
from pathlib import Path
from typing import Literal
from fastapi import APIRouter, HTTPException, Response, status
@@ -8,32 +9,42 @@ from app.services.qr_png import generiere_qr_png
router = APIRouter()
AppName = Literal["scan", "doku"]
def _lade_version_info() -> dict:
pfad = Path(settings.downloads_dir) / "version.json"
# FLUT-003 (2026-09-10): Nutzer-Entscheidung - ZWEI eigenständige Apps statt
# einer gemeinsamen. mabea-scan (FLUT-001, "version.json") bleibt
# schlank/Inventur-fokussiert, mabea-doku (FLUT-003, "version-doku.json")
# deckt die Fristen-Übersicht ab (perspektivisch Erfassen, Phase 2).
_VERSION_DATEI = {"scan": "version.json", "doku": "version-doku.json"}
def _lade_version_info(app: AppName) -> dict:
pfad = Path(settings.downloads_dir) / _VERSION_DATEI[app]
if not pfad.exists():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Keine App-Version veröffentlicht")
return json.loads(pfad.read_text())
@router.get("/app-download/info")
async def app_download_info() -> dict:
"""FLUT-001: bewusst OHNE Auth - Helfer im Feld sollen die Scan-App auch
ohne MABEA-Login installieren können (Henne-Ei: die App IST der
Login-Client). Liefert dieselbe version.json wie der In-App-Update-Check,
plus die volle URL fürs Frontend/den QR-Code."""
daten = _lade_version_info()
async def app_download_info(app: AppName = "scan") -> dict:
"""Bewusst OHNE Auth - Helfer im Feld sollen die Apps auch ohne
MABEA-Login installieren können (Henne-Ei: die App IST der
Login-Client). Liefert dieselbe version*.json wie der In-App-Update-
Check (nur mabea-scan hat aktuell einen In-App-Update-Check), plus die
volle URL fürs Frontend/den QR-Code."""
daten = _lade_version_info(app)
return {
"app": app,
"version": daten["version"],
"apk_url": f"{settings.portal_url}{daten['apk_url']}",
}
@router.get("/app-download/qr.png")
async def app_download_qr() -> Response:
async def app_download_qr(app: AppName = "scan") -> Response:
"""QR-Code direkt auf die aktuelle APK - zum Ausdrucken (Poster/Aushang)
oder Scannen von einem zweiten Gerät aus. Bewusst OHNE Auth, siehe
app_download_info()."""
daten = _lade_version_info()
daten = _lade_version_info(app)
apk_url = f"{settings.portal_url}{daten['apk_url']}"
return Response(content=generiere_qr_png(apk_url), media_type="image/png")
+5
View File
@@ -52,6 +52,11 @@ class Objekttyp(Base):
# fahrzeug_id) zugeordnet werden - grenzt die Fahrzeug-Dropdowns serverseitig
# ein, ohne einen eigenen Fahrzeug-Objekttyp fachlich zu erzwingen.
ist_zugfahrzeug: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# ASSET-012 Nutzer-Feedback 2026-09-10: Ressourcenbuchung war in JEDER
# Akte sichtbar, unabhängig vom Objekttyp - für die meisten Objekttypen
# (Verbrauchsmaterial-Rucksäcke etc.) ergibt eine Buchungskalender-Ansicht
# keinen Sinn. Jetzt Opt-in je Objekttyp statt global erzwungen.
ressourcenbuchung_aktiv: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
class Nummernschema(Base):
+1
View File
@@ -40,6 +40,7 @@ class AkteRead(BaseModel):
einsatzbereitschaft_status: str
einsatzbereitschaft_gruende: list[str]
objekttyp_name: str
ressourcenbuchung_aktiv: bool
standort_name: str
fahrzeugdetails: FahrzeugdetailsRead | None
zustaendige_benutzer: list[ZustaendigerBenutzer]
+2
View File
@@ -90,6 +90,7 @@ class ObjekttypCreate(BaseModel):
kategorie_id: int | None = None
name: str
ist_zugfahrzeug: bool = False
ressourcenbuchung_aktiv: bool = False
class ObjekttypRead(ObjekttypCreate):
@@ -102,6 +103,7 @@ class ObjekttypUpdate(BaseModel):
kategorie_id: int | None = None
name: str | None = None
ist_zugfahrzeug: bool | None = None
ressourcenbuchung_aktiv: bool | None = None
class FachCreate(BaseModel):
+1
View File
@@ -139,6 +139,7 @@ async def akte_fuer_objekt(db: AsyncSession, objekt_id: int) -> AkteRead | None:
einsatzbereitschaft_status=readiness["status"],
einsatzbereitschaft_gruende=readiness["gruende"],
objekttyp_name=objekttyp.name if objekttyp else "",
ressourcenbuchung_aktiv=objekttyp.ressourcenbuchung_aktiv if objekttyp else False,
standort_name=standort.name if standort else "",
fahrzeugdetails=FahrzeugdetailsRead.model_validate(fahrzeugdetails) if fahrzeugdetails else None,
zustaendige_benutzer=zustaendige,
+23
View File
@@ -48,6 +48,29 @@ async def test_akte_buendelt_teilbereiche(client, db_session, objekt_mit_positio
assert len(body["maengel"]) == 1
assert body["maengel"][0]["beschreibung"] == "Riss im Gurt"
assert len(body["historie"]) == 1
assert body["ressourcenbuchung_aktiv"] is False
@pytest.mark.asyncio
async def test_akte_ressourcenbuchung_aktiv_folgt_objekttyp_flag(client, objekt_mit_position, admin_user):
"""ASSET-012 Nutzer-Feedback 2026-09-10: Ressourcenbuchung ist Opt-in je
Objekttyp, nicht mehr in jeder Akte automatisch sichtbar."""
from app.models.stammdaten import Objekttyp
objekt, _material = objekt_mit_position
token = await login(client, "admin1")
objekttyp_id = objekt.objekttyp_id
aendern = await client.patch(
f"/api/v1/objekttypen/{objekttyp_id}",
json={"ressourcenbuchung_aktiv": True},
headers=auth_header(token),
)
assert aendern.status_code == 200
antwort = await client.get(f"/api/v1/akte/objekt/{objekt.id}", headers=auth_header(token))
assert antwort.status_code == 200
assert antwort.json()["ressourcenbuchung_aktiv"] is True
assert body["historie"][0]["entitaet_typ"] == "objekt"
assert body["einsatzbereitschaft_status"] == "not-ready"
assert "mangel_kritisch_offen" in body["einsatzbereitschaft_gruende"]
+22
View File
@@ -51,3 +51,25 @@ async def test_app_download_ohne_auth_erreichbar(client, tmp_path, monkeypatch):
response = await client.get("/api/v1/app-download/info")
assert response.status_code != 401
assert response.status_code != 403
@pytest.mark.asyncio
async def test_app_download_info_zwei_apps_getrennt(client, tmp_path, monkeypatch):
"""FLUT-003: mabea-scan und mabea-doku sind eigenständige Apps mit
getrennten version*.json-Dateien, kein gemeinsamer Zustand."""
monkeypatch.setattr(settings, "downloads_dir", str(tmp_path))
monkeypatch.setattr(settings, "portal_url", "https://example.test")
(tmp_path / "version.json").write_text(
json.dumps({"version": "0.7.0", "apk_url": "/downloads/mabea-scan-prototyp-v0.7.apk"})
)
(tmp_path / "version-doku.json").write_text(
json.dumps({"version": "0.1.0", "apk_url": "/downloads/mabea-doku-v0.1.apk"})
)
scan = await client.get("/api/v1/app-download/info?app=scan")
assert scan.status_code == 200
assert scan.json()["version"] == "0.7.0"
doku = await client.get("/api/v1/app-download/info?app=doku")
assert doku.status_code == 200
assert doku.json()["version"] == "0.1.0"
+2
View File
@@ -419,6 +419,7 @@ export interface Objekttyp {
kategorie_id: number | null;
name: string;
ist_zugfahrzeug: boolean;
ressourcenbuchung_aktiv: boolean;
}
export interface Nummernschema {
@@ -476,6 +477,7 @@ export interface Akte {
einsatzbereitschaft_status: "ready" | "limited" | "not-ready" | "unknown";
einsatzbereitschaft_gruende: string[];
objekttyp_name: string;
ressourcenbuchung_aktiv: boolean;
standort_name: string;
fahrzeugdetails: Fahrzeugdetails | null;
zustaendige_benutzer: ZustaendigerBenutzer[];
+7 -3
View File
@@ -371,10 +371,14 @@ export function AdminPage() {
function ScanAppDownloadHinweis() {
return (
<p className="text-muted" style={{ marginTop: "2rem", fontSize: "0.85rem" }}>
<a href="/app" target="_blank" rel="noreferrer">
MABEA Scan-App (Android, Prototyp) — Download-Seite mit QR-Code
<a href="/app?app=scan" target="_blank" rel="noreferrer">
MABEA Scan-App
</a>{" "}
— FLUT-001-Sondierung, kein Produktivstand.
/{" "}
<a href="/app?app=doku" target="_blank" rel="noreferrer">
MABEA Doku-App
</a>{" "}
(Android, Prototypen) — Download-Seite mit QR-Code — kein Produktivstand.
</p>
);
}
+6 -4
View File
@@ -314,10 +314,12 @@ export function AktePage() {
<ReadinessVerlaufPanel objektId={objekt.id} />
</div>
<div className="akte-ressourcenbuchung card">
<h3 style={{ marginTop: 0 }}>Buchungen</h3>
<RessourcenbuchungPanel objektId={objekt.id} onFehler={setFehler} />
</div>
{akte.ressourcenbuchung_aktiv && (
<div className="akte-ressourcenbuchung card">
<h3 style={{ marginTop: 0 }}>Buchungen</h3>
<RessourcenbuchungPanel objektId={objekt.id} onFehler={setFehler} />
</div>
)}
<div className="akte-historie card">
<div className="row-between" style={{ alignItems: "baseline" }}>
+41 -8
View File
@@ -1,37 +1,70 @@
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api/v1";
type AppName = "scan" | "doku";
interface AppInfo {
app: AppName;
version: string;
apk_url: string;
}
// FLUT-001: öffentliche Seite (kein Login nötig - Henne-Ei, die App IST der
// Login-Client), damit Helfer im Feld die Scan-App per Link oder QR-Code
// installieren können, ohne vorher im Portal angemeldet zu sein.
const APP_META: Record<AppName, { titel: string; beschreibung: string }> = {
scan: {
titel: "MABEA Scan",
beschreibung: "Android-Prototyp (FLUT-001) zum QR-Scan von Objekten inkl. Kontroll-/Inventur-Erfassung.",
},
doku: {
titel: "MABEA Doku",
beschreibung: "Android-App (FLUT-003) — Fristen-Übersicht (Prüfungen/Ablaufdaten/Wartung) auf einen Blick.",
},
};
// FLUT-001/FLUT-003: öffentliche Seite (kein Login nötig - Henne-Ei, die
// App IST der Login-Client), damit Helfer im Feld eine der beiden MABEA-
// Apps per Link oder QR-Code installieren können. ?app=scan (Default) oder
// ?app=doku wählt die Ziel-App - zwei eigenständige APKs (Nutzer-
// Entscheidung 2026-09-10), kein gemeinsames Bundle.
export function AppDownloadPage() {
const [suchparameter] = useSearchParams();
const app: AppName = suchparameter.get("app") === "doku" ? "doku" : "scan";
const [info, setInfo] = useState<AppInfo | null>(null);
const [fehler, setFehler] = useState(false);
useEffect(() => {
fetch(`${API_BASE}/app-download/info`)
setInfo(null);
setFehler(false);
fetch(`${API_BASE}/app-download/info?app=${app}`)
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then(setInfo)
.catch(() => setFehler(true));
}, []);
}, [app]);
const meta = APP_META[app];
return (
<main style={{ maxWidth: "28rem", margin: "3rem auto", padding: "0 1rem", textAlign: "center" }}>
<h1>MABEA Scan-App</h1>
<p className="text-muted">Android-Prototyp (FLUT-001) zum QR-Scan von Objekten.</p>
<div className="row" style={{ justifyContent: "center", gap: "0.5rem", marginBottom: "1rem" }}>
<a href="/app?app=scan" className={`btn ${app === "scan" ? "btn-primary" : "btn-secondary"}`}>
MABEA Scan
</a>
<a href="/app?app=doku" className={`btn ${app === "doku" ? "btn-primary" : "btn-secondary"}`}>
MABEA Doku
</a>
</div>
<h1>{meta.titel}</h1>
<p className="text-muted">{meta.beschreibung}</p>
{fehler && <p className="alert alert-danger">Aktuell keine App-Version verfügbar.</p>}
{info && (
<>
<img
src={`${API_BASE}/app-download/qr.png`}
src={`${API_BASE}/app-download/qr.png?app=${app}`}
alt="QR-Code zum App-Download"
style={{ width: "220px", height: "220px", margin: "1.5rem auto", display: "block" }}
/>
+33 -2
View File
@@ -29,6 +29,8 @@ export function ObjekttypSection({
const [bearbeitungsName, setBearbeitungsName] = useState("");
const [istZugfahrzeug, setIstZugfahrzeug] = useState(false);
const [bearbeitungsZugfahrzeug, setBearbeitungsZugfahrzeug] = useState(false);
const [ressourcenbuchungAktiv, setRessourcenbuchungAktiv] = useState(false);
const [bearbeitungsRessourcenbuchung, setBearbeitungsRessourcenbuchung] = useState(false);
const [schemaBearbeitenId, setSchemaBearbeitenId] = useState<number | null>(null);
const [schemaPraefix, setSchemaPraefix] = useState("");
const [schemaStellenzahl, setSchemaStellenzahl] = useState(5);
@@ -42,13 +44,18 @@ export function ObjekttypSection({
setBearbeiteteId(o.id);
setBearbeitungsName(o.name);
setBearbeitungsZugfahrzeug(o.ist_zugfahrzeug);
setBearbeitungsRessourcenbuchung(o.ressourcenbuchung_aktiv);
}
async function speichern(id: number) {
try {
await apiRequest(`/objekttypen/${id}`, {
method: "PATCH",
body: { name: bearbeitungsName, ist_zugfahrzeug: bearbeitungsZugfahrzeug },
body: {
name: bearbeitungsName,
ist_zugfahrzeug: bearbeitungsZugfahrzeug,
ressourcenbuchung_aktiv: bearbeitungsRessourcenbuchung,
},
});
setBearbeiteteId(null);
await onGeaendert();
@@ -67,10 +74,17 @@ export function ObjekttypSection({
try {
await apiRequest("/objekttypen", {
method: "POST",
body: { name, bereich_id: bereichId, kategorie_id: kategorieId || null, ist_zugfahrzeug: istZugfahrzeug },
body: {
name,
bereich_id: bereichId,
kategorie_id: kategorieId || null,
ist_zugfahrzeug: istZugfahrzeug,
ressourcenbuchung_aktiv: ressourcenbuchungAktiv,
},
});
setName("");
setIstZugfahrzeug(false);
setRessourcenbuchungAktiv(false);
await onGeaendert();
} catch {
onFehler("Objekttyp konnte nicht angelegt werden.");
@@ -167,6 +181,14 @@ export function ObjekttypSection({
/>
Zugfahrzeug (koppelbar)
</label>
<label className="row" style={{ alignItems: "center", gap: "0.4rem", marginTop: "1.5rem" }}>
<input
type="checkbox"
checked={ressourcenbuchungAktiv}
onChange={(e) => setRessourcenbuchungAktiv(e.target.checked)}
/>
Ressourcenbuchung in der Akte anzeigen
</label>
<button
className="btn btn-primary"
style={{ marginTop: "1.5rem" }}
@@ -200,6 +222,14 @@ export function ObjekttypSection({
/>
Zugfahrzeug
</label>
<label className="row" style={{ alignItems: "center", gap: "0.4rem" }}>
<input
type="checkbox"
checked={bearbeitungsRessourcenbuchung}
onChange={(e) => setBearbeitungsRessourcenbuchung(e.target.checked)}
/>
Ressourcenbuchung
</label>
<button className="btn btn-primary" onClick={() => speichern(o.id)}>
Speichern
</button>
@@ -212,6 +242,7 @@ export function ObjekttypSection({
<span>
{o.name}
{o.ist_zugfahrzeug && <span className="text-muted"> (Zugfahrzeug)</span>}
{o.ressourcenbuchung_aktiv && <span className="text-muted"> (Buchungen)</span>}
</span>
<button className="btn btn-secondary" onClick={() => bearbeiten(o)}>
Bearbeiten