Compare commits

..

15 Commits

Author SHA1 Message Date
m
4b0ccac384 fix: auto-strip /api/ prefix in api client + document convention
The api client now calls normalizePath() to strip accidental /api/
prefixes. This prevents the recurring /api/api/ double-prefix bug.
Added convention note to .claude/CLAUDE.md so future workers know.
2026-03-30 13:05:02 +02:00
m
3030ef1e8b fix: add all missing type exports (TimeEntry, Invoice, reports, notifications, audit) 2026-03-30 11:52:10 +02:00
m
2578060638 fix: add missing TEMPLATE_CATEGORY_LABELS export to types.ts 2026-03-30 11:43:36 +02:00
m
8f91feee0e feat: UPC deadline determination — event-driven proceeding timeline wizard 2026-03-30 11:38:08 +02:00
m
a89ef26ebd feat: UPC deadline determination — event-driven model with proceeding timeline
Full event-driven deadline determination system ported from youpc.org:

Backend:
- DetermineService: walks proceeding event tree, calculates cascading
  dates with holiday adjustment and conditional logic
- GET /api/proceeding-types/{code}/timeline — full event tree structure
- POST /api/deadlines/determine — calculate timeline with conditions
- POST /api/cases/{caseID}/deadlines/batch — batch-create deadlines
- DeadlineRule model: added is_spawn, spawn_label fields
- GetFullTimeline: recursive CTE following cross-type spawn branches
- Conditional deadlines: condition_rule_id toggles alt_duration/rule_code
  (e.g. Reply changes from RoP.029b to RoP.029a when CCR is filed)
- Seed SQL with full UPC event trees (INF, REV, CCR, APM, APP, AMD)

Frontend:
- DeadlineWizard: interactive proceeding timeline with step-by-step flow
  1. Select proceeding type (visual cards)
  2. Enter trigger event date
  3. Toggle conditional branches (CCR, Appeal, Amend)
  4. See full calculated timeline with color-coded urgency
  5. Batch-create all deadlines on a selected case
- Visual timeline tree with party icons, rule codes, duration badges
- Kept existing DeadlineCalculator as "Schnell" quick mode

Also resolved merge conflicts across 6 files (auth, router, handlers)
merging role-based permissions + audit trail features.
2026-03-30 11:33:59 +02:00
m
6b8c6f761d feat: HL tenant + email domain auto-assignment 2026-03-30 11:29:53 +02:00
m
93a25e3d72 feat: AI features — drafting, strategy, similar cases (P2) 2026-03-30 11:29:41 +02:00
m
81c2bb29b9 feat: reporting dashboard with charts (P1) 2026-03-30 11:29:35 +02:00
m
9f18fbab80 feat: document templates with auto-fill (P1) 2026-03-30 11:29:23 +02:00
m
ae55d9814a feat: time tracking + billing (P1) 2026-03-30 11:29:10 +02:00
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
m
118bae1ae3 feat: HL tenant setup + email domain auto-assignment
- Create pre-configured Hogan Lovells tenant with demo flag and
  auto_assign_domains: ["hoganlovells.com"]
- Add POST /api/tenants/auto-assign endpoint: checks email domain
  against tenant settings, auto-assigns user as associate if match
- Add AutoAssignByDomain to TenantService
- Update registration flow: after signup, check auto-assign before
  showing tenant creation form. Skip tenant creation if auto-assigned.
- Add DemoBanner component shown when tenant.settings.demo is true
- Extend GET /api/me to return is_demo flag from tenant settings
2026-03-30 11:24:52 +02:00
m
fdef5af32e feat: reporting dashboard — case stats, deadline compliance, workload, billing (P1)
Backend:
- ReportingService with aggregation queries (CTEs, FILTER clauses)
- 4 API endpoints: /api/reports/{cases,deadlines,workload,billing}
- Date range filtering via ?from=&to= query params

Frontend:
- /berichte page with 4 tabs: Akten, Fristen, Auslastung, Abrechnung
- recharts: bar/pie/line charts for all report types
- Date range picker, CSV export, print-friendly view
- Sidebar nav entry with BarChart3 icon

Also resolves merge conflicts between role-based, notification, and
audit trail branches, and adds missing TS types (AuditLogResponse,
Notification, NotificationPreferences).
2026-03-30 11:24:45 +02:00
m
34dcbb74fe fix: resolve merge conflicts from role-based permissions + audit trail branches
Combines auth context keys (user role, IP, user-agent), tenant resolver
(GetUserRole-based access verification), middleware (deferred tenant
resolution + request info capture), and router (audit log + notifications
+ assignments).
2026-03-30 11:24:43 +02:00
m
238811727d feat: time tracking + billing — hourly rates, time entries, invoices (P1)
Database: time_entries, billing_rates, invoices tables with RLS.
Backend: CRUD services+handlers for time entries, billing rates, invoices.
  - Time entries: list/create/update/delete, summary by case/user/month
  - Billing rates: upsert with auto-close previous, current rate lookup
  - Invoices: create with auto-number (RE-YYYY-NNN), status transitions
    (draft->sent->paid, cancellation), link time entries on invoice create
API: 11 new endpoints under /api/time-entries, /api/billing-rates, /api/invoices
Frontend: Zeiterfassung tab on case detail, /abrechnung overview with filters,
  /abrechnung/rechnungen list+detail with status actions, billing rates settings
Also: resolved merge conflicts between audit-trail and role-based branches,
  added missing types (Notification, AuditLogResponse, NotificationPreferences)
2026-03-30 11:24:36 +02:00
54 changed files with 7274 additions and 127 deletions

View File

@@ -18,6 +18,7 @@
- ESLint must pass before committing
- Import aliases: `@/` maps to `src/`
- Bun as package manager (not npm/yarn/pnpm)
- **API paths: NEVER include `/api/` prefix.** The `api` client in `lib/api.ts` already has `baseUrl="/api"`. Write `api.get("/cases")` NOT `api.get("/api/cases")`. The client auto-strips accidental `/api/` prefixes but don't rely on it.
## General

View File

@@ -52,7 +52,7 @@ head:
infinity_mode: false
capacity:
global:
max_workers: 5
max_workers: 6
max_heads: 3
per_worker:
max_tasks_lifetime: 0

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

@@ -43,8 +43,7 @@ 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.
// Tenant and role resolution handled by TenantResolver middleware for scoped routes.
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View File

@@ -12,7 +12,6 @@ import (
// Defined as an interface to avoid circular dependency with services.
type TenantLookup interface {
FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error)
VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error)
GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error)
}
@@ -35,6 +34,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,9 +56,9 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
}
tenantID = parsed
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)
@@ -70,9 +70,18 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
return
}
tenantID = *first
// Look up 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

@@ -10,43 +10,32 @@ import (
)
type mockTenantLookup struct {
tenantID *uuid.UUID
err error
hasAccess bool
accessErr error
role string
noAccess bool // when true, GetUserRole returns ""
tenantID *uuid.UUID
role string
err error
}
func (m *mockTenantLookup) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) {
return m.tenantID, m.err
}
func (m *mockTenantLookup) VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error) {
return m.hasAccess, m.accessErr
}
func (m *mockTenantLookup) GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) {
if m.noAccess {
return "", m.err
}
if m.role != "" {
return m.role, m.err
}
return "associate", m.err
return m.role, m.err
}
func TestTenantResolver_FromHeader(t *testing.T) {
tenantID := uuid.New()
tr := NewTenantResolver(&mockTenantLookup{role: "partner", hasAccess: true})
tr := NewTenantResolver(&mockTenantLookup{role: "partner"})
var gotTenantID uuid.UUID
var gotRole string
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id, ok := TenantFromContext(r.Context())
if !ok {
t.Fatal("tenant ID not in context")
}
gotTenantID = id
gotRole = UserRoleFromContext(r.Context())
w.WriteHeader(http.StatusOK)
})
@@ -63,11 +52,14 @@ func TestTenantResolver_FromHeader(t *testing.T) {
if gotTenantID != tenantID {
t.Errorf("expected tenant %s, got %s", tenantID, gotTenantID)
}
if gotRole != "partner" {
t.Errorf("expected role partner, got %s", gotRole)
}
}
func TestTenantResolver_FromHeader_NoAccess(t *testing.T) {
tenantID := uuid.New()
tr := NewTenantResolver(&mockTenantLookup{noAccess: true})
tr := NewTenantResolver(&mockTenantLookup{role: ""})
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next should not be called")
@@ -87,7 +79,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

@@ -0,0 +1,66 @@
package handlers
import (
"encoding/json"
"net/http"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
type BillingRateHandler struct {
svc *services.BillingRateService
}
func NewBillingRateHandler(svc *services.BillingRateService) *BillingRateHandler {
return &BillingRateHandler{svc: svc}
}
// List handles GET /api/billing-rates
func (h *BillingRateHandler) List(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
rates, err := h.svc.List(r.Context(), tenantID)
if err != nil {
internalError(w, "failed to list billing rates", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"billing_rates": rates})
}
// Upsert handles PUT /api/billing-rates
func (h *BillingRateHandler) Upsert(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
var input services.UpsertBillingRateInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if input.Rate < 0 {
writeError(w, http.StatusBadRequest, "rate must be non-negative")
return
}
if input.ValidFrom == "" {
writeError(w, http.StatusBadRequest, "valid_from is required")
return
}
rate, err := h.svc.Upsert(r.Context(), tenantID, input)
if err != nil {
internalError(w, "failed to upsert billing rate", err)
return
}
writeJSON(w, http.StatusOK, rate)
}

View File

@@ -0,0 +1,127 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
// DetermineHandlers holds handlers for deadline determination endpoints
type DetermineHandlers struct {
determine *services.DetermineService
deadlines *services.DeadlineService
}
// NewDetermineHandlers creates determine handlers
func NewDetermineHandlers(determine *services.DetermineService, deadlines *services.DeadlineService) *DetermineHandlers {
return &DetermineHandlers{determine: determine, deadlines: deadlines}
}
// GetTimeline handles GET /api/proceeding-types/{code}/timeline
// Returns the full event tree for a proceeding type (no date calculations)
func (h *DetermineHandlers) GetTimeline(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("code")
if code == "" {
writeError(w, http.StatusBadRequest, "proceeding type code required")
return
}
timeline, pt, err := h.determine.GetTimeline(code)
if err != nil {
writeError(w, http.StatusNotFound, "proceeding type not found")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"proceeding_type": pt,
"timeline": timeline,
})
}
// Determine handles POST /api/deadlines/determine
// Calculates the full timeline with cascading dates and conditional logic
func (h *DetermineHandlers) Determine(w http.ResponseWriter, r *http.Request) {
var req services.DetermineRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.ProceedingType == "" || req.TriggerEventDate == "" {
writeError(w, http.StatusBadRequest, "proceeding_type and trigger_event_date are required")
return
}
resp, err := h.determine.Determine(req)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, resp)
}
// BatchCreate handles POST /api/cases/{caseID}/deadlines/batch
// Creates multiple deadlines on a case from determined timeline
func (h *DetermineHandlers) BatchCreate(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
caseID, err := parsePathUUID(r, "caseID")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case ID")
return
}
var req struct {
Deadlines []struct {
Title string `json:"title"`
DueDate string `json:"due_date"`
OriginalDueDate *string `json:"original_due_date,omitempty"`
RuleID *uuid.UUID `json:"rule_id,omitempty"`
RuleCode *string `json:"rule_code,omitempty"`
Notes *string `json:"notes,omitempty"`
} `json:"deadlines"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if len(req.Deadlines) == 0 {
writeError(w, http.StatusBadRequest, "at least one deadline is required")
return
}
var created int
for _, d := range req.Deadlines {
if d.Title == "" || d.DueDate == "" {
continue
}
input := services.CreateDeadlineInput{
CaseID: caseID,
Title: d.Title,
DueDate: d.DueDate,
Source: "determined",
RuleID: d.RuleID,
Notes: d.Notes,
}
_, err := h.deadlines.Create(r.Context(), tenantID, input)
if err != nil {
internalError(w, "failed to create deadline", err)
return
}
created++
}
writeJSON(w, http.StatusCreated, map[string]any{
"created": created,
})
}

View File

@@ -0,0 +1,170 @@
package handlers
import (
"encoding/json"
"net/http"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
"github.com/google/uuid"
)
type InvoiceHandler struct {
svc *services.InvoiceService
}
func NewInvoiceHandler(svc *services.InvoiceService) *InvoiceHandler {
return &InvoiceHandler{svc: svc}
}
// List handles GET /api/invoices?case_id=&status=
func (h *InvoiceHandler) List(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
var caseID *uuid.UUID
if caseStr := r.URL.Query().Get("case_id"); caseStr != "" {
parsed, err := uuid.Parse(caseStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case_id")
return
}
caseID = &parsed
}
invoices, err := h.svc.List(r.Context(), tenantID, caseID, r.URL.Query().Get("status"))
if err != nil {
internalError(w, "failed to list invoices", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"invoices": invoices})
}
// Get handles GET /api/invoices/{id}
func (h *InvoiceHandler) Get(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
invoiceID, err := parsePathUUID(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid invoice ID")
return
}
inv, err := h.svc.GetByID(r.Context(), tenantID, invoiceID)
if err != nil {
internalError(w, "failed to get invoice", err)
return
}
if inv == nil {
writeError(w, http.StatusNotFound, "invoice not found")
return
}
writeJSON(w, http.StatusOK, inv)
}
// Create handles POST /api/invoices
func (h *InvoiceHandler) Create(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())
var input services.CreateInvoiceInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if input.ClientName == "" {
writeError(w, http.StatusBadRequest, "client_name is required")
return
}
if input.CaseID == uuid.Nil {
writeError(w, http.StatusBadRequest, "case_id is required")
return
}
inv, err := h.svc.Create(r.Context(), tenantID, userID, input)
if err != nil {
internalError(w, "failed to create invoice", err)
return
}
writeJSON(w, http.StatusCreated, inv)
}
// Update handles PUT /api/invoices/{id}
func (h *InvoiceHandler) Update(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
invoiceID, err := parsePathUUID(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid invoice ID")
return
}
var input services.UpdateInvoiceInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
inv, err := h.svc.Update(r.Context(), tenantID, invoiceID, input)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, inv)
}
// UpdateStatus handles PATCH /api/invoices/{id}/status
func (h *InvoiceHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
invoiceID, err := parsePathUUID(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid invoice ID")
return
}
var body struct {
Status string `json:"status"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if body.Status == "" {
writeError(w, http.StatusBadRequest, "status is required")
return
}
inv, err := h.svc.UpdateStatus(r.Context(), tenantID, invoiceID, body.Status)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, inv)
}

View File

@@ -0,0 +1,109 @@
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

