feat(dashboard): implement trash view with restore and permanent delete actions
This commit is contained in:
parent
de64b94869
commit
48d37c7907
7 changed files with 316 additions and 11 deletions
|
|
@ -13,13 +13,20 @@ import KanbanBoardHeader from "./components/KanbanBoardHeader";
|
|||
|
||||
/**
|
||||
* Renders the responsive grid container of kanban columns, coordinating state tracking
|
||||
* for active task updates/deletions and triggering mutation API requests.
|
||||
* for active task updates/deletions, trash counts, and triggering mutation API requests.
|
||||
*
|
||||
* @param {Object} props - The component props.
|
||||
* @param {Task[]} props.tasks - The array of task items displayed across the board.
|
||||
* @param {number} props.trashCount - The count of items currently in the trash bin.
|
||||
* @returns {JSX.Element} The rendered kanban board component.
|
||||
*/
|
||||
export default function KanbanBoard({ tasks }: { tasks: Task[] }) {
|
||||
export default function KanbanBoard({
|
||||
tasks,
|
||||
trashCount,
|
||||
}: {
|
||||
tasks: Task[];
|
||||
trashCount: number;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [, startTransition] = useTransition();
|
||||
const [updatingTaskIds, setUpdatingTaskIds] = useState<Set<string>>(
|
||||
|
|
@ -93,7 +100,7 @@ export default function KanbanBoard({ tasks }: { tasks: Task[] }) {
|
|||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Workspace Header */}
|
||||
<KanbanBoardHeader onTaskDelete={deleteTask} />
|
||||
<KanbanBoardHeader onTaskDelete={deleteTask} trashCount={trashCount} />
|
||||
|
||||
{/* Kanban Columns Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6 items-start">
|
||||
|
|
|
|||
|
|
@ -1,25 +1,29 @@
|
|||
/**
|
||||
* @file dashboard/components/KanbanBoardHeader.tsx
|
||||
* @description Component rendering the top title header with an integrated delete drop zone that appears during drag-and-drop interactions.
|
||||
* @description Component rendering the top title header with an integrated delete drop zone that appears during drag-and-drop interactions, plus a link to the trash bin view.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Plus, Sparkles, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
/**
|
||||
* Renders the dashboard header featuring a workspace title, subtitle, new task action button,
|
||||
* and a dynamic drop zone for deleting tasks during drag operations.
|
||||
* a dynamic delete drop zone during drag operations, and a trash navigation link with a counter badge.
|
||||
*
|
||||
* @param {Object} props - The component props.
|
||||
* @param {(taskId: string) => void} props.onTaskDelete - Callback function invoked when a task is dropped into the delete zone.
|
||||
* @param {number} props.trashCount - The current count of items in the trash bin.
|
||||
* @returns {JSX.Element} The rendered kanban board header component.
|
||||
*/
|
||||
export default function KanbanBoardHeader({
|
||||
onTaskDelete,
|
||||
trashCount,
|
||||
}: {
|
||||
onTaskDelete: (taskId: string) => void;
|
||||
trashCount: number;
|
||||
}) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isOver, setIsOver] = useState(false);
|
||||
|
|
@ -86,6 +90,24 @@ export default function KanbanBoardHeader({
|
|||
New Task
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
href="/trash"
|
||||
className="relative group p-2 rounded-xl border border-border hover:border-primary transition-all duration-200"
|
||||
title="View Trash"
|
||||
>
|
||||
{/* Icon */}
|
||||
<Trash2
|
||||
size={16}
|
||||
className="text-foreground-muted group-hover:text-primary transition-colors"
|
||||
/>
|
||||
|
||||
{/* Badge */}
|
||||
{trashCount > 0 && (
|
||||
<span className="absolute -top-1.5 -right-1.5 flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-destructive text-[9px] font-bold text-white shadow-sm ring-1 ring-background">
|
||||
{trashCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
/**
|
||||
* @file dashboard/page.tsx
|
||||
* @description Server component rendering the main dashboard page, handling authentication, fetching user-related tasks and assignees, and passing them to the board.
|
||||
* @description Server component rendering the main dashboard page, handling authentication, fetching active user-related tasks and assignees, computing trash counts, and passing data to the kanban board container.
|
||||
*/
|
||||
|
||||
import { db } from "@/db";
|
||||
import { tasksTable, taskAssigneesTable, usersTable } from "@/db/schema";
|
||||
import { eq, inArray, or } from "drizzle-orm";
|
||||
import { and, count, eq, inArray, isNotNull, isNull, or } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import KanbanBoard from "./KanbanBoard";
|
||||
|
|
@ -13,7 +13,8 @@ import { Task } from "@/types/tasks";
|
|||
|
||||
/**
|
||||
* Renders the dashboard page component with user session validation,
|
||||
* database queries for relevant tasks and team assignees, and passes the structured dataset to the Kanban board container.
|
||||
* database queries for active tasks, team assignees, and soft-deleted trash counts,
|
||||
* before passing the structured dataset to the Kanban board container.
|
||||
*
|
||||
* @async
|
||||
* @returns {Promise<JSX.Element>} The rendered dashboard page component.
|
||||
|
|
@ -33,13 +34,15 @@ export default async function Dashboard() {
|
|||
|
||||
const assignedTaskIds = assignedTaskRows.map((r) => r.taskId);
|
||||
|
||||
const taskWhereClause =
|
||||
const taskWhereClause = and(
|
||||
isNull(tasksTable.deletedAt),
|
||||
assignedTaskIds.length > 0
|
||||
? or(
|
||||
eq(tasksTable.userId, currentUserId),
|
||||
inArray(tasksTable.id, assignedTaskIds),
|
||||
)
|
||||
: eq(tasksTable.userId, currentUserId);
|
||||
: eq(tasksTable.userId, currentUserId),
|
||||
);
|
||||
|
||||
const rawTasksWithCreator = await db
|
||||
.select({
|
||||
|
|
@ -78,9 +81,21 @@ export default async function Dashboard() {
|
|||
commentsCount: 0,
|
||||
}));
|
||||
|
||||
const [trashCountResult] = await db
|
||||
.select({ count: count() })
|
||||
.from(tasksTable)
|
||||
.where(
|
||||
and(
|
||||
eq(tasksTable.userId, currentUserId),
|
||||
isNotNull(tasksTable.deletedAt),
|
||||
),
|
||||
);
|
||||
|
||||
const trashCount = trashCountResult.count;
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-7xl mx-auto pb-12">
|
||||
<KanbanBoard tasks={tasks} />
|
||||
<KanbanBoard tasks={tasks} trashCount={trashCount} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
92
app/(app)/trash/TrashList.tsx
Normal file
92
app/(app)/trash/TrashList.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* @file trash/TrashList.tsx
|
||||
* @description Client component rendering the list of deleted tasks with rich metadata, priority badges, and restore/delete actions.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { Task, PRIORITY_CONFIG } from "@/types/tasks";
|
||||
import { Calendar } from "lucide-react";
|
||||
import TaskActionButton from "./components/TaskActionButton";
|
||||
|
||||
/**
|
||||
* Renders a list of deleted tasks stored in the trash, featuring priority badges,
|
||||
* deletion dates, and action controls for permanent deletion or restoration.
|
||||
*
|
||||
* @param {Object} props - The component props.
|
||||
* @param {Task[]} props.tasks - The array of deleted tasks to render.
|
||||
* @returns {JSX.Element} The rendered trash list component or an empty state placeholder.
|
||||
*/
|
||||
export default function TrashList({ tasks }: { tasks: Task[] }) {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
{tasks.length === 0 ? (
|
||||
<div className="text-center py-20 border-2 border-dashed border-border/50 rounded-3xl bg-card/20 space-y-2">
|
||||
<p className="text-sm font-medium text-foreground-muted">
|
||||
No deleted tasks found.
|
||||
</p>
|
||||
<p className="text-xs text-foreground-muted/65">
|
||||
Your trash is completely empty.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
tasks.map((task) => {
|
||||
const priorityConfig =
|
||||
PRIORITY_CONFIG[task.priority as keyof typeof PRIORITY_CONFIG] ||
|
||||
PRIORITY_CONFIG.medium;
|
||||
|
||||
const deletedDate = task.deletedAt
|
||||
? new Date(task.deletedAt).toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
})
|
||||
: "Unknown";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className="group relative flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 bg-card/40 border border-border/80 rounded-2xl hover:border-primary/40 hover:bg-card/70 transition-all duration-200 shadow-sm overflow-hidden"
|
||||
>
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-rose-500/40 group-hover:bg-rose-500 transition-colors" />
|
||||
|
||||
{/* --- Left Side --- */}
|
||||
<div className="flex flex-col gap-2 pl-2 max-w-2xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="font-semibold text-base group-hover:text-primary transition-colors line-clamp-1">
|
||||
{task.title}
|
||||
</h3>
|
||||
|
||||
<span
|
||||
className={`text-[10px] px-2 py-0.5 rounded-md font-semibold border ${priorityConfig.className}`}
|
||||
>
|
||||
{priorityConfig.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{task.description && (
|
||||
<p className="text-xs text-foreground-muted line-clamp-1">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1 text-xs">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-card/60 text-foreground-muted border border-border/60">
|
||||
<Calendar size={12} className="text-foreground-muted/70" />
|
||||
Deleted {deletedDate}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- Right Side --- */}
|
||||
<div className="flex sm:flex-col flex-row sm:items-stretch items-center justify-end sm:pt-2 pt-0 sm:border-t border-t-0 border-border/60 gap-2">
|
||||
<TaskActionButton taskId={task.id} action="delete" />
|
||||
<TaskActionButton taskId={task.id} action="restore" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
app/(app)/trash/components/TaskActionButton.tsx
Normal file
84
app/(app)/trash/components/TaskActionButton.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* @file trash/components/TaskActionButton.tsx
|
||||
* @description Client component handling task restoration or permanent deletion requests with loading state and router refresh.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { RotateCcw, Trash2, Loader2 } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Renders an action button for either restoring or permanently deleting a task,
|
||||
* managing the request lifecycle and visual transition states.
|
||||
*
|
||||
* @param {Object} props - The component props.
|
||||
* @param {string} props.taskId - The unique identifier of the target task.
|
||||
* @param {"restore" | "delete"} props.action - The type of action to execute (restore or delete).
|
||||
* @returns {JSX.Element} The rendered task action button component.
|
||||
*/
|
||||
export default function TaskActionButton({
|
||||
taskId,
|
||||
action,
|
||||
}: {
|
||||
taskId: string;
|
||||
action: "restore" | "delete";
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const isRestore = action === "restore";
|
||||
|
||||
/**
|
||||
* Executes the API request to either restore or delete the task,
|
||||
* handling potential errors and refreshing the router upon success.
|
||||
*/
|
||||
const handleClick = () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
isRestore
|
||||
? `/api/tasks/${taskId}`
|
||||
: `/api/tasks/${taskId}?permanent=true`,
|
||||
{
|
||||
method: isRestore ? "PATCH" : "DELETE",
|
||||
headers: isRestore
|
||||
? { "Content-Type": "application/json" }
|
||||
: undefined,
|
||||
body: isRestore ? JSON.stringify({ restore: true }) : undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok)
|
||||
throw new Error(data.error || `Failed to ${action} task`);
|
||||
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
console.error(`Error during task ${action}:`, error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const Icon = isRestore ? RotateCcw : Trash2;
|
||||
|
||||
const styles = isRestore
|
||||
? "bg-primary/10 text-primary hover:bg-primary/20"
|
||||
: "bg-rose-500/10 text-rose-500 hover:bg-rose-500/20";
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={isPending}
|
||||
className={`flex items-center justify-center gap-2 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors cursor-pointer disabled:opacity-50 ${styles}`}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<Icon size={12} />
|
||||
)}
|
||||
{isRestore ? "Restore" : "Delete"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
44
app/(app)/trash/components/TrashHeader.tsx
Normal file
44
app/(app)/trash/components/TrashHeader.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* @file trash/components/TrashHeader.tsx
|
||||
* @description Client component rendering the header section for the trash view, including an icon, title, description, and navigation back to the dashboard.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
/**
|
||||
* Renders the trash page header featuring title details, an indicator icon,
|
||||
* and a link to navigate back to the main dashboard.
|
||||
*
|
||||
* @returns {JSX.Element} The rendered trash header component.
|
||||
*/
|
||||
export default function TrashHeader() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-3 bg-rose-500/10 text-rose-500 rounded-2xl">
|
||||
<Trash2 size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Trash</h1>
|
||||
<p className="text-sm text-foreground-muted">
|
||||
Tasks removed from your board.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="inline-flex items-center gap-2 text-xs text-primary hover:text-primary-hover transition-colors mb-6 group"
|
||||
>
|
||||
<ArrowLeft
|
||||
size={14}
|
||||
className="group-hover:-translate-x-1 transition-transform"
|
||||
/>
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
}
|
||||
41
app/(app)/trash/page.tsx
Normal file
41
app/(app)/trash/page.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* @file trash/page.tsx
|
||||
* @description Server component rendering the trash management view, fetching soft-deleted tasks belonging to the authenticated user.
|
||||
*/
|
||||
|
||||
import { db } from "@/db";
|
||||
import { tasksTable } from "@/db/schema";
|
||||
import { eq, and, isNotNull } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import TrashList from "./TrashList";
|
||||
import TrashHeader from "./components/TrashHeader";
|
||||
|
||||
/**
|
||||
* Renders the trash page verifying user authentication, querying soft-deleted tasks,
|
||||
* and passing them down to the list and header components.
|
||||
*
|
||||
* @async
|
||||
* @returns {Promise<JSX.Element>} The rendered trash page component.
|
||||
*/
|
||||
export default async function TrashPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
|
||||
const trashedTasks = await db
|
||||
.select()
|
||||
.from(tasksTable)
|
||||
.where(
|
||||
and(
|
||||
eq(tasksTable.userId, session.user.id),
|
||||
isNotNull(tasksTable.deletedAt),
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-7xl mx-auto pb-12">
|
||||
<TrashHeader />
|
||||
<TrashList tasks={trashedTasks} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue