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 [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
|
||||||
|
|||||||
@@ -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": "لم يتم تحديد أدوار بعد. أضف أول دور."
|
||||||
}
|
}
|
||||||
@@ -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."
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user