262 lines
11 KiB
TypeScript
262 lines
11 KiB
TypeScript
import { CheckCircle2, Circle, Pencil, Plus, Save, Trash2 } from "lucide-react";
|
|
import { FormEvent, 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 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"
|
|
};
|
|
|
|
type TodoPanelProps = {
|
|
ticketId?: string;
|
|
todos: TicketTodo[];
|
|
onChanged: () => Promise<void> | void;
|
|
showCreate?: boolean;
|
|
showTicketMeta?: boolean;
|
|
onNavigate?: (to: string) => void;
|
|
};
|
|
|
|
export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showTicketMeta = false, onNavigate }: TodoPanelProps) {
|
|
const [title, setTitle] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
const [priority, setPriority] = useState<TodoPriority>("medium");
|
|
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 [busyId, setBusyId] = useState<string | null>(null);
|
|
|
|
async function submitTodo(event: FormEvent) {
|
|
event.preventDefault();
|
|
|
|
if (!ticketId) {
|
|
return;
|
|
}
|
|
|
|
setSaving(true);
|
|
try {
|
|
await createTicketTodo(ticketId, { title, description, priority });
|
|
setTitle("");
|
|
setDescription("");
|
|
setPriority("medium");
|
|
setCreateOpen(false);
|
|
toast.success("ToDo angelegt");
|
|
await onChanged();
|
|
} 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);
|
|
}
|
|
|
|
async function saveTodo(todo: TicketTodo, status: TodoStatus = todo.status) {
|
|
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
|
|
});
|
|
setEditingId(null);
|
|
await onChanged();
|
|
} 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();
|
|
} catch (error) {
|
|
toast.error("ToDo konnte nicht gelöscht werden", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
} finally {
|
|
setBusyId(null);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{showCreate && ticketId ? (
|
|
<div className="flex justify-end">
|
|
<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="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>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="space-y-2">
|
|
{todos.map((todo) => {
|
|
const isEditing = editingId === todo.id;
|
|
|
|
return (
|
|
<div key={todo.id} className={cn("rounded-md border bg-card/50 p-3", todo.status === "done" && "opacity-70")}>
|
|
{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="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">
|
|
<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>
|
|
{todo.description ? <span className="mt-1 block whitespace-pre-wrap break-words text-xs text-muted-foreground">{todo.description}</span> : null}
|
|
</span>
|
|
</button>
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
<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="rounded-md border bg-muted/20 p-4 text-center text-sm text-muted-foreground">Keine ToDos vorhanden.</p> : null}
|
|
</div>
|
|
);
|
|
}
|