feat: slide panels, task calendar, team management, project editing, collapsible sections
- 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)
This commit is contained in:
468
client/src/components/TeamMemberPanel.jsx
Normal file
468
client/src/components/TeamMemberPanel.jsx
Normal file
@@ -0,0 +1,468 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user