- Responsive sidebar: collapses on mobile with hamburger menu, slide-in animation - Skeleton loaders: dashboard cards, case table, case detail page - Empty states: friendly messages with icons for cases, deadlines, parties, documents - Error states: retry button on dashboard, proper error message on case not found - Form validation: inline error messages on case creation form - German language: fix all missing umlauts (Zurück, wählen, Anhängig, Verfügung, etc.) - Status labels: display German translations instead of raw status values - Transitions: fade-in animations on page load, hover/transition-colors on all interactive elements - Focus states: focus-visible ring for keyboard accessibility - Mobile layout: stacking for filters, forms, tabs; horizontal scroll for tables - Extraction results: card layout on mobile, table on desktop - Missing types: add DashboardData, DeadlineSummary, CaseSummary, ExtractedDeadline etc. - Fix QuickActions links to use correct routes (/cases/new, /ai/extract) - Consistent input focus styles across all forms
289 lines
9.7 KiB
TypeScript
289 lines
9.7 KiB
TypeScript
"use client";
|
|
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { api } from "@/lib/api";
|
|
import type { Deadline, Case } from "@/lib/types";
|
|
import { format, isPast, isThisWeek, parseISO } from "date-fns";
|
|
import { de } from "date-fns/locale";
|
|
import { Check, Clock, Filter } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { useState, useMemo } from "react";
|
|
import { EmptyState } from "@/components/ui/EmptyState";
|
|
|
|
type StatusFilter = "all" | "pending" | "completed" | "overdue";
|
|
|
|
function getUrgency(deadline: Deadline): "red" | "amber" | "green" {
|
|
if (deadline.status === "completed") return "green";
|
|
const due = parseISO(deadline.due_date);
|
|
if (isPast(due)) return "red";
|
|
if (isThisWeek(due, { weekStartsOn: 1 })) return "amber";
|
|
return "green";
|
|
}
|
|
|
|
const urgencyConfig = {
|
|
red: {
|
|
bg: "bg-red-50",
|
|
border: "border-red-200",
|
|
badge: "bg-red-100 text-red-700",
|
|
dot: "bg-red-500",
|
|
label: "Überfällig",
|
|
},
|
|
amber: {
|
|
bg: "bg-amber-50",
|
|
border: "border-amber-200",
|
|
badge: "bg-amber-100 text-amber-700",
|
|
dot: "bg-amber-500",
|
|
label: "Diese Woche",
|
|
},
|
|
green: {
|
|
bg: "bg-white",
|
|
border: "border-neutral-200",
|
|
badge: "bg-green-100 text-green-700",
|
|
dot: "bg-green-500",
|
|
label: "OK",
|
|
},
|
|
};
|
|
|
|
const selectClass =
|
|
"rounded-md border border-neutral-200 bg-white px-2.5 py-1 text-sm text-neutral-700 transition-colors focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400 outline-none";
|
|
|
|
export function DeadlineList() {
|
|
const queryClient = useQueryClient();
|
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
|
const [caseFilter, setCaseFilter] = useState<string>("all");
|
|
|
|
const { data: deadlines, isLoading } = useQuery({
|
|
queryKey: ["deadlines"],
|
|
queryFn: () => api.get<Deadline[]>("/api/deadlines"),
|
|
});
|
|
|
|
const { data: cases } = useQuery({
|
|
queryKey: ["cases"],
|
|
queryFn: () => api.get<Case[]>("/api/cases"),
|
|
});
|
|
|
|
const completeMutation = useMutation({
|
|
mutationFn: (id: string) =>
|
|
api.patch<Deadline>(`/api/deadlines/${id}/complete`),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["deadlines"] });
|
|
toast.success("Frist als erledigt markiert");
|
|
},
|
|
onError: () => {
|
|
toast.error("Fehler beim Abschließen der Frist");
|
|
},
|
|
});
|
|
|
|
const caseMap = useMemo(() => {
|
|
const map = new Map<string, Case>();
|
|
cases?.forEach((c) => map.set(c.id, c));
|
|
return map;
|
|
}, [cases]);
|
|
|
|
const filtered = useMemo(() => {
|
|
if (!deadlines) return [];
|
|
return deadlines.filter((d) => {
|
|
if (statusFilter === "pending" && d.status !== "pending") return false;
|
|
if (statusFilter === "completed" && d.status !== "completed")
|
|
return false;
|
|
if (statusFilter === "overdue") {
|
|
if (d.status === "completed") return false;
|
|
if (!isPast(parseISO(d.due_date))) return false;
|
|
}
|
|
if (caseFilter !== "all" && d.case_id !== caseFilter) return false;
|
|
return true;
|
|
});
|
|
}, [deadlines, statusFilter, caseFilter]);
|
|
|
|
const counts = useMemo(() => {
|
|
if (!deadlines) return { overdue: 0, thisWeek: 0, ok: 0 };
|
|
let overdue = 0,
|
|
thisWeek = 0,
|
|
ok = 0;
|
|
for (const d of deadlines) {
|
|
if (d.status === "completed") continue;
|
|
const urgency = getUrgency(d);
|
|
if (urgency === "red") overdue++;
|
|
else if (urgency === "amber") thisWeek++;
|
|
else ok++;
|
|
}
|
|
return { overdue, thisWeek, ok };
|
|
}, [deadlines]);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="space-y-3">
|
|
{[1, 2, 3, 4, 5].map((i) => (
|
|
<div
|
|
key={i}
|
|
className="h-16 animate-pulse rounded-lg bg-neutral-100"
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Summary cards */}
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<button
|
|
onClick={() =>
|
|
setStatusFilter(statusFilter === "overdue" ? "all" : "overdue")
|
|
}
|
|
className={`rounded-lg border p-3 text-left transition-all ${
|
|
statusFilter === "overdue"
|
|
? "border-red-300 bg-red-50 ring-1 ring-red-200"
|
|
: "border-neutral-200 bg-white hover:bg-neutral-50"
|
|
}`}
|
|
>
|
|
<div className="text-2xl font-semibold tabular-nums text-red-600">
|
|
{counts.overdue}
|
|
</div>
|
|
<div className="text-xs text-neutral-500">Überfällig</div>
|
|
</button>
|
|
<button
|
|
onClick={() =>
|
|
setStatusFilter(statusFilter === "pending" ? "all" : "pending")
|
|
}
|
|
className={`rounded-lg border p-3 text-left transition-all ${
|
|
statusFilter === "pending"
|
|
? "border-amber-300 bg-amber-50 ring-1 ring-amber-200"
|
|
: "border-neutral-200 bg-white hover:bg-neutral-50"
|
|
}`}
|
|
>
|
|
<div className="text-2xl font-semibold tabular-nums text-amber-600">
|
|
{counts.thisWeek}
|
|
</div>
|
|
<div className="text-xs text-neutral-500">Diese Woche</div>
|
|
</button>
|
|
<button
|
|
onClick={() => setStatusFilter("all")}
|
|
className={`rounded-lg border p-3 text-left transition-all ${
|
|
statusFilter === "all"
|
|
? "border-green-300 bg-green-50 ring-1 ring-green-200"
|
|
: "border-neutral-200 bg-white hover:bg-neutral-50"
|
|
}`}
|
|
>
|
|
<div className="text-2xl font-semibold tabular-nums text-green-600">
|
|
{counts.ok}
|
|
</div>
|
|
<div className="text-xs text-neutral-500">OK</div>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<div className="flex items-center gap-1.5 text-sm text-neutral-500">
|
|
<Filter className="h-3.5 w-3.5" />
|
|
<span>Filter:</span>
|
|
</div>
|
|
<select
|
|
value={statusFilter}
|
|
onChange={(e) => setStatusFilter(e.target.value as StatusFilter)}
|
|
className={selectClass}
|
|
>
|
|
<option value="all">Alle Status</option>
|
|
<option value="pending">Offen</option>
|
|
<option value="completed">Erledigt</option>
|
|
<option value="overdue">Überfällig</option>
|
|
</select>
|
|
{cases && cases.length > 0 && (
|
|
<select
|
|
value={caseFilter}
|
|
onChange={(e) => setCaseFilter(e.target.value)}
|
|
className={selectClass}
|
|
>
|
|
<option value="all">Alle Akten</option>
|
|
{cases.map((c) => (
|
|
<option key={c.id} value={c.id}>
|
|
{c.case_number} — {c.title}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</div>
|
|
|
|
{/* Deadline list */}
|
|
{filtered.length === 0 ? (
|
|
<EmptyState
|
|
icon={Clock}
|
|
title="Keine Fristen gefunden"
|
|
description={
|
|
statusFilter !== "all" || caseFilter !== "all"
|
|
? "Versuchen Sie andere Filtereinstellungen."
|
|
: "Es sind noch keine Fristen vorhanden."
|
|
}
|
|
/>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{filtered.map((deadline) => {
|
|
const urgency = getUrgency(deadline);
|
|
const config = urgencyConfig[urgency];
|
|
const caseInfo = caseMap.get(deadline.case_id);
|
|
|
|
return (
|
|
<div
|
|
key={deadline.id}
|
|
className={`flex items-center gap-3 rounded-lg border px-4 py-3 transition-colors ${config.bg} ${config.border}`}
|
|
>
|
|
<div
|
|
className={`h-2.5 w-2.5 shrink-0 rounded-full ${config.dot}`}
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="truncate text-sm font-medium text-neutral-900">
|
|
{deadline.title}
|
|
</span>
|
|
<span
|
|
className={`shrink-0 rounded px-1.5 py-0.5 text-xs font-medium ${config.badge}`}
|
|
>
|
|
{config.label}
|
|
</span>
|
|
{deadline.status === "completed" && (
|
|
<span className="shrink-0 rounded bg-neutral-100 px-1.5 py-0.5 text-xs font-medium text-neutral-500">
|
|
Erledigt
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-neutral-500">
|
|
<span>
|
|
{format(parseISO(deadline.due_date), "dd. MMM yyyy", {
|
|
locale: de,
|
|
})}
|
|
</span>
|
|
{caseInfo && (
|
|
<>
|
|
<span>·</span>
|
|
<span className="truncate">
|
|
{caseInfo.case_number} — {caseInfo.title}
|
|
</span>
|
|
</>
|
|
)}
|
|
{deadline.source !== "manual" && (
|
|
<>
|
|
<span>·</span>
|
|
<span>{deadline.source}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{deadline.status !== "completed" && (
|
|
<button
|
|
onClick={() => completeMutation.mutate(deadline.id)}
|
|
disabled={completeMutation.isPending}
|
|
title="Als erledigt markieren"
|
|
className="shrink-0 rounded-md p-1.5 text-neutral-400 transition-colors hover:bg-white hover:text-green-600"
|
|
>
|
|
<Check className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|