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:
m
2026-03-30 11:26:25 +02:00
parent 8e65463130
commit 642877ae54
17 changed files with 1501 additions and 137 deletions

View File

@@ -17,6 +17,7 @@ import {
StickyNote,
AlertTriangle,
ScrollText,
FilePlus,
} from "lucide-react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
@@ -171,19 +172,28 @@ export default function CaseDetailLayout({
{caseDetail.court_ref && <span>({caseDetail.court_ref})</span>}
</div>
</div>
<div className="text-right text-xs text-neutral-400">
<p>
Erstellt:{" "}
{format(new Date(caseDetail.created_at), "d. MMM yyyy", {
locale: de,
})}
</p>
<p>
Aktualisiert:{" "}
{format(new Date(caseDetail.updated_at), "d. MMM yyyy", {
locale: de,
})}
</p>
<div className="flex flex-col items-end gap-2">
<Link
href={`/vorlagen?case_id=${id}`}
className="flex items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
>
<FilePlus className="h-3.5 w-3.5" />
Schriftsatz erstellen
</Link>
<div className="text-right text-xs text-neutral-400">
<p>
Erstellt:{" "}
{format(new Date(caseDetail.created_at), "d. MMM yyyy", {
locale: de,
})}
</p>
<p>
Aktualisiert:{" "}
{format(new Date(caseDetail.updated_at), "d. MMM yyyy", {
locale: de,
})}
</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,174 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useParams, useRouter } from "next/navigation";
import { api } from "@/lib/api";
import type { DocumentTemplate } from "@/lib/types";
import { TEMPLATE_CATEGORY_LABELS } from "@/lib/types";
import { Breadcrumb } from "@/components/layout/Breadcrumb";
import { TemplateEditor } from "@/components/templates/TemplateEditor";
import Link from "next/link";
import {
Loader2,
Lock,
Trash2,
FileDown,
ArrowRight,
} from "lucide-react";
import { toast } from "sonner";
import { useState } from "react";
export default function TemplateDetailPage() {
const { id } = useParams<{ id: string }>();
const router = useRouter();
const queryClient = useQueryClient();
const [isEditing, setIsEditing] = useState(false);
const { data: template, isLoading } = useQuery({
queryKey: ["template", id],
queryFn: () => api.get<DocumentTemplate>(`/templates/${id}`),
});
const deleteMutation = useMutation({
mutationFn: () => api.delete(`/templates/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["templates"] });
toast.success("Vorlage gelöscht");
router.push("/vorlagen");
},
onError: () => toast.error("Fehler beim Löschen"),
});
const updateMutation = useMutation({
mutationFn: (data: Partial<DocumentTemplate>) =>
api.put<DocumentTemplate>(`/templates/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["template", id] });
queryClient.invalidateQueries({ queryKey: ["templates"] });
toast.success("Vorlage gespeichert");
setIsEditing(false);
},
onError: () => toast.error("Fehler beim Speichern"),
});
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
</div>
);
}
if (!template) {
return (
<div className="py-12 text-center text-sm text-neutral-500">
Vorlage nicht gefunden
</div>
);
}
return (
<div className="animate-fade-in space-y-4">
<Breadcrumb
items={[
{ label: "Dashboard", href: "/dashboard" },
{ label: "Vorlagen", href: "/vorlagen" },
{ label: template.name },
]}
/>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-neutral-900">
{template.name}
</h1>
{template.is_system && (
<Lock className="h-4 w-4 text-neutral-400" aria-label="Systemvorlage" />
)}
</div>
<div className="mt-1 flex items-center gap-2">
<span className="rounded-full bg-neutral-100 px-2 py-0.5 text-xs text-neutral-600">
{TEMPLATE_CATEGORY_LABELS[template.category] ?? template.category}
</span>
{template.description && (
<span className="text-xs text-neutral-500">
{template.description}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Link
href={`/vorlagen/${id}/render`}
className="flex items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
>
<FileDown className="h-3.5 w-3.5" />
Dokument erstellen
<ArrowRight className="h-3.5 w-3.5" />
</Link>
{!template.is_system && (
<>
<button
onClick={() => setIsEditing(!isEditing)}
className="rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm text-neutral-700 transition-colors hover:bg-neutral-50"
>
{isEditing ? "Abbrechen" : "Bearbeiten"}
</button>
<button
onClick={() => {
if (confirm("Vorlage wirklich löschen?")) {
deleteMutation.mutate();
}
}}
className="rounded-md border border-red-200 bg-white p-1.5 text-red-600 transition-colors hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</button>
</>
)}
</div>
</div>
{isEditing ? (
<TemplateEditor
template={template}
onSave={(data) => updateMutation.mutate(data)}
isSaving={updateMutation.isPending}
/>
) : (
<div className="space-y-4">
{/* Variables */}
{template.variables && template.variables.length > 0 && (
<div className="rounded-lg border border-neutral-200 bg-white p-4">
<h3 className="mb-2 text-sm font-medium text-neutral-700">
Variablen
</h3>
<div className="flex flex-wrap gap-1.5">
{template.variables.map((v: string) => (
<code
key={v}
className="rounded bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-600"
>
{`{{${v}}}`}
</code>
))}
</div>
</div>
)}
{/* Content preview */}
<div className="rounded-lg border border-neutral-200 bg-white p-6">
<h3 className="mb-3 text-sm font-medium text-neutral-700">
Vorschau
</h3>
<div className="prose prose-sm prose-neutral max-w-none whitespace-pre-wrap font-mono text-xs leading-relaxed text-neutral-700">
{template.content}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,177 @@
"use client";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import { api } from "@/lib/api";
import type { DocumentTemplate, Case, RenderResponse } from "@/lib/types";
import { Breadcrumb } from "@/components/layout/Breadcrumb";
import {
Loader2,
FileDown,
Copy,
Check,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
export default function RenderTemplatePage() {
const { id } = useParams<{ id: string }>();
const [selectedCaseId, setSelectedCaseId] = useState("");
const [rendered, setRendered] = useState<RenderResponse | null>(null);
const [copied, setCopied] = useState(false);
const { data: template, isLoading: templateLoading } = useQuery({
queryKey: ["template", id],
queryFn: () => api.get<DocumentTemplate>(`/templates/${id}`),
});
const { data: casesData, isLoading: casesLoading } = useQuery({
queryKey: ["cases"],
queryFn: () =>
api.get<{ data: Case[]; total: number }>("/cases?limit=100"),
});
const cases = casesData?.data ?? [];
const renderMutation = useMutation({
mutationFn: () =>
api.post<RenderResponse>(
`/templates/${id}/render${selectedCaseId ? `?case_id=${selectedCaseId}` : ""}`,
),
onSuccess: (data) => setRendered(data),
onError: () => toast.error("Fehler beim Erstellen"),
});
const handleCopy = async () => {
if (!rendered) return;
await navigator.clipboard.writeText(rendered.content);
setCopied(true);
toast.success("In Zwischenablage kopiert");
setTimeout(() => setCopied(false), 2000);
};
const handleDownload = () => {
if (!rendered) return;
const blob = new Blob([rendered.content], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${rendered.name.replace(/\s+/g, "_")}.md`;
a.click();
URL.revokeObjectURL(url);
toast.success("Dokument heruntergeladen");
};
if (templateLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
</div>
);
}
if (!template) {
return (
<div className="py-12 text-center text-sm text-neutral-500">
Vorlage nicht gefunden
</div>
);
}
return (
<div className="animate-fade-in space-y-4">
<Breadcrumb
items={[
{ label: "Dashboard", href: "/dashboard" },
{ label: "Vorlagen", href: "/vorlagen" },
{ label: template.name, href: `/vorlagen/${id}` },
{ label: "Dokument erstellen" },
]}
/>
<h1 className="text-lg font-semibold text-neutral-900">
Dokument erstellen
</h1>
<p className="text-sm text-neutral-500">
Vorlage &ldquo;{template.name}&rdquo; mit Falldaten befüllen
</p>
{/* Step 1: Select case */}
<div className="rounded-lg border border-neutral-200 bg-white p-4">
<h3 className="mb-3 text-sm font-medium text-neutral-700">
1. Akte auswählen
</h3>
{casesLoading ? (
<Loader2 className="h-4 w-4 animate-spin text-neutral-400" />
) : (
<select
value={selectedCaseId}
onChange={(e) => {
setSelectedCaseId(e.target.value);
setRendered(null);
}}
className="w-full rounded-md border border-neutral-200 px-3 py-2 text-sm text-neutral-700 focus:border-neutral-400 focus:outline-none"
>
<option value="">Ohne Akte (nur Datumsvariablen)</option>
{cases.map((c) => (
<option key={c.id} value={c.id}>
{c.case_number} {c.title}
</option>
))}
</select>
)}
</div>
{/* Step 2: Render */}
<div className="rounded-lg border border-neutral-200 bg-white p-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-neutral-700">
2. Vorschau erstellen
</h3>
<button
onClick={() => renderMutation.mutate()}
disabled={renderMutation.isPending}
className="flex items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800 disabled:opacity-50"
>
{renderMutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<FileDown className="h-3.5 w-3.5" />
)}
Vorschau
</button>
</div>
{rendered && (
<div className="mt-4">
<div className="mb-2 flex items-center justify-end gap-2">
<button
onClick={handleCopy}
className="flex items-center gap-1 rounded-md border border-neutral-200 px-2.5 py-1 text-xs text-neutral-600 transition-colors hover:bg-neutral-50"
>
{copied ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
{copied ? "Kopiert" : "Kopieren"}
</button>
<button
onClick={handleDownload}
className="flex items-center gap-1 rounded-md border border-neutral-200 px-2.5 py-1 text-xs text-neutral-600 transition-colors hover:bg-neutral-50"
>
<FileDown className="h-3 w-3" />
Herunterladen
</button>
</div>
<div className="rounded-lg border border-neutral-200 bg-neutral-50 p-6">
<div className="whitespace-pre-wrap font-mono text-xs leading-relaxed text-neutral-700">
{rendered.content}
</div>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,46 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
import type { DocumentTemplate } from "@/lib/types";
import { Breadcrumb } from "@/components/layout/Breadcrumb";
import { TemplateEditor } from "@/components/templates/TemplateEditor";
import { toast } from "sonner";
export default function NeueVorlagePage() {
const router = useRouter();
const queryClient = useQueryClient();
const createMutation = useMutation({
mutationFn: (data: Partial<DocumentTemplate>) =>
api.post<DocumentTemplate>("/templates", data),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ["templates"] });
toast.success("Vorlage erstellt");
router.push(`/vorlagen/${result.id}`);
},
onError: () => toast.error("Fehler beim Erstellen"),
});
return (
<div className="animate-fade-in space-y-4">
<Breadcrumb
items={[
{ label: "Dashboard", href: "/dashboard" },
{ label: "Vorlagen", href: "/vorlagen" },
{ label: "Neue Vorlage" },
]}
/>
<h1 className="text-lg font-semibold text-neutral-900">
Neue Vorlage erstellen
</h1>
<TemplateEditor
onSave={(data) => createMutation.mutate(data)}
isSaving={createMutation.isPending}
/>
</div>
);
}

View File

@@ -0,0 +1,121 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type { DocumentTemplate } from "@/lib/types";
import { TEMPLATE_CATEGORY_LABELS } from "@/lib/types";
import { Breadcrumb } from "@/components/layout/Breadcrumb";
import Link from "next/link";
import { FileText, Plus, Loader2, Lock } from "lucide-react";
import { useState } from "react";
const CATEGORIES = ["", "schriftsatz", "vertrag", "korrespondenz", "intern"];
export default function VorlagenPage() {
const [category, setCategory] = useState("");
const { data, isLoading } = useQuery({
queryKey: ["templates", category],
queryFn: () =>
api.get<{ data: DocumentTemplate[]; total: number }>(
`/templates${category ? `?category=${category}` : ""}`,
),
});
const templates = data?.data ?? [];
return (
<div className="animate-fade-in space-y-4">
<div>
<Breadcrumb
items={[
{ label: "Dashboard", href: "/dashboard" },
{ label: "Vorlagen" },
]}
/>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-lg font-semibold text-neutral-900">
Vorlagen
</h1>
<p className="mt-0.5 text-sm text-neutral-500">
Dokumentvorlagen mit automatischer Befüllung
</p>
</div>
<Link
href="/vorlagen/neu"
className="flex items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
>
<Plus className="h-3.5 w-3.5" />
Neue Vorlage
</Link>
</div>
</div>
{/* Category filter */}
<div className="flex gap-1.5 overflow-x-auto">
{CATEGORIES.map((cat) => (
<button
key={cat}
onClick={() => setCategory(cat)}
className={`whitespace-nowrap rounded-md px-3 py-1.5 text-sm transition-colors ${
category === cat
? "bg-neutral-900 font-medium text-white"
: "bg-white text-neutral-600 ring-1 ring-neutral-200 hover:bg-neutral-50"
}`}
>
{cat === "" ? "Alle" : TEMPLATE_CATEGORY_LABELS[cat] ?? cat}
</button>
))}
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
</div>
) : templates.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-neutral-300 py-12 text-center">
<FileText className="mb-2 h-8 w-8 text-neutral-300" />
<p className="text-sm text-neutral-500">Keine Vorlagen gefunden</p>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{templates.map((t) => (
<Link
key={t.id}
href={`/vorlagen/${t.id}`}
className="group rounded-lg border border-neutral-200 bg-white p-4 transition-colors hover:border-neutral-300 hover:shadow-sm"
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-neutral-400" />
<h3 className="text-sm font-medium text-neutral-900 group-hover:text-neutral-700">
{t.name}
</h3>
</div>
{t.is_system && (
<Lock className="h-3.5 w-3.5 text-neutral-300" aria-label="Systemvorlage" />
)}
</div>
{t.description && (
<p className="mt-1.5 text-xs text-neutral-500 line-clamp-2">
{t.description}
</p>
)}
<div className="mt-3 flex items-center gap-2">
<span className="rounded-full bg-neutral-100 px-2 py-0.5 text-xs text-neutral-600">
{TEMPLATE_CATEGORY_LABELS[t.category] ?? t.category}
</span>
{t.is_system && (
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs text-blue-600">
System
</span>
)}
</div>
</Link>
))}
</div>
)}
</div>
);
}

View File

@@ -9,6 +9,7 @@ import {
Calendar,
Brain,
Settings,
FileText,
Menu,
X,
} from "lucide-react";
@@ -27,6 +28,7 @@ const allNavigation: NavItem[] = [
{ name: "Akten", href: "/cases", icon: FolderOpen },
{ name: "Fristen", href: "/fristen", icon: Clock },
{ name: "Termine", href: "/termine", icon: Calendar },
{ name: "Vorlagen", href: "/vorlagen", icon: FileText },
{ name: "AI Analyse", href: "/ai/extract", icon: Brain, permission: "ai_extraction" },
{ name: "Einstellungen", href: "/einstellungen", icon: Settings, permission: "manage_settings" },
];

View 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&#10;&#10;Schreiben Sie hier den Vorlageninhalt...&#10;&#10;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>
);
}

View File

@@ -223,6 +223,82 @@ export const CASE_ASSIGNMENT_ROLE_LABELS: Record<CaseAssignmentRole, string> = {
viewer: "Einsicht",
};
// Document Templates
export interface DocumentTemplate {
id: string;
tenant_id?: string;
name: string;
description?: string;
category: "schriftsatz" | "vertrag" | "korrespondenz" | "intern";
content: string;
variables: string[];
is_system: boolean;
created_at: string;
updated_at: string;
}
export const TEMPLATE_CATEGORY_LABELS: Record<string, string> = {
schriftsatz: "Schriftsatz",
vertrag: "Vertrag",
korrespondenz: "Korrespondenz",
intern: "Intern",
};
export interface RenderResponse {
content: string;
template_id: string;
name: string;
}
// Notifications
export interface Notification {
id: string;
tenant_id: string;
user_id: string;
type: string;
title: string;
message: string;
body?: string;
entity_type?: string;
entity_id?: string;
read: boolean;
read_at?: string;
created_at: string;
}
export interface NotificationListResponse {
data: Notification[];
notifications: Notification[];
total: number;
unread_count: number;
}
export interface NotificationPreferences {
deadline_reminder_days: number[];
email_enabled: boolean;
daily_digest: boolean;
}
// Audit Log
export interface AuditLogEntry {
id: string;
tenant_id: string;
user_id?: string;
action: string;
entity_type: string;
entity_id?: string;
old_values?: Record<string, unknown>;
new_values?: Record<string, unknown>;
ip_address?: string;
user_agent?: string;
created_at: string;
}
export interface AuditLogResponse {
entries: AuditLogEntry[];
total: number;
}
export interface ApiError {
error: string;
status: number;