Compare commits

..

1 Commits

Author SHA1 Message Date
m
642877ae54 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
2026-03-30 11:26:25 +02:00
25 changed files with 1503 additions and 1827 deletions

View File

@@ -11,9 +11,9 @@ type contextKey string
const (
userIDKey contextKey = "user_id"
tenantIDKey contextKey = "tenant_id"
userRoleKey contextKey = "user_role"
ipKey contextKey = "ip_address"
userAgentKey contextKey = "user_agent"
userRoleKey contextKey = "user_role"
)
func ContextWithUserID(ctx context.Context, userID uuid.UUID) context.Context {
@@ -34,15 +34,6 @@ func TenantFromContext(ctx context.Context) (uuid.UUID, bool) {
return id, ok
}
func ContextWithUserRole(ctx context.Context, role string) context.Context {
return context.WithValue(ctx, userRoleKey, role)
}
func UserRoleFromContext(ctx context.Context) string {
role, _ := ctx.Value(userRoleKey).(string)
return role
}
func ContextWithRequestInfo(ctx context.Context, ip, userAgent string) context.Context {
ctx = context.WithValue(ctx, ipKey, ip)
ctx = context.WithValue(ctx, userAgentKey, userAgent)
@@ -62,3 +53,12 @@ func UserAgentFromContext(ctx context.Context) *string {
}
return nil
}
func ContextWithUserRole(ctx context.Context, role string) context.Context {
return context.WithValue(ctx, userRoleKey, role)
}
func UserRoleFromContext(ctx context.Context) string {
role, _ := ctx.Value(userRoleKey).(string)
return role
}

View File

@@ -35,8 +35,6 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler {
}
ctx := ContextWithUserID(r.Context(), userID)
// Tenant resolution is handled by TenantResolver middleware for scoped routes.
// Tenant management routes handle their own access control.
// Capture IP and user-agent for audit logging
ip := r.Header.Get("X-Forwarded-For")
@@ -45,6 +43,9 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler {
}
ctx = ContextWithRequestInfo(ctx, ip, r.UserAgent())
// Tenant resolution is handled by TenantResolver middleware for scoped routes.
// Tenant management routes handle their own access control.
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View File

@@ -35,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)
@@ -56,10 +57,9 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
}
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)
@@ -71,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))
})
}

View File

@@ -86,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) {

View File

@@ -1,109 +0,0 @@
package handlers
import (
"net/http"
"time"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
type ReportHandler struct {
svc *services.ReportingService
}
func NewReportHandler(svc *services.ReportingService) *ReportHandler {
return &ReportHandler{svc: svc}
}
// parseDateRange extracts from/to query params, defaulting to last 12 months.
func parseDateRange(r *http.Request) (time.Time, time.Time) {
now := time.Now()
from := time.Date(now.Year()-1, now.Month(), 1, 0, 0, 0, 0, time.UTC)
to := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, time.UTC)
if v := r.URL.Query().Get("from"); v != "" {
if t, err := time.Parse("2006-01-02", v); err == nil {
from = t
}
}
if v := r.URL.Query().Get("to"); v != "" {
if t, err := time.Parse("2006-01-02", v); err == nil {
to = time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, time.UTC)
}
}
return from, to
}
func (h *ReportHandler) Cases(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
from, to := parseDateRange(r)
data, err := h.svc.CaseReport(r.Context(), tenantID, from, to)
if err != nil {
internalError(w, "failed to generate case report", err)
return
}
writeJSON(w, http.StatusOK, data)
}
func (h *ReportHandler) Deadlines(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
from, to := parseDateRange(r)
data, err := h.svc.DeadlineReport(r.Context(), tenantID, from, to)
if err != nil {
internalError(w, "failed to generate deadline report", err)
return
}
writeJSON(w, http.StatusOK, data)
}
func (h *ReportHandler) Workload(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
from, to := parseDateRange(r)
data, err := h.svc.WorkloadReport(r.Context(), tenantID, from, to)
if err != nil {
internalError(w, "failed to generate workload report", err)
return
}
writeJSON(w, http.StatusOK, data)
}
func (h *ReportHandler) Billing(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
from, to := parseDateRange(r)
data, err := h.svc.BillingReport(r.Context(), tenantID, from, to)
if err != nil {
internalError(w, "failed to generate billing report", err)
return
}
writeJSON(w, http.StatusOK, data)
}

View File

@@ -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,
})
}

View File

@@ -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"`
}

View File

