Feste Fächer-Liste je Objekttyp (Vorlagen-Positionen: Dropdown + Sonstiges)
CI / backend-tests (push) Successful in 1m0s
CI / frontend-build (push) Successful in 5m56s

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:
2026-09-04 10:24:15 +02:00
co-authored by Claude Sonnet 5
parent 2f11011ed1
commit a2877f5672
11 changed files with 493 additions and 17 deletions
+7
View File
@@ -98,6 +98,13 @@ export interface Objekttyp {
name: string;
}
export interface Fach {
id: number;
objekttyp_id: number;
name: string;
sortierung: number;
}
export interface Me {
id: number;
name: string;
+13 -2
View File
@@ -1,9 +1,10 @@
import { useEffect, useState } from "react";
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 { BereichSection } from "./admin/BereichSection";
import { FachSection } from "./admin/FachSection";
import { KategorieSection } from "./admin/KategorieSection";
import { MaterialSection } from "./admin/MaterialSection";
import { ObjektSection } from "./admin/ObjektSection";
@@ -40,12 +41,13 @@ export function AdminPage() {
const [objekte, setObjekte] = useState<Objekt[]>([]);
const [materialien, setMaterialien] = useState<Material[]>([]);
const [benutzer, setBenutzer] = useState<Benutzer[]>([]);
const [faecher, setFaecher] = useState<Fach[]>([]);
const [fehler, setFehler] = useState<string | null>(null);
const [laedt, setLaedt] = useState(true);
const [tab, setTab] = useState<Tab>("objekte");
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<Kategorie[]>("/kategorien"),
apiRequest<Objekttyp[]>("/objekttypen"),
@@ -54,6 +56,7 @@ export function AdminPage() {
apiRequest<Objekt[]>("/objekte"),
apiRequest<Material[]>("/materialien"),
apiRequest<Benutzer[]>("/benutzer"),
apiRequest<Fach[]>("/faecher"),
]);
setBereiche(be);
setKategorien(ka);
@@ -63,6 +66,7 @@ export function AdminPage() {
setObjekte(o);
setMaterialien(m);
setBenutzer(b);
setFaecher(f);
}
useEffect(() => {
@@ -111,6 +115,7 @@ export function AdminPage() {
vorlagen={vorlagen}
objekttypen={objekttypen}
materialien={materialien}
faecher={faecher}
onGeaendert={ladeAlles}
onFehler={setFehler}
/>
@@ -134,6 +139,12 @@ export function AdminPage() {
onGeaendert={ladeAlles}
onFehler={setFehler}
/>
<FachSection
objekttypen={objekttypen}
faecher={faecher}
onGeaendert={ladeAlles}
onFehler={setFehler}
/>
</>
)}
{tab === "standorte" && (
+54
View File
@@ -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>
);
}
+99
View File
@@ -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>
);
}
+12 -14
View File
@@ -1,7 +1,8 @@
import { useState } from "react";
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 {
materialId: number | "";
@@ -15,11 +16,12 @@ interface Props {
vorlagen: Beladungsvorlage[];
objekttypen: Objekttyp[];
materialien: Material[];
faecher: Fach[];
onGeaendert: () => Promise<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 [objekttypId, setObjekttypId] = useState<number | "">("");
const [zeilen, setZeilen] = useState<PositionZeile[]>([LEERE_ZEILE]);
@@ -129,12 +131,10 @@ export function VorlageSection({ vorlagen, objekttypen, materialien, onGeaendert
</option>
))}
</select>
<input
className="input"
style={{ width: "8rem" }}
value={zeile.fach}
onChange={(e) => zeileAendern(i, { fach: e.target.value })}
placeholder="Fach"
<FachAuswahl
faecher={faecher.filter((f) => f.objekttyp_id === objekttypId)}
wert={zeile.fach}
onAendern={(wert) => zeileAendern(i, { fach: wert })}
/>
<input
className="input"
@@ -189,12 +189,10 @@ export function VorlageSection({ vorlagen, objekttypen, materialien, onGeaendert
</option>
))}
</select>
<input
className="input"
style={{ width: "8rem" }}
value={zeile.fach}
onChange={(e) => neueVersionZeileAendern(i, { fach: e.target.value })}
placeholder="Fach"
<FachAuswahl
faecher={faecher.filter((f) => f.objekttyp_id === v.objekttyp_id)}
wert={zeile.fach}
onAendern={(wert) => neueVersionZeileAendern(i, { fach: wert })}
/>
<input
className="input"