Compare commits

..

17 Commits

Author SHA1 Message Date
m
3599e302df feat: redesign Fristenrechner — cards, no tabs, inline calculation 2026-03-30 21:00:14 +02:00
m
899b461833 feat: redesign Fristenrechner as single-flow card-based UI
Replace the Schnell/Wizard tab layout with a unified flow:
1. Proceeding type selection via compact clickable cards grouped
   by jurisdiction + category (UPC Hauptverfahren, im Verfahren,
   Rechtsbehelfe, Deutsche Patentverfahren)
2. Vertical deadline rule list for the selected type showing name,
   duration, rule code, and acting party
3. Inline expansion on click with date picker, auto-calculated due
   date (via selected_rule_ids API), holiday/weekend adjustment
   note, and save-to-case option

Old DeadlineCalculator.tsx and DeadlineWizard.tsx are no longer
imported but kept for reference.
2026-03-30 20:55:46 +02:00
m
260f65ea02 feat: auto-calculate deadlines on proceeding type selection (no click needed) 2026-03-30 19:41:02 +02:00
m
501b573967 fix: use typed category field instead of Record cast 2026-03-30 19:37:52 +02:00
m
23b8ef4bba chore: gitignore server binary and local state files 2026-03-30 19:34:34 +02:00
m
54c6eb8dae feat: 15 UPC proceeding types in 3 groups + category field
Added 10 new UPC types: DNI, EPO, AMD, CCI, EVP, DAM, COS, REH, DEF, RST.
Grouped as: Hauptverfahren / Verfahren im Verfahren / Rechtsbehelfe.
Frontend dropdown shows sub-groups within jurisdiction. German names throughout.
2026-03-30 19:34:07 +02:00
m
967f2f6d09 feat: direct SMTP email sending via Hostinger (replaces m CLI) 2026-03-30 17:28:40 +02:00
m
e5387734aa fix: use mgmt@msbls.de as default MAIL_FROM (alias now exists) 2026-03-30 17:28:11 +02:00
m
6cb87c6868 feat: replace m CLI email with direct SMTP over TLS
The m CLI isn't available in Docker containers. Replace exec.Command("m", "mail", "send")
with direct SMTP using crypto/tls + net/smtp (implicit TLS on port 465).

Env vars: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, MAIL_FROM
Gracefully skips sending if SMTP is not configured.

Note: mgmt@msbls.de rejected by Hostinger as not owned by mail@msbls.de.
Default from address set to mail@msbls.de until alias is created.
2026-03-30 17:23:54 +02:00
m
d38719db2f fix: add email field to UserTenant TypeScript type 2026-03-30 17:19:15 +02:00
m
b21efccfb5 fix: add MAIL_FROM env (default mgmt@msbls.de) + graceful fallback when m CLI unavailable 2026-03-30 17:10:25 +02:00
m
f51d189a3b fix: show member email instead of UUID in team management 2026-03-30 17:09:14 +02:00
m
481b299e03 test: comprehensive integration tests for all API endpoints 2026-03-30 14:43:32 +02:00
m
40a11a4c49 feat: group proceeding types by jurisdiction (UPC/DE) + add German patent proceedings 2026-03-30 14:33:28 +02:00
m
eca0cde5e7 fix: timeline 404 + calculate endpoint fixes 2026-03-30 14:32:51 +02:00
m
cf3711b2e4 fix: update seed files to use mgmt schema after migration
The search_path was changed from kanzlai to mgmt but seed files
still referenced the old schema. Also added missing is_spawn and
spawn_label columns to mgmt.deadline_rules via direct DB migration.

Root cause of timeline 404 / calculate+determine 400: the ruleColumns
query selected is_spawn and spawn_label which didn't exist in the
mgmt.deadline_rules table, causing all deadline rule queries to fail.
2026-03-30 14:30:40 +02:00
m
dea49f6f8e feat: group proceeding types by jurisdiction in UI dropdowns
- DeadlineCalculator: use optgroup to group by UPC/DE
- DeadlineWizard: add section headers for each jurisdiction
- CaseForm: replace hardcoded TYPE_OPTIONS with API-fetched
  proceeding types grouped by jurisdiction
- Added 3 new DE proceeding types to DB: DE_PATENT,
  DE_NULLITY, DE_OPPOSITION
2026-03-30 14:29:42 +02:00
15 changed files with 861 additions and 111 deletions

6
.gitignore vendored
View File

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

View File

@@ -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 == "" {

View File

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

View File

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

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

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

View File

@@ -51,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;
@@ -80,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>
@@ -99,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>

View File

@@ -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.

View 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 &mdash; 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">&middot;</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">&middot;</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>
);
}

View File

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

View File

@@ -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;