Dashboard fix, expense system, currency settings, visual upgrade
- 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>
This commit is contained in:
@@ -12,6 +12,9 @@ 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'
|
||||
import PostDetailPanel from '../components/PostDetailPanel'
|
||||
|
||||
const TRACK_TYPES = {
|
||||
organic_social: { label: 'Organic Social', icon: Megaphone, color: 'text-green-600 bg-green-50', hasBudget: false },
|
||||
@@ -23,14 +26,6 @@ const TRACK_TYPES = {
|
||||
|
||||
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">
|
||||
@@ -44,8 +39,8 @@ function MetricBox({ label, value, icon: Icon, color = 'text-text-primary' }) {
|
||||
export default function CampaignDetail() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { brands, getBrandName } = useContext(AppContext)
|
||||
const { lang } = useLanguage()
|
||||
const { brands, getBrandName, teamMembers } = useContext(AppContext)
|
||||
const { lang, currencySymbol } = useLanguage()
|
||||
const { permissions, user } = useAuth()
|
||||
const isSuperadmin = user?.role === 'superadmin'
|
||||
const [campaign, setCampaign] = useState(null)
|
||||
@@ -59,25 +54,25 @@ export default function CampaignDetail() {
|
||||
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 [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 : (r.data || []))).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const loadAll = async () => {
|
||||
try {
|
||||
@@ -141,28 +136,46 @@ export default function CampaignDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
// 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 handlePostPanelSave = async (postId, data) => {
|
||||
if (postId) {
|
||||
await api.patch(`/posts/${postId}`, data)
|
||||
} else {
|
||||
await api.post('/posts', data)
|
||||
}
|
||||
loadAll()
|
||||
}
|
||||
|
||||
const handlePostPanelDelete = async (postId) => {
|
||||
await api.delete(`/posts/${postId}`)
|
||||
setSelectedPost(null)
|
||||
loadAll()
|
||||
}
|
||||
|
||||
const deleteTrack = async (trackId) => {
|
||||
@@ -177,87 +190,6 @@ export default function CampaignDetail() {
|
||||
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>
|
||||
}
|
||||
@@ -299,7 +231,7 @@ export default function CampaignDetail() {
|
||||
<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'}
|
||||
Budget: {campaign.budget > 0 ? `${campaign.budget.toLocaleString()} ${currencySymbol}` : 'Not set'}
|
||||
</span>
|
||||
{campaign.platforms && campaign.platforms.length > 0 && (
|
||||
<PlatformIcons platforms={campaign.platforms} size={16} />
|
||||
@@ -330,7 +262,7 @@ export default function CampaignDetail() {
|
||||
)}
|
||||
{canManage && (
|
||||
<button
|
||||
onClick={openEditCampaign}
|
||||
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" />
|
||||
@@ -409,7 +341,7 @@ export default function CampaignDetail() {
|
||||
<h3 className="font-semibold text-text-primary">Tracks</h3>
|
||||
{canManage && (
|
||||
<button
|
||||
onClick={() => { setEditingTrack(null); setTrackForm(EMPTY_TRACK); setShowTrackModal(true) }}
|
||||
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" /> Add Track
|
||||
@@ -461,7 +393,7 @@ export default function CampaignDetail() {
|
||||
{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>
|
||||
<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>
|
||||
@@ -485,14 +417,14 @@ export default function CampaignDetail() {
|
||||
{canManage && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={() => openMetrics(track)}
|
||||
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={() => openEditTrack(track)}
|
||||
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"
|
||||
>
|
||||
@@ -571,176 +503,6 @@ export default function CampaignDetail() {
|
||||
</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}
|
||||
@@ -806,7 +568,7 @@ export default function CampaignDetail() {
|
||||
<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>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Budget ({currencySymbol})</label>
|
||||
<input
|
||||
type="number"
|
||||
value={budgetValue}
|
||||
@@ -842,307 +604,42 @@ export default function CampaignDetail() {
|
||||
</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>
|
||||
{/* Post Detail Panel */}
|
||||
{selectedPost && (
|
||||
<PostDetailPanel
|
||||
post={selectedPost}
|
||||
onClose={() => setSelectedPost(null)}
|
||||
onSave={handlePostPanelSave}
|
||||
onDelete={handlePostPanelDelete}
|
||||
brands={brands}
|
||||
teamMembers={teamMembers}
|
||||
campaigns={allCampaigns}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
{/* Campaign Edit Panel */}
|
||||
{panelCampaign && (
|
||||
<CampaignDetailPanel
|
||||
campaign={panelCampaign}
|
||||
onClose={() => setPanelCampaign(null)}
|
||||
onSave={handleCampaignPanelSave}
|
||||
onDelete={permissions?.canDeleteCampaigns ? handleCampaignPanelDelete : null}
|
||||
brands={brands}
|
||||
permissions={permissions}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
{/* Track Detail Panel */}
|
||||
{panelTrack && (
|
||||
<TrackDetailPanel
|
||||
track={panelTrack}
|
||||
campaignId={id}
|
||||
onClose={() => setPanelTrack(null)}
|
||||
onSave={handleTrackPanelSave}
|
||||
onDelete={handleTrackPanelDelete}
|
||||
scrollToMetrics={trackScrollToMetrics}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user