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

View File

@@ -9,15 +9,9 @@ import { api, PLATFORMS } from '../utils/api'
import { PlatformIcons } from '../components/PlatformIcon'
import StatusBadge from '../components/StatusBadge'
import BrandBadge from '../components/BrandBadge'
import Modal from '../components/Modal'
import BudgetBar from '../components/BudgetBar'
import InteractiveTimeline from '../components/InteractiveTimeline'
const EMPTY_CAMPAIGN = {
name: '', description: '', brand_id: '', status: 'planning',
start_date: '', end_date: '', budget: '', goals: '', platforms: [],
budget_spent: '', revenue: '', impressions: '', clicks: '', conversions: '', cost_per_click: '', notes: '',
}
import CampaignDetailPanel from '../components/CampaignDetailPanel'
function ROIBadge({ revenue, spent }) {
if (!spent || spent <= 0) return null
@@ -42,17 +36,13 @@ function MetricCard({ icon: Icon, label, value, color = 'text-text-primary' }) {
export default function Campaigns() {
const { brands, getBrandName } = useContext(AppContext)
const { lang } = useLanguage()
const { lang, currencySymbol } = useLanguage()
const { permissions } = useAuth()
const navigate = useNavigate()
const [campaigns, setCampaigns] = useState([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingCampaign, setEditingCampaign] = useState(null)
const [formData, setFormData] = useState(EMPTY_CAMPAIGN)
const [panelCampaign, setPanelCampaign] = useState(null)
const [filters, setFilters] = useState({ brand: '', status: '' })
const [activeTab, setActiveTab] = useState('details') // details | performance
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
useEffect(() => { loadCampaigns() }, [])
@@ -67,69 +57,22 @@ export default function Campaigns() {
}
}
const handleSave = async () => {
try {
const data = {
name: formData.name,
description: formData.description,
brand_id: formData.brand_id ? Number(formData.brand_id) : null,
status: formData.status,
start_date: formData.start_date,
end_date: formData.end_date,
budget: formData.budget ? Number(formData.budget) : null,
goals: formData.goals,
platforms: formData.platforms || [],
budget_spent: formData.budget_spent ? Number(formData.budget_spent) : 0,
revenue: formData.revenue ? Number(formData.revenue) : 0,
impressions: formData.impressions ? Number(formData.impressions) : 0,
clicks: formData.clicks ? Number(formData.clicks) : 0,
conversions: formData.conversions ? Number(formData.conversions) : 0,
cost_per_click: formData.cost_per_click ? Number(formData.cost_per_click) : 0,
notes: formData.notes || '',
}
if (editingCampaign) {
await api.patch(`/campaigns/${editingCampaign.id || editingCampaign._id}`, data)
} else {
await api.post('/campaigns', data)
}
setShowModal(false)
setEditingCampaign(null)
setFormData(EMPTY_CAMPAIGN)
loadCampaigns()
} catch (err) {
console.error('Save failed:', err)
const handlePanelSave = async (campaignId, data) => {
if (campaignId) {
await api.patch(`/campaigns/${campaignId}`, data)
} else {
await api.post('/campaigns', data)
}
loadCampaigns()
}
const openEdit = (campaign) => {
setEditingCampaign(campaign)
setFormData({
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) : '',
end_date: campaign.endDate ? new Date(campaign.endDate).toISOString().slice(0, 10) : '',
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 || '',
cost_per_click: campaign.costPerClick || campaign.cost_per_click || '',
notes: campaign.notes || '',
})
setActiveTab('details')
setShowModal(true)
const handlePanelDelete = async (campaignId) => {
await api.delete(`/campaigns/${campaignId}`)
loadCampaigns()
}
const openNew = () => {
setEditingCampaign(null)
setFormData(EMPTY_CAMPAIGN)
setActiveTab('details')
setShowModal(true)
setPanelCampaign({ status: 'planning', platforms: [] })
}
const filtered = campaigns.filter(c => {
@@ -201,7 +144,7 @@ export default function Campaigns() {
<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">SAR total</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">
@@ -209,7 +152,7 @@ export default function Campaigns() {
<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">SAR spent</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">
@@ -238,7 +181,7 @@ export default function Campaigns() {
<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">SAR</div>
<div className="text-[10px] text-text-tertiary">{currencySymbol}</div>
</div>
</div>
)}
@@ -338,317 +281,17 @@ export default function Campaigns() {
</div>
</div>
{/* Create/Edit Modal */}
<Modal
isOpen={showModal}
onClose={() => { setShowModal(false); setEditingCampaign(null) }}
title={editingCampaign ? 'Edit Campaign' : 'Create Campaign'}
size="lg"
>
<div className="space-y-4">
{/* Tabs */}
{editingCampaign && (
<div className="flex gap-1 p-1 bg-surface-tertiary rounded-lg">
<button
onClick={() => setActiveTab('details')}
className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
activeTab === 'details' ? 'bg-white text-text-primary shadow-sm' : 'text-text-tertiary hover:text-text-secondary'
}`}
>
Details
</button>
<button
onClick={() => setActiveTab('performance')}
className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
activeTab === 'performance' ? 'bg-white text-text-primary shadow-sm' : 'text-text-tertiary hover:text-text-secondary'
}`}
>
Performance & ROI
</button>
</div>
)}
{activeTab === 'details' ? (
<>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Name *</label>
<input
type="text"
value={formData.name}
onChange={e => setFormData(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="Campaign name"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Description</label>
<textarea
value={formData.description}
onChange={e => setFormData(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"
placeholder="Campaign description..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Brand</label>
<select
value={formData.brand_id}
onChange={e => setFormData(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="">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-sm font-medium text-text-primary mb-1">Status</label>
<select
value={formData.status}
onChange={e => setFormData(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>
{/* Platforms multi-select */}
<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 = (formData.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={() => {
setFormData(f => ({
...f,
platforms: checked
? f.platforms.filter(p => p !== k)
: [...(f.platforms || []), k]
}))
}}
className="sr-only"
/>
{v.label}
</label>
)
})}
</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={formData.start_date}
onChange={e => setFormData(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={formData.end_date}
onChange={e => setFormData(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 className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">
Budget (SAR)
{!permissions?.canSetBudget && <span className="text-[10px] text-text-tertiary ml-1">(Superadmin only)</span>}
</label>
<input
type="number"
value={formData.budget}
onChange={e => setFormData(f => ({ ...f, 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 ${!permissions?.canSetBudget ? 'bg-surface-tertiary text-text-tertiary cursor-not-allowed' : ''}`}
placeholder="e.g., 50000"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Goals</label>
<input
type="text"
value={formData.goals}
onChange={e => setFormData(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"
placeholder="Campaign goals"
/>
</div>
</div>
</>
) : (
/* Performance & ROI Tab */
<>
{/* Live metrics summary */}
{(formData.budget_spent || formData.impressions || formData.clicks) && (
<div className="grid grid-cols-4 gap-2 mb-2">
<MetricCard icon={DollarSign} label="Spent" value={formData.budget_spent ? `${Number(formData.budget_spent).toLocaleString()} SAR` : null} color="text-amber-600" />
<MetricCard icon={Eye} label="Impressions" value={formData.impressions ? Number(formData.impressions).toLocaleString() : null} color="text-purple-600" />
<MetricCard icon={MousePointer} label="Clicks" value={formData.clicks ? Number(formData.clicks).toLocaleString() : null} color="text-blue-600" />
<MetricCard icon={Target} label="Conversions" value={formData.conversions ? Number(formData.conversions).toLocaleString() : null} color="text-emerald-600" />
</div>
)}
{formData.budget && formData.budget_spent && (
<div className="p-3 bg-surface-secondary rounded-lg">
<BudgetBar budget={Number(formData.budget)} spent={Number(formData.budget_spent)} />
<div className="flex items-center gap-2 mt-2">
<ROIBadge revenue={Number(formData.revenue) || 0} spent={Number(formData.budget_spent) || 0} />
{formData.clicks > 0 && formData.budget_spent > 0 && (
<span className="text-[10px] text-text-tertiary">
CPC: {(Number(formData.budget_spent) / Number(formData.clicks)).toFixed(2)} SAR
</span>
)}
{formData.impressions > 0 && formData.clicks > 0 && (
<span className="text-[10px] text-text-tertiary">
CTR: {(Number(formData.clicks) / Number(formData.impressions) * 100).toFixed(2)}%
</span>
)}
</div>
</div>
)}
<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={formData.budget_spent}
onChange={e => setFormData(f => ({ ...f, 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"
placeholder="Amount spent so far"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Revenue (SAR)</label>
<input
type="number"
value={formData.revenue}
onChange={e => setFormData(f => ({ ...f, 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"
placeholder="Revenue generated"
/>
</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={formData.impressions}
onChange={e => setFormData(f => ({ ...f, 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"
placeholder="Total impressions"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Clicks</label>
<input
type="number"
value={formData.clicks}
onChange={e => setFormData(f => ({ ...f, 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"
placeholder="Total clicks"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Conversions</label>
<input
type="number"
value={formData.conversions}
onChange={e => setFormData(f => ({ ...f, 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"
placeholder="Conversions (visits, tickets...)"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Notes</label>
<textarea
value={formData.notes}
onChange={e => setFormData(f => ({ ...f, notes: 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="Performance notes, observations, what's working..."
/>
</div>
</>
)}
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
{editingCampaign && permissions?.canDeleteCampaigns && (
<button
onClick={() => setShowDeleteConfirm(true)}
className="px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg mr-auto"
>
Delete
</button>
)}
<button
onClick={() => { setShowModal(false); setEditingCampaign(null) }}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={!formData.name || !formData.start_date || !formData.end_date}
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"
>
{editingCampaign ? 'Save Changes' : 'Create Campaign'}
</button>
</div>
</div>
</Modal>
{/* Delete Confirmation */}
<Modal
isOpen={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)}
title="Delete Campaign?"
isConfirm
danger
confirmText="Delete Campaign"
onConfirm={async () => {
if (editingCampaign) {
await api.delete(`/campaigns/${editingCampaign.id || editingCampaign._id}`)
setShowModal(false)
setEditingCampaign(null)
loadCampaigns()
}
}}
>
Are you sure you want to delete this campaign? All associated posts and tracks will also be deleted. This action cannot be undone.
</Modal>
{/* Campaign Panel */}
{panelCampaign && (
<CampaignDetailPanel
campaign={panelCampaign}
onClose={() => setPanelCampaign(null)}
onSave={handlePanelSave}
onDelete={permissions?.canDeleteCampaigns ? handlePanelDelete : null}
brands={brands}
permissions={permissions}
/>
)}
</div>
)
}