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
+233 -56
View File
@@ -1,13 +1,14 @@
import { useContext, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { useContext, useEffect, useState, useMemo } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { format, isAfter, isBefore, addDays } from 'date-fns'
import { FileText, Megaphone, AlertTriangle, ArrowRight, Clock, Wallet, TrendingUp, DollarSign, PiggyBank } from 'lucide-react'
import { FileText, Megaphone, AlertTriangle, ArrowRight, Clock, Wallet, TrendingUp, TrendingDown, DollarSign, Landmark, CheckSquare, FolderKanban } from 'lucide-react'
import { AppContext } from '../App'
import { useLanguage } from '../i18n/LanguageContext'
import { api, PRIORITY_CONFIG } from '../utils/api'
import StatCard from '../components/StatCard'
import StatusBadge from '../components/StatusBadge'
import BrandBadge from '../components/BrandBadge'
import DatePresetPicker from '../components/DatePresetPicker'
import { SkeletonDashboard } from '../components/SkeletonLoader'
function getBudgetBarColor(percentage) {
@@ -17,14 +18,20 @@ function getBudgetBarColor(percentage) {
}
function FinanceMini({ finance }) {
const { t } = useLanguage()
const { t, currencySymbol } = useLanguage()
if (!finance) return null
const totalReceived = finance.totalReceived || 0
const spent = finance.spent || 0
const remaining = finance.remaining || 0
const roi = finance.roi || 0
const totalExpenses = finance.totalExpenses || 0
const campaignBudget = finance.totalCampaignBudget || 0
const projectBudget = finance.totalProjectBudget || 0
const unallocated = finance.unallocated ?? (totalReceived - campaignBudget - projectBudget)
const pct = totalReceived > 0 ? (spent / totalReceived) * 100 : 0
const barColor = getBudgetBarColor(pct)
const campPct = totalReceived > 0 ? (campaignBudget / totalReceived) * 100 : 0
const projPct = totalReceived > 0 ? (projectBudget / totalReceived) * 100 : 0
return (
<div className="bg-white rounded-xl border border-border p-5">
@@ -37,35 +44,55 @@ function FinanceMini({ finance }) {
{totalReceived === 0 ? (
<div className="text-center py-6 text-sm text-text-tertiary">
{t('dashboard.noBudgetRecorded')}. <Link to="/finance" className="text-brand-primary underline">{t('dashboard.addBudget')}</Link>
{t('dashboard.noBudgetRecorded')}. <Link to="/budgets" className="text-brand-primary underline">{t('dashboard.addBudget')}</Link>
</div>
) : (
<>
{/* Budget bar */}
<div className="mb-4">
{/* Spending bar */}
<div className="mb-3">
<div className="flex justify-between text-xs text-text-tertiary mb-1">
<span>{spent.toLocaleString()} {t('dashboard.sar')} {t('dashboard.spent')}</span>
<span>{totalReceived.toLocaleString()} {t('dashboard.sar')} {t('dashboard.received')}</span>
<span>{spent.toLocaleString()} {currencySymbol} {t('dashboard.spent')}</span>
<span>{totalReceived.toLocaleString()} {currencySymbol} {t('dashboard.received')}</span>
</div>
<div className="h-3 bg-surface-tertiary rounded-full overflow-hidden">
<div className="h-2.5 bg-surface-tertiary rounded-full overflow-hidden">
<div className={`h-full ${barColor} rounded-full transition-all`} style={{ width: `${Math.min(pct, 100)}%` }} />
</div>
</div>
{/* Allocation bar */}
{(campaignBudget > 0 || projectBudget > 0) && (
<div className="mb-3">
<div className="text-[10px] text-text-tertiary mb-1 font-medium uppercase tracking-wide">Allocation</div>
<div className="h-2 bg-surface-tertiary rounded-full overflow-hidden flex">
{campPct > 0 && <div className="h-full bg-blue-500" style={{ width: `${campPct}%` }} />}
{projPct > 0 && <div className="h-full bg-purple-500" style={{ width: `${projPct}%` }} />}
</div>
<div className="flex gap-3 mt-1 text-[10px] text-text-tertiary">
{campaignBudget > 0 && <span><span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-500 mr-1" />{campaignBudget.toLocaleString()}</span>}
{projectBudget > 0 && <span><span className="inline-block w-1.5 h-1.5 rounded-full bg-purple-500 mr-1" />{projectBudget.toLocaleString()}</span>}
{unallocated > 0 && <span><span className="inline-block w-1.5 h-1.5 rounded-full bg-gray-300 mr-1" />{unallocated.toLocaleString()} free</span>}
</div>
</div>
)}
{/* Key numbers */}
<div className="grid grid-cols-3 gap-3">
<div className={`grid ${totalExpenses > 0 ? 'grid-cols-3' : 'grid-cols-2'} gap-3`}>
<div className="text-center p-2 bg-surface-secondary rounded-lg">
<PiggyBank className="w-4 h-4 mx-auto mb-1 text-emerald-500" />
<Landmark className="w-4 h-4 mx-auto mb-1 text-emerald-500" />
<div className={`text-sm font-bold ${remaining >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
{remaining.toLocaleString()}
</div>
<div className="text-[10px] text-text-tertiary">{t('dashboard.remaining')}</div>
</div>
<div className="text-center p-2 bg-surface-secondary rounded-lg">
<DollarSign className="w-4 h-4 mx-auto mb-1 text-purple-500" />
<div className="text-sm font-bold text-purple-600">{(finance.revenue || 0).toLocaleString()}</div>
<div className="text-[10px] text-text-tertiary">{t('dashboard.revenue')}</div>
</div>
{totalExpenses > 0 && (
<div className="text-center p-2 bg-surface-secondary rounded-lg">
<TrendingDown className="w-4 h-4 mx-auto mb-1 text-red-500" />
<div className="text-sm font-bold text-red-600">
{totalExpenses.toLocaleString()}
</div>
<div className="text-[10px] text-text-tertiary">{t('dashboard.expenses')}</div>
</div>
)}
<div className="text-center p-2 bg-surface-secondary rounded-lg">
<TrendingUp className="w-4 h-4 mx-auto mb-1 text-blue-500" />
<div className={`text-sm font-bold ${roi >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
@@ -81,22 +108,22 @@ function FinanceMini({ finance }) {
}
function ActiveCampaignsList({ campaigns, finance }) {
const { t } = useLanguage()
const { t, currencySymbol } = useLanguage()
const active = campaigns.filter(c => c.status === 'active')
const campaignData = (finance?.campaigns || []).filter(c => c.status === 'active')
if (active.length === 0) return null
return (
<div className="bg-white rounded-xl border border-border">
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<div className="section-card">
<div className="section-card-header flex items-center justify-between">
<h3 className="font-semibold text-text-primary">{t('dashboard.activeCampaigns')}</h3>
<Link to="/campaigns" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
<div className="divide-y divide-border-light">
{active.map(c => {
{active.slice(0, 5).map(c => {
const cd = campaignData.find(d => d.id === (c._id || c.id)) || {}
const spent = cd.tracks_spent || 0
const allocated = cd.tracks_allocated || 0
@@ -110,7 +137,7 @@ function ActiveCampaignsList({ campaigns, finance }) {
<div className="mt-1.5 w-32">
<div className="flex justify-between text-[9px] text-text-tertiary mb-0.5">
<span>{spent.toLocaleString()}</span>
<span>{allocated.toLocaleString()} SAR</span>
<span>{allocated.toLocaleString()} {currencySymbol}</span>
</div>
<div className="h-1.5 bg-surface-tertiary rounded-full overflow-hidden">
<div className={`h-full ${barColor} rounded-full`} style={{ width: `${Math.min(pct, 100)}%` }} />
@@ -121,7 +148,7 @@ function ActiveCampaignsList({ campaigns, finance }) {
<div className="text-right shrink-0">
{cd.tracks_impressions > 0 && (
<div className="text-[10px] text-text-tertiary">
👁 {cd.tracks_impressions.toLocaleString()} · 🖱 {cd.tracks_clicks.toLocaleString()}
{cd.tracks_impressions.toLocaleString()} imp. / {cd.tracks_clicks.toLocaleString()} clicks
</div>
)}
</div>
@@ -133,31 +160,140 @@ function ActiveCampaignsList({ campaigns, finance }) {
)
}
function MyTasksList({ tasks, currentUserId, navigate, t }) {
const myTasks = tasks
.filter(task => {
const assignedId = task.assigned_to_id || task.assignedTo
return assignedId === currentUserId && task.status !== 'done'
})
.slice(0, 5)
return (
<div className="section-card">
<div className="section-card-header flex items-center justify-between">
<h3 className="font-semibold text-text-primary flex items-center gap-2">
<CheckSquare className="w-4 h-4 text-brand-primary" />
{t('dashboard.myTasks')}
</h3>
<Link to="/tasks" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
<div className="divide-y divide-border-light">
{myTasks.length === 0 ? (
<div className="py-8 text-center text-sm text-text-tertiary">
{t('dashboard.allOnTrack')}
</div>
) : (
myTasks.map(task => (
<div
key={task._id || task.id}
onClick={() => navigate('/tasks')}
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors cursor-pointer"
>
<div className={`w-2 h-2 rounded-full shrink-0 ${(PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium).color}`} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{task.title}</p>
<StatusBadge status={task.status} size="xs" />
</div>
{task.dueDate && (
<div className="flex items-center gap-1 text-xs text-text-tertiary shrink-0">
<Clock className="w-3.5 h-3.5" />
{format(new Date(task.dueDate), 'MMM d')}
</div>
)}
</div>
))
)}
</div>
</div>
)
}
function ProjectProgress({ projects, tasks, t }) {
if (!projects || projects.length === 0) return null
const activeProjects = projects
.filter(p => p.status === 'active' || p.status === 'in_progress')
.slice(0, 5)
if (activeProjects.length === 0) return null
return (
<div className="section-card">
<div className="section-card-header flex items-center justify-between">
<h3 className="font-semibold text-text-primary flex items-center gap-2">
<FolderKanban className="w-4 h-4 text-purple-500" />
{t('dashboard.projectProgress')}
</h3>
<Link to="/projects" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
<div className="divide-y divide-border-light">
{activeProjects.map(project => {
const projectId = project._id || project.id
const projectTasks = tasks.filter(t => (t.project_id || t.projectId) === projectId)
const doneTasks = projectTasks.filter(t => t.status === 'done').length
const totalTasks = projectTasks.length
const pct = totalTasks > 0 ? (doneTasks / totalTasks) * 100 : 0
return (
<Link key={projectId} to={`/projects/${projectId}`} className="flex items-center gap-4 px-5 py-3 hover:bg-surface-secondary transition-colors">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{project.name}</p>
<div className="flex items-center gap-2 mt-1.5">
<div className="flex-1 h-1.5 bg-surface-tertiary rounded-full overflow-hidden max-w-[120px]">
<div className="h-full bg-purple-500 rounded-full transition-all" style={{ width: `${pct}%` }} />
</div>
<span className="text-[10px] text-text-tertiary shrink-0">
{doneTasks}/{totalTasks} {t('tasks.tasks')}
</span>
</div>
</div>
<StatusBadge status={project.status} size="xs" />
</Link>
)
})}
</div>
</div>
)
}
export default function Dashboard() {
const { t } = useLanguage()
const { t, currencySymbol } = useLanguage()
const navigate = useNavigate()
const { currentUser, teamMembers } = useContext(AppContext)
const [posts, setPosts] = useState([])
const [campaigns, setCampaigns] = useState([])
const [tasks, setTasks] = useState([])
const [projects, setProjects] = useState([])
const [finance, setFinance] = useState(null)
const [loading, setLoading] = useState(true)
// Date filtering
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [activePreset, setActivePreset] = useState('')
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
try {
const [postsRes, campaignsRes, tasksRes, financeRes] = await Promise.allSettled([
api.get('/posts?limit=10&sort=-createdAt'),
const [postsRes, campaignsRes, tasksRes, financeRes, projectsRes] = await Promise.allSettled([
api.get('/posts?limit=50&sort=-createdAt'),
api.get('/campaigns'),
api.get('/tasks'),
api.get('/finance/summary'),
api.get('/projects'),
])
setPosts(postsRes.status === 'fulfilled' ? (postsRes.value.data || postsRes.value || []) : [])
setCampaigns(campaignsRes.status === 'fulfilled' ? (campaignsRes.value.data || campaignsRes.value || []) : [])
setTasks(tasksRes.status === 'fulfilled' ? (tasksRes.value.data || tasksRes.value || []) : [])
setFinance(financeRes.status === 'fulfilled' ? (financeRes.value.data || financeRes.value || null) : null)
setProjects(projectsRes.status === 'fulfilled' ? (projectsRes.value.data || projectsRes.value || []) : [])
} catch (err) {
console.error('Dashboard load error:', err)
} finally {
@@ -165,12 +301,35 @@ export default function Dashboard() {
}
}
// Filtered data based on date range
const filteredPosts = useMemo(() => {
if (!dateFrom && !dateTo) return posts
return posts.filter(p => {
const d = p.scheduled_date || p.scheduledDate
if (!d) return true
if (dateFrom && d < dateFrom) return false
if (dateTo && d > dateTo) return false
return true
})
}, [posts, dateFrom, dateTo])
const filteredTasks = useMemo(() => {
if (!dateFrom && !dateTo) return tasks
return tasks.filter(t => {
const d = t.due_date || t.dueDate
if (!d) return true
if (dateFrom && d < dateFrom) return false
if (dateTo && d > dateTo) return false
return true
})
}, [tasks, dateFrom, dateTo])
const activeCampaigns = campaigns.filter(c => c.status === 'active').length
const overdueTasks = tasks.filter(t =>
const overdueTasks = filteredTasks.filter(t =>
t.dueDate && new Date(t.dueDate) < new Date() && t.status !== 'done'
).length
const upcomingDeadlines = tasks
const upcomingDeadlines = filteredTasks
.filter(t => {
if (!t.dueDate || t.status === 'done') return false
const due = new Date(t.dueDate)
@@ -186,14 +345,21 @@ export default function Dashboard() {
return (
<div className="space-y-6 animate-fade-in">
{/* Welcome */}
<div>
<h1 className="text-2xl font-bold text-text-primary">
{t('dashboard.welcomeBack')}, {currentUser?.name || 'there'}
</h1>
<p className="text-text-secondary mt-1">
{t('dashboard.happeningToday')}
</p>
{/* Welcome + Date presets */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-gradient">
{t('dashboard.welcomeBack')}, {currentUser?.name || 'there'}
</h1>
<p className="text-text-secondary mt-1">
{t('dashboard.happeningToday')}
</p>
</div>
<DatePresetPicker
activePreset={activePreset}
onSelect={(from, to, key) => { setDateFrom(from); setDateTo(to); setActivePreset(key) }}
onClear={() => { setDateFrom(''); setDateTo(''); setActivePreset('') }}
/>
</div>
{/* Stats */}
@@ -201,8 +367,8 @@ export default function Dashboard() {
<StatCard
icon={FileText}
label={t('dashboard.totalPosts')}
value={posts.length || 0}
subtitle={`${posts.filter(p => p.status === 'published').length} ${t('dashboard.published')}`}
value={filteredPosts.length || 0}
subtitle={`${filteredPosts.filter(p => p.status === 'published').length} ${t('dashboard.published')}`}
color="brand-primary"
/>
<StatCard
@@ -213,10 +379,10 @@ export default function Dashboard() {
color="brand-secondary"
/>
<StatCard
icon={Wallet}
label={t('dashboard.budgetSpent')}
value={`${(finance?.spent || 0).toLocaleString()}`}
subtitle={finance?.totalReceived ? `${t('dashboard.of')} ${finance.totalReceived.toLocaleString()} ${t('dashboard.sar')}` : t('dashboard.noBudget')}
icon={Landmark}
label={t('dashboard.budgetRemaining')}
value={`${(finance?.remaining ?? 0).toLocaleString()}`}
subtitle={finance?.totalReceived ? `${(finance.spent || 0).toLocaleString()} ${t('dashboard.spent')} ${t('dashboard.of')} ${finance.totalReceived.toLocaleString()} ${currencySymbol}` : t('dashboard.noBudget')}
color="brand-tertiary"
/>
<StatCard
@@ -228,35 +394,42 @@ export default function Dashboard() {
/>
</div>
{/* Three columns on large, stack on small */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Budget Overview */}
<FinanceMini finance={finance} />
{/* My Tasks + Project Progress */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<MyTasksList tasks={filteredTasks} currentUserId={currentUser?.id || currentUser?._id} navigate={navigate} t={t} />
<ProjectProgress projects={projects} tasks={tasks} t={t} />
</div>
{/* Active Campaigns with budget bars */}
{/* Budget + Active Campaigns */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<FinanceMini finance={finance} />
<div className="lg:col-span-2">
<ActiveCampaignsList campaigns={campaigns} finance={finance} />
</div>
</div>
{/* Two columns */}
{/* Recent Posts + Upcoming Deadlines */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Recent Posts */}
<div className="bg-white rounded-xl border border-border">
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<div className="section-card">
<div className="section-card-header flex items-center justify-between">
<h3 className="font-semibold text-text-primary">{t('dashboard.recentPosts')}</h3>
<Link to="/posts" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
<div className="divide-y divide-border-light">
{posts.length === 0 ? (
{filteredPosts.length === 0 ? (
<div className="py-12 text-center text-sm text-text-tertiary">
{t('dashboard.noPostsYet')}
</div>
) : (
posts.slice(0, 8).map((post) => (
<div key={post._id} className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors">
filteredPosts.slice(0, 8).map((post) => (
<div
key={post._id}
onClick={() => navigate('/posts')}
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors cursor-pointer"
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{post.title}</p>
<div className="flex items-center gap-2 mt-1">
@@ -271,8 +444,8 @@ export default function Dashboard() {
</div>
{/* Upcoming Deadlines */}
<div className="bg-white rounded-xl border border-border">
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<div className="section-card">
<div className="section-card-header flex items-center justify-between">
<h3 className="font-semibold text-text-primary">{t('dashboard.upcomingDeadlines')}</h3>
<Link to="/tasks" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
@@ -285,7 +458,11 @@ export default function Dashboard() {
</div>
) : (
upcomingDeadlines.map((task) => (
<div key={task._id} className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors">
<div
key={task._id}
onClick={() => navigate('/tasks')}
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors cursor-pointer"
>
<div className={`w-2 h-2 rounded-full ${(PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium).color}`} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{task.title}</p>