Admin-Portal: Bearbeiten für Bereich/Kategorie/Objekttyp/Standort/Benutzer,
CI / backend-tests (push) Successful in 57s
CI / frontend-build (push) Successful in 20s

Vorlagen-Versionierung und Objekt-Duplizieren im Frontend nachgerüstet

Backend: PATCH-Endpoints für Bereich/Kategorie/Standort/Objekttyp ergänzt
(analog zu Material), die bislang fehlten und Bearbeiten grundsätzlich
unmöglich machten.

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 09:55:52 +02:00
co-authored by Claude Sonnet 5
parent 1987818b8c
commit 2f11011ed1
12 changed files with 786 additions and 24 deletions
+9
View File
@@ -61,12 +61,21 @@ export interface Standort {
adresse: string | null;
}
export interface Vorlagenposition {
id: string;
vorlage_id: number;
material_id: number;
fach: string | null;
sollmenge: string;
}
export interface Beladungsvorlage {
id: number;
objekttyp_id: number;
name: string;
version: number;
status: string;
positionen: Vorlagenposition[];
}
export interface Bereich {
+84 -8
View File
@@ -23,6 +23,10 @@ export function BenutzerSection({ benutzer, onGeaendert, onFehler }: Props) {
const [email, setEmail] = useState("");
const [rollen, setRollen] = useState<Rolle[]>(["mitarbeiter"]);
const [wirdAngelegt, setWirdAngelegt] = useState(false);
const [bearbeiteteId, setBearbeiteteId] = useState<number | null>(null);
const [bearbeitungsName, setBearbeitungsName] = useState("");
const [bearbeitungsEmail, setBearbeitungsEmail] = useState("");
const [bearbeitungsRollen, setBearbeitungsRollen] = useState<Rolle[]>([]);
function rolleUmschalten(rolle: Rolle) {
setRollen((bisherige) =>
@@ -59,6 +63,32 @@ export function BenutzerSection({ benutzer, onGeaendert, onFehler }: Props) {
}
}
function bearbeiten(b: Benutzer) {
setBearbeiteteId(b.id);
setBearbeitungsName(b.name);
setBearbeitungsEmail(b.email ?? "");
setBearbeitungsRollen(b.rollen as Rolle[]);
}
function bearbeitungsRolleUmschalten(rolle: Rolle) {
setBearbeitungsRollen((bisherige) =>
bisherige.includes(rolle) ? bisherige.filter((r) => r !== rolle) : [...bisherige, rolle]
);
}
async function speichern(id: number) {
try {
await apiRequest(`/benutzer/${id}`, {
method: "PATCH",
body: { name: bearbeitungsName, email: bearbeitungsEmail || null, rollen: bearbeitungsRollen },
});
setBearbeiteteId(null);
await onGeaendert();
} catch {
onFehler("Benutzer konnte nicht geändert werden.");
}
}
return (
<section className="section">
<h2>Benutzer anlegen</h2>
@@ -99,14 +129,60 @@ export function BenutzerSection({ benutzer, onGeaendert, onFehler }: Props) {
{benutzer.length > 0 && (
<ul className="card-list" style={{ marginTop: "1rem" }}>
{benutzer.map((b) => (
<li key={b.id} className="row-between" style={{ padding: "0.4rem 0" }}>
<span>
<strong>{b.name}</strong> ({b.login}) {b.rollen.join(", ")}
{!b.aktiv && " deaktiviert"}
</span>
<button className="btn btn-secondary" onClick={() => aktivSchalten(b)}>
{b.aktiv ? "Deaktivieren" : "Aktivieren"}
</button>
<li key={b.id} style={{ padding: "0.4rem 0" }}>
{bearbeiteteId === b.id ? (
<div className="stack">
<div className="row" style={{ flexWrap: "wrap", gap: "0.5rem" }}>
<input
className="input"
value={bearbeitungsName}
onChange={(e) => setBearbeitungsName(e.target.value)}
placeholder="Name"
/>
<input
className="input"
value={bearbeitungsEmail}
onChange={(e) => setBearbeitungsEmail(e.target.value)}
placeholder="E-Mail"
/>
</div>
<div className="row" style={{ flexWrap: "wrap", gap: "0.5rem" }}>
{ALLE_ROLLEN.map((r) => (
<label key={r} className="row" style={{ gap: "0.3rem" }}>
<input
type="checkbox"
checked={bearbeitungsRollen.includes(r)}
onChange={() => bearbeitungsRolleUmschalten(r)}
/>
{r}
</label>
))}
</div>
<div className="row">
<button className="btn btn-primary" onClick={() => speichern(b.id)}>
Speichern
</button>
<button className="btn btn-secondary" onClick={() => setBearbeiteteId(null)}>
Abbrechen
</button>
</div>
</div>
) : (
<div className="row-between">
<span>
<strong>{b.name}</strong> ({b.login}) {b.rollen.join(", ")}
{!b.aktiv && " deaktiviert"}
</span>
<div className="row">
<button className="btn btn-secondary" onClick={() => bearbeiten(b)}>
Bearbeiten
</button>
<button className="btn btn-secondary" onClick={() => aktivSchalten(b)}>
{b.aktiv ? "Deaktivieren" : "Aktivieren"}
</button>
</div>
</div>
)}
</li>
))}
</ul>
+40 -2
View File
@@ -12,6 +12,8 @@ interface Props {
export function BereichSection({ bereiche, onGeaendert, onFehler }: Props) {
const [name, setName] = useState("");
const [wirdAngelegt, setWirdAngelegt] = useState(false);
const [bearbeiteteId, setBearbeiteteId] = useState<number | null>(null);
const [bearbeitungsName, setBearbeitungsName] = useState("");
async function anlegen() {
setWirdAngelegt(true);
@@ -26,6 +28,21 @@ export function BereichSection({ bereiche, onGeaendert, onFehler }: Props) {
}
}
function bearbeiten(b: Bereich) {
setBearbeiteteId(b.id);
setBearbeitungsName(b.name);
}
async function speichern(id: number) {
try {
await apiRequest(`/bereiche/${id}`, { method: "PATCH", body: { name: bearbeitungsName } });
setBearbeiteteId(null);
await onGeaendert();
} catch {
onFehler("Bereich konnte nicht geändert werden.");
}
}
return (
<section className="section">
<h2>Bereich anlegen</h2>
@@ -39,8 +56,29 @@ export function BereichSection({ bereiche, onGeaendert, onFehler }: Props) {
{bereiche.length > 0 && (
<ul className="card-list">
{bereiche.map((b) => (
<li key={b.id} style={{ padding: "0.4rem 0" }}>
{b.name}
<li key={b.id} className="row-between" style={{ padding: "0.4rem 0" }}>
{bearbeiteteId === b.id ? (
<div className="row" style={{ flex: 1 }}>
<input
className="input"
value={bearbeitungsName}
onChange={(e) => setBearbeitungsName(e.target.value)}
/>
<button className="btn btn-primary" onClick={() => speichern(b.id)}>
Speichern
</button>
<button className="btn btn-secondary" onClick={() => setBearbeiteteId(null)}>
Abbrechen
</button>
</div>
) : (
<>
<span>{b.name}</span>
<button className="btn btn-secondary" onClick={() => bearbeiten(b)}>
Bearbeiten
</button>
</>
)}
</li>
))}
</ul>
+41 -2
View File
@@ -14,6 +14,8 @@ export function KategorieSection({ bereiche, kategorien, onGeaendert, onFehler }
const [name, setName] = useState("");
const [bereichId, setBereichId] = useState<number | "">("");
const [wirdAngelegt, setWirdAngelegt] = useState(false);
const [bearbeiteteId, setBearbeiteteId] = useState<number | null>(null);
const [bearbeitungsName, setBearbeitungsName] = useState("");
async function anlegen() {
if (bereichId === "") {
@@ -36,6 +38,21 @@ export function KategorieSection({ bereiche, kategorien, onGeaendert, onFehler }
return bereiche.find((b) => b.id === id)?.name ?? `#${id}`;
}
function bearbeiten(k: Kategorie) {
setBearbeiteteId(k.id);
setBearbeitungsName(k.name);
}
async function speichern(id: number) {
try {
await apiRequest(`/kategorien/${id}`, { method: "PATCH", body: { name: bearbeitungsName } });
setBearbeiteteId(null);
await onGeaendert();
} catch {
onFehler("Kategorie konnte nicht geändert werden.");
}
}
return (
<section className="section">
<h2>Kategorie anlegen</h2>
@@ -64,8 +81,30 @@ export function KategorieSection({ bereiche, kategorien, onGeaendert, onFehler }
<ul className="card-list">
{kategorien.map((k) => (
<li key={k.id} className="row-between" style={{ padding: "0.4rem 0" }}>
<span>{k.name}</span>
<span className="text-muted">{bereichName(k.bereich_id)}</span>
{bearbeiteteId === k.id ? (
<div className="row" style={{ flex: 1 }}>
<input
className="input"
value={bearbeitungsName}
onChange={(e) => setBearbeitungsName(e.target.value)}
/>
<button className="btn btn-primary" onClick={() => speichern(k.id)}>
Speichern
</button>
<button className="btn btn-secondary" onClick={() => setBearbeiteteId(null)}>
Abbrechen
</button>
</div>
) : (
<>
<span>
{k.name} <span className="text-muted">({bereichName(k.bereich_id)})</span>
</span>
<button className="btn btn-secondary" onClick={() => bearbeiten(k)}>
Bearbeiten
</button>
</>
)}
</li>
))}
</ul>
+73 -6
View File
@@ -20,6 +20,10 @@ export function ObjektSection({ objekte, vorlagen, standorte, materialien, onGea
const [standortId, setStandortId] = useState<number | "">("");
const [wirdAngelegt, setWirdAngelegt] = useState(false);
const [ausgeklapptId, setAusgeklapptId] = useState<number | null>(null);
const [duplizierId, setDuplizierId] = useState<number | null>(null);
const [neuerCode, setNeuerCode] = useState("");
const [neuerName, setNeuerName] = useState("");
const [neuerStandortId, setNeuerStandortId] = useState<number | "">("");
async function anlegen() {
if (vorlageId === "" || standortId === "") {
@@ -49,6 +53,30 @@ export function ObjektSection({ objekte, vorlagen, standorte, materialien, onGea
}
}
function duplizierenStarten(o: Objekt) {
setDuplizierId(duplizierId === o.id ? null : o.id);
setNeuerCode("");
setNeuerName(`${o.name} (Kopie)`);
setNeuerStandortId(o.standort_id);
}
async function duplizieren(quelleId: number) {
if (neuerStandortId === "") {
onFehler("Standort auswählen.");
return;
}
try {
await apiRequest(`/objekte/${quelleId}/duplizieren`, {
method: "POST",
body: { neuer_code: neuerCode, neuer_name: neuerName, neuer_standort_id: neuerStandortId },
});
setDuplizierId(null);
await onGeaendert();
} catch {
onFehler("Objekt konnte nicht dupliziert werden (Code evtl. schon vergeben).");
}
}
return (
<section className="section">
<h2>Objekt aus Vorlage anlegen</h2>
@@ -107,18 +135,57 @@ export function ObjektSection({ objekte, vorlagen, standorte, materialien, onGea
<span>
{o.name} <span className="text-muted">({o.code})</span>
</span>
<button
className="btn btn-secondary"
onClick={() => setAusgeklapptId(ausgeklapptId === o.id ? null : o.id)}
>
{ausgeklapptId === o.id ? "Positionen ausblenden" : "Ablauf/Charge/SN pflegen"}
</button>
<div className="row">
<button
className="btn btn-secondary"
onClick={() => setAusgeklapptId(ausgeklapptId === o.id ? null : o.id)}
>
{ausgeklapptId === o.id ? "Positionen ausblenden" : "Ablauf/Charge/SN pflegen"}
</button>
<button className="btn btn-secondary" onClick={() => duplizierenStarten(o)}>
{duplizierId === o.id ? "Schließen" : "Duplizieren"}
</button>
</div>
</div>
{ausgeklapptId === o.id && (
<div style={{ marginTop: "0.5rem" }}>
<ObjektPositionenPanel objektId={o.id} materialien={materialien} onFehler={onFehler} />
</div>
)}
{duplizierId === o.id && (
<div className="row" style={{ marginTop: "0.5rem", flexWrap: "wrap" }}>
<input
className="input"
value={neuerCode}
onChange={(e) => setNeuerCode(e.target.value)}
placeholder="Neuer Code (eindeutig)"
/>
<input
className="input"
value={neuerName}
onChange={(e) => setNeuerName(e.target.value)}
placeholder="Neuer Name"
/>
<select
className="input"
value={neuerStandortId}
onChange={(e) => setNeuerStandortId(Number(e.target.value) || "")}
>
{standorte.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
<button
className="btn btn-primary"
onClick={() => duplizieren(o.id)}
disabled={!neuerCode || !neuerName}
>
Duplizieren
</button>
</div>
)}
</li>
))}
</ul>
+40 -2
View File
@@ -16,6 +16,23 @@ export function ObjekttypSection({ bereiche, kategorien, objekttypen, onGeaender
const [bereichId, setBereichId] = useState<number | "">("");
const [kategorieId, setKategorieId] = useState<number | "">("");
const [wirdAngelegt, setWirdAngelegt] = useState(false);
const [bearbeiteteId, setBearbeiteteId] = useState<number | null>(null);
const [bearbeitungsName, setBearbeitungsName] = useState("");
function bearbeiten(o: Objekttyp) {
setBearbeiteteId(o.id);
setBearbeitungsName(o.name);
}
async function speichern(id: number) {
try {
await apiRequest(`/objekttypen/${id}`, { method: "PATCH", body: { name: bearbeitungsName } });
setBearbeiteteId(null);
await onGeaendert();
} catch {
onFehler("Objekttyp konnte nicht geändert werden.");
}
}
async function anlegen() {
if (bereichId === "") {
@@ -74,8 +91,29 @@ export function ObjekttypSection({ bereiche, kategorien, objekttypen, onGeaender
{objekttypen.length > 0 && (
<ul className="card-list">
{objekttypen.map((o) => (
<li key={o.id} style={{ padding: "0.4rem 0" }}>
{o.name}
<li key={o.id} className="row-between" style={{ padding: "0.4rem 0" }}>
{bearbeiteteId === o.id ? (
<div className="row" style={{ flex: 1 }}>
<input
className="input"
value={bearbeitungsName}
onChange={(e) => setBearbeitungsName(e.target.value)}
/>
<button className="btn btn-primary" onClick={() => speichern(o.id)}>
Speichern
</button>
<button className="btn btn-secondary" onClick={() => setBearbeiteteId(null)}>
Abbrechen
</button>
</div>
) : (
<>
<span>{o.name}</span>
<button className="btn btn-secondary" onClick={() => bearbeiten(o)}>
Bearbeiten
</button>
</>
)}
</li>
))}
</ul>
+39 -1
View File
@@ -12,6 +12,8 @@ interface Props {
export function StandortSection({ standorte, onGeaendert, onFehler }: Props) {
const [name, setName] = useState("");
const [wirdAngelegt, setWirdAngelegt] = useState(false);
const [bearbeiteteId, setBearbeiteteId] = useState<number | null>(null);
const [bearbeitungsName, setBearbeitungsName] = useState("");
async function anlegen() {
setWirdAngelegt(true);
@@ -26,6 +28,21 @@ export function StandortSection({ standorte, onGeaendert, onFehler }: Props) {
}
}
function bearbeiten(s: Standort) {
setBearbeiteteId(s.id);
setBearbeitungsName(s.name);
}
async function speichern(id: number) {
try {
await apiRequest(`/standorte/${id}`, { method: "PATCH", body: { name: bearbeitungsName } });
setBearbeiteteId(null);
await onGeaendert();
} catch {
onFehler("Standort konnte nicht geändert werden.");
}
}
return (
<section className="section">
<h2>Standort anlegen</h2>
@@ -45,7 +62,28 @@ export function StandortSection({ standorte, onGeaendert, onFehler }: Props) {
<ul className="card-list">
{standorte.map((s) => (
<li key={s.id} className="row-between" style={{ padding: "0.4rem 0" }}>
<span>{s.name}</span>
{bearbeiteteId === s.id ? (
<div className="row" style={{ flex: 1 }}>
<input
className="input"
value={bearbeitungsName}
onChange={(e) => setBearbeitungsName(e.target.value)}
/>
<button className="btn btn-primary" onClick={() => speichern(s.id)}>
Speichern
</button>
<button className="btn btn-secondary" onClick={() => setBearbeiteteId(null)}>
Abbrechen
</button>
</div>
) : (
<>
<span>{s.name}</span>
<button className="btn btn-secondary" onClick={() => bearbeiten(s)}>
Bearbeiten
</button>
</>
)}
</li>
))}
</ul>
+96 -3
View File
@@ -24,6 +24,40 @@ export function VorlageSection({ vorlagen, objekttypen, materialien, onGeaendert
const [objekttypId, setObjekttypId] = useState<number | "">("");
const [zeilen, setZeilen] = useState<PositionZeile[]>([LEERE_ZEILE]);
const [wirdAngelegt, setWirdAngelegt] = useState(false);
const [ausgeklapptId, setAusgeklapptId] = useState<number | null>(null);
const [neueVersionZeilen, setNeueVersionZeilen] = useState<PositionZeile[]>([]);
const [wirdVersioniert, setWirdVersioniert] = useState(false);
function versionBearbeiten(v: Beladungsvorlage) {
setAusgeklapptId(ausgeklapptId === v.id ? null : v.id);
setNeueVersionZeilen(
v.positionen.map((p) => ({
materialId: p.material_id,
fach: p.fach ?? "",
sollmenge: p.sollmenge,
}))
);
}
function neueVersionZeileAendern(index: number, zeile: Partial<PositionZeile>) {
setNeueVersionZeilen((bisherige) => bisherige.map((z, i) => (i === index ? { ...z, ...zeile } : z)));
}
async function neueVersionSpeichern(vorlageId: number) {
const positionen = neueVersionZeilen
.filter((z) => z.materialId !== "")
.map((z) => ({ material_id: z.materialId, fach: z.fach || null, sollmenge: z.sollmenge }));
setWirdVersioniert(true);
try {
await apiRequest(`/vorlagen/${vorlageId}/neue-version`, { method: "POST", body: { positionen } });
setAusgeklapptId(null);
await onGeaendert();
} catch {
onFehler("Neue Version konnte nicht angelegt werden.");
} finally {
setWirdVersioniert(false);
}
}
function zeileAendern(index: number, zeile: Partial<PositionZeile>) {
setZeilen((bisherige) => bisherige.map((z, i) => (i === index ? { ...z, ...zeile } : z)));
@@ -124,9 +158,68 @@ export function VorlageSection({ vorlagen, objekttypen, materialien, onGeaendert
{vorlagen.length > 0 && (
<ul className="card-list" style={{ marginTop: "1.25rem" }}>
{vorlagen.map((v) => (
<li key={v.id} className="row-between" style={{ padding: "0.4rem 0" }}>
<span>{v.name}</span>
<span className="badge badge-neutral">v{v.version}</span>
<li key={v.id} style={{ padding: "0.4rem 0" }}>
<div className="row-between">
<span>
{v.name} <span className="badge badge-neutral">v{v.version}</span>
</span>
<button className="btn btn-secondary" onClick={() => versionBearbeiten(v)}>
{ausgeklapptId === v.id ? "Schließen" : "Positionen / neue Version"}
</button>
</div>
{ausgeklapptId === v.id && (
<div className="stack" style={{ marginTop: "0.6rem" }}>
<p className="text-muted" style={{ marginBottom: 0 }}>
Änderungen erzeugen eine neue Version die aktuelle v{v.version} bleibt unverändert
erhalten.
</p>
{neueVersionZeilen.map((zeile, i) => (
<div className="row" key={i}>
<select
className="input"
value={zeile.materialId}
onChange={(e) =>
neueVersionZeileAendern(i, { materialId: Number(e.target.value) || "" })
}
>
<option value="">Material</option>
{materialien.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
<input
className="input"
style={{ width: "8rem" }}
value={zeile.fach}
onChange={(e) => neueVersionZeileAendern(i, { fach: e.target.value })}
placeholder="Fach"
/>
<input
className="input"
style={{ width: "5rem" }}
type="number"
value={zeile.sollmenge}
onChange={(e) => neueVersionZeileAendern(i, { sollmenge: e.target.value })}
/>
</div>
))}
<button
className="btn btn-secondary"
onClick={() => setNeueVersionZeilen((z) => [...z, LEERE_ZEILE])}
>
+ Position
</button>
<button
className="btn btn-primary"
onClick={() => neueVersionSpeichern(v.id)}
disabled={wirdVersioniert}
>
Neue Version speichern
</button>
</div>
)}
</li>
))}
</ul>