Feste Fächer-Liste je Objekttyp (Vorlagen-Positionen: Dropdown + Sonstiges)
Nutzer-Vorgabe: Rucksack-Fächer sollen aus einer festen, je Objekttyp gepflegten Liste gewählt werden können statt jedes Mal Freitext einzutippen. Neue Tabelle fach (Migration 0004), CRUD-Endpoints /faecher, Admin-Tab Struktur bekommt Fach-Verwaltung, Vorlagen-Editor nutzt Dropdown mit "Sonstiges"-Freitext-Fallback. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L85hmKbvX7Cqkq47KnQhFt
This commit is contained in:
@@ -2263,3 +2263,135 @@ Keine Commits in dieser Session.
|
|||||||
- frontend/src/pages/admin/BenutzerSection.tsx | 117 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
- frontend/src/pages/admin/BenutzerSection.tsx | 117 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
|
||||||
---
|
---
|
||||||
|
## 2026-09-04 09:51 – 09:56 (4m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** backend
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- 2f11011 Admin-Portal: Bearbeiten für Bereich/Kategorie/Objekttyp/Standort/Benutzer, Vorlagen-Versionierung und Objekt-Duplizieren im Frontend nachgerüstet
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 212 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- backend/app/api/v1/endpoints/stammdaten.py | 68 +++++++++++++++++++++++
|
||||||
|
- backend/app/schemas/stammdaten.py | 22 ++++++++
|
||||||
|
- backend/tests/test_stammdaten.py | 62 +++++++++++++++++++++
|
||||||
|
- frontend/src/api/types.ts | 9 ++++
|
||||||
|
- frontend/src/pages/admin/BenutzerSection.tsx | 92 ++++++++++++++++++++++++++++---
|
||||||
|
- frontend/src/pages/admin/BereichSection.tsx | 42 ++++++++++++++-
|
||||||
|
- frontend/src/pages/admin/KategorieSection.tsx | 43 ++++++++++++++-
|
||||||
|
- frontend/src/pages/admin/ObjektSection.tsx | 79 ++++++++++++++++++++++++---
|
||||||
|
- frontend/src/pages/admin/ObjekttypSection.tsx | 42 ++++++++++++++-
|
||||||
|
- frontend/src/pages/admin/StandortSection.tsx | 40 +++++++++++++-
|
||||||
|
- frontend/src/pages/admin/VorlageSection.tsx | 99 ++++++++++++++++++++++++++++++++--
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-09-04 09:57 – 09:58 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** asb-material
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 212 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- backend/app/api/v1/endpoints/stammdaten.py | 68 +++++++++++++++++++++++
|
||||||
|
- backend/app/schemas/stammdaten.py | 22 ++++++++
|
||||||
|
- backend/tests/test_stammdaten.py | 62 +++++++++++++++++++++
|
||||||
|
- frontend/src/api/types.ts | 9 ++++
|
||||||
|
- frontend/src/pages/admin/BenutzerSection.tsx | 92 ++++++++++++++++++++++++++++---
|
||||||
|
- frontend/src/pages/admin/BereichSection.tsx | 42 ++++++++++++++-
|
||||||
|
- frontend/src/pages/admin/KategorieSection.tsx | 43 ++++++++++++++-
|
||||||
|
- frontend/src/pages/admin/ObjektSection.tsx | 79 ++++++++++++++++++++++++---
|
||||||
|
- frontend/src/pages/admin/ObjekttypSection.tsx | 42 ++++++++++++++-
|
||||||
|
- frontend/src/pages/admin/StandortSection.tsx | 40 +++++++++++++-
|
||||||
|
- frontend/src/pages/admin/VorlageSection.tsx | 99 ++++++++++++++++++++++++++++++++--
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-09-04 10:14 – 10:14 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** asb-material
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 212 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- backend/app/api/v1/endpoints/stammdaten.py | 68 +++++++++++++++++++++++
|
||||||
|
- backend/app/schemas/stammdaten.py | 22 ++++++++
|
||||||
|
- backend/tests/test_stammdaten.py | 62 +++++++++++++++++++++
|
||||||
|
- frontend/src/api/types.ts | 9 ++++
|
||||||
|
- frontend/src/pages/admin/BenutzerSection.tsx | 92 ++++++++++++++++++++++++++++---
|
||||||
|
- frontend/src/pages/admin/BereichSection.tsx | 42 ++++++++++++++-
|
||||||
|
- frontend/src/pages/admin/KategorieSection.tsx | 43 ++++++++++++++-
|
||||||
|
- frontend/src/pages/admin/ObjektSection.tsx | 79 ++++++++++++++++++++++++---
|
||||||
|
- frontend/src/pages/admin/ObjekttypSection.tsx | 42 ++++++++++++++-
|
||||||
|
- frontend/src/pages/admin/StandortSection.tsx | 40 +++++++++++++-
|
||||||
|
- frontend/src/pages/admin/VorlageSection.tsx | 99 ++++++++++++++++++++++++++++++++--
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-09-04 10:15 – 10:15 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** asb-material
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 212 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- backend/app/api/v1/endpoints/stammdaten.py | 68 +++++++++++++++++++++++
|
||||||
|
- backend/app/schemas/stammdaten.py | 22 ++++++++
|
||||||
|
- backend/tests/test_stammdaten.py | 62 +++++++++++++++++++++
|
||||||
|
- frontend/src/api/types.ts | 9 ++++
|
||||||
|
- frontend/src/pages/admin/BenutzerSection.tsx | 92 ++++++++++++++++++++++++++++---
|
||||||
|
- frontend/src/pages/admin/BereichSection.tsx | 42 ++++++++++++++-
|
||||||
|
- frontend/src/pages/admin/KategorieSection.tsx | 43 ++++++++++++++-
|
||||||
|
- frontend/src/pages/admin/ObjektSection.tsx | 79 ++++++++++++++++++++++++---
|
||||||
|
- frontend/src/pages/admin/ObjekttypSection.tsx | 42 ++++++++++++++-
|
||||||
|
- frontend/src/pages/admin/StandortSection.tsx | 40 +++++++++++++-
|
||||||
|
- frontend/src/pages/admin/VorlageSection.tsx | 99 ++++++++++++++++++++++++++++++++--
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-09-04 10:15 – 10:15 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** asb-material
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 212 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- backend/app/api/v1/endpoints/stammdaten.py | 68 +++++++++++++++++++++++
|
||||||
|
- backend/app/schemas/stammdaten.py | 22 ++++++++
|
||||||
|
- backend/tests/test_stammdaten.py | 62 +++++++++++++++++++++
|
||||||
|
- frontend/src/api/types.ts | 9 ++++
|
||||||
|
- frontend/src/pages/admin/BenutzerSection.tsx | 92 ++++++++++++++++++++++++++++---
|
||||||
|
- frontend/src/pages/admin/BereichSection.tsx | 42 ++++++++++++++-
|
||||||
|
- frontend/src/pages/admin/KategorieSection.tsx | 43 ++++++++++++++-
|
||||||
|
- frontend/src/pages/admin/ObjektSection.tsx | 79 ++++++++++++++++++++++++---
|
||||||
|
- frontend/src/pages/admin/ObjekttypSection.tsx | 42 ++++++++++++++-
|
||||||
|
- frontend/src/pages/admin/StandortSection.tsx | 40 +++++++++++++-
|
||||||
|
- frontend/src/pages/admin/VorlageSection.tsx | 99 ++++++++++++++++++++++++++++++++--
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-09-04 10:17 – 10:17 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** asb-material
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 212 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- backend/app/api/v1/endpoints/stammdaten.py | 68 +++++++++++++++++++++++
|
||||||
|
- backend/app/schemas/stammdaten.py | 22 ++++++++
|
||||||
|
- backend/tests/test_stammdaten.py | 62 +++++++++++++++++++++
|
||||||
|
- frontend/src/api/types.ts | 9 ++++
|
||||||
|
- frontend/src/pages/admin/BenutzerSection.tsx | 92 ++++++++++++++++++++++++++++---
|
||||||
|
- frontend/src/pages/admin/BereichSection.tsx | 42 ++++++++++++++-
|
||||||
|
- frontend/src/pages/admin/KategorieSection.tsx | 43 ++++++++++++++-
|
||||||
|
- frontend/src/pages/admin/ObjektSection.tsx | 79 ++++++++++++++++++++++++---
|
||||||
|
- frontend/src/pages/admin/ObjekttypSection.tsx | 42 ++++++++++++++-
|
||||||
|
- frontend/src/pages/admin/StandortSection.tsx | 40 +++++++++++++-
|
||||||
|
- frontend/src/pages/admin/VorlageSection.tsx | 99 ++++++++++++++++++++++++++++++++--
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""fach-Tabelle: feste Fächer-Liste je Objekttyp
|
||||||
|
|
||||||
|
Revision ID: 0004_add_fach
|
||||||
|
Revises: 0003_add_benutzer_email
|
||||||
|
Create Date: 2026-09-04
|
||||||
|
|
||||||
|
Nutzer-Vorgabe: feste Fächer-Liste pro Objekttyp statt Freitext bei jeder
|
||||||
|
Vorlagenposition. Additive Schema-Erweiterung, bestehende Vorlagenpositionen
|
||||||
|
(freies Fach-Textfeld) bleiben unverändert nutzbar (Prompt-Vorgabe "optional
|
||||||
|
sonstiges für freies Textfeld").
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0004_add_fach"
|
||||||
|
down_revision: Union[str, None] = "0003_add_benutzer_email"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE fach (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
objekttyp_id INTEGER NOT NULL REFERENCES objekttyp(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
sortierung INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP TABLE fach;")
|
||||||
@@ -5,11 +5,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.api.deps import get_current_user, require_roles
|
from app.api.deps import get_current_user, require_roles
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models.auth import RolleTyp
|
from app.models.auth import RolleTyp
|
||||||
from app.models.stammdaten import Bereich, Kategorie, Material, Objekttyp, Standort
|
from app.models.stammdaten import Bereich, Fach, Kategorie, Material, Objekttyp, Standort
|
||||||
from app.schemas.stammdaten import (
|
from app.schemas.stammdaten import (
|
||||||
BereichCreate,
|
BereichCreate,
|
||||||
BereichRead,
|
BereichRead,
|
||||||
BereichUpdate,
|
BereichUpdate,
|
||||||
|
FachCreate,
|
||||||
|
FachRead,
|
||||||
|
FachUpdate,
|
||||||
KategorieCreate,
|
KategorieCreate,
|
||||||
KategorieRead,
|
KategorieRead,
|
||||||
KategorieUpdate,
|
KategorieUpdate,
|
||||||
@@ -179,6 +182,57 @@ async def aendere_objekttyp(
|
|||||||
return objekttyp
|
return objekttyp
|
||||||
|
|
||||||
|
|
||||||
|
# -- Fach (feste Fächer-Liste je Objekttyp) ----------------------------------
|
||||||
|
|
||||||
|
@router.get("/faecher", response_model=list[FachRead])
|
||||||
|
async def liste_faecher(
|
||||||
|
db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
|
||||||
|
) -> list[Fach]:
|
||||||
|
result = await db.execute(select(Fach).order_by(Fach.objekttyp_id, Fach.sortierung, Fach.name))
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/faecher", response_model=FachRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def erstelle_fach(
|
||||||
|
payload: FachCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_=Depends(require_roles(RolleTyp.administration)),
|
||||||
|
) -> Fach:
|
||||||
|
fach = Fach(**payload.model_dump())
|
||||||
|
db.add(fach)
|
||||||
|
await db.flush()
|
||||||
|
return fach
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/faecher/{fach_id}", response_model=FachRead)
|
||||||
|
async def aendere_fach(
|
||||||
|
fach_id: int,
|
||||||
|
payload: FachUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_=Depends(require_roles(RolleTyp.administration)),
|
||||||
|
) -> Fach:
|
||||||
|
fach = await db.get(Fach, fach_id)
|
||||||
|
if fach is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Fach nicht gefunden")
|
||||||
|
for feld, wert in payload.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(fach, feld, wert)
|
||||||
|
await db.flush()
|
||||||
|
return fach
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/faecher/{fach_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def loesche_fach(
|
||||||
|
fach_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_=Depends(require_roles(RolleTyp.administration)),
|
||||||
|
) -> None:
|
||||||
|
fach = await db.get(Fach, fach_id)
|
||||||
|
if fach is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Fach nicht gefunden")
|
||||||
|
await db.delete(fach)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
|
||||||
# -- Material (Prompt 07) --------------------------------------------------
|
# -- Material (Prompt 07) --------------------------------------------------
|
||||||
|
|
||||||
@router.get("/materialien", response_model=list[MaterialRead])
|
@router.get("/materialien", response_model=list[MaterialRead])
|
||||||
|
|||||||
@@ -50,6 +50,19 @@ class Objekttyp(Base):
|
|||||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Fach(Base):
|
||||||
|
"""Nutzer-Vorgabe: feste Fächer-Liste je Objekttyp, damit beim Anlegen einer
|
||||||
|
Vorlagenposition nicht jedes Mal Freitext getippt werden muss. `sortierung`
|
||||||
|
steuert die Anzeigereihenfolge (z. B. von oben nach unten im Rucksack)."""
|
||||||
|
|
||||||
|
__tablename__ = "fach"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
objekttyp_id: Mapped[int] = mapped_column(ForeignKey("objekttyp.id"), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
|
sortierung: Mapped[int] = mapped_column(default=0)
|
||||||
|
|
||||||
|
|
||||||
class Material(Base):
|
class Material(Base):
|
||||||
__tablename__ = "material"
|
__tablename__ = "material"
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,22 @@ class ObjekttypUpdate(BaseModel):
|
|||||||
name: str | None = None
|
name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FachCreate(BaseModel):
|
||||||
|
objekttyp_id: int
|
||||||
|
name: str
|
||||||
|
sortierung: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class FachRead(FachCreate):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
|
||||||
|
|
||||||
|
class FachUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
sortierung: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class MaterialCreate(BaseModel):
|
class MaterialCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
artikelnummer: str | None = None
|
artikelnummer: str | None = None
|
||||||
|
|||||||
@@ -122,3 +122,59 @@ async def test_admin_can_update_objekttyp(client, admin_user):
|
|||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["name"] == "Neu"
|
assert response.json()["name"] == "Neu"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fach_lifecycle(client, admin_user):
|
||||||
|
"""Nutzer-Vorgabe: feste Fächer-Liste je Objekttyp."""
|
||||||
|
token = await login(client, "admin1")
|
||||||
|
bereich = await client.post("/api/v1/bereiche", json={"name": "B"}, headers=auth_header(token))
|
||||||
|
objekttyp = await client.post(
|
||||||
|
"/api/v1/objekttypen",
|
||||||
|
json={"bereich_id": bereich.json()["id"], "name": "Rucksack"},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
objekttyp_id = objekttyp.json()["id"]
|
||||||
|
|
||||||
|
erstellt = await client.post(
|
||||||
|
"/api/v1/faecher",
|
||||||
|
json={"objekttyp_id": objekttyp_id, "name": "Fach 1", "sortierung": 1},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
assert erstellt.status_code == 201
|
||||||
|
fach_id = erstellt.json()["id"]
|
||||||
|
|
||||||
|
liste = await client.get("/api/v1/faecher", headers=auth_header(token))
|
||||||
|
assert any(f["id"] == fach_id for f in liste.json())
|
||||||
|
|
||||||
|
patch_response = await client.patch(
|
||||||
|
f"/api/v1/faecher/{fach_id}", json={"name": "Fach 1 links"}, headers=auth_header(token)
|
||||||
|
)
|
||||||
|
assert patch_response.status_code == 200
|
||||||
|
assert patch_response.json()["name"] == "Fach 1 links"
|
||||||
|
|
||||||
|
delete_response = await client.delete(f"/api/v1/faecher/{fach_id}", headers=auth_header(token))
|
||||||
|
assert delete_response.status_code == 204
|
||||||
|
|
||||||
|
liste_nach_delete = await client.get("/api/v1/faecher", headers=auth_header(token))
|
||||||
|
assert not any(f["id"] == fach_id for f in liste_nach_delete.json())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mitarbeiter_cannot_create_fach(client, mitarbeiter_user, admin_user, db_session):
|
||||||
|
from app.models.stammdaten import Bereich, Objekttyp
|
||||||
|
|
||||||
|
bereich = Bereich(name="B2")
|
||||||
|
db_session.add(bereich)
|
||||||
|
await db_session.flush()
|
||||||
|
objekttyp = Objekttyp(bereich_id=bereich.id, name="Rucksack2")
|
||||||
|
db_session.add(objekttyp)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
token = await login(client, "mitarbeiter1")
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/faecher",
|
||||||
|
json={"objekttyp_id": objekttyp.id, "name": "Fach X"},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|||||||
@@ -98,6 +98,13 @@ export interface Objekttyp {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Fach {
|
||||||
|
id: number;
|
||||||
|
objekttyp_id: number;
|
||||||
|
name: string;
|
||||||
|
sortierung: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Me {
|
export interface Me {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { apiRequest } from "../api/client";
|
import { apiRequest } from "../api/client";
|
||||||
import type { Benutzer, Bereich, Beladungsvorlage, Kategorie, Material, Objekt, Objekttyp, Standort } from "../api/types";
|
import type { Benutzer, Bereich, Beladungsvorlage, Fach, Kategorie, Material, Objekt, Objekttyp, 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 { FachSection } from "./admin/FachSection";
|
||||||
import { KategorieSection } from "./admin/KategorieSection";
|
import { KategorieSection } from "./admin/KategorieSection";
|
||||||
import { MaterialSection } from "./admin/MaterialSection";
|
import { MaterialSection } from "./admin/MaterialSection";
|
||||||
import { ObjektSection } from "./admin/ObjektSection";
|
import { ObjektSection } from "./admin/ObjektSection";
|
||||||
@@ -40,12 +41,13 @@ export function AdminPage() {
|
|||||||
const [objekte, setObjekte] = useState<Objekt[]>([]);
|
const [objekte, setObjekte] = useState<Objekt[]>([]);
|
||||||
const [materialien, setMaterialien] = useState<Material[]>([]);
|
const [materialien, setMaterialien] = useState<Material[]>([]);
|
||||||
const [benutzer, setBenutzer] = useState<Benutzer[]>([]);
|
const [benutzer, setBenutzer] = useState<Benutzer[]>([]);
|
||||||
|
const [faecher, setFaecher] = useState<Fach[]>([]);
|
||||||
const [fehler, setFehler] = useState<string | null>(null);
|
const [fehler, setFehler] = useState<string | null>(null);
|
||||||
const [laedt, setLaedt] = useState(true);
|
const [laedt, setLaedt] = useState(true);
|
||||||
const [tab, setTab] = useState<Tab>("objekte");
|
const [tab, setTab] = useState<Tab>("objekte");
|
||||||
|
|
||||||
async function ladeAlles() {
|
async function ladeAlles() {
|
||||||
const [be, ka, ot, s, v, o, m, b] = await Promise.all([
|
const [be, ka, ot, s, v, o, m, b, f] = await Promise.all([
|
||||||
apiRequest<Bereich[]>("/bereiche"),
|
apiRequest<Bereich[]>("/bereiche"),
|
||||||
apiRequest<Kategorie[]>("/kategorien"),
|
apiRequest<Kategorie[]>("/kategorien"),
|
||||||
apiRequest<Objekttyp[]>("/objekttypen"),
|
apiRequest<Objekttyp[]>("/objekttypen"),
|
||||||
@@ -54,6 +56,7 @@ export function AdminPage() {
|
|||||||
apiRequest<Objekt[]>("/objekte"),
|
apiRequest<Objekt[]>("/objekte"),
|
||||||
apiRequest<Material[]>("/materialien"),
|
apiRequest<Material[]>("/materialien"),
|
||||||
apiRequest<Benutzer[]>("/benutzer"),
|
apiRequest<Benutzer[]>("/benutzer"),
|
||||||
|
apiRequest<Fach[]>("/faecher"),
|
||||||
]);
|
]);
|
||||||
setBereiche(be);
|
setBereiche(be);
|
||||||
setKategorien(ka);
|
setKategorien(ka);
|
||||||
@@ -63,6 +66,7 @@ export function AdminPage() {
|
|||||||
setObjekte(o);
|
setObjekte(o);
|
||||||
setMaterialien(m);
|
setMaterialien(m);
|
||||||
setBenutzer(b);
|
setBenutzer(b);
|
||||||
|
setFaecher(f);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -111,6 +115,7 @@ export function AdminPage() {
|
|||||||
vorlagen={vorlagen}
|
vorlagen={vorlagen}
|
||||||
objekttypen={objekttypen}
|
objekttypen={objekttypen}
|
||||||
materialien={materialien}
|
materialien={materialien}
|
||||||
|
faecher={faecher}
|
||||||
onGeaendert={ladeAlles}
|
onGeaendert={ladeAlles}
|
||||||
onFehler={setFehler}
|
onFehler={setFehler}
|
||||||
/>
|
/>
|
||||||
@@ -134,6 +139,12 @@ export function AdminPage() {
|
|||||||
onGeaendert={ladeAlles}
|
onGeaendert={ladeAlles}
|
||||||
onFehler={setFehler}
|
onFehler={setFehler}
|
||||||
/>
|
/>
|
||||||
|
<FachSection
|
||||||
|
objekttypen={objekttypen}
|
||||||
|
faecher={faecher}
|
||||||
|
onGeaendert={ladeAlles}
|
||||||
|
onFehler={setFehler}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{tab === "standorte" && (
|
{tab === "standorte" && (
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import type { Fach } from "../../api/types";
|
||||||
|
|
||||||
|
const SONSTIGES = "__sonstiges__";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
faecher: Fach[];
|
||||||
|
wert: string;
|
||||||
|
onAendern: (wert: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dropdown mit fester Fächer-Liste (je Objekttyp) + "Sonstiges" für Freitext,
|
||||||
|
* damit Vorlagen-Positionen nicht immer per Hand getippt werden müssen. */
|
||||||
|
export function FachAuswahl({ faecher, wert, onAendern }: Props) {
|
||||||
|
const istBekanntesFach = faecher.some((f) => f.name === wert);
|
||||||
|
const [sonstigesAktiv, setSonstigesAktiv] = useState(wert !== "" && !istBekanntesFach);
|
||||||
|
|
||||||
|
if (sonstigesAktiv) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
style={{ width: "8rem" }}
|
||||||
|
value={wert}
|
||||||
|
onChange={(e) => onAendern(e.target.value)}
|
||||||
|
placeholder="Fach (frei)"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
style={{ width: "8rem" }}
|
||||||
|
value={wert}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value === SONSTIGES) {
|
||||||
|
setSonstigesAktiv(true);
|
||||||
|
onAendern("");
|
||||||
|
} else {
|
||||||
|
onAendern(e.target.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Fach…</option>
|
||||||
|
{faecher.map((f) => (
|
||||||
|
<option key={f.id} value={f.name}>
|
||||||
|
{f.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value={SONSTIGES}>Sonstiges…</option>
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { apiRequest } from "../../api/client";
|
||||||
|
import type { Fach, Objekttyp } from "../../api/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
objekttypen: Objekttyp[];
|
||||||
|
faecher: Fach[];
|
||||||
|
onGeaendert: () => Promise<void>;
|
||||||
|
onFehler: (text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nutzer-Vorgabe: feste Fächer-Liste je Objekttyp (z. B. Rucksack-Fächer),
|
||||||
|
* damit bei Vorlagen-Positionen aus einer Liste gewählt werden kann statt
|
||||||
|
* jedes Mal Freitext einzutippen (Sonstiges bleibt dort als Freitext erhalten). */
|
||||||
|
export function FachSection({ objekttypen, faecher, onGeaendert, onFehler }: Props) {
|
||||||
|
const [objekttypId, setObjekttypId] = useState<number | "">("");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [wirdAngelegt, setWirdAngelegt] = useState(false);
|
||||||
|
|
||||||
|
async function anlegen() {
|
||||||
|
if (objekttypId === "") {
|
||||||
|
onFehler("Objekttyp auswählen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWirdAngelegt(true);
|
||||||
|
try {
|
||||||
|
await apiRequest("/faecher", { method: "POST", body: { objekttyp_id: objekttypId, name } });
|
||||||
|
setName("");
|
||||||
|
await onGeaendert();
|
||||||
|
} catch {
|
||||||
|
onFehler("Fach konnte nicht angelegt werden.");
|
||||||
|
} finally {
|
||||||
|
setWirdAngelegt(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loeschen(id: number) {
|
||||||
|
try {
|
||||||
|
await apiRequest(`/faecher/${id}`, { method: "DELETE" });
|
||||||
|
await onGeaendert();
|
||||||
|
} catch {
|
||||||
|
onFehler("Fach konnte nicht gelöscht werden.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function objekttypName(id: number) {
|
||||||
|
return objekttypen.find((o) => o.id === id)?.name ?? `#${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<h2>Fächer je Objekttyp</h2>
|
||||||
|
<div className="card">
|
||||||
|
{objekttypen.length === 0 ? (
|
||||||
|
<p className="text-muted">Erst einen Objekttyp anlegen.</p>
|
||||||
|
) : (
|
||||||
|
<div className="row" style={{ marginBottom: faecher.length ? "1rem" : 0 }}>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={objekttypId}
|
||||||
|
onChange={(e) => setObjekttypId(Number(e.target.value) || "")}
|
||||||
|
>
|
||||||
|
<option value="">Objekttyp wählen…</option>
|
||||||
|
{objekttypen.map((o) => (
|
||||||
|
<option key={o.id} value={o.id}>
|
||||||
|
{o.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Fach-Name (z. B. Fach 1)"
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary" onClick={anlegen} disabled={!name || wirdAngelegt}>
|
||||||
|
Anlegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{faecher.length > 0 && (
|
||||||
|
<ul className="card-list">
|
||||||
|
{faecher.map((f) => (
|
||||||
|
<li key={f.id} className="row-between" style={{ padding: "0.4rem 0" }}>
|
||||||
|
<span>
|
||||||
|
{f.name} <span className="text-muted">({objekttypName(f.objekttyp_id)})</span>
|
||||||
|
</span>
|
||||||
|
<button className="btn btn-danger" onClick={() => loeschen(f.id)}>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { apiRequest } from "../../api/client";
|
import { apiRequest } from "../../api/client";
|
||||||
import type { Beladungsvorlage, Material, Objekttyp } from "../../api/types";
|
import type { Beladungsvorlage, Fach, Material, Objekttyp } from "../../api/types";
|
||||||
|
import { FachAuswahl } from "./FachAuswahl";
|
||||||
|
|
||||||
interface PositionZeile {
|
interface PositionZeile {
|
||||||
materialId: number | "";
|
materialId: number | "";
|
||||||
@@ -15,11 +16,12 @@ interface Props {
|
|||||||
vorlagen: Beladungsvorlage[];
|
vorlagen: Beladungsvorlage[];
|
||||||
objekttypen: Objekttyp[];
|
objekttypen: Objekttyp[];
|
||||||
materialien: Material[];
|
materialien: Material[];
|
||||||
|
faecher: Fach[];
|
||||||
onGeaendert: () => Promise<void>;
|
onGeaendert: () => Promise<void>;
|
||||||
onFehler: (text: string) => void;
|
onFehler: (text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VorlageSection({ vorlagen, objekttypen, materialien, onGeaendert, onFehler }: Props) {
|
export function VorlageSection({ vorlagen, objekttypen, materialien, faecher, onGeaendert, onFehler }: Props) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [objekttypId, setObjekttypId] = useState<number | "">("");
|
const [objekttypId, setObjekttypId] = useState<number | "">("");
|
||||||
const [zeilen, setZeilen] = useState<PositionZeile[]>([LEERE_ZEILE]);
|
const [zeilen, setZeilen] = useState<PositionZeile[]>([LEERE_ZEILE]);
|
||||||
@@ -129,12 +131,10 @@ export function VorlageSection({ vorlagen, objekttypen, materialien, onGeaendert
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<input
|
<FachAuswahl
|
||||||
className="input"
|
faecher={faecher.filter((f) => f.objekttyp_id === objekttypId)}
|
||||||
style={{ width: "8rem" }}
|
wert={zeile.fach}
|
||||||
value={zeile.fach}
|
onAendern={(wert) => zeileAendern(i, { fach: wert })}
|
||||||
onChange={(e) => zeileAendern(i, { fach: e.target.value })}
|
|
||||||
placeholder="Fach"
|
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
@@ -189,12 +189,10 @@ export function VorlageSection({ vorlagen, objekttypen, materialien, onGeaendert
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<input
|
<FachAuswahl
|
||||||
className="input"
|
faecher={faecher.filter((f) => f.objekttyp_id === v.objekttyp_id)}
|
||||||
style={{ width: "8rem" }}
|
wert={zeile.fach}
|
||||||
value={zeile.fach}
|
onAendern={(wert) => neueVersionZeileAendern(i, { fach: wert })}
|
||||||
onChange={(e) => neueVersionZeileAendern(i, { fach: e.target.value })}
|
|
||||||
placeholder="Fach"
|
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
|
|||||||
Reference in New Issue
Block a user