Files
tickettracker/frontend/src/components/TodoPanel.tsx
T
2026-10-15 08:09:47 +02:00

406 lines
18 KiB
TypeScript

import { CalendarDays, CheckCircle2, Circle, GripVertical, Pencil, Plus, Save, Trash2, X } from "lucide-react";
import { type FormEvent, type ReactNode, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { createTicketTodo, deleteTodo, updateTodo } from "../api";
import { currentDay } from "../format";
import { formatTodoPeriod, isTodoOverdue } from "../todos";
import type { TicketTodo, TodoPriority, TodoStatus } from "../types";
const priorityLabels: Record<TodoPriority, string> = {
low: "Niedrig",
medium: "Normal",
high: "Hoch",
urgent: "Dringend"
};
const priorityClasses: Record<TodoPriority, string> = {
low: "border-sky-500/30 bg-sky-500/10 text-sky-300",
medium: "border-muted-foreground/25 bg-muted/50 text-muted-foreground",
high: "border-amber-500/30 bg-amber-500/10 text-amber-300",
urgent: "border-red-500/30 bg-red-500/10 text-red-300"
};
function notifyTodosChanged() {
window.dispatchEvent(new Event("tickettracker:todos-changed"));
}
type TodoPanelProps = {
ticketId?: string;
todos: TicketTodo[];
onChanged: () => Promise<void> | void;
showCreate?: boolean;
showTicketMeta?: boolean;
onNavigate?: (to: string) => void;
reorderEnabled?: boolean;
onReorder?: (draggedTodoId: string, targetTodoId: string) => void;
toolbar?: ReactNode;
compact?: boolean;
};
export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showTicketMeta = false, onNavigate, reorderEnabled = false, onReorder, toolbar, compact = false }: TodoPanelProps) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [priority, setPriority] = useState<TodoPriority>("medium");
const [startsOn, setStartsOn] = useState("");
const [endsOn, setEndsOn] = useState("");
const [saving, setSaving] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editTitle, setEditTitle] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editPriority, setEditPriority] = useState<TodoPriority>("medium");
const [editStartsOn, setEditStartsOn] = useState("");
const [editEndsOn, setEditEndsOn] = useState("");
const [busyId, setBusyId] = useState<string | null>(null);
const [draggingId, setDraggingId] = useState<string | null>(null);
async function submitTodo(event: FormEvent) {
event.preventDefault();
if (!ticketId) {
return;
}
if (startsOn && endsOn && endsOn < startsOn) {
toast.error("Zeitraum prüfen", {
description: "Das Bis-Datum darf nicht vor dem Von-Datum liegen."
});
return;
}
setSaving(true);
try {
await createTicketTodo(ticketId, { title, description, priority, startsOn: startsOn || null, endsOn: endsOn || null });
setTitle("");
setDescription("");
setPriority("medium");
setStartsOn("");
setEndsOn("");
setCreateOpen(false);
toast.success("ToDo angelegt");
await onChanged();
notifyTodosChanged();
} catch (error) {
toast.error("ToDo konnte nicht angelegt werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSaving(false);
}
}
function startEdit(todo: TicketTodo) {
setEditingId(todo.id);
setEditTitle(todo.title);
setEditDescription(todo.description);
setEditPriority(todo.priority);
setEditStartsOn(todo.starts_on ?? "");
setEditEndsOn(todo.ends_on ?? "");
}
async function saveTodo(todo: TicketTodo, status: TodoStatus = todo.status) {
if (editingId === todo.id && editStartsOn && editEndsOn && editEndsOn < editStartsOn) {
toast.error("Zeitraum prüfen", {
description: "Das Bis-Datum darf nicht vor dem Von-Datum liegen."
});
return;
}
setBusyId(todo.id);
try {
await updateTodo(todo.id, {
title: editingId === todo.id ? editTitle : todo.title,
description: editingId === todo.id ? editDescription : todo.description,
priority: editingId === todo.id ? editPriority : todo.priority,
status,
startsOn: editingId === todo.id ? editStartsOn || null : todo.starts_on,
endsOn: editingId === todo.id ? editEndsOn || null : todo.ends_on
});
setEditingId(null);
await onChanged();
notifyTodosChanged();
} catch (error) {
toast.error("ToDo konnte nicht gespeichert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setBusyId(null);
}
}
async function removeTodo(todo: TicketTodo) {
const confirmed = window.confirm(`ToDo "${todo.title}" wirklich löschen?`);
if (!confirmed) {
return;
}
setBusyId(todo.id);
try {
await deleteTodo(todo.id);
toast.success("ToDo gelöscht");
await onChanged();
notifyTodosChanged();
} catch (error) {
toast.error("ToDo konnte nicht gelöscht werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setBusyId(null);
}
}
return (
<div className={compact ? "space-y-2" : "space-y-3"}>
{toolbar || (showCreate && ticketId) ? (
<div className={cn("flex flex-wrap items-center gap-2", toolbar ? "justify-start" : "justify-end")}>
<div className="min-w-0">{toolbar}</div>
{showCreate && ticketId ? (
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button type="button" size="sm">
<Plus className="size-4" />
ToDo anlegen
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>ToDo anlegen</DialogTitle>
<DialogDescription>Neue Aufgabe zu diesem Ticket oder Projekt erfassen.</DialogDescription>
</DialogHeader>
<form className="space-y-4" onSubmit={submitTodo}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor={`todo-title-${ticketId}`}>
Aufgabe
</label>
<Input id={`todo-title-${ticketId}`} value={title} onChange={(event) => setTitle(event.currentTarget.value)} autoFocus required />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor={`todo-priority-${ticketId}`}>
Priorität
</label>
<select
id={`todo-priority-${ticketId}`}
className="h-9 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={priority}
onChange={(event) => setPriority(event.currentTarget.value as TodoPriority)}
>
{Object.entries(priorityLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor={`todo-starts-on-${ticketId}`}>
Umsetzung von
</label>
<div className="flex gap-2">
<Input id={`todo-starts-on-${ticketId}`} type="date" value={startsOn} onChange={(event) => setStartsOn(event.currentTarget.value)} />
{startsOn ? (
<Button type="button" size="icon" variant="ghost" className="shrink-0" onClick={() => setStartsOn("")} aria-label="Von-Datum entfernen">
<X className="size-4" />
</Button>
) : null}
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor={`todo-ends-on-${ticketId}`}>
Umsetzung bis
</label>
<div className="flex gap-2">
<Input id={`todo-ends-on-${ticketId}`} type="date" value={endsOn} onChange={(event) => setEndsOn(event.currentTarget.value)} />
{endsOn ? (
<Button type="button" size="icon" variant="ghost" className="shrink-0" onClick={() => setEndsOn("")} aria-label="Bis-Datum entfernen">
<X className="size-4" />
</Button>
) : null}
</div>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor={`todo-description-${ticketId}`}>
Details
</label>
<Textarea
id={`todo-description-${ticketId}`}
className="min-h-24"
value={description}
onChange={(event) => setDescription(event.currentTarget.value)}
/>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={saving}>
<Plus className="size-4" />
{saving ? "Speichert..." : "Anlegen"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
) : null}
</div>
) : null}
<div className={compact ? "space-y-1.5" : "space-y-2"}>
{todos.map((todo) => {
const isEditing = editingId === todo.id;
const periodLabel = formatTodoPeriod(todo);
const overdue = isTodoOverdue(todo, currentDay());
return (
<div
key={todo.id}
className={cn(
"rounded-md border bg-card/50 transition-colors",
compact ? "p-2" : "p-3",
overdue && "border-red-500/50 bg-red-500/10",
todo.status === "done" && "opacity-70",
reorderEnabled && draggingId === todo.id && "border-primary/50 bg-primary/5"
)}
onDragOver={
reorderEnabled
? (event) => {
event.preventDefault();
}
: undefined
}
onDrop={
reorderEnabled
? (event) => {
event.preventDefault();
const draggedTodoId = event.dataTransfer.getData("text/todo-id") || draggingId;
if (draggedTodoId && draggedTodoId !== todo.id) {
onReorder?.(draggedTodoId, todo.id);
}
setDraggingId(null);
}
: undefined
}
>
{showTicketMeta ? (
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<button type="button" className="font-medium text-foreground hover:underline" onClick={() => onNavigate?.(`/tickets/${todo.ticket_id}`)}>
{todo.ticket_number}
</button>
{todo.project_id ? <Badge variant="outline">Projekt</Badge> : null}
{todo.organization_name ? <span>{todo.organization_name}</span> : null}
</div>
) : null}
{isEditing ? (
<div className="space-y-2">
<Input value={editTitle} onChange={(event) => setEditTitle(event.currentTarget.value)} />
<Textarea className="min-h-16" value={editDescription} onChange={(event) => setEditDescription(event.currentTarget.value)} />
<div className="grid gap-2 sm:grid-cols-2">
<div className="flex gap-2">
<Input type="date" value={editStartsOn} onChange={(event) => setEditStartsOn(event.currentTarget.value)} />
{editStartsOn ? (
<Button type="button" size="icon" variant="ghost" className="shrink-0" onClick={() => setEditStartsOn("")} aria-label="Von-Datum entfernen">
<X className="size-4" />
</Button>
) : null}
</div>
<div className="flex gap-2">
<Input type="date" value={editEndsOn} onChange={(event) => setEditEndsOn(event.currentTarget.value)} />
{editEndsOn ? (
<Button type="button" size="icon" variant="ghost" className="shrink-0" onClick={() => setEditEndsOn("")} aria-label="Bis-Datum entfernen">
<X className="size-4" />
</Button>
) : null}
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<select
className="h-8 rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={editPriority}
onChange={(event) => setEditPriority(event.currentTarget.value as TodoPriority)}
>
{Object.entries(priorityLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<div className="flex gap-1">
<Button type="button" size="sm" variant="ghost" onClick={() => setEditingId(null)}>
Abbrechen
</Button>
<Button type="button" size="sm" disabled={busyId === todo.id} onClick={() => void saveTodo(todo)}>
<Save className="size-4" />
Speichern
</Button>
</div>
</div>
</div>
) : (
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 flex-1 items-start gap-2">
{reorderEnabled ? (
<button
type="button"
className="mt-0.5 flex size-6 shrink-0 cursor-grab items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground active:cursor-grabbing"
aria-label="ToDo verschieben"
draggable
onDragStart={(event) => {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/todo-id", todo.id);
setDraggingId(todo.id);
}}
onDragEnd={() => setDraggingId(null)}
>
<GripVertical className="size-4" />
</button>
) : null}
<button
type="button"
className="flex min-w-0 flex-1 items-start gap-2 text-left"
disabled={busyId === todo.id}
onClick={() => void saveTodo(todo, todo.status === "done" ? "open" : "done")}
>
{todo.status === "done" ? <CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-400" /> : <Circle className="mt-0.5 size-4 shrink-0 text-muted-foreground" />}
<span className="min-w-0">
<span className={cn("block break-words text-sm font-medium", todo.status === "done" && "line-through")}>{todo.title}</span>
{periodLabel ? (
<span className={cn("mt-1 flex items-center gap-1 text-xs", overdue ? "text-red-300" : "text-muted-foreground")}>
<CalendarDays className="size-3.5" />
{periodLabel}
</span>
) : null}
{todo.description ? <span className="mt-1 block whitespace-pre-wrap break-words text-xs text-muted-foreground">{todo.description}</span> : null}
</span>
</button>
</div>
<div className="flex shrink-0 items-center gap-1">
{overdue ? <Badge variant="destructive">überfällig</Badge> : null}
<span className={cn("rounded-full border px-2 py-0.5 text-xs font-medium", priorityClasses[todo.priority])}>{priorityLabels[todo.priority]}</span>
<Button type="button" size="icon" variant="ghost" className="size-8" onClick={() => startEdit(todo)}>
<Pencil className="size-4" />
</Button>
<Button type="button" size="icon" variant="ghost" className="size-8 text-destructive hover:text-destructive" onClick={() => void removeTodo(todo)}>
<Trash2 className="size-4" />
</Button>
</div>
</div>
)}
</div>
);
})}
</div>
{todos.length === 0 ? <p className={cn("rounded-md border bg-muted/20 text-center text-sm text-muted-foreground", compact ? "p-3" : "p-4")}>Keine ToDos vorhanden.</p> : null}
</div>
);
}