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.
This commit is contained in:
@@ -1,17 +1,12 @@
|
|||||||
"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"
|
||||||
@@ -21,41 +16,14 @@ export default function FristenrechnerPage() {
|
|||||||
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">
|
||||||
Fristenbestimmung
|
Fristenrechner
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-0.5 text-sm text-neutral-500">
|
<p className="mt-0.5 text-sm text-neutral-500">
|
||||||
{mode === "wizard"
|
Verfahrensart waehlen, Fristen einsehen und Termine berechnen
|
||||||
? "Vollstaendige Verfahrens-Timeline mit automatischer Fristenberechnung"
|
|
||||||
: "Schnellberechnung einzelner Fristen nach Verfahrensart"}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mode toggle */}
|
<FristenRechner />
|
||||||
<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 />}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
602
frontend/src/components/deadlines/FristenRechner.tsx
Normal file
602
frontend/src/components/deadlines/FristenRechner.tsx
Normal file
@@ -0,0 +1,602 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type {
|
||||||
|
ProceedingType,
|
||||||
|
RuleTreeNode,
|
||||||
|
CalculateResponse,
|
||||||
|
Case,
|
||||||
|
} from "@/lib/types";
|
||||||
|
import { format, parseISO } from "date-fns";
|
||||||
|
import { de } from "date-fns/locale";
|
||||||
|
import {
|
||||||
|
Scale,
|
||||||
|
Users,
|
||||||
|
Gavel,
|
||||||
|
FileText,
|
||||||
|
Clock,
|
||||||
|
CalendarDays,
|
||||||
|
AlertTriangle,
|
||||||
|
ChevronRight,
|
||||||
|
RotateCcw,
|
||||||
|
Loader2,
|
||||||
|
Check,
|
||||||
|
FolderOpen,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
function formatDuration(value: number, unit: string): string {
|
||||||
|
if (value === 0) return "";
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
days: value === 1 ? "Tag" : "Tage",
|
||||||
|
weeks: value === 1 ? "Woche" : "Wochen",
|
||||||
|
months: value === 1 ? "Monat" : "Monate",
|
||||||
|
};
|
||||||
|
return `${value} ${labels[unit] || unit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPartyIcon(party?: string) {
|
||||||
|
switch (party) {
|
||||||
|
case "claimant":
|
||||||
|
return <Scale className="h-3.5 w-3.5" />;
|
||||||
|
case "defendant":
|
||||||
|
return <Users className="h-3.5 w-3.5" />;
|
||||||
|
case "court":
|
||||||
|
return <Gavel className="h-3.5 w-3.5" />;
|
||||||
|
default:
|
||||||
|
return <FileText className="h-3.5 w-3.5" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPartyLabel(party?: string): string {
|
||||||
|
switch (party) {
|
||||||
|
case "claimant":
|
||||||
|
return "Klaeger";
|
||||||
|
case "defendant":
|
||||||
|
return "Beklagter";
|
||||||
|
case "court":
|
||||||
|
return "Gericht";
|
||||||
|
case "both":
|
||||||
|
return "Beide Parteien";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FlatRule {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
duration_value: number;
|
||||||
|
duration_unit: string;
|
||||||
|
rule_code?: string;
|
||||||
|
primary_party?: string;
|
||||||
|
event_type?: string;
|
||||||
|
is_mandatory: boolean;
|
||||||
|
deadline_notes?: string;
|
||||||
|
description?: string;
|
||||||
|
depth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenRuleTree(nodes: RuleTreeNode[], depth = 0): FlatRule[] {
|
||||||
|
const result: FlatRule[] = [];
|
||||||
|
for (const node of nodes) {
|
||||||
|
result.push({
|
||||||
|
id: node.id,
|
||||||
|
name: node.name,
|
||||||
|
duration_value: node.duration_value,
|
||||||
|
duration_unit: node.duration_unit,
|
||||||
|
rule_code: node.rule_code,
|
||||||
|
primary_party: node.primary_party,
|
||||||
|
event_type: node.event_type,
|
||||||
|
is_mandatory: node.is_mandatory,
|
||||||
|
deadline_notes: node.deadline_notes,
|
||||||
|
description: node.description,
|
||||||
|
depth,
|
||||||
|
});
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
result.push(...flattenRuleTree(node.children, depth + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Group labels ---
|
||||||
|
|
||||||
|
const categoryLabels: Record<string, string> = {
|
||||||
|
hauptverfahren: "Hauptverfahren",
|
||||||
|
im_verfahren: "Verfahren im Verfahren",
|
||||||
|
rechtsbehelf: "Rechtsbehelfe",
|
||||||
|
};
|
||||||
|
|
||||||
|
const jurisdictionLabels: Record<string, string> = {
|
||||||
|
UPC: "UPC",
|
||||||
|
DE: "Deutsche Patentverfahren",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TypeGroup {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
items: ProceedingType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupProceedingTypes(types: ProceedingType[]): TypeGroup[] {
|
||||||
|
const groups: TypeGroup[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const pt of types) {
|
||||||
|
const j = pt.jurisdiction ?? "Sonstige";
|
||||||
|
const c = pt.category ?? "hauptverfahren";
|
||||||
|
const key = `${j}::${c}`;
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
const jLabel = jurisdictionLabels[j] ?? j;
|
||||||
|
const cLabel = categoryLabels[c] ?? c;
|
||||||
|
const label = j === "DE" ? jLabel : `${jLabel} — ${cLabel}`;
|
||||||
|
groups.push({ key, label, items: [] });
|
||||||
|
}
|
||||||
|
groups.find((g) => g.key === key)!.items.push(pt);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main Component ---
|
||||||
|
|
||||||
|
export function FristenRechner() {
|
||||||
|
const [selectedType, setSelectedType] = useState<string | null>(null);
|
||||||
|
const [expandedRuleId, setExpandedRuleId] = useState<string | null>(null);
|
||||||
|
const [triggerDate, setTriggerDate] = useState(
|
||||||
|
new Date().toISOString().split("T")[0],
|
||||||
|
);
|
||||||
|
const [calcResults, setCalcResults] = useState<
|
||||||
|
Record<string, { due_date: string; original_due_date: string; was_adjusted: boolean }>
|
||||||
|
>({});
|
||||||
|
const [savingRuleId, setSavingRuleId] = useState<string | null>(null);
|
||||||
|
const [selectedCaseId, setSelectedCaseId] = useState<string>("");
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Fetch proceeding types
|
||||||
|
const { data: proceedingTypes, isLoading: typesLoading } = useQuery({
|
||||||
|
queryKey: ["proceeding-types"],
|
||||||
|
queryFn: () => api.get<ProceedingType[]>("/proceeding-types"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch rule tree when type is selected
|
||||||
|
const { data: ruleTree, isLoading: rulesLoading } = useQuery({
|
||||||
|
queryKey: ["deadline-rules", selectedType],
|
||||||
|
queryFn: () => api.get<RuleTreeNode[]>(`/deadline-rules/${selectedType}`),
|
||||||
|
enabled: !!selectedType,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch cases for "save to case"
|
||||||
|
const { data: cases } = useQuery({
|
||||||
|
queryKey: ["cases"],
|
||||||
|
queryFn: () => api.get<Case[]>("/cases"),
|
||||||
|
enabled: savingRuleId !== null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate single deadline
|
||||||
|
const calcMutation = useMutation({
|
||||||
|
mutationFn: (params: {
|
||||||
|
proceeding_type: string;
|
||||||
|
trigger_event_date: string;
|
||||||
|
selected_rule_ids: string[];
|
||||||
|
}) => api.post<CalculateResponse>("/deadlines/calculate", params),
|
||||||
|
onSuccess: (data, variables) => {
|
||||||
|
if (data.deadlines && data.deadlines.length > 0) {
|
||||||
|
const d = data.deadlines[0];
|
||||||
|
setCalcResults((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[variables.selected_rule_ids[0]]: {
|
||||||
|
due_date: d.due_date,
|
||||||
|
original_due_date: d.original_due_date,
|
||||||
|
was_adjusted: d.was_adjusted,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save deadline to case
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: (params: {
|
||||||
|
caseId: string;
|
||||||
|
deadline: { title: string; due_date: string; original_due_date?: string; rule_code?: string };
|
||||||
|
}) =>
|
||||||
|
api.post(`/cases/${params.caseId}/deadlines`, {
|
||||||
|
title: params.deadline.title,
|
||||||
|
due_date: params.deadline.due_date,
|
||||||
|
original_due_date: params.deadline.original_due_date,
|
||||||
|
rule_code: params.deadline.rule_code,
|
||||||
|
source: "calculator",
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Frist auf Akte gespeichert");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["deadlines"] });
|
||||||
|
setSavingRuleId(null);
|
||||||
|
setSelectedCaseId("");
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Fehler beim Speichern");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Flat list of rules
|
||||||
|
const flatRules = useMemo(() => {
|
||||||
|
if (!ruleTree) return [];
|
||||||
|
return flattenRuleTree(ruleTree);
|
||||||
|
}, [ruleTree]);
|
||||||
|
|
||||||
|
// Groups
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
if (!proceedingTypes) return [];
|
||||||
|
return groupProceedingTypes(proceedingTypes);
|
||||||
|
}, [proceedingTypes]);
|
||||||
|
|
||||||
|
const selectedPT = proceedingTypes?.find((pt) => pt.code === selectedType);
|
||||||
|
|
||||||
|
function handleTypeSelect(code: string) {
|
||||||
|
setSelectedType(code);
|
||||||
|
setExpandedRuleId(null);
|
||||||
|
setCalcResults({});
|
||||||
|
setSavingRuleId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
setSelectedType(null);
|
||||||
|
setExpandedRuleId(null);
|
||||||
|
setCalcResults({});
|
||||||
|
setSavingRuleId(null);
|
||||||
|
setSelectedCaseId("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRuleClick(ruleId: string) {
|
||||||
|
if (expandedRuleId === ruleId) {
|
||||||
|
setExpandedRuleId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExpandedRuleId(ruleId);
|
||||||
|
setSavingRuleId(null);
|
||||||
|
// Auto-calculate with current date
|
||||||
|
if (selectedType && triggerDate) {
|
||||||
|
calcMutation.mutate({
|
||||||
|
proceeding_type: selectedType,
|
||||||
|
trigger_event_date: triggerDate,
|
||||||
|
selected_rule_ids: [ruleId],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDateChange(ruleId: string, date: string) {
|
||||||
|
setTriggerDate(date);
|
||||||
|
if (selectedType && date) {
|
||||||
|
calcMutation.mutate({
|
||||||
|
proceeding_type: selectedType,
|
||||||
|
trigger_event_date: date,
|
||||||
|
selected_rule_ids: [ruleId],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Step 1: Proceeding Type Cards */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-medium text-neutral-900">
|
||||||
|
Verfahrensart waehlen
|
||||||
|
</h2>
|
||||||
|
{selectedType && (
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
className="flex items-center gap-1 text-xs text-neutral-500 hover:text-neutral-700"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3 w-3" />
|
||||||
|
Zuruecksetzen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{typesLoading ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<div key={group.key}>
|
||||||
|
<div className="mb-2 text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||||
|
{group.label}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{group.items.map((pt) => {
|
||||||
|
const isSelected = selectedType === pt.code;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={pt.id}
|
||||||
|
onClick={() => handleTypeSelect(pt.code)}
|
||||||
|
className={`rounded-lg border px-3 py-2 text-sm transition-all ${
|
||||||
|
isSelected
|
||||||
|
? "border-neutral-900 bg-neutral-900 text-white shadow-sm"
|
||||||
|
: "border-neutral-200 bg-white text-neutral-700 hover:border-neutral-300 hover:shadow-sm"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pt.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 2: Deadline Rules for Selected Type */}
|
||||||
|
{selectedType && (
|
||||||
|
<div className="animate-fade-in">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-medium text-neutral-900">
|
||||||
|
Fristen: {selectedPT?.name}
|
||||||
|
</h2>
|
||||||
|
{flatRules.length > 0 && (
|
||||||
|
<p className="mt-0.5 text-xs text-neutral-500">
|
||||||
|
{flatRules.length} Fristen — Frist anklicken zum Berechnen
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rulesLoading ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
|
||||||
|
</div>
|
||||||
|
) : flatRules.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed border-neutral-300 bg-white px-6 py-8 text-center text-sm text-neutral-500">
|
||||||
|
Keine Fristenregeln fuer diesen Verfahrenstyp hinterlegt.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white divide-y divide-neutral-100">
|
||||||
|
{flatRules.map((rule, i) => {
|
||||||
|
const isExpanded = expandedRuleId === rule.id;
|
||||||
|
const result = calcResults[rule.id];
|
||||||
|
const duration = formatDuration(rule.duration_value, rule.duration_unit);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={rule.id}>
|
||||||
|
{/* Rule Row */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleRuleClick(rule.id)}
|
||||||
|
className={`flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-neutral-50 ${
|
||||||
|
isExpanded ? "bg-neutral-50" : ""
|
||||||
|
}`}
|
||||||
|
style={{ paddingLeft: `${16 + rule.depth * 20}px` }}
|
||||||
|
>
|
||||||
|
{/* Timeline dot + connector */}
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div
|
||||||
|
className={`h-2.5 w-2.5 shrink-0 rounded-full border-2 ${
|
||||||
|
isExpanded
|
||||||
|
? "border-neutral-900 bg-neutral-900"
|
||||||
|
: "border-neutral-300 bg-white"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{i < flatRules.length - 1 && (
|
||||||
|
<div className="mt-0.5 h-3 w-px bg-neutral-200" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-neutral-900">
|
||||||
|
{rule.name}
|
||||||
|
</span>
|
||||||
|
{!rule.is_mandatory && (
|
||||||
|
<span className="rounded bg-neutral-100 px-1 py-0.5 text-[10px] text-neutral-400">
|
||||||
|
optional
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-neutral-500">
|
||||||
|
{duration && (
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{duration}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{rule.rule_code && (
|
||||||
|
<>
|
||||||
|
<span className="text-neutral-300">·</span>
|
||||||
|
<span className="rounded bg-neutral-100 px-1 py-0.5 font-mono text-[10px]">
|
||||||
|
{rule.rule_code}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{rule.primary_party && (
|
||||||
|
<>
|
||||||
|
<span className="text-neutral-300">·</span>
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
{getPartyIcon(rule.primary_party)}
|
||||||
|
{getPartyLabel(rule.primary_party)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chevron */}
|
||||||
|
<ChevronRight
|
||||||
|
className={`h-4 w-4 shrink-0 text-neutral-400 transition-transform ${
|
||||||
|
isExpanded ? "rotate-90" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Step 3: Expanded Calculation Panel */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="border-t border-neutral-100 bg-neutral-50 px-4 py-4 animate-fade-in"
|
||||||
|
style={{ paddingLeft: `${36 + rule.depth * 20}px` }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||||
|
{/* Date picker */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-neutral-500">
|
||||||
|
Ausloesedatum
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={triggerDate}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleDateChange(rule.id, e.target.value)
|
||||||
|
}
|
||||||
|
className="rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm text-neutral-900 outline-none transition-colors focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Result */}
|
||||||
|
{calcMutation.isPending &&
|
||||||
|
calcMutation.variables?.selected_rule_ids[0] === rule.id ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-neutral-500">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Berechne...
|
||||||
|
</div>
|
||||||
|
) : result ? (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-neutral-500">
|
||||||
|
Fristende
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CalendarDays className="h-4 w-4 text-neutral-700" />
|
||||||
|
<span className="text-lg font-semibold tabular-nums text-neutral-900">
|
||||||
|
{format(
|
||||||
|
parseISO(result.due_date),
|
||||||
|
"dd. MMMM yyyy",
|
||||||
|
{ locale: de },
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{result.was_adjusted && (
|
||||||
|
<div className="mt-0.5 flex items-center gap-1 text-xs text-amber-600">
|
||||||
|
<AlertTriangle className="h-3 w-3" />
|
||||||
|
Angepasst (Original:{" "}
|
||||||
|
{format(
|
||||||
|
parseISO(result.original_due_date),
|
||||||
|
"dd.MM.yyyy",
|
||||||
|
)}
|
||||||
|
)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save to case button */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSavingRuleId(
|
||||||
|
savingRuleId === rule.id ? null : rule.id,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 rounded-md border border-neutral-200 bg-white px-2.5 py-1.5 text-xs font-medium text-neutral-700 transition-colors hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
<FolderOpen className="h-3.5 w-3.5" />
|
||||||
|
Auf Akte
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save to case panel */}
|
||||||
|
{savingRuleId === rule.id && result && (
|
||||||
|
<div className="mt-3 flex items-center gap-2 animate-fade-in">
|
||||||
|
<select
|
||||||
|
value={selectedCaseId}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSelectedCaseId(e.target.value)
|
||||||
|
}
|
||||||
|
className="flex-1 rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-xs text-neutral-900 outline-none focus:border-neutral-400"
|
||||||
|
>
|
||||||
|
<option value="">Akte waehlen...</option>
|
||||||
|
{cases
|
||||||
|
?.filter((c) => c.status !== "closed")
|
||||||
|
.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.case_number} — {c.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
disabled={
|
||||||
|
!selectedCaseId || saveMutation.isPending
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
saveMutation.mutate({
|
||||||
|
caseId: selectedCaseId,
|
||||||
|
deadline: {
|
||||||
|
title: rule.name,
|
||||||
|
due_date: result.due_date,
|
||||||
|
original_due_date: result.was_adjusted
|
||||||
|
? result.original_due_date
|
||||||
|
: undefined,
|
||||||
|
rule_code: rule.rule_code,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 rounded-md bg-neutral-900 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-neutral-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{rule.deadline_notes && (
|
||||||
|
<p className="mt-2 text-xs italic text-neutral-400">
|
||||||
|
{rule.deadline_notes}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{!selectedType && !typesLoading && (
|
||||||
|
<div className="flex flex-col items-center rounded-lg border border-dashed border-neutral-300 bg-white px-6 py-12 text-center">
|
||||||
|
<div className="rounded-xl bg-neutral-100 p-3">
|
||||||
|
<Scale className="h-6 w-6 text-neutral-400" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm font-medium text-neutral-700">
|
||||||
|
Fristenrechner
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 max-w-sm text-xs text-neutral-500">
|
||||||
|
Waehlen Sie oben eine Verfahrensart, um alle zugehoerigen Fristen
|
||||||
|
anzuzeigen und einzelne Termine zu berechnen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Calculation error */}
|
||||||
|
{calcMutation.isError && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||||
|
Fehler bei der Berechnung. Bitte Eingaben pruefen.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user