feat(personal): PERS-004 Einsatzfunktionen (Gruppenführer/Melder/...)
CI / backend-tests (push) Successful in 2m17s
CI / frontend-build (push) Successful in 26s

Neue Stammdaten-Tabelle einsatzfunktion (Migration 0031), benutzer bekommt
optionales einsatzfunktion_id-Feld - analog zu einheit_id modelliert, eine
Person hat im Regelfall genau eine primäre Einsatzfunktion. Bewusst
getrennt von den technischen App-Rollen (RolleTyp), die nur steuern, was
jemand in MABEA tun darf, nicht welche Funktion er im Einsatz hat.

CRUD-Endpunkt /einsatzfunktionen nach Kategorie-Muster, Duplikat-Prüfung
vorab (Projekt-Konvention). Admin-UI: Auswahl + Inline-Neuanlage in der
Benutzerverwaltung (BenutzerSection).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-08 20:07:16 +02:00
co-authored by Claude Sonnet 5
parent b7d9581b90
commit 15f783fb5e
12 changed files with 228 additions and 7 deletions
+1 -1
View File
@@ -205,7 +205,7 @@ vergeben statt geraten.
| PERS-001 | Personnel | Person (fachlich) | P1 | M | HIGH | FOUND-002 | ⬜ (laut DECISION-Abschnitt der Datei selbst nicht getrennt, nur `Benutzer`) | | PERS-001 | Personnel | Person (fachlich) | P1 | M | HIGH | FOUND-002 | ⬜ (laut DECISION-Abschnitt der Datei selbst nicht getrennt, nur `Benutzer`) |
| PERS-002 | Personnel | Benutzer (technisch, verknüpft) | P0 | M | HIGH | PERS-001, FOUND-003 | ✅ (`Benutzer`-Modell, `benutzer.py`-Endpunkt) | | PERS-002 | Personnel | Benutzer (technisch, verknüpft) | P0 | M | HIGH | PERS-001, FOUND-003 | ✅ (`Benutzer`-Modell, `benutzer.py`-Endpunkt) |
| PERS-003 | Personnel | Einheiten/Organisationsstruktur | P1 | S | LOW | PERS-001 | ✅ (`Einheit`-Modell, hierarchisch, `einheiten`-Endpunkt) | | PERS-003 | Personnel | Einheiten/Organisationsstruktur | P1 | S | LOW | PERS-001 | ✅ (`Einheit`-Modell, hierarchisch, `einheiten`-Endpunkt) |
| PERS-004 | Personnel | Funktionen/Rollen im Einsatzkontext | P2 | S | LOW | PERS-003 | (nur technische Rollen `RolleTyp` gefunden, keine Einsatzfunktionen) | | PERS-004 | Personnel | Funktionen/Rollen im Einsatzkontext | P2 | S | LOW | PERS-003 | (`Einsatzfunktion`-Stammdaten, Migration 0031, `benutzer.einsatzfunktion_id`, Admin-UI in BenutzerSection) |
| PERS-005 | Personnel | Qualifikationen/Lehrgänge | P1 | S | LOW | PERS-001 | ✅ (`Qualifikationstyp`, `BenutzerQualifikation`) | | PERS-005 | Personnel | Qualifikationen/Lehrgänge | P1 | S | LOW | PERS-001 | ✅ (`Qualifikationstyp`, `BenutzerQualifikation`) |
| PERS-006 | Personnel | Führerscheine/Berechtigungen mit Gültigkeit | P1 | M | MEDIUM | PERS-005, ASSET-002 | ✅ (`Qualifikationskategorie.fuehrerschein`, `ObjekttypQualifikationsanforderung`) | | PERS-006 | Personnel | Führerscheine/Berechtigungen mit Gültigkeit | P1 | M | MEDIUM | PERS-005, ASSET-002 | ✅ (`Qualifikationskategorie.fuehrerschein`, `ObjekttypQualifikationsanforderung`) |
| PERS-007 | Personnel | Qualifikationsablauf-Warnungen | P1 | S | LOW | PERS-006 | ✅ (`QualifikationTile.tsx`, `/dashboard/qualifikationsablaeufe`) | | PERS-007 | Personnel | Qualifikationsablauf-Warnungen | P1 | S | LOW | PERS-006 | ✅ (`QualifikationTile.tsx`, `/dashboard/qualifikationsablaeufe`) |
@@ -0,0 +1,33 @@
"""PERS-004: Einsatzfunktionen (Gruppenführer/Melder/...), benutzer.einsatzfunktion_id.
Revision ID: 0031_einsatzfunktion
Revises: 0030_hersteller_modell
Create Date: 2026-09-08
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0031_einsatzfunktion"
down_revision: Union[str, None] = "0030_hersteller_modell"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"""
CREATE TABLE einsatzfunktion (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE
)
"""
)
op.execute("ALTER TABLE benutzer ADD COLUMN einsatzfunktion_id INTEGER REFERENCES einsatzfunktion(id)")
op.execute("CREATE INDEX ix_benutzer_einsatzfunktion_id ON benutzer (einsatzfunktion_id)")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_benutzer_einsatzfunktion_id")
op.execute("ALTER TABLE benutzer DROP COLUMN einsatzfunktion_id")
op.execute("DROP TABLE einsatzfunktion")
+3
View File
@@ -38,6 +38,7 @@ async def erstelle_benutzer(
passwort_hash=hash_password(payload.passwort), passwort_hash=hash_password(payload.passwort),
aktiv=True, aktiv=True,
einheit_id=payload.einheit_id, einheit_id=payload.einheit_id,
einsatzfunktion_id=payload.einsatzfunktion_id,
) )
db.add(benutzer) db.add(benutzer)
await db.flush() await db.flush()
@@ -70,6 +71,8 @@ async def aendere_benutzer(
# im Request enthalten" - anders als die übrigen Felder hier, da null # im Request enthalten" - anders als die übrigen Felder hier, da null
# ein gültiger, gewollter Zielzustand ist (Bugfix vor Auslieferung). # ein gültiger, gewollter Zielzustand ist (Bugfix vor Auslieferung).
benutzer.einheit_id = payload.einheit_id benutzer.einheit_id = payload.einheit_id
if "einsatzfunktion_id" in payload.model_fields_set:
benutzer.einsatzfunktion_id = payload.einsatzfunktion_id
if payload.rollen is not None: if payload.rollen is not None:
result = await db.execute(select(BenutzerRolle).where(BenutzerRolle.benutzer_id == benutzer_id)) result = await db.execute(select(BenutzerRolle).where(BenutzerRolle.benutzer_id == benutzer_id))
for bestehende in result.scalars().all(): for bestehende in result.scalars().all():
+28
View File
@@ -9,6 +9,7 @@ from app.models.auth import KnotenTyp, RolleTyp, Systemknoten
from app.models.personal import ( from app.models.personal import (
BenutzerQualifikation, BenutzerQualifikation,
Einheit, Einheit,
Einsatzfunktion,
ObjekttypQualifikationsanforderung, ObjekttypQualifikationsanforderung,
Qualifikationstyp, Qualifikationstyp,
) )
@@ -19,6 +20,8 @@ from app.schemas.personal import (
EinheitCreate, EinheitCreate,
EinheitRead, EinheitRead,
EinheitUpdate, EinheitUpdate,
EinsatzfunktionCreate,
EinsatzfunktionRead,
ObjekttypQualifikationsanforderungCreate, ObjekttypQualifikationsanforderungCreate,
ObjekttypQualifikationsanforderungRead, ObjekttypQualifikationsanforderungRead,
QualifikationstypCreate, QualifikationstypCreate,
@@ -75,6 +78,31 @@ async def aendere_einheit(
return einheit return einheit
# -- Einsatzfunktion (PERS-004) ------------------------------------------------
@router.get("/einsatzfunktionen", response_model=list[EinsatzfunktionRead])
async def liste_einsatzfunktionen(
db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
) -> list[Einsatzfunktion]:
result = await db.execute(select(Einsatzfunktion))
return list(result.scalars().all())
@router.post("/einsatzfunktionen", response_model=EinsatzfunktionRead, status_code=status.HTTP_201_CREATED)
async def erstelle_einsatzfunktion(
payload: EinsatzfunktionCreate, db: AsyncSession = Depends(get_db), _=Depends(_admin_only)
) -> Einsatzfunktion:
# Explizit vorab prüfen statt sich auf den UNIQUE-Constraint zu verlassen
# (PendingRollbackError-Risiko in geteilter Session, siehe geraet_instanz.py).
bestehende = await db.execute(select(Einsatzfunktion).where(Einsatzfunktion.name == payload.name))
if bestehende.scalar_one_or_none() is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Einsatzfunktion existiert bereits")
einsatzfunktion = Einsatzfunktion(**payload.model_dump())
db.add(einsatzfunktion)
await db.flush()
return einsatzfunktion
# -- Qualifikationstyp --------------------------------------------------------- # -- Qualifikationstyp ---------------------------------------------------------
@router.get("/qualifikationstypen", response_model=list[QualifikationstypRead]) @router.get("/qualifikationstypen", response_model=list[QualifikationstypRead])
+2
View File
@@ -45,6 +45,8 @@ class Benutzer(Base):
aktiv: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) aktiv: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
email: Mapped[str | None] = mapped_column(String) # Sprint 6 / Karte 05 (Migration 0003) email: Mapped[str | None] = mapped_column(String) # Sprint 6 / Karte 05 (Migration 0003)
einheit_id: Mapped[int | None] = mapped_column(ForeignKey("einheit.id")) # Personal-Modul (Migration 0012) einheit_id: Mapped[int | None] = mapped_column(ForeignKey("einheit.id")) # Personal-Modul (Migration 0012)
# PERS-004: primäre Funktion im Einsatzkontext, optional.
einsatzfunktion_id: Mapped[int | None] = mapped_column(ForeignKey("einsatzfunktion.id"))
rollen: Mapped[list["BenutzerRolle"]] = relationship(back_populates="benutzer", lazy="selectin") rollen: Mapped[list["BenutzerRolle"]] = relationship(back_populates="benutzer", lazy="selectin")
+15
View File
@@ -74,6 +74,21 @@ class BenutzerQualifikation(Base):
) )
class Einsatzfunktion(Base):
"""PERS-004: Funktion im Einsatzkontext (z.B. Gruppenführer, Truppführer,
Melder, Maschinist, Atemschutzgeräteträger) - unabhängig von den
technischen App-Rollen (RolleTyp: mitarbeiter/materialverantwortlicher/
...), die nur steuern, was jemand in MABEA tun darf, nicht welche Funktion
er im Einsatz hat. Bewusst analog zu Benutzer.einheit_id als einzelnes
optionales Feld modelliert, nicht als M:N - eine Person hat im Regelfall
genau eine primäre Einsatzfunktion, keine Historie/Zeitraum nötig."""
__tablename__ = "einsatzfunktion"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String, unique=True, nullable=False)
class ObjekttypQualifikationsanforderung(Base): class ObjekttypQualifikationsanforderung(Base):
"""M:N: welche Qualifikationstypen braucht man, um Objekte dieses Objekttyps """M:N: welche Qualifikationstypen braucht man, um Objekte dieses Objekttyps
zu bedienen/fahren (Nutzer-Beispiel: "wer darf GW-San 01 fahren?").""" zu bedienen/fahren (Nutzer-Beispiel: "wer darf GW-San 01 fahren?")."""
+4
View File
@@ -10,6 +10,7 @@ class BenutzerCreate(BaseModel):
email: str | None = None email: str | None = None
rollen: list[RolleTyp] = [] rollen: list[RolleTyp] = []
einheit_id: int | None = None einheit_id: int | None = None
einsatzfunktion_id: int | None = None
class BenutzerRead(BaseModel): class BenutzerRead(BaseModel):
@@ -21,6 +22,7 @@ class BenutzerRead(BaseModel):
aktiv: bool aktiv: bool
rollen: list[str] rollen: list[str]
einheit_id: int | None einheit_id: int | None
einsatzfunktion_id: int | None
@classmethod @classmethod
def from_orm_benutzer(cls, benutzer) -> "BenutzerRead": def from_orm_benutzer(cls, benutzer) -> "BenutzerRead":
@@ -32,6 +34,7 @@ class BenutzerRead(BaseModel):
aktiv=benutzer.aktiv, aktiv=benutzer.aktiv,
rollen=benutzer.rollen_namen, rollen=benutzer.rollen_namen,
einheit_id=benutzer.einheit_id, einheit_id=benutzer.einheit_id,
einsatzfunktion_id=benutzer.einsatzfunktion_id,
) )
@@ -41,3 +44,4 @@ class BenutzerUpdate(BaseModel):
aktiv: bool | None = None aktiv: bool | None = None
rollen: list[RolleTyp] | None = None rollen: list[RolleTyp] | None = None
einheit_id: int | None = None einheit_id: int | None = None
einsatzfunktion_id: int | None = None
+9
View File
@@ -26,6 +26,15 @@ class EinheitUpdate(BaseModel):
standort_id: int | None = None standort_id: int | None = None
class EinsatzfunktionCreate(BaseModel):
name: str
class EinsatzfunktionRead(EinsatzfunktionCreate):
model_config = ConfigDict(from_attributes=True)
id: int
class QualifikationstypCreate(BaseModel): class QualifikationstypCreate(BaseModel):
name: str name: str
kategorie: Qualifikationskategorie kategorie: Qualifikationskategorie
+45
View File
@@ -139,6 +139,51 @@ async def test_benutzer_einheit_zuordnung(client, admin_user):
assert geloest.json()["einheit_id"] is None assert geloest.json()["einheit_id"] is None
@pytest.mark.asyncio
async def test_einsatzfunktion_anlegen_und_benutzer_zuordnen(client, admin_user):
"""PERS-004: Einsatzfunktion unabhängig von der technischen App-Rolle."""
token = await login(client, "admin1")
funktion = await client.post(
"/api/v1/einsatzfunktionen", json={"name": "Gruppenführer"}, headers=auth_header(token)
)
assert funktion.status_code == 201
funktion_id = funktion.json()["id"]
duplikat = await client.post(
"/api/v1/einsatzfunktionen", json={"name": "Gruppenführer"}, headers=auth_header(token)
)
assert duplikat.status_code == 409
benutzer = await client.post(
"/api/v1/benutzer",
json={
"name": "Neuer Gruppenführer",
"login": "gf1",
"passwort": "test-passwort-123",
"einsatzfunktion_id": funktion_id,
},
headers=auth_header(token),
)
assert benutzer.status_code == 201
assert benutzer.json()["einsatzfunktion_id"] == funktion_id
benutzer_id = benutzer.json()["id"]
geloest = await client.patch(
f"/api/v1/benutzer/{benutzer_id}", json={"einsatzfunktion_id": None}, headers=auth_header(token)
)
assert geloest.json()["einsatzfunktion_id"] is None
@pytest.mark.asyncio
async def test_mitarbeiter_darf_einsatzfunktion_nicht_anlegen(client, mitarbeiter_user):
token = await login(client, "mitarbeiter1")
response = await client.post(
"/api/v1/einsatzfunktionen", json={"name": "Melder"}, headers=auth_header(token)
)
assert response.status_code == 403
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_bald_ablaufende_qualifikation_erscheint_im_dashboard( async def test_bald_ablaufende_qualifikation_erscheint_im_dashboard(
client, admin_user, mitarbeiter_user client, admin_user, mitarbeiter_user
+6
View File
@@ -392,6 +392,12 @@ export interface Benutzer {
aktiv: boolean; aktiv: boolean;
rollen: string[]; rollen: string[];
einheit_id: number | null; einheit_id: number | null;
einsatzfunktion_id: number | null;
}
export interface Einsatzfunktion {
id: number;
name: string;
} }
export interface Berechtigung { export interface Berechtigung {
+12 -3
View File
@@ -4,7 +4,7 @@ import { useSearchParams } from "react-router-dom";
import { apiRequest } from "../api/client"; import { apiRequest } from "../api/client";
import { AusgabeSection } from "./admin/AusgabeSection"; import { AusgabeSection } from "./admin/AusgabeSection";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import type { Benutzer, Bereich, Beladungsvorlage, Einheit, Fach, Kategorie, Material, Objekt, Objekttyp, Qualifikationstyp, Standort } from "../api/types"; import type { Benutzer, Bereich, Beladungsvorlage, Einheit, Einsatzfunktion, Fach, Kategorie, Material, Objekt, Objekttyp, Qualifikationstyp, Standort } from "../api/types";
import { BenutzerSection } from "./admin/BenutzerSection"; import { BenutzerSection } from "./admin/BenutzerSection";
import { BereichSection } from "./admin/BereichSection"; import { BereichSection } from "./admin/BereichSection";
import { EinheitSection } from "./admin/EinheitSection"; import { EinheitSection } from "./admin/EinheitSection";
@@ -108,6 +108,7 @@ export function AdminPage() {
const [benutzer, setBenutzer] = useState<Benutzer[]>([]); const [benutzer, setBenutzer] = useState<Benutzer[]>([]);
const [faecher, setFaecher] = useState<Fach[]>([]); const [faecher, setFaecher] = useState<Fach[]>([]);
const [einheiten, setEinheiten] = useState<Einheit[]>([]); const [einheiten, setEinheiten] = useState<Einheit[]>([]);
const [einsatzfunktionen, setEinsatzfunktionen] = useState<Einsatzfunktion[]>([]);
const [qualifikationstypen, setQualifikationstypen] = useState<Qualifikationstyp[]>([]); const [qualifikationstypen, setQualifikationstypen] = useState<Qualifikationstyp[]>([]);
const [fehler, setFehler] = useState<string | null>(null); const [fehler, setFehler] = useState<string | null>(null);
const [laedt, setLaedt] = useState(true); const [laedt, setLaedt] = useState(true);
@@ -127,7 +128,7 @@ export function AdminPage() {
// Seite als "nicht geladen" zeigen, obwohl deren Tabs (z.B. Kontroll- // Seite als "nicht geladen" zeigen, obwohl deren Tabs (z.B. Kontroll-
// verantwortung) trotzdem funktionieren sollen. allSettled + leerer // verantwortung) trotzdem funktionieren sollen. allSettled + leerer
// Fallback statt hartem Fehler. // Fallback statt hartem Fehler.
const [be, ka, ot, s, v, o, m, b, f, ei, qt] = await Promise.allSettled([ const [be, ka, ot, s, v, o, m, b, f, ei, qt, ef] = await Promise.allSettled([
apiRequest<Bereich[]>("/bereiche"), apiRequest<Bereich[]>("/bereiche"),
apiRequest<Kategorie[]>("/kategorien"), apiRequest<Kategorie[]>("/kategorien"),
apiRequest<Objekttyp[]>("/objekttypen"), apiRequest<Objekttyp[]>("/objekttypen"),
@@ -139,6 +140,7 @@ export function AdminPage() {
apiRequest<Fach[]>("/faecher"), apiRequest<Fach[]>("/faecher"),
apiRequest<Einheit[]>("/einheiten"), apiRequest<Einheit[]>("/einheiten"),
apiRequest<Qualifikationstyp[]>("/qualifikationstypen"), apiRequest<Qualifikationstyp[]>("/qualifikationstypen"),
apiRequest<Einsatzfunktion[]>("/einsatzfunktionen"),
]); ]);
const wert = <T,>(r: PromiseSettledResult<T[]>): T[] => (r.status === "fulfilled" ? r.value : []); const wert = <T,>(r: PromiseSettledResult<T[]>): T[] => (r.status === "fulfilled" ? r.value : []);
setBereiche(wert(be)); setBereiche(wert(be));
@@ -152,6 +154,7 @@ export function AdminPage() {
setFaecher(wert(f)); setFaecher(wert(f));
setEinheiten(wert(ei)); setEinheiten(wert(ei));
setQualifikationstypen(wert(qt)); setQualifikationstypen(wert(qt));
setEinsatzfunktionen(wert(ef));
} }
useEffect(() => { useEffect(() => {
@@ -238,7 +241,13 @@ export function AdminPage() {
<StandortSection standorte={standorte} onGeaendert={ladeAlles} onFehler={setFehler} /> <StandortSection standorte={standorte} onGeaendert={ladeAlles} onFehler={setFehler} />
)} )}
{aktiverTab === "benutzer" && ( {aktiverTab === "benutzer" && (
<BenutzerSection benutzer={benutzer} einheiten={einheiten} onGeaendert={ladeAlles} onFehler={setFehler} /> <BenutzerSection
benutzer={benutzer}
einheiten={einheiten}
einsatzfunktionen={einsatzfunktionen}
onGeaendert={ladeAlles}
onFehler={setFehler}
/>
)} )}
{aktiverTab === "zustaendigkeit" && ( {aktiverTab === "zustaendigkeit" && (
<ZustaendigkeitSection standorte={standorte} objekte={objekte} benutzer={benutzer} onFehler={setFehler} /> <ZustaendigkeitSection standorte={standorte} objekte={objekte} benutzer={benutzer} onFehler={setFehler} />
+70 -3
View File
@@ -1,11 +1,12 @@
import { useState } from "react"; import { useState } from "react";
import { apiRequest } from "../../api/client"; import { apiRequest } from "../../api/client";
import type { Benutzer, Einheit, Rolle } from "../../api/types"; import type { Benutzer, Einheit, Einsatzfunktion, Rolle } from "../../api/types";
interface Props { interface Props {
benutzer: Benutzer[]; benutzer: Benutzer[];
einheiten: Einheit[]; einheiten: Einheit[];
einsatzfunktionen: Einsatzfunktion[];
onGeaendert: () => Promise<void>; onGeaendert: () => Promise<void>;
onFehler: (text: string) => void; onFehler: (text: string) => void;
} }
@@ -17,18 +18,21 @@ const ALLE_ROLLEN: Rolle[] = [
"administration", "administration",
]; ];
export function BenutzerSection({ benutzer, einheiten, onGeaendert, onFehler }: Props) { export function BenutzerSection({ benutzer, einheiten, einsatzfunktionen, onGeaendert, onFehler }: Props) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [login, setLogin] = useState(""); const [login, setLogin] = useState("");
const [passwort, setPasswort] = useState(""); const [passwort, setPasswort] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [einheitId, setEinheitId] = useState<number | "">(""); const [einheitId, setEinheitId] = useState<number | "">("");
const [einsatzfunktionId, setEinsatzfunktionId] = useState<number | "">("");
const [neueEinsatzfunktion, setNeueEinsatzfunktion] = useState("");
const [rollen, setRollen] = useState<Rolle[]>(["mitarbeiter"]); const [rollen, setRollen] = useState<Rolle[]>(["mitarbeiter"]);
const [wirdAngelegt, setWirdAngelegt] = useState(false); const [wirdAngelegt, setWirdAngelegt] = useState(false);
const [bearbeiteteId, setBearbeiteteId] = useState<number | null>(null); const [bearbeiteteId, setBearbeiteteId] = useState<number | null>(null);
const [bearbeitungsName, setBearbeitungsName] = useState(""); const [bearbeitungsName, setBearbeitungsName] = useState("");
const [bearbeitungsEmail, setBearbeitungsEmail] = useState(""); const [bearbeitungsEmail, setBearbeitungsEmail] = useState("");
const [bearbeitungsEinheitId, setBearbeitungsEinheitId] = useState<number | "">(""); const [bearbeitungsEinheitId, setBearbeitungsEinheitId] = useState<number | "">("");
const [bearbeitungsEinsatzfunktionId, setBearbeitungsEinsatzfunktionId] = useState<number | "">("");
const [bearbeitungsRollen, setBearbeitungsRollen] = useState<Rolle[]>([]); const [bearbeitungsRollen, setBearbeitungsRollen] = useState<Rolle[]>([]);
function einheitName(id: number | null): string { function einheitName(id: number | null): string {
@@ -37,6 +41,12 @@ export function BenutzerSection({ benutzer, einheiten, onGeaendert, onFehler }:
return e ? e.name : `#${id}`; return e ? e.name : `#${id}`;
} }
function einsatzfunktionName(id: number | null): string {
if (id === null) return "";
const f = einsatzfunktionen.find((x) => x.id === id);
return f ? f.name : `#${id}`;
}
function rolleUmschalten(rolle: Rolle) { function rolleUmschalten(rolle: Rolle) {
setRollen((bisherige) => setRollen((bisherige) =>
bisherige.includes(rolle) ? bisherige.filter((r) => r !== rolle) : [...bisherige, rolle] bisherige.includes(rolle) ? bisherige.filter((r) => r !== rolle) : [...bisherige, rolle]
@@ -48,13 +58,22 @@ export function BenutzerSection({ benutzer, einheiten, onGeaendert, onFehler }:
try { try {
await apiRequest("/benutzer", { await apiRequest("/benutzer", {
method: "POST", method: "POST",
body: { name, login, passwort, email: email || null, einheit_id: einheitId === "" ? null : einheitId, rollen }, body: {
name,
login,
passwort,
email: email || null,
einheit_id: einheitId === "" ? null : einheitId,
einsatzfunktion_id: einsatzfunktionId === "" ? null : einsatzfunktionId,
rollen,
},
}); });
setName(""); setName("");
setLogin(""); setLogin("");
setPasswort(""); setPasswort("");
setEmail(""); setEmail("");
setEinheitId(""); setEinheitId("");
setEinsatzfunktionId("");
setRollen(["mitarbeiter"]); setRollen(["mitarbeiter"]);
await onGeaendert(); await onGeaendert();
} catch { } catch {
@@ -64,6 +83,17 @@ export function BenutzerSection({ benutzer, einheiten, onGeaendert, onFehler }:
} }
} }
async function einsatzfunktionAnlegen() {
if (!neueEinsatzfunktion.trim()) return;
try {
await apiRequest("/einsatzfunktionen", { method: "POST", body: { name: neueEinsatzfunktion.trim() } });
setNeueEinsatzfunktion("");
await onGeaendert();
} catch {
onFehler("Einsatzfunktion konnte nicht angelegt werden (existiert evtl. schon).");
}
}
async function aktivSchalten(b: Benutzer) { async function aktivSchalten(b: Benutzer) {
try { try {
await apiRequest(`/benutzer/${b.id}`, { method: "PATCH", body: { aktiv: !b.aktiv } }); await apiRequest(`/benutzer/${b.id}`, { method: "PATCH", body: { aktiv: !b.aktiv } });
@@ -78,6 +108,7 @@ export function BenutzerSection({ benutzer, einheiten, onGeaendert, onFehler }:
setBearbeitungsName(b.name); setBearbeitungsName(b.name);
setBearbeitungsEmail(b.email ?? ""); setBearbeitungsEmail(b.email ?? "");
setBearbeitungsEinheitId(b.einheit_id ?? ""); setBearbeitungsEinheitId(b.einheit_id ?? "");
setBearbeitungsEinsatzfunktionId(b.einsatzfunktion_id ?? "");
setBearbeitungsRollen(b.rollen as Rolle[]); setBearbeitungsRollen(b.rollen as Rolle[]);
} }
@@ -95,6 +126,7 @@ export function BenutzerSection({ benutzer, einheiten, onGeaendert, onFehler }:
name: bearbeitungsName, name: bearbeitungsName,
email: bearbeitungsEmail || null, email: bearbeitungsEmail || null,
einheit_id: bearbeitungsEinheitId === "" ? null : bearbeitungsEinheitId, einheit_id: bearbeitungsEinheitId === "" ? null : bearbeitungsEinheitId,
einsatzfunktion_id: bearbeitungsEinsatzfunktionId === "" ? null : bearbeitungsEinsatzfunktionId,
rollen: bearbeitungsRollen, rollen: bearbeitungsRollen,
}, },
}); });
@@ -133,6 +165,28 @@ export function BenutzerSection({ benutzer, einheiten, onGeaendert, onFehler }:
</option> </option>
))} ))}
</select> </select>
<select
className="input"
value={einsatzfunktionId}
onChange={(e) => setEinsatzfunktionId(Number(e.target.value) || "")}
>
<option value="">Keine Einsatzfunktion</option>
{einsatzfunktionen.map((f) => (
<option key={f.id} value={f.id}>
{f.name}
</option>
))}
</select>
<input
className="input"
value={neueEinsatzfunktion}
onChange={(e) => setNeueEinsatzfunktion(e.target.value)}
placeholder="Neue Einsatzfunktion"
style={{ maxWidth: "10rem" }}
/>
<button className="btn btn-secondary" onClick={einsatzfunktionAnlegen} disabled={!neueEinsatzfunktion.trim()}>
+ Anlegen
</button>
</div> </div>
<div className="row" style={{ flexWrap: "wrap", gap: "0.5rem", marginBottom: "0.75rem" }}> <div className="row" style={{ flexWrap: "wrap", gap: "0.5rem", marginBottom: "0.75rem" }}>
{ALLE_ROLLEN.map((r) => ( {ALLE_ROLLEN.map((r) => (
@@ -181,6 +235,18 @@ export function BenutzerSection({ benutzer, einheiten, onGeaendert, onFehler }:
</option> </option>
))} ))}
</select> </select>
<select
className="input"
value={bearbeitungsEinsatzfunktionId}
onChange={(e) => setBearbeitungsEinsatzfunktionId(Number(e.target.value) || "")}
>
<option value="">Keine Einsatzfunktion</option>
{einsatzfunktionen.map((f) => (
<option key={f.id} value={f.id}>
{f.name}
</option>
))}
</select>
</div> </div>
<div className="row" style={{ flexWrap: "wrap", gap: "0.5rem" }}> <div className="row" style={{ flexWrap: "wrap", gap: "0.5rem" }}>
{ALLE_ROLLEN.map((r) => ( {ALLE_ROLLEN.map((r) => (
@@ -208,6 +274,7 @@ export function BenutzerSection({ benutzer, einheiten, onGeaendert, onFehler }:
<span> <span>
<strong>{b.name}</strong> ({b.login}) {b.rollen.join(", ")} <strong>{b.name}</strong> ({b.login}) {b.rollen.join(", ")}
{b.einheit_id !== null && ` ${einheitName(b.einheit_id)}`} {b.einheit_id !== null && ` ${einheitName(b.einheit_id)}`}
{b.einsatzfunktion_id !== null && ` ${einsatzfunktionName(b.einsatzfunktion_id)}`}
{!b.aktiv && " deaktiviert"} {!b.aktiv && " deaktiviert"}
</span> </span>
<div className="row"> <div className="row">