diff --git a/app/(app)/dashboard/Board.tsx b/app/(app)/dashboard/Board.tsx index c243cba..5c96623 100644 --- a/app/(app)/dashboard/Board.tsx +++ b/app/(app)/dashboard/Board.tsx @@ -11,6 +11,7 @@ import Column from "./column/Column"; import { COLUMNS, Task, TaskStatus } from "@/lib/types/task"; import Header from "./header/Header"; import { mutate } from "swr"; +import TaskDetailModal from "./modal/TaskModal"; /** * Renders the responsive grid container of columns, coordinating state tracking @@ -23,6 +24,7 @@ import { mutate } from "swr"; export default function Board({ tasks }: { tasks: Task[] }) { const router = useRouter(); const [, startTransition] = useTransition(); + const [selectedTask, setSelectedTask] = useState(null); const [updatingTaskIds, setUpdatingTaskIds] = useState>( new Set(), ); @@ -112,10 +114,21 @@ export default function Board({ tasks }: { tasks: Task[] }) { updatingTaskIds={updatingTaskIds} onTaskMove={updateTaskStatus} onTaskDelete={deleteTask} + onTaskClick={(task) => setSelectedTask(task)} /> ); })} + + {/* Detail Modal */} + setSelectedTask(null)} + onDelete={(taskId) => { + deleteTask(taskId); + setSelectedTask(null); + }} + /> ); } diff --git a/app/(app)/dashboard/card/Card.tsx b/app/(app)/dashboard/card/Card.tsx index 166b250..6d67c34 100644 --- a/app/(app)/dashboard/card/Card.tsx +++ b/app/(app)/dashboard/card/Card.tsx @@ -22,19 +22,21 @@ import { useSearchParams } from "next/navigation"; * @property {boolean} [isUpdating] - Flag indicating whether the card is currently undergoing an asynchronous update operation. * @property {(taskId: string, newStatus: TaskStatus) => void} [onStatusChange] - Callback triggered when the task status changes. * @property {(taskId: string) => void} [onDelete] - Callback triggered when the task is deleted. + * @property {(task: Task) => void} [onTaskClick] - Callback triggered when clicking on the card body to open task details. */ export interface CardProps { task: Task; isUpdating?: boolean; onStatusChange?: (taskId: string, newStatus: TaskStatus) => void; onDelete?: (taskId: string) => void; + onTaskClick?: (task: Task) => void; } /** * Renders an interactive card container supporting search match highlighting, drag-and-drop actions, * loading states, and modular sub-components for priorities, due dates, avatars, and actions. * - * @param {CardProps} props - The component props containing the task object, updating status flag, and status change handler. + * @param {CardProps} props - The component props containing the task object, updating status flag, status change handler, delete handler, and task click handler. * @returns {JSX.Element} The rendered card component. */ export default function Card({ @@ -42,6 +44,7 @@ export default function Card({ isUpdating = false, onStatusChange, onDelete, + onTaskClick, }: CardProps) { const searchParams = useSearchParams(); const searchQuery = searchParams.get("search") || ""; @@ -76,6 +79,15 @@ export default function Card({
{ + if ( + (e.target as HTMLElement).closest("button") || + (e.target as HTMLElement).closest("a") + ) { + return; + } + onTaskClick?.(task); + }} className={`group relative bg-card/40 border border-border/80 hover:border-primary/60 p-4 rounded-2xl shadow-sm hover:shadow-xl transition-all duration-300 space-y-3 flex flex-col h-full ${ isUpdating ? "opacity-50 pointer-events-none cursor-wait bg-primary/5 border-primary/40 animate-pulse" diff --git a/app/(app)/dashboard/column/Column.tsx b/app/(app)/dashboard/column/Column.tsx index 14ddc0b..25e6d37 100644 --- a/app/(app)/dashboard/column/Column.tsx +++ b/app/(app)/dashboard/column/Column.tsx @@ -20,6 +20,7 @@ import { ColumnConfig, Task, TaskStatus } from "@/lib/types/task"; * @property {Set} [updatingTaskIds] - A set of task IDs currently undergoing updates. * @property {(taskId: string, targetStatus: TaskStatus) => void} [onTaskMove] - Callback triggered when a task is moved to a new status column. * @property {(taskId: string) => void} [onTaskDelete] - Callback triggered when a task deletion is requested. + * @property {(task: Task) => void} [onTaskClick] - Callback triggered when a task card is clicked to view details. */ export interface ColumnProps extends ColumnConfig { count: number; @@ -27,6 +28,7 @@ export interface ColumnProps extends ColumnConfig { updatingTaskIds?: Set; onTaskMove?: (taskId: string, targetStatus: TaskStatus) => void; onTaskDelete?: (taskId: string) => void; + onTaskClick?: (task: Task) => void; } /** @@ -103,6 +105,7 @@ export default function Column(props: ColumnProps) { isUpdating={props.updatingTaskIds?.has(task.id)} onStatusChange={props.onTaskMove} onDelete={props.onTaskDelete} + onTaskClick={props.onTaskClick} /> )) )} diff --git a/app/(app)/dashboard/modal/TaskModal.tsx b/app/(app)/dashboard/modal/TaskModal.tsx new file mode 100644 index 0000000..e725fa0 --- /dev/null +++ b/app/(app)/dashboard/modal/TaskModal.tsx @@ -0,0 +1,263 @@ +/** + * @file app/(app)/dashboard/modal/TaskModal.tsx + * @description Client component rendering a detailed modal overlay for viewing task metadata, status, assignees, and quick actions with ESC key support. + */ + +"use client"; + +import { useEffect } from "react"; +import { Task, PRIORITY_CONFIG } from "@/lib/types/task"; +import { getFullName, getStatusColor } from "@/lib/utils/user"; +import { + X, + CalendarDays, + AlertCircle, + User, + Users, + FileText, + Pencil, + Trash2, +} from "lucide-react"; +import { useRouter } from "next/navigation"; + +/** + * Properties for the TaskModal component. + * + * @interface TaskModalProps + * @property {Task | null} task - The selected task object to display, or null if hidden. + * @property {() => void} onClose - Callback handler to close the modal dialog. + * @property {(taskId: string) => void} [onDelete] - Optional callback function triggered when deleting the task. + */ +interface TaskModalProps { + task: Task | null; + onClose: () => void; + onDelete?: (taskId: string) => void; +} + +/** + * Renders a full task detail modal with status indicators, priority details, description, assignees, and creator-only action buttons. + * Integrates keydown listeners to dismiss the modal on pressing the Escape key. + * + * @param {TaskModalProps} props - The component props. + * @returns {JSX.Element | null} The rendered modal component or null when no task is selected. + */ +export default function TaskModal({ task, onClose, onDelete }: TaskModalProps) { + const router = useRouter(); + + useEffect(() => { + /** + * Keyboard event handler closing the modal dialog when pressing the Escape key. + * + * @param {KeyboardEvent} e - The global window keydown event. + */ + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [onClose]); + + if (!task) return null; + + const priorityConfig = task.priority && PRIORITY_CONFIG[task.priority]; + const isOverdue = + task.dueDate && + new Date(task.dueDate) < new Date() && + task.status !== "done"; + + return ( +
+
e.stopPropagation()} + > + {/* Dynamic Status Indicator Strip */} +
+ + {/* --- SCROLLABLE CONTENT AREA --- */} +
+ {/* Header / Status, Priority & Title */} +
+
+
+ + {task.status} + + + {priorityConfig && ( + + {priorityConfig.label} + + )} +
+ +

+ {task.title} +

+
+ + +
+ + {/* Description Section */} +
+
+ + + Description + +
+
+ {task.description || "No description provided for this task."} +
+
+ + {/* Meta Grid (Creator & Due Date) */} +
+ {/* Creator */} +
+
+ +
+
+

+ Creator +

+

+ {getFullName(task.creator.firstName, task.creator.lastName)} +

+
+
+ + {/* Due Date */} +
+
+ {isOverdue ? ( + + ) : ( + + )} +
+
+

+ {isOverdue ? "Overdue Due Date" : "Due Date"} +

+

+ {task.dueDate + ? `${new Date(task.dueDate).toLocaleDateString("de-DE", { + day: "2-digit", + month: "short", + year: "numeric", + })} (${new Date(task.dueDate).toLocaleTimeString( + "de-DE", + { + hour: "2-digit", + minute: "2-digit", + }, + )})` + : "No due date"} +

+
+
+
+ + {/* Assignees Section */} +
+
+ + + Assignees ({task.assignees.length}) + +
+
+ {task.assignees.length > 0 ? ( + task.assignees.map((assignee) => ( +
+ + + {getFullName(assignee.firstName, assignee.lastName)} + +
+ )) + ) : ( +

+ No assignees assigned to this task. +

+ )} +
+
+
+ + {/* Footer Actions */} + {task.isCreator && ( +
+ + + +
+ )} +
+
+ ); +}