feat: use modals for creation, side panels for editing
All checks were successful
Deploy / deploy (push) Successful in 11s

- Team page: add member via modal with password confirmation,
  keep SlidePanel for editing existing members only
- Settings: add role via modal with color picker presets,
  keep inline editing for existing roles
- Remove create-mode code from TeamMemberPanel
- Add i18n keys: confirmPassword, passwordsDoNotMatch, memberAdded,
  roleColor (en + ar)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
fahed
2026-03-04 16:37:37 +03:00
parent da161014af
commit 959bd6066d
5 changed files with 362 additions and 151 deletions

View File

@@ -24,8 +24,6 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [showBrandsDropdown, setShowBrandsDropdown] = useState(false) const [showBrandsDropdown, setShowBrandsDropdown] = useState(false)
const [confirmPassword, setConfirmPassword] = useState('')
const [passwordError, setPasswordError] = useState('')
const brandsDropdownRef = useRef(null) const brandsDropdownRef = useRef(null)
// Workload state (loaded internally) // Workload state (loaded internally)
@@ -34,7 +32,6 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
const [loadingWorkload, setLoadingWorkload] = useState(false) const [loadingWorkload, setLoadingWorkload] = useState(false)
const memberId = member?._id || member?.id const memberId = member?._id || member?.id
const isCreateMode = !memberId
useEffect(() => { useEffect(() => {
if (member) { if (member) {
@@ -49,10 +46,8 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
modules: Array.isArray(member.modules) ? member.modules : ALL_MODULES, modules: Array.isArray(member.modules) ? member.modules : ALL_MODULES,
team_ids: Array.isArray(member.teams) ? member.teams.map(t => t.id) : [], team_ids: Array.isArray(member.teams) ? member.teams.map(t => t.id) : [],
}) })
setDirty(isCreateMode) setDirty(false)
setConfirmPassword('') if (memberId) loadWorkload()
setPasswordError('')
if (!isCreateMode) loadWorkload()
} }
}, [member]) }, [member])
@@ -101,14 +96,9 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
} }
const handleSave = async () => { const handleSave = async () => {
setPasswordError('')
if (isCreateMode && form.password && form.password !== confirmPassword) {
setPasswordError('Passwords do not match')
return
}
setSaving(true) setSaving(true)
try { try {
await onSave(isCreateMode ? null : memberId, { await onSave(memberId, {
name: form.name, name: form.name,
email: form.email, email: form.email,
password: form.password, password: form.password,
@@ -120,7 +110,6 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
team_ids: form.team_ids, team_ids: form.team_ids,
}, isEditingSelf) }, isEditingSelf)
setDirty(false) setDirty(false)
if (isCreateMode) onClose()
} finally { } finally {
setSaving(false) setSaving(false)
} }
@@ -176,51 +165,15 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
<CollapsibleSection title={t('team.details')}> <CollapsibleSection title={t('team.details')}>
<div className="px-5 pb-4 space-y-3"> <div className="px-5 pb-4 space-y-3">
{!isEditingSelf && ( {!isEditingSelf && (
<> <div>
<div> <label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.email')}</label>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.email')} *</label> <input
<input type="email"
type="email" value={form.email}
value={form.email} disabled
onChange={e => update('email', e.target.value)} className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface-tertiary text-text-tertiary cursor-not-allowed"
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" </div>
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>
)}
{isCreateMode && form.password && (
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">Confirm Password</label>
<input
type="password"
value={confirmPassword}
onChange={e => { setConfirmPassword(e.target.value); setPasswordError('') }}
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="••••••••"
/>
{passwordError && (
<p className="text-xs text-red-500 mt-1">{passwordError}</p>
)}
</div>
)}
</>
)} )}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -405,13 +358,13 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
{dirty && ( {dirty && (
<button <button
onClick={handleSave} onClick={handleSave}
disabled={!form.name || (!isEditingSelf && isCreateMode && !form.email) || saving} disabled={!form.name || 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' : ''}`} 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'))} {isEditingSelf ? t('team.saveProfile') : t('team.saveChanges')}
</button> </button>
)} )}
{!isCreateMode && !isEditingSelf && canManageTeam && onDelete && ( {!isEditingSelf && canManageTeam && onDelete && (
<button <button
onClick={() => setShowDeleteConfirm(true)} onClick={() => setShowDeleteConfirm(true)}
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors" className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
@@ -424,9 +377,8 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
</div> </div>
</CollapsibleSection> </CollapsibleSection>
{/* Workload Section (hidden in create mode) */} {/* Workload Section */}
{!isCreateMode && ( <CollapsibleSection title={t('team.workload')} noBorder>
<CollapsibleSection title={t('team.workload')} noBorder>
<div className="px-5 pb-4 space-y-3"> <div className="px-5 pb-4 space-y-3">
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-4 gap-2"> <div className="grid grid-cols-4 gap-2">
@@ -485,7 +437,6 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
)} )}
</div> </div>
</CollapsibleSection> </CollapsibleSection>
)}
</SlidePanel> </SlidePanel>
<Modal <Modal

