Campaign assignments, ownership-based editing, and role-scoped data
- Add campaign_assignments table for user-to-campaign mapping - Superadmin/managers can assign users to campaigns; visibility filtered by assignment/ownership - Managers can only manage (tracks, assignments) on campaigns they created - Budget controlled by superadmin only, with proper modal UI for editing - Ownership-based editing for campaigns, projects, comments (creators can edit their own) - Role-scoped dashboard and finance data (managers see only their campaigns' data) - Manager's budget derived from sum of their campaign budgets set by superadmin - Hide UI features users cannot use (principle of least privilege across all pages) - Fix profile completion prompt persisting after saving (login response now includes profileComplete) - Add post detail modal in campaign detail with thumbnails, publication links, and metadata - Add comment inline editing for comment authors - Move financial summary cards below filters on Campaigns page Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { ArrowLeft, Plus, Edit2, Trash2, DollarSign, Eye, MousePointer, Target, TrendingUp, FileText, Megaphone, Search, Globe, Pencil } from 'lucide-react'
|
||||
import { ArrowLeft, Plus, Edit2, Trash2, DollarSign, Eye, MousePointer, Target, TrendingUp, FileText, Megaphone, Search, Globe, Pencil, Users, X, UserPlus } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
import { AppContext } from '../App'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { api, PLATFORMS } from '../utils/api'
|
||||
import { api, PLATFORMS, getInitials } from '../utils/api'
|
||||
import PlatformIcon, { PlatformIcons } from '../components/PlatformIcon'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import BrandBadge from '../components/BrandBadge'
|
||||
@@ -44,12 +44,19 @@ export default function CampaignDetail() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { brands } = useContext(AppContext)
|
||||
const { permissions } = useAuth()
|
||||
const canManage = permissions?.canEditCampaigns
|
||||
const { permissions, user } = useAuth()
|
||||
const isSuperadmin = user?.role === 'superadmin'
|
||||
const [campaign, setCampaign] = useState(null)
|
||||
const [tracks, setTracks] = useState([])
|
||||
const [posts, setPosts] = useState([])
|
||||
const [assignments, setAssignments] = useState([])
|
||||
const [allUsers, setAllUsers] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showAssignModal, setShowAssignModal] = useState(false)
|
||||
const [selectedUserIds, setSelectedUserIds] = useState([])
|
||||
const canSetBudget = permissions?.canSetBudget
|
||||
const [editingBudget, setEditingBudget] = useState(false)
|
||||
const [budgetValue, setBudgetValue] = useState('')
|
||||
const [showTrackModal, setShowTrackModal] = useState(false)
|
||||
const [editingTrack, setEditingTrack] = useState(null)
|
||||
const [trackForm, setTrackForm] = useState(EMPTY_TRACK)
|
||||
@@ -58,21 +65,26 @@ export default function CampaignDetail() {
|
||||
const [metricsForm, setMetricsForm] = useState(EMPTY_METRICS)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [trackToDelete, setTrackToDelete] = useState(null)
|
||||
const [selectedPost, setSelectedPost] = useState(null)
|
||||
|
||||
const isCreator = campaign?.createdByUserId === user?.id || campaign?.created_by_user_id === user?.id
|
||||
const canManage = isSuperadmin || (permissions?.canEditCampaigns && isCreator)
|
||||
const canAssign = isSuperadmin || (permissions?.canAssignCampaigns && isCreator)
|
||||
|
||||
useEffect(() => { loadAll() }, [id])
|
||||
|
||||
const loadAll = async () => {
|
||||
try {
|
||||
const [campRes, tracksRes, postsRes] = await Promise.all([
|
||||
api.get(`/campaigns`),
|
||||
const [campRes, tracksRes, postsRes, assignRes] = await Promise.all([
|
||||
api.get(`/campaigns/${id}`),
|
||||
api.get(`/campaigns/${id}/tracks`),
|
||||
api.get(`/campaigns/${id}/posts`),
|
||||
api.get(`/campaigns/${id}/assignments`),
|
||||
])
|
||||
const allCampaigns = campRes.data || campRes || []
|
||||
const found = allCampaigns.find(c => String(c.id) === String(id) || String(c._id) === String(id))
|
||||
setCampaign(found || null)
|
||||
setCampaign(campRes.data || campRes || null)
|
||||
setTracks(tracksRes.data || tracksRes || [])
|
||||
setPosts(postsRes.data || postsRes || [])
|
||||
setAssignments(Array.isArray(assignRes) ? assignRes : (assignRes.data || []))
|
||||
} catch (err) {
|
||||
console.error('Failed to load campaign:', err)
|
||||
} finally {
|
||||
@@ -80,6 +92,49 @@ export default function CampaignDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
const loadUsersForAssign = async () => {
|
||||
try {
|
||||
const users = await api.get('/users/team?all=true')
|
||||
setAllUsers(Array.isArray(users) ? users : (users.data || []))
|
||||
} catch (err) {
|
||||
console.error('Failed to load users:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const openAssignModal = () => {
|
||||
loadUsersForAssign()
|
||||
setSelectedUserIds(assignments.map(a => a.user_id))
|
||||
setShowAssignModal(true)
|
||||
}
|
||||
|
||||
const saveAssignments = async () => {
|
||||
try {
|
||||
const currentIds = assignments.map(a => a.user_id)
|
||||
const toAdd = selectedUserIds.filter(id => !currentIds.includes(id))
|
||||
const toRemove = currentIds.filter(id => !selectedUserIds.includes(id))
|
||||
|
||||
if (toAdd.length > 0) {
|
||||
await api.post(`/campaigns/${id}/assignments`, { user_ids: toAdd })
|
||||
}
|
||||
for (const uid of toRemove) {
|
||||
await api.delete(`/campaigns/${id}/assignments/${uid}`)
|
||||
}
|
||||
setShowAssignModal(false)
|
||||
loadAll()
|
||||
} catch (err) {
|
||||
console.error('Failed to save assignments:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const removeAssignment = async (userId) => {
|
||||
try {
|
||||
await api.delete(`/campaigns/${id}/assignments/${userId}`)
|
||||
loadAll()
|
||||
} catch (err) {
|
||||
console.error('Failed to remove assignment:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const saveTrack = async () => {
|
||||
try {
|
||||
const data = {
|
||||
@@ -198,11 +253,65 @@ export default function CampaignDetail() {
|
||||
{campaign.start_date && campaign.end_date && (
|
||||
<span>{format(new Date(campaign.start_date), 'MMM d')} – {format(new Date(campaign.end_date), 'MMM d, yyyy')}</span>
|
||||
)}
|
||||
{campaign.budget > 0 && <span>Budget: {campaign.budget.toLocaleString()} SAR</span>}
|
||||
<span className="flex items-center gap-1">
|
||||
Budget: {campaign.budget > 0 ? `${campaign.budget.toLocaleString()} SAR` : 'Not set'}
|
||||
{canSetBudget && (
|
||||
<button
|
||||
onClick={() => { setBudgetValue(campaign.budget || ''); setEditingBudget(true) }}
|
||||
className="p-0.5 rounded text-text-tertiary hover:text-brand-primary hover:bg-surface-tertiary"
|
||||
title="Edit budget"
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Assigned Team */}
|
||||
<div className="bg-white rounded-xl border border-border p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium flex items-center gap-1.5">
|
||||
<Users className="w-3.5 h-3.5" /> Assigned Team
|
||||
</h3>
|
||||
{canAssign && (
|
||||
<button
|
||||
onClick={openAssignModal}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-brand-primary text-white rounded-lg text-xs font-medium hover:bg-brand-primary-light"
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5" /> Assign Members
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{assignments.length === 0 ? (
|
||||
<p className="text-xs text-text-tertiary py-2">No team members assigned yet.</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{assignments.map(a => (
|
||||
<div key={a.user_id} className="flex items-center gap-2 bg-surface-secondary rounded-full pl-1 pr-2 py-1">
|
||||
<div className="w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-bold shrink-0">
|
||||
{a.user_avatar ? (
|
||||
<img src={a.user_avatar} className="w-full h-full rounded-full object-cover" alt="" />
|
||||
) : (
|
||||
getInitials(a.user_name)
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs font-medium text-text-primary">{a.user_name}</span>
|
||||
{canAssign && (
|
||||
<button
|
||||
onClick={() => removeAssignment(a.user_id)}
|
||||
className="p-0.5 rounded-full hover:bg-red-100 text-text-tertiary hover:text-red-500"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Aggregate Metrics */}
|
||||
{tracks.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-border p-5">
|
||||
@@ -343,7 +452,14 @@ export default function CampaignDetail() {
|
||||
</div>
|
||||
<div className="divide-y divide-border-light">
|
||||
{posts.map(post => (
|
||||
<div key={post.id} className="flex items-center gap-3 px-5 py-3">
|
||||
<div
|
||||
key={post.id}
|
||||
onClick={() => setSelectedPost(post)}
|
||||
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary cursor-pointer transition-colors"
|
||||
>
|
||||
{post.thumbnail_url && (
|
||||
<img src={post.thumbnail_url} alt="" className="w-10 h-10 rounded-lg object-cover shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-medium text-text-primary">{post.title}</h4>
|
||||
@@ -351,6 +467,7 @@ export default function CampaignDetail() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-text-tertiary">
|
||||
{post.track_name && <span className="px-1.5 py-0.5 rounded bg-surface-tertiary">{post.track_name}</span>}
|
||||
{post.brand_name && <BrandBadge brand={post.brand_name} />}
|
||||
{post.assigned_name && <span>→ {post.assigned_name}</span>}
|
||||
{post.platforms && post.platforms.length > 0 && (
|
||||
<PlatformIcons platforms={post.platforms} size={14} />
|
||||
@@ -550,6 +667,211 @@ export default function CampaignDetail() {
|
||||
>
|
||||
Are you sure you want to delete this campaign track? This action cannot be undone.
|
||||
</Modal>
|
||||
|
||||
{/* Assign Members Modal */}
|
||||
<Modal
|
||||
isOpen={showAssignModal}
|
||||
onClose={() => setShowAssignModal(false)}
|
||||
title="Assign Team Members"
|
||||
>
|
||||
<div className="space-y-3 max-h-80 overflow-y-auto">
|
||||
{allUsers.map(u => {
|
||||
const checked = selectedUserIds.includes(u.id || u._id)
|
||||
return (
|
||||
<label
|
||||
key={u.id || u._id}
|
||||
className={`flex items-center gap-3 p-2 rounded-lg cursor-pointer hover:bg-surface-secondary ${checked ? 'bg-brand-primary/5' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
const uid = u.id || u._id
|
||||
setSelectedUserIds(prev =>
|
||||
prev.includes(uid) ? prev.filter(id => id !== uid) : [...prev, uid]
|
||||
)
|
||||
}}
|
||||
className="rounded border-border text-brand-primary focus:ring-brand-primary"
|
||||
/>
|
||||
<div className="w-7 h-7 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-bold shrink-0">
|
||||
{u.avatar ? (
|
||||
<img src={u.avatar} className="w-full h-full rounded-full object-cover" alt="" />
|
||||
) : (
|
||||
getInitials(u.name)
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-text-primary">{u.name}</div>
|
||||
{u.team_role && <div className="text-[10px] text-text-tertiary">{u.team_role}</div>}
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-border mt-4">
|
||||
<button onClick={() => setShowAssignModal(false)} className="px-4 py-2 text-sm text-text-secondary hover:bg-surface-tertiary rounded-lg">Cancel</button>
|
||||
<button onClick={saveAssignments} className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm">
|
||||
Save Assignments
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Budget Modal */}
|
||||
<Modal isOpen={editingBudget} onClose={() => setEditingBudget(false)} title="Set Campaign Budget" size="sm">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Budget (SAR)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={budgetValue}
|
||||
onChange={e => setBudgetValue(e.target.value)}
|
||||
autoFocus
|
||||
min="0"
|
||||
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="Enter budget amount"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||
<button
|
||||
onClick={() => setEditingBudget(false)}
|
||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await api.patch(`/campaigns/${id}`, { budget: budgetValue ? Number(budgetValue) : null })
|
||||
setEditingBudget(false)
|
||||
loadAll()
|
||||
} catch (err) {
|
||||
console.error('Failed to update budget:', err)
|
||||
}
|
||||
}}
|
||||
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Post Detail Modal */}
|
||||
<Modal
|
||||
isOpen={!!selectedPost}
|
||||
onClose={() => setSelectedPost(null)}
|
||||
title={selectedPost?.title || 'Post Details'}
|
||||
size="lg"
|
||||
>
|
||||
{selectedPost && (
|
||||
<div className="space-y-4">
|
||||
{/* Thumbnail / Media */}
|
||||
{selectedPost.thumbnail_url && (
|
||||
<div className="rounded-lg overflow-hidden border border-border">
|
||||
<img
|
||||
src={selectedPost.thumbnail_url}
|
||||
alt={selectedPost.title}
|
||||
className="w-full max-h-64 object-contain bg-surface-secondary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status & Platforms */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge status={selectedPost.status} />
|
||||
{selectedPost.brand_name && <BrandBadge brand={selectedPost.brand_name} />}
|
||||
{selectedPost.platforms && selectedPost.platforms.length > 0 && (
|
||||
<PlatformIcons platforms={selectedPost.platforms} size={18} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{selectedPost.description && (
|
||||
<div>
|
||||
<h4 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-1">Description</h4>
|
||||
<p className="text-sm text-text-secondary whitespace-pre-wrap">{selectedPost.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Meta info grid */}
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
{selectedPost.track_name && (
|
||||
<div>
|
||||
<span className="text-text-tertiary text-xs">Track</span>
|
||||
<p className="font-medium text-text-primary">{selectedPost.track_name}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedPost.assigned_name && (
|
||||
<div>
|
||||
<span className="text-text-tertiary text-xs">Assigned to</span>
|
||||
<p className="font-medium text-text-primary">{selectedPost.assigned_name}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedPost.creator_user_name && (
|
||||
<div>
|
||||
<span className="text-text-tertiary text-xs">Created by</span>
|
||||
<p className="font-medium text-text-primary">{selectedPost.creator_user_name}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedPost.scheduled_date && (
|
||||
<div>
|
||||
<span className="text-text-tertiary text-xs">Scheduled</span>
|
||||
<p className="font-medium text-text-primary">{format(new Date(selectedPost.scheduled_date), 'MMM d, yyyy')}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedPost.published_date && (
|
||||
<div>
|
||||
<span className="text-text-tertiary text-xs">Published</span>
|
||||
<p className="font-medium text-text-primary">{format(new Date(selectedPost.published_date), 'MMM d, yyyy')}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedPost.created_at && (
|
||||
<div>
|
||||
<span className="text-text-tertiary text-xs">Created</span>
|
||||
<p className="font-medium text-text-primary">{format(new Date(selectedPost.created_at), 'MMM d, yyyy')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Publication Links */}
|
||||
{selectedPost.publication_links && selectedPost.publication_links.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-2">Publication Links</h4>
|
||||
<div className="space-y-2">
|
||||
{selectedPost.publication_links.map((link, i) => {
|
||||
const url = typeof link === 'string' ? link : link.url
|
||||
const platform = typeof link === 'string' ? null : link.platform
|
||||
const platformInfo = platform ? PLATFORMS[platform] : null
|
||||
return (
|
||||
<a
|
||||
key={i}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 p-2 rounded-lg border border-border hover:bg-surface-secondary transition-colors group"
|
||||
>
|
||||
{platformInfo && <PlatformIcon platform={platform} size={18} />}
|
||||
<span className="text-sm font-medium text-brand-primary group-hover:underline truncate flex-1">
|
||||
{platformInfo ? platformInfo.label : url}
|
||||
</span>
|
||||
<span className="text-[10px] text-text-tertiary truncate max-w-[200px]">{url}</span>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{selectedPost.notes && (
|
||||
<div>
|
||||
<h4 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-1">Notes</h4>
|
||||
<p className="text-sm text-text-secondary whitespace-pre-wrap">{selectedPost.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user