Compare commits

...

13 Commits

Author SHA1 Message Date
m
68d48100b9 test: comprehensive integration tests for all API endpoints
Replace the existing integration test suite with a complete test covering
every registered API route. Tests use httptest with the real router and
a real DB connection (youpc.org mgmt schema).

Endpoint groups tested:
- Health, Auth (JWT validation, expired/invalid/wrong-secret)
- Current user (GET /api/me)
- Tenants (CRUD, auto-assign)
- Cases (CRUD with search/status filters)
- Parties (CRUD)
- Deadlines (CRUD, complete, batch create)
- Appointments (CRUD)
- Notes (CRUD)
- Dashboard
- Proceeding types & deadline rules
- Deadline calculator & determination (timeline, determine)
- Reports (cases, deadlines, workload, billing)
- Templates (CRUD, render)
- Time entries (CRUD, summary)
- Invoices (CRUD, status update)
- Billing rates (list, upsert)
- Notifications (list, unread count, mark read, preferences)
- Audit log (list, filtered)
- Case assignments (assign, unassign)
- Documents (list, meta)
- AI endpoints (availability check)
- Critical path E2E (case -> deadline -> appointment -> note -> time entry -> dashboard -> complete)
2026-03-30 14:41:59 +02:00
m
5e401d2eac fix: default deadline calculator date to today 2026-03-30 14:21:08 +02:00
m
3f90904e0c fix: update search_path from kanzlai to mgmt after migration 2026-03-30 14:18:35 +02:00
m
f285d4451d refactor: switch to youpc.org Supabase, remove separate YouPCDatabaseURL 2026-03-30 14:09:52 +02:00
m
bf1b1cdd82 refactor: remove YouPCDatabaseURL, use same DB connection for case finder
Now that KanzlAI is on the youpc.org Supabase instance, the separate
YouPCDatabaseURL connection is unnecessary. The main database connection
can query mlex.* tables directly since they're on the same Postgres.

- Remove YouPCDatabaseURL from config
- Remove separate sqlx.Connect block in main.go
- Pass main database handle as youpcDB parameter to router
- Update CLAUDE.md: mgmt schema in youpc.org (was kanzlai in flexsiebels)
2026-03-30 14:01:19 +02:00
m
9d89b97ad5 fix: open reports endpoints to all roles, only billing restricted 2026-03-30 13:44:04 +02:00
m
2f572fafc9 fix: wire all missing routes (reports, time entries, invoices, templates, billing) 2026-03-30 13:14:18 +02:00
m
d76ffec758 fix: wire all missing routes in router.go
Register routes for reports, time entries, invoices, billing rates,
and document templates. All handlers and services already existed but
were not connected in the router.

Permission mapping:
- Reports, invoices, billing rates: PermManageBilling (partners+owners)
- Templates create/update/delete: PermCreateCase
- Time entries, template read/render: all authenticated users
2026-03-30 13:11:17 +02:00
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
21 changed files with 3031 additions and 343 deletions

View File

@@ -18,6 +18,7 @@
- ESLint must pass before committing - ESLint must pass before committing
- Import aliases: `@/` maps to `src/` - Import aliases: `@/` maps to `src/`
- Bun as package manager (not npm/yarn/pnpm) - 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 ## General

View File

@@ -18,7 +18,7 @@ frontend/ Next.js 15 (TypeScript, Tailwind CSS, App Router)
- **Frontend:** Next.js 15 with TypeScript, Tailwind CSS v4, App Router, Bun - **Frontend:** Next.js 15 with TypeScript, Tailwind CSS v4, App Router, Bun
- **Backend:** Go (standard library HTTP server) - **Backend:** Go (standard library HTTP server)
- **Database:** Supabase (PostgreSQL) — `kanzlai` schema in flexsiebels instance - **Database:** Supabase (PostgreSQL) — `mgmt` schema in youpc.org instance
- **Deploy:** Dokploy on mLake, domain: kanzlai.msbls.de - **Deploy:** Dokploy on mLake, domain: kanzlai.msbls.de
## Development ## Development

View File

