feat: mabea-doku Zwei-Apps-Support + Ressourcenbuchung Opt-in
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:
1 parent
3d560738de
commit
ce99aa6fbb
15 files changed
+231
-29
No files matched your search
@@ -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
|
(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
|
der Akte JEDES Objekts (nicht nur Fahrzeuge — Material-Sets/Geräte können
|
||||||
genauso gebucht werden). `backend/tests/test_ressourcenbuchung.py`.
|
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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
Store-Veröffentlichung (Sideload/interner Test-Build reicht für die
|
||||||
Sondierung).
|
Sondierung).
|
||||||
- **DoD:** ✅ **Prototyp läuft, iterativ über den ursprünglichen Scope hinaus
|
- **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),
|
bewertet (keine Grundsatzentscheidung Ergänzung-vs-Ablösung-PWA getroffen),
|
||||||
aber der Prototyp selbst ist weit über "ein Screen QR-Scan" hinausgewachsen:
|
aber der Prototyp selbst ist weit über "ein Screen QR-Scan" hinausgewachsen:
|
||||||
- **v0.1.0** — Basis-Scope: Login-Screen (JWT), QR-Scan-Screen gegen
|
- **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
|
— 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
|
gelöscht, real passiert und behoben). Download-Link im Portal nur für
|
||||||
Administrator sichtbar (`AdminPage.tsx`).
|
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,
|
Objekt-Scan (Einsatzbereitschafts-Ampel + Gründe, letzte Kontrolle,
|
||||||
Anzahl offener Mängel, via `GET /akte/objekt/{id}`, Ladefehler nicht
|
Anzahl offener Mängel, via `GET /akte/objekt/{id}`, Ladefehler nicht
|
||||||
fatal — Basis-Objekt bleibt sichtbar); Taschenlampe-Toggle
|
fatal — Basis-Objekt bleibt sichtbar); Taschenlampe-Toggle
|
||||||
(`MobileScannerController.toggleTorch()`); Geräte-Exemplar-Code als
|
(`MobileScannerController.toggleTorch()`); Geräte-Exemplar-Code als
|
||||||
Fallback, wenn kein Objekt-Code passt (`GET /objektpositionen/code/
|
Fallback, wenn kein Objekt-Code passt (`GET /objektpositionen/code/
|
||||||
{code}` — Geräte-Etiketten waren bis dahin gar nicht scanbar).
|
{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
|
- **Feldtest-Vorbereitung 2026-09-10:** öffentliche Download-Seite
|
||||||
`/app` (KEIN Login nötig — Henne-Ei, die App IST der Login-Client)
|
`/app` (KEIN Login nötig — Henne-Ei, die App IST der Login-Client)
|
||||||
zeigt QR-Code + Download-Button + Installationsanleitung für Helfer.
|
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")
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Response, status
|
from fastapi import APIRouter, HTTPException, Response, status
|
||||||
|
|
||||||
@@ -8,32 +9,42 @@ from app.services.qr_png import generiere_qr_png
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
AppName = Literal["scan", "doku"]
|
||||||
|
|
||||||
def _lade_version_info() -> dict:
|
# FLUT-003 (2026-09-10): Nutzer-Entscheidung - ZWEI eigenständige Apps statt
|
||||||
pfad = Path(settings.downloads_dir) / "version.json"
|
# 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():
|
if not pfad.exists():
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Keine App-Version veröffentlicht")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Keine App-Version veröffentlicht")
|
||||||
return json.loads(pfad.read_text())
|
return json.loads(pfad.read_text())
|
||||||
|
|
||||||
|
|
||||||
@router.get("/app-download/info")
|
@router.get("/app-download/info")
|
||||||
async def app_download_info() -> dict:
|
async def app_download_info(app: AppName = "scan") -> dict:
|
||||||
"""FLUT-001: bewusst OHNE Auth - Helfer im Feld sollen die Scan-App auch
|
"""Bewusst OHNE Auth - Helfer im Feld sollen die Apps auch ohne
|
||||||
ohne MABEA-Login installieren können (Henne-Ei: die App IST der
|
MABEA-Login installieren können (Henne-Ei: die App IST der
|
||||||
Login-Client). Liefert dieselbe version.json wie der In-App-Update-Check,
|
Login-Client). Liefert dieselbe version*.json wie der In-App-Update-
|
||||||
plus die volle URL fürs Frontend/den QR-Code."""
|
Check (nur mabea-scan hat aktuell einen In-App-Update-Check), plus die
|
||||||
daten = _lade_version_info()
|
volle URL fürs Frontend/den QR-Code."""
|
||||||
|
daten = _lade_version_info(app)
|
||||||
return {
|
return {
|
||||||
|
"app": app,
|
||||||
"version": daten["version"],
|
"version": daten["version"],
|
||||||
"apk_url": f"{settings.portal_url}{daten['apk_url']}",
|
"apk_url": f"{settings.portal_url}{daten['apk_url']}",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/app-download/qr.png")
|
@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)
|
"""QR-Code direkt auf die aktuelle APK - zum Ausdrucken (Poster/Aushang)
|
||||||
oder Scannen von einem zweiten Gerät aus. Bewusst OHNE Auth, siehe
|
oder Scannen von einem zweiten Gerät aus. Bewusst OHNE Auth, siehe
|
||||||
app_download_info()."""
|
app_download_info()."""
|
||||||
daten = _lade_version_info()
|
daten = _lade_version_info(app)
|
||||||
apk_url = f"{settings.portal_url}{daten['apk_url']}"
|
apk_url = f"{settings.portal_url}{daten['apk_url']}"
|
||||||
return Response(content=generiere_qr_png(apk_url), media_type="image/png")
|
return Response(content=generiere_qr_png(apk_url), media_type="image/png")
|
||||||
@@ -52,6 +52,11 @@ class Objekttyp(Base):
|
|||||||
# fahrzeug_id) zugeordnet werden - grenzt die Fahrzeug-Dropdowns serverseitig
|
# fahrzeug_id) zugeordnet werden - grenzt die Fahrzeug-Dropdowns serverseitig
|
||||||
# ein, ohne einen eigenen Fahrzeug-Objekttyp fachlich zu erzwingen.
|
# ein, ohne einen eigenen Fahrzeug-Objekttyp fachlich zu erzwingen.
|
||||||
ist_zugfahrzeug: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
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):
|
class Nummernschema(Base):
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ class AkteRead(BaseModel):
|
|||||||
einsatzbereitschaft_status: str
|
einsatzbereitschaft_status: str
|
||||||
einsatzbereitschaft_gruende: list[str]
|
einsatzbereitschaft_gruende: list[str]
|
||||||
objekttyp_name: str
|
objekttyp_name: str
|
||||||
|
ressourcenbuchung_aktiv: bool
|
||||||
standort_name: str
|
standort_name: str
|
||||||
fahrzeugdetails: FahrzeugdetailsRead | None
|
fahrzeugdetails: FahrzeugdetailsRead | None
|
||||||
zustaendige_benutzer: list[ZustaendigerBenutzer]
|
zustaendige_benutzer: list[ZustaendigerBenutzer]
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ class ObjekttypCreate(BaseModel):
|
|||||||
kategorie_id: int | None = None
|
kategorie_id: int | None = None
|
||||||
name: str
|
name: str
|
||||||
ist_zugfahrzeug: bool = False
|
ist_zugfahrzeug: bool = False
|
||||||
|
ressourcenbuchung_aktiv: bool = False
|
||||||
|
|
||||||
|
|
||||||
class ObjekttypRead(ObjekttypCreate):
|
class ObjekttypRead(ObjekttypCreate):
|
||||||
@@ -102,6 +103,7 @@ class ObjekttypUpdate(BaseModel):
|
|||||||
kategorie_id: int | None = None
|
kategorie_id: int | None = None
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
ist_zugfahrzeug: bool | None = None
|
ist_zugfahrzeug: bool | None = None
|
||||||
|
ressourcenbuchung_aktiv: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
class FachCreate(BaseModel):
|
class FachCreate(BaseModel):
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ async def akte_fuer_objekt(db: AsyncSession, objekt_id: int) -> AkteRead | None:
|
|||||||
einsatzbereitschaft_status=readiness["status"],
|
einsatzbereitschaft_status=readiness["status"],
|
||||||
einsatzbereitschaft_gruende=readiness["gruende"],
|
einsatzbereitschaft_gruende=readiness["gruende"],
|
||||||
objekttyp_name=objekttyp.name if objekttyp else "",
|
objekttyp_name=objekttyp.name if objekttyp else "",
|
||||||
|
ressourcenbuchung_aktiv=objekttyp.ressourcenbuchung_aktiv if objekttyp else False,
|
||||||
standort_name=standort.name if standort else "",
|
standort_name=standort.name if standort else "",
|
||||||
fahrzeugdetails=FahrzeugdetailsRead.model_validate(fahrzeugdetails) if fahrzeugdetails else None,
|
fahrzeugdetails=FahrzeugdetailsRead.model_validate(fahrzeugdetails) if fahrzeugdetails else None,
|
||||||
zustaendige_benutzer=zustaendige,
|
zustaendige_benutzer=zustaendige,
|
||||||
|
|||||||
@@ -48,6 +48,29 @@ async def test_akte_buendelt_teilbereiche(client, db_session, objekt_mit_positio
|
|||||||
assert len(body["maengel"]) == 1
|
assert len(body["maengel"]) == 1
|
||||||
assert body["maengel"][0]["beschreibung"] == "Riss im Gurt"
|
assert body["maengel"][0]["beschreibung"] == "Riss im Gurt"
|
||||||
assert len(body["historie"]) == 1
|
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["historie"][0]["entitaet_typ"] == "objekt"
|
||||||
assert body["einsatzbereitschaft_status"] == "not-ready"
|
assert body["einsatzbereitschaft_status"] == "not-ready"
|
||||||
assert "mangel_kritisch_offen" in body["einsatzbereitschaft_gruende"]
|
assert "mangel_kritisch_offen" in body["einsatzbereitschaft_gruende"]
|
||||||
|
|||||||
@@ -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")
|
response = await client.get("/api/v1/app-download/info")
|
||||||
assert response.status_code != 401
|
assert response.status_code != 401
|
||||||
assert response.status_code != 403
|
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"
|
||||||
@@ -419,6 +419,7 @@ export interface Objekttyp {
|
|||||||
kategorie_id: number | null;
|
kategorie_id: number | null;
|
||||||
name: string;
|
name: string;
|
||||||
ist_zugfahrzeug: boolean;
|
ist_zugfahrzeug: boolean;
|
||||||
|
ressourcenbuchung_aktiv: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Nummernschema {
|
export interface Nummernschema {
|
||||||
@@ -476,6 +477,7 @@ export interface Akte {
|
|||||||
einsatzbereitschaft_status: "ready" | "limited" | "not-ready" | "unknown";
|
einsatzbereitschaft_status: "ready" | "limited" | "not-ready" | "unknown";
|
||||||
einsatzbereitschaft_gruende: string[];
|
einsatzbereitschaft_gruende: string[];
|
||||||
objekttyp_name: string;
|
objekttyp_name: string;
|
||||||
|
ressourcenbuchung_aktiv: boolean;
|
||||||
standort_name: string;
|
standort_name: string;
|
||||||
fahrzeugdetails: Fahrzeugdetails | null;
|
fahrzeugdetails: Fahrzeugdetails | null;
|
||||||
zustaendige_benutzer: ZustaendigerBenutzer[];
|
zustaendige_benutzer: ZustaendigerBenutzer[];
|
||||||
|
|||||||
@@ -371,10 +371,14 @@ export function AdminPage() {
|
|||||||
function ScanAppDownloadHinweis() {
|
function ScanAppDownloadHinweis() {
|
||||||
return (
|
return (
|
||||||
<p className="text-muted" style={{ marginTop: "2rem", fontSize: "0.85rem" }}>
|
<p className="text-muted" style={{ marginTop: "2rem", fontSize: "0.85rem" }}>
|
||||||
<a href="/app" target="_blank" rel="noreferrer">
|
<a href="/app?app=scan" target="_blank" rel="noreferrer">
|
||||||
MABEA Scan-App (Android, Prototyp) — Download-Seite mit QR-Code
|
MABEA Scan-App
|
||||||
</a>{" "}
|
</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>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -314,10 +314,12 @@ export function AktePage() {
|
|||||||
<ReadinessVerlaufPanel objektId={objekt.id} />
|
<ReadinessVerlaufPanel objektId={objekt.id} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="akte-ressourcenbuchung card">
|
{akte.ressourcenbuchung_aktiv && (
|
||||||
<h3 style={{ marginTop: 0 }}>Buchungen</h3>
|
<div className="akte-ressourcenbuchung card">
|
||||||
<RessourcenbuchungPanel objektId={objekt.id} onFehler={setFehler} />
|
<h3 style={{ marginTop: 0 }}>Buchungen</h3>
|
||||||
</div>
|
<RessourcenbuchungPanel objektId={objekt.id} onFehler={setFehler} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="akte-historie card">
|
<div className="akte-historie card">
|
||||||
<div className="row-between" style={{ alignItems: "baseline" }}>
|
<div className="row-between" style={{ alignItems: "baseline" }}>
|
||||||
|
|||||||
@@ -1,37 +1,70 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api/v1";
|
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api/v1";
|
||||||
|
|
||||||
|
type AppName = "scan" | "doku";
|
||||||
|
|
||||||
interface AppInfo {
|
interface AppInfo {
|
||||||
|
app: AppName;
|
||||||
version: string;
|
version: string;
|
||||||
apk_url: string;
|
apk_url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// FLUT-001: öffentliche Seite (kein Login nötig - Henne-Ei, die App IST der
|
const APP_META: Record<AppName, { titel: string; beschreibung: string }> = {
|
||||||
// Login-Client), damit Helfer im Feld die Scan-App per Link oder QR-Code
|
scan: {
|
||||||
// installieren können, ohne vorher im Portal angemeldet zu sein.
|
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() {
|
export function AppDownloadPage() {
|
||||||
|
const [suchparameter] = useSearchParams();
|
||||||
|
const app: AppName = suchparameter.get("app") === "doku" ? "doku" : "scan";
|
||||||
|
|
||||||
const [info, setInfo] = useState<AppInfo | null>(null);
|
const [info, setInfo] = useState<AppInfo | null>(null);
|
||||||
const [fehler, setFehler] = useState(false);
|
const [fehler, setFehler] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
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((r) => (r.ok ? r.json() : Promise.reject()))
|
||||||
.then(setInfo)
|
.then(setInfo)
|
||||||
.catch(() => setFehler(true));
|
.catch(() => setFehler(true));
|
||||||
}, []);
|
}, [app]);
|
||||||
|
|
||||||
|
const meta = APP_META[app];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ maxWidth: "28rem", margin: "3rem auto", padding: "0 1rem", textAlign: "center" }}>
|
<main style={{ maxWidth: "28rem", margin: "3rem auto", padding: "0 1rem", textAlign: "center" }}>
|
||||||
<h1>MABEA Scan-App</h1>
|
<div className="row" style={{ justifyContent: "center", gap: "0.5rem", marginBottom: "1rem" }}>
|
||||||
<p className="text-muted">Android-Prototyp (FLUT-001) zum QR-Scan von Objekten.</p>
|
<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>}
|
{fehler && <p className="alert alert-danger">Aktuell keine App-Version verfügbar.</p>}
|
||||||
|
|
||||||
{info && (
|
{info && (
|
||||||
<>
|
<>
|
||||||
<img
|
<img
|
||||||
src={`${API_BASE}/app-download/qr.png`}
|
src={`${API_BASE}/app-download/qr.png?app=${app}`}
|
||||||
alt="QR-Code zum App-Download"
|
alt="QR-Code zum App-Download"
|
||||||
style={{ width: "220px", height: "220px", margin: "1.5rem auto", display: "block" }}
|
style={{ width: "220px", height: "220px", margin: "1.5rem auto", display: "block" }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export function ObjekttypSection({
|
|||||||
const [bearbeitungsName, setBearbeitungsName] = useState("");
|
const [bearbeitungsName, setBearbeitungsName] = useState("");
|
||||||
const [istZugfahrzeug, setIstZugfahrzeug] = useState(false);
|
const [istZugfahrzeug, setIstZugfahrzeug] = useState(false);
|
||||||
const [bearbeitungsZugfahrzeug, setBearbeitungsZugfahrzeug] = 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 [schemaBearbeitenId, setSchemaBearbeitenId] = useState<number | null>(null);
|
||||||
const [schemaPraefix, setSchemaPraefix] = useState("");
|
const [schemaPraefix, setSchemaPraefix] = useState("");
|
||||||
const [schemaStellenzahl, setSchemaStellenzahl] = useState(5);
|
const [schemaStellenzahl, setSchemaStellenzahl] = useState(5);
|
||||||
@@ -42,13 +44,18 @@ export function ObjekttypSection({
|
|||||||
setBearbeiteteId(o.id);
|
setBearbeiteteId(o.id);
|
||||||
setBearbeitungsName(o.name);
|
setBearbeitungsName(o.name);
|
||||||
setBearbeitungsZugfahrzeug(o.ist_zugfahrzeug);
|
setBearbeitungsZugfahrzeug(o.ist_zugfahrzeug);
|
||||||
|
setBearbeitungsRessourcenbuchung(o.ressourcenbuchung_aktiv);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function speichern(id: number) {
|
async function speichern(id: number) {
|
||||||
try {
|
try {
|
||||||
await apiRequest(`/objekttypen/${id}`, {
|
await apiRequest(`/objekttypen/${id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: { name: bearbeitungsName, ist_zugfahrzeug: bearbeitungsZugfahrzeug },
|
body: {
|
||||||
|
name: bearbeitungsName,
|
||||||
|
ist_zugfahrzeug: bearbeitungsZugfahrzeug,
|
||||||
|
ressourcenbuchung_aktiv: bearbeitungsRessourcenbuchung,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
setBearbeiteteId(null);
|
setBearbeiteteId(null);
|
||||||
await onGeaendert();
|
await onGeaendert();
|
||||||
@@ -67,10 +74,17 @@ export function ObjekttypSection({
|
|||||||
try {
|
try {
|
||||||
await apiRequest("/objekttypen", {
|
await apiRequest("/objekttypen", {
|
||||||
method: "POST",
|
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("");
|
setName("");
|
||||||
setIstZugfahrzeug(false);
|
setIstZugfahrzeug(false);
|
||||||
|
setRessourcenbuchungAktiv(false);
|
||||||
await onGeaendert();
|
await onGeaendert();
|
||||||
} catch {
|
} catch {
|
||||||
onFehler("Objekttyp konnte nicht angelegt werden.");
|
onFehler("Objekttyp konnte nicht angelegt werden.");
|
||||||
@@ -167,6 +181,14 @@ export function ObjekttypSection({
|
|||||||
/>
|
/>
|
||||||
Zugfahrzeug (koppelbar)
|
Zugfahrzeug (koppelbar)
|
||||||
</label>
|
</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
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
style={{ marginTop: "1.5rem" }}
|
style={{ marginTop: "1.5rem" }}
|
||||||
@@ -200,6 +222,14 @@ export function ObjekttypSection({
|
|||||||
/>
|
/>
|
||||||
Zugfahrzeug
|
Zugfahrzeug
|
||||||
</label>
|
</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)}>
|
<button className="btn btn-primary" onClick={() => speichern(o.id)}>
|
||||||
Speichern
|
Speichern
|
||||||
</button>
|
</button>
|
||||||
@@ -212,6 +242,7 @@ export function ObjekttypSection({
|
|||||||
<span>
|
<span>
|
||||||
{o.name}
|
{o.name}
|
||||||
{o.ist_zugfahrzeug && <span className="text-muted"> (Zugfahrzeug)</span>}
|
{o.ist_zugfahrzeug && <span className="text-muted"> (Zugfahrzeug)</span>}
|
||||||
|
{o.ressourcenbuchung_aktiv && <span className="text-muted"> (Buchungen)</span>}
|
||||||
</span>
|
</span>
|
||||||
<button className="btn btn-secondary" onClick={() => bearbeiten(o)}>
|
<button className="btn btn-secondary" onClick={() => bearbeiten(o)}>
|
||||||
Bearbeiten
|
Bearbeiten
|
||||||
|
|||||||
Reference in new issue
Block a user