@@ -31,6 +31,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
storageCli := services.NewStorageClient(cfg.SupabaseURL, cfg.SupabaseServiceKey)
documentSvc := services.NewDocumentService(db, storageCli, auditSvc)
assignmentSvc := services.NewCaseAssignmentService(db)
templateSvc := services.NewTemplateService(db, auditSvc)
// AI service (optional — only if API key is configured)
var aiH *handlers.AIHandler
@@ -44,7 +45,6 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
noteSvc := services.NewNoteService(db, auditSvc)
dashboardSvc := services.NewDashboardService(db)
reportingSvc := services.NewReportingService(db)
// Notification handler (optional — nil in tests)
var notifH *handlers.NotificationHandler
@@ -62,11 +62,11 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
ruleH := handlers.NewDeadlineRuleHandlers(deadlineRuleSvc)
calcH := handlers.NewCalculateHandlers(calculator, deadlineRuleSvc)
dashboardH := handlers.NewDashboardHandler(dashboardSvc)
reportH := handlers.NewReportHandler(reportingSvc)
noteH := handlers.NewNoteHandler(noteSvc)
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))
@@ -108,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
@@ -134,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))
@@ -158,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)
// Reports — all can view
scoped.HandleFunc("GET /api/reports/cases", reportH.Cases)
scoped.HandleFunc("GET /api/reports/deadlines", reportH.Deadlines)
scoped.HandleFunc("GET /api/reports/workload", reportH.Workload)
scoped.HandleFunc("GET /api/reports/billing", reportH.Billing)
// Audit log
scoped.HandleFunc("GET /api/audit-log", auditH.List)
// Documents — all can upload, delete checked in handler (own vs all)
// 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 {
@@ -191,7 +193,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
scoped.HandleFunc("PUT /api/notification-preferences", notifH.UpdatePreferences)
}
// CalDAV sync endpoints — settings permission required
// CalDAV sync endpoints
if calDAVSvc != nil {
calDAVH := handlers.NewCalDAVHandler(calDAVSvc)
scoped.HandleFunc("POST /api/caldav/sync", perm(auth.PermManageSettings, calDAVH.TriggerSync))

View File

@@ -1,329 +0,0 @@
package services
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
type ReportingService struct {
db *sqlx.DB
}
func NewReportingService(db *sqlx.DB) *ReportingService {
return &ReportingService{db: db}
}
// --- Case Statistics ---
type CaseStats struct {
Period string `json:"period" db:"period"`
Opened int `json:"opened" db:"opened"`
Closed int `json:"closed" db:"closed"`
Active int `json:"active" db:"active"`
}
type CasesByType struct {
CaseType string `json:"case_type" db:"case_type"`
Count int `json:"count" db:"count"`
}
type CasesByCourt struct {
Court string `json:"court" db:"court"`
Count int `json:"count" db:"count"`
}
type CaseReport struct {
Monthly []CaseStats `json:"monthly"`
ByType []CasesByType `json:"by_type"`
ByCourt []CasesByCourt `json:"by_court"`
Total CaseReportTotals `json:"total"`
}
type CaseReportTotals struct {
Opened int `json:"opened"`
Closed int `json:"closed"`
Active int `json:"active"`
}
func (s *ReportingService) CaseReport(ctx context.Context, tenantID uuid.UUID, from, to time.Time) (*CaseReport, error) {
report := &CaseReport{}
// Monthly breakdown
monthlyQuery := `
SELECT
TO_CHAR(DATE_TRUNC('month', created_at), 'YYYY-MM') AS period,
COUNT(*) AS opened,
COUNT(*) FILTER (WHERE status IN ('closed', 'archived')) AS closed,
COUNT(*) FILTER (WHERE status = 'active') AS active
FROM cases
WHERE tenant_id = $1 AND created_at >= $2 AND created_at <= $3
GROUP BY DATE_TRUNC('month', created_at)
ORDER BY DATE_TRUNC('month', created_at)`
report.Monthly = []CaseStats{}
if err := s.db.SelectContext(ctx, &report.Monthly, monthlyQuery, tenantID, from, to); err != nil {
return nil, fmt.Errorf("case report monthly: %w", err)
}
// By type
typeQuery := `
SELECT COALESCE(case_type, 'Sonstiges') AS case_type, COUNT(*) AS count
FROM cases
WHERE tenant_id = $1 AND created_at >= $2 AND created_at <= $3
GROUP BY case_type
ORDER BY count DESC`
report.ByType = []CasesByType{}
if err := s.db.SelectContext(ctx, &report.ByType, typeQuery, tenantID, from, to); err != nil {
return nil, fmt.Errorf("case report by type: %w", err)
}
// By court
courtQuery := `
SELECT COALESCE(court, 'Ohne Gericht') AS court, COUNT(*) AS count
FROM cases
WHERE tenant_id = $1 AND created_at >= $2 AND created_at <= $3
GROUP BY court
ORDER BY count DESC`
report.ByCourt = []CasesByCourt{}
if err := s.db.SelectContext(ctx, &report.ByCourt, courtQuery, tenantID, from, to); err != nil {
return nil, fmt.Errorf("case report by court: %w", err)
}
// Totals
totalsQuery := `
SELECT
COUNT(*) AS opened,
COUNT(*) FILTER (WHERE status IN ('closed', 'archived')) AS closed,
COUNT(*) FILTER (WHERE status = 'active') AS active
FROM cases
WHERE tenant_id = $1 AND created_at >= $2 AND created_at <= $3`
if err := s.db.GetContext(ctx, &report.Total, totalsQuery, tenantID, from, to); err != nil {
return nil, fmt.Errorf("case report totals: %w", err)
}
return report, nil
}
// --- Deadline Compliance ---
type DeadlineCompliance struct {
Period string `json:"period" db:"period"`
Total int `json:"total" db:"total"`
Met int `json:"met" db:"met"`
Missed int `json:"missed" db:"missed"`
Pending int `json:"pending" db:"pending"`
ComplianceRate float64 `json:"compliance_rate"`
}
type MissedDeadline struct {
ID uuid.UUID `json:"id" db:"id"`
Title string `json:"title" db:"title"`
DueDate string `json:"due_date" db:"due_date"`
CaseID uuid.UUID `json:"case_id" db:"case_id"`
CaseNumber string `json:"case_number" db:"case_number"`
CaseTitle string `json:"case_title" db:"case_title"`
DaysOverdue int `json:"days_overdue" db:"days_overdue"`
}
type DeadlineReport struct {
Monthly []DeadlineCompliance `json:"monthly"`
Missed []MissedDeadline `json:"missed"`
Total DeadlineReportTotals `json:"total"`
}
type DeadlineReportTotals struct {
Total int `json:"total"`
Met int `json:"met"`
Missed int `json:"missed"`
Pending int `json:"pending"`
ComplianceRate float64 `json:"compliance_rate"`
}
func (s *ReportingService) DeadlineReport(ctx context.Context, tenantID uuid.UUID, from, to time.Time) (*DeadlineReport, error) {
report := &DeadlineReport{}
// Monthly compliance
monthlyQuery := `
SELECT
TO_CHAR(DATE_TRUNC('month', due_date), 'YYYY-MM') AS period,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'completed' AND (completed_at IS NULL OR completed_at::date <= due_date)) AS met,
COUNT(*) FILTER (WHERE (status = 'completed' AND completed_at::date > due_date) OR (status = 'pending' AND due_date < CURRENT_DATE)) AS missed,
COUNT(*) FILTER (WHERE status = 'pending' AND due_date >= CURRENT_DATE) AS pending
FROM deadlines
WHERE tenant_id = $1 AND due_date >= $2 AND due_date <= $3
GROUP BY DATE_TRUNC('month', due_date)
ORDER BY DATE_TRUNC('month', due_date)`
report.Monthly = []DeadlineCompliance{}
if err := s.db.SelectContext(ctx, &report.Monthly, monthlyQuery, tenantID, from, to); err != nil {
return nil, fmt.Errorf("deadline report monthly: %w", err)
}
// Calculate compliance rates
for i := range report.Monthly {
completed := report.Monthly[i].Met + report.Monthly[i].Missed
if completed > 0 {
report.Monthly[i].ComplianceRate = float64(report.Monthly[i].Met) / float64(completed) * 100
}
}
// Missed deadlines list
missedQuery := `
SELECT d.id, d.title, d.due_date, d.case_id, c.case_number, c.title AS case_title,
(CURRENT_DATE - d.due_date::date) AS days_overdue
FROM deadlines d
JOIN cases c ON c.id = d.case_id AND c.tenant_id = d.tenant_id
WHERE d.tenant_id = $1 AND d.due_date >= $2 AND d.due_date <= $3
AND ((d.status = 'pending' AND d.due_date < CURRENT_DATE)
OR (d.status = 'completed' AND d.completed_at::date > d.due_date))
ORDER BY d.due_date ASC
LIMIT 50`
report.Missed = []MissedDeadline{}
if err := s.db.SelectContext(ctx, &report.Missed, missedQuery, tenantID, from, to); err != nil {
return nil, fmt.Errorf("deadline report missed: %w", err)
}
// Totals
totalsQuery := `
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'completed' AND (completed_at IS NULL OR completed_at::date <= due_date)) AS met,
COUNT(*) FILTER (WHERE (status = 'completed' AND completed_at::date > due_date) OR (status = 'pending' AND due_date < CURRENT_DATE)) AS missed,
COUNT(*) FILTER (WHERE status = 'pending' AND due_date >= CURRENT_DATE) AS pending
FROM deadlines
WHERE tenant_id = $1 AND due_date >= $2 AND due_date <= $3`
if err := s.db.GetContext(ctx, &report.Total, totalsQuery, tenantID, from, to); err != nil {
return nil, fmt.Errorf("deadline report totals: %w", err)
}
completed := report.Total.Met + report.Total.Missed
if completed > 0 {
report.Total.ComplianceRate = float64(report.Total.Met) / float64(completed) * 100
}
return report, nil
}
// --- Workload ---
type UserWorkload struct {
UserID uuid.UUID `json:"user_id" db:"user_id"`
ActiveCases int `json:"active_cases" db:"active_cases"`
Deadlines int `json:"deadlines" db:"deadlines"`
Overdue int `json:"overdue" db:"overdue"`
Completed int `json:"completed" db:"completed"`
}
type WorkloadReport struct {
Users []UserWorkload `json:"users"`
}
func (s *ReportingService) WorkloadReport(ctx context.Context, tenantID uuid.UUID, from, to time.Time) (*WorkloadReport, error) {
report := &WorkloadReport{}
query := `
WITH user_cases AS (
SELECT ca.user_id, COUNT(DISTINCT ca.case_id) AS active_cases
FROM case_assignments ca
JOIN cases c ON c.id = ca.case_id AND c.tenant_id = $1
WHERE c.status = 'active'
GROUP BY ca.user_id
),
user_deadlines AS (
SELECT ca.user_id,
COUNT(*) AS deadlines,
COUNT(*) FILTER (WHERE d.status = 'pending' AND d.due_date < CURRENT_DATE) AS overdue,
COUNT(*) FILTER (WHERE d.status = 'completed' AND d.completed_at >= $2 AND d.completed_at <= $3) AS completed
FROM case_assignments ca
JOIN deadlines d ON d.case_id = ca.case_id AND d.tenant_id = $1
WHERE d.due_date >= $2 AND d.due_date <= $3
GROUP BY ca.user_id
)
SELECT
COALESCE(uc.user_id, ud.user_id) AS user_id,
COALESCE(uc.active_cases, 0) AS active_cases,
COALESCE(ud.deadlines, 0) AS deadlines,
COALESCE(ud.overdue, 0) AS overdue,
COALESCE(ud.completed, 0) AS completed
FROM user_cases uc
FULL OUTER JOIN user_deadlines ud ON uc.user_id = ud.user_id
ORDER BY active_cases DESC`
report.Users = []UserWorkload{}
if err := s.db.SelectContext(ctx, &report.Users, query, tenantID, from, to); err != nil {
return nil, fmt.Errorf("workload report: %w", err)
}
return report, nil
}
// --- Billing (summary from case data) ---
type BillingByMonth struct {
Period string `json:"period" db:"period"`
CasesActive int `json:"cases_active" db:"cases_active"`
CasesClosed int `json:"cases_closed" db:"cases_closed"`
CasesNew int `json:"cases_new" db:"cases_new"`
}
type BillingByType struct {
CaseType string `json:"case_type" db:"case_type"`
Active int `json:"active" db:"active"`
Closed int `json:"closed" db:"closed"`
Total int `json:"total" db:"total"`
}
type BillingReport struct {
Monthly []BillingByMonth `json:"monthly"`
ByType []BillingByType `json:"by_type"`
}
func (s *ReportingService) BillingReport(ctx context.Context, tenantID uuid.UUID, from, to time.Time) (*BillingReport, error) {
report := &BillingReport{}
// Monthly activity for billing overview
monthlyQuery := `
SELECT
TO_CHAR(DATE_TRUNC('month', created_at), 'YYYY-MM') AS period,
COUNT(*) FILTER (WHERE status = 'active') AS cases_active,
COUNT(*) FILTER (WHERE status IN ('closed', 'archived')) AS cases_closed,
COUNT(*) AS cases_new
FROM cases
WHERE tenant_id = $1 AND created_at >= $2 AND created_at <= $3
GROUP BY DATE_TRUNC('month', created_at)
ORDER BY DATE_TRUNC('month', created_at)`
report.Monthly = []BillingByMonth{}
if err := s.db.SelectContext(ctx, &report.Monthly, monthlyQuery, tenantID, from, to); err != nil {
return nil, fmt.Errorf("billing report monthly: %w", err)
}
// By case type
typeQuery := `
SELECT
COALESCE(case_type, 'Sonstiges') AS case_type,
COUNT(*) FILTER (WHERE status = 'active') AS active,
COUNT(*) FILTER (WHERE status IN ('closed', 'archived')) AS closed,
COUNT(*) AS total
FROM cases
WHERE tenant_id = $1 AND created_at >= $2 AND created_at <= $3
GROUP BY case_type
ORDER BY total DESC`
report.ByType = []BillingByType{}
if err := s.db.SelectContext(ctx, &report.ByType, typeQuery, tenantID, from, to); err != nil {
return nil, fmt.Errorf("billing report by type: %w", err)
}
return report, nil
}

View File

@@ -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 ""
}