View File

@@ -209,6 +209,7 @@
"team.title": "الفريق", "team.title": "الفريق",
"team.members": "أعضاء الفريق", "team.members": "أعضاء الفريق",
"team.addMember": "إضافة عضو", "team.addMember": "إضافة عضو",
"team.memberAdded": "تمت إضافة العضو بنجاح",
"team.newMember": "عضو جديد", "team.newMember": "عضو جديد",
"team.editMember": "تعديل العضو", "team.editMember": "تعديل العضو",
"team.myProfile": "ملفي الشخصي", "team.myProfile": "ملفي الشخصي",
@@ -231,6 +232,8 @@
"team.membersPlural": "أعضاء فريق", "team.membersPlural": "أعضاء فريق",
"team.fullName": "الاسم الكامل", "team.fullName": "الاسم الكامل",
"team.defaultPassword": "افتراضياً: changeme123", "team.defaultPassword": "افتراضياً: changeme123",
"team.confirmPassword": "تأكيد كلمة المرور",
"team.passwordsDoNotMatch": "كلمتا المرور غير متطابقتين",
"team.optional": "(اختياري)", "team.optional": "(اختياري)",
"team.fixedRole": "دور ثابت للمديرين", "team.fixedRole": "دور ثابت للمديرين",
"team.remove": "إزالة", "team.remove": "إزالة",
@@ -673,6 +676,7 @@
"settings.rolesDesc": "حدد أدوار العمل مثل مصمم، استراتيجي، إلخ. يتم تعيينها لأعضاء الفريق بشكل منفصل عن مستويات الصلاحية.", "settings.rolesDesc": "حدد أدوار العمل مثل مصمم، استراتيجي، إلخ. يتم تعيينها لأعضاء الفريق بشكل منفصل عن مستويات الصلاحية.",
"settings.addRole": "إضافة دور", "settings.addRole": "إضافة دور",
"settings.roleName": "اسم الدور", "settings.roleName": "اسم الدور",
"settings.roleColor": "اللون",
"settings.deleteRoleConfirm": "هل أنت متأكد من حذف هذا الدور؟", "settings.deleteRoleConfirm": "هل أنت متأكد من حذف هذا الدور؟",
"settings.noRoles": "لم يتم تحديد أدوار بعد. أضف أول دور." "settings.noRoles": "لم يتم تحديد أدوار بعد. أضف أول دور."
} }

View File

