feat: multi view kanban
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user