erp-platform/ui/src/components/intranet/Tasks/index.tsx

656 lines
26 KiB
TypeScript
Raw Normal View History

2025-10-18 22:37:20 +00:00
import React, { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
PointerSensor,
useSensor,
useSensors,
closestCorners,
useDroppable
} from '@dnd-kit/core'
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import {
HiPlus,
HiXMark,
HiClock,
HiChatBubbleLeftRight,
HiPaperClip,
HiTrash
} from 'react-icons/hi2'
import dayjs from 'dayjs'
import 'dayjs/locale/tr'
import { mockTasks, Task } from '../../../mocks/mockIntranetData'
import { Badge } from '@/components/ui'
dayjs.locale('tr')
type TaskStatus = 'todo' | 'in-progress' | 'review' | 'done'
// Droppable Column Component
interface DroppableColumnProps {
id: TaskStatus
children: React.ReactNode
}
const DroppableColumn: React.FC<DroppableColumnProps> = ({ id, children }) => {
const { setNodeRef } = useDroppable({ id })
return (
<div ref={setNodeRef} >
{children}
</div>
)
}
// Sortable Task Card Component
interface SortableTaskCardProps {
task: Task
onTaskClick: (task: Task) => void
getPriorityColor: (priority: string) => string
getPriorityLabel: (priority: string) => string
isOverdue: (date: Date | string) => boolean
}
const SortableTaskCard: React.FC<SortableTaskCardProps> = ({
task,
onTaskClick,
getPriorityColor,
getPriorityLabel,
isOverdue
}) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging
} = useSortable({ id: task.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1
}
const overdue = isOverdue(task.dueDate) && task.status !== 'done'
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={`bg-white dark:bg-gray-800 rounded-lg p-3 sm:p-4 border-2 cursor-move hover:shadow-lg transition-all ${
overdue
? 'border-red-300 dark:border-red-700'
: 'border-gray-200 dark:border-gray-700'
} ${isDragging ? 'shadow-2xl ring-4 ring-blue-500/50' : ''}`}
onClick={(e) => {
if (!(e.target as HTMLElement).closest('[data-no-click]')) {
onTaskClick(task)
}
}}
>
<div className="flex items-start justify-between mb-2 sm:mb-3">
<span className={`px-2 py-1 text-xs font-medium rounded border ${getPriorityColor(task.priority)}`}>
{getPriorityLabel(task.priority)}
</span>
{overdue && (
<span className="text-xs text-red-600 dark:text-red-400 font-medium">
Gecikmiş
</span>
)}
</div>
<h4 className="font-semibold text-gray-900 dark:text-white mb-2">
{task.title}
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2">
{task.description}
</p>
<div className="mb-3">
<span className="inline-flex items-center px-2 py-1 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded text-xs">
📁 {task.project}
</span>
</div>
{task.labels.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3">
{task.labels.map((label, idx) => (
<span
key={idx}
className="px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 text-xs rounded"
>
{label}
</span>
))}
</div>
)}
<div className="flex items-center justify-between pt-3 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
<div className="flex items-center gap-1">
<HiClock className="w-4 h-4" />
{dayjs(task.dueDate).format('DD MMM')}
</div>
{task.comments > 0 && (
<div className="flex items-center gap-1">
<HiChatBubbleLeftRight className="w-4 h-4" />
{task.comments}
</div>
)}
{task.attachments && task.attachments.length > 0 && (
<div className="flex items-center gap-1">
<HiPaperClip className="w-4 h-4" />
{task.attachments.length}
</div>
)}
</div>
<div className="flex -space-x-2">
{task.assignedTo.slice(0, 3).map((assignee, idx) => (
<img
key={idx}
src={assignee.avatar}
alt={assignee.fullName}
className="w-6 h-6 rounded-full border-2 border-white dark:border-gray-800"
title={assignee.fullName}
/>
))}
{task.assignedTo.length > 3 && (
<div className="w-6 h-6 rounded-full border-2 border-white dark:border-gray-800 bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-xs font-medium text-gray-700 dark:text-gray-300">
+{task.assignedTo.length - 3}
</div>
)}
</div>
</div>
</div>
)
}
const TasksModule: React.FC = () => {
const [tasks, setTasks] = useState<Task[]>(mockTasks)
const [selectedTask, setSelectedTask] = useState<Task | null>(null)
const [activeId, setActiveId] = useState<string | null>(null)
const [showNewTaskModal, setShowNewTaskModal] = useState(false)
const [newTaskColumn, setNewTaskColumn] = useState<TaskStatus>('todo')
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
})
)
const columns: { id: TaskStatus; title: string; icon: string; color: string }[] = [
{ id: 'todo', title: 'Yapılacak', icon: '📋', color: 'gray' },
{ id: 'in-progress', title: 'Devam Ediyor', icon: '⚙️', color: 'blue' },
{ id: 'review', title: 'İncelemede', icon: '👀', color: 'yellow' },
{ id: 'done', title: 'Tamamlandı', icon: '✅', color: 'green' }
]
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string)
}
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
setActiveId(null)
if (!over) return
const taskId = active.id as string
// over.id could be either a column id or a task id
// If it's a column id (from DroppableColumn), use it directly
// If it's a task id, find that task's column
let newStatus: TaskStatus
const overColumn = columns.find(col => col.id === over.id)
if (overColumn) {
newStatus = overColumn.id
} else {
// over.id is a task, find its column
const overTask = tasks.find(t => t.id === over.id)
if (!overTask) return
newStatus = overTask.status
}
// Update task status
setTasks(prevTasks =>
prevTasks.map(task =>
task.id === taskId ? { ...task, status: newStatus } : task
)
)
if (selectedTask?.id === taskId) {
setSelectedTask(prev => prev ? { ...prev, status: newStatus } : null)
}
}
const handleStatusChange = (taskId: string, newStatus: TaskStatus) => {
setTasks(prevTasks =>
prevTasks.map(task =>
task.id === taskId ? { ...task, status: newStatus } : task
)
)
if (selectedTask?.id === taskId) {
setSelectedTask(prev => prev ? { ...prev, status: newStatus } : null)
}
}
const handleAddTask = (status: TaskStatus) => {
setNewTaskColumn(status)
setShowNewTaskModal(true)
}
const handleCreateTask = (title: string, description: string) => {
const newTask: Task = {
id: `task-${Date.now()}`,
title,
description,
project: 'Genel',
assignedTo: [mockTasks[0].assignedTo[0]], // Default assignee
assignedBy: mockTasks[0].assignedBy,
priority: 'medium',
status: newTaskColumn,
dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now
createdAt: new Date(),
labels: [],
comments: 0
}
setTasks(prev => [...prev, newTask])
setShowNewTaskModal(false)
}
const handleDeleteTask = (taskId: string) => {
if (window.confirm('Bu görevi silmek istediğinizden emin misiniz?')) {
setTasks(prevTasks => prevTasks.filter(task => task.id !== taskId))
setSelectedTask(null)
}
}
const getTasksByStatus = (status: TaskStatus) => {
return tasks.filter((task: Task) => task.status === status)
}
const getPriorityColor = (priority: string) => {
const colors = {
low: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 border-gray-200 dark:border-gray-600',
medium: 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-300 border-blue-200 dark:border-blue-700',
high: 'bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-300 border-orange-200 dark:border-orange-700',
urgent: 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-300 border-red-200 dark:border-red-700'
}
return colors[priority as keyof typeof colors]
}
const getPriorityLabel = (priority: string) => {
const labels = {
low: 'Düşük',
medium: 'Orta',
high: 'Yüksek',
urgent: '🔥 Acil'
}
return labels[priority as keyof typeof labels]
}
const isOverdue = (dueDate: Date | string) => {
return dayjs(dueDate).isBefore(dayjs(), 'day')
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-3 sm:p-4 md:p-6">
<div className="max-w-[1600px] mx-auto space-y-4 md:space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-white">
Görev & Proje Yönetimi
</h1>
<p className="text-sm sm:text-base text-gray-600 dark:text-gray-400 mt-1">
Görevleri Kanban board ile yönetin
</p>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 text-xs sm:text-sm text-gray-600 dark:text-gray-400">
<span className="font-medium">Toplam:</span>
<span>{tasks.length} görev</span>
</div>
<button className="px-3 sm:px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg flex items-center gap-2 transition-colors text-sm sm:text-base">
<HiPlus className="w-4 h-4 sm:w-5 sm:h-5" />
<span className="hidden sm:inline">Yeni Görev</span>
<span className="sm:hidden">Yeni</span>
</button>
</div>
</div>
{/* Kanban Board */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-4 overflow-x-auto pb-4">
<div className="kanban-container sm:contents">
{columns.map(column => {
const columnTasks = getTasksByStatus(column.id)
return (
<DroppableColumn key={column.id} id={column.id}>
<SortableContext
id={column.id}
items={columnTasks.map(t => t.id)}
strategy={verticalListSortingStrategy}
>
<div
className="kanban-column flex flex-col gap-2 sm:gap-3 bg-gray-100 dark:bg-gray-800/50 rounded-lg p-3 sm:p-4 min-h-[400px] sm:min-h-[500px] lg:min-h-[600px]"
data-status={column.id}
>
{/* Column Header */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-lg sm:text-xl">{column.icon}</span>
<h3 className="font-semibold text-gray-900 dark:text-white text-sm sm:text-base">
{column.title}
</h3>
</div>
<Badge content={columnTasks.length}></Badge>
</div>
{/* Tasks */}
<div className="space-y-3 flex-1">
{columnTasks.map(task => (
<SortableTaskCard
key={task.id}
task={task}
onTaskClick={setSelectedTask}
getPriorityColor={getPriorityColor}
getPriorityLabel={getPriorityLabel}
isOverdue={isOverdue}
/>
))}
</div>
{/* Add Task Button */}
<button
onClick={() => handleAddTask(column.id)}
className="w-full p-3 sm:p-4 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg hover:border-blue-500 dark:hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/10 transition-colors text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400"
>
<HiPlus className="w-4 h-4 sm:w-5 sm:h-5 mx-auto" />
</button>
</div>
</SortableContext>
</DroppableColumn>
)
})}
</div>
</div>
{/* DragOverlay */}
<DragOverlay>
{activeId ? (
<div className="opacity-50">
{(() => {
const task = tasks.find(t => t.id === activeId)
if (!task) return null
return (
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 border-2 border-blue-500 shadow-2xl">
<h4 className="font-semibold text-gray-900 dark:text-white">
{task.title}
</h4>
</div>
)
})()}
</div>
) : null}
</DragOverlay>
{/* New Task Modal */}
<AnimatePresence>
{showNewTaskModal && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 z-40"
onClick={() => setShowNewTaskModal(false)}
/>
<div className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4">
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-lg w-full"
onClick={(e) => e.stopPropagation()}
>
<div className="p-4 sm:p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h2 className="text-lg sm:text-xl font-semibold text-gray-900 dark:text-white">
Yeni Görev Oluştur
</h2>
<button
onClick={() => setShowNewTaskModal(false)}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<HiXMark className="w-5 h-5 text-gray-500" />
</button>
</div>
<form
onSubmit={(e) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const title = formData.get('title') as string
const description = formData.get('description') as string
if (title && description) {
handleCreateTask(title, description)
}
}}
className="p-4 sm:p-6 space-y-4"
>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Görev Başlığı *
</label>
<input
type="text"
name="title"
required
placeholder="Görev başlığını yazın"
className="w-full px-3 sm:px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 text-sm sm:text-base"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
ıklama *
</label>
<textarea
name="description"
required
rows={4}
placeholder="Görev detaylarını yazın"
className="w-full px-3 sm:px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 text-sm sm:text-base"
/>
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-3">
<p className="text-xs sm:text-sm text-blue-700 dark:text-blue-300">
📋 Görev <strong>{columns.find(c => c.id === newTaskColumn)?.title}</strong> kolonuna eklenecek
</p>
</div>
<div className="flex gap-2 sm:gap-3 pt-4">
<button
type="button"
onClick={() => setShowNewTaskModal(false)}
className="flex-1 px-3 sm:px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-sm sm:text-base"
>
İptal
</button>
<button
type="submit"
className="flex-1 px-3 sm:px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors text-sm sm:text-base"
>
Oluştur
</button>
</div>
</form>
</motion.div>
</div>
</>
)}
</AnimatePresence>
{/* Task Detail Modal */}
<AnimatePresence>
{selectedTask && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 z-40"
onClick={() => setSelectedTask(null)}
/>
<div className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4">
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] overflow-y-auto"
>
<div className="p-4 sm:p-6 border-b border-gray-200 dark:border-gray-700 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<span className={`px-2 sm:px-3 py-1 sm:py-1.5 text-xs sm:text-sm font-medium rounded border ${getPriorityColor(selectedTask.priority)}`}>
{getPriorityLabel(selectedTask.priority)}
</span>
<span className="px-2 sm:px-3 py-1 sm:py-1.5 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 text-xs sm:text-sm font-medium rounded">
📁 {selectedTask.project}
</span>
</div>
<button
onClick={() => setSelectedTask(null)}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<HiXMark className="w-5 h-5 text-gray-500" />
</button>
</div>
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
<div>
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-white mb-2">
{selectedTask.title}
</h2>
<p className="text-sm sm:text-base text-gray-700 dark:text-gray-300">
{selectedTask.description}
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
<div>
<p className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">
Durum
</p>
<select
value={selectedTask.status}
onChange={(e) => handleStatusChange(selectedTask.id, e.target.value as TaskStatus)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 cursor-pointer"
>
<option value="todo">📋 Yapılacak</option>
<option value="in-progress"> Devam Ediyor</option>
<option value="review">👀 İncelemede</option>
<option value="done"> Tamamlandı</option>
</select>
</div>
<div>
<p className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">
Son Tarih
</p>
<div className="flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg">
<HiClock className="w-5 h-5 text-gray-400" />
<span className="text-gray-900 dark:text-white">
{dayjs(selectedTask.dueDate).format('DD MMMM YYYY')}
</span>
</div>
</div>
</div>
<div>
<p className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2 sm:mb-3">
Atananlar
</p>
<div className="flex flex-wrap gap-2 sm:gap-3">
{selectedTask.assignedTo.map((user, idx) => (
<div
key={idx}
className="flex items-center gap-2 px-2 sm:px-3 py-1.5 sm:py-2 bg-gray-100 dark:bg-gray-700 rounded-lg"
>
<img
src={user.avatar}
alt={user.fullName}
className="w-6 h-6 sm:w-8 sm:h-8 rounded-full"
/>
<span className="text-xs sm:text-sm text-gray-900 dark:text-white">
{user.fullName}
</span>
</div>
))}
</div>
</div>
{selectedTask.labels.length > 0 && (
<div>
<p className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">
Etiketler
</p>
<div className="flex flex-wrap gap-2">
{selectedTask.labels.map((label, idx) => (
<span
key={idx}
className="px-2 sm:px-3 py-1 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 text-xs sm:text-sm rounded"
>
{label}
</span>
))}
</div>
</div>
)}
<div className="pt-6 border-t border-gray-200 dark:border-gray-700 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<p className="text-xs text-gray-500 dark:text-gray-400">
Oluşturan: {selectedTask.assignedBy.fullName} {dayjs(selectedTask.createdAt).format('DD MMMM YYYY')}
</p>
<button
onClick={() => handleDeleteTask(selectedTask.id)}
className="px-3 sm:px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center gap-2 transition-colors text-sm"
>
<HiTrash className="w-4 h-4" />
Görevi Sil
</button>
</div>
</div>
</motion.div>
</div>
</>
)}
</AnimatePresence>
</div>
</div>
</DndContext>
)
}
export default TasksModule