feat: Phase C frontend detail pages for deadlines, appointments, events

- 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
This commit is contained in:
m
2026-03-25 19:29:12 +01:00
parent 9ad58e1ba3
commit 7094212dcf
8 changed files with 1330 additions and 0 deletions

View File

@@ -0,0 +1,209 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type { Note } from "@/lib/types";
import { format, parseISO } from "date-fns";
import { de } from "date-fns/locale";
import { Plus, Pencil, Trash2, X, Check, MessageSquare } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
interface NotesListProps {
parentType: "case" | "deadline" | "appointment" | "case_event";
parentId: string;
}
export function NotesList({ parentType, parentId }: NotesListProps) {
const queryClient = useQueryClient();
const queryKey = ["notes", parentType, parentId];
const [newContent, setNewContent] = useState("");
const [showNew, setShowNew] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editContent, setEditContent] = useState("");
const { data: notes, isLoading } = useQuery({
queryKey,
queryFn: () =>
api.get<Note[]>(`/notes?${parentType}_id=${parentId}`),
});
const createMutation = useMutation({
mutationFn: (content: string) => {
const body: Record<string, string> = {
content,
[`${parentType}_id`]: parentId,
};
return api.post<Note>("/notes", body);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey });
setNewContent("");
setShowNew(false);
toast.success("Notiz erstellt");
},
onError: () => toast.error("Fehler beim Erstellen der Notiz"),
});
const updateMutation = useMutation({
mutationFn: ({ id, content }: { id: string; content: string }) =>
api.put<Note>(`/notes/${id}`, { content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey });
setEditingId(null);
toast.success("Notiz aktualisiert");
},
onError: () => toast.error("Fehler beim Aktualisieren der Notiz"),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/notes/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey });
toast.success("Notiz geloescht");
},
onError: () => toast.error("Fehler beim Loeschen der Notiz"),
});
function handleCreate() {
if (!newContent.trim()) return;
createMutation.mutate(newContent.trim());
}
function handleUpdate(id: string) {
if (!editContent.trim()) return;
updateMutation.mutate({ id, content: editContent.trim() });
}
function startEdit(note: Note) {
setEditingId(note.id);
setEditContent(note.content);
}
const notesList = Array.isArray(notes) ? notes : [];
return (
<div className="rounded-lg border border-neutral-200 bg-white">
<div className="flex items-center justify-between border-b border-neutral-100 px-4 py-3">
<h3 className="text-sm font-medium text-neutral-900">Notizen</h3>
{!showNew && (
<button
onClick={() => setShowNew(true)}
className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-neutral-500 transition-colors hover:bg-neutral-50 hover:text-neutral-700"
>
<Plus className="h-3.5 w-3.5" />
Neu
</button>
)}
</div>
{showNew && (
<div className="border-b border-neutral-100 p-4">
<textarea
value={newContent}
onChange={(e) => setNewContent(e.target.value)}
rows={3}
autoFocus
placeholder="Notiz schreiben..."
className="w-full rounded-md border border-neutral-200 px-3 py-2 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
/>
<div className="mt-2 flex justify-end gap-2">
<button
onClick={() => {
setShowNew(false);
setNewContent("");
}}
className="rounded-md px-2.5 py-1 text-xs text-neutral-500 hover:bg-neutral-50"
>
Abbrechen
</button>
<button
onClick={handleCreate}
disabled={!newContent.trim() || createMutation.isPending}
className="rounded-md bg-neutral-900 px-2.5 py-1 text-xs font-medium text-white hover:bg-neutral-800 disabled:opacity-50"
>
{createMutation.isPending ? "Speichern..." : "Speichern"}
</button>
</div>
</div>
)}
{isLoading ? (
<div className="space-y-2 p-4">
{[1, 2].map((i) => (
<div key={i} className="h-12 animate-pulse rounded-md bg-neutral-100" />
))}
</div>
) : notesList.length === 0 ? (
<div className="flex flex-col items-center py-8 text-center">
<MessageSquare className="h-5 w-5 text-neutral-300" />
<p className="mt-2 text-sm text-neutral-400">
Keine Notizen vorhanden.
</p>
</div>
) : (
<div className="divide-y divide-neutral-100">
{notesList.map((note) => (
<div key={note.id} className="group px-4 py-3">
{editingId === note.id ? (
<div>
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
rows={3}
autoFocus
className="w-full rounded-md border border-neutral-200 px-3 py-2 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
/>
<div className="mt-2 flex justify-end gap-2">
<button
onClick={() => setEditingId(null)}
className="rounded-md p-1 text-neutral-400 hover:bg-neutral-50 hover:text-neutral-600"
>
<X className="h-3.5 w-3.5" />
</button>
<button
onClick={() => handleUpdate(note.id)}
disabled={!editContent.trim() || updateMutation.isPending}
className="rounded-md p-1 text-neutral-400 hover:bg-neutral-50 hover:text-green-600"
>
<Check className="h-3.5 w-3.5" />
</button>
</div>
</div>
) : (
<div>
<div className="flex items-start justify-between">
<p className="whitespace-pre-wrap text-sm text-neutral-700">
{note.content}
</p>
<div className="ml-4 flex shrink-0 gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={() => startEdit(note)}
className="rounded p-1 text-neutral-400 hover:bg-neutral-50 hover:text-neutral-600"
>
<Pencil className="h-3 w-3" />
</button>
<button
onClick={() => deleteMutation.mutate(note.id)}
className="rounded p-1 text-neutral-400 hover:bg-red-50 hover:text-red-500"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
</div>
<p className="mt-1 text-xs text-neutral-400">
{format(parseISO(note.created_at), "d. MMM yyyy, HH:mm", {
locale: de,
})}
{note.updated_at !== note.created_at && " (bearbeitet)"}
</p>
</div>
)}
</div>
))}
</div>
)}
</div>
);
}