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
+212
View File
@@ -2051,3 +2051,215 @@ Keine Commits in dieser Session.
- frontend/src/pages/kontrolle/PositionCard.tsx | 14 +++- - frontend/src/pages/kontrolle/PositionCard.tsx | 14 +++-
--- ---
## 2026-09-04 09:01 09:04 (3m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
- d70558f Inline-Nachfüll-Dialog nach Abweichung in der Kontrolle (Karte 07)
### Geänderte Dateien
- DEVLOG.md | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- frontend/src/offline/queue.ts | 18 +++++++++++++++++-
- frontend/src/pages/KontrollPage.tsx | 29 ++++++++++++++++++++++-------
- frontend/src/pages/kontrolle/NachfuellDialog.tsx | 36 ++++++++++++++++++++++++++++++++++++
- frontend/src/pages/kontrolle/PositionCard.tsx | 4 ++--
- frontend/src/pages/kontrolle/useKontrolle.ts | 32 +++++++++++++++++++++++++++++++-
---
## 2026-09-04 09:05 09:05 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- frontend/src/offline/queue.ts | 18 +++++++++++++++++-
- frontend/src/pages/KontrollPage.tsx | 29 ++++++++++++++++++++++-------
- frontend/src/pages/kontrolle/NachfuellDialog.tsx | 36 ++++++++++++++++++++++++++++++++++++
- frontend/src/pages/kontrolle/PositionCard.tsx | 4 ++--
- frontend/src/pages/kontrolle/useKontrolle.ts | 32 +++++++++++++++++++++++++++++++-
---
## 2026-09-04 09:11 09:11 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- frontend/src/offline/queue.ts | 18 +++++++++++++++++-
- frontend/src/pages/KontrollPage.tsx | 29 ++++++++++++++++++++++-------
- frontend/src/pages/kontrolle/NachfuellDialog.tsx | 36 ++++++++++++++++++++++++++++++++++++
- frontend/src/pages/kontrolle/PositionCard.tsx | 4 ++--
- frontend/src/pages/kontrolle/useKontrolle.ts | 32 +++++++++++++++++++++++++++++++-
---
## 2026-09-04 09:11 09:11 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- frontend/src/offline/queue.ts | 18 +++++++++++++++++-
- frontend/src/pages/KontrollPage.tsx | 29 ++++++++++++++++++++++-------
- frontend/src/pages/kontrolle/NachfuellDialog.tsx | 36 ++++++++++++++++++++++++++++++++++++
- frontend/src/pages/kontrolle/PositionCard.tsx | 4 ++--
- frontend/src/pages/kontrolle/useKontrolle.ts | 32 +++++++++++++++++++++++++++++++-
---
## 2026-09-04 09:11 09:12 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- frontend/src/offline/queue.ts | 18 +++++++++++++++++-
- frontend/src/pages/KontrollPage.tsx | 29 ++++++++++++++++++++++-------
- frontend/src/pages/kontrolle/NachfuellDialog.tsx | 36 ++++++++++++++++++++++++++++++++++++
- frontend/src/pages/kontrolle/PositionCard.tsx | 4 ++--
- frontend/src/pages/kontrolle/useKontrolle.ts | 32 +++++++++++++++++++++++++++++++-
---
## 2026-09-04 09:12 09:14 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
- 032126c fix(kontrolle): Abbruch löscht bereits nachgefüllten Fehlbestand nicht mehr
### Geänderte Dateien
- backend/app/services/kontrolle/lifecycle.py | 8 +++++++-
- backend/tests/test_kontrolle.py | 40 ++++++++++++++++++++++++++++++++++++++++
---
## 2026-09-04 09:21 09:21 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- backend/app/services/kontrolle/lifecycle.py | 8 +++++++-
- backend/tests/test_kontrolle.py | 40 ++++++++++++++++++++++++++++++++++++++++
---
## 2026-09-04 09:21 09:22 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- backend/app/services/kontrolle/lifecycle.py | 8 +++++++-
- backend/tests/test_kontrolle.py | 40 ++++++++++++++++++++++++++++++++++++++++
---
## 2026-09-04 09:23 09:23 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- backend/app/services/kontrolle/lifecycle.py | 8 +++++++-
- backend/tests/test_kontrolle.py | 40 ++++++++++++++++++++++++++++++++++++++++
---
## 2026-09-04 09:26 09:26 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- backend/app/services/kontrolle/lifecycle.py | 8 +++++++-
- backend/tests/test_kontrolle.py | 40 ++++++++++++++++++++++++++++++++++++++++
---
## 2026-09-04 09:29 09:30 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
- 2c9ca3e Admin-Portal: Tab-Menü statt einer langen Scroll-Seite
### Geänderte Dateien
- frontend/src/pages/AdminPage.tsx | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
---
## 2026-09-04 09:38 09:38 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- frontend/src/pages/AdminPage.tsx | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
---
## 2026-09-04 09:43 09:43 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- frontend/src/pages/AdminPage.tsx | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
---
## 2026-09-04 09:43 09:44 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
- 1987818 Admin-Portal: Benutzerverwaltung (anlegen, Rollen, aktivieren/deaktivieren)
### Geänderte Dateien
- frontend/src/api/types.ts | 11 +++++++
- frontend/src/pages/AdminPage.tsx | 14 +++++++--
- frontend/src/pages/admin/BenutzerSection.tsx | 117 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-09-04 09:44 09:45 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- frontend/src/api/types.ts | 11 +++++++
- frontend/src/pages/AdminPage.tsx | 14 +++++++--
- frontend/src/pages/admin/BenutzerSection.tsx | 117 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-09-04 09:47 09:47 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- frontend/src/api/types.ts | 11 +++++++
- frontend/src/pages/AdminPage.tsx | 14 +++++++--
- frontend/src/pages/admin/BenutzerSection.tsx | 117 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
@@ -9,15 +9,19 @@ from app.models.stammdaten import Bereich, Kategorie, Material, Objekttyp, Stand
from app.schemas.stammdaten import ( from app.schemas.stammdaten import (
BereichCreate, BereichCreate,
BereichRead, BereichRead,
BereichUpdate,
KategorieCreate, KategorieCreate,
KategorieRead, KategorieRead,
KategorieUpdate,
MaterialCreate, MaterialCreate,
MaterialRead, MaterialRead,
MaterialUpdate, MaterialUpdate,
ObjekttypCreate, ObjekttypCreate,
ObjekttypRead, ObjekttypRead,
ObjekttypUpdate,
StandortCreate, StandortCreate,
StandortRead, StandortRead,
StandortUpdate,
) )
router = APIRouter() router = APIRouter()
@@ -45,6 +49,22 @@ async def erstelle_bereich(
return bereich return bereich
@router.patch("/bereiche/{bereich_id}", response_model=BereichRead)
async def aendere_bereich(
bereich_id: int,
payload: BereichUpdate,
db: AsyncSession = Depends(get_db),
_=Depends(require_roles(RolleTyp.administration)),
) -> Bereich:
bereich = await db.get(Bereich, bereich_id)
if bereich is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bereich nicht gefunden")
for feld, wert in payload.model_dump(exclude_unset=True).items():
setattr(bereich, feld, wert)
await db.flush()
return bereich
# -- Kategorie ------------------------------------------------------------------ # -- Kategorie ------------------------------------------------------------------
@router.get("/kategorien", response_model=list[KategorieRead]) @router.get("/kategorien", response_model=list[KategorieRead])
@@ -67,6 +87,22 @@ async def erstelle_kategorie(
return kategorie return kategorie
@router.patch("/kategorien/{kategorie_id}", response_model=KategorieRead)
async def aendere_kategorie(
kategorie_id: int,
payload: KategorieUpdate,
db: AsyncSession = Depends(get_db),
_=Depends(require_roles(RolleTyp.administration)),
) -> Kategorie:
kategorie = await db.get(Kategorie, kategorie_id)
if kategorie is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kategorie nicht gefunden")
for feld, wert in payload.model_dump(exclude_unset=True).items():
setattr(kategorie, feld, wert)
await db.flush()
return kategorie
# -- Standort ------------------------------------------------------------------ # -- Standort ------------------------------------------------------------------
@router.get("/standorte", response_model=list[StandortRead]) @router.get("/standorte", response_model=list[StandortRead])
@@ -89,6 +125,22 @@ async def erstelle_standort(
return standort return standort
@router.patch("/standorte/{standort_id}", response_model=StandortRead)
async def aendere_standort(
standort_id: int,
payload: StandortUpdate,
db: AsyncSession = Depends(get_db),
_=Depends(require_roles(RolleTyp.administration)),
) -> Standort:
standort = await db.get(Standort, standort_id)
if standort is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Standort nicht gefunden")
for feld, wert in payload.model_dump(exclude_unset=True).items():
setattr(standort, feld, wert)
await db.flush()
return standort
# -- Objekttyp ------------------------------------------------------------------ # -- Objekttyp ------------------------------------------------------------------
@router.get("/objekttypen", response_model=list[ObjekttypRead]) @router.get("/objekttypen", response_model=list[ObjekttypRead])
@@ -111,6 +163,22 @@ async def erstelle_objekttyp(
return objekttyp return objekttyp
@router.patch("/objekttypen/{objekttyp_id}", response_model=ObjekttypRead)
async def aendere_objekttyp(
objekttyp_id: int,
payload: ObjekttypUpdate,
db: AsyncSession = Depends(get_db),
_=Depends(require_roles(RolleTyp.administration)),
) -> Objekttyp:
objekttyp = await db.get(Objekttyp, objekttyp_id)
if objekttyp is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekttyp nicht gefunden")
for feld, wert in payload.model_dump(exclude_unset=True).items():
setattr(objekttyp, feld, wert)
await db.flush()
return objekttyp
# -- Material (Prompt 07) -------------------------------------------------- # -- Material (Prompt 07) --------------------------------------------------
@router.get("/materialien", response_model=list[MaterialRead]) @router.get("/materialien", response_model=list[MaterialRead])
+22
View File
@@ -13,6 +13,11 @@ class BereichRead(BereichCreate):
id: int id: int
class BereichUpdate(BaseModel):
name: str | None = None
beschreibung: str | None = None
class KategorieCreate(BaseModel): class KategorieCreate(BaseModel):
bereich_id: int bereich_id: int
name: str name: str
@@ -24,6 +29,12 @@ class KategorieRead(KategorieCreate):
id: int id: int
class KategorieUpdate(BaseModel):
bereich_id: int | None = None
name: str | None = None
ueberkategorie_id: int | None = None
class StandortCreate(BaseModel): class StandortCreate(BaseModel):
name: str name: str
adresse: str | None = None adresse: str | None = None
@@ -34,6 +45,11 @@ class StandortRead(StandortCreate):
id: int id: int
class StandortUpdate(BaseModel):
name: str | None = None
adresse: str | None = None
class ObjekttypCreate(BaseModel): class ObjekttypCreate(BaseModel):
bereich_id: int bereich_id: int
kategorie_id: int | None = None kategorie_id: int | None = None
@@ -45,6 +61,12 @@ class ObjekttypRead(ObjekttypCreate):
id: int id: int
class ObjekttypUpdate(BaseModel):
bereich_id: int | None = None
kategorie_id: int | None = None
name: str | None = None
class MaterialCreate(BaseModel): class MaterialCreate(BaseModel):
name: str name: str
artikelnummer: str | None = None artikelnummer: str | None = None
+62
View File
@@ -60,3 +60,65 @@ async def test_material_not_found(client, admin_user):
token = await login(client, "admin1") token = await login(client, "admin1")
response = await client.get("/api/v1/materialien/999999", headers=auth_header(token)) response = await client.get("/api/v1/materialien/999999", headers=auth_header(token))
assert response.status_code == 404 assert response.status_code == 404
@pytest.mark.asyncio
async def test_admin_can_update_bereich(client, admin_user):
token = await login(client, "admin1")
erstellt = await client.post("/api/v1/bereiche", json={"name": "Alt"}, headers=auth_header(token))
bereich_id = erstellt.json()["id"]
response = await client.patch(
f"/api/v1/bereiche/{bereich_id}", json={"name": "Neu"}, headers=auth_header(token)
)
assert response.status_code == 200
assert response.json()["name"] == "Neu"
@pytest.mark.asyncio
async def test_admin_can_update_kategorie(client, admin_user):
token = await login(client, "admin1")
bereich = await client.post("/api/v1/bereiche", json={"name": "B"}, headers=auth_header(token))
erstellt = await client.post(
"/api/v1/kategorien",
json={"bereich_id": bereich.json()["id"], "name": "Alt"},
headers=auth_header(token),
)
kategorie_id = erstellt.json()["id"]
response = await client.patch(
f"/api/v1/kategorien/{kategorie_id}", json={"name": "Neu"}, headers=auth_header(token)
)
assert response.status_code == 200
assert response.json()["name"] == "Neu"
@pytest.mark.asyncio
async def test_admin_can_update_standort(client, admin_user):
token = await login(client, "admin1")
erstellt = await client.post("/api/v1/standorte", json={"name": "Alt"}, headers=auth_header(token))
standort_id = erstellt.json()["id"]
response = await client.patch(
f"/api/v1/standorte/{standort_id}", json={"name": "Neu"}, headers=auth_header(token)
)
assert response.status_code == 200
assert response.json()["name"] == "Neu"
@pytest.mark.asyncio
async def test_admin_can_update_objekttyp(client, admin_user):
token = await login(client, "admin1")
bereich = await client.post("/api/v1/bereiche", json={"name": "B"}, headers=auth_header(token))
erstellt = await client.post(
"/api/v1/objekttypen",
json={"bereich_id": bereich.json()["id"], "name": "Alt"},
headers=auth_header(token),
)
objekttyp_id = erstellt.json()["id"]
response = await client.patch(
f"/api/v1/objekttypen/{objekttyp_id}", json={"name": "Neu"}, headers=auth_header(token)
)
assert response.status_code == 200
assert response.json()["name"] == "Neu"
+9
View File
@@ -61,12 +61,21 @@ export interface Standort {
adresse: string | null; adresse: string | null;
} }
export interface Vorlagenposition {
id: string;
vorlage_id: number;
material_id: number;
fach: string | null;
sollmenge: string;
}
export interface Beladungsvorlage { export interface Beladungsvorlage {
id: number; id: number;
objekttyp_id: number; objekttyp_id: number;
name: string; name: string;
version: number; version: number;
status: string; status: string;
positionen: Vorlagenposition[];
} }
export interface Bereich { export interface Bereich {
+77 -1
View File
@@ -23,6 +23,10 @@ export function BenutzerSection({ benutzer, onGeaendert, onFehler }: Props) {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [rollen, setRollen] = useState<Rolle[]>(["mitarbeiter"]); const [rollen, setRollen] = useState<Rolle[]>(["mitarbeiter"]);
const [wirdAngelegt, setWirdAngelegt] = useState(false); 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) { function rolleUmschalten(rolle: Rolle) {
setRollen((bisherige) => 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 ( return (
<section className="section"> <section className="section">
<h2>Benutzer anlegen</h2> <h2>Benutzer anlegen</h2>
@@ -99,14 +129,60 @@ export function BenutzerSection({ benutzer, onGeaendert, onFehler }: Props) {
{benutzer.length > 0 && ( {benutzer.length > 0 && (
<ul className="card-list" style={{ marginTop: "1rem" }}> <ul className="card-list" style={{ marginTop: "1rem" }}>
{benutzer.map((b) => ( {benutzer.map((b) => (
<li key={b.id} className="row-between" style={{ padding: "0.4rem 0" }}> <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> <span>
<strong>{b.name}</strong> ({b.login}) {b.rollen.join(", ")} <strong>{b.name}</strong> ({b.login}) {b.rollen.join(", ")}
{!b.aktiv && " deaktiviert"} {!b.aktiv && " deaktiviert"}
</span> </span>
<div className="row">
<button className="btn btn-secondary" onClick={() => bearbeiten(b)}>
Bearbeiten
</button>
<button className="btn btn-secondary" onClick={() => aktivSchalten(b)}> <button className="btn btn-secondary" onClick={() => aktivSchalten(b)}>
{b.aktiv ? "Deaktivieren" : "Aktivieren"} {b.aktiv ? "Deaktivieren" : "Aktivieren"}
</button> </button>
</div>
</div>
)}
</li> </li>
))} ))}
</ul> </ul>
+40 -2
View File
@@ -12,6 +12,8 @@ interface Props {
export function BereichSection({ bereiche, onGeaendert, onFehler }: Props) { export function BereichSection({ bereiche, onGeaendert, onFehler }: Props) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [wirdAngelegt, setWirdAngelegt] = useState(false); const [wirdAngelegt, setWirdAngelegt] = useState(false);
const [bearbeiteteId, setBearbeiteteId] = useState<number | null>(null);
const [bearbeitungsName, setBearbeitungsName] = useState("");
async function anlegen() { async function anlegen() {
setWirdAngelegt(true); 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 ( return (
<section className="section"> <section className="section">
<h2>Bereich anlegen</h2> <h2>Bereich anlegen</h2>
@@ -39,8 +56,29 @@ export function BereichSection({ bereiche, onGeaendert, onFehler }: Props) {
{bereiche.length > 0 && ( {bereiche.length > 0 && (
<ul className="card-list"> <ul className="card-list">
{bereiche.map((b) => ( {bereiche.map((b) => (
<li key={b.id} style={{ padding: "0.4rem 0" }}> <li key={b.id} className="row-between" style={{ padding: "0.4rem 0" }}>
{b.name} {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> </li>
))} ))}
</ul> </ul>
+41 -2
View File
@@ -14,6 +14,8 @@ export function KategorieSection({ bereiche, kategorien, onGeaendert, onFehler }
const [name, setName] = useState(""); const [name, setName] = useState("");
const [bereichId, setBereichId] = useState<number | "">(""); const [bereichId, setBereichId] = useState<number | "">("");
const [wirdAngelegt, setWirdAngelegt] = useState(false); const [wirdAngelegt, setWirdAngelegt] = useState(false);
const [bearbeiteteId, setBearbeiteteId] = useState<number | null>(null);
const [bearbeitungsName, setBearbeitungsName] = useState("");
async function anlegen() { async function anlegen() {
if (bereichId === "") { if (bereichId === "") {
@@ -36,6 +38,21 @@ export function KategorieSection({ bereiche, kategorien, onGeaendert, onFehler }
return bereiche.find((b) => b.id === id)?.name ?? `#${id}`; 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 ( return (
<section className="section"> <section className="section">
<h2>Kategorie anlegen</h2> <h2>Kategorie anlegen</h2>
@@ -64,8 +81,30 @@ export function KategorieSection({ bereiche, kategorien, onGeaendert, onFehler }
<ul className="card-list"> <ul className="card-list">
{kategorien.map((k) => ( {kategorien.map((k) => (
<li key={k.id} className="row-between" style={{ padding: "0.4rem 0" }}> <li key={k.id} className="row-between" style={{ padding: "0.4rem 0" }}>
<span>{k.name}</span> {bearbeiteteId === k.id ? (
<span className="text-muted">{bereichName(k.bereich_id)}</span> <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> </li>
))} ))}
</ul> </ul>
@@ -20,6 +20,10 @@ export function ObjektSection({ objekte, vorlagen, standorte, materialien, onGea
const [standortId, setStandortId] = useState<number | "">(""); const [standortId, setStandortId] = useState<number | "">("");
const [wirdAngelegt, setWirdAngelegt] = useState(false); const [wirdAngelegt, setWirdAngelegt] = useState(false);
const [ausgeklapptId, setAusgeklapptId] = useState<number | null>(null); 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() { async function anlegen() {
if (vorlageId === "" || standortId === "") { 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 ( return (
<section className="section"> <section className="section">
<h2>Objekt aus Vorlage anlegen</h2> <h2>Objekt aus Vorlage anlegen</h2>
@@ -107,18 +135,57 @@ export function ObjektSection({ objekte, vorlagen, standorte, materialien, onGea
<span> <span>
{o.name} <span className="text-muted">({o.code})</span> {o.name} <span className="text-muted">({o.code})</span>
</span> </span>
<div className="row">
<button <button
className="btn btn-secondary" className="btn btn-secondary"
onClick={() => setAusgeklapptId(ausgeklapptId === o.id ? null : o.id)} onClick={() => setAusgeklapptId(ausgeklapptId === o.id ? null : o.id)}
> >
{ausgeklapptId === o.id ? "Positionen ausblenden" : "Ablauf/Charge/SN pflegen"} {ausgeklapptId === o.id ? "Positionen ausblenden" : "Ablauf/Charge/SN pflegen"}
</button> </button>
<button className="btn btn-secondary" onClick={() => duplizierenStarten(o)}>
{duplizierId === o.id ? "Schließen" : "Duplizieren"}
</button>
</div>
</div> </div>
{ausgeklapptId === o.id && ( {ausgeklapptId === o.id && (
<div style={{ marginTop: "0.5rem" }}> <div style={{ marginTop: "0.5rem" }}>
<ObjektPositionenPanel objektId={o.id} materialien={materialien} onFehler={onFehler} /> <ObjektPositionenPanel objektId={o.id} materialien={materialien} onFehler={onFehler} />
</div> </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> </li>
))} ))}
</ul> </ul>
+40 -2
View File
@@ -16,6 +16,23 @@ export function ObjekttypSection({ bereiche, kategorien, objekttypen, onGeaender
const [bereichId, setBereichId] = useState<number | "">(""); const [bereichId, setBereichId] = useState<number | "">("");
const [kategorieId, setKategorieId] = useState<number | "">(""); const [kategorieId, setKategorieId] = useState<number | "">("");
const [wirdAngelegt, setWirdAngelegt] = useState(false); 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() { async function anlegen() {
if (bereichId === "") { if (bereichId === "") {
@@ -74,8 +91,29 @@ 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} style={{ padding: "0.4rem 0" }}> <li key={o.id} className="row-between" style={{ padding: "0.4rem 0" }}>
{o.name} {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> </li>
))} ))}
</ul> </ul>
@@ -12,6 +12,8 @@ interface Props {
export function StandortSection({ standorte, onGeaendert, onFehler }: Props) { export function StandortSection({ standorte, onGeaendert, onFehler }: Props) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [wirdAngelegt, setWirdAngelegt] = useState(false); const [wirdAngelegt, setWirdAngelegt] = useState(false);
const [bearbeiteteId, setBearbeiteteId] = useState<number | null>(null);
const [bearbeitungsName, setBearbeitungsName] = useState("");
async function anlegen() { async function anlegen() {
setWirdAngelegt(true); 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 ( return (
<section className="section"> <section className="section">
<h2>Standort anlegen</h2> <h2>Standort anlegen</h2>
@@ -45,7 +62,28 @@ export function StandortSection({ standorte, onGeaendert, onFehler }: Props) {
<ul className="card-list"> <ul className="card-list">
{standorte.map((s) => ( {standorte.map((s) => (
<li key={s.id} className="row-between" style={{ padding: "0.4rem 0" }}> <li key={s.id} className="row-between" style={{ padding: "0.4rem 0" }}>
{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> <span>{s.name}</span>
<button className="btn btn-secondary" onClick={() => bearbeiten(s)}>
Bearbeiten
</button>
</>
)}
</li> </li>
))} ))}
</ul> </ul>
+96 -3
View File
@@ -24,6 +24,40 @@ export function VorlageSection({ vorlagen, objekttypen, materialien, onGeaendert
const [objekttypId, setObjekttypId] = useState<number | "">(""); const [objekttypId, setObjekttypId] = useState<number | "">("");
const [zeilen, setZeilen] = useState<PositionZeile[]>([LEERE_ZEILE]); const [zeilen, setZeilen] = useState<PositionZeile[]>([LEERE_ZEILE]);
const [wirdAngelegt, setWirdAngelegt] = useState(false); 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>) { function zeileAendern(index: number, zeile: Partial<PositionZeile>) {
setZeilen((bisherige) => bisherige.map((z, i) => (i === index ? { ...z, ...zeile } : z))); 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 && ( {vorlagen.length > 0 && (
<ul className="card-list" style={{ marginTop: "1.25rem" }}> <ul className="card-list" style={{ marginTop: "1.25rem" }}>
{vorlagen.map((v) => ( {vorlagen.map((v) => (
<li key={v.id} className="row-between" style={{ padding: "0.4rem 0" }}> <li key={v.id} style={{ padding: "0.4rem 0" }}>
<span>{v.name}</span> <div className="row-between">
<span className="badge badge-neutral">v{v.version}</span> <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> </li>
))} ))}
</ul> </ul>