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:
fahed
2026-02-08 20:46:58 +03:00
commit 35d84b6bff
2240 changed files with 846749 additions and 0 deletions

423
client/src/pages/Tasks.jsx Normal file
View 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>
)
}