init
This commit is contained in:
44
src/features/kanban/components/board-column.tsx
Normal file
44
src/features/kanban/components/board-column.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { KanbanColumn, KanbanColumnHandle } from '@/components/ui/kanban';
|
||||
import type { Task } from '../utils/store';
|
||||
import { TaskCard } from './task-card';
|
||||
|
||||
const COLUMN_TITLES: Record<string, string> = {
|
||||
backlog: 'Backlog',
|
||||
inProgress: 'In Progress',
|
||||
review: 'Review',
|
||||
done: 'Done'
|
||||
};
|
||||
|
||||
interface TaskColumnProps extends Omit<React.ComponentProps<typeof KanbanColumn>, 'children'> {
|
||||
tasks: Task[];
|
||||
}
|
||||
|
||||
export function TaskColumn({ value, tasks, ...props }: TaskColumnProps) {
|
||||
return (
|
||||
<KanbanColumn value={value} className='w-full shrink-0 md:w-[320px]' {...props}>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-sm font-semibold'>{COLUMN_TITLES[value] ?? value}</span>
|
||||
<Badge variant='secondary' className='pointer-events-none rounded-sm'>
|
||||
{tasks.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<KanbanColumnHandle asChild>
|
||||
<Button variant='ghost' size='icon'>
|
||||
<Icons.gripVertical className='h-4 w-4' />
|
||||
</Button>
|
||||
</KanbanColumnHandle>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2 p-0.5'>
|
||||
{tasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} asHandle />
|
||||
))}
|
||||
</div>
|
||||
</KanbanColumn>
|
||||
);
|
||||
}
|
||||
54
src/features/kanban/components/kanban-board.tsx
Normal file
54
src/features/kanban/components/kanban-board.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { Kanban, KanbanBoard as KanbanBoardPrimitive, KanbanOverlay } from '@/components/ui/kanban';
|
||||
import { useTaskStore } from '../utils/store';
|
||||
import { TaskColumn } from './board-column';
|
||||
import { TaskCard } from './task-card';
|
||||
import { createRestrictToContainer } from '../utils/restrict-to-container';
|
||||
|
||||
export function KanbanBoard() {
|
||||
const { columns, setColumns } = useTaskStore();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- factory function, stable after mount
|
||||
const restrictToBoard = useCallback(
|
||||
createRestrictToContainer(() => containerRef.current),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<Kanban
|
||||
value={columns}
|
||||
onValueChange={setColumns}
|
||||
getItemValue={(item) => item.id}
|
||||
modifiers={[restrictToBoard]}
|
||||
autoScroll={false}
|
||||
>
|
||||
<div className='w-full overflow-x-auto rounded-md pb-4'>
|
||||
<KanbanBoardPrimitive className='flex flex-col items-start gap-4 md:flex-row'>
|
||||
{Object.entries(columns).map(([columnValue, tasks]) => (
|
||||
<TaskColumn key={columnValue} value={columnValue} tasks={tasks} />
|
||||
))}
|
||||
</KanbanBoardPrimitive>
|
||||
</div>
|
||||
<KanbanOverlay>
|
||||
{({ value, variant }) => {
|
||||
if (variant === 'column') {
|
||||
const tasks = columns[value] ?? [];
|
||||
return <TaskColumn value={value} tasks={tasks} />;
|
||||
}
|
||||
|
||||
const task = Object.values(columns)
|
||||
.flat()
|
||||
.find((task) => task.id === value);
|
||||
|
||||
if (!task) return null;
|
||||
return <TaskCard task={task} />;
|
||||
}}
|
||||
</KanbanOverlay>
|
||||
</Kanban>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
src/features/kanban/components/kanban-view-page.tsx
Normal file
15
src/features/kanban/components/kanban-view-page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import PageContainer from '@/components/layout/page-container';
|
||||
import { KanbanBoard } from './kanban-board';
|
||||
import NewTaskDialog from './new-task-dialog';
|
||||
|
||||
export default function KanbanViewPage() {
|
||||
return (
|
||||
<PageContainer
|
||||
pageTitle='Kanban'
|
||||
pageDescription='Manage tasks with drag and drop'
|
||||
pageHeaderAction={<NewTaskDialog />}
|
||||
>
|
||||
<KanbanBoard />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
66
src/features/kanban/components/new-task-dialog.tsx
Normal file
66
src/features/kanban/components/new-task-dialog.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useTaskStore } from '../utils/store';
|
||||
|
||||
export default function NewTaskDialog() {
|
||||
const addTask = useTaskStore((state) => state.addTask);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const form = e.currentTarget;
|
||||
const formData = new FormData(form);
|
||||
const { title, description } = Object.fromEntries(formData);
|
||||
|
||||
if (typeof title !== 'string' || typeof description !== 'string') return;
|
||||
addTask(title, description);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant='secondary' size='sm'>
|
||||
+ Add New Task
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className='sm:max-w-[425px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New Task</DialogTitle>
|
||||
<DialogDescription>What do you want to get done today?</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form id='task-form' className='grid gap-4 py-4' onSubmit={handleSubmit}>
|
||||
<div className='grid grid-cols-4 items-center gap-4'>
|
||||
<Input id='title' name='title' placeholder='Task title...' className='col-span-4' />
|
||||
</div>
|
||||
<div className='grid grid-cols-4 items-center gap-4'>
|
||||
<Textarea
|
||||
id='description'
|
||||
name='description'
|
||||
placeholder='Description...'
|
||||
className='col-span-4'
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<DialogFooter>
|
||||
<DialogTrigger asChild>
|
||||
<Button type='submit' size='sm' form='task-form'>
|
||||
Add Task
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
44
src/features/kanban/components/task-card.tsx
Normal file
44
src/features/kanban/components/task-card.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { KanbanItem } from '@/components/ui/kanban';
|
||||
import type { Task } from '../utils/store';
|
||||
|
||||
interface TaskCardProps extends Omit<React.ComponentProps<typeof KanbanItem>, 'value'> {
|
||||
task: Task;
|
||||
}
|
||||
|
||||
export function TaskCard({ task, ...props }: TaskCardProps) {
|
||||
return (
|
||||
<KanbanItem key={task.id} value={task.id} asChild {...props}>
|
||||
<div className='bg-card rounded-md border p-3 shadow-xs'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<span className='line-clamp-1 text-sm font-medium'>{task.title}</span>
|
||||
<Badge
|
||||
variant={
|
||||
task.priority === 'high'
|
||||
? 'destructive'
|
||||
: task.priority === 'medium'
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
className='pointer-events-none h-5 rounded-sm px-1.5 text-[11px] capitalize'
|
||||
>
|
||||
{task.priority}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className='text-muted-foreground flex items-center justify-between text-xs'>
|
||||
{task.assignee && (
|
||||
<div className='flex items-center gap-1'>
|
||||
<div className='bg-primary/20 size-2 rounded-full' />
|
||||
<span className='line-clamp-1'>{task.assignee}</span>
|
||||
</div>
|
||||
)}
|
||||
{task.dueDate && <time className='text-[10px] tabular-nums'>{task.dueDate}</time>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</KanbanItem>
|
||||
);
|
||||
}
|
||||
24
src/features/kanban/utils/restrict-to-container.ts
Normal file
24
src/features/kanban/utils/restrict-to-container.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { Modifier } from '@dnd-kit/core';
|
||||
|
||||
export function createRestrictToContainer(getElement: () => HTMLElement | null): Modifier {
|
||||
return ({ transform, draggingNodeRect, containerNodeRect: _containerNodeRect }) => {
|
||||
const container = getElement();
|
||||
|
||||
if (!draggingNodeRect || !container) {
|
||||
return transform;
|
||||
}
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
|
||||
const minX = rect.left - draggingNodeRect.left;
|
||||
const maxX = rect.right - draggingNodeRect.right;
|
||||
const minY = rect.top - draggingNodeRect.top;
|
||||
const maxY = rect.bottom - draggingNodeRect.bottom;
|
||||
|
||||
return {
|
||||
...transform,
|
||||
x: Math.min(Math.max(transform.x, minX), maxX),
|
||||
y: Math.min(Math.max(transform.y, minY), maxY)
|
||||
};
|
||||
};
|
||||
}
|
||||
130
src/features/kanban/utils/store.ts
Normal file
130
src/features/kanban/utils/store.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { create } from 'zustand';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
// import { persist } from 'zustand/middleware';
|
||||
|
||||
export type Priority = 'low' | 'medium' | 'high';
|
||||
|
||||
export type Task = {
|
||||
id: string;
|
||||
title: string;
|
||||
priority: Priority;
|
||||
description?: string;
|
||||
assignee?: string;
|
||||
dueDate?: string;
|
||||
};
|
||||
|
||||
type KanbanState = {
|
||||
columns: Record<string, Task[]>;
|
||||
setColumns: (columns: Record<string, Task[]>) => void;
|
||||
addTask: (title: string, description?: string) => void;
|
||||
};
|
||||
|
||||
const initialColumns: Record<string, Task[]> = {
|
||||
backlog: [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Migrate to Stripe billing API',
|
||||
priority: 'high',
|
||||
assignee: 'Sarah Chen',
|
||||
dueDate: '2026-04-08'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Add CSV export to reports',
|
||||
priority: 'medium',
|
||||
assignee: 'Marcus Rivera',
|
||||
dueDate: '2026-04-12'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Update onboarding flow copy',
|
||||
priority: 'low',
|
||||
assignee: 'Priya Sharma',
|
||||
dueDate: '2026-04-15'
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
title: 'Audit RBAC permissions',
|
||||
priority: 'medium',
|
||||
assignee: 'Jordan Kim',
|
||||
dueDate: '2026-04-10'
|
||||
}
|
||||
],
|
||||
inProgress: [
|
||||
{
|
||||
id: '4',
|
||||
title: 'Refactor notification service',
|
||||
priority: 'high',
|
||||
assignee: 'Alex Turner',
|
||||
dueDate: '2026-04-03'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'Build team invitation flow',
|
||||
priority: 'medium',
|
||||
assignee: 'Emily Nakamura',
|
||||
dueDate: '2026-04-06'
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
title: 'Fix timezone handling in scheduler',
|
||||
priority: 'high',
|
||||
assignee: 'Sarah Chen',
|
||||
dueDate: '2026-04-04'
|
||||
}
|
||||
],
|
||||
done: [
|
||||
{
|
||||
id: '6',
|
||||
title: 'SSO integration with Okta',
|
||||
priority: 'high',
|
||||
assignee: 'Jordan Kim',
|
||||
dueDate: '2026-03-22'
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
title: 'Dashboard analytics charts',
|
||||
priority: 'medium',
|
||||
assignee: 'Marcus Rivera',
|
||||
dueDate: '2026-03-20'
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
title: 'Webhook retry mechanism',
|
||||
priority: 'low',
|
||||
assignee: 'Alex Turner',
|
||||
dueDate: '2026-03-18'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const useTaskStore = create<KanbanState>()(
|
||||
// To enable persistence across refreshes, uncomment the persist wrapper below:
|
||||
// persist(
|
||||
(set) => ({
|
||||
columns: initialColumns,
|
||||
|
||||
setColumns: (columns) => set({ columns }),
|
||||
|
||||
addTask: (title, description) =>
|
||||
set((state) => ({
|
||||
columns: {
|
||||
...state.columns,
|
||||
backlog: [
|
||||
{
|
||||
id: uuid(),
|
||||
title,
|
||||
description,
|
||||
priority: 'medium' as Priority,
|
||||
assignee: undefined,
|
||||
dueDate: undefined
|
||||
},
|
||||
...(state.columns.backlog ?? [])
|
||||
]
|
||||
}
|
||||
}))
|
||||
})
|
||||
// ,
|
||||
// { name: 'kanban-store' }
|
||||
// )
|
||||
);
|
||||
Reference in New Issue
Block a user