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:
fahed
2026-02-19 11:35:42 +03:00
parent e76be78498
commit 4522edeea8
2207 changed files with 3767 additions and 831225 deletions

View 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>
</>
)
}