Files
todolist/src/components/tasks/TaskBoard.tsx
2025-12-16 23:13:06 +07:00

321 lines
10 KiB
TypeScript

'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>
)
}