@@ -209,6 +209,7 @@
"team.title": "Team", "team.title": "Team",
"team.members": "Team Members", "team.members": "Team Members",
"team.addMember": "Add Member", "team.addMember": "Add Member",
"team.memberAdded": "Member added successfully",
"team.newMember": "New Team Member", "team.newMember": "New Team Member",
"team.editMember": "Edit Team Member", "team.editMember": "Edit Team Member",
"team.myProfile": "My Profile", "team.myProfile": "My Profile",
@@ -231,6 +232,8 @@
"team.membersPlural": "team members", "team.membersPlural": "team members",
"team.fullName": "Full name", "team.fullName": "Full name",
"team.defaultPassword": "Default: changeme123", "team.defaultPassword": "Default: changeme123",
"team.confirmPassword": "Confirm Password",
"team.passwordsDoNotMatch": "Passwords do not match",
"team.optional": "(optional)", "team.optional": "(optional)",
"team.fixedRole": "Fixed role for managers", "team.fixedRole": "Fixed role for managers",
"team.remove": "Remove", "team.remove": "Remove",
@@ -673,6 +676,7 @@
"settings.rolesDesc": "Define job roles like Designer, Strategist, etc. These are assigned to team members separately from permission levels.", "settings.rolesDesc": "Define job roles like Designer, Strategist, etc. These are assigned to team members separately from permission levels.",
"settings.addRole": "Add Role", "settings.addRole": "Add Role",
"settings.roleName": "Role name", "settings.roleName": "Role name",
"settings.roleColor": "Color",
"settings.deleteRoleConfirm": "Are you sure you want to delete this role?", "settings.deleteRoleConfirm": "Are you sure you want to delete this role?",
"settings.noRoles": "No roles defined yet. Add your first role." "settings.noRoles": "No roles defined yet. Add your first role."
} }

View File

