- 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
162 lines
6.0 KiB
TypeScript
162 lines
6.0 KiB
TypeScript
"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>
|
|
);
|
|
}
|