@@ -356,6 +356,71 @@ func (h *TenantHandler) UpdateMemberRole(w http.ResponseWriter, r *http.Request)
jsonResponse(w, map[string]string{"status": "updated"}, http.StatusOK)
}
// AutoAssign handles POST /api/tenants/auto-assign — checks if the user's email domain
// matches any tenant's auto_assign_domains and assigns them if so.
func (h *TenantHandler) AutoAssign(w http.ResponseWriter, r *http.Request) {
userID, ok := auth.UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req struct {
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid request body", http.StatusBadRequest)
return
}
if req.Email == "" {
jsonError(w, "email is required", http.StatusBadRequest)
return
}
// Extract domain from email
parts := splitEmail(req.Email)
if parts == "" {
jsonError(w, "invalid email format", http.StatusBadRequest)
return
}
result, err := h.svc.AutoAssignByDomain(r.Context(), userID, parts)
if err != nil {
slog.Error("auto-assign failed", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return
}
if result == nil {
jsonResponse(w, map[string]any{"assigned": false}, http.StatusOK)
return
}
jsonResponse(w, map[string]any{
"assigned": true,
"tenant_id": result.ID,
"name": result.Name,
"slug": result.Slug,
"role": result.Role,
"settings": result.Settings,
}, http.StatusOK)
}
// splitEmail extracts the domain part from an email address.
func splitEmail(email string) string {
at := -1
for i, c := range email {
if c == '@' {
at = i
break
}
}
if at < 0 || at >= len(email)-1 {
return ""
}
return email[at+1:]
}
// GetMe handles GET /api/me — returns the current user's ID and role in the active tenant.
func (h *TenantHandler) GetMe(w http.ResponseWriter, r *http.Request) {
userID, ok := auth.UserFromContext(r.Context())
@@ -370,11 +435,26 @@ func (h *TenantHandler) GetMe(w http.ResponseWriter, r *http.Request) {
// Get user's permissions for frontend UI
perms := auth.GetRolePermissions(role)
// Check if tenant is in demo mode
isDemo := false
if tenant, err := h.svc.GetByID(r.Context(), tenantID); err == nil && tenant != nil {
var settings map[string]json.RawMessage
if json.Unmarshal(tenant.Settings, &settings) == nil {
if demoRaw, ok := settings["demo"]; ok {
var demo bool
if json.Unmarshal(demoRaw, &demo) == nil {
isDemo = demo
}
}
}
}
jsonResponse(w, map[string]any{
"user_id": userID,
"tenant_id": tenantID,
"role": role,
"permissions": perms,
"is_demo": isDemo,
}, http.StatusOK)
}

View File

@@ -0,0 +1,209 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
"github.com/google/uuid"
)
type TimeEntryHandler struct {
svc *services.TimeEntryService
}
func NewTimeEntryHandler(svc *services.TimeEntryService) *TimeEntryHandler {
return &TimeEntryHandler{svc: svc}
}
// ListForCase handles GET /api/cases/{id}/time-entries
func (h *TimeEntryHandler) ListForCase(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
caseID, err := parsePathUUID(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case ID")
return
}
entries, err := h.svc.ListForCase(r.Context(), tenantID, caseID)
if err != nil {
internalError(w, "failed to list time entries", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"time_entries": entries})
}
// List handles GET /api/time-entries?case_id=&user_id=&from=&to=
func (h *TimeEntryHandler) List(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
limit, offset = clampPagination(limit, offset)
filter := services.TimeEntryFilter{
From: r.URL.Query().Get("from"),
To: r.URL.Query().Get("to"),
Limit: limit,
Offset: offset,
}
if caseStr := r.URL.Query().Get("case_id"); caseStr != "" {
caseID, err := uuid.Parse(caseStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case_id")
return
}
filter.CaseID = &caseID
}
if userStr := r.URL.Query().Get("user_id"); userStr != "" {
userID, err := uuid.Parse(userStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid user_id")
return
}
filter.UserID = &userID
}
entries, total, err := h.svc.List(r.Context(), tenantID, filter)
if err != nil {
internalError(w, "failed to list time entries", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"time_entries": entries,
"total": total,
})
}
// Create handles POST /api/cases/{id}/time-entries
func (h *TimeEntryHandler) Create(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())
caseID, err := parsePathUUID(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case ID")
return
}
var input services.CreateTimeEntryInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
input.CaseID = caseID
if input.Description == "" {
writeError(w, http.StatusBadRequest, "description is required")
return
}
if input.DurationMinutes <= 0 {
writeError(w, http.StatusBadRequest, "duration_minutes must be positive")
return
}
if input.Date == "" {
writeError(w, http.StatusBadRequest, "date is required")
return
}
entry, err := h.svc.Create(r.Context(), tenantID, userID, input)
if err != nil {
internalError(w, "failed to create time entry", err)
return
}
writeJSON(w, http.StatusCreated, entry)
}
// Update handles PUT /api/time-entries/{id}
func (h *TimeEntryHandler) Update(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
entryID, err := parsePathUUID(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid time entry ID")
return
}
var input services.UpdateTimeEntryInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
entry, err := h.svc.Update(r.Context(), tenantID, entryID, input)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, entry)
}
// Delete handles DELETE /api/time-entries/{id}
func (h *TimeEntryHandler) Delete(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
entryID, err := parsePathUUID(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid time entry ID")
return
}
if err := h.svc.Delete(r.Context(), tenantID, entryID); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// Summary handles GET /api/time-entries/summary?group_by=case|user|month&from=&to=
func (h *TimeEntryHandler) Summary(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
groupBy := r.URL.Query().Get("group_by")
if groupBy == "" {
groupBy = "case"
}
summaries, err := h.svc.Summary(r.Context(), tenantID, groupBy,
r.URL.Query().Get("from"), r.URL.Query().Get("to"))
if err != nil {
internalError(w, "failed to get summary", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"summary": summaries})
}

View File

@@ -0,0 +1,18 @@
package models
import (
"time"
"github.com/google/uuid"
)
type BillingRate struct {
ID uuid.UUID `db:"id" json:"id"`
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
UserID *uuid.UUID `db:"user_id" json:"user_id,omitempty"`
Rate float64 `db:"rate" json:"rate"`
Currency string `db:"currency" json:"currency"`
ValidFrom string `db:"valid_from" json:"valid_from"`
ValidTo *string `db:"valid_to" json:"valid_to,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}

View File

@@ -26,6 +26,8 @@ type DeadlineRule struct {
AltDurationValue *int `db:"alt_duration_value" json:"alt_duration_value,omitempty"`
AltDurationUnit *string `db:"alt_duration_unit" json:"alt_duration_unit,omitempty"`
AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,omitempty"`
IsSpawn bool `db:"is_spawn" json:"is_spawn"`
SpawnLabel *string `db:"spawn_label" json:"spawn_label,omitempty"`
IsActive bool `db:"is_active" json:"is_active"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`

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

@@ -0,0 +1,38 @@
package models
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
type Invoice struct {
ID uuid.UUID `db:"id" json:"id"`
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
CaseID uuid.UUID `db:"case_id" json:"case_id"`
InvoiceNumber string `db:"invoice_number" json:"invoice_number"`
ClientName string `db:"client_name" json:"client_name"`
ClientAddress *string `db:"client_address" json:"client_address,omitempty"`
Items json.RawMessage `db:"items" json:"items"`
Subtotal float64 `db:"subtotal" json:"subtotal"`
TaxRate float64 `db:"tax_rate" json:"tax_rate"`
TaxAmount float64 `db:"tax_amount" json:"tax_amount"`
Total float64 `db:"total" json:"total"`
Status string `db:"status" json:"status"`
IssuedAt *string `db:"issued_at" json:"issued_at,omitempty"`
DueAt *string `db:"due_at" json:"due_at,omitempty"`
PaidAt *time.Time `db:"paid_at" json:"paid_at,omitempty"`
Notes *string `db:"notes" json:"notes,omitempty"`
CreatedBy uuid.UUID `db:"created_by" json:"created_by"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
type InvoiceItem struct {
Description string `json:"description"`
DurationMinutes int `json:"duration_minutes,omitempty"`
HourlyRate float64 `json:"hourly_rate,omitempty"`
Amount float64 `json:"amount"`
TimeEntryID *string `json:"time_entry_id,omitempty"`
}

View File

@@ -0,0 +1,24 @@
package models
import (
"time"
"github.com/google/uuid"
)
type TimeEntry struct {
ID uuid.UUID `db:"id" json:"id"`
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
CaseID uuid.UUID `db:"case_id" json:"case_id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
Date string `db:"date" json:"date"`
DurationMinutes int `db:"duration_minutes" json:"duration_minutes"`
Description string `db:"description" json:"description"`
Activity *string `db:"activity" json:"activity,omitempty"`
Billable bool `db:"billable" json:"billable"`
Billed bool `db:"billed" json:"billed"`
InvoiceID *uuid.UUID `db:"invoice_id" json:"invoice_id,omitempty"`
HourlyRate *float64 `db:"hourly_rate" json:"hourly_rate,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}

View File

@@ -28,6 +28,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
deadlineSvc := services.NewDeadlineService(db, auditSvc)
deadlineRuleSvc := services.NewDeadlineRuleService(db)
calculator := services.NewDeadlineCalculator(holidaySvc)
determineSvc := services.NewDetermineService(db, calculator)
storageCli := services.NewStorageClient(cfg.SupabaseURL, cfg.SupabaseServiceKey)
documentSvc := services.NewDocumentService(db, storageCli, auditSvc)
assignmentSvc := services.NewCaseAssignmentService(db)
@@ -64,6 +65,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
deadlineH := handlers.NewDeadlineHandlers(deadlineSvc)
ruleH := handlers.NewDeadlineRuleHandlers(deadlineRuleSvc)
calcH := handlers.NewCalculateHandlers(calculator, deadlineRuleSvc)
determineH := handlers.NewDetermineHandlers(determineSvc, deadlineSvc)
dashboardH := handlers.NewDashboardHandler(dashboardSvc)
noteH := handlers.NewNoteHandler(noteSvc)
eventH := handlers.NewCaseEventHandler(db)
@@ -77,6 +79,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
api := http.NewServeMux()
// Tenant management (no tenant resolver — these operate across tenants)
api.HandleFunc("POST /api/tenants/auto-assign", tenantH.AutoAssign)
api.HandleFunc("POST /api/tenants", tenantH.CreateTenant)
api.HandleFunc("GET /api/tenants", tenantH.ListTenants)
api.HandleFunc("GET /api/tenants/{id}", tenantH.GetTenant)
@@ -110,7 +113,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
@@ -136,6 +139,11 @@ 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)
// Deadline determination — full timeline calculation with conditions
scoped.HandleFunc("GET /api/proceeding-types/{code}/timeline", determineH.GetTimeline)
scoped.HandleFunc("POST /api/deadlines/determine", determineH.Determine)
scoped.HandleFunc("POST /api/cases/{caseID}/deadlines/batch", perm(auth.PermManageDeadlines, determineH.BatchCreate))
// Appointments — all can manage (PermManageAppointments granted to all)
scoped.HandleFunc("GET /api/appointments/{id}", apptH.Get)
scoped.HandleFunc("GET /api/appointments", apptH.List)
@@ -161,14 +169,14 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
scoped.HandleFunc("GET /api/dashboard", dashboardH.Get)
// Audit log
scoped.HandleFunc("GET /api/audit-log", auditH.List)
scoped.HandleFunc("GET /api/audit-log", perm(auth.PermViewAuditLog, auditH.List))
// Documents — all can upload, delete checked in handler (own vs all)
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)
// AI endpoints (rate limited: 5 req/min burst 10 per IP)
if aiH != nil {

View File

@@ -0,0 +1,88 @@
package services
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
type BillingRateService struct {
db *sqlx.DB
audit *AuditService
}
func NewBillingRateService(db *sqlx.DB, audit *AuditService) *BillingRateService {
return &BillingRateService{db: db, audit: audit}
}
type UpsertBillingRateInput struct {
UserID *uuid.UUID `json:"user_id,omitempty"`
Rate float64 `json:"rate"`
Currency string `json:"currency"`
ValidFrom string `json:"valid_from"`
ValidTo *string `json:"valid_to,omitempty"`
}
func (s *BillingRateService) List(ctx context.Context, tenantID uuid.UUID) ([]models.BillingRate, error) {
var rates []models.BillingRate
err := s.db.SelectContext(ctx, &rates,
`SELECT id, tenant_id, user_id, rate, currency, valid_from, valid_to, created_at
FROM billing_rates
WHERE tenant_id = $1
ORDER BY valid_from DESC, user_id NULLS LAST`,
tenantID)
if err != nil {
return nil, fmt.Errorf("list billing rates: %w", err)
}
return rates, nil
}
func (s *BillingRateService) Upsert(ctx context.Context, tenantID uuid.UUID, input UpsertBillingRateInput) (*models.BillingRate, error) {
if input.Currency == "" {
input.Currency = "EUR"
}
// Close any existing open-ended rate for this user
_, err := s.db.ExecContext(ctx,
`UPDATE billing_rates SET valid_to = $3
WHERE tenant_id = $1
AND (($2::uuid IS NULL AND user_id IS NULL) OR user_id = $2)
AND valid_to IS NULL
AND valid_from < $3`,
tenantID, input.UserID, input.ValidFrom)
if err != nil {
return nil, fmt.Errorf("close existing rate: %w", err)
}
var rate models.BillingRate
err = s.db.QueryRowxContext(ctx,
`INSERT INTO billing_rates (tenant_id, user_id, rate, currency, valid_from, valid_to)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, tenant_id, user_id, rate, currency, valid_from, valid_to, created_at`,
tenantID, input.UserID, input.Rate, input.Currency, input.ValidFrom, input.ValidTo,
).StructScan(&rate)
if err != nil {
return nil, fmt.Errorf("upsert billing rate: %w", err)
}
s.audit.Log(ctx, "create", "billing_rate", &rate.ID, nil, rate)
return &rate, nil
}
func (s *BillingRateService) GetCurrentRate(ctx context.Context, tenantID uuid.UUID, userID uuid.UUID, date string) (*float64, error) {
var rate float64
err := s.db.GetContext(ctx, &rate,
`SELECT rate FROM billing_rates
WHERE tenant_id = $1 AND (user_id = $2 OR user_id IS NULL)
AND valid_from <= $3 AND (valid_to IS NULL OR valid_to >= $3)
ORDER BY user_id NULLS LAST LIMIT 1`,
tenantID, userID, date)
if err != nil {
return nil, err
}
return &rate, nil
}

View File

@@ -8,6 +8,12 @@ import (
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
const ruleColumns = `id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
timing, rule_code, deadline_notes, sequence_order, condition_rule_id,
alt_duration_value, alt_duration_unit, alt_rule_code,
is_spawn, spawn_label, is_active, created_at, updated_at`
// DeadlineRuleService handles deadline rule queries
type DeadlineRuleService struct {
db *sqlx.DB
@@ -25,21 +31,13 @@ func (s *DeadlineRuleService) List(proceedingTypeID *int) ([]models.DeadlineRule
if proceedingTypeID != nil {
err = s.db.Select(&rules,
`SELECT id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
timing, rule_code, deadline_notes, sequence_order, condition_rule_id,
alt_duration_value, alt_duration_unit, alt_rule_code, is_active,
created_at, updated_at
`SELECT `+ruleColumns+`
FROM deadline_rules
WHERE proceeding_type_id = $1 AND is_active = true
ORDER BY sequence_order`, *proceedingTypeID)
} else {
err = s.db.Select(&rules,
`SELECT id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
timing, rule_code, deadline_notes, sequence_order, condition_rule_id,
alt_duration_value, alt_duration_unit, alt_rule_code, is_active,
created_at, updated_at
`SELECT `+ruleColumns+`
FROM deadline_rules
WHERE is_active = true
ORDER BY proceeding_type_id, sequence_order`)
@@ -72,11 +70,7 @@ func (s *DeadlineRuleService) GetRuleTree(proceedingTypeCode string) ([]RuleTree
// Get all rules for this proceeding type
var rules []models.DeadlineRule
err = s.db.Select(&rules,
`SELECT id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
timing, rule_code, deadline_notes, sequence_order, condition_rule_id,
alt_duration_value, alt_duration_unit, alt_rule_code, is_active,
created_at, updated_at
`SELECT `+ruleColumns+`
FROM deadline_rules
WHERE proceeding_type_id = $1 AND is_active = true
ORDER BY sequence_order`, pt.ID)
@@ -87,6 +81,36 @@ func (s *DeadlineRuleService) GetRuleTree(proceedingTypeCode string) ([]RuleTree
return buildTree(rules), nil
}
// GetFullTimeline returns the full event tree for a proceeding type using a recursive CTE.
// Unlike GetRuleTree, this follows parent_id across proceeding types (includes cross-type spawns).
func (s *DeadlineRuleService) GetFullTimeline(proceedingTypeCode string) ([]models.DeadlineRule, *models.ProceedingType, error) {
var pt models.ProceedingType
err := s.db.Get(&pt,
`SELECT id, code, name, description, jurisdiction, default_color, sort_order, is_active
FROM proceeding_types
WHERE code = $1 AND is_active = true`, proceedingTypeCode)
if err != nil {
return nil, nil, fmt.Errorf("resolving proceeding type %q: %w", proceedingTypeCode, err)
}
var rules []models.DeadlineRule
err = s.db.Select(&rules, `
WITH RECURSIVE tree AS (
SELECT * FROM deadline_rules
WHERE proceeding_type_id = $1 AND parent_id IS NULL AND is_active = true
UNION ALL
SELECT dr.* FROM deadline_rules dr
JOIN tree t ON dr.parent_id = t.id
WHERE dr.is_active = true
)
SELECT `+ruleColumns+` FROM tree ORDER BY sequence_order`, pt.ID)
if err != nil {
return nil, nil, fmt.Errorf("fetching timeline for type %q: %w", proceedingTypeCode, err)
}
return rules, &pt, nil
}
// GetByIDs returns deadline rules by their IDs
func (s *DeadlineRuleService) GetByIDs(ids []string) ([]models.DeadlineRule, error) {
if len(ids) == 0 {
@@ -94,11 +118,7 @@ func (s *DeadlineRuleService) GetByIDs(ids []string) ([]models.DeadlineRule, err
}
query, args, err := sqlx.In(
`SELECT id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
timing, rule_code, deadline_notes, sequence_order, condition_rule_id,
alt_duration_value, alt_duration_unit, alt_rule_code, is_active,
created_at, updated_at
`SELECT `+ruleColumns+`
FROM deadline_rules
WHERE id IN (?) AND is_active = true
ORDER BY sequence_order`, ids)
@@ -119,11 +139,7 @@ func (s *DeadlineRuleService) GetByIDs(ids []string) ([]models.DeadlineRule, err
func (s *DeadlineRuleService) GetRulesForProceedingType(proceedingTypeID int) ([]models.DeadlineRule, error) {
var rules []models.DeadlineRule
err := s.db.Select(&rules,
`SELECT id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
timing, rule_code, deadline_notes, sequence_order, condition_rule_id,
alt_duration_value, alt_duration_unit, alt_rule_code, is_active,
created_at, updated_at
`SELECT `+ruleColumns+`
FROM deadline_rules
WHERE proceeding_type_id = $1 AND is_active = true
ORDER BY sequence_order`, proceedingTypeID)

View File

@@ -0,0 +1,236 @@
package services
import (
"fmt"
"time"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
// DetermineService handles event-driven deadline determination.
// It walks the proceeding event tree and calculates cascading dates.
type DetermineService struct {
rules *DeadlineRuleService
calculator *DeadlineCalculator
}
// NewDetermineService creates a new determine service
func NewDetermineService(db *sqlx.DB, calculator *DeadlineCalculator) *DetermineService {
return &DetermineService{
rules: NewDeadlineRuleService(db),
calculator: calculator,
}
}
// TimelineEvent represents a calculated event in the proceeding timeline
type TimelineEvent struct {
ID string `json:"id"`
Code string `json:"code,omitempty"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
PrimaryParty string `json:"primary_party,omitempty"`
EventType string `json:"event_type,omitempty"`
IsMandatory bool `json:"is_mandatory"`
DurationValue int `json:"duration_value"`
DurationUnit string `json:"duration_unit"`
RuleCode string `json:"rule_code,omitempty"`
DeadlineNotes string `json:"deadline_notes,omitempty"`
IsSpawn bool `json:"is_spawn"`
SpawnLabel string `json:"spawn_label,omitempty"`
HasCondition bool `json:"has_condition"`
ConditionRuleID string `json:"condition_rule_id,omitempty"`
AltRuleCode string `json:"alt_rule_code,omitempty"`
AltDurationValue *int `json:"alt_duration_value,omitempty"`
AltDurationUnit string `json:"alt_duration_unit,omitempty"`
Date string `json:"date,omitempty"`
OriginalDate string `json:"original_date,omitempty"`
WasAdjusted bool `json:"was_adjusted"`
Children []TimelineEvent `json:"children,omitempty"`
}
// DetermineRequest is the input for POST /api/deadlines/determine
type DetermineRequest struct {
ProceedingType string `json:"proceeding_type"`
TriggerEventDate string `json:"trigger_event_date"`
Conditions map[string]bool `json:"conditions"`
}
// DetermineResponse is the output of the determine endpoint
type DetermineResponse struct {
ProceedingType string `json:"proceeding_type"`
ProceedingName string `json:"proceeding_name"`
ProceedingColor string `json:"proceeding_color"`
TriggerDate string `json:"trigger_event_date"`
Timeline []TimelineEvent `json:"timeline"`
TotalDeadlines int `json:"total_deadlines"`
}
// GetTimeline returns the proceeding event tree (without date calculations)
func (s *DetermineService) GetTimeline(proceedingTypeCode string) ([]TimelineEvent, *models.ProceedingType, error) {
rules, pt, err := s.rules.GetFullTimeline(proceedingTypeCode)
if err != nil {
return nil, nil, err
}
tree := buildTimelineTree(rules)
return tree, pt, nil
}
// Determine calculates the full timeline with cascading dates
func (s *DetermineService) Determine(req DetermineRequest) (*DetermineResponse, error) {
timeline, pt, err := s.GetTimeline(req.ProceedingType)
if err != nil {
return nil, fmt.Errorf("loading timeline: %w", err)
}
triggerDate, err := time.Parse("2006-01-02", req.TriggerEventDate)
if err != nil {
return nil, fmt.Errorf("invalid trigger_event_date: %w", err)
}
conditions := req.Conditions
if conditions == nil {
conditions = make(map[string]bool)
}
total := s.calculateDates(timeline, triggerDate, conditions)
return &DetermineResponse{
ProceedingType: pt.Code,
ProceedingName: pt.Name,
ProceedingColor: pt.DefaultColor,
TriggerDate: req.TriggerEventDate,
Timeline: timeline,
TotalDeadlines: total,
}, nil
}
// calculateDates walks the tree and calculates dates for each node
func (s *DetermineService) calculateDates(events []TimelineEvent, parentDate time.Time, conditions map[string]bool) int {
total := 0
for i := range events {
ev := &events[i]
// Skip inactive spawns: if this is a spawn node and conditions don't include it, skip
if ev.IsSpawn && !conditions[ev.ID] {
continue
}
durationValue := ev.DurationValue
durationUnit := ev.DurationUnit
ruleCode := ev.RuleCode
// Apply conditional logic
if ev.HasCondition && ev.ConditionRuleID != "" {
if conditions[ev.ConditionRuleID] {
if ev.AltDurationValue != nil {
durationValue = *ev.AltDurationValue
}
if ev.AltDurationUnit != "" {
durationUnit = ev.AltDurationUnit
}
if ev.AltRuleCode != "" {
ruleCode = ev.AltRuleCode
}
}
}
// Calculate this node's date
if durationValue > 0 {
rule := models.DeadlineRule{
DurationValue: durationValue,
DurationUnit: durationUnit,
}
adjusted, original, wasAdjusted := s.calculator.CalculateEndDate(parentDate, rule)
ev.Date = adjusted.Format("2006-01-02")
ev.OriginalDate = original.Format("2006-01-02")
ev.WasAdjusted = wasAdjusted
} else {
ev.Date = parentDate.Format("2006-01-02")
ev.OriginalDate = parentDate.Format("2006-01-02")
}
ev.RuleCode = ruleCode
total++
// Recurse: children's dates cascade from this node's date
if len(ev.Children) > 0 {
childDate, _ := time.Parse("2006-01-02", ev.Date)
total += s.calculateDates(ev.Children, childDate, conditions)
}
}
return total
}
// buildTimelineTree converts flat rules to a tree of TimelineEvents
func buildTimelineTree(rules []models.DeadlineRule) []TimelineEvent {
nodeMap := make(map[string]*TimelineEvent, len(rules))
var roots []TimelineEvent
// Create event nodes
for _, r := range rules {
ev := ruleToEvent(r)
nodeMap[r.ID.String()] = &ev
}
// Build tree by parent_id
for _, r := range rules {
ev := nodeMap[r.ID.String()]
if r.ParentID != nil {
parentKey := r.ParentID.String()
if parent, ok := nodeMap[parentKey]; ok {
parent.Children = append(parent.Children, *ev)
continue
}
}
roots = append(roots, *ev)
}
return roots
}
func ruleToEvent(r models.DeadlineRule) TimelineEvent {
ev := TimelineEvent{
ID: r.ID.String(),
Name: r.Name,
IsMandatory: r.IsMandatory,
DurationValue: r.DurationValue,
DurationUnit: r.DurationUnit,
IsSpawn: r.IsSpawn,
HasCondition: r.ConditionRuleID != nil,
}
if r.Code != nil {
ev.Code = *r.Code
}
if r.Description != nil {
ev.Description = *r.Description
}
if r.PrimaryParty != nil {
ev.PrimaryParty = *r.PrimaryParty
}
if r.EventType != nil {
ev.EventType = *r.EventType
}
if r.RuleCode != nil {
ev.RuleCode = *r.RuleCode
}
if r.DeadlineNotes != nil {
ev.DeadlineNotes = *r.DeadlineNotes
}
if r.SpawnLabel != nil {
ev.SpawnLabel = *r.SpawnLabel
}
if r.ConditionRuleID != nil {
ev.ConditionRuleID = r.ConditionRuleID.String()
}
if r.AltRuleCode != nil {
ev.AltRuleCode = *r.AltRuleCode
}
ev.AltDurationValue = r.AltDurationValue
if r.AltDurationUnit != nil {
ev.AltDurationUnit = *r.AltDurationUnit
}
return ev
}

View File

@@ -0,0 +1,292 @@
package services
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
type InvoiceService struct {
db *sqlx.DB
audit *AuditService
}
func NewInvoiceService(db *sqlx.DB, audit *AuditService) *InvoiceService {
return &InvoiceService{db: db, audit: audit}
}
type CreateInvoiceInput struct {
CaseID uuid.UUID `json:"case_id"`
ClientName string `json:"client_name"`
ClientAddress *string `json:"client_address,omitempty"`
Items []models.InvoiceItem `json:"items"`
TaxRate *float64 `json:"tax_rate,omitempty"`
IssuedAt *string `json:"issued_at,omitempty"`
DueAt *string `json:"due_at,omitempty"`
Notes *string `json:"notes,omitempty"`
TimeEntryIDs []uuid.UUID `json:"time_entry_ids,omitempty"`
}
type UpdateInvoiceInput struct {
ClientName *string `json:"client_name,omitempty"`
ClientAddress *string `json:"client_address,omitempty"`
Items []models.InvoiceItem `json:"items,omitempty"`
TaxRate *float64 `json:"tax_rate,omitempty"`
IssuedAt *string `json:"issued_at,omitempty"`
DueAt *string `json:"due_at,omitempty"`
Notes *string `json:"notes,omitempty"`
}
const invoiceCols = `id, tenant_id, case_id, invoice_number, client_name, client_address,
items, subtotal, tax_rate, tax_amount, total, status, issued_at, due_at, paid_at, notes,
created_by, created_at, updated_at`
func (s *InvoiceService) List(ctx context.Context, tenantID uuid.UUID, caseID *uuid.UUID, status string) ([]models.Invoice, error) {
where := "WHERE tenant_id = $1"
args := []any{tenantID}
argIdx := 2
if caseID != nil {
where += fmt.Sprintf(" AND case_id = $%d", argIdx)
args = append(args, *caseID)
argIdx++
}
if status != "" {
where += fmt.Sprintf(" AND status = $%d", argIdx)
args = append(args, status)
argIdx++
}
var invoices []models.Invoice
err := s.db.SelectContext(ctx, &invoices,
fmt.Sprintf("SELECT %s FROM invoices %s ORDER BY created_at DESC", invoiceCols, where),
args...)
if err != nil {
return nil, fmt.Errorf("list invoices: %w", err)
}
return invoices, nil
}
func (s *InvoiceService) GetByID(ctx context.Context, tenantID, invoiceID uuid.UUID) (*models.Invoice, error) {
var inv models.Invoice
err := s.db.GetContext(ctx, &inv,
`SELECT `+invoiceCols+` FROM invoices WHERE tenant_id = $1 AND id = $2`,
tenantID, invoiceID)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get invoice: %w", err)
}
return &inv, nil
}
func (s *InvoiceService) Create(ctx context.Context, tenantID, userID uuid.UUID, input CreateInvoiceInput) (*models.Invoice, error) {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
// Generate invoice number: RE-YYYY-NNN
year := time.Now().Year()
var seq int
err = tx.GetContext(ctx, &seq,
`SELECT COUNT(*) + 1 FROM invoices WHERE tenant_id = $1 AND invoice_number LIKE $2`,
tenantID, fmt.Sprintf("RE-%d-%%", year))
if err != nil {
return nil, fmt.Errorf("generate invoice number: %w", err)
}
invoiceNumber := fmt.Sprintf("RE-%d-%03d", year, seq)
// Calculate totals
taxRate := 19.00
if input.TaxRate != nil {
taxRate = *input.TaxRate
}
var subtotal float64
for _, item := range input.Items {
subtotal += item.Amount
}
taxAmount := subtotal * taxRate / 100
total := subtotal + taxAmount
itemsJSON, err := json.Marshal(input.Items)
if err != nil {
return nil, fmt.Errorf("marshal items: %w", err)
}
var inv models.Invoice
err = tx.QueryRowxContext(ctx,
`INSERT INTO invoices (tenant_id, case_id, invoice_number, client_name, client_address,
items, subtotal, tax_rate, tax_amount, total, issued_at, due_at, notes, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING `+invoiceCols,
tenantID, input.CaseID, invoiceNumber, input.ClientName, input.ClientAddress,
itemsJSON, subtotal, taxRate, taxAmount, total, input.IssuedAt, input.DueAt, input.Notes, userID,
).StructScan(&inv)
if err != nil {
return nil, fmt.Errorf("create invoice: %w", err)
}
// Mark linked time entries as billed
if len(input.TimeEntryIDs) > 0 {
query, args, err := sqlx.In(
`UPDATE time_entries SET billed = true, invoice_id = ? WHERE tenant_id = ? AND id IN (?)`,
inv.ID, tenantID, input.TimeEntryIDs)
if err != nil {
return nil, fmt.Errorf("build time entry update: %w", err)
}
query = tx.Rebind(query)
_, err = tx.ExecContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("mark time entries billed: %w", err)
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit: %w", err)
}
s.audit.Log(ctx, "create", "invoice", &inv.ID, nil, inv)
return &inv, nil
}
func (s *InvoiceService) Update(ctx context.Context, tenantID, invoiceID uuid.UUID, input UpdateInvoiceInput) (*models.Invoice, error) {
old, err := s.GetByID(ctx, tenantID, invoiceID)
if err != nil {
return nil, err
}
if old == nil {
return nil, fmt.Errorf("invoice not found")
}
if old.Status != "draft" {
return nil, fmt.Errorf("can only update draft invoices")
}
// Recalculate totals if items changed
var itemsJSON json.RawMessage
var subtotal float64
taxRate := old.TaxRate
if input.Items != nil {
for _, item := range input.Items {
subtotal += item.Amount
}
itemsJSON, _ = json.Marshal(input.Items)
}
if input.TaxRate != nil {
taxRate = *input.TaxRate
}
if input.Items != nil {
taxAmount := subtotal * taxRate / 100
total := subtotal + taxAmount
var inv models.Invoice
err = s.db.QueryRowxContext(ctx,
`UPDATE invoices SET
client_name = COALESCE($3, client_name),
client_address = COALESCE($4, client_address),
items = $5,
subtotal = $6,
tax_rate = $7,
tax_amount = $8,
total = $9,
issued_at = COALESCE($10, issued_at),
due_at = COALESCE($11, due_at),
notes = COALESCE($12, notes),
updated_at = now()
WHERE tenant_id = $1 AND id = $2
RETURNING `+invoiceCols,
tenantID, invoiceID, input.ClientName, input.ClientAddress,
itemsJSON, subtotal, taxRate, subtotal*taxRate/100, total,
input.IssuedAt, input.DueAt, input.Notes,
).StructScan(&inv)
if err != nil {
return nil, fmt.Errorf("update invoice: %w", err)
}
s.audit.Log(ctx, "update", "invoice", &inv.ID, old, inv)
return &inv, nil
}
// Update without changing items
var inv models.Invoice
err = s.db.QueryRowxContext(ctx,
`UPDATE invoices SET
client_name = COALESCE($3, client_name),
client_address = COALESCE($4, client_address),
tax_rate = COALESCE($5, tax_rate),
issued_at = COALESCE($6, issued_at),
due_at = COALESCE($7, due_at),
notes = COALESCE($8, notes),
updated_at = now()
WHERE tenant_id = $1 AND id = $2
RETURNING `+invoiceCols,
tenantID, invoiceID, input.ClientName, input.ClientAddress,
input.TaxRate, input.IssuedAt, input.DueAt, input.Notes,
).StructScan(&inv)
if err != nil {
return nil, fmt.Errorf("update invoice: %w", err)
}
s.audit.Log(ctx, "update", "invoice", &inv.ID, old, inv)
return &inv, nil
}
func (s *InvoiceService) UpdateStatus(ctx context.Context, tenantID, invoiceID uuid.UUID, newStatus string) (*models.Invoice, error) {
old, err := s.GetByID(ctx, tenantID, invoiceID)
if err != nil {
return nil, err
}
if old == nil {
return nil, fmt.Errorf("invoice not found")
}
// Validate transitions
validTransitions := map[string][]string{
"draft": {"sent", "cancelled"},
"sent": {"paid", "cancelled"},
"paid": {},
"cancelled": {},
}
allowed := validTransitions[old.Status]
valid := false
for _, s := range allowed {
if s == newStatus {
valid = true
break
}
}
if !valid {
return nil, fmt.Errorf("invalid status transition from %s to %s", old.Status, newStatus)
}
var paidAt *time.Time
if newStatus == "paid" {
now := time.Now()
paidAt = &now
}
var inv models.Invoice
err = s.db.QueryRowxContext(ctx,
`UPDATE invoices SET status = $3, paid_at = COALESCE($4, paid_at), updated_at = now()
WHERE tenant_id = $1 AND id = $2
RETURNING `+invoiceCols,
tenantID, invoiceID, newStatus, paidAt,
).StructScan(&inv)
if err != nil {
return nil, fmt.Errorf("update invoice status: %w", err)
}
s.audit.Log(ctx, "update", "invoice", &inv.ID, old, inv)
return &inv, nil
}

View File

@@ -0,0 +1,329 @@
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

@@ -240,6 +240,54 @@ func (s *TenantService) UpdateMemberRole(ctx context.Context, tenantID, userID u
return nil
}
// AutoAssignByDomain finds a tenant with a matching auto_assign_domains setting
// and adds the user as a member. Returns the tenant and role, or nil if no match.
func (s *TenantService) AutoAssignByDomain(ctx context.Context, userID uuid.UUID, emailDomain string) (*models.TenantWithRole, error) {
// Find tenant where settings.auto_assign_domains contains this domain
var tenant models.Tenant
err := s.db.GetContext(ctx, &tenant,
`SELECT id, name, slug, settings, created_at, updated_at
FROM tenants
WHERE settings->'auto_assign_domains' ? $1
LIMIT 1`,
emailDomain,
)
if err != nil {
return nil, nil // no match — not an error
}
// Check if already a member
var exists bool
err = s.db.GetContext(ctx, &exists,
`SELECT EXISTS(SELECT 1 FROM user_tenants WHERE user_id = $1 AND tenant_id = $2)`,
userID, tenant.ID,
)
if err != nil {
return nil, fmt.Errorf("check membership: %w", err)
}
if exists {
// Already a member — return the existing membership
role, err := s.GetUserRole(ctx, userID, tenant.ID)
if err != nil {
return nil, fmt.Errorf("get existing role: %w", err)
}
return &models.TenantWithRole{Tenant: tenant, Role: role}, nil
}
// Add as member (associate by default for auto-assigned users)
role := "associate"
_, err = s.db.ExecContext(ctx,
`INSERT INTO user_tenants (user_id, tenant_id, role) VALUES ($1, $2, $3)`,
userID, tenant.ID, role,
)
if err != nil {
return nil, fmt.Errorf("auto-assign user: %w", err)
}
s.audit.Log(ctx, "create", "auto_membership", &tenant.ID, map[string]any{"domain": emailDomain}, map[string]any{"user_id": userID, "role": role})
return &models.TenantWithRole{Tenant: tenant, Role: role}, nil
}
// RemoveMember removes a user from a tenant. Cannot remove the last owner.
func (s *TenantService) RemoveMember(ctx context.Context, tenantID, userID uuid.UUID) error {
// Check if the user being removed is an owner

View File

@@ -0,0 +1,276 @@
package services
import (
"context"
"database/sql"
"fmt"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
type TimeEntryService struct {
db *sqlx.DB
audit *AuditService
}
func NewTimeEntryService(db *sqlx.DB, audit *AuditService) *TimeEntryService {
return &TimeEntryService{db: db, audit: audit}
}
type CreateTimeEntryInput struct {
CaseID uuid.UUID `json:"case_id"`
Date string `json:"date"`
DurationMinutes int `json:"duration_minutes"`
Description string `json:"description"`
Activity *string `json:"activity,omitempty"`
Billable *bool `json:"billable,omitempty"`
HourlyRate *float64 `json:"hourly_rate,omitempty"`
}
type UpdateTimeEntryInput struct {
Date *string `json:"date,omitempty"`
DurationMinutes *int `json:"duration_minutes,omitempty"`
Description *string `json:"description,omitempty"`
Activity *string `json:"activity,omitempty"`
Billable *bool `json:"billable,omitempty"`
HourlyRate *float64 `json:"hourly_rate,omitempty"`
}
type TimeEntryFilter struct {
CaseID *uuid.UUID
UserID *uuid.UUID
From string
To string
Limit int
Offset int
}
type TimeEntrySummary struct {
GroupKey string `db:"group_key" json:"group_key"`
TotalMinutes int `db:"total_minutes" json:"total_minutes"`
BillableMinutes int `db:"billable_minutes" json:"billable_minutes"`
TotalAmount float64 `db:"total_amount" json:"total_amount"`
EntryCount int `db:"entry_count" json:"entry_count"`
}
const timeEntryCols = `id, tenant_id, case_id, user_id, date, duration_minutes, description,
activity, billable, billed, invoice_id, hourly_rate, created_at, updated_at`
func (s *TimeEntryService) ListForCase(ctx context.Context, tenantID, caseID uuid.UUID) ([]models.TimeEntry, error) {
var entries []models.TimeEntry
err := s.db.SelectContext(ctx, &entries,
`SELECT `+timeEntryCols+` FROM time_entries
WHERE tenant_id = $1 AND case_id = $2
ORDER BY date DESC, created_at DESC`,
tenantID, caseID)
if err != nil {
return nil, fmt.Errorf("list time entries for case: %w", err)
}
return entries, nil
}
func (s *TimeEntryService) List(ctx context.Context, tenantID uuid.UUID, filter TimeEntryFilter) ([]models.TimeEntry, int, error) {
if filter.Limit <= 0 {
filter.Limit = 20
}
if filter.Limit > 100 {
filter.Limit = 100
}
where := "WHERE tenant_id = $1"
args := []any{tenantID}
argIdx := 2
if filter.CaseID != nil {
where += fmt.Sprintf(" AND case_id = $%d", argIdx)
args = append(args, *filter.CaseID)
argIdx++
}
if filter.UserID != nil {
where += fmt.Sprintf(" AND user_id = $%d", argIdx)
args = append(args, *filter.UserID)
argIdx++
}
if filter.From != "" {
where += fmt.Sprintf(" AND date >= $%d", argIdx)
args = append(args, filter.From)
argIdx++
}
if filter.To != "" {
where += fmt.Sprintf(" AND date <= $%d", argIdx)
args = append(args, filter.To)
argIdx++
}
var total int
err := s.db.GetContext(ctx, &total,
"SELECT COUNT(*) FROM time_entries "+where, args...)
if err != nil {
return nil, 0, fmt.Errorf("count time entries: %w", err)
}
query := fmt.Sprintf("SELECT %s FROM time_entries %s ORDER BY date DESC, created_at DESC LIMIT $%d OFFSET $%d",
timeEntryCols, where, argIdx, argIdx+1)
args = append(args, filter.Limit, filter.Offset)
var entries []models.TimeEntry
err = s.db.SelectContext(ctx, &entries, query, args...)
if err != nil {
return nil, 0, fmt.Errorf("list time entries: %w", err)
}
return entries, total, nil
}
func (s *TimeEntryService) GetByID(ctx context.Context, tenantID, entryID uuid.UUID) (*models.TimeEntry, error) {
var entry models.TimeEntry
err := s.db.GetContext(ctx, &entry,
`SELECT `+timeEntryCols+` FROM time_entries WHERE tenant_id = $1 AND id = $2`,
tenantID, entryID)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get time entry: %w", err)
}
return &entry, nil
}
func (s *TimeEntryService) Create(ctx context.Context, tenantID, userID uuid.UUID, input CreateTimeEntryInput) (*models.TimeEntry, error) {
billable := true
if input.Billable != nil {
billable = *input.Billable
}
// If no hourly rate provided, look up the current billing rate
hourlyRate := input.HourlyRate
if hourlyRate == nil {
var rate float64
err := s.db.GetContext(ctx, &rate,
`SELECT rate FROM billing_rates
WHERE tenant_id = $1 AND (user_id = $2 OR user_id IS NULL)
AND valid_from <= $3 AND (valid_to IS NULL OR valid_to >= $3)
ORDER BY user_id NULLS LAST LIMIT 1`,
tenantID, userID, input.Date)
if err == nil {
hourlyRate = &rate
}
}
var entry models.TimeEntry
err := s.db.QueryRowxContext(ctx,
`INSERT INTO time_entries (tenant_id, case_id, user_id, date, duration_minutes, description, activity, billable, hourly_rate)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING `+timeEntryCols,
tenantID, input.CaseID, userID, input.Date, input.DurationMinutes, input.Description, input.Activity, billable, hourlyRate,
).StructScan(&entry)
if err != nil {
return nil, fmt.Errorf("create time entry: %w", err)
}
s.audit.Log(ctx, "create", "time_entry", &entry.ID, nil, entry)
return &entry, nil
}
func (s *TimeEntryService) Update(ctx context.Context, tenantID, entryID uuid.UUID, input UpdateTimeEntryInput) (*models.TimeEntry, error) {
old, err := s.GetByID(ctx, tenantID, entryID)
if err != nil {
return nil, err
}
if old == nil {
return nil, fmt.Errorf("time entry not found")
}
if old.Billed {
return nil, fmt.Errorf("cannot update a billed time entry")
}
var entry models.TimeEntry
err = s.db.QueryRowxContext(ctx,
`UPDATE time_entries SET
date = COALESCE($3, date),
duration_minutes = COALESCE($4, duration_minutes),
description = COALESCE($5, description),
activity = COALESCE($6, activity),
billable = COALESCE($7, billable),
hourly_rate = COALESCE($8, hourly_rate),
updated_at = now()
WHERE tenant_id = $1 AND id = $2
RETURNING `+timeEntryCols,
tenantID, entryID, input.Date, input.DurationMinutes, input.Description, input.Activity, input.Billable, input.HourlyRate,
).StructScan(&entry)
if err != nil {
return nil, fmt.Errorf("update time entry: %w", err)
}
s.audit.Log(ctx, "update", "time_entry", &entry.ID, old, entry)
return &entry, nil
}
func (s *TimeEntryService) Delete(ctx context.Context, tenantID, entryID uuid.UUID) error {
old, err := s.GetByID(ctx, tenantID, entryID)
if err != nil {
return err
}
if old == nil {
return fmt.Errorf("time entry not found")
}
if old.Billed {
return fmt.Errorf("cannot delete a billed time entry")
}
_, err = s.db.ExecContext(ctx,
`DELETE FROM time_entries WHERE tenant_id = $1 AND id = $2`,
tenantID, entryID)
if err != nil {
return fmt.Errorf("delete time entry: %w", err)
}
s.audit.Log(ctx, "delete", "time_entry", &entryID, old, nil)
return nil
}
func (s *TimeEntryService) Summary(ctx context.Context, tenantID uuid.UUID, groupBy string, from, to string) ([]TimeEntrySummary, error) {
var groupExpr string
switch groupBy {
case "user":
groupExpr = "user_id::text"
case "month":
groupExpr = "to_char(date, 'YYYY-MM')"
default:
groupExpr = "case_id::text"
}
where := "WHERE tenant_id = $1"
args := []any{tenantID}
argIdx := 2
if from != "" {
where += fmt.Sprintf(" AND date >= $%d", argIdx)
args = append(args, from)
argIdx++
}
if to != "" {
where += fmt.Sprintf(" AND date <= $%d", argIdx)
args = append(args, to)
argIdx++
}
query := fmt.Sprintf(`SELECT %s AS group_key,
SUM(duration_minutes) AS total_minutes,
SUM(CASE WHEN billable THEN duration_minutes ELSE 0 END) AS billable_minutes,
SUM(CASE WHEN billable AND hourly_rate IS NOT NULL THEN duration_minutes * hourly_rate / 60.0 ELSE 0 END) AS total_amount,
COUNT(*) AS entry_count
FROM time_entries %s
GROUP BY %s
ORDER BY %s`,
groupExpr, where, groupExpr, groupExpr)
var summaries []TimeEntrySummary
err := s.db.SelectContext(ctx, &summaries, query, args...)
if err != nil {
return nil, fmt.Errorf("time entry summary: %w", err)
}
return summaries, nil
}

View File

@@ -0,0 +1,466 @@
-- UPC Proceeding Timeline: Full event tree with conditional deadlines
-- Ported from youpc.org migrations 039 + 040
-- Run against kanzlai schema in flexsiebels Supabase instance
-- ========================================
-- 1. Add is_spawn + spawn_label columns
-- ========================================
ALTER TABLE deadline_rules
ADD COLUMN IF NOT EXISTS is_spawn BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS spawn_label TEXT;
-- ========================================
-- 2. Clear existing UPC rules (fresh seed)
-- ========================================
DELETE FROM deadline_rules WHERE proceeding_type_id IN (
SELECT id FROM proceeding_types WHERE code IN ('INF', 'REV', 'CCR', 'APM', 'APP', 'AMD')
);
-- ========================================
-- 3. Ensure all proceeding types exist
-- ========================================
INSERT INTO proceeding_types (code, name, description, is_active, sort_order, default_color)
VALUES
('INF', 'Infringement', 'Patent infringement proceedings', true, 1, '#3b82f6'),
('REV', 'Revocation', 'Standalone revocation proceedings', true, 2, '#ef4444'),
('CCR', 'Counterclaim for Revocation', 'Counterclaim for revocation within infringement', true, 3, '#ef4444'),
('APM', 'Provisional Measures', 'Application for preliminary injunction', true, 4, '#f59e0b'),
('APP', 'Appeal', 'Appeal to the Court of Appeal', true, 5, '#8b5cf6'),
('AMD', 'Application to Amend Patent', 'Sub-proceeding for patent amendment during revocation', true, 6, '#10b981')
ON CONFLICT (code) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
default_color = EXCLUDED.default_color,
sort_order = EXCLUDED.sort_order,
is_active = EXCLUDED.is_active;
-- ========================================
-- 4. Seed all proceeding events
-- ========================================
DO $$
DECLARE
v_inf INTEGER;
v_rev INTEGER;
v_ccr INTEGER;
v_apm INTEGER;
v_app INTEGER;
v_amd INTEGER;
-- INF event IDs
v_inf_soc UUID;
v_inf_sod UUID;
v_inf_reply UUID;
v_inf_rejoin UUID;
v_inf_interim UUID;
v_inf_oral UUID;
v_inf_decision UUID;
v_inf_prelim UUID;
-- CCR event IDs
v_ccr_root UUID;
v_ccr_defence UUID;
v_ccr_reply UUID;
v_ccr_rejoin UUID;
v_ccr_interim UUID;
v_ccr_oral UUID;
v_ccr_decision UUID;
-- REV event IDs
v_rev_app UUID;
v_rev_defence UUID;
v_rev_reply UUID;
v_rev_rejoin UUID;
v_rev_interim UUID;
v_rev_oral UUID;
v_rev_decision UUID;
-- PI event IDs
v_pi_app UUID;
v_pi_resp UUID;
v_pi_oral UUID;
-- APP event IDs
v_app_notice UUID;
v_app_grounds UUID;
v_app_response UUID;
v_app_oral UUID;
BEGIN
SELECT id INTO v_inf FROM proceeding_types WHERE code = 'INF';
SELECT id INTO v_rev FROM proceeding_types WHERE code = 'REV';
SELECT id INTO v_ccr FROM proceeding_types WHERE code = 'CCR';
SELECT id INTO v_apm FROM proceeding_types WHERE code = 'APM';
SELECT id INTO v_app FROM proceeding_types WHERE code = 'APP';
SELECT id INTO v_amd FROM proceeding_types WHERE code = 'AMD';
-- ========================================
-- INFRINGEMENT PROCEEDINGS
-- ========================================
-- Root: Statement of Claim
v_inf_soc := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_inf_soc, v_inf, NULL, 'inf.soc', 'Statement of Claim',
'Claimant files the statement of claim with the Registry',
'claimant', 'filing', true, 0, 'months', NULL, NULL, false, NULL, 0, true);
-- Preliminary Objection (from SoC)
v_inf_prelim := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_inf_prelim, v_inf, v_inf_soc, 'inf.prelim', 'Preliminary Objection',
'Defendant raises preliminary objection (jurisdiction, admissibility)',
'defendant', 'filing', false, 1, 'months', 'R.19',
'Rarely triggers separate decision; usually decided with main case',
false, NULL, 1, true);
-- Statement of Defence (from SoC)
v_inf_sod := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_inf_sod, v_inf, v_inf_soc, 'inf.sod', 'Statement of Defence',
'Defendant files the statement of defence',
'defendant', 'filing', true, 3, 'months', 'RoP.023', NULL,
false, NULL, 2, true);
-- Reply to Defence (from SoD) — CONDITIONAL: rule code changes if CCR
v_inf_reply := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_inf_reply, v_inf, v_inf_sod, 'inf.reply', 'Reply to Defence',
'Claimant''s reply to the statement of defence (includes Defence to Counterclaim if CCR active)',
'claimant', 'filing', true, 2, 'months', 'RoP.029b', NULL,
false, NULL, 1, true);
-- Rejoinder (from Reply) — CONDITIONAL: duration changes if CCR
v_inf_rejoin := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_inf_rejoin, v_inf, v_inf_reply, 'inf.rejoin', 'Rejoinder',
'Defendant''s rejoinder to the reply',
'defendant', 'filing', true, 1, 'months', 'RoP.029c', NULL,
false, NULL, 0, true);
-- Interim Conference
v_inf_interim := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_inf_interim, v_inf, v_inf_rejoin, 'inf.interim', 'Interim Conference',
'Interim conference with the judge-rapporteur',
'court', 'hearing', true, 0, 'months', NULL, NULL, false, NULL, 0, true);
-- Oral Hearing
v_inf_oral := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_inf_oral, v_inf, v_inf_interim, 'inf.oral', 'Oral Hearing',
'Oral hearing before the panel',
'court', 'hearing', true, 0, 'months', NULL, NULL, false, NULL, 0, true);
-- Decision
v_inf_decision := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_inf_decision, v_inf, v_inf_oral, 'inf.decision', 'Decision',
'Panel delivers its decision',
'court', 'decision', true, 0, 'months', NULL, NULL, false, NULL, 0, true);
-- Appeal (spawn from Decision — cross-type to APP)
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (gen_random_uuid(), v_app, v_inf_decision, 'inf.appeal', 'Appeal',
'Appeal against infringement decision to Court of Appeal',
'both', 'filing', true, 2, 'months', 'RoP.220.1', NULL,
true, 'Appeal filed', 0, true);
-- ========================================
-- COUNTERCLAIM FOR REVOCATION (spawn from SoD)
-- ========================================
v_ccr_root := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_ccr_root, v_ccr, v_inf_sod, 'ccr.counterclaim', 'Counterclaim for Revocation',
'Defendant files counterclaim challenging patent validity (included in SoD)',
'defendant', 'filing', true, 0, 'months', NULL, NULL,
true, 'Includes counterclaim for revocation', 0, true);
-- Defence to Counterclaim
v_ccr_defence := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_ccr_defence, v_ccr, v_ccr_root, 'ccr.defence', 'Defence to Counterclaim',
'Patent proprietor files defence to revocation counterclaim',
'claimant', 'filing', true, 3, 'months', 'RoP.050', NULL,
false, NULL, 0, true);
-- Reply in CCR
v_ccr_reply := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_ccr_reply, v_ccr, v_ccr_defence, 'ccr.reply', 'Reply in CCR',
'Reply in the counterclaim for revocation',
'defendant', 'filing', true, 2, 'months', NULL,
'Timing overlaps with infringement Rejoinder',
false, NULL, 1, true);
-- Rejoinder in CCR
v_ccr_rejoin := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_ccr_rejoin, v_ccr, v_ccr_reply, 'ccr.rejoin', 'Rejoinder in CCR',
'Rejoinder in the counterclaim for revocation',
'claimant', 'filing', true, 2, 'months', NULL, NULL,
false, NULL, 0, true);
-- Interim Conference
v_ccr_interim := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_ccr_interim, v_ccr, v_ccr_rejoin, 'ccr.interim', 'Interim Conference',
'Interim conference covering revocation issues',
'court', 'hearing', true, 0, 'months', NULL,
'May be combined with infringement IC',
false, NULL, 0, true);
-- Oral Hearing
v_ccr_oral := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_ccr_oral, v_ccr, v_ccr_interim, 'ccr.oral', 'Oral Hearing',
'Oral hearing on validity',
'court', 'hearing', true, 0, 'months', NULL, NULL,
false, NULL, 0, true);
-- Decision
v_ccr_decision := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_ccr_decision, v_ccr, v_ccr_oral, 'ccr.decision', 'Decision',
'Decision on validity of the patent',
'court', 'decision', true, 0, 'months', NULL, NULL,
false, NULL, 0, true);
-- Appeal from CCR
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (gen_random_uuid(), v_app, v_ccr_decision, 'ccr.appeal', 'Appeal',
'Appeal against revocation decision to Court of Appeal',
'both', 'filing', true, 2, 'months', 'RoP.220.1', NULL,
true, 'Appeal filed', 0, true);
-- Application to Amend Patent (spawn from Defence to CCR)
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (gen_random_uuid(), v_amd, v_ccr_defence, 'ccr.amend', 'Application to Amend Patent',
'Patent proprietor applies to amend the patent during revocation proceedings',
'claimant', 'filing', false, 0, 'months', NULL, NULL,
true, 'Includes application to amend patent', 2, true);
-- ========================================
-- STANDALONE REVOCATION
-- ========================================
v_rev_app := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_rev_app, v_rev, NULL, 'rev.app', 'Application for Revocation',
'Applicant files standalone application for revocation of the patent',
'claimant', 'filing', true, 0, 'months', NULL, NULL,
false, NULL, 0, true);
v_rev_defence := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_rev_defence, v_rev, v_rev_app, 'rev.defence', 'Defence to Revocation',
'Patent proprietor files defence to revocation application',
'defendant', 'filing', true, 3, 'months', NULL, NULL,
false, NULL, 0, true);
v_rev_reply := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_rev_reply, v_rev, v_rev_defence, 'rev.reply', 'Reply',
'Reply in standalone revocation proceedings',
'claimant', 'filing', true, 2, 'months', NULL, NULL,
false, NULL, 1, true);
v_rev_rejoin := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_rev_rejoin, v_rev, v_rev_reply, 'rev.rejoin', 'Rejoinder',
'Rejoinder in standalone revocation proceedings',
'defendant', 'filing', true, 2, 'months', NULL, NULL,
false, NULL, 0, true);
v_rev_interim := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_rev_interim, v_rev, v_rev_rejoin, 'rev.interim', 'Interim Conference',
'Interim conference with the judge-rapporteur',
'court', 'hearing', true, 0, 'months', NULL, NULL,
false, NULL, 0, true);
v_rev_oral := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_rev_oral, v_rev, v_rev_interim, 'rev.oral', 'Oral Hearing',
'Oral hearing on validity in standalone revocation',
'court', 'hearing', true, 0, 'months', NULL, NULL,
false, NULL, 0, true);
v_rev_decision := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_rev_decision, v_rev, v_rev_oral, 'rev.decision', 'Decision',
'Decision on patent validity',
'court', 'decision', true, 0, 'months', NULL, NULL,
false, NULL, 0, true);
-- Appeal from REV
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (gen_random_uuid(), v_app, v_rev_decision, 'rev.appeal', 'Appeal',
'Appeal against revocation decision to Court of Appeal',
'both', 'filing', true, 2, 'months', 'RoP.220.1', NULL,
true, 'Appeal filed', 0, true);
-- Application to Amend Patent from REV Defence
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (gen_random_uuid(), v_amd, v_rev_defence, 'rev.amend', 'Application to Amend Patent',
'Patent proprietor applies to amend the patent',
'claimant', 'filing', false, 0, 'months', NULL, NULL,
true, 'Includes application to amend patent', 2, true);
-- ========================================
-- PRELIMINARY INJUNCTION
-- ========================================
v_pi_app := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_pi_app, v_apm, NULL, 'pi.app', 'Application for Provisional Measures',
'Claimant files application for preliminary injunction',
'claimant', 'filing', true, 0, 'months', NULL, NULL,
false, NULL, 0, true);
v_pi_resp := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_pi_resp, v_apm, v_pi_app, 'pi.response', 'Response to PI Application',
'Defendant files response to preliminary injunction application',
'defendant', 'filing', true, 0, 'months', NULL,
'Deadline set by court',
false, NULL, 0, true);
v_pi_oral := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_pi_oral, v_apm, v_pi_resp, 'pi.oral', 'Oral Hearing',
'Oral hearing on provisional measures',
'court', 'hearing', true, 0, 'months', NULL, NULL,
false, NULL, 0, true);
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (gen_random_uuid(), v_apm, v_pi_oral, 'pi.order', 'Order on Provisional Measures',
'Court issues order on preliminary injunction',
'court', 'decision', true, 0, 'months', NULL, NULL,
false, NULL, 0, true);
-- ========================================
-- APPEAL (standalone)
-- ========================================
v_app_notice := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_app_notice, v_app, NULL, 'app.notice', 'Notice of Appeal',
'Appellant files notice of appeal with the Court of Appeal',
'both', 'filing', true, 0, 'months', NULL, NULL,
false, NULL, 0, true);
v_app_grounds := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_app_grounds, v_app, v_app_notice, 'app.grounds', 'Statement of Grounds of Appeal',
'Appellant files statement of grounds',
'both', 'filing', true, 2, 'months', 'RoP.220.1', NULL,
false, NULL, 0, true);
v_app_response := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_app_response, v_app, v_app_grounds, 'app.response', 'Response to Appeal',
'Respondent files response to the appeal',
'both', 'filing', true, 2, 'months', NULL, NULL,
false, NULL, 0, true);
v_app_oral := gen_random_uuid();
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (v_app_oral, v_app, v_app_response, 'app.oral', 'Oral Hearing',
'Oral hearing before the Court of Appeal',
'court', 'hearing', true, 0, 'months', NULL, NULL,
false, NULL, 0, true);
INSERT INTO deadline_rules (id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
rule_code, deadline_notes, is_spawn, spawn_label, sequence_order, is_active)
VALUES (gen_random_uuid(), v_app, v_app_oral, 'app.decision', 'Decision',
'Court of Appeal delivers its decision',
'court', 'decision', true, 0, 'months', NULL, NULL,
false, NULL, 0, true);
-- ========================================
-- 5. Set conditional deadlines (from 040)
-- ========================================
-- Reply to Defence: rule code changes when CCR is active
-- Default: RoP.029b | With CCR: RoP.029a
UPDATE deadline_rules
SET condition_rule_id = v_ccr_root,
alt_rule_code = 'RoP.029a'
WHERE id = v_inf_reply;
-- Rejoinder: duration changes when CCR is active
-- Default: 1 month RoP.029c | With CCR: 2 months RoP.029d
UPDATE deadline_rules
SET condition_rule_id = v_ccr_root,
alt_duration_value = 2,
alt_duration_unit = 'months',
alt_rule_code = 'RoP.029d'
WHERE id = v_inf_rejoin;
END $$;

View File

@@ -14,6 +14,7 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"react-dropzone": "^15.0.0",
"recharts": "^3.8.1",
"sonner": "^2.0.7",
},
"devDependencies": {
@@ -244,6 +245,8 @@
"@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=="],
@@ -298,6 +301,10 @@
"@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=="],
@@ -362,6 +369,24 @@
"@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=="],
@@ -376,6 +401,8 @@
"@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=="],
@@ -528,6 +555,8 @@
"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=="],
@@ -546,6 +575,28 @@
"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=="],
@@ -562,6 +613,8 @@
"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=="],
@@ -606,6 +659,8 @@
"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=="],
@@ -646,6 +701,8 @@
"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=="],
@@ -736,6 +793,8 @@
"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=="],
@@ -744,6 +803,8 @@
"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=="],
@@ -978,10 +1039,18 @@
"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@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"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=="],
"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=="],
@@ -990,6 +1059,8 @@
"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=="],
@@ -1096,6 +1167,8 @@
"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=="],
@@ -1152,6 +1225,10 @@
"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=="],
@@ -1202,6 +1279,8 @@
"@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=="],
@@ -1254,7 +1333,7 @@
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],

View File

@@ -20,6 +20,7 @@
"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

@@ -0,0 +1,164 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type { TimeEntry } from "@/lib/types";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { Timer, Loader2 } from "lucide-react";
import { useState } from "react";
import Link from "next/link";
import { Breadcrumb } from "@/components/layout/Breadcrumb";
function formatDuration(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
if (h === 0) return `${m}min`;
if (m === 0) return `${h}h`;
return `${h}h ${m}min`;
}
export default function AbrechnungPage() {
const [from, setFrom] = useState(() => {
const d = new Date();
d.setDate(1);
return format(d, "yyyy-MM-dd");
});
const [to, setTo] = useState(() => format(new Date(), "yyyy-MM-dd"));
const { data, isLoading } = useQuery({
queryKey: ["time-entries", from, to],
queryFn: () =>
api.get<{ time_entries: TimeEntry[]; total: number }>(
`/time-entries?from=${from}&to=${to}&limit=100`,
),
});
const entries = data?.time_entries ?? [];
const totalMinutes = entries.reduce((s, e) => s + e.duration_minutes, 0);
const billableMinutes = entries
.filter((e) => e.billable)
.reduce((s, e) => s + e.duration_minutes, 0);
const totalAmount = entries
.filter((e) => e.billable && e.hourly_rate)
.reduce((s, e) => s + (e.duration_minutes / 60) * (e.hourly_rate ?? 0), 0);
return (
<div className="animate-fade-in">
<Breadcrumb
items={[
{ label: "Dashboard", href: "/dashboard" },
{ label: "Abrechnung" },
]}
/>
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-lg font-semibold text-neutral-900">
Zeiterfassung
</h1>
<Link
href="/abrechnung/rechnungen"
className="text-sm text-neutral-500 transition-colors hover:text-neutral-700"
>
Rechnungen ansehen &rarr;
</Link>
</div>
{/* Filters */}
<div className="mt-4 flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<label className="text-xs text-neutral-500">Von</label>
<input
type="date"
value={from}
onChange={(e) => setFrom(e.target.value)}
className="rounded-md border border-neutral-300 px-2 py-1 text-sm focus:border-neutral-500 focus:outline-none"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-neutral-500">Bis</label>
<input
type="date"
value={to}
onChange={(e) => setTo(e.target.value)}
className="rounded-md border border-neutral-300 px-2 py-1 text-sm focus:border-neutral-500 focus:outline-none"
/>
</div>
</div>
{/* Summary cards */}
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-3">
<div className="rounded-md border border-neutral-200 bg-white p-4">
<p className="text-xs text-neutral-500">Gesamt</p>
<p className="mt-1 text-xl font-semibold text-neutral-900">
{formatDuration(totalMinutes)}
</p>
</div>
<div className="rounded-md border border-neutral-200 bg-white p-4">
<p className="text-xs text-neutral-500">Abrechenbar</p>
<p className="mt-1 text-xl font-semibold text-neutral-900">
{formatDuration(billableMinutes)}
</p>
</div>
<div className="rounded-md border border-neutral-200 bg-white p-4">
<p className="text-xs text-neutral-500">Betrag</p>
<p className="mt-1 text-xl font-semibold text-neutral-900">
{totalAmount.toFixed(2)} EUR
</p>
</div>
</div>
{/* Entries */}
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
</div>
) : entries.length === 0 ? (
<div className="flex flex-col items-center py-12 text-center">
<div className="rounded-xl bg-neutral-100 p-3">
<Timer className="h-5 w-5 text-neutral-400" />
</div>
<p className="mt-2 text-sm text-neutral-500">
Keine Zeiteintraege im gewaehlten Zeitraum.
</p>
</div>
) : (
<div className="mt-4 space-y-2">
{entries.map((entry) => (
<div
key={entry.id}
className="flex items-center justify-between rounded-md border border-neutral-200 bg-white px-4 py-3"
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-neutral-900 truncate">
{entry.description}
</p>
<div className="mt-0.5 flex gap-3 text-xs text-neutral-500">
<span>
{format(new Date(entry.date), "d. MMM yyyy", { locale: de })}
</span>
<Link
href={`/cases/${entry.case_id}/zeiterfassung`}
className="hover:text-neutral-700"
>
Akte ansehen
</Link>
</div>
</div>
<div className="flex items-center gap-4 ml-4 text-sm">
{entry.billable ? (
<span className="text-emerald-600">abrechenbar</span>
) : (
<span className="text-neutral-400">intern</span>
)}
<span className="font-medium text-neutral-900 whitespace-nowrap">
{formatDuration(entry.duration_minutes)}
</span>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,225 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import { api } from "@/lib/api";
import type { Invoice } from "@/lib/types";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { Loader2, AlertTriangle } from "lucide-react";
import { Breadcrumb } from "@/components/layout/Breadcrumb";
const STATUS_BADGE: Record<string, string> = {
draft: "bg-neutral-100 text-neutral-600",
sent: "bg-blue-50 text-blue-700",
paid: "bg-emerald-50 text-emerald-700",
cancelled: "bg-red-50 text-red-600",
};
const STATUS_LABEL: Record<string, string> = {
draft: "Entwurf",
sent: "Versendet",
paid: "Bezahlt",
cancelled: "Storniert",
};
const TRANSITIONS: Record<string, { label: string; next: string }[]> = {
draft: [
{ label: "Als versendet markieren", next: "sent" },
{ label: "Stornieren", next: "cancelled" },
],
sent: [
{ label: "Als bezahlt markieren", next: "paid" },
{ label: "Stornieren", next: "cancelled" },
],
paid: [],
cancelled: [],
};
export default function InvoiceDetailPage() {
const { id } = useParams<{ id: string }>();
const queryClient = useQueryClient();
const { data: invoice, isLoading, error } = useQuery({
queryKey: ["invoice", id],
queryFn: () => api.get<Invoice>(`/invoices/${id}`),
});
const statusMutation = useMutation({
mutationFn: (status: string) =>
api.patch<Invoice>(`/invoices/${id}/status`, { status }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["invoice", id] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
},
});
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 (error || !invoice) {
return (
<div className="py-12 text-center">
<AlertTriangle className="mx-auto h-6 w-6 text-red-500" />
<p className="mt-2 text-sm text-neutral-500">
Rechnung nicht gefunden.
</p>
</div>
);
}
const items = Array.isArray(invoice.items) ? invoice.items : [];
const actions = TRANSITIONS[invoice.status] ?? [];
return (
<div className="animate-fade-in">
<Breadcrumb
items={[
{ label: "Dashboard", href: "/dashboard" },
{ label: "Abrechnung", href: "/abrechnung" },
{ label: "Rechnungen", href: "/abrechnung/rechnungen" },
{ label: invoice.invoice_number },
]}
/>
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="flex items-center gap-3">
<h1 className="text-lg font-semibold text-neutral-900">
{invoice.invoice_number}
</h1>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_BADGE[invoice.status]}`}
>
{STATUS_LABEL[invoice.status]}
</span>
</div>
<p className="mt-1 text-sm text-neutral-500">
{invoice.client_name}
</p>
</div>
<div className="flex gap-2">
{actions.map((action) => (
<button
key={action.next}
onClick={() => statusMutation.mutate(action.next)}
disabled={statusMutation.isPending}
className="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"
>
{action.label}
</button>
))}
</div>
</div>
{/* Invoice details */}
<div className="mt-6 rounded-md border border-neutral-200 bg-white">
{/* Client info */}
<div className="border-b border-neutral-100 p-4">
<p className="text-xs text-neutral-500">Empfaenger</p>
<p className="mt-1 text-sm font-medium text-neutral-900">
{invoice.client_name}
</p>
{invoice.client_address && (
<p className="mt-0.5 text-sm text-neutral-500 whitespace-pre-line">
{invoice.client_address}
</p>
)}
</div>
{/* Dates */}
<div className="flex flex-wrap gap-6 border-b border-neutral-100 p-4">
{invoice.issued_at && (
<div>
<p className="text-xs text-neutral-500">Rechnungsdatum</p>
<p className="mt-0.5 text-sm text-neutral-900">
{format(new Date(invoice.issued_at), "d. MMMM yyyy", { locale: de })}
</p>
</div>
)}
{invoice.due_at && (
<div>
<p className="text-xs text-neutral-500">Faellig am</p>
<p className="mt-0.5 text-sm text-neutral-900">
{format(new Date(invoice.due_at), "d. MMMM yyyy", { locale: de })}
</p>
</div>
)}
{invoice.paid_at && (
<div>
<p className="text-xs text-neutral-500">Bezahlt am</p>
<p className="mt-0.5 text-sm text-neutral-900">
{format(new Date(invoice.paid_at), "d. MMMM yyyy", { locale: de })}
</p>
</div>
)}
</div>
{/* Line items */}
<div className="p-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-100 text-left text-xs text-neutral-500">
<th className="pb-2 font-medium">Beschreibung</th>
<th className="pb-2 font-medium text-right">Betrag</th>
</tr>
</thead>
<tbody>
{items.map((item, i) => (
<tr key={i} className="border-b border-neutral-50">
<td className="py-2 text-neutral-900">
{item.description}
{item.duration_minutes && item.hourly_rate && (
<span className="ml-2 text-xs text-neutral-400">
({Math.floor(item.duration_minutes / 60)}h{" "}
{item.duration_minutes % 60}min x {item.hourly_rate} EUR/h)
</span>
)}
</td>
<td className="py-2 text-right text-neutral-900">
{item.amount.toFixed(2)} EUR
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Totals */}
<div className="border-t border-neutral-200 p-4">
<div className="flex justify-end">
<div className="w-48 space-y-1">
<div className="flex justify-between text-sm">
<span className="text-neutral-500">Netto</span>
<span>{invoice.subtotal.toFixed(2)} EUR</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-neutral-500">
USt. {invoice.tax_rate}%
</span>
<span>{invoice.tax_amount.toFixed(2)} EUR</span>
</div>
<div className="flex justify-between border-t border-neutral-200 pt-1 text-sm font-semibold">
<span>Gesamt</span>
<span>{invoice.total.toFixed(2)} EUR</span>
</div>
</div>
</div>
</div>
{/* Notes */}
{invoice.notes && (
<div className="border-t border-neutral-100 p-4">
<p className="text-xs text-neutral-500">Anmerkungen</p>
<p className="mt-1 text-sm text-neutral-700">{invoice.notes}</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,118 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type { Invoice } from "@/lib/types";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { Receipt, Loader2 } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { Breadcrumb } from "@/components/layout/Breadcrumb";
const STATUS_BADGE: Record<string, string> = {
draft: "bg-neutral-100 text-neutral-600",
sent: "bg-blue-50 text-blue-700",
paid: "bg-emerald-50 text-emerald-700",
cancelled: "bg-red-50 text-red-600",
};
const STATUS_LABEL: Record<string, string> = {
draft: "Entwurf",
sent: "Versendet",
paid: "Bezahlt",
cancelled: "Storniert",
};
export default function RechnungenPage() {
const [statusFilter, setStatusFilter] = useState("");
const { data, isLoading } = useQuery({
queryKey: ["invoices", statusFilter],
queryFn: () => {
const params = statusFilter ? `?status=${statusFilter}` : "";
return api.get<{ invoices: Invoice[] }>(`/invoices${params}`);
},
});
const invoices = data?.invoices ?? [];
return (
<div className="animate-fade-in">
<Breadcrumb
items={[
{ label: "Dashboard", href: "/dashboard" },
{ label: "Abrechnung", href: "/abrechnung" },
{ label: "Rechnungen" },
]}
/>
<div className="mt-4 flex items-center justify-between">
<h1 className="text-lg font-semibold text-neutral-900">Rechnungen</h1>
</div>
{/* Filters */}
<div className="mt-4 flex gap-2">
{["", "draft", "sent", "paid", "cancelled"].map((s) => (
<button
key={s}
onClick={() => setStatusFilter(s)}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
statusFilter === s
? "bg-neutral-900 text-white"
: "bg-neutral-100 text-neutral-600 hover:bg-neutral-200"
}`}
>
{s === "" ? "Alle" : STATUS_LABEL[s]}
</button>
))}
</div>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
</div>
) : invoices.length === 0 ? (
<div className="flex flex-col items-center py-12 text-center">
<div className="rounded-xl bg-neutral-100 p-3">
<Receipt className="h-5 w-5 text-neutral-400" />
</div>
<p className="mt-2 text-sm text-neutral-500">
Keine Rechnungen vorhanden.
</p>
</div>
) : (
<div className="mt-4 space-y-2">
{invoices.map((inv) => (
<Link
key={inv.id}
href={`/abrechnung/rechnungen/${inv.id}`}
className="flex items-center justify-between rounded-md border border-neutral-200 bg-white px-4 py-3 transition-colors hover:bg-neutral-50"
>
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-neutral-900">
{inv.invoice_number}
</p>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_BADGE[inv.status]}`}
>
{STATUS_LABEL[inv.status]}
</span>
</div>
<p className="mt-0.5 text-xs text-neutral-500">
{inv.client_name}
{inv.issued_at &&
`${format(new Date(inv.issued_at), "d. MMM yyyy", { locale: de })}`}
</p>
</div>
<p className="text-sm font-semibold text-neutral-900">
{inv.total.toFixed(2)} EUR
</p>
</Link>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,262 @@
"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

@@ -0,0 +1,306 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import { api } from "@/lib/api";
import type { TimeEntry } from "@/lib/types";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { Timer, Loader2, Plus, Trash2 } from "lucide-react";
import { useState } from "react";
function formatDuration(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
if (h === 0) return `${m}min`;
if (m === 0) return `${h}h`;
return `${h}h ${m}min`;
}
function formatAmount(minutes: number, rate?: number): string {
if (!rate) return "-";
return `${((minutes / 60) * rate).toFixed(2)} EUR`;
}
const ACTIVITIES = [
{ value: "", label: "Keine Kategorie" },
{ value: "research", label: "Recherche" },
{ value: "drafting", label: "Entwurf" },
{ value: "hearing", label: "Verhandlung" },
{ value: "call", label: "Telefonat" },
{ value: "review", label: "Prüfung" },
{ value: "travel", label: "Reise" },
{ value: "meeting", label: "Besprechung" },
];
export default function ZeiterfassungPage() {
const { id } = useParams<{ id: string }>();
const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false);
const [desc, setDesc] = useState("");
const [hours, setHours] = useState("");
const [minutes, setMinutes] = useState("");
const [date, setDate] = useState(format(new Date(), "yyyy-MM-dd"));
const [activity, setActivity] = useState("");
const [billable, setBillable] = useState(true);
const { data, isLoading } = useQuery({
queryKey: ["case-time-entries", id],
queryFn: () =>
api.get<{ time_entries: TimeEntry[] }>(`/cases/${id}/time-entries`),
});
const createMutation = useMutation({
mutationFn: (input: {
description: string;
duration_minutes: number;
date: string;
activity?: string;
billable: boolean;
}) => api.post<TimeEntry>(`/cases/${id}/time-entries`, input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["case-time-entries", id] });
setShowForm(false);
setDesc("");
setHours("");
setMinutes("");
setActivity("");
setBillable(true);
},
});
const deleteMutation = useMutation({
mutationFn: (entryId: string) =>
api.delete(`/time-entries/${entryId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["case-time-entries", id] });
},
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const totalMinutes = (parseInt(hours || "0") * 60) + parseInt(minutes || "0");
if (totalMinutes <= 0 || !desc.trim()) return;
createMutation.mutate({
description: desc.trim(),
duration_minutes: totalMinutes,
date,
activity: activity || undefined,
billable,
});
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
</div>
);
}
const entries = data?.time_entries ?? [];
const totalMinutes = entries.reduce((s, e) => s + e.duration_minutes, 0);
const billableMinutes = entries
.filter((e) => e.billable)
.reduce((s, e) => s + e.duration_minutes, 0);
return (
<div className="space-y-4">
{/* Summary bar */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex gap-6 text-sm text-neutral-500">
<span>
Gesamt: <span className="font-medium text-neutral-900">{formatDuration(totalMinutes)}</span>
</span>
<span>
Abrechenbar: <span className="font-medium text-neutral-900">{formatDuration(billableMinutes)}</span>
</span>
</div>
<button
onClick={() => setShowForm(!showForm)}
className="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"
>
<Plus className="h-3.5 w-3.5" />
Eintrag
</button>
</div>
{/* Quick add form */}
{showForm && (
<form
onSubmit={handleSubmit}
className="rounded-md border border-neutral-200 bg-neutral-50 p-4 space-y-3"
>
<div>
<label className="block text-xs font-medium text-neutral-600 mb-1">
Beschreibung
</label>
<input
type="text"
value={desc}
onChange={(e) => setDesc(e.target.value)}
placeholder="Was wurde getan?"
className="w-full rounded-md border border-neutral-300 px-3 py-1.5 text-sm focus:border-neutral-500 focus:outline-none"
required
/>
</div>
<div className="flex flex-wrap gap-3">
<div className="flex-1 min-w-[120px]">
<label className="block text-xs font-medium text-neutral-600 mb-1">
Dauer
</label>
<div className="flex gap-2">
<div className="flex items-center gap-1">
<input
type="number"
min="0"
value={hours}
onChange={(e) => setHours(e.target.value)}
placeholder="0"
className="w-16 rounded-md border border-neutral-300 px-2 py-1.5 text-sm focus:border-neutral-500 focus:outline-none"
/>
<span className="text-xs text-neutral-500">h</span>
</div>
<div className="flex items-center gap-1">
<input
type="number"
min="0"
max="59"
value={minutes}
onChange={(e) => setMinutes(e.target.value)}
placeholder="0"
className="w-16 rounded-md border border-neutral-300 px-2 py-1.5 text-sm focus:border-neutral-500 focus:outline-none"
/>
<span className="text-xs text-neutral-500">min</span>
</div>
</div>
</div>
<div className="flex-1 min-w-[120px]">
<label className="block text-xs font-medium text-neutral-600 mb-1">
Datum
</label>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="w-full rounded-md border border-neutral-300 px-3 py-1.5 text-sm focus:border-neutral-500 focus:outline-none"
/>
</div>
<div className="flex-1 min-w-[120px]">
<label className="block text-xs font-medium text-neutral-600 mb-1">
Kategorie
</label>
<select
value={activity}
onChange={(e) => setActivity(e.target.value)}
className="w-full rounded-md border border-neutral-300 px-3 py-1.5 text-sm focus:border-neutral-500 focus:outline-none"
>
{ACTIVITIES.map((a) => (
<option key={a.value} value={a.value}>
{a.label}
</option>
))}
</select>
</div>
</div>
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 text-sm text-neutral-600">
<input
type="checkbox"
checked={billable}
onChange={(e) => setBillable(e.target.checked)}
className="rounded border-neutral-300"
/>
Abrechenbar
</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setShowForm(false)}
className="rounded-md px-3 py-1.5 text-sm text-neutral-600 transition-colors hover:bg-neutral-100"
>
Abbrechen
</button>
<button
type="submit"
disabled={createMutation.isPending}
className="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"
>
{createMutation.isPending ? "Speichern..." : "Speichern"}
</button>
</div>
</div>
{createMutation.isError && (
<p className="text-sm text-red-600">Fehler beim Speichern.</p>
)}
</form>
)}
{/* Entries list */}
{entries.length === 0 ? (
<div className="flex flex-col items-center py-8 text-center">
<div className="rounded-xl bg-neutral-100 p-3">
<Timer className="h-5 w-5 text-neutral-400" />
</div>
<p className="mt-2 text-sm text-neutral-500">
Keine Zeiteintraege vorhanden.
</p>
</div>
) : (
<div className="space-y-2">
{entries.map((entry) => (
<div
key={entry.id}
className="flex items-center justify-between rounded-md border border-neutral-200 bg-white px-4 py-3"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-neutral-900 truncate">
{entry.description}
</p>
{entry.activity && (
<span className="shrink-0 rounded-full bg-neutral-100 px-2 py-0.5 text-xs text-neutral-500">
{ACTIVITIES.find((a) => a.value === entry.activity)?.label ?? entry.activity}
</span>
)}
{!entry.billable && (
<span className="shrink-0 rounded-full bg-neutral-100 px-2 py-0.5 text-xs text-neutral-400">
nicht abrechenbar
</span>
)}
{entry.billed && (
<span className="shrink-0 rounded-full bg-emerald-50 px-2 py-0.5 text-xs text-emerald-700">
abgerechnet
</span>
)}
</div>
<div className="mt-0.5 flex gap-4 text-xs text-neutral-500">
<span>
{format(new Date(entry.date), "d. MMM yyyy", { locale: de })}
</span>
{entry.hourly_rate && (
<span>{formatAmount(entry.duration_minutes, entry.hourly_rate)}</span>
)}
</div>
</div>
<div className="flex items-center gap-3 ml-4">
<span className="text-sm font-medium text-neutral-900 whitespace-nowrap">
{formatDuration(entry.duration_minutes)}
</span>
{!entry.billed && (
<button
onClick={() => deleteMutation.mutate(entry.id)}
className="rounded-md p-1 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-600"
title="Loeschen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,166 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type { BillingRate } from "@/lib/types";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { Loader2, Plus } from "lucide-react";
import { useState } from "react";
import { Breadcrumb } from "@/components/layout/Breadcrumb";
export default function BillingRatesPage() {
const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false);
const [rate, setRate] = useState("");
const [validFrom, setValidFrom] = useState(format(new Date(), "yyyy-MM-dd"));
const { data, isLoading } = useQuery({
queryKey: ["billing-rates"],
queryFn: () =>
api.get<{ billing_rates: BillingRate[] }>("/billing-rates"),
});
const upsertMutation = useMutation({
mutationFn: (input: { rate: number; valid_from: string; currency: string }) =>
api.put<BillingRate>("/billing-rates", input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["billing-rates"] });
setShowForm(false);
setRate("");
},
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const rateNum = parseFloat(rate);
if (isNaN(rateNum) || rateNum < 0) return;
upsertMutation.mutate({
rate: rateNum,
valid_from: validFrom,
currency: "EUR",
});
}
const rates = data?.billing_rates ?? [];
return (
<div className="animate-fade-in">
<Breadcrumb
items={[
{ label: "Dashboard", href: "/dashboard" },
{ label: "Einstellungen", href: "/einstellungen" },
{ label: "Stundensaetze" },
]}
/>
<div className="mt-4 flex items-center justify-between">
<h1 className="text-lg font-semibold text-neutral-900">
Stundensaetze
</h1>
<button
onClick={() => setShowForm(!showForm)}
className="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"
>
<Plus className="h-3.5 w-3.5" />
Neuer Satz
</button>
</div>
{showForm && (
<form
onSubmit={handleSubmit}
className="mt-4 rounded-md border border-neutral-200 bg-neutral-50 p-4 space-y-3"
>
<div className="flex flex-wrap gap-3">
<div className="flex-1 min-w-[150px]">
<label className="block text-xs font-medium text-neutral-600 mb-1">
Stundensatz (EUR)
</label>
<input
type="number"
min="0"
step="0.01"
value={rate}
onChange={(e) => setRate(e.target.value)}
placeholder="z.B. 350.00"
className="w-full rounded-md border border-neutral-300 px-3 py-1.5 text-sm focus:border-neutral-500 focus:outline-none"
required
/>
</div>
<div className="flex-1 min-w-[150px]">
<label className="block text-xs font-medium text-neutral-600 mb-1">
Gueltig ab
</label>
<input
type="date"
value={validFrom}
onChange={(e) => setValidFrom(e.target.value)}
className="w-full rounded-md border border-neutral-300 px-3 py-1.5 text-sm focus:border-neutral-500 focus:outline-none"
required
/>
</div>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setShowForm(false)}
className="rounded-md px-3 py-1.5 text-sm text-neutral-600 hover:bg-neutral-100"
>
Abbrechen
</button>
<button
type="submit"
disabled={upsertMutation.isPending}
className="rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50"
>
Speichern
</button>
</div>
</form>
)}
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
</div>
) : rates.length === 0 ? (
<div className="mt-8 text-center">
<p className="text-sm text-neutral-500">
Noch keine Stundensaetze definiert.
</p>
</div>
) : (
<div className="mt-4 space-y-2">
{rates.map((r) => (
<div
key={r.id}
className="flex items-center justify-between rounded-md border border-neutral-200 bg-white px-4 py-3"
>
<div>
<p className="text-sm font-medium text-neutral-900">
{r.rate.toFixed(2)} {r.currency}/h
</p>
<p className="mt-0.5 text-xs text-neutral-500">
{r.user_id ? `Benutzer: ${r.user_id.slice(0, 8)}...` : "Standard (alle Benutzer)"}
</p>
</div>
<div className="text-right text-xs text-neutral-500">
<p>
Ab{" "}
{format(new Date(r.valid_from), "d. MMM yyyy", { locale: de })}
</p>
{r.valid_to && (
<p>
Bis{" "}
{format(new Date(r.valid_to), "d. MMM yyyy", { locale: de })}
</p>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -1,28 +1,61 @@
"use client";
import { DeadlineCalculator } from "@/components/deadlines/DeadlineCalculator";
import { DeadlineWizard } from "@/components/deadlines/DeadlineWizard";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
export default function FristenrechnerPage() {
const [mode, setMode] = useState<"wizard" | "quick">("wizard");
return (
<div className="animate-fade-in space-y-4">
<div>
<Link
href="/fristen"
className="mb-2 inline-flex items-center gap-1 text-sm text-neutral-500 transition-colors hover:text-neutral-700"
>
<ArrowLeft className="h-3.5 w-3.5" />
Zurück zu Fristen
</Link>
<h1 className="text-lg font-semibold text-neutral-900">
Fristenrechner
</h1>
<p className="mt-0.5 text-sm text-neutral-500">
Berechnen Sie Fristen basierend auf Verfahrensart und Auslösedatum
</p>
<div className="flex items-start justify-between">
<div>
<Link
href="/fristen"
className="mb-2 inline-flex items-center gap-1 text-sm text-neutral-500 transition-colors hover:text-neutral-700"
>
<ArrowLeft className="h-3.5 w-3.5" />
Zurueck zu Fristen
</Link>
<h1 className="text-lg font-semibold text-neutral-900">
Fristenbestimmung
</h1>
<p className="mt-0.5 text-sm text-neutral-500">
{mode === "wizard"
? "Vollstaendige Verfahrens-Timeline mit automatischer Fristenberechnung"
: "Schnellberechnung einzelner Fristen nach Verfahrensart"}
</p>
</div>
{/* Mode toggle */}
<div className="flex rounded-md border border-neutral-200 bg-neutral-50 p-0.5">
<button
onClick={() => setMode("wizard")}
className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
mode === "wizard"
? "bg-white text-neutral-900 shadow-sm"
: "text-neutral-500 hover:text-neutral-700"
}`}
>
Verfahren
</button>
<button
onClick={() => setMode("quick")}
className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
mode === "quick"
? "bg-white text-neutral-900 shadow-sm"
: "text-neutral-500 hover:text-neutral-700"
}`}
>
Schnell
</button>
</div>
</div>
<DeadlineCalculator />
{mode === "wizard" ? <DeadlineWizard /> : <DeadlineCalculator />}
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { Sidebar } from "@/components/layout/Sidebar";
import { Header } from "@/components/layout/Header";
import { DemoBanner } from "@/components/layout/DemoBanner";
export const dynamic = "force-dynamic";
@@ -12,6 +13,7 @@ export default function AppLayout({
<div className="flex h-screen overflow-hidden bg-neutral-50">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<DemoBanner />
<Header />
<main className="flex-1 overflow-y-auto p-4 sm:p-6">{children}</main>
</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

@@ -5,12 +5,22 @@ import { api } from "@/lib/api";
import { useRouter } from "next/navigation";
import { useState } from "react";
interface AutoAssignResponse {
assigned: boolean;
tenant_id?: string;
name?: string;
slug?: string;
role?: string;
settings?: Record<string, unknown>;
}
export default function RegisterPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [firmName, setFirmName] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showFirmName, setShowFirmName] = useState(true);
const router = useRouter();
const supabase = createClient();
@@ -34,8 +44,30 @@ export default function RegisterPage() {
return;
}
// 2. Create tenant via backend (the backend adds the user as owner)
if (data.session) {
// 2. Check if email domain matches an existing tenant for auto-assignment
try {
const result = await api.post<AutoAssignResponse>("/tenants/auto-assign", { email });
if (result.assigned && result.tenant_id) {
// Auto-assigned — store tenant and go to dashboard
localStorage.setItem("kanzlai_tenant_id", result.tenant_id);
router.push("/");
router.refresh();
return;
}
} catch {
// Auto-assign failed — fall through to manual tenant creation
}
// 3. No auto-assignment — create tenant manually
if (!firmName) {
// Show firm name field if not yet visible
setShowFirmName(true);
setError("Bitte geben Sie einen Kanzleinamen ein");
setLoading(false);
return;
}
try {
await api.post("/tenants", { name: firmName });
} catch (err: unknown) {
@@ -68,23 +100,27 @@ export default function RegisterPage() {
</div>
<form onSubmit={handleRegister} className="space-y-4">
<div>
<label
htmlFor="firm"
className="block text-sm font-medium text-neutral-700"
>
Kanzleiname
</label>
<input
id="firm"
type="text"
value={firmName}
onChange={(e) => setFirmName(e.target.value)}
required
className="mt-1 block w-full rounded-md border border-neutral-300 px-3 py-2 text-sm placeholder-neutral-400 focus:border-neutral-900 focus:outline-none focus:ring-1 focus:ring-neutral-900"
placeholder="Muster & Partner Rechtsanwaelte"
/>
</div>
{showFirmName && (
<div>
<label
htmlFor="firm"
className="block text-sm font-medium text-neutral-700"
>
Kanzleiname
</label>
<input
id="firm"
type="text"
value={firmName}
onChange={(e) => setFirmName(e.target.value)}
className="mt-1 block w-full rounded-md border border-neutral-300 px-3 py-2 text-sm placeholder-neutral-400 focus:border-neutral-900 focus:outline-none focus:ring-1 focus:ring-neutral-900"
placeholder="Muster & Partner Rechtsanwaelte"
/>
<p className="mt-1 text-xs text-neutral-400">
Leer lassen, falls Sie zu einer bestehenden Kanzlei eingeladen wurden
</p>
</div>
)}
<div>
<label

View File

@@ -0,0 +1,622 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type {
ProceedingType,
TimelineResponse,
DetermineResponse,
TimelineEvent,
Case,
} from "@/lib/types";
import { format, parseISO, isPast, isThisWeek, isBefore, addDays } from "date-fns";
import { de } from "date-fns/locale";
import {
Scale,
Calendar,
ChevronRight,
ChevronDown,
GitBranch,
Check,
Clock,
AlertTriangle,
FileText,
Users,
Gavel,
ArrowRight,
RotateCcw,
Loader2,
CheckCircle2,
FolderOpen,
} from "lucide-react";
import { useState, useCallback, useMemo } from "react";
import { toast } from "sonner";
// --- Helpers ---
function formatDuration(value: number, unit: string): string {
if (value === 0) return "";
const labels: Record<string, string> = {
days: value === 1 ? "Tag" : "Tage",
weeks: value === 1 ? "Woche" : "Wochen",
months: value === 1 ? "Monat" : "Monate",
};
return `${value} ${labels[unit] || unit}`;
}
function getPartyIcon(party?: string) {
switch (party) {
case "claimant":
return <Scale className="h-3.5 w-3.5" />;
case "defendant":
return <Users className="h-3.5 w-3.5" />;
case "court":
return <Gavel className="h-3.5 w-3.5" />;
default:
return <FileText className="h-3.5 w-3.5" />;
}
}
function getPartyLabel(party?: string): string {
switch (party) {
case "claimant":
return "Klaeger";
case "defendant":
return "Beklagter";
case "court":
return "Gericht";
case "both":
return "Beide Parteien";
default:
return "";
}
}
function getEventTypeLabel(type?: string): string {
switch (type) {
case "filing":
return "Einreichung";
case "hearing":
return "Verhandlung";
case "decision":
return "Entscheidung";
default:
return "";
}
}
type Urgency = "past" | "overdue" | "this_week" | "upcoming" | "future" | "none";
function getUrgency(dateStr?: string): Urgency {
if (!dateStr) return "none";
const date = parseISO(dateStr);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (isPast(date) && isBefore(date, today)) return "overdue";
if (isThisWeek(date, { weekStartsOn: 1 })) return "this_week";
if (isBefore(date, addDays(today, 30))) return "upcoming";
return "future";
}
const urgencyStyles: Record<Urgency, { dot: string; text: string; bg: string }> = {
past: { dot: "bg-neutral-400", text: "text-neutral-500", bg: "bg-neutral-50" },
overdue: { dot: "bg-red-500", text: "text-red-700", bg: "bg-red-50" },
this_week: { dot: "bg-amber-500", text: "text-amber-700", bg: "bg-amber-50" },
upcoming: { dot: "bg-blue-500", text: "text-blue-700", bg: "bg-blue-50" },
future: { dot: "bg-green-500", text: "text-green-700", bg: "bg-green-50" },
none: { dot: "bg-neutral-300", text: "text-neutral-500", bg: "bg-neutral-50" },
};
// --- Spawn Extraction ---
function extractSpawns(events: TimelineEvent[]): TimelineEvent[] {
const spawns: TimelineEvent[] = [];
function walk(evts: TimelineEvent[]) {
for (const ev of evts) {
if (ev.is_spawn) spawns.push(ev);
if (ev.children) walk(ev.children);
}
}
walk(events);
return spawns;
}
// --- Flat timeline extraction ---
function flattenTimeline(events: TimelineEvent[], depth = 0): (TimelineEvent & { depth: number })[] {
const result: (TimelineEvent & { depth: number })[] = [];
for (const ev of events) {
result.push({ ...ev, depth });
if (ev.children && ev.children.length > 0) {
result.push(...flattenTimeline(ev.children, depth + 1));
}
}
return result;
}
// --- Main Component ---
export function DeadlineWizard() {
const [selectedType, setSelectedType] = useState<string>("");
const [triggerDate, setTriggerDate] = useState("");
const [conditions, setConditions] = useState<Record<string, boolean>>({});
const [selectedCaseId, setSelectedCaseId] = useState<string>("");
const [showBatchPanel, setShowBatchPanel] = useState(false);
const queryClient = useQueryClient();
// Fetch proceeding types
const { data: proceedingTypes, isLoading: typesLoading } = useQuery({
queryKey: ["proceeding-types"],
queryFn: () => api.get<ProceedingType[]>("/proceeding-types"),
});
// Fetch timeline structure when type is selected
const { data: timelineData } = useQuery({
queryKey: ["timeline", selectedType],
queryFn: () => api.get<TimelineResponse>(`/proceeding-types/${selectedType}/timeline`),
enabled: !!selectedType,
});
// Determine mutation
const determineMutation = useMutation({
mutationFn: (params: { proceeding_type: string; trigger_event_date: string; conditions: Record<string, boolean> }) =>
api.post<DetermineResponse>("/deadlines/determine", params),
});
// Cases for batch create
const { data: cases } = useQuery({
queryKey: ["cases"],
queryFn: () => api.get<Case[]>("/cases"),
enabled: showBatchPanel,
});
// Batch create mutation
const batchMutation = useMutation({
mutationFn: (params: { caseId: string; deadlines: { title: string; due_date: string; rule_code?: string }[] }) =>
api.post(`/cases/${params.caseId}/deadlines/batch`, { deadlines: params.deadlines }),
onSuccess: () => {
toast.success("Alle Fristen wurden auf die Akte uebernommen");
queryClient.invalidateQueries({ queryKey: ["deadlines"] });
setShowBatchPanel(false);
},
onError: () => {
toast.error("Fehler beim Erstellen der Fristen");
},
});
// Spawns from timeline structure (for condition toggles)
const spawns = useMemo(() => {
if (!timelineData?.timeline) return [];
return extractSpawns(timelineData.timeline);
}, [timelineData]);
// Calculate on type/date/condition change
const calculate = useCallback(() => {
if (!selectedType || !triggerDate) return;
determineMutation.mutate({
proceeding_type: selectedType,
trigger_event_date: triggerDate,
conditions,
});
}, [selectedType, triggerDate, conditions, determineMutation]);
// Auto-calculate when date or conditions change
const handleDateChange = (date: string) => {
setTriggerDate(date);
if (selectedType && date) {
determineMutation.mutate({
proceeding_type: selectedType,
trigger_event_date: date,
conditions,
});
}
};
const handleConditionToggle = (spawnId: string) => {
const next = { ...conditions, [spawnId]: !conditions[spawnId] };
setConditions(next);
if (selectedType && triggerDate) {
determineMutation.mutate({
proceeding_type: selectedType,
trigger_event_date: triggerDate,
conditions: next,
});
}
};
const handleTypeSelect = (code: string) => {
setSelectedType(code);
setConditions({});
if (triggerDate) {
// Will recalculate once timeline loads
determineMutation.reset();
}
};
const handleReset = () => {
setSelectedType("");
setTriggerDate("");
setConditions({});
setShowBatchPanel(false);
determineMutation.reset();
};
// Collect calculated deadlines for batch create
const collectDeadlines = (events: TimelineEvent[]): { title: string; due_date: string; rule_code?: string }[] => {
const result: { title: string; due_date: string; rule_code?: string }[] = [];
for (const ev of events) {
if (ev.date && ev.duration_value > 0) {
result.push({ title: ev.name, due_date: ev.date, rule_code: ev.rule_code || undefined });
}
if (ev.children) result.push(...collectDeadlines(ev.children));
}
return result;
};
const results = determineMutation.data;
const selectedPT = proceedingTypes?.find((pt) => pt.code === selectedType);
return (
<div className="space-y-5">
{/* Step 1: Proceeding Type Selection */}
<div className="rounded-lg border border-neutral-200 bg-white p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-medium text-neutral-900">
<Scale className="h-4 w-4" />
Verfahrensart waehlen
</div>
{selectedType && (
<button
onClick={handleReset}
className="flex items-center gap-1 text-xs text-neutral-500 hover:text-neutral-700"
>
<RotateCcw className="h-3 w-3" />
Zuruecksetzen
</button>
)}
</div>
<div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
{typesLoading ? (
<div className="col-span-full flex justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
</div>
) : (
proceedingTypes?.map((pt) => (
<button
key={pt.id}
onClick={() => handleTypeSelect(pt.code)}
className={`rounded-lg border px-3 py-2.5 text-left transition-all ${
selectedType === pt.code
? "border-neutral-900 bg-neutral-900 text-white shadow-sm"
: "border-neutral-200 bg-white text-neutral-700 hover:border-neutral-400 hover:bg-neutral-50"
}`}
>
<div className="flex items-center gap-1.5">
<div
className="h-2 w-2 rounded-full"
style={{ backgroundColor: pt.default_color }}
/>
<span className="text-xs font-semibold">{pt.code}</span>
</div>
<div className="mt-1 text-xs leading-tight opacity-80">{pt.name}</div>
</button>
))
)}
</div>
</div>
{/* Step 2: Date + Conditions */}
{selectedType && (
<div className="animate-fade-in rounded-lg border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2 text-sm font-medium text-neutral-900">
<Calendar className="h-4 w-4" />
Ausloesendes Ereignis
</div>
<div className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end">
<div className="flex-1">
<label className="mb-1 block text-xs font-medium text-neutral-500">
Datum des {selectedPT?.name || selectedType} (z.B. Klagezustellung)
</label>
<input
type="date"
value={triggerDate}
onChange={(e) => handleDateChange(e.target.value)}
className="w-full rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm text-neutral-900 outline-none transition-colors focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
/>
</div>
{/* Condition toggles */}
{spawns.length > 0 && (
<div className="flex flex-wrap gap-2">
{spawns.map((spawn) => (
<button
key={spawn.id}
onClick={() => handleConditionToggle(spawn.id)}
className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
conditions[spawn.id]
? "bg-neutral-900 text-white"
: "border border-neutral-300 bg-white text-neutral-600 hover:bg-neutral-50"
}`}
>
<GitBranch className="h-3 w-3" />
{spawn.spawn_label || spawn.name}
{conditions[spawn.id] && <Check className="h-3 w-3" />}
</button>
))}
</div>
)}
</div>
</div>
)}
{/* Error */}
{determineMutation.isError && (
<div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
<AlertTriangle className="h-4 w-4 shrink-0" />
Fehler bei der Berechnung. Bitte Eingaben pruefen.
</div>
)}
{/* Step 3: Calculated Timeline */}
{results && results.timeline && (
<div className="animate-fade-in space-y-3">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-neutral-900">
Verfahrens-Timeline: {results.proceeding_name}
</h3>
<p className="mt-0.5 text-xs text-neutral-500">
{results.total_deadlines} Ereignisse ab{" "}
{format(parseISO(results.trigger_event_date), "dd. MMMM yyyy", { locale: de })}
</p>
</div>
<button
onClick={() => setShowBatchPanel(!showBatchPanel)}
className="flex items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-neutral-800"
>
<CheckCircle2 className="h-3.5 w-3.5" />
Alle uebernehmen
</button>
</div>
{/* Timeline visualization */}
<div className="rounded-lg border border-neutral-200 bg-white">
<TimelineTree events={results.timeline} conditions={conditions} depth={0} />
</div>
{/* Batch create panel */}
{showBatchPanel && (
<div className="animate-fade-in rounded-lg border border-neutral-200 bg-neutral-50 p-4">
<div className="flex items-center gap-2 text-sm font-medium text-neutral-900">
<FolderOpen className="h-4 w-4" />
Fristen auf Akte uebernehmen
</div>
<div className="mt-3 flex gap-3">
<select
value={selectedCaseId}
onChange={(e) => setSelectedCaseId(e.target.value)}
className="flex-1 rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm text-neutral-900 outline-none focus:border-neutral-400"
>
<option value="">Akte waehlen...</option>
{cases
?.filter((c) => c.status !== "closed")
.map((c) => (
<option key={c.id} value={c.id}>
{c.case_number} {c.title}
</option>
))}
</select>
<button
disabled={!selectedCaseId || batchMutation.isPending}
onClick={() => {
const deadlines = collectDeadlines(results.timeline);
if (deadlines.length === 0) return;
batchMutation.mutate({ caseId: selectedCaseId, deadlines });
}}
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:cursor-not-allowed disabled:opacity-50"
>
{batchMutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ArrowRight className="h-3.5 w-3.5" />
)}
{batchMutation.isPending ? "Erstelle..." : `${collectDeadlines(results.timeline).length} Fristen erstellen`}
</button>
</div>
</div>
)}
</div>
)}
{/* Empty state */}
{!results && !determineMutation.isPending && selectedType && triggerDate && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
</div>
)}
{!selectedType && (
<div className="flex flex-col items-center rounded-lg border border-dashed border-neutral-300 bg-white px-6 py-12 text-center">
<div className="rounded-xl bg-neutral-100 p-3">
<Scale className="h-6 w-6 text-neutral-400" />
</div>
<p className="mt-3 text-sm font-medium text-neutral-700">
UPC-Fristenbestimmung
</p>
<p className="mt-1 max-w-sm text-xs text-neutral-500">
Waehlen Sie die Verfahrensart und geben Sie das Datum des ausloesenden Ereignisses ein.
Alle Fristen des Verfahrens werden automatisch berechnet.
</p>
</div>
)}
</div>
);
}
// --- Timeline Tree Component ---
function TimelineTree({
events,
conditions,
depth,
}: {
events: TimelineEvent[];
conditions: Record<string, boolean>;
depth: number;
}) {
return (
<>
{events.map((ev, i) => (
<TimelineNode
key={ev.id}
event={ev}
conditions={conditions}
depth={depth}
isLast={i === events.length - 1}
/>
))}
</>
);
}
function TimelineNode({
event: ev,
conditions,
depth,
isLast,
}: {
event: TimelineEvent;
conditions: Record<string, boolean>;
depth: number;
isLast: boolean;
}) {
const [expanded, setExpanded] = useState(true);
// Skip inactive spawns
if (ev.is_spawn && !conditions[ev.id]) return null;
const hasChildren = ev.children && ev.children.length > 0;
const visibleChildren = ev.children?.filter(
(c) => !c.is_spawn || conditions[c.id]
);
const hasVisibleChildren = visibleChildren && visibleChildren.length > 0;
const urgency = getUrgency(ev.date);
const styles = urgencyStyles[urgency];
const duration = formatDuration(ev.duration_value, ev.duration_unit);
const isConditional = ev.has_condition && ev.condition_rule_id;
return (
<>
<div
className={`group relative flex gap-3 px-4 py-3 transition-colors hover:bg-neutral-50 ${
!isLast && depth === 0 ? "border-b border-neutral-100" : ""
}`}
style={{ paddingLeft: `${16 + depth * 24}px` }}
>
{/* Timeline connector */}
<div className="flex flex-col items-center pt-1">
<div className={`h-3 w-3 shrink-0 rounded-full border-2 border-white shadow-sm ${styles.dot}`} />
{!isLast && <div className="mt-1 w-px flex-1 bg-neutral-200" />}
</div>
{/* Content */}
<div className="min-w-0 flex-1">
<div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
<div className="flex items-center gap-1.5">
{hasVisibleChildren && (
<button
onClick={() => setExpanded(!expanded)}
className="text-neutral-400 hover:text-neutral-600"
>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</button>
)}
{ev.is_spawn && (
<GitBranch className="h-3.5 w-3.5 text-violet-500" />
)}
<span className="text-sm font-medium text-neutral-900">{ev.name}</span>
{!ev.is_mandatory && (
<span className="rounded bg-neutral-100 px-1 py-0.5 text-[10px] text-neutral-500">
optional
</span>
)}
</div>
{/* Date */}
{ev.date && (
<div className="flex items-center gap-1.5 shrink-0">
{ev.was_adjusted && (
<span className="text-[10px] text-amber-600" title={`Original: ${ev.original_date}`}>
angepasst
</span>
)}
<span className={`text-sm font-medium tabular-nums ${styles.text}`}>
{format(parseISO(ev.date), "dd.MM.yyyy")}
</span>
</div>
)}
</div>
{/* Meta row */}
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-neutral-500">
{ev.primary_party && (
<span className="flex items-center gap-0.5">
{getPartyIcon(ev.primary_party)}
{getPartyLabel(ev.primary_party)}
</span>
)}
{ev.event_type && (
<>
<span className="text-neutral-300">·</span>
<span>{getEventTypeLabel(ev.event_type)}</span>
</>
)}
{duration && (
<>
<span className="text-neutral-300">·</span>
<span className="flex items-center gap-0.5">
<Clock className="h-3 w-3" />
{duration}
</span>
</>
)}
{ev.rule_code && (
<>
<span className="text-neutral-300">·</span>
<span className="rounded bg-neutral-100 px-1 py-0.5 font-mono text-[10px]">
{ev.rule_code}
</span>
</>
)}
{isConditional && (
<>
<span className="text-neutral-300">·</span>
<span className="text-violet-600">
bedingt{ev.alt_rule_code ? ` (${ev.alt_rule_code})` : ""}
</span>
</>
)}
</div>
{/* Notes */}
{ev.deadline_notes && (
<p className="mt-1 text-xs text-neutral-400 italic">{ev.deadline_notes}</p>
)}
</div>
</div>
{/* Children */}
{expanded && hasVisibleChildren && (
<TimelineTree events={visibleChildren!} conditions={conditions} depth={depth + 1} />
)}
</>
);
}

