feat(identity): IDENT-003 konfigurierbare Nummernschemata je Objekttyp
CI / backend-tests (push) Successful in 2m24s
CI / frontend-build (push) Successful in 27s

Neue Stammdaten-Tabelle nummernschema (Migration 0032): Präfix + Stellenzahl
einmal je Objekttyp festlegen statt bei jeder Objekt-Anlage manuell
einzutippen (Fehlerquelle - Tippfehler, uneinheitliche Schreibweise,
falsches Präfix für den Typ). GET /objekte/naechster-code akzeptiert jetzt
objekttyp_id als Alternative zu praefix; ohne konfiguriertes Schema bleibt
der bisherige manuelle Präfix-Weg nutzbar (Rückwärtskompatibilität).

Admin-UI: Nummernschema-Pflege direkt in der Objekttyp-Verwaltung
(ObjekttypSection). ObjektAnlegenFormular versucht zuerst das Schema des
gewählten Objekttyps, fällt bei 404 auf manuelles Präfix zurück.

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:23:14 +02:00
co-authored by Claude Sonnet 5
parent 15f783fb5e
commit 3b5cf8feab
12 changed files with 380 additions and 53 deletions
+1 -1
View File
@@ -135,7 +135,7 @@ vergeben statt geraten.
| FOUND-012 | Foundation | Dokumentationsgerüst | P1 | XS | LOW | FOUND-001 | ✅ (`README.md` im Projektwurzelverzeichnis) | | FOUND-012 | Foundation | Dokumentationsgerüst | P1 | XS | LOW | FOUND-001 | ✅ (`README.md` im Projektwurzelverzeichnis) |
| IDENT-001 | Identity | Objekt-ID-Schema | P0 | S | LOW | FOUND-002 | ✅ (Objekt-Code-Vergabe, `/objekte/naechster-code`) | | IDENT-001 | Identity | Objekt-ID-Schema | P0 | S | LOW | FOUND-002 | ✅ (Objekt-Code-Vergabe, `/objekte/naechster-code`) |
| IDENT-002 | Identity | Inventarnummer manuell | P0 | S | LOW | IDENT-001 | ✅ (Objektposition-Code, `objektposition.py`) | | IDENT-002 | Identity | Inventarnummer manuell | P0 | S | LOW | IDENT-001 | ✅ (Objektposition-Code, `objektposition.py`) |
| IDENT-003 | Identity | Inventarnummer-Nummernschemata | P1 | M | MEDIUM | IDENT-002 | (kein konfigurierbares Schema gefunden, nur fester Code-Zähler) | | IDENT-003 | Identity | Inventarnummer-Nummernschemata | P1 | M | MEDIUM | IDENT-002 | (`Nummernschema` je Objekttyp, Migration 0032, `/objekte/naechster-code?objekttyp_id=` nutzt es, Admin-UI in ObjekttypSection) |
| IDENT-004 | Identity | Seriennummer + Eindeutigkeitsprüfung | P0 | S | LOW | IDENT-001 | ✅ (`geraet_instanz.py` Seriennummer-Feld/Unique) | | IDENT-004 | Identity | Seriennummer + Eindeutigkeitsprüfung | P0 | S | LOW | IDENT-001 | ✅ (`geraet_instanz.py` Seriennummer-Feld/Unique) |
| IDENT-005 | Identity | Hersteller/Modell-Stammdaten | P1 | S | LOW | IDENT-004 | ✅ (`Hersteller`/`Modell`-Tabellen, Migration 0030, `geraet_instanz.modell_id` optional; kein eigenes Admin-UI, nur API) | | IDENT-005 | Identity | Hersteller/Modell-Stammdaten | P1 | S | LOW | IDENT-004 | ✅ (`Hersteller`/`Modell`-Tabellen, Migration 0030, `geraet_instanz.modell_id` optional; kein eigenes Admin-UI, nur API) |
| IDENT-006 | Identity | QR-Code-Erzeugung | P0 | S | LOW | IDENT-001 | ✅ (`label.pdf`-Endpunkte für Objekt/Objektposition) | | IDENT-006 | Identity | QR-Code-Erzeugung | P0 | S | LOW | IDENT-001 | ✅ (`label.pdf`-Endpunkte für Objekt/Objektposition) |
@@ -0,0 +1,31 @@
"""IDENT-003: Nummernschema je Objekttyp (Präfix + Stellenzahl konfigurierbar).
Revision ID: 0032_nummernschema
Revises: 0031_einsatzfunktion
Create Date: 2026-09-08
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0032_nummernschema"
down_revision: Union[str, None] = "0031_einsatzfunktion"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"""
CREATE TABLE nummernschema (
id SERIAL PRIMARY KEY,
objekttyp_id INTEGER NOT NULL UNIQUE REFERENCES objekttyp(id),
praefix TEXT NOT NULL,
stellenzahl INTEGER NOT NULL DEFAULT 5
)
"""
)
def downgrade() -> None:
op.execute("DROP TABLE nummernschema")
+25 -4
View File
@@ -12,7 +12,7 @@ from app.models.auth import KnotenTyp, RolleTyp, Systemknoten
from app.models.fahrzeugdetails import Fahrzeugdetails from app.models.fahrzeugdetails import Fahrzeugdetails
from app.models.objekt import Objekt from app.models.objekt import Objekt
from app.models.objektposition import Objektposition from app.models.objektposition import Objektposition
from app.models.stammdaten import Material, Objekttyp from app.models.stammdaten import Material, Nummernschema, Objekttyp
from app.models.vorlage import Beladungsvorlage from app.models.vorlage import Beladungsvorlage
from app.services import historie as historie_service from app.services import historie as historie_service
from app.services.label import generiere_code128_label_pdf, generiere_qr_label_pdf from app.services.label import generiere_code128_label_pdf, generiere_qr_label_pdf
@@ -105,11 +105,32 @@ async def hole_objekt_per_code(
@router.get("/objekte/naechster-code") @router.get("/objekte/naechster-code")
async def naechster_objekt_code( async def naechster_objekt_code(
praefix: str, db: AsyncSession = Depends(get_db), _=Depends(get_current_user) praefix: str | None = None,
objekttyp_id: int | None = None,
db: AsyncSession = Depends(get_db),
_=Depends(get_current_user),
) -> dict[str, str]: ) -> dict[str, str]:
"""Code-Vorschlag beim Anlegen/Duplizieren (Konzept-Ergänzung Karte 10/Prompt 09). """Code-Vorschlag beim Anlegen/Duplizieren (Konzept-Ergänzung Karte 10/Prompt 09).
Vorschlag ist überschreibbar, Eindeutigkeit erzwingt weiterhin objekt.code UNIQUE.""" Vorschlag ist überschreibbar, Eindeutigkeit erzwingt weiterhin objekt.code UNIQUE.
return {"code": await naechster_freier_code(db, Objekt.code, praefix)}
IDENT-003: `objekttyp_id` statt `praefix` nutzt das konfigurierte
Nummernschema (Präfix + Mindest-Stellenzahl) - kein manuelles Eintippen/
Merken des richtigen Präfix je Objekttyp mehr nötig. `praefix` bleibt für
Objekttypen ohne konfiguriertes Schema nutzbar (Rückwärtskompatibilität)."""
stellenzahl = 5
if objekttyp_id is not None:
result = await db.execute(select(Nummernschema).where(Nummernschema.objekttyp_id == objekttyp_id))
schema = result.scalar_one_or_none()
if schema is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Kein Nummernschema für diesen Objekttyp konfiguriert - Präfix manuell angeben",
)
praefix = schema.praefix
stellenzahl = schema.stellenzahl
if not praefix:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Präfix oder Objekttyp angeben")
return {"code": await naechster_freier_code(db, Objekt.code, praefix, stellenzahl)}
@router.get("/objektpositionen/naechster-code") @router.get("/objektpositionen/naechster-code")
+51 -1
View File
@@ -6,7 +6,7 @@ from app.api.deps import get_current_user, require_roles, require_roles_or_permi
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.objekt import Objekt from app.models.objekt import Objekt
from app.models.stammdaten import Bereich, Fach, Hersteller, Kategorie, Material, Modell, Objekttyp, Standort from app.models.stammdaten import Bereich, Fach, Hersteller, Kategorie, Material, Modell, Nummernschema, Objekttyp, Standort
from app.models.vorlage import Beladungsvorlage, Vorlagenposition from app.models.vorlage import Beladungsvorlage, Vorlagenposition
from app.services.material_merge import finde_konflikte, fuehre_zusammen, hole_verwendung from app.services.material_merge import finde_konflikte, fuehre_zusammen, hole_verwendung
from app.schemas.stammdaten import ( from app.schemas.stammdaten import (
@@ -25,6 +25,9 @@ from app.schemas.stammdaten import (
KategorieUpdate, KategorieUpdate,
ModellCreate, ModellCreate,
ModellRead, ModellRead,
NummernschemaCreate,
NummernschemaRead,
NummernschemaUpdate,
MaterialCreate, MaterialCreate,
MaterialRead, MaterialRead,
MaterialUpdate, MaterialUpdate,
@@ -141,6 +144,53 @@ async def erstelle_modell(
return modell return modell
# -- Nummernschema (IDENT-003) ---------------------------------------------------
@router.get("/nummernschemata", response_model=list[NummernschemaRead])
async def liste_nummernschemata(
db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
) -> list[Nummernschema]:
result = await db.execute(select(Nummernschema))
return list(result.scalars().all())
@router.post("/nummernschemata", response_model=NummernschemaRead, status_code=status.HTTP_201_CREATED)
async def erstelle_nummernschema(
payload: NummernschemaCreate,
db: AsyncSession = Depends(get_db),
_=Depends(require_roles(RolleTyp.administration)),
) -> Nummernschema:
if await db.get(Objekttyp, payload.objekttyp_id) is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekttyp nicht gefunden")
bestehendes = await db.execute(
select(Nummernschema).where(Nummernschema.objekttyp_id == payload.objekttyp_id)
)
if bestehendes.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Für diesen Objekttyp existiert bereits ein Nummernschema"
)
schema = Nummernschema(**payload.model_dump())
db.add(schema)
await db.flush()
return schema
@router.patch("/nummernschemata/{schema_id}", response_model=NummernschemaRead)
async def aendere_nummernschema(
schema_id: int,
payload: NummernschemaUpdate,
db: AsyncSession = Depends(get_db),
_=Depends(require_roles(RolleTyp.administration)),
) -> Nummernschema:
schema = await db.get(Nummernschema, schema_id)
if schema is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Nummernschema nicht gefunden")
for feld, wert in payload.model_dump(exclude_unset=True).items():
setattr(schema, feld, wert)
await db.flush()
return schema
# -- Kategorie ------------------------------------------------------------------ # -- Kategorie ------------------------------------------------------------------
@router.get("/kategorien", response_model=list[KategorieRead]) @router.get("/kategorien", response_model=list[KategorieRead])
+19 -1
View File
@@ -1,6 +1,6 @@
import enum import enum
from sqlalchemy import Boolean, ForeignKey, String, Text, UniqueConstraint from sqlalchemy import Boolean, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import ENUM as PgEnum from sqlalchemy.dialects.postgresql import ENUM as PgEnum
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -54,6 +54,24 @@ class Objekttyp(Base):
ist_zugfahrzeug: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) ist_zugfahrzeug: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
class Nummernschema(Base):
"""IDENT-003: statt bei jeder Objekt-Anlage das Präfix manuell einzutippen
(Fehlerquelle - Tippfehler, uneinheitliche Groß-/Kleinschreibung, falsches
Präfix für den Objekttyp) legt der Admin je Objekttyp einmal Präfix +
Stellenzahl fest. `naechster_freier_code` schlägt darauf aufbauend den
nächsten Code vor (PRAEFIX-NNN...), wie bisher, nur nicht mehr frei
eingebbar. Ein Objekttyp OHNE Schema kann weiterhin mit explizitem Präfix
angelegt werden (Rückwärtskompatibilität, kein Zwang zur Migration
bestehender Objekte)."""
__tablename__ = "nummernschema"
id: Mapped[int] = mapped_column(primary_key=True)
objekttyp_id: Mapped[int] = mapped_column(ForeignKey("objekttyp.id"), unique=True, nullable=False)
praefix: Mapped[str] = mapped_column(String, nullable=False)
stellenzahl: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
class Fach(Base): class Fach(Base):
"""Nutzer-Vorgabe: feste Fächer-Liste je Objekttyp, damit beim Anlegen einer """Nutzer-Vorgabe: feste Fächer-Liste je Objekttyp, damit beim Anlegen einer
Vorlagenposition nicht jedes Mal Freitext getippt werden muss. `sortierung` Vorlagenposition nicht jedes Mal Freitext getippt werden muss. `sortierung`
+16
View File
@@ -54,6 +54,22 @@ class ModellRead(ModellCreate):
id: int id: int
class NummernschemaCreate(BaseModel):
objekttyp_id: int
praefix: str
stellenzahl: int = 5
class NummernschemaRead(NummernschemaCreate):
model_config = ConfigDict(from_attributes=True)
id: int
class NummernschemaUpdate(BaseModel):
praefix: str | None = None
stellenzahl: int | None = None
class StandortCreate(BaseModel): class StandortCreate(BaseModel):
name: str name: str
adresse: str | None = None adresse: str | None = None
+6 -4
View File
@@ -13,14 +13,16 @@ from app.models.vorlage import Beladungsvorlage, Vorlagenposition
_CODE_MUSTER = re.compile(r"^-(\d+)$") _CODE_MUSTER = re.compile(r"^-(\d+)$")
async def naechster_freier_code(db: AsyncSession, spalte, praefix: str) -> str: async def naechster_freier_code(db: AsyncSession, spalte, praefix: str, mindestbreite: int = 5) -> str:
"""Konzept-Ergänzung Karte 10/Prompt 09: Code-Vorschlag PRAEFIX-NNN mit """Konzept-Ergänzung Karte 10/Prompt 09: Code-Vorschlag PRAEFIX-NNN mit
fortlaufender Nummer je Präfix. Zahlenbreite folgt der bisher größten fortlaufender Nummer je Präfix. Zahlenbreite folgt der bisher größten
vergebenen Nummer (mind. 5 Stellen bei Erstvergabe), damit die Sortierung im vergebenen Nummer (mindestens `mindestbreite` bei Erstvergabe - IDENT-003:
Regal stabil bleibt, solange nicht mehr als 10^Breite Objekte existieren.""" kommt aus dem konfigurierten Nummernschema, falls vorhanden, sonst 5 wie
bisher), damit die Sortierung im Regal stabil bleibt, solange nicht mehr
als 10^Breite Objekte existieren."""
result = await db.execute(select(spalte).where(spalte.like(f"{praefix}-%"))) result = await db.execute(select(spalte).where(spalte.like(f"{praefix}-%")))
hoechste = 0 hoechste = 0
breite = 5 breite = mindestbreite
for (code,) in result.all(): for (code,) in result.all():
treffer = _CODE_MUSTER.match(code[len(praefix) :]) treffer = _CODE_MUSTER.match(code[len(praefix) :])
if treffer is None: if treffer is None:
+65
View File
@@ -262,6 +262,71 @@ async def test_naechster_objekt_code_zaehlt_hoch(
assert response.json()["code"] == "RK-00018" assert response.json()["code"] == "RK-00018"
@pytest.mark.asyncio
async def test_nummernschema_liefert_code_ohne_manuelles_praefix(
client, objekttyp_factory, admin_user
):
"""IDENT-003: Präfix + Stellenzahl kommen aus dem konfigurierten Schema,
nicht mehr manuell eingegeben."""
objekttyp = await objekttyp_factory("Typ-Nummernschema")
token = await login(client, "admin1")
schema = await client.post(
"/api/v1/nummernschemata",
json={"objekttyp_id": objekttyp.id, "praefix": "SEG", "stellenzahl": 3},
headers=auth_header(token),
)
assert schema.status_code == 201
duplikat = await client.post(
"/api/v1/nummernschemata",
json={"objekttyp_id": objekttyp.id, "praefix": "ANDERES"},
headers=auth_header(token),
)
assert duplikat.status_code == 409
vorschlag = await client.get(
f"/api/v1/objekte/naechster-code?objekttyp_id={objekttyp.id}", headers=auth_header(token)
)
assert vorschlag.status_code == 200
assert vorschlag.json()["code"] == "SEG-001"
aenderung = await client.patch(
f"/api/v1/nummernschemata/{schema.json()['id']}",
json={"stellenzahl": 4},
headers=auth_header(token),
)
assert aenderung.json()["stellenzahl"] == 4
@pytest.mark.asyncio
async def test_nummernschema_ohne_konfiguration_verlangt_praefix(
client, objekttyp_factory, admin_user
):
objekttyp = await objekttyp_factory("Typ-Ohne-Schema")
token = await login(client, "admin1")
ohne_schema = await client.get(
f"/api/v1/objekte/naechster-code?objekttyp_id={objekttyp.id}", headers=auth_header(token)
)
assert ohne_schema.status_code == 404
ohne_beides = await client.get("/api/v1/objekte/naechster-code", headers=auth_header(token))
assert ohne_beides.status_code == 422
@pytest.mark.asyncio
async def test_mitarbeiter_darf_nummernschema_nicht_anlegen(client, objekttyp_factory, mitarbeiter_user):
objekttyp = await objekttyp_factory("Typ-Nummernschema-Verboten")
token = await login(client, "mitarbeiter1")
response = await client.post(
"/api/v1/nummernschemata",
json={"objekttyp_id": objekttyp.id, "praefix": "X"},
headers=auth_header(token),
)
assert response.status_code == 403
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_naechster_objektposition_code( async def test_naechster_objektposition_code(
client, db_session, objekt_mit_position, materialverantwortlicher_user client, db_session, objekt_mit_position, materialverantwortlicher_user
+7
View File
@@ -302,6 +302,13 @@ export interface Objekttyp {
ist_zugfahrzeug: boolean; ist_zugfahrzeug: boolean;
} }
export interface Nummernschema {
id: number;
objekttyp_id: number;
praefix: string;
stellenzahl: number;
}
export interface Fach { export interface Fach {
id: number; id: number;
objekttyp_id: number; objekttyp_id: number;
+6 -2
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, Einsatzfunktion, Fach, Kategorie, Material, Objekt, Objekttyp, Qualifikationstyp, Standort } from "../api/types"; import type { Benutzer, Bereich, Beladungsvorlage, Einheit, Einsatzfunktion, Fach, Kategorie, Material, Nummernschema, 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";
@@ -109,6 +109,7 @@ export function AdminPage() {
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 [einsatzfunktionen, setEinsatzfunktionen] = useState<Einsatzfunktion[]>([]);
const [nummernschemata, setNummernschemata] = useState<Nummernschema[]>([]);
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);
@@ -128,7 +129,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, ef] = await Promise.allSettled([ const [be, ka, ot, s, v, o, m, b, f, ei, qt, ef, ns] = await Promise.allSettled([
apiRequest<Bereich[]>("/bereiche"), apiRequest<Bereich[]>("/bereiche"),
apiRequest<Kategorie[]>("/kategorien"), apiRequest<Kategorie[]>("/kategorien"),
apiRequest<Objekttyp[]>("/objekttypen"), apiRequest<Objekttyp[]>("/objekttypen"),
@@ -141,6 +142,7 @@ export function AdminPage() {
apiRequest<Einheit[]>("/einheiten"), apiRequest<Einheit[]>("/einheiten"),
apiRequest<Qualifikationstyp[]>("/qualifikationstypen"), apiRequest<Qualifikationstyp[]>("/qualifikationstypen"),
apiRequest<Einsatzfunktion[]>("/einsatzfunktionen"), apiRequest<Einsatzfunktion[]>("/einsatzfunktionen"),
apiRequest<Nummernschema[]>("/nummernschemata"),
]); ]);
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));
@@ -155,6 +157,7 @@ export function AdminPage() {
setEinheiten(wert(ei)); setEinheiten(wert(ei));
setQualifikationstypen(wert(qt)); setQualifikationstypen(wert(qt));
setEinsatzfunktionen(wert(ef)); setEinsatzfunktionen(wert(ef));
setNummernschemata(wert(ns));
} }
useEffect(() => { useEffect(() => {
@@ -226,6 +229,7 @@ export function AdminPage() {
bereiche={bereiche} bereiche={bereiche}
kategorien={kategorien} kategorien={kategorien}
objekttypen={objekttypen} objekttypen={objekttypen}
nummernschemata={nummernschemata}
onGeaendert={ladeAlles} onGeaendert={ladeAlles}
onFehler={setFehler} onFehler={setFehler}
/> />
@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { apiRequest } from "../../api/client"; import { apiRequest, ApiError } from "../../api/client";
import type { Beladungsvorlage, Objekt, Objekttyp, Standort } from "../../api/types"; import type { Beladungsvorlage, Objekt, Objekttyp, Standort } from "../../api/types";
interface Props { interface Props {
@@ -30,8 +30,27 @@ export function ObjektAnlegenFormular({ objekte, objekttypen, vorlagen, standort
}); });
async function codeVorschlagen() { async function codeVorschlagen() {
const objekttypId = vorlagen.find((v) => v.id === vorlageId)?.objekttyp_id;
// IDENT-003: erst versuchen, das für den Objekttyp konfigurierte
// Nummernschema zu nutzen (kein manuelles Präfix nötig) - nur wenn keins
// konfiguriert ist (404), auf das Präfix-Feld zurückfallen.
if (objekttypId !== undefined) {
try {
const antwort = await apiRequest<{ code: string }>(
`/objekte/naechster-code?objekttyp_id=${objekttypId}`
);
setCode(antwort.code);
return;
} catch (fehler) {
if (!(fehler instanceof ApiError && fehler.status === 404)) {
onFehler("Code-Vorschlag fehlgeschlagen.");
return;
}
// kein Nummernschema für diesen Objekttyp -> weiter mit manuellem Präfix
}
}
if (!praefix) { if (!praefix) {
onFehler("Präfix eingeben (z. B. RK, FZ)."); onFehler("Kein Nummernschema für diesen Objekttyp konfiguriert - Präfix eingeben (z. B. RK, FZ).");
return; return;
} }
try { try {
+132 -38
View File
@@ -1,17 +1,25 @@
import { useState } from "react"; import { useState } from "react";
import { apiRequest } from "../../api/client"; import { apiRequest } from "../../api/client";
import type { Bereich, Kategorie, Objekttyp } from "../../api/types"; import type { Bereich, Kategorie, Nummernschema, Objekttyp } from "../../api/types";
interface Props { interface Props {
bereiche: Bereich[]; bereiche: Bereich[];
kategorien: Kategorie[]; kategorien: Kategorie[];
objekttypen: Objekttyp[]; objekttypen: Objekttyp[];
nummernschemata: Nummernschema[];
onGeaendert: () => Promise<void>; onGeaendert: () => Promise<void>;
onFehler: (text: string) => void; onFehler: (text: string) => void;
} }
export function ObjekttypSection({ bereiche, kategorien, objekttypen, onGeaendert, onFehler }: Props) { export function ObjekttypSection({
bereiche,
kategorien,
objekttypen,
nummernschemata,
onGeaendert,
onFehler,
}: Props) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [bereichId, setBereichId] = useState<number | "">(""); const [bereichId, setBereichId] = useState<number | "">("");
const [kategorieId, setKategorieId] = useState<number | "">(""); const [kategorieId, setKategorieId] = useState<number | "">("");
@@ -20,6 +28,13 @@ export function ObjekttypSection({ bereiche, kategorien, objekttypen, onGeaender
const [bearbeitungsName, setBearbeitungsName] = useState(""); const [bearbeitungsName, setBearbeitungsName] = useState("");
const [istZugfahrzeug, setIstZugfahrzeug] = useState(false); const [istZugfahrzeug, setIstZugfahrzeug] = useState(false);
const [bearbeitungsZugfahrzeug, setBearbeitungsZugfahrzeug] = useState(false); const [bearbeitungsZugfahrzeug, setBearbeitungsZugfahrzeug] = useState(false);
const [schemaBearbeitenId, setSchemaBearbeitenId] = useState<number | null>(null);
const [schemaPraefix, setSchemaPraefix] = useState("");
const [schemaStellenzahl, setSchemaStellenzahl] = useState(5);
function schemaFuer(objekttypId: number): Nummernschema | undefined {
return nummernschemata.find((n) => n.objekttyp_id === objekttypId);
}
function bearbeiten(o: Objekttyp) { function bearbeiten(o: Objekttyp) {
setBearbeiteteId(o.id); setBearbeiteteId(o.id);
@@ -61,6 +76,38 @@ export function ObjekttypSection({ bereiche, kategorien, objekttypen, onGeaender
} }
} }
function schemaBearbeiten(objekttypId: number) {
const bestehendes = schemaFuer(objekttypId);
setSchemaBearbeitenId(objekttypId);
setSchemaPraefix(bestehendes?.praefix ?? "");
setSchemaStellenzahl(bestehendes?.stellenzahl ?? 5);
}
async function schemaSpeichern(objekttypId: number) {
if (!schemaPraefix.trim()) {
onFehler("Präfix eingeben.");
return;
}
const bestehendes = schemaFuer(objekttypId);
try {
if (bestehendes) {
await apiRequest(`/nummernschemata/${bestehendes.id}`, {
method: "PATCH",
body: { praefix: schemaPraefix.toUpperCase(), stellenzahl: schemaStellenzahl },
});
} else {
await apiRequest("/nummernschemata", {
method: "POST",
body: { objekttyp_id: objekttypId, praefix: schemaPraefix.toUpperCase(), stellenzahl: schemaStellenzahl },
});
}
setSchemaBearbeitenId(null);
await onGeaendert();
} catch {
onFehler("Nummernschema konnte nicht gespeichert werden.");
}
}
return ( return (
<section className="section"> <section className="section">
<h2>Objekttyp anlegen</h2> <h2>Objekttyp anlegen</h2>
@@ -105,43 +152,90 @@ export function ObjekttypSection({ bereiche, kategorien, objekttypen, onGeaender
)} )}
{objekttypen.length > 0 && ( {objekttypen.length > 0 && (
<ul className="card-list"> <ul className="card-list">
{objekttypen.map((o) => ( {objekttypen.map((o) => {
<li key={o.id} className="row-between" style={{ padding: "0.4rem 0" }}> const schema = schemaFuer(o.id);
{bearbeiteteId === o.id ? ( return (
<div className="row" style={{ flex: 1 }}> <li key={o.id} style={{ padding: "0.4rem 0" }}>
<input <div className="row-between">
className="input" {bearbeiteteId === o.id ? (
value={bearbeitungsName} <div className="row" style={{ flex: 1 }}>
onChange={(e) => setBearbeitungsName(e.target.value)} <input
/> className="input"
<label className="row" style={{ alignItems: "center", gap: "0.4rem" }}> value={bearbeitungsName}
<input onChange={(e) => setBearbeitungsName(e.target.value)}
type="checkbox" />
checked={bearbeitungsZugfahrzeug} <label className="row" style={{ alignItems: "center", gap: "0.4rem" }}>
onChange={(e) => setBearbeitungsZugfahrzeug(e.target.checked)} <input
/> type="checkbox"
Zugfahrzeug checked={bearbeitungsZugfahrzeug}
</label> onChange={(e) => setBearbeitungsZugfahrzeug(e.target.checked)}
<button className="btn btn-primary" onClick={() => speichern(o.id)}> />
Speichern Zugfahrzeug
</button> </label>
<button className="btn btn-secondary" onClick={() => setBearbeiteteId(null)}> <button className="btn btn-primary" onClick={() => speichern(o.id)}>
Abbrechen Speichern
</button> </button>
<button className="btn btn-secondary" onClick={() => setBearbeiteteId(null)}>
Abbrechen
</button>
</div>
) : (
<>
<span>
{o.name}
{o.ist_zugfahrzeug && <span className="text-muted"> (Zugfahrzeug)</span>}
</span>
<button className="btn btn-secondary" onClick={() => bearbeiten(o)}>
Bearbeiten
</button>
</>
)}
</div> </div>
) : ( <div className="row" style={{ alignItems: "center", gap: "0.4rem", marginTop: "0.25rem" }}>
<> {schemaBearbeitenId === o.id ? (
<span> <>
{o.name} <label htmlFor={`schema-praefix-${o.id}`} className="sr-only">
{o.ist_zugfahrzeug && <span className="text-muted"> (Zugfahrzeug)</span>} Präfix für {o.name}
</span> </label>
<button className="btn btn-secondary" onClick={() => bearbeiten(o)}> <input
Bearbeiten id={`schema-praefix-${o.id}`}
</button> className="input"
</> style={{ maxWidth: "6rem" }}
)} value={schemaPraefix}
</li> onChange={(e) => setSchemaPraefix(e.target.value)}
))} placeholder="Präfix"
/>
<input
className="input"
type="number"
min={1}
max={8}
style={{ maxWidth: "5rem" }}
value={schemaStellenzahl}
onChange={(e) => setSchemaStellenzahl(Number(e.target.value) || 5)}
/>
<button className="btn btn-primary" onClick={() => schemaSpeichern(o.id)}>
Speichern
</button>
<button className="btn btn-secondary" onClick={() => setSchemaBearbeitenId(null)}>
Abbrechen
</button>
</>
) : (
<>
<span className="text-muted" style={{ fontSize: "0.85rem" }}>
Nummernschema:{" "}
{schema ? `${schema.praefix}-${"0".repeat(schema.stellenzahl)}` : "keins konfiguriert"}
</span>
<button className="btn btn-secondary" onClick={() => schemaBearbeiten(o.id)}>
{schema ? "Ändern" : "Festlegen"}
</button>
</>
)}
</div>
</li>
);
})}
</ul> </ul>
)} )}
</div> </div>