@@ -6,6 +6,7 @@ import { useToast } from '../components/ToastContainer'
import { CURRENCIES } from '../i18n/LanguageContext' import { CURRENCIES } from '../i18n/LanguageContext'
import { AppContext } from '../App' import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import Modal from '../components/Modal'
const ROLE_COLORS = [ const ROLE_COLORS = [
'#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6',
@@ -194,20 +195,34 @@ export default function Settings() {
function RolesSection({ roles, loadRoles, t, toast }) { function RolesSection({ roles, loadRoles, t, toast }) {
const [editingRole, setEditingRole] = useState(null) const [editingRole, setEditingRole] = useState(null)
const [newRole, setNewRole] = useState(null) const [showAddModal, setShowAddModal] = useState(false)
const [modalForm, setModalForm] = useState({ name: '', color: ROLE_COLORS[0] })
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const openAddModal = () => {
setModalForm({ name: '', color: ROLE_COLORS[roles.length % ROLE_COLORS.length] })
setShowAddModal(true)
}
const handleCreate = async () => {
setSaving(true)
try {
await api.post('/roles', { name: modalForm.name, color: modalForm.color })
await loadRoles()
setShowAddModal(false)
} catch (err) {
toast.error(err.message || t('common.error'))
} finally {
setSaving(false)
}
}
const handleSave = async (role) => { const handleSave = async (role) => {
setSaving(true) setSaving(true)
try { try {
if (role.Id || role.id) { await api.patch(`/roles/${role.Id || role.id}`, { name: role.name, color: role.color })
await api.patch(`/roles/${role.Id || role.id}`, { name: role.name, color: role.color })
} else {
await api.post('/roles', { name: role.name, color: role.color })
}
await loadRoles() await loadRoles()
setEditingRole(null) setEditingRole(null)
setNewRole(null)
} catch (err) { } catch (err) {
toast.error(err.message || t('common.error')) toast.error(err.message || t('common.error'))
} finally { } finally {
@@ -226,78 +241,90 @@ function RolesSection({ roles, loadRoles, t, toast }) {
} }
return ( return (
<div className="bg-white dark:bg-surface-primary rounded-xl border border-border overflow-hidden"> <>
<div className="px-6 py-4 border-b border-border flex items-center justify-between"> <div className="bg-white dark:bg-surface-primary rounded-xl border border-border overflow-hidden">
<h2 className="text-lg font-semibold text-text-primary flex items-center gap-2"> <div className="px-6 py-4 border-b border-border flex items-center justify-between">
<Tag className="w-5 h-5 text-brand-primary" /> <h2 className="text-lg font-semibold text-text-primary flex items-center gap-2">
{t('settings.roles')} <Tag className="w-5 h-5 text-brand-primary" />
</h2> {t('settings.roles')}
<button </h2>
onClick={() => setNewRole({ name: '', color: ROLE_COLORS[roles.length % ROLE_COLORS.length] })} <button
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors" onClick={openAddModal}
> className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
<Plus className="w-4 h-4" /> >
{t('settings.addRole')} <Plus className="w-4 h-4" />
</button> {t('settings.addRole')}
</div> </button>
<div className="p-6"> </div>
<p className="text-sm text-text-tertiary mb-4">{t('settings.rolesDesc')}</p> <div className="p-6">
<div className="space-y-2"> <p className="text-sm text-text-tertiary mb-4">{t('settings.rolesDesc')}</p>
{roles.map(role => ( <div className="space-y-2">
<div key={role.Id || role.id} className="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-surface-secondary transition-colors"> {roles.map(role => (
{editingRole?.Id === role.Id ? ( <div key={role.Id || role.id} className="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-surface-secondary transition-colors">
<RoleForm role={editingRole} onChange={setEditingRole} onSave={() => handleSave(editingRole)} onCancel={() => setEditingRole(null)} saving={saving} t={t} /> {editingRole && (editingRole.Id || editingRole.id) === (role.Id || role.id) ? (
) : ( <div className="flex items-center gap-3 flex-1">
<> <input type="color" value={editingRole.color || '#94A3B8'} onChange={e => setEditingRole({ ...editingRole, color: e.target.value })}
<div className="w-4 h-4 rounded-full shrink-0" style={{ backgroundColor: role.color || '#94A3B8' }} /> className="w-8 h-8 rounded-lg border border-border cursor-pointer" />
<span className="flex-1 text-sm font-medium text-text-primary">{role.name}</span> <input type="text" value={editingRole.name} onChange={e => setEditingRole({ ...editingRole, name: e.target.value })}
<button onClick={() => setEditingRole({ ...role })} className="p-1.5 text-text-tertiary hover:text-brand-primary rounded-lg hover:bg-surface-tertiary transition-colors"> placeholder={t('settings.roleName')} autoFocus
<Pencil className="w-4 h-4" /> className="flex-1 px-3 py-1.5 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" />
</button> <button onClick={() => handleSave(editingRole)} disabled={!editingRole.name || saving}
<button onClick={() => handleDelete(role)} className="p-1.5 text-text-tertiary hover:text-red-500 rounded-lg hover:bg-red-50 transition-colors"> className="px-3 py-1.5 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light disabled:opacity-50 transition-colors">
<Trash2 className="w-4 h-4" /> {saving ? '...' : t('common.save')}
</button> </button>
</> <button onClick={() => setEditingRole(null)} className="p-1.5 text-text-tertiary hover:text-text-primary rounded-lg hover:bg-surface-tertiary transition-colors">
)} <X className="w-4 h-4" />
</div> </button>
))} </div>
{newRole && ( ) : (
<div className="p-3 rounded-lg border-2 border-dashed border-brand-primary/30 bg-brand-primary/5"> <>
<RoleForm role={newRole} onChange={setNewRole} onSave={() => handleSave(newRole)} onCancel={() => setNewRole(null)} saving={saving} t={t} /> <div className="w-4 h-4 rounded-full shrink-0" style={{ backgroundColor: role.color || '#94A3B8' }} />
</div> <span className="flex-1 text-sm font-medium text-text-primary">{role.name}</span>
)} <button onClick={() => setEditingRole({ ...role })} className="p-1.5 text-text-tertiary hover:text-brand-primary rounded-lg hover:bg-surface-tertiary transition-colors">
{roles.length === 0 && !newRole && ( <Pencil className="w-4 h-4" />
<p className="text-sm text-text-tertiary text-center py-6">{t('settings.noRoles')}</p> </button>
)} <button onClick={() => handleDelete(role)} className="p-1.5 text-text-tertiary hover:text-red-500 rounded-lg hover:bg-red-50 transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</>
)}
</div>
))}
{roles.length === 0 && (
<p className="text-sm text-text-tertiary text-center py-6">{t('settings.noRoles')}</p>
)}
</div>
</div> </div>
</div> </div>
</div>
)
}
function RoleForm({ role, onChange, onSave, onCancel, saving, t }) { <Modal isOpen={showAddModal} onClose={() => setShowAddModal(false)} title={t('settings.addRole')} size="sm">
return ( <div className="space-y-4">
<div className="flex items-center gap-3 flex-1"> <div>
<input <label className="block text-xs font-medium text-text-tertiary mb-1">{t('settings.roleName')}</label>
type="color" <input type="text" value={modalForm.name} onChange={e => setModalForm(f => ({ ...f, name: e.target.value }))}
value={role.color || '#94A3B8'} 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"
onChange={e => onChange({ ...role, color: e.target.value })} placeholder={t('settings.roleName')} autoFocus />
className="w-8 h-8 rounded-lg border border-border cursor-pointer" </div>
/> <div>
<input <label className="block text-xs font-medium text-text-tertiary mb-1">{t('settings.roleColor') || 'Color'}</label>
type="text" <div className="flex items-center gap-3">
value={role.name} <input type="color" value={modalForm.color} onChange={e => setModalForm(f => ({ ...f, color: e.target.value }))}
onChange={e => onChange({ ...role, name: e.target.value })} className="w-10 h-10 rounded-lg border border-border cursor-pointer" />
placeholder={t('settings.roleName')} <div className="flex flex-wrap gap-1.5">
className="flex-1 px-3 py-1.5 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" {ROLE_COLORS.map(c => (
autoFocus <button key={c} type="button" onClick={() => setModalForm(f => ({ ...f, color: c }))}
/> className={`w-6 h-6 rounded-full border-2 transition-colors ${modalForm.color === c ? 'border-text-primary scale-110' : 'border-transparent'}`}
<button onClick={onSave} disabled={!role.name || saving} className="px-3 py-1.5 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light disabled:opacity-50 transition-colors"> style={{ backgroundColor: c }} />
{saving ? '...' : t('common.save')} ))}
</button> </div>
<button onClick={onCancel} className="p-1.5 text-text-tertiary hover:text-text-primary rounded-lg hover:bg-surface-tertiary transition-colors"> </div>
<X className="w-4 h-4" /> </div>
</button> <button onClick={handleCreate} disabled={!modalForm.name || saving}
</div> className={`w-full px-4 py-2.5 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' : ''}`}>
{t('settings.addRole')}
</button>
</div>
</Modal>
</>
) )
} }

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useContext } from 'react' import { useState, useEffect, useContext, useRef } from 'react'
import { Plus, Users, ArrowLeft, User as UserIcon, Edit2, LayoutGrid, Network, Link2 } from 'lucide-react' import { Plus, Users, ArrowLeft, User as UserIcon, Edit2, LayoutGrid, Network, Link2, ChevronDown, Check, X } from 'lucide-react'
import { getInitials } from '../utils/api' import { getInitials } from '../utils/api'
import { AppContext } from '../App' import { AppContext, PERMISSION_LEVELS } from '../App'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext' import { useLanguage } from '../i18n/LanguageContext'
import { api } from '../utils/api' import { api } from '../utils/api'
@@ -10,12 +10,26 @@ import StatusBadge from '../components/StatusBadge'
import BrandBadge from '../components/BrandBadge' import BrandBadge from '../components/BrandBadge'
import TeamMemberPanel from '../components/TeamMemberPanel' import TeamMemberPanel from '../components/TeamMemberPanel'
import TeamPanel from '../components/TeamPanel' import TeamPanel from '../components/TeamPanel'
import Modal from '../components/Modal'
import { useToast } from '../components/ToastContainer' import { useToast } from '../components/ToastContainer'
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' },
}
const EMPTY_MEMBER = {
name: '', email: '', password: '', permission_level: 'contributor',
role_id: '', brands: [], phone: '', modules: [...ALL_MODULES], team_ids: [],
}
export default function Team() { export default function Team() {
const { t } = useLanguage() const { t, lang } = useLanguage()
const toast = useToast() const toast = useToast()
const { teamMembers, loadTeam, currentUser, teams, loadTeams, brands } = useContext(AppContext) const { teamMembers, loadTeam, currentUser, teams, loadTeams, brands, roles } = useContext(AppContext)
const { user } = useAuth() const { user } = useAuth()
const [panelMember, setPanelMember] = useState(null) const [panelMember, setPanelMember] = useState(null)
const [panelIsEditingSelf, setPanelIsEditingSelf] = useState(false) const [panelIsEditingSelf, setPanelIsEditingSelf] = useState(false)
@@ -27,6 +41,15 @@ export default function Team() {
const [teamFilter, setTeamFilter] = useState(null) const [teamFilter, setTeamFilter] = useState(null)
const [viewMode, setViewMode] = useState('grid') // 'grid' | 'teams' const [viewMode, setViewMode] = useState('grid') // 'grid' | 'teams'
// Add member modal state
const [showAddModal, setShowAddModal] = useState(false)
const [addForm, setAddForm] = useState({ ...EMPTY_MEMBER })
const [addConfirmPassword, setAddConfirmPassword] = useState('')
const [addPasswordError, setAddPasswordError] = useState('')
const [addSaving, setAddSaving] = useState(false)
const [showAddBrandsDropdown, setShowAddBrandsDropdown] = useState(false)
const addBrandsRef = useRef(null)
const canManageTeam = user?.role === 'superadmin' || user?.role === 'manager' const canManageTeam = user?.role === 'superadmin' || user?.role === 'manager'
const copyIssueLink = (teamId) => { const copyIssueLink = (teamId) => {
@@ -36,9 +59,68 @@ export default function Team() {
toast.success(t('issues.linkCopied')) toast.success(t('issues.linkCopied'))
} }
// Close brands dropdown on outside click
useEffect(() => {
const handler = (e) => {
if (addBrandsRef.current && !addBrandsRef.current.contains(e.target)) setShowAddBrandsDropdown(false)
}
if (showAddBrandsDropdown) document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [showAddBrandsDropdown])
const openNew = () => { const openNew = () => {
setPanelMember({ role: 'content_writer' }) setAddForm({ ...EMPTY_MEMBER })
setPanelIsEditingSelf(false) setAddConfirmPassword('')
setAddPasswordError('')
setShowAddModal(true)
}
const handleAddMember = async () => {
setAddPasswordError('')
if (addForm.password && addForm.password !== addConfirmPassword) {
setAddPasswordError(t('team.passwordsDoNotMatch'))
return
}
setAddSaving(true)
try {
const payload = {
name: addForm.name,
email: addForm.email,
role: addForm.permission_level,
role_id: addForm.role_id || null,
brands: addForm.brands,
phone: addForm.phone,
modules: addForm.modules,
}
if (addForm.password) payload.password = addForm.password
const created = await api.post('/users/team', payload)
const memberId = created?.id || created?.Id
// Sync team memberships
if (addForm.team_ids.length > 0 && memberId) {
for (const teamId of addForm.team_ids) {
await api.post(`/teams/${teamId}/members`, { user_id: memberId })
}
}
await loadTeam()
await loadTeams()
setShowAddModal(false)
toast.success(t('team.memberAdded') || 'Member added')
} catch (err) {
console.error('Add member failed:', err)
toast.error(err.message || t('common.failedToSave'))
} finally {
setAddSaving(false)
}
}
const updateAdd = (field, value) => setAddForm(f => ({ ...f, [field]: value }))
const toggleAddBrand = (name) => {
setAddForm(f => ({
...f,
brands: f.brands.includes(name) ? f.brands.filter(b => b !== name) : [...f.brands, name],
}))
} }
const openEdit = (member) => { const openEdit = (member) => {
@@ -562,7 +644,150 @@ export default function Team() {
</div> </div>
)} )}
{/* Team Member Panel */} {/* Add Member Modal */}
<Modal isOpen={showAddModal} onClose={() => setShowAddModal(false)} title={t('team.addMember')} size="md">
<div className="space-y-4">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.fullName')} *</label>
<input type="text" value={addForm.name} onChange={e => updateAdd('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>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.email')} *</label>
<input type="email" value={addForm.email} onChange={e => updateAdd('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" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.password')}</label>
<input type="password" value={addForm.password} onChange={e => updateAdd('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="••••••••" />
{!addForm.password && <p className="text-xs text-text-tertiary mt-1">{t('team.defaultPassword')}</p>}
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.confirmPassword')}</label>
<input type="password" value={addConfirmPassword}
onChange={e => { setAddConfirmPassword(e.target.value); setAddPasswordError('') }}
disabled={!addForm.password}
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 disabled:opacity-50" placeholder="••••••••" />
{addPasswordError && <p className="text-xs text-red-500 mt-1">{addPasswordError}</p>}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
{user?.role === 'superadmin' && (
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.permissionLevel')}</label>
<select value={addForm.permission_level} onChange={e => updateAdd('permission_level', 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">
{PERMISSION_LEVELS.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
</select>
</div>
)}
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.role')}</label>
<select value={addForm.role_id || ''} onChange={e => updateAdd('role_id', e.target.value ? Number(e.target.value) : null)}
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">
<option value="">{t('team.selectRole')}</option>
{roles.map(r => <option key={r.Id || r.id} value={r.Id || r.id}>{r.name}</option>)}
</select>
</div>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.phone')}</label>
<input type="text" value={addForm.phone} onChange={e => updateAdd('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>
{/* Brands multi-select */}
<div ref={addBrandsRef} className="relative">
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.brands')}</label>
<button type="button" onClick={() => setShowAddBrandsDropdown(p => !p)}
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-border rounded-lg bg-white text-left focus:outline-none focus:ring-2 focus:ring-brand-primary/20">
<span className={`flex-1 truncate ${addForm.brands.length === 0 ? 'text-text-tertiary' : 'text-text-primary'}`}>
{addForm.brands.length === 0 ? t('team.selectBrands') : addForm.brands.join(', ')}
</span>
<ChevronDown className={`w-4 h-4 text-text-tertiary shrink-0 transition-transform ${showAddBrandsDropdown ? 'rotate-180' : ''}`} />
</button>
{addForm.brands.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{addForm.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={() => toggleAddBrand(b)} className="hover:text-red-500"><X className="w-2.5 h-2.5" /></button>
</span>
))}
</div>
)}
{showAddBrandsDropdown && (
<div className="absolute z-20 mt-1 w-full bg-white border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
{brands.map(brand => {
const name = lang === 'ar' && brand.name_ar ? brand.name_ar : brand.name
const checked = addForm.brands.includes(name)
return (
<button key={brand.id || brand._id} type="button" onClick={() => toggleAddBrand(name)}
className={`w-full flex items-center gap-2.5 px-3 py-2 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 ${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>
)
})}
</div>
)}
</div>
{/* Modules */}
<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 = addForm.modules.includes(mod)
const colors = MODULE_COLORS[mod]
return (
<button key={mod} type="button"
onClick={() => updateAdd('modules', active ? addForm.modules.filter(m => m !== mod) : [...addForm.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 */}
{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 tid = team.id || team._id
const active = addForm.team_ids.includes(tid)
return (
<button key={tid} type="button"
onClick={() => updateAdd('team_ids', active ? addForm.team_ids.filter(id => id !== tid) : [...addForm.team_ids, tid])}
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>
)}
<button onClick={handleAddMember}
disabled={!addForm.name || !addForm.email || addSaving}
className={`w-full px-4 py-2.5 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 ${addSaving ? 'btn-loading' : ''}`}>
{t('team.addMember')}
</button>
</div>
</Modal>
{/* Team Member Panel (edit only) */}
{panelMember && ( {panelMember && (
<TeamMemberPanel <TeamMemberPanel
member={panelMember} member={panelMember}