Files
KanzlAI-mGMT/frontend/src/components/deadlines/DeadlineCalculator.tsx

259 lines
9.3 KiB
TypeScript

"use client";
import { useQuery, useMutation } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type {
ProceedingType,
CalculateResponse,
CalculatedDeadline,
} from "@/lib/types";
import { format, parseISO, isPast, isThisWeek } from "date-fns";
import { de } from "date-fns/locale";
import {
Calculator,
Calendar,
ArrowRight,
AlertTriangle,
} from "lucide-react";
import { useState } from "react";
function getTimelineUrgency(dueDate: string): "red" | "amber" | "green" {
const due = parseISO(dueDate);
if (isPast(due)) return "red";
if (isThisWeek(due, { weekStartsOn: 1 })) return "amber";
return "green";
}
const dotColors = {
red: "bg-red-500",
amber: "bg-amber-500",
green: "bg-green-500",
};
const inputClass =
"w-full 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";
export function DeadlineCalculator() {
const [proceedingType, setProceedingType] = useState("");
const [triggerDate, setTriggerDate] = useState(
new Date().toISOString().split("T")[0],
);
const { data: proceedingTypes, isLoading: typesLoading } = useQuery({
queryKey: ["proceeding-types"],
queryFn: () => api.get<ProceedingType[]>("/proceeding-types"),
});
const calculateMutation = useMutation({
mutationFn: (params: {
proceeding_type: string;
trigger_event_date: string;
}) => 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();
doCalculate(proceedingType, triggerDate);
}
const results = calculateMutation.data;
return (
<div className="space-y-6">
{/* Input form */}
<form
onSubmit={handleCalculate}
className="rounded-lg border border-neutral-200 bg-white p-5"
>
<div className="flex items-center gap-2 text-sm font-medium text-neutral-900">
<Calculator className="h-4 w-4" />
Fristenberechnung
</div>
<div className="mt-4 grid gap-4 sm:grid-cols-3">
<div>
<label className="mb-1 block text-xs font-medium text-neutral-500">
Verfahrensart
</label>
<select
value={proceedingType}
onChange={(e) => handleProceedingChange(e.target.value)}
disabled={typesLoading}
className={inputClass}
>
<option value="">Bitte wählen...</option>
{(() => {
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}
</option>
))}
</optgroup>
));
})()}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-neutral-500">
Auslösedatum
</label>
<input
type="date"
value={triggerDate}
onChange={(e) => handleDateChange(e.target.value)}
className={inputClass}
/>
</div>
<div className="flex items-end">
<button
type="submit"
disabled={
!proceedingType ||
!triggerDate ||
calculateMutation.isPending
}
className="flex w-full items-center justify-center gap-2 rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-neutral-800 disabled:cursor-not-allowed disabled:opacity-50"
>
{calculateMutation.isPending ? "Berechne..." : "Berechnen"}
<ArrowRight className="h-3.5 w-3.5" />
</button>
</div>
</div>
</form>
{/* Error */}
{calculateMutation.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 prüfen.
</div>
)}
{/* Results */}
{results && results.deadlines && (
<div className="animate-fade-in space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-neutral-900">
Berechnete Fristen
</h3>
<span className="text-xs text-neutral-500">
{results.deadlines.length} Fristen ab{" "}
{format(parseISO(results.trigger_event_date), "dd. MMM yyyy", {
locale: de,
})}
</span>
</div>
{/* Timeline */}
<div className="relative rounded-lg border border-neutral-200 bg-white">
{results.deadlines.map((d: CalculatedDeadline, i: number) => {
const urgency = getTimelineUrgency(d.due_date);
const isLast = i === results.deadlines.length - 1;
return (
<div
key={d.rule_id}
className={`flex gap-3 px-4 py-3 ${!isLast ? "border-b border-neutral-100" : ""}`}
>
<div className="flex flex-col items-center pt-1">
<div
className={`h-2.5 w-2.5 shrink-0 rounded-full ${dotColors[urgency]}`}
/>
{!isLast && (
<div className="mt-1 w-px flex-1 bg-neutral-200" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
<span className="text-sm font-medium text-neutral-900">
{d.title}
</span>
<span className="shrink-0 text-sm font-medium tabular-nums text-neutral-700">
{format(parseISO(d.due_date), "dd.MM.yyyy")}
</span>
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-neutral-500">
{d.rule_code && <span>{d.rule_code}</span>}
{d.was_adjusted && (
<>
{d.rule_code && <span>·</span>}
<span className="text-amber-600">
Angepasst (Original:{" "}
{format(
parseISO(d.original_due_date),
"dd.MM.yyyy",
)}
)
</span>
</>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Empty state */}
{!results && !calculateMutation.isPending && (
<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">
<Calendar className="h-6 w-6 text-neutral-400" />
</div>
<p className="mt-3 text-sm text-neutral-500">
Verfahrensart und Auslösedatum wählen, um Fristen zu berechnen
</p>
</div>
)}
</div>
);
}