feat: use modals for creation, side panels for editing
All checks were successful
Deploy / deploy (push) Successful in 11s
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:
@@ -24,8 +24,6 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [showBrandsDropdown, setShowBrandsDropdown] = useState(false)
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [passwordError, setPasswordError] = useState('')
|
||||
const brandsDropdownRef = useRef(null)
|
||||
|
||||
// Workload state (loaded internally)
|
||||
@@ -34,7 +32,6 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
||||
const [loadingWorkload, setLoadingWorkload] = useState(false)
|
||||
|
||||
const memberId = member?._id || member?.id
|
||||
const isCreateMode = !memberId
|
||||
|
||||
useEffect(() => {
|
||||
if (member) {
|
||||
@@ -49,10 +46,8 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
||||
modules: Array.isArray(member.modules) ? member.modules : ALL_MODULES,
|
||||
team_ids: Array.isArray(member.teams) ? member.teams.map(t => t.id) : [],
|
||||
})
|
||||
setDirty(isCreateMode)
|
||||
setConfirmPassword('')
|
||||
setPasswordError('')
|
||||
if (!isCreateMode) loadWorkload()
|
||||
setDirty(false)
|
||||
if (memberId) loadWorkload()
|
||||
}
|
||||
}, [member])
|
||||
|
||||
@@ -101,14 +96,9 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setPasswordError('')
|
||||
if (isCreateMode && form.password && form.password !== confirmPassword) {
|
||||
setPasswordError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSave(isCreateMode ? null : memberId, {
|
||||
await onSave(memberId, {
|
||||
name: form.name,
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
@@ -120,7 +110,6 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
||||
team_ids: form.team_ids,
|
||||
}, isEditingSelf)
|
||||
setDirty(false)
|
||||
if (isCreateMode) onClose()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -176,51 +165,15 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
||||
<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>
|
||||
<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}
|
||||
disabled
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface-tertiary text-text-tertiary cursor-not-allowed"
|
||||
/>
|
||||
</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">
|
||||
@@ -405,13 +358,13 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
||||
{dirty && (
|
||||
<button
|
||||
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' : ''}`}
|
||||
>
|
||||
{isEditingSelf ? t('team.saveProfile') : (isCreateMode ? t('team.addMember') : t('team.saveChanges'))}
|
||||
{isEditingSelf ? t('team.saveProfile') : t('team.saveChanges')}
|
||||
</button>
|
||||
)}
|
||||
{!isCreateMode && !isEditingSelf && canManageTeam && onDelete && (
|
||||
{!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"
|
||||
@@ -424,8 +377,7 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Workload Section (hidden in create mode) */}
|
||||
{!isCreateMode && (
|
||||
{/* Workload Section */}
|
||||
<CollapsibleSection title={t('team.workload')} noBorder>
|
||||
<div className="px-5 pb-4 space-y-3">
|
||||
{/* Stats */}
|
||||
@@ -485,7 +437,6 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
</SlidePanel>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -209,6 +209,7 @@
|
||||
"team.title": "الفريق",
|
||||
"team.members": "أعضاء الفريق",
|
||||
"team.addMember": "إضافة عضو",
|
||||
"team.memberAdded": "تمت إضافة العضو بنجاح",
|
||||
"team.newMember": "عضو جديد",
|
||||
"team.editMember": "تعديل العضو",
|
||||
"team.myProfile": "ملفي الشخصي",
|
||||
@@ -231,6 +232,8 @@
|
||||
"team.membersPlural": "أعضاء فريق",
|
||||
"team.fullName": "الاسم الكامل",
|
||||
"team.defaultPassword": "افتراضياً: changeme123",
|
||||
"team.confirmPassword": "تأكيد كلمة المرور",
|
||||
"team.passwordsDoNotMatch": "كلمتا المرور غير متطابقتين",
|
||||
"team.optional": "(اختياري)",
|
||||
"team.fixedRole": "دور ثابت للمديرين",
|
||||
"team.remove": "إزالة",
|
||||
@@ -673,6 +676,7 @@
|
||||
"settings.rolesDesc": "حدد أدوار العمل مثل مصمم، استراتيجي، إلخ. يتم تعيينها لأعضاء الفريق بشكل منفصل عن مستويات الصلاحية.",
|
||||
"settings.addRole": "إضافة دور",
|
||||
"settings.roleName": "اسم الدور",
|
||||
"settings.roleColor": "اللون",
|
||||
"settings.deleteRoleConfirm": "هل أنت متأكد من حذف هذا الدور؟",
|
||||
"settings.noRoles": "لم يتم تحديد أدوار بعد. أضف أول دور."
|
||||
}
|
||||
@@ -209,6 +209,7 @@
|
||||
"team.title": "Team",
|
||||
"team.members": "Team Members",
|
||||
"team.addMember": "Add Member",
|
||||
"team.memberAdded": "Member added successfully",
|
||||
"team.newMember": "New Team Member",
|
||||
"team.editMember": "Edit Team Member",
|
||||
"team.myProfile": "My Profile",
|
||||
@@ -231,6 +232,8 @@
|
||||
"team.membersPlural": "team members",
|
||||
"team.fullName": "Full name",
|
||||
"team.defaultPassword": "Default: changeme123",
|
||||
"team.confirmPassword": "Confirm Password",
|
||||
"team.passwordsDoNotMatch": "Passwords do not match",
|
||||
"team.optional": "(optional)",
|
||||
"team.fixedRole": "Fixed role for managers",
|
||||
"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.addRole": "Add Role",
|
||||
"settings.roleName": "Role name",
|
||||
"settings.roleColor": "Color",
|
||||
"settings.deleteRoleConfirm": "Are you sure you want to delete this role?",
|
||||
"settings.noRoles": "No roles defined yet. Add your first role."
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useToast } from '../components/ToastContainer'
|
||||
import { CURRENCIES } from '../i18n/LanguageContext'
|
||||
import { AppContext } from '../App'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import Modal from '../components/Modal'
|
||||
|
||||
const ROLE_COLORS = [
|
||||
'#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6',
|
||||
@@ -194,20 +195,34 @@ export default function Settings() {
|
||||
|
||||
function RolesSection({ roles, loadRoles, t, toast }) {
|
||||
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 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) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
if (role.Id || role.id) {
|
||||
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()
|
||||
setEditingRole(null)
|
||||
setNewRole(null)
|
||||
} catch (err) {
|
||||
toast.error(err.message || t('common.error'))
|
||||
} finally {
|
||||
@@ -226,6 +241,7 @@ function RolesSection({ roles, loadRoles, t, toast }) {
|
||||
}
|
||||
|
||||
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">
|
||||
<h2 className="text-lg font-semibold text-text-primary flex items-center gap-2">
|
||||
@@ -233,7 +249,7 @@ function RolesSection({ roles, loadRoles, t, toast }) {
|
||||
{t('settings.roles')}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setNewRole({ name: '', color: ROLE_COLORS[roles.length % ROLE_COLORS.length] })}
|
||||
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" />
|
||||
@@ -245,8 +261,21 @@ function RolesSection({ roles, loadRoles, t, toast }) {
|
||||
<div className="space-y-2">
|
||||
{roles.map(role => (
|
||||
<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">
|
||||
{editingRole?.Id === role.Id ? (
|
||||
<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 })}
|
||||
className="w-8 h-8 rounded-lg border border-border cursor-pointer" />
|
||||
<input type="text" value={editingRole.name} onChange={e => setEditingRole({ ...editingRole, name: e.target.value })}
|
||||
placeholder={t('settings.roleName')} autoFocus
|
||||
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 onClick={() => handleSave(editingRole)} disabled={!editingRole.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">
|
||||
{saving ? '...' : t('common.save')}
|
||||
</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" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-4 h-4 rounded-full shrink-0" style={{ backgroundColor: role.color || '#94A3B8' }} />
|
||||
@@ -261,43 +290,41 @@ function RolesSection({ roles, loadRoles, t, toast }) {
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
{roles.length === 0 && !newRole && (
|
||||
{roles.length === 0 && (
|
||||
<p className="text-sm text-text-tertiary text-center py-6">{t('settings.noRoles')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RoleForm({ role, onChange, onSave, onCancel, saving, t }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<input
|
||||
type="color"
|
||||
value={role.color || '#94A3B8'}
|
||||
onChange={e => onChange({ ...role, color: e.target.value })}
|
||||
className="w-8 h-8 rounded-lg border border-border cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={role.name}
|
||||
onChange={e => onChange({ ...role, name: e.target.value })}
|
||||
placeholder={t('settings.roleName')}
|
||||
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"
|
||||
autoFocus
|
||||
/>
|
||||
<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">
|
||||
{saving ? '...' : t('common.save')}
|
||||
</button>
|
||||
<button onClick={onCancel} 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" />
|
||||
<Modal isOpen={showAddModal} onClose={() => setShowAddModal(false)} title={t('settings.addRole')} size="sm">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('settings.roleName')}</label>
|
||||
<input type="text" value={modalForm.name} onChange={e => setModalForm(f => ({ ...f, 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('settings.roleName')} autoFocus />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('settings.roleColor') || 'Color'}</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="color" value={modalForm.color} onChange={e => setModalForm(f => ({ ...f, color: e.target.value }))}
|
||||
className="w-10 h-10 rounded-lg border border-border cursor-pointer" />
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ROLE_COLORS.map(c => (
|
||||
<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'}`}
|
||||
style={{ backgroundColor: c }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleCreate} disabled={!modalForm.name || saving}
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { Plus, Users, ArrowLeft, User as UserIcon, Edit2, LayoutGrid, Network, Link2 } from 'lucide-react'
|
||||
import { useState, useEffect, useContext, useRef } from 'react'
|
||||
import { Plus, Users, ArrowLeft, User as UserIcon, Edit2, LayoutGrid, Network, Link2, ChevronDown, Check, X } from 'lucide-react'
|
||||
import { getInitials } from '../utils/api'
|
||||
import { AppContext } from '../App'
|
||||
import { AppContext, PERMISSION_LEVELS } from '../App'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api } from '../utils/api'
|
||||
@@ -10,12 +10,26 @@ import StatusBadge from '../components/StatusBadge'
|
||||
import BrandBadge from '../components/BrandBadge'
|
||||
import TeamMemberPanel from '../components/TeamMemberPanel'
|
||||
import TeamPanel from '../components/TeamPanel'
|
||||
import Modal from '../components/Modal'
|
||||
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() {
|
||||
const { t } = useLanguage()
|
||||
const { t, lang } = useLanguage()
|
||||
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 [panelMember, setPanelMember] = useState(null)
|
||||
const [panelIsEditingSelf, setPanelIsEditingSelf] = useState(false)
|
||||
@@ -27,6 +41,15 @@ export default function Team() {
|
||||
const [teamFilter, setTeamFilter] = useState(null)
|
||||
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 copyIssueLink = (teamId) => {
|
||||
@@ -36,9 +59,68 @@ export default function Team() {
|
||||
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 = () => {
|
||||
setPanelMember({ role: 'content_writer' })
|
||||
setPanelIsEditingSelf(false)
|
||||
setAddForm({ ...EMPTY_MEMBER })
|
||||
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) => {
|
||||
@@ -562,7 +644,150 @@ export default function Team() {
|
||||
</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 && (
|
||||
<TeamMemberPanel
|
||||
member={panelMember}
|
||||
|
||||
Reference in New Issue
Block a user