Compare commits
45 Commits
mai/carmac
...
mai/knuth/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
850f3a62c8 | ||
|
|
d4092acc33 | ||
|
|
7c70649494 | ||
|
|
3599e302df | ||
|
|
899b461833 | ||
|
|
260f65ea02 | ||
|
|
501b573967 | ||
|
|
23b8ef4bba | ||
|
|
54c6eb8dae | ||
|
|
967f2f6d09 | ||
|
|
e5387734aa | ||
|
|
6cb87c6868 | ||
|
|
d38719db2f | ||
|
|
b21efccfb5 | ||
|
|
f51d189a3b | ||
|
|
481b299e03 | ||
|
|
68d48100b9 | ||
|
|
40a11a4c49 | ||
|
|
eca0cde5e7 | ||
|
|
cf3711b2e4 | ||
|
|
dea49f6f8e | ||
|
|
5e401d2eac | ||
|
|
3f90904e0c | ||
|
|
f285d4451d | ||
|
|
bf1b1cdd82 | ||
|
|
9d89b97ad5 | ||
|
|
2f572fafc9 | ||
|
|
d76ffec758 | ||
|
|
4b0ccac384 | ||
|
|
3030ef1e8b | ||
|
|
2578060638 | ||
|
|
8f91feee0e | ||
|
|
6b8c6f761d | ||
|
|
93a25e3d72 | ||
|
|
81c2bb29b9 | ||
|
|
9f18fbab80 | ||
|
|
ae55d9814a | ||
|
|
642877ae54 | ||
|
|
fdb4ac55a1 | ||
|
|
dd683281e0 | ||
|
|
bfd5e354ad | ||
|
|
118bae1ae3 | ||
|
|
fdef5af32e | ||
|
|
34dcbb74fe | ||
|
|
238811727d |
@@ -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
|
||||||
|
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -46,3 +46,9 @@ tmp/
|
|||||||
# TypeScript
|
# TypeScript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
backend/server
|
||||||
|
backend/.m/
|
||||||
|
.m/inbox_lastread
|
||||||
|
backend/server
|
||||||
|
backend/.m/
|
||||||
|
.m/inbox_lastread
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ head:
|
|||||||
infinity_mode: false
|
infinity_mode: false
|
||||||
capacity:
|
capacity:
|
||||||
global:
|
global:
|
||||||
max_workers: 5
|
max_workers: 6
|
||||||
max_heads: 3
|
max_heads: 3
|
||||||
per_worker:
|
per_worker:
|
||||||
max_tasks_lifetime: 0
|
max_tasks_lifetime: 0
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
|
||||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
||||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/config"
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/config"
|
||||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/db"
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/db"
|
||||||
@@ -41,7 +43,7 @@ func main() {
|
|||||||
notifSvc.Start()
|
notifSvc.Start()
|
||||||
defer notifSvc.Stop()
|
defer notifSvc.Stop()
|
||||||
|
|
||||||
handler := router.New(database, authMW, cfg, calDAVSvc, notifSvc)
|
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 {
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ type Config struct {
|
|||||||
SupabaseJWTSecret string
|
SupabaseJWTSecret string
|
||||||
AnthropicAPIKey string
|
AnthropicAPIKey string
|
||||||
FrontendOrigin string
|
FrontendOrigin string
|
||||||
|
|
||||||
|
// SMTP settings (optional — email sending disabled if SMTPHost is empty)
|
||||||
|
SMTPHost string
|
||||||
|
SMTPPort string
|
||||||
|
SMTPUser string
|
||||||
|
SMTPPass string
|
||||||
|
MailFrom string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
@@ -26,6 +33,12 @@ 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"),
|
||||||
|
|
||||||
|
SMTPHost: os.Getenv("SMTP_HOST"),
|
||||||
|
SMTPPort: getEnv("SMTP_PORT", "465"),
|
||||||
|
SMTPUser: os.Getenv("SMTP_USER"),
|
||||||
|
SMTPPass: os.Getenv("SMTP_PASS"),
|
||||||
|
MailFrom: getEnv("MAIL_FROM", "mgmt@msbls.de"),
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.DatabaseURL == "" {
|
if cfg.DatabaseURL == "" {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
||||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
||||||
)
|
)
|
||||||
@@ -115,3 +117,139 @@ func (h *AIHandler) SummarizeCase(w http.ResponseWriter, r *http.Request) {
|
|||||||
"summary": summary,
|
"summary": summary,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DraftDocument handles POST /api/ai/draft-document
|
||||||
|
// Accepts JSON {"case_id": "uuid", "template_type": "string", "instructions": "string", "language": "de|en|fr"}.
|
||||||
|
func (h *AIHandler) DraftDocument(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusForbidden, "missing tenant")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
CaseID string `json:"case_id"`
|
||||||
|
TemplateType string `json:"template_type"`
|
||||||
|
Instructions string `json:"instructions"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.CaseID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "case_id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.TemplateType == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "template_type is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
caseID, err := parseUUID(body.CaseID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid case_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(body.Instructions) > maxDescriptionLen {
|
||||||
|
writeError(w, http.StatusBadRequest, "instructions exceeds maximum length")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
draft, err := h.ai.DraftDocument(r.Context(), tenantID, caseID, body.TemplateType, body.Instructions, body.Language)
|
||||||
|
if err != nil {
|
||||||
|
internalError(w, "AI document drafting failed", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, draft)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaseStrategy handles POST /api/ai/case-strategy
|
||||||
|
// Accepts JSON {"case_id": "uuid"}.
|
||||||
|
func (h *AIHandler) CaseStrategy(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusForbidden, "missing tenant")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
CaseID string `json:"case_id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.CaseID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "case_id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
caseID, err := parseUUID(body.CaseID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid case_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
strategy, err := h.ai.CaseStrategy(r.Context(), tenantID, caseID)
|
||||||
|
if err != nil {
|
||||||
|
internalError(w, "AI case strategy analysis failed", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, strategy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SimilarCases handles POST /api/ai/similar-cases
|
||||||
|
// Accepts JSON {"case_id": "uuid", "description": "string"}.
|
||||||
|
func (h *AIHandler) SimilarCases(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusForbidden, "missing tenant")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
CaseID string `json:"case_id"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.CaseID == "" && body.Description == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "either case_id or description is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(body.Description) > maxDescriptionLen {
|
||||||
|
writeError(w, http.StatusBadRequest, "description exceeds maximum length")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var caseID uuid.UUID
|
||||||
|
if body.CaseID != "" {
|
||||||
|
var err error
|
||||||
|
caseID, err = parseUUID(body.CaseID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid case_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cases, err := h.ai.FindSimilarCases(r.Context(), tenantID, caseID, body.Description)
|
||||||
|
if err != nil {
|
||||||
|
internalError(w, "AI similar case search failed", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"cases": cases,
|
||||||
|
"count": len(cases),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
66
backend/internal/handlers/billing_rates.go
Normal file
66
backend/internal/handlers/billing_rates.go
Normal 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)
|
||||||
|
}
|
||||||
53
backend/internal/handlers/fee_calculator.go
Normal file
53
backend/internal/handlers/fee_calculator.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FeeCalculatorHandler handles fee calculation API endpoints.
|
||||||
|
type FeeCalculatorHandler struct {
|
||||||
|
calc *services.FeeCalculator
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFeeCalculatorHandler(calc *services.FeeCalculator) *FeeCalculatorHandler {
|
||||||
|
return &FeeCalculatorHandler{calc: calc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate handles POST /api/fees/calculate.
|
||||||
|
func (h *FeeCalculatorHandler) Calculate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req models.FeeCalculateRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Streitwert <= 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "streitwert must be positive")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.VATRate < 0 || req.VATRate > 1 {
|
||||||
|
writeError(w, http.StatusBadRequest, "vat_rate must be between 0 and 1")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Instances) == 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "at least one instance is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.calc.CalculateFullLitigation(req)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedules handles GET /api/fees/schedules.
|
||||||
|
func (h *FeeCalculatorHandler) Schedules(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, h.calc.GetSchedules())
|
||||||
|
}
|
||||||
170
backend/internal/handlers/invoices.go
Normal file
170
backend/internal/handlers/invoices.go
Normal 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)
|
||||||
|
}
|
||||||
109
backend/internal/handlers/reports.go
Normal file
109
backend/internal/handlers/reports.go
Normal 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)
|
||||||
|
}
|
||||||
328
backend/internal/handlers/templates.go
Normal file
328
backend/internal/handlers/templates.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -356,6 +356,71 @@ func (h *TenantHandler) UpdateMemberRole(w http.ResponseWriter, r *http.Request)
|
|||||||
jsonResponse(w, map[string]string{"status": "updated"}, http.StatusOK)
|
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.
|
// 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) {
|
func (h *TenantHandler) GetMe(w http.ResponseWriter, r *http.Request) {
|
||||||
userID, ok := auth.UserFromContext(r.Context())
|
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
|
// Get user's permissions for frontend UI
|
||||||
perms := auth.GetRolePermissions(role)
|
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{
|
jsonResponse(w, map[string]any{
|
||||||
"user_id": userID,
|
"user_id": userID,
|
||||||
"tenant_id": tenantID,
|
"tenant_id": tenantID,
|
||||||
"role": role,
|
"role": role,
|
||||||
"permissions": perms,
|
"permissions": perms,
|
||||||
|
"is_demo": isDemo,
|
||||||
}, http.StatusOK)
|
}, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
209
backend/internal/handlers/time_entries.go
Normal file
209
backend/internal/handlers/time_entries.go
Normal 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})
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
18
backend/internal/models/billing_rate.go
Normal file
18
backend/internal/models/billing_rate.go
Normal 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"`
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ type ProceedingType struct {
|
|||||||
Name string `db:"name" json:"name"`
|
Name string `db:"name" json:"name"`
|
||||||
Description *string `db:"description" json:"description,omitempty"`
|
Description *string `db:"description" json:"description,omitempty"`
|
||||||
Jurisdiction *string `db:"jurisdiction" json:"jurisdiction,omitempty"`
|
Jurisdiction *string `db:"jurisdiction" json:"jurisdiction,omitempty"`
|
||||||
|
Category *string `db:"category" json:"category,omitempty"`
|
||||||
DefaultColor string `db:"default_color" json:"default_color"`
|
DefaultColor string `db:"default_color" json:"default_color"`
|
||||||
SortOrder int `db:"sort_order" json:"sort_order"`
|
SortOrder int `db:"sort_order" json:"sort_order"`
|
||||||
IsActive bool `db:"is_active" json:"is_active"`
|
IsActive bool `db:"is_active" json:"is_active"`
|
||||||
|
|||||||
21
backend/internal/models/document_template.go
Normal file
21
backend/internal/models/document_template.go
Normal 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"`
|
||||||
|
}
|
||||||
125
backend/internal/models/fee.go
Normal file
125
backend/internal/models/fee.go
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
// FeeScheduleVersion identifies a fee schedule version.
|
||||||
|
type FeeScheduleVersion string
|
||||||
|
|
||||||
|
const (
|
||||||
|
FeeVersion2005 FeeScheduleVersion = "2005"
|
||||||
|
FeeVersion2013 FeeScheduleVersion = "2013"
|
||||||
|
FeeVersion2021 FeeScheduleVersion = "2021"
|
||||||
|
FeeVersion2025 FeeScheduleVersion = "2025"
|
||||||
|
FeeVersionAktuell FeeScheduleVersion = "Aktuell"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InstanceType identifies a court instance.
|
||||||
|
type InstanceType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
InstanceLG InstanceType = "LG"
|
||||||
|
InstanceOLG InstanceType = "OLG"
|
||||||
|
InstanceBGHNZB InstanceType = "BGH_NZB"
|
||||||
|
InstanceBGHRev InstanceType = "BGH_Rev"
|
||||||
|
InstanceBPatG InstanceType = "BPatG"
|
||||||
|
InstanceBGHNull InstanceType = "BGH_Null"
|
||||||
|
InstanceDPMA InstanceType = "DPMA"
|
||||||
|
InstanceBPatGCanc InstanceType = "BPatG_Canc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProceedingPath identifies the type of patent litigation proceeding.
|
||||||
|
type ProceedingPath string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PathInfringement ProceedingPath = "infringement"
|
||||||
|
PathNullity ProceedingPath = "nullity"
|
||||||
|
PathCancellation ProceedingPath = "cancellation"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Request ---
|
||||||
|
|
||||||
|
// FeeCalculateRequest is the request body for POST /api/fees/calculate.
|
||||||
|
type FeeCalculateRequest struct {
|
||||||
|
Streitwert float64 `json:"streitwert"`
|
||||||
|
VATRate float64 `json:"vat_rate"`
|
||||||
|
ProceedingPath ProceedingPath `json:"proceeding_path"`
|
||||||
|
Instances []InstanceInput `json:"instances"`
|
||||||
|
IncludeSecurityCosts bool `json:"include_security_costs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstanceInput configures one court instance in the calculation request.
|
||||||
|
type InstanceInput struct {
|
||||||
|
Type InstanceType `json:"type"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
FeeVersion FeeScheduleVersion `json:"fee_version"`
|
||||||
|
NumAttorneys int `json:"num_attorneys"`
|
||||||
|
NumPatentAttorneys int `json:"num_patent_attorneys"`
|
||||||
|
NumClients int `json:"num_clients"`
|
||||||
|
OralHearing bool `json:"oral_hearing"`
|
||||||
|
ExpertFees float64 `json:"expert_fees"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Response ---
|
||||||
|
|
||||||
|
// FeeCalculateResponse is the response for POST /api/fees/calculate.
|
||||||
|
type FeeCalculateResponse struct {
|
||||||
|
Instances []InstanceResult `json:"instances"`
|
||||||
|
Totals []FeeTotal `json:"totals"`
|
||||||
|
SecurityForCosts *SecurityForCosts `json:"security_for_costs,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstanceResult contains the cost breakdown for one court instance.
|
||||||
|
type InstanceResult struct {
|
||||||
|
Type InstanceType `json:"type"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
CourtFeeBase float64 `json:"court_fee_base"`
|
||||||
|
CourtFeeMultiplier float64 `json:"court_fee_multiplier"`
|
||||||
|
CourtFeeSource string `json:"court_fee_source"`
|
||||||
|
CourtFee float64 `json:"court_fee"`
|
||||||
|
ExpertFees float64 `json:"expert_fees"`
|
||||||
|
CourtSubtotal float64 `json:"court_subtotal"`
|
||||||
|
AttorneyBreakdown *AttorneyBreakdown `json:"attorney_breakdown,omitempty"`
|
||||||
|
PatentAttorneyBreakdown *AttorneyBreakdown `json:"patent_attorney_breakdown,omitempty"`
|
||||||
|
AttorneySubtotal float64 `json:"attorney_subtotal"`
|
||||||
|
PatentAttorneySubtotal float64 `json:"patent_attorney_subtotal"`
|
||||||
|
InstanceTotal float64 `json:"instance_total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AttorneyBreakdown details the fee computation for one attorney type.
|
||||||
|
type AttorneyBreakdown struct {
|
||||||
|
BaseFee float64 `json:"base_fee"`
|
||||||
|
VGFactor float64 `json:"vg_factor"`
|
||||||
|
VGFee float64 `json:"vg_fee"`
|
||||||
|
IncreaseFee float64 `json:"increase_fee"`
|
||||||
|
TGFactor float64 `json:"tg_factor"`
|
||||||
|
TGFee float64 `json:"tg_fee"`
|
||||||
|
Pauschale float64 `json:"pauschale"`
|
||||||
|
SubtotalNet float64 `json:"subtotal_net"`
|
||||||
|
VAT float64 `json:"vat"`
|
||||||
|
SubtotalGross float64 `json:"subtotal_gross"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
TotalGross float64 `json:"total_gross"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeeTotal is a labeled total amount.
|
||||||
|
type FeeTotal struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Total float64 `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecurityForCosts is the Prozesskostensicherheit calculation result.
|
||||||
|
type SecurityForCosts struct {
|
||||||
|
Instance1 float64 `json:"instance_1"`
|
||||||
|
Instance2 float64 `json:"instance_2"`
|
||||||
|
NZB float64 `json:"nzb"`
|
||||||
|
SubtotalNet float64 `json:"subtotal_net"`
|
||||||
|
VAT float64 `json:"vat"`
|
||||||
|
TotalGross float64 `json:"total_gross"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeeScheduleInfo describes a fee schedule version for the schedules endpoint.
|
||||||
|
type FeeScheduleInfo struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
ValidFrom string `json:"valid_from"`
|
||||||
|
IsAlias bool `json:"is_alias,omitempty"`
|
||||||
|
AliasOf string `json:"alias_of,omitempty"`
|
||||||
|
}
|
||||||
38
backend/internal/models/invoice.go
Normal file
38
backend/internal/models/invoice.go
Normal 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"`
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ type UserTenant struct {
|
|||||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||||
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
|
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
|
||||||
Role string `db:"role" json:"role"`
|
Role string `db:"role" json:"role"`
|
||||||
|
Email string `db:"email" json:"email"`
|
||||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
24
backend/internal/models/time_entry.go
Normal file
24
backend/internal/models/time_entry.go
Normal 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"`
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *services.CalDAVService, notifSvc *services.NotificationService) http.Handler {
|
func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *services.CalDAVService, notifSvc *services.NotificationService, youpcDB ...*sqlx.DB) http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
@@ -32,11 +32,20 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
|
|||||||
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
|
||||||
if cfg.AnthropicAPIKey != "" {
|
if cfg.AnthropicAPIKey != "" {
|
||||||
aiSvc := services.NewAIService(cfg.AnthropicAPIKey, db)
|
var ydb *sqlx.DB
|
||||||
|
if len(youpcDB) > 0 {
|
||||||
|
ydb = youpcDB[0]
|
||||||
|
}
|
||||||
|
aiSvc := services.NewAIService(cfg.AnthropicAPIKey, db, ydb)
|
||||||
aiH = handlers.NewAIHandler(aiSvc)
|
aiH = handlers.NewAIHandler(aiSvc)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +76,17 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
|
|||||||
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)
|
||||||
|
|
||||||
|
// Fee calculator (public — no auth required, pure computation)
|
||||||
|
feeCalc := services.NewFeeCalculator()
|
||||||
|
feeCalcH := handlers.NewFeeCalculatorHandler(feeCalc)
|
||||||
|
mux.HandleFunc("POST /api/fees/calculate", feeCalcH.Calculate)
|
||||||
|
mux.HandleFunc("GET /api/fees/schedules", feeCalcH.Schedules)
|
||||||
|
|
||||||
// Public routes
|
// Public routes
|
||||||
mux.HandleFunc("GET /health", handleHealth(db))
|
mux.HandleFunc("GET /health", handleHealth(db))
|
||||||
@@ -75,6 +95,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
|
|||||||
api := http.NewServeMux()
|
api := http.NewServeMux()
|
||||||
|
|
||||||
// Tenant management (no tenant resolver — these operate across tenants)
|
// 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("POST /api/tenants", tenantH.CreateTenant)
|
||||||
api.HandleFunc("GET /api/tenants", tenantH.ListTenants)
|
api.HandleFunc("GET /api/tenants", tenantH.ListTenants)
|
||||||
api.HandleFunc("GET /api/tenants/{id}", tenantH.GetTenant)
|
api.HandleFunc("GET /api/tenants/{id}", tenantH.GetTenant)
|
||||||
@@ -164,7 +185,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
|
|||||||
scoped.HandleFunc("GET /api/dashboard", dashboardH.Get)
|
scoped.HandleFunc("GET /api/dashboard", dashboardH.Get)
|
||||||
|
|
||||||
// Audit log
|
// 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)
|
// Documents — all can upload, delete checked in handler (own vs all)
|
||||||
scoped.HandleFunc("GET /api/cases/{id}/documents", docH.ListByCase)
|
scoped.HandleFunc("GET /api/cases/{id}/documents", docH.ListByCase)
|
||||||
@@ -178,6 +199,9 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
|
|||||||
aiLimiter := middleware.NewTokenBucket(5.0/60.0, 10)
|
aiLimiter := middleware.NewTokenBucket(5.0/60.0, 10)
|
||||||
scoped.HandleFunc("POST /api/ai/extract-deadlines", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.ExtractDeadlines)))
|
scoped.HandleFunc("POST /api/ai/extract-deadlines", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.ExtractDeadlines)))
|
||||||
scoped.HandleFunc("POST /api/ai/summarize-case", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.SummarizeCase)))
|
scoped.HandleFunc("POST /api/ai/summarize-case", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.SummarizeCase)))
|
||||||
|
scoped.HandleFunc("POST /api/ai/draft-document", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.DraftDocument)))
|
||||||
|
scoped.HandleFunc("POST /api/ai/case-strategy", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.CaseStrategy)))
|
||||||
|
scoped.HandleFunc("POST /api/ai/similar-cases", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.SimilarCases)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
@@ -197,6 +221,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))
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/anthropics/anthropic-sdk-go"
|
"github.com/anthropics/anthropic-sdk-go"
|
||||||
@@ -18,11 +19,12 @@ import (
|
|||||||
type AIService struct {
|
type AIService struct {
|
||||||
client anthropic.Client
|
client anthropic.Client
|
||||||
db *sqlx.DB
|
db *sqlx.DB
|
||||||
|
youpcDB *sqlx.DB // read-only connection to youpc.org for similar case finder (may be nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAIService(apiKey string, db *sqlx.DB) *AIService {
|
func NewAIService(apiKey string, db *sqlx.DB, youpcDB *sqlx.DB) *AIService {
|
||||||
client := anthropic.NewClient(option.WithAPIKey(apiKey))
|
client := anthropic.NewClient(option.WithAPIKey(apiKey))
|
||||||
return &AIService{client: client, db: db}
|
return &AIService{client: client, db: db, youpcDB: youpcDB}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtractedDeadline represents a deadline extracted by AI from a document.
|
// ExtractedDeadline represents a deadline extracted by AI from a document.
|
||||||
@@ -281,3 +283,726 @@ func (s *AIService) SummarizeCase(ctx context.Context, tenantID, caseID uuid.UUI
|
|||||||
|
|
||||||
return summary, nil
|
return summary, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Document Drafting ---
|
||||||
|
|
||||||
|
// DocumentDraft represents an AI-generated document draft.
|
||||||
|
type DocumentDraft struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// templateDescriptions maps template type IDs to descriptions for Claude.
|
||||||
|
var templateDescriptions = map[string]string{
|
||||||
|
"klageschrift": "Klageschrift (Statement of Claim) — formal complaint initiating legal proceedings",
|
||||||
|
"klageerwiderung": "Klageerwiderung (Statement of Defence) — formal response to a statement of claim",
|
||||||
|
"abmahnung": "Abmahnung (Cease and Desist Letter) — formal warning letter demanding cessation of an activity",
|
||||||
|
"schriftsatz": "Schriftsatz (Legal Brief) — formal legal submission to the court",
|
||||||
|
"berufung": "Berufungsschrift (Appeal Brief) — formal appeal against a court decision",
|
||||||
|
"antrag": "Antrag (Motion/Application) — formal application or motion to the court",
|
||||||
|
"stellungnahme": "Stellungnahme (Statement/Position Paper) — formal response or position paper",
|
||||||
|
"gutachten": "Gutachten (Legal Opinion/Expert Report) — detailed legal analysis or opinion",
|
||||||
|
"vertrag": "Vertrag (Contract/Agreement) — legal contract or agreement between parties",
|
||||||
|
"vollmacht": "Vollmacht (Power of Attorney) — formal authorization document",
|
||||||
|
"upc_claim": "UPC Statement of Claim — claim filed at the Unified Patent Court",
|
||||||
|
"upc_defence": "UPC Statement of Defence — defence filed at the Unified Patent Court",
|
||||||
|
"upc_counterclaim": "UPC Counterclaim for Revocation — counterclaim for patent revocation at the UPC",
|
||||||
|
"upc_injunction": "UPC Application for Provisional Measures — application for injunctive relief at the UPC",
|
||||||
|
}
|
||||||
|
|
||||||
|
const draftDocumentSystemPrompt = `You are an expert legal document drafter for German and UPC (Unified Patent Court) patent litigation.
|
||||||
|
|
||||||
|
You draft professional legal documents in the requested language, following proper legal formatting conventions.
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
- Use proper legal structure with numbered sections and paragraphs
|
||||||
|
- Include standard legal formalities (headers, salutations, signatures block)
|
||||||
|
- Reference relevant legal provisions (BGB, ZPO, UPC Rules of Procedure, etc.)
|
||||||
|
- Use precise legal terminology appropriate for the jurisdiction
|
||||||
|
- Include placeholders in [BRACKETS] for information that needs to be filled in
|
||||||
|
- Base the content on the provided case data and instructions
|
||||||
|
- Output the document as clean text with proper formatting`
|
||||||
|
|
||||||
|
// DraftDocument generates an AI-drafted legal document based on case data and a template type.
|
||||||
|
func (s *AIService) DraftDocument(ctx context.Context, tenantID, caseID uuid.UUID, templateType, instructions, language string) (*DocumentDraft, error) {
|
||||||
|
if language == "" {
|
||||||
|
language = "de"
|
||||||
|
}
|
||||||
|
|
||||||
|
langLabel := "German"
|
||||||
|
if language == "en" {
|
||||||
|
langLabel = "English"
|
||||||
|
} else if language == "fr" {
|
||||||
|
langLabel = "French"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load case data
|
||||||
|
var c models.Case
|
||||||
|
if err := s.db.GetContext(ctx, &c,
|
||||||
|
"SELECT * FROM cases WHERE id = $1 AND tenant_id = $2", caseID, tenantID); err != nil {
|
||||||
|
return nil, fmt.Errorf("loading case: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load parties
|
||||||
|
var parties []models.Party
|
||||||
|
_ = s.db.SelectContext(ctx, &parties,
|
||||||
|
"SELECT * FROM parties WHERE case_id = $1 AND tenant_id = $2", caseID, tenantID)
|
||||||
|
|
||||||
|
// Load recent events
|
||||||
|
var events []models.CaseEvent
|
||||||
|
_ = s.db.SelectContext(ctx, &events,
|
||||||
|
"SELECT * FROM case_events WHERE case_id = $1 AND tenant_id = $2 ORDER BY created_at DESC LIMIT 15",
|
||||||
|
caseID, tenantID)
|
||||||
|
|
||||||
|
// Load active deadlines
|
||||||
|
var deadlines []models.Deadline
|
||||||
|
_ = s.db.SelectContext(ctx, &deadlines,
|
||||||
|
"SELECT * FROM deadlines WHERE case_id = $1 AND tenant_id = $2 AND status = 'active' ORDER BY due_date ASC LIMIT 10",
|
||||||
|
caseID, tenantID)
|
||||||
|
|
||||||
|
// Load documents metadata for context
|
||||||
|
var documents []models.Document
|
||||||
|
_ = s.db.SelectContext(ctx, &documents,
|
||||||
|
"SELECT id, title, doc_type, created_at FROM documents WHERE case_id = $1 AND tenant_id = $2 ORDER BY created_at DESC LIMIT 10",
|
||||||
|
caseID, tenantID)
|
||||||
|
|
||||||
|
// Build context
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(fmt.Sprintf("Case: %s — %s\nStatus: %s\n", c.CaseNumber, c.Title, c.Status))
|
||||||
|
if c.Court != nil {
|
||||||
|
b.WriteString(fmt.Sprintf("Court: %s\n", *c.Court))
|
||||||
|
}
|
||||||
|
if c.CourtRef != nil {
|
||||||
|
b.WriteString(fmt.Sprintf("Court Reference: %s\n", *c.CourtRef))
|
||||||
|
}
|
||||||
|
if c.CaseType != nil {
|
||||||
|
b.WriteString(fmt.Sprintf("Type: %s\n", *c.CaseType))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parties) > 0 {
|
||||||
|
b.WriteString("\nParties:\n")
|
||||||
|
for _, p := range parties {
|
||||||
|
role := "unknown role"
|
||||||
|
if p.Role != nil {
|
||||||
|
role = *p.Role
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf("- %s (%s)", p.Name, role))
|
||||||
|
if p.Representative != nil {
|
||||||
|
b.WriteString(fmt.Sprintf(" — represented by %s", *p.Representative))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(events) > 0 {
|
||||||
|
b.WriteString("\nRecent Events:\n")
|
||||||
|
for _, e := range events {
|
||||||
|
b.WriteString(fmt.Sprintf("- [%s] %s", e.CreatedAt.Format("2006-01-02"), e.Title))
|
||||||
|
if e.Description != nil {
|
||||||
|
b.WriteString(fmt.Sprintf(": %s", *e.Description))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(deadlines) > 0 {
|
||||||
|
b.WriteString("\nUpcoming Deadlines:\n")
|
||||||
|
for _, d := range deadlines {
|
||||||
|
b.WriteString(fmt.Sprintf("- %s: due %s\n", d.Title, d.DueDate))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templateDesc, ok := templateDescriptions[templateType]
|
||||||
|
if !ok {
|
||||||
|
templateDesc = templateType
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt := fmt.Sprintf(`Draft a %s for this case in %s.
|
||||||
|
|
||||||
|
Document type: %s
|
||||||
|
|
||||||
|
Case context:
|
||||||
|
%s
|
||||||
|
Additional instructions from the lawyer:
|
||||||
|
%s
|
||||||
|
|
||||||
|
Generate the complete document now.`, templateDesc, langLabel, templateDesc, b.String(), instructions)
|
||||||
|
|
||||||
|
msg, err := s.client.Messages.New(ctx, anthropic.MessageNewParams{
|
||||||
|
Model: anthropic.ModelClaudeSonnet4_20250514,
|
||||||
|
MaxTokens: 8192,
|
||||||
|
System: []anthropic.TextBlockParam{
|
||||||
|
{Text: draftDocumentSystemPrompt},
|
||||||
|
},
|
||||||
|
Messages: []anthropic.MessageParam{
|
||||||
|
anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("claude API call: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var content string
|
||||||
|
for _, block := range msg.Content {
|
||||||
|
if block.Type == "text" {
|
||||||
|
content += block.Text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if content == "" {
|
||||||
|
return nil, fmt.Errorf("empty response from Claude")
|
||||||
|
}
|
||||||
|
|
||||||
|
title := fmt.Sprintf("%s — %s", templateDesc, c.CaseNumber)
|
||||||
|
return &DocumentDraft{
|
||||||
|
Title: title,
|
||||||
|
Content: content,
|
||||||
|
Language: language,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Case Strategy ---
|
||||||
|
|
||||||
|
// StrategyRecommendation represents an AI-generated case strategy analysis.
|
||||||
|
type StrategyRecommendation struct {
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
NextSteps []StrategyStep `json:"next_steps"`
|
||||||
|
RiskAssessment []RiskItem `json:"risk_assessment"`
|
||||||
|
Timeline []TimelineItem `json:"timeline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StrategyStep struct {
|
||||||
|
Priority string `json:"priority"` // high, medium, low
|
||||||
|
Action string `json:"action"`
|
||||||
|
Reasoning string `json:"reasoning"`
|
||||||
|
Deadline string `json:"deadline,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RiskItem struct {
|
||||||
|
Level string `json:"level"` // high, medium, low
|
||||||
|
Risk string `json:"risk"`
|
||||||
|
Mitigation string `json:"mitigation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimelineItem struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Event string `json:"event"`
|
||||||
|
Importance string `json:"importance"` // critical, important, routine
|
||||||
|
}
|
||||||
|
|
||||||
|
type strategyToolInput struct {
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
NextSteps []StrategyStep `json:"next_steps"`
|
||||||
|
RiskAssessment []RiskItem `json:"risk_assessment"`
|
||||||
|
Timeline []TimelineItem `json:"timeline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var caseStrategyTool = anthropic.ToolParam{
|
||||||
|
Name: "case_strategy",
|
||||||
|
Description: anthropic.String("Provide strategic case analysis with next steps, risk assessment, and timeline optimization."),
|
||||||
|
InputSchema: anthropic.ToolInputSchemaParam{
|
||||||
|
Properties: map[string]any{
|
||||||
|
"summary": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Executive summary of the case situation and strategic outlook (2-4 sentences)",
|
||||||
|
},
|
||||||
|
"next_steps": map[string]any{
|
||||||
|
"type": "array",
|
||||||
|
"description": "Recommended next actions in priority order",
|
||||||
|
"items": map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"priority": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"enum": []string{"high", "medium", "low"},
|
||||||
|
},
|
||||||
|
"action": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Specific recommended action",
|
||||||
|
},
|
||||||
|
"reasoning": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Why this action is recommended",
|
||||||
|
},
|
||||||
|
"deadline": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Suggested deadline in YYYY-MM-DD format, if applicable",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"priority", "action", "reasoning"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"risk_assessment": map[string]any{
|
||||||
|
"type": "array",
|
||||||
|
"description": "Key risks and mitigation strategies",
|
||||||
|
"items": map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"level": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"enum": []string{"high", "medium", "low"},
|
||||||
|
},
|
||||||
|
"risk": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Description of the risk",
|
||||||
|
},
|
||||||
|
"mitigation": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Recommended mitigation strategy",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"level", "risk", "mitigation"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"timeline": map[string]any{
|
||||||
|
"type": "array",
|
||||||
|
"description": "Optimized timeline of upcoming milestones and events",
|
||||||
|
"items": map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"date": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Date in YYYY-MM-DD format",
|
||||||
|
},
|
||||||
|
"event": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Description of the milestone or event",
|
||||||
|
},
|
||||||
|
"importance": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"enum": []string{"critical", "important", "routine"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"date", "event", "importance"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Required: []string{"summary", "next_steps", "risk_assessment", "timeline"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const caseStrategySystemPrompt = `You are a senior litigation strategist specializing in German law and UPC (Unified Patent Court) patent proceedings.
|
||||||
|
|
||||||
|
Analyze the case thoroughly and provide:
|
||||||
|
1. An executive summary of the current strategic position
|
||||||
|
2. Prioritized next steps with clear reasoning
|
||||||
|
3. Risk assessment with mitigation strategies
|
||||||
|
4. An optimized timeline of upcoming milestones
|
||||||
|
|
||||||
|
Consider:
|
||||||
|
- Procedural deadlines and their implications
|
||||||
|
- Strength of the parties' positions based on available information
|
||||||
|
- Potential settlement opportunities
|
||||||
|
- Cost-efficiency of different strategic approaches
|
||||||
|
- UPC-specific procedural peculiarities if applicable (bifurcation, preliminary injunctions, etc.)
|
||||||
|
|
||||||
|
Be practical and actionable. Avoid generic advice — tailor recommendations to the specific case data provided.`
|
||||||
|
|
||||||
|
// CaseStrategy analyzes a case and returns strategic recommendations.
|
||||||
|
func (s *AIService) CaseStrategy(ctx context.Context, tenantID, caseID uuid.UUID) (*StrategyRecommendation, error) {
|
||||||
|
// Load case
|
||||||
|
var c models.Case
|
||||||
|
if err := s.db.GetContext(ctx, &c,
|
||||||
|
"SELECT * FROM cases WHERE id = $1 AND tenant_id = $2", caseID, tenantID); err != nil {
|
||||||
|
return nil, fmt.Errorf("loading case: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load parties
|
||||||
|
var parties []models.Party
|
||||||
|
_ = s.db.SelectContext(ctx, &parties,
|
||||||
|
"SELECT * FROM parties WHERE case_id = $1 AND tenant_id = $2", caseID, tenantID)
|
||||||
|
|
||||||
|
// Load all events
|
||||||
|
var events []models.CaseEvent
|
||||||
|
_ = s.db.SelectContext(ctx, &events,
|
||||||
|
"SELECT * FROM case_events WHERE case_id = $1 AND tenant_id = $2 ORDER BY created_at DESC LIMIT 25",
|
||||||
|
caseID, tenantID)
|
||||||
|
|
||||||
|
// Load all deadlines (active + completed for context)
|
||||||
|
var deadlines []models.Deadline
|
||||||
|
_ = s.db.SelectContext(ctx, &deadlines,
|
||||||
|
"SELECT * FROM deadlines WHERE case_id = $1 AND tenant_id = $2 ORDER BY due_date ASC LIMIT 20",
|
||||||
|
caseID, tenantID)
|
||||||
|
|
||||||
|
// Load documents metadata
|
||||||
|
var documents []models.Document
|
||||||
|
_ = s.db.SelectContext(ctx, &documents,
|
||||||
|
"SELECT id, title, doc_type, created_at FROM documents WHERE case_id = $1 AND tenant_id = $2 ORDER BY created_at DESC LIMIT 15",
|
||||||
|
caseID, tenantID)
|
||||||
|
|
||||||
|
// Build comprehensive context
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(fmt.Sprintf("Case: %s — %s\nStatus: %s\n", c.CaseNumber, c.Title, c.Status))
|
||||||
|
if c.Court != nil {
|
||||||
|
b.WriteString(fmt.Sprintf("Court: %s\n", *c.Court))
|
||||||
|
}
|
||||||
|
if c.CourtRef != nil {
|
||||||
|
b.WriteString(fmt.Sprintf("Court Reference: %s\n", *c.CourtRef))
|
||||||
|
}
|
||||||
|
if c.CaseType != nil {
|
||||||
|
b.WriteString(fmt.Sprintf("Type: %s\n", *c.CaseType))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parties) > 0 {
|
||||||
|
b.WriteString("\nParties:\n")
|
||||||
|
for _, p := range parties {
|
||||||
|
role := "unknown"
|
||||||
|
if p.Role != nil {
|
||||||
|
role = *p.Role
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf("- %s (%s)", p.Name, role))
|
||||||
|
if p.Representative != nil {
|
||||||
|
b.WriteString(fmt.Sprintf(" — represented by %s", *p.Representative))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(events) > 0 {
|
||||||
|
b.WriteString("\nCase Events (chronological):\n")
|
||||||
|
for _, e := range events {
|
||||||
|
b.WriteString(fmt.Sprintf("- [%s] %s", e.CreatedAt.Format("2006-01-02"), e.Title))
|
||||||
|
if e.Description != nil {
|
||||||
|
b.WriteString(fmt.Sprintf(": %s", *e.Description))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(deadlines) > 0 {
|
||||||
|
b.WriteString("\nDeadlines:\n")
|
||||||
|
for _, d := range deadlines {
|
||||||
|
b.WriteString(fmt.Sprintf("- %s: due %s (status: %s)\n", d.Title, d.DueDate, d.Status))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(documents) > 0 {
|
||||||
|
b.WriteString("\nDocuments on file:\n")
|
||||||
|
for _, d := range documents {
|
||||||
|
docType := ""
|
||||||
|
if d.DocType != nil {
|
||||||
|
docType = fmt.Sprintf(" [%s]", *d.DocType)
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf("- %s%s (%s)\n", d.Title, docType, d.CreatedAt.Format("2006-01-02")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
msg, err := s.client.Messages.New(ctx, anthropic.MessageNewParams{
|
||||||
|
Model: anthropic.ModelClaudeOpus4_6,
|
||||||
|
MaxTokens: 4096,
|
||||||
|
System: []anthropic.TextBlockParam{
|
||||||
|
{Text: caseStrategySystemPrompt},
|
||||||
|
},
|
||||||
|
Messages: []anthropic.MessageParam{
|
||||||
|
anthropic.NewUserMessage(anthropic.NewTextBlock("Analyze this case and provide strategic recommendations:\n\n" + b.String())),
|
||||||
|
},
|
||||||
|
Tools: []anthropic.ToolUnionParam{
|
||||||
|
{OfTool: &caseStrategyTool},
|
||||||
|
},
|
||||||
|
ToolChoice: anthropic.ToolChoiceParamOfTool("case_strategy"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("claude API call: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, block := range msg.Content {
|
||||||
|
if block.Type == "tool_use" && block.Name == "case_strategy" {
|
||||||
|
var input strategyToolInput
|
||||||
|
if err := json.Unmarshal(block.Input, &input); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing strategy output: %w", err)
|
||||||
|
}
|
||||||
|
result := &StrategyRecommendation{
|
||||||
|
Summary: input.Summary,
|
||||||
|
NextSteps: input.NextSteps,
|
||||||
|
RiskAssessment: input.RiskAssessment,
|
||||||
|
Timeline: input.Timeline,
|
||||||
|
}
|
||||||
|
// Cache in database
|
||||||
|
strategyJSON, _ := json.Marshal(result)
|
||||||
|
_, _ = s.db.ExecContext(ctx,
|
||||||
|
"UPDATE cases SET ai_summary = $1, updated_at = $2 WHERE id = $3 AND tenant_id = $4",
|
||||||
|
string(strategyJSON), time.Now(), caseID, tenantID)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no tool_use block in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Similar Case Finder ---
|
||||||
|
|
||||||
|
// SimilarCase represents a UPC case found to be similar.
|
||||||
|
type SimilarCase struct {
|
||||||
|
CaseNumber string `json:"case_number"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Court string `json:"court"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
Relevance float64 `json:"relevance"` // 0.0-1.0
|
||||||
|
Explanation string `json:"explanation"` // why this case is similar
|
||||||
|
KeyHoldings string `json:"key_holdings"` // relevant holdings
|
||||||
|
URL string `json:"url,omitempty"` // link to youpc.org
|
||||||
|
}
|
||||||
|
|
||||||
|
// youpcCase represents a case from the youpc.org database.
|
||||||
|
type youpcCase struct {
|
||||||
|
ID string `db:"id" json:"id"`
|
||||||
|
CaseNumber *string `db:"case_number" json:"case_number"`
|
||||||
|
Title *string `db:"title" json:"title"`
|
||||||
|
Court *string `db:"court" json:"court"`
|
||||||
|
DecisionDate *string `db:"decision_date" json:"decision_date"`
|
||||||
|
CaseType *string `db:"case_type" json:"case_type"`
|
||||||
|
Outcome *string `db:"outcome" json:"outcome"`
|
||||||
|
PatentNumbers *string `db:"patent_numbers" json:"patent_numbers"`
|
||||||
|
Summary *string `db:"summary" json:"summary"`
|
||||||
|
Claimant *string `db:"claimant" json:"claimant"`
|
||||||
|
Defendant *string `db:"defendant" json:"defendant"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type similarCaseToolInput struct {
|
||||||
|
Cases []struct {
|
||||||
|
CaseID string `json:"case_id"`
|
||||||
|
Relevance float64 `json:"relevance"`
|
||||||
|
Explanation string `json:"explanation"`
|
||||||
|
KeyHoldings string `json:"key_holdings"`
|
||||||
|
} `json:"cases"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var similarCaseTool = anthropic.ToolParam{
|
||||||
|
Name: "rank_similar_cases",
|
||||||
|
Description: anthropic.String("Rank the provided UPC cases by relevance to the query case and explain why each is similar."),
|
||||||
|
InputSchema: anthropic.ToolInputSchemaParam{
|
||||||
|
Properties: map[string]any{
|
||||||
|
"cases": map[string]any{
|
||||||
|
"type": "array",
|
||||||
|
"description": "UPC cases ranked by relevance (most relevant first)",
|
||||||
|
"items": map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"case_id": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "The ID of the UPC case from the provided list",
|
||||||
|
},
|
||||||
|
"relevance": map[string]any{
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 1,
|
||||||
|
"description": "Relevance score from 0.0 to 1.0",
|
||||||
|
},
|
||||||
|
"explanation": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Why this case is relevant — what legal issues, parties, patents, or procedural aspects are similar",
|
||||||
|
},
|
||||||
|
"key_holdings": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Key holdings or legal principles from this case that are relevant",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"case_id", "relevance", "explanation", "key_holdings"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Required: []string{"cases"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const similarCaseSystemPrompt = `You are a UPC (Unified Patent Court) case law expert.
|
||||||
|
|
||||||
|
Given a case description and a list of UPC cases from the database, rank the cases by relevance and explain why each one is similar or relevant.
|
||||||
|
|
||||||
|
Consider:
|
||||||
|
- Similar patents or technology areas
|
||||||
|
- Same parties or representatives
|
||||||
|
- Similar legal issues (infringement, validity, injunctions, etc.)
|
||||||
|
- Similar procedural situations
|
||||||
|
- Relevant legal principles that could apply
|
||||||
|
|
||||||
|
Only include cases that are genuinely relevant (relevance > 0.3). Order by relevance descending.`
|
||||||
|
|
||||||
|
// FindSimilarCases searches the youpc.org database for similar UPC cases.
|
||||||
|
func (s *AIService) FindSimilarCases(ctx context.Context, tenantID, caseID uuid.UUID, description string) ([]SimilarCase, error) {
|
||||||
|
if s.youpcDB == nil {
|
||||||
|
return nil, fmt.Errorf("youpc.org database not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build query context from the case (if provided) or description
|
||||||
|
var queryText string
|
||||||
|
if caseID != uuid.Nil {
|
||||||
|
var c models.Case
|
||||||
|
if err := s.db.GetContext(ctx, &c,
|
||||||
|
"SELECT * FROM cases WHERE id = $1 AND tenant_id = $2", caseID, tenantID); err != nil {
|
||||||
|
return nil, fmt.Errorf("loading case: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var parties []models.Party
|
||||||
|
_ = s.db.SelectContext(ctx, &parties,
|
||||||
|
"SELECT * FROM parties WHERE case_id = $1 AND tenant_id = $2", caseID, tenantID)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(fmt.Sprintf("Case: %s — %s\n", c.CaseNumber, c.Title))
|
||||||
|
if c.CaseType != nil {
|
||||||
|
b.WriteString(fmt.Sprintf("Type: %s\n", *c.CaseType))
|
||||||
|
}
|
||||||
|
if c.Court != nil {
|
||||||
|
b.WriteString(fmt.Sprintf("Court: %s\n", *c.Court))
|
||||||
|
}
|
||||||
|
for _, p := range parties {
|
||||||
|
role := ""
|
||||||
|
if p.Role != nil {
|
||||||
|
role = *p.Role
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf("Party: %s (%s)\n", p.Name, role))
|
||||||
|
}
|
||||||
|
if description != "" {
|
||||||
|
b.WriteString(fmt.Sprintf("\nAdditional context: %s\n", description))
|
||||||
|
}
|
||||||
|
queryText = b.String()
|
||||||
|
} else if description != "" {
|
||||||
|
queryText = description
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("either case_id or description must be provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query youpc.org database for candidate cases
|
||||||
|
// Search by text similarity across case titles, summaries, party names
|
||||||
|
var candidates []youpcCase
|
||||||
|
err := s.youpcDB.SelectContext(ctx, &candidates, `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
case_number,
|
||||||
|
title,
|
||||||
|
court,
|
||||||
|
decision_date,
|
||||||
|
case_type,
|
||||||
|
outcome,
|
||||||
|
patent_numbers,
|
||||||
|
summary,
|
||||||
|
claimant,
|
||||||
|
defendant
|
||||||
|
FROM mlex.cases
|
||||||
|
ORDER BY decision_date DESC NULLS LAST
|
||||||
|
LIMIT 50
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying youpc.org cases: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return []SimilarCase{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build candidate list for Claude
|
||||||
|
var candidateText strings.Builder
|
||||||
|
for _, c := range candidates {
|
||||||
|
candidateText.WriteString(fmt.Sprintf("ID: %s\n", c.ID))
|
||||||
|
if c.CaseNumber != nil {
|
||||||
|
candidateText.WriteString(fmt.Sprintf("Case Number: %s\n", *c.CaseNumber))
|
||||||
|
}
|
||||||
|
if c.Title != nil {
|
||||||
|
candidateText.WriteString(fmt.Sprintf("Title: %s\n", *c.Title))
|
||||||
|
}
|
||||||
|
if c.Court != nil {
|
||||||
|
candidateText.WriteString(fmt.Sprintf("Court: %s\n", *c.Court))
|
||||||
|
}
|
||||||
|
if c.DecisionDate != nil {
|
||||||
|
candidateText.WriteString(fmt.Sprintf("Decision Date: %s\n", *c.DecisionDate))
|
||||||
|
}
|
||||||
|
if c.CaseType != nil {
|
||||||
|
candidateText.WriteString(fmt.Sprintf("Type: %s\n", *c.CaseType))
|
||||||
|
}
|
||||||
|
if c.Outcome != nil {
|
||||||
|
candidateText.WriteString(fmt.Sprintf("Outcome: %s\n", *c.Outcome))
|
||||||
|
}
|
||||||
|
if c.PatentNumbers != nil {
|
||||||
|
candidateText.WriteString(fmt.Sprintf("Patents: %s\n", *c.PatentNumbers))
|
||||||
|
}
|
||||||
|
if c.Claimant != nil {
|
||||||
|
candidateText.WriteString(fmt.Sprintf("Claimant: %s\n", *c.Claimant))
|
||||||
|
}
|
||||||
|
if c.Defendant != nil {
|
||||||
|
candidateText.WriteString(fmt.Sprintf("Defendant: %s\n", *c.Defendant))
|
||||||
|
}
|
||||||
|
if c.Summary != nil {
|
||||||
|
candidateText.WriteString(fmt.Sprintf("Summary: %s\n", *c.Summary))
|
||||||
|
}
|
||||||
|
candidateText.WriteString("---\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt := fmt.Sprintf(`Find UPC cases relevant to this matter:
|
||||||
|
|
||||||
|
%s
|
||||||
|
|
||||||
|
Here are the UPC cases from the database to evaluate:
|
||||||
|
|
||||||
|
%s
|
||||||
|
|
||||||
|
Rank only the genuinely relevant cases by similarity.`, queryText, candidateText.String())
|
||||||
|
|
||||||
|
msg, err := s.client.Messages.New(ctx, anthropic.MessageNewParams{
|
||||||
|
Model: anthropic.ModelClaudeSonnet4_20250514,
|
||||||
|
MaxTokens: 4096,
|
||||||
|
System: []anthropic.TextBlockParam{
|
||||||
|
{Text: similarCaseSystemPrompt},
|
||||||
|
},
|
||||||
|
Messages: []anthropic.MessageParam{
|
||||||
|
anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)),
|
||||||
|
},
|
||||||
|
Tools: []anthropic.ToolUnionParam{
|
||||||
|
{OfTool: &similarCaseTool},
|
||||||
|
},
|
||||||
|
ToolChoice: anthropic.ToolChoiceParamOfTool("rank_similar_cases"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("claude API call: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, block := range msg.Content {
|
||||||
|
if block.Type == "tool_use" && block.Name == "rank_similar_cases" {
|
||||||
|
var input similarCaseToolInput
|
||||||
|
if err := json.Unmarshal(block.Input, &input); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing similar cases output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build lookup map for candidate data
|
||||||
|
candidateMap := make(map[string]youpcCase)
|
||||||
|
for _, c := range candidates {
|
||||||
|
candidateMap[c.ID] = c
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []SimilarCase
|
||||||
|
for _, ranked := range input.Cases {
|
||||||
|
if ranked.Relevance < 0.3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c, ok := candidateMap[ranked.CaseID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sc := SimilarCase{
|
||||||
|
Relevance: ranked.Relevance,
|
||||||
|
Explanation: ranked.Explanation,
|
||||||
|
KeyHoldings: ranked.KeyHoldings,
|
||||||
|
}
|
||||||
|
if c.CaseNumber != nil {
|
||||||
|
sc.CaseNumber = *c.CaseNumber
|
||||||
|
}
|
||||||
|
if c.Title != nil {
|
||||||
|
sc.Title = *c.Title
|
||||||
|
}
|
||||||
|
if c.Court != nil {
|
||||||
|
sc.Court = *c.Court
|
||||||
|
}
|
||||||
|
if c.DecisionDate != nil {
|
||||||
|
sc.Date = *c.DecisionDate
|
||||||
|
}
|
||||||
|
if c.CaseNumber != nil {
|
||||||
|
sc.URL = fmt.Sprintf("https://youpc.org/cases/%s", *c.CaseNumber)
|
||||||
|
}
|
||||||
|
results = append(results, sc)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no tool_use block in response")
|
||||||
|
}
|
||||||
|
|||||||
88
backend/internal/services/billing_rate_service.go
Normal file
88
backend/internal/services/billing_rate_service.go
Normal 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
|
||||||
|
}
|
||||||
393
backend/internal/services/fee_calculator.go
Normal file
393
backend/internal/services/fee_calculator.go
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FeeCalculator computes patent litigation costs based on GKG/RVG fee schedules.
|
||||||
|
type FeeCalculator struct{}
|
||||||
|
|
||||||
|
func NewFeeCalculator() *FeeCalculator {
|
||||||
|
return &FeeCalculator{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveSchedule returns the actual schedule data, resolving aliases.
|
||||||
|
func resolveSchedule(version string) (*feeScheduleData, error) {
|
||||||
|
sched, ok := feeSchedules[version]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unknown fee schedule: %s", version)
|
||||||
|
}
|
||||||
|
if sched.AliasOf != "" {
|
||||||
|
target, ok := feeSchedules[sched.AliasOf]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("alias target not found: %s", sched.AliasOf)
|
||||||
|
}
|
||||||
|
return target, nil
|
||||||
|
}
|
||||||
|
return sched, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateBaseFee computes the base 1.0x fee using the step-based accumulator.
|
||||||
|
// feeType must be "GKG" or "RVG".
|
||||||
|
//
|
||||||
|
// Algorithm: The first bracket defines the minimum fee (one step). Each subsequent
|
||||||
|
// bracket accumulates ceil(portion / stepSize) * increment for the portion of the
|
||||||
|
// Streitwert that falls within that bracket's range.
|
||||||
|
func (fc *FeeCalculator) CalculateBaseFee(streitwert float64, version string, feeType string) (float64, error) {
|
||||||
|
sched, err := resolveSchedule(version)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if streitwert <= 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
brackets := sched.Brackets
|
||||||
|
fee := 0.0
|
||||||
|
prevUpper := 0.0
|
||||||
|
|
||||||
|
for i, b := range brackets {
|
||||||
|
increment := b.GKGIncrement
|
||||||
|
if feeType == "RVG" {
|
||||||
|
increment = b.RVGIncrement
|
||||||
|
}
|
||||||
|
|
||||||
|
if i == 0 {
|
||||||
|
// First bracket: minimum fee = one increment
|
||||||
|
fee = increment
|
||||||
|
prevUpper = b.UpperBound
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if streitwert <= prevUpper {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
portion := math.Min(streitwert, b.UpperBound) - prevUpper
|
||||||
|
if portion <= 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
steps := math.Ceil(portion / b.StepSize)
|
||||||
|
fee += steps * increment
|
||||||
|
prevUpper = b.UpperBound
|
||||||
|
}
|
||||||
|
|
||||||
|
return fee, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateCourtFees computes court fees for a given instance type.
|
||||||
|
// Returns the base fee, multiplier, computed court fee, and the fee source.
|
||||||
|
func (fc *FeeCalculator) CalculateCourtFees(streitwert float64, instanceType string, version string) (baseFee, multiplier, courtFee float64, source string, err error) {
|
||||||
|
cfg, ok := instanceConfigs[instanceType]
|
||||||
|
if !ok {
|
||||||
|
return 0, 0, 0, "", fmt.Errorf("unknown instance type: %s", instanceType)
|
||||||
|
}
|
||||||
|
|
||||||
|
source = cfg.CourtSource
|
||||||
|
|
||||||
|
if cfg.CourtSource == "fixed" {
|
||||||
|
return 0, 0, cfg.FixedCourtFee, source, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both GKG and PatKostG use the same step-based GKG bracket lookup
|
||||||
|
baseFee, err = fc.CalculateBaseFee(streitwert, version, "GKG")
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, 0, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
multiplier = cfg.CourtFactor
|
||||||
|
courtFee = baseFee * multiplier
|
||||||
|
return baseFee, multiplier, courtFee, source, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateAttorneyFees computes the fees for one attorney type at one instance.
|
||||||
|
// Returns nil if numAttorneys <= 0.
|
||||||
|
func (fc *FeeCalculator) CalculateAttorneyFees(
|
||||||
|
streitwert float64,
|
||||||
|
version string,
|
||||||
|
vgFactor, tgFactor float64,
|
||||||
|
numAttorneys, numClients int,
|
||||||
|
oralHearing bool,
|
||||||
|
vatRate float64,
|
||||||
|
) (*models.AttorneyBreakdown, error) {
|
||||||
|
if numAttorneys <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
baseFee, err := fc.CalculateBaseFee(streitwert, version, "RVG")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
vgFee := vgFactor * baseFee
|
||||||
|
|
||||||
|
// Increase fee (Nr. 1008 VV RVG) for multiple clients
|
||||||
|
increaseFee := 0.0
|
||||||
|
if numClients > 1 {
|
||||||
|
factor := float64(numClients-1) * erhoehungsfaktor
|
||||||
|
if factor > erhoehungsfaktorMax {
|
||||||
|
factor = erhoehungsfaktorMax
|
||||||
|
}
|
||||||
|
increaseFee = factor * baseFee
|
||||||
|
}
|
||||||
|
|
||||||
|
tgFee := 0.0
|
||||||
|
if oralHearing {
|
||||||
|
tgFee = tgFactor * baseFee
|
||||||
|
}
|
||||||
|
|
||||||
|
subtotalNet := vgFee + increaseFee + tgFee + auslagenpauschale
|
||||||
|
vat := subtotalNet * vatRate
|
||||||
|
subtotalGross := subtotalNet + vat
|
||||||
|
totalGross := subtotalGross * float64(numAttorneys)
|
||||||
|
|
||||||
|
return &models.AttorneyBreakdown{
|
||||||
|
BaseFee: baseFee,
|
||||||
|
VGFactor: vgFactor,
|
||||||
|
VGFee: vgFee,
|
||||||
|
IncreaseFee: increaseFee,
|
||||||
|
TGFactor: tgFactor,
|
||||||
|
TGFee: tgFee,
|
||||||
|
Pauschale: auslagenpauschale,
|
||||||
|
SubtotalNet: subtotalNet,
|
||||||
|
VAT: vat,
|
||||||
|
SubtotalGross: subtotalGross,
|
||||||
|
Count: numAttorneys,
|
||||||
|
TotalGross: totalGross,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateInstanceTotal computes the full cost for one court instance.
|
||||||
|
// Bug 3 fix: expert fees are included in the court subtotal (not silently dropped).
|
||||||
|
func (fc *FeeCalculator) CalculateInstanceTotal(streitwert float64, inst models.InstanceInput, vatRate float64) (*models.InstanceResult, error) {
|
||||||
|
cfg, ok := instanceConfigs[string(inst.Type)]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unknown instance type: %s", inst.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
version := string(inst.FeeVersion)
|
||||||
|
if version == "" {
|
||||||
|
version = "Aktuell"
|
||||||
|
}
|
||||||
|
|
||||||
|
baseFee, multiplier, courtFee, source, err := fc.CalculateCourtFees(streitwert, string(inst.Type), version)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("court fees: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bug 3 fix: include expert fees in court subtotal
|
||||||
|
courtSubtotal := courtFee + inst.ExpertFees
|
||||||
|
|
||||||
|
// Attorney (Rechtsanwalt) fees
|
||||||
|
raBreakdown, err := fc.CalculateAttorneyFees(
|
||||||
|
streitwert, version,
|
||||||
|
cfg.RAVGFactor, cfg.RATGFactor,
|
||||||
|
inst.NumAttorneys, inst.NumClients,
|
||||||
|
inst.OralHearing, vatRate,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("attorney fees: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patent attorney (Patentanwalt) fees
|
||||||
|
var paBreakdown *models.AttorneyBreakdown
|
||||||
|
if cfg.HasPA && inst.NumPatentAttorneys > 0 {
|
||||||
|
paBreakdown, err = fc.CalculateAttorneyFees(
|
||||||
|
streitwert, version,
|
||||||
|
cfg.PAVGFactor, cfg.PATGFactor,
|
||||||
|
inst.NumPatentAttorneys, inst.NumClients,
|
||||||
|
inst.OralHearing, vatRate,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("patent attorney fees: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
attorneyTotal := 0.0
|
||||||
|
if raBreakdown != nil {
|
||||||
|
attorneyTotal = raBreakdown.TotalGross
|
||||||
|
}
|
||||||
|
paTotal := 0.0
|
||||||
|
if paBreakdown != nil {
|
||||||
|
paTotal = paBreakdown.TotalGross
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.InstanceResult{
|
||||||
|
Type: inst.Type,
|
||||||
|
Label: cfg.Label,
|
||||||
|
CourtFeeBase: baseFee,
|
||||||
|
CourtFeeMultiplier: multiplier,
|
||||||
|
CourtFeeSource: source,
|
||||||
|
CourtFee: courtFee,
|
||||||
|
ExpertFees: inst.ExpertFees,
|
||||||
|
CourtSubtotal: courtSubtotal,
|
||||||
|
AttorneyBreakdown: raBreakdown,
|
||||||
|
PatentAttorneyBreakdown: paBreakdown,
|
||||||
|
AttorneySubtotal: attorneyTotal,
|
||||||
|
PatentAttorneySubtotal: paTotal,
|
||||||
|
InstanceTotal: courtSubtotal + attorneyTotal + paTotal,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateSecurityForCosts computes the Prozesskostensicherheit.
|
||||||
|
//
|
||||||
|
// Bug 1 fix: uses (1 + VAT) for the total, not (1 - VAT) as in the Excel.
|
||||||
|
// Bug 2 fix: uses GKG base fee for the court fee component, not RVG.
|
||||||
|
func (fc *FeeCalculator) CalculateSecurityForCosts(streitwert float64, version string, numClients int, vatRate float64) (*models.SecurityForCosts, error) {
|
||||||
|
rvgBase, err := fc.CalculateBaseFee(streitwert, version, "RVG")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bug 2 fix: use GKG base for court fees, not RVG
|
||||||
|
gkgBase, err := fc.CalculateBaseFee(streitwert, version, "GKG")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increase fee (Nr. 1008 VV RVG) for multiple clients
|
||||||
|
increaseFee := 0.0
|
||||||
|
if numClients > 1 {
|
||||||
|
factor := float64(numClients-1) * erhoehungsfaktor
|
||||||
|
if factor > erhoehungsfaktorMax {
|
||||||
|
factor = erhoehungsfaktorMax
|
||||||
|
}
|
||||||
|
increaseFee = factor * rvgBase
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Instanz: 2.5x RA + increase + 2.5x PA + increase + EUR 5,000
|
||||||
|
inst1 := 2.5*rvgBase + increaseFee + 2.5*rvgBase + increaseFee + 5000
|
||||||
|
|
||||||
|
// 2. Instanz: 2.8x RA + increase + 2.8x PA + increase + 4.0x GKG court + EUR 5,000
|
||||||
|
inst2 := 2.8*rvgBase + increaseFee + 2.8*rvgBase + increaseFee + 4.0*gkgBase + 5000
|
||||||
|
|
||||||
|
// NZB: 2.3x RA + increase + 2.3x PA + increase
|
||||||
|
nzb := 2.3*rvgBase + increaseFee + 2.3*rvgBase + increaseFee
|
||||||
|
|
||||||
|
subtotalNet := inst1 + inst2 + nzb
|
||||||
|
|
||||||
|
// Bug 1 fix: add VAT, don't subtract
|
||||||
|
vat := subtotalNet * vatRate
|
||||||
|
totalGross := subtotalNet + vat
|
||||||
|
|
||||||
|
return &models.SecurityForCosts{
|
||||||
|
Instance1: inst1,
|
||||||
|
Instance2: inst2,
|
||||||
|
NZB: nzb,
|
||||||
|
SubtotalNet: subtotalNet,
|
||||||
|
VAT: vat,
|
||||||
|
TotalGross: totalGross,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateFullLitigation computes costs for all enabled instances in a proceeding path.
|
||||||
|
func (fc *FeeCalculator) CalculateFullLitigation(req models.FeeCalculateRequest) (*models.FeeCalculateResponse, error) {
|
||||||
|
if req.Streitwert <= 0 {
|
||||||
|
return nil, fmt.Errorf("streitwert must be positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &models.FeeCalculateResponse{}
|
||||||
|
|
||||||
|
grandTotal := 0.0
|
||||||
|
for _, inst := range req.Instances {
|
||||||
|
if !inst.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result, err := fc.CalculateInstanceTotal(req.Streitwert, inst, req.VATRate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("instance %s: %w", inst.Type, err)
|
||||||
|
}
|
||||||
|
resp.Instances = append(resp.Instances, *result)
|
||||||
|
grandTotal += result.InstanceTotal
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build totals based on proceeding path
|
||||||
|
switch req.ProceedingPath {
|
||||||
|
case models.PathInfringement:
|
||||||
|
resp.Totals = fc.infringementTotals(resp.Instances)
|
||||||
|
case models.PathNullity:
|
||||||
|
resp.Totals = []models.FeeTotal{{Label: "Gesamtkosten Nichtigkeitsverfahren", Total: grandTotal}}
|
||||||
|
case models.PathCancellation:
|
||||||
|
resp.Totals = []models.FeeTotal{{Label: "Gesamtkosten Löschungsverfahren", Total: grandTotal}}
|
||||||
|
default:
|
||||||
|
resp.Totals = []models.FeeTotal{{Label: "Gesamtkosten", Total: grandTotal}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security for costs (only for infringement proceedings)
|
||||||
|
if req.IncludeSecurityCosts && req.ProceedingPath == models.PathInfringement {
|
||||||
|
version := "Aktuell"
|
||||||
|
numClients := 1
|
||||||
|
for _, inst := range req.Instances {
|
||||||
|
if inst.Enabled {
|
||||||
|
version = string(inst.FeeVersion)
|
||||||
|
if version == "" {
|
||||||
|
version = "Aktuell"
|
||||||
|
}
|
||||||
|
numClients = inst.NumClients
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sec, err := fc.CalculateSecurityForCosts(req.Streitwert, version, numClients, req.VATRate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("security for costs: %w", err)
|
||||||
|
}
|
||||||
|
resp.SecurityForCosts = sec
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// infringementTotals builds summary totals for infringement proceedings.
|
||||||
|
func (fc *FeeCalculator) infringementTotals(instances []models.InstanceResult) []models.FeeTotal {
|
||||||
|
byType := make(map[models.InstanceType]float64)
|
||||||
|
for _, inst := range instances {
|
||||||
|
byType[inst.Type] = inst.InstanceTotal
|
||||||
|
}
|
||||||
|
|
||||||
|
var totals []models.FeeTotal
|
||||||
|
|
||||||
|
// "Gesamtkosten bei Nichtzulassung" = LG + OLG + BGH NZB
|
||||||
|
nzb := byType[models.InstanceLG] + byType[models.InstanceOLG] + byType[models.InstanceBGHNZB]
|
||||||
|
if nzb > 0 {
|
||||||
|
totals = append(totals, models.FeeTotal{Label: "Gesamtkosten bei Nichtzulassung", Total: nzb})
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Gesamtkosten bei Revision" = LG + OLG + BGH Rev
|
||||||
|
rev := byType[models.InstanceLG] + byType[models.InstanceOLG] + byType[models.InstanceBGHRev]
|
||||||
|
if rev > 0 {
|
||||||
|
totals = append(totals, models.FeeTotal{Label: "Gesamtkosten bei Revision", Total: rev})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(totals) == 0 {
|
||||||
|
total := 0.0
|
||||||
|
for _, v := range byType {
|
||||||
|
total += v
|
||||||
|
}
|
||||||
|
totals = append(totals, models.FeeTotal{Label: "Gesamtkosten Verletzungsverfahren", Total: total})
|
||||||
|
}
|
||||||
|
return totals
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSchedules returns information about all available fee schedules.
|
||||||
|
func (fc *FeeCalculator) GetSchedules() []models.FeeScheduleInfo {
|
||||||
|
order := []string{"2005", "2013", "2021", "2025", "Aktuell"}
|
||||||
|
result := make([]models.FeeScheduleInfo, 0, len(order))
|
||||||
|
for _, key := range order {
|
||||||
|
sched := feeSchedules[key]
|
||||||
|
info := models.FeeScheduleInfo{
|
||||||
|
Key: key,
|
||||||
|
Label: sched.Label,
|
||||||
|
ValidFrom: sched.ValidFrom,
|
||||||
|
}
|
||||||
|
if sched.AliasOf != "" {
|
||||||
|
info.IsAlias = true
|
||||||
|
info.AliasOf = sched.AliasOf
|
||||||
|
}
|
||||||
|
result = append(result, info)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
421
backend/internal/services/fee_calculator_test.go
Normal file
421
backend/internal/services/fee_calculator_test.go
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCalculateBaseFee_GKG_2025(t *testing.T) {
|
||||||
|
fc := NewFeeCalculator()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
streitwert float64
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"minimum (500 EUR)", 500, 40},
|
||||||
|
{"1000 EUR", 1000, 61}, // 40 + ceil(500/500)*21 = 40+21
|
||||||
|
{"2000 EUR", 2000, 103}, // 40 + ceil(1500/500)*21 = 40+63
|
||||||
|
{"500k EUR", 500000, 4138}, // verified against GKG Anlage 2
|
||||||
|
{"1M EUR", 1000000, 6238}, // 4138 + ceil(500k/50k)*210 = 4138+2100
|
||||||
|
{"3M EUR", 3000000, 14638}, // 4138 + ceil(2.5M/50k)*210 = 4138+10500
|
||||||
|
{"5M EUR", 5000000, 23038}, // 4138 + ceil(4.5M/50k)*210 = 4138+18900
|
||||||
|
{"10M EUR", 10000000, 44038}, // 4138 + ceil(9.5M/50k)*210 = 4138+39900
|
||||||
|
{"30M EUR", 30000000, 128038}, // 4138 + ceil(29.5M/50k)*210 = 4138+123900
|
||||||
|
{"50M EUR", 50000000, 212038}, // 4138 + ceil(49.5M/50k)*210 = 4138+207900
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := fc.CalculateBaseFee(tt.streitwert, "2025", "GKG")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if math.Abs(got-tt.want) > 0.01 {
|
||||||
|
t.Errorf("CalculateBaseFee(%v, 2025, GKG) = %v, want %v", tt.streitwert, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateBaseFee_RVG_2025(t *testing.T) {
|
||||||
|
fc := NewFeeCalculator()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
streitwert float64
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"minimum (500 EUR)", 500, 51.5},
|
||||||
|
{"1000 EUR", 1000, 93}, // 51.5 + ceil(500/500)*41.5 = 51.5+41.5
|
||||||
|
{"2000 EUR", 2000, 176}, // 51.5 + ceil(1500/500)*41.5 = 51.5+124.5
|
||||||
|
{"500k EUR", 500000, 3752}, // computed via brackets
|
||||||
|
{"1M EUR", 1000000, 5502}, // 3752 + ceil(500k/50k)*175 = 3752+1750
|
||||||
|
{"3M EUR", 3000000, 12502}, // 3752 + ceil(2.5M/50k)*175 = 3752+8750
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := fc.CalculateBaseFee(tt.streitwert, "2025", "RVG")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if math.Abs(got-tt.want) > 0.01 {
|
||||||
|
t.Errorf("CalculateBaseFee(%v, 2025, RVG) = %v, want %v", tt.streitwert, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateBaseFee_GKG_2013(t *testing.T) {
|
||||||
|
fc := NewFeeCalculator()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
streitwert float64
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"minimum (500 EUR)", 500, 35},
|
||||||
|
{"1000 EUR", 1000, 53}, // 35 + ceil(500/500)*18 = 35+18
|
||||||
|
{"1500 EUR", 1500, 71}, // 35 + ceil(1000/500)*18 = 35+36
|
||||||
|
{"2000 EUR", 2000, 89}, // 35 + ceil(1500/500)*18 = 35+54
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := fc.CalculateBaseFee(tt.streitwert, "2013", "GKG")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if math.Abs(got-tt.want) > 0.01 {
|
||||||
|
t.Errorf("CalculateBaseFee(%v, 2013, GKG) = %v, want %v", tt.streitwert, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateBaseFee_Aktuell_IsAlias(t *testing.T) {
|
||||||
|
fc := NewFeeCalculator()
|
||||||
|
|
||||||
|
aktuell, err := fc.CalculateBaseFee(1000000, "Aktuell", "GKG")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
v2025, err := fc.CalculateBaseFee(1000000, "2025", "GKG")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if aktuell != v2025 {
|
||||||
|
t.Errorf("Aktuell (%v) should equal 2025 (%v)", aktuell, v2025)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateBaseFee_InvalidSchedule(t *testing.T) {
|
||||||
|
fc := NewFeeCalculator()
|
||||||
|
_, err := fc.CalculateBaseFee(1000, "1999", "GKG")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for unknown schedule")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateCourtFees(t *testing.T) {
|
||||||
|
fc := NewFeeCalculator()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
streitwert float64
|
||||||
|
instanceType string
|
||||||
|
version string
|
||||||
|
wantCourtFee float64
|
||||||
|
wantSource string
|
||||||
|
}{
|
||||||
|
{"LG 1M (3.0x GKG)", 1000000, "LG", "2025", 18714, "GKG"},
|
||||||
|
{"LG 3M (3.0x GKG)", 3000000, "LG", "2025", 43914, "GKG"},
|
||||||
|
{"OLG 3M (4.0x GKG)", 3000000, "OLG", "2025", 58552, "GKG"},
|
||||||
|
{"BPatG 1M (4.5x PatKostG)", 1000000, "BPatG", "2025", 28071, "PatKostG"},
|
||||||
|
{"DPMA (fixed 300)", 1000000, "DPMA", "2025", 300, "fixed"},
|
||||||
|
{"BPatG_Canc (fixed 500)", 1000000, "BPatG_Canc", "2025", 500, "fixed"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, _, courtFee, source, err := fc.CalculateCourtFees(tt.streitwert, tt.instanceType, tt.version)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if math.Abs(courtFee-tt.wantCourtFee) > 0.01 {
|
||||||
|
t.Errorf("court fee = %v, want %v", courtFee, tt.wantCourtFee)
|
||||||
|
}
|
||||||
|
if source != tt.wantSource {
|
||||||
|
t.Errorf("source = %v, want %v", source, tt.wantSource)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateAttorneyFees(t *testing.T) {
|
||||||
|
fc := NewFeeCalculator()
|
||||||
|
|
||||||
|
// 1 RA at LG, 1M Streitwert, 1 client, oral hearing, no VAT
|
||||||
|
bd, err := fc.CalculateAttorneyFees(1000000, "2025", 1.3, 1.2, 1, 1, true, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if bd == nil {
|
||||||
|
t.Fatal("expected non-nil breakdown")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RVG base at 1M = 5502
|
||||||
|
if math.Abs(bd.BaseFee-5502) > 0.01 {
|
||||||
|
t.Errorf("base fee = %v, want 5502", bd.BaseFee)
|
||||||
|
}
|
||||||
|
// VG = 1.3 * 5502 = 7152.6
|
||||||
|
if math.Abs(bd.VGFee-7152.6) > 0.01 {
|
||||||
|
t.Errorf("VG fee = %v, want 7152.6", bd.VGFee)
|
||||||
|
}
|
||||||
|
// No increase (1 client)
|
||||||
|
if bd.IncreaseFee != 0 {
|
||||||
|
t.Errorf("increase fee = %v, want 0", bd.IncreaseFee)
|
||||||
|
}
|
||||||
|
// TG = 1.2 * 5502 = 6602.4
|
||||||
|
if math.Abs(bd.TGFee-6602.4) > 0.01 {
|
||||||
|
t.Errorf("TG fee = %v, want 6602.4", bd.TGFee)
|
||||||
|
}
|
||||||
|
// Subtotal net = 7152.6 + 0 + 6602.4 + 20 = 13775
|
||||||
|
if math.Abs(bd.SubtotalNet-13775) > 0.01 {
|
||||||
|
t.Errorf("subtotal net = %v, want 13775", bd.SubtotalNet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateAttorneyFees_WithVAT(t *testing.T) {
|
||||||
|
fc := NewFeeCalculator()
|
||||||
|
|
||||||
|
bd, err := fc.CalculateAttorneyFees(1000000, "2025", 1.3, 1.2, 1, 1, true, 0.19)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Net = 13775, VAT = 13775 * 0.19 = 2617.25
|
||||||
|
wantVAT := 13775 * 0.19
|
||||||
|
if math.Abs(bd.VAT-wantVAT) > 0.01 {
|
||||||
|
t.Errorf("VAT = %v, want %v", bd.VAT, wantVAT)
|
||||||
|
}
|
||||||
|
wantGross := 13775 + wantVAT
|
||||||
|
if math.Abs(bd.SubtotalGross-wantGross) > 0.01 {
|
||||||
|
t.Errorf("subtotal gross = %v, want %v", bd.SubtotalGross, wantGross)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateAttorneyFees_MultipleClients(t *testing.T) {
|
||||||
|
fc := NewFeeCalculator()
|
||||||
|
|
||||||
|
// 3 clients → increase factor = min((3-1)*0.3, 2.0) = 0.6
|
||||||
|
bd, err := fc.CalculateAttorneyFees(1000000, "2025", 1.3, 1.2, 1, 3, true, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantIncrease := 0.6 * 5502 // 3301.2
|
||||||
|
if math.Abs(bd.IncreaseFee-wantIncrease) > 0.01 {
|
||||||
|
t.Errorf("increase fee = %v, want %v", bd.IncreaseFee, wantIncrease)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateAttorneyFees_IncreaseCapped(t *testing.T) {
|
||||||
|
fc := NewFeeCalculator()
|
||||||
|
|
||||||
|
// 10 clients → factor = min((10-1)*0.3, 2.0) = min(2.7, 2.0) = 2.0
|
||||||
|
bd, err := fc.CalculateAttorneyFees(1000000, "2025", 1.3, 1.2, 1, 10, true, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantIncrease := 2.0 * 5502 // 11004
|
||||||
|
if math.Abs(bd.IncreaseFee-wantIncrease) > 0.01 {
|
||||||
|
t.Errorf("increase fee = %v, want %v (should be capped at 2.0x)", bd.IncreaseFee, wantIncrease)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateAttorneyFees_NoHearing(t *testing.T) {
|
||||||
|
fc := NewFeeCalculator()
|
||||||
|
|
||||||
|
bd, err := fc.CalculateAttorneyFees(1000000, "2025", 1.3, 1.2, 1, 1, false, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if bd.TGFee != 0 {
|
||||||
|
t.Errorf("TG fee = %v, want 0 (no hearing)", bd.TGFee)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateAttorneyFees_ZeroAttorneys(t *testing.T) {
|
||||||
|
fc := NewFeeCalculator()
|
||||||
|
|
||||||
|
bd, err := fc.CalculateAttorneyFees(1000000, "2025", 1.3, 1.2, 0, 1, true, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if bd != nil {
|
||||||
|
t.Error("expected nil breakdown for 0 attorneys")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateInstanceTotal_ExpertFees(t *testing.T) {
|
||||||
|
fc := NewFeeCalculator()
|
||||||
|
|
||||||
|
// Bug 3 fix: expert fees must be included in court subtotal
|
||||||
|
inst := models.InstanceInput{
|
||||||
|
Type: models.InstanceBPatG,
|
||||||
|
Enabled: true,
|
||||||
|
FeeVersion: "2025",
|
||||||
|
NumAttorneys: 1,
|
||||||
|
NumPatentAttorneys: 1,
|
||||||
|
NumClients: 1,
|
||||||
|
OralHearing: true,
|
||||||
|
ExpertFees: 10000,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := fc.CalculateInstanceTotal(1000000, inst, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Court subtotal should include expert fees
|
||||||
|
wantCourtSubtotal := result.CourtFee + 10000
|
||||||
|
if math.Abs(result.CourtSubtotal-wantCourtSubtotal) > 0.01 {
|
||||||
|
t.Errorf("court subtotal = %v, want %v (should include expert fees)", result.CourtSubtotal, wantCourtSubtotal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instance total should include expert fees
|
||||||
|
if result.InstanceTotal < result.CourtFee+10000 {
|
||||||
|
t.Errorf("instance total %v should include expert fees (court fee %v + expert 10000)", result.InstanceTotal, result.CourtFee)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateSecurityForCosts_BugFixes(t *testing.T) {
|
||||||
|
fc := NewFeeCalculator()
|
||||||
|
|
||||||
|
// Bug 1: VAT should be added (1 + VAT), not subtracted
|
||||||
|
sec, err := fc.CalculateSecurityForCosts(1000000, "2025", 1, 0.19)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sec.TotalGross < sec.SubtotalNet {
|
||||||
|
t.Errorf("Bug 1: total %v should be > subtotal %v (VAT should add, not subtract)", sec.TotalGross, sec.SubtotalNet)
|
||||||
|
}
|
||||||
|
wantTotal := sec.SubtotalNet * 1.19
|
||||||
|
if math.Abs(sec.TotalGross-wantTotal) > 0.01 {
|
||||||
|
t.Errorf("Bug 1: total = %v, want %v", sec.TotalGross, wantTotal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bug 2: verify inst2 uses GKG base, not RVG base
|
||||||
|
// At 1M: GKG base = 6238, RVG base = 5502
|
||||||
|
// inst2 includes "4.0x court" = 4.0 * GKG_base = 24952
|
||||||
|
// If it incorrectly used RVG: 4.0 * 5502 = 22008
|
||||||
|
rvgBase := 5502.0
|
||||||
|
gkgBase := 6238.0
|
||||||
|
expectedInst2 := 2.8*rvgBase + 2.8*rvgBase + 4.0*gkgBase + 5000
|
||||||
|
if math.Abs(sec.Instance2-expectedInst2) > 0.01 {
|
||||||
|
t.Errorf("Bug 2: instance2 = %v, want %v (should use GKG base for court fee)", sec.Instance2, expectedInst2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateSecurityForCosts_ZeroVAT(t *testing.T) {
|
||||||
|
fc := NewFeeCalculator()
|
||||||
|
|
||||||
|
sec, err := fc.CalculateSecurityForCosts(1000000, "2025", 1, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// With 0% VAT, total should equal subtotal (bug is invisible)
|
||||||
|
if sec.TotalGross != sec.SubtotalNet {
|
||||||
|
t.Errorf("with 0%% VAT: total %v should equal subtotal %v", sec.TotalGross, sec.SubtotalNet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateFullLitigation_Infringement(t *testing.T) {
|
||||||
|
fc := NewFeeCalculator()
|
||||||
|
|
||||||
|
req := models.FeeCalculateRequest{
|
||||||
|
Streitwert: 1000000,
|
||||||
|
VATRate: 0,
|
||||||
|
ProceedingPath: models.PathInfringement,
|
||||||
|
Instances: []models.InstanceInput{
|
||||||
|
{Type: models.InstanceLG, Enabled: true, FeeVersion: "2025", NumAttorneys: 1, NumPatentAttorneys: 1, NumClients: 1, OralHearing: true},
|
||||||
|
{Type: models.InstanceOLG, Enabled: true, FeeVersion: "2025", NumAttorneys: 1, NumPatentAttorneys: 1, NumClients: 1, OralHearing: true},
|
||||||
|
{Type: models.InstanceBGHNZB, Enabled: true, FeeVersion: "2025", NumAttorneys: 1, NumPatentAttorneys: 1, NumClients: 1, OralHearing: true},
|
||||||
|
{Type: models.InstanceBGHRev, Enabled: false, FeeVersion: "2025", NumAttorneys: 1, NumPatentAttorneys: 1, NumClients: 1, OralHearing: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := fc.CalculateFullLitigation(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3 enabled instances
|
||||||
|
if len(resp.Instances) != 3 {
|
||||||
|
t.Errorf("got %d instances, want 3", len(resp.Instances))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have at least "Gesamtkosten bei Nichtzulassung"
|
||||||
|
found := false
|
||||||
|
for _, total := range resp.Totals {
|
||||||
|
if total.Label == "Gesamtkosten bei Nichtzulassung" {
|
||||||
|
found = true
|
||||||
|
if total.Total <= 0 {
|
||||||
|
t.Errorf("Nichtzulassung total = %v, should be > 0", total.Total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("missing 'Gesamtkosten bei Nichtzulassung' total")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateFullLitigation_Nullity(t *testing.T) {
|
||||||
|
fc := NewFeeCalculator()
|
||||||
|
|
||||||
|
req := models.FeeCalculateRequest{
|
||||||
|
Streitwert: 1000000,
|
||||||
|
VATRate: 0,
|
||||||
|
ProceedingPath: models.PathNullity,
|
||||||
|
Instances: []models.InstanceInput{
|
||||||
|
{Type: models.InstanceBPatG, Enabled: true, FeeVersion: "2025", NumAttorneys: 1, NumPatentAttorneys: 1, NumClients: 1, OralHearing: true},
|
||||||
|
{Type: models.InstanceBGHNull, Enabled: true, FeeVersion: "2025", NumAttorneys: 1, NumPatentAttorneys: 1, NumClients: 1, OralHearing: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := fc.CalculateFullLitigation(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Instances) != 2 {
|
||||||
|
t.Errorf("got %d instances, want 2", len(resp.Instances))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Totals) != 1 || resp.Totals[0].Label != "Gesamtkosten Nichtigkeitsverfahren" {
|
||||||
|
t.Errorf("unexpected totals: %+v", resp.Totals)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSchedules(t *testing.T) {
|
||||||
|
fc := NewFeeCalculator()
|
||||||
|
schedules := fc.GetSchedules()
|
||||||
|
|
||||||
|
if len(schedules) != 5 {
|
||||||
|
t.Errorf("got %d schedules, want 5", len(schedules))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aktuell should be an alias
|
||||||
|
last := schedules[len(schedules)-1]
|
||||||
|
if !last.IsAlias || last.AliasOf != "2025" {
|
||||||
|
t.Errorf("Aktuell should be alias of 2025, got IsAlias=%v AliasOf=%v", last.IsAlias, last.AliasOf)
|
||||||
|
}
|
||||||
|
}
|
||||||
249
backend/internal/services/fee_data.go
Normal file
249
backend/internal/services/fee_data.go
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import "math"
|
||||||
|
|
||||||
|
// feeBracket defines one bracket in the GKG/RVG fee schedule.
|
||||||
|
// Each bracket covers a range of Streitwert values.
|
||||||
|
type feeBracket struct {
|
||||||
|
UpperBound float64 // upper bound of this bracket
|
||||||
|
StepSize float64 // step size within this bracket
|
||||||
|
GKGIncrement float64 // GKG fee increment per step
|
||||||
|
RVGIncrement float64 // RVG fee increment per step
|
||||||
|
}
|
||||||
|
|
||||||
|
// feeScheduleData holds the bracket data for a fee schedule version.
|
||||||
|
type feeScheduleData struct {
|
||||||
|
Label string
|
||||||
|
ValidFrom string
|
||||||
|
Brackets []feeBracket
|
||||||
|
AliasOf string // if non-empty, this schedule is an alias for another
|
||||||
|
}
|
||||||
|
|
||||||
|
// feeSchedules contains all historical GKG/RVG fee schedule versions.
|
||||||
|
// Data extracted from Patentprozesskostenrechner.xlsm ListObjects.
|
||||||
|
var feeSchedules = map[string]*feeScheduleData{
|
||||||
|
"2005": {
|
||||||
|
Label: "GKG/RVG 2006-09-01",
|
||||||
|
ValidFrom: "2006-09-01",
|
||||||
|
Brackets: []feeBracket{
|
||||||
|
{300, 300, 25, 25},
|
||||||
|
{1500, 300, 10, 20},
|
||||||
|
{5000, 500, 8, 28},
|
||||||
|
{10000, 1000, 15, 37},
|
||||||
|
{25000, 3000, 23, 40},
|
||||||
|
{50000, 5000, 29, 72},
|
||||||
|
{200000, 15000, 100, 77},
|
||||||
|
{500000, 30000, 150, 118},
|
||||||
|
{math.MaxFloat64, 50000, 150, 150},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"2013": {
|
||||||
|
Label: "GKG/RVG 2013-08-01",
|
||||||
|
ValidFrom: "2013-08-01",
|
||||||
|
Brackets: []feeBracket{
|
||||||
|
{500, 300, 35, 45},
|
||||||
|
{2000, 500, 18, 35},
|
||||||
|
{10000, 1000, 19, 51},
|
||||||
|
{25000, 3000, 26, 46},
|
||||||
|
{50000, 5000, 35, 75},
|
||||||
|
{200000, 15000, 120, 85},
|
||||||
|
{500000, 30000, 179, 120},
|
||||||
|
{math.MaxFloat64, 50000, 180, 150},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"2021": {
|
||||||
|
Label: "GKG/RVG 2021-01-01",
|
||||||
|
ValidFrom: "2021-01-01",
|
||||||
|
Brackets: []feeBracket{
|
||||||
|
{500, 300, 38, 49},
|
||||||
|
{2000, 500, 20, 39},
|
||||||
|
{10000, 1000, 21, 56},
|
||||||
|
{25000, 3000, 29, 52},
|
||||||
|
{50000, 5000, 38, 81},
|
||||||
|
{200000, 15000, 132, 94},
|
||||||
|
{500000, 30000, 198, 132},
|
||||||
|
{math.MaxFloat64, 50000, 198, 165},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"2025": {
|
||||||
|
Label: "GKG/RVG 2025-06-01",
|
||||||
|
ValidFrom: "2025-06-01",
|
||||||
|
Brackets: []feeBracket{
|
||||||
|
{500, 300, 40, 51.5},
|
||||||
|
{2000, 500, 21, 41.5},
|
||||||
|
{10000, 1000, 22.5, 59.5},
|
||||||
|
{25000, 3000, 30.5, 55},
|
||||||
|
{50000, 5000, 40.5, 86},
|
||||||
|
{200000, 15000, 140, 99.5},
|
||||||
|
{500000, 30000, 210, 140},
|
||||||
|
{math.MaxFloat64, 50000, 210, 175},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Aktuell": {
|
||||||
|
Label: "Aktuell (= 2025-06-01)",
|
||||||
|
ValidFrom: "2025-06-01",
|
||||||
|
AliasOf: "2025",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// instanceConfig holds the multipliers and fee basis for each court instance.
|
||||||
|
type instanceConfig struct {
|
||||||
|
Label string
|
||||||
|
CourtFactor float64 // multiplier for base court fee
|
||||||
|
CourtSource string // "GKG", "PatKostG", or "fixed"
|
||||||
|
FixedCourtFee float64 // only used when CourtSource is "fixed"
|
||||||
|
RAVGFactor float64 // Rechtsanwalt Verfahrensgebühr factor
|
||||||
|
RATGFactor float64 // Rechtsanwalt Terminsgebühr factor
|
||||||
|
PAVGFactor float64 // Patentanwalt Verfahrensgebühr factor
|
||||||
|
PATGFactor float64 // Patentanwalt Terminsgebühr factor
|
||||||
|
HasPA bool // whether patent attorneys are applicable
|
||||||
|
}
|
||||||
|
|
||||||
|
// instanceConfigs defines the fee parameters for each court instance type.
|
||||||
|
var instanceConfigs = map[string]instanceConfig{
|
||||||
|
"LG": {"LG (Verletzung 1. Instanz)", 3.0, "GKG", 0, 1.3, 1.2, 1.3, 1.2, true},
|
||||||
|
"OLG": {"OLG (Berufung)", 4.0, "GKG", 0, 1.6, 1.2, 1.6, 1.2, true},
|
||||||
|
"BGH_NZB": {"BGH (Nichtzulassungsbeschwerde)", 2.0, "GKG", 0, 2.3, 1.2, 1.6, 1.2, true},
|
||||||
|
"BGH_Rev": {"BGH (Revision)", 5.0, "GKG", 0, 2.3, 1.5, 1.6, 1.5, true},
|
||||||
|
"BPatG": {"BPatG (Nichtigkeitsverfahren)", 4.5, "PatKostG", 0, 1.3, 1.2, 1.3, 1.2, true},
|
||||||
|
"BGH_Null": {"BGH (Nichtigkeitsberufung)", 6.0, "GKG", 0, 1.6, 1.5, 1.6, 1.5, true},
|
||||||
|
"DPMA": {"DPMA (Löschungsverfahren)", 0, "fixed", 300, 1.3, 1.2, 0, 0, false},
|
||||||
|
"BPatG_Canc": {"BPatG (Löschungsbeschwerde)", 0, "fixed", 500, 1.3, 1.2, 0, 0, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- UPC Fee Data ---
|
||||||
|
|
||||||
|
// upcFeeBracket defines one bracket in a UPC fee table.
|
||||||
|
// MaxValue 0 means unlimited (last bracket).
|
||||||
|
type upcFeeBracket struct {
|
||||||
|
MaxValue float64
|
||||||
|
Fee float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type upcFixedFees struct {
|
||||||
|
Infringement float64
|
||||||
|
CounterclaimInfringement float64
|
||||||
|
NonInfringement float64
|
||||||
|
LicenseCompensation float64
|
||||||
|
DetermineDamages float64
|
||||||
|
RevocationStandalone float64
|
||||||
|
CounterclaimRevocation float64
|
||||||
|
ProvisionalMeasures float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type upcScheduleData struct {
|
||||||
|
Label string
|
||||||
|
ValidFrom string
|
||||||
|
FixedFees upcFixedFees
|
||||||
|
ValueBased []upcFeeBracket
|
||||||
|
Recoverable []upcFeeBracket
|
||||||
|
SMReduction float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// upcSchedules contains UPC fee data for pre-2026 and 2026+ schedules.
|
||||||
|
var upcSchedules = map[string]*upcScheduleData{
|
||||||
|
"pre2026": {
|
||||||
|
Label: "UPC (vor 2026)",
|
||||||
|
ValidFrom: "2023-06-01",
|
||||||
|
FixedFees: upcFixedFees{
|
||||||
|
Infringement: 11000,
|
||||||
|
CounterclaimInfringement: 11000,
|
||||||
|
NonInfringement: 11000,
|
||||||
|
LicenseCompensation: 11000,
|
||||||
|
DetermineDamages: 3000,
|
||||||
|
RevocationStandalone: 20000,
|
||||||
|
CounterclaimRevocation: 20000,
|
||||||
|
ProvisionalMeasures: 11000,
|
||||||
|
},
|
||||||
|
ValueBased: []upcFeeBracket{
|
||||||
|
{500000, 0},
|
||||||
|
{750000, 2500},
|
||||||
|
{1000000, 4000},
|
||||||
|
{1500000, 8000},
|
||||||
|
{2000000, 13000},
|
||||||
|
{3000000, 20000},
|
||||||
|
{4000000, 26000},
|
||||||
|
{5000000, 32000},
|
||||||
|
{6000000, 39000},
|
||||||
|
{7000000, 46000},
|
||||||
|
{8000000, 52000},
|
||||||
|
{9000000, 58000},
|
||||||
|
{10000000, 65000},
|
||||||
|
{15000000, 75000},
|
||||||
|
{20000000, 100000},
|
||||||
|
{25000000, 125000},
|
||||||
|
{30000000, 150000},
|
||||||
|
{50000000, 250000},
|
||||||
|
{0, 325000},
|
||||||
|
},
|
||||||
|
Recoverable: []upcFeeBracket{
|
||||||
|
{250000, 38000},
|
||||||
|
{500000, 56000},
|
||||||
|
{1000000, 112000},
|
||||||
|
{2000000, 200000},
|
||||||
|
{4000000, 400000},
|
||||||
|
{8000000, 600000},
|
||||||
|
{16000000, 800000},
|
||||||
|
{30000000, 1200000},
|
||||||
|
{50000000, 1500000},
|
||||||
|
{0, 2000000},
|
||||||
|
},
|
||||||
|
SMReduction: 0.40,
|
||||||
|
},
|
||||||
|
"2026": {
|
||||||
|
Label: "UPC (ab 2026)",
|
||||||
|
ValidFrom: "2026-01-01",
|
||||||
|
FixedFees: upcFixedFees{
|
||||||
|
Infringement: 14600,
|
||||||
|
CounterclaimInfringement: 14600,
|
||||||
|
NonInfringement: 14600,
|
||||||
|
LicenseCompensation: 14600,
|
||||||
|
DetermineDamages: 4000,
|
||||||
|
RevocationStandalone: 26500,
|
||||||
|
CounterclaimRevocation: 26500,
|
||||||
|
ProvisionalMeasures: 14600,
|
||||||
|
},
|
||||||
|
// Estimated ~32% increase on pre-2026 values; replace with official data when available.
|
||||||
|
ValueBased: []upcFeeBracket{
|
||||||
|
{500000, 0},
|
||||||
|
{750000, 3300},
|
||||||
|
{1000000, 5300},
|
||||||
|
{1500000, 10600},
|
||||||
|
{2000000, 17200},
|
||||||
|
{3000000, 26400},
|
||||||
|
{4000000, 34300},
|
||||||
|
{5000000, 42200},
|
||||||
|
{6000000, 51500},
|
||||||
|
{7000000, 60700},
|
||||||
|
{8000000, 68600},
|
||||||
|
{9000000, 76600},
|
||||||
|
{10000000, 85800},
|
||||||
|
{15000000, 99000},
|
||||||
|
{20000000, 132000},
|
||||||
|
{25000000, 165000},
|
||||||
|
{30000000, 198000},
|
||||||
|
{50000000, 330000},
|
||||||
|
{0, 429000},
|
||||||
|
},
|
||||||
|
Recoverable: []upcFeeBracket{
|
||||||
|
{250000, 38000},
|
||||||
|
{500000, 56000},
|
||||||
|
{1000000, 112000},
|
||||||
|
{2000000, 200000},
|
||||||
|
{4000000, 400000},
|
||||||
|
{8000000, 600000},
|
||||||
|
{16000000, 800000},
|
||||||
|
{30000000, 1200000},
|
||||||
|
{50000000, 1500000},
|
||||||
|
{0, 2000000},
|
||||||
|
},
|
||||||
|
SMReduction: 0.50,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fee calculation constants (RVG)
|
||||||
|
const (
|
||||||
|
erhoehungsfaktor = 0.3 // Nr. 1008 VV RVG increase per additional client
|
||||||
|
erhoehungsfaktorMax = 2.0 // maximum increase factor
|
||||||
|
auslagenpauschale = 20.0 // Nr. 7002 VV RVG flat expense allowance (EUR)
|
||||||
|
)
|
||||||
292
backend/internal/services/invoice_service.go
Normal file
292
backend/internal/services/invoice_service.go
Normal 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
|
||||||
|
}
|
||||||
@@ -2,9 +2,12 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os/exec"
|
"net"
|
||||||
|
"net/smtp"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -457,18 +460,85 @@ type UpdatePreferencesInput struct {
|
|||||||
DailyDigest bool `json:"daily_digest"`
|
DailyDigest bool `json:"daily_digest"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendEmail sends an email using the `m mail send` CLI command.
|
// SendEmail sends an email via direct SMTP over TLS.
|
||||||
|
// Requires SMTP_HOST, SMTP_USER, SMTP_PASS env vars. Falls back to no-op if unconfigured.
|
||||||
func SendEmail(to, subject, body string) error {
|
func SendEmail(to, subject, body string) error {
|
||||||
cmd := exec.Command("m", "mail", "send",
|
host := os.Getenv("SMTP_HOST")
|
||||||
"--to", to,
|
port := os.Getenv("SMTP_PORT")
|
||||||
"--subject", subject,
|
user := os.Getenv("SMTP_USER")
|
||||||
"--body", body,
|
pass := os.Getenv("SMTP_PASS")
|
||||||
"--yes")
|
from := os.Getenv("MAIL_FROM")
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
if port == "" {
|
||||||
return fmt.Errorf("m mail send failed: %w (output: %s)", err, string(output))
|
port = "465"
|
||||||
}
|
}
|
||||||
slog.Info("email sent", "to", to, "subject", subject)
|
if from == "" {
|
||||||
|
from = "mgmt@msbls.de"
|
||||||
|
}
|
||||||
|
|
||||||
|
if host == "" || user == "" || pass == "" {
|
||||||
|
slog.Warn("SMTP not configured, skipping email", "to", to, "subject", subject)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build RFC 2822 message
|
||||||
|
msg := fmt.Sprintf("From: \"KanzlAI-mGMT\" <%s>\r\n"+
|
||||||
|
"To: %s\r\n"+
|
||||||
|
"Subject: [KanzlAI] %s\r\n"+
|
||||||
|
"MIME-Version: 1.0\r\n"+
|
||||||
|
"Content-Type: text/plain; charset=utf-8\r\n"+
|
||||||
|
"Date: %s\r\n"+
|
||||||
|
"\r\n%s",
|
||||||
|
from, to, subject,
|
||||||
|
time.Now().Format(time.RFC1123Z),
|
||||||
|
body)
|
||||||
|
|
||||||
|
addr := net.JoinHostPort(host, port)
|
||||||
|
|
||||||
|
// Connect with implicit TLS (port 465)
|
||||||
|
tlsConfig := &tls.Config{ServerName: host}
|
||||||
|
conn, err := tls.Dial("tcp", addr, tlsConfig)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("smtp tls dial: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := smtp.NewClient(conn, host)
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return fmt.Errorf("smtp new client: %w", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
// Authenticate
|
||||||
|
auth := smtp.PlainAuth("", user, pass, host)
|
||||||
|
if err := client.Auth(auth); err != nil {
|
||||||
|
return fmt.Errorf("smtp auth: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send
|
||||||
|
if err := client.Mail(from); err != nil {
|
||||||
|
return fmt.Errorf("smtp mail from: %w", err)
|
||||||
|
}
|
||||||
|
if err := client.Rcpt(to); err != nil {
|
||||||
|
return fmt.Errorf("smtp rcpt to: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w, err := client.Data()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("smtp data: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := w.Write([]byte(msg)); err != nil {
|
||||||
|
return fmt.Errorf("smtp write: %w", err)
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return fmt.Errorf("smtp close data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.Quit(); err != nil {
|
||||||
|
slog.Warn("smtp quit error (non-fatal)", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("email sent via SMTP", "from", from, "to", to, "subject", subject)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
329
backend/internal/services/reporting_service.go
Normal file
329
backend/internal/services/reporting_service.go
Normal 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
|
||||||
|
}
|
||||||
330
backend/internal/services/template_service.go
Normal file
330
backend/internal/services/template_service.go
Normal 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 ""
|
||||||
|
}
|
||||||
@@ -139,7 +139,11 @@ func (s *TenantService) FirstTenantForUser(ctx context.Context, userID uuid.UUID
|
|||||||
func (s *TenantService) ListMembers(ctx context.Context, tenantID uuid.UUID) ([]models.UserTenant, error) {
|
func (s *TenantService) ListMembers(ctx context.Context, tenantID uuid.UUID) ([]models.UserTenant, error) {
|
||||||
var members []models.UserTenant
|
var members []models.UserTenant
|
||||||
err := s.db.SelectContext(ctx, &members,
|
err := s.db.SelectContext(ctx, &members,
|
||||||
`SELECT user_id, tenant_id, role, created_at FROM user_tenants WHERE tenant_id = $1 ORDER BY created_at`,
|
`SELECT ut.user_id, ut.tenant_id, ut.role, ut.created_at, COALESCE(au.email, '') as email
|
||||||
|
FROM user_tenants ut
|
||||||
|
LEFT JOIN auth.users au ON au.id = ut.user_id
|
||||||
|
WHERE ut.tenant_id = $1
|
||||||
|
ORDER BY ut.created_at`,
|
||||||
tenantID,
|
tenantID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -240,6 +244,54 @@ func (s *TenantService) UpdateMemberRole(ctx context.Context, tenantID, userID u
|
|||||||
return nil
|
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.
|
// RemoveMember removes a user from a tenant. Cannot remove the last owner.
|
||||||
func (s *TenantService) RemoveMember(ctx context.Context, tenantID, userID uuid.UUID) error {
|
func (s *TenantService) RemoveMember(ctx context.Context, tenantID, userID uuid.UUID) error {
|
||||||
// Check if the user being removed is an owner
|
// Check if the user being removed is an owner
|
||||||
|
|||||||
276
backend/internal/services/time_entry_service.go
Normal file
276
backend/internal/services/time_entry_service.go
Normal 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
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
-- Creates 1 test tenant, 5 cases with deadlines and appointments
|
-- Creates 1 test tenant, 5 cases with deadlines and appointments
|
||||||
-- Run with: psql $DATABASE_URL -f demo_data.sql
|
-- Run with: psql $DATABASE_URL -f demo_data.sql
|
||||||
|
|
||||||
SET search_path TO kanzlai, public;
|
SET search_path TO mgmt, public;
|
||||||
|
|
||||||
-- Demo tenant
|
-- Demo tenant
|
||||||
INSERT INTO tenants (id, name, slug, settings) VALUES
|
INSERT INTO tenants (id, name, slug, settings) VALUES
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
-- UPC Proceeding Timeline: Full event tree with conditional deadlines
|
-- UPC Proceeding Timeline: Full event tree with conditional deadlines
|
||||||
-- Ported from youpc.org migrations 039 + 040
|
-- Ported from youpc.org migrations 039 + 040
|
||||||
-- Run against kanzlai schema in flexsiebels Supabase instance
|
-- Run against mgmt schema in youpc.org Supabase instance
|
||||||
|
|
||||||
-- ========================================
|
-- ========================================
|
||||||
-- 1. Add is_spawn + spawn_label columns
|
-- 1. Add is_spawn + spawn_label columns
|
||||||
|
|||||||
514
docs/kostenrechner-plan.md
Normal file
514
docs/kostenrechner-plan.md
Normal file
@@ -0,0 +1,514 @@
|
|||||||
|
# Patentprozesskostenrechner — Implementation Plan
|
||||||
|
|
||||||
|
**Date:** 2026-03-31
|
||||||
|
**Source:** Analysis of `Patentprozesskostenrechner.xlsm` (c) 2021 M. Siebels
|
||||||
|
**Status:** Research complete, ready for implementation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Fee Calculation Logic Summary
|
||||||
|
|
||||||
|
The calculator computes costs for German patent litigation using two fee systems:
|
||||||
|
|
||||||
|
### GKG (Gerichtskostengesetz) — Court Fees
|
||||||
|
|
||||||
|
A **step-based accumulator**. The Streitwert is divided into brackets, each with a step size and per-step increment. The algorithm:
|
||||||
|
|
||||||
|
1. Start with the minimum fee (first row of the fee table)
|
||||||
|
2. For each bracket: compute `steps = ceil(portion_in_bracket / step_size)`
|
||||||
|
3. Accumulate: `fee += steps * increment`
|
||||||
|
4. Result = "einfache Gebühr" (1.0x base fee)
|
||||||
|
5. Multiply by the instance-specific factor (e.g., 3.0x for LG, 4.0x for OLG)
|
||||||
|
|
||||||
|
For Streitwert > EUR 500,000 (post-2025 schedule): `base = 4,138 + ceil((Streitwert - 500,000) / 50,000) * 210`
|
||||||
|
|
||||||
|
### RVG (Rechtsanwaltsvergütungsgesetz) — Attorney Fees
|
||||||
|
|
||||||
|
Same step-based lookup but with its own column in the fee table. Per attorney, the formula is:
|
||||||
|
|
||||||
|
```
|
||||||
|
attorney_cost = (VG_factor * base_RVG + increase_fee + TG_factor * base_RVG + Pauschale) * (1 + VAT)
|
||||||
|
```
|
||||||
|
|
||||||
|
Where:
|
||||||
|
- **VG** = Verfahrensgebühr (procedural fee): 1.3x (LG/BPatG), 1.6x (OLG/BGH nullity), 2.3x (BGH NZB/Rev for RA)
|
||||||
|
- **TG** = Terminsgebühr (hearing fee): 1.2x or 1.5x (BGH), only if hearing held
|
||||||
|
- **Increase fee** (Nr. 1008 VV RVG): `MIN((clients - 1) * 0.3, 2.0) * base_RVG` for multiple clients
|
||||||
|
- **Pauschale** = EUR 20 (Auslagenpauschale Nr. 7002 VV RVG)
|
||||||
|
|
||||||
|
### PatKostG — Patent Court Fees
|
||||||
|
|
||||||
|
BPatG nullity uses PatKostG instead of GKG for court fees (but same step-based lookup from the same table). DPMA/BPatG cancellation uses fixed fees (EUR 300 / EUR 500).
|
||||||
|
|
||||||
|
### Instance Multipliers (Complete Reference)
|
||||||
|
|
||||||
|
| Instance | Court Fee Factor | Source | Fee Basis |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **LG** (infringement 1st) | 3.0x GKG | Nr. 1210 Anl. 1 GKG | GKG |
|
||||||
|
| **OLG** (infringement appeal) | 4.0x GKG | Nr. 1420 KV GKG | GKG |
|
||||||
|
| **BGH NZB** (leave to appeal) | 2.0x GKG | Nr. 1242 KV GKG | GKG |
|
||||||
|
| **BGH Revision** | 5.0x GKG | Nr. 1230 KV GKG | GKG |
|
||||||
|
| **BPatG** (nullity 1st) | 4.5x | Nr. 402 100 Anl. PatKostG | PatKostG |
|
||||||
|
| **BGH** (nullity appeal) | 6.0x GKG | Nr. 1250 KV GKG | GKG |
|
||||||
|
| **DPMA** (cancellation) | EUR 300 flat | Nr. 323 100 Anl. PatKostG | Fixed |
|
||||||
|
| **BPatG** (cancellation appeal) | EUR 500 flat | Nr. 401 100 Anl. PatKostG | Fixed |
|
||||||
|
|
||||||
|
| Instance | RA VG Factor | RA TG Factor | PA VG Factor | PA TG Factor |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **LG** | 1.3x | 1.2x | 1.3x | 1.2x |
|
||||||
|
| **OLG** | 1.6x | 1.2x | 1.6x | 1.2x |
|
||||||
|
| **BGH NZB** | 2.3x | 1.2x | 1.6x | 1.2x |
|
||||||
|
| **BGH Revision** | 2.3x | 1.5x | 1.6x | 1.5x |
|
||||||
|
| **BPatG** (nullity) | 1.3x | 1.2x | 1.3x | 1.2x |
|
||||||
|
| **BGH** (nullity appeal) | 1.6x | 1.5x | 1.6x | 1.5x |
|
||||||
|
| **DPMA** | 1.3x | 1.2x | — | — |
|
||||||
|
| **BPatG** (cancellation) | 1.3x | 1.2x | — | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Fee Schedule Data (JSON)
|
||||||
|
|
||||||
|
Five historical versions of the fee table. Each row: `[upperBound, stepSize, gkgIncrement, rvgIncrement]`.
|
||||||
|
|
||||||
|
Each row: `[upperBound, stepSize, gkgIncrement, rvgIncrement]`. Values extracted directly from the Excel ListObjects. Note: increments are decimal (e.g., 51.5 EUR per step). The last bracket uses a very large upper bound (effectively infinity).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"feeSchedules": {
|
||||||
|
"2005": {
|
||||||
|
"label": "GKG/RVG 2006-09-01",
|
||||||
|
"validFrom": "2006-09-01",
|
||||||
|
"brackets": [
|
||||||
|
[300, 300, 25, 25],
|
||||||
|
[1500, 300, 10, 20],
|
||||||
|
[5000, 500, 8, 28],
|
||||||
|
[10000, 1000, 15, 37],
|
||||||
|
[25000, 3000, 23, 40],
|
||||||
|
[50000, 5000, 29, 72],
|
||||||
|
[200000, 15000, 100, 77],
|
||||||
|
[500000, 30000, 150, 118],
|
||||||
|
[Infinity, 50000, 150, 150]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"2013": {
|
||||||
|
"label": "GKG/RVG 2013-08-01",
|
||||||
|
"validFrom": "2013-08-01",
|
||||||
|
"brackets": [
|
||||||
|
[500, 300, 35, 45],
|
||||||
|
[2000, 500, 18, 35],
|
||||||
|
[10000, 1000, 19, 51],
|
||||||
|
[25000, 3000, 26, 46],
|
||||||
|
[50000, 5000, 35, 75],
|
||||||
|
[200000, 15000, 120, 85],
|
||||||
|
[500000, 30000, 179, 120],
|
||||||
|
[Infinity, 50000, 180, 150]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"2021": {
|
||||||
|
"label": "GKG/RVG 2021-01-01",
|
||||||
|
"validFrom": "2021-01-01",
|
||||||
|
"brackets": [
|
||||||
|
[500, 300, 38, 49],
|
||||||
|
[2000, 500, 20, 39],
|
||||||
|
[10000, 1000, 21, 56],
|
||||||
|
[25000, 3000, 29, 52],
|
||||||
|
[50000, 5000, 38, 81],
|
||||||
|
[200000, 15000, 132, 94],
|
||||||
|
[500000, 30000, 198, 132],
|
||||||
|
[Infinity, 50000, 198, 165]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"2025": {
|
||||||
|
"label": "GKG/RVG 2025-06-01",
|
||||||
|
"validFrom": "2025-06-01",
|
||||||
|
"brackets": [
|
||||||
|
[500, 300, 40, 51.5],
|
||||||
|
[2000, 500, 21, 41.5],
|
||||||
|
[10000, 1000, 22.5, 59.5],
|
||||||
|
[25000, 3000, 30.5, 55],
|
||||||
|
[50000, 5000, 40.5, 86],
|
||||||
|
[200000, 15000, 140, 99.5],
|
||||||
|
[500000, 30000, 210, 140],
|
||||||
|
[Infinity, 50000, 210, 175]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Aktuell": {
|
||||||
|
"label": "Aktuell (= 2025-06-01)",
|
||||||
|
"validFrom": "2025-06-01",
|
||||||
|
"aliasOf": "2025"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"constants": {
|
||||||
|
"erhoehungsfaktor": 0.3,
|
||||||
|
"erhoehungsfaktorMax": 2.0,
|
||||||
|
"auslagenpauschale": 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes on the data:**
|
||||||
|
- The 2005 version has 9 brackets (starts at 300, not 500). Older versions differ more than expected.
|
||||||
|
- Increments are **not integers** in the 2025 version (e.g., 51.5, 41.5, 59.5) — the implementation must handle decimal arithmetic.
|
||||||
|
- The last bracket upper bound is `1e+20` in the Excel (effectively infinity). Use `Infinity` in TypeScript or a sentinel value.
|
||||||
|
- "Aktuell" is currently identical to "2025" — implement as an alias that can diverge when fees are next updated.
|
||||||
|
|
||||||
|
### UPC Fee Data (New — Not in Excel)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"upcFees": {
|
||||||
|
"pre2026": {
|
||||||
|
"label": "UPC (vor 2026)",
|
||||||
|
"validFrom": "2023-06-01",
|
||||||
|
"fixedFees": {
|
||||||
|
"infringement": 11000,
|
||||||
|
"counterclaim_infringement": 11000,
|
||||||
|
"non_infringement": 11000,
|
||||||
|
"license_compensation": 11000,
|
||||||
|
"determine_damages": 3000,
|
||||||
|
"revocation_standalone": 20000,
|
||||||
|
"counterclaim_revocation": 20000,
|
||||||
|
"provisional_measures": 11000
|
||||||
|
},
|
||||||
|
"valueBased": [
|
||||||
|
{ "maxValue": 500000, "fee": 0 },
|
||||||
|
{ "maxValue": 750000, "fee": 2500 },
|
||||||
|
{ "maxValue": 1000000, "fee": 4000 },
|
||||||
|
{ "maxValue": 1500000, "fee": 8000 },
|
||||||
|
{ "maxValue": 2000000, "fee": 13000 },
|
||||||
|
{ "maxValue": 3000000, "fee": 20000 },
|
||||||
|
{ "maxValue": 4000000, "fee": 26000 },
|
||||||
|
{ "maxValue": 5000000, "fee": 32000 },
|
||||||
|
{ "maxValue": 6000000, "fee": 39000 },
|
||||||
|
{ "maxValue": 7000000, "fee": 46000 },
|
||||||
|
{ "maxValue": 8000000, "fee": 52000 },
|
||||||
|
{ "maxValue": 9000000, "fee": 58000 },
|
||||||
|
{ "maxValue": 10000000, "fee": 65000 },
|
||||||
|
{ "maxValue": 15000000, "fee": 75000 },
|
||||||
|
{ "maxValue": 20000000, "fee": 100000 },
|
||||||
|
{ "maxValue": 25000000, "fee": 125000 },
|
||||||
|
{ "maxValue": 30000000, "fee": 150000 },
|
||||||
|
{ "maxValue": 50000000, "fee": 250000 },
|
||||||
|
{ "maxValue": null, "fee": 325000 }
|
||||||
|
],
|
||||||
|
"recoverableCosts": [
|
||||||
|
{ "maxValue": 250000, "ceiling": 38000 },
|
||||||
|
{ "maxValue": 500000, "ceiling": 56000 },
|
||||||
|
{ "maxValue": 1000000, "ceiling": 112000 },
|
||||||
|
{ "maxValue": 2000000, "ceiling": 200000 },
|
||||||
|
{ "maxValue": 4000000, "ceiling": 400000 },
|
||||||
|
{ "maxValue": 8000000, "ceiling": 600000 },
|
||||||
|
{ "maxValue": 16000000, "ceiling": 800000 },
|
||||||
|
{ "maxValue": 30000000, "ceiling": 1200000 },
|
||||||
|
{ "maxValue": 50000000, "ceiling": 1500000 },
|
||||||
|
{ "maxValue": null, "ceiling": 2000000 }
|
||||||
|
],
|
||||||
|
"smReduction": 0.40
|
||||||
|
},
|
||||||
|
"2026": {
|
||||||
|
"label": "UPC (ab 2026)",
|
||||||
|
"validFrom": "2026-01-01",
|
||||||
|
"fixedFees": {
|
||||||
|
"infringement": 14600,
|
||||||
|
"counterclaim_infringement": 14600,
|
||||||
|
"non_infringement": 14600,
|
||||||
|
"license_compensation": 14600,
|
||||||
|
"determine_damages": 4000,
|
||||||
|
"revocation_standalone": 26500,
|
||||||
|
"counterclaim_revocation": 26500,
|
||||||
|
"provisional_measures": 14600
|
||||||
|
},
|
||||||
|
"valueBased": "TODO: exact 2026 table not yet published in extractable form. Estimated ~32% increase on pre-2026 values. Replace with official data when available.",
|
||||||
|
"smReduction": 0.50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Architecture Decision
|
||||||
|
|
||||||
|
### Recommendation: New page within KanzlAI-mGMT at `/kosten/rechner`
|
||||||
|
|
||||||
|
**Reasons:**
|
||||||
|
|
||||||
|
1. **Existing infrastructure**: KanzlAI already has billing/cost infrastructure (time tracking, invoices, RVG rates). The Kostenrechner is a natural extension.
|
||||||
|
2. **Shared UI patterns**: Sidebar nav, card layout, Tailwind styling, Recharts for any comparison charts — all already established.
|
||||||
|
3. **Future integration**: Cost calculations can link to cases (attach estimated costs to a case), feed into Prozesskostensicherheit calculations, and inform billing.
|
||||||
|
4. **No auth required for core calculator**: The page can work without login (public tool for marketing), but logged-in users get case-linking and save functionality.
|
||||||
|
5. **No backend needed initially**: All fee calculations are deterministic lookups + arithmetic — pure frontend. Data lives as static JSON/TypeScript constants.
|
||||||
|
|
||||||
|
**Against standalone deployment:**
|
||||||
|
- Maintaining a separate deploy adds operational overhead for zero benefit
|
||||||
|
- Can't integrate with cases or billing later without cross-origin complexity
|
||||||
|
- Duplicates styling/build tooling
|
||||||
|
|
||||||
|
### Proposed Route Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
/kosten/ — Overview page (links to sub-calculators)
|
||||||
|
/kosten/rechner — Patentprozesskostenrechner (main calculator)
|
||||||
|
/kosten/rechner/vergleich — (future) Venue comparison tool (DE vs UPC)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/
|
||||||
|
app/(app)/kosten/
|
||||||
|
page.tsx — Overview
|
||||||
|
rechner/
|
||||||
|
page.tsx — Calculator page (client component)
|
||||||
|
lib/
|
||||||
|
costs/
|
||||||
|
fee-tables.ts — All fee schedule data (GKG, RVG, UPC)
|
||||||
|
calculator.ts — Pure calculation functions
|
||||||
|
types.ts — TypeScript types for inputs/outputs
|
||||||
|
components/
|
||||||
|
costs/
|
||||||
|
CostCalculator.tsx — Main calculator component
|
||||||
|
InstanceCard.tsx — Per-instance input card (LG, OLG, etc.)
|
||||||
|
CostSummary.tsx — Results display with breakdown
|
||||||
|
CostComparison.tsx — (future) Side-by-side venue comparison
|
||||||
|
```
|
||||||
|
|
||||||
|
**No backend changes needed.** All calculation logic is client-side. If we later want to save calculations to a case, we add one API endpoint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. All Inputs
|
||||||
|
|
||||||
|
### Global Inputs
|
||||||
|
|
||||||
|
| Input | Type | Range | Default | Description |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `vatRate` | enum | 0%, 16%, 19% | 0% | Umsatzsteuer |
|
||||||
|
| `streitwert` | number | 500–30,000,000 | 100,000 | Amount in dispute (EUR) |
|
||||||
|
| `erhoehungsStreitwert` | number | >= streitwert | = streitwert | Increased amount (for Erhoehungsgebuehr) |
|
||||||
|
| `proceedingType` | enum | infringement, nullity, cancellation, security | infringement | Which proceeding to calculate |
|
||||||
|
|
||||||
|
### Per-Instance Inputs (Infringement: LG, OLG, BGH-NZB, BGH-Rev)
|
||||||
|
|
||||||
|
| Input | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `enabled` | boolean | true (LG), false (others) | Include this instance |
|
||||||
|
| `feeVersion` | enum | "Aktuell" | Fee schedule version (2005/2013/2021/2025/Aktuell) |
|
||||||
|
| `numAttorneys` | integer >= 0 | 1 | Rechtsanwälte |
|
||||||
|
| `numPatentAttorneys` | integer >= 0 | 1 | Patentanwälte |
|
||||||
|
| `oralHearing` | boolean | true | Mündliche Verhandlung held? |
|
||||||
|
| `expertFees` | number >= 0 | 0 | Sachverständigenvergütung (EUR) |
|
||||||
|
| `terminationType` | enum | "Urteil" | How case ended (Urteil/Vergleich/Klagerücknahme/etc.) |
|
||||||
|
| `numClients` | integer >= 1 | 1 | Mandanten (for Erhöhungsgebühr) |
|
||||||
|
|
||||||
|
### Per-Instance Inputs (Nullity: BPatG, BGH)
|
||||||
|
|
||||||
|
Same structure as infringement instances.
|
||||||
|
|
||||||
|
### Per-Instance Inputs (Cancellation: DPMA, BPatG)
|
||||||
|
|
||||||
|
Same structure but no patent attorneys at DPMA level, fixed court fees.
|
||||||
|
|
||||||
|
### UPC-Specific Inputs (New)
|
||||||
|
|
||||||
|
| Input | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `actionType` | enum | "infringement" | UPC action type (affects fixed fee + value-based applicability) |
|
||||||
|
| `feeVersion` | enum | "2026" | pre-2026 or 2026 |
|
||||||
|
| `isSME` | boolean | false | Small/micro enterprise (40%/50% court fee reduction) |
|
||||||
|
| `includeAppeal` | boolean | false | Include Court of Appeal |
|
||||||
|
| `includeRevocation` | boolean | false | Include counterclaim for revocation |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. All Outputs
|
||||||
|
|
||||||
|
### Per-Instance Breakdown
|
||||||
|
|
||||||
|
| Output | Description |
|
||||||
|
|---|---|
|
||||||
|
| Court fee (base) | e.g., "3.0x GKG = EUR 18,714" |
|
||||||
|
| Expert fees | If applicable |
|
||||||
|
| **Court subtotal** | Court fee + expert fees |
|
||||||
|
| Per-attorney cost | VG + Erhöhung + TG + Pauschale, before VAT |
|
||||||
|
| Per-attorney cost (incl. VAT) | × (1 + VAT) |
|
||||||
|
| Attorney subtotal | Per-attorney × num_attorneys |
|
||||||
|
| Patent attorney subtotal | Same calculation × num_patent_attorneys |
|
||||||
|
| **Instance total** | Court subtotal + attorney subtotal + patent attorney subtotal |
|
||||||
|
|
||||||
|
### Summary Totals (Infringement)
|
||||||
|
|
||||||
|
| Output | Description |
|
||||||
|
|---|---|
|
||||||
|
| Gesamtkosten bei Nichtzulassung | LG + OLG + BGH-NZB |
|
||||||
|
| Gesamtkosten bei Revision | LG + OLG + BGH-Rev |
|
||||||
|
|
||||||
|
### Summary Totals (Nullity)
|
||||||
|
|
||||||
|
| Output | Description |
|
||||||
|
|---|---|
|
||||||
|
| Gesamtkosten Nichtigkeitsverfahren | BPatG + BGH |
|
||||||
|
|
||||||
|
### Summary Totals (Cancellation)
|
||||||
|
|
||||||
|
| Output | Description |
|
||||||
|
|---|---|
|
||||||
|
| Gesamtkosten Löschungsverfahren | DPMA + BPatG |
|
||||||
|
|
||||||
|
### Security for Costs (Prozesskostensicherheit)
|
||||||
|
|
||||||
|
| Output | Description |
|
||||||
|
|---|---|
|
||||||
|
| 1. Instanz | 2.5x RA + increase + 2.5x PA + increase + EUR 5,000 |
|
||||||
|
| 2. Instanz | 2.8x RA + increase + 2.8x PA + increase + 4.0x court + EUR 5,000 |
|
||||||
|
| NZB | 2.3x RA + increase + 2.3x PA + increase |
|
||||||
|
| Total (incl. VAT) | Sum × (1 + VAT) |
|
||||||
|
|
||||||
|
### UPC Outputs (New)
|
||||||
|
|
||||||
|
| Output | Description |
|
||||||
|
|---|---|
|
||||||
|
| Fixed court fee | Per action type |
|
||||||
|
| Value-based fee | Per Streitwert bracket |
|
||||||
|
| Total court fees | Fixed + value-based |
|
||||||
|
| Court fees (SME) | With 40%/50% reduction |
|
||||||
|
| Recoverable costs ceiling | Per Streitwert bracket |
|
||||||
|
| Appeal court fees | If appeal enabled |
|
||||||
|
| **Total cost risk** | All court fees + recoverable costs ceiling |
|
||||||
|
|
||||||
|
### Fee Schedule Reference (Quick Lookup)
|
||||||
|
|
||||||
|
| Output | Description |
|
||||||
|
|---|---|
|
||||||
|
| Base 1.0x GKG fee | For each fee version at given Streitwert |
|
||||||
|
| Base 1.0x RVG fee | For each fee version at given Streitwert |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Bugs to Fix (from Excel)
|
||||||
|
|
||||||
|
### Bug 1: Prozesskostensicherheit VAT Formula (CRITICAL)
|
||||||
|
|
||||||
|
- **Location:** Excel cell C31 on Prozesskostensicherheit sheet
|
||||||
|
- **Problem:** Formula `=C30*(1-Umsatzsteuer)` subtracts VAT instead of adding it
|
||||||
|
- **Label says:** "inkl. Umsatzsteuer" (including VAT)
|
||||||
|
- **Fix:** `=C30*(1+Umsatzsteuer)` → in web version: `total * (1 + vatRate)`
|
||||||
|
- **Impact:** 32% error when VAT = 19% (EUR 35,394 vs correct EUR 51,998)
|
||||||
|
- **Why hidden:** Default VAT is 0%, so `1-0 = 1+0 = 1` — bug only manifests with non-zero VAT
|
||||||
|
|
||||||
|
### Bug 2: Prozesskostensicherheit Uses Wrong Fee Type
|
||||||
|
|
||||||
|
- **Location:** Excel cell C22 on Prozesskostensicherheit sheet
|
||||||
|
- **Problem:** `mGebuehrensatz(Streitwert, 1, SelectedVersion)` — parameter `1` selects RVG (attorney) fees
|
||||||
|
- **Should be:** `mGebuehrensatz(Streitwert, 0, SelectedVersion)` — parameter `0` selects GKG (court) fees
|
||||||
|
- **Context:** This cell calculates "4-fache Gerichts-Verfahrensgebühr (Nr. 1420 KV)" — clearly a court fee
|
||||||
|
- **Fix:** Use GKG fee schedule for court fee calculations
|
||||||
|
- **Impact:** GKG and RVG fees differ, so the result is always wrong
|
||||||
|
|
||||||
|
### Bug 3: Nichtigkeitsverfahren Missing Expert Fees in Total
|
||||||
|
|
||||||
|
- **Location:** Excel cell D24 on Nichtigkeitsverfahren sheet
|
||||||
|
- **Problem:** `=C23+C13` adds attorney total (C23) + bare court fee (C13), but C13 is only the 4.5x fee line item
|
||||||
|
- **Should be:** `=C23+C15` where C15 is the Zwischensumme (court fees + expert fees)
|
||||||
|
- **Fix:** Include expert fees subtotal in instance total
|
||||||
|
- **Impact:** Expert fees are silently dropped from the BPatG total. Consistent with Verletzungsverfahren pattern where D26 = C25 + C17 (uses Zwischensumme)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. UPC Fee Structure (New Feature)
|
||||||
|
|
||||||
|
### How UPC Fees Differ from German Courts
|
||||||
|
|
||||||
|
| Aspect | German Courts (GKG/RVG) | UPC |
|
||||||
|
|---|---|---|
|
||||||
|
| **Court fee model** | Step-based accumulator | Fixed fee + bracket lookup |
|
||||||
|
| **Attorney fees** | RVG statutory table | Contractual (market rates) |
|
||||||
|
| **Recoverable costs** | RVG-based (predictable) | Ceiling table (much higher) |
|
||||||
|
| **Scope** | Single country | Pan-European |
|
||||||
|
| **Nullity** | Separate BPatG proceeding | Counterclaim in same action |
|
||||||
|
|
||||||
|
### UPC Court Fees: Two Components
|
||||||
|
|
||||||
|
1. **Fixed fee** — always due, depends on action type:
|
||||||
|
- Infringement: EUR 14,600 (2026) / EUR 11,000 (pre-2026)
|
||||||
|
- Revocation: EUR 26,500 (2026) / EUR 20,000 (pre-2026) — flat, no value-based component
|
||||||
|
- Appeal: same fixed fees as first instance
|
||||||
|
|
||||||
|
2. **Value-based fee** — only for infringement-type actions when Streitwert > EUR 500,000:
|
||||||
|
- 19 brackets from EUR 0 (≤500k) to EUR 325,000 (>50M) — pre-2026
|
||||||
|
- ~32% increase in 2026 (exact table pending official publication)
|
||||||
|
- Revocation actions have NO value-based fee
|
||||||
|
|
||||||
|
3. **SME reduction**: 50% (2026) / 40% (pre-2026) on all court fees
|
||||||
|
|
||||||
|
### Recoverable Costs Ceilings (Attorney Fee Caps)
|
||||||
|
|
||||||
|
Per instance, the losing party reimburses up to:
|
||||||
|
|
||||||
|
| Streitwert | Ceiling |
|
||||||
|
|---|---|
|
||||||
|
| ≤ EUR 250,000 | EUR 38,000 |
|
||||||
|
| ≤ EUR 500,000 | EUR 56,000 |
|
||||||
|
| ≤ EUR 1,000,000 | EUR 112,000 |
|
||||||
|
| ≤ EUR 2,000,000 | EUR 200,000 |
|
||||||
|
| ≤ EUR 4,000,000 | EUR 400,000 |
|
||||||
|
| ≤ EUR 8,000,000 | EUR 600,000 |
|
||||||
|
| ≤ EUR 16,000,000 | EUR 800,000 |
|
||||||
|
| ≤ EUR 30,000,000 | EUR 1,200,000 |
|
||||||
|
| ≤ EUR 50,000,000 | EUR 1,500,000 |
|
||||||
|
| > EUR 50,000,000 | EUR 2,000,000 |
|
||||||
|
|
||||||
|
Court can raise ceiling by 50% (cases ≤1M) or 25% (1–50M). Expert/translator fees recoverable separately on top.
|
||||||
|
|
||||||
|
### Cost Comparison (Key Insight)
|
||||||
|
|
||||||
|
At EUR 3M Streitwert (infringement, 1st instance):
|
||||||
|
|
||||||
|
| | UPC (2026) | German LG |
|
||||||
|
|---|---|---|
|
||||||
|
| Court fees | ~EUR 41,000 | EUR 43,914 |
|
||||||
|
| Recoverable attorney costs | up to EUR 400,000 | ~EUR 100,388 |
|
||||||
|
| **Total cost risk** | **~EUR 441,000** | **~EUR 144,302** |
|
||||||
|
|
||||||
|
**Key takeaway:** UPC court fees are comparable to or lower than German courts. But recoverable attorney costs are 3–5x higher, making total cost risk at UPC roughly 2–3x German courts. This is the critical information patent litigators need.
|
||||||
|
|
||||||
|
### UPC Sources
|
||||||
|
|
||||||
|
- Rule 370 RoP (court fees), Rule 152 RoP (recoverable costs), Art. 69 UPCA
|
||||||
|
- UPC Administrative Committee fee table AC/05/08072022 (pre-2026)
|
||||||
|
- UPC Administrative Committee amendment, 4 Nov 2025 (2026 changes)
|
||||||
|
- Scale of Ceilings for Recoverable Costs: D-AC/10/24042023
|
||||||
|
- Maiwald MAIinsight April 2025 (practitioner analysis with verified figures)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Implementation Recommendations
|
||||||
|
|
||||||
|
### Phase 1: Core Calculator (MVP)
|
||||||
|
|
||||||
|
- Implement `fee-tables.ts` with all 5 GKG/RVG schedule versions as typed constants
|
||||||
|
- Implement `calculator.ts` with pure functions: `computeBaseFee(streitwert, isRVG, version)`, per-instance calculations, totals
|
||||||
|
- Build the UI as a single `"use client"` page at `/kosten/rechner`
|
||||||
|
- Card-based layout: global inputs at top, collapsible instance cards, results summary at bottom
|
||||||
|
- Fix all 3 bugs in the implementation (don't port them from Excel)
|
||||||
|
- German language UI throughout
|
||||||
|
|
||||||
|
### Phase 2: UPC Extension
|
||||||
|
|
||||||
|
- Add UPC fee data and calculation functions (bracket lookup, not step-based)
|
||||||
|
- Add UPC section to the calculator (separate card or tab)
|
||||||
|
- Add venue comparison view: side-by-side DE vs UPC for the same Streitwert
|
||||||
|
|
||||||
|
### Phase 3: Integration
|
||||||
|
|
||||||
|
- Allow saving calculations to a case (requires one backend endpoint)
|
||||||
|
- PDF export of cost breakdown
|
||||||
|
- Wire up Verfahrensbeendigung (termination type affects fee multipliers)
|
||||||
|
|
||||||
|
### Data Extraction TODO
|
||||||
|
|
||||||
|
Before implementation begins, the exact fee table values must be extracted from the Excel file. The analysis doc describes the structure but doesn't list every cell value. The implementer should either:
|
||||||
|
1. Open the Excel and manually read the Hebesaetze sheet values, or
|
||||||
|
2. Use a Python script with `openpyxl` to extract the ListObject data programmatically
|
||||||
|
|
||||||
|
This is critical — the older fee versions (2005, 2013, 2021) have different step sizes and increments.
|
||||||
391
docs/research/upc-fee-structure.md
Normal file
391
docs/research/upc-fee-structure.md
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
# UPC Fee Structure Research
|
||||||
|
|
||||||
|
> Research date: 2026-03-31
|
||||||
|
> Status: Complete (pre-2026 tables verified from official sources; 2026 amendments documented with confirmed changes)
|
||||||
|
> Purpose: Data for implementing a patent litigation cost calculator
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The UPC fee system consists of:
|
||||||
|
1. **Fixed fees** (always due, depend on action type)
|
||||||
|
2. **Value-based fees** (additional, for infringement-type actions with value > EUR 500,000)
|
||||||
|
3. **Recoverable costs ceilings** (caps on lawyer fees the losing party must reimburse)
|
||||||
|
|
||||||
|
Legal basis: Rule 370 RoP (court fees), Rule 152 RoP (recoverable costs), Art. 69 UPCA (cost allocation).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Fixed Court Fees (Court of First Instance)
|
||||||
|
|
||||||
|
### Pre-2026 Schedule (actions filed before 1 Jan 2026)
|
||||||
|
|
||||||
|
| Action Type | Fixed Fee |
|
||||||
|
|---|---|
|
||||||
|
| Infringement action | EUR 11,000 |
|
||||||
|
| Counterclaim for infringement | EUR 11,000 |
|
||||||
|
| Declaration of non-infringement | EUR 11,000 |
|
||||||
|
| Compensation for license of right | EUR 11,000 |
|
||||||
|
| Application to determine damages | EUR 3,000 |
|
||||||
|
| **Standalone revocation action** | **EUR 20,000** (flat, no value-based fee) |
|
||||||
|
| **Counterclaim for revocation** | **EUR 20,000** (flat, no value-based fee) |
|
||||||
|
| Application for provisional measures | EUR 11,000 |
|
||||||
|
| Order to preserve evidence | EUR 350 |
|
||||||
|
| Order for inspection | EUR 350 |
|
||||||
|
| Order to freeze assets | EUR 1,000 |
|
||||||
|
| Protective letter filing | EUR 200 |
|
||||||
|
| Protective letter extension (per 6 months) | EUR 100 |
|
||||||
|
| Action against EPO decision | EUR 1,000 |
|
||||||
|
| Re-establishment of rights | EUR 350 |
|
||||||
|
| Review case management order | EUR 300 |
|
||||||
|
| Set aside decision by default | EUR 1,000 |
|
||||||
|
|
||||||
|
### 2026 Schedule (actions filed from 1 Jan 2026)
|
||||||
|
|
||||||
|
~33% increase across the board:
|
||||||
|
|
||||||
|
| Action Type | Old Fee | New Fee (2026) | Change |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Infringement action | EUR 11,000 | **EUR 14,600** | +33% |
|
||||||
|
| Counterclaim for infringement | EUR 11,000 | **EUR 14,600** | +33% |
|
||||||
|
| Declaration of non-infringement | EUR 11,000 | **EUR 14,600** | +33% |
|
||||||
|
| Standalone revocation action | EUR 20,000 | **EUR 26,500** | +33% |
|
||||||
|
| Counterclaim for revocation | EUR 20,000 | **EUR 26,500** | +33% |
|
||||||
|
| Application for provisional measures | EUR 11,000 | **EUR 14,600** | +33% |
|
||||||
|
| Application to determine damages | EUR 3,000 | **EUR 4,000** | +33% |
|
||||||
|
| Order to preserve evidence | EUR 350 | **EUR 5,000** | +1,329% |
|
||||||
|
| Order for inspection | EUR 350 | **EUR 5,000** | +1,329% |
|
||||||
|
| Order to freeze assets | EUR 1,000 | **EUR 5,000** | +400% |
|
||||||
|
| Protective letter filing | EUR 200 | **EUR 300** | +50% |
|
||||||
|
| Protective letter extension | EUR 100 | **EUR 130** | +30% |
|
||||||
|
| Application for rehearing | EUR 2,500 | **EUR 14,600** | +484% |
|
||||||
|
|
||||||
|
**Key 2026 change**: Provisional measures now also have a **value-based fee** (new). The value is deemed to be 2/3 of the value of the merits proceedings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Value-Based Fees (Additional to Fixed Fee)
|
||||||
|
|
||||||
|
Applies to: infringement actions, counterclaim for infringement, declaration of non-infringement, compensation for license of right, application to determine damages.
|
||||||
|
|
||||||
|
Does NOT apply to: revocation actions (standalone or counterclaim) -- these are flat fee only.
|
||||||
|
|
||||||
|
### Pre-2026 Value-Based Fee Table
|
||||||
|
|
||||||
|
| Value of the Action | Additional Value-Based Fee |
|
||||||
|
|---|---|
|
||||||
|
| <= EUR 500,000 | EUR 0 |
|
||||||
|
| <= EUR 750,000 | EUR 2,500 |
|
||||||
|
| <= EUR 1,000,000 | EUR 4,000 |
|
||||||
|
| <= EUR 1,500,000 | EUR 8,000 |
|
||||||
|
| <= EUR 2,000,000 | EUR 13,000 |
|
||||||
|
| <= EUR 3,000,000 | EUR 20,000 |
|
||||||
|
| <= EUR 4,000,000 | EUR 26,000 |
|
||||||
|
| <= EUR 5,000,000 | EUR 32,000 |
|
||||||
|
| <= EUR 6,000,000 | EUR 39,000 |
|
||||||
|
| <= EUR 7,000,000 | EUR 46,000 |
|
||||||
|
| <= EUR 8,000,000 | EUR 52,000 |
|
||||||
|
| <= EUR 9,000,000 | EUR 58,000 |
|
||||||
|
| <= EUR 10,000,000 | EUR 65,000 |
|
||||||
|
| <= EUR 15,000,000 | EUR 75,000 |
|
||||||
|
| <= EUR 20,000,000 | EUR 100,000 |
|
||||||
|
| <= EUR 25,000,000 | EUR 125,000 |
|
||||||
|
| <= EUR 30,000,000 | EUR 150,000 |
|
||||||
|
| <= EUR 50,000,000 | EUR 250,000 |
|
||||||
|
| > EUR 50,000,000 | EUR 325,000 |
|
||||||
|
|
||||||
|
Source: Maiwald MAIinsight April 2025 (verified against commentedupc.com and official UPC fee table AC/05/08072022).
|
||||||
|
|
||||||
|
### 2026 Value-Based Fee Table (estimated)
|
||||||
|
|
||||||
|
The 2026 amendment increases value-based fees by ~32% at first instance and ~45% on appeal. Applying the 32% increase:
|
||||||
|
|
||||||
|
| Value of the Action | Old Fee | New Fee (est. ~32%) |
|
||||||
|
|---|---|---|
|
||||||
|
| <= EUR 500,000 | EUR 0 | EUR 0 |
|
||||||
|
| <= EUR 750,000 | EUR 2,500 | ~EUR 3,300 |
|
||||||
|
| <= EUR 1,000,000 | EUR 4,000 | ~EUR 5,300 |
|
||||||
|
| <= EUR 1,500,000 | EUR 8,000 | ~EUR 10,600 |
|
||||||
|
| <= EUR 2,000,000 | EUR 13,000 | ~EUR 17,200 |
|
||||||
|
| <= EUR 3,000,000 | EUR 20,000 | ~EUR 26,400 |
|
||||||
|
| <= EUR 5,000,000 | EUR 32,000 | ~EUR 42,200 |
|
||||||
|
| <= EUR 10,000,000 | EUR 65,000 | ~EUR 85,800 |
|
||||||
|
| <= EUR 30,000,000 | EUR 150,000 | ~EUR 198,000 |
|
||||||
|
| <= EUR 50,000,000 | EUR 250,000 | ~EUR 330,000 |
|
||||||
|
| > EUR 50,000,000 | EUR 325,000 | ~EUR 429,000 |
|
||||||
|
|
||||||
|
**Note**: The official consolidated 2026 fee table PDF was not extractable (403 on UPC website). The Maiwald blog confirms: for a dispute valued at EUR 5M, total court fees (fixed + value-based) went from EUR 43,000 to EUR 44,600, suggesting the 2026 value-based fee for <=5M is EUR 30,000 (i.e., 14,600 + 30,000 = 44,600). This is close to the ~32% estimate. When the exact 2026 table becomes available, these estimates should be replaced.
|
||||||
|
|
||||||
|
**Verified 2026 data point** (Secerna): For a typical infringement action valued at EUR 2,000,000, total court fees increase from EUR 24,000 to EUR 31,800. This means: new value-based fee for <=2M = 31,800 - 14,600 = EUR 17,200 (vs. old: 13,000 + 11,000 = 24,000).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Appeal Fees (Court of Appeal)
|
||||||
|
|
||||||
|
### Pre-2026
|
||||||
|
|
||||||
|
| Appeal Type | Fixed Fee | Value-Based Fee |
|
||||||
|
|---|---|---|
|
||||||
|
| Appeal on infringement action | EUR 11,000 | Same table as CFI |
|
||||||
|
| Appeal on counterclaim for infringement | EUR 11,000 | Same table as CFI |
|
||||||
|
| Appeal on non-infringement declaration | EUR 11,000 | Same table as CFI |
|
||||||
|
| Appeal on license compensation | EUR 11,000 | Same table as CFI |
|
||||||
|
| Appeal on damages determination | EUR 3,000 | Same table as CFI |
|
||||||
|
| Appeal on revocation action | EUR 20,000 | None |
|
||||||
|
| Appeal on counterclaim for revocation | Same as first instance | None |
|
||||||
|
| Appeal on provisional measures | EUR 11,000 | None |
|
||||||
|
| Appeal on other interlocutory orders | EUR 3,000 | None |
|
||||||
|
| Application for rehearing | EUR 2,500 | None |
|
||||||
|
| Appeal on cost decision | EUR 3,000 | None |
|
||||||
|
| Leave to appeal costs | EUR 1,500 | None |
|
||||||
|
| Discretionary review request | EUR 350 | None |
|
||||||
|
|
||||||
|
### 2026 Appeal Changes
|
||||||
|
|
||||||
|
- Fixed fees: ~33% increase (same as CFI)
|
||||||
|
- Value-based fees: ~45% increase (10% more than CFI increase)
|
||||||
|
- Revocation appeal: EUR 20,000 -> EUR 29,200 (+46%)
|
||||||
|
- Rehearing application: EUR 2,500 -> EUR 14,600 (+484%)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Recoverable Costs (Attorney Fee Ceilings)
|
||||||
|
|
||||||
|
Per Rule 152(2) RoP and the Administrative Committee's Scale of Ceilings. These are caps on what the winning party can recover from the losing party for legal representation costs. Apply **per instance**.
|
||||||
|
|
||||||
|
| Value of the Proceedings | Ceiling for Recoverable Costs |
|
||||||
|
|---|---|
|
||||||
|
| <= EUR 250,000 | EUR 38,000 |
|
||||||
|
| <= EUR 500,000 | EUR 56,000 |
|
||||||
|
| <= EUR 1,000,000 | EUR 112,000 |
|
||||||
|
| <= EUR 2,000,000 | EUR 200,000 |
|
||||||
|
| <= EUR 4,000,000 | EUR 400,000 |
|
||||||
|
| <= EUR 8,000,000 | EUR 600,000 |
|
||||||
|
| <= EUR 16,000,000 | EUR 800,000 |
|
||||||
|
| <= EUR 30,000,000 | EUR 1,200,000 |
|
||||||
|
| <= EUR 50,000,000 | EUR 1,500,000 |
|
||||||
|
| > EUR 50,000,000 | EUR 2,000,000 |
|
||||||
|
|
||||||
|
### Ceiling Adjustments
|
||||||
|
|
||||||
|
The court may **raise** the ceiling upon party request:
|
||||||
|
- Up to **50% increase** for cases valued up to EUR 1 million
|
||||||
|
- Up to **25% increase** for cases valued EUR 1-50 million
|
||||||
|
- Up to an **absolute cap of EUR 5 million** for cases valued above EUR 50 million
|
||||||
|
|
||||||
|
The court may **lower** the ceiling if it would threaten the economic existence of a party (SMEs, non-profits, universities, individuals).
|
||||||
|
|
||||||
|
### What is recoverable
|
||||||
|
|
||||||
|
- Lawyer fees (Rechtsanwalt)
|
||||||
|
- Patent attorney fees (Patentanwalt)
|
||||||
|
- Expert fees
|
||||||
|
- Witness costs
|
||||||
|
- Interpreter and translator fees
|
||||||
|
- Note: Expert/translator/witness fees are NOT subject to the ceiling -- they must be reasonable but are recoverable on top of the ceiling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. SME / Micro-Enterprise Reductions
|
||||||
|
|
||||||
|
### Pre-2026
|
||||||
|
- Small and micro enterprises: **40% reduction** on all court fees (pay 60%)
|
||||||
|
- Conditions per Rule 370(8) RoP
|
||||||
|
|
||||||
|
### 2026
|
||||||
|
- Increased to **50% reduction** (pay 50%)
|
||||||
|
|
||||||
|
### Legal Aid
|
||||||
|
- Available in principle under Rule 375 et seq. RoP
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Comparison: UPC vs. German National Courts
|
||||||
|
|
||||||
|
### German Court Fee Calculation (GKG)
|
||||||
|
|
||||||
|
German court fees are calculated using **Anlage 2 zu § 34 GKG**:
|
||||||
|
- Fee table covers Streitwert up to EUR 500,000 (base fee at 500k: EUR 4,138)
|
||||||
|
- Above EUR 500,000: fee increases by **EUR 210 per each additional EUR 50,000**
|
||||||
|
- This gives a "simple fee" (einfache Gebuehr)
|
||||||
|
- Actual court fees = simple fee x multiplier from Kostenverzeichnis
|
||||||
|
|
||||||
|
**Multipliers** for patent cases:
|
||||||
|
- LG infringement, 1st instance: **3.0x** simple fee
|
||||||
|
- OLG appeal (Berufung): **4.0x** simple fee
|
||||||
|
- BPatG nullity, 1st instance: **4.5x** simple fee (separate proceedings!)
|
||||||
|
- BGH nullity appeal: **6.0x** simple fee
|
||||||
|
|
||||||
|
### GKG Simple Fee Calculation for Key Streitwerte
|
||||||
|
|
||||||
|
Formula for Streitwert > 500,000: `4,138 + ceil((Streitwert - 500,000) / 50,000) * 210`
|
||||||
|
|
||||||
|
| Streitwert | Simple Fee | LG 1st (3.0x) | OLG Appeal (4.0x) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| EUR 500,000 | EUR 4,138 | EUR 12,414 | EUR 16,552 |
|
||||||
|
| EUR 1,000,000 | EUR 6,238 | EUR 18,714 | EUR 24,952 |
|
||||||
|
| EUR 2,000,000 | EUR 10,438 | EUR 31,314 | EUR 41,752 |
|
||||||
|
| EUR 3,000,000 | EUR 14,638 | EUR 43,914 | EUR 58,552 |
|
||||||
|
| EUR 5,000,000 | EUR 23,038 | EUR 69,114 | EUR 92,152 |
|
||||||
|
| EUR 10,000,000 | EUR 44,038 | EUR 132,114 | EUR 176,152 |
|
||||||
|
| EUR 30,000,000 | EUR 128,038 | EUR 384,114 | EUR 512,152 |
|
||||||
|
| EUR 50,000,000 | EUR 212,038 | EUR 636,114 | EUR 848,152 |
|
||||||
|
|
||||||
|
Note: GKG was updated effective 01.06.2025 -- the increment changed from EUR 198 to EUR 210 per 50k.
|
||||||
|
|
||||||
|
### German Attorney Fees (RVG)
|
||||||
|
|
||||||
|
RVG fees use the same Streitwert table (§ 13 RVG with Anlage 2 GKG) but with their own multipliers:
|
||||||
|
- Verfahrensgebuehr (procedural fee): 1.3x
|
||||||
|
- Terminsgebuehr (hearing fee): 1.2x
|
||||||
|
- Einigungsgebuehr (settlement fee): 1.0x (if applicable)
|
||||||
|
- Patent attorney (Patentanwalt) adds same fees again
|
||||||
|
|
||||||
|
Recoverable costs in Germany = court fees + 1 Rechtsanwalt (RVG) + 1 Patentanwalt (RVG).
|
||||||
|
|
||||||
|
### Side-by-Side Comparison
|
||||||
|
|
||||||
|
#### Example: Infringement action, Streitwert EUR 1,000,000
|
||||||
|
|
||||||
|
| Cost Component | UPC (pre-2026) | UPC (2026) | Germany LG |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Court fees | EUR 15,000 | ~EUR 19,900 | EUR 18,714 |
|
||||||
|
| Max recoverable attorney costs | EUR 112,000 | EUR 112,000 | ~EUR 30,000* |
|
||||||
|
| **Total cost risk (loser)** | **~EUR 127,000** | **~EUR 131,900** | **~EUR 48,714** |
|
||||||
|
|
||||||
|
#### Example: Infringement action, Streitwert EUR 3,000,000
|
||||||
|
|
||||||
|
| Cost Component | UPC (pre-2026) | UPC (2026) | Germany LG |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Court fees | EUR 31,000 | ~EUR 41,000 | EUR 43,914 |
|
||||||
|
| Max recoverable attorney costs | EUR 400,000 | EUR 400,000 | ~EUR 100,388** |
|
||||||
|
| **Total cost risk (loser)** | **~EUR 431,000** | **~EUR 441,000** | **~EUR 144,302** |
|
||||||
|
|
||||||
|
*German RVG: 1x RA (1.3 VG + 1.2 TG) + 1x PA (same) + Auslagenpauschale + USt on Streitwert EUR 1M
|
||||||
|
**Maiwald comparison figure for EUR 3M (1x PA + 1x RA, including court fees, based on RVG)
|
||||||
|
|
||||||
|
#### Example: Infringement action, Streitwert EUR 10,000,000
|
||||||
|
|
||||||
|
| Cost Component | UPC (pre-2026) | UPC (2026) | Germany LG |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Court fees | EUR 76,000 | ~EUR 100,400 | EUR 132,114 |
|
||||||
|
| Max recoverable attorney costs | EUR 600,000 | EUR 600,000 | ~EUR 222,000 |
|
||||||
|
| **Total cost risk (loser)** | **~EUR 676,000** | **~EUR 700,400** | **~EUR 354,114** |
|
||||||
|
|
||||||
|
### Key Insight
|
||||||
|
|
||||||
|
- **Court fees**: German courts are **more expensive** than UPC for court fees alone at moderate Streitwerte (EUR 1-5M). At very high Streitwerte (EUR 10M+), German courts become more expensive still.
|
||||||
|
- **Recoverable attorney costs**: UPC is **dramatically more expensive** -- ceilings are 3-5x higher than German RVG-based costs.
|
||||||
|
- **Total cost risk**: UPC total exposure is typically **2-3x German courts** for the same Streitwert, primarily driven by the high attorney cost ceilings.
|
||||||
|
- **Territorial scope**: UPC covers multiple countries in one action, so the higher cost may be justified by the broader geographic effect compared to a single German LG action.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Implementation Notes for Calculator
|
||||||
|
|
||||||
|
### Data structures needed
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Value-based fee brackets (UPC)
|
||||||
|
interface FeeBracket {
|
||||||
|
maxValue: number; // upper bound of bracket (Infinity for last)
|
||||||
|
fee: number; // EUR amount
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-2026 brackets
|
||||||
|
const upcValueFees2025: FeeBracket[] = [
|
||||||
|
{ maxValue: 500_000, fee: 0 },
|
||||||
|
{ maxValue: 750_000, fee: 2_500 },
|
||||||
|
{ maxValue: 1_000_000, fee: 4_000 },
|
||||||
|
{ maxValue: 1_500_000, fee: 8_000 },
|
||||||
|
{ maxValue: 2_000_000, fee: 13_000 },
|
||||||
|
{ maxValue: 3_000_000, fee: 20_000 },
|
||||||
|
{ maxValue: 4_000_000, fee: 26_000 },
|
||||||
|
{ maxValue: 5_000_000, fee: 32_000 },
|
||||||
|
{ maxValue: 6_000_000, fee: 39_000 },
|
||||||
|
{ maxValue: 7_000_000, fee: 46_000 },
|
||||||
|
{ maxValue: 8_000_000, fee: 52_000 },
|
||||||
|
{ maxValue: 9_000_000, fee: 58_000 },
|
||||||
|
{ maxValue: 10_000_000, fee: 65_000 },
|
||||||
|
{ maxValue: 15_000_000, fee: 75_000 },
|
||||||
|
{ maxValue: 20_000_000, fee: 100_000 },
|
||||||
|
{ maxValue: 25_000_000, fee: 125_000 },
|
||||||
|
{ maxValue: 30_000_000, fee: 150_000 },
|
||||||
|
{ maxValue: 50_000_000, fee: 250_000 },
|
||||||
|
{ maxValue: Infinity, fee: 325_000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Recoverable costs ceilings
|
||||||
|
const upcRecoverableCosts: FeeBracket[] = [
|
||||||
|
{ maxValue: 250_000, fee: 38_000 },
|
||||||
|
{ maxValue: 500_000, fee: 56_000 },
|
||||||
|
{ maxValue: 1_000_000, fee: 112_000 },
|
||||||
|
{ maxValue: 2_000_000, fee: 200_000 },
|
||||||
|
{ maxValue: 4_000_000, fee: 400_000 },
|
||||||
|
{ maxValue: 8_000_000, fee: 600_000 },
|
||||||
|
{ maxValue: 16_000_000, fee: 800_000 },
|
||||||
|
{ maxValue: 30_000_000, fee: 1_200_000 },
|
||||||
|
{ maxValue: 50_000_000, fee: 1_500_000 },
|
||||||
|
{ maxValue: Infinity, fee: 2_000_000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// German GKG simple fee calculation
|
||||||
|
function gkgSimpleFee(streitwert: number): number {
|
||||||
|
if (streitwert <= 500_000) {
|
||||||
|
// Use lookup table from Anlage 2 GKG
|
||||||
|
return gkgLookupTable(streitwert);
|
||||||
|
}
|
||||||
|
// Above 500k: base + 210 EUR per 50k increment
|
||||||
|
const base = 4_138; // simple fee at 500k (as of 2025-06-01)
|
||||||
|
const excess = streitwert - 500_000;
|
||||||
|
const increments = Math.ceil(excess / 50_000);
|
||||||
|
return base + increments * 210;
|
||||||
|
}
|
||||||
|
|
||||||
|
// German court fee = simple fee * multiplier
|
||||||
|
// LG 1st instance infringement: 3.0x
|
||||||
|
// OLG appeal: 4.0x
|
||||||
|
// BPatG nullity 1st: 4.5x
|
||||||
|
// BGH nullity appeal: 6.0x
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fixed fees by action type (for calculator dropdown)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type ActionType =
|
||||||
|
| 'infringement'
|
||||||
|
| 'counterclaim_infringement'
|
||||||
|
| 'non_infringement'
|
||||||
|
| 'license_compensation'
|
||||||
|
| 'determine_damages'
|
||||||
|
| 'revocation_standalone'
|
||||||
|
| 'counterclaim_revocation'
|
||||||
|
| 'provisional_measures'
|
||||||
|
| 'preserve_evidence'
|
||||||
|
| 'inspection_order'
|
||||||
|
| 'freeze_assets'
|
||||||
|
| 'protective_letter'
|
||||||
|
| 'protective_letter_extension';
|
||||||
|
|
||||||
|
interface ActionFees {
|
||||||
|
fixedFee2025: number;
|
||||||
|
fixedFee2026: number;
|
||||||
|
hasValueBasedFee: boolean;
|
||||||
|
hasValueBasedFee2026: boolean; // provisional measures gained value-based in 2026
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- Maiwald MAIinsight Issue No. 2, April 2025: "Court fees and recoverable costs at the UPC" (PDF, authoritative practitioner analysis)
|
||||||
|
- UPC Official Table of Court Fees: AC/05/08072022 (original schedule)
|
||||||
|
- UPC Administrative Committee amendment decision, 4 Nov 2025 (2026 changes)
|
||||||
|
- commentedupc.com/table-of-court-fees/ (complete pre-2026 table)
|
||||||
|
- Scale of Ceilings for Recoverable Costs: D-AC/10/24042023
|
||||||
|
- GKG Anlage 2 zu § 34 (German court fee table, as of 01.06.2025)
|
||||||
|
- Secerna: "Major Financial Overhaul: Significant UPC Fee Increase" (2026 data points)
|
||||||
|
- Casalonga: "Unified Patent Court: Significant Increase in Court Costs in 2026"
|
||||||
|
- Bird & Bird: "Unified Patent Court Fees increase from 1 January 2026"
|
||||||
|
- Vossius: "Costs and Cost Risk in UPC Proceedings"
|
||||||
|
- Haug Partners: "A U.S. View on the UPC -- Part 5: Of Costs and Fees"
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-dropzone": "^15.0.0",
|
"react-dropzone": "^15.0.0",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -244,6 +245,8 @@
|
|||||||
|
|
||||||
"@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="],
|
"@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-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=="],
|
"@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=="],
|
"@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/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=="],
|
"@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/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/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||||
|
|
||||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
"@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/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=="],
|
"@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=="],
|
"@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=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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": ["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-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=="],
|
"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-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=="],
|
"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=="],
|
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||||
@@ -646,6 +701,8 @@
|
|||||||
|
|
||||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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": ["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=="],
|
"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=="],
|
"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=="],
|
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||||
|
|
||||||
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
"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=="],
|
"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": ["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=="],
|
"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=="],
|
"@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/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=="],
|
"@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/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=="],
|
"sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-dropzone": "^15.0.0",
|
"react-dropzone": "^15.0.0",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
"sonner": "^2.0.7"
|
"sonner": "^2.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
164
frontend/src/app/(app)/abrechnung/page.tsx
Normal file
164
frontend/src/app/(app)/abrechnung/page.tsx
Normal 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 →
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
225
frontend/src/app/(app)/abrechnung/rechnungen/[id]/page.tsx
Normal file
225
frontend/src/app/(app)/abrechnung/rechnungen/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
frontend/src/app/(app)/abrechnung/rechnungen/page.tsx
Normal file
118
frontend/src/app/(app)/abrechnung/rechnungen/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
262
frontend/src/app/(app)/berichte/page.tsx
Normal file
262
frontend/src/app/(app)/berichte/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
frontend/src/app/(app)/cases/[id]/ki/page.tsx
Normal file
51
frontend/src/app/(app)/cases/[id]/ki/page.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { Brain, FileText, Search } from "lucide-react";
|
||||||
|
import { CaseStrategy } from "@/components/ai/CaseStrategy";
|
||||||
|
import { DocumentDrafter } from "@/components/ai/DocumentDrafter";
|
||||||
|
import { SimilarCaseFinder } from "@/components/ai/SimilarCaseFinder";
|
||||||
|
|
||||||
|
type AITab = "strategy" | "draft" | "similar";
|
||||||
|
|
||||||
|
const TABS: { id: AITab; label: string; icon: typeof Brain }[] = [
|
||||||
|
{ id: "strategy", label: "KI-Strategie", icon: Brain },
|
||||||
|
{ id: "draft", label: "KI-Entwurf", icon: FileText },
|
||||||
|
{ id: "similar", label: "Aehnliche Faelle", icon: Search },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function CaseAIPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const [activeTab, setActiveTab] = useState<AITab>("strategy");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Sub-tabs */}
|
||||||
|
<div className="mb-6 flex gap-1 rounded-lg border border-neutral-200 bg-neutral-50 p-1">
|
||||||
|
{TABS.map((tab) => {
|
||||||
|
const isActive = activeTab === tab.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`inline-flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? "bg-white text-neutral-900 shadow-sm"
|
||||||
|
: "text-neutral-500 hover:text-neutral-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<tab.icon className="h-4 w-4" />
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{activeTab === "strategy" && <CaseStrategy caseId={id} />}
|
||||||
|
{activeTab === "draft" && <DocumentDrafter caseId={id} />}
|
||||||
|
{activeTab === "similar" && <SimilarCaseFinder caseId={id} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
StickyNote,
|
StickyNote,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
|
Brain,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { de } from "date-fns/locale";
|
import { de } from "date-fns/locale";
|
||||||
@@ -48,6 +49,7 @@ const TABS = [
|
|||||||
{ segment: "mitarbeiter", label: "Mitarbeiter", icon: UserCheck },
|
{ segment: "mitarbeiter", label: "Mitarbeiter", icon: UserCheck },
|
||||||
{ segment: "notizen", label: "Notizen", icon: StickyNote },
|
{ segment: "notizen", label: "Notizen", icon: StickyNote },
|
||||||
{ segment: "protokoll", label: "Protokoll", icon: ScrollText },
|
{ segment: "protokoll", label: "Protokoll", icon: ScrollText },
|
||||||
|
{ segment: "ki", label: "KI", icon: Brain },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const TAB_LABELS: Record<string, string> = {
|
const TAB_LABELS: Record<string, string> = {
|
||||||
@@ -58,6 +60,7 @@ const TAB_LABELS: Record<string, string> = {
|
|||||||
mitarbeiter: "Mitarbeiter",
|
mitarbeiter: "Mitarbeiter",
|
||||||
notizen: "Notizen",
|
notizen: "Notizen",
|
||||||
protokoll: "Protokoll",
|
protokoll: "Protokoll",
|
||||||
|
ki: "KI",
|
||||||
};
|
};
|
||||||
|
|
||||||
function CaseDetailSkeleton() {
|
function CaseDetailSkeleton() {
|
||||||
|
|||||||
306
frontend/src/app/(app)/cases/[id]/zeiterfassung/page.tsx
Normal file
306
frontend/src/app/(app)/cases/[id]/zeiterfassung/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
frontend/src/app/(app)/einstellungen/abrechnung/page.tsx
Normal file
166
frontend/src/app/(app)/einstellungen/abrechnung/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,61 +1,29 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { DeadlineCalculator } from "@/components/deadlines/DeadlineCalculator";
|
import { FristenRechner } from "@/components/deadlines/FristenRechner";
|
||||||
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" />
|
Zurueck 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">
|
Verfahrensart waehlen, Fristen einsehen und Termine berechnen
|
||||||
{mode === "wizard"
|
</p>
|
||||||
? "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>
|
</div>
|
||||||
|
|
||||||
{mode === "wizard" ? <DeadlineWizard /> : <DeadlineCalculator />}
|
<FristenRechner />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Sidebar } from "@/components/layout/Sidebar";
|
import { Sidebar } from "@/components/layout/Sidebar";
|
||||||
import { Header } from "@/components/layout/Header";
|
import { Header } from "@/components/layout/Header";
|
||||||
|
import { DemoBanner } from "@/components/layout/DemoBanner";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ export default function AppLayout({
|
|||||||
<div className="flex h-screen overflow-hidden bg-neutral-50">
|
<div className="flex h-screen overflow-hidden bg-neutral-50">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<DemoBanner />
|
||||||
<Header />
|
<Header />
|
||||||
<main className="flex-1 overflow-y-auto p-4 sm:p-6">{children}</main>
|
<main className="flex-1 overflow-y-auto p-4 sm:p-6">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
174
frontend/src/app/(app)/vorlagen/[id]/page.tsx
Normal file
174
frontend/src/app/(app)/vorlagen/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
177
frontend/src/app/(app)/vorlagen/[id]/render/page.tsx
Normal file
177
frontend/src/app/(app)/vorlagen/[id]/render/page.tsx
Normal 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 “{template.name}” 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
frontend/src/app/(app)/vorlagen/neu/page.tsx
Normal file
46
frontend/src/app/(app)/vorlagen/neu/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
frontend/src/app/(app)/vorlagen/page.tsx
Normal file
121
frontend/src/app/(app)/vorlagen/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,12 +5,22 @@ import { api } from "@/lib/api";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
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() {
|
export default function RegisterPage() {
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [firmName, setFirmName] = useState("");
|
const [firmName, setFirmName] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showFirmName, setShowFirmName] = useState(true);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
|
|
||||||
@@ -34,8 +44,30 @@ export default function RegisterPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Create tenant via backend (the backend adds the user as owner)
|
|
||||||
if (data.session) {
|
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 {
|
try {
|
||||||
await api.post("/tenants", { name: firmName });
|
await api.post("/tenants", { name: firmName });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -68,23 +100,27 @@ export default function RegisterPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleRegister} className="space-y-4">
|
<form onSubmit={handleRegister} className="space-y-4">
|
||||||
<div>
|
{showFirmName && (
|
||||||
<label
|
<div>
|
||||||
htmlFor="firm"
|
<label
|
||||||
className="block text-sm font-medium text-neutral-700"
|
htmlFor="firm"
|
||||||
>
|
className="block text-sm font-medium text-neutral-700"
|
||||||
Kanzleiname
|
>
|
||||||
</label>
|
Kanzleiname
|
||||||
<input
|
</label>
|
||||||
id="firm"
|
<input
|
||||||
type="text"
|
id="firm"
|
||||||
value={firmName}
|
type="text"
|
||||||
onChange={(e) => setFirmName(e.target.value)}
|
value={firmName}
|
||||||
required
|
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"
|
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"
|
placeholder="Muster & Partner Rechtsanwaelte"
|
||||||
/>
|
/>
|
||||||
</div>
|
<p className="mt-1 text-xs text-neutral-400">
|
||||||
|
Leer lassen, falls Sie zu einer bestehenden Kanzlei eingeladen wurden
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
|
|||||||
226
frontend/src/components/ai/CaseStrategy.tsx
Normal file
226
frontend/src/components/ai/CaseStrategy.tsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { StrategyRecommendation } from "@/lib/types";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Brain,
|
||||||
|
AlertTriangle,
|
||||||
|
ArrowRight,
|
||||||
|
Shield,
|
||||||
|
Calendar,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface CaseStrategyProps {
|
||||||
|
caseId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIORITY_STYLES = {
|
||||||
|
high: "bg-red-50 text-red-700 border-red-200",
|
||||||
|
medium: "bg-amber-50 text-amber-700 border-amber-200",
|
||||||
|
low: "bg-emerald-50 text-emerald-700 border-emerald-200",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const IMPORTANCE_STYLES = {
|
||||||
|
critical: "border-l-red-500",
|
||||||
|
important: "border-l-amber-500",
|
||||||
|
routine: "border-l-neutral-300",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function CaseStrategy({ caseId }: CaseStrategyProps) {
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
api.post<StrategyRecommendation>("/ai/case-strategy", {
|
||||||
|
case_id: caseId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!mutation.data && !mutation.isPending && !mutation.isError) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
||||||
|
<div className="rounded-xl bg-neutral-100 p-3">
|
||||||
|
<Brain className="h-6 w-6 text-neutral-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-neutral-900">
|
||||||
|
KI-Strategieanalyse
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-neutral-500">
|
||||||
|
Claude analysiert die Akte und gibt strategische Empfehlungen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => mutation.mutate()}
|
||||||
|
className="inline-flex items-center gap-2 rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
|
||||||
|
>
|
||||||
|
<Brain className="h-4 w-4" />
|
||||||
|
Strategie analysieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mutation.isPending) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-12 text-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-neutral-400" />
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
Claude analysiert die Akte...
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-neutral-400">
|
||||||
|
Dies kann bis zu 30 Sekunden dauern.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mutation.isError) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
||||||
|
<div className="rounded-xl bg-red-50 p-3">
|
||||||
|
<AlertTriangle className="h-6 w-6 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-neutral-900">Analyse fehlgeschlagen</p>
|
||||||
|
<button
|
||||||
|
onClick={() => mutation.mutate()}
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-neutral-500 transition-colors hover:text-neutral-700"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
Erneut versuchen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = mutation.data!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-neutral-900">
|
||||||
|
KI-Strategieanalyse
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => mutation.mutate()}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-neutral-200 px-2.5 py-1.5 text-xs font-medium text-neutral-600 transition-colors hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
Aktualisieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="rounded-md border border-blue-100 bg-blue-50 px-4 py-3 text-sm text-blue-800">
|
||||||
|
{data.summary}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Next Steps */}
|
||||||
|
{data.next_steps?.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
|
<ArrowRight className="h-3.5 w-3.5" />
|
||||||
|
Naechste Schritte
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data.next_steps.map((step, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="rounded-md border border-neutral-200 bg-white px-4 py-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span
|
||||||
|
className={`mt-0.5 inline-block shrink-0 rounded-full border px-2 py-0.5 text-xs font-medium ${PRIORITY_STYLES[step.priority]}`}
|
||||||
|
>
|
||||||
|
{step.priority === "high"
|
||||||
|
? "Hoch"
|
||||||
|
: step.priority === "medium"
|
||||||
|
? "Mittel"
|
||||||
|
: "Niedrig"}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-neutral-900">
|
||||||
|
{step.action}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-neutral-500">
|
||||||
|
{step.reasoning}
|
||||||
|
</p>
|
||||||
|
{step.deadline && (
|
||||||
|
<p className="mt-1 text-xs text-neutral-400">
|
||||||
|
Frist: {step.deadline}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Risk Assessment */}
|
||||||
|
{data.risk_assessment?.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
|
<Shield className="h-3.5 w-3.5" />
|
||||||
|
Risikobewertung
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data.risk_assessment.map((risk, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="rounded-md border border-neutral-200 bg-white px-4 py-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span
|
||||||
|
className={`mt-0.5 inline-block shrink-0 rounded-full border px-2 py-0.5 text-xs font-medium ${PRIORITY_STYLES[risk.level]}`}
|
||||||
|
>
|
||||||
|
{risk.level === "high"
|
||||||
|
? "Hoch"
|
||||||
|
: risk.level === "medium"
|
||||||
|
? "Mittel"
|
||||||
|
: "Niedrig"}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-neutral-900">
|
||||||
|
{risk.risk}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-neutral-500">
|
||||||
|
Massnahme: {risk.mitigation}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
|
{data.timeline?.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
|
<Calendar className="h-3.5 w-3.5" />
|
||||||
|
Zeitplan
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{data.timeline.map((item, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`border-l-2 py-2 pl-4 ${IMPORTANCE_STYLES[item.importance]}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="shrink-0 text-xs font-medium text-neutral-400">
|
||||||
|
{item.date}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-neutral-900">{item.event}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
198
frontend/src/components/ai/DocumentDrafter.tsx
Normal file
198
frontend/src/components/ai/DocumentDrafter.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { DocumentDraft, DraftDocumentRequest } from "@/lib/types";
|
||||||
|
import { FileText, Loader2, Copy, Check, Download } from "lucide-react";
|
||||||
|
|
||||||
|
const TEMPLATES = {
|
||||||
|
klageschrift: "Klageschrift",
|
||||||
|
klageerwiderung: "Klageerwiderung",
|
||||||
|
abmahnung: "Abmahnung",
|
||||||
|
schriftsatz: "Schriftsatz",
|
||||||
|
berufung: "Berufungsschrift",
|
||||||
|
antrag: "Antrag",
|
||||||
|
stellungnahme: "Stellungnahme",
|
||||||
|
gutachten: "Gutachten",
|
||||||
|
vertrag: "Vertrag",
|
||||||
|
vollmacht: "Vollmacht",
|
||||||
|
upc_claim: "UPC Statement of Claim",
|
||||||
|
upc_defence: "UPC Statement of Defence",
|
||||||
|
upc_counterclaim: "UPC Counterclaim for Revocation",
|
||||||
|
upc_injunction: "UPC Provisional Measures",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const LANGUAGES = [
|
||||||
|
{ value: "de", label: "Deutsch" },
|
||||||
|
{ value: "en", label: "English" },
|
||||||
|
{ value: "fr", label: "Francais" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"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";
|
||||||
|
|
||||||
|
interface DocumentDrafterProps {
|
||||||
|
caseId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentDrafter({ caseId }: DocumentDrafterProps) {
|
||||||
|
const [templateType, setTemplateType] = useState("");
|
||||||
|
const [instructions, setInstructions] = useState("");
|
||||||
|
const [language, setLanguage] = useState("de");
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (req: DraftDocumentRequest) =>
|
||||||
|
api.post<DocumentDraft>("/ai/draft-document", req),
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!templateType) return;
|
||||||
|
mutation.mutate({
|
||||||
|
case_id: caseId,
|
||||||
|
template_type: templateType,
|
||||||
|
instructions,
|
||||||
|
language,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCopy() {
|
||||||
|
if (mutation.data?.content) {
|
||||||
|
navigator.clipboard.writeText(mutation.data.content);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDownload() {
|
||||||
|
if (!mutation.data?.content) return;
|
||||||
|
const blob = new Blob([mutation.data.content], { type: "text/plain;charset=utf-8" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${templateType}_entwurf.txt`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-neutral-500">
|
||||||
|
Dokumenttyp
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={templateType}
|
||||||
|
onChange={(e) => setTemplateType(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
>
|
||||||
|
<option value="">Dokumenttyp waehlen...</option>
|
||||||
|
{Object.entries(TEMPLATES).map(([key, label]) => (
|
||||||
|
<option key={key} value={key}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-neutral-500">
|
||||||
|
Sprache
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={language}
|
||||||
|
onChange={(e) => setLanguage(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
>
|
||||||
|
{LANGUAGES.map((lang) => (
|
||||||
|
<option key={lang.value} value={lang.value}>
|
||||||
|
{lang.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-neutral-500">
|
||||||
|
Anweisungen (optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={instructions}
|
||||||
|
onChange={(e) => setInstructions(e.target.value)}
|
||||||
|
placeholder="z.B. 'Fokus auf Patentanspruch 1, besonders die technischen Merkmale...'"
|
||||||
|
rows={3}
|
||||||
|
className={inputClass}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!templateType || mutation.isPending}
|
||||||
|
className="inline-flex items-center gap-2 rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-neutral-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{mutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Dokument wird erstellt...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
KI-Entwurf erstellen
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{mutation.isError && (
|
||||||
|
<div className="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
Fehler beim Erstellen des Entwurfs. Bitte versuchen Sie es erneut.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mutation.data && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-medium text-neutral-900">
|
||||||
|
{mutation.data.title}
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-neutral-200 px-2.5 py-1.5 text-xs font-medium text-neutral-600 transition-colors hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<Check className="h-3.5 w-3.5 text-emerald-500" />
|
||||||
|
Kopiert
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
Kopieren
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-neutral-200 px-2.5 py-1.5 text-xs font-medium text-neutral-600 transition-colors hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre className="max-h-[600px] overflow-auto whitespace-pre-wrap rounded-md border border-neutral-200 bg-neutral-50 p-4 text-sm text-neutral-800">
|
||||||
|
{mutation.data.content}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
183
frontend/src/components/ai/SimilarCaseFinder.tsx
Normal file
183
frontend/src/components/ai/SimilarCaseFinder.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { SimilarCasesResponse } from "@/lib/types";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Search,
|
||||||
|
ExternalLink,
|
||||||
|
AlertTriangle,
|
||||||
|
Scale,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface SimilarCaseFinderProps {
|
||||||
|
caseId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"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";
|
||||||
|
|
||||||
|
function RelevanceBadge({ score }: { score: number }) {
|
||||||
|
const pct = Math.round(score * 100);
|
||||||
|
let color = "bg-neutral-100 text-neutral-600";
|
||||||
|
if (pct >= 80) color = "bg-emerald-50 text-emerald-700";
|
||||||
|
else if (pct >= 60) color = "bg-blue-50 text-blue-700";
|
||||||
|
else if (pct >= 40) color = "bg-amber-50 text-amber-700";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-block shrink-0 rounded-full px-2 py-0.5 text-xs font-medium ${color}`}
|
||||||
|
>
|
||||||
|
{pct}%
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SimilarCaseFinder({ caseId }: SimilarCaseFinderProps) {
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (req: { case_id: string; description: string }) =>
|
||||||
|
api.post<SimilarCasesResponse>("/ai/similar-cases", req),
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSearch(e?: React.FormEvent) {
|
||||||
|
e?.preventDefault();
|
||||||
|
mutation.mutate({ case_id: caseId, description });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<form onSubmit={handleSearch} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-neutral-500">
|
||||||
|
Zusaetzliche Beschreibung (optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="z.B. 'SEP-Lizenzierung im Mobilfunkbereich, FRAND-Verteidigung...'"
|
||||||
|
rows={2}
|
||||||
|
className={inputClass}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
className="inline-flex items-center gap-2 rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-neutral-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{mutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Suche laeuft...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
Aehnliche Faelle suchen
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{mutation.isError && (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||||
|
<div className="rounded-xl bg-red-50 p-3">
|
||||||
|
<AlertTriangle className="h-6 w-6 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-neutral-900">Suche fehlgeschlagen</p>
|
||||||
|
<p className="text-xs text-neutral-500">
|
||||||
|
Die youpc.org-Datenbank ist moeglicherweise nicht verfuegbar.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSearch()}
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-neutral-500 transition-colors hover:text-neutral-700"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
Erneut versuchen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mutation.data && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-neutral-500">
|
||||||
|
{mutation.data.count} aehnliche{" "}
|
||||||
|
{mutation.data.count === 1 ? "Fall" : "Faelle"} gefunden
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSearch()}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-neutral-200 px-2.5 py-1.5 text-xs font-medium text-neutral-600 transition-colors hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
Aktualisieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mutation.data.cases?.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center gap-2 py-6 text-center">
|
||||||
|
<Scale className="h-6 w-6 text-neutral-300" />
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
Keine aehnlichen UPC-Faelle gefunden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mutation.data.cases?.map((c, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="rounded-md border border-neutral-200 bg-white px-4 py-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RelevanceBadge score={c.relevance} />
|
||||||
|
<span className="text-xs font-medium text-neutral-400">
|
||||||
|
{c.case_number}
|
||||||
|
</span>
|
||||||
|
{c.url && (
|
||||||
|
<a
|
||||||
|
href={c.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-neutral-400 transition-colors hover:text-neutral-600"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm font-medium text-neutral-900">
|
||||||
|
{c.title}
|
||||||
|
</p>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-x-3 text-xs text-neutral-400">
|
||||||
|
{c.court && <span>{c.court}</span>}
|
||||||
|
{c.date && <span>{c.date}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-2 text-sm text-neutral-600">{c.explanation}</p>
|
||||||
|
|
||||||
|
{c.key_holdings && (
|
||||||
|
<div className="mt-2 rounded border border-neutral-100 bg-neutral-50 px-3 py-2">
|
||||||
|
<p className="text-xs font-medium text-neutral-500">
|
||||||
|
Relevante Entscheidungsgruende
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-neutral-600">
|
||||||
|
{c.key_holdings}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { ProceedingType } from "@/lib/types";
|
||||||
|
|
||||||
const TYPE_OPTIONS = [
|
const JURISDICTION_LABELS: Record<string, string> = {
|
||||||
{ value: "", label: "-- Typ wählen --" },
|
UPC: "UPC-Verfahren",
|
||||||
{ value: "INF", label: "Verletzungsklage (INF)" },
|
DE: "Deutsche Patentverfahren",
|
||||||
{ value: "REV", label: "Widerruf (REV)" },
|
};
|
||||||
{ value: "CCR", label: "Einstweilige Verfügung (CCR)" },
|
|
||||||
{ value: "APP", label: "Berufung (APP)" },
|
|
||||||
{ value: "PI", label: "Vorläufiger Rechtsschutz (PI)" },
|
|
||||||
{ value: "ZPO_CIVIL", label: "ZPO Zivilverfahren" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export interface CaseFormData {
|
export interface CaseFormData {
|
||||||
case_number: string;
|
case_number: string;
|
||||||
@@ -34,6 +32,10 @@ export function CaseForm({
|
|||||||
isSubmitting,
|
isSubmitting,
|
||||||
submitLabel = "Akte anlegen",
|
submitLabel = "Akte anlegen",
|
||||||
}: CaseFormProps) {
|
}: CaseFormProps) {
|
||||||
|
const { data: proceedingTypes } = useQuery({
|
||||||
|
queryKey: ["proceeding-types"],
|
||||||
|
queryFn: () => api.get<ProceedingType[]>("/proceeding-types"),
|
||||||
|
});
|
||||||
const [form, setForm] = useState<CaseFormData>({
|
const [form, setForm] = useState<CaseFormData>({
|
||||||
case_number: initialData?.case_number ?? "",
|
case_number: initialData?.case_number ?? "",
|
||||||
title: initialData?.title ?? "",
|
title: initialData?.title ?? "",
|
||||||
@@ -139,11 +141,24 @@ export function CaseForm({
|
|||||||
onChange={(e) => update("case_type", e.target.value)}
|
onChange={(e) => update("case_type", e.target.value)}
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
>
|
>
|
||||||
{TYPE_OPTIONS.map((o) => (
|
<option value="">-- Typ wählen --</option>
|
||||||
<option key={o.value} value={o.value}>
|
{(() => {
|
||||||
{o.label}
|
const grouped = new Map<string, ProceedingType[]>();
|
||||||
</option>
|
for (const pt of proceedingTypes ?? []) {
|
||||||
))}
|
const key = pt.jurisdiction ?? "Sonstige";
|
||||||
|
if (!grouped.has(key)) grouped.set(key, []);
|
||||||
|
grouped.get(key)!.push(pt);
|
||||||
|
}
|
||||||
|
return Array.from(grouped.entries()).map(([jurisdiction, types]) => (
|
||||||
|
<optgroup key={jurisdiction} label={JURISDICTION_LABELS[jurisdiction] ?? jurisdiction}>
|
||||||
|
{types.map((pt) => (
|
||||||
|
<option key={pt.id} value={pt.code}>
|
||||||
|
{pt.name} ({pt.code})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -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"],
|
||||||
@@ -49,13 +51,28 @@ export function DeadlineCalculator() {
|
|||||||
}) => api.post<CalculateResponse>("/deadlines/calculate", params),
|
}) => api.post<CalculateResponse>("/deadlines/calculate", params),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-calculate when proceeding type changes (using current trigger date)
|
||||||
|
function doCalculate(type: string, date: string) {
|
||||||
|
if (!type || !date) return;
|
||||||
|
calculateMutation.mutate({
|
||||||
|
proceeding_type: type,
|
||||||
|
trigger_event_date: date,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleProceedingChange(newType: string) {
|
||||||
|
setProceedingType(newType);
|
||||||
|
doCalculate(newType, triggerDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDateChange(newDate: string) {
|
||||||
|
setTriggerDate(newDate);
|
||||||
|
if (proceedingType) doCalculate(proceedingType, newDate);
|
||||||
|
}
|
||||||
|
|
||||||
function handleCalculate(e: React.FormEvent) {
|
function handleCalculate(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!proceedingType || !triggerDate) return;
|
doCalculate(proceedingType, triggerDate);
|
||||||
calculateMutation.mutate({
|
|
||||||
proceeding_type: proceedingType,
|
|
||||||
trigger_event_date: triggerDate,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = calculateMutation.data;
|
const results = calculateMutation.data;
|
||||||
@@ -78,16 +95,48 @@ export function DeadlineCalculator() {
|
|||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={proceedingType}
|
value={proceedingType}
|
||||||
onChange={(e) => setProceedingType(e.target.value)}
|
onChange={(e) => handleProceedingChange(e.target.value)}
|
||||||
disabled={typesLoading}
|
disabled={typesLoading}
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
>
|
>
|
||||||
<option value="">Bitte wählen...</option>
|
<option value="">Bitte wählen...</option>
|
||||||
{proceedingTypes?.map((pt) => (
|
{(() => {
|
||||||
<option key={pt.id} value={pt.code}>
|
const types = proceedingTypes ?? [];
|
||||||
{pt.name} ({pt.code})
|
const categoryLabels: Record<string, string> = {
|
||||||
</option>
|
hauptverfahren: "Hauptverfahren",
|
||||||
))}
|
im_verfahren: "Verfahren im Verfahren",
|
||||||
|
rechtsbehelf: "Rechtsbehelfe",
|
||||||
|
};
|
||||||
|
const jurisdictionLabels: Record<string, string> = {
|
||||||
|
UPC: "UPC",
|
||||||
|
DE: "Deutsche Patentverfahren",
|
||||||
|
};
|
||||||
|
// Group by jurisdiction + category
|
||||||
|
const groups: { key: string; label: string; items: typeof types }[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const pt of types) {
|
||||||
|
const j = pt.jurisdiction ?? "Sonstige";
|
||||||
|
const c = pt.category ?? "hauptverfahren";
|
||||||
|
const key = `${j}::${c}`;
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
const jLabel = jurisdictionLabels[j] ?? j;
|
||||||
|
const cLabel = categoryLabels[c] ?? c;
|
||||||
|
const label = j === "DE" ? jLabel : `${jLabel} — ${cLabel}`;
|
||||||
|
groups.push({ key, label, items: [] });
|
||||||
|
}
|
||||||
|
groups.find((g) => g.key === key)!.items.push(pt);
|
||||||
|
}
|
||||||
|
return groups.map((g) => (
|
||||||
|
<optgroup key={g.key} label={g.label}>
|
||||||
|
{g.items.map((pt) => (
|
||||||
|
<option key={pt.id} value={pt.code}>
|
||||||
|
{pt.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -97,7 +146,7 @@ export function DeadlineCalculator() {
|
|||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={triggerDate}
|
value={triggerDate}
|
||||||
onChange={(e) => setTriggerDate(e.target.value)}
|
onChange={(e) => handleDateChange(e.target.value)}
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -276,32 +276,53 @@ export function DeadlineWizard() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
|
<div className="mt-4 space-y-4">
|
||||||
{typesLoading ? (
|
{typesLoading ? (
|
||||||
<div className="col-span-full flex justify-center py-4">
|
<div className="flex justify-center py-4">
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
|
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
proceedingTypes?.map((pt) => (
|
(() => {
|
||||||
<button
|
const grouped = new Map<string, ProceedingType[]>();
|
||||||
key={pt.id}
|
for (const pt of proceedingTypes ?? []) {
|
||||||
onClick={() => handleTypeSelect(pt.code)}
|
const key = pt.jurisdiction ?? "Sonstige";
|
||||||
className={`rounded-lg border px-3 py-2.5 text-left transition-all ${
|
if (!grouped.has(key)) grouped.set(key, []);
|
||||||
selectedType === pt.code
|
grouped.get(key)!.push(pt);
|
||||||
? "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"
|
const labels: Record<string, string> = {
|
||||||
}`}
|
UPC: "UPC-Verfahren",
|
||||||
>
|
DE: "Deutsche Patentverfahren",
|
||||||
<div className="flex items-center gap-1.5">
|
};
|
||||||
<div
|
return Array.from(grouped.entries()).map(([jurisdiction, types]) => (
|
||||||
className="h-2 w-2 rounded-full"
|
<div key={jurisdiction}>
|
||||||
style={{ backgroundColor: pt.default_color }}
|
<div className="mb-2 text-xs font-medium text-neutral-500">
|
||||||
/>
|
{labels[jurisdiction] ?? jurisdiction}
|
||||||
<span className="text-xs font-semibold">{pt.code}</span>
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
|
||||||
|
{types.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>
|
</div>
|
||||||
<div className="mt-1 text-xs leading-tight opacity-80">{pt.name}</div>
|
));
|
||||||
</button>
|
})()
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -444,7 +465,7 @@ export function DeadlineWizard() {
|
|||||||
<Scale className="h-6 w-6 text-neutral-400" />
|
<Scale className="h-6 w-6 text-neutral-400" />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-sm font-medium text-neutral-700">
|
<p className="mt-3 text-sm font-medium text-neutral-700">
|
||||||
UPC-Fristenbestimmung
|
Fristenbestimmung
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 max-w-sm text-xs text-neutral-500">
|
<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.
|
Waehlen Sie die Verfahrensart und geben Sie das Datum des ausloesenden Ereignisses ein.
|
||||||
|
|||||||
602
frontend/src/components/deadlines/FristenRechner.tsx
Normal file
602
frontend/src/components/deadlines/FristenRechner.tsx
Normal file
@@ -0,0 +1,602 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type {
|
||||||
|
ProceedingType,
|
||||||
|
RuleTreeNode,
|
||||||
|
CalculateResponse,
|
||||||
|
Case,
|
||||||
|
} from "@/lib/types";
|
||||||
|
import { format, parseISO } from "date-fns";
|
||||||
|
import { de } from "date-fns/locale";
|
||||||
|
import {
|
||||||
|
Scale,
|
||||||
|
Users,
|
||||||
|
Gavel,
|
||||||
|
FileText,
|
||||||
|
Clock,
|
||||||
|
CalendarDays,
|
||||||
|
AlertTriangle,
|
||||||
|
ChevronRight,
|
||||||
|
RotateCcw,
|
||||||
|
Loader2,
|
||||||
|
Check,
|
||||||
|
FolderOpen,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState, 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 "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FlatRule {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
duration_value: number;
|
||||||
|
duration_unit: string;
|
||||||
|
rule_code?: string;
|
||||||
|
primary_party?: string;
|
||||||
|
event_type?: string;
|
||||||
|
is_mandatory: boolean;
|
||||||
|
deadline_notes?: string;
|
||||||
|
description?: string;
|
||||||
|
depth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenRuleTree(nodes: RuleTreeNode[], depth = 0): FlatRule[] {
|
||||||
|
const result: FlatRule[] = [];
|
||||||
|
for (const node of nodes) {
|
||||||
|
result.push({
|
||||||
|
id: node.id,
|
||||||
|
name: node.name,
|
||||||
|
duration_value: node.duration_value,
|
||||||
|
duration_unit: node.duration_unit,
|
||||||
|
rule_code: node.rule_code,
|
||||||
|
primary_party: node.primary_party,
|
||||||
|
event_type: node.event_type,
|
||||||
|
is_mandatory: node.is_mandatory,
|
||||||
|
deadline_notes: node.deadline_notes,
|
||||||
|
description: node.description,
|
||||||
|
depth,
|
||||||
|
});
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
result.push(...flattenRuleTree(node.children, depth + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Group labels ---
|
||||||
|
|
||||||
|
const categoryLabels: Record<string, string> = {
|
||||||
|
hauptverfahren: "Hauptverfahren",
|
||||||
|
im_verfahren: "Verfahren im Verfahren",
|
||||||
|
rechtsbehelf: "Rechtsbehelfe",
|
||||||
|
};
|
||||||
|
|
||||||
|
const jurisdictionLabels: Record<string, string> = {
|
||||||
|
UPC: "UPC",
|
||||||
|
DE: "Deutsche Patentverfahren",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TypeGroup {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
items: ProceedingType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupProceedingTypes(types: ProceedingType[]): TypeGroup[] {
|
||||||
|
const groups: TypeGroup[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const pt of types) {
|
||||||
|
const j = pt.jurisdiction ?? "Sonstige";
|
||||||
|
const c = pt.category ?? "hauptverfahren";
|
||||||
|
const key = `${j}::${c}`;
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
const jLabel = jurisdictionLabels[j] ?? j;
|
||||||
|
const cLabel = categoryLabels[c] ?? c;
|
||||||
|
const label = j === "DE" ? jLabel : `${jLabel} — ${cLabel}`;
|
||||||
|
groups.push({ key, label, items: [] });
|
||||||
|
}
|
||||||
|
groups.find((g) => g.key === key)!.items.push(pt);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main Component ---
|
||||||
|
|
||||||
|
export function FristenRechner() {
|
||||||
|
const [selectedType, setSelectedType] = useState<string | null>(null);
|
||||||
|
const [expandedRuleId, setExpandedRuleId] = useState<string | null>(null);
|
||||||
|
const [triggerDate, setTriggerDate] = useState(
|
||||||
|
new Date().toISOString().split("T")[0],
|
||||||
|
);
|
||||||
|
const [calcResults, setCalcResults] = useState<
|
||||||
|
Record<string, { due_date: string; original_due_date: string; was_adjusted: boolean }>
|
||||||
|
>({});
|
||||||
|
const [savingRuleId, setSavingRuleId] = useState<string | null>(null);
|
||||||
|
const [selectedCaseId, setSelectedCaseId] = useState<string>("");
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Fetch proceeding types
|
||||||
|
const { data: proceedingTypes, isLoading: typesLoading } = useQuery({
|
||||||
|
queryKey: ["proceeding-types"],
|
||||||
|
queryFn: () => api.get<ProceedingType[]>("/proceeding-types"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch rule tree when type is selected
|
||||||
|
const { data: ruleTree, isLoading: rulesLoading } = useQuery({
|
||||||
|
queryKey: ["deadline-rules", selectedType],
|
||||||
|
queryFn: () => api.get<RuleTreeNode[]>(`/deadline-rules/${selectedType}`),
|
||||||
|
enabled: !!selectedType,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch cases for "save to case"
|
||||||
|
const { data: cases } = useQuery({
|
||||||
|
queryKey: ["cases"],
|
||||||
|
queryFn: () => api.get<Case[]>("/cases"),
|
||||||
|
enabled: savingRuleId !== null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate single deadline
|
||||||
|
const calcMutation = useMutation({
|
||||||
|
mutationFn: (params: {
|
||||||
|
proceeding_type: string;
|
||||||
|
trigger_event_date: string;
|
||||||
|
selected_rule_ids: string[];
|
||||||
|
}) => api.post<CalculateResponse>("/deadlines/calculate", params),
|
||||||
|
onSuccess: (data, variables) => {
|
||||||
|
if (data.deadlines && data.deadlines.length > 0) {
|
||||||
|
const d = data.deadlines[0];
|
||||||
|
setCalcResults((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[variables.selected_rule_ids[0]]: {
|
||||||
|
due_date: d.due_date,
|
||||||
|
original_due_date: d.original_due_date,
|
||||||
|
was_adjusted: d.was_adjusted,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save deadline to case
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: (params: {
|
||||||
|
caseId: string;
|
||||||
|
deadline: { title: string; due_date: string; original_due_date?: string; rule_code?: string };
|
||||||
|
}) =>
|
||||||
|
api.post(`/cases/${params.caseId}/deadlines`, {
|
||||||
|
title: params.deadline.title,
|
||||||
|
due_date: params.deadline.due_date,
|
||||||
|
original_due_date: params.deadline.original_due_date,
|
||||||
|
rule_code: params.deadline.rule_code,
|
||||||
|
source: "calculator",
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Frist auf Akte gespeichert");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["deadlines"] });
|
||||||
|
setSavingRuleId(null);
|
||||||
|
setSelectedCaseId("");
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Fehler beim Speichern");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Flat list of rules
|
||||||
|
const flatRules = useMemo(() => {
|
||||||
|
if (!ruleTree) return [];
|
||||||
|
return flattenRuleTree(ruleTree);
|
||||||
|
}, [ruleTree]);
|
||||||
|
|
||||||
|
// Groups
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
if (!proceedingTypes) return [];
|
||||||
|
return groupProceedingTypes(proceedingTypes);
|
||||||
|
}, [proceedingTypes]);
|
||||||
|
|
||||||
|
const selectedPT = proceedingTypes?.find((pt) => pt.code === selectedType);
|
||||||
|
|
||||||
|
function handleTypeSelect(code: string) {
|
||||||
|
setSelectedType(code);
|
||||||
|
setExpandedRuleId(null);
|
||||||
|
setCalcResults({});
|
||||||
|
setSavingRuleId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
setSelectedType(null);
|
||||||
|
setExpandedRuleId(null);
|
||||||
|
setCalcResults({});
|
||||||
|
setSavingRuleId(null);
|
||||||
|
setSelectedCaseId("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRuleClick(ruleId: string) {
|
||||||
|
if (expandedRuleId === ruleId) {
|
||||||
|
setExpandedRuleId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExpandedRuleId(ruleId);
|
||||||
|
setSavingRuleId(null);
|
||||||
|
// Auto-calculate with current date
|
||||||
|
if (selectedType && triggerDate) {
|
||||||
|
calcMutation.mutate({
|
||||||
|
proceeding_type: selectedType,
|
||||||
|
trigger_event_date: triggerDate,
|
||||||
|
selected_rule_ids: [ruleId],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDateChange(ruleId: string, date: string) {
|
||||||
|
setTriggerDate(date);
|
||||||
|
if (selectedType && date) {
|
||||||
|
calcMutation.mutate({
|
||||||
|
proceeding_type: selectedType,
|
||||||
|
trigger_event_date: date,
|
||||||
|
selected_rule_ids: [ruleId],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Step 1: Proceeding Type Cards */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-medium text-neutral-900">
|
||||||
|
Verfahrensart waehlen
|
||||||
|
</h2>
|
||||||
|
{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>
|
||||||
|
|
||||||
|
{typesLoading ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<div key={group.key}>
|
||||||
|
<div className="mb-2 text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||||
|
{group.label}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{group.items.map((pt) => {
|
||||||
|
const isSelected = selectedType === pt.code;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={pt.id}
|
||||||
|
onClick={() => handleTypeSelect(pt.code)}
|
||||||
|
className={`rounded-lg border px-3 py-2 text-sm transition-all ${
|
||||||
|
isSelected
|
||||||
|
? "border-neutral-900 bg-neutral-900 text-white shadow-sm"
|
||||||
|
: "border-neutral-200 bg-white text-neutral-700 hover:border-neutral-300 hover:shadow-sm"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pt.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 2: Deadline Rules for Selected Type */}
|
||||||
|
{selectedType && (
|
||||||
|
<div className="animate-fade-in">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-medium text-neutral-900">
|
||||||
|
Fristen: {selectedPT?.name}
|
||||||
|
</h2>
|
||||||
|
{flatRules.length > 0 && (
|
||||||
|
<p className="mt-0.5 text-xs text-neutral-500">
|
||||||
|
{flatRules.length} Fristen — Frist anklicken zum Berechnen
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rulesLoading ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
|
||||||
|
</div>
|
||||||
|
) : flatRules.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed border-neutral-300 bg-white px-6 py-8 text-center text-sm text-neutral-500">
|
||||||
|
Keine Fristenregeln fuer diesen Verfahrenstyp hinterlegt.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white divide-y divide-neutral-100">
|
||||||
|
{flatRules.map((rule, i) => {
|
||||||
|
const isExpanded = expandedRuleId === rule.id;
|
||||||
|
const result = calcResults[rule.id];
|
||||||
|
const duration = formatDuration(rule.duration_value, rule.duration_unit);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={rule.id}>
|
||||||
|
{/* Rule Row */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleRuleClick(rule.id)}
|
||||||
|
className={`flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-neutral-50 ${
|
||||||
|
isExpanded ? "bg-neutral-50" : ""
|
||||||
|
}`}
|
||||||
|
style={{ paddingLeft: `${16 + rule.depth * 20}px` }}
|
||||||
|
>
|
||||||
|
{/* Timeline dot + connector */}
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div
|
||||||
|
className={`h-2.5 w-2.5 shrink-0 rounded-full border-2 ${
|
||||||
|
isExpanded
|
||||||
|
? "border-neutral-900 bg-neutral-900"
|
||||||
|
: "border-neutral-300 bg-white"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{i < flatRules.length - 1 && (
|
||||||
|
<div className="mt-0.5 h-3 w-px bg-neutral-200" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-neutral-900">
|
||||||
|
{rule.name}
|
||||||
|
</span>
|
||||||
|
{!rule.is_mandatory && (
|
||||||
|
<span className="rounded bg-neutral-100 px-1 py-0.5 text-[10px] text-neutral-400">
|
||||||
|
optional
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-neutral-500">
|
||||||
|
{duration && (
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{duration}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{rule.rule_code && (
|
||||||
|
<>
|
||||||
|
<span className="text-neutral-300">·</span>
|
||||||
|
<span className="rounded bg-neutral-100 px-1 py-0.5 font-mono text-[10px]">
|
||||||
|
{rule.rule_code}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{rule.primary_party && (
|
||||||
|
<>
|
||||||
|
<span className="text-neutral-300">·</span>
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
{getPartyIcon(rule.primary_party)}
|
||||||
|
{getPartyLabel(rule.primary_party)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chevron */}
|
||||||
|
<ChevronRight
|
||||||
|
className={`h-4 w-4 shrink-0 text-neutral-400 transition-transform ${
|
||||||
|
isExpanded ? "rotate-90" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Step 3: Expanded Calculation Panel */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="border-t border-neutral-100 bg-neutral-50 px-4 py-4 animate-fade-in"
|
||||||
|
style={{ paddingLeft: `${36 + rule.depth * 20}px` }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||||
|
{/* Date picker */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-neutral-500">
|
||||||
|
Ausloesedatum
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={triggerDate}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleDateChange(rule.id, e.target.value)
|
||||||
|
}
|
||||||
|
className="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>
|
||||||
|
|
||||||
|
{/* Result */}
|
||||||
|
{calcMutation.isPending &&
|
||||||
|
calcMutation.variables?.selected_rule_ids[0] === rule.id ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-neutral-500">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Berechne...
|
||||||
|
</div>
|
||||||
|
) : result ? (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-neutral-500">
|
||||||
|
Fristende
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CalendarDays className="h-4 w-4 text-neutral-700" />
|
||||||
|
<span className="text-lg font-semibold tabular-nums text-neutral-900">
|
||||||
|
{format(
|
||||||
|
parseISO(result.due_date),
|
||||||
|
"dd. MMMM yyyy",
|
||||||
|
{ locale: de },
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{result.was_adjusted && (
|
||||||
|
<div className="mt-0.5 flex items-center gap-1 text-xs text-amber-600">
|
||||||
|
<AlertTriangle className="h-3 w-3" />
|
||||||
|
Angepasst (Original:{" "}
|
||||||
|
{format(
|
||||||
|
parseISO(result.original_due_date),
|
||||||
|
"dd.MM.yyyy",
|
||||||
|
)}
|
||||||
|
)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save to case button */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSavingRuleId(
|
||||||
|
savingRuleId === rule.id ? null : rule.id,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 rounded-md border border-neutral-200 bg-white px-2.5 py-1.5 text-xs font-medium text-neutral-700 transition-colors hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
<FolderOpen className="h-3.5 w-3.5" />
|
||||||
|
Auf Akte
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save to case panel */}
|
||||||
|
{savingRuleId === rule.id && result && (
|
||||||
|
<div className="mt-3 flex items-center gap-2 animate-fade-in">
|
||||||
|
<select
|
||||||
|
value={selectedCaseId}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSelectedCaseId(e.target.value)
|
||||||
|
}
|
||||||
|
className="flex-1 rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-xs 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 || saveMutation.isPending
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
saveMutation.mutate({
|
||||||
|
caseId: selectedCaseId,
|
||||||
|
deadline: {
|
||||||
|
title: rule.name,
|
||||||
|
due_date: result.due_date,
|
||||||
|
original_due_date: result.was_adjusted
|
||||||
|
? result.original_due_date
|
||||||
|
: undefined,
|
||||||
|
rule_code: rule.rule_code,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 rounded-md bg-neutral-900 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-neutral-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{rule.deadline_notes && (
|
||||||
|
<p className="mt-2 text-xs italic text-neutral-400">
|
||||||
|
{rule.deadline_notes}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{!selectedType && !typesLoading && (
|
||||||
|
<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">
|
||||||
|
Fristenrechner
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 max-w-sm text-xs text-neutral-500">
|
||||||
|
Waehlen Sie oben eine Verfahrensart, um alle zugehoerigen Fristen
|
||||||
|
anzuzeigen und einzelne Termine zu berechnen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Calculation error */}
|
||||||
|
{calcMutation.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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
frontend/src/components/layout/DemoBanner.tsx
Normal file
17
frontend/src/components/layout/DemoBanner.tsx
Normal 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">—</span>
|
||||||
|
<span>Keine echten Mandantendaten eingeben</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
Calendar,
|
Calendar,
|
||||||
Brain,
|
Brain,
|
||||||
|
BarChart3,
|
||||||
Settings,
|
Settings,
|
||||||
Menu,
|
Menu,
|
||||||
X,
|
X,
|
||||||
@@ -27,6 +28,7 @@ const allNavigation: NavItem[] = [
|
|||||||
{ name: "Akten", href: "/cases", icon: FolderOpen },
|
{ name: "Akten", href: "/cases", icon: FolderOpen },
|
||||||
{ name: "Fristen", href: "/fristen", icon: Clock },
|
{ name: "Fristen", href: "/fristen", icon: Clock },
|
||||||
{ name: "Termine", href: "/termine", icon: Calendar },
|
{ name: "Termine", href: "/termine", icon: Calendar },
|
||||||
|
{ name: "Berichte", href: "/berichte", icon: BarChart3 },
|
||||||
{ name: "AI Analyse", href: "/ai/extract", icon: Brain, permission: "ai_extraction" },
|
{ name: "AI Analyse", href: "/ai/extract", icon: Brain, permission: "ai_extraction" },
|
||||||
{ name: "Einstellungen", href: "/einstellungen", icon: Settings, permission: "manage_settings" },
|
{ name: "Einstellungen", href: "/einstellungen", icon: Settings, permission: "manage_settings" },
|
||||||
];
|
];
|
||||||
|
|||||||
240
frontend/src/components/reports/BillingTab.tsx
Normal file
240
frontend/src/components/reports/BillingTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
223
frontend/src/components/reports/CasesTab.tsx
Normal file
223
frontend/src/components/reports/CasesTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
204
frontend/src/components/reports/DeadlinesTab.tsx
Normal file
204
frontend/src/components/reports/DeadlinesTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
frontend/src/components/reports/WorkloadTab.tsx
Normal file
187
frontend/src/components/reports/WorkloadTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -164,7 +164,7 @@ export function TeamSettings() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-neutral-900">
|
<p className="text-sm font-medium text-neutral-900">
|
||||||
{member.user_id.slice(0, 8)}...
|
{member.email || member.user_id.slice(0, 8) + "..."}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-neutral-500">{roleInfo.label}</p>
|
<p className="text-xs text-neutral-500">{roleInfo.label}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
161
frontend/src/components/templates/TemplateEditor.tsx
Normal file
161
frontend/src/components/templates/TemplateEditor.tsx
Normal 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 Schreiben Sie hier den Vorlageninhalt... 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
|||||||
@@ -25,5 +25,6 @@ export function usePermissions() {
|
|||||||
isLoading,
|
isLoading,
|
||||||
userId: data?.user_id ?? null,
|
userId: data?.user_id ?? null,
|
||||||
tenantId: data?.tenant_id ?? null,
|
tenantId: data?.tenant_id ?? null,
|
||||||
|
isDemo: data?.is_demo ?? false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface UserTenant {
|
|||||||
user_id: string;
|
user_id: string;
|
||||||
tenant_id: string;
|
tenant_id: string;
|
||||||
role: string;
|
role: string;
|
||||||
|
email: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +197,7 @@ export interface ProceedingType {
|
|||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
jurisdiction?: string;
|
jurisdiction?: string;
|
||||||
|
category?: string;
|
||||||
default_color: string;
|
default_color: string;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
@@ -266,6 +268,7 @@ export interface UserInfo {
|
|||||||
tenant_id: string;
|
tenant_id: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
|
is_demo: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserRole = "owner" | "partner" | "associate" | "paralegal" | "secretary";
|
export type UserRole = "owner" | "partner" | "associate" | "paralegal" | "secretary";
|
||||||
@@ -352,31 +355,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 {
|
||||||
@@ -393,3 +371,329 @@ export interface ExtractionResponse {
|
|||||||
deadlines: ExtractedDeadline[];
|
deadlines: ExtractedDeadline[];
|
||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AI Document Drafting
|
||||||
|
|
||||||
|
export interface DocumentDraft {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
language: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DraftDocumentRequest {
|
||||||
|
case_id: string;
|
||||||
|
template_type: string;
|
||||||
|
instructions: string;
|
||||||
|
language: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TEMPLATE_TYPES: Record<string, string> = {
|
||||||
|
klageschrift: "Klageschrift",
|
||||||
|
klageerwiderung: "Klageerwiderung",
|
||||||
|
abmahnung: "Abmahnung",
|
||||||
|
schriftsatz: "Schriftsatz",
|
||||||
|
berufung: "Berufungsschrift",
|
||||||
|
antrag: "Antrag",
|
||||||
|
stellungnahme: "Stellungnahme",
|
||||||
|
gutachten: "Gutachten",
|
||||||
|
vertrag: "Vertrag",
|
||||||
|
vollmacht: "Vollmacht",
|
||||||
|
upc_claim: "UPC Statement of Claim",
|
||||||
|
upc_defence: "UPC Statement of Defence",
|
||||||
|
upc_counterclaim: "UPC Counterclaim for Revocation",
|
||||||
|
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 {
|
||||||
|
priority: "high" | "medium" | "low";
|
||||||
|
action: string;
|
||||||
|
reasoning: string;
|
||||||
|
deadline?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RiskItem {
|
||||||
|
level: "high" | "medium" | "low";
|
||||||
|
risk: string;
|
||||||
|
mitigation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineItem {
|
||||||
|
date: string;
|
||||||
|
event: string;
|
||||||
|
importance: "critical" | "important" | "routine";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StrategyRecommendation {
|
||||||
|
summary: string;
|
||||||
|
next_steps: StrategyStep[];
|
||||||
|
risk_assessment: RiskItem[];
|
||||||
|
timeline: TimelineItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI Similar Case Finder
|
||||||
|
|
||||||
|
export interface SimilarCase {
|
||||||
|
case_number: string;
|
||||||
|
title: string;
|
||||||
|
court: string;
|
||||||
|
date: string;
|
||||||
|
relevance: number;
|
||||||
|
explanation: string;
|
||||||
|
key_holdings: string;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user