feat(dashboard): add modular task detail modal with edit/delete actions
All checks were successful
Deploy Flowstate to VPS / deploy (push) Successful in 51s

This commit is contained in:
Chneemann 2026-08-25 10:07:13 +02:00
parent 5ba906cf84
commit 3d4029af72
No known key found for this signature in database
4 changed files with 292 additions and 1 deletions

View file

@ -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<Task | null>(null);
const [updatingTaskIds, setUpdatingTaskIds] = useState<Set<string>>(
new Set(),
);
@ -112,10 +114,21 @@ export default function Board({ tasks }: { tasks: Task[] }) {
updatingTaskIds={updatingTaskIds}
onTaskMove={updateTaskStatus}
onTaskDelete={deleteTask}
onTaskClick={(task) => setSelectedTask(task)}
/>
);
})}
</div>
{/* Detail Modal */}
<TaskDetailModal
task={selectedTask}
onClose={() => setSelectedTask(null)}
onDelete={(taskId) => {
deleteTask(taskId);
setSelectedTask(null);
}}
/>
</div>
);
}

View file

@ -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({
<div
draggable={!isUpdating}
onDragStart={handleDragStart}
onClick={(e) => {
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"

View file

@ -20,6 +20,7 @@ import { ColumnConfig, Task, TaskStatus } from "@/lib/types/task";
* @property {Set<string>} [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<string>;
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}
/>
))
)}

View file

@ -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 (
<div
className="fixed inset-0 z-49 flex items-center justify-center bg-black/80 backdrop-blur-md p-4 animate-in fade-in duration-300"
onClick={onClose}
>
<div
className="bg-card border border-border rounded-3xl max-w-xl w-full max-h-[80vh] md:max-h-[90vh] shadow-2xl relative flex flex-col animate-in zoom-in-95 duration-200 overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Dynamic Status Indicator Strip */}
<div
className={`absolute top-0 left-0 right-0 h-1 z-10 ${
task.status === "done"
? "bg-emerald-500"
: task.priority === "high"
? "bg-destructive"
: "bg-linear-to-r from-primary to-accent"
}`}
/>
{/* --- SCROLLABLE CONTENT AREA --- */}
<div className="p-8 space-y-6 overflow-y-auto flex-1">
{/* Header / Status, Priority & Title */}
<div className="flex items-start justify-between gap-4">
<div className="space-y-2.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/25 tracking-wide uppercase">
{task.status}
</span>
{priorityConfig && (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-lg text-xs font-bold border-2 shrink-0 ${priorityConfig.className}`}
>
{priorityConfig.label}
</span>
)}
</div>
<h2 className="text-2xl font-bold text-foreground tracking-tight leading-snug">
{task.title}
</h2>
</div>
<button
onClick={onClose}
className="text-foreground-muted hover:text-foreground p-2 rounded-xl bg-background/50 hover:bg-background border border-border transition-all cursor-pointer shrink-0"
aria-label="Close modal"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Description Section */}
<div className="space-y-2.5">
<div className="flex items-center space-x-2 text-foreground-muted">
<FileText className="w-4 h-4 text-primary" />
<span className="text-xs font-semibold uppercase tracking-wider">
Description
</span>
</div>
<div className="text-sm text-foreground bg-background-muted/60 p-4 rounded-2xl border border-border/60 leading-relaxed whitespace-pre-wrap max-h-48 overflow-y-auto">
{task.description || "No description provided for this task."}
</div>
</div>
{/* Meta Grid (Creator & Due Date) */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Creator */}
<div className="flex items-center space-x-3.5 bg-background-muted/40 p-3.5 rounded-2xl border border-border/60">
<div className="p-2.5 rounded-xl bg-primary/10 text-primary border border-primary/20">
<User className="w-4 h-4" />
</div>
<div className="min-w-0">
<p className="text-xs text-foreground-muted font-medium">
Creator
</p>
<p className="text-sm font-semibold text-foreground truncate">
{getFullName(task.creator.firstName, task.creator.lastName)}
</p>
</div>
</div>
{/* Due Date */}
<div
className={`flex items-center space-x-3.5 p-3.5 rounded-2xl border transition-all ${
isOverdue
? "bg-destructive/10 border-destructive/40 text-destructive animate-pulse"
: "bg-background-muted/40 border-border/60 text-foreground"
}`}
>
<div
className={`p-2.5 rounded-xl border shadow-sm ${
isOverdue
? "bg-destructive/20 border-destructive/30 text-destructive"
: "bg-accent/10 border-accent/20 text-accent"
}`}
>
{isOverdue ? (
<AlertCircle className="w-4 h-4" />
) : (
<CalendarDays className="w-4 h-4" />
)}
</div>
<div className="min-w-0">
<p
className={`text-xs font-medium ${isOverdue ? "text-destructive/80" : "text-foreground-muted"}`}
>
{isOverdue ? "Overdue Due Date" : "Due Date"}
</p>
<p className="text-sm font-semibold truncate">
{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"}
</p>
</div>
</div>
</div>
{/* Assignees Section */}
<div className="space-y-3 pt-1">
<div className="flex items-center space-x-2 text-foreground-muted">
<Users className="w-4 h-4 text-accent" />
<span className="text-xs font-semibold uppercase tracking-wider">
Assignees ({task.assignees.length})
</span>
</div>
<div className="flex flex-wrap gap-2">
{task.assignees.length > 0 ? (
task.assignees.map((assignee) => (
<div
key={assignee.id}
className="inline-flex items-center space-x-2 px-3.5 py-2 rounded-xl bg-background-muted/60 border border-border/60 text-foreground text-xs font-medium shadow-sm"
>
<span
className={`w-2 h-2 rounded-full shadow-sm ${getStatusColor(
assignee.isOnline ?? false,
)}`}
/>
<span>
{getFullName(assignee.firstName, assignee.lastName)}
</span>
</div>
))
) : (
<p className="text-sm text-foreground-muted italic bg-background-muted/20 p-3 rounded-xl border border-border/40 w-full text-center">
No assignees assigned to this task.
</p>
)}
</div>
</div>
</div>
{/* Footer Actions */}
{task.isCreator && (
<div className="flex items-center justify-end gap-3 px-8 py-4 bg-card border-t border-border/80 shrink-0">
<button
onClick={() => {
onDelete?.(task.id);
onClose();
}}
className="inline-flex items-center space-x-2 px-4 py-2.5 text-sm font-medium bg-destructive-bg text-destructive border border-destructive-border hover:bg-destructive/20 rounded-xl transition-all cursor-pointer"
>
<Trash2 className="w-4 h-4" />
<span>Delete</span>
</button>
<button
onClick={() => {
router.push(`/tasks?task=edit&id=${task.id}`);
}}
className="inline-flex items-center space-x-2 px-5 py-2.5 text-sm font-semibold bg-primary text-background hover:bg-primary-hover rounded-xl transition-all shadow-lg shadow-primary/10 cursor-pointer"
>
<Pencil className="w-4 h-4" />
<span>Edit Task</span>
</button>
</div>
)}
</div>
</div>
);
}