From 899b4618332066543799d32af51e50b23c2d0d6b Mon Sep 17 00:00:00 2001 From: m Date: Mon, 30 Mar 2026 20:55:46 +0200 Subject: [PATCH] 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. --- .../src/app/(app)/fristen/rechner/page.tsx | 64 +- .../components/deadlines/FristenRechner.tsx | 602 ++++++++++++++++++ 2 files changed, 618 insertions(+), 48 deletions(-) create mode 100644 frontend/src/components/deadlines/FristenRechner.tsx diff --git a/frontend/src/app/(app)/fristen/rechner/page.tsx b/frontend/src/app/(app)/fristen/rechner/page.tsx index f4c3f75..29ac2b3 100644 --- a/frontend/src/app/(app)/fristen/rechner/page.tsx +++ b/frontend/src/app/(app)/fristen/rechner/page.tsx @@ -1,61 +1,29 @@ "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 (
-
-
- - - Zurueck zu Fristen - -

- Fristenbestimmung -

-

- {mode === "wizard" - ? "Vollstaendige Verfahrens-Timeline mit automatischer Fristenberechnung" - : "Schnellberechnung einzelner Fristen nach Verfahrensart"} -

-
- - {/* Mode toggle */} -
- - -
+
+ + + Zurueck zu Fristen + +

+ Fristenrechner +

+

+ Verfahrensart waehlen, Fristen einsehen und Termine berechnen +

- {mode === "wizard" ? : } +
); } diff --git a/frontend/src/components/deadlines/FristenRechner.tsx b/frontend/src/components/deadlines/FristenRechner.tsx new file mode 100644 index 0000000..40ffccc --- /dev/null +++ b/frontend/src/components/deadlines/FristenRechner.tsx @@ -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 = { + 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 ; + case "defendant": + return ; + case "court": + return ; + default: + return ; + } +} + +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 = { + hauptverfahren: "Hauptverfahren", + im_verfahren: "Verfahren im Verfahren", + rechtsbehelf: "Rechtsbehelfe", +}; + +const jurisdictionLabels: Record = { + 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(); + 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(null); + const [expandedRuleId, setExpandedRuleId] = useState(null); + const [triggerDate, setTriggerDate] = useState( + new Date().toISOString().split("T")[0], + ); + const [calcResults, setCalcResults] = useState< + Record + >({}); + const [savingRuleId, setSavingRuleId] = useState(null); + const [selectedCaseId, setSelectedCaseId] = useState(""); + const queryClient = useQueryClient(); + + // Fetch proceeding types + const { data: proceedingTypes, isLoading: typesLoading } = useQuery({ + queryKey: ["proceeding-types"], + queryFn: () => api.get("/proceeding-types"), + }); + + // Fetch rule tree when type is selected + const { data: ruleTree, isLoading: rulesLoading } = useQuery({ + queryKey: ["deadline-rules", selectedType], + queryFn: () => api.get(`/deadline-rules/${selectedType}`), + enabled: !!selectedType, + }); + + // Fetch cases for "save to case" + const { data: cases } = useQuery({ + queryKey: ["cases"], + queryFn: () => api.get("/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("/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 ( +
+ {/* Step 1: Proceeding Type Cards */} +
+
+

+ Verfahrensart waehlen +

+ {selectedType && ( + + )} +
+ + {typesLoading ? ( +
+ +
+ ) : ( +
+ {groups.map((group) => ( +
+
+ {group.label} +
+
+ {group.items.map((pt) => { + const isSelected = selectedType === pt.code; + return ( + + ); + })} +
+
+ ))} +
+ )} +
+ + {/* Step 2: Deadline Rules for Selected Type */} + {selectedType && ( +
+
+
+

+ Fristen: {selectedPT?.name} +

+ {flatRules.length > 0 && ( +

+ {flatRules.length} Fristen — Frist anklicken zum Berechnen +

+ )} +
+
+ + {rulesLoading ? ( +
+ +
+ ) : flatRules.length === 0 ? ( +
+ Keine Fristenregeln fuer diesen Verfahrenstyp hinterlegt. +
+ ) : ( +
+ {flatRules.map((rule, i) => { + const isExpanded = expandedRuleId === rule.id; + const result = calcResults[rule.id]; + const duration = formatDuration(rule.duration_value, rule.duration_unit); + + return ( +
+ {/* Rule Row */} + + + {/* Step 3: Expanded Calculation Panel */} + {isExpanded && ( +
+
+ {/* Date picker */} +
+ + + 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" + /> +
+ + {/* Result */} + {calcMutation.isPending && + calcMutation.variables?.selected_rule_ids[0] === rule.id ? ( +
+ + Berechne... +
+ ) : result ? ( +
+
+
+ Fristende +
+
+ + + {format( + parseISO(result.due_date), + "dd. MMMM yyyy", + { locale: de }, + )} + +
+ {result.was_adjusted && ( +
+ + Angepasst (Original:{" "} + {format( + parseISO(result.original_due_date), + "dd.MM.yyyy", + )} + ) +
+ )} +
+ + {/* Save to case button */} + +
+ ) : null} +
+ + {/* Save to case panel */} + {savingRuleId === rule.id && result && ( +
+ + +
+ )} + + {/* Notes */} + {rule.deadline_notes && ( +

+ {rule.deadline_notes} +

+ )} +
+ )} +
+ ); + })} +
+ )} +
+ )} + + {/* Empty state */} + {!selectedType && !typesLoading && ( +
+
+ +
+

+ Fristenrechner +

+

+ Waehlen Sie oben eine Verfahrensart, um alle zugehoerigen Fristen + anzuzeigen und einzelne Termine zu berechnen. +

+
+ )} + + {/* Calculation error */} + {calcMutation.isError && ( +
+ + Fehler bei der Berechnung. Bitte Eingaben pruefen. +
+ )} +
+ ); +}