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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user