Compare commits

...

2 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
2 changed files with 618 additions and 48 deletions

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

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