feat: UPC deadline determination — event-driven proceeding timeline wizard
This commit is contained in:
@@ -1,28 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { DeadlineCalculator } from "@/components/deadlines/DeadlineCalculator";
|
||||
import { DeadlineWizard } from "@/components/deadlines/DeadlineWizard";
|
||||
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>
|
||||
<Link
|
||||
href="/fristen"
|
||||
className="mb-2 inline-flex items-center gap-1 text-sm text-neutral-500 transition-colors hover:text-neutral-700"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Zurück zu Fristen
|
||||
</Link>
|
||||
<h1 className="text-lg font-semibold text-neutral-900">
|
||||
Fristenrechner
|
||||
</h1>
|
||||
<p className="mt-0.5 text-sm text-neutral-500">
|
||||
Berechnen Sie Fristen basierend auf Verfahrensart und Auslösedatum
|
||||
</p>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<Link
|
||||
href="/fristen"
|
||||
className="mb-2 inline-flex items-center gap-1 text-sm text-neutral-500 transition-colors hover:text-neutral-700"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Zurueck zu Fristen
|
||||
</Link>
|
||||
<h1 className="text-lg font-semibold text-neutral-900">
|
||||
Fristenbestimmung
|
||||
</h1>
|
||||
<p className="mt-0.5 text-sm text-neutral-500">
|
||||
{mode === "wizard"
|
||||
? "Vollstaendige Verfahrens-Timeline mit automatischer Fristenberechnung"
|
||||
: "Schnellberechnung einzelner Fristen nach Verfahrensart"}
|
||||
</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>
|
||||
<DeadlineCalculator />
|
||||
|
||||
{mode === "wizard" ? <DeadlineWizard /> : <DeadlineCalculator />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
622
frontend/src/components/deadlines/DeadlineWizard.tsx
Normal file
622
frontend/src/components/deadlines/DeadlineWizard.tsx
Normal file
@@ -0,0 +1,622 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import type {
|
||||
ProceedingType,
|
||||
TimelineResponse,
|
||||
DetermineResponse,
|
||||
TimelineEvent,
|
||||
Case,
|
||||
} from "@/lib/types";
|
||||
import { format, parseISO, isPast, isThisWeek, isBefore, addDays } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import {
|
||||
Scale,
|
||||
Calendar,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
GitBranch,
|
||||
Check,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
FileText,
|
||||
Users,
|
||||
Gavel,
|
||||
ArrowRight,
|
||||
RotateCcw,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
FolderOpen,
|
||||
} from "lucide-react";
|
||||
import { useState, useCallback, 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 "";
|
||||
}
|
||||
}
|
||||
|
||||
function getEventTypeLabel(type?: string): string {
|
||||
switch (type) {
|
||||
case "filing":
|
||||
return "Einreichung";
|
||||
case "hearing":
|
||||
return "Verhandlung";
|
||||
case "decision":
|
||||
return "Entscheidung";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
type Urgency = "past" | "overdue" | "this_week" | "upcoming" | "future" | "none";
|
||||
|
||||
function getUrgency(dateStr?: string): Urgency {
|
||||
if (!dateStr) return "none";
|
||||
const date = parseISO(dateStr);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (isPast(date) && isBefore(date, today)) return "overdue";
|
||||
if (isThisWeek(date, { weekStartsOn: 1 })) return "this_week";
|
||||
if (isBefore(date, addDays(today, 30))) return "upcoming";
|
||||
return "future";
|
||||
}
|
||||
|
||||
const urgencyStyles: Record<Urgency, { dot: string; text: string; bg: string }> = {
|
||||
past: { dot: "bg-neutral-400", text: "text-neutral-500", bg: "bg-neutral-50" },
|
||||
overdue: { dot: "bg-red-500", text: "text-red-700", bg: "bg-red-50" },
|
||||
this_week: { dot: "bg-amber-500", text: "text-amber-700", bg: "bg-amber-50" },
|
||||
upcoming: { dot: "bg-blue-500", text: "text-blue-700", bg: "bg-blue-50" },
|
||||
future: { dot: "bg-green-500", text: "text-green-700", bg: "bg-green-50" },
|
||||
none: { dot: "bg-neutral-300", text: "text-neutral-500", bg: "bg-neutral-50" },
|
||||
};
|
||||
|
||||
// --- Spawn Extraction ---
|
||||
|
||||
function extractSpawns(events: TimelineEvent[]): TimelineEvent[] {
|
||||
const spawns: TimelineEvent[] = [];
|
||||
function walk(evts: TimelineEvent[]) {
|
||||
for (const ev of evts) {
|
||||
if (ev.is_spawn) spawns.push(ev);
|
||||
if (ev.children) walk(ev.children);
|
||||
}
|
||||
}
|
||||
walk(events);
|
||||
return spawns;
|
||||
}
|
||||
|
||||
// --- Flat timeline extraction ---
|
||||
|
||||
function flattenTimeline(events: TimelineEvent[], depth = 0): (TimelineEvent & { depth: number })[] {
|
||||
const result: (TimelineEvent & { depth: number })[] = [];
|
||||
for (const ev of events) {
|
||||
result.push({ ...ev, depth });
|
||||
if (ev.children && ev.children.length > 0) {
|
||||
result.push(...flattenTimeline(ev.children, depth + 1));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- Main Component ---
|
||||
|
||||
export function DeadlineWizard() {
|
||||
const [selectedType, setSelectedType] = useState<string>("");
|
||||
const [triggerDate, setTriggerDate] = useState("");
|
||||
const [conditions, setConditions] = useState<Record<string, boolean>>({});
|
||||
const [selectedCaseId, setSelectedCaseId] = useState<string>("");
|
||||
const [showBatchPanel, setShowBatchPanel] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch proceeding types
|
||||
const { data: proceedingTypes, isLoading: typesLoading } = useQuery({
|
||||
queryKey: ["proceeding-types"],
|
||||
queryFn: () => api.get<ProceedingType[]>("/proceeding-types"),
|
||||
});
|
||||
|
||||
// Fetch timeline structure when type is selected
|
||||
const { data: timelineData } = useQuery({
|
||||
queryKey: ["timeline", selectedType],
|
||||
queryFn: () => api.get<TimelineResponse>(`/proceeding-types/${selectedType}/timeline`),
|
||||
enabled: !!selectedType,
|
||||
});
|
||||
|
||||
// Determine mutation
|
||||
const determineMutation = useMutation({
|
||||
mutationFn: (params: { proceeding_type: string; trigger_event_date: string; conditions: Record<string, boolean> }) =>
|
||||
api.post<DetermineResponse>("/deadlines/determine", params),
|
||||
});
|
||||
|
||||
// Cases for batch create
|
||||
const { data: cases } = useQuery({
|
||||
queryKey: ["cases"],
|
||||
queryFn: () => api.get<Case[]>("/cases"),
|
||||
enabled: showBatchPanel,
|
||||
});
|
||||
|
||||
// Batch create mutation
|
||||
const batchMutation = useMutation({
|
||||
mutationFn: (params: { caseId: string; deadlines: { title: string; due_date: string; rule_code?: string }[] }) =>
|
||||
api.post(`/cases/${params.caseId}/deadlines/batch`, { deadlines: params.deadlines }),
|
||||
onSuccess: () => {
|
||||
toast.success("Alle Fristen wurden auf die Akte uebernommen");
|
||||
queryClient.invalidateQueries({ queryKey: ["deadlines"] });
|
||||
setShowBatchPanel(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Fehler beim Erstellen der Fristen");
|
||||
},
|
||||
});
|
||||
|
||||
// Spawns from timeline structure (for condition toggles)
|
||||
const spawns = useMemo(() => {
|
||||
if (!timelineData?.timeline) return [];
|
||||
return extractSpawns(timelineData.timeline);
|
||||
}, [timelineData]);
|
||||
|
||||
// Calculate on type/date/condition change
|
||||
const calculate = useCallback(() => {
|
||||
if (!selectedType || !triggerDate) return;
|
||||
determineMutation.mutate({
|
||||
proceeding_type: selectedType,
|
||||
trigger_event_date: triggerDate,
|
||||
conditions,
|
||||
});
|
||||
}, [selectedType, triggerDate, conditions, determineMutation]);
|
||||
|
||||
// Auto-calculate when date or conditions change
|
||||
const handleDateChange = (date: string) => {
|
||||
setTriggerDate(date);
|
||||
if (selectedType && date) {
|
||||
determineMutation.mutate({
|
||||
proceeding_type: selectedType,
|
||||
trigger_event_date: date,
|
||||
conditions,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleConditionToggle = (spawnId: string) => {
|
||||
const next = { ...conditions, [spawnId]: !conditions[spawnId] };
|
||||
setConditions(next);
|
||||
if (selectedType && triggerDate) {
|
||||
determineMutation.mutate({
|
||||
proceeding_type: selectedType,
|
||||
trigger_event_date: triggerDate,
|
||||
conditions: next,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleTypeSelect = (code: string) => {
|
||||
setSelectedType(code);
|
||||
setConditions({});
|
||||
if (triggerDate) {
|
||||
// Will recalculate once timeline loads
|
||||
determineMutation.reset();
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSelectedType("");
|
||||
setTriggerDate("");
|
||||
setConditions({});
|
||||
setShowBatchPanel(false);
|
||||
determineMutation.reset();
|
||||
};
|
||||
|
||||
// Collect calculated deadlines for batch create
|
||||
const collectDeadlines = (events: TimelineEvent[]): { title: string; due_date: string; rule_code?: string }[] => {
|
||||
const result: { title: string; due_date: string; rule_code?: string }[] = [];
|
||||
for (const ev of events) {
|
||||
if (ev.date && ev.duration_value > 0) {
|
||||
result.push({ title: ev.name, due_date: ev.date, rule_code: ev.rule_code || undefined });
|
||||
}
|
||||
if (ev.children) result.push(...collectDeadlines(ev.children));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const results = determineMutation.data;
|
||||
const selectedPT = proceedingTypes?.find((pt) => pt.code === selectedType);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Step 1: Proceeding Type Selection */}
|
||||
<div className="rounded-lg border border-neutral-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-neutral-900">
|
||||
<Scale className="h-4 w-4" />
|
||||
Verfahrensart waehlen
|
||||
</div>
|
||||
{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>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{typesLoading ? (
|
||||
<div className="col-span-full flex justify-center py-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
|
||||
</div>
|
||||
) : (
|
||||
proceedingTypes?.map((pt) => (
|
||||
<button
|
||||
key={pt.id}
|
||||
onClick={() => handleTypeSelect(pt.code)}
|
||||
className={`rounded-lg border px-3 py-2.5 text-left transition-all ${
|
||||
selectedType === pt.code
|
||||
? "border-neutral-900 bg-neutral-900 text-white shadow-sm"
|
||||
: "border-neutral-200 bg-white text-neutral-700 hover:border-neutral-400 hover:bg-neutral-50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: pt.default_color }}
|
||||
/>
|
||||
<span className="text-xs font-semibold">{pt.code}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-tight opacity-80">{pt.name}</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 2: Date + Conditions */}
|
||||
{selectedType && (
|
||||
<div className="animate-fade-in rounded-lg border border-neutral-200 bg-white p-5">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-neutral-900">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Ausloesendes Ereignis
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-xs font-medium text-neutral-500">
|
||||
Datum des {selectedPT?.name || selectedType} (z.B. Klagezustellung)
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={triggerDate}
|
||||
onChange={(e) => handleDateChange(e.target.value)}
|
||||
className="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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Condition toggles */}
|
||||
{spawns.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{spawns.map((spawn) => (
|
||||
<button
|
||||
key={spawn.id}
|
||||
onClick={() => handleConditionToggle(spawn.id)}
|
||||
className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
conditions[spawn.id]
|
||||
? "bg-neutral-900 text-white"
|
||||
: "border border-neutral-300 bg-white text-neutral-600 hover:bg-neutral-50"
|
||||
}`}
|
||||
>
|
||||
<GitBranch className="h-3 w-3" />
|
||||
{spawn.spawn_label || spawn.name}
|
||||
{conditions[spawn.id] && <Check className="h-3 w-3" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{determineMutation.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>
|
||||
)}
|
||||
|
||||
{/* Step 3: Calculated Timeline */}
|
||||
{results && results.timeline && (
|
||||
<div className="animate-fade-in space-y-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-neutral-900">
|
||||
Verfahrens-Timeline: {results.proceeding_name}
|
||||
</h3>
|
||||
<p className="mt-0.5 text-xs text-neutral-500">
|
||||
{results.total_deadlines} Ereignisse ab{" "}
|
||||
{format(parseISO(results.trigger_event_date), "dd. MMMM yyyy", { locale: de })}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowBatchPanel(!showBatchPanel)}
|
||||
className="flex items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-neutral-800"
|
||||
>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
Alle uebernehmen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Timeline visualization */}
|
||||
<div className="rounded-lg border border-neutral-200 bg-white">
|
||||
<TimelineTree events={results.timeline} conditions={conditions} depth={0} />
|
||||
</div>
|
||||
|
||||
{/* Batch create panel */}
|
||||
{showBatchPanel && (
|
||||
<div className="animate-fade-in rounded-lg border border-neutral-200 bg-neutral-50 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-neutral-900">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Fristen auf Akte uebernehmen
|
||||
</div>
|
||||
<div className="mt-3 flex gap-3">
|
||||
<select
|
||||
value={selectedCaseId}
|
||||
onChange={(e) => setSelectedCaseId(e.target.value)}
|
||||
className="flex-1 rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm 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 || batchMutation.isPending}
|
||||
onClick={() => {
|
||||
const deadlines = collectDeadlines(results.timeline);
|
||||
if (deadlines.length === 0) return;
|
||||
batchMutation.mutate({ caseId: selectedCaseId, deadlines });
|
||||
}}
|
||||
className="flex items-center gap-1.5 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"
|
||||
>
|
||||
{batchMutation.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{batchMutation.isPending ? "Erstelle..." : `${collectDeadlines(results.timeline).length} Fristen erstellen`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!results && !determineMutation.isPending && selectedType && triggerDate && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selectedType && (
|
||||
<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">
|
||||
UPC-Fristenbestimmung
|
||||
</p>
|
||||
<p className="mt-1 max-w-sm text-xs text-neutral-500">
|
||||
Waehlen Sie die Verfahrensart und geben Sie das Datum des ausloesenden Ereignisses ein.
|
||||
Alle Fristen des Verfahrens werden automatisch berechnet.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Timeline Tree Component ---
|
||||
|
||||
function TimelineTree({
|
||||
events,
|
||||
conditions,
|
||||
depth,
|
||||
}: {
|
||||
events: TimelineEvent[];
|
||||
conditions: Record<string, boolean>;
|
||||
depth: number;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{events.map((ev, i) => (
|
||||
<TimelineNode
|
||||
key={ev.id}
|
||||
event={ev}
|
||||
conditions={conditions}
|
||||
depth={depth}
|
||||
isLast={i === events.length - 1}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineNode({
|
||||
event: ev,
|
||||
conditions,
|
||||
depth,
|
||||
isLast,
|
||||
}: {
|
||||
event: TimelineEvent;
|
||||
conditions: Record<string, boolean>;
|
||||
depth: number;
|
||||
isLast: boolean;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
// Skip inactive spawns
|
||||
if (ev.is_spawn && !conditions[ev.id]) return null;
|
||||
|
||||
const hasChildren = ev.children && ev.children.length > 0;
|
||||
const visibleChildren = ev.children?.filter(
|
||||
(c) => !c.is_spawn || conditions[c.id]
|
||||
);
|
||||
const hasVisibleChildren = visibleChildren && visibleChildren.length > 0;
|
||||
|
||||
const urgency = getUrgency(ev.date);
|
||||
const styles = urgencyStyles[urgency];
|
||||
const duration = formatDuration(ev.duration_value, ev.duration_unit);
|
||||
const isConditional = ev.has_condition && ev.condition_rule_id;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`group relative flex gap-3 px-4 py-3 transition-colors hover:bg-neutral-50 ${
|
||||
!isLast && depth === 0 ? "border-b border-neutral-100" : ""
|
||||
}`}
|
||||
style={{ paddingLeft: `${16 + depth * 24}px` }}
|
||||
>
|
||||
{/* Timeline connector */}
|
||||
<div className="flex flex-col items-center pt-1">
|
||||
<div className={`h-3 w-3 shrink-0 rounded-full border-2 border-white shadow-sm ${styles.dot}`} />
|
||||
{!isLast && <div className="mt-1 w-px flex-1 bg-neutral-200" />}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<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">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{hasVisibleChildren && (
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="text-neutral-400 hover:text-neutral-600"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{ev.is_spawn && (
|
||||
<GitBranch className="h-3.5 w-3.5 text-violet-500" />
|
||||
)}
|
||||
<span className="text-sm font-medium text-neutral-900">{ev.name}</span>
|
||||
{!ev.is_mandatory && (
|
||||
<span className="rounded bg-neutral-100 px-1 py-0.5 text-[10px] text-neutral-500">
|
||||
optional
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
{ev.date && (
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{ev.was_adjusted && (
|
||||
<span className="text-[10px] text-amber-600" title={`Original: ${ev.original_date}`}>
|
||||
angepasst
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-sm font-medium tabular-nums ${styles.text}`}>
|
||||
{format(parseISO(ev.date), "dd.MM.yyyy")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Meta row */}
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-neutral-500">
|
||||
{ev.primary_party && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
{getPartyIcon(ev.primary_party)}
|
||||
{getPartyLabel(ev.primary_party)}
|
||||
</span>
|
||||
)}
|
||||
{ev.event_type && (
|
||||
<>
|
||||
<span className="text-neutral-300">·</span>
|
||||
<span>{getEventTypeLabel(ev.event_type)}</span>
|
||||
</>
|
||||
)}
|
||||
{duration && (
|
||||
<>
|
||||
<span className="text-neutral-300">·</span>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Clock className="h-3 w-3" />
|
||||
{duration}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{ev.rule_code && (
|
||||
<>
|
||||
<span className="text-neutral-300">·</span>
|
||||
<span className="rounded bg-neutral-100 px-1 py-0.5 font-mono text-[10px]">
|
||||
{ev.rule_code}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{isConditional && (
|
||||
<>
|
||||
<span className="text-neutral-300">·</span>
|
||||
<span className="text-violet-600">
|
||||
bedingt{ev.alt_rule_code ? ` (${ev.alt_rule_code})` : ""}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{ev.deadline_notes && (
|
||||
<p className="mt-1 text-xs text-neutral-400 italic">{ev.deadline_notes}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Children */}
|
||||
{expanded && hasVisibleChildren && (
|
||||
<TimelineTree events={visibleChildren!} conditions={conditions} depth={depth + 1} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -120,12 +120,76 @@ export interface DeadlineRule {
|
||||
rule_code?: string;
|
||||
deadline_notes?: string;
|
||||
sequence_order: number;
|
||||
condition_rule_id?: string;
|
||||
alt_duration_value?: number;
|
||||
alt_duration_unit?: string;
|
||||
alt_rule_code?: string;
|
||||
is_spawn?: boolean;
|
||||
spawn_label?: string;
|
||||
}
|
||||
|
||||
export interface RuleTreeNode extends DeadlineRule {
|
||||
children?: RuleTreeNode[];
|
||||
}
|
||||
|
||||
// Timeline determination types
|
||||
|
||||
export interface TimelineEvent {
|
||||
id: string;
|
||||
code?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
primary_party?: string;
|
||||
event_type?: string;
|
||||
is_mandatory: boolean;
|
||||
duration_value: number;
|
||||
duration_unit: string;
|
||||
rule_code?: string;
|
||||
deadline_notes?: string;
|
||||
is_spawn: boolean;
|
||||
spawn_label?: string;
|
||||
has_condition: boolean;
|
||||
condition_rule_id?: string;
|
||||
alt_rule_code?: string;
|
||||
alt_duration_value?: number;
|
||||
alt_duration_unit?: string;
|
||||
date?: string;
|
||||
original_date?: string;
|
||||
was_adjusted: boolean;
|
||||
children?: TimelineEvent[];
|
||||
}
|
||||
|
||||
export interface TimelineResponse {
|
||||
proceeding_type: ProceedingType;
|
||||
timeline: TimelineEvent[];
|
||||
}
|
||||
|
||||
export interface DetermineRequest {
|
||||
proceeding_type: string;
|
||||
trigger_event_date: string;
|
||||
conditions: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface DetermineResponse {
|
||||
proceeding_type: string;
|
||||
proceeding_name: string;
|
||||
proceeding_color: string;
|
||||
trigger_event_date: string;
|
||||
timeline: TimelineEvent[];
|
||||
total_deadlines: number;
|
||||
}
|
||||
|
||||
export interface BatchCreateRequest {
|
||||
deadlines: {
|
||||
title: string;
|
||||
due_date: string;
|
||||
original_due_date?: string;
|
||||
rule_id?: string;
|
||||
rule_code?: string;
|
||||
notes?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface ProceedingType {
|
||||
id: number;
|
||||
code: string;
|
||||
|
||||
Reference in New Issue
Block a user