View File

@@ -14,7 +14,6 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"react-dropzone": "^15.0.0",
"recharts": "^3.8.1",
"sonner": "^2.0.7",
},
"devDependencies": {
@@ -245,8 +244,6 @@
"@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="],
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.0", "", { "os": "android", "cpu": "arm" }, "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.0", "", { "os": "android", "cpu": "arm64" }, "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw=="],
@@ -301,10 +298,6 @@
"@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.16.1", "", {}, "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
"@supabase/auth-js": ["@supabase/auth-js@2.100.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-pdT3ye3UVRN1Cg0wom6BmyY+XTtp5DiJaYnPi6j8ht5i8Lq8kfqxJMJz9GI9YDKk3w1nhGOPnh6Qz5qpyYm+1w=="],
"@supabase/functions-js": ["@supabase/functions-js@2.100.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-keLg79RPwP+uiwHuxFPTFgDRxPV46LM4j/swjyR2GKJgWniTVSsgiBHfbIBDcrQwehLepy09b/9QSHUywtKRWQ=="],
@@ -369,24 +362,6 @@
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
"@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="],
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
@@ -401,8 +376,6 @@
"@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="],
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.57.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/type-utils": "8.57.2", "@typescript-eslint/utils": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.57.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w=="],
@@ -555,8 +528,6 @@
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
@@ -575,28 +546,6 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
"d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="],
"data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="],
@@ -613,8 +562,6 @@
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
@@ -659,8 +606,6 @@
"es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="],
"es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="],
"esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
@@ -701,8 +646,6 @@
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
@@ -793,8 +736,6 @@
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
@@ -803,8 +744,6 @@
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
"is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="],
@@ -1039,18 +978,10 @@
"react-dropzone": ["react-dropzone@15.0.0", "", { "dependencies": { "attr-accept": "^2.2.4", "file-selector": "^2.1.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8 || 18.0.0" } }, "sha512-lGjYV/EoqEjEWPnmiSvH4v5IoIAwQM2W4Z1C0Q/Pw2xD0eVzKPS359BQTUMum+1fa0kH2nrKjuavmTPOGhpLPg=="],
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
"recharts": ["recharts@3.8.1", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg=="],
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
"redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
@@ -1059,8 +990,6 @@
"requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="],
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
@@ -1167,8 +1096,6 @@
"tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
@@ -1225,10 +1152,6 @@
"url-parse": ["url-parse@1.5.10", "", { "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" } }, "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
"vite-node": ["vite-node@2.1.8", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.3.7", "es-module-lexer": "^1.5.4", "pathe": "^1.1.2", "vite": "^5.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-uPAwSr57kYjAUux+8E2j0q0Fxpn8M9VoyfGiRI8Kfktz9NcYMCenwY5RnZxnF1WTu3TGiYipirIzacLL3VVGFg=="],
@@ -1279,8 +1202,6 @@
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],
@@ -1333,7 +1254,7 @@
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],

