feat(dashboard): add client-side drag-and-drop support and mobile status change dropdown for kanban tasks
This commit is contained in:
parent
73bc52e41a
commit
96b654078c
2 changed files with 194 additions and 17 deletions
|
|
@ -1,20 +1,50 @@
|
|||
/**
|
||||
* @file dashboard/KanbanCard.tsx
|
||||
* @description Client component rendering an individual task card within a kanban column, featuring priority configuration, due dates, overdue highlights, and user avatars.
|
||||
* @description Client component rendering a single kanban card with support for priority indicators, due date alerts, team avatars, and a mobile status dropdown.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
import { AlertCircle, Crown, CalendarDays } from "lucide-react";
|
||||
import { KanbanCardProps } from "@/types/tasks";
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
Crown,
|
||||
MoreHorizontal,
|
||||
CornerDownRight,
|
||||
CalendarDays,
|
||||
} from "lucide-react";
|
||||
import { KANBAN_COLUMNS, KanbanCardProps, TaskStatus } from "@/types/tasks";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Renders a task card component displaying its title, priority badge, description,
|
||||
* deadline with hover time details, and creator/assignee avatars.
|
||||
* Renders an interactive kanban card featuring title, description, priority levels,
|
||||
* deadline alerts, team avatars, and a mobile-friendly status-shifting dropdown menu.
|
||||
*
|
||||
* @param {KanbanCardProps} props - The component props containing the task object.
|
||||
* @param {KanbanCardProps} props - The component props containing the task object and status change handler.
|
||||
* @returns {JSX.Element} The rendered kanban card component.
|
||||
*/
|
||||
export default function KanbanCard({ task }: KanbanCardProps) {
|
||||
export default function KanbanCard({ task, onStatusChange }: KanbanCardProps) {
|
||||
const [showMobileActions, setShowMobileActions] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setShowMobileActions(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (showMobileActions) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [showMobileActions]);
|
||||
|
||||
const isOverdue =
|
||||
task.dueDate &&
|
||||
new Date(task.dueDate) < new Date() &&
|
||||
|
|
@ -24,6 +54,20 @@ export default function KanbanCard({ task }: KanbanCardProps) {
|
|||
(a) => a !== task.creator,
|
||||
);
|
||||
|
||||
/**
|
||||
* Handles shifting the task to a new status category.
|
||||
*
|
||||
* @param {React.MouseEvent} e - The mouse event triggered by clicking a column destination.
|
||||
* @param {TaskStatus} newStatus - The target task status to transition to.
|
||||
*/
|
||||
const handleMove = (e: React.MouseEvent, newStatus: TaskStatus) => {
|
||||
e.stopPropagation();
|
||||
if (onStatusChange) {
|
||||
onStatusChange(task.id, newStatus);
|
||||
}
|
||||
setShowMobileActions(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3">
|
||||
{/* --- Card Header (Title & Priority) --- */}
|
||||
|
|
@ -112,6 +156,52 @@ export default function KanbanCard({ task }: KanbanCardProps) {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile Switcher */}
|
||||
<div className="relative sm:hidden" ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowMobileActions(!showMobileActions);
|
||||
}}
|
||||
className="p-1.5 rounded-lg border border-border bg-background/50 text-foreground-muted transition-all cursor-pointer hover:text-foreground hover:border-primary-hover flex items-center justify-center"
|
||||
title="Move Task"
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
<div
|
||||
className={`absolute right-0 bottom-8 z-50 w-50 bg-card border border-border rounded-xl shadow-2xl p-1.5 space-y-1 text-xs origin-bottom-right transition-all duration-200 ease-out ${
|
||||
showMobileActions
|
||||
? "opacity-100 scale-100 pointer-events-auto"
|
||||
: "opacity-0 scale-10 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
{KANBAN_COLUMNS.map((col) => {
|
||||
if (col.id === task.status) return null;
|
||||
return (
|
||||
<button
|
||||
key={col.id}
|
||||
onClick={(e) => handleMove(e, col.id)}
|
||||
className="w-full text-left px-3 py-2 rounded-lg hover:bg-border flex items-center justify-between hover:text-primary group/btn transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="flex items-center gap-2 font-medium">
|
||||
<span
|
||||
className={`w-2.5 h-2.5 rounded-full ${col.color}`}
|
||||
/>
|
||||
{col.title}
|
||||
</span>
|
||||
<CornerDownRight
|
||||
size={12}
|
||||
className="text-foreground-muted group-hover/btn:text-primary transition-all"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,33 +1,115 @@
|
|||
/**
|
||||
* @file dashboard/KanbanColumn.tsx
|
||||
* @description Client component rendering an individual kanban column container with its header, task count, action button, and list of task cards.
|
||||
* @description Client component rendering an single kanban column container supporting drag-and-drop task reordering, status updates, and interactive card layouts.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import KanbanCard from "./KanbanCard";
|
||||
import { KanbanColumnProps } from "@/types/tasks";
|
||||
import { KanbanColumnProps, TaskStatus } from "@/types/tasks";
|
||||
|
||||
/**
|
||||
* Renders a kanban column containing a header with category indicator and counter,
|
||||
* an add button, and a sorted list of associated task cards or an empty placeholder.
|
||||
* Renders a kanban column with title indicators, task counters, drag-and-drop event handlers,
|
||||
* and lists of nested KanbanCard items.
|
||||
*
|
||||
* @param {KanbanColumnProps} props - The component props including column details, task list, and styling options.
|
||||
* @param {KanbanColumnProps} props - The component props including column ID, title, count, tasks, and color configuration.
|
||||
* @returns {JSX.Element} The rendered kanban column component.
|
||||
*/
|
||||
export default function KanbanColumn(props: KanbanColumnProps) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
const indicatorColor = props.color ?? "bg-primary";
|
||||
|
||||
/**
|
||||
* Updates the status of a task via a state transition and refreshes the router data.
|
||||
*
|
||||
* @param {string} taskId - The unique identifier of the task being updated.
|
||||
* @param {TaskStatus} targetStatus - The new target status category for the task.
|
||||
*/
|
||||
const updateTaskStatus = (taskId: string, targetStatus: TaskStatus) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
// TODO: Implement actual API endpoint for updating task status
|
||||
console.log(`Successfully moved task ${taskId} to ${targetStatus}`);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
console.error("Error during task status update:", error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the drag-over event to allow dropping items onto the column.
|
||||
*
|
||||
* @param {React.DragEvent<HTMLDivElement>} e - The drag event object.
|
||||
*/
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDraggingOver(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the drag-leave event when a dragged item leaves the column bounds.
|
||||
*/
|
||||
const handleDragLeave = () => {
|
||||
setIsDraggingOver(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Initiates the drag action on a task card, storing its ID and current status in the data transfer payload.
|
||||
*
|
||||
* @param {React.DragEvent<HTMLDivElement>} e - The drag event object.
|
||||
* @param {string} taskId - The identifier of the task being dragged.
|
||||
* @param {TaskStatus} currentStatus - The original status category of the task.
|
||||
*/
|
||||
const handleDragStart = (
|
||||
e: React.DragEvent<HTMLDivElement>,
|
||||
taskId: string,
|
||||
currentStatus: TaskStatus,
|
||||
) => {
|
||||
e.dataTransfer.setData("text/plain", taskId);
|
||||
e.dataTransfer.setData("sourceStatus", currentStatus);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles dropping a task card onto the column, triggering status updates if valid.
|
||||
*
|
||||
* @param {React.DragEvent<HTMLDivElement>} e - The drop event object.
|
||||
*/
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDraggingOver(false);
|
||||
|
||||
const taskId = e.dataTransfer.getData("text/plain");
|
||||
const sourceStatus = e.dataTransfer.getData("sourceStatus") as TaskStatus;
|
||||
|
||||
if (!taskId || sourceStatus === props.id) return;
|
||||
|
||||
updateTaskStatus(taskId, props.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col rounded-2xl p-4 transition-all duration-300 bg-background-muted/40 border border-border">
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={`w-full flex flex-col rounded-2xl p-4 transition-all duration-300 bg-background-muted/40 border ${
|
||||
isDraggingOver
|
||||
? "border-primary/80 bg-primary/5 shadow-lg ring-4 ring-primary/10"
|
||||
: "border-border/60 shadow-sm"
|
||||
} ${isPending ? "opacity-60 pointer-events-none" : ""}`}
|
||||
>
|
||||
{/* Column Header */}
|
||||
<div className="flex items-center justify-between mb-4 pb-2 border-b border-border/40">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
className={`w-2.5 h-2.5 rounded-full shadow-sm ${indicatorColor}`}
|
||||
/>
|
||||
<h2 className="font-bold text-xs uppercase tracking-wider text-foreground">
|
||||
<h2 className="font-bold text-xs uppercase tracking-wider ">
|
||||
{props.title}
|
||||
</h2>
|
||||
<span className="text-xs bg-card text-foreground-muted px-2 py-0.5 rounded-full font-semibold border border-border/60">
|
||||
|
|
@ -40,7 +122,6 @@ export default function KanbanColumn(props: KanbanColumnProps) {
|
|||
</div>
|
||||
|
||||
{/* Card List */}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{props.tasks.length === 0 ? (
|
||||
<div className="h-28 rounded-xl border border-dashed border-border/80 flex flex-col items-center justify-center text-xs text-foreground-muted/60 bg-card/20 gap-1">
|
||||
|
|
@ -50,9 +131,15 @@ export default function KanbanColumn(props: KanbanColumnProps) {
|
|||
props.tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
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 hover:-translate-y-0.5"
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, task.id, task.status)}
|
||||
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 cursor-grab active:cursor-grabbing hover:-translate-y-0.5"
|
||||
>
|
||||
<KanbanCard key={task.id} task={task} />
|
||||
<KanbanCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onStatusChange={updateTaskStatus}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue