Files
marketing-app/client/src/pages/CampaignDetail.jsx
2026-02-10 21:03:36 +03:00

1149 lines
50 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, Users, X, UserPlus, 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'
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']
const EMPTY_TRACK = {
name: '', type: 'organic_social', platform: '', budget_allocated: '', status: 'planned', notes: '',
}
const EMPTY_METRICS = {
budget_spent: '', revenue: '', impressions: '', clicks: '', conversions: '', notes: '',
}
function MetricBox({ label, value, icon: Icon, color = 'text-text-primary' }) {
return (
<div className="text-center">
<Icon className={`w-4 h-4 mx-auto mb-0.5 ${color}`} />
<div className={`text-base font-bold ${color}`}>{value ?? '—'}</div>
<div className="text-[10px] text-text-tertiary">{label}</div>
</div>
)
}
export default function CampaignDetail() {
const { id } = useParams()
const navigate = useNavigate()
const { brands, getBrandName } = useContext(AppContext)
const { lang } = 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 [showTrackModal, setShowTrackModal] = useState(false)
const [editingTrack, setEditingTrack] = useState(null)
const [trackForm, setTrackForm] = useState(EMPTY_TRACK)
const [showMetricsModal, setShowMetricsModal] = useState(false)
const [metricsTrack, setMetricsTrack] = useState(null)
const [metricsForm, setMetricsForm] = useState(EMPTY_METRICS)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [trackToDelete, setTrackToDelete] = useState(null)
const [selectedPost, setSelectedPost] = useState(null)
const [showDiscussion, setShowDiscussion] = useState(false)
const [showEditModal, setShowEditModal] = useState(false)
const [editForm, setEditForm] = useState({})
const [showDeleteCampaignConfirm, setShowDeleteCampaignConfirm] = 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])
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.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 {
setLoading(false)
}
}
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 = {
name: trackForm.name,
type: trackForm.type,
platform: trackForm.platform || null,
budget_allocated: trackForm.budget_allocated ? Number(trackForm.budget_allocated) : 0,
status: trackForm.status,
notes: trackForm.notes,
}
if (editingTrack) {
await api.patch(`/tracks/${editingTrack.id}`, data)
} else {
await api.post(`/campaigns/${id}/tracks`, data)
}
setShowTrackModal(false)
setEditingTrack(null)
setTrackForm(EMPTY_TRACK)
loadAll()
} catch (err) {
console.error('Save track failed:', err)
}
}
const deleteTrack = async (trackId) => {
setTrackToDelete(trackId)
setShowDeleteConfirm(true)
}
const confirmDeleteTrack = async () => {
if (!trackToDelete) return
await api.delete(`/tracks/${trackToDelete}`)
setTrackToDelete(null)
loadAll()
}
const saveMetrics = async () => {
try {
await api.patch(`/tracks/${metricsTrack.id}`, {
budget_spent: metricsForm.budget_spent ? Number(metricsForm.budget_spent) : 0,
revenue: metricsForm.revenue ? Number(metricsForm.revenue) : 0,
impressions: metricsForm.impressions ? Number(metricsForm.impressions) : 0,
clicks: metricsForm.clicks ? Number(metricsForm.clicks) : 0,
conversions: metricsForm.conversions ? Number(metricsForm.conversions) : 0,
notes: metricsForm.notes || '',
})
setShowMetricsModal(false)
setMetricsTrack(null)
loadAll()
} catch (err) {
console.error('Save metrics failed:', err)
}
}
const openEditTrack = (track) => {
setEditingTrack(track)
setTrackForm({
name: track.name || '',
type: track.type || 'organic_social',
platform: track.platform || '',
budget_allocated: track.budget_allocated || '',
status: track.status || 'planned',
notes: track.notes || '',
})
setShowTrackModal(true)
}
const openEditCampaign = () => {
setEditForm({
name: campaign.name || '',
description: campaign.description || '',
status: campaign.status || 'planning',
start_date: campaign.start_date ? new Date(campaign.start_date).toISOString().slice(0, 10) : '',
end_date: campaign.end_date ? new Date(campaign.end_date).toISOString().slice(0, 10) : '',
goals: campaign.goals || '',
platforms: campaign.platforms || [],
notes: campaign.notes || '',
brand_id: campaign.brand_id || '',
budget: campaign.budget || '',
})
setShowEditModal(true)
}
const saveCampaignEdit = async () => {
try {
await api.patch(`/campaigns/${id}`, {
name: editForm.name,
description: editForm.description,
status: editForm.status,
start_date: editForm.start_date,
end_date: editForm.end_date,
goals: editForm.goals,
platforms: editForm.platforms,
notes: editForm.notes,
brand_id: editForm.brand_id || null,
budget: editForm.budget ? Number(editForm.budget) : null,
})
setShowEditModal(false)
loadAll()
} catch (err) {
console.error('Failed to update campaign:', err)
}
}
const openMetrics = (track) => {
setMetricsTrack(track)
setMetricsForm({
budget_spent: track.budget_spent || '',
revenue: track.revenue || '',
impressions: track.impressions || '',
clicks: track.clicks || '',
conversions: track.conversions || '',
notes: track.notes || '',
})
setShowMetricsModal(true)
}
if (loading) {
return <div className="animate-pulse"><div className="h-64 bg-surface-tertiary rounded-xl"></div></div>
}
if (!campaign) {
return (
<div className="text-center py-12 text-text-tertiary">
Campaign not found. <button onClick={() => navigate('/campaigns')} className="text-brand-primary underline">Go back</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>
)}
<span>
Budget: {campaign.budget > 0 ? `${campaign.budget.toLocaleString()} SAR` : 'Not set'}
</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" />
Discussion
</button>
{canSetBudget && (
<button
onClick={() => { setBudgetValue(campaign.budget || ''); setEditingBudget(true) }}
className="flex items-center gap-1.5 px-3 py-2 bg-surface-tertiary text-text-secondary hover:bg-surface-tertiary/80 hover:text-text-primary rounded-lg text-sm font-medium transition-colors"
>
<DollarSign className="w-4 h-4" />
Budget
</button>
)}
{canManage && (
<button
onClick={openEditCampaign}
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" />
Edit
</button>
)}
</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">
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">Campaign Totals (from tracks)</h3>
<div className="grid grid-cols-3 md:grid-cols-6 gap-4">
<MetricBox icon={DollarSign} label="Allocated" value={`${totalAllocated.toLocaleString()}`} color="text-blue-600" />
<MetricBox icon={TrendingUp} label="Spent" value={`${totalSpent.toLocaleString()}`} color="text-amber-600" />
<MetricBox icon={Eye} label="Impressions" value={totalImpressions.toLocaleString()} color="text-purple-600" />
<MetricBox icon={MousePointer} label="Clicks" value={totalClicks.toLocaleString()} color="text-green-600" />
<MetricBox icon={Target} label="Conversions" value={totalConversions.toLocaleString()} color="text-red-600" />
<MetricBox icon={DollarSign} label="Revenue" value={`${totalRevenue.toLocaleString()}`} color="text-emerald-600" />
</div>
{totalAllocated > 0 && (
<div className="mt-4">
<BudgetBar budget={totalAllocated} spent={totalSpent} height="h-2" />
</div>
)}
</div>
)}
{/* Tracks */}
<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">Tracks</h3>
{canManage && (
<button
onClick={() => { setEditingTrack(null); setTrackForm(EMPTY_TRACK); setShowTrackModal(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 Track
</button>
)}
</div>
{tracks.length === 0 ? (
<div className="py-12 text-center text-sm text-text-tertiary">
No tracks yet. Add organic, paid, or SEO tracks to organize this campaign.
</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>👁 {track.impressions.toLocaleString()}</span>}
{track.clicks > 0 && <span>🖱 {track.clicks.toLocaleString()}</span>}
{track.conversions > 0 && <span>🎯 {track.conversions.toLocaleString()}</span>}
{track.clicks > 0 && track.budget_spent > 0 && (
<span>CPC: {(track.budget_spent / track.clicks).toFixed(2)} SAR</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">
📝 {trackPosts.length} post{trackPosts.length !== 1 ? 's' : ''} linked
</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={() => openMetrics(track)}
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={() => openEditTrack(track)}
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>
{/* Linked Posts */}
{posts.length > 0 && (
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">Linked Posts ({posts.length})</h3>
</div>
<div className="divide-y divide-border-light">
{posts.map(post => (
<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>
<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-white 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" />
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>
)}
{/* Add/Edit Track Modal */}
<Modal
isOpen={showTrackModal}
onClose={() => { setShowTrackModal(false); setEditingTrack(null) }}
title={editingTrack ? 'Edit Track' : 'Add Track'}
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Track Name</label>
<input
type="text"
value={trackForm.name}
onChange={e => setTrackForm(f => ({ ...f, name: 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"
placeholder="e.g., Instagram Paid Ads, Organic Wave, Google Search..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Type</label>
<select
value={trackForm.type}
onChange={e => setTrackForm(f => ({ ...f, type: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
>
{Object.entries(TRACK_TYPES).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Platform</label>
<select
value={trackForm.platform}
onChange={e => setTrackForm(f => ({ ...f, platform: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
>
<option value="">All / Multiple</option>
{Object.entries(PLATFORMS).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
<option value="google_ads">Google Ads</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Budget Allocated (SAR)</label>
<input
type="number"
value={trackForm.budget_allocated}
onChange={e => setTrackForm(f => ({ ...f, budget_allocated: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
placeholder="0 for free/organic"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Status</label>
<select
value={trackForm.status}
onChange={e => setTrackForm(f => ({ ...f, status: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
>
{TRACK_STATUSES.map(s => (
<option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Notes</label>
<textarea
value={trackForm.notes}
onChange={e => setTrackForm(f => ({ ...f, notes: e.target.value }))}
rows={2}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none resize-none"
placeholder="Keywords, targeting details, content plan..."
/>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-border">
<button onClick={() => setShowTrackModal(false)} className="px-4 py-2 text-sm text-text-secondary hover:bg-surface-tertiary rounded-lg">Cancel</button>
<button onClick={saveTrack} className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm">
{editingTrack ? 'Save' : 'Add Track'}
</button>
</div>
</div>
</Modal>
{/* Update Metrics Modal */}
<Modal
isOpen={showMetricsModal}
onClose={() => { setShowMetricsModal(false); setMetricsTrack(null) }}
title={`Update Metrics — ${metricsTrack?.name || ''}`}
>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Budget Spent (SAR)</label>
<input
type="number"
value={metricsForm.budget_spent}
onChange={e => setMetricsForm(f => ({ ...f, budget_spent: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Revenue (SAR)</label>
<input
type="number"
value={metricsForm.revenue}
onChange={e => setMetricsForm(f => ({ ...f, revenue: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Impressions</label>
<input
type="number"
value={metricsForm.impressions}
onChange={e => setMetricsForm(f => ({ ...f, impressions: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Clicks</label>
<input
type="number"
value={metricsForm.clicks}
onChange={e => setMetricsForm(f => ({ ...f, clicks: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Conversions</label>
<input
type="number"
value={metricsForm.conversions}
onChange={e => setMetricsForm(f => ({ ...f, conversions: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Notes</label>
<textarea
value={metricsForm.notes}
onChange={e => setMetricsForm(f => ({ ...f, notes: e.target.value }))}
rows={2}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none resize-none"
placeholder="What's working, what to adjust..."
/>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-border">
<button onClick={() => setShowMetricsModal(false)} className="px-4 py-2 text-sm text-text-secondary hover:bg-surface-tertiary rounded-lg">Cancel</button>
<button onClick={saveMetrics} className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm">
Save Metrics
</button>
</div>
</div>
</Modal>
{/* 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="" />
) : (
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>
{/* Edit Campaign Modal */}
<Modal
isOpen={showEditModal}
onClose={() => setShowEditModal(false)}
title="Edit Campaign"
size="lg"
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Name *</label>
<input
type="text"
value={editForm.name || ''}
onChange={e => setEditForm(f => ({ ...f, name: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Brand</label>
<select
value={editForm.brand_id || ''}
onChange={e => setEditForm(f => ({ ...f, brand_id: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="">No brand</option>
{brands.map(b => (
<option key={b.id} value={b.id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Description</label>
<textarea
value={editForm.description || ''}
onChange={e => setEditForm(f => ({ ...f, description: e.target.value }))}
rows={3}
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 resize-none"
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Status</label>
<select
value={editForm.status || 'planning'}
onChange={e => setEditForm(f => ({ ...f, status: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value="planning">Planning</option>
<option value="active">Active</option>
<option value="paused">Paused</option>
<option value="completed">Completed</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Goals</label>
<input
type="text"
value={editForm.goals || ''}
onChange={e => setEditForm(f => ({ ...f, goals: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Budget (SAR)</label>
<input
type="number"
value={editForm.budget || ''}
onChange={e => setEditForm(f => ({ ...f, budget: e.target.value }))}
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="0"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Start Date</label>
<input
type="date"
value={editForm.start_date || ''}
onChange={e => setEditForm(f => ({ ...f, start_date: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">End Date</label>
<input
type="date"
value={editForm.end_date || ''}
onChange={e => setEditForm(f => ({ ...f, end_date: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Platforms</label>
<div className="flex flex-wrap gap-2 p-2 border border-border rounded-lg bg-white min-h-[42px]">
{Object.entries(PLATFORMS).map(([k, v]) => {
const checked = (editForm.platforms || []).includes(k)
return (
<label
key={k}
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-full cursor-pointer border transition-colors ${
checked
? 'bg-brand-primary/10 border-brand-primary/30 text-brand-primary font-medium'
: 'bg-surface-tertiary border-transparent text-text-secondary hover:bg-surface-tertiary/80'
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => {
setEditForm(f => ({
...f,
platforms: checked
? f.platforms.filter(p => p !== k)
: [...(f.platforms || []), k]
}))
}}
className="sr-only"
/>
{v.label}
</label>
)
})}
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Notes</label>
<textarea
value={editForm.notes || ''}
onChange={e => setEditForm(f => ({ ...f, notes: e.target.value }))}
rows={2}
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 resize-none"
/>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
{permissions?.canDeleteCampaigns && (
<button
onClick={() => setShowDeleteCampaignConfirm(true)}
className="px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg mr-auto"
>
Delete Campaign
</button>
)}
<button
onClick={() => setShowEditModal(false)}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
>
Cancel
</button>
<button
onClick={saveCampaignEdit}
disabled={!editForm.name}
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
>
Save Changes
</button>
</div>
</div>
</Modal>
{/* Delete Campaign Confirmation */}
<Modal
isOpen={showDeleteCampaignConfirm}
onClose={() => setShowDeleteCampaignConfirm(false)}
title="Delete Campaign?"
isConfirm
danger
confirmText="Delete Campaign"
onConfirm={async () => {
try {
await api.delete(`/campaigns/${id}`)
setShowDeleteCampaignConfirm(false)
setShowEditModal(false)
navigate('/campaigns')
} catch (err) {
console.error('Failed to delete campaign:', err)
}
}}
>
Are you sure you want to delete this campaign? All tracks and linked data will be permanently removed. This action cannot be undone.
</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>
)
}