View File

@@ -0,0 +1,17 @@
"use client";
import { usePermissions } from "@/lib/hooks/usePermissions";
export function DemoBanner() {
const { isDemo, isLoading } = usePermissions();
if (isLoading || !isDemo) return null;
return (
<div className="flex items-center justify-center gap-2 bg-amber-50 border-b border-amber-200 px-4 py-2 text-sm text-amber-800">
<span className="font-medium">Demo-Modus</span>
<span className="text-amber-600">&mdash;</span>
<span>Keine echten Mandantendaten eingeben</span>
</div>
);
}

View File

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

View File

@@ -0,0 +1,240 @@
"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

@@ -0,0 +1,223 @@
"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

@@ -0,0 +1,204 @@
"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

@@ -0,0 +1,187 @@
"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

@@ -4,6 +4,14 @@ import type { ApiError } from "@/lib/types";
class ApiClient {
private baseUrl = "/api";
/** Strip leading /api/ if accidentally included — baseUrl already provides it */
private normalizePath(path: string): string {
if (path.startsWith("/api/")) {
return path.slice(4); // "/api/foo" -> "/foo"
}
return path;
}
private async getHeaders(): Promise<HeadersInit> {
const supabase = createClient();
const {
@@ -29,9 +37,10 @@ class ApiClient {
}
private async request<T>(
path: string,
rawPath: string,
options: RequestInit = {},
): Promise<T> {
const path = this.normalizePath(rawPath);
const headers = await this.getHeaders();
const res = await fetch(`${this.baseUrl}${path}`, {
...options,
@@ -80,7 +89,8 @@ class ApiClient {
return this.request<T>(path, { method: "DELETE" });
}
async postFormData<T>(path: string, formData: FormData): Promise<T> {
async postFormData<T>(rawPath: string, formData: FormData): Promise<T> {
const path = this.normalizePath(rawPath);
const supabase = createClient();
const {
data: { session },

View File

@@ -25,5 +25,6 @@ export function usePermissions() {
isLoading,
userId: data?.user_id ?? null,
tenantId: data?.tenant_id ?? null,
isDemo: data?.is_demo ?? false,
};
}

View File

@@ -120,12 +120,76 @@ export interface DeadlineRule {
rule_code?: string;
deadline_notes?: string;
sequence_order: number;
condition_rule_id?: string;
alt_duration_value?: number;
alt_duration_unit?: string;
alt_rule_code?: string;
is_spawn?: boolean;
spawn_label?: string;
}
export interface RuleTreeNode extends DeadlineRule {
children?: RuleTreeNode[];
}
// Timeline determination types
export interface TimelineEvent {
id: string;
code?: string;
name: string;
description?: string;
primary_party?: string;
event_type?: string;
is_mandatory: boolean;
duration_value: number;
duration_unit: string;
rule_code?: string;
deadline_notes?: string;
is_spawn: boolean;
spawn_label?: string;
has_condition: boolean;
condition_rule_id?: string;
alt_rule_code?: string;
alt_duration_value?: number;
alt_duration_unit?: string;
date?: string;
original_date?: string;
was_adjusted: boolean;
children?: TimelineEvent[];
}
export interface TimelineResponse {
proceeding_type: ProceedingType;
timeline: TimelineEvent[];
}
export interface DetermineRequest {
proceeding_type: string;
trigger_event_date: string;
conditions: Record<string, boolean>;
}
export interface DetermineResponse {
proceeding_type: string;
proceeding_name: string;
proceeding_color: string;
trigger_event_date: string;
timeline: TimelineEvent[];
total_deadlines: number;
}
export interface BatchCreateRequest {
deadlines: {
title: string;
due_date: string;
original_due_date?: string;
rule_id?: string;
rule_code?: string;
notes?: string;
}[];
}
export interface ProceedingType {
id: number;
code: string;
@@ -202,6 +266,7 @@ export interface UserInfo {
tenant_id: string;
role: UserRole;
permissions: string[];
is_demo: boolean;
}
export type UserRole = "owner" | "partner" | "associate" | "paralegal" | "secretary";
@@ -288,31 +353,6 @@ export interface DashboardData {
recent_activity?: RecentActivity[];
}
// Notes
export interface Note {
id: string;
tenant_id: string;
case_id?: string;
deadline_id?: string;
appointment_id?: string;
case_event_id?: string;
content: string;
created_by?: string;
created_at: string;
updated_at: string;
}
// Recent Activity
export interface RecentActivity {
id: string;
event_type?: string;
title: string;
case_id: string;
case_number: string;
event_date?: string;
created_at: string;
}
// AI Extraction types
export interface ExtractedDeadline {
@@ -362,6 +402,13 @@ export const TEMPLATE_TYPES: Record<string, string> = {
upc_injunction: "UPC Provisional Measures",
};
export const TEMPLATE_CATEGORY_LABELS: Record<string, string> = {
schriftsatz: "Schriftsatz",
vertrag: "Vertrag",
korrespondenz: "Korrespondenz",
intern: "Intern",
};
// AI Case Strategy
export interface StrategyStep {
@@ -407,3 +454,244 @@ export interface SimilarCasesResponse {
cases: SimilarCase[];
count: number;
}
// Time Tracking
export interface TimeEntry {
id: string;
tenant_id: string;
case_id: string;
user_id: string;
description: string;
duration_minutes: number;
hourly_rate: number;
billable: boolean;
billed?: boolean;
activity?: string;
date: string;
created_at: string;
updated_at: string;
}
// Billing
export interface InvoiceItem {
description: string;
amount: number;
duration_minutes?: number;
hourly_rate?: number;
}
export interface Invoice {
id: string;
tenant_id: string;
case_id: string;
invoice_number: string;
client_name: string;
client_address?: string;
items: InvoiceItem[];
subtotal: number;
tax_rate: number;
tax_amount: number;
total: number;
status: string;
notes?: string;
issued_at?: string;
due_at?: string;
paid_at?: string;
created_at: string;
updated_at: string;
}
export interface BillingRate {
id: string;
tenant_id: string;
user_id?: string;
rate: number;
currency: string;
valid_from: string;
valid_to?: string;
created_at: string;
updated_at: string;
}
// Reports
export interface BillingReportMonthly {
period: string;
cases_new: number;
cases_closed: number;
cases_active: number;
}
export interface BillingReportByType {
case_type: string;
total: number;
active: number;
closed: number;
}
export interface BillingReport {
total_revenue: number;
outstanding: number;
billable_hours: number;
non_billable_hours: number;
monthly: BillingReportMonthly[];
by_type: BillingReportByType[];
}
export interface CaseReportTotal {
opened: number;
closed: number;
active: number;
}
export interface CaseReportMonthly {
period: string;
opened: number;
closed: number;
active: number;
}
export interface CaseReportByType {
case_type: string;
count: number;
active: number;
closed: number;
total: number;
}
export interface CaseReportByCourt {
court: string;
count: number;
}
export interface CaseReport {
opened: number;
closed: number;
active: number;
total: CaseReportTotal;
monthly: CaseReportMonthly[];
by_type: CaseReportByType[];
by_court: CaseReportByCourt[];
}
export interface DeadlineReportTotal {
total: number;
met: number;
missed: number;
compliance_rate: number;
}
export interface DeadlineReportMonthly {
period: string;
total: number;
met: number;
missed: number;
pending: number;
compliance_rate: number;
}
export interface MissedDeadline {
id: string;
title: string;
case_id: string;
case_number: string;
case_title: string;
due_date: string;
days_overdue: number;
}
export interface DeadlineReport {
compliance_rate: number;
met: number;
total: DeadlineReportTotal;
monthly: DeadlineReportMonthly[];
missed: MissedDeadline[];
by_case: Record<string, number>;
}
export interface WorkloadUser {
name: string;
user_id: string;
hours: number;
utilization: number;
active_cases: number;
deadlines: number;
overdue: number;
completed: number;
}
export interface WorkloadReport {
users: WorkloadUser[];
}
// Document Templates
export interface DocumentTemplate {
id: string;
tenant_id: string;
name: string;
description?: string;
category: string;
content: string;
variables: string[];
is_system: boolean;
created_at: string;
updated_at: string;
}
export interface RenderResponse {
rendered_content: string;
content: string;
name: string;
}
// Notifications
export interface Notification {
id: string;
tenant_id: string;
type: string;
entity_type: string;
entity_id: string;
title: string;
body: string;
sent_at?: string;
read_at?: string;
created_at: string;
updated_at: string;
}
export interface NotificationPreferences {
deadline_reminder_days: number[];
email_enabled: boolean;
daily_digest: boolean;
}
export interface NotificationListResponse {
notifications: Notification[];
data: Notification[];
total: number;
unread_count: number;
}
// 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;
created_at: string;
}
export interface AuditLogResponse {
entries: AuditLogEntry[];
total: number;
}