diff --git a/backend/alembic/versions/0012_personal_qualifikationen.py b/backend/alembic/versions/0012_personal_qualifikationen.py new file mode 100644 index 0000000..fc0656a --- /dev/null +++ b/backend/alembic/versions/0012_personal_qualifikationen.py @@ -0,0 +1,86 @@ +"""Personal/Qualifikationen-Modul (Roadmap-Erweiterung, Phase 1 nach BOS- +Ressourcenplattform-Anforderung): Einheit-Hierarchie, Qualifikationstypen +(Führerschein/Lehrgang/Berechtigung), Benutzer-Qualifikationszuordnung mit +Gültigkeit, sowie welche Qualifikation ein Objekttyp (z.B. Fahrzeug) zum +Bedienen voraussetzt - beantwortet "wer darf dieses Fahrzeug fahren?". + +Revision ID: 0012_personal_qualifikationen +Revises: 0011_objekttyp_zugfahrzeug +Create Date: 2026-09-05 +""" +from typing import Sequence, Union + +from alembic import op + +revision: str = "0012_personal_qualifikationen" +down_revision: Union[str, None] = "0011_objekttyp_zugfahrzeug" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("CREATE TYPE qualifikationskategorie AS ENUM ('fuehrerschein', 'lehrgang', 'berechtigung')") + + op.execute( + """ + CREATE TABLE einheit ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + uebergeordnete_einheit_id INTEGER REFERENCES einheit(id), + standort_id INTEGER REFERENCES standort(id), + CONSTRAINT einheit_kein_selbstbezug CHECK (uebergeordnete_einheit_id <> id) + ) + """ + ) + op.execute("CREATE INDEX idx_einheit_uebergeordnet ON einheit (uebergeordnete_einheit_id)") + + op.execute("ALTER TABLE benutzer ADD COLUMN einheit_id INTEGER REFERENCES einheit(id)") + # Fahrzeuge/Objekte künftig einer Einheit zuordenbar (analog fahrzeug_id-Muster + # aus Migration 0010: additiv, NULL = keiner Einheit zugeordnet). + op.execute("ALTER TABLE objekt ADD COLUMN einheit_id INTEGER REFERENCES einheit(id)") + + op.execute( + """ + CREATE TABLE qualifikationstyp ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + kategorie qualifikationskategorie NOT NULL, + gueltigkeit_monate INTEGER + ) + """ + ) + + op.execute( + """ + CREATE TABLE benutzer_qualifikation ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + benutzer_id INTEGER NOT NULL REFERENCES benutzer(id), + qualifikationstyp_id INTEGER NOT NULL REFERENCES qualifikationstyp(id), + erworben_am DATE NOT NULL, + gueltig_bis DATE, + nachweis_nummer TEXT, + UNIQUE (benutzer_id, qualifikationstyp_id, erworben_am) + ) + """ + ) + op.execute("CREATE INDEX idx_benutzer_qualifikation_benutzer ON benutzer_qualifikation (benutzer_id)") + + op.execute( + """ + CREATE TABLE objekttyp_qualifikationsanforderung ( + objekttyp_id INTEGER NOT NULL REFERENCES objekttyp(id), + qualifikationstyp_id INTEGER NOT NULL REFERENCES qualifikationstyp(id), + PRIMARY KEY (objekttyp_id, qualifikationstyp_id) + ) + """ + ) + + +def downgrade() -> None: + op.execute("DROP TABLE objekttyp_qualifikationsanforderung") + op.execute("DROP TABLE benutzer_qualifikation") + op.execute("DROP TABLE qualifikationstyp") + op.execute("ALTER TABLE objekt DROP COLUMN einheit_id") + op.execute("ALTER TABLE benutzer DROP COLUMN einheit_id") + op.execute("DROP TABLE einheit") + op.execute("DROP TYPE IF EXISTS qualifikationskategorie") diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index 9b71fef..53b4431 100644 --- a/backend/app/api/v1/api.py +++ b/backend/app/api/v1/api.py @@ -11,6 +11,7 @@ from app.api.v1.endpoints import ( historie, kontrollen, objekte, + personal, stammdaten, vorlagen, zustaendigkeit, @@ -30,3 +31,4 @@ api_router.include_router(eskalation.router, tags=["eskalation"]) api_router.include_router(historie.router, tags=["historie"]) api_router.include_router(geraet_instanz.router, tags=["geraet_instanz"]) api_router.include_router(dashboard.router, tags=["dashboard"]) +api_router.include_router(personal.router, tags=["personal"]) diff --git a/backend/app/api/v1/endpoints/benutzer.py b/backend/app/api/v1/endpoints/benutzer.py index f7508ba..ed579b6 100644 --- a/backend/app/api/v1/endpoints/benutzer.py +++ b/backend/app/api/v1/endpoints/benutzer.py @@ -37,6 +37,7 @@ async def erstelle_benutzer( email=payload.email, passwort_hash=hash_password(payload.passwort), aktiv=True, + einheit_id=payload.einheit_id, ) db.add(benutzer) await db.flush() @@ -64,6 +65,11 @@ async def aendere_benutzer( benutzer.email = payload.email if payload.aktiv is not None: benutzer.aktiv = payload.aktiv + if "einheit_id" in payload.model_fields_set: + # explizit gesetzt (auch null = Einheit lösen) unterscheiden von "nicht + # im Request enthalten" - anders als die übrigen Felder hier, da null + # ein gültiger, gewollter Zielzustand ist (Bugfix vor Auslieferung). + benutzer.einheit_id = payload.einheit_id if payload.rollen is not None: result = await db.execute(select(BenutzerRolle).where(BenutzerRolle.benutzer_id == benutzer_id)) for bestehende in result.scalars().all(): diff --git a/backend/app/api/v1/endpoints/personal.py b/backend/app/api/v1/endpoints/personal.py new file mode 100644 index 0000000..0feec58 --- /dev/null +++ b/backend/app/api/v1/endpoints/personal.py @@ -0,0 +1,213 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_user, require_roles +from app.db.session import get_db +from app.models.auth import RolleTyp +from app.models.personal import ( + BenutzerQualifikation, + Einheit, + ObjekttypQualifikationsanforderung, + Qualifikationstyp, +) +from app.schemas.personal import ( + BenutzerQualifikationCreate, + BenutzerQualifikationRead, + BerechtigungspruefungRead, + EinheitCreate, + EinheitRead, + EinheitUpdate, + ObjekttypQualifikationsanforderungCreate, + ObjekttypQualifikationsanforderungRead, + QualifikationstypCreate, + QualifikationstypRead, + QualifikationstypUpdate, +) +from app.services.personal import pruefe_berechtigung + +router = APIRouter() + +_admin_only = require_roles(RolleTyp.administration) + + +# -- Einheit ------------------------------------------------------------------ + +@router.get("/einheiten", response_model=list[EinheitRead]) +async def liste_einheiten(db: AsyncSession = Depends(get_db), _=Depends(get_current_user)) -> list[Einheit]: + result = await db.execute(select(Einheit)) + return list(result.scalars().all()) + + +@router.post("/einheiten", response_model=EinheitRead, status_code=status.HTTP_201_CREATED) +async def erstelle_einheit( + payload: EinheitCreate, db: AsyncSession = Depends(get_db), _=Depends(_admin_only) +) -> Einheit: + einheit = Einheit(**payload.model_dump()) + db.add(einheit) + try: + await db.flush() + except IntegrityError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Ungültige Zuordnung") from exc + return einheit + + +@router.patch("/einheiten/{einheit_id}", response_model=EinheitRead) +async def aendere_einheit( + einheit_id: int, payload: EinheitUpdate, db: AsyncSession = Depends(get_db), _=Depends(_admin_only) +) -> Einheit: + einheit = await db.get(Einheit, einheit_id) + if einheit is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Einheit nicht gefunden") + for feld, wert in payload.model_dump(exclude_unset=True).items(): + setattr(einheit, feld, wert) + try: + await db.flush() + except IntegrityError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Ungültige Zuordnung") from exc + return einheit + + +# -- Qualifikationstyp --------------------------------------------------------- + +@router.get("/qualifikationstypen", response_model=list[QualifikationstypRead]) +async def liste_qualifikationstypen( + db: AsyncSession = Depends(get_db), _=Depends(get_current_user) +) -> list[Qualifikationstyp]: + result = await db.execute(select(Qualifikationstyp)) + return list(result.scalars().all()) + + +@router.post("/qualifikationstypen", response_model=QualifikationstypRead, status_code=status.HTTP_201_CREATED) +async def erstelle_qualifikationstyp( + payload: QualifikationstypCreate, db: AsyncSession = Depends(get_db), _=Depends(_admin_only) +) -> Qualifikationstyp: + qualifikationstyp = Qualifikationstyp(**payload.model_dump()) + db.add(qualifikationstyp) + await db.flush() + return qualifikationstyp + + +@router.patch("/qualifikationstypen/{qualifikationstyp_id}", response_model=QualifikationstypRead) +async def aendere_qualifikationstyp( + qualifikationstyp_id: int, + payload: QualifikationstypUpdate, + db: AsyncSession = Depends(get_db), + _=Depends(_admin_only), +) -> Qualifikationstyp: + qualifikationstyp = await db.get(Qualifikationstyp, qualifikationstyp_id) + if qualifikationstyp is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Qualifikationstyp nicht gefunden") + for feld, wert in payload.model_dump(exclude_unset=True).items(): + setattr(qualifikationstyp, feld, wert) + await db.flush() + return qualifikationstyp + + +# -- Benutzer-Qualifikation ----------------------------------------------------- + +@router.get("/benutzer/{benutzer_id}/qualifikationen", response_model=list[BenutzerQualifikationRead]) +async def liste_benutzer_qualifikationen( + benutzer_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_user) +) -> list[BenutzerQualifikation]: + result = await db.execute( + select(BenutzerQualifikation).where(BenutzerQualifikation.benutzer_id == benutzer_id) + ) + return list(result.scalars().all()) + + +@router.post( + "/benutzer/{benutzer_id}/qualifikationen", + response_model=BenutzerQualifikationRead, + status_code=status.HTTP_201_CREATED, +) +async def erfasse_benutzer_qualifikation( + benutzer_id: int, + payload: BenutzerQualifikationCreate, + db: AsyncSession = Depends(get_db), + _=Depends(_admin_only), +) -> BenutzerQualifikation: + if payload.benutzer_id != benutzer_id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="benutzer_id im Pfad und Body weichen ab") + qualifikation = BenutzerQualifikation(**payload.model_dump()) + db.add(qualifikation) + try: + await db.flush() + except IntegrityError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Qualifikation mit diesem Erwerbsdatum existiert bereits" + ) from exc + return qualifikation + + +# -- Objekttyp-Qualifikationsanforderung ----------------------------------------- + +@router.get( + "/objekttypen/{objekttyp_id}/qualifikationsanforderungen", + response_model=list[ObjekttypQualifikationsanforderungRead], +) +async def liste_qualifikationsanforderungen( + objekttyp_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_user) +) -> list[ObjekttypQualifikationsanforderung]: + result = await db.execute( + select(ObjekttypQualifikationsanforderung).where( + ObjekttypQualifikationsanforderung.objekttyp_id == objekttyp_id + ) + ) + return list(result.scalars().all()) + + +@router.post( + "/objekttypen/{objekttyp_id}/qualifikationsanforderungen", + response_model=ObjekttypQualifikationsanforderungRead, + status_code=status.HTTP_201_CREATED, +) +async def erstelle_qualifikationsanforderung( + objekttyp_id: int, + payload: ObjekttypQualifikationsanforderungCreate, + db: AsyncSession = Depends(get_db), + _=Depends(_admin_only), +) -> ObjekttypQualifikationsanforderung: + if payload.objekttyp_id != objekttyp_id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="objekttyp_id im Pfad und Body weichen ab") + anforderung = ObjekttypQualifikationsanforderung(**payload.model_dump()) + db.add(anforderung) + try: + await db.flush() + except IntegrityError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Anforderung existiert bereits") from exc + return anforderung + + +@router.delete( + "/objekttypen/{objekttyp_id}/qualifikationsanforderungen/{qualifikationstyp_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def loesche_qualifikationsanforderung( + objekttyp_id: int, + qualifikationstyp_id: int, + db: AsyncSession = Depends(get_db), + _=Depends(_admin_only), +) -> None: + anforderung = await db.get( + ObjekttypQualifikationsanforderung, {"objekttyp_id": objekttyp_id, "qualifikationstyp_id": qualifikationstyp_id} + ) + if anforderung is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Anforderung nicht gefunden") + await db.delete(anforderung) + await db.flush() + + +# -- Berechtigungsprüfung ------------------------------------------------------- + +@router.get("/objekte/{objekt_id}/berechtigung/{benutzer_id}", response_model=BerechtigungspruefungRead) +async def pruefe_objekt_berechtigung( + objekt_id: int, benutzer_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_user) +) -> BerechtigungspruefungRead: + """Nutzer-Beispiel Personal-Modul: "wer darf dieses Fahrzeug fahren?".""" + fehlende = await pruefe_berechtigung(db, benutzer_id=benutzer_id, objekt_id=objekt_id) + return BerechtigungspruefungRead( + berechtigt=len(fehlende) == 0, + fehlende_qualifikationstypen=list(fehlende), + ) diff --git a/backend/app/models/auth.py b/backend/app/models/auth.py index 903fded..75c5bdb 100644 --- a/backend/app/models/auth.py +++ b/backend/app/models/auth.py @@ -44,6 +44,7 @@ class Benutzer(Base): passwort_hash: Mapped[str] = mapped_column(String, nullable=False) aktiv: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) 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) rollen: Mapped[list["BenutzerRolle"]] = relationship(back_populates="benutzer", lazy="selectin") diff --git a/backend/app/models/objekt.py b/backend/app/models/objekt.py index 93ce42f..07f0d99 100644 --- a/backend/app/models/objekt.py +++ b/backend/app/models/objekt.py @@ -40,3 +40,6 @@ class Objekt(Base): fahrzeug_id: Mapped[int | None] = mapped_column( ForeignKey("objekt.id", ondelete="SET NULL") ) + # Personal-Modul (Migration 0012): Zuordnung zu einer Organisationseinheit + # (Zug/Gruppe), additiv/optional wie fahrzeug_id. + einheit_id: Mapped[int | None] = mapped_column(ForeignKey("einheit.id")) diff --git a/backend/app/models/personal.py b/backend/app/models/personal.py new file mode 100644 index 0000000..8c7e8ed --- /dev/null +++ b/backend/app/models/personal.py @@ -0,0 +1,86 @@ +import enum +import uuid +from datetime import date + +from sqlalchemy import CheckConstraint, Date, ForeignKey, String, UniqueConstraint +from sqlalchemy.dialects.postgresql import ENUM as PgEnum, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class Qualifikationskategorie(str, enum.Enum): + fuehrerschein = "fuehrerschein" + lehrgang = "lehrgang" + berechtigung = "berechtigung" + + +qualifikationskategorie_pg = PgEnum( + Qualifikationskategorie, name="qualifikationskategorie", create_type=False +) + + +class Einheit(Base): + """Roadmap-Erweiterung (Personal-Modul): Organisationseinheit (Zug/Gruppe), + self-referenzierend für Hierarchie (analog Kategorie.ueberkategorie_id). + Bewusst eigenständig statt an Standort gekoppelt - eine Einheit kann + ortsübergreifend sein, ein Standort mehrere Einheiten beherbergen.""" + + __tablename__ = "einheit" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String, nullable=False) + uebergeordnete_einheit_id: Mapped[int | None] = mapped_column(ForeignKey("einheit.id")) + standort_id: Mapped[int | None] = mapped_column(ForeignKey("standort.id")) + + __table_args__ = ( + CheckConstraint("uebergeordnete_einheit_id <> id", name="einheit_kein_selbstbezug"), + ) + + +class Qualifikationstyp(Base): + """Führerschein/Lehrgang/Berechtigung als ein Stammdaten-Konzept, damit + "welche Qualifikation braucht Objekttyp X" generisch bleibt (kein separates + Führerschein-Feld am Fahrzeug nötig, Karte-Nachtrag Personal-Modul).""" + + __tablename__ = "qualifikationstyp" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String, nullable=False) + kategorie: Mapped[Qualifikationskategorie] = mapped_column( + qualifikationskategorie_pg, nullable=False + ) + # NULL = unbefristet (z.B. manche Berechtigungen), sonst Gültigkeitsdauer in + # Monaten ab erworben_am - Ablauf wird nicht hier, sondern je + # BenutzerQualifikation über gueltig_bis geführt (individuelles Datum je + # Nachweis, nicht rein rechnerisch aus dem Typ abgeleitet). + gueltigkeit_monate: Mapped[int | None] = mapped_column() + + +class BenutzerQualifikation(Base): + __tablename__ = "benutzer_qualifikation" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + benutzer_id: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False) + qualifikationstyp_id: Mapped[int] = mapped_column(ForeignKey("qualifikationstyp.id"), nullable=False) + erworben_am: Mapped[date] = mapped_column(Date, nullable=False) + gueltig_bis: Mapped[date | None] = mapped_column(Date) + nachweis_nummer: Mapped[str | None] = mapped_column(String) + + __table_args__ = ( + UniqueConstraint( + "benutzer_id", "qualifikationstyp_id", "erworben_am", name="uq_benutzer_qualifikation" + ), + ) + + +class ObjekttypQualifikationsanforderung(Base): + """M:N: welche Qualifikationstypen braucht man, um Objekte dieses Objekttyps + zu bedienen/fahren (Nutzer-Beispiel: "wer darf GW-San 01 fahren?").""" + + __tablename__ = "objekttyp_qualifikationsanforderung" + + objekttyp_id: Mapped[int] = mapped_column(ForeignKey("objekttyp.id"), primary_key=True) + qualifikationstyp_id: Mapped[int] = mapped_column( + ForeignKey("qualifikationstyp.id"), primary_key=True + ) diff --git a/backend/app/schemas/benutzer.py b/backend/app/schemas/benutzer.py index 1daa972..b19709d 100644 --- a/backend/app/schemas/benutzer.py +++ b/backend/app/schemas/benutzer.py @@ -9,6 +9,7 @@ class BenutzerCreate(BaseModel): passwort: str email: str | None = None rollen: list[RolleTyp] = [] + einheit_id: int | None = None class BenutzerRead(BaseModel): @@ -19,6 +20,7 @@ class BenutzerRead(BaseModel): email: str | None aktiv: bool rollen: list[str] + einheit_id: int | None @classmethod def from_orm_benutzer(cls, benutzer) -> "BenutzerRead": @@ -29,6 +31,7 @@ class BenutzerRead(BaseModel): email=benutzer.email, aktiv=benutzer.aktiv, rollen=benutzer.rollen_namen, + einheit_id=benutzer.einheit_id, ) @@ -37,3 +40,4 @@ class BenutzerUpdate(BaseModel): email: str | None = None aktiv: bool | None = None rollen: list[RolleTyp] | None = None + einheit_id: int | None = None diff --git a/backend/app/schemas/personal.py b/backend/app/schemas/personal.py new file mode 100644 index 0000000..fb7880b --- /dev/null +++ b/backend/app/schemas/personal.py @@ -0,0 +1,82 @@ +import uuid +from datetime import date + +from pydantic import BaseModel, ConfigDict + +from app.models.personal import Qualifikationskategorie + + +class EinheitCreate(BaseModel): + name: str + uebergeordnete_einheit_id: int | None = None + standort_id: int | None = None + + +class EinheitRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + name: str + uebergeordnete_einheit_id: int | None + standort_id: int | None + + +class EinheitUpdate(BaseModel): + name: str | None = None + uebergeordnete_einheit_id: int | None = None + standort_id: int | None = None + + +class QualifikationstypCreate(BaseModel): + name: str + kategorie: Qualifikationskategorie + gueltigkeit_monate: int | None = None + + +class QualifikationstypRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + name: str + kategorie: Qualifikationskategorie + gueltigkeit_monate: int | None + + +class QualifikationstypUpdate(BaseModel): + name: str | None = None + kategorie: Qualifikationskategorie | None = None + gueltigkeit_monate: int | None = None + + +class BenutzerQualifikationCreate(BaseModel): + benutzer_id: int + qualifikationstyp_id: int + erworben_am: date + gueltig_bis: date | None = None + nachweis_nummer: str | None = None + + +class BenutzerQualifikationRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + benutzer_id: int + qualifikationstyp_id: int + erworben_am: date + gueltig_bis: date | None + nachweis_nummer: str | None + + +class ObjekttypQualifikationsanforderungCreate(BaseModel): + objekttyp_id: int + qualifikationstyp_id: int + + +class ObjekttypQualifikationsanforderungRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + objekttyp_id: int + qualifikationstyp_id: int + + +class BerechtigungspruefungRead(BaseModel): + """Antwort auf "darf Benutzer X Objekt Y bedienen/fahren?" (Nutzer-Beispiel).""" + + berechtigt: bool + fehlende_qualifikationstypen: list[QualifikationstypRead] diff --git a/backend/app/services/personal.py b/backend/app/services/personal.py new file mode 100644 index 0000000..baf83ee --- /dev/null +++ b/backend/app/services/personal.py @@ -0,0 +1,53 @@ +from datetime import date + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.objekt import Objekt +from app.models.personal import ( + BenutzerQualifikation, + ObjekttypQualifikationsanforderung, + Qualifikationstyp, +) + + +async def pruefe_berechtigung(db: AsyncSession, *, benutzer_id: int, objekt_id: int) -> list[Qualifikationstyp]: + """Nutzer-Beispiel Personal-Modul: "wer darf dieses Fahrzeug fahren?". + Liefert die Qualifikationstypen, die der Objekttyp verlangt, aber der + Benutzer nicht (mehr) gültig nachgewiesen hat - leere Liste = berechtigt.""" + objekt = await db.get(Objekt, objekt_id) + if objekt is None: + return [] + + erforderlich = ( + ( + await db.execute( + select(Qualifikationstyp) + .join( + ObjekttypQualifikationsanforderung, + ObjekttypQualifikationsanforderung.qualifikationstyp_id == Qualifikationstyp.id, + ) + .where(ObjekttypQualifikationsanforderung.objekttyp_id == objekt.objekttyp_id) + ) + ) + .scalars() + .all() + ) + if not erforderlich: + return [] + + heute = date.today() + gueltige_ids = set( + ( + await db.execute( + select(BenutzerQualifikation.qualifikationstyp_id).where( + BenutzerQualifikation.benutzer_id == benutzer_id, + (BenutzerQualifikation.gueltig_bis.is_(None)) + | (BenutzerQualifikation.gueltig_bis >= heute), + ) + ) + ) + .scalars() + .all() + ) + return [q for q in erforderlich if q.id not in gueltige_ids] diff --git a/backend/tests/test_personal.py b/backend/tests/test_personal.py new file mode 100644 index 0000000..73228c0 --- /dev/null +++ b/backend/tests/test_personal.py @@ -0,0 +1,139 @@ +from datetime import date, timedelta + +import pytest + +from tests.conftest import auth_header, login + + +@pytest.mark.asyncio +async def test_einheit_anlegen_und_hierarchie(client, admin_user): + token = await login(client, "admin1") + zug = await client.post("/api/v1/einheiten", json={"name": "Sanitätszug 1"}, headers=auth_header(token)) + assert zug.status_code == 201 + zug_id = zug.json()["id"] + + gruppe = await client.post( + "/api/v1/einheiten", + json={"name": "Gruppe 1", "uebergeordnete_einheit_id": zug_id}, + headers=auth_header(token), + ) + assert gruppe.status_code == 201 + assert gruppe.json()["uebergeordnete_einheit_id"] == zug_id + + +@pytest.mark.asyncio +async def test_mitarbeiter_darf_einheit_nicht_anlegen(client, mitarbeiter_user): + token = await login(client, "mitarbeiter1") + response = await client.post("/api/v1/einheiten", json={"name": "X"}, headers=auth_header(token)) + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_qualifikationstyp_anlegen(client, admin_user): + token = await login(client, "admin1") + response = await client.post( + "/api/v1/qualifikationstypen", + json={"name": "Führerschein C1", "kategorie": "fuehrerschein"}, + headers=auth_header(token), + ) + assert response.status_code == 201 + assert response.json()["gueltigkeit_monate"] is None + + +@pytest.mark.asyncio +async def test_wer_darf_fahrzeug_fahren( + client, db_session, objekttyp_factory, standort_factory, admin_user, mitarbeiter_user, hauptserver_id +): + """Nutzer-Beispiel Personal-Modul: Objekttyp verlangt Führerschein C1, + Benutzer ohne gültigen Nachweis darf nicht, mit gültigem Nachweis schon.""" + from app.services.vorlagen import erstelle_vorlage + from app.services.objekte import objekt_aus_vorlage + + objekttyp = await objekttyp_factory("GW-San", ist_zugfahrzeug=True) + standort = await standort_factory("Wache-Personal") + vorlage = await erstelle_vorlage(db_session, objekttyp_id=objekttyp.id, name="GW-San Standard", positionen=[]) + fahrzeug = await objekt_aus_vorlage( + db_session, + code="GW-SAN-01", + name="GW-San 01", + standort_id=standort.id, + vorlage=vorlage, + zustaendiger_server_id=hauptserver_id, + ) + + token = await login(client, "admin1") + quali = await client.post( + "/api/v1/qualifikationstypen", + json={"name": "Führerschein C1", "kategorie": "fuehrerschein"}, + headers=auth_header(token), + ) + quali_id = quali.json()["id"] + + await client.post( + f"/api/v1/objekttypen/{objekttyp.id}/qualifikationsanforderungen", + json={"objekttyp_id": objekttyp.id, "qualifikationstyp_id": quali_id}, + headers=auth_header(token), + ) + + mitarbeiter_id = mitarbeiter_user.id + + # ohne Nachweis: nicht berechtigt + pruefung = await client.get( + f"/api/v1/objekte/{fahrzeug.id}/berechtigung/{mitarbeiter_id}", headers=auth_header(token) + ) + assert pruefung.json()["berechtigt"] is False + assert len(pruefung.json()["fehlende_qualifikationstypen"]) == 1 + + # abgelaufener Nachweis zählt nicht + await client.post( + f"/api/v1/benutzer/{mitarbeiter_id}/qualifikationen", + json={ + "benutzer_id": mitarbeiter_id, + "qualifikationstyp_id": quali_id, + "erworben_am": (date.today() - timedelta(days=1000)).isoformat(), + "gueltig_bis": (date.today() - timedelta(days=1)).isoformat(), + }, + headers=auth_header(token), + ) + abgelaufen = await client.get( + f"/api/v1/objekte/{fahrzeug.id}/berechtigung/{mitarbeiter_id}", headers=auth_header(token) + ) + assert abgelaufen.json()["berechtigt"] is False + + # gültiger Nachweis: berechtigt + await client.post( + f"/api/v1/benutzer/{mitarbeiter_id}/qualifikationen", + json={ + "benutzer_id": mitarbeiter_id, + "qualifikationstyp_id": quali_id, + "erworben_am": date.today().isoformat(), + "gueltig_bis": None, + }, + headers=auth_header(token), + ) + berechtigt = await client.get( + f"/api/v1/objekte/{fahrzeug.id}/berechtigung/{mitarbeiter_id}", headers=auth_header(token) + ) + assert berechtigt.json()["berechtigt"] is True + assert berechtigt.json()["fehlende_qualifikationstypen"] == [] + + +@pytest.mark.asyncio +async def test_benutzer_einheit_zuordnung(client, admin_user): + token = await login(client, "admin1") + einheit = await client.post("/api/v1/einheiten", json={"name": "Zug 2"}, headers=auth_header(token)) + einheit_id = einheit.json()["id"] + + benutzer = await client.post( + "/api/v1/benutzer", + json={"name": "Neuer Helfer", "login": "helfer1", "passwort": "test-passwort-123", "einheit_id": einheit_id}, + headers=auth_header(token), + ) + assert benutzer.status_code == 201 + assert benutzer.json()["einheit_id"] == einheit_id + + benutzer_id = benutzer.json()["id"] + geloest = await client.patch( + f"/api/v1/benutzer/{benutzer_id}", json={"einheit_id": None}, headers=auth_header(token) + ) + assert geloest.json()["einheit_id"] is None diff --git a/frontend/DEVLOG.md b/frontend/DEVLOG.md index c98990b..9860161 100644 --- a/frontend/DEVLOG.md +++ b/frontend/DEVLOG.md @@ -224,3 +224,63 @@ Keine Commits in dieser Session. - frontend/src/pages/LoginPage.tsx | 11 ++++++++++- --- +## 2026-09-05 10:51 – 11:01 (9m) +**Beschreibung:** Claude Code Session +**Projekt:** frontend + +### Commits +- c3f141f feat(objekt): letzte abgeschlossene Kontrolle je Objekt anzeigen + +### Geänderte Dateien +- DEVLOG.md | 202 +++ +- backend/app/api/v1/endpoints/objekte.py | 46 +- +- backend/app/schemas/objekt.py | 3 +- +- backend/app/services/objekte.py | 20 +- +- backend/tests/test_objekte.py | 32 + +- frontend/DEVLOG.md | 33 + +- frontend/package-lock.json | 6264 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- frontend/src/api/types.ts | 1 + +- frontend/src/pages/ObjektListPage.tsx | 5 + +- frontend/src/pages/admin/ObjektSection.tsx | 7 + + +--- +## 2026-09-05 11:04 – 11:04 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** frontend + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 202 +++ +- backend/app/api/v1/endpoints/objekte.py | 46 +- +- backend/app/schemas/objekt.py | 3 +- +- backend/app/services/objekte.py | 20 +- +- backend/tests/test_objekte.py | 32 + +- frontend/DEVLOG.md | 33 + +- frontend/package-lock.json | 6264 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- frontend/src/api/types.ts | 1 + +- frontend/src/pages/ObjektListPage.tsx | 5 + +- frontend/src/pages/admin/ObjektSection.tsx | 7 + + +--- +## 2026-09-05 11:07 – 11:07 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** frontend + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 202 +++ +- backend/app/api/v1/endpoints/objekte.py | 46 +- +- backend/app/schemas/objekt.py | 3 +- +- backend/app/services/objekte.py | 20 +- +- backend/tests/test_objekte.py | 32 + +- frontend/DEVLOG.md | 33 + +- frontend/package-lock.json | 6264 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- frontend/src/api/types.ts | 1 + +- frontend/src/pages/ObjektListPage.tsx | 5 + +- frontend/src/pages/admin/ObjektSection.tsx | 7 + + +--- diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index d6f7422..1d6e75e 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -185,4 +185,30 @@ export interface Benutzer { email: string | null; aktiv: boolean; rollen: string[]; + einheit_id: number | null; +} + +export interface Einheit { + id: number; + name: string; + uebergeordnete_einheit_id: number | null; + standort_id: number | null; +} + +export type Qualifikationskategorie = "fuehrerschein" | "lehrgang" | "berechtigung"; + +export interface Qualifikationstyp { + id: number; + name: string; + kategorie: Qualifikationskategorie; + gueltigkeit_monate: number | null; +} + +export interface BenutzerQualifikation { + id: string; + benutzer_id: number; + qualifikationstyp_id: number; + erworben_am: string; + gueltig_bis: string | null; + nachweis_nummer: string | null; } diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 61ea5b8..9254c70 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -2,9 +2,10 @@ import { useEffect, useState } from "react"; import { apiRequest } from "../api/client"; import { useAuth } from "../auth/AuthContext"; -import type { Benutzer, Bereich, Beladungsvorlage, Fach, Kategorie, Material, Objekt, Objekttyp, Standort } from "../api/types"; +import type { Benutzer, Bereich, Beladungsvorlage, Einheit, Fach, Kategorie, Material, Objekt, Objekttyp, Qualifikationstyp, Standort } from "../api/types"; import { BenutzerSection } from "./admin/BenutzerSection"; import { BereichSection } from "./admin/BereichSection"; +import { EinheitSection } from "./admin/EinheitSection"; import { EskalationSection } from "./admin/EskalationSection"; import { FachSection } from "./admin/FachSection"; import { HistorieSection } from "./admin/HistorieSection"; @@ -13,6 +14,7 @@ import { KontrollverantwortungSection } from "./admin/KontrollverantwortungSecti import { MaterialSection } from "./admin/MaterialSection"; import { ObjektSection } from "./admin/ObjektSection"; import { ObjekttypSection } from "./admin/ObjekttypSection"; +import { QualifikationSection } from "./admin/QualifikationSection"; import { StandortSection } from "./admin/StandortSection"; import { VorlageSection } from "./admin/VorlageSection"; import { ZustaendigkeitSection } from "./admin/ZustaendigkeitSection"; @@ -27,7 +29,8 @@ type Tab = | "historie" | "eskalation" | "kontrollverantwortung" - | "zustaendigkeit"; + | "zustaendigkeit" + | "personal"; // Deckt sich mit den require_roles()-Gates der jeweiligen Backend-Endpunkte // (Rollen-Vorgabe: Material-/Leitungsverantwortliche sahen bisher exakt @@ -52,6 +55,7 @@ const TABS: { key: Tab; label: string; rollen: ("administration" | "materialvera rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"], }, { key: "eskalation", label: "Eskalation", rollen: ["administration"] }, + { key: "personal", label: "Personal", rollen: ["administration"] }, ]; /** @@ -75,6 +79,8 @@ export function AdminPage() { const [materialien, setMaterialien] = useState([]); const [benutzer, setBenutzer] = useState([]); const [faecher, setFaecher] = useState([]); + const [einheiten, setEinheiten] = useState([]); + const [qualifikationstypen, setQualifikationstypen] = useState([]); const [fehler, setFehler] = useState(null); const [laedt, setLaedt] = useState(true); const [tab, setTab] = useState(null); @@ -86,7 +92,7 @@ export function AdminPage() { // Seite als "nicht geladen" zeigen, obwohl deren Tabs (z.B. Kontroll- // verantwortung) trotzdem funktionieren sollen. allSettled + leerer // Fallback statt hartem Fehler. - const [be, ka, ot, s, v, o, m, b, f] = await Promise.allSettled([ + const [be, ka, ot, s, v, o, m, b, f, ei, qt] = await Promise.allSettled([ apiRequest("/bereiche"), apiRequest("/kategorien"), apiRequest("/objekttypen"), @@ -96,6 +102,8 @@ export function AdminPage() { apiRequest("/materialien"), apiRequest("/benutzer"), apiRequest("/faecher"), + apiRequest("/einheiten"), + apiRequest("/qualifikationstypen"), ]); const wert = (r: PromiseSettledResult): T[] => (r.status === "fulfilled" ? r.value : []); setBereiche(wert(be)); @@ -107,6 +115,8 @@ export function AdminPage() { setMaterialien(wert(m)); setBenutzer(wert(b)); setFaecher(wert(f)); + setEinheiten(wert(ei)); + setQualifikationstypen(wert(qt)); } useEffect(() => { @@ -193,7 +203,7 @@ export function AdminPage() { )} {aktiverTab === "benutzer" && ( - + )} {aktiverTab === "zustaendigkeit" && ( @@ -203,6 +213,17 @@ export function AdminPage() { )} {aktiverTab === "historie" && } {aktiverTab === "eskalation" && } + {aktiverTab === "personal" && ( + <> + + + + )} {aktiverTab === null &&

