feat(dashboard): implement kanban board page with server-side batch querying and simplified responsive layout
This commit is contained in:
parent
0b3636a96f
commit
3057310b13
4 changed files with 267 additions and 3 deletions
64
app/(app)/dashboard/KanbanCard.tsx
Normal file
64
app/(app)/dashboard/KanbanCard.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
/**
|
||||||
|
* @file dashboard/KanbanCard.tsx
|
||||||
|
* @description Client component rendering a simplified kanban task card displaying title, priority, description, and metadata.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { KanbanCardProps } from "@/types/tasks";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a task card component with a basic layout for title, priority, description, due date, and creator info.
|
||||||
|
*
|
||||||
|
* @param {KanbanCardProps} props - The component props containing the task object.
|
||||||
|
* @returns {JSX.Element} The rendered kanban card component.
|
||||||
|
*/
|
||||||
|
export default function KanbanCard({ task }: KanbanCardProps) {
|
||||||
|
const assignees = task.assignees || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 text-xs">
|
||||||
|
{/* Title & Priority */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<span className="font-medium text-base">{task.title}</span>
|
||||||
|
{task.priority && (
|
||||||
|
<span className="text-xs uppercase">{task.priority}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{task.description && (
|
||||||
|
<p className="text-gray-500 line-clamp-2 text-sm">{task.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer / Meta & People */}
|
||||||
|
<div className="flex items-center justify-between pt-2 mt-1 border-t text-xs">
|
||||||
|
<div>
|
||||||
|
{task.dueDate && (
|
||||||
|
<span>{new Date(task.dueDate).toLocaleDateString()}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{task.creator && (
|
||||||
|
<span
|
||||||
|
className="px-1.5 py-0.5 bg-gray-900 text-white rounded text-xs font-medium"
|
||||||
|
title={`Creator: ${task.creator}`}
|
||||||
|
>
|
||||||
|
{task.creator.substring(0, 2).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{assignees.map((assignee, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="px-1.5 py-0.5 bg-gray-100 rounded text-xs text-gray-600"
|
||||||
|
title={`Assignee: ${assignee}`}
|
||||||
|
>
|
||||||
|
{assignee.substring(0, 2).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
app/(app)/dashboard/KanbanColumn.tsx
Normal file
52
app/(app)/dashboard/KanbanColumn.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
/**
|
||||||
|
* @file dashboard/KanbanColumn.tsx
|
||||||
|
* @description Client component rendering a single column in the kanban board, containing a status header, count, task list, and fallback empty states.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import KanbanCard from "./KanbanCard";
|
||||||
|
import { KanbanColumnProps } from "@/types/tasks";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a kanban column with an indicator color, title, task count, quick-add trigger,
|
||||||
|
* and a list of rendered task cards or an empty placeholder.
|
||||||
|
*
|
||||||
|
* @param {KanbanColumnProps} props - The component props defining column metadata and task lists.
|
||||||
|
* @returns {JSX.Element} The rendered kanban column component.
|
||||||
|
*/
|
||||||
|
export default function KanbanColumn(props: KanbanColumnProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col p-2 gap-2 w-full bg-background-muted rounded">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-1 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`w-2 h-2 rounded-full ${props.color ?? "bg-gray-400"}`}
|
||||||
|
/>
|
||||||
|
<h2 className="text-sm font-medium">{props.title}</h2>
|
||||||
|
<span className="text-sm">{props.count}</span>
|
||||||
|
</div>
|
||||||
|
<button className="p-1 hover:bg-gray-100 rounded cursor-pointer">
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Task List */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{props.tasks.length === 0 ? (
|
||||||
|
<div className="p-4 text-sm text-center border border-dashed rounded bg-card">
|
||||||
|
No tasks
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
props.tasks.map((task) => (
|
||||||
|
<div key={task.id} className="p-3 border rounded shadow-sm bg-card">
|
||||||
|
<KanbanCard task={task} />
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,105 @@
|
||||||
/**
|
/**
|
||||||
* Dashboard
|
* @file dashboard/page.tsx
|
||||||
|
* @description Server component rendering the main dashboard page, handling authentication, fetching user-related tasks and assignees, and displaying them across kanban columns.
|
||||||
*/
|
*/
|
||||||
export default function Dashboard() {
|
|
||||||
return <div className="space-y-6">Dashboard</div>;
|
import { db } from "@/db";
|
||||||
|
import { tasksTable, taskAssigneesTable, usersTable } from "@/db/schema";
|
||||||
|
import { eq, inArray, or } from "drizzle-orm";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import KanbanColumn from "./KanbanColumn";
|
||||||
|
import { KANBAN_COLUMNS, Task } from "@/types/tasks";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the dashboard page layout with user session validation, task queries, assignee mapping,
|
||||||
|
* and distributes the tasks into respective kanban columns.
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @returns {Promise<JSX.Element>} The rendered dashboard page component.
|
||||||
|
*/
|
||||||
|
export default async function Dashboard() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUserId = session.user.id;
|
||||||
|
|
||||||
|
// Determine Assigned Task IDs
|
||||||
|
const assignedTaskRows = await db
|
||||||
|
.select({ taskId: taskAssigneesTable.taskId })
|
||||||
|
.from(taskAssigneesTable)
|
||||||
|
.where(eq(taskAssigneesTable.userId, currentUserId));
|
||||||
|
|
||||||
|
const assignedTaskIds = assignedTaskRows.map((r) => r.taskId);
|
||||||
|
|
||||||
|
// Load tasks, including their creators (created by the user OR assigned)
|
||||||
|
const taskWhereClause =
|
||||||
|
assignedTaskIds.length > 0
|
||||||
|
? or(
|
||||||
|
eq(tasksTable.userId, currentUserId),
|
||||||
|
inArray(tasksTable.id, assignedTaskIds),
|
||||||
|
)
|
||||||
|
: eq(tasksTable.userId, currentUserId);
|
||||||
|
|
||||||
|
const rawTasksWithCreator = await db
|
||||||
|
.select({
|
||||||
|
task: tasksTable,
|
||||||
|
creatorEmail: usersTable.email,
|
||||||
|
})
|
||||||
|
.from(tasksTable)
|
||||||
|
.innerJoin(usersTable, eq(tasksTable.userId, usersTable.id))
|
||||||
|
.where(taskWhereClause);
|
||||||
|
|
||||||
|
// Load all assignees for the loaded tasks in a batch
|
||||||
|
const allTaskIds = rawTasksWithCreator.map((item) => item.task.id);
|
||||||
|
|
||||||
|
const assigneesData =
|
||||||
|
allTaskIds.length > 0
|
||||||
|
? await db
|
||||||
|
.select({
|
||||||
|
taskId: taskAssigneesTable.taskId,
|
||||||
|
email: usersTable.email,
|
||||||
|
})
|
||||||
|
.from(taskAssigneesTable)
|
||||||
|
.innerJoin(usersTable, eq(taskAssigneesTable.userId, usersTable.id))
|
||||||
|
.where(inArray(taskAssigneesTable.taskId, allTaskIds))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Map for high-performance mapping (taskId -> array of emails)
|
||||||
|
const assigneesMap = new Map<string, string[]>();
|
||||||
|
for (const row of assigneesData) {
|
||||||
|
const existing = assigneesMap.get(row.taskId) || [];
|
||||||
|
assigneesMap.set(row.taskId, [...existing, row.email]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preparing Tasks
|
||||||
|
const tasks: Task[] = rawTasksWithCreator.map(({ task, creatorEmail }) => ({
|
||||||
|
...task,
|
||||||
|
creator: creatorEmail,
|
||||||
|
assignees: assigneesMap.get(task.id) || [],
|
||||||
|
tags: [],
|
||||||
|
commentsCount: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 max-w-7xl mx-auto pb-12">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6 items-start">
|
||||||
|
{KANBAN_COLUMNS.map((col) => {
|
||||||
|
const columnTasks = tasks.filter((t) => t.status === col.id);
|
||||||
|
return (
|
||||||
|
<KanbanColumn
|
||||||
|
key={col.id}
|
||||||
|
id={col.id}
|
||||||
|
title={col.title}
|
||||||
|
count={columnTasks.length}
|
||||||
|
tasks={columnTasks}
|
||||||
|
color={col.color}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
49
types/tasks.ts
Normal file
49
types/tasks.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
/**
|
||||||
|
* @file types/tasks.ts
|
||||||
|
* @description Type definitions, interfaces, and UI configuration mappings for task management and kanban views.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { taskStatusEnum, type Task as DbTask } from "@/db/schema";
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Types
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
export type TaskStatus = (typeof taskStatusEnum.enumValues)[number];
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Interfaces
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
export interface Task extends Omit<DbTask, "dueDate"> {
|
||||||
|
dueDate?: Date | null;
|
||||||
|
assignees?: string[];
|
||||||
|
creator?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KanbanColumnConfig {
|
||||||
|
id: TaskStatus;
|
||||||
|
title: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KanbanColumnProps extends KanbanColumnConfig {
|
||||||
|
count: number;
|
||||||
|
tasks: Task[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KanbanCardProps {
|
||||||
|
task: Task;
|
||||||
|
onStatusChange?: (taskId: string, newStatus: TaskStatus) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// UI Configurations
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
export const KANBAN_COLUMNS = [
|
||||||
|
{ id: "todo", title: "To-do", color: "bg-zinc-400" },
|
||||||
|
{ id: "in_progress", title: "In Progress", color: "bg-indigo-500" },
|
||||||
|
{ id: "await_feedback", title: "Await Feedback", color: "bg-amber-500" },
|
||||||
|
{ id: "done", title: "Done", color: "bg-emerald-500" },
|
||||||
|
] as const satisfies readonly KanbanColumnConfig[];
|
||||||
Loading…
Reference in a new issue