feat: team-based visibility, roles management, unified users, UI fixes
All checks were successful
Deploy / deploy (push) Successful in 12s

- Add Roles table with CRUD routes and Settings page management
- Unify user management: remove Users page, enhance Team page with
  permission level + role dropdowns
- Add team-based visibility scoping to projects, campaigns, posts,
  tasks, issues, artefacts, and dashboard
- Add team_id to projects and campaigns (create + edit forms)
- Add getUserTeamIds/getUserVisibilityContext helpers
- Fix Budgets modal horizontal scroll (separate linked-to row)
- Add collapsible filter bar to PostProduction page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
fahed
2026-03-04 15:55:15 +03:00
parent 7c6e8dce08
commit da161014af
14 changed files with 655 additions and 308 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useContext } from 'react'
import { X, Trash2, DollarSign, Eye, MousePointer, Target } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import { PLATFORMS, getBrandColor } from '../utils/api'
@@ -7,9 +7,11 @@ import Modal from './Modal'
import SlidePanel from './SlidePanel'
import CollapsibleSection from './CollapsibleSection'
import BudgetBar from './BudgetBar'
import { AppContext } from '../App'
export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelete, brands, permissions }) {
const { t, lang, currencySymbol } = useLanguage()
const { teams } = useContext(AppContext)
const [form, setForm] = useState({})
const [dirty, setDirty] = useState(false)
const [saving, setSaving] = useState(false)
@@ -24,6 +26,7 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
name: campaign.name || '',
description: campaign.description || '',
brand_id: campaign.brandId || campaign.brand_id || '',
team_id: campaign.team_id || '',
status: campaign.status || 'planning',
start_date: campaign.startDate ? new Date(campaign.startDate).toISOString().slice(0, 10) : (campaign.start_date || ''),
end_date: campaign.endDate ? new Date(campaign.endDate).toISOString().slice(0, 10) : (campaign.end_date || ''),
@@ -63,6 +66,7 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
name: form.name,
description: form.description,
brand_id: form.brand_id ? Number(form.brand_id) : null,
team_id: form.team_id ? Number(form.team_id) : null,
status: form.status,
start_date: form.start_date,
end_date: form.end_date,
@@ -177,6 +181,19 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
</div>
</div>
{/* Team */}
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('common.team')}</label>
<select
value={form.team_id}
onChange={e => update('team_id', 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"
>
<option value="">{t('common.noTeam')}</option>
{(teams || []).map(t => <option key={t.id || t._id} value={t.id || t._id}>{t.name}</option>)}
</select>
</div>
{/* Platforms */}
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.platforms')}</label>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useRef, useContext } from 'react'
import { X, Trash2, Upload } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import { api, getBrandColor } from '../utils/api'
@@ -6,8 +6,10 @@ import CommentsSection from './CommentsSection'
import Modal from './Modal'
import SlidePanel from './SlidePanel'
import CollapsibleSection from './CollapsibleSection'
import { AppContext } from '../App'
export default function ProjectEditPanel({ project, onClose, onSave, onDelete, brands, teamMembers }) {
const { teams } = useContext(AppContext)
const { t, lang } = useLanguage()
const thumbnailInputRef = useRef(null)
const [form, setForm] = useState({})
@@ -26,6 +28,7 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
description: project.description || '',
brand_id: project.brandId || project.brand_id || '',
owner_id: project.ownerId || project.owner_id || '',
team_id: project.team_id || '',
status: project.status || 'active',
start_date: project.startDate || project.start_date ? new Date(project.startDate || project.start_date).toISOString().slice(0, 10) : '',
due_date: project.dueDate ? new Date(project.dueDate).toISOString().slice(0, 10) : '',
@@ -54,6 +57,7 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
description: form.description,
brand_id: form.brand_id ? Number(form.brand_id) : null,
owner_id: form.owner_id ? Number(form.owner_id) : null,
team_id: form.team_id ? Number(form.team_id) : null,
status: form.status,
start_date: form.start_date || null,
due_date: form.due_date || null,
@@ -195,16 +199,28 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
</select>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.startDate')}</label>
<input
type="date"
value={form.start_date}
onChange={e => update('start_date', e.target.value)}
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('common.team')}</label>
<select
value={form.team_id}
onChange={e => update('team_id', 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"
/>
>
<option value="">{t('common.noTeam')}</option>
{(teams || []).map(t => <option key={t.id || t._id} value={t.id || t._id}>{t.name}</option>)}
</select>
</div>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.startDate')}</label>
<input
type="date"
value={form.start_date}
onChange={e => update('start_date', 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"
/>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.dueDate')}</label>
<input

View File

@@ -3,7 +3,7 @@ import { NavLink } from 'react-router-dom'
import {
LayoutDashboard, FileEdit, Image, Calendar, Wallet,
FolderKanban, CheckSquare, Users, ChevronLeft, ChevronRight, ChevronDown,
Sparkles, Shield, LogOut, User, Settings, Languages, Tag, LayoutList, Receipt, BarChart3, Palette, CalendarDays, AlertCircle
Sparkles, LogOut, User, Settings, Languages, Tag, LayoutList, Receipt, BarChart3, Palette, CalendarDays, AlertCircle
} from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
@@ -167,23 +167,6 @@ export default function Sidebar({ collapsed, setCollapsed }) {
{standaloneBottom.map(item => navLink(item))}
</div>
{/* Superadmin Only: Users Management */}
{currentUser?.role === 'superadmin' && (
<NavLink
to="/users"
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 group ${
isActive
? 'bg-white/15 text-white shadow-sm'
: 'text-text-on-dark-muted hover:bg-white/8 hover:text-white'
}`
}
>
<Shield className="w-5 h-5 shrink-0" />
{!collapsed && <span className="animate-fade-in whitespace-nowrap">{t('nav.users')}</span>}
</NavLink>
)}
{/* Settings (visible to all) */}
<NavLink
to="/settings"

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useRef, useContext } from 'react'
import { X, Trash2, ChevronDown, Check } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import { api } from '../utils/api'
@@ -6,20 +6,7 @@ import Modal from './Modal'
import SlidePanel from './SlidePanel'
import CollapsibleSection from './CollapsibleSection'
import StatusBadge from './StatusBadge'
const ROLES = [
{ value: 'manager', label: 'Manager' },
{ value: 'approver', label: 'Approver' },
{ value: 'publisher', label: 'Publisher' },
{ value: 'content_creator', label: 'Content Creator' },
{ value: 'producer', label: 'Producer' },
{ value: 'designer', label: 'Designer' },
{ value: 'content_writer', label: 'Content Writer' },
{ value: 'social_media_manager', label: 'Social Media Manager' },
{ value: 'photographer', label: 'Photographer' },
{ value: 'videographer', label: 'Videographer' },
{ value: 'strategist', label: 'Strategist' },
]
import { AppContext, PERMISSION_LEVELS } from '../App'
const ALL_MODULES = ['marketing', 'projects', 'finance']
const MODULE_LABELS = { marketing: 'Marketing', projects: 'Projects', finance: 'Finance' }
@@ -31,6 +18,7 @@ const MODULE_COLORS = {
export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave, onDelete, canManageTeam, userRole, teams, brands: brandsList }) {
const { t, lang } = useLanguage()
const { roles } = useContext(AppContext)
const [form, setForm] = useState({})
const [dirty, setDirty] = useState(false)
const [saving, setSaving] = useState(false)
@@ -54,7 +42,8 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
name: member.name || '',
email: member.email || '',
password: '',
role: member.team_role || member.role || 'content_writer',
permission_level: member.role || 'contributor',
role_id: member.role_id || '',
brands: Array.isArray(member.brands) ? member.brands : [],
phone: member.phone || '',
modules: Array.isArray(member.modules) ? member.modules : ALL_MODULES,
@@ -123,7 +112,8 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
name: form.name,
email: form.email,
password: form.password,
role: form.role,
role: form.permission_level,
role_id: form.role_id || null,
brands: form.brands || [],
phone: form.phone,
modules: form.modules,
@@ -143,7 +133,8 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
}
const initials = member.name?.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase() || '?'
const roleName = (form.role || '').replace(/_/g, ' ')
const currentRole = roles.find(r => (r.Id || r.id) === form.role_id)
const roleName = currentRole?.name || member.role_name || member.team_role || ''
const todoCount = memberTasks.filter(t => t.status === 'todo').length
const inProgressCount = memberTasks.filter(t => t.status === 'in_progress').length
const doneCount = memberTasks.filter(t => t.status === 'done').length
@@ -233,35 +224,42 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
)}
<div className="grid grid-cols-2 gap-3">
{/* Permission Level (superadmin only) */}
{userRole === 'superadmin' && !isEditingSelf && (
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.permissionLevel')}</label>
<select
value={form.permission_level}
onChange={e => update('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>
)}
{/* Role (from Roles table) */}
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.teamRole')}</label>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.role')}</label>
{isEditingSelf ? (
<input
type="text"
value={ROLES.find(r => r.value === form.role)?.label || form.role || '—'}
value={roleName || '—'}
disabled
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface-tertiary text-text-tertiary cursor-not-allowed"
/>
) : userRole === 'manager' && isCreateMode ? (
<>
<input
type="text"
value="Contributor"
disabled
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface-tertiary text-text-tertiary cursor-not-allowed"
/>
<p className="text-xs text-text-tertiary mt-1">{t('team.fixedRole')}</p>
</>
) : (
<select
value={form.role}
onChange={e => update('role', e.target.value)}
value={form.role_id || ''}
onChange={e => update('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"
>
{ROLES.map(r => <option key={r.value} value={r.value}>{r.label}</option>)}
<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>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.phone')}</label>
<input