diff --git a/client/src/components/TeamMemberPanel.jsx b/client/src/components/TeamMemberPanel.jsx index 97a0ac1..b6866da 100644 --- a/client/src/components/TeamMemberPanel.jsx +++ b/client/src/components/TeamMemberPanel.jsx @@ -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
{!isEditingSelf && ( - <> -
- - 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} - /> -
- - {isCreateMode && ( -
- - 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 && ( -

{t('team.defaultPassword')}

- )} -
- )} - - {isCreateMode && form.password && ( -
- - { 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 && ( -

{passwordError}

- )} -
- )} - +
+ + +
)}
@@ -405,13 +358,13 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave {dirty && ( )} - {!isCreateMode && !isEditingSelf && canManageTeam && onDelete && ( + {!isEditingSelf && canManageTeam && onDelete && (
- {/* Workload Section (hidden in create mode) */} - {!isCreateMode && ( - + {/* Workload Section */} +
{/* Stats */}
@@ -485,7 +437,6 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave )}
- )} { + 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 api.patch(`/roles/${role.Id || role.id}`, { name: role.name, color: role.color }) await loadRoles() setEditingRole(null) - setNewRole(null) } catch (err) { toast.error(err.message || t('common.error')) } finally { @@ -226,78 +241,90 @@ function RolesSection({ roles, loadRoles, t, toast }) { } return ( -
-
-

- - {t('settings.roles')} -

- -
-
-

{t('settings.rolesDesc')}

-
- {roles.map(role => ( -
- {editingRole?.Id === role.Id ? ( - handleSave(editingRole)} onCancel={() => setEditingRole(null)} saving={saving} t={t} /> - ) : ( - <> -
- {role.name} - - - - )} -
- ))} - {newRole && ( -
- handleSave(newRole)} onCancel={() => setNewRole(null)} saving={saving} t={t} /> -
- )} - {roles.length === 0 && !newRole && ( -

{t('settings.noRoles')}

- )} + <> +
+
+

+ + {t('settings.roles')} +

+ +
+
+

{t('settings.rolesDesc')}

+
+ {roles.map(role => ( +
+ {editingRole && (editingRole.Id || editingRole.id) === (role.Id || role.id) ? ( +
+ setEditingRole({ ...editingRole, color: e.target.value })} + className="w-8 h-8 rounded-lg border border-border cursor-pointer" /> + 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" /> + + +
+ ) : ( + <> +
+ {role.name} + + + + )} +
+ ))} + {roles.length === 0 && ( +

{t('settings.noRoles')}

+ )} +
-
- ) -} -function RoleForm({ role, onChange, onSave, onCancel, saving, t }) { - return ( -
- onChange({ ...role, color: e.target.value })} - className="w-8 h-8 rounded-lg border border-border cursor-pointer" - /> - 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 - /> - - -
+ setShowAddModal(false)} title={t('settings.addRole')} size="sm"> +
+
+ + 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 /> +
+
+ +
+ setModalForm(f => ({ ...f, color: e.target.value }))} + className="w-10 h-10 rounded-lg border border-border cursor-pointer" /> +
+ {ROLE_COLORS.map(c => ( +
+
+
+ +
+
+ ) } diff --git a/client/src/pages/Team.jsx b/client/src/pages/Team.jsx index 05fcd2c..fda2306 100644 --- a/client/src/pages/Team.jsx +++ b/client/src/pages/Team.jsx @@ -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() {
)} - {/* Team Member Panel */} + {/* Add Member Modal */} + setShowAddModal(false)} title={t('team.addMember')} size="md"> +
+
+ + 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')} /> +
+ +
+ + 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" /> +
+ +
+
+ + 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 &&

{t('team.defaultPassword')}

} +
+
+ + { 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 &&

{addPasswordError}

} +
+
+ +
+ {user?.role === 'superadmin' && ( +
+ + +
+ )} +
+ + +
+
+ +
+ + 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 ..." /> +
+ + {/* Brands multi-select */} +
+ + + {addForm.brands.length > 0 && ( +
+ {addForm.brands.map(b => ( + + {b} + + + ))} +
+ )} + {showAddBrandsDropdown && ( +
+ {brands.map(brand => { + const name = lang === 'ar' && brand.name_ar ? brand.name_ar : brand.name + const checked = addForm.brands.includes(name) + return ( + + ) + })} +
+ )} +
+ + {/* Modules */} +
+ +
+ {ALL_MODULES.map(mod => { + const active = addForm.modules.includes(mod) + const colors = MODULE_COLORS[mod] + return ( + + ) + })} +
+
+ + {/* Teams */} + {teams.length > 0 && ( +
+ +
+ {teams.map(team => { + const tid = team.id || team._id + const active = addForm.team_ids.includes(tid) + return ( + + ) + })} +
+
+ )} + + +
+
+ + {/* Team Member Panel (edit only) */} {panelMember && (