Update
This commit is contained in:
1 parent
3d9674bd5a
commit
d1da86beab
20 files changed
+6109
-923
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { CheckCircle2, Circle, Pencil, Plus, Save, Trash2 } from "lucide-react";
|
||||
import { CalendarDays, CheckCircle2, Circle, GripVertical, Pencil, Plus, Save, Trash2, X } from "lucide-react";
|
||||
import { FormEvent, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -9,6 +9,8 @@ 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> = {
|
||||
@@ -25,6 +27,10 @@ const priorityClasses: Record<TodoPriority, string> = {
|
||||
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[];
|
||||
@@ -32,19 +38,26 @@ type TodoPanelProps = {
|
||||
showCreate?: boolean;
|
||||
showTicketMeta?: boolean;
|
||||
onNavigate?: (to: string) => void;
|
||||
reorderEnabled?: boolean;
|
||||
onReorder?: (draggedTodoId: string, targetTodoId: string) => void;
|
||||
};
|
||||
|
||||
export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showTicketMeta = false, onNavigate }: TodoPanelProps) {
|
||||
export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showTicketMeta = false, onNavigate, reorderEnabled = false, onReorder }: 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();
|
||||
@@ -53,15 +66,25 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
||||
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 });
|
||||
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"
|
||||
@@ -76,19 +99,31 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
||||
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
|
||||
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"
|
||||
@@ -110,6 +145,7 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
||||
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"
|
||||
@@ -159,6 +195,34 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
||||
))}
|
||||
</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
|
||||
@@ -185,9 +249,40 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
||||
<div className="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 p-3", todo.status === "done" && "opacity-70")}>
|
||||
<div
|
||||
key={todo.id}
|
||||
className={cn(
|
||||
"rounded-md border bg-card/50 p-3 transition-colors",
|
||||
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}`)}>
|
||||
@@ -202,6 +297,24 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
||||
<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"
|
||||
@@ -227,19 +340,44 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
||||
</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 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" />
|
||||
|
||||
Reference in new issue
Block a user