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:
423
client/src/pages/Tasks.jsx
Normal file
423
client/src/pages/Tasks.jsx
Normal file
@@ -0,0 +1,423 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { Plus, CheckSquare, Edit2, Trash2, Filter } from 'lucide-react'
|
||||
import { AppContext } from '../App'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api } from '../utils/api'
|
||||
import TaskCard from '../components/TaskCard'
|
||||
import Modal from '../components/Modal'
|
||||
|
||||
export default function Tasks() {
|
||||
const { t } = useLanguage()
|
||||
const { currentUser, teamMembers } = useContext(AppContext)
|
||||
const { user: authUser, canEditResource, canDeleteResource } = useAuth()
|
||||
const [tasks, setTasks] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editingTask, setEditingTask] = useState(null)
|
||||
const [draggedTask, setDraggedTask] = useState(null)
|
||||
const [dragOverCol, setDragOverCol] = useState(null)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [taskToDelete, setTaskToDelete] = useState(null)
|
||||
const [filterView, setFilterView] = useState('all') // 'all' | 'assigned_to_me' | 'created_by_me' | member id
|
||||
const [users, setUsers] = useState([]) // for superadmin member filter
|
||||
const [formData, setFormData] = useState({
|
||||
title: '', description: '', priority: 'medium', due_date: '', status: 'todo', assigned_to: ''
|
||||
})
|
||||
|
||||
const isSuperadmin = authUser?.role === 'superadmin'
|
||||
|
||||
useEffect(() => { loadTasks() }, [currentUser])
|
||||
useEffect(() => {
|
||||
if (isSuperadmin) {
|
||||
// Load team members for superadmin filter
|
||||
api.get('/team').then(res => setUsers(res.data || res || [])).catch(() => {})
|
||||
}
|
||||
}, [isSuperadmin])
|
||||
|
||||
const loadTasks = async () => {
|
||||
try {
|
||||
const res = await api.get('/tasks')
|
||||
setTasks(res.data || res || [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load tasks:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter tasks client-side based on selected view
|
||||
const filteredTasks = tasks.filter(task => {
|
||||
if (filterView === 'all') return true
|
||||
|
||||
if (filterView === 'assigned_to_me') {
|
||||
// Tasks where I'm the assignee (via team_member_id on my user record)
|
||||
const myTeamMemberId = authUser?.team_member_id
|
||||
return myTeamMemberId && task.assigned_to === myTeamMemberId
|
||||
}
|
||||
|
||||
if (filterView === 'created_by_me') {
|
||||
return task.created_by_user_id === authUser?.id
|
||||
}
|
||||
|
||||
// Superadmin filtering by specific team member (assigned_to = member id)
|
||||
if (isSuperadmin && !isNaN(Number(filterView))) {
|
||||
return task.assigned_to === Number(filterView)
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const data = {
|
||||
title: formData.title,
|
||||
description: formData.description,
|
||||
priority: formData.priority,
|
||||
due_date: formData.due_date || null,
|
||||
status: formData.status,
|
||||
assigned_to: formData.assigned_to || null,
|
||||
is_personal: false,
|
||||
}
|
||||
if (editingTask) {
|
||||
await api.patch(`/tasks/${editingTask._id || editingTask.id}`, data)
|
||||
} else {
|
||||
await api.post('/tasks', data)
|
||||
}
|
||||
setShowModal(false)
|
||||
setEditingTask(null)
|
||||
setFormData({ title: '', description: '', priority: 'medium', due_date: '', status: 'todo', assigned_to: '' })
|
||||
loadTasks()
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err)
|
||||
if (err.message?.includes('403') || err.message?.includes('modify your own')) {
|
||||
alert('You can only edit your own tasks')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleMove = async (taskId, newStatus) => {
|
||||
try {
|
||||
await api.patch(`/tasks/${taskId}`, { status: newStatus })
|
||||
loadTasks()
|
||||
} catch (err) {
|
||||
console.error('Move failed:', err)
|
||||
if (err.message?.includes('403')) {
|
||||
alert('You can only modify your own tasks')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const openEdit = (task) => {
|
||||
if (!canEditResource('task', task)) return
|
||||
setEditingTask(task)
|
||||
setFormData({
|
||||
title: task.title || '',
|
||||
description: task.description || '',
|
||||
priority: task.priority || 'medium',
|
||||
due_date: task.due_date || task.dueDate || '',
|
||||
status: task.status || 'todo',
|
||||
assigned_to: task.assigned_to || '',
|
||||
})
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const handleDelete = (task) => {
|
||||
if (!canDeleteResource('task', task)) return
|
||||
setTaskToDelete(task)
|
||||
setShowDeleteConfirm(true)
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!taskToDelete) return
|
||||
try {
|
||||
await api.delete(`/tasks/${taskToDelete._id || taskToDelete.id}`)
|
||||
setTaskToDelete(null)
|
||||
loadTasks()
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragStart = (e, task) => {
|
||||
setDraggedTask(task)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
if (e.target) {
|
||||
setTimeout(() => e.target.style.opacity = '0.4', 0)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnd = (e) => {
|
||||
e.target.style.opacity = '1'
|
||||
setDraggedTask(null)
|
||||
setDragOverCol(null)
|
||||
}
|
||||
|
||||
const handleDragOver = (e, colStatus) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
setDragOverCol(colStatus)
|
||||
}
|
||||
|
||||
const handleDragLeave = (e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||||
setDragOverCol(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (e, colStatus) => {
|
||||
e.preventDefault()
|
||||
setDragOverCol(null)
|
||||
if (draggedTask && draggedTask.status !== colStatus) {
|
||||
const taskId = draggedTask._id || draggedTask.id
|
||||
handleMove(taskId, colStatus)
|
||||
}
|
||||
setDraggedTask(null)
|
||||
}
|
||||
|
||||
const todoTasks = filteredTasks.filter(t => t.status === 'todo')
|
||||
const inProgressTasks = filteredTasks.filter(t => t.status === 'in_progress')
|
||||
const doneTasks = filteredTasks.filter(t => t.status === 'done')
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
<div className="h-12 bg-surface-tertiary rounded-xl mb-4"></div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{[...Array(3)].map((_, i) => <div key={i} className="h-64 bg-surface-tertiary rounded-xl"></div>)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ label: t('tasks.todo'), status: 'todo', items: todoTasks, color: 'bg-gray-400' },
|
||||
{ label: t('tasks.in_progress'), status: 'in_progress', items: inProgressTasks, color: 'bg-blue-400' },
|
||||
{ label: t('tasks.done'), status: 'done', items: doneTasks, color: 'bg-emerald-400' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-4 h-4 text-text-tertiary" />
|
||||
<select
|
||||
value={filterView}
|
||||
onChange={e => setFilterView(e.target.value)}
|
||||
className="px-3 py-1.5 text-sm border border-border rounded-lg bg-surface-primary text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
<option value="all">{t('tasks.allTasks')}</option>
|
||||
<option value="assigned_to_me">{t('tasks.assignedToMe')}</option>
|
||||
<option value="created_by_me">{t('tasks.createdByMe')}</option>
|
||||
{isSuperadmin && users.length > 0 && (
|
||||
<optgroup label={t('tasks.byTeamMember')}>
|
||||
{users.map(m => (
|
||||
<option key={m.id} value={m.id}>{m.name}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{filteredTasks.length} {filteredTasks.length !== 1 ? t('tasks.tasks') : t('tasks.task')}
|
||||
{filterView !== 'all' && tasks.length !== filteredTasks.length && (
|
||||
<span className="text-text-tertiary"> {t('tasks.of')} {tasks.length}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setEditingTask(null); setFormData({ title: '', description: '', priority: 'medium', due_date: '', status: 'todo', assigned_to: '' }); setShowModal(true) }}
|
||||
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" />
|
||||
{t('tasks.newTask')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Task columns */}
|
||||
{filteredTasks.length === 0 ? (
|
||||
<div className="py-20 text-center">
|
||||
<CheckSquare className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
|
||||
<p className="text-text-secondary font-medium">
|
||||
{tasks.length === 0 ? t('tasks.noTasks') : t('tasks.noMatch')}
|
||||
</p>
|
||||
<p className="text-sm text-text-tertiary mt-1">
|
||||
{tasks.length === 0 ? t('tasks.createFirst') : t('tasks.tryFilter')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{columns.map(col => {
|
||||
const isOver = dragOverCol === col.status && draggedTask?.status !== col.status
|
||||
|
||||
return (
|
||||
<div key={col.status}>
|
||||
<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">
|
||||
{col.items.length}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`kanban-column rounded-xl p-2 space-y-2 min-h-[200px] 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.status)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, col.status)}
|
||||
>
|
||||
{col.items.length === 0 ? (
|
||||
<div className={`py-8 text-center text-xs ${isOver ? 'text-brand-primary font-medium' : 'text-text-tertiary'}`}>
|
||||
{isOver ? t('posts.dropHere') : t('tasks.noTasks')}
|
||||
</div>
|
||||
) : (
|
||||
col.items.map(task => {
|
||||
const canEdit = canEditResource('task', task)
|
||||
const canDelete = canDeleteResource('task', task)
|
||||
return (
|
||||
<div
|
||||
key={task._id || task.id}
|
||||
draggable={canEdit}
|
||||
onDragStart={(e) => canEdit && handleDragStart(e, task)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={canEdit ? 'cursor-grab active:cursor-grabbing' : ''}
|
||||
>
|
||||
<div className="relative group">
|
||||
<TaskCard task={task} onMove={canEdit ? handleMove : undefined} showProject />
|
||||
{/* Edit/Delete overlay */}
|
||||
{(canEdit || canDelete) && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); openEdit(task) }}
|
||||
className="p-1 hover:bg-surface-tertiary rounded text-text-tertiary hover:text-text-primary"
|
||||
title={t('tasks.editTask')}
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDelete(task) }}
|
||||
className="p-1 hover:bg-red-50 rounded text-text-tertiary hover:text-red-500"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Task Modal */}
|
||||
<Modal isOpen={showModal} onClose={() => { setShowModal(false); setEditingTask(null) }} title={editingTask ? t('tasks.editTask') : t('tasks.createTask')} size="md">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('tasks.taskTitle')} *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={e => setFormData(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={t('posts.whatNeedsDone')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('tasks.description')}</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={e => setFormData(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={t('posts.optionalDetails')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('tasks.assignTo')}</label>
|
||||
<select
|
||||
value={formData.assigned_to}
|
||||
onChange={e => setFormData(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="">{t('common.unassigned')}</option>
|
||||
{(teamMembers || []).map(m => (
|
||||
<option key={m.id || m._id} value={m.id || m._id}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('tasks.priority')}</label>
|
||||
<select
|
||||
value={formData.priority}
|
||||
onChange={e => setFormData(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">{t('tasks.priority.low')}</option>
|
||||
<option value="medium">{t('tasks.priority.medium')}</option>
|
||||
<option value="high">{t('tasks.priority.high')}</option>
|
||||
<option value="urgent">{t('tasks.priority.urgent')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('tasks.dueDate')}</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.due_date}
|
||||
onChange={e => setFormData(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={() => { setShowModal(false); setEditingTask(null) }}
|
||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!formData.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 ? t('tasks.saveChanges') : t('tasks.createTask')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => { setShowDeleteConfirm(false); setTaskToDelete(null) }}
|
||||
title={t('tasks.deleteTask')}
|
||||
isConfirm
|
||||
danger
|
||||
confirmText={t('tasks.deleteTask')}
|
||||
onConfirm={confirmDelete}
|
||||
>
|
||||
{t('tasks.deleteConfirm')}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user