feat(dashboard): implement kanban board page with server-side batch querying and simplified responsive layout

This commit is contained in:
Chneemann 2026-08-08 12:54:55 +02:00
parent 0b3636a96f
commit 3057310b13
No known key found for this signature in database
4 changed files with 267 additions and 3 deletions

View 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>
);
}

View 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>
);
}

View file

@ -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
View 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[];