Marketing Hub: RBAC, i18n (AR/EN), tasks overhaul, team/user merge, tutorial
Features: - Full RBAC with 3 roles (superadmin/manager/contributor) - Ownership tracking on posts, tasks, campaigns, projects - Task system: assign to anyone, filter combobox, visibility scoping - Team members merged into users table (single source of truth) - Post thumbnails on kanban cards from attachments - Publication link validation before publishing - Interactive onboarding tutorial with Settings restart - Full Arabic/English i18n with RTL layout support - Language toggle in sidebar, IBM Plex Sans Arabic font - Brand-based visibility filtering for non-superadmins - Manager can only create contributors - Profile completion flow for new users - Cookie-based sessions (express-session + SQLite)
This commit is contained in:
777
client/src/pages/ProjectDetail.jsx
Normal file
777
client/src/pages/ProjectDetail.jsx
Normal file
@@ -0,0 +1,777 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
ArrowLeft, Plus, Check, Trash2, Edit3, LayoutGrid, List,
|
||||
GanttChart, Settings, Calendar, Clock
|
||||
} from 'lucide-react'
|
||||
import { format, differenceInDays, startOfDay, addDays, isAfter, isBefore, parseISO } from 'date-fns'
|
||||
import { AppContext } from '../App'
|
||||
import { api, PRIORITY_CONFIG } from '../utils/api'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import BrandBadge from '../components/BrandBadge'
|
||||
import Modal from '../components/Modal'
|
||||
|
||||
const TASK_COLUMNS = [
|
||||
{ id: 'todo', label: 'To Do', color: 'bg-gray-400' },
|
||||
{ id: 'in_progress', label: 'In Progress', color: 'bg-blue-400' },
|
||||
{ id: 'done', label: 'Done', color: 'bg-emerald-400' },
|
||||
]
|
||||
|
||||
export default function ProjectDetail() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { teamMembers, brands } = useContext(AppContext)
|
||||
const [project, setProject] = useState(null)
|
||||
const [tasks, setTasks] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [view, setView] = useState('kanban')
|
||||
const [showTaskModal, setShowTaskModal] = useState(false)
|
||||
const [showProjectModal, setShowProjectModal] = useState(false)
|
||||
const [editingTask, setEditingTask] = useState(null)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [taskToDelete, setTaskToDelete] = useState(null)
|
||||
const [taskForm, setTaskForm] = useState({
|
||||
title: '', description: '', priority: 'medium', assigned_to: '', due_date: '', status: 'todo'
|
||||
})
|
||||
const [projectForm, setProjectForm] = useState({
|
||||
name: '', description: '', brand_id: '', owner_id: '', status: 'active', due_date: ''
|
||||
})
|
||||
|
||||
// Drag state for kanban
|
||||
const [draggedTask, setDraggedTask] = useState(null)
|
||||
const [dragOverCol, setDragOverCol] = useState(null)
|
||||
|
||||
useEffect(() => { loadProject() }, [id])
|
||||
|
||||
const loadProject = async () => {
|
||||
try {
|
||||
const proj = await api.get(`/projects/${id}`)
|
||||
setProject(proj.data || proj)
|
||||
const tasksRes = await api.get(`/tasks?project_id=${id}`)
|
||||
setTasks(Array.isArray(tasksRes) ? tasksRes : (tasksRes.data || []))
|
||||
} catch (err) {
|
||||
console.error('Failed to load project:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTaskSave = async () => {
|
||||
try {
|
||||
const data = {
|
||||
title: taskForm.title,
|
||||
description: taskForm.description,
|
||||
priority: taskForm.priority,
|
||||
assigned_to: taskForm.assigned_to ? Number(taskForm.assigned_to) : null,
|
||||
due_date: taskForm.due_date || null,
|
||||
status: taskForm.status,
|
||||
project_id: Number(id),
|
||||
}
|
||||
if (editingTask) {
|
||||
await api.patch(`/tasks/${editingTask._id}`, data)
|
||||
} else {
|
||||
await api.post('/tasks', data)
|
||||
}
|
||||
setShowTaskModal(false)
|
||||
setEditingTask(null)
|
||||
setTaskForm({ title: '', description: '', priority: 'medium', assigned_to: '', due_date: '', status: 'todo' })
|
||||
loadProject()
|
||||
} catch (err) {
|
||||
console.error('Task save failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTaskStatusChange = async (taskId, newStatus) => {
|
||||
try {
|
||||
await api.patch(`/tasks/${taskId}`, { status: newStatus })
|
||||
loadProject()
|
||||
} catch (err) {
|
||||
console.error('Status change failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteTask = async (taskId) => {
|
||||
setTaskToDelete(taskId)
|
||||
setShowDeleteConfirm(true)
|
||||
}
|
||||
|
||||
const confirmDeleteTask = async () => {
|
||||
if (!taskToDelete) return
|
||||
try {
|
||||
await api.delete(`/tasks/${taskToDelete}`)
|
||||
loadProject()
|
||||
setTaskToDelete(null)
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const openEditTask = (task) => {
|
||||
setEditingTask(task)
|
||||
setTaskForm({
|
||||
title: task.title || '',
|
||||
description: task.description || '',
|
||||
priority: task.priority || 'medium',
|
||||
assigned_to: task.assignedTo || task.assigned_to || '',
|
||||
due_date: task.dueDate ? new Date(task.dueDate).toISOString().slice(0, 10) : '',
|
||||
status: task.status || 'todo',
|
||||
})
|
||||
setShowTaskModal(true)
|
||||
}
|
||||
|
||||
const openNewTask = () => {
|
||||
setEditingTask(null)
|
||||
setTaskForm({ title: '', description: '', priority: 'medium', assigned_to: '', due_date: '', status: 'todo' })
|
||||
setShowTaskModal(true)
|
||||
}
|
||||
|
||||
const openEditProject = () => {
|
||||
if (!project) return
|
||||
setProjectForm({
|
||||
name: project.name || '',
|
||||
description: project.description || '',
|
||||
brand_id: project.brandId || project.brand_id || '',
|
||||
owner_id: project.ownerId || project.owner_id || '',
|
||||
status: project.status || 'active',
|
||||
due_date: project.dueDate ? new Date(project.dueDate).toISOString().slice(0, 10) : '',
|
||||
})
|
||||
setShowProjectModal(true)
|
||||
}
|
||||
|
||||
const handleProjectSave = async () => {
|
||||
try {
|
||||
await api.patch(`/projects/${id}`, {
|
||||
name: projectForm.name,
|
||||
description: projectForm.description,
|
||||
brand_id: projectForm.brand_id ? Number(projectForm.brand_id) : null,
|
||||
owner_id: projectForm.owner_id ? Number(projectForm.owner_id) : null,
|
||||
status: projectForm.status,
|
||||
due_date: projectForm.due_date || null,
|
||||
})
|
||||
setShowProjectModal(false)
|
||||
loadProject()
|
||||
} catch (err) {
|
||||
console.error('Project save failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Drag handlers
|
||||
const handleDragStart = (e, task) => {
|
||||
setDraggedTask(task)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
setTimeout(() => { e.target.style.opacity = '0.4' }, 0)
|
||||
}
|
||||
const handleDragEnd = (e) => {
|
||||
e.target.style.opacity = '1'
|
||||
setDraggedTask(null)
|
||||
setDragOverCol(null)
|
||||
}
|
||||
const handleDragOver = (e, colId) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
setDragOverCol(colId)
|
||||
}
|
||||
const handleDragLeave = (e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) setDragOverCol(null)
|
||||
}
|
||||
const handleDrop = (e, colId) => {
|
||||
e.preventDefault()
|
||||
setDragOverCol(null)
|
||||
if (draggedTask && draggedTask.status !== colId) {
|
||||
handleTaskStatusChange(draggedTask._id, colId)
|
||||
}
|
||||
setDraggedTask(null)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-8 w-48 bg-surface-tertiary rounded-lg"></div>
|
||||
<div className="h-40 bg-surface-tertiary rounded-xl"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="py-20 text-center">
|
||||
<p className="text-text-secondary">Project not found</p>
|
||||
<button onClick={() => navigate('/projects')} className="mt-4 text-brand-primary hover:underline text-sm">
|
||||
Back to Projects
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const completedTasks = tasks.filter(t => t.status === 'done').length
|
||||
const progress = tasks.length > 0 ? Math.round((completedTasks / tasks.length) * 100) : 0
|
||||
const ownerName = project.ownerName || project.owner_name
|
||||
const brandName = project.brandName || project.brand_name
|
||||
|
||||
// Gantt chart helpers
|
||||
const getGanttRange = () => {
|
||||
const today = startOfDay(new Date())
|
||||
let earliest = today
|
||||
let latest = addDays(today, 14)
|
||||
|
||||
tasks.forEach(t => {
|
||||
if (t.createdAt) {
|
||||
const d = startOfDay(new Date(t.createdAt))
|
||||
if (isBefore(d, earliest)) earliest = d
|
||||
}
|
||||
if (t.dueDate) {
|
||||
const d = startOfDay(new Date(t.dueDate))
|
||||
if (isAfter(d, latest)) latest = addDays(d, 1)
|
||||
}
|
||||
})
|
||||
if (project.dueDate) {
|
||||
const d = startOfDay(new Date(project.dueDate))
|
||||
if (isAfter(d, latest)) latest = addDays(d, 1)
|
||||
}
|
||||
// Ensure minimum 14 days
|
||||
if (differenceInDays(latest, earliest) < 14) latest = addDays(earliest, 14)
|
||||
return { earliest, latest, totalDays: differenceInDays(latest, earliest) + 1 }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Back button */}
|
||||
<button
|
||||
onClick={() => navigate('/projects')}
|
||||
className="flex items-center gap-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to Projects
|
||||
</button>
|
||||
|
||||
{/* Project header */}
|
||||
<div className="bg-white rounded-xl border border-border p-6">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="text-2xl font-bold text-text-primary">{project.name}</h1>
|
||||
<StatusBadge status={project.status} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{brandName && <BrandBadge brand={brandName} />}
|
||||
{ownerName && (
|
||||
<span className="text-sm text-text-secondary">
|
||||
Owned by <span className="font-medium">{ownerName}</span>
|
||||
</span>
|
||||
)}
|
||||
{project.dueDate && (
|
||||
<span className="text-sm text-text-tertiary flex items-center gap-1">
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
Due {format(new Date(project.dueDate), 'MMMM d, yyyy')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={openEditProject}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-text-secondary hover:text-text-primary hover:bg-surface-tertiary rounded-lg transition-colors"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{project.description && (
|
||||
<p className="text-sm text-text-secondary mb-4">{project.description}</p>
|
||||
)}
|
||||
|
||||
{/* Progress */}
|
||||
<div className="max-w-md">
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className="text-text-secondary font-medium">Progress</span>
|
||||
<span className="font-semibold text-text-primary">{progress}%</span>
|
||||
</div>
|
||||
<div className="h-2.5 bg-surface-tertiary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-brand-primary to-brand-primary-light rounded-full transition-all duration-500"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-text-tertiary mt-1">{completedTasks} of {tasks.length} tasks completed</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* View switcher + Add Task */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 bg-surface-tertiary rounded-lg p-0.5">
|
||||
{[
|
||||
{ id: 'kanban', icon: LayoutGrid, label: 'Board' },
|
||||
{ id: 'list', icon: List, label: 'List' },
|
||||
{ id: 'gantt', icon: GanttChart, label: 'Timeline' },
|
||||
].map(v => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => setView(v.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
view === v.id ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
<v.icon className="w-4 h-4" />
|
||||
{v.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={openNewTask}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Task
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ─── KANBAN VIEW ─── */}
|
||||
{view === 'kanban' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{TASK_COLUMNS.map(col => {
|
||||
const colTasks = tasks.filter(t => t.status === col.id)
|
||||
const isOver = dragOverCol === col.id && draggedTask?.status !== col.id
|
||||
return (
|
||||
<div key={col.id}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${col.color}`} />
|
||||
<h4 className="text-sm font-semibold text-text-primary">{col.label}</h4>
|
||||
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-2 py-0.5 rounded-full">
|
||||
{colTasks.length}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`rounded-xl p-2 space-y-2 min-h-[150px] border-2 transition-colors ${
|
||||
isOver
|
||||
? 'bg-brand-primary/5 border-brand-primary/40 border-dashed'
|
||||
: 'bg-surface-secondary border-border-light border-solid'
|
||||
}`}
|
||||
onDragOver={(e) => handleDragOver(e, col.id)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, col.id)}
|
||||
>
|
||||
{colTasks.length === 0 ? (
|
||||
<div className={`py-8 text-center text-xs ${isOver ? 'text-brand-primary font-medium' : 'text-text-tertiary'}`}>
|
||||
{isOver ? 'Drop here' : 'No tasks'}
|
||||
</div>
|
||||
) : (
|
||||
colTasks.map(task => (
|
||||
<TaskKanbanCard
|
||||
key={task._id}
|
||||
task={task}
|
||||
onEdit={() => openEditTask(task)}
|
||||
onDelete={() => handleDeleteTask(task._id)}
|
||||
onStatusChange={handleTaskStatusChange}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── LIST VIEW ─── */}
|
||||
{view === 'list' && (
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-secondary">
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider w-8"></th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Task</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Status</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Priority</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Assignee</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Due</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider w-16"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-light">
|
||||
{tasks.length === 0 ? (
|
||||
<tr><td colSpan={7} className="py-12 text-center text-sm text-text-tertiary">No tasks yet</td></tr>
|
||||
) : (
|
||||
tasks.map(task => {
|
||||
const prio = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
|
||||
const assigneeName = task.assignedName || task.assigned_name
|
||||
const isOverdue = task.dueDate && new Date(task.dueDate) < new Date() && task.status !== 'done'
|
||||
return (
|
||||
<tr key={task._id} className="hover:bg-surface-secondary group">
|
||||
<td className="px-4 py-3">
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${prio.color}`} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button onClick={() => openEditTask(task)} className="text-sm font-medium text-text-primary hover:text-brand-primary text-left">
|
||||
{task.title}
|
||||
</button>
|
||||
{task.description && <p className="text-xs text-text-tertiary line-clamp-1 mt-0.5">{task.description}</p>}
|
||||
</td>
|
||||
<td className="px-4 py-3"><StatusBadge status={task.status} size="xs" /></td>
|
||||
<td className="px-4 py-3 text-xs font-medium text-text-secondary capitalize">{prio.label}</td>
|
||||
<td className="px-4 py-3 text-xs text-text-secondary">{assigneeName || '—'}</td>
|
||||
<td className={`px-4 py-3 text-xs ${isOverdue ? 'text-red-500 font-medium' : 'text-text-tertiary'}`}>
|
||||
{task.dueDate ? format(new Date(task.dueDate), 'MMM d, yyyy') : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={() => openEditTask(task)} className="p-1 rounded hover:bg-surface-tertiary text-text-tertiary">
|
||||
<Edit3 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => handleDeleteTask(task._id)} className="p-1 rounded hover:bg-red-50 text-red-400">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── GANTT / TIMELINE VIEW ─── */}
|
||||
{view === 'gantt' && <GanttView tasks={tasks} project={project} onEditTask={openEditTask} />}
|
||||
|
||||
{/* ─── TASK MODAL ─── */}
|
||||
<Modal
|
||||
isOpen={showTaskModal}
|
||||
onClose={() => { setShowTaskModal(false); setEditingTask(null) }}
|
||||
title={editingTask ? 'Edit Task' : 'Add Task'}
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Title *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={taskForm.title}
|
||||
onChange={e => setTaskForm(f => ({ ...f, title: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
placeholder="Task title"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Description</label>
|
||||
<textarea
|
||||
value={taskForm.description}
|
||||
onChange={e => setTaskForm(f => ({ ...f, description: e.target.value }))}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
|
||||
placeholder="Optional description"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Priority</label>
|
||||
<select value={taskForm.priority} onChange={e => setTaskForm(f => ({ ...f, priority: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="urgent">Urgent</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Status</label>
|
||||
<select value={taskForm.status} onChange={e => setTaskForm(f => ({ ...f, status: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
|
||||
<option value="todo">To Do</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="done">Done</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Assign To</label>
|
||||
<select value={taskForm.assigned_to} onChange={e => setTaskForm(f => ({ ...f, assigned_to: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
|
||||
<option value="">Unassigned</option>
|
||||
{teamMembers.map(m => <option key={m._id} value={m._id}>{m.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Due Date</label>
|
||||
<input type="date" value={taskForm.due_date} onChange={e => setTaskForm(f => ({ ...f, due_date: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||
{editingTask && (
|
||||
<button onClick={() => handleDeleteTask(editingTask._id)}
|
||||
className="px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg mr-auto">
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => { setShowTaskModal(false); setEditingTask(null) }}
|
||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg">
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={handleTaskSave} disabled={!taskForm.title}
|
||||
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm">
|
||||
{editingTask ? 'Save Changes' : 'Add Task'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* ─── PROJECT EDIT MODAL ─── */}
|
||||
<Modal
|
||||
isOpen={showProjectModal}
|
||||
onClose={() => setShowProjectModal(false)}
|
||||
title="Edit Project"
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Name *</label>
|
||||
<input type="text" value={projectForm.name} onChange={e => setProjectForm(f => ({ ...f, name: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
placeholder="Project name" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Description</label>
|
||||
<textarea value={projectForm.description} onChange={e => setProjectForm(f => ({ ...f, description: e.target.value }))}
|
||||
rows={3} className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
|
||||
placeholder="Project description..." />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Brand</label>
|
||||
<select value={projectForm.brand_id} onChange={e => setProjectForm(f => ({ ...f, brand_id: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
|
||||
<option value="">Select brand</option>
|
||||
{brands.map(b => <option key={b._id} value={b._id}>{b.icon} {b.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Status</label>
|
||||
<select value={projectForm.status} onChange={e => setProjectForm(f => ({ ...f, status: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
|
||||
<option value="active">Active</option>
|
||||
<option value="paused">Paused</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Owner</label>
|
||||
<select value={projectForm.owner_id} onChange={e => setProjectForm(f => ({ ...f, owner_id: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
|
||||
<option value="">Unassigned</option>
|
||||
{teamMembers.map(m => <option key={m._id} value={m._id}>{m.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Due Date</label>
|
||||
<input type="date" value={projectForm.due_date} onChange={e => setProjectForm(f => ({ ...f, due_date: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||
<button onClick={() => setShowProjectModal(false)}
|
||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg">
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={handleProjectSave} disabled={!projectForm.name}
|
||||
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm">
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Task Kanban Card ───────────────────────────────
|
||||
function TaskKanbanCard({ task, onEdit, onDelete, onStatusChange, onDragStart, onDragEnd }) {
|
||||
const priority = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
|
||||
const assigneeName = task.assignedName || task.assigned_name
|
||||
const isOverdue = task.dueDate && new Date(task.dueDate) < new Date() && task.status !== 'done'
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, task)}
|
||||
onDragEnd={onDragEnd}
|
||||
className="bg-white rounded-lg border border-border p-3 group hover:border-brand-primary/30 hover:shadow-sm transition-all cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${priority.color} mt-1.5 shrink-0`} title={priority.label} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h5 className={`text-sm font-medium ${task.status === 'done' ? 'text-text-tertiary line-through' : 'text-text-primary'}`}>
|
||||
{task.title}
|
||||
</h5>
|
||||
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
|
||||
{assigneeName && (
|
||||
<span className="text-[10px] text-text-tertiary">{assigneeName}</span>
|
||||
)}
|
||||
{task.dueDate && (
|
||||
<span className={`text-[10px] flex items-center gap-0.5 ${isOverdue ? 'text-red-500 font-medium' : 'text-text-tertiary'}`}>
|
||||
<Clock className="w-3 h-3" />
|
||||
{format(new Date(task.dueDate), 'MMM d')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Actions on hover */}
|
||||
<div className="flex items-center gap-1 mt-2 pt-2 border-t border-border-light opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{task.status !== 'done' && (
|
||||
<button onClick={() => onStatusChange(task._id, task.status === 'todo' ? 'in_progress' : 'done')}
|
||||
className="text-[10px] text-brand-primary hover:bg-brand-primary/10 px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
<Check className="w-3 h-3" />
|
||||
{task.status === 'todo' ? 'Start' : 'Complete'}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onEdit}
|
||||
className="text-[10px] text-text-tertiary hover:bg-surface-tertiary px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
<Edit3 className="w-3 h-3" /> Edit
|
||||
</button>
|
||||
<button onClick={onDelete}
|
||||
className="text-[10px] text-red-400 hover:bg-red-50 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Gantt / Timeline View ──────────────────────────
|
||||
function GanttView({ tasks, project, onEditTask }) {
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-border py-16 text-center">
|
||||
<GanttChart className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
|
||||
<p className="text-text-secondary font-medium">No tasks to display</p>
|
||||
<p className="text-sm text-text-tertiary mt-1">Add tasks with due dates to see the timeline</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const today = startOfDay(new Date())
|
||||
|
||||
// Calculate range
|
||||
let earliest = today
|
||||
let latest = addDays(today, 21)
|
||||
tasks.forEach(t => {
|
||||
const created = t.createdAt ? startOfDay(new Date(t.createdAt)) : today
|
||||
const due = t.dueDate ? startOfDay(new Date(t.dueDate)) : null
|
||||
if (isBefore(created, earliest)) earliest = created
|
||||
if (due && isAfter(due, latest)) latest = addDays(due, 2)
|
||||
})
|
||||
if (project.dueDate) {
|
||||
const pd = startOfDay(new Date(project.dueDate))
|
||||
if (isAfter(pd, latest)) latest = addDays(pd, 2)
|
||||
}
|
||||
const totalDays = differenceInDays(latest, earliest) + 1
|
||||
|
||||
// Generate day headers
|
||||
const days = []
|
||||
for (let i = 0; i < totalDays; i++) {
|
||||
days.push(addDays(earliest, i))
|
||||
}
|
||||
|
||||
const dayWidth = Math.max(36, Math.min(60, 800 / totalDays))
|
||||
|
||||
const getBarStyle = (task) => {
|
||||
const start = task.createdAt ? startOfDay(new Date(task.createdAt)) : today
|
||||
const end = task.dueDate ? startOfDay(new Date(task.dueDate)) : addDays(start, 3)
|
||||
const left = differenceInDays(start, earliest) * dayWidth
|
||||
const width = Math.max(dayWidth, (differenceInDays(end, start) + 1) * dayWidth)
|
||||
return { left: `${left}px`, width: `${width}px` }
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
todo: 'bg-gray-300',
|
||||
in_progress: 'bg-blue-400',
|
||||
done: 'bg-emerald-400',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<div style={{ minWidth: `${totalDays * dayWidth + 200}px` }}>
|
||||
{/* Day headers */}
|
||||
<div className="flex border-b border-border bg-surface-secondary sticky top-0 z-10">
|
||||
<div className="w-[200px] shrink-0 px-4 py-2 text-xs font-semibold text-text-tertiary uppercase border-r border-border">
|
||||
Task
|
||||
</div>
|
||||
<div className="flex">
|
||||
{days.map((day, i) => {
|
||||
const isToday = differenceInDays(day, today) === 0
|
||||
const isWeekend = day.getDay() === 0 || day.getDay() === 6
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{ width: `${dayWidth}px` }}
|
||||
className={`text-center py-2 border-r border-border-light text-[10px] ${
|
||||
isToday ? 'bg-brand-primary/10 font-bold text-brand-primary' :
|
||||
isWeekend ? 'bg-surface-tertiary/50 text-text-tertiary' : 'text-text-tertiary'
|
||||
}`}
|
||||
>
|
||||
<div>{format(day, 'd')}</div>
|
||||
<div className="text-[8px] uppercase">{format(day, 'EEE')}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task rows */}
|
||||
{tasks.map(task => {
|
||||
const prio = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
|
||||
const barStyle = getBarStyle(task)
|
||||
return (
|
||||
<div key={task._id} className="flex border-b border-border-light hover:bg-surface-secondary/50 group">
|
||||
<div className="w-[200px] shrink-0 px-4 py-3 border-r border-border flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${prio.color} shrink-0`} />
|
||||
<button onClick={() => onEditTask(task)}
|
||||
className="text-xs font-medium text-text-primary truncate hover:text-brand-primary text-left">
|
||||
{task.title}
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative flex-1" style={{ height: '44px' }}>
|
||||
{/* Today line */}
|
||||
{differenceInDays(today, earliest) >= 0 && (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-px bg-brand-primary/30 z-10"
|
||||
style={{ left: `${differenceInDays(today, earliest) * dayWidth + dayWidth / 2}px` }}
|
||||
/>
|
||||
)}
|
||||
{/* Bar */}
|
||||
<div
|
||||
className={`absolute top-2.5 h-5 rounded-full ${statusColors[task.status] || 'bg-gray-300'} opacity-80 hover:opacity-100 transition-opacity cursor-pointer`}
|
||||
style={barStyle}
|
||||
onClick={() => onEditTask(task)}
|
||||
title={`${task.title}${task.dueDate ? ` — Due ${format(new Date(task.dueDate), 'MMM d')}` : ''}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Task Confirmation */}
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => { setShowDeleteConfirm(false); setTaskToDelete(null) }}
|
||||
title="Delete Task?"
|
||||
isConfirm
|
||||
danger
|
||||
confirmText="Delete Task"
|
||||
onConfirm={confirmDeleteTask}
|
||||
>
|
||||
Are you sure you want to delete this task? This action cannot be undone.
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user