diff --git a/arbeitskacheln/04_assets.md b/arbeitskacheln/04_assets.md index 4a06102..5cca1bf 100644 --- a/arbeitskacheln/04_assets.md +++ b/arbeitskacheln/04_assets.md @@ -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`. --- diff --git a/arbeitskacheln/23_flutter_begleitapp.md b/arbeitskacheln/23_flutter_begleitapp.md index 4312c52..1c7e45d 100644 --- a/arbeitskacheln/23_flutter_begleitapp.md +++ b/arbeitskacheln/23_flutter_begleitapp.md @@ -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. diff --git a/backend/alembic/versions/0054_ressourcenbuchung_optin.py b/backend/alembic/versions/0054_ressourcenbuchung_optin.py new file mode 100644 index 0000000..4241118 --- /dev/null +++ b/backend/alembic/versions/0054_ressourcenbuchung_optin.py @@ -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") diff --git a/backend/app/api/v1/endpoints/app_download.py b/backend/app/api/v1/endpoints/app_download.py index e93b019..14fb37c 100644 --- a/backend/app/api/v1/endpoints/app_download.py +++ b/backend/app/api/v1/endpoints/app_download.py @@ -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") diff --git a/backend/app/models/stammdaten.py b/backend/app/models/stammdaten.py index ce74876..4e4f233 100644 --- a/backend/app/models/stammdaten.py +++ b/backend/app/models/stammdaten.py @@ -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): diff --git a/backend/app/schemas/akte.py b/backend/app/schemas/akte.py index 349b34d..5c7a378 100644 --- a/backend/app/schemas/akte.py +++ b/backend/app/schemas/akte.py @@ -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] diff --git a/backend/app/schemas/stammdaten.py b/backend/app/schemas/stammdaten.py index e23a8fc..3f93bb2 100644 --- a/backend/app/schemas/stammdaten.py +++ b/backend/app/schemas/stammdaten.py @@ -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): diff --git a/backend/app/services/akte.py b/backend/app/services/akte.py index b60a7ca..f37c421 100644 --- a/backend/app/services/akte.py +++ b/backend/app/services/akte.py @@ -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, diff --git a/backend/tests/test_akte.py b/backend/tests/test_akte.py index 8128943..5d06667 100644 --- a/backend/tests/test_akte.py +++ b/backend/tests/test_akte.py @@ -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"] diff --git a/backend/tests/test_app_download.py b/backend/tests/test_app_download.py index d8d5f94..62d1199 100644 --- a/backend/tests/test_app_download.py +++ b/backend/tests/test_app_download.py @@ -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" diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 941ac3f..0ff3eb8 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -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[]; diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index dba1d2a..8519e3a 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -371,10 +371,14 @@ export function AdminPage() { function ScanAppDownloadHinweis() { return (

- - MABEA Scan-App (Android, Prototyp) — Download-Seite mit QR-Code + + MABEA Scan-App {" "} - — FLUT-001-Sondierung, kein Produktivstand. + /{" "} + + MABEA Doku-App + {" "} + (Android, Prototypen) — Download-Seite mit QR-Code — kein Produktivstand.

); } diff --git a/frontend/src/pages/AktePage.tsx b/frontend/src/pages/AktePage.tsx index 97a1b63..f006104 100644 --- a/frontend/src/pages/AktePage.tsx +++ b/frontend/src/pages/AktePage.tsx @@ -314,10 +314,12 @@ export function AktePage() { -
-

Buchungen

- -
+ {akte.ressourcenbuchung_aktiv && ( +
+

Buchungen

+ +
+ )}
diff --git a/frontend/src/pages/AppDownloadPage.tsx b/frontend/src/pages/AppDownloadPage.tsx index cd92630..da882a4 100644 --- a/frontend/src/pages/AppDownloadPage.tsx +++ b/frontend/src/pages/AppDownloadPage.tsx @@ -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 = { + 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(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 (
-

MABEA Scan-App

-

Android-Prototyp (FLUT-001) zum QR-Scan von Objekten.

+ + +

{meta.titel}

+

{meta.beschreibung}

{fehler &&

Aktuell keine App-Version verfügbar.

} {info && ( <> QR-Code zum App-Download diff --git a/frontend/src/pages/admin/ObjekttypSection.tsx b/frontend/src/pages/admin/ObjekttypSection.tsx index 3e8e611..15fd21b 100644 --- a/frontend/src/pages/admin/ObjekttypSection.tsx +++ b/frontend/src/pages/admin/ObjekttypSection.tsx @@ -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(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) + @@ -212,6 +242,7 @@ export function ObjekttypSection({ {o.name} {o.ist_zugfahrzeug && (Zugfahrzeug)} + {o.ressourcenbuchung_aktiv && (Buchungen)}