- 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
210 lines
7.6 KiB
TypeScript
210 lines
7.6 KiB
TypeScript
"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>
|
|
);
|
|
}
|