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:
@@ -13,6 +13,9 @@ interface ExtractionFormProps {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
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 ExtractionForm({
|
||||
cases,
|
||||
selectedCaseId,
|
||||
@@ -63,10 +66,10 @@ export function ExtractionForm({
|
||||
id="case-select"
|
||||
value={selectedCaseId}
|
||||
onChange={(e) => onCaseChange(e.target.value)}
|
||||
className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 focus:border-neutral-500 focus:outline-none focus:ring-1 focus:ring-neutral-500"
|
||||
className={inputClass}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<option value="">Akte auswaehlen...</option>
|
||||
<option value="">Akte auswählen...</option>
|
||||
{cases.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.case_number} - {c.title}
|
||||
@@ -95,7 +98,7 @@ export function ExtractionForm({
|
||||
type="button"
|
||||
onClick={removeFile}
|
||||
disabled={isLoading}
|
||||
className="rounded p-1 text-neutral-400 hover:bg-neutral-200 hover:text-neutral-600"
|
||||
className="rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-200 hover:text-neutral-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -142,10 +145,10 @@ export function ExtractionForm({
|
||||
setText(e.target.value);
|
||||
if (e.target.value.trim()) setFile(null);
|
||||
}}
|
||||
placeholder="Gerichtsschriftsatz, Beschluss oder sonstigen Text hier einfuegen..."
|
||||
placeholder="Gerichtsschriftsatz, Beschluss oder sonstigen Text hier einfügen..."
|
||||
rows={6}
|
||||
disabled={isLoading}
|
||||
className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-1 focus:ring-neutral-500 disabled:opacity-50"
|
||||
className={`${inputClass} resize-y placeholder:text-neutral-400 disabled:opacity-50`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Trash2, Check, Pencil, X, Loader2 } from "lucide-react";
|
||||
import { Trash2, Check, Pencil, X, Loader2, Brain } from "lucide-react";
|
||||
import type { ExtractedDeadline } from "@/lib/types";
|
||||
|
||||
interface ExtractionResultsProps {
|
||||
@@ -22,6 +22,9 @@ function confidenceLabel(confidence: number): string {
|
||||
return "Niedrig";
|
||||
}
|
||||
|
||||
const editInputClass =
|
||||
"w-full rounded border border-neutral-300 px-2 py-1 text-sm outline-none transition-colors focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400";
|
||||
|
||||
export function ExtractionResults({
|
||||
deadlines: initialDeadlines,
|
||||
onAdopt,
|
||||
@@ -56,8 +59,11 @@ export function ExtractionResults({
|
||||
|
||||
if (deadlines.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-neutral-200 bg-neutral-50 p-6 text-center">
|
||||
<p className="text-sm text-neutral-500">
|
||||
<div className="flex flex-col items-center py-8 text-center">
|
||||
<div className="rounded-xl bg-neutral-100 p-3">
|
||||
<Brain className="h-5 w-5 text-neutral-400" />
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-neutral-500">
|
||||
Keine Fristen gefunden. Alle extrahierten Fristen wurden entfernt.
|
||||
</p>
|
||||
</div>
|
||||
@@ -66,7 +72,7 @@ export function ExtractionResults({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h3 className="text-sm font-medium text-neutral-900">
|
||||
{deadlines.length} Frist{deadlines.length !== 1 ? "en" : ""} erkannt
|
||||
</h3>
|
||||
@@ -78,18 +84,19 @@ export function ExtractionResults({
|
||||
{isAdopting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Uebernehme...
|
||||
Übernehme...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="h-4 w-4" />
|
||||
Fristen uebernehmen
|
||||
Fristen übernehmen
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-md border border-neutral-200">
|
||||
{/* Mobile: card layout, Desktop: table */}
|
||||
<div className="hidden overflow-hidden rounded-md border border-neutral-200 sm:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-neutral-200 bg-neutral-50">
|
||||
@@ -97,7 +104,7 @@ export function ExtractionResults({
|
||||
Frist
|
||||
</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium text-neutral-700">
|
||||
Faelligkeitsdatum
|
||||
Fälligkeitsdatum
|
||||
</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium text-neutral-700">
|
||||
Rechtsgrundlage
|
||||
@@ -105,7 +112,7 @@ export function ExtractionResults({
|
||||
<th className="px-4 py-2.5 text-left font-medium text-neutral-700">
|
||||
Konfidenz
|
||||
</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium text-neutral-700">
|
||||
<th className="hidden px-4 py-2.5 text-left font-medium text-neutral-700 lg:table-cell">
|
||||
Quellenangabe
|
||||
</th>
|
||||
<th className="px-4 py-2.5 text-right font-medium text-neutral-700">
|
||||
@@ -117,7 +124,7 @@ export function ExtractionResults({
|
||||
{deadlines.map((d, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className="border-b border-neutral-100 last:border-b-0"
|
||||
className="border-b border-neutral-100 transition-colors last:border-b-0 hover:bg-neutral-50"
|
||||
>
|
||||
{editingIndex === i && editForm ? (
|
||||
<>
|
||||
@@ -127,7 +134,7 @@ export function ExtractionResults({
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, title: e.target.value })
|
||||
}
|
||||
className="w-full rounded border border-neutral-300 px-2 py-1 text-sm"
|
||||
className={editInputClass}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
@@ -140,7 +147,7 @@ export function ExtractionResults({
|
||||
due_date: e.target.value || null,
|
||||
})
|
||||
}
|
||||
className="rounded border border-neutral-300 px-2 py-1 text-sm"
|
||||
className={editInputClass}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
@@ -152,7 +159,7 @@ export function ExtractionResults({
|
||||
rule_reference: e.target.value,
|
||||
})
|
||||
}
|
||||
className="w-full rounded border border-neutral-300 px-2 py-1 text-sm"
|
||||
className={editInputClass}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
@@ -162,21 +169,21 @@ export function ExtractionResults({
|
||||
{confidenceLabel(editForm.confidence)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-neutral-500">
|
||||
<td className="hidden px-4 py-2 text-xs text-neutral-500 lg:table-cell">
|
||||
{editForm.source_quote}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={saveEdit}
|
||||
className="rounded p-1 text-green-600 hover:bg-green-50"
|
||||
className="rounded p-1 text-green-600 transition-colors hover:bg-green-50"
|
||||
title="Speichern"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEdit}
|
||||
className="rounded p-1 text-neutral-400 hover:bg-neutral-100"
|
||||
className="rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-100"
|
||||
title="Abbrechen"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
@@ -205,21 +212,21 @@ export function ExtractionResults({
|
||||
{Math.round(d.confidence * 100)}%
|
||||
</span>
|
||||
</td>
|
||||
<td className="max-w-48 truncate px-4 py-2.5 text-xs text-neutral-500">
|
||||
<td className="hidden max-w-48 truncate px-4 py-2.5 text-xs text-neutral-500 lg:table-cell">
|
||||
{d.source_quote || "-"}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={() => startEdit(i)}
|
||||
className="rounded p-1 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600"
|
||||
className="rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600"
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeDeadline(i)}
|
||||
className="rounded p-1 text-neutral-400 hover:bg-red-50 hover:text-red-600"
|
||||
className="rounded p-1 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-600"
|
||||
title="Entfernen"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
@@ -233,6 +240,53 @@ export function ExtractionResults({
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile card layout */}
|
||||
<div className="space-y-3 sm:hidden">
|
||||
{deadlines.map((d, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-md border border-neutral-200 bg-white p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium text-neutral-900">{d.title}</p>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
onClick={() => startEdit(i)}
|
||||
className="rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeDeadline(i)}
|
||||
className="rounded p-1 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-neutral-500">
|
||||
<span>
|
||||
{d.due_date
|
||||
? new Date(d.due_date).toLocaleDateString("de-DE")
|
||||
: `${d.duration_value} ${d.duration_unit}`}
|
||||
</span>
|
||||
{d.rule_reference && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{d.rule_reference}</span>
|
||||
</>
|
||||
)}
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 font-medium ${confidenceColor(d.confidence)}`}
|
||||
>
|
||||
{confidenceLabel(d.confidence)} {Math.round(d.confidence * 100)}
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import { useState } from "react";
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: "", label: "-- Typ wahlen --" },
|
||||
{ value: "", label: "-- Typ wählen --" },
|
||||
{ value: "INF", label: "Verletzungsklage (INF)" },
|
||||
{ value: "REV", label: "Widerruf (REV)" },
|
||||
{ value: "CCR", label: "Einstweilige Verfugung (CCR)" },
|
||||
{ value: "CCR", label: "Einstweilige Verfügung (CCR)" },
|
||||
{ value: "APP", label: "Berufung (APP)" },
|
||||
{ value: "PI", label: "Vorlaufiger Rechtsschutz (PI)" },
|
||||
{ value: "PI", label: "Vorläufiger Rechtsschutz (PI)" },
|
||||
{ value: "ZPO_CIVIL", label: "ZPO Zivilverfahren" },
|
||||
];
|
||||
|
||||
@@ -43,8 +43,23 @@ export function CaseForm({
|
||||
status: initialData?.status ?? "active",
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof CaseFormData, string>>>({});
|
||||
|
||||
function validate(): boolean {
|
||||
const newErrors: Partial<Record<keyof CaseFormData, string>> = {};
|
||||
if (!form.case_number.trim()) {
|
||||
newErrors.case_number = "Aktenzeichen ist erforderlich";
|
||||
}
|
||||
if (!form.title.trim()) {
|
||||
newErrors.title = "Titel ist erforderlich";
|
||||
}
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
const data: CaseFormData = {
|
||||
...form,
|
||||
case_type: form.case_type || undefined,
|
||||
@@ -56,26 +71,31 @@ export function CaseForm({
|
||||
|
||||
function update(field: keyof CaseFormData, value: string) {
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
if (errors[field]) {
|
||||
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
||||
}
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400";
|
||||
"w-full rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm outline-none transition-colors focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400";
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-neutral-700">
|
||||
Aktenzeichen *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={form.case_number}
|
||||
onChange={(e) => update("case_number", e.target.value)}
|
||||
placeholder="z.B. 2026/001"
|
||||
className={inputClass}
|
||||
className={`${inputClass} ${errors.case_number ? "border-red-300 focus:border-red-400 focus:ring-red-400" : ""}`}
|
||||
/>
|
||||
{errors.case_number && (
|
||||
<p className="mt-1 text-xs text-red-600">{errors.case_number}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-neutral-700">
|
||||
@@ -87,7 +107,7 @@ export function CaseForm({
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="active">Aktiv</option>
|
||||
<option value="pending">Anhangig</option>
|
||||
<option value="pending">Anhängig</option>
|
||||
<option value="closed">Geschlossen</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -99,15 +119,17 @@ export function CaseForm({
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={form.title}
|
||||
onChange={(e) => update("title", e.target.value)}
|
||||
placeholder="Bezeichnung der Akte"
|
||||
className={inputClass}
|
||||
className={`${inputClass} ${errors.title ? "border-red-300 focus:border-red-400 focus:ring-red-400" : ""}`}
|
||||
/>
|
||||
{errors.title && (
|
||||
<p className="mt-1 text-xs text-red-600">{errors.title}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-neutral-700">
|
||||
Verfahrensart
|
||||
@@ -132,7 +154,7 @@ export function CaseForm({
|
||||
type="text"
|
||||
value={form.court}
|
||||
onChange={(e) => update("court", e.target.value)}
|
||||
placeholder="z.B. UPC Munich Central Division"
|
||||
placeholder="z.B. UPC München Zentralkammer"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
@@ -155,7 +177,7 @@ export function CaseForm({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="rounded-md bg-neutral-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50"
|
||||
className="rounded-md bg-neutral-900 px-4 py-1.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800 disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? "Speichern..." : submitLabel}
|
||||
</button>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { CaseEvent } from "@/lib/types";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { Activity } from "lucide-react";
|
||||
|
||||
const EVENT_ICONS: Record<string, string> = {
|
||||
case_created: "bg-emerald-500",
|
||||
@@ -20,9 +21,14 @@ interface CaseTimelineProps {
|
||||
export function CaseTimeline({ events }: CaseTimelineProps) {
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<p className="py-8 text-center text-sm text-neutral-400">
|
||||
Keine Ereignisse vorhanden.
|
||||
</p>
|
||||
<div className="flex flex-col items-center py-8 text-center">
|
||||
<div className="rounded-xl bg-neutral-100 p-3">
|
||||
<Activity className="h-5 w-5 text-neutral-400" />
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-neutral-500">
|
||||
Keine Ereignisse vorhanden.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Party } from "@/lib/types";
|
||||
import { Plus, Trash2, X } from "lucide-react";
|
||||
import { Plus, Trash2, X, Users } from "lucide-react";
|
||||
|
||||
interface PartyListProps {
|
||||
caseId: string;
|
||||
@@ -19,13 +19,16 @@ interface PartyFormData {
|
||||
}
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
"Klager",
|
||||
"Kläger",
|
||||
"Beklagter",
|
||||
"Nebenintervenient",
|
||||
"Patentinhaber",
|
||||
"Streithelfer",
|
||||
];
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm outline-none transition-colors focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400";
|
||||
|
||||
export function PartyList({ caseId, parties }: PartyListProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
@@ -44,11 +47,11 @@ export function PartyList({ caseId, parties }: PartyListProps) {
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["case", caseId] });
|
||||
toast.success("Partei hinzugefugt");
|
||||
toast.success("Partei hinzugefügt");
|
||||
setShowForm(false);
|
||||
setForm({ name: "", role: "", representative: "" });
|
||||
},
|
||||
onError: () => toast.error("Fehler beim Hinzufugen"),
|
||||
onError: () => toast.error("Fehler beim Hinzufügen"),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
@@ -60,9 +63,6 @@ export function PartyList({ caseId, parties }: PartyListProps) {
|
||||
onError: () => toast.error("Fehler beim Entfernen"),
|
||||
});
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -72,25 +72,37 @@ export function PartyList({ caseId, parties }: PartyListProps) {
|
||||
{!showForm && (
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="inline-flex items-center gap-1 text-sm text-neutral-500 hover:text-neutral-700"
|
||||
className="inline-flex items-center gap-1 text-sm text-neutral-500 transition-colors hover:text-neutral-700"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Hinzufugen
|
||||
Hinzufügen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{parties.length === 0 && !showForm && (
|
||||
<p className="mt-4 py-4 text-center text-sm text-neutral-400">
|
||||
Keine Parteien vorhanden.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-col items-center py-6 text-center">
|
||||
<div className="rounded-xl bg-neutral-100 p-3">
|
||||
<Users className="h-5 w-5 text-neutral-400" />
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-neutral-500">
|
||||
Keine Parteien vorhanden.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="mt-3 inline-flex items-center gap-1 text-sm text-neutral-500 transition-colors hover:text-neutral-700"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Erste Partei hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
{parties.map((party) => (
|
||||
<div
|
||||
key={party.id}
|
||||
className="flex items-center justify-between rounded-md border border-neutral-200 bg-white px-4 py-2.5"
|
||||
className="flex items-center justify-between rounded-md border border-neutral-200 bg-white px-4 py-2.5 transition-colors hover:bg-neutral-50"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-900">
|
||||
@@ -105,7 +117,7 @@ export function PartyList({ caseId, parties }: PartyListProps) {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(party.id)}
|
||||
className="rounded p-1 text-neutral-300 hover:bg-neutral-100 hover:text-red-500"
|
||||
className="rounded p-1 text-neutral-300 transition-colors hover:bg-neutral-100 hover:text-red-500"
|
||||
title="Partei entfernen"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
@@ -122,7 +134,7 @@ export function PartyList({ caseId, parties }: PartyListProps) {
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowForm(false)}
|
||||
className="text-neutral-400 hover:text-neutral-600"
|
||||
className="text-neutral-400 transition-colors hover:text-neutral-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -130,19 +142,22 @@ export function PartyList({ caseId, parties }: PartyListProps) {
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!form.name.trim()) {
|
||||
toast.error("Bitte Namen eingeben");
|
||||
return;
|
||||
}
|
||||
addMutation.mutate(form);
|
||||
}}
|
||||
className="mt-3 space-y-3"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Name der Partei"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className={inputClass}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<select
|
||||
value={form.role}
|
||||
onChange={(e) => setForm({ ...form, role: e.target.value })}
|
||||
@@ -169,9 +184,9 @@ export function PartyList({ caseId, parties }: PartyListProps) {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={addMutation.isPending}
|
||||
className="rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50"
|
||||
className="rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800 disabled:opacity-50"
|
||||
>
|
||||
{addMutation.isPending ? "..." : "Hinzufugen"}
|
||||
{addMutation.isPending ? "Speichern..." : "Hinzufügen"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -6,19 +6,19 @@ import { FolderPlus, Clock, Sparkles, CalendarSync } from "lucide-react";
|
||||
const actions = [
|
||||
{
|
||||
label: "Neue Akte",
|
||||
href: "/akten?new=1",
|
||||
href: "/cases/new",
|
||||
icon: FolderPlus,
|
||||
color: "text-blue-600 bg-blue-50 hover:bg-blue-100",
|
||||
},
|
||||
{
|
||||
label: "Frist eintragen",
|
||||
href: "/fristen?new=1",
|
||||
href: "/fristen",
|
||||
icon: Clock,
|
||||
color: "text-amber-600 bg-amber-50 hover:bg-amber-100",
|
||||
},
|
||||
{
|
||||
label: "AI Analyse",
|
||||
href: "/ai",
|
||||
href: "/ai/extract",
|
||||
icon: Sparkles,
|
||||
color: "text-violet-600 bg-violet-50 hover:bg-violet-100",
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -25,16 +25,19 @@ export function Header() {
|
||||
|
||||
return (
|
||||
<header className="flex h-14 items-center justify-between border-b border-neutral-200 bg-white px-4">
|
||||
<div />
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Spacer for mobile hamburger */}
|
||||
<div className="w-8 lg:w-0" />
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<TenantSwitcher />
|
||||
{email && (
|
||||
<span className="text-sm text-neutral-500">{email}</span>
|
||||
<span className="hidden text-sm text-neutral-500 sm:inline">
|
||||
{email}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
title="Abmelden"
|
||||
className="rounded-md p-1.5 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600"
|
||||
className="rounded-md p-1.5 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
@@ -9,11 +9,14 @@ import {
|
||||
Calendar,
|
||||
Brain,
|
||||
Settings,
|
||||
Menu,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
const navigation = [
|
||||
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
||||
{ name: "Akten", href: "/akten", icon: FolderOpen },
|
||||
{ name: "Akten", href: "/cases", icon: FolderOpen },
|
||||
{ name: "Fristen", href: "/fristen", icon: Clock },
|
||||
{ name: "Termine", href: "/termine", icon: Calendar },
|
||||
{ name: "AI Analyse", href: "/ai/extract", icon: Brain },
|
||||
@@ -22,20 +25,43 @@ const navigation = [
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-56 flex-col border-r border-neutral-200 bg-white">
|
||||
<div className="flex h-14 items-center border-b border-neutral-200 px-4">
|
||||
// Close on route change
|
||||
useEffect(() => {
|
||||
setMobileOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
// Close on escape
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setMobileOpen(false);
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
const navContent = (
|
||||
<>
|
||||
<div className="flex h-14 items-center justify-between border-b border-neutral-200 px-4">
|
||||
<span className="text-sm font-semibold text-neutral-900">KanzlAI</span>
|
||||
<button
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="rounded-md p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600 lg:hidden"
|
||||
aria-label="Menü schließen"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-0.5 p-2">
|
||||
{navigation.map((item) => {
|
||||
const isActive = pathname.startsWith(item.href);
|
||||
const isActive =
|
||||
pathname === item.href || pathname.startsWith(item.href + "/");
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors ${
|
||||
className={`flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm transition-colors ${
|
||||
isActive
|
||||
? "bg-neutral-100 font-medium text-neutral-900"
|
||||
: "text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900"
|
||||
@@ -47,6 +73,39 @@ export function Sidebar() {
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile hamburger button */}
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="fixed left-3 top-3.5 z-40 rounded-md bg-white p-1.5 shadow-sm ring-1 ring-neutral-200 transition-colors hover:bg-neutral-50 lg:hidden"
|
||||
aria-label="Menü öffnen"
|
||||
>
|
||||
<Menu className="h-5 w-5 text-neutral-700" />
|
||||
</button>
|
||||
|
||||
{/* Mobile overlay */}
|
||||
{mobileOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm lg:hidden"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile sidebar */}
|
||||
{mobileOpen && (
|
||||
<aside className="animate-slide-in-left fixed inset-y-0 left-0 z-50 flex w-56 flex-col border-r border-neutral-200 bg-white shadow-lg lg:hidden">
|
||||
{navContent}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* Desktop sidebar */}
|
||||
<aside className="hidden h-full w-56 flex-col border-r border-neutral-200 bg-white lg:flex">
|
||||
{navContent}
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,17 +12,20 @@ export function TenantSwitcher() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<TenantWithRole[]>("/tenants").then((data) => {
|
||||
setTenants(data);
|
||||
const savedId = localStorage.getItem("kanzlai_tenant_id");
|
||||
const match = data.find((t) => t.id === savedId) || data[0];
|
||||
if (match) {
|
||||
setCurrent(match);
|
||||
localStorage.setItem("kanzlai_tenant_id", match.id);
|
||||
}
|
||||
}).catch(() => {
|
||||
// Not authenticated or no tenants
|
||||
});
|
||||
api
|
||||
.get<TenantWithRole[]>("/tenants")
|
||||
.then((data) => {
|
||||
setTenants(data);
|
||||
const savedId = localStorage.getItem("kanzlai_tenant_id");
|
||||
const match = data.find((t) => t.id === savedId) || data[0];
|
||||
if (match) {
|
||||
setCurrent(match);
|
||||
localStorage.setItem("kanzlai_tenant_id", match.id);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Not authenticated or no tenants
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -48,14 +51,16 @@ export function TenantSwitcher() {
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-2.5 py-1.5 text-sm text-neutral-700 hover:bg-neutral-50"
|
||||
className="flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-2.5 py-1.5 text-sm text-neutral-700 transition-colors hover:bg-neutral-50"
|
||||
>
|
||||
<span className="max-w-[160px] truncate">{current.name}</span>
|
||||
<span className="max-w-[120px] truncate sm:max-w-[160px]">
|
||||
{current.name}
|
||||
</span>
|
||||
<ChevronsUpDown className="h-3.5 w-3.5 text-neutral-400" />
|
||||
</button>
|
||||
|
||||
{open && tenants.length > 1 && (
|
||||
<div className="absolute right-0 top-full z-50 mt-1 w-56 rounded-md border border-neutral-200 bg-white py-1 shadow-lg">
|
||||
<div className="animate-fade-in absolute right-0 top-full z-50 mt-1 w-56 rounded-md border border-neutral-200 bg-white py-1 shadow-lg">
|
||||
{tenants.map((tenant) => (
|
||||
<button
|
||||
key={tenant.id}
|
||||
|
||||
28
frontend/src/components/ui/EmptyState.tsx
Normal file
28
frontend/src/components/ui/EmptyState.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-neutral-300 bg-white px-6 py-12 text-center">
|
||||
<div className="rounded-xl bg-neutral-100 p-3">
|
||||
<Icon className="h-6 w-6 text-neutral-400" />
|
||||
</div>
|
||||
<h3 className="mt-3 text-sm font-medium text-neutral-900">{title}</h3>
|
||||
{description && (
|
||||
<p className="mt-1 max-w-sm text-sm text-neutral-500">{description}</p>
|
||||
)}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
frontend/src/components/ui/Skeleton.tsx
Normal file
43
frontend/src/components/ui/Skeleton.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
export function Skeleton({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={`animate-pulse rounded-md bg-neutral-200/60 ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonCard({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-xl border border-neutral-200 bg-white p-5 ${className}`}
|
||||
>
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<div className="mt-4 space-y-3">
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonTable({ rows = 5 }: { rows?: number }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-md border border-neutral-200 bg-white">
|
||||
<div className="border-b border-neutral-100 px-4 py-3">
|
||||
<Skeleton className="h-3 w-full" />
|
||||
</div>
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-4 border-b border-neutral-100 px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-3 flex-1" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-3 w-12" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user