refactor(dashboard): split task modal into modular TaskModalContent component
All checks were successful
Deploy Flowstate to VPS / deploy (push) Successful in 49s
All checks were successful
Deploy Flowstate to VPS / deploy (push) Successful in 49s
This commit is contained in:
parent
093944e8c4
commit
68183b770e
2 changed files with 178 additions and 192 deletions
|
|
@ -1,24 +1,15 @@
|
|||
/**
|
||||
* @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.
|
||||
* @description Client component orchestrating the task modal overlay, history sync, and action buttons.
|
||||
*/
|
||||
|
||||
"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 { Task } from "@/lib/types/task";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import TaskModalContent from "./TaskModalContent";
|
||||
|
||||
/**
|
||||
* Properties for the TaskModal component.
|
||||
|
|
@ -35,31 +26,26 @@ interface TaskModalProps {
|
|||
}
|
||||
|
||||
/**
|
||||
* Renders a full task detail modal with status indicators, priority details, description, assignees, and creator-only action buttons.
|
||||
* Supports ESC key navigation and updates URL query parameters dynamically.
|
||||
* Renders the task detail modal container with URL state synchronization, keyboard event handling, and action triggers.
|
||||
*
|
||||
* @param {TaskModalProps} props - The component props.
|
||||
* @returns {JSX.Element | null} The rendered modal component or null when no task is selected.
|
||||
* @returns {JSX.Element | null} The rendered modal overlay or null when no task is selected.
|
||||
*/
|
||||
export default function TaskModal({ task, onClose, onDelete }: TaskModalProps) {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Attaches a global keydown event listener to close the modal when the Escape key is pressed.
|
||||
* Handles keyboard events to close the modal when the Escape key is pressed.
|
||||
*
|
||||
* @param {KeyboardEvent} e - The keyboard event object.
|
||||
* @param {KeyboardEvent} e - The keyboard event instance.
|
||||
*/
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
handleClose();
|
||||
}
|
||||
if (e.key === "Escape") handleClose();
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [onClose]);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
|
|
@ -80,32 +66,22 @@ export default function TaskModal({ task, onClose, onDelete }: TaskModalProps) {
|
|||
}, [task]);
|
||||
|
||||
/**
|
||||
* Removes modal query parameters from the browser location history without causing a Next.js soft navigation, then triggers onClose.
|
||||
* Removes modal query parameters from browser history without triggering Next.js routing, then executes the onClose callback.
|
||||
*/
|
||||
const handleClose = () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete("modal");
|
||||
const newQuery = params.toString();
|
||||
|
||||
const query = params.toString();
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
newQuery
|
||||
? `${window.location.pathname}?${newQuery}`
|
||||
: window.location.pathname,
|
||||
query ? `${window.location.pathname}?${query}` : window.location.pathname,
|
||||
);
|
||||
|
||||
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"
|
||||
|
|
@ -115,159 +91,13 @@ export default function TaskModal({ task, onClose, onDelete }: TaskModalProps) {
|
|||
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 */}
|
||||
{/* Status Indikator */}
|
||||
<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"
|
||||
}`}
|
||||
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={handleClose}
|
||||
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>
|
||||
{/* Content */}
|
||||
<TaskModalContent task={task} onClose={handleClose} />
|
||||
|
||||
{/* Footer Actions */}
|
||||
{task.isCreator && (
|
||||
|
|
@ -282,11 +112,8 @@ export default function TaskModal({ task, onClose, onDelete }: TaskModalProps) {
|
|||
<Trash2 className="w-4 h-4" />
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
router.push(`/tasks?task=edit&id=${task.id}`);
|
||||
}}
|
||||
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" />
|
||||
|
|
|
|||
159
app/(app)/dashboard/modal/TaskModalContent.tsx
Normal file
159
app/(app)/dashboard/modal/TaskModalContent.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* @file app/(app)/dashboard/modal/TaskModalContent.tsx
|
||||
* @description Client component rendering the scrollable inner details of the task modal.
|
||||
*/
|
||||
|
||||
import { Task, PRIORITY_CONFIG } from "@/lib/types/task";
|
||||
import { getFullName, getStatusColor } from "@/lib/utils/user";
|
||||
import {
|
||||
X,
|
||||
CalendarDays,
|
||||
AlertCircle,
|
||||
User,
|
||||
Users,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
|
||||
/**
|
||||
* Properties for the TaskModalContent component.
|
||||
*
|
||||
* @interface TaskModalContentProps
|
||||
* @property {Task} task - The task entity containing header, assignee, priority, and date details.
|
||||
* @property {() => void} onClose - Callback handler to trigger closing the modal overlay.
|
||||
*/
|
||||
interface TaskModalContentProps {
|
||||
task: Task;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the body content of the task detail modal including title, badges, description, creator, due date, and assignees.
|
||||
*
|
||||
* @param {TaskModalContentProps} props - The component props.
|
||||
* @returns {JSX.Element} The rendered scrollable task details section.
|
||||
*/
|
||||
export default function TaskModalContent({
|
||||
task,
|
||||
onClose,
|
||||
}: TaskModalContentProps) {
|
||||
const priorityConfig = task.priority && PRIORITY_CONFIG[task.priority];
|
||||
const isOverdue =
|
||||
task.dueDate &&
|
||||
new Date(task.dueDate) < new Date() &&
|
||||
task.status !== "done";
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6 overflow-y-auto flex-1">
|
||||
{/* Header */}
|
||||
<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 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 */}
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<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 */}
|
||||
<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((a) => (
|
||||
<div
|
||||
key={a.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(a.isOnline ?? false)}`}
|
||||
/>
|
||||
<span>{getFullName(a.firstName, a.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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue