- Fix Dashboard stat card: show "Budget Remaining" instead of "Budget Spent" with correct remaining value accounting for campaign allocations - Add expense system: budget entries now have income/expense type with server-side split, per-campaign and per-project expense tracking, colored amounts, type filters, and summary bar in Budgets page - Add configurable currency in Settings (SAR default, supports 10 currencies) replacing all hardcoded SAR references across the app - Replace PiggyBank icon with Landmark (culturally appropriate for KSA) - Visual upgrade: mesh background, gradient text, premium stat cards with accent bars, section-card containers, sidebar active glow - UX polish: consistent text-2xl headers, skeleton loaders for Finance and Budgets pages - Finance page: expenses column in campaign/project breakdown tables, ROI accounts for expenses, expense stat card Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
298 lines
13 KiB
JavaScript
298 lines
13 KiB
JavaScript
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'
|
||
|
||
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 } = useContext(AppContext)
|
||
const { 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: '' })
|
||
|
||
useEffect(() => { loadCampaigns() }, [])
|
||
|
||
const loadCampaigns = async () => {
|
||
try {
|
||
const res = await api.get('/campaigns')
|
||
setCampaigns(res.data || 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 = () => {
|
||
setPanelCampaign({ status: 'planning', platforms: [] })
|
||
}
|
||
|
||
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-4 animate-pulse">
|
||
<div className="h-12 bg-surface-tertiary rounded-xl"></div>
|
||
<div className="h-[400px] bg-surface-tertiary rounded-xl"></div>
|
||
</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-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}>{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-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">
|
||
<div className="bg-white 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-white 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-white 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-white 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-white 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-white 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 || [],
|
||
})}
|
||
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()
|
||
}
|
||
}}
|
||
onItemClick={(campaign) => {
|
||
navigate(`/campaigns/${campaign._id || campaign.id}`)
|
||
}}
|
||
/>
|
||
|
||
{/* Campaign list */}
|
||
<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">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-right 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>
|
||
|
||
{/* Campaign Panel */}
|
||
{panelCampaign && (
|
||
<CampaignDetailPanel
|
||
campaign={panelCampaign}
|
||
onClose={() => setPanelCampaign(null)}
|
||
onSave={handlePanelSave}
|
||
onDelete={permissions?.canDeleteCampaigns ? handlePanelDelete : null}
|
||
brands={brands}
|
||
permissions={permissions}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|