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:
fahed
2026-02-15 15:49:28 +03:00
parent f3e6fc848d
commit e76be78498
17 changed files with 2817 additions and 1379 deletions
@@ -0,0 +1,427 @@
import { useState, useEffect } from 'react'
import { X, Trash2, DollarSign, Eye, MousePointer, Target } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import { PLATFORMS, getBrandColor } from '../utils/api'
import CommentsSection from './CommentsSection'
import Modal from './Modal'
import SlidePanel from './SlidePanel'
import CollapsibleSection from './CollapsibleSection'
import BudgetBar from './BudgetBar'
export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelete, brands, permissions }) {
const { t, lang, currencySymbol } = useLanguage()
const [form, setForm] = useState({})
const [dirty, setDirty] = useState(false)
const [saving, setSaving] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
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 || '',
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,
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 header = (
<div className="px-5 py-4 border-b border-border shrink-0">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<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-gray-600'
}`}>
{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>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
)
return (
<>
<SlidePanel onClose={onClose} maxWidth="520px" header={header}>
{/* Details Section */}
<CollapsibleSection title={t('campaigns.details')}>
<div className="px-5 pb-4 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>
<select
value={form.brand_id}
onChange={e => update('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="">Select brand</option>
{(brands || []).map(b => <option key={b.id || b._id} value={b.id || b._id}>{b.icon} {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('campaigns.status')}</label>
<select
value={form.status}
onChange={e => update('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"
>
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
</div>
</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-white 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 ml-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 className="flex items-center gap-2 pt-2">
{dirty && (
<button
onClick={handleSave}
disabled={!form.name || !form.start_date || !form.end_date || saving}
className={`flex-1 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>
)}
{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>
</CollapsibleSection>
{/* Performance Section (hidden in create mode) */}
{!isCreateMode && (
<CollapsibleSection title={t('campaigns.performance')}>
<div className="px-5 pb-4 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>
</CollapsibleSection>
)}
{/* Discussion Section (hidden in create mode) */}
{!isCreateMode && (
<CollapsibleSection title={t('campaigns.discussion')} noBorder>
<div className="px-5 pb-5">
<CommentsSection entityType="campaign" entityId={campaignId} />
</div>
</CollapsibleSection>
)}
</SlidePanel>
<Modal
isOpen={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)}
title={t('campaigns.deleteCampaign')}
isConfirm
danger
confirmText={t('common.delete')}
onConfirm={confirmDelete}
>
{t('campaigns.deleteConfirm')}
</Modal>
</>
)
}