321 lines
10 KiB
TypeScript
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>
|
|
)
|
|
}
|