feat: document templates with auto-fill from case data (P1)
- Database: kanzlai.document_templates table with RLS policies
- Seed: 4 system templates (Klageerwiderung UPC, Berufungsschrift,
Mandatsbestätigung, Kostenrechnung)
- Backend: TemplateService (CRUD + render), TemplateHandler with
endpoints: GET/POST /api/templates, GET/PUT/DELETE /api/templates/{id},
POST /api/templates/{id}/render?case_id=X
- Template variables: case.*, party.*, tenant.*, user.*, date.*, deadline.*
- Frontend: /vorlagen page with category filters, template detail/editor,
render flow (select case -> preview -> copy/download), variable toolbar
- Quick action: "Schriftsatz erstellen" button on case detail page
- Also: resolved merge conflicts between audit-trail and role-based branches,
added missing Notification/AuditLog types to frontend
This commit is contained in:
161
frontend/src/components/templates/TemplateEditor.tsx
Normal file
161
frontend/src/components/templates/TemplateEditor.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import type { DocumentTemplate } from "@/lib/types";
|
||||
import { TEMPLATE_CATEGORY_LABELS } from "@/lib/types";
|
||||
import { Loader2, Plus } from "lucide-react";
|
||||
import { useState, useRef } from "react";
|
||||
|
||||
const AVAILABLE_VARIABLES = [
|
||||
{ group: "Akte", vars: ["case.number", "case.title", "case.court", "case.court_ref"] },
|
||||
{ group: "Parteien", vars: ["party.claimant.name", "party.defendant.name", "party.claimant.representative", "party.defendant.representative"] },
|
||||
{ group: "Kanzlei", vars: ["tenant.name", "tenant.address"] },
|
||||
{ group: "Benutzer", vars: ["user.name", "user.email"] },
|
||||
{ group: "Datum", vars: ["date.today", "date.today_long"] },
|
||||
{ group: "Frist", vars: ["deadline.title", "deadline.due_date"] },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
template?: DocumentTemplate;
|
||||
onSave: (data: Partial<DocumentTemplate>) => void;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
export function TemplateEditor({ template, onSave, isSaving }: Props) {
|
||||
const [name, setName] = useState(template?.name ?? "");
|
||||
const [description, setDescription] = useState(template?.description ?? "");
|
||||
const [category, setCategory] = useState<string>(template?.category ?? "schriftsatz");
|
||||
const [content, setContent] = useState(template?.content ?? "");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const insertVariable = (variable: string) => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const placeholder = `{{${variable}}}`;
|
||||
const start = el.selectionStart;
|
||||
const end = el.selectionEnd;
|
||||
const newContent =
|
||||
content.substring(0, start) + placeholder + content.substring(end);
|
||||
setContent(newContent);
|
||||
|
||||
// Restore cursor position after the inserted text
|
||||
requestAnimationFrame(() => {
|
||||
el.focus();
|
||||
el.selectionStart = el.selectionEnd = start + placeholder.length;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!name.trim()) return;
|
||||
onSave({
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
category: category as DocumentTemplate["category"],
|
||||
content,
|
||||
variables: AVAILABLE_VARIABLES.flatMap((g) => g.vars).filter((v) =>
|
||||
content.includes(`{{${v}}}`),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Metadata */}
|
||||
<div className="grid gap-3 rounded-lg border border-neutral-200 bg-white p-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-neutral-600">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="z.B. Klageerwiderung"
|
||||
className="w-full rounded-md border border-neutral-200 px-3 py-2 text-sm focus:border-neutral-400 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-neutral-600">
|
||||
Kategorie
|
||||
</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="w-full rounded-md border border-neutral-200 px-3 py-2 text-sm focus:border-neutral-400 focus:outline-none"
|
||||
>
|
||||
{Object.entries(TEMPLATE_CATEGORY_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="mb-1 block text-xs font-medium text-neutral-600">
|
||||
Beschreibung
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Optionale Beschreibung"
|
||||
className="w-full rounded-md border border-neutral-200 px-3 py-2 text-sm focus:border-neutral-400 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Variable toolbar */}
|
||||
<div className="rounded-lg border border-neutral-200 bg-white p-4">
|
||||
<h3 className="mb-2 text-xs font-medium text-neutral-600">
|
||||
Variablen einfügen
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{AVAILABLE_VARIABLES.map((group) => (
|
||||
<div key={group.group} className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-xs font-medium text-neutral-400 w-16 shrink-0">
|
||||
{group.group}
|
||||
</span>
|
||||
{group.vars.map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => insertVariable(v)}
|
||||
className="flex items-center gap-0.5 rounded bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-600 transition-colors hover:bg-neutral-200"
|
||||
>
|
||||
<Plus className="h-2.5 w-2.5" />
|
||||
{v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content editor */}
|
||||
<div className="rounded-lg border border-neutral-200 bg-white p-4">
|
||||
<label className="mb-2 block text-xs font-medium text-neutral-600">
|
||||
Inhalt (Markdown)
|
||||
</label>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
rows={24}
|
||||
placeholder="# Dokumenttitel Schreiben Sie hier den Vorlageninhalt... Verwenden Sie {{variablen}} für automatische Befüllung."
|
||||
className="w-full rounded-md border border-neutral-200 px-3 py-2 font-mono text-sm leading-relaxed focus:border-neutral-400 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Save button */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!name.trim() || isSaving}
|
||||
className="flex items-center gap-1.5 rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-neutral-800 disabled:opacity-50"
|
||||
>
|
||||
{isSaving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
{template ? "Speichern" : "Vorlage erstellen"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user