feat(akte): FILE-009 Freitext-Notizen an beliebiger Ressource
CI / backend-tests (push) Successful in 2m39s
CI / frontend-build (push) Successful in 20s

Polymorphes akte_notiz-Modell (gleiches entitaet_typ/entitaet_id-Muster wie
Dokument/Historie/Mangel), CRUD-Endpunkte, NotizenPanel im Akte-Grid. Löschen
nur durch Autor oder Administrator.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-10 00:49:47 +02:00
co-authored by Claude Sonnet 5
parent 9c6426190e
commit 1e8d66f636
13 changed files with 366 additions and 0 deletions
+9
View File
@@ -157,6 +157,15 @@ export interface Dokument {
ist_original: boolean;
}
export interface AkteNotiz {
id: string;
entitaet_typ: string;
entitaet_id: string;
text: string;
autor_id: number;
erstellt_am: string;
}
export interface Lagerbewegung {
id: string;
objekt_id: number;
+105
View File
@@ -0,0 +1,105 @@
import { useEffect, useState } from "react";
import { ApiError, apiRequest } from "../api/client";
import type { AkteNotiz } from "../api/types";
interface Props {
entitaetTyp: string;
entitaetId: string;
onFehler: (text: string) => void;
}
function formatZeitpunkt(iso: string): string {
return new Date(iso).toLocaleString("de-DE");
}
// FILE-009: formlose Freitext-Notizen, gleiche entitaet_typ/entitaet_id-
// Adressierung wie DokumentePanel.
export function NotizenPanel({ entitaetTyp, entitaetId, onFehler }: Props) {
const [notizen, setNotizen] = useState<AkteNotiz[]>([]);
const [laedt, setLaedt] = useState(true);
const [text, setText] = useState("");
const [wirdAngelegt, setWirdAngelegt] = useState(false);
async function laden() {
setLaedt(true);
try {
const daten = await apiRequest<AkteNotiz[]>(
`/notizen?entitaet_typ=${entitaetTyp}&entitaet_id=${encodeURIComponent(entitaetId)}`
);
setNotizen(daten);
} catch {
onFehler("Notizen konnten nicht geladen werden.");
} finally {
setLaedt(false);
}
}
useEffect(() => {
laden();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [entitaetTyp, entitaetId]);
async function anlegen() {
if (!text.trim()) return;
setWirdAngelegt(true);
try {
await apiRequest("/notizen", {
method: "POST",
body: { entitaet_typ: entitaetTyp, entitaet_id: entitaetId, text: text.trim() },
});
setText("");
await laden();
} catch {
onFehler("Notiz konnte nicht angelegt werden.");
} finally {
setWirdAngelegt(false);
}
}
async function loeschen(notiz: AkteNotiz) {
try {
await apiRequest(`/notizen/${notiz.id}`, { method: "DELETE" });
await laden();
} catch (fehler) {
if (fehler instanceof ApiError && fehler.status === 403) {
onFehler("Nur eigene Notizen oder als Administrator löschbar.");
} else {
onFehler("Notiz konnte nicht gelöscht werden.");
}
}
}
return (
<div className="stack">
<div className="row" style={{ flexWrap: "wrap", gap: "0.4rem" }}>
<input
className="input"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Notiz hinzufügen…"
style={{ flex: 1, minWidth: "12rem" }}
/>
<button className="btn btn-primary" onClick={anlegen} disabled={!text.trim() || wirdAngelegt}>
Hinzufügen
</button>
</div>
{laedt && <p className="text-muted">Lade</p>}
{!laedt && notizen.length === 0 && <p className="text-muted">Keine Notizen.</p>}
{!laedt && notizen.length > 0 && (
<ul className="card-list">
{notizen.map((n) => (
<li key={n.id} className="row-between" style={{ padding: "0.3rem 0" }}>
<span>
{n.text} <span className="text-muted">({formatZeitpunkt(n.erstellt_am)})</span>
</span>
<button className="btn btn-secondary" onClick={() => loeschen(n)}>
Löschen
</button>
</li>
))}
</ul>
)}
</div>
);
}
+6
View File
@@ -5,6 +5,7 @@ import { apiRequest, ladeObjektEtikett } from "../api/client";
import { useAuth } from "../auth/AuthContext";
import type { Akte } from "../api/types";
import { DokumentePanel } from "../components/DokumentePanel";
import { NotizenPanel } from "../components/NotizenPanel";
import { WartungSection } from "../components/WartungSection";
import { ReadinessBadge } from "../components/status/ReadinessBadge";
import { GRUND_TEXT } from "../components/status/gruendeText";
@@ -283,6 +284,11 @@ export function AktePage() {
<DokumentePanel entitaetTyp="objekt" entitaetId={String(objekt.id)} onFehler={setFehler} />
</div>
<div className="akte-notizen card">
<h3 style={{ marginTop: 0 }}>Notizen</h3>
<NotizenPanel entitaetTyp="objekt" entitaetId={String(objekt.id)} onFehler={setFehler} />
</div>
<div className="akte-historie card">
<h3 style={{ marginTop: 0 }}>Historie</h3>
{akte.historie.length === 0 && <p className="text-muted">Noch keine Änderungen protokolliert.</p>}
+2
View File
@@ -12,6 +12,7 @@
"verantwortlich"
"maengel"
"dokumente"
"notizen"
"historie";
}
@@ -24,4 +25,5 @@
.akte-verantwortlich { grid-area: verantwortlich; }
.akte-maengel { grid-area: maengel; }
.akte-dokumente { grid-area: dokumente; }
.akte-notizen { grid-area: notizen; }
.akte-historie { grid-area: historie; }
@@ -19,6 +19,7 @@
"pruefungen beladung"
"maengel verantwortlich"
"dokumente dokumente"
"notizen notizen"
"historie historie";
}
}