Keine Bereiche für deine Rolle verfügbar.

} ); diff --git a/frontend/src/pages/admin/BenutzerSection.tsx b/frontend/src/pages/admin/BenutzerSection.tsx index 2b0d05d..45d492d 100644 --- a/frontend/src/pages/admin/BenutzerSection.tsx +++ b/frontend/src/pages/admin/BenutzerSection.tsx @@ -1,10 +1,11 @@ import { useState } from "react"; import { apiRequest } from "../../api/client"; -import type { Benutzer, Rolle } from "../../api/types"; +import type { Benutzer, Einheit, Rolle } from "../../api/types"; interface Props { benutzer: Benutzer[]; + einheiten: Einheit[]; onGeaendert: () => Promise; onFehler: (text: string) => void; } @@ -16,18 +17,26 @@ const ALLE_ROLLEN: Rolle[] = [ "administration", ]; -export function BenutzerSection({ benutzer, onGeaendert, onFehler }: Props) { +export function BenutzerSection({ benutzer, einheiten, onGeaendert, onFehler }: Props) { const [name, setName] = useState(""); const [login, setLogin] = useState(""); const [passwort, setPasswort] = useState(""); const [email, setEmail] = useState(""); + const [einheitId, setEinheitId] = useState(""); const [rollen, setRollen] = useState(["mitarbeiter"]); const [wirdAngelegt, setWirdAngelegt] = useState(false); const [bearbeiteteId, setBearbeiteteId] = useState(null); const [bearbeitungsName, setBearbeitungsName] = useState(""); const [bearbeitungsEmail, setBearbeitungsEmail] = useState(""); + const [bearbeitungsEinheitId, setBearbeitungsEinheitId] = useState(""); const [bearbeitungsRollen, setBearbeitungsRollen] = useState([]); + function einheitName(id: number | null): string { + if (id === null) return ""; + const e = einheiten.find((x) => x.id === id); + return e ? e.name : `#${id}`; + } + function rolleUmschalten(rolle: Rolle) { setRollen((bisherige) => bisherige.includes(rolle) ? bisherige.filter((r) => r !== rolle) : [...bisherige, rolle] @@ -39,12 +48,13 @@ export function BenutzerSection({ benutzer, onGeaendert, onFehler }: Props) { try { await apiRequest("/benutzer", { method: "POST", - body: { name, login, passwort, email: email || null, rollen }, + body: { name, login, passwort, email: email || null, einheit_id: einheitId === "" ? null : einheitId, rollen }, }); setName(""); setLogin(""); setPasswort(""); setEmail(""); + setEinheitId(""); setRollen(["mitarbeiter"]); await onGeaendert(); } catch { @@ -67,6 +77,7 @@ export function BenutzerSection({ benutzer, onGeaendert, onFehler }: Props) { setBearbeiteteId(b.id); setBearbeitungsName(b.name); setBearbeitungsEmail(b.email ?? ""); + setBearbeitungsEinheitId(b.einheit_id ?? ""); setBearbeitungsRollen(b.rollen as Rolle[]); } @@ -80,7 +91,12 @@ export function BenutzerSection({ benutzer, onGeaendert, onFehler }: Props) { try { await apiRequest(`/benutzer/${id}`, { method: "PATCH", - body: { name: bearbeitungsName, email: bearbeitungsEmail || null, rollen: bearbeitungsRollen }, + body: { + name: bearbeitungsName, + email: bearbeitungsEmail || null, + einheit_id: bearbeitungsEinheitId === "" ? null : bearbeitungsEinheitId, + rollen: bearbeitungsRollen, + }, }); setBearbeiteteId(null); await onGeaendert(); @@ -109,6 +125,14 @@ export function BenutzerSection({ benutzer, onGeaendert, onFehler }: Props) { onChange={(e) => setEmail(e.target.value)} placeholder="E-Mail (optional)" /> +
{ALLE_ROLLEN.map((r) => ( @@ -145,6 +169,18 @@ export function BenutzerSection({ benutzer, onGeaendert, onFehler }: Props) { onChange={(e) => setBearbeitungsEmail(e.target.value)} placeholder="E-Mail" /> +
{ALLE_ROLLEN.map((r) => ( @@ -171,6 +207,7 @@ export function BenutzerSection({ benutzer, onGeaendert, onFehler }: Props) {
{b.name} ({b.login}) – {b.rollen.join(", ")} + {b.einheit_id !== null && ` – ${einheitName(b.einheit_id)}`} {!b.aktiv && " – deaktiviert"}
diff --git a/frontend/src/pages/admin/EinheitSection.tsx b/frontend/src/pages/admin/EinheitSection.tsx new file mode 100644 index 0000000..b8bba46 --- /dev/null +++ b/frontend/src/pages/admin/EinheitSection.tsx @@ -0,0 +1,136 @@ +import { useState } from "react"; + +import { apiRequest } from "../../api/client"; +import type { Einheit, Standort } from "../../api/types"; + +interface Props { + einheiten: Einheit[]; + standorte: Standort[]; + onGeaendert: () => Promise; + onFehler: (text: string) => void; +} + +// Personal-Modul (Karte-Nachtrag): Organisationseinheit (Zug/Gruppe), +// self-referenzierend für Hierarchie (analog Kategorie/Objekttyp-Muster). +export function EinheitSection({ einheiten, standorte, onGeaendert, onFehler }: Props) { + const [name, setName] = useState(""); + const [uebergeordneteId, setUebergeordneteId] = useState(""); + const [standortId, setStandortId] = useState(""); + const [wirdAngelegt, setWirdAngelegt] = useState(false); + const [bearbeiteteId, setBearbeiteteId] = useState(null); + const [bearbeitungsName, setBearbeitungsName] = useState(""); + + function einheitName(id: number | null): string { + if (id === null) return "–"; + const e = einheiten.find((x) => x.id === id); + return e ? e.name : `#${id}`; + } + + async function anlegen() { + setWirdAngelegt(true); + try { + await apiRequest("/einheiten", { + method: "POST", + body: { + name, + uebergeordnete_einheit_id: uebergeordneteId === "" ? null : uebergeordneteId, + standort_id: standortId === "" ? null : standortId, + }, + }); + setName(""); + setUebergeordneteId(""); + setStandortId(""); + await onGeaendert(); + } catch { + onFehler("Einheit konnte nicht angelegt werden."); + } finally { + setWirdAngelegt(false); + } + } + + function bearbeiten(e: Einheit) { + setBearbeiteteId(e.id); + setBearbeitungsName(e.name); + } + + async function speichern(id: number) { + try { + await apiRequest(`/einheiten/${id}`, { method: "PATCH", body: { name: bearbeitungsName } }); + setBearbeiteteId(null); + await onGeaendert(); + } catch { + onFehler("Einheit konnte nicht geändert werden."); + } + } + + return ( +
+

Einheit anlegen

+
+
+
+ setName(e.target.value)} placeholder="Name (z.B. Sanitätszug 1)" /> + + + +
+
+ {einheiten.length > 0 && ( +
    + {einheiten.map((e) => ( +
  • + {bearbeiteteId === e.id ? ( +
    + setBearbeitungsName(ev.target.value)} + /> + + +
    + ) : ( + <> + + {e.name} + {e.uebergeordnete_einheit_id !== null && ( + – Teil von {einheitName(e.uebergeordnete_einheit_id)} + )} + + + + )} +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/admin/QualifikationSection.tsx b/frontend/src/pages/admin/QualifikationSection.tsx new file mode 100644 index 0000000..8af4ac6 --- /dev/null +++ b/frontend/src/pages/admin/QualifikationSection.tsx @@ -0,0 +1,204 @@ +import { useEffect, useState } from "react"; + +import { apiRequest } from "../../api/client"; +import type { Benutzer, BenutzerQualifikation, Qualifikationskategorie, Qualifikationstyp } from "../../api/types"; + +interface Props { + qualifikationstypen: Qualifikationstyp[]; + benutzer: Benutzer[]; + onGeaendert: () => Promise; + onFehler: (text: string) => void; +} + +const KATEGORIE_LABEL: Record = { + fuehrerschein: "Führerschein", + lehrgang: "Lehrgang", + berechtigung: "Berechtigung", +}; + +// Personal-Modul (Karte-Nachtrag): Qualifikationstypen (Stammdaten) + Zuordnung +// zu einzelnen Benutzern mit Gültigkeit ("wer darf was fahren/bedienen"). +export function QualifikationSection({ qualifikationstypen, benutzer, onGeaendert, onFehler }: Props) { + const [name, setName] = useState(""); + const [kategorie, setKategorie] = useState("lehrgang"); + const [gueltigkeitMonate, setGueltigkeitMonate] = useState(""); + const [wirdAngelegt, setWirdAngelegt] = useState(false); + + const [ausgewaehlterBenutzer, setAusgewaehlterBenutzer] = useState(""); + const [qualifikationen, setQualifikationen] = useState([]); + const [neueQualifikationstypId, setNeueQualifikationstypId] = useState(""); + const [erworbenAm, setErworbenAm] = useState(""); + const [gueltigBis, setGueltigBis] = useState(""); + + async function anlegen() { + setWirdAngelegt(true); + try { + await apiRequest("/qualifikationstypen", { + method: "POST", + body: { name, kategorie, gueltigkeit_monate: gueltigkeitMonate ? Number(gueltigkeitMonate) : null }, + }); + setName(""); + setGueltigkeitMonate(""); + await onGeaendert(); + } catch { + onFehler("Qualifikationstyp konnte nicht angelegt werden."); + } finally { + setWirdAngelegt(false); + } + } + + async function ladeQualifikationen(benutzerId: number) { + try { + const daten = await apiRequest(`/benutzer/${benutzerId}/qualifikationen`); + setQualifikationen(daten); + } catch { + onFehler("Qualifikationen konnten nicht geladen werden."); + } + } + + useEffect(() => { + if (ausgewaehlterBenutzer !== "") { + ladeQualifikationen(ausgewaehlterBenutzer); + } else { + setQualifikationen([]); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ausgewaehlterBenutzer]); + + function qualifikationstypName(id: number): string { + const q = qualifikationstypen.find((x) => x.id === id); + return q ? `${q.name} (${KATEGORIE_LABEL[q.kategorie]})` : `#${id}`; + } + + function istGueltig(q: BenutzerQualifikation): boolean { + return q.gueltig_bis === null || q.gueltig_bis >= new Date().toISOString().slice(0, 10); + } + + async function qualifikationErfassen() { + if (ausgewaehlterBenutzer === "" || neueQualifikationstypId === "" || !erworbenAm) { + onFehler("Benutzer, Qualifikationstyp und Erwerbsdatum angeben."); + return; + } + try { + await apiRequest(`/benutzer/${ausgewaehlterBenutzer}/qualifikationen`, { + method: "POST", + body: { + benutzer_id: ausgewaehlterBenutzer, + qualifikationstyp_id: neueQualifikationstypId, + erworben_am: erworbenAm, + gueltig_bis: gueltigBis || null, + }, + }); + setNeueQualifikationstypId(""); + setErworbenAm(""); + setGueltigBis(""); + await ladeQualifikationen(ausgewaehlterBenutzer); + } catch { + onFehler("Qualifikation konnte nicht erfasst werden."); + } + } + + return ( + <> +
+

Qualifikationstyp anlegen

+
+
+ setName(e.target.value)} placeholder="Name (z.B. Führerschein C1)" /> + + setGueltigkeitMonate(e.target.value)} + placeholder="Gültig (Monate, optional)" + /> + +
+ {qualifikationstypen.length > 0 && ( +
    + {qualifikationstypen.map((q) => ( +
  • + {q.name} ({KATEGORIE_LABEL[q.kategorie]}) +
  • + ))} +
+ )} +
+
+ +
+

Qualifikationen je Benutzer

+
+ + + {ausgewaehlterBenutzer !== "" && ( + <> +
    + {qualifikationen.length === 0 &&
  • Keine Qualifikationen erfasst.
  • } + {qualifikationen.map((q) => ( +
  • + {qualifikationstypName(q.qualifikationstyp_id)} + + seit {q.erworben_am} + {q.gueltig_bis ? `, gültig bis ${q.gueltig_bis}` : ", unbefristet"} + {!istGueltig(q) && " (abgelaufen)"} + +
  • + ))} +
+ +
+ + setErworbenAm(e.target.value)} /> + setGueltigBis(e.target.value)} + placeholder="Gültig bis (optional)" + /> + +
+ + )} +
+
+ + ); +}