Files
marketing-app/client/src/pages/Team.jsx
fahed 6cdec2b4b5
All checks were successful
Deploy / deploy (push) Successful in 11s
Restrict team_role and brands to admin-only editing
- Remove team_role and brands from profile completion wizard
- Lock team_role and brands fields when user edits own profile
- Remove team_role and brands from PATCH /users/me/profile endpoint
- Profile completeness now checks name instead of team_role

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:36:48 +03:00

564 lines
24 KiB
JavaScript

import { useState, useEffect, useContext } from 'react'
import { Plus, Users, ArrowLeft, User as UserIcon, Edit2, LayoutGrid, Network } from 'lucide-react'
import { getInitials } from '../utils/api'
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 TeamMemberPanel from '../components/TeamMemberPanel'
import TeamPanel from '../components/TeamPanel'
export default function Team() {
const { t } = useLanguage()
const { teamMembers, loadTeam, currentUser, teams, loadTeams, brands } = useContext(AppContext)
const { user } = useAuth()
const [panelMember, setPanelMember] = useState(null)
const [panelIsEditingSelf, setPanelIsEditingSelf] = useState(false)
const [selectedMember, setSelectedMember] = useState(null)
const [memberTasks, setMemberTasks] = useState([])
const [memberPosts, setMemberPosts] = useState([])
const [loadingDetail, setLoadingDetail] = useState(false)
const [panelTeam, setPanelTeam] = useState(null)
const [teamFilter, setTeamFilter] = useState(null)
const [viewMode, setViewMode] = useState('grid') // 'grid' | 'teams'
const canManageTeam = user?.role === 'superadmin' || user?.role === 'manager'
const openNew = () => {
setPanelMember({ role: 'content_writer' })
setPanelIsEditingSelf(false)
}
const openEdit = (member) => {
const isSelf = member._id === user?.id || member.id === user?.id
setPanelMember(member)
setPanelIsEditingSelf(isSelf)
}
const handlePanelSave = async (memberId, data, isEditingSelf) => {
try {
if (isEditingSelf) {
await api.patch('/users/me/profile', {
name: data.name,
phone: data.phone,
})
} else {
const payload = {
name: data.name,
email: data.email,
team_role: data.role,
brands: data.brands,
phone: data.phone,
modules: data.modules,
}
if (data.password) payload.password = data.password
if (memberId) {
await api.patch(`/users/team/${memberId}`, payload)
} else {
const created = await api.post('/users/team', payload)
memberId = created?.id || created?.Id
}
}
// Sync team memberships if team_ids provided
if (data.team_ids !== undefined && memberId && !isEditingSelf) {
const member = teamMembers.find(m => (m.id || m._id) === memberId)
const currentTeamIds = member?.teams ? member.teams.map(t => t.id) : []
const targetTeamIds = data.team_ids || []
const toAdd = targetTeamIds.filter(id => !currentTeamIds.includes(id))
const toRemove = currentTeamIds.filter(id => !targetTeamIds.includes(id))
for (const teamId of toAdd) {
await api.post(`/teams/${teamId}/members`, { user_id: memberId })
}
for (const teamId of toRemove) {
await api.delete(`/teams/${teamId}/members/${memberId}`)
}
}
await loadTeam()
await loadTeams()
} catch (err) {
console.error('Save failed:', err)
alert(err.message || 'Failed to save')
}
}
const handleTeamSave = async (teamId, data) => {
try {
if (teamId) {
await api.patch(`/teams/${teamId}`, data)
} else {
await api.post('/teams', data)
}
await loadTeams()
await loadTeam()
} catch (err) {
console.error('Team save failed:', err)
alert(err.message || 'Failed to save team')
}
}
const handleTeamDelete = async (teamId) => {
try {
await api.delete(`/teams/${teamId}`)
setPanelTeam(null)
if (teamFilter === teamId) setTeamFilter(null)
await loadTeams()
await loadTeam()
} catch (err) {
console.error('Team delete failed:', err)
}
}
const handlePanelDelete = async (memberId) => {
await api.delete(`/users/team/${memberId}`)
if (selectedMember?._id === memberId) {
setSelectedMember(null)
}
setPanelMember(null)
await loadTeam()
}
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>
{/* Team Member Panel */}
{panelMember && (
<TeamMemberPanel
member={panelMember}
isEditingSelf={panelIsEditingSelf}
onClose={() => setPanelMember(null)}
onSave={handlePanelSave}
onDelete={canManageTeam ? handlePanelDelete : null}
canManageTeam={canManageTeam}
userRole={user?.role}
teams={teams}
brands={brands}
/>
)}
</div>
)
}
const displayedMembers = teamFilter
? teamMembers.filter(m => m.teams?.some(t => t.id === teamFilter))
: teamMembers
// Members not in any team
const unassignedMembers = teamMembers.filter(m => !m.teams || m.teams.length === 0)
const avatarColors = [
'from-indigo-400 to-purple-500',
'from-pink-400 to-rose-500',
'from-emerald-400 to-teal-500',
'from-amber-400 to-orange-500',
'from-cyan-400 to-blue-500',
]
// Team grid
return (
<div className="space-y-4 animate-fade-in">
{/* Toolbar */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-text-secondary">
{displayedMembers.length} {displayedMembers.length !== 1 ? t('team.membersPlural') : t('team.member')}
</p>
{/* View toggle */}
<div className="flex items-center bg-white border border-border rounded-lg overflow-hidden">
<button
onClick={() => setViewMode('grid')}
className={`p-2 transition-colors ${viewMode === 'grid' ? 'bg-brand-primary text-white' : 'text-text-tertiary hover:text-text-primary'}`}
title={t('team.gridView')}
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('teams')}
className={`p-2 transition-colors ${viewMode === 'teams' ? 'bg-brand-primary text-white' : 'text-text-tertiary hover:text-text-primary'}`}
title={t('team.teamsView')}
>
<Network className="w-4 h-4" />
</button>
</div>
</div>
<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>
{/* Create Team button (managers and superadmins only) */}
{canManageTeam && (
<button
onClick={() => setPanelTeam({})}
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"
>
<Users className="w-4 h-4" />
{t('teams.createTeam')}
</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>
{/* Grid view: team filter pills + member cards */}
{viewMode === 'grid' && (
<>
{/* Team filter pills */}
{teams.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-text-tertiary">{t('teams.teams')}:</span>
<button
onClick={() => setTeamFilter(null)}
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${
!teamFilter ? 'bg-brand-primary text-white border-brand-primary' : 'bg-white text-text-secondary border-border hover:bg-surface-tertiary'
}`}
>
{t('common.all')}
</button>
{teams.map(team => {
const tid = team.id || team._id
const active = teamFilter === tid
return (
<div key={tid} className="flex items-center gap-0.5">
<button
onClick={() => setTeamFilter(active ? null : tid)}
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${
active ? 'bg-blue-600 text-white border-blue-600' : 'bg-white text-text-secondary border-border hover:bg-surface-tertiary'
}`}
>
{team.name} ({team.member_count || 0})
</button>
{canManageTeam && (
<button
onClick={() => setPanelTeam(team)}
className="p-1 text-text-tertiary hover:text-text-primary rounded"
title={t('teams.editTeam')}
>
<Edit2 className="w-3 h-3" />
</button>
)}
</div>
)
})}
</div>
)}
{/* Member grid */}
{displayedMembers.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">
{displayedMembers.map(member => (
<MemberCard key={member._id} member={member} onClick={openMemberDetail} />
))}
</div>
)}
</>
)}
{/* Teams (org chart) view */}
{viewMode === 'teams' && (
<div className="space-y-6">
{teams.length === 0 && unassignedMembers.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>
) : (
<>
{teams.map(team => {
const tid = team.id || team._id
const members = teamMembers.filter(m => m.teams?.some(t => t.id === tid))
return (
<div key={tid} className="bg-white rounded-xl border border-border overflow-hidden">
{/* Team header */}
<div className="flex items-center justify-between px-5 py-4 bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-border">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-600 flex items-center justify-center text-white">
<Users className="w-5 h-5" />
</div>
<div>
<h3 className="font-semibold text-text-primary">{team.name}</h3>
<p className="text-xs text-text-tertiary">
{members.length} {members.length !== 1 ? t('team.membersPlural') : t('team.member')}
{team.description && ` · ${team.description}`}
</p>
</div>
</div>
{canManageTeam && (
<button
onClick={() => setPanelTeam(team)}
className="px-3 py-1.5 text-sm font-medium text-blue-600 hover:bg-blue-100 rounded-lg transition-colors"
>
<Edit2 className="w-4 h-4" />
</button>
)}
</div>
{/* Team members */}
{members.length === 0 ? (
<div className="py-8 text-center text-sm text-text-tertiary">{t('team.noMembers')}</div>
) : (
<div className="divide-y divide-border-light">
{members.map(member => {
const colorIndex = (member.name?.charCodeAt(0) || 0) % avatarColors.length
return (
<div
key={member._id}
onClick={() => openMemberDetail(member)}
className="flex items-center gap-4 px-5 py-3 hover:bg-surface-secondary transition-colors cursor-pointer"
>
<div className={`w-10 h-10 rounded-full bg-gradient-to-br ${avatarColors[colorIndex]} flex items-center justify-center text-white text-sm font-bold shrink-0`}>
{getInitials(member.name)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary">{member.name}</p>
<p className="text-xs text-text-tertiary capitalize">{(member.team_role || member.role)?.replace('_', ' ')}</p>
</div>
{member.brands && member.brands.length > 0 && (
<div className="flex flex-wrap gap-1 shrink-0">
{member.brands.slice(0, 3).map(b => <BrandBadge key={b} brand={b} />)}
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
})}
{/* Unassigned members */}
{unassignedMembers.length > 0 && (
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="flex items-center gap-3 px-5 py-4 bg-gray-50 border-b border-border">
<div className="w-10 h-10 rounded-lg bg-gray-400 flex items-center justify-center text-white">
<UserIcon className="w-5 h-5" />
</div>
<div>
<h3 className="font-semibold text-text-primary">{t('team.unassigned')}</h3>
<p className="text-xs text-text-tertiary">
{unassignedMembers.length} {unassignedMembers.length !== 1 ? t('team.membersPlural') : t('team.member')}
</p>
</div>
</div>
<div className="divide-y divide-border-light">
{unassignedMembers.map(member => {
const colorIndex = (member.name?.charCodeAt(0) || 0) % avatarColors.length
return (
<div
key={member._id}
onClick={() => openMemberDetail(member)}
className="flex items-center gap-4 px-5 py-3 hover:bg-surface-secondary transition-colors cursor-pointer"
>
<div className={`w-10 h-10 rounded-full bg-gradient-to-br ${avatarColors[colorIndex]} flex items-center justify-center text-white text-sm font-bold shrink-0`}>
{getInitials(member.name)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary">{member.name}</p>
<p className="text-xs text-text-tertiary capitalize">{(member.team_role || member.role)?.replace('_', ' ')}</p>
</div>
{member.brands && member.brands.length > 0 && (
<div className="flex flex-wrap gap-1 shrink-0">
{member.brands.slice(0, 3).map(b => <BrandBadge key={b} brand={b} />)}
</div>
)}
</div>
)
})}
</div>
</div>
)}
</>
)}
</div>
)}
{/* Team Member Panel */}
{panelMember && (
<TeamMemberPanel
member={panelMember}
isEditingSelf={panelIsEditingSelf}
onClose={() => setPanelMember(null)}
onSave={handlePanelSave}
onDelete={canManageTeam ? handlePanelDelete : null}
canManageTeam={canManageTeam}
userRole={user?.role}
teams={teams}
brands={brands}
/>
)}
{/* Team Panel */}
{panelTeam && (
<TeamPanel
team={panelTeam}
onClose={() => setPanelTeam(null)}
onSave={handleTeamSave}
onDelete={canManageTeam ? handleTeamDelete : null}
teamMembers={teamMembers}
/>
)}
</div>
)
}