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

448
client/src/pages/Team.jsx Normal file
View File

@@ -0,0 +1,448 @@
import { useState, useEffect, useContext } from 'react'
import { Plus, Users, ArrowLeft, User as UserIcon } from 'lucide-react'
import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
import { api } from '../utils/api'
import MemberCard from '../components/MemberCard'
import StatusBadge from '../components/StatusBadge'
import BrandBadge from '../components/BrandBadge'
import Modal from '../components/Modal'
const EMPTY_MEMBER = {
name: '', email: '', password: '', role: 'content_writer', brands: '', phone: '',
}
const ROLES = [
{ value: 'manager', label: 'Manager' },
{ value: 'approver', label: 'Approver' },
{ value: 'publisher', label: 'Publisher' },
{ value: 'content_creator', label: 'Content Creator' },
{ value: 'producer', label: 'Producer' },
{ value: 'designer', label: 'Designer' },
{ value: 'content_writer', label: 'Content Writer' },
{ value: 'social_media_manager', label: 'Social Media Manager' },
{ value: 'photographer', label: 'Photographer' },
{ value: 'videographer', label: 'Videographer' },
{ value: 'strategist', label: 'Strategist' },
]
export default function Team() {
const { t } = useLanguage()
const { teamMembers, loadTeam, currentUser } = useContext(AppContext)
const { user } = useAuth()
const [showModal, setShowModal] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [editingMember, setEditingMember] = useState(null)
const [isEditingSelf, setIsEditingSelf] = useState(false)
const [formData, setFormData] = useState(EMPTY_MEMBER)
const [selectedMember, setSelectedMember] = useState(null)
const [memberTasks, setMemberTasks] = useState([])
const [memberPosts, setMemberPosts] = useState([])
const [loadingDetail, setLoadingDetail] = useState(false)
const canManageTeam = user?.role === 'superadmin' || user?.role === 'manager'
const openNew = () => {
setEditingMember(null)
setIsEditingSelf(false)
setFormData(EMPTY_MEMBER)
setShowModal(true)
}
const openEdit = (member) => {
const isSelf = member._id === user?.id || member.id === user?.id
setEditingMember(member)
setIsEditingSelf(isSelf)
setFormData({
name: member.name || '',
email: member.email || '',
password: '',
role: member.team_role || member.role || 'content_writer',
brands: Array.isArray(member.brands) ? member.brands.join(', ') : (member.brands || ''),
phone: member.phone || '',
})
setShowModal(true)
}
const handleSave = async () => {
try {
const brands = typeof formData.brands === 'string'
? formData.brands.split(',').map(b => b.trim()).filter(Boolean)
: formData.brands
// If editing self, use self-service endpoint
if (isEditingSelf) {
const data = {
name: formData.name,
team_role: formData.role,
brands,
phone: formData.phone,
}
await api.patch('/users/me/profile', data)
} else {
// Manager/superadmin creating or editing other users
const data = {
name: formData.name,
email: formData.email,
team_role: formData.role,
brands,
phone: formData.phone,
}
if (formData.password) {
data.password = formData.password
}
if (editingMember) {
await api.patch(`/users/team/${editingMember._id}`, data)
} else {
await api.post('/users/team', data)
}
}
setShowModal(false)
setEditingMember(null)
setIsEditingSelf(false)
setFormData(EMPTY_MEMBER)
loadTeam()
} catch (err) {
console.error('Save failed:', err)
alert(err.message || 'Failed to save')
}
}
const openMemberDetail = async (member) => {
setSelectedMember(member)
setLoadingDetail(true)
try {
const [tasksRes, postsRes] = await Promise.allSettled([
api.get(`/tasks?assignedTo=${member._id}`),
api.get(`/posts?assignedTo=${member._id}`),
])
setMemberTasks(tasksRes.status === 'fulfilled' ? (tasksRes.value.data || tasksRes.value || []) : [])
setMemberPosts(postsRes.status === 'fulfilled' ? (postsRes.value.data || postsRes.value || []) : [])
} catch {
setMemberTasks([])
setMemberPosts([])
} finally {
setLoadingDetail(false)
}
}
// Member detail view
if (selectedMember) {
const todoCount = memberTasks.filter(t => t.status === 'todo').length
const inProgressCount = memberTasks.filter(t => t.status === 'in_progress').length
const doneCount = memberTasks.filter(t => t.status === 'done').length
return (
<div className="space-y-6 animate-fade-in">
<button
onClick={() => setSelectedMember(null)}
className="flex items-center gap-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
>
<ArrowLeft className="w-4 h-4" />
{t('team.backToTeam')}
</button>
{/* Member profile */}
<div className="bg-white rounded-xl border border-border p-6">
<div className="flex items-start gap-4">
<div className={`w-16 h-16 rounded-full bg-gradient-to-br from-indigo-400 to-purple-500 flex items-center justify-center text-white text-xl font-bold`}>
{selectedMember.name?.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()}
</div>
<div className="flex-1">
<h2 className="text-xl font-bold text-text-primary">{selectedMember.name}</h2>
<p className="text-sm text-text-secondary capitalize">{(selectedMember.team_role || selectedMember.role)?.replace('_', ' ')}</p>
{selectedMember.email && (
<p className="text-sm text-text-tertiary mt-1">{selectedMember.email}</p>
)}
{selectedMember.brands && selectedMember.brands.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3">
{selectedMember.brands.map(b => <BrandBadge key={b} brand={b} />)}
</div>
)}
</div>
<button
onClick={() => openEdit(selectedMember)}
className="px-3 py-1.5 text-sm font-medium text-brand-primary hover:bg-brand-primary/10 rounded-lg"
>
{t('common.edit')}
</button>
</div>
</div>
{/* Workload stats */}
<div className="grid grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-border p-4 text-center">
<p className="text-2xl font-bold text-text-primary">{memberTasks.length}</p>
<p className="text-xs text-text-tertiary">{t('team.totalTasks')}</p>
</div>
<div className="bg-white rounded-xl border border-border p-4 text-center">
<p className="text-2xl font-bold text-amber-500">{todoCount}</p>
<p className="text-xs text-text-tertiary">{t('team.toDo')}</p>
</div>
<div className="bg-white rounded-xl border border-border p-4 text-center">
<p className="text-2xl font-bold text-blue-500">{inProgressCount}</p>
<p className="text-xs text-text-tertiary">{t('team.inProgress')}</p>
</div>
<div className="bg-white rounded-xl border border-border p-4 text-center">
<p className="text-2xl font-bold text-emerald-500">{doneCount}</p>
<p className="text-xs text-text-tertiary">{t('tasks.done')}</p>
</div>
</div>
{/* Tasks & Posts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Tasks */}
<div className="bg-white rounded-xl border border-border">
<div className="px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">{t('tasks.title')} ({memberTasks.length})</h3>
</div>
<div className="divide-y divide-border-light max-h-[400px] overflow-y-auto">
{loadingDetail ? (
<div className="py-8 text-center text-sm text-text-tertiary">{t('common.loading')}</div>
) : memberTasks.length === 0 ? (
<div className="py-8 text-center text-sm text-text-tertiary">{t('team.noTasks')}</div>
) : (
memberTasks.map(task => (
<div key={task._id} className="flex items-center gap-3 px-5 py-3">
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium ${task.status === 'done' ? 'text-text-tertiary line-through' : 'text-text-primary'}`}>
{task.title}
</p>
</div>
<StatusBadge status={task.status} size="xs" />
</div>
))
)}
</div>
</div>
{/* Posts */}
<div className="bg-white rounded-xl border border-border">
<div className="px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">{t('nav.posts')} ({memberPosts.length})</h3>
</div>
<div className="divide-y divide-border-light max-h-[400px] overflow-y-auto">
{loadingDetail ? (
<div className="py-8 text-center text-sm text-text-tertiary">{t('common.loading')}</div>
) : memberPosts.length === 0 ? (
<div className="py-8 text-center text-sm text-text-tertiary">{t('posts.noPosts')}</div>
) : (
memberPosts.map(post => (
<div key={post._id} className="flex items-center gap-3 px-5 py-3">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{post.title}</p>
{post.brand && <BrandBadge brand={post.brand} />}
</div>
<StatusBadge status={post.status} size="xs" />
</div>
))
)}
</div>
</div>
</div>
</div>
)
}
// Team grid
return (
<div className="space-y-4 animate-fade-in">
{/* Toolbar */}
<div className="flex items-center justify-between">
<p className="text-sm text-text-secondary">
{teamMembers.length} {teamMembers.length !== 1 ? t('team.membersPlural') : t('team.member')}
</p>
<div className="flex gap-2">
{/* Edit own profile button */}
<button
onClick={() => {
const self = teamMembers.find(m => m._id === user?.id || m.id === user?.id)
if (self) openEdit(self)
}}
className="flex items-center gap-2 px-4 py-2 bg-white border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
>
<UserIcon className="w-4 h-4" />
{t('team.myProfile')}
</button>
{/* Add member button (managers and superadmins only) */}
{canManageTeam && (
<button
onClick={openNew}
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('team.addMember')}
</button>
)}
</div>
</div>
{/* Member grid */}
{teamMembers.length === 0 ? (
<div className="py-20 text-center">
<Users className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
<p className="text-text-secondary font-medium">{t('team.noMembers')}</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 stagger-children">
{teamMembers.map(member => (
<MemberCard key={member._id} member={member} onClick={openMemberDetail} />
))}
</div>
)}
{/* Create/Edit Modal */}
<Modal
isOpen={showModal}
onClose={() => { setShowModal(false); setEditingMember(null); setIsEditingSelf(false) }}
title={isEditingSelf ? t('team.editProfile') : (editingMember ? t('team.editMember') : t('team.newMember'))}
size="md"
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.name')} *</label>
<input
type="text"
value={formData.name}
onChange={e => setFormData(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={t('team.fullName')}
/>
</div>
{!isEditingSelf && (
<>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.email')} *</label>
<input
type="email"
value={formData.email}
onChange={e => setFormData(f => ({ ...f, email: 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="email@example.com"
disabled={editingMember}
/>
</div>
{!editingMember && (
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.password')} {editingMember && t('team.optional')}</label>
<input
type="password"
value={formData.password}
onChange={e => setFormData(f => ({ ...f, password: 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="••••••••"
/>
{!formData.password && !editingMember && (
<p className="text-xs text-text-tertiary mt-1">{t('team.defaultPassword')}</p>
)}
</div>
)}
</>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.teamRole')}</label>
{user?.role === 'manager' && !editingMember && !isEditingSelf ? (
<>
<input
type="text"
value="Contributor"
disabled
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface-tertiary text-text-tertiary cursor-not-allowed"
/>
<p className="text-xs text-text-tertiary mt-1">{t('team.fixedRole')}</p>
</>
) : (
<select
value={formData.role}
onChange={e => setFormData(f => ({ ...f, role: 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"
>
{ROLES.map(r => <option key={r.value} value={r.value}>{r.label}</option>)}
</select>
)}
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.phone')}</label>
<input
type="text"
value={formData.phone}
onChange={e => setFormData(f => ({ ...f, phone: 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="+966 ..."
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.brands')}</label>
<input
type="text"
value={formData.brands}
onChange={e => setFormData(f => ({ ...f, brands: 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="Samaya Investment, Hira Cultural District"
/>
<p className="text-xs text-text-tertiary mt-1">{t('team.brandsHelp')}</p>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
{editingMember && !isEditingSelf && canManageTeam && (
<button
onClick={() => setShowDeleteConfirm(true)}
className="px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg mr-auto"
>
{t('team.remove')}
</button>
)}
<button
onClick={() => { setShowModal(false); setEditingMember(null); setIsEditingSelf(false) }}
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.name || (!isEditingSelf && !editingMember && !formData.email)}
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"
>
{isEditingSelf ? t('team.saveProfile') : (editingMember ? t('team.saveChanges') : t('team.addMember'))}
</button>
</div>
</div>
</Modal>
{/* Delete Confirmation */}
<Modal
isOpen={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)}
title={t('team.removeMember')}
isConfirm
danger
confirmText={t('team.remove')}
onConfirm={async () => {
if (editingMember) {
await api.delete(`/users/team/${editingMember._id}`)
setShowModal(false)
setEditingMember(null)
setIsEditingSelf(false)
setShowDeleteConfirm(false)
if (selectedMember?._id === editingMember._id) {
setSelectedMember(null)
}
loadTeam()
}
}}
>
{t('team.removeConfirm', { name: editingMember?.name })}
</Modal>
</div>
)
}