From 48d37c7907436436ce9acab402e2685357c40576 Mon Sep 17 00:00:00 2001 From: Chneemann Date: Tue, 11 Aug 2026 17:02:12 +0200 Subject: [PATCH] feat(dashboard): implement trash view with restore and permanent delete actions --- app/(app)/dashboard/KanbanBoard.tsx | 13 ++- .../components/KanbanBoardHeader.tsx | 26 +++++- app/(app)/dashboard/page.tsx | 27 ++++-- app/(app)/trash/TrashList.tsx | 92 +++++++++++++++++++ .../trash/components/TaskActionButton.tsx | 84 +++++++++++++++++ app/(app)/trash/components/TrashHeader.tsx | 44 +++++++++ app/(app)/trash/page.tsx | 41 +++++++++ 7 files changed, 316 insertions(+), 11 deletions(-) create mode 100644 app/(app)/trash/TrashList.tsx create mode 100644 app/(app)/trash/components/TaskActionButton.tsx create mode 100644 app/(app)/trash/components/TrashHeader.tsx create mode 100644 app/(app)/trash/page.tsx diff --git a/app/(app)/dashboard/KanbanBoard.tsx b/app/(app)/dashboard/KanbanBoard.tsx index baed71d..b25c67c 100644 --- a/app/(app)/dashboard/KanbanBoard.tsx +++ b/app/(app)/dashboard/KanbanBoard.tsx @@ -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>( @@ -93,7 +100,7 @@ export default function KanbanBoard({ tasks }: { tasks: Task[] }) { return (
{/* Workspace Header */} - + {/* Kanban Columns Grid */}
diff --git a/app/(app)/dashboard/components/KanbanBoardHeader.tsx b/app/(app)/dashboard/components/KanbanBoardHeader.tsx index 918f7bd..193b6c9 100644 --- a/app/(app)/dashboard/components/KanbanBoardHeader.tsx +++ b/app/(app)/dashboard/components/KanbanBoardHeader.tsx @@ -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 )} + + {/* Icon */} + + + {/* Badge */} + {trashCount > 0 && ( + + {trashCount} + + )} +
); diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx index f5d6184..5d4841c 100644 --- a/app/(app)/dashboard/page.tsx +++ b/app/(app)/dashboard/page.tsx @@ -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} 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 (
- +
); } diff --git a/app/(app)/trash/TrashList.tsx b/app/(app)/trash/TrashList.tsx new file mode 100644 index 0000000..c860390 --- /dev/null +++ b/app/(app)/trash/TrashList.tsx @@ -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 ( +
+ {tasks.length === 0 ? ( +
+

+ No deleted tasks found. +

+

+ Your trash is completely empty. +

+
+ ) : ( + 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 ( +
+
+ + {/* --- Left Side --- */} +
+
+

+ {task.title} +

+ + + {priorityConfig.label} + +
+ + {task.description && ( +

+ {task.description} +

+ )} + +
+ + + Deleted {deletedDate} + +
+
+ + {/* --- Right Side --- */} +
+ + +
+
+ ); + }) + )} +
+ ); +} diff --git a/app/(app)/trash/components/TaskActionButton.tsx b/app/(app)/trash/components/TaskActionButton.tsx new file mode 100644 index 0000000..84e33d3 --- /dev/null +++ b/app/(app)/trash/components/TaskActionButton.tsx @@ -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 ( + + ); +} diff --git a/app/(app)/trash/components/TrashHeader.tsx b/app/(app)/trash/components/TrashHeader.tsx new file mode 100644 index 0000000..37de9be --- /dev/null +++ b/app/(app)/trash/components/TrashHeader.tsx @@ -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 ( + <> +
+
+ +
+
+

Trash

+

+ Tasks removed from your board. +

+
+
+ + + + Back to Dashboard + + + ); +} diff --git a/app/(app)/trash/page.tsx b/app/(app)/trash/page.tsx new file mode 100644 index 0000000..f9091b4 --- /dev/null +++ b/app/(app)/trash/page.tsx @@ -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} 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 ( +
+ + +
+ ); +}