From 642877ae5408ac50d81d244b112f8d394dda4c19 Mon Sep 17 00:00:00 2001 From: m Date: Mon, 30 Mar 2026 11:26:25 +0200 Subject: [PATCH] feat: document templates with auto-fill from case data (P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/internal/auth/context.go | 14 +- backend/internal/auth/middleware.go | 34 +- backend/internal/auth/tenant_resolver.go | 41 +-- backend/internal/auth/tenant_resolver_test.go | 29 +- backend/internal/handlers/deadlines.go | 12 +- backend/internal/handlers/templates.go | 328 +++++++++++++++++ backend/internal/models/document_template.go | 21 ++ backend/internal/router/router.go | 36 +- backend/internal/services/template_service.go | 330 ++++++++++++++++++ frontend/src/app/(app)/cases/[id]/layout.tsx | 36 +- frontend/src/app/(app)/vorlagen/[id]/page.tsx | 174 +++++++++ .../app/(app)/vorlagen/[id]/render/page.tsx | 177 ++++++++++ frontend/src/app/(app)/vorlagen/neu/page.tsx | 46 +++ frontend/src/app/(app)/vorlagen/page.tsx | 121 +++++++ frontend/src/components/layout/Sidebar.tsx | 2 + .../components/templates/TemplateEditor.tsx | 161 +++++++++ frontend/src/lib/types.ts | 76 ++++ 17 files changed, 1501 insertions(+), 137 deletions(-) create mode 100644 backend/internal/handlers/templates.go create mode 100644 backend/internal/models/document_template.go create mode 100644 backend/internal/services/template_service.go create mode 100644 frontend/src/app/(app)/vorlagen/[id]/page.tsx create mode 100644 frontend/src/app/(app)/vorlagen/[id]/render/page.tsx create mode 100644 frontend/src/app/(app)/vorlagen/neu/page.tsx create mode 100644 frontend/src/app/(app)/vorlagen/page.tsx create mode 100644 frontend/src/components/templates/TemplateEditor.tsx diff --git a/backend/internal/auth/context.go b/backend/internal/auth/context.go index c2aeaef..35bfa8c 100644 --- a/backend/internal/auth/context.go +++ b/backend/internal/auth/context.go @@ -9,19 +9,11 @@ import ( type contextKey string const ( -<<<<<<< HEAD userIDKey contextKey = "user_id" tenantIDKey contextKey = "tenant_id" ipKey contextKey = "ip_address" userAgentKey contextKey = "user_agent" -||||||| 82878df - userIDKey contextKey = "user_id" - tenantIDKey contextKey = "tenant_id" -======= - userIDKey contextKey = "user_id" - tenantIDKey contextKey = "tenant_id" - userRoleKey contextKey = "user_role" ->>>>>>> mai/pike/p0-role-based + userRoleKey contextKey = "user_role" ) func ContextWithUserID(ctx context.Context, userID uuid.UUID) context.Context { @@ -41,7 +33,6 @@ func TenantFromContext(ctx context.Context) (uuid.UUID, bool) { id, ok := ctx.Value(tenantIDKey).(uuid.UUID) return id, ok } -<<<<<<< HEAD func ContextWithRequestInfo(ctx context.Context, ip, userAgent string) context.Context { ctx = context.WithValue(ctx, ipKey, ip) @@ -62,8 +53,6 @@ func UserAgentFromContext(ctx context.Context) *string { } return nil } -||||||| 82878df -======= func ContextWithUserRole(ctx context.Context, role string) context.Context { return context.WithValue(ctx, userRoleKey, role) @@ -73,4 +62,3 @@ func UserRoleFromContext(ctx context.Context) string { role, _ := ctx.Value(userRoleKey).(string) return role } ->>>>>>> mai/pike/p0-role-based diff --git a/backend/internal/auth/middleware.go b/backend/internal/auth/middleware.go index 02428c6..fa25c98 100644 --- a/backend/internal/auth/middleware.go +++ b/backend/internal/auth/middleware.go @@ -35,36 +35,6 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler { } ctx := ContextWithUserID(r.Context(), userID) -<<<<<<< HEAD - // Tenant resolution is handled by TenantResolver middleware for scoped routes. - // Tenant management routes handle their own access control. -||||||| 82878df - - // Resolve tenant and role from user_tenants - var membership struct { - TenantID uuid.UUID `db:"tenant_id"` - Role string `db:"role"` - } - err = m.db.GetContext(r.Context(), &membership, - "SELECT tenant_id, role FROM user_tenants WHERE user_id = $1 LIMIT 1", userID) - if err != nil { - http.Error(w, "no tenant found for user", http.StatusForbidden) - return - } - ctx = ContextWithTenantID(ctx, membership.TenantID) - ctx = ContextWithUserRole(ctx, membership.Role) - -======= - - // Resolve tenant from user_tenants - var tenantID uuid.UUID - err = m.db.GetContext(r.Context(), &tenantID, - "SELECT tenant_id FROM user_tenants WHERE user_id = $1 LIMIT 1", userID) - if err != nil { - http.Error(w, "no tenant found for user", http.StatusForbidden) - return - } - ctx = ContextWithTenantID(ctx, tenantID) // Capture IP and user-agent for audit logging ip := r.Header.Get("X-Forwarded-For") @@ -73,7 +43,9 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler { } ctx = ContextWithRequestInfo(ctx, ip, r.UserAgent()) ->>>>>>> mai/knuth/p0-audit-trail-append + // Tenant resolution is handled by TenantResolver middleware for scoped routes. + // Tenant management routes handle their own access control. + next.ServeHTTP(w, r.WithContext(ctx)) }) } diff --git a/backend/internal/auth/tenant_resolver.go b/backend/internal/auth/tenant_resolver.go index 0dab57f..9d97d6e 100644 --- a/backend/internal/auth/tenant_resolver.go +++ b/backend/internal/auth/tenant_resolver.go @@ -12,12 +12,8 @@ import ( // Defined as an interface to avoid circular dependency with services. type TenantLookup interface { FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) -<<<<<<< HEAD VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error) -||||||| 82878df -======= GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) ->>>>>>> mai/pike/p0-role-based } // TenantResolver is middleware that resolves the tenant from X-Tenant-ID header @@ -39,6 +35,7 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler { } var tenantID uuid.UUID + ctx := r.Context() if header := r.Header.Get("X-Tenant-ID"); header != "" { parsed, err := uuid.Parse(header) @@ -46,38 +43,23 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler { http.Error(w, `{"error":"invalid X-Tenant-ID"}`, http.StatusBadRequest) return } -<<<<<<< HEAD - // Verify user has access to this tenant - hasAccess, err := tr.lookup.VerifyAccess(r.Context(), userID, parsed) + // Verify user has access and get their role + role, err := tr.lookup.GetUserRole(r.Context(), userID, parsed) if err != nil { slog.Error("tenant access check failed", "error", err, "user_id", userID, "tenant_id", parsed) http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) return } - if !hasAccess { + if role == "" { http.Error(w, `{"error":"no access to tenant"}`, http.StatusForbidden) return } -||||||| 82878df -======= - // Verify user has access and get their role - role, err := tr.lookup.GetUserRole(r.Context(), userID, parsed) - if err != nil { - http.Error(w, "error checking tenant access", http.StatusInternalServerError) - return - } - if role == "" { - http.Error(w, "no access to this tenant", http.StatusForbidden) - return - } ->>>>>>> mai/pike/p0-role-based tenantID = parsed - // Override the role from middleware with the correct one for this tenant - r = r.WithContext(ContextWithUserRole(r.Context(), role)) + ctx = ContextWithUserRole(ctx, role) } else { - // Default to user's first tenant (role already set by middleware) + // Default to user's first tenant first, err := tr.lookup.FirstTenantForUser(r.Context(), userID) if err != nil { slog.Error("failed to resolve default tenant", "error", err, "user_id", userID) @@ -89,9 +71,18 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler { return } tenantID = *first + + // Get role for default tenant + role, err := tr.lookup.GetUserRole(r.Context(), userID, tenantID) + if err != nil { + slog.Error("failed to get user role", "error", err, "user_id", userID, "tenant_id", tenantID) + http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) + return + } + ctx = ContextWithUserRole(ctx, role) } - ctx := ContextWithTenantID(r.Context(), tenantID) + ctx = ContextWithTenantID(ctx, tenantID) next.ServeHTTP(w, r.WithContext(ctx)) }) } diff --git a/backend/internal/auth/tenant_resolver_test.go b/backend/internal/auth/tenant_resolver_test.go index d0300c2..ce95676 100644 --- a/backend/internal/auth/tenant_resolver_test.go +++ b/backend/internal/auth/tenant_resolver_test.go @@ -10,49 +10,34 @@ import ( ) type mockTenantLookup struct { -<<<<<<< HEAD tenantID *uuid.UUID err error hasAccess bool accessErr error -||||||| 82878df - tenantID *uuid.UUID - err error -======= - tenantID *uuid.UUID - role string - err error ->>>>>>> mai/pike/p0-role-based + role string } func (m *mockTenantLookup) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) { return m.tenantID, m.err } -<<<<<<< HEAD func (m *mockTenantLookup) VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error) { return m.hasAccess, m.accessErr } -||||||| 82878df -======= func (m *mockTenantLookup) GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) { if m.role != "" { return m.role, m.err } - return "associate", m.err + if m.hasAccess { + return "associate", m.err + } + return "", m.err } ->>>>>>> mai/pike/p0-role-based func TestTenantResolver_FromHeader(t *testing.T) { tenantID := uuid.New() -<<<<<<< HEAD - tr := NewTenantResolver(&mockTenantLookup{hasAccess: true}) -||||||| 82878df - tr := NewTenantResolver(&mockTenantLookup{}) -======= - tr := NewTenantResolver(&mockTenantLookup{role: "partner"}) ->>>>>>> mai/pike/p0-role-based + tr := NewTenantResolver(&mockTenantLookup{hasAccess: true, role: "partner"}) var gotTenantID uuid.UUID next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -101,7 +86,7 @@ func TestTenantResolver_FromHeader_NoAccess(t *testing.T) { func TestTenantResolver_DefaultsToFirst(t *testing.T) { tenantID := uuid.New() - tr := NewTenantResolver(&mockTenantLookup{tenantID: &tenantID}) + tr := NewTenantResolver(&mockTenantLookup{tenantID: &tenantID, role: "associate"}) var gotTenantID uuid.UUID next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/backend/internal/handlers/deadlines.go b/backend/internal/handlers/deadlines.go index e1a2a39..1ff7629 100644 --- a/backend/internal/handlers/deadlines.go +++ b/backend/internal/handlers/deadlines.go @@ -198,18 +198,8 @@ func (h *DeadlineHandlers) Delete(w http.ResponseWriter, r *http.Request) { return } -<<<<<<< HEAD - if err := h.deadlines.Delete(tenantID, deadlineID); err != nil { + if err := h.deadlines.Delete(r.Context(), tenantID, deadlineID); err != nil { writeError(w, http.StatusNotFound, "deadline not found") -||||||| 82878df - err = h.deadlines.Delete(tenantID, deadlineID) - if err != nil { - writeError(w, http.StatusNotFound, err.Error()) -======= - err = h.deadlines.Delete(r.Context(), tenantID, deadlineID) - if err != nil { - writeError(w, http.StatusNotFound, err.Error()) ->>>>>>> mai/knuth/p0-audit-trail-append return } diff --git a/backend/internal/handlers/templates.go b/backend/internal/handlers/templates.go new file mode 100644 index 0000000..3be0062 --- /dev/null +++ b/backend/internal/handlers/templates.go @@ -0,0 +1,328 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + + "mgit.msbls.de/m/KanzlAI-mGMT/internal/auth" + "mgit.msbls.de/m/KanzlAI-mGMT/internal/services" +) + +type TemplateHandler struct { + templates *services.TemplateService + cases *services.CaseService + parties *services.PartyService + deadlines *services.DeadlineService + tenants *services.TenantService +} + +func NewTemplateHandler( + templates *services.TemplateService, + cases *services.CaseService, + parties *services.PartyService, + deadlines *services.DeadlineService, + tenants *services.TenantService, +) *TemplateHandler { + return &TemplateHandler{ + templates: templates, + cases: cases, + parties: parties, + deadlines: deadlines, + tenants: tenants, + } +} + +// List handles GET /api/templates +func (h *TemplateHandler) List(w http.ResponseWriter, r *http.Request) { + tenantID, ok := auth.TenantFromContext(r.Context()) + if !ok { + writeError(w, http.StatusForbidden, "missing tenant") + return + } + + q := r.URL.Query() + limit, _ := strconv.Atoi(q.Get("limit")) + offset, _ := strconv.Atoi(q.Get("offset")) + limit, offset = clampPagination(limit, offset) + + filter := services.TemplateFilter{ + Category: q.Get("category"), + Search: q.Get("search"), + Limit: limit, + Offset: offset, + } + + if filter.Search != "" { + if msg := validateStringLength("search", filter.Search, maxSearchLen); msg != "" { + writeError(w, http.StatusBadRequest, msg) + return + } + } + + templates, total, err := h.templates.List(r.Context(), tenantID, filter) + if err != nil { + internalError(w, "failed to list templates", err) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "data": templates, + "total": total, + }) +} + +// Get handles GET /api/templates/{id} +func (h *TemplateHandler) Get(w http.ResponseWriter, r *http.Request) { + tenantID, ok := auth.TenantFromContext(r.Context()) + if !ok { + writeError(w, http.StatusForbidden, "missing tenant") + return + } + + templateID, err := parsePathUUID(r, "id") + if err != nil { + writeError(w, http.StatusBadRequest, "invalid template ID") + return + } + + t, err := h.templates.GetByID(r.Context(), tenantID, templateID) + if err != nil { + internalError(w, "failed to get template", err) + return + } + if t == nil { + writeError(w, http.StatusNotFound, "template not found") + return + } + + writeJSON(w, http.StatusOK, t) +} + +// Create handles POST /api/templates +func (h *TemplateHandler) Create(w http.ResponseWriter, r *http.Request) { + tenantID, ok := auth.TenantFromContext(r.Context()) + if !ok { + writeError(w, http.StatusForbidden, "missing tenant") + return + } + + var raw struct { + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Category string `json:"category"` + Content string `json:"content"` + Variables any `json:"variables,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&raw); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + if raw.Name == "" { + writeError(w, http.StatusBadRequest, "name is required") + return + } + if msg := validateStringLength("name", raw.Name, maxTitleLen); msg != "" { + writeError(w, http.StatusBadRequest, msg) + return + } + if raw.Category == "" { + writeError(w, http.StatusBadRequest, "category is required") + return + } + + var variables []byte + if raw.Variables != nil { + var err error + variables, err = json.Marshal(raw.Variables) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid variables") + return + } + } + + input := services.CreateTemplateInput{ + Name: raw.Name, + Description: raw.Description, + Category: raw.Category, + Content: raw.Content, + Variables: variables, + } + + t, err := h.templates.Create(r.Context(), tenantID, input) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusCreated, t) +} + +// Update handles PUT /api/templates/{id} +func (h *TemplateHandler) Update(w http.ResponseWriter, r *http.Request) { + tenantID, ok := auth.TenantFromContext(r.Context()) + if !ok { + writeError(w, http.StatusForbidden, "missing tenant") + return + } + + templateID, err := parsePathUUID(r, "id") + if err != nil { + writeError(w, http.StatusBadRequest, "invalid template ID") + return + } + + var raw struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Category *string `json:"category,omitempty"` + Content *string `json:"content,omitempty"` + Variables any `json:"variables,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&raw); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + if raw.Name != nil { + if msg := validateStringLength("name", *raw.Name, maxTitleLen); msg != "" { + writeError(w, http.StatusBadRequest, msg) + return + } + } + + var variables []byte + if raw.Variables != nil { + variables, err = json.Marshal(raw.Variables) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid variables") + return + } + } + + input := services.UpdateTemplateInput{ + Name: raw.Name, + Description: raw.Description, + Category: raw.Category, + Content: raw.Content, + Variables: variables, + } + + t, err := h.templates.Update(r.Context(), tenantID, templateID, input) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if t == nil { + writeError(w, http.StatusNotFound, "template not found") + return + } + + writeJSON(w, http.StatusOK, t) +} + +// Delete handles DELETE /api/templates/{id} +func (h *TemplateHandler) Delete(w http.ResponseWriter, r *http.Request) { + tenantID, ok := auth.TenantFromContext(r.Context()) + if !ok { + writeError(w, http.StatusForbidden, "missing tenant") + return + } + + templateID, err := parsePathUUID(r, "id") + if err != nil { + writeError(w, http.StatusBadRequest, "invalid template ID") + return + } + + if err := h.templates.Delete(r.Context(), tenantID, templateID); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) +} + +// Render handles POST /api/templates/{id}/render?case_id=X +func (h *TemplateHandler) Render(w http.ResponseWriter, r *http.Request) { + tenantID, ok := auth.TenantFromContext(r.Context()) + if !ok { + writeError(w, http.StatusForbidden, "missing tenant") + return + } + + userID, _ := auth.UserFromContext(r.Context()) + + templateID, err := parsePathUUID(r, "id") + if err != nil { + writeError(w, http.StatusBadRequest, "invalid template ID") + return + } + + // Get template + tmpl, err := h.templates.GetByID(r.Context(), tenantID, templateID) + if err != nil { + internalError(w, "failed to get template", err) + return + } + if tmpl == nil { + writeError(w, http.StatusNotFound, "template not found") + return + } + + // Build render data + data := services.RenderData{} + + // Case data (optional) + caseIDStr := r.URL.Query().Get("case_id") + if caseIDStr != "" { + caseID, err := parseUUID(caseIDStr) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid case_id") + return + } + + caseDetail, err := h.cases.GetByID(r.Context(), tenantID, caseID) + if err != nil { + internalError(w, "failed to get case", err) + return + } + if caseDetail == nil { + writeError(w, http.StatusNotFound, "case not found") + return + } + data.Case = &caseDetail.Case + data.Parties = caseDetail.Parties + + // Get next upcoming deadline for this case + deadlines, err := h.deadlines.ListForCase(tenantID, caseID) + if err == nil && len(deadlines) > 0 { + // Find next non-completed deadline + for i := range deadlines { + if deadlines[i].Status != "completed" { + data.Deadline = &deadlines[i] + break + } + } + } + } + + // Tenant data + tenant, err := h.tenants.GetByID(r.Context(), tenantID) + if err == nil && tenant != nil { + data.Tenant = tenant + } + + // User data (userID from context — detailed name/email would need a user table lookup) + data.UserName = userID.String() + data.UserEmail = "" + + rendered := h.templates.Render(tmpl, data) + + writeJSON(w, http.StatusOK, map[string]any{ + "content": rendered, + "template_id": tmpl.ID, + "name": tmpl.Name, + }) +} diff --git a/backend/internal/models/document_template.go b/backend/internal/models/document_template.go new file mode 100644 index 0000000..d80809d --- /dev/null +++ b/backend/internal/models/document_template.go @@ -0,0 +1,21 @@ +package models + +import ( + "encoding/json" + "time" + + "github.com/google/uuid" +) + +type DocumentTemplate struct { + ID uuid.UUID `db:"id" json:"id"` + TenantID *uuid.UUID `db:"tenant_id" json:"tenant_id,omitempty"` + Name string `db:"name" json:"name"` + Description *string `db:"description" json:"description,omitempty"` + Category string `db:"category" json:"category"` + Content string `db:"content" json:"content"` + Variables json.RawMessage `db:"variables" json:"variables"` + IsSystem bool `db:"is_system" json:"is_system"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 84b7f8e..256ac58 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -29,14 +29,9 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se deadlineRuleSvc := services.NewDeadlineRuleService(db) calculator := services.NewDeadlineCalculator(holidaySvc) storageCli := services.NewStorageClient(cfg.SupabaseURL, cfg.SupabaseServiceKey) -<<<<<<< HEAD documentSvc := services.NewDocumentService(db, storageCli, auditSvc) -||||||| 82878df - documentSvc := services.NewDocumentService(db, storageCli) -======= - documentSvc := services.NewDocumentService(db, storageCli) assignmentSvc := services.NewCaseAssignmentService(db) ->>>>>>> mai/pike/p0-role-based + templateSvc := services.NewTemplateService(db, auditSvc) // AI service (optional — only if API key is configured) var aiH *handlers.AIHandler @@ -71,6 +66,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se eventH := handlers.NewCaseEventHandler(db) docH := handlers.NewDocumentHandler(documentSvc) assignmentH := handlers.NewCaseAssignmentHandler(assignmentSvc) + templateH := handlers.NewTemplateHandler(templateSvc, caseSvc, partySvc, deadlineSvc, tenantSvc) // Public routes mux.HandleFunc("GET /health", handleHealth(db)) @@ -112,7 +108,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se scoped.HandleFunc("GET /api/cases", caseH.List) scoped.HandleFunc("POST /api/cases", perm(auth.PermCreateCase, caseH.Create)) scoped.HandleFunc("GET /api/cases/{id}", caseH.Get) - scoped.HandleFunc("PUT /api/cases/{id}", caseH.Update) // case-level access checked in handler + scoped.HandleFunc("PUT /api/cases/{id}", caseH.Update) scoped.HandleFunc("DELETE /api/cases/{id}", perm(auth.PermCreateCase, caseH.Delete)) // Parties — same access as case editing @@ -138,7 +134,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se // Deadline calculator — all can use scoped.HandleFunc("POST /api/deadlines/calculate", calcH.Calculate) - // Appointments — all can manage (PermManageAppointments granted to all) + // Appointments — all can manage scoped.HandleFunc("GET /api/appointments/{id}", apptH.Get) scoped.HandleFunc("GET /api/appointments", apptH.List) scoped.HandleFunc("POST /api/appointments", perm(auth.PermManageAppointments, apptH.Create)) @@ -162,21 +158,23 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se // Dashboard — all can view scoped.HandleFunc("GET /api/dashboard", dashboardH.Get) -<<<<<<< HEAD // Audit log scoped.HandleFunc("GET /api/audit-log", auditH.List) - // Documents -||||||| 82878df - // Documents -======= - // Documents — all can upload, delete checked in handler (own vs all) ->>>>>>> mai/pike/p0-role-based + // Documents — all can upload, delete checked in handler scoped.HandleFunc("GET /api/cases/{id}/documents", docH.ListByCase) scoped.HandleFunc("POST /api/cases/{id}/documents", perm(auth.PermUploadDocuments, docH.Upload)) scoped.HandleFunc("GET /api/documents/{docId}", docH.Download) scoped.HandleFunc("GET /api/documents/{docId}/meta", docH.GetMeta) - scoped.HandleFunc("DELETE /api/documents/{docId}", docH.Delete) // permission check inside handler + scoped.HandleFunc("DELETE /api/documents/{docId}", docH.Delete) + + // Document templates — all can view, create/edit needs PermCreateCase + scoped.HandleFunc("GET /api/templates", templateH.List) + scoped.HandleFunc("GET /api/templates/{id}", templateH.Get) + scoped.HandleFunc("POST /api/templates", perm(auth.PermCreateCase, templateH.Create)) + scoped.HandleFunc("PUT /api/templates/{id}", perm(auth.PermCreateCase, templateH.Update)) + scoped.HandleFunc("DELETE /api/templates/{id}", perm(auth.PermCreateCase, templateH.Delete)) + scoped.HandleFunc("POST /api/templates/{id}/render", templateH.Render) // AI endpoints (rate limited: 5 req/min burst 10 per IP) if aiH != nil { @@ -185,7 +183,6 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se scoped.HandleFunc("POST /api/ai/summarize-case", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.SummarizeCase))) } -<<<<<<< HEAD // Notifications if notifH != nil { scoped.HandleFunc("GET /api/notifications", notifH.List) @@ -197,11 +194,6 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se } // CalDAV sync endpoints -||||||| 82878df - // CalDAV sync endpoints -======= - // CalDAV sync endpoints — settings permission required ->>>>>>> mai/pike/p0-role-based if calDAVSvc != nil { calDAVH := handlers.NewCalDAVHandler(calDAVSvc) scoped.HandleFunc("POST /api/caldav/sync", perm(auth.PermManageSettings, calDAVH.TriggerSync)) diff --git a/backend/internal/services/template_service.go b/backend/internal/services/template_service.go new file mode 100644 index 0000000..c66b86a --- /dev/null +++ b/backend/internal/services/template_service.go @@ -0,0 +1,330 @@ +package services + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + + "mgit.msbls.de/m/KanzlAI-mGMT/internal/models" +) + +type TemplateService struct { + db *sqlx.DB + audit *AuditService +} + +func NewTemplateService(db *sqlx.DB, audit *AuditService) *TemplateService { + return &TemplateService{db: db, audit: audit} +} + +type TemplateFilter struct { + Category string + Search string + Limit int + Offset int +} + +type CreateTemplateInput struct { + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Category string `json:"category"` + Content string `json:"content"` + Variables []byte `json:"variables,omitempty"` +} + +type UpdateTemplateInput struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Category *string `json:"category,omitempty"` + Content *string `json:"content,omitempty"` + Variables []byte `json:"variables,omitempty"` +} + +var validCategories = map[string]bool{ + "schriftsatz": true, + "vertrag": true, + "korrespondenz": true, + "intern": true, +} + +func (s *TemplateService) List(ctx context.Context, tenantID uuid.UUID, filter TemplateFilter) ([]models.DocumentTemplate, int, error) { + if filter.Limit <= 0 { + filter.Limit = 50 + } + if filter.Limit > 100 { + filter.Limit = 100 + } + + // Show system templates + tenant's own templates + where := "WHERE (tenant_id = $1 OR is_system = true)" + args := []any{tenantID} + argIdx := 2 + + if filter.Category != "" { + where += fmt.Sprintf(" AND category = $%d", argIdx) + args = append(args, filter.Category) + argIdx++ + } + if filter.Search != "" { + where += fmt.Sprintf(" AND (name ILIKE $%d OR description ILIKE $%d)", argIdx, argIdx) + args = append(args, "%"+filter.Search+"%") + argIdx++ + } + + var total int + countQ := "SELECT COUNT(*) FROM document_templates " + where + if err := s.db.GetContext(ctx, &total, countQ, args...); err != nil { + return nil, 0, fmt.Errorf("counting templates: %w", err) + } + + query := "SELECT * FROM document_templates " + where + " ORDER BY is_system DESC, name ASC" + query += fmt.Sprintf(" LIMIT $%d OFFSET $%d", argIdx, argIdx+1) + args = append(args, filter.Limit, filter.Offset) + + var templates []models.DocumentTemplate + if err := s.db.SelectContext(ctx, &templates, query, args...); err != nil { + return nil, 0, fmt.Errorf("listing templates: %w", err) + } + + return templates, total, nil +} + +func (s *TemplateService) GetByID(ctx context.Context, tenantID, templateID uuid.UUID) (*models.DocumentTemplate, error) { + var t models.DocumentTemplate + err := s.db.GetContext(ctx, &t, + "SELECT * FROM document_templates WHERE id = $1 AND (tenant_id = $2 OR is_system = true)", + templateID, tenantID) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("getting template: %w", err) + } + return &t, nil +} + +func (s *TemplateService) Create(ctx context.Context, tenantID uuid.UUID, input CreateTemplateInput) (*models.DocumentTemplate, error) { + if input.Name == "" { + return nil, fmt.Errorf("name is required") + } + if !validCategories[input.Category] { + return nil, fmt.Errorf("invalid category: %s", input.Category) + } + + variables := input.Variables + if variables == nil { + variables = []byte("[]") + } + + var t models.DocumentTemplate + err := s.db.GetContext(ctx, &t, + `INSERT INTO document_templates (tenant_id, name, description, category, content, variables, is_system) + VALUES ($1, $2, $3, $4, $5, $6, false) + RETURNING *`, + tenantID, input.Name, input.Description, input.Category, input.Content, variables) + if err != nil { + return nil, fmt.Errorf("creating template: %w", err) + } + + s.audit.Log(ctx, "create", "document_template", &t.ID, nil, t) + return &t, nil +} + +func (s *TemplateService) Update(ctx context.Context, tenantID, templateID uuid.UUID, input UpdateTemplateInput) (*models.DocumentTemplate, error) { + // Don't allow editing system templates + existing, err := s.GetByID(ctx, tenantID, templateID) + if err != nil { + return nil, err + } + if existing == nil { + return nil, nil + } + if existing.IsSystem { + return nil, fmt.Errorf("system templates cannot be edited") + } + if existing.TenantID == nil || *existing.TenantID != tenantID { + return nil, fmt.Errorf("template does not belong to tenant") + } + + sets := []string{} + args := []any{} + argIdx := 1 + + if input.Name != nil { + sets = append(sets, fmt.Sprintf("name = $%d", argIdx)) + args = append(args, *input.Name) + argIdx++ + } + if input.Description != nil { + sets = append(sets, fmt.Sprintf("description = $%d", argIdx)) + args = append(args, *input.Description) + argIdx++ + } + if input.Category != nil { + if !validCategories[*input.Category] { + return nil, fmt.Errorf("invalid category: %s", *input.Category) + } + sets = append(sets, fmt.Sprintf("category = $%d", argIdx)) + args = append(args, *input.Category) + argIdx++ + } + if input.Content != nil { + sets = append(sets, fmt.Sprintf("content = $%d", argIdx)) + args = append(args, *input.Content) + argIdx++ + } + if input.Variables != nil { + sets = append(sets, fmt.Sprintf("variables = $%d", argIdx)) + args = append(args, input.Variables) + argIdx++ + } + + if len(sets) == 0 { + return existing, nil + } + + sets = append(sets, "updated_at = now()") + query := fmt.Sprintf("UPDATE document_templates SET %s WHERE id = $%d AND tenant_id = $%d RETURNING *", + strings.Join(sets, ", "), argIdx, argIdx+1) + args = append(args, templateID, tenantID) + + var t models.DocumentTemplate + if err := s.db.GetContext(ctx, &t, query, args...); err != nil { + return nil, fmt.Errorf("updating template: %w", err) + } + + s.audit.Log(ctx, "update", "document_template", &t.ID, existing, t) + return &t, nil +} + +func (s *TemplateService) Delete(ctx context.Context, tenantID, templateID uuid.UUID) error { + // Don't allow deleting system templates + existing, err := s.GetByID(ctx, tenantID, templateID) + if err != nil { + return err + } + if existing == nil { + return fmt.Errorf("template not found") + } + if existing.IsSystem { + return fmt.Errorf("system templates cannot be deleted") + } + if existing.TenantID == nil || *existing.TenantID != tenantID { + return fmt.Errorf("template does not belong to tenant") + } + + _, err = s.db.ExecContext(ctx, "DELETE FROM document_templates WHERE id = $1 AND tenant_id = $2", templateID, tenantID) + if err != nil { + return fmt.Errorf("deleting template: %w", err) + } + + s.audit.Log(ctx, "delete", "document_template", &templateID, existing, nil) + return nil +} + +// RenderData holds all the data available for template variable replacement. +type RenderData struct { + Case *models.Case + Parties []models.Party + Tenant *models.Tenant + Deadline *models.Deadline + UserName string + UserEmail string +} + +// Render replaces {{placeholders}} in the template content with actual data. +func (s *TemplateService) Render(template *models.DocumentTemplate, data RenderData) string { + content := template.Content + + now := time.Now() + + replacements := map[string]string{ + "{{date.today}}": now.Format("02.01.2006"), + "{{date.today_long}}": formatGermanDate(now), + } + + // Case data + if data.Case != nil { + replacements["{{case.number}}"] = data.Case.CaseNumber + replacements["{{case.title}}"] = data.Case.Title + if data.Case.Court != nil { + replacements["{{case.court}}"] = *data.Case.Court + } + if data.Case.CourtRef != nil { + replacements["{{case.court_ref}}"] = *data.Case.CourtRef + } + } + + // Party data + for _, p := range data.Parties { + role := "" + if p.Role != nil { + role = *p.Role + } + switch role { + case "claimant", "plaintiff", "klaeger": + replacements["{{party.claimant.name}}"] = p.Name + if p.Representative != nil { + replacements["{{party.claimant.representative}}"] = *p.Representative + } + case "defendant", "beklagter": + replacements["{{party.defendant.name}}"] = p.Name + if p.Representative != nil { + replacements["{{party.defendant.representative}}"] = *p.Representative + } + } + } + + // Tenant data + if data.Tenant != nil { + replacements["{{tenant.name}}"] = data.Tenant.Name + // Extract address from settings if available + replacements["{{tenant.address}}"] = extractSettingsField(data.Tenant.Settings, "address") + } + + // User data + replacements["{{user.name}}"] = data.UserName + replacements["{{user.email}}"] = data.UserEmail + + // Deadline data + if data.Deadline != nil { + replacements["{{deadline.title}}"] = data.Deadline.Title + replacements["{{deadline.due_date}}"] = data.Deadline.DueDate + } + + for placeholder, value := range replacements { + content = strings.ReplaceAll(content, placeholder, value) + } + + return content +} + +func formatGermanDate(t time.Time) string { + months := []string{ + "Januar", "Februar", "März", "April", "Mai", "Juni", + "Juli", "August", "September", "Oktober", "November", "Dezember", + } + return fmt.Sprintf("%d. %s %d", t.Day(), months[t.Month()-1], t.Year()) +} + +func extractSettingsField(settings []byte, field string) string { + if len(settings) == 0 { + return "" + } + var m map[string]any + if err := json.Unmarshal(settings, &m); err != nil { + return "" + } + if v, ok := m[field]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} diff --git a/frontend/src/app/(app)/cases/[id]/layout.tsx b/frontend/src/app/(app)/cases/[id]/layout.tsx index 857f570..1c816a1 100644 --- a/frontend/src/app/(app)/cases/[id]/layout.tsx +++ b/frontend/src/app/(app)/cases/[id]/layout.tsx @@ -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 && ({caseDetail.court_ref})} -
-

- Erstellt:{" "} - {format(new Date(caseDetail.created_at), "d. MMM yyyy", { - locale: de, - })} -

-

- Aktualisiert:{" "} - {format(new Date(caseDetail.updated_at), "d. MMM yyyy", { - locale: de, - })} -

+
+ + + Schriftsatz erstellen + +
+

+ Erstellt:{" "} + {format(new Date(caseDetail.created_at), "d. MMM yyyy", { + locale: de, + })} +

+

+ Aktualisiert:{" "} + {format(new Date(caseDetail.updated_at), "d. MMM yyyy", { + locale: de, + })} +

+
diff --git a/frontend/src/app/(app)/vorlagen/[id]/page.tsx b/frontend/src/app/(app)/vorlagen/[id]/page.tsx new file mode 100644 index 0000000..09a7b43 --- /dev/null +++ b/frontend/src/app/(app)/vorlagen/[id]/page.tsx @@ -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(`/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) => + api.put(`/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 ( +
+ +
+ ); + } + + if (!template) { + return ( +
+ Vorlage nicht gefunden +
+ ); + } + + return ( +
+ + +
+
+
+

+ {template.name} +

+ {template.is_system && ( + + )} +
+
+ + {TEMPLATE_CATEGORY_LABELS[template.category] ?? template.category} + + {template.description && ( + + {template.description} + + )} +
+
+ +
+ + + Dokument erstellen + + + {!template.is_system && ( + <> + + + + )} +
+
+ + {isEditing ? ( + updateMutation.mutate(data)} + isSaving={updateMutation.isPending} + /> + ) : ( +
+ {/* Variables */} + {template.variables && template.variables.length > 0 && ( +
+

+ Variablen +

+
+ {template.variables.map((v: string) => ( + + {`{{${v}}}`} + + ))} +
+
+ )} + + {/* Content preview */} +
+

+ Vorschau +

+
+ {template.content} +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/app/(app)/vorlagen/[id]/render/page.tsx b/frontend/src/app/(app)/vorlagen/[id]/render/page.tsx new file mode 100644 index 0000000..5cec13f --- /dev/null +++ b/frontend/src/app/(app)/vorlagen/[id]/render/page.tsx @@ -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(null); + const [copied, setCopied] = useState(false); + + const { data: template, isLoading: templateLoading } = useQuery({ + queryKey: ["template", id], + queryFn: () => api.get(`/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( + `/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 ( +
+ +
+ ); + } + + if (!template) { + return ( +
+ Vorlage nicht gefunden +
+ ); + } + + return ( +
+ + +

+ Dokument erstellen +

+

+ Vorlage “{template.name}” mit Falldaten befüllen +

+ + {/* Step 1: Select case */} +
+

+ 1. Akte auswählen +

+ {casesLoading ? ( + + ) : ( + + )} +
+ + {/* Step 2: Render */} +
+
+

+ 2. Vorschau erstellen +

+ +
+ + {rendered && ( +
+
+ + +
+
+
+ {rendered.content} +
+
+
+ )} +
+
+ ); +} diff --git a/frontend/src/app/(app)/vorlagen/neu/page.tsx b/frontend/src/app/(app)/vorlagen/neu/page.tsx new file mode 100644 index 0000000..8bff749 --- /dev/null +++ b/frontend/src/app/(app)/vorlagen/neu/page.tsx @@ -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) => + api.post("/templates", data), + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey: ["templates"] }); + toast.success("Vorlage erstellt"); + router.push(`/vorlagen/${result.id}`); + }, + onError: () => toast.error("Fehler beim Erstellen"), + }); + + return ( +
+ + +

+ Neue Vorlage erstellen +

+ + createMutation.mutate(data)} + isSaving={createMutation.isPending} + /> +
+ ); +} diff --git a/frontend/src/app/(app)/vorlagen/page.tsx b/frontend/src/app/(app)/vorlagen/page.tsx new file mode 100644 index 0000000..eea0e9c --- /dev/null +++ b/frontend/src/app/(app)/vorlagen/page.tsx @@ -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 ( +
+
+ +
+
+

+ Vorlagen +

+

+ Dokumentvorlagen mit automatischer Befüllung +

+
+ + + Neue Vorlage + +
+
+ + {/* Category filter */} +
+ {CATEGORIES.map((cat) => ( + + ))} +
+ + {isLoading ? ( +
+ +
+ ) : templates.length === 0 ? ( +
+ +

Keine Vorlagen gefunden

+
+ ) : ( +
+ {templates.map((t) => ( + +
+
+ +

+ {t.name} +

+
+ {t.is_system && ( + + )} +
+ {t.description && ( +

+ {t.description} +

+ )} +
+ + {TEMPLATE_CATEGORY_LABELS[t.category] ?? t.category} + + {t.is_system && ( + + System + + )} +
+ + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 8896b17..7db5150 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -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" }, ]; diff --git a/frontend/src/components/templates/TemplateEditor.tsx b/frontend/src/components/templates/TemplateEditor.tsx new file mode 100644 index 0000000..b8a4bda --- /dev/null +++ b/frontend/src/components/templates/TemplateEditor.tsx @@ -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) => 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(template?.category ?? "schriftsatz"); + const [content, setContent] = useState(template?.content ?? ""); + const textareaRef = useRef(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 ( +
+ {/* Metadata */} +
+
+ + 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" + /> +
+
+ + +
+
+ + 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" + /> +
+
+ + {/* Variable toolbar */} +
+

+ Variablen einfügen +

+
+ {AVAILABLE_VARIABLES.map((group) => ( +
+ + {group.group} + + {group.vars.map((v) => ( + + ))} +
+ ))} +
+
+ + {/* Content editor */} +
+ +