49e1a796ed
Critical fixes: - XSS: escapeHtml() on all user-supplied text in email notifications - Budget PATCH: added mutex lock + availability validation (prevents corruption) - batchResolveNames: fixed wrong signature for budget request earmark names Dead code cleanup: - Deleted 8 unused PostComposition* files (replaced by PostDetail full page) Performance: - budget-helpers: single-fetch with computeFromEntries(), optional prefetch param - post-composition: parallelized text + thumbnail fetches with Promise.all Consistency: - PostDetail.jsx: native <select> → PortalSelect (matches all panels) - Finance.jsx: 11 hardcoded English table headers → t() with i18n keys - PostCalendar.jsx: day names, Month/Week labels → t() with i18n keys - App.jsx Suspense: "Loading..." → brand spinner (can't use i18n in fallback) - UploadZone: proper useRef pattern, no vanilla JS document.createElement - All file inputs: className="hidden" → absolute w-0 h-0 opacity-0 - ArtefactDetailPanel: removed campaign/project selects (inherited from post) - TranslationDetailPanel: removed brand/linked post selects (inherited from post) - ApproverMultiSelect: portal-based dropdown (fixes clipping in modals) - Thumbnail fix: post-composition constructs URL from filename (was undefined) - Upload fix: UploadZone with drag-and-drop for design + video artefacts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
445 lines
20 KiB
React
445 lines
20 KiB
React
import { useState, useEffect, useContext } from 'react'
|
|
import { Trash2, DollarSign, Eye, MousePointer, Target, FileEdit, BarChart3, MessageSquare } from 'lucide-react'
|
|
import { useLanguage } from '../i18n/LanguageContext'
|
|
import { PLATFORMS, getBrandColor } from '../utils/api'
|
|
import CommentsSection from './CommentsSection'
|
|
import Modal from './Modal'
|
|
import TabbedModal from './TabbedModal'
|
|
import BudgetBar from './BudgetBar'
|
|
import PortalSelect from './PortalSelect'
|
|
import { AppContext } from '../App'
|
|
|
|
export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelete, brands, permissions }) {
|
|
const { t, lang, currencySymbol } = useLanguage()
|
|
const { teams } = useContext(AppContext)
|
|
const [form, setForm] = useState({})
|
|
const [dirty, setDirty] = useState(false)
|
|
const [saving, setSaving] = useState(false)
|
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
|
const [activeTab, setActiveTab] = useState('details')
|
|
|
|
const campaignId = campaign?._id || campaign?.id
|
|
const isCreateMode = !campaignId
|
|
|
|
useEffect(() => {
|
|
if (campaign) {
|
|
setForm({
|
|
name: campaign.name || '',
|
|
description: campaign.description || '',
|
|
brand_id: campaign.brandId || campaign.brand_id || '',
|
|
team_id: campaign.team_id || '',
|
|
status: campaign.status || 'planning',
|
|
start_date: campaign.startDate ? new Date(campaign.startDate).toISOString().slice(0, 10) : (campaign.start_date || ''),
|
|
end_date: campaign.endDate ? new Date(campaign.endDate).toISOString().slice(0, 10) : (campaign.end_date || ''),
|
|
budget: campaign.budget || '',
|
|
goals: campaign.goals || '',
|
|
platforms: campaign.platforms || [],
|
|
budget_spent: campaign.budgetSpent || campaign.budget_spent || '',
|
|
revenue: campaign.revenue || '',
|
|
impressions: campaign.impressions || '',
|
|
clicks: campaign.clicks || '',
|
|
conversions: campaign.conversions || '',
|
|
notes: campaign.notes || '',
|
|
})
|
|
setDirty(isCreateMode)
|
|
}
|
|
}, [campaign])
|
|
|
|
if (!campaign) return null
|
|
|
|
const statusOptions = [
|
|
{ value: 'planning', label: 'Planning' },
|
|
{ value: 'active', label: 'Active' },
|
|
{ value: 'paused', label: 'Paused' },
|
|
{ value: 'completed', label: 'Completed' },
|
|
{ value: 'cancelled', label: 'Cancelled' },
|
|
]
|
|
|
|
const update = (field, value) => {
|
|
setForm(f => ({ ...f, [field]: value }))
|
|
setDirty(true)
|
|
}
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true)
|
|
try {
|
|
const data = {
|
|
name: form.name,
|
|
description: form.description,
|
|
brand_id: form.brand_id ? Number(form.brand_id) : null,
|
|
team_id: form.team_id ? Number(form.team_id) : null,
|
|
status: form.status,
|
|
start_date: form.start_date,
|
|
end_date: form.end_date,
|
|
budget: form.budget ? Number(form.budget) : null,
|
|
goals: form.goals,
|
|
platforms: form.platforms || [],
|
|
budget_spent: form.budget_spent ? Number(form.budget_spent) : 0,
|
|
revenue: form.revenue ? Number(form.revenue) : 0,
|
|
impressions: form.impressions ? Number(form.impressions) : 0,
|
|
clicks: form.clicks ? Number(form.clicks) : 0,
|
|
conversions: form.conversions ? Number(form.conversions) : 0,
|
|
notes: form.notes || '',
|
|
}
|
|
await onSave(isCreateMode ? null : campaignId, data)
|
|
setDirty(false)
|
|
if (isCreateMode) onClose()
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const confirmDelete = async () => {
|
|
setShowDeleteConfirm(false)
|
|
await onDelete(campaignId)
|
|
onClose()
|
|
}
|
|
|
|
const brandName = (() => {
|
|
if (form.brand_id) {
|
|
const b = brands?.find(b => String(b._id || b.id) === String(form.brand_id))
|
|
return b ? (lang === 'ar' && b.name_ar ? b.name_ar : b.name) : null
|
|
}
|
|
return campaign.brand_name || campaign.brandName || null
|
|
})()
|
|
|
|
const tabs = isCreateMode
|
|
? [{ key: 'details', label: t('campaigns.details'), icon: FileEdit }]
|
|
: [
|
|
{ key: 'details', label: t('campaigns.details'), icon: FileEdit },
|
|
{ key: 'performance', label: t('campaigns.performance'), icon: BarChart3 },
|
|
{ key: 'discussion', label: t('campaigns.discussion'), icon: MessageSquare },
|
|
]
|
|
|
|
return (
|
|
<>
|
|
<TabbedModal
|
|
onClose={onClose}
|
|
size="lg"
|
|
header={
|
|
<>
|
|
<input
|
|
type="text"
|
|
value={form.name}
|
|
onChange={e => update('name', e.target.value)}
|
|
className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0"
|
|
placeholder={t('campaigns.name')}
|
|
/>
|
|
<div className="flex items-center gap-2 mt-2">
|
|
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${
|
|
form.status === 'active' ? 'bg-emerald-100 text-emerald-700' :
|
|
form.status === 'paused' ? 'bg-amber-100 text-amber-700' :
|
|
form.status === 'completed' ? 'bg-blue-100 text-blue-700' :
|
|
form.status === 'cancelled' ? 'bg-red-100 text-red-700' :
|
|
'bg-gray-100 text-text-secondary'
|
|
}`}>
|
|
{statusOptions.find(s => s.value === form.status)?.label}
|
|
</span>
|
|
{brandName && (
|
|
<span className={`text-[10px] px-1.5 py-0.5 rounded ${getBrandColor(brandName).bg} ${getBrandColor(brandName).text}`}>
|
|
{brandName}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</>
|
|
}
|
|
tabs={tabs}
|
|
activeTab={activeTab}
|
|
onTabChange={setActiveTab}
|
|
footer={
|
|
<>
|
|
<div className="flex items-center gap-2">
|
|
{onDelete && !isCreateMode && (
|
|
<button
|
|
onClick={() => setShowDeleteConfirm(true)}
|
|
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
|
title={t('common.delete')}
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2.5">
|
|
{dirty && (
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={!form.name || !form.start_date || !form.end_date || saving}
|
|
className={`px-4 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 ${saving ? 'btn-loading' : ''}`}
|
|
>
|
|
{isCreateMode ? t('campaigns.createCampaign') : t('tasks.saveChanges')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</>
|
|
}
|
|
>
|
|
{/* Details Tab */}
|
|
{activeTab === 'details' && (
|
|
<div className="p-6 space-y-3">
|
|
<div>
|
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.description')}</label>
|
|
<textarea
|
|
value={form.description}
|
|
onChange={e => update('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"
|
|
placeholder="Campaign description..."
|
|
/>
|
|
</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>
|
|
<PortalSelect
|
|
value={form.brand_id}
|
|
onChange={val => update('brand_id', val)}
|
|
options={[{ value: '', label: 'Select brand' }, ...(brands || []).map(b => ({ value: b.id || b._id, label: `${b.icon || ''} ${lang === 'ar' && b.name_ar ? b.name_ar : b.name}`.trim() }))]}
|
|
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.status')}</label>
|
|
<PortalSelect
|
|
value={form.status}
|
|
onChange={val => update('status', val)}
|
|
options={statusOptions}
|
|
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>
|
|
|
|
{/* Team */}
|
|
<div>
|
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('common.team')}</label>
|
|
<PortalSelect
|
|
value={form.team_id}
|
|
onChange={val => update('team_id', val)}
|
|
options={[{ value: '', label: t('common.noTeam') }, ...(teams || []).map(tm => ({ value: tm.id || tm._id, label: tm.name }))]}
|
|
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>
|
|
|
|
{/* Platforms */}
|
|
<div>
|
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.platforms')}</label>
|
|
<div className="flex flex-wrap gap-2 p-2 border border-border rounded-lg bg-surface min-h-[38px]">
|
|
{Object.entries(PLATFORMS).map(([k, v]) => {
|
|
const checked = (form.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={() => {
|
|
update('platforms', checked
|
|
? form.platforms.filter(p => p !== k)
|
|
: [...(form.platforms || []), k]
|
|
)
|
|
}}
|
|
className="sr-only"
|
|
/>
|
|
{v.label}
|
|
</label>
|
|
)
|
|
})}
|
|
</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={form.start_date}
|
|
onChange={e => update('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={form.end_date}
|
|
onChange={e => update('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 className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-xs font-medium text-text-tertiary mb-1">
|
|
{t('campaigns.budget')} ({currencySymbol})
|
|
{!permissions?.canSetBudget && <span className="text-[10px] text-text-tertiary ms-1">(Superadmin only)</span>}
|
|
</label>
|
|
<input
|
|
type="number"
|
|
value={form.budget}
|
|
onChange={e => update('budget', e.target.value)}
|
|
disabled={!permissions?.canSetBudget}
|
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
placeholder="e.g., 50000"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.goals')}</label>
|
|
<input
|
|
type="text"
|
|
value={form.goals}
|
|
onChange={e => update('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"
|
|
placeholder="Campaign goals"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Performance Tab */}
|
|
{activeTab === 'performance' && !isCreateMode && (
|
|
<div className="p-6 space-y-3">
|
|
{(form.budget_spent || form.impressions || form.clicks) && (
|
|
<div className="grid grid-cols-4 gap-2">
|
|
<div className="bg-surface-secondary rounded-lg p-2 text-center">
|
|
<DollarSign className="w-3.5 h-3.5 mx-auto mb-0.5 text-amber-600" />
|
|
<div className="text-xs font-bold text-amber-600">{form.budget_spent ? Number(form.budget_spent).toLocaleString() : '—'}</div>
|
|
<div className="text-[10px] text-text-tertiary">{t('campaigns.budgetSpent')}</div>
|
|
</div>
|
|
<div className="bg-surface-secondary rounded-lg p-2 text-center">
|
|
<Eye className="w-3.5 h-3.5 mx-auto mb-0.5 text-purple-600" />
|
|
<div className="text-xs font-bold text-purple-600">{form.impressions ? Number(form.impressions).toLocaleString() : '—'}</div>
|
|
<div className="text-[10px] text-text-tertiary">{t('campaigns.impressions')}</div>
|
|
</div>
|
|
<div className="bg-surface-secondary rounded-lg p-2 text-center">
|
|
<MousePointer className="w-3.5 h-3.5 mx-auto mb-0.5 text-blue-600" />
|
|
<div className="text-xs font-bold text-blue-600">{form.clicks ? Number(form.clicks).toLocaleString() : '—'}</div>
|
|
<div className="text-[10px] text-text-tertiary">{t('campaigns.clicks')}</div>
|
|
</div>
|
|
<div className="bg-surface-secondary rounded-lg p-2 text-center">
|
|
<Target className="w-3.5 h-3.5 mx-auto mb-0.5 text-emerald-600" />
|
|
<div className="text-xs font-bold text-emerald-600">{form.conversions ? Number(form.conversions).toLocaleString() : '—'}</div>
|
|
<div className="text-[10px] text-text-tertiary">{t('campaigns.conversions')}</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{form.budget && form.budget_spent && (
|
|
<div className="p-3 bg-surface-secondary rounded-lg">
|
|
<BudgetBar budget={Number(form.budget)} spent={Number(form.budget_spent)} />
|
|
<div className="flex items-center gap-2 mt-2">
|
|
{Number(form.budget_spent) > 0 && (
|
|
<span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded ${
|
|
((Number(form.revenue) - Number(form.budget_spent)) / Number(form.budget_spent) * 100) >= 0
|
|
? 'text-emerald-600 bg-emerald-50' : 'text-red-600 bg-red-50'
|
|
}`}>
|
|
ROI {((Number(form.revenue || 0) - Number(form.budget_spent)) / Number(form.budget_spent) * 100).toFixed(0)}%
|
|
</span>
|
|
)}
|
|
{Number(form.clicks) > 0 && Number(form.budget_spent) > 0 && (
|
|
<span className="text-[10px] text-text-tertiary">
|
|
CPC: {(Number(form.budget_spent) / Number(form.clicks)).toFixed(2)} {currencySymbol}
|
|
</span>
|
|
)}
|
|
{Number(form.impressions) > 0 && Number(form.clicks) > 0 && (
|
|
<span className="text-[10px] text-text-tertiary">
|
|
CTR: {(Number(form.clicks) / Number(form.impressions) * 100).toFixed(2)}%
|
|
</span>
|
|
)}
|
|
</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.budgetSpent')} ({currencySymbol})</label>
|
|
<input
|
|
type="number"
|
|
value={form.budget_spent}
|
|
onChange={e => update('budget_spent', 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.revenue')} ({currencySymbol})</label>
|
|
<input
|
|
type="number"
|
|
value={form.revenue}
|
|
onChange={e => update('revenue', 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 className="grid grid-cols-3 gap-3">
|
|
<div>
|
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.impressions')}</label>
|
|
<input
|
|
type="number"
|
|
value={form.impressions}
|
|
onChange={e => update('impressions', 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.clicks')}</label>
|
|
<input
|
|
type="number"
|
|
value={form.clicks}
|
|
onChange={e => update('clicks', 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.conversions')}</label>
|
|
<input
|
|
type="number"
|
|
value={form.conversions}
|
|
onChange={e => update('conversions', 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.notes')}</label>
|
|
<textarea
|
|
value={form.notes}
|
|
onChange={e => update('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"
|
|
placeholder="Performance notes..."
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Discussion Tab */}
|
|
{activeTab === 'discussion' && !isCreateMode && (
|
|
<div className="p-6 space-y-3">
|
|
<CommentsSection entityType="campaign" entityId={campaignId} />
|
|
</div>
|
|
)}
|
|
</TabbedModal>
|
|
|
|
<Modal
|
|
isOpen={showDeleteConfirm}
|
|
onClose={() => setShowDeleteConfirm(false)}
|
|
title={t('campaigns.deleteCampaign')}
|
|
isConfirm
|
|
danger
|
|
confirmText={t('common.delete')}
|
|
onConfirm={confirmDelete}
|
|
>
|
|
{t('campaigns.deleteConfirm')}
|
|
</Modal>
|
|
</>
|
|
)
|
|
}
|