ce4d6025d7
Post Workflow: - PostDetail full page (/posts/:id) replaces slide panel approach - Post = 1 Caption Copy + 1 Body Copy + 1 Design + 0-1 Video - copy_type field on Translations (caption/body) - Composition endpoint returns rich data (content preview, languages, thumbnails) - Stage auto-advances on translation/artefact changes (both link and unlink) - "Translations" renamed to "Copy" in navigation - GET /api/posts/:id, /api/translations/:id, /api/artefacts/:id routes added - PostProduction: "New Post" creates → navigates to full page - CampaignDetail: click post → navigates to full page - Inline link picker (no modals) with search + rich item display - PostComposition sub-components for caption, copy, designs, video, formats, readiness Budget Allocation: - Single source of truth: BudgetEntries (Campaign.budget deprecated) - Budget mutex for race conditions - Validation at all levels (main → campaign → track, expenses) - CEO approval workflow: BudgetRequests table, public approval page - Finance page: request budget UI, budget requests section - Settings: CEO email field - All emails branded with "Rawaj —" prefix Brand Identity: - Name: Rawaj (رواج) — trending/virality - Deep teal palette (#0d9488), forest-tinted dark mode - DM Sans font, custom SVG logo - Consistent across login, sidebar, emails, public pages Approval Workflow: - Single reviewer per artefact (not multi-select) - Reviewer redirect on public review page - Server blocks submit-review without reviewer - Review URLs use APP_URL (not server URL) UI/UX: - Scroll clipping fix: Modal, TabbedModal, SlidePanel restructured to avoid overflow-y-auto clipping native select dropdowns - section-card overflow-hidden → overflow-clip - All page titles via Header.jsx (removed duplicate h1s) - CampaignDetail redesigned: prominent budget card, compact team Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
601 lines
27 KiB
React
601 lines
27 KiB
React
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, Users, X, MessageCircle, Settings } from 'lucide-react'
|
||
import { format } from 'date-fns'
|
||
import { AppContext } from '../App'
|
||
import { useAuth } from '../contexts/AuthContext'
|
||
import { useLanguage } from '../i18n/LanguageContext'
|
||
import { api, PLATFORMS, getInitials } from '../utils/api'
|
||
import PlatformIcon, { PlatformIcons } from '../components/PlatformIcon'
|
||
import StatusBadge from '../components/StatusBadge'
|
||
import BrandBadge from '../components/BrandBadge'
|
||
import Modal from '../components/Modal'
|
||
import BudgetBar from '../components/BudgetBar'
|
||
import CommentsSection from '../components/CommentsSection'
|
||
import CampaignDetailPanel from '../components/CampaignDetailPanel'
|
||
import TrackDetailPanel from '../components/TrackDetailPanel'
|
||
|
||
const TRACK_TYPES = {
|
||
organic_social: { label: 'Organic Social', icon: Megaphone, color: 'text-green-600 bg-green-50', hasBudget: false },
|
||
paid_social: { label: 'Paid Social', icon: DollarSign, color: 'text-blue-600 bg-blue-50', hasBudget: true },
|
||
paid_search: { label: 'Paid Search (PPC)', icon: Search, color: 'text-amber-600 bg-amber-50', hasBudget: true },
|
||
seo_content: { label: 'SEO / Content', icon: Globe, color: 'text-purple-600 bg-purple-50', hasBudget: false },
|
||
production: { label: 'Production', icon: FileText, color: 'text-red-600 bg-red-50', hasBudget: true },
|
||
}
|
||
|
||
const TRACK_STATUSES = ['planned', 'active', 'paused', 'completed']
|
||
|
||
export default function CampaignDetail() {
|
||
const { id } = useParams()
|
||
const navigate = useNavigate()
|
||
const { brands, getBrandName, teamMembers } = useContext(AppContext)
|
||
const { t, lang, currencySymbol } = useLanguage()
|
||
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 [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||
const [trackToDelete, setTrackToDelete] = useState(null)
|
||
const [showDiscussion, setShowDiscussion] = useState(false)
|
||
const [allCampaigns, setAllCampaigns] = useState([])
|
||
|
||
// Panel state
|
||
const [panelCampaign, setPanelCampaign] = useState(null)
|
||
const [panelTrack, setPanelTrack] = useState(null)
|
||
const [trackScrollToMetrics, setTrackScrollToMetrics] = useState(false)
|
||
|
||
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])
|
||
useEffect(() => {
|
||
api.get('/campaigns').then(r => setAllCampaigns(Array.isArray(r) ? r : [])).catch(() => {})
|
||
}, [])
|
||
|
||
const loadAll = async () => {
|
||
try {
|
||
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`),
|
||
])
|
||
setCampaign(campRes)
|
||
setTracks(Array.isArray(tracksRes) ? tracksRes : [])
|
||
setPosts(Array.isArray(postsRes) ? postsRes : [])
|
||
setAssignments(Array.isArray(assignRes) ? assignRes : [])
|
||
} catch (err) {
|
||
console.error('Failed to load campaign:', err)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const loadUsersForAssign = async () => {
|
||
try {
|
||
const users = await api.get('/users/team?all=true')
|
||
setAllUsers(Array.isArray(users) ? users : [])
|
||
} 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)
|
||
}
|
||
}
|
||
|
||
// Panel handlers
|
||
const handleCampaignPanelSave = async (campaignId, data) => {
|
||
await api.patch(`/campaigns/${campaignId}`, data)
|
||
loadAll()
|
||
}
|
||
|
||
const handleCampaignPanelDelete = async (campaignId) => {
|
||
await api.delete(`/campaigns/${campaignId}`)
|
||
navigate('/campaigns')
|
||
}
|
||
|
||
const handleTrackPanelSave = async (trackId, data) => {
|
||
if (trackId) {
|
||
await api.patch(`/tracks/${trackId}`, data)
|
||
} else {
|
||
await api.post(`/campaigns/${id}/tracks`, data)
|
||
}
|
||
setPanelTrack(null)
|
||
loadAll()
|
||
}
|
||
|
||
const handleTrackPanelDelete = async (trackId) => {
|
||
await api.delete(`/tracks/${trackId}`)
|
||
setPanelTrack(null)
|
||
loadAll()
|
||
}
|
||
|
||
const deleteTrack = async (trackId) => {
|
||
setTrackToDelete(trackId)
|
||
setShowDeleteConfirm(true)
|
||
}
|
||
|
||
const confirmDeleteTrack = async () => {
|
||
if (!trackToDelete) return
|
||
await api.delete(`/tracks/${trackToDelete}`)
|
||
setTrackToDelete(null)
|
||
loadAll()
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="space-y-6 animate-pulse">
|
||
<div className="flex items-start gap-4">
|
||
<div className="w-8 h-8 bg-surface-tertiary rounded-lg"></div>
|
||
<div className="flex-1">
|
||
<div className="h-6 bg-surface-tertiary rounded w-64 mb-2"></div>
|
||
<div className="h-4 bg-surface-tertiary rounded w-96 mb-2"></div>
|
||
<div className="h-3 bg-surface-tertiary rounded w-48"></div>
|
||
</div>
|
||
</div>
|
||
<div className="h-24 bg-surface-tertiary rounded-xl"></div>
|
||
<div className="h-48 bg-surface-tertiary rounded-xl"></div>
|
||
<div className="h-64 bg-surface-tertiary rounded-xl"></div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!campaign) {
|
||
return (
|
||
<div className="text-center py-12 text-text-tertiary">
|
||
{t('campaigns.notFound')} <button onClick={() => navigate('/campaigns')} className="text-brand-primary underline">{t('common.goBack')}</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Aggregates from tracks
|
||
const totalAllocated = tracks.reduce((s, t) => s + (t.budget_allocated || 0), 0)
|
||
const totalSpent = tracks.reduce((s, t) => s + (t.budget_spent || 0), 0)
|
||
const totalImpressions = tracks.reduce((s, t) => s + (t.impressions || 0), 0)
|
||
const totalClicks = tracks.reduce((s, t) => s + (t.clicks || 0), 0)
|
||
const totalConversions = tracks.reduce((s, t) => s + (t.conversions || 0), 0)
|
||
const totalRevenue = tracks.reduce((s, t) => s + (t.revenue || 0), 0)
|
||
|
||
return (
|
||
<div className="flex gap-6 animate-fade-in">
|
||
{/* Main content */}
|
||
<div className={`space-y-6 min-w-0 ${showDiscussion ? 'flex-1' : 'w-full'}`}>
|
||
{/* Header */}
|
||
<div className="flex items-start gap-4">
|
||
<button onClick={() => navigate('/campaigns')} className="mt-1 p-1.5 hover:bg-surface-tertiary rounded-lg">
|
||
<ArrowLeft className="w-5 h-5 text-text-secondary" />
|
||
</button>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-3 mb-1">
|
||
<h1 className="text-xl font-bold text-text-primary">{campaign.name}</h1>
|
||
<StatusBadge status={campaign.status} />
|
||
{(campaign.brand_id || campaign.brand_name) && <BrandBadge brand={getBrandName(campaign.brand_id) || campaign.brand_name} />}
|
||
</div>
|
||
{campaign.description && <p className="text-sm text-text-secondary">{campaign.description}</p>}
|
||
<div className="flex items-center gap-3 mt-2 text-xs text-text-tertiary">
|
||
{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.platforms && campaign.platforms.length > 0 && (
|
||
<PlatformIcons platforms={campaign.platforms} size={16} />
|
||
)}
|
||
</div>
|
||
</div>
|
||
{/* Action buttons */}
|
||
<div className="flex items-center gap-2 shrink-0">
|
||
<button
|
||
onClick={() => setShowDiscussion(prev => !prev)}
|
||
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||
showDiscussion
|
||
? 'bg-brand-primary text-white shadow-sm'
|
||
: 'bg-surface-tertiary text-text-secondary hover:bg-surface-tertiary/80 hover:text-text-primary'
|
||
}`}
|
||
>
|
||
<MessageCircle className="w-4 h-4" />
|
||
{t('campaigns.discussion')}
|
||
</button>
|
||
{canManage && (
|
||
<button
|
||
onClick={() => setPanelCampaign(campaign)}
|
||
className="flex items-center gap-1.5 px-3 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm transition-colors"
|
||
>
|
||
<Settings className="w-4 h-4" />
|
||
{t('common.edit')}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Budget Card */}
|
||
<div className="bg-surface 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">{t('campaigns.budget')}</h3>
|
||
{canSetBudget && (
|
||
<button onClick={() => { setBudgetValue(campaign.budget || ''); setEditingBudget(true) }}
|
||
className="text-xs text-brand-primary hover:text-brand-primary-light font-medium">
|
||
{t('common.edit')}
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="flex items-baseline gap-2 mb-3">
|
||
<span className="text-2xl font-bold text-text-primary">
|
||
{totalAllocated.toLocaleString()} {currencySymbol}
|
||
</span>
|
||
<span className="text-sm text-text-tertiary">{t('finance.allocated')}</span>
|
||
</div>
|
||
{totalAllocated > 0 && (
|
||
<>
|
||
<BudgetBar budget={totalAllocated} spent={totalSpent} height="h-2.5" />
|
||
<div className="flex justify-between mt-2 text-xs text-text-tertiary">
|
||
<span>{totalSpent.toLocaleString()} {currencySymbol} {t('dashboard.spent')}</span>
|
||
<span>{(totalAllocated - totalSpent).toLocaleString()} {currencySymbol} {t('dashboard.remaining')}</span>
|
||
</div>
|
||
</>
|
||
)}
|
||
{(totalImpressions > 0 || totalClicks > 0) && (
|
||
<div className="flex items-center gap-4 mt-4 pt-3 border-t border-border-light text-xs text-text-secondary">
|
||
<span><Eye className="w-3.5 h-3.5 inline me-1" />{totalImpressions.toLocaleString()}</span>
|
||
<span><MousePointer className="w-3.5 h-3.5 inline me-1" />{totalClicks.toLocaleString()}</span>
|
||
{totalConversions > 0 && <span><Target className="w-3.5 h-3.5 inline me-1" />{totalConversions.toLocaleString()}</span>}
|
||
{totalRevenue > 0 && <span><DollarSign className="w-3.5 h-3.5 inline me-1" />{totalRevenue.toLocaleString()} {currencySymbol}</span>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Tracks */}
|
||
<div className="bg-surface rounded-xl border border-border overflow-clip">
|
||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
||
<h3 className="font-semibold text-text-primary">{t('campaigns.tracks')}</h3>
|
||
{canManage && (
|
||
<button
|
||
onClick={() => { setPanelTrack({}); setTrackScrollToMetrics(false) }}
|
||
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" /> {t('campaigns.addTrack')}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{tracks.length === 0 ? (
|
||
<div className="py-12 text-center text-sm text-text-tertiary">
|
||
{t('campaigns.noTracks')}
|
||
</div>
|
||
) : (
|
||
<div className="divide-y divide-border-light">
|
||
{tracks.map(track => {
|
||
const typeInfo = TRACK_TYPES[track.type] || TRACK_TYPES.organic_social
|
||
const TypeIcon = typeInfo.icon
|
||
const trackPosts = posts.filter(p => p.track_id === track.id)
|
||
return (
|
||
<div key={track.id} className="px-5 py-4">
|
||
<div className="flex items-start gap-3">
|
||
<div className={`p-2 rounded-lg ${typeInfo.color}`}>
|
||
<TypeIcon className="w-4 h-4" />
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 mb-0.5">
|
||
<h4 className="text-sm font-semibold text-text-primary">
|
||
{track.name || typeInfo.label}
|
||
</h4>
|
||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-tertiary">
|
||
{typeInfo.label}
|
||
</span>
|
||
{track.platform && (
|
||
<PlatformIcon platform={track.platform} size={16} />
|
||
)}
|
||
<StatusBadge status={track.status} size="xs" />
|
||
</div>
|
||
|
||
{/* Budget bar for paid tracks */}
|
||
{track.budget_allocated > 0 && (
|
||
<div className="w-48 mt-1.5">
|
||
<BudgetBar budget={track.budget_allocated} spent={track.budget_spent || 0} height="h-2" />
|
||
</div>
|
||
)}
|
||
|
||
{/* Quick metrics */}
|
||
{(track.impressions > 0 || track.clicks > 0 || track.conversions > 0) && (
|
||
<div className="flex items-center gap-3 mt-1.5 text-[10px] text-text-tertiary">
|
||
{track.impressions > 0 && <span><Eye className="w-3 h-3 inline" /> {track.impressions.toLocaleString()}</span>}
|
||
{track.clicks > 0 && <span><MousePointer className="w-3 h-3 inline" /> {track.clicks.toLocaleString()}</span>}
|
||
{track.conversions > 0 && <span><Target className="w-3 h-3 inline" /> {track.conversions.toLocaleString()}</span>}
|
||
{track.clicks > 0 && track.budget_spent > 0 && (
|
||
<span>CPC: {(track.budget_spent / track.clicks).toFixed(2)} {currencySymbol}</span>
|
||
)}
|
||
{track.impressions > 0 && track.clicks > 0 && (
|
||
<span>CTR: {(track.clicks / track.impressions * 100).toFixed(2)}%</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Linked posts count */}
|
||
{trackPosts.length > 0 && (
|
||
<div className="text-[10px] text-text-tertiary mt-1">
|
||
<FileText className="w-3 h-3 inline" /> {trackPosts.length} {t('campaigns.postsLinked')}
|
||
</div>
|
||
)}
|
||
|
||
{track.notes && (
|
||
<p className="text-xs text-text-secondary mt-1 line-clamp-1">{track.notes}</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Actions */}
|
||
{canManage && (
|
||
<div className="flex items-center gap-1 shrink-0">
|
||
<button
|
||
onClick={() => { setPanelTrack(track); setTrackScrollToMetrics(true) }}
|
||
title="Update metrics"
|
||
className="p-1.5 hover:bg-surface-tertiary rounded-lg text-text-tertiary hover:text-brand-primary"
|
||
>
|
||
<TrendingUp className="w-4 h-4" />
|
||
</button>
|
||
<button
|
||
onClick={() => { setPanelTrack(track); setTrackScrollToMetrics(false) }}
|
||
title="Edit track"
|
||
className="p-1.5 hover:bg-surface-tertiary rounded-lg text-text-tertiary hover:text-text-primary"
|
||
>
|
||
<Edit2 className="w-4 h-4" />
|
||
</button>
|
||
<button
|
||
onClick={() => deleteTrack(track.id)}
|
||
title="Delete track"
|
||
className="p-1.5 hover:bg-red-50 rounded-lg text-text-tertiary hover:text-red-500"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Team */}
|
||
{(assignments.length > 0 || canAssign) && (
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-xs text-text-tertiary font-medium">{t('campaigns.team')}:</span>
|
||
<div className="flex -space-x-1.5">
|
||
{assignments.slice(0, 6).map(a => (
|
||
<div key={a.user_id} className="w-7 h-7 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-bold border-2 border-surface" title={a.user_name}>
|
||
{a.user_avatar ? <img src={a.user_avatar} className="w-full h-full rounded-full object-cover" alt="" loading="lazy" /> : getInitials(a.user_name)}
|
||
</div>
|
||
))}
|
||
{assignments.length > 6 && <div className="w-7 h-7 rounded-full bg-surface-tertiary flex items-center justify-center text-[10px] text-text-tertiary font-medium border-2 border-surface">+{assignments.length - 6}</div>}
|
||
</div>
|
||
{canAssign && (
|
||
<button onClick={openAssignModal} className="text-xs text-brand-primary hover:text-brand-primary-light font-medium">
|
||
{t('campaigns.assignMembers')}
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Linked Posts */}
|
||
{posts.length > 0 && (
|
||
<div className="bg-surface rounded-xl border border-border overflow-clip">
|
||
<div className="px-5 py-4 border-b border-border">
|
||
<h3 className="font-semibold text-text-primary">{t('campaigns.linkedPosts')} ({posts.length})</h3>
|
||
</div>
|
||
<div className="divide-y divide-border-light">
|
||
{posts.map(post => (
|
||
<div
|
||
key={post.id}
|
||
onClick={() => navigate(`/posts/${post._id || post.id || post.Id}`)}
|
||
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" loading="lazy" />
|
||
)}
|
||
<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>
|
||
<StatusBadge status={post.status} size="xs" />
|
||
</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} />
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
</div>{/* end main content */}
|
||
|
||
{/* ─── DISCUSSION SIDEBAR ─── */}
|
||
{showDiscussion && (
|
||
<div className="w-[340px] shrink-0 bg-surface rounded-xl border border-border flex flex-col self-start sticky top-4" style={{ maxHeight: 'calc(100vh - 6rem)' }}>
|
||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-1.5">
|
||
<MessageCircle className="w-4 h-4" />
|
||
{t('campaigns.discussion')}
|
||
</h3>
|
||
<button onClick={() => setShowDiscussion(false)} className="p-1 hover:bg-surface-tertiary rounded-lg text-text-tertiary">
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
<div className="flex-1 overflow-y-auto p-4">
|
||
<CommentsSection entityType="campaign" entityId={Number(id)} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Delete Track Confirmation */}
|
||
<Modal
|
||
isOpen={showDeleteConfirm}
|
||
onClose={() => { setShowDeleteConfirm(false); setTrackToDelete(null) }}
|
||
title="Delete Track?"
|
||
isConfirm
|
||
danger
|
||
confirmText="Delete Track"
|
||
onConfirm={confirmDeleteTrack}
|
||
>
|
||
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="" loading="lazy" />
|
||
) : (
|
||
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 ({currencySymbol})</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>
|
||
|
||
{/* Campaign Edit Panel */}
|
||
{panelCampaign && (
|
||
<CampaignDetailPanel
|
||
campaign={panelCampaign}
|
||
onClose={() => setPanelCampaign(null)}
|
||
onSave={handleCampaignPanelSave}
|
||
onDelete={permissions?.canDeleteCampaigns ? handleCampaignPanelDelete : null}
|
||
brands={brands}
|
||
permissions={permissions}
|
||
/>
|
||
)}
|
||
|
||
{/* Track Detail Panel */}
|
||
{panelTrack && (
|
||
<TrackDetailPanel
|
||
track={panelTrack}
|
||
campaignId={id}
|
||
onClose={() => setPanelTrack(null)}
|
||
onSave={handleTrackPanelSave}
|
||
onDelete={handleTrackPanelDelete}
|
||
scrollToMetrics={trackScrollToMetrics}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|