Compare commits

...

16 Commits

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

Endpoint groups tested:
- Health, Auth (JWT validation, expired/invalid/wrong-secret)
- Current user (GET /api/me)
- Tenants (CRUD, auto-assign)
- Cases (CRUD with search/status filters)
- Parties (CRUD)
- Deadlines (CRUD, complete, batch create)
- Appointments (CRUD)
- Notes (CRUD)
- Dashboard
- Proceeding types & deadline rules
- Deadline calculator & determination (timeline, determine)
- Reports (cases, deadlines, workload, billing)
- Templates (CRUD, render)
- Time entries (CRUD, summary)
- Invoices (CRUD, status update)
- Billing rates (list, upsert)
- Notifications (list, unread count, mark read, preferences)
- Audit log (list, filtered)
- Case assignments (assign, unassign)
- Documents (list, meta)
- AI endpoints (availability check)
- Critical path E2E (case -> deadline -> appointment -> note -> time entry -> dashboard -> complete)
2026-03-30 14:41:59 +02:00
m
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
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
14 changed files with 1938 additions and 336 deletions

6
.gitignore vendored
View File

@@ -46,3 +46,9 @@ tmp/
# TypeScript
*.tsbuildinfo
.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
AnthropicAPIKey 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) {
@@ -26,6 +33,12 @@ func Load() (*Config, error) {
SupabaseJWTSecret: os.Getenv("SUPABASE_JWT_SECRET"),
AnthropicAPIKey: os.Getenv("ANTHROPIC_API_KEY"),
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 == "" {

File diff suppressed because it is too large Load Diff

View File

@@ -39,6 +39,7 @@ type ProceedingType struct {
Name string `db:"name" json:"name"`
Description *string `db:"description" json:"description,omitempty"`
Jurisdiction *string `db:"jurisdiction" json:"jurisdiction,omitempty"`
Category *string `db:"category" json:"category,omitempty"`
DefaultColor string `db:"default_color" json:"default_color"`
SortOrder int `db:"sort_order" json:"sort_order"`
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"`
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
Role string `db:"role" json:"role"`
Email string `db:"email" json:"email"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}

View File

@@ -2,9 +2,12 @@ package services
import (
"context"
"crypto/tls"
"fmt"
"log/slog"
"os/exec"
"net"
"net/smtp"
"os"
"strings"
"sync"
"time"
@@ -457,18 +460,85 @@ type UpdatePreferencesInput struct {
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 {
cmd := exec.Command("m", "mail", "send",
"--to", to,
"--subject", subject,
"--body", body,
"--yes")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("m mail send failed: %w (output: %s)", err, string(output))
host := os.Getenv("SMTP_HOST")
port := os.Getenv("SMTP_PORT")
user := os.Getenv("SMTP_USER")
pass := os.Getenv("SMTP_PASS")
from := os.Getenv("MAIL_FROM")
if port == "" {
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
}

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) {
var members []models.UserTenant
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,
)
if err != nil {

View File

@@ -1,17 +1,12 @@
"use client";
import { DeadlineCalculator } from "@/components/deadlines/DeadlineCalculator";
import { DeadlineWizard } from "@/components/deadlines/DeadlineWizard";
import { FristenRechner } from "@/components/deadlines/FristenRechner";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
export default function FristenrechnerPage() {
const [mode, setMode] = useState<"wizard" | "quick">("wizard");
return (
<div className="animate-fade-in space-y-4">
<div className="flex items-start justify-between">
<div>
<Link
href="/fristen"
@@ -21,41 +16,14 @@ export default function FristenrechnerPage() {
Zurueck zu Fristen
</Link>
<h1 className="text-lg font-semibold text-neutral-900">
Fristenbestimmung
Fristenrechner
</h1>
<p className="mt-0.5 text-sm text-neutral-500">
{mode === "wizard"
? "Vollstaendige Verfahrens-Timeline mit automatischer Fristenberechnung"
: "Schnellberechnung einzelner Fristen nach Verfahrensart"}
Verfahrensart waehlen, Fristen einsehen und Termine berechnen
</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>
{mode === "wizard" ? <DeadlineWizard /> : <DeadlineCalculator />}
<FristenRechner />
</div>
);
}

View File

@@ -1,16 +1,14 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type { ProceedingType } from "@/lib/types";
const TYPE_OPTIONS = [
{ value: "", label: "-- Typ wählen --" },
{ value: "INF", label: "Verletzungsklage (INF)" },
{ 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" },
];
const JURISDICTION_LABELS: Record<string, string> = {
UPC: "UPC-Verfahren",
DE: "Deutsche Patentverfahren",
};
export interface CaseFormData {
case_number: string;
@@ -34,6 +32,10 @@ export function CaseForm({
isSubmitting,
submitLabel = "Akte anlegen",
}: CaseFormProps) {
const { data: proceedingTypes } = useQuery({
queryKey: ["proceeding-types"],
queryFn: () => api.get<ProceedingType[]>("/proceeding-types"),
});
const [form, setForm] = useState<CaseFormData>({
case_number: initialData?.case_number ?? "",
title: initialData?.title ?? "",
@@ -139,11 +141,24 @@ export function CaseForm({
onChange={(e) => update("case_type", e.target.value)}
className={inputClass}
>
{TYPE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
<option value="">-- Typ wählen --</option>
{(() => {
const grouped = new Map<string, ProceedingType[]>();
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>
</div>
<div>

View File

@@ -51,13 +51,28 @@ export function DeadlineCalculator() {
}) => 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) {
e.preventDefault();
if (!proceedingType || !triggerDate) return;
calculateMutation.mutate({
proceeding_type: proceedingType,
trigger_event_date: triggerDate,
});
doCalculate(proceedingType, triggerDate);
}
const results = calculateMutation.data;
@@ -80,16 +95,48 @@ export function DeadlineCalculator() {
</label>
<select
value={proceedingType}
onChange={(e) => setProceedingType(e.target.value)}
onChange={(e) => handleProceedingChange(e.target.value)}
disabled={typesLoading}
className={inputClass}
>
<option value="">Bitte wählen...</option>
{proceedingTypes?.map((pt) => (
{(() => {
const types = proceedingTypes ?? [];
const categoryLabels: Record<string, string> = {
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} ({pt.code})
{pt.name}
</option>
))}
</optgroup>
));
})()}
</select>
</div>
<div>
@@ -99,7 +146,7 @@ export function DeadlineCalculator() {
<input
type="date"
value={triggerDate}
onChange={(e) => setTriggerDate(e.target.value)}
onChange={(e) => handleDateChange(e.target.value)}
className={inputClass}
/>
</div>

View File

@@ -276,13 +276,30 @@ export function DeadlineWizard() {
)}
</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 ? (
<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" />
</div>
) : (
proceedingTypes?.map((pt) => (
(() => {
const grouped = new Map<string, ProceedingType[]>();
for (const pt of proceedingTypes ?? []) {
const key = pt.jurisdiction ?? "Sonstige";
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key)!.push(pt);
}
const labels: Record<string, string> = {
UPC: "UPC-Verfahren",
DE: "Deutsche Patentverfahren",
};
return Array.from(grouped.entries()).map(([jurisdiction, types]) => (
<div key={jurisdiction}>
<div className="mb-2 text-xs font-medium text-neutral-500">
{labels[jurisdiction] ?? jurisdiction}
</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)}
@@ -301,7 +318,11 @@ export function DeadlineWizard() {
</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" />
</div>
<p className="mt-3 text-sm font-medium text-neutral-700">
UPC-Fristenbestimmung
Fristenbestimmung
</p>
<p className="mt-1 max-w-sm text-xs text-neutral-500">
Waehlen Sie die Verfahrensart und geben Sie das Datum des ausloesenden Ereignisses ein.

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>
<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 className="text-xs text-neutral-500">{roleInfo.label}</p>
</div>

View File

@@ -15,6 +15,7 @@ export interface UserTenant {
user_id: string;
tenant_id: string;
role: string;
email: string;
created_at: string;
}
@@ -196,6 +197,7 @@ export interface ProceedingType {
name: string;
description?: string;
jurisdiction?: string;
category?: string;
default_color: string;
sort_order: number;
is_active: boolean;