@@ -5,7 +5,6 @@ import (
"net/http" "net/http"
"os" "os"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq" _ "github.com/lib/pq"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth" "mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
@@ -34,21 +33,6 @@ func main() {
authMW := auth.NewMiddleware(cfg.SupabaseJWTSecret, database) authMW := auth.NewMiddleware(cfg.SupabaseJWTSecret, database)
// Optional: connect to youpc.org database for similar case finder
var youpcDB *sqlx.DB
if cfg.YouPCDatabaseURL != "" {
youpcDB, err = sqlx.Connect("postgres", cfg.YouPCDatabaseURL)
if err != nil {
slog.Warn("failed to connect to youpc.org database — similar case finder disabled", "error", err)
youpcDB = nil
} else {
youpcDB.SetMaxOpenConns(5)
youpcDB.SetMaxIdleConns(2)
defer youpcDB.Close()
slog.Info("connected to youpc.org database for similar case finder")
}
}
// Start CalDAV sync service // Start CalDAV sync service
calDAVSvc := services.NewCalDAVService(database) calDAVSvc := services.NewCalDAVService(database)
calDAVSvc.Start() calDAVSvc.Start()
@@ -59,7 +43,7 @@ func main() {
notifSvc.Start() notifSvc.Start()
defer notifSvc.Stop() defer notifSvc.Stop()
handler := router.New(database, authMW, cfg, calDAVSvc, notifSvc, youpcDB) handler := router.New(database, authMW, cfg, calDAVSvc, notifSvc, database)
slog.Info("starting KanzlAI API server", "port", cfg.Port) slog.Info("starting KanzlAI API server", "port", cfg.Port)
if err := http.ListenAndServe(":"+cfg.Port, handler); err != nil { if err := http.ListenAndServe(":"+cfg.Port, handler); err != nil {

View File

@@ -11,9 +11,9 @@ type contextKey string
const ( const (
userIDKey contextKey = "user_id" userIDKey contextKey = "user_id"
tenantIDKey contextKey = "tenant_id" tenantIDKey contextKey = "tenant_id"
userRoleKey contextKey = "user_role"
ipKey contextKey = "ip_address" ipKey contextKey = "ip_address"
userAgentKey contextKey = "user_agent" userAgentKey contextKey = "user_agent"
userRoleKey contextKey = "user_role"
) )
func ContextWithUserID(ctx context.Context, userID uuid.UUID) context.Context { 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 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 { func ContextWithRequestInfo(ctx context.Context, ip, userAgent string) context.Context {
ctx = context.WithValue(ctx, ipKey, ip) ctx = context.WithValue(ctx, ipKey, ip)
ctx = context.WithValue(ctx, userAgentKey, userAgent) ctx = context.WithValue(ctx, userAgentKey, userAgent)
@@ -62,3 +53,12 @@ func UserAgentFromContext(ctx context.Context) *string {
} }
return nil 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()) ctx = ContextWithRequestInfo(ctx, ip, r.UserAgent())
// Tenant/role resolution is handled by TenantResolver middleware for scoped routes. // Tenant and role resolution handled by TenantResolver middleware for scoped routes.
// Tenant management routes handle their own access control.
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
} }

View File

@@ -34,6 +34,7 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
} }
var tenantID uuid.UUID var tenantID uuid.UUID
ctx := r.Context()
if header := r.Header.Get("X-Tenant-ID"); header != "" { if header := r.Header.Get("X-Tenant-ID"); header != "" {
parsed, err := uuid.Parse(header) parsed, err := uuid.Parse(header)
@@ -41,6 +42,7 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
http.Error(w, `{"error":"invalid X-Tenant-ID"}`, http.StatusBadRequest) http.Error(w, `{"error":"invalid X-Tenant-ID"}`, http.StatusBadRequest)
return return
} }
// Verify user has access and get their role // Verify user has access and get their role
role, err := tr.lookup.GetUserRole(r.Context(), userID, parsed) role, err := tr.lookup.GetUserRole(r.Context(), userID, parsed)
if err != nil { if err != nil {
@@ -54,7 +56,7 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
} }
tenantID = parsed tenantID = parsed
r = r.WithContext(ContextWithUserRole(r.Context(), role)) ctx = ContextWithUserRole(ctx, role)
} else { } else {
// Default to user's first tenant // Default to user's first tenant
first, err := tr.lookup.FirstTenantForUser(r.Context(), userID) first, err := tr.lookup.FirstTenantForUser(r.Context(), userID)
@@ -69,17 +71,17 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
} }
tenantID = *first tenantID = *first
// Resolve role for default tenant // Look up role for default tenant
role, err := tr.lookup.GetUserRole(r.Context(), userID, tenantID) role, err := tr.lookup.GetUserRole(r.Context(), userID, tenantID)
if err != nil { if err != nil {
slog.Error("failed to resolve role for default tenant", "error", err) slog.Error("failed to get user role", "error", err, "user_id", userID, "tenant_id", tenantID)
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return return
} }
r = r.WithContext(ContextWithUserRole(r.Context(), role)) ctx = ContextWithUserRole(ctx, role)
} }
ctx := ContextWithTenantID(r.Context(), tenantID) ctx = ContextWithTenantID(ctx, tenantID)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
} }

