Initial commit: Todo app with Jira-style board
This commit is contained in:
320
src/components/tasks/TaskBoard.tsx
Normal file
320
src/components/tasks/TaskBoard.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Task } from '@prisma/client'
|
||||
import { FiEdit2, FiTrash2, FiClock, FiAlertCircle, FiPlus, FiMoreHorizontal } from 'react-icons/fi'
|
||||
import { formatDate, isOverdue } from '@/lib/utils'
|
||||
|
||||
interface TaskBoardProps {
|
||||
tasks: Task[]
|
||||
loading?: boolean
|
||||
onEdit: (task: Task) => void
|
||||
onDelete: (taskId: string) => void
|
||||
onStatusChange: (taskId: string, status: string) => void
|
||||
onCreateTask: (status: string) => void
|
||||
}
|
||||
|
||||
const COLUMNS = [
|
||||
{
|
||||
id: 'TODO',
|
||||
title: 'Chưa làm',
|
||||
color: 'bg-yellow-500',
|
||||
bgColor: 'bg-yellow-50',
|
||||
borderColor: 'border-yellow-200',
|
||||
},
|
||||
{
|
||||
id: 'IN_PROGRESS',
|
||||
title: 'Đang làm',
|
||||
color: 'bg-blue-500',
|
||||
bgColor: 'bg-blue-50',
|
||||
borderColor: 'border-blue-200',
|
||||
},
|
||||
{
|
||||
id: 'DONE',
|
||||
title: 'Hoàn thành',
|
||||
color: 'bg-green-500',
|
||||
bgColor: 'bg-green-50',
|
||||
borderColor: 'border-green-200',
|
||||
},
|
||||
]
|
||||
|
||||
export function TaskBoard({
|
||||
tasks,
|
||||
loading,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onStatusChange,
|
||||
onCreateTask,
|
||||
}: TaskBoardProps) {
|
||||
const [draggedTask, setDraggedTask] = useState<Task | null>(null)
|
||||
const [dragOverColumn, setDragOverColumn] = useState<string | null>(null)
|
||||
|
||||
const getTasksByStatus = (status: string) => {
|
||||
return tasks.filter((task) => task.status === status)
|
||||
}
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, task: Task) => {
|
||||
setDraggedTask(task)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
// Add visual feedback
|
||||
if (e.currentTarget instanceof HTMLElement) {
|
||||
e.currentTarget.style.opacity = '0.5'
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnd = (e: React.DragEvent) => {
|
||||
setDraggedTask(null)
|
||||
setDragOverColumn(null)
|
||||
if (e.currentTarget instanceof HTMLElement) {
|
||||
e.currentTarget.style.opacity = '1'
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, columnId: string) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
setDragOverColumn(columnId)
|
||||
}
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setDragOverColumn(null)
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent, columnId: string) => {
|
||||
e.preventDefault()
|
||||
if (draggedTask && draggedTask.status !== columnId) {
|
||||
onStatusChange(draggedTask.id, columnId)
|
||||
}
|
||||
setDraggedTask(null)
|
||||
setDragOverColumn(null)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{COLUMNS.map((column) => (
|
||||
<div key={column.id} className="bg-gray-100 rounded-xl p-4 min-h-[400px]">
|
||||
<div className="h-6 bg-gray-200 rounded w-24 mb-4 animate-pulse" />
|
||||
<div className="space-y-3">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="bg-white rounded-lg p-4 animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2" />
|
||||
<div className="h-3 bg-gray-200 rounded w-1/2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{COLUMNS.map((column) => {
|
||||
const columnTasks = getTasksByStatus(column.id)
|
||||
const isDropTarget = dragOverColumn === column.id && draggedTask?.status !== column.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={column.id}
|
||||
onDragOver={(e) => handleDragOver(e, column.id)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, column.id)}
|
||||
className={`
|
||||
rounded-xl p-4 min-h-[500px] transition-all
|
||||
${column.bgColor} ${column.borderColor} border-2
|
||||
${isDropTarget ? 'ring-2 ring-primary-500 ring-offset-2 scale-[1.02]' : ''}
|
||||
`}
|
||||
>
|
||||
{/* Column Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={`w-3 h-3 rounded-full ${column.color}`} />
|
||||
<h3 className="font-semibold text-gray-800">{column.title}</h3>
|
||||
<span className="bg-gray-200 text-gray-600 text-xs font-medium px-2 py-0.5 rounded-full">
|
||||
{columnTasks.length}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onCreateTask(column.id)}
|
||||
className="p-1.5 text-gray-500 hover:text-primary-600 hover:bg-white rounded-lg transition-colors"
|
||||
title={`Thêm task vào ${column.title}`}
|
||||
>
|
||||
<FiPlus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tasks */}
|
||||
<div className="space-y-3">
|
||||
{columnTasks.length === 0 ? (
|
||||
<div className={`
|
||||
border-2 border-dashed rounded-lg p-6 text-center
|
||||
${isDropTarget ? 'border-primary-400 bg-primary-50' : 'border-gray-300'}
|
||||
`}>
|
||||
<p className="text-sm text-gray-500">
|
||||
{isDropTarget ? 'Thả task vào đây' : 'Không có task nào'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
columnTasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Drop zone at bottom */}
|
||||
{columnTasks.length > 0 && isDropTarget && (
|
||||
<div className="mt-3 border-2 border-dashed border-primary-400 bg-primary-50 rounded-lg p-4 text-center">
|
||||
<p className="text-sm text-primary-600">Thả task vào đây</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Task Card Component
|
||||
interface TaskCardProps {
|
||||
task: Task
|
||||
onEdit: (task: Task) => void
|
||||
onDelete: (taskId: string) => void
|
||||
onDragStart: (e: React.DragEvent, task: Task) => void
|
||||
onDragEnd: (e: React.DragEvent) => void
|
||||
}
|
||||
|
||||
function TaskCard({ task, onEdit, onDelete, onDragStart, onDragEnd }: TaskCardProps) {
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
const taskIsOverdue = task.deadline && isOverdue(task.deadline) && task.status !== 'DONE'
|
||||
|
||||
const handleDelete = () => {
|
||||
if (confirm('Bạn có chắc muốn xóa công việc này?')) {
|
||||
onDelete(task.id)
|
||||
}
|
||||
setShowMenu(false)
|
||||
}
|
||||
|
||||
const getPriorityColor = () => {
|
||||
if (taskIsOverdue) return 'border-l-red-500'
|
||||
if (task.deadline) {
|
||||
const daysUntil = Math.ceil(
|
||||
(new Date(task.deadline).getTime() - Date.now()) / (1000 * 60 * 60 * 24)
|
||||
)
|
||||
if (daysUntil <= 1) return 'border-l-orange-500'
|
||||
if (daysUntil <= 3) return 'border-l-yellow-500'
|
||||
}
|
||||
return 'border-l-gray-300'
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, task)}
|
||||
onDragEnd={onDragEnd}
|
||||
className={`
|
||||
bg-white rounded-lg border-l-4 shadow-sm hover:shadow-md
|
||||
transition-all cursor-grab active:cursor-grabbing
|
||||
${getPriorityColor()}
|
||||
${taskIsOverdue ? 'bg-red-50' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="p-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h4
|
||||
className={`font-medium text-sm flex-1 ${
|
||||
task.status === 'DONE' ? 'line-through text-gray-400' : 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{task.title}
|
||||
</h4>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowMenu(!showMenu)}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 rounded"
|
||||
>
|
||||
<FiMoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
{showMenu && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setShowMenu(false)}
|
||||
/>
|
||||
<div className="absolute right-0 top-6 z-20 bg-white rounded-lg shadow-lg border py-1 min-w-[120px]">
|
||||
<button
|
||||
onClick={() => {
|
||||
onEdit(task)
|
||||
setShowMenu(false)
|
||||
}}
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 flex items-center"
|
||||
>
|
||||
<FiEdit2 className="h-3.5 w-3.5 mr-2" />
|
||||
Sửa
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 text-red-600 flex items-center"
|
||||
>
|
||||
<FiTrash2 className="h-3.5 w-3.5 mr-2" />
|
||||
Xóa
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{task.description && (
|
||||
<p className="text-xs text-gray-500 mt-1 line-clamp-2">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between mt-3 pt-2 border-t border-gray-100">
|
||||
{task.deadline ? (
|
||||
<div
|
||||
className={`flex items-center text-xs ${
|
||||
taskIsOverdue
|
||||
? 'text-red-600'
|
||||
: task.status === 'DONE'
|
||||
? 'text-green-600'
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{taskIsOverdue ? (
|
||||
<FiAlertCircle className="h-3 w-3 mr-1" />
|
||||
) : (
|
||||
<FiClock className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
<span>
|
||||
{new Date(task.deadline).toLocaleDateString('vi-VN', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
|
||||
{/* Task ID badge */}
|
||||
<span className="text-[10px] text-gray-400 font-mono">
|
||||
#{task.id.slice(-4).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user