feat: UI polish — responsive, loading/empty/error states, German fixes (Phase 3Q)

- 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
This commit is contained in:
m
2026-03-25 14:16:30 +01:00
parent 2cf01073a3
commit f81a2492c6
23 changed files with 834 additions and 288 deletions

View File

@@ -2,10 +2,19 @@
import { useQuery, useMutation } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type { ProceedingType, CalculateResponse, CalculatedDeadline } from "@/lib/types";
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 {
Calculator,
Calendar,
ArrowRight,
AlertTriangle,
} from "lucide-react";
import { useState } from "react";
function getTimelineUrgency(dueDate: string): "red" | "amber" | "green" {
@@ -21,6 +30,9 @@ const dotColors = {
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("");
@@ -31,8 +43,10 @@ export function DeadlineCalculator() {
});
const calculateMutation = useMutation({
mutationFn: (params: { proceeding_type: string; trigger_event_date: string }) =>
api.post<CalculateResponse>("/api/deadlines/calculate", params),
mutationFn: (params: {
proceeding_type: string;
trigger_event_date: string;
}) => api.post<CalculateResponse>("/api/deadlines/calculate", params),
});
function handleCalculate(e: React.FormEvent) {
@@ -49,7 +63,10 @@ export function DeadlineCalculator() {
return (
<div className="space-y-6">
{/* Input form */}
<form onSubmit={handleCalculate} className="rounded-lg border border-neutral-200 bg-white p-5">
<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
@@ -63,9 +80,9 @@ export function DeadlineCalculator() {
value={proceedingType}
onChange={(e) => setProceedingType(e.target.value)}
disabled={typesLoading}
className="w-full rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm text-neutral-900"
className={inputClass}
>
<option value="">Bitte wahlen...</option>
<option value="">Bitte wählen...</option>
{proceedingTypes?.map((pt) => (
<option key={pt.id} value={pt.code}>
{pt.name} ({pt.code})
@@ -75,19 +92,23 @@ export function DeadlineCalculator() {
</div>
<div>
<label className="mb-1 block text-xs font-medium text-neutral-500">
Auslosedatum
Auslösedatum
</label>
<input
type="date"
value={triggerDate}
onChange={(e) => setTriggerDate(e.target.value)}
className="w-full rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm text-neutral-900"
className={inputClass}
/>
</div>
<div className="flex items-end">
<button
type="submit"
disabled={!proceedingType || !triggerDate || calculateMutation.isPending}
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"}
@@ -101,20 +122,22 @@ export function DeadlineCalculator() {
{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 prufen.
Fehler bei der Berechnung. Bitte Eingaben prüfen.
</div>
)}
{/* Results */}
{results && results.deadlines && (
<div className="space-y-3">
<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 })}
{format(parseISO(results.trigger_event_date), "dd. MMM yyyy", {
locale: de,
})}
</span>
</div>
@@ -129,15 +152,16 @@ export function DeadlineCalculator() {
key={d.rule_id}
className={`flex gap-3 px-4 py-3 ${!isLast ? "border-b border-neutral-100" : ""}`}
>
{/* Timeline dot + line */}
<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
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>
{/* Content */}
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<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>
@@ -145,13 +169,18 @@ export function DeadlineCalculator() {
{format(parseISO(d.due_date), "dd.MM.yyyy")}
</span>
</div>
<div className="mt-0.5 flex items-center gap-2 text-xs text-neutral-500">
<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")})
Angepasst (Original:{" "}
{format(
parseISO(d.original_due_date),
"dd.MM.yyyy",
)}
)
</span>
</>
)}
@@ -166,10 +195,12 @@ export function DeadlineCalculator() {
{/* Empty state */}
{!results && !calculateMutation.isPending && (
<div className="rounded-lg border border-neutral-200 bg-white p-8 text-center">
<Calendar className="mx-auto h-8 w-8 text-neutral-300" />
<p className="mt-2 text-sm text-neutral-500">
Verfahrensart und Auslosedatum wahlen, um Fristen zu berechnen
<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>
)}

View File

@@ -8,6 +8,7 @@ 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";
@@ -25,7 +26,7 @@ const urgencyConfig = {
border: "border-red-200",
badge: "bg-red-100 text-red-700",
dot: "bg-red-500",
label: "Uberschritten",
label: "Überfällig",
},
amber: {
bg: "bg-amber-50",
@@ -43,6 +44,9 @@ const urgencyConfig = {
},
};
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");
@@ -66,7 +70,7 @@ export function DeadlineList() {
toast.success("Frist als erledigt markiert");
},
onError: () => {
toast.error("Fehler beim Abschliessen der Frist");
toast.error("Fehler beim Abschließen der Frist");
},
});
@@ -80,7 +84,8 @@ export function DeadlineList() {
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 === "completed" && d.status !== "completed")
return false;
if (statusFilter === "overdue") {
if (d.status === "completed") return false;
if (!isPast(parseISO(d.due_date))) return false;
@@ -92,7 +97,9 @@ export function DeadlineList() {
const counts = useMemo(() => {
if (!deadlines) return { overdue: 0, thisWeek: 0, ok: 0 };
let 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);
@@ -107,7 +114,10 @@ export function DeadlineList() {
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
key={i}
className="h-16 animate-pulse rounded-lg bg-neutral-100"
/>
))}
</div>
);
@@ -118,42 +128,52 @@ export function DeadlineList() {
{/* 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-colors ${
onClick={() =>
setStatusFilter(statusFilter === "overdue" ? "all" : "overdue")
}
className={`rounded-lg border p-3 text-left transition-all ${
statusFilter === "overdue"
? "border-red-300 bg-red-50"
? "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 text-red-600">{counts.overdue}</div>
<div className="text-xs text-neutral-500">Uberschritten</div>
<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-colors ${
onClick={() =>
setStatusFilter(statusFilter === "pending" ? "all" : "pending")
}
className={`rounded-lg border p-3 text-left transition-all ${
statusFilter === "pending"
? "border-amber-300 bg-amber-50"
? "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 text-amber-600">{counts.thisWeek}</div>
<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-colors ${
className={`rounded-lg border p-3 text-left transition-all ${
statusFilter === "all"
? "border-green-300 bg-green-50"
? "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 text-green-600">{counts.ok}</div>
<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 items-center gap-3">
<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>
@@ -161,18 +181,18 @@ export function DeadlineList() {
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as StatusFilter)}
className="rounded-md border border-neutral-200 bg-white px-2.5 py-1 text-sm text-neutral-700"
className={selectClass}
>
<option value="all">Alle Status</option>
<option value="pending">Offen</option>
<option value="completed">Erledigt</option>
<option value="overdue">Uberschritten</option>
<option value="overdue">Überfällig</option>
</select>
{cases && cases.length > 0 && (
<select
value={caseFilter}
onChange={(e) => setCaseFilter(e.target.value)}
className="rounded-md border border-neutral-200 bg-white px-2.5 py-1 text-sm text-neutral-700"
className={selectClass}
>
<option value="all">Alle Akten</option>
{cases.map((c) => (
@@ -186,10 +206,15 @@ export function DeadlineList() {
{/* Deadline list */}
{filtered.length === 0 ? (
<div className="rounded-lg border border-neutral-200 bg-white p-8 text-center">
<Clock className="mx-auto h-8 w-8 text-neutral-300" />
<p className="mt-2 text-sm text-neutral-500">Keine Fristen gefunden</p>
</div>
<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) => {
@@ -200,15 +225,19 @@ export function DeadlineList() {
return (
<div
key={deadline.id}
className={`flex items-center gap-3 rounded-lg border px-4 py-3 ${config.bg} ${config.border}`}
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={`h-2.5 w-2.5 shrink-0 rounded-full ${config.dot}`}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<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}`}>
<span
className={`shrink-0 rounded px-1.5 py-0.5 text-xs font-medium ${config.badge}`}
>
{config.label}
</span>
{deadline.status === "completed" && (
@@ -217,9 +246,11 @@ export function DeadlineList() {
</span>
)}
</div>
<div className="mt-0.5 flex items-center gap-2 text-xs text-neutral-500">
<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 })}
{format(parseISO(deadline.due_date), "dd. MMM yyyy", {
locale: de,
})}
</span>
{caseInfo && (
<>
@@ -242,7 +273,7 @@ export function DeadlineList() {
onClick={() => completeMutation.mutate(deadline.id)}
disabled={completeMutation.isPending}
title="Als erledigt markieren"
className="shrink-0 rounded-md p-1.5 text-neutral-400 hover:bg-white hover:text-green-600"
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>