View File

@@ -20,7 +20,6 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"react-dropzone": "^15.0.0",
"recharts": "^3.8.1",
"sonner": "^2.0.7"
},
"devDependencies": {

View File

@@ -1,262 +0,0 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type {
CaseReport,
DeadlineReport,
WorkloadReport,
BillingReport,
} from "@/lib/types";
import { Breadcrumb } from "@/components/layout/Breadcrumb";
import { Skeleton } from "@/components/ui/Skeleton";
import {
AlertTriangle,
RefreshCw,
Download,
Printer,
FolderOpen,
Clock,
Users,
Receipt,
} from "lucide-react";
import { CasesTab } from "@/components/reports/CasesTab";
import { DeadlinesTab } from "@/components/reports/DeadlinesTab";
import { WorkloadTab } from "@/components/reports/WorkloadTab";
import { BillingTab } from "@/components/reports/BillingTab";
type TabKey = "cases" | "deadlines" | "workload" | "billing";
const TABS: { key: TabKey; label: string; icon: typeof FolderOpen }[] = [
{ key: "cases", label: "Akten", icon: FolderOpen },
{ key: "deadlines", label: "Fristen", icon: Clock },
{ key: "workload", label: "Auslastung", icon: Users },
{ key: "billing", label: "Abrechnung", icon: Receipt },
];
function getDefaultDateRange(): { from: string; to: string } {
const now = new Date();
const from = new Date(now.getFullYear() - 1, now.getMonth(), 1);
return {
from: from.toISOString().split("T")[0],
to: now.toISOString().split("T")[0],
};
}
function ReportSkeleton() {
return (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-24 rounded-xl" />
))}
</div>
<Skeleton className="h-72 rounded-xl" />
<Skeleton className="h-48 rounded-xl" />
</div>
);
}
export default function BerichtePage() {
const [activeTab, setActiveTab] = useState<TabKey>("cases");
const defaults = getDefaultDateRange();
const [from, setFrom] = useState(defaults.from);
const [to, setTo] = useState(defaults.to);
const queryParams = `?from=${from}&to=${to}`;
const casesQuery = useQuery({
queryKey: ["reports", "cases", from, to],
queryFn: () => api.get<CaseReport>(`/reports/cases${queryParams}`),
enabled: activeTab === "cases",
});
const deadlinesQuery = useQuery({
queryKey: ["reports", "deadlines", from, to],
queryFn: () => api.get<DeadlineReport>(`/reports/deadlines${queryParams}`),
enabled: activeTab === "deadlines",
});
const workloadQuery = useQuery({
queryKey: ["reports", "workload", from, to],
queryFn: () => api.get<WorkloadReport>(`/reports/workload${queryParams}`),
enabled: activeTab === "workload",
});
const billingQuery = useQuery({
queryKey: ["reports", "billing", from, to],
queryFn: () => api.get<BillingReport>(`/reports/billing${queryParams}`),
enabled: activeTab === "billing",
});
const currentQuery = {
cases: casesQuery,
deadlines: deadlinesQuery,
workload: workloadQuery,
billing: billingQuery,
}[activeTab];
function exportCSV() {
if (!currentQuery.data) return;
let csv = "";
const data = currentQuery.data;
if (activeTab === "cases") {
const d = data as CaseReport;
csv = "Monat,Eroeffnet,Geschlossen,Aktiv\n";
csv += d.monthly
.map((r) => `${r.period},${r.opened},${r.closed},${r.active}`)
.join("\n");
} else if (activeTab === "deadlines") {
const d = data as DeadlineReport;
csv = "Monat,Gesamt,Eingehalten,Versaeumt,Ausstehend,Quote (%)\n";
csv += d.monthly
.map(
(r) =>
`${r.period},${r.total},${r.met},${r.missed},${r.pending},${r.compliance_rate.toFixed(1)}`,
)
.join("\n");
} else if (activeTab === "workload") {
const d = data as WorkloadReport;
csv = "Benutzer-ID,Aktive Akten,Fristen,Ueberfaellig,Erledigt\n";
csv += d.users
.map(
(r) =>
`${r.user_id},${r.active_cases},${r.deadlines},${r.overdue},${r.completed}`,
)
.join("\n");
} else if (activeTab === "billing") {
const d = data as BillingReport;
csv = "Monat,Aktiv,Geschlossen,Neu\n";
csv += d.monthly
.map(
(r) =>
`${r.period},${r.cases_active},${r.cases_closed},${r.cases_new}`,
)
.join("\n");
}
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `bericht-${activeTab}-${from}-${to}.csv`;
link.click();
URL.revokeObjectURL(url);
}
return (
<div className="animate-fade-in mx-auto max-w-6xl space-y-6 print:max-w-none">
<div className="print:hidden">
<Breadcrumb items={[{ label: "Berichte" }]} />
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-lg font-semibold text-neutral-900">Berichte</h1>
<p className="mt-0.5 text-sm text-neutral-500">
Statistiken und Auswertungen
</p>
</div>
<div className="flex items-center gap-3 print:hidden">
<div className="flex items-center gap-2 text-sm">
<label className="text-neutral-500">Von</label>
<input
type="date"
value={from}
onChange={(e) => setFrom(e.target.value)}
className="rounded-md border border-neutral-200 bg-white px-2.5 py-1.5 text-sm text-neutral-900 outline-none focus:border-neutral-400"
/>
<label className="text-neutral-500">Bis</label>
<input
type="date"
value={to}
onChange={(e) => setTo(e.target.value)}
className="rounded-md border border-neutral-200 bg-white px-2.5 py-1.5 text-sm text-neutral-900 outline-none focus:border-neutral-400"
/>
</div>
<button
onClick={exportCSV}
disabled={!currentQuery.data}
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 disabled:opacity-50"
>
<Download className="h-3.5 w-3.5" />
CSV
</button>
<button
onClick={() => window.print()}
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50"
>
<Printer className="h-3.5 w-3.5" />
Drucken
</button>
</div>
</div>
{/* Tabs */}
<div className="border-b border-neutral-200 print:hidden">
<nav className="-mb-px flex gap-6">
{TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex items-center gap-1.5 border-b-2 py-2.5 text-sm font-medium transition-colors ${
activeTab === tab.key
? "border-neutral-900 text-neutral-900"
: "border-transparent text-neutral-500 hover:border-neutral-300 hover:text-neutral-700"
}`}
>
<tab.icon className="h-4 w-4" />
{tab.label}
</button>
))}
</nav>
</div>
{/* Tab content */}
{currentQuery.isLoading && <ReportSkeleton />}
{currentQuery.error && (
<div className="py-12 text-center">
<div className="mx-auto mb-3 w-fit rounded-xl bg-red-50 p-3">
<AlertTriangle className="h-6 w-6 text-red-500" />
</div>
<h2 className="text-sm font-medium text-neutral-900">
Bericht konnte nicht geladen werden
</h2>
<p className="mt-1 text-sm text-neutral-500">
Bitte versuchen Sie es erneut.
</p>
<button
onClick={() => currentQuery.refetch()}
className="mt-4 inline-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"
>
<RefreshCw className="h-3.5 w-3.5" />
Erneut laden
</button>
</div>
)}
{!currentQuery.isLoading && !currentQuery.error && currentQuery.data && (
<>
{activeTab === "cases" && (
<CasesTab data={currentQuery.data as CaseReport} />
)}
{activeTab === "deadlines" && (
<DeadlinesTab data={currentQuery.data as DeadlineReport} />
)}
{activeTab === "workload" && (
<WorkloadTab data={currentQuery.data as WorkloadReport} />
)}
{activeTab === "billing" && (
<BillingTab data={currentQuery.data as BillingReport} />
)}
</>
)}
</div>
);
}

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

@@ -8,8 +8,8 @@ import {
Clock,
Calendar,
Brain,
BarChart3,
Settings,
FileText,
Menu,
X,
} from "lucide-react";
@@ -28,7 +28,7 @@ const allNavigation: NavItem[] = [
{ name: "Akten", href: "/cases", icon: FolderOpen },
{ name: "Fristen", href: "/fristen", icon: Clock },
{ name: "Termine", href: "/termine", icon: Calendar },
{ name: "Berichte", href: "/berichte", icon: BarChart3 },
{ 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

@@ -1,240 +0,0 @@
"use client";
import type { BillingReport } from "@/lib/types";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
LineChart,
Line,
} from "recharts";
import { Receipt, TrendingUp, FolderOpen } from "lucide-react";
function formatMonth(period: string): string {
const [year, month] = period.split("-");
const months = [
"Jan",
"Feb",
"Mär",
"Apr",
"Mai",
"Jun",
"Jul",
"Aug",
"Sep",
"Okt",
"Nov",
"Dez",
];
return `${months[parseInt(month, 10) - 1]} ${year.slice(2)}`;
}
export function BillingTab({ data }: { data: BillingReport }) {
const chartData = data.monthly.map((m) => ({
...m,
name: formatMonth(m.period),
}));
const totalNew = data.monthly.reduce((sum, m) => sum + m.cases_new, 0);
const totalClosed = data.monthly.reduce((sum, m) => sum + m.cases_closed, 0);
const totalByType = data.by_type.reduce((sum, t) => sum + t.total, 0);
return (
<div className="space-y-6">
{/* Summary cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2 text-sm text-neutral-500">
<FolderOpen className="h-4 w-4" />
Neue Mandate
</div>
<p className="mt-2 text-2xl font-semibold text-neutral-900">
{totalNew}
</p>
<p className="mt-1 text-xs text-neutral-500">im Zeitraum</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2 text-sm text-neutral-500">
<Receipt className="h-4 w-4" />
Abgeschlossen
</div>
<p className="mt-2 text-2xl font-semibold text-neutral-900">
{totalClosed}
</p>
<p className="mt-1 text-xs text-neutral-500">abrechenbar</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2 text-sm text-neutral-500">
<TrendingUp className="h-4 w-4" />
Verfahrensarten
</div>
<p className="mt-2 text-2xl font-semibold text-neutral-900">
{data.by_type.length}
</p>
<p className="mt-1 text-xs text-neutral-500">
{totalByType} Akten gesamt
</p>
</div>
</div>
{/* New cases trend */}
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<h3 className="mb-4 text-sm font-medium text-neutral-900">
Umsatzentwicklung (Mandate)
</h3>
{chartData.length === 0 ? (
<p className="py-8 text-center text-sm text-neutral-400">
Keine Daten im gewählten Zeitraum
</p>
) : (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e5e5" />
<XAxis dataKey="name" tick={{ fontSize: 12 }} stroke="#a3a3a3" />
<YAxis
allowDecimals={false}
tick={{ fontSize: 12 }}
stroke="#a3a3a3"
/>
<Tooltip
contentStyle={{
border: "1px solid #e5e5e5",
borderRadius: 8,
fontSize: 13,
}}
/>
<Legend wrapperStyle={{ fontSize: 13 }} />
<Line
type="monotone"
dataKey="cases_new"
name="Neue Mandate"
stroke="#171717"
strokeWidth={2}
dot={{ fill: "#171717", r: 4 }}
/>
<Line
type="monotone"
dataKey="cases_closed"
name="Abgeschlossen"
stroke="#a3a3a3"
strokeWidth={2}
dot={{ fill: "#a3a3a3", r: 4 }}
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
{/* By type breakdown */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<h3 className="mb-4 text-sm font-medium text-neutral-900">
Mandate nach Verfahrensart
</h3>
{data.by_type.length === 0 ? (
<p className="py-8 text-center text-sm text-neutral-400">
Keine Daten
</p>
) : (
<ResponsiveContainer width="100%" height={250}>
<BarChart data={data.by_type} layout="vertical">
<CartesianGrid strokeDasharray="3 3" stroke="#e5e5e5" />
<XAxis
type="number"
allowDecimals={false}
tick={{ fontSize: 12 }}
stroke="#a3a3a3"
/>
<YAxis
type="category"
dataKey="case_type"
tick={{ fontSize: 12 }}
stroke="#a3a3a3"
width={100}
/>
<Tooltip
contentStyle={{
border: "1px solid #e5e5e5",
borderRadius: 8,
fontSize: 13,
}}
/>
<Legend wrapperStyle={{ fontSize: 13 }} />
<Bar
dataKey="active"
name="Aktiv"
stackId="a"
fill="#171717"
/>
<Bar
dataKey="closed"
name="Geschlossen"
stackId="a"
fill="#a3a3a3"
radius={[0, 4, 4, 0]}
/>
</BarChart>
</ResponsiveContainer>
)}
</div>
{/* Summary table */}
<div className="rounded-xl border border-neutral-200 bg-white">
<div className="border-b border-neutral-100 px-5 py-4">
<h3 className="text-sm font-medium text-neutral-900">
Zusammenfassung
</h3>
</div>
{data.by_type.length === 0 ? (
<p className="px-5 py-8 text-center text-sm text-neutral-400">
Keine Daten
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-100 text-left text-neutral-500">
<th className="px-5 py-3 font-medium">Verfahrensart</th>
<th className="px-5 py-3 font-medium text-right">Aktiv</th>
<th className="px-5 py-3 font-medium text-right">
Geschlossen
</th>
<th className="px-5 py-3 font-medium text-right">
Gesamt
</th>
</tr>
</thead>
<tbody>
{data.by_type.map((t) => (
<tr
key={t.case_type}
className="border-b border-neutral-50 last:border-b-0"
>
<td className="px-5 py-3 text-neutral-900">
{t.case_type}
</td>
<td className="px-5 py-3 text-right text-neutral-600">
{t.active}
</td>
<td className="px-5 py-3 text-right text-neutral-600">
{t.closed}
</td>
<td className="px-5 py-3 text-right font-medium text-neutral-900">
{t.total}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,223 +0,0 @@
"use client";
import type { CaseReport } from "@/lib/types";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
PieChart,
Pie,
Cell,
} from "recharts";
import { FolderOpen, TrendingUp, TrendingDown } from "lucide-react";
const COLORS = [
"#171717",
"#525252",
"#a3a3a3",
"#d4d4d4",
"#737373",
"#404040",
"#e5e5e5",
"#262626",
];
function formatMonth(period: string): string {
const [year, month] = period.split("-");
const months = [
"Jan",
"Feb",
"Mär",
"Apr",
"Mai",
"Jun",
"Jul",
"Aug",
"Sep",
"Okt",
"Nov",
"Dez",
];
return `${months[parseInt(month, 10) - 1]} ${year.slice(2)}`;
}
export function CasesTab({ data }: { data: CaseReport }) {
const chartData = data.monthly.map((m) => ({
...m,
name: formatMonth(m.period),
}));
return (
<div className="space-y-6">
{/* Summary cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2 text-sm text-neutral-500">
<FolderOpen className="h-4 w-4" />
Eröffnet
</div>
<p className="mt-2 text-2xl font-semibold text-neutral-900">
{data.total.opened}
</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2 text-sm text-neutral-500">
<TrendingDown className="h-4 w-4" />
Geschlossen
</div>
<p className="mt-2 text-2xl font-semibold text-neutral-900">
{data.total.closed}
</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2 text-sm text-neutral-500">
<TrendingUp className="h-4 w-4" />
Aktiv
</div>
<p className="mt-2 text-2xl font-semibold text-emerald-600">
{data.total.active}
</p>
</div>
</div>
{/* Bar chart: opened/closed per month */}
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<h3 className="mb-4 text-sm font-medium text-neutral-900">
Akten pro Monat
</h3>
{chartData.length === 0 ? (
<p className="py-8 text-center text-sm text-neutral-400">
Keine Daten im gewählten Zeitraum
</p>
) : (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e5e5" />
<XAxis dataKey="name" tick={{ fontSize: 12 }} stroke="#a3a3a3" />
<YAxis
allowDecimals={false}
tick={{ fontSize: 12 }}
stroke="#a3a3a3"
/>
<Tooltip
contentStyle={{
border: "1px solid #e5e5e5",
borderRadius: 8,
fontSize: 13,
}}
/>
<Legend wrapperStyle={{ fontSize: 13 }} />
<Bar
dataKey="opened"
name="Eröffnet"
fill="#171717"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="closed"
name="Geschlossen"
fill="#a3a3a3"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
)}
</div>
{/* Pie charts row */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* By type */}
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<h3 className="mb-4 text-sm font-medium text-neutral-900">
Nach Verfahrensart
</h3>
{data.by_type.length === 0 ? (
<p className="py-8 text-center text-sm text-neutral-400">
Keine Daten
</p>
) : (
<div className="flex items-center gap-4">
<ResponsiveContainer width="50%" height={200}>
<PieChart>
<Pie
data={data.by_type}
dataKey="count"
nameKey="case_type"
cx="50%"
cy="50%"
innerRadius={40}
outerRadius={80}
>
{data.by_type.map((_, i) => (
<Cell
key={i}
fill={COLORS[i % COLORS.length]}
/>
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
<div className="flex-1 space-y-2">
{data.by_type.map((item, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<div
className="h-3 w-3 rounded-full"
style={{
backgroundColor: COLORS[i % COLORS.length],
}}
/>
<span className="text-neutral-600">{item.case_type}</span>
<span className="ml-auto font-medium text-neutral-900">
{item.count}
</span>
</div>
))}
</div>
</div>
)}
</div>
{/* By court */}
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<h3 className="mb-4 text-sm font-medium text-neutral-900">
Nach Gericht
</h3>
{data.by_court.length === 0 ? (
<p className="py-8 text-center text-sm text-neutral-400">
Keine Daten
</p>
) : (
<div className="space-y-3">
{data.by_court.map((item, i) => {
const maxCount = Math.max(...data.by_court.map((c) => c.count));
const pct = maxCount > 0 ? (item.count / maxCount) * 100 : 0;
return (
<div key={i}>
<div className="flex items-center justify-between text-sm">
<span className="text-neutral-600">{item.court}</span>
<span className="font-medium text-neutral-900">
{item.count}
</span>
</div>
<div className="mt-1 h-2 rounded-full bg-neutral-100">
<div
className="h-2 rounded-full bg-neutral-900 transition-all"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,204 +0,0 @@
"use client";
import type { DeadlineReport } from "@/lib/types";
import Link from "next/link";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts";
import { CheckCircle, XCircle, Clock, AlertTriangle } from "lucide-react";
function formatMonth(period: string): string {
const [year, month] = period.split("-");
const months = [
"Jan",
"Feb",
"Mär",
"Apr",
"Mai",
"Jun",
"Jul",
"Aug",
"Sep",
"Okt",
"Nov",
"Dez",
];
return `${months[parseInt(month, 10) - 1]} ${year.slice(2)}`;
}
function formatDate(dateStr: string): string {
const d = new Date(dateStr);
return d.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
export function DeadlinesTab({ data }: { data: DeadlineReport }) {
const chartData = data.monthly.map((m) => ({
...m,
name: formatMonth(m.period),
compliance_rate: Math.round(m.compliance_rate * 10) / 10,
}));
const complianceColor =
data.total.compliance_rate >= 90
? "text-emerald-600"
: data.total.compliance_rate >= 70
? "text-amber-600"
: "text-red-600";
return (
<div className="space-y-6">
{/* Summary cards */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2 text-sm text-neutral-500">
<Clock className="h-4 w-4" />
Gesamt
</div>
<p className="mt-2 text-2xl font-semibold text-neutral-900">
{data.total.total}
</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2 text-sm text-neutral-500">
<CheckCircle className="h-4 w-4" />
Eingehalten
</div>
<p className="mt-2 text-2xl font-semibold text-emerald-600">
{data.total.met}
</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2 text-sm text-neutral-500">
<XCircle className="h-4 w-4" />
Versäumt
</div>
<p className="mt-2 text-2xl font-semibold text-red-600">
{data.total.missed}
</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2 text-sm text-neutral-500">
<CheckCircle className="h-4 w-4" />
Einhaltungsquote
</div>
<p className={`mt-2 text-2xl font-semibold ${complianceColor}`}>
{data.total.compliance_rate.toFixed(1)}%
</p>
</div>
</div>
{/* Compliance rate over time */}
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<h3 className="mb-4 text-sm font-medium text-neutral-900">
Fristeneinhaltung im Zeitverlauf
</h3>
{chartData.length === 0 ? (
<p className="py-8 text-center text-sm text-neutral-400">
Keine Daten im gewählten Zeitraum
</p>
) : (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e5e5" />
<XAxis dataKey="name" tick={{ fontSize: 12 }} stroke="#a3a3a3" />
<YAxis
domain={[0, 100]}
tick={{ fontSize: 12 }}
stroke="#a3a3a3"
unit="%"
/>
<Tooltip
contentStyle={{
border: "1px solid #e5e5e5",
borderRadius: 8,
fontSize: 13,
}}
formatter={(value) => [`${value}%`, "Quote"]}
/>
<Legend wrapperStyle={{ fontSize: 13 }} />
<Line
type="monotone"
dataKey="compliance_rate"
name="Einhaltungsquote"
stroke="#171717"
strokeWidth={2}
dot={{ fill: "#171717", r: 4 }}
activeDot={{ r: 6 }}
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
{/* Missed deadlines table */}
<div className="rounded-xl border border-neutral-200 bg-white">
<div className="border-b border-neutral-100 px-5 py-4">
<h3 className="text-sm font-medium text-neutral-900">
Versäumte Fristen
</h3>
</div>
{data.missed.length === 0 ? (
<div className="px-5 py-8 text-center">
<CheckCircle className="mx-auto h-8 w-8 text-emerald-400" />
<p className="mt-2 text-sm text-neutral-500">
Keine versäumten Fristen im gewählten Zeitraum
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-100 text-left text-neutral-500">
<th className="px-5 py-3 font-medium">Frist</th>
<th className="px-5 py-3 font-medium">Akte</th>
<th className="px-5 py-3 font-medium">Fällig am</th>
<th className="px-5 py-3 font-medium text-right">
Tage überfällig
</th>
</tr>
</thead>
<tbody>
{data.missed.map((d) => (
<tr
key={d.id}
className="border-b border-neutral-50 last:border-b-0"
>
<td className="px-5 py-3 text-neutral-900">{d.title}</td>
<td className="px-5 py-3">
<Link
href={`/cases/${d.case_id}`}
className="text-neutral-600 hover:text-neutral-900"
>
{d.case_number} {d.case_title}
</Link>
</td>
<td className="px-5 py-3 text-neutral-600">
{formatDate(d.due_date)}
</td>
<td className="px-5 py-3 text-right">
<span className="inline-flex items-center gap-1 rounded-full bg-red-50 px-2 py-0.5 text-xs font-medium text-red-700">
<AlertTriangle className="h-3 w-3" />
{d.days_overdue}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,187 +0,0 @@
"use client";
import type { WorkloadReport } from "@/lib/types";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts";
import { Users, AlertTriangle, CheckCircle } from "lucide-react";
export function WorkloadTab({ data }: { data: WorkloadReport }) {
const chartData = data.users.map((u, i) => ({
name: `Nutzer ${i + 1}`,
user_id: u.user_id,
active_cases: u.active_cases,
deadlines: u.deadlines,
overdue: u.overdue,
completed: u.completed,
}));
const totalCases = data.users.reduce((sum, u) => sum + u.active_cases, 0);
const totalOverdue = data.users.reduce((sum, u) => sum + u.overdue, 0);
const totalCompleted = data.users.reduce((sum, u) => sum + u.completed, 0);
return (
<div className="space-y-6">
{/* Summary cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2 text-sm text-neutral-500">
<Users className="h-4 w-4" />
Mitarbeiter
</div>
<p className="mt-2 text-2xl font-semibold text-neutral-900">
{data.users.length}
</p>
<p className="mt-1 text-xs text-neutral-500">
{totalCases} aktive Akten gesamt
</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2 text-sm text-neutral-500">
<AlertTriangle className="h-4 w-4" />
Überfällige Fristen
</div>
<p className="mt-2 text-2xl font-semibold text-red-600">
{totalOverdue}
</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2 text-sm text-neutral-500">
<CheckCircle className="h-4 w-4" />
Erledigte Fristen
</div>
<p className="mt-2 text-2xl font-semibold text-emerald-600">
{totalCompleted}
</p>
</div>
</div>
{/* Stacked bar chart */}
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<h3 className="mb-4 text-sm font-medium text-neutral-900">
Auslastung pro Mitarbeiter
</h3>
{chartData.length === 0 ? (
<p className="py-8 text-center text-sm text-neutral-400">
Keine Daten im gewählten Zeitraum
</p>
) : (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e5e5" />
<XAxis dataKey="name" tick={{ fontSize: 12 }} stroke="#a3a3a3" />
<YAxis
allowDecimals={false}
tick={{ fontSize: 12 }}
stroke="#a3a3a3"
/>
<Tooltip
contentStyle={{
border: "1px solid #e5e5e5",
borderRadius: 8,
fontSize: 13,
}}
/>
<Legend wrapperStyle={{ fontSize: 13 }} />
<Bar
dataKey="active_cases"
name="Aktive Akten"
stackId="work"
fill="#171717"
radius={[0, 0, 0, 0]}
/>
<Bar
dataKey="completed"
name="Erledigt"
stackId="deadlines"
fill="#a3a3a3"
radius={[0, 0, 0, 0]}
/>
<Bar
dataKey="overdue"
name="Überfällig"
stackId="deadlines"
fill="#dc2626"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
)}
</div>
{/* Table */}
<div className="rounded-xl border border-neutral-200 bg-white">
<div className="border-b border-neutral-100 px-5 py-4">
<h3 className="text-sm font-medium text-neutral-900">
Übersicht pro Mitarbeiter
</h3>
</div>
{data.users.length === 0 ? (
<p className="px-5 py-8 text-center text-sm text-neutral-400">
Keine Mitarbeiter mit zugewiesenen Akten
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-100 text-left text-neutral-500">
<th className="px-5 py-3 font-medium">Mitarbeiter</th>
<th className="px-5 py-3 font-medium text-right">
Aktive Akten
</th>
<th className="px-5 py-3 font-medium text-right">Fristen</th>
<th className="px-5 py-3 font-medium text-right">
Überfällig
</th>
<th className="px-5 py-3 font-medium text-right">
Erledigt
</th>
</tr>
</thead>
<tbody>
{data.users.map((u, i) => (
<tr
key={u.user_id}
className="border-b border-neutral-50 last:border-b-0"
>
<td className="px-5 py-3 text-neutral-900">
Nutzer {i + 1}
<span className="ml-2 text-xs text-neutral-400">
{u.user_id.slice(0, 8)}...
</span>
</td>
<td className="px-5 py-3 text-right font-medium text-neutral-900">
{u.active_cases}
</td>
<td className="px-5 py-3 text-right text-neutral-600">
{u.deadlines}
</td>
<td className="px-5 py-3 text-right">
{u.overdue > 0 ? (
<span className="inline-flex items-center rounded-full bg-red-50 px-2 py-0.5 text-xs font-medium text-red-700">
{u.overdue}
</span>
) : (
<span className="text-neutral-400">0</span>
)}
</td>
<td className="px-5 py-3 text-right text-emerald-600">
{u.completed}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

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;
@@ -329,149 +405,3 @@ export interface ExtractionResponse {
deadlines: ExtractedDeadline[];
count: number;
}
// Notification types
export interface Notification {
id: string;
tenant_id: string;
user_id: string;
type: string;
entity_type?: string;
entity_id?: string;
title: string;
body?: string;
sent_at?: string;
read_at?: string;
created_at: string;
}
export interface NotificationPreferences {
user_id: string;
tenant_id: string;
deadline_reminder_days: number[];
email_enabled: boolean;
daily_digest: boolean;
created_at: string;
updated_at: string;
}
export interface NotificationListResponse {
data: Notification[];
total: number;
}
// Audit log types
export interface AuditLogEntry {
id: number;
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;
page: number;
limit: number;
}
// Reporting types
export interface CaseStats {
period: string;
opened: number;
closed: number;
active: number;
}
export interface CasesByType {
case_type: string;
count: number;
}
export interface CasesByCourt {
court: string;
count: number;
}
export interface CaseReport {
monthly: CaseStats[];
by_type: CasesByType[];
by_court: CasesByCourt[];
total: {
opened: number;
closed: number;
active: number;
};
}
export interface DeadlineCompliance {
period: string;
total: number;
met: number;
missed: number;
pending: number;
compliance_rate: number;
}
export interface MissedDeadline {
id: string;
title: string;
due_date: string;
case_id: string;
case_number: string;
case_title: string;
days_overdue: number;
}
export interface DeadlineReport {
monthly: DeadlineCompliance[];
missed: MissedDeadline[];
total: {
total: number;
met: number;
missed: number;
pending: number;
compliance_rate: number;
};
}
export interface UserWorkload {
user_id: string;
active_cases: number;
deadlines: number;
overdue: number;
completed: number;
}
export interface WorkloadReport {
users: UserWorkload[];
}
export interface BillingByMonth {
period: string;
cases_active: number;
cases_closed: number;
cases_new: number;
}
export interface BillingByType {
case_type: string;
active: number;
closed: number;
total: number;
}
export interface BillingReport {
monthly: BillingByMonth[];
by_type: BillingByType[];
}