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>
403 lines
19 KiB
React
403 lines
19 KiB
React
import { useState, useEffect, useContext } from 'react'
|
||
import { useNavigate } from 'react-router-dom'
|
||
import { Plus, Search, TrendingUp, DollarSign, Eye, MousePointer, Target, BarChart3 } 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 } from '../utils/api'
|
||
import { PlatformIcons } from '../components/PlatformIcon'
|
||
import StatusBadge from '../components/StatusBadge'
|
||
import BrandBadge from '../components/BrandBadge'
|
||
import BudgetBar from '../components/BudgetBar'
|
||
import InteractiveTimeline from '../components/InteractiveTimeline'
|
||
import CampaignDetailPanel from '../components/CampaignDetailPanel'
|
||
import Modal from '../components/Modal'
|
||
import { SkeletonStatCard, SkeletonTable } from '../components/SkeletonLoader'
|
||
|
||
const EMPTY_CAMPAIGN = {
|
||
name: '', description: '', brand_id: '', status: 'planning',
|
||
start_date: '', end_date: '', budget: '', team_id: '',
|
||
}
|
||
|
||
function ROIBadge({ revenue, spent }) {
|
||
if (!spent || spent <= 0) return null
|
||
const roi = ((revenue - spent) / spent * 100).toFixed(0)
|
||
const color = roi >= 0 ? 'text-emerald-600 bg-emerald-50' : 'text-red-600 bg-red-50'
|
||
return (
|
||
<span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded ${color}`}>
|
||
ROI {roi}%
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function MetricCard({ icon: Icon, label, value, color = 'text-text-primary' }) {
|
||
return (
|
||
<div className="bg-surface-secondary rounded-lg p-3 text-center">
|
||
<Icon className={`w-4 h-4 mx-auto mb-1 ${color}`} />
|
||
<div className={`text-sm font-bold ${color}`}>{value || '—'}</div>
|
||
<div className="text-[10px] text-text-tertiary">{label}</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function Campaigns() {
|
||
const { brands, getBrandName, teams } = useContext(AppContext)
|
||
const { t, lang, currencySymbol } = useLanguage()
|
||
const { permissions } = useAuth()
|
||
const navigate = useNavigate()
|
||
const [campaigns, setCampaigns] = useState([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [panelCampaign, setPanelCampaign] = useState(null)
|
||
const [filters, setFilters] = useState({ brand: '', status: '' })
|
||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||
const [createForm, setCreateForm] = useState({ ...EMPTY_CAMPAIGN })
|
||
const [createSaving, setCreateSaving] = useState(false)
|
||
|
||
useEffect(() => { loadCampaigns() }, [])
|
||
|
||
const loadCampaigns = async () => {
|
||
try {
|
||
const res = await api.get('/campaigns')
|
||
setCampaigns(Array.isArray(res) ? res : [])
|
||
} catch (err) {
|
||
console.error('Failed to load campaigns:', err)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const handlePanelSave = async (campaignId, data) => {
|
||
if (campaignId) {
|
||
await api.patch(`/campaigns/${campaignId}`, data)
|
||
} else {
|
||
await api.post('/campaigns', data)
|
||
}
|
||
loadCampaigns()
|
||
}
|
||
|
||
const handlePanelDelete = async (campaignId) => {
|
||
await api.delete(`/campaigns/${campaignId}`)
|
||
loadCampaigns()
|
||
}
|
||
|
||
const openNew = () => {
|
||
setCreateForm({ ...EMPTY_CAMPAIGN })
|
||
setShowCreateModal(true)
|
||
}
|
||
|
||
const handleCreate = async () => {
|
||
setCreateSaving(true)
|
||
try {
|
||
const data = {
|
||
name: createForm.name,
|
||
description: createForm.description,
|
||
brand_id: createForm.brand_id ? Number(createForm.brand_id) : null,
|
||
status: createForm.status,
|
||
start_date: createForm.start_date || null,
|
||
end_date: createForm.end_date || null,
|
||
budget: createForm.budget ? Number(createForm.budget) : null,
|
||
team_id: createForm.team_id ? Number(createForm.team_id) : null,
|
||
}
|
||
const created = await api.post('/campaigns', data)
|
||
setShowCreateModal(false)
|
||
loadCampaigns()
|
||
// Navigate to the new campaign detail page
|
||
const id = created?.Id || created?.id || created?._id
|
||
if (id) navigate(`/campaigns/${id}`)
|
||
} catch (err) {
|
||
console.error('Create campaign failed:', err)
|
||
} finally {
|
||
setCreateSaving(false)
|
||
}
|
||
}
|
||
|
||
const filtered = campaigns.filter(c => {
|
||
if (filters.brand && String(c.brandId || c.brand_id) !== filters.brand) return false
|
||
if (filters.status && c.status !== filters.status) return false
|
||
return true
|
||
})
|
||
|
||
// Aggregate stats
|
||
const totalBudget = filtered.reduce((sum, c) => sum + (c.budget || 0), 0)
|
||
const totalSpent = filtered.reduce((sum, c) => sum + (c.budgetSpent || c.budget_spent || 0), 0)
|
||
const totalImpressions = filtered.reduce((sum, c) => sum + (c.impressions || 0), 0)
|
||
const totalClicks = filtered.reduce((sum, c) => sum + (c.clicks || 0), 0)
|
||
const totalConversions = filtered.reduce((sum, c) => sum + (c.conversions || 0), 0)
|
||
const totalRevenue = filtered.reduce((sum, c) => sum + (c.revenue || 0), 0)
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="h-12 bg-surface-tertiary rounded-xl animate-pulse"></div>
|
||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
||
{[...Array(6)].map((_, i) => <SkeletonStatCard key={i} />)}
|
||
</div>
|
||
<SkeletonTable rows={5} cols={6} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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-surface 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}>{lang === 'ar' && b.name_ar ? b.name_ar : 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-surface 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 ms-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 stagger-children">
|
||
<div className="bg-surface rounded-xl border border-border p-4">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<DollarSign className="w-4 h-4 text-blue-500" />
|
||
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Budget</span>
|
||
</div>
|
||
<div className="text-lg font-bold text-text-primary">{totalBudget.toLocaleString()}</div>
|
||
<div className="text-[10px] text-text-tertiary">{currencySymbol} total</div>
|
||
</div>
|
||
<div className="bg-surface rounded-xl border border-border p-4">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<TrendingUp className="w-4 h-4 text-amber-500" />
|
||
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Spent</span>
|
||
</div>
|
||
<div className="text-lg font-bold text-text-primary">{totalSpent.toLocaleString()}</div>
|
||
<div className="text-[10px] text-text-tertiary">{currencySymbol} spent</div>
|
||
</div>
|
||
<div className="bg-surface rounded-xl border border-border p-4">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<Eye className="w-4 h-4 text-purple-500" />
|
||
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Impressions</span>
|
||
</div>
|
||
<div className="text-lg font-bold text-text-primary">{totalImpressions.toLocaleString()}</div>
|
||
</div>
|
||
<div className="bg-surface rounded-xl border border-border p-4">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<MousePointer className="w-4 h-4 text-green-500" />
|
||
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Clicks</span>
|
||
</div>
|
||
<div className="text-lg font-bold text-text-primary">{totalClicks.toLocaleString()}</div>
|
||
</div>
|
||
<div className="bg-surface rounded-xl border border-border p-4">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<Target className="w-4 h-4 text-red-500" />
|
||
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Conversions</span>
|
||
</div>
|
||
<div className="text-lg font-bold text-text-primary">{totalConversions.toLocaleString()}</div>
|
||
</div>
|
||
<div className="bg-surface rounded-xl border border-border p-4">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<BarChart3 className="w-4 h-4 text-emerald-500" />
|
||
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Revenue</span>
|
||
</div>
|
||
<div className="text-lg font-bold text-text-primary">{totalRevenue.toLocaleString()}</div>
|
||
<div className="text-[10px] text-text-tertiary">{currencySymbol}</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Timeline */}
|
||
<InteractiveTimeline
|
||
items={filtered}
|
||
mapItem={(campaign) => ({
|
||
id: campaign._id || campaign.id,
|
||
label: campaign.name,
|
||
description: campaign.description,
|
||
startDate: campaign.startDate || campaign.start_date || campaign.createdAt,
|
||
endDate: campaign.endDate || campaign.end_date,
|
||
status: campaign.status,
|
||
assigneeName: campaign.brandName || campaign.brand_name,
|
||
tags: campaign.platforms || [],
|
||
color: campaign.color,
|
||
})}
|
||
onDateChange={async (campaignId, { startDate, endDate }) => {
|
||
try {
|
||
await api.patch(`/campaigns/${campaignId}`, { start_date: startDate, end_date: endDate })
|
||
} catch (err) {
|
||
console.error('Timeline date update failed:', err)
|
||
} finally {
|
||
loadCampaigns()
|
||
}
|
||
}}
|
||
onColorChange={async (campaignId, color) => {
|
||
try {
|
||
await api.patch(`/campaigns/${campaignId}`, { color: color || '' })
|
||
} catch (err) {
|
||
console.error('Color update failed:', err)
|
||
} finally {
|
||
loadCampaigns()
|
||
}
|
||
}}
|
||
onItemClick={(campaign) => {
|
||
navigate(`/campaigns/${campaign._id || campaign.id}`)
|
||
}}
|
||
/>
|
||
|
||
{/* Campaign list */}
|
||
<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">All Campaigns</h3>
|
||
</div>
|
||
<div className="divide-y divide-border-light">
|
||
{filtered.length === 0 ? (
|
||
<div className="py-12 text-center text-sm text-text-tertiary">
|
||
No campaigns found
|
||
</div>
|
||
) : (
|
||
filtered.map(campaign => {
|
||
const spent = campaign.budgetSpent || campaign.budget_spent || 0
|
||
const budget = campaign.budget || 0
|
||
return (
|
||
<div
|
||
key={campaign.id || campaign._id}
|
||
onClick={() => navigate(`/campaigns/${campaign.id || campaign._id}`)}
|
||
className="relative px-5 py-4 hover:bg-surface-secondary cursor-pointer transition-colors"
|
||
>
|
||
<div className="flex items-center gap-4">
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<h4 className="text-sm font-semibold text-text-primary">{campaign.name}</h4>
|
||
{(campaign.brand_id || campaign.brandName) && <BrandBadge brand={getBrandName(campaign.brand_id) || campaign.brandName} />}
|
||
<ROIBadge revenue={campaign.revenue || 0} spent={spent} />
|
||
</div>
|
||
{campaign.description && (
|
||
<p className="text-xs text-text-secondary line-clamp-1">{campaign.description}</p>
|
||
)}
|
||
<div className="flex items-center gap-3 mt-1.5">
|
||
{budget > 0 && (
|
||
<div className="w-32">
|
||
<BudgetBar budget={budget} spent={spent} />
|
||
</div>
|
||
)}
|
||
{(campaign.impressions > 0 || campaign.clicks > 0) && (
|
||
<div className="flex items-center gap-3 text-[10px] text-text-tertiary">
|
||
{campaign.impressions > 0 && <span>👁 {campaign.impressions.toLocaleString()}</span>}
|
||
{campaign.clicks > 0 && <span>🖱 {campaign.clicks.toLocaleString()}</span>}
|
||
{campaign.conversions > 0 && <span>🎯 {campaign.conversions.toLocaleString()}</span>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="text-end shrink-0">
|
||
<StatusBadge status={campaign.status} size="xs" />
|
||
<div className="text-xs text-text-tertiary mt-1">
|
||
{campaign.startDate && campaign.endDate ? (
|
||
<>
|
||
{format(new Date(campaign.startDate), 'MMM d')} – {format(new Date(campaign.endDate), 'MMM d, yyyy')}
|
||
</>
|
||
) : '—'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{campaign.platforms && campaign.platforms.length > 0 && (
|
||
<div className="flex justify-end mt-2">
|
||
<PlatformIcons platforms={campaign.platforms} size={16} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Create Campaign Modal */}
|
||
<Modal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} title={t('campaigns.newCampaign') || 'New Campaign'} size="md">
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.name')} *</label>
|
||
<input type="text" value={createForm.name} onChange={e => setCreateForm(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" autoFocus />
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.description')}</label>
|
||
<textarea value={createForm.description} onChange={e => setCreateForm(f => ({ ...f, description: 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="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.brand')}</label>
|
||
<select value={createForm.brand_id} onChange={e => setCreateForm(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="">{t('posts.allBrands')}</option>
|
||
{brands.map(b => <option key={b.id || b._id} value={b.id || b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('common.team')}</label>
|
||
<select value={createForm.team_id} onChange={e => setCreateForm(f => ({ ...f, team_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="">{t('common.noTeam')}</option>
|
||
{(teams || []).map(team => <option key={team.id || team._id} value={team.id || team._id}>{team.name}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.startDate')}</label>
|
||
<input type="date" value={createForm.start_date} onChange={e => setCreateForm(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-xs font-medium text-text-tertiary mb-1">{t('campaigns.endDate')}</label>
|
||
<input type="date" value={createForm.end_date} onChange={e => setCreateForm(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-xs font-medium text-text-tertiary mb-1">{t('campaigns.budget')}</label>
|
||
<input type="number" value={createForm.budget} onChange={e => setCreateForm(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" placeholder="0" />
|
||
</div>
|
||
<button onClick={handleCreate} disabled={!createForm.name || createSaving}
|
||
className={`w-full px-4 py-2.5 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 ${createSaving ? 'btn-loading' : ''}`}>
|
||
{t('campaigns.newCampaign') || 'Create Campaign'}
|
||
</button>
|
||
</div>
|
||
</Modal>
|
||
|
||
{/* Campaign Panel (edit only) */}
|
||
{panelCampaign && (
|
||
<CampaignDetailPanel
|
||
campaign={panelCampaign}
|
||
onClose={() => setPanelCampaign(null)}
|
||
onSave={handlePanelSave}
|
||
onDelete={permissions?.canDeleteCampaigns ? handlePanelDelete : null}
|
||
brands={brands}
|
||
permissions={permissions}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|