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:
fahed
2026-02-09 13:59:40 +03:00
parent 9b58e5e9aa
commit d15e54044e
11 changed files with 797 additions and 154 deletions

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { Send, Trash2, MessageCircle } from 'lucide-react'
import { Send, Trash2, MessageCircle, Pencil, Check, X } from 'lucide-react'
import { api, getInitials } from '../utils/api'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
@@ -23,6 +23,8 @@ export default function CommentsSection({ entityType, entityId }) {
const [comments, setComments] = useState([])
const [newComment, setNewComment] = useState('')
const [sending, setSending] = useState(false)
const [editingId, setEditingId] = useState(null)
const [editContent, setEditContent] = useState('')
useEffect(() => {
if (entityType && entityId) loadComments()
@@ -60,6 +62,33 @@ export default function CommentsSection({ entityType, entityId }) {
}
}
const startEdit = (comment) => {
setEditingId(comment.id)
setEditContent(comment.content)
}
const cancelEdit = () => {
setEditingId(null)
setEditContent('')
}
const saveEdit = async (id) => {
if (!editContent.trim()) return
try {
await api.patch(`/comments/${id}`, { content: editContent.trim() })
setEditingId(null)
setEditContent('')
loadComments()
} catch (err) {
console.error('Failed to edit comment:', err)
}
}
const canEdit = (comment) => {
if (!user) return false
return comment.user_id === user.id
}
const canDelete = (comment) => {
if (!user) return false
if (comment.user_id === user.id) return true
@@ -96,16 +125,48 @@ export default function CommentsSection({ entityType, entityId }) {
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-text-primary">{c.user_name}</span>
<span className="text-[10px] text-text-tertiary">{relativeTime(c.created_at, t)}</span>
{canDelete(c) && (
<button
onClick={() => handleDelete(c.id)}
className="p-0.5 rounded text-text-tertiary hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity ml-auto"
>
<Trash2 className="w-3 h-3" />
</button>
)}
<div className="flex items-center gap-0.5 ml-auto opacity-0 group-hover:opacity-100 transition-opacity">
{canEdit(c) && editingId !== c.id && (
<button
onClick={() => startEdit(c)}
className="p-0.5 rounded text-text-tertiary hover:text-brand-primary"
>
<Pencil className="w-3 h-3" />
</button>
)}
{canDelete(c) && (
<button
onClick={() => handleDelete(c.id)}
className="p-0.5 rounded text-text-tertiary hover:text-red-500"
>
<Trash2 className="w-3 h-3" />
</button>
)}
</div>
</div>
<p className="text-xs text-text-secondary whitespace-pre-wrap break-words">{c.content}</p>
{editingId === c.id ? (
<div className="flex items-center gap-1.5 mt-1">
<input
type="text"
value={editContent}
onChange={e => setEditContent(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') saveEdit(c.id)
if (e.key === 'Escape') cancelEdit()
}}
autoFocus
className="flex-1 px-2 py-1 text-xs border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-brand-primary/30"
/>
<button onClick={() => saveEdit(c.id)} className="p-0.5 rounded text-green-600 hover:bg-green-50">
<Check className="w-3.5 h-3.5" />
</button>
<button onClick={cancelEdit} className="p-0.5 rounded text-text-tertiary hover:bg-surface-tertiary">
<X className="w-3.5 h-3.5" />
</button>
</div>
) : (
<p className="text-xs text-text-secondary whitespace-pre-wrap break-words">{c.content}</p>
)}
</div>
</div>
))}