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:
@@ -59,6 +59,8 @@ function AppContent() {
|
||||
// Check if profile is incomplete
|
||||
if (!user.profileComplete && user.role !== 'superadmin') {
|
||||
setShowProfilePrompt(true)
|
||||
} else {
|
||||
setShowProfilePrompt(false)
|
||||
}
|
||||
} else if (!authLoading) {
|
||||
setLoading(false)
|
||||
|
||||
@@ -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>
|
||||
<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 opacity-0 group-hover:opacity-100 transition-opacity ml-auto"
|
||||
className="p-0.5 rounded text-text-tertiary hover:text-red-500"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{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>
|
||||
))}
|
||||
|
||||
@@ -72,6 +72,8 @@ export function AuthProvider({ children }) {
|
||||
if (!permissions) return false
|
||||
if (type === 'post') return permissions.canEditAnyPost || isOwner(resource) || isAssignedTo(resource)
|
||||
if (type === 'task') return permissions.canEditAnyTask || isOwner(resource) || isAssignedTo(resource)
|
||||
if (type === 'campaign') return permissions.canEditCampaigns || isOwner(resource)
|
||||
if (type === 'project') return permissions.canEditProjects || isOwner(resource)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -79,6 +81,8 @@ export function AuthProvider({ children }) {
|
||||
if (!permissions) return false
|
||||
if (type === 'post') return permissions.canDeleteAnyPost || isOwner(resource) || isAssignedTo(resource)
|
||||
if (type === 'task') return permissions.canDeleteAnyTask || isOwner(resource) || isAssignedTo(resource)
|
||||
if (type === 'campaign') return permissions.canDeleteCampaigns || isOwner(resource)
|
||||
if (type === 'project') return permissions.canDeleteProjects || isOwner(resource)
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -155,6 +155,41 @@ export default function Campaigns() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<select
|
||||
value={filters.brand}
|
||||
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
|
||||
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
|
||||
>
|
||||
<option value="">All Brands</option>
|
||||
{brands.map(b => <option key={b.id || b._id} value={b.id || b._id}>{b.name}</option>)}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={e => setFilters(f => ({ ...f, status: e.target.value }))}
|
||||
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="planning">Planning</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="paused">Paused</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
|
||||
{permissions?.canCreateCampaigns && (
|
||||
<button
|
||||
onClick={openNew}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ml-auto"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
New Campaign
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
{(totalBudget > 0 || totalSpent > 0) && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
@@ -206,41 +241,6 @@ export default function Campaigns() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<select
|
||||
value={filters.brand}
|
||||
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
|
||||
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
|
||||
>
|
||||
<option value="">All Brands</option>
|
||||
{brands.map(b => <option key={b.id || b._id} value={b.id || b._id}>{b.name}</option>)}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={e => setFilters(f => ({ ...f, status: e.target.value }))}
|
||||
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="planning">Planning</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="paused">Paused</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
|
||||
{permissions?.canCreateCampaigns && (
|
||||
<button
|
||||
onClick={openNew}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ml-auto"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
New Campaign
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Calendar */}
|
||||
<CampaignCalendar campaigns={filtered} />
|
||||
|
||||
@@ -449,12 +449,16 @@ export default function Campaigns() {
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Budget (SAR)</label>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">
|
||||
Budget (SAR)
|
||||
{!permissions?.canSetBudget && <span className="text-[10px] text-text-tertiary ml-1">(Superadmin only)</span>}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.budget}
|
||||
onChange={e => setFormData(f => ({ ...f, budget: 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"
|
||||
disabled={!permissions?.canSetBudget}
|
||||
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 ${!permissions?.canSetBudget ? 'bg-surface-tertiary text-text-tertiary cursor-not-allowed' : ''}`}
|
||||
placeholder="e.g., 50000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Plus, DollarSign, TrendingUp, TrendingDown, Wallet, PiggyBank, Eye, Mou
|
||||
import { format } from 'date-fns'
|
||||
import { AppContext } from '../App'
|
||||
import { api } from '../utils/api'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import Modal from '../components/Modal'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
|
||||
@@ -52,6 +53,8 @@ function ProgressRing({ pct, size = 80, stroke = 8, color = '#10b981' }) {
|
||||
|
||||
export default function Finance() {
|
||||
const { brands } = useContext(AppContext)
|
||||
const { permissions } = useAuth()
|
||||
const canManageFinance = permissions?.canManageFinance
|
||||
const [entries, setEntries] = useState([])
|
||||
const [summary, setSummary] = useState(null)
|
||||
const [campaigns, setCampaigns] = useState([])
|
||||
@@ -268,12 +271,14 @@ export default function Finance() {
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
||||
<h3 className="font-semibold text-text-primary">Budget Received</h3>
|
||||
{canManageFinance && (
|
||||
<button
|
||||
onClick={() => { setEditing(null); setForm(EMPTY_ENTRY); setShowModal(true) }}
|
||||
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"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Add Entry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
@@ -304,6 +309,7 @@ export default function Finance() {
|
||||
<div className="text-right shrink-0">
|
||||
<div className="text-base font-bold text-emerald-600">{Number(entry.amount).toLocaleString()} SAR</div>
|
||||
</div>
|
||||
{canManageFinance && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button onClick={() => openEdit(entry)} className="p-1.5 hover:bg-surface-tertiary rounded-lg text-text-tertiary hover:text-text-primary">
|
||||
<Edit2 className="w-4 h-4" />
|
||||
@@ -312,6 +318,7 @@ export default function Finance() {
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { format, differenceInDays, startOfDay, addDays, isAfter, isBefore } from 'date-fns'
|
||||
import { AppContext } from '../App'
|
||||
import { api, PRIORITY_CONFIG } from '../utils/api'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import BrandBadge from '../components/BrandBadge'
|
||||
import Modal from '../components/Modal'
|
||||
@@ -22,6 +23,9 @@ export default function ProjectDetail() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { teamMembers, brands } = useContext(AppContext)
|
||||
const { permissions, canEditResource, canDeleteResource } = useAuth()
|
||||
const canEditProject = canEditResource('project', project)
|
||||
const canManageProject = permissions?.canEditProjects
|
||||
const [project, setProject] = useState(null)
|
||||
const [tasks, setTasks] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -247,6 +251,7 @@ export default function ProjectDetail() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{canEditProject && (
|
||||
<button
|
||||
onClick={openEditProject}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-text-secondary hover:text-text-primary hover:bg-surface-tertiary rounded-lg transition-colors"
|
||||
@@ -254,6 +259,7 @@ export default function ProjectDetail() {
|
||||
<Settings className="w-4 h-4" />
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{project.description && (
|
||||
@@ -344,6 +350,8 @@ export default function ProjectDetail() {
|
||||
<TaskKanbanCard
|
||||
key={task._id}
|
||||
task={task}
|
||||
canEdit={canEditResource('task', task)}
|
||||
canDelete={canDeleteResource('task', task)}
|
||||
onEdit={() => openEditTask(task)}
|
||||
onDelete={() => handleDeleteTask(task._id)}
|
||||
onStatusChange={handleTaskStatusChange}
|
||||
@@ -401,12 +409,16 @@ export default function ProjectDetail() {
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{canEditResource('task', task) && (
|
||||
<button onClick={() => openEditTask(task)} className="p-1 rounded hover:bg-surface-tertiary text-text-tertiary">
|
||||
<Edit3 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{canDeleteResource('task', task) && (
|
||||
<button onClick={() => handleDeleteTask(task._id)} className="p-1 rounded hover:bg-red-50 text-red-400">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -486,7 +498,7 @@ export default function ProjectDetail() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||
{editingTask && (
|
||||
{editingTask && canDeleteResource('task', editingTask) && (
|
||||
<button onClick={() => handleDeleteTask(editingTask._id)}
|
||||
className="px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg mr-auto">
|
||||
Delete
|
||||
@@ -589,7 +601,7 @@ export default function ProjectDetail() {
|
||||
}
|
||||
|
||||
// ─── Task Kanban Card ───────────────────────────────
|
||||
function TaskKanbanCard({ task, onEdit, onDelete, onStatusChange, onDragStart, onDragEnd }) {
|
||||
function TaskKanbanCard({ task, canEdit, canDelete, onEdit, onDelete, onStatusChange, onDragStart, onDragEnd }) {
|
||||
const priority = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
|
||||
const assigneeName = task.assignedName || task.assigned_name
|
||||
const isOverdue = task.dueDate && new Date(task.dueDate) < new Date() && task.status !== 'done'
|
||||
@@ -621,23 +633,29 @@ function TaskKanbanCard({ task, onEdit, onDelete, onStatusChange, onDragStart, o
|
||||
</div>
|
||||
</div>
|
||||
{/* Actions on hover */}
|
||||
{(canEdit || canDelete) && (
|
||||
<div className="flex items-center gap-1 mt-2 pt-2 border-t border-border-light opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{task.status !== 'done' && (
|
||||
{canEdit && task.status !== 'done' && (
|
||||
<button onClick={() => onStatusChange(task._id, task.status === 'todo' ? 'in_progress' : 'done')}
|
||||
className="text-[10px] text-brand-primary hover:bg-brand-primary/10 px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
<Check className="w-3 h-3" />
|
||||
{task.status === 'todo' ? 'Start' : 'Complete'}
|
||||
</button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button onClick={onEdit}
|
||||
className="text-[10px] text-text-tertiary hover:bg-surface-tertiary px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
<Edit3 className="w-3 h-3" /> Edit
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button onClick={onDelete}
|
||||
className="text-[10px] text-red-400 hover:bg-red-50 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useContext } from 'react'
|
||||
import { Plus, Search, FolderKanban } from 'lucide-react'
|
||||
import { AppContext } from '../App'
|
||||
import { api } from '../utils/api'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import ProjectCard from '../components/ProjectCard'
|
||||
import Modal from '../components/Modal'
|
||||
|
||||
@@ -12,6 +13,7 @@ const EMPTY_PROJECT = {
|
||||
|
||||
export default function Projects() {
|
||||
const { teamMembers, brands } = useContext(AppContext)
|
||||
const { permissions } = useAuth()
|
||||
const [projects, setProjects] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
@@ -81,6 +83,7 @@ export default function Projects() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{permissions?.canCreateProjects && (
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ml-auto"
|
||||
@@ -88,6 +91,7 @@ export default function Projects() {
|
||||
<Plus className="w-4 h-4" />
|
||||
New Project
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project grid */}
|
||||
|
||||
12
server/db.js
12
server/db.js
@@ -178,6 +178,18 @@ function initialize() {
|
||||
);
|
||||
`);
|
||||
|
||||
// Campaign assignments (user-to-campaign junction table)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS campaign_assignments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
campaign_id INTEGER NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
assigned_by INTEGER REFERENCES users(id),
|
||||
assigned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(campaign_id, user_id)
|
||||
);
|
||||
`);
|
||||
|
||||
// ─── Column migrations ───
|
||||
// Helper: adds a column to a table if it does not already exist.
|
||||
function addColumnIfMissing(table, column, definition) {
|
||||
|
||||
265
server/server.js
265
server/server.js
@@ -143,7 +143,7 @@ function requireRole(...roles) {
|
||||
}
|
||||
|
||||
// Ownership check: contributors can only modify their own resources (or resources assigned to them)
|
||||
const VALID_OWNER_TABLES = new Set(['posts', 'tasks']);
|
||||
const VALID_OWNER_TABLES = new Set(['posts', 'tasks', 'campaigns', 'projects']);
|
||||
|
||||
function requireOwnerOrRole(table, ...allowedRoles) {
|
||||
if (!VALID_OWNER_TABLES.has(table)) {
|
||||
@@ -158,7 +158,7 @@ function requireOwnerOrRole(table, ...allowedRoles) {
|
||||
return next();
|
||||
}
|
||||
// Contributors must own the resource or be assigned to it
|
||||
const row = db.prepare(`SELECT created_by_user_id, assigned_to FROM ${table} WHERE id = ?`).get(req.params.id);
|
||||
const row = db.prepare(`SELECT * FROM ${table} WHERE id = ?`).get(req.params.id);
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: 'Not found' });
|
||||
}
|
||||
@@ -166,11 +166,13 @@ function requireOwnerOrRole(table, ...allowedRoles) {
|
||||
if (row.created_by_user_id === req.session.userId) {
|
||||
return next();
|
||||
}
|
||||
// Check if resource is assigned to user's team member
|
||||
// Check if resource is assigned to user's team member (for tables with assigned_to)
|
||||
if (row.assigned_to !== undefined) {
|
||||
const currentUser = db.prepare('SELECT team_member_id FROM users WHERE id = ?').get(req.session.userId);
|
||||
if (currentUser?.team_member_id && row.assigned_to === currentUser.team_member_id) {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
return res.status(403).json({ error: 'You can only modify your own items' });
|
||||
};
|
||||
}
|
||||
@@ -201,6 +203,7 @@ app.post('/api/auth/login', async (req, res) => {
|
||||
req.session.userName = user.name;
|
||||
req.session.teamMemberId = user.team_member_id;
|
||||
|
||||
const profileComplete = !!user.team_role;
|
||||
res.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
@@ -209,6 +212,9 @@ app.post('/api/auth/login', async (req, res) => {
|
||||
role: user.role,
|
||||
avatar: user.avatar,
|
||||
team_member_id: user.team_member_id,
|
||||
team_role: user.team_role,
|
||||
tutorial_completed: user.tutorial_completed,
|
||||
profileComplete,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -233,7 +239,7 @@ app.get('/api/auth/me', requireAuth, (req, res) => {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
// Check if profile is complete
|
||||
const profileComplete = !!(user.team_role && user.brands);
|
||||
const profileComplete = !!user.team_role;
|
||||
res.json({ ...user, profileComplete, brands: JSON.parse(user.brands || '[]') });
|
||||
});
|
||||
|
||||
@@ -386,6 +392,8 @@ app.get('/api/auth/permissions', requireAuth, (req, res) => {
|
||||
canManageFinance: canManage,
|
||||
canManageTeam: canManage,
|
||||
canManageUsers: role === 'superadmin',
|
||||
canAssignCampaigns: canManage,
|
||||
canSetBudget: role === 'superadmin',
|
||||
// Posts & tasks: everyone can create, but only own (for contributors)
|
||||
canCreatePosts: true,
|
||||
canCreateTasks: true,
|
||||
@@ -418,9 +426,12 @@ app.get('/api/users/team', requireAuth, (req, res) => {
|
||||
ORDER BY name
|
||||
`).all();
|
||||
|
||||
// Filter based on brand overlap for non-superadmins
|
||||
// Skip brand filter when loading users for campaign assignment (managers need to see all users)
|
||||
const skipBrandFilter = req.query.all === 'true' && (req.session.userRole === 'superadmin' || req.session.userRole === 'manager');
|
||||
|
||||
// Filter based on brand overlap for non-superadmins (unless skipped)
|
||||
let filteredUsers = users;
|
||||
if (req.session.userRole !== 'superadmin') {
|
||||
if (req.session.userRole !== 'superadmin' && !skipBrandFilter) {
|
||||
const currentUser = db.prepare('SELECT brands FROM users WHERE id = ?').get(req.session.userId);
|
||||
const myBrands = JSON.parse(currentUser?.brands || '[]');
|
||||
|
||||
@@ -929,10 +940,27 @@ app.delete('/api/assets/:id', requireAuth, requireRole('superadmin', 'manager'),
|
||||
|
||||
app.get('/api/campaigns', requireAuth, (req, res) => {
|
||||
const { brand_id, status, start_date, end_date } = req.query;
|
||||
let sql = CAMPAIGN_SELECT_SQL;
|
||||
const isSuperadmin = req.session.userRole === 'superadmin';
|
||||
|
||||
let sql;
|
||||
if (isSuperadmin) {
|
||||
sql = CAMPAIGN_SELECT_SQL;
|
||||
} else {
|
||||
// Non-superadmins only see campaigns assigned to them or created by them
|
||||
sql = `SELECT DISTINCT c.*, b.name as brand_name
|
||||
FROM campaigns c
|
||||
LEFT JOIN brands b ON c.brand_id = b.id
|
||||
LEFT JOIN campaign_assignments ca ON ca.campaign_id = c.id`;
|
||||
}
|
||||
|
||||
const conditions = [];
|
||||
const values = [];
|
||||
|
||||
if (!isSuperadmin) {
|
||||
conditions.push('(c.created_by_user_id = ? OR ca.user_id = ?)');
|
||||
values.push(req.session.userId, req.session.userId);
|
||||
}
|
||||
|
||||
if (brand_id) { conditions.push('c.brand_id = ?'); values.push(brand_id); }
|
||||
if (status) { conditions.push('c.status = ?'); values.push(status); }
|
||||
if (start_date) { conditions.push('c.end_date >= ?'); values.push(start_date); }
|
||||
@@ -945,30 +973,55 @@ app.get('/api/campaigns', requireAuth, (req, res) => {
|
||||
res.json(campaigns.map(c => ({ ...c, platforms: JSON.parse(c.platforms || '[]') })));
|
||||
});
|
||||
|
||||
app.get('/api/campaigns/:id', requireAuth, (req, res) => {
|
||||
const campaign = db.prepare(`${CAMPAIGN_SELECT_SQL} WHERE c.id = ?`).get(req.params.id);
|
||||
if (!campaign) return res.status(404).json({ error: 'Campaign not found' });
|
||||
|
||||
if (!userHasCampaignAccess(req.session.userId, req.session.userRole, req.params.id)) {
|
||||
return res.status(403).json({ error: 'You do not have access to this campaign' });
|
||||
}
|
||||
|
||||
res.json({ ...campaign, platforms: JSON.parse(campaign.platforms || '[]') });
|
||||
});
|
||||
|
||||
app.post('/api/campaigns', requireAuth, requireRole('superadmin', 'manager'), (req, res) => {
|
||||
const { name, description, brand_id, start_date, end_date, status, color, budget, goals, platforms } = req.body;
|
||||
if (!name) return res.status(400).json({ error: 'Name is required' });
|
||||
if (!start_date || !end_date) return res.status(400).json({ error: 'Start and end dates are required' });
|
||||
|
||||
// Managers cannot set budget — only superadmin can
|
||||
const effectiveBudget = req.session.userRole === 'superadmin' ? (budget || null) : null;
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO campaigns (name, description, brand_id, start_date, end_date, status, color, budget, goals, platforms, created_by_user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(name, description || null, brand_id || null, start_date, end_date, status || 'planning', color || null, budget || null, goals || null, JSON.stringify(platforms || []), req.session.userId);
|
||||
`).run(name, description || null, brand_id || null, start_date, end_date, status || 'planning', color || null, effectiveBudget, goals || null, JSON.stringify(platforms || []), req.session.userId);
|
||||
|
||||
const campaign = db.prepare(`${CAMPAIGN_SELECT_SQL} WHERE c.id = ?`).get(result.lastInsertRowid);
|
||||
const campaignId = result.lastInsertRowid;
|
||||
|
||||
// Auto-assign creator to campaign
|
||||
db.prepare('INSERT OR IGNORE INTO campaign_assignments (campaign_id, user_id, assigned_by) VALUES (?, ?, ?)').run(campaignId, req.session.userId, req.session.userId);
|
||||
|
||||
const campaign = db.prepare(`${CAMPAIGN_SELECT_SQL} WHERE c.id = ?`).get(campaignId);
|
||||
res.status(201).json({ ...campaign, platforms: JSON.parse(campaign.platforms || '[]') });
|
||||
});
|
||||
|
||||
app.patch('/api/campaigns/:id', requireAuth, requireRole('superadmin', 'manager'), (req, res) => {
|
||||
app.patch('/api/campaigns/:id', requireAuth, requireOwnerOrRole('campaigns', 'superadmin', 'manager'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
const existing = db.prepare('SELECT * FROM campaigns WHERE id = ?').get(id);
|
||||
if (!existing) return res.status(404).json({ error: 'Campaign not found' });
|
||||
|
||||
// Strip budget field if user is not superadmin
|
||||
const body = { ...req.body };
|
||||
if (req.session.userRole !== 'superadmin') {
|
||||
delete body.budget;
|
||||
}
|
||||
|
||||
const campaignFields = [
|
||||
'name', 'description', 'brand_id', 'start_date', 'end_date', 'status', 'color', 'budget', 'goals',
|
||||
'budget_spent', 'revenue', 'impressions', 'clicks', 'conversions', 'cost_per_click', 'notes', 'platforms',
|
||||
];
|
||||
const { clauses, values, hasUpdates } = buildUpdate(req.body, campaignFields, { jsonFields: ['platforms'] });
|
||||
const { clauses, values, hasUpdates } = buildUpdate(body, campaignFields, { jsonFields: ['platforms'] });
|
||||
|
||||
if (!hasUpdates) return res.status(400).json({ error: 'No fields to update' });
|
||||
|
||||
@@ -1003,13 +1056,93 @@ app.delete('/api/campaigns/:id', requireAuth, requireRole('superadmin', 'manager
|
||||
}
|
||||
});
|
||||
|
||||
// ─── CAMPAIGN ASSIGNMENTS ───────────────────────────────────────
|
||||
|
||||
// Helper: check if user has access to a campaign
|
||||
function userHasCampaignAccess(userId, userRole, campaignId) {
|
||||
if (userRole === 'superadmin') return true;
|
||||
const row = db.prepare(`
|
||||
SELECT 1 FROM campaigns c
|
||||
LEFT JOIN campaign_assignments ca ON ca.campaign_id = c.id AND ca.user_id = ?
|
||||
WHERE c.id = ? AND (c.created_by_user_id = ? OR ca.user_id = ?)
|
||||
`).get(userId, campaignId, userId, userId);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
app.get('/api/campaigns/:id/assignments', requireAuth, (req, res) => {
|
||||
const assignments = db.prepare(`
|
||||
SELECT ca.*, u.name as user_name, u.email as user_email, u.avatar as user_avatar, u.role as user_role,
|
||||
ab.name as assigned_by_name
|
||||
FROM campaign_assignments ca
|
||||
JOIN users u ON ca.user_id = u.id
|
||||
LEFT JOIN users ab ON ca.assigned_by = ab.id
|
||||
WHERE ca.campaign_id = ?
|
||||
ORDER BY ca.assigned_at ASC
|
||||
`).all(req.params.id);
|
||||
res.json(assignments);
|
||||
});
|
||||
|
||||
app.post('/api/campaigns/:id/assignments', requireAuth, requireRole('superadmin', 'manager'), (req, res) => {
|
||||
const { user_ids } = req.body;
|
||||
if (!Array.isArray(user_ids) || user_ids.length === 0) {
|
||||
return res.status(400).json({ error: 'user_ids array is required' });
|
||||
}
|
||||
|
||||
const campaign = db.prepare('SELECT id, created_by_user_id FROM campaigns WHERE id = ?').get(req.params.id);
|
||||
if (!campaign) return res.status(404).json({ error: 'Campaign not found' });
|
||||
|
||||
// Only superadmin or campaign creator can assign
|
||||
if (req.session.userRole !== 'superadmin' && campaign.created_by_user_id !== req.session.userId) {
|
||||
return res.status(403).json({ error: 'Only the campaign creator or superadmin can assign members' });
|
||||
}
|
||||
|
||||
const insert = db.prepare('INSERT OR IGNORE INTO campaign_assignments (campaign_id, user_id, assigned_by) VALUES (?, ?, ?)');
|
||||
const tx = db.transaction(() => {
|
||||
for (const userId of user_ids) {
|
||||
insert.run(req.params.id, userId, req.session.userId);
|
||||
}
|
||||
});
|
||||
tx();
|
||||
|
||||
// Return updated assignments
|
||||
const assignments = db.prepare(`
|
||||
SELECT ca.*, u.name as user_name, u.email as user_email, u.avatar as user_avatar, u.role as user_role
|
||||
FROM campaign_assignments ca
|
||||
JOIN users u ON ca.user_id = u.id
|
||||
WHERE ca.campaign_id = ?
|
||||
ORDER BY ca.assigned_at ASC
|
||||
`).all(req.params.id);
|
||||
res.json(assignments);
|
||||
});
|
||||
|
||||
app.delete('/api/campaigns/:id/assignments/:userId', requireAuth, requireRole('superadmin', 'manager'), (req, res) => {
|
||||
const campaign = db.prepare('SELECT id, created_by_user_id FROM campaigns WHERE id = ?').get(req.params.id);
|
||||
if (!campaign) return res.status(404).json({ error: 'Campaign not found' });
|
||||
|
||||
// Only superadmin or campaign creator can unassign
|
||||
if (req.session.userRole !== 'superadmin' && campaign.created_by_user_id !== req.session.userId) {
|
||||
return res.status(403).json({ error: 'Only the campaign creator or superadmin can remove members' });
|
||||
}
|
||||
|
||||
const result = db.prepare('DELETE FROM campaign_assignments WHERE campaign_id = ? AND user_id = ?').run(req.params.id, req.params.userId);
|
||||
if (result.changes === 0) return res.status(404).json({ error: 'Assignment not found' });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ─── BUDGET ENTRIES ─────────────────────────────────────────────
|
||||
|
||||
app.get('/api/budget', requireAuth, requireRole('superadmin', 'manager'), (req, res) => {
|
||||
const isSuperadmin = req.session.userRole === 'superadmin';
|
||||
const userId = req.session.userId;
|
||||
const campaignFilter = isSuperadmin
|
||||
? ''
|
||||
: `AND be.campaign_id IN (SELECT id FROM campaigns WHERE created_by_user_id = ${userId} UNION SELECT campaign_id FROM campaign_assignments WHERE user_id = ${userId})`;
|
||||
|
||||
const entries = db.prepare(`
|
||||
SELECT be.*, c.name as campaign_name
|
||||
FROM budget_entries be
|
||||
LEFT JOIN campaigns c ON be.campaign_id = c.id
|
||||
WHERE 1=1 ${campaignFilter}
|
||||
ORDER BY be.date_received DESC
|
||||
`).all();
|
||||
res.json(entries);
|
||||
@@ -1050,10 +1183,23 @@ app.delete('/api/budget/:id', requireAuth, requireRole('superadmin', 'manager'),
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Finance summary — aggregates across all campaigns & tracks
|
||||
// Finance summary — aggregates across campaigns & tracks (scoped by user's campaigns)
|
||||
app.get('/api/finance/summary', requireAuth, requireRole('superadmin', 'manager'), (req, res) => {
|
||||
const totalReceived = db.prepare('SELECT COALESCE(SUM(amount), 0) as total FROM budget_entries').get().total;
|
||||
const isSuperadmin = req.session.userRole === 'superadmin';
|
||||
const userId = req.session.userId;
|
||||
const myCampaignIds = isSuperadmin
|
||||
? null
|
||||
: `SELECT id FROM campaigns WHERE created_by_user_id = ${userId} UNION SELECT campaign_id FROM campaign_assignments WHERE user_id = ${userId}`;
|
||||
|
||||
// For superadmin: totalReceived from budget_entries (org-wide funding)
|
||||
// For managers: totalReceived = sum of their campaigns' budgets (allocated by superadmin)
|
||||
const totalReceived = isSuperadmin
|
||||
? db.prepare('SELECT COALESCE(SUM(amount), 0) as total FROM budget_entries').get().total
|
||||
: db.prepare(`SELECT COALESCE(SUM(budget), 0) as total FROM campaigns WHERE id IN (${myCampaignIds})`).get().total;
|
||||
|
||||
const campaignFilter = isSuperadmin
|
||||
? ''
|
||||
: `WHERE c.id IN (${myCampaignIds})`;
|
||||
const campaignStats = db.prepare(`
|
||||
SELECT
|
||||
c.id, c.name, c.budget, c.status,
|
||||
@@ -1065,6 +1211,7 @@ app.get('/api/finance/summary', requireAuth, requireRole('superadmin', 'manager'
|
||||
COALESCE(SUM(ct.conversions), 0) as tracks_conversions
|
||||
FROM campaigns c
|
||||
LEFT JOIN campaign_tracks ct ON ct.campaign_id = c.id
|
||||
${campaignFilter}
|
||||
GROUP BY c.id
|
||||
ORDER BY c.start_date DESC
|
||||
`).all();
|
||||
@@ -1090,6 +1237,9 @@ app.get('/api/finance/summary', requireAuth, requireRole('superadmin', 'manager'
|
||||
// ─── CAMPAIGN TRACKS ────────────────────────────────────────────
|
||||
|
||||
app.get('/api/campaigns/:id/tracks', requireAuth, (req, res) => {
|
||||
if (!userHasCampaignAccess(req.session.userId, req.session.userRole, req.params.id)) {
|
||||
return res.status(403).json({ error: 'You do not have access to this campaign' });
|
||||
}
|
||||
const tracks = db.prepare('SELECT * FROM campaign_tracks WHERE campaign_id = ? ORDER BY created_at').all(req.params.id);
|
||||
res.json(tracks);
|
||||
});
|
||||
@@ -1133,16 +1283,25 @@ app.delete('/api/tracks/:id', requireAuth, requireRole('superadmin', 'manager'),
|
||||
|
||||
// Get posts linked to a campaign (across all tracks)
|
||||
app.get('/api/campaigns/:id/posts', requireAuth, (req, res) => {
|
||||
if (!userHasCampaignAccess(req.session.userId, req.session.userRole, req.params.id)) {
|
||||
return res.status(403).json({ error: 'You do not have access to this campaign' });
|
||||
}
|
||||
const posts = db.prepare(`
|
||||
SELECT p.*, t.name as assigned_name, b.name as brand_name, ct.name as track_name, ct.type as track_type
|
||||
SELECT p.*, t.name as assigned_name, b.name as brand_name, ct.name as track_name, ct.type as track_type,
|
||||
u.name as creator_user_name
|
||||
FROM posts p
|
||||
LEFT JOIN team_members t ON p.assigned_to = t.id
|
||||
LEFT JOIN brands b ON p.brand_id = b.id
|
||||
LEFT JOIN campaign_tracks ct ON p.track_id = ct.id
|
||||
LEFT JOIN users u ON p.created_by_user_id = u.id
|
||||
WHERE p.campaign_id = ?
|
||||
ORDER BY p.created_at DESC
|
||||
`).all(req.params.id);
|
||||
res.json(posts.map(parsePostJson));
|
||||
const thumbnailStmt = db.prepare("SELECT url FROM post_attachments WHERE post_id = ? AND mime_type LIKE 'image/%' ORDER BY created_at ASC LIMIT 1");
|
||||
res.json(posts.map(p => ({
|
||||
...parsePostJson(p),
|
||||
thumbnail_url: thumbnailStmt.get(p.id)?.url || null,
|
||||
})));
|
||||
});
|
||||
|
||||
// ─── PROJECTS ───────────────────────────────────────────────────
|
||||
@@ -1182,7 +1341,7 @@ app.post('/api/projects', requireAuth, requireRole('superadmin', 'manager'), (re
|
||||
res.status(201).json(project);
|
||||
});
|
||||
|
||||
app.patch('/api/projects/:id', requireAuth, requireRole('superadmin', 'manager'), (req, res) => {
|
||||
app.patch('/api/projects/:id', requireAuth, requireOwnerOrRole('projects', 'superadmin', 'manager'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
const existing = db.prepare('SELECT * FROM projects WHERE id = ?').get(id);
|
||||
if (!existing) return res.status(404).json({ error: 'Project not found' });
|
||||
@@ -1303,25 +1462,36 @@ app.delete('/api/tasks/:id', requireAuth, requireOwnerOrRole('tasks', 'superadmi
|
||||
// ─── DASHBOARD ──────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/dashboard', requireAuth, (req, res) => {
|
||||
// Post counts by status
|
||||
const postsByStatus = db.prepare('SELECT status, COUNT(*) as count FROM posts GROUP BY status').all();
|
||||
const totalPosts = db.prepare('SELECT COUNT(*) as count FROM posts').get().count;
|
||||
const isSuperadmin = req.session.userRole === 'superadmin';
|
||||
const userId = req.session.userId;
|
||||
|
||||
// Active campaigns
|
||||
const activeCampaigns = db.prepare("SELECT COUNT(*) as count FROM campaigns WHERE status = 'active'").get().count;
|
||||
const totalCampaigns = db.prepare('SELECT COUNT(*) as count FROM campaigns').get().count;
|
||||
// Subquery for user's campaign IDs (non-superadmins)
|
||||
const myCampaignIds = `SELECT id FROM campaigns WHERE created_by_user_id = ${userId} UNION SELECT campaign_id FROM campaign_assignments WHERE user_id = ${userId}`;
|
||||
const campaignFilter = isSuperadmin
|
||||
? ''
|
||||
: `AND (c.created_by_user_id = ${userId} OR c.id IN (SELECT campaign_id FROM campaign_assignments WHERE user_id = ${userId}))`;
|
||||
|
||||
// Overdue tasks
|
||||
// Post counts by status (scoped to user's campaigns for non-superadmins)
|
||||
const postCampaignFilter = isSuperadmin ? '' : `WHERE p.campaign_id IN (${myCampaignIds})`;
|
||||
const postsByStatus = db.prepare(`SELECT status, COUNT(*) as count FROM posts p ${postCampaignFilter} GROUP BY status`).all();
|
||||
const totalPosts = db.prepare(`SELECT COUNT(*) as count FROM posts p ${postCampaignFilter}`).get().count;
|
||||
|
||||
// Active campaigns (filtered by assignment for non-superadmins)
|
||||
const activeCampaigns = db.prepare(`SELECT COUNT(*) as count FROM campaigns c WHERE status = 'active' ${campaignFilter}`).get().count;
|
||||
const totalCampaigns = db.prepare(`SELECT COUNT(*) as count FROM campaigns c WHERE 1=1 ${campaignFilter}`).get().count;
|
||||
|
||||
// Overdue tasks (scoped to user's campaigns via projects for non-superadmins)
|
||||
const taskCampaignFilter = isSuperadmin ? '' : `AND (tasks.created_by_user_id = ${userId} OR tasks.project_id IN (SELECT id FROM projects WHERE created_by_user_id = ${userId}))`;
|
||||
const overdueTasks = db.prepare(`
|
||||
SELECT COUNT(*) as count FROM tasks
|
||||
WHERE due_date < date('now') AND status != 'done'
|
||||
WHERE due_date < date('now') AND status != 'done' ${taskCampaignFilter}
|
||||
`).get().count;
|
||||
|
||||
// Total tasks by status
|
||||
const tasksByStatus = db.prepare('SELECT status, COUNT(*) as count FROM tasks GROUP BY status').all();
|
||||
// Total tasks by status (scoped)
|
||||
const tasksByStatus = db.prepare(`SELECT status, COUNT(*) as count FROM tasks WHERE 1=1 ${taskCampaignFilter} GROUP BY status`).all();
|
||||
|
||||
// Team workload (tasks assigned per member)
|
||||
const teamWorkload = db.prepare(`
|
||||
// Team workload — only for superadmins
|
||||
const teamWorkload = isSuperadmin ? db.prepare(`
|
||||
SELECT t.id, t.name, t.role,
|
||||
COUNT(CASE WHEN tk.status != 'done' THEN 1 END) as active_tasks,
|
||||
COUNT(CASE WHEN tk.status = 'done' THEN 1 END) as completed_tasks,
|
||||
@@ -1331,26 +1501,29 @@ app.get('/api/dashboard', requireAuth, (req, res) => {
|
||||
LEFT JOIN posts p ON p.assigned_to = t.id
|
||||
GROUP BY t.id
|
||||
ORDER BY active_tasks DESC
|
||||
`).all();
|
||||
`).all() : [];
|
||||
|
||||
// Active projects
|
||||
const activeProjects = db.prepare("SELECT COUNT(*) as count FROM projects WHERE status = 'active'").get().count;
|
||||
// Active projects (scoped for non-superadmins)
|
||||
const projectFilter = isSuperadmin ? '' : `AND (created_by_user_id = ${userId})`;
|
||||
const activeProjects = db.prepare(`SELECT COUNT(*) as count FROM projects WHERE status = 'active' ${projectFilter}`).get().count;
|
||||
|
||||
// Recent posts
|
||||
// Recent posts (scoped to user's campaigns for non-superadmins)
|
||||
const recentPostFilter = isSuperadmin ? '' : `WHERE p.campaign_id IN (${myCampaignIds})`;
|
||||
const recentPosts = db.prepare(`
|
||||
SELECT p.*, b.name as brand_name, t.name as assigned_name
|
||||
FROM posts p
|
||||
LEFT JOIN brands b ON p.brand_id = b.id
|
||||
LEFT JOIN team_members t ON p.assigned_to = t.id
|
||||
${recentPostFilter}
|
||||
ORDER BY p.updated_at DESC LIMIT 5
|
||||
`).all();
|
||||
|
||||
// Upcoming campaigns
|
||||
// Upcoming campaigns (filtered by assignment for non-superadmins)
|
||||
const upcomingCampaigns = db.prepare(`
|
||||
SELECT c.*, b.name as brand_name
|
||||
FROM campaigns c
|
||||
LEFT JOIN brands b ON c.brand_id = b.id
|
||||
WHERE c.end_date >= date('now')
|
||||
WHERE c.end_date >= date('now') ${campaignFilter}
|
||||
ORDER BY c.start_date ASC LIMIT 5
|
||||
`).all();
|
||||
|
||||
@@ -1417,6 +1590,30 @@ app.post('/api/comments/:entityType/:entityId', requireAuth, (req, res) => {
|
||||
res.status(201).json(comment);
|
||||
});
|
||||
|
||||
app.patch('/api/comments/:id', requireAuth, (req, res) => {
|
||||
const comment = db.prepare('SELECT * FROM comments WHERE id = ?').get(req.params.id);
|
||||
if (!comment) return res.status(404).json({ error: 'Comment not found' });
|
||||
|
||||
// Only the comment author can edit
|
||||
if (comment.user_id !== req.session.userId) {
|
||||
return res.status(403).json({ error: 'You can only edit your own comments' });
|
||||
}
|
||||
|
||||
const { content } = req.body;
|
||||
if (!content || !content.trim()) {
|
||||
return res.status(400).json({ error: 'Content is required' });
|
||||
}
|
||||
|
||||
db.prepare('UPDATE comments SET content = ? WHERE id = ?').run(content.trim(), req.params.id);
|
||||
const updated = db.prepare(`
|
||||
SELECT c.*, u.name as user_name, u.avatar as user_avatar
|
||||
FROM comments c
|
||||
LEFT JOIN users u ON c.user_id = u.id
|
||||
WHERE c.id = ?
|
||||
`).get(req.params.id);
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
app.delete('/api/comments/:id', requireAuth, (req, res) => {
|
||||
const comment = db.prepare('SELECT * FROM comments WHERE id = ?').get(req.params.id);
|
||||
if (!comment) return res.status(404).json({ error: 'Comment not found' });
|
||||
|
||||
Reference in New Issue
Block a user