- Add SlidePanel, TaskDetailPanel, PostDetailPanel, TeamPanel, TeamMemberPanel - Add ProjectEditPanel, CollapsibleSection, DatePresetPicker, TaskCalendarView - Update App, AuthContext, i18n (ar/en), PostProduction, ProjectDetail, Projects - Update Settings, Tasks, Team pages - Update InteractiveTimeline, MemberCard, ProjectCard, TaskCard components - Update server API utilities - Remove tracked server/node_modules (now properly gitignored)
469 lines
20 KiB
JavaScript
469 lines
20 KiB
JavaScript
import { useState, useEffect, useRef } from 'react'
|
|
import { X, Trash2, ChevronDown, Check } from 'lucide-react'
|
|
import { useLanguage } from '../i18n/LanguageContext'
|
|
import { api } from '../utils/api'
|
|
import Modal from './Modal'
|
|
import SlidePanel from './SlidePanel'
|
|
import CollapsibleSection from './CollapsibleSection'
|
|
import StatusBadge from './StatusBadge'
|
|
|
|
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' },
|
|
]
|
|
|
|
const ALL_MODULES = ['marketing', 'projects', 'finance']
|
|
const MODULE_LABELS = { marketing: 'Marketing', projects: 'Projects', finance: 'Finance' }
|
|
const MODULE_COLORS = {
|
|
marketing: { on: 'bg-emerald-100 text-emerald-700 border-emerald-300', off: 'bg-gray-100 text-gray-400 border-gray-200' },
|
|
projects: { on: 'bg-blue-100 text-blue-700 border-blue-300', off: 'bg-gray-100 text-gray-400 border-gray-200' },
|
|
finance: { on: 'bg-amber-100 text-amber-700 border-amber-300', off: 'bg-gray-100 text-gray-400 border-gray-200' },
|
|
}
|
|
|
|
export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave, onDelete, canManageTeam, userRole, teams, brands: brandsList }) {
|
|
const { t, lang } = useLanguage()
|
|
const [form, setForm] = useState({})
|
|
const [dirty, setDirty] = useState(false)
|
|
const [saving, setSaving] = useState(false)
|
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
|
const [showBrandsDropdown, setShowBrandsDropdown] = useState(false)
|
|
const brandsDropdownRef = useRef(null)
|
|
|
|
// Workload state (loaded internally)
|
|
const [memberTasks, setMemberTasks] = useState([])
|
|
const [memberPosts, setMemberPosts] = useState([])
|
|
const [loadingWorkload, setLoadingWorkload] = useState(false)
|
|
|
|
const memberId = member?._id || member?.id
|
|
const isCreateMode = !memberId
|
|
|
|
useEffect(() => {
|
|
if (member) {
|
|
setForm({
|
|
name: member.name || '',
|
|
email: member.email || '',
|
|
password: '',
|
|
role: member.team_role || member.role || 'content_writer',
|
|
brands: Array.isArray(member.brands) ? member.brands : [],
|
|
phone: member.phone || '',
|
|
modules: Array.isArray(member.modules) ? member.modules : ALL_MODULES,
|
|
team_ids: Array.isArray(member.teams) ? member.teams.map(t => t.id) : [],
|
|
})
|
|
setDirty(isCreateMode)
|
|
if (!isCreateMode) loadWorkload()
|
|
}
|
|
}, [member])
|
|
|
|
const loadWorkload = async () => {
|
|
if (!memberId) return
|
|
setLoadingWorkload(true)
|
|
try {
|
|
const [tasksRes, postsRes] = await Promise.allSettled([
|
|
api.get(`/tasks?assignedTo=${memberId}`),
|
|
api.get(`/posts?assignedTo=${memberId}`),
|
|
])
|
|
setMemberTasks(tasksRes.status === 'fulfilled' ? (tasksRes.value.data || tasksRes.value || []) : [])
|
|
setMemberPosts(postsRes.status === 'fulfilled' ? (postsRes.value.data || postsRes.value || []) : [])
|
|
} catch {
|
|
setMemberTasks([])
|
|
setMemberPosts([])
|
|
} finally {
|
|
setLoadingWorkload(false)
|
|
}
|
|
}
|
|
|
|
if (!member) return null
|
|
|
|
const update = (field, value) => {
|
|
setForm(f => ({ ...f, [field]: value }))
|
|
setDirty(true)
|
|
}
|
|
|
|
// Close brands dropdown on outside click
|
|
useEffect(() => {
|
|
const handleClickOutside = (e) => {
|
|
if (brandsDropdownRef.current && !brandsDropdownRef.current.contains(e.target)) {
|
|
setShowBrandsDropdown(false)
|
|
}
|
|
}
|
|
if (showBrandsDropdown) document.addEventListener('mousedown', handleClickOutside)
|
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
}, [showBrandsDropdown])
|
|
|
|
const toggleBrand = (brandName) => {
|
|
const current = form.brands || []
|
|
update('brands', current.includes(brandName)
|
|
? current.filter(b => b !== brandName)
|
|
: [...current, brandName]
|
|
)
|
|
}
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true)
|
|
try {
|
|
await onSave(isCreateMode ? null : memberId, {
|
|
name: form.name,
|
|
email: form.email,
|
|
password: form.password,
|
|
role: form.role,
|
|
brands: form.brands || [],
|
|
phone: form.phone,
|
|
modules: form.modules,
|
|
team_ids: form.team_ids,
|
|
}, isEditingSelf)
|
|
setDirty(false)
|
|
if (isCreateMode) onClose()
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const confirmDelete = async () => {
|
|
setShowDeleteConfirm(false)
|
|
await onDelete(memberId)
|
|
onClose()
|
|
}
|
|
|
|
const initials = member.name?.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase() || '?'
|
|
const roleName = (form.role || '').replace(/_/g, ' ')
|
|
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
|
|
|
|
const header = (
|
|
<div className="px-5 py-4 border-b border-border shrink-0">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-indigo-400 to-purple-500 flex items-center justify-center text-white text-sm font-bold shrink-0">
|
|
{initials}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<input
|
|
type="text"
|
|
value={form.name}
|
|
onChange={e => update('name', e.target.value)}
|
|
className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0"
|
|
placeholder={t('team.fullName')}
|
|
/>
|
|
<span className="text-[11px] px-2 py-0.5 rounded-full font-medium bg-brand-primary/10 text-brand-primary capitalize">
|
|
{roleName}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
return (
|
|
<>
|
|
<SlidePanel onClose={onClose} maxWidth="420px" header={header}>
|
|
{/* Details Section */}
|
|
<CollapsibleSection title={t('team.details')}>
|
|
<div className="px-5 pb-4 space-y-3">
|
|
{!isEditingSelf && (
|
|
<>
|
|
<div>
|
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.email')} *</label>
|
|
<input
|
|
type="email"
|
|
value={form.email}
|
|
onChange={e => update('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={!isCreateMode}
|
|
/>
|
|
</div>
|
|
|
|
{isCreateMode && (
|
|
<div>
|
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.password')}</label>
|
|
<input
|
|
type="password"
|
|
value={form.password}
|
|
onChange={e => update('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="••••••••"
|
|
/>
|
|
{!form.password && (
|
|
<p className="text-xs text-text-tertiary mt-1">{t('team.defaultPassword')}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.teamRole')}</label>
|
|
{userRole === 'manager' && isCreateMode && !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={form.role}
|
|
onChange={e => update('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-xs font-medium text-text-tertiary mb-1">{t('team.phone')}</label>
|
|
<input
|
|
type="text"
|
|
value={form.phone}
|
|
onChange={e => update('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 ref={brandsDropdownRef} className="relative">
|
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.brands')}</label>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowBrandsDropdown(prev => !prev)}
|
|
className="w-full flex items-center justify-between 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 bg-white text-left"
|
|
>
|
|
<span className={`flex-1 truncate ${(form.brands || []).length === 0 ? 'text-text-tertiary' : 'text-text-primary'}`}>
|
|
{(form.brands || []).length === 0
|
|
? t('team.selectBrands')
|
|
: (form.brands || []).join(', ')
|
|
}
|
|
</span>
|
|
<ChevronDown className={`w-4 h-4 text-text-tertiary shrink-0 transition-transform ${showBrandsDropdown ? 'rotate-180' : ''}`} />
|
|
</button>
|
|
|
|
{/* Selected brand chips */}
|
|
{(form.brands || []).length > 0 && (
|
|
<div className="flex flex-wrap gap-1 mt-1.5">
|
|
{(form.brands || []).map(b => (
|
|
<span
|
|
key={b}
|
|
className="inline-flex items-center gap-1 text-[10px] px-2 py-0.5 rounded-full bg-brand-primary/10 text-brand-primary font-medium"
|
|
>
|
|
{b}
|
|
<button type="button" onClick={() => toggleBrand(b)} className="hover:text-red-500">
|
|
<X className="w-2.5 h-2.5" />
|
|
</button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Dropdown */}
|
|
{showBrandsDropdown && (
|
|
<div className="absolute z-20 mt-1 w-full bg-white border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
|
|
{brandsList && brandsList.length > 0 ? (
|
|
brandsList.map(brand => {
|
|
const name = lang === 'ar' && brand.name_ar ? brand.name_ar : brand.name
|
|
const checked = (form.brands || []).includes(name)
|
|
return (
|
|
<button
|
|
type="button"
|
|
key={brand.id || brand._id}
|
|
onClick={() => toggleBrand(name)}
|
|
className={`w-full flex items-center gap-2.5 px-3 py-2 cursor-pointer hover:bg-surface-secondary transition-colors text-left ${checked ? 'bg-brand-primary/5' : ''}`}
|
|
>
|
|
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors ${
|
|
checked ? 'bg-brand-primary border-brand-primary' : 'border-border'
|
|
}`}>
|
|
{checked && <Check className="w-3 h-3 text-white" />}
|
|
</div>
|
|
<span className="text-sm text-text-primary">{brand.icon ? `${brand.icon} ` : ''}{name}</span>
|
|
</button>
|
|
)
|
|
})
|
|
) : (
|
|
<p className="px-3 py-3 text-xs text-text-tertiary text-center">{t('brands.noBrands')}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Modules toggle */}
|
|
{!isEditingSelf && canManageTeam && (
|
|
<div>
|
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.modules')}</label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{ALL_MODULES.map(mod => {
|
|
const active = (form.modules || []).includes(mod)
|
|
const colors = MODULE_COLORS[mod]
|
|
return (
|
|
<button
|
|
key={mod}
|
|
type="button"
|
|
onClick={() => {
|
|
update('modules', active
|
|
? form.modules.filter(m => m !== mod)
|
|
: [...(form.modules || []), mod]
|
|
)
|
|
}}
|
|
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${active ? colors.on : colors.off}`}
|
|
>
|
|
{MODULE_LABELS[mod]}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Teams multi-select */}
|
|
{teams && teams.length > 0 && (
|
|
<div>
|
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('teams.teams')}</label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{teams.map(team => {
|
|
const active = (form.team_ids || []).includes(team.id || team._id)
|
|
const teamId = team.id || team._id
|
|
return (
|
|
<button
|
|
key={teamId}
|
|
type="button"
|
|
onClick={() => {
|
|
update('team_ids', active
|
|
? form.team_ids.filter(id => id !== teamId)
|
|
: [...(form.team_ids || []), teamId]
|
|
)
|
|
}}
|
|
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${
|
|
active
|
|
? 'bg-blue-100 text-blue-700 border-blue-300'
|
|
: 'bg-gray-100 text-gray-400 border-gray-200'
|
|
}`}
|
|
>
|
|
{team.name}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-2 pt-2">
|
|
{dirty && (
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={!form.name || (!isEditingSelf && isCreateMode && !form.email) || saving}
|
|
className={`flex-1 px-4 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 ${saving ? 'btn-loading' : ''}`}
|
|
>
|
|
{isEditingSelf ? t('team.saveProfile') : (isCreateMode ? t('team.addMember') : t('team.saveChanges'))}
|
|
</button>
|
|
)}
|
|
{!isCreateMode && !isEditingSelf && canManageTeam && onDelete && (
|
|
<button
|
|
onClick={() => setShowDeleteConfirm(true)}
|
|
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
|
title={t('team.remove')}
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CollapsibleSection>
|
|
|
|
{/* Workload Section (hidden in create mode) */}
|
|
{!isCreateMode && (
|
|
<CollapsibleSection title={t('team.workload')} noBorder>
|
|
<div className="px-5 pb-4 space-y-3">
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-4 gap-2">
|
|
<div className="bg-surface-secondary rounded-lg p-2 text-center">
|
|
<p className="text-lg font-bold text-text-primary">{memberTasks.length}</p>
|
|
<p className="text-[10px] text-text-tertiary">{t('team.totalTasks')}</p>
|
|
</div>
|
|
<div className="bg-surface-secondary rounded-lg p-2 text-center">
|
|
<p className="text-lg font-bold text-amber-500">{todoCount}</p>
|
|
<p className="text-[10px] text-text-tertiary">{t('team.toDo')}</p>
|
|
</div>
|
|
<div className="bg-surface-secondary rounded-lg p-2 text-center">
|
|
<p className="text-lg font-bold text-blue-500">{inProgressCount}</p>
|
|
<p className="text-[10px] text-text-tertiary">{t('team.inProgress')}</p>
|
|
</div>
|
|
<div className="bg-surface-secondary rounded-lg p-2 text-center">
|
|
<p className="text-lg font-bold text-emerald-500">{doneCount}</p>
|
|
<p className="text-[10px] text-text-tertiary">{t('tasks.done')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recent tasks */}
|
|
{memberTasks.length > 0 && (
|
|
<div>
|
|
<h4 className="text-xs font-medium text-text-tertiary mb-2">{t('team.recentTasks')}</h4>
|
|
<div className="space-y-1 max-h-40 overflow-y-auto">
|
|
{memberTasks.slice(0, 8).map(task => (
|
|
<div key={task._id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-surface-secondary">
|
|
<span className={`text-xs flex-1 min-w-0 truncate ${task.status === 'done' ? 'text-text-tertiary line-through' : 'text-text-primary'}`}>
|
|
{task.title}
|
|
</span>
|
|
<StatusBadge status={task.status} size="xs" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Recent posts */}
|
|
{memberPosts.length > 0 && (
|
|
<div>
|
|
<h4 className="text-xs font-medium text-text-tertiary mb-2">{t('team.recentPosts')}</h4>
|
|
<div className="space-y-1 max-h-40 overflow-y-auto">
|
|
{memberPosts.slice(0, 8).map(post => (
|
|
<div key={post._id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-surface-secondary">
|
|
<span className="text-xs text-text-primary flex-1 min-w-0 truncate">{post.title}</span>
|
|
<StatusBadge status={post.status} size="xs" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{loadingWorkload && (
|
|
<p className="text-xs text-text-tertiary text-center py-2">{t('common.loading')}</p>
|
|
)}
|
|
</div>
|
|
</CollapsibleSection>
|
|
)}
|
|
</SlidePanel>
|
|
|
|
<Modal
|
|
isOpen={showDeleteConfirm}
|
|
onClose={() => setShowDeleteConfirm(false)}
|
|
title={t('team.removeMember')}
|
|
isConfirm
|
|
danger
|
|
confirmText={t('team.remove')}
|
|
onConfirm={confirmDelete}
|
|
>
|
|
{t('team.removeConfirm', { name: member?.name })}
|
|
</Modal>
|
|
</>
|
|
)
|
|
}
|