feat: team-based visibility, roles management, unified users, UI fixes
All checks were successful
Deploy / deploy (push) Successful in 12s
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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user