View File

@@ -12,7 +12,6 @@ import (
type mockTenantLookup struct { type mockTenantLookup struct {
tenantID *uuid.UUID tenantID *uuid.UUID
role string role string
roleSet bool // true means role was explicitly set (even if empty)
err error err error
} }
@@ -21,23 +20,22 @@ func (m *mockTenantLookup) FirstTenantForUser(ctx context.Context, userID uuid.U
} }
func (m *mockTenantLookup) GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) { func (m *mockTenantLookup) GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) {
if m.roleSet {
return m.role, m.err return m.role, m.err
} }
return "associate", m.err
}
func TestTenantResolver_FromHeader(t *testing.T) { func TestTenantResolver_FromHeader(t *testing.T) {
tenantID := uuid.New() tenantID := uuid.New()
tr := NewTenantResolver(&mockTenantLookup{role: "partner"}) tr := NewTenantResolver(&mockTenantLookup{role: "partner"})
var gotTenantID uuid.UUID var gotTenantID uuid.UUID
var gotRole string
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id, ok := TenantFromContext(r.Context()) id, ok := TenantFromContext(r.Context())
if !ok { if !ok {
t.Fatal("tenant ID not in context") t.Fatal("tenant ID not in context")
} }
gotTenantID = id gotTenantID = id
gotRole = UserRoleFromContext(r.Context())
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
}) })
@@ -54,11 +52,14 @@ func TestTenantResolver_FromHeader(t *testing.T) {
if gotTenantID != tenantID { if gotTenantID != tenantID {
t.Errorf("expected tenant %s, got %s", tenantID, gotTenantID) 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) { func TestTenantResolver_FromHeader_NoAccess(t *testing.T) {
tenantID := uuid.New() tenantID := uuid.New()
tr := NewTenantResolver(&mockTenantLookup{role: "", roleSet: true}) tr := NewTenantResolver(&mockTenantLookup{role: ""})
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next should not be called") t.Fatal("next should not be called")
@@ -78,7 +79,7 @@ func TestTenantResolver_FromHeader_NoAccess(t *testing.T) {
func TestTenantResolver_DefaultsToFirst(t *testing.T) { func TestTenantResolver_DefaultsToFirst(t *testing.T) {
tenantID := uuid.New() tenantID := uuid.New()
tr := NewTenantResolver(&mockTenantLookup{tenantID: &tenantID, role: "owner"}) tr := NewTenantResolver(&mockTenantLookup{tenantID: &tenantID, role: "associate"})
var gotTenantID uuid.UUID var gotTenantID uuid.UUID
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View File

@@ -14,7 +14,6 @@ type Config struct {
SupabaseJWTSecret string SupabaseJWTSecret string
AnthropicAPIKey string AnthropicAPIKey string
FrontendOrigin string FrontendOrigin string
YouPCDatabaseURL string // read-only connection to youpc.org Supabase for similar case finder
} }
func Load() (*Config, error) { func Load() (*Config, error) {
@@ -27,7 +26,6 @@ func Load() (*Config, error) {
SupabaseJWTSecret: os.Getenv("SUPABASE_JWT_SECRET"), SupabaseJWTSecret: os.Getenv("SUPABASE_JWT_SECRET"),
AnthropicAPIKey: os.Getenv("ANTHROPIC_API_KEY"), AnthropicAPIKey: os.Getenv("ANTHROPIC_API_KEY"),
FrontendOrigin: getEnv("FRONTEND_ORIGIN", "https://kanzlai.msbls.de"), FrontendOrigin: getEnv("FRONTEND_ORIGIN", "https://kanzlai.msbls.de"),
YouPCDatabaseURL: os.Getenv("YOUPC_DATABASE_URL"),
} }
if cfg.DatabaseURL == "" { if cfg.DatabaseURL == "" {

View File

@@ -13,8 +13,8 @@ func Connect(databaseURL string) (*sqlx.DB, error) {
return nil, fmt.Errorf("connecting to database: %w", err) return nil, fmt.Errorf("connecting to database: %w", err)
} }
// Set search_path so queries use kanzlai schema by default // Set search_path so queries use mgmt schema by default
if _, err := db.Exec("SET search_path TO kanzlai, public"); err != nil { if _, err := db.Exec("SET search_path TO mgmt, public"); err != nil {
db.Close() db.Close()
return nil, fmt.Errorf("setting search_path: %w", err) return nil, fmt.Errorf("setting search_path: %w", err)
} }

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

File diff suppressed because it is too large Load Diff

View File

@@ -26,6 +26,8 @@ type DeadlineRule struct {
AltDurationValue *int `db:"alt_duration_value" json:"alt_duration_value,omitempty"` AltDurationValue *int `db:"alt_duration_value" json:"alt_duration_value,omitempty"`
AltDurationUnit *string `db:"alt_duration_unit" json:"alt_duration_unit,omitempty"` AltDurationUnit *string `db:"alt_duration_unit" json:"alt_duration_unit,omitempty"`
AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,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"` IsActive bool `db:"is_active" json:"is_active"`
CreatedAt time.Time `db:"created_at" json:"created_at"` CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"`

View File

@@ -28,9 +28,15 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
deadlineSvc := services.NewDeadlineService(db, auditSvc) deadlineSvc := services.NewDeadlineService(db, auditSvc)
deadlineRuleSvc := services.NewDeadlineRuleService(db) deadlineRuleSvc := services.NewDeadlineRuleService(db)
calculator := services.NewDeadlineCalculator(holidaySvc) calculator := services.NewDeadlineCalculator(holidaySvc)
determineSvc := services.NewDetermineService(db, calculator)
storageCli := services.NewStorageClient(cfg.SupabaseURL, cfg.SupabaseServiceKey) storageCli := services.NewStorageClient(cfg.SupabaseURL, cfg.SupabaseServiceKey)
documentSvc := services.NewDocumentService(db, storageCli, auditSvc) documentSvc := services.NewDocumentService(db, storageCli, auditSvc)
assignmentSvc := services.NewCaseAssignmentService(db) assignmentSvc := services.NewCaseAssignmentService(db)
reportSvc := services.NewReportingService(db)
timeEntrySvc := services.NewTimeEntryService(db, auditSvc)
invoiceSvc := services.NewInvoiceService(db, auditSvc)
billingRateSvc := services.NewBillingRateService(db, auditSvc)
templateSvc := services.NewTemplateService(db, auditSvc)
// AI service (optional — only if API key is configured) // AI service (optional — only if API key is configured)
var aiH *handlers.AIHandler var aiH *handlers.AIHandler
@@ -64,11 +70,17 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
deadlineH := handlers.NewDeadlineHandlers(deadlineSvc) deadlineH := handlers.NewDeadlineHandlers(deadlineSvc)
ruleH := handlers.NewDeadlineRuleHandlers(deadlineRuleSvc) ruleH := handlers.NewDeadlineRuleHandlers(deadlineRuleSvc)
calcH := handlers.NewCalculateHandlers(calculator, deadlineRuleSvc) calcH := handlers.NewCalculateHandlers(calculator, deadlineRuleSvc)
determineH := handlers.NewDetermineHandlers(determineSvc, deadlineSvc)
dashboardH := handlers.NewDashboardHandler(dashboardSvc) dashboardH := handlers.NewDashboardHandler(dashboardSvc)
noteH := handlers.NewNoteHandler(noteSvc) noteH := handlers.NewNoteHandler(noteSvc)
eventH := handlers.NewCaseEventHandler(db) eventH := handlers.NewCaseEventHandler(db)
docH := handlers.NewDocumentHandler(documentSvc) docH := handlers.NewDocumentHandler(documentSvc)
assignmentH := handlers.NewCaseAssignmentHandler(assignmentSvc) assignmentH := handlers.NewCaseAssignmentHandler(assignmentSvc)
reportH := handlers.NewReportHandler(reportSvc)
timeH := handlers.NewTimeEntryHandler(timeEntrySvc)
invoiceH := handlers.NewInvoiceHandler(invoiceSvc)
billingH := handlers.NewBillingRateHandler(billingRateSvc)
templateH := handlers.NewTemplateHandler(templateSvc, caseSvc, partySvc, deadlineSvc, tenantSvc)
// Public routes // Public routes
mux.HandleFunc("GET /health", handleHealth(db)) mux.HandleFunc("GET /health", handleHealth(db))
@@ -111,7 +123,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
scoped.HandleFunc("GET /api/cases", caseH.List) scoped.HandleFunc("GET /api/cases", caseH.List)
scoped.HandleFunc("POST /api/cases", perm(auth.PermCreateCase, caseH.Create)) scoped.HandleFunc("POST /api/cases", perm(auth.PermCreateCase, caseH.Create))
scoped.HandleFunc("GET /api/cases/{id}", caseH.Get) 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)) scoped.HandleFunc("DELETE /api/cases/{id}", perm(auth.PermCreateCase, caseH.Delete))
// Parties — same access as case editing // Parties — same access as case editing
@@ -137,6 +149,11 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
// Deadline calculator — all can use // Deadline calculator — all can use
scoped.HandleFunc("POST /api/deadlines/calculate", calcH.Calculate) 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) // Appointments — all can manage (PermManageAppointments granted to all)
scoped.HandleFunc("GET /api/appointments/{id}", apptH.Get) scoped.HandleFunc("GET /api/appointments/{id}", apptH.Get)
scoped.HandleFunc("GET /api/appointments", apptH.List) scoped.HandleFunc("GET /api/appointments", apptH.List)
@@ -169,7 +186,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
scoped.HandleFunc("POST /api/cases/{id}/documents", perm(auth.PermUploadDocuments, docH.Upload)) 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}", docH.Download)
scoped.HandleFunc("GET /api/documents/{docId}/meta", docH.GetMeta) 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) // AI endpoints (rate limited: 5 req/min burst 10 per IP)
if aiH != nil { if aiH != nil {
@@ -198,6 +215,39 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
scoped.HandleFunc("GET /api/caldav/status", calDAVH.GetStatus) scoped.HandleFunc("GET /api/caldav/status", calDAVH.GetStatus)
} }
// Reports — cases/deadlines/workload open to all, billing restricted
scoped.HandleFunc("GET /api/reports/cases", reportH.Cases)
scoped.HandleFunc("GET /api/reports/deadlines", reportH.Deadlines)
scoped.HandleFunc("GET /api/reports/workload", reportH.Workload)
scoped.HandleFunc("GET /api/reports/billing", perm(auth.PermManageBilling, reportH.Billing))
// Time entries — all can view/create, tied to cases
scoped.HandleFunc("GET /api/cases/{id}/time-entries", timeH.ListForCase)
scoped.HandleFunc("GET /api/time-entries", timeH.List)
scoped.HandleFunc("POST /api/cases/{id}/time-entries", timeH.Create)
scoped.HandleFunc("PUT /api/time-entries/{id}", timeH.Update)
scoped.HandleFunc("DELETE /api/time-entries/{id}", timeH.Delete)
scoped.HandleFunc("GET /api/time-entries/summary", timeH.Summary)
// Invoices — billing permission required
scoped.HandleFunc("GET /api/invoices", perm(auth.PermManageBilling, invoiceH.List))
scoped.HandleFunc("GET /api/invoices/{id}", perm(auth.PermManageBilling, invoiceH.Get))
scoped.HandleFunc("POST /api/invoices", perm(auth.PermManageBilling, invoiceH.Create))
scoped.HandleFunc("PUT /api/invoices/{id}", perm(auth.PermManageBilling, invoiceH.Update))
scoped.HandleFunc("PATCH /api/invoices/{id}/status", perm(auth.PermManageBilling, invoiceH.UpdateStatus))
// Billing rates — billing permission required
scoped.HandleFunc("GET /api/billing-rates", perm(auth.PermManageBilling, billingH.List))
scoped.HandleFunc("PUT /api/billing-rates", perm(auth.PermManageBilling, billingH.Upsert))
// Document templates — all can view/use, manage needs case creation permission
scoped.HandleFunc("GET /api/templates", templateH.List)
scoped.HandleFunc("GET /api/templates/{id}", templateH.Get)
scoped.HandleFunc("POST /api/templates", perm(auth.PermCreateCase, templateH.Create))
scoped.HandleFunc("PUT /api/templates/{id}", perm(auth.PermCreateCase, templateH.Update))
scoped.HandleFunc("DELETE /api/templates/{id}", perm(auth.PermCreateCase, templateH.Delete))
scoped.HandleFunc("POST /api/templates/{id}/render", templateH.Render)
// Wire: auth -> tenant routes go directly, scoped routes get tenant resolver // Wire: auth -> tenant routes go directly, scoped routes get tenant resolver
api.Handle("/api/", tenantResolver.Resolve(scoped)) api.Handle("/api/", tenantResolver.Resolve(scoped))

View File

@@ -8,6 +8,12 @@ import (
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models" "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 // DeadlineRuleService handles deadline rule queries
type DeadlineRuleService struct { type DeadlineRuleService struct {
db *sqlx.DB db *sqlx.DB
@@ -25,21 +31,13 @@ func (s *DeadlineRuleService) List(proceedingTypeID *int) ([]models.DeadlineRule
if proceedingTypeID != nil { if proceedingTypeID != nil {
err = s.db.Select(&rules, err = s.db.Select(&rules,
`SELECT id, proceeding_type_id, parent_id, code, name, description, `SELECT `+ruleColumns+`
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
FROM deadline_rules FROM deadline_rules
WHERE proceeding_type_id = $1 AND is_active = true WHERE proceeding_type_id = $1 AND is_active = true
ORDER BY sequence_order`, *proceedingTypeID) ORDER BY sequence_order`, *proceedingTypeID)
} else { } else {
err = s.db.Select(&rules, err = s.db.Select(&rules,
`SELECT id, proceeding_type_id, parent_id, code, name, description, `SELECT `+ruleColumns+`
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
FROM deadline_rules FROM deadline_rules
WHERE is_active = true WHERE is_active = true
ORDER BY proceeding_type_id, sequence_order`) 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 // Get all rules for this proceeding type
var rules []models.DeadlineRule var rules []models.DeadlineRule
err = s.db.Select(&rules, err = s.db.Select(&rules,
`SELECT id, proceeding_type_id, parent_id, code, name, description, `SELECT `+ruleColumns+`
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
FROM deadline_rules FROM deadline_rules
WHERE proceeding_type_id = $1 AND is_active = true WHERE proceeding_type_id = $1 AND is_active = true
ORDER BY sequence_order`, pt.ID) ORDER BY sequence_order`, pt.ID)
@@ -87,6 +81,36 @@ func (s *DeadlineRuleService) GetRuleTree(proceedingTypeCode string) ([]RuleTree
return buildTree(rules), nil 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 // GetByIDs returns deadline rules by their IDs
func (s *DeadlineRuleService) GetByIDs(ids []string) ([]models.DeadlineRule, error) { func (s *DeadlineRuleService) GetByIDs(ids []string) ([]models.DeadlineRule, error) {
if len(ids) == 0 { if len(ids) == 0 {
@@ -94,11 +118,7 @@ func (s *DeadlineRuleService) GetByIDs(ids []string) ([]models.DeadlineRule, err
} }
query, args, err := sqlx.In( query, args, err := sqlx.In(
`SELECT id, proceeding_type_id, parent_id, code, name, description, `SELECT `+ruleColumns+`
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
FROM deadline_rules FROM deadline_rules
WHERE id IN (?) AND is_active = true WHERE id IN (?) AND is_active = true
ORDER BY sequence_order`, ids) 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) { func (s *DeadlineRuleService) GetRulesForProceedingType(proceedingTypeID int) ([]models.DeadlineRule, error) {
var rules []models.DeadlineRule var rules []models.DeadlineRule
err := s.db.Select(&rules, err := s.db.Select(&rules,
`SELECT id, proceeding_type_id, parent_id, code, name, description, `SELECT `+ruleColumns+`
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
FROM deadline_rules FROM deadline_rules
WHERE proceeding_type_id = $1 AND is_active = true WHERE proceeding_type_id = $1 AND is_active = true
ORDER BY sequence_order`, proceedingTypeID) 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,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

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

View File

@@ -35,7 +35,9 @@ const inputClass =
export function DeadlineCalculator() { export function DeadlineCalculator() {
const [proceedingType, setProceedingType] = useState(""); const [proceedingType, setProceedingType] = useState("");
const [triggerDate, setTriggerDate] = useState(""); const [triggerDate, setTriggerDate] = useState(
new Date().toISOString().split("T")[0],
);
const { data: proceedingTypes, isLoading: typesLoading } = useQuery({ const { data: proceedingTypes, isLoading: typesLoading } = useQuery({
queryKey: ["proceeding-types"], queryKey: ["proceeding-types"],

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

@@ -4,6 +4,14 @@ import type { ApiError } from "@/lib/types";
class ApiClient { class ApiClient {
private baseUrl = "/api"; 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> { private async getHeaders(): Promise<HeadersInit> {
const supabase = createClient(); const supabase = createClient();
const { const {
@@ -29,9 +37,10 @@ class ApiClient {
} }
private async request<T>( private async request<T>(
path: string, rawPath: string,
options: RequestInit = {}, options: RequestInit = {},
): Promise<T> { ): Promise<T> {
const path = this.normalizePath(rawPath);
const headers = await this.getHeaders(); const headers = await this.getHeaders();
const res = await fetch(`${this.baseUrl}${path}`, { const res = await fetch(`${this.baseUrl}${path}`, {
...options, ...options,
@@ -80,7 +89,8 @@ class ApiClient {
return this.request<T>(path, { method: "DELETE" }); 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 supabase = createClient();
const { const {
data: { session }, data: { session },

View File

@@ -120,12 +120,76 @@ export interface DeadlineRule {
rule_code?: string; rule_code?: string;
deadline_notes?: string; deadline_notes?: string;
sequence_order: number; 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 { export interface RuleTreeNode extends DeadlineRule {
children?: RuleTreeNode[]; 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 { export interface ProceedingType {
id: number; id: number;
code: string; code: string;
@@ -289,31 +353,6 @@ export interface DashboardData {
recent_activity?: RecentActivity[]; 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 // AI Extraction types
export interface ExtractedDeadline { export interface ExtractedDeadline {
@@ -363,6 +402,13 @@ export const TEMPLATE_TYPES: Record<string, string> = {
upc_injunction: "UPC Provisional Measures", upc_injunction: "UPC Provisional Measures",
}; };
export const TEMPLATE_CATEGORY_LABELS: Record<string, string> = {
schriftsatz: "Schriftsatz",
vertrag: "Vertrag",
korrespondenz: "Korrespondenz",
intern: "Intern",
};
// AI Case Strategy // AI Case Strategy
export interface StrategyStep { export interface StrategyStep {
@@ -408,3 +454,244 @@ export interface SimilarCasesResponse {
cases: SimilarCase[]; cases: SimilarCase[];
count: number; 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;
}