feat: Frontend-Formulare fuer Entscheidungen und Normen anlegen

- /entscheidungen/new: Formular fuer neue Entscheidungen (Typ, Gericht, Aktenzeichen, Datum, Leitsatz, Tenor, Sachverhalt, Gruende, Volltext, Rechtsgebiete, Schlagwoerter)
- /normen/new: Formular fuer neue Regelwerke mit optionalen Paragraphen inline
- POST /api/norms: Neue API-Route fuer normInstrument-Erstellung
- Buttons auf den Listenseiten /entscheidungen und /normen

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
CTO (LegalAI)
2026-04-09 11:31:11 +00:00
parent 7b1407268b
commit 58d96864cc
5 changed files with 652 additions and 5 deletions

View File

@@ -0,0 +1,262 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
const DECISION_TYPES = [
{ value: 'schiedsspruch', label: 'Schiedsspruch' },
{ value: 'urteil', label: 'Urteil' },
{ value: 'beschluss', label: 'Beschluss' },
{ value: 'vergleich', label: 'Vergleich' },
{ value: 'einstweilige_verfuegung', label: 'Einstweilige Verfügung' },
];
export default function NewDecisionPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError('');
setLoading(true);
const form = new FormData(e.currentTarget);
const domainsRaw = (form.get('domains') as string) || '';
const keywordsRaw = (form.get('keywords') as string) || '';
const body = {
type: form.get('type'),
court: form.get('court'),
caseReference: form.get('caseReference') || undefined,
decisionDate: form.get('decisionDate'),
chamber: form.get('chamber') || undefined,
headnote: form.get('headnote') || undefined,
tenor: form.get('tenor') || undefined,
facts: form.get('facts') || undefined,
reasoning: form.get('reasoning') || undefined,
fullText: form.get('fullText') || undefined,
domains: domainsRaw ? domainsRaw.split(',').map((s) => s.trim()).filter(Boolean) : [],
keywords: keywordsRaw ? keywordsRaw.split(',').map((s) => s.trim()).filter(Boolean) : [],
};
try {
const res = await fetch('/api/decisions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(data?.error ?? 'Entscheidung konnte nicht angelegt werden.');
}
const data = await res.json();
router.push(`/entscheidungen/${data.decision.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten.');
setLoading(false);
}
}
const inputClass =
'w-full rounded-lg border border-card-border px-3 py-2.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary';
return (
<div className="space-y-6">
<div className="flex items-center gap-2 text-sm text-muted">
<Link href="/entscheidungen" className="hover:text-primary transition-colors">
Entscheidungen
</Link>
<span>/</span>
<span>Neue Entscheidung</span>
</div>
<h1 className="text-xl font-bold text-foreground">Neue Entscheidung anlegen</h1>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 rounded-lg px-4 py-3 text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="bg-card-bg border border-card-border rounded-xl p-6 space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label htmlFor="type" className="block text-sm font-medium text-foreground mb-1">
Typ *
</label>
<select id="type" name="type" required className={inputClass}>
<option value="">Bitte waehlen...</option>
{DECISION_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
<div>
<label htmlFor="court" className="block text-sm font-medium text-foreground mb-1">
Gericht *
</label>
<input
id="court"
name="court"
type="text"
required
placeholder="z.B. Buehnenoberschiedsgericht"
className={inputClass}
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label htmlFor="caseReference" className="block text-sm font-medium text-foreground mb-1">
Aktenzeichen
</label>
<input
id="caseReference"
name="caseReference"
type="text"
placeholder="z.B. BOSchG 3/2024"
className={inputClass}
/>
</div>
<div>
<label htmlFor="decisionDate" className="block text-sm font-medium text-foreground mb-1">
Datum *
</label>
<input id="decisionDate" name="decisionDate" type="date" required className={inputClass} />
</div>
<div>
<label htmlFor="chamber" className="block text-sm font-medium text-foreground mb-1">
Kammer / Senat
</label>
<input
id="chamber"
name="chamber"
type="text"
placeholder="z.B. 3. Kammer"
className={inputClass}
/>
</div>
</div>
<div>
<label htmlFor="headnote" className="block text-sm font-medium text-foreground mb-1">
Leitsatz
</label>
<textarea
id="headnote"
name="headnote"
rows={3}
placeholder="Leitsatz der Entscheidung"
className={inputClass}
/>
</div>
<div>
<label htmlFor="tenor" className="block text-sm font-medium text-foreground mb-1">
Tenor
</label>
<textarea
id="tenor"
name="tenor"
rows={3}
placeholder="Tenor / Urteilsformel"
className={inputClass}
/>
</div>
<div>
<label htmlFor="facts" className="block text-sm font-medium text-foreground mb-1">
Sachverhalt
</label>
<textarea
id="facts"
name="facts"
rows={4}
placeholder="Tatbestand / Sachverhalt"
className={inputClass}
/>
</div>
<div>
<label htmlFor="reasoning" className="block text-sm font-medium text-foreground mb-1">
Entscheidungsgruende
</label>
<textarea
id="reasoning"
name="reasoning"
rows={4}
placeholder="Entscheidungsgruende"
className={inputClass}
/>
</div>
<div>
<label htmlFor="fullText" className="block text-sm font-medium text-foreground mb-1">
Volltext
</label>
<textarea
id="fullText"
name="fullText"
rows={6}
placeholder="Volltext der Entscheidung (optional)"
className={inputClass}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label htmlFor="domains" className="block text-sm font-medium text-foreground mb-1">
Rechtsgebiete
</label>
<input
id="domains"
name="domains"
type="text"
placeholder="z.B. Buehnenrecht, Arbeitsrecht (kommagetrennt)"
className={inputClass}
/>
</div>
<div>
<label htmlFor="keywords" className="block text-sm font-medium text-foreground mb-1">
Schlagwoerter
</label>
<input
id="keywords"
name="keywords"
type="text"
placeholder="z.B. Kuendigungsschutz, NV Buehne (kommagetrennt)"
className={inputClass}
/>
</div>
</div>
<div className="flex items-center gap-3 pt-2">
<button
type="submit"
disabled={loading}
className="px-4 py-2.5 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-light transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Wird angelegt...' : 'Entscheidung anlegen'}
</button>
<Link
href="/entscheidungen"
className="px-4 py-2.5 border border-card-border rounded-lg text-sm font-medium text-muted hover:text-foreground transition-colors"
>
Abbrechen
</Link>
</div>
</form>
</div>
);
}

View File

@@ -49,10 +49,18 @@ export default async function EntscheidungenPage({
return (
<div className="space-y-6">
<div>
<p className="text-sm text-muted">
Entscheidungsdatenbank für Bühnenrecht, Schiedssprüche und Arbeitsgerichtsurteile.
</p>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted">
Entscheidungsdatenbank für Bühnenrecht, Schiedssprüche und Arbeitsgerichtsurteile.
</p>
</div>
<Link
href="/entscheidungen/new"
className="px-4 py-2.5 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-light transition-colors"
>
Neue Entscheidung
</Link>
</div>
<form className="flex gap-2">

View File

@@ -0,0 +1,324 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
const NORM_TYPES = [
{ value: 'gesetz', label: 'Gesetz' },
{ value: 'tarifvertrag', label: 'Tarifvertrag' },
{ value: 'schiedsordnung', label: 'Schiedsordnung' },
{ value: 'verordnung', label: 'Verordnung' },
{ value: 'satzung', label: 'Satzung' },
];
const SOURCE_RANKS = [
{ value: 'gesetz', label: 'Gesetz' },
{ value: 'tarif', label: 'Tarifvertrag' },
{ value: 'schiedsordnung', label: 'Schiedsordnung' },
{ value: 'praxis', label: 'Praxis' },
{ value: 'kommentar', label: 'Kommentar' },
];
interface Provision {
paragraph: string;
title: string;
body: string;
validFrom: string;
}
export default function NewNormPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [provisions, setProvisions] = useState<Provision[]>([]);
function addProvision() {
setProvisions((prev) => [
...prev,
{ paragraph: '', title: '', body: '', validFrom: new Date().toISOString().split('T')[0] },
]);
}
function removeProvision(index: number) {
setProvisions((prev) => prev.filter((_, i) => i !== index));
}
function updateProvision(index: number, field: keyof Provision, value: string) {
setProvisions((prev) =>
prev.map((p, i) => (i === index ? { ...p, [field]: value } : p)),
);
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError('');
setLoading(true);
const form = new FormData(e.currentTarget);
const instrumentBody = {
type: form.get('type'),
sourceRank: form.get('sourceRank'),
abbreviation: form.get('abbreviation'),
fullTitle: form.get('fullTitle'),
enactedAt: form.get('enactedAt') || undefined,
issuingBody: form.get('issuingBody') || undefined,
};
try {
const res = await fetch('/api/norms', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(instrumentBody),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(data?.error ?? 'Regelwerk konnte nicht angelegt werden.');
}
const data = await res.json();
const instrumentId = data.instrument.id;
if (provisions.length > 0) {
const validProvisions = provisions.filter((p) => p.paragraph && p.body && p.validFrom);
if (validProvisions.length > 0) {
const importRes = await fetch('/api/norms/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
instrumentId,
provisions: validProvisions,
}),
});
if (!importRes.ok) {
const importData = await importRes.json().catch(() => null);
throw new Error(
importData?.error ?? 'Normen konnten nicht importiert werden.',
);
}
}
}
router.push(`/normen/${instrumentId}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten.');
setLoading(false);
}
}
const inputClass =
'w-full rounded-lg border border-card-border px-3 py-2.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary';
return (
<div className="space-y-6">
<div className="flex items-center gap-2 text-sm text-muted">
<Link href="/normen" className="hover:text-primary transition-colors">
Normen
</Link>
<span>/</span>
<span>Neues Regelwerk</span>
</div>
<h1 className="text-xl font-bold text-foreground">Neues Regelwerk anlegen</h1>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 rounded-lg px-4 py-3 text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div className="bg-card-bg border border-card-border rounded-xl p-6 space-y-4">
<h2 className="text-sm font-semibold text-foreground">Regelwerk</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label htmlFor="type" className="block text-sm font-medium text-foreground mb-1">
Typ *
</label>
<select id="type" name="type" required className={inputClass}>
<option value="">Bitte waehlen...</option>
{NORM_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
<div>
<label htmlFor="sourceRank" className="block text-sm font-medium text-foreground mb-1">
Quellenrang *
</label>
<select id="sourceRank" name="sourceRank" required className={inputClass}>
<option value="">Bitte waehlen...</option>
{SOURCE_RANKS.map((r) => (
<option key={r.value} value={r.value}>
{r.label}
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label htmlFor="abbreviation" className="block text-sm font-medium text-foreground mb-1">
Abkuerzung *
</label>
<input
id="abbreviation"
name="abbreviation"
type="text"
required
placeholder="z.B. NV Buehne"
className={inputClass}
/>
</div>
<div>
<label htmlFor="fullTitle" className="block text-sm font-medium text-foreground mb-1">
Vollstaendiger Titel *
</label>
<input
id="fullTitle"
name="fullTitle"
type="text"
required
placeholder="z.B. Normalvertrag Buehne"
className={inputClass}
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label htmlFor="enactedAt" className="block text-sm font-medium text-foreground mb-1">
Inkrafttreten
</label>
<input id="enactedAt" name="enactedAt" type="date" className={inputClass} />
</div>
<div>
<label htmlFor="issuingBody" className="block text-sm font-medium text-foreground mb-1">
Herausgeber
</label>
<input
id="issuingBody"
name="issuingBody"
type="text"
placeholder="z.B. GDBA/DBV"
className={inputClass}
/>
</div>
</div>
</div>
<div className="bg-card-bg border border-card-border rounded-xl p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-foreground">
Paragraphen / Normen <span className="font-normal text-muted">(optional)</span>
</h2>
<button
type="button"
onClick={addProvision}
className="px-3 py-1.5 text-xs font-medium text-primary border border-primary/30 rounded-lg hover:bg-primary/5 transition-colors"
>
+ Paragraph hinzufuegen
</button>
</div>
{provisions.length === 0 ? (
<p className="text-sm text-muted">
Noch keine Paragraphen hinzugefuegt. Sie koennen Paragraphen auch spaeter ueber die API importieren.
</p>
) : (
<div className="space-y-4">
{provisions.map((prov, idx) => (
<div key={idx} className="border border-card-border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted">Paragraph {idx + 1}</span>
<button
type="button"
onClick={() => removeProvision(idx)}
className="text-xs text-red-500 hover:text-red-700 transition-colors"
>
Entfernen
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium text-foreground mb-1">
Bezeichnung *
</label>
<input
type="text"
value={prov.paragraph}
onChange={(e) => updateProvision(idx, 'paragraph', e.target.value)}
placeholder="z.B. § 1"
className={inputClass}
/>
</div>
<div>
<label className="block text-xs font-medium text-foreground mb-1">
Titel
</label>
<input
type="text"
value={prov.title}
onChange={(e) => updateProvision(idx, 'title', e.target.value)}
placeholder="z.B. Geltungsbereich"
className={inputClass}
/>
</div>
<div>
<label className="block text-xs font-medium text-foreground mb-1">
Gueltig ab *
</label>
<input
type="date"
value={prov.validFrom}
onChange={(e) => updateProvision(idx, 'validFrom', e.target.value)}
className={inputClass}
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-foreground mb-1">
Normtext *
</label>
<textarea
value={prov.body}
onChange={(e) => updateProvision(idx, 'body', e.target.value)}
rows={3}
placeholder="Vollstaendiger Normtext"
className={inputClass}
/>
</div>
</div>
))}
</div>
)}
</div>
<div className="flex items-center gap-3">
<button
type="submit"
disabled={loading}
className="px-4 py-2.5 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-light transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Wird angelegt...' : 'Regelwerk anlegen'}
</button>
<Link
href="/normen"
className="px-4 py-2.5 border border-card-border rounded-lg text-sm font-medium text-muted hover:text-foreground transition-colors"
>
Abbrechen
</Link>
</div>
</form>
</div>
);
}

View File

@@ -43,6 +43,12 @@ export default async function NormenPage() {
Rechtsquellen nach Quellenrang geordnet. Höherrangige Normen gehen vor.
</p>
</div>
<Link
href="/normen/new"
className="px-4 py-2.5 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-light transition-colors"
>
Neues Regelwerk
</Link>
</div>
<div className="flex flex-wrap gap-2">
@@ -56,7 +62,9 @@ export default async function NormenPage() {
{instruments.length === 0 ? (
<div className="bg-card-bg border border-card-border rounded-xl p-8 text-center">
<p className="text-muted text-sm">Noch keine Normen importiert.</p>
<p className="text-xs text-muted mt-1">Verwenden Sie die API zum Importieren: POST /api/norms/import</p>
<p className="text-xs text-muted mt-1">
<Link href="/normen/new" className="text-primary hover:underline">Neues Regelwerk anlegen</Link> oder per API importieren: POST /api/norms/import
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">

View File

@@ -0,0 +1,45 @@
// POST /api/norms — create a new norm instrument (Regelwerk)
import { db } from "@/lib/db";
import { normInstruments } from "@/lib/db/schema";
export async function POST(request: Request) {
let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
}
const { type, sourceRank, abbreviation, fullTitle, enactedAt, issuingBody, citation } =
body as any;
if (!type || !sourceRank || !abbreviation || !fullTitle) {
return Response.json(
{ error: "type, sourceRank, abbreviation, and fullTitle are required." },
{ status: 400 },
);
}
if (enactedAt && !/^\d{4}-\d{2}-\d{2}$/.test(enactedAt)) {
return Response.json(
{ error: "enactedAt must be YYYY-MM-DD." },
{ status: 400 },
);
}
const [created] = await db
.insert(normInstruments)
.values({
type,
sourceRank,
abbreviation,
fullTitle,
enactedAt: enactedAt ?? null,
issuingBody: issuingBody ?? null,
citation: citation ?? null,
})
.returning();
return Response.json({ instrument: created }, { status: 201 });
}