This commit is contained in:
phaichayon
2026-06-11 12:46:57 +07:00
parent 1993d88306
commit 7a390cf0df
720 changed files with 100919 additions and 0 deletions

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

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

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

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

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

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

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