259 lines
9.3 KiB
TypeScript
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>
|
|
);
|
|
}
|