feat: multi view kanban

This commit is contained in:
Carlos Florêncio
2026-01-10 16:07:59 +00:00
parent 9c845dbc16
commit b01fd2886d
5 changed files with 157 additions and 36 deletions
+110 -17
View File
@@ -16,15 +16,52 @@ import { ThemeToggle } from './theme-toggle';
import { Button } from '@/components/ui/button';
import { IconPlus } from '@tabler/icons-react';
import { TaskCreateDialog } from './task-create-dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
type ColumnId = 'todo' | 'in-progress' | 'in-review' | 'done';
const COLUMNS: Column[] = [
{ id: 'todo', title: 'To Do', color: 'bg-neutral-400' },
{ id: 'in-progress', title: 'In Progress', color: 'bg-blue-500' },
{ id: 'in-review', title: 'In Review', color: 'bg-yellow-500' },
{ id: 'done', title: 'Done', color: 'bg-green-500' },
];
const VIEWS: Record<
string,
{
label: string;
columns: Column[];
}
> = {
team: {
label: 'Team view',
columns: [
{ id: 'backlog', title: 'Backlog', color: 'bg-neutral-400' },
{ id: 'solution-design', title: 'Solution Design', color: 'bg-sky-500' },
{ id: 'ready-for-dev', title: 'Ready for Dev', color: 'bg-indigo-500' },
{ id: 'in-progress', title: 'In Progress', color: 'bg-blue-500' },
{ id: 'review', title: 'Review', color: 'bg-yellow-500' },
{ id: 'done', title: 'Done', color: 'bg-green-500' },
],
},
user: {
label: 'User view',
columns: [
{ id: 'todo', title: 'To Do', color: 'bg-neutral-400' },
{ id: 'in-progress', title: 'In Progress', color: 'bg-blue-500' },
{ id: 'review', title: 'Review', color: 'bg-yellow-500' },
{ id: 'done', title: 'Done', color: 'bg-green-500' },
],
},
architect: {
label: 'Architect view',
columns: [
{ id: 'backlog', title: 'Backlog', color: 'bg-neutral-400' },
{ id: 'high-level-design', title: 'High Level Design', color: 'bg-cyan-500' },
{ id: 'low-level-design', title: 'Low Level Design', color: 'bg-violet-500' },
{ id: 'review', title: 'Review', color: 'bg-yellow-500' },
{ id: 'done', title: 'Done', color: 'bg-green-500' },
],
},
};
const initialTasks: Task[] = [
{ id: '1', title: 'Design database schema', status: 'todo' },
@@ -37,6 +74,8 @@ export function KanbanBoard() {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const [isMounted, setIsMounted] = useState(false);
const [activeViewId, setActiveViewId] = useState('team');
const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
useEffect(() => {
setIsMounted(true);
@@ -54,6 +93,12 @@ export function KanbanBoard() {
() => tasks.find((task) => task.id === activeTaskId) ?? null,
[tasks, activeTaskId]
);
const editingTask = useMemo(
() => tasks.find((task) => task.id === editingTaskId) ?? null,
[tasks, editingTaskId]
);
const activeView = VIEWS[activeViewId] ?? VIEWS.team;
const activeColumns = activeView.columns;
const handleDragStart = (event: DragStartEvent) => {
setActiveTaskId(event.active.id as string);
@@ -79,7 +124,18 @@ export function KanbanBoard() {
setActiveTaskId(null);
};
const handleAddTask = (columnId: string, title: string, description?: string) => {
const handleDialogSubmit = (title: string, description?: string) => {
if (editingTaskId) {
setTasks((tasks) =>
tasks.map((task) =>
task.id === editingTaskId ? { ...task, title, description } : task
)
);
setEditingTaskId(null);
return;
}
const columnId = activeColumns[0]?.id ?? 'todo';
const newTask: Task = {
id: crypto.randomUUID(),
title,
@@ -89,6 +145,11 @@ export function KanbanBoard() {
setTasks((tasks) => [...tasks, newTask]);
};
const handleEditTask = (task: Task) => {
setEditingTaskId(task.id);
setIsDialogOpen(true);
};
const getTasksForColumn = (columnId: string) => {
return tasks.filter((task) => task.status === columnId);
};
@@ -100,9 +161,26 @@ export function KanbanBoard() {
return (
<div className="h-screen w-full flex flex-col bg-background">
<header className="flex items-center justify-between p-6 pb-4">
<h1 className="text-2xl font-bold">Kanban Board</h1>
<div className="flex items-center gap-2">
<Button onClick={() => setIsDialogOpen(true)}>
<h1 className="text-2xl font-bold">KanDev.ai</h1>
<div className="flex items-center gap-3">
<Select value={activeViewId} onValueChange={setActiveViewId}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select view" />
</SelectTrigger>
<SelectContent>
{Object.entries(VIEWS).map(([id, view]) => (
<SelectItem key={id} value={id}>
{view.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
onClick={() => {
setEditingTaskId(null);
setIsDialogOpen(true);
}}
>
<IconPlus className="h-4 w-4" />
Add task
</Button>
@@ -111,8 +189,19 @@ export function KanbanBoard() {
</header>
<TaskCreateDialog
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
onSubmit={(title, description) => handleAddTask('todo', title, description)}
onOpenChange={(open) => {
setIsDialogOpen(open);
if (!open) {
setEditingTaskId(null);
}
}}
onSubmit={(title, description) => handleDialogSubmit(title, description)}
initialValues={
editingTask
? { title: editingTask.title, description: editingTask.description }
: undefined
}
submitLabel={editingTask ? 'Save' : 'Create'}
/>
<DndContext
sensors={sensors}
@@ -120,13 +209,17 @@ export function KanbanBoard() {
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<div className="flex-1 overflow-x-auto px-6 pb-6">
<div className="inline-grid grid-flow-col auto-cols-[minmax(280px,360px)] gap-4">
{COLUMNS.map((column) => (
<div className="flex-1 min-h-0 px-6 pb-6">
<div
className="grid gap-px bg-border rounded-lg overflow-hidden h-full"
style={{ gridTemplateColumns: `repeat(${activeColumns.length}, minmax(0, 1fr))` }}
>
{activeColumns.map((column) => (
<KanbanColumn
key={column.id}
column={column}
tasks={getTasksForColumn(column.id)}
onEditTask={handleEditTask}
/>
))}
</div>
+9 -7
View File
@@ -14,22 +14,23 @@ export interface Task {
interface KanbanCardProps {
task: Task;
onClick?: (task: Task) => void;
}
function KanbanCardLayout({ task, className }: KanbanCardProps & { className?: string }) {
return (
<Card size="sm" className={cn('w-full py-0', className)}>
<CardContent className="px-3 py-2">
<p className="text-sm font-medium">{task.title}</p>
<CardContent className="px-2 py-1">
<p className="text-sm font-medium leading-tight">{task.title}</p>
{task.description && (
<p className="text-xs text-muted-foreground mt-1">{task.description}</p>
<p className="text-xs text-muted-foreground mt-1 leading-tight">{task.description}</p>
)}
</CardContent>
</Card>
);
}
export function KanbanCard({ task }: KanbanCardProps) {
export function KanbanCard({ task, onClick }: KanbanCardProps) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: task.id,
});
@@ -49,13 +50,14 @@ export function KanbanCard({ task }: KanbanCardProps) {
'cursor-grab active:cursor-grabbing mb-2 w-full py-0',
isDragging && 'opacity-50 z-50'
)}
onClick={() => onClick?.(task)}
{...listeners}
{...attributes}
>
<CardContent className="px-3 py-2">
<p className="text-sm font-medium">{task.title}</p>
<CardContent className="px-2 py-1">
<p className="text-sm font-medium leading-tight">{task.title}</p>
{task.description && (
<p className="text-xs text-muted-foreground mt-1">{task.description}</p>
<p className="text-xs text-muted-foreground mt-1 leading-tight">{task.description}</p>
)}
</CardContent>
</Card>
+8 -5
View File
@@ -14,9 +14,10 @@ export interface Column {
interface KanbanColumnProps {
column: Column;
tasks: Task[];
onEditTask: (task: Task) => void;
}
export function KanbanColumn({ column, tasks }: KanbanColumnProps) {
export function KanbanColumn({ column, tasks, onEditTask }: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({
id: column.id,
});
@@ -25,12 +26,12 @@ export function KanbanColumn({ column, tasks }: KanbanColumnProps) {
<div
ref={setNodeRef}
className={cn(
'flex flex-col h-full min-w-0 bg-card rounded-lg border border-border p-4 min-h-[600px]',
'flex flex-col h-full min-w-0 bg-card p-4 min-h-[600px]',
isOver && 'ring-2 ring-primary'
)}
>
{/* Column Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center justify-between border-b border-border/70 pb-3 mb-4 px-2">
<div className="flex items-center gap-2">
<div className={cn('w-3 h-3 rounded-full', column.color)} />
<h2 className="font-semibold text-base">{column.title}</h2>
@@ -41,11 +42,13 @@ export function KanbanColumn({ column, tasks }: KanbanColumnProps) {
</div>
{/* Tasks */}
<div className="flex-1 overflow-y-auto overflow-x-hidden space-y-0">
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden space-y-2 px-2 pt-2">
{tasks.length === 0 ? (
<p className="text-sm text-muted-foreground text-center mt-8">No tasks yet</p>
) : (
tasks.map((task) => <KanbanCard key={task.id} task={task} />)
tasks.map((task) => (
<KanbanCard key={task.id} task={task} onClick={onEditTask} />
))
)}
</div>
</div>
+22 -4
View File
@@ -1,6 +1,6 @@
'use client';
import { useState, FormEvent } from 'react';
import { useEffect, useState, FormEvent } from 'react';
import {
AlertDialog,
AlertDialogContent,
@@ -18,16 +18,29 @@ interface TaskCreateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (title: string, description: string) => void;
initialValues?: {
title: string;
description?: string;
};
submitLabel?: string;
}
export function TaskCreateDialog({
open,
onOpenChange,
onSubmit,
initialValues,
submitLabel = 'Create',
}: TaskCreateDialogProps) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
useEffect(() => {
if (!open) return;
setTitle(initialValues?.title ?? '');
setDescription(initialValues?.description ?? '');
}, [open, initialValues]);
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (title.trim()) {
@@ -46,9 +59,14 @@ export function TaskCreateDialog({
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent size="default">
<AlertDialogContent
size="default"
overlayProps={{
onClick: () => onOpenChange(false),
}}
>
<AlertDialogHeader>
<AlertDialogTitle>Create Task</AlertDialogTitle>
<AlertDialogTitle>{submitLabel === 'Create' ? 'Create Task' : 'Edit Task'}</AlertDialogTitle>
</AlertDialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<Field>
@@ -74,7 +92,7 @@ export function TaskCreateDialog({
<AlertDialogCancel type="button" onClick={handleCancel}>
Cancel
</AlertDialogCancel>
<Button type="submit">Create</Button>
<Button type="submit">{submitLabel}</Button>
</AlertDialogFooter>
</form>
</AlertDialogContent>
+8 -3
View File
@@ -35,7 +35,10 @@ function AlertDialogOverlay({
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50", className)}
className={cn(
"bg-black/10 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50",
className
)}
{...props}
/>
)
@@ -44,18 +47,20 @@ function AlertDialogOverlay({
function AlertDialogContent({
className,
size = "default",
overlayProps,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
overlayProps?: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogOverlay {...overlayProps} />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-background ring-foreground/10 gap-6 rounded-xl p-6 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none",
"bg-background ring-foreground/10 gap-6 rounded-xl p-6 ring-1 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none",
className
)}
{...props}