- Deadline detail page (/fristen/[id]) with status badge, due date, case context, complete button, and notes - Appointment detail page (/termine/[id]) with datetime, location, type badge, case link, description, and notes - Case event detail page (/cases/[id]/ereignisse/[eventId]) with event type icon, description, metadata, and notes - Standalone deadline creation (/fristen/neu) with case dropdown - Standalone appointment creation (/termine/neu) with optional case - Reusable Breadcrumb component for navigation hierarchy - Reusable NotesList component with inline create/edit/delete - Added Note and RecentActivity types to lib/types.ts
251 lines
8.0 KiB
TypeScript
251 lines
8.0 KiB
TypeScript
"use client";
|
|
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { useParams, useRouter } from "next/navigation";
|
|
import { api } from "@/lib/api";
|
|
import type { Deadline } from "@/lib/types";
|
|
import { Breadcrumb } from "@/components/layout/Breadcrumb";
|
|
import { NotesList } from "@/components/notes/NotesList";
|
|
import { Skeleton } from "@/components/ui/Skeleton";
|
|
import { format, parseISO, formatDistanceToNow, isPast } from "date-fns";
|
|
import { de } from "date-fns/locale";
|
|
import {
|
|
AlertTriangle,
|
|
CheckCircle2,
|
|
Clock,
|
|
ExternalLink,
|
|
} from "lucide-react";
|
|
import Link from "next/link";
|
|
import { toast } from "sonner";
|
|
|
|
interface DeadlineDetail extends Deadline {
|
|
case_number?: string;
|
|
case_title?: string;
|
|
}
|
|
|
|
const STATUS_CONFIG: Record<
|
|
string,
|
|
{ label: string; bg: string; icon: typeof Clock }
|
|
> = {
|
|
pending: { label: "Offen", bg: "bg-amber-50 text-amber-700", icon: Clock },
|
|
completed: {
|
|
label: "Erledigt",
|
|
bg: "bg-emerald-50 text-emerald-700",
|
|
icon: CheckCircle2,
|
|
},
|
|
overdue: {
|
|
label: "Ueberfaellig",
|
|
bg: "bg-red-50 text-red-700",
|
|
icon: AlertTriangle,
|
|
},
|
|
};
|
|
|
|
function getEffectiveStatus(d: DeadlineDetail): string {
|
|
if (d.status === "completed") return "completed";
|
|
if (isPast(parseISO(d.due_date))) return "overdue";
|
|
return "pending";
|
|
}
|
|
|
|
function DetailSkeleton() {
|
|
return (
|
|
<div>
|
|
<Skeleton className="h-4 w-48" />
|
|
<div className="mt-6 space-y-4">
|
|
<Skeleton className="h-8 w-64" />
|
|
<Skeleton className="h-4 w-40" />
|
|
<Skeleton className="h-32 rounded-lg" />
|
|
<Skeleton className="h-48 rounded-lg" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function DeadlineDetailPage() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const router = useRouter();
|
|
const queryClient = useQueryClient();
|
|
|
|
const {
|
|
data: deadline,
|
|
isLoading,
|
|
error,
|
|
} = useQuery({
|
|
queryKey: ["deadline", id],
|
|
queryFn: () => api.get<DeadlineDetail>(`/deadlines/${id}`),
|
|
});
|
|
|
|
const completeMutation = useMutation({
|
|
mutationFn: () => api.patch<Deadline>(`/deadlines/${id}/complete`),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["deadline", id] });
|
|
queryClient.invalidateQueries({ queryKey: ["deadlines"] });
|
|
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
|
toast.success("Frist als erledigt markiert");
|
|
},
|
|
onError: () => toast.error("Fehler beim Abschliessen der Frist"),
|
|
});
|
|
|
|
if (isLoading) return <DetailSkeleton />;
|
|
|
|
if (error || !deadline) {
|
|
return (
|
|
<div className="py-12 text-center">
|
|
<div className="mx-auto mb-3 w-fit rounded-xl bg-red-50 p-3">
|
|
<AlertTriangle className="h-6 w-6 text-red-500" />
|
|
</div>
|
|
<p className="text-sm font-medium text-neutral-900">
|
|
Frist nicht gefunden
|
|
</p>
|
|
<p className="mt-1 text-sm text-neutral-500">
|
|
Die Frist existiert nicht oder Sie haben keine Berechtigung.
|
|
</p>
|
|
<Link
|
|
href="/fristen"
|
|
className="mt-4 inline-block text-sm text-neutral-500 transition-colors hover:text-neutral-700"
|
|
>
|
|
Zurueck zu Fristen
|
|
</Link>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const status = getEffectiveStatus(deadline);
|
|
const config = STATUS_CONFIG[status] ?? STATUS_CONFIG.pending;
|
|
const StatusIcon = config.icon;
|
|
const dueDate = parseISO(deadline.due_date);
|
|
const relativeTime = formatDistanceToNow(dueDate, {
|
|
addSuffix: true,
|
|
locale: de,
|
|
});
|
|
|
|
return (
|
|
<div className="animate-fade-in">
|
|
<Breadcrumb
|
|
items={[
|
|
{ label: "Dashboard", href: "/dashboard" },
|
|
{ label: "Fristen", href: "/fristen" },
|
|
{ label: deadline.title },
|
|
]}
|
|
/>
|
|
|
|
{/* Header */}
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
<div>
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<span
|
|
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium ${config.bg}`}
|
|
>
|
|
<StatusIcon className="h-3 w-3" />
|
|
{config.label}
|
|
</span>
|
|
<h1 className="text-lg font-semibold text-neutral-900">
|
|
{deadline.title}
|
|
</h1>
|
|
</div>
|
|
{deadline.description && (
|
|
<p className="mt-1 text-sm text-neutral-500">
|
|
{deadline.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
{deadline.status !== "completed" && (
|
|
<button
|
|
onClick={() => completeMutation.mutate()}
|
|
disabled={completeMutation.isPending}
|
|
className="shrink-0 rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-emerald-700 disabled:opacity-50"
|
|
>
|
|
{completeMutation.isPending ? "Wird erledigt..." : "Erledigen"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Due date */}
|
|
<div className="mt-4 rounded-lg border border-neutral-200 bg-white px-4 py-3">
|
|
<div className="flex items-baseline gap-2">
|
|
<span className="text-sm font-medium text-neutral-900">
|
|
Faellig: {format(dueDate, "d. MMMM yyyy", { locale: de })}
|
|
</span>
|
|
<span
|
|
className={`text-xs ${status === "overdue" ? "font-medium text-red-600" : "text-neutral-500"}`}
|
|
>
|
|
({relativeTime})
|
|
</span>
|
|
</div>
|
|
{deadline.warning_date && (
|
|
<p className="mt-1 text-xs text-neutral-500">
|
|
Warnung am:{" "}
|
|
{format(parseISO(deadline.warning_date), "d. MMMM yyyy", {
|
|
locale: de,
|
|
})}
|
|
</p>
|
|
)}
|
|
{deadline.original_due_date &&
|
|
deadline.original_due_date !== deadline.due_date && (
|
|
<p className="mt-1 text-xs text-neutral-500">
|
|
Urspruengliches Datum:{" "}
|
|
{format(parseISO(deadline.original_due_date), "d. MMMM yyyy", {
|
|
locale: de,
|
|
})}
|
|
</p>
|
|
)}
|
|
{deadline.completed_at && (
|
|
<p className="mt-1 text-xs text-emerald-600">
|
|
Erledigt am:{" "}
|
|
{format(parseISO(deadline.completed_at), "d. MMMM yyyy, HH:mm", {
|
|
locale: de,
|
|
})}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Case context */}
|
|
{deadline.case_id && (
|
|
<div className="mt-3 rounded-lg border border-neutral-200 bg-white px-4 py-3">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-xs font-medium uppercase tracking-wide text-neutral-400">
|
|
Akte
|
|
</p>
|
|
<p className="mt-0.5 text-sm text-neutral-900">
|
|
{deadline.case_number
|
|
? `Az. ${deadline.case_number}`
|
|
: "Verknuepfte Akte"}
|
|
{deadline.case_title && ` — ${deadline.case_title}`}
|
|
</p>
|
|
</div>
|
|
<Link
|
|
href={`/cases/${deadline.case_id}`}
|
|
className="flex items-center gap-1 text-xs text-neutral-500 transition-colors hover:text-neutral-700"
|
|
>
|
|
Zur Akte
|
|
<ExternalLink className="h-3 w-3" />
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Source info */}
|
|
{deadline.source && deadline.source !== "manual" && (
|
|
<div className="mt-3 rounded-lg border border-neutral-200 bg-white px-4 py-3">
|
|
<p className="text-xs font-medium uppercase tracking-wide text-neutral-400">
|
|
Quelle
|
|
</p>
|
|
<p className="mt-0.5 text-sm text-neutral-700">
|
|
{deadline.source === "calculated"
|
|
? "Berechnet"
|
|
: deadline.source === "caldav"
|
|
? "CalDAV Sync"
|
|
: deadline.source}
|
|
{deadline.rule_id && ` (Regel: ${deadline.rule_id})`}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Notes */}
|
|
<div className="mt-6">
|
|
<NotesList parentType="deadline" parentId={id} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|