e1d1c392eb
Audit & Quality: - RTL: replaced 121 LTR-only utilities (text-left, pl-, left-) with logical properties - A11y: focus traps + ARIA on Modal/SlidePanel/TabbedModal, clickable divs→buttons - Theming: bg-white→bg-surface (170 instances), text-gray→semantic tokens - Performance: useMemo on filters, loading="lazy" on 24 images - CSS: prefers-reduced-motion, removed dead animations Component Splits: - PostDetailPanel: 1332→623 lines + 4 sub-components - ArtefactDetailPanel: 972→590 lines + 1 sub-component Brand Identity — Rawaj (رواج): - New name, DM Sans font, deep teal palette (#0d9488) - Custom SVG logo, forest-tinted dark mode - All emails branded with app name in subject line Design Refinement: - Dashboard: merged posts+deadlines into tabbed ActivityFeed, inline stats - Quieter: removed card lift, brand glow, gradient text, mesh backgrounds - CampaignDetail: prominent budget card, compact team avatars, Lucide icons - Consistent page titles via Header.jsx, standardized section headers - Finance page fully i18n'd (20+ hardcoded strings replaced) Budget Allocation Redesign: - Single source of truth: BudgetEntries (Campaign.budget deprecated) - Validation at all levels: main→campaign→track, expenses blocked if insufficient - Budget request workflow with CEO approval via public link - BudgetRequests table, CRUD routes, public approval page - Budget mutex for race condition prevention - Idempotent migration for existing campaign budgets Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
443 lines
19 KiB
React
443 lines
19 KiB
React
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, Landmark, CheckSquare, FolderKanban } from 'lucide-react'
|
|
import { AppContext } from '../App'
|
|
import { useAuth } from '../contexts/AuthContext'
|
|
import { useLanguage } from '../i18n/LanguageContext'
|
|
import { api, PRIORITY_CONFIG } from '../utils/api'
|
|
import StatusBadge from '../components/StatusBadge'
|
|
import BrandBadge from '../components/BrandBadge'
|
|
import DatePresetPicker from '../components/DatePresetPicker'
|
|
import { SkeletonDashboard } from '../components/SkeletonLoader'
|
|
|
|
function getBudgetBarColor(percentage) {
|
|
if (percentage > 90) return 'bg-red-500'
|
|
if (percentage > 70) return 'bg-amber-500'
|
|
return 'bg-emerald-500'
|
|
}
|
|
|
|
function BudgetSummary({ finance }) {
|
|
const { t, currencySymbol } = useLanguage()
|
|
if (!finance) return null
|
|
const totalReceived = finance.totalReceived || 0
|
|
const mainAvailable = finance.mainAvailable != null ? finance.mainAvailable : (finance.remaining || 0)
|
|
const consumed = totalReceived - mainAvailable
|
|
const pct = totalReceived > 0 ? (consumed / totalReceived) * 100 : 0
|
|
const barColor = getBudgetBarColor(pct)
|
|
|
|
return (
|
|
<div className="bg-surface rounded-xl border border-border p-5">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="font-semibold text-text-primary">{t('dashboard.budgetOverview')}</h3>
|
|
<Link to="/finance" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
|
|
{t('dashboard.details')} <ArrowRight className="w-3.5 h-3.5" />
|
|
</Link>
|
|
</div>
|
|
|
|
{totalReceived === 0 ? (
|
|
<div className="text-center py-6 text-sm text-text-tertiary">
|
|
{t('dashboard.noBudgetRecorded')}. <Link to="/budgets" className="text-brand-primary underline">{t('dashboard.addBudget')}</Link>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="flex justify-between text-xs text-text-tertiary mb-1">
|
|
<span>{consumed.toLocaleString()} {currencySymbol} {t('dashboard.spent')}</span>
|
|
<span>{totalReceived.toLocaleString()} {currencySymbol} {t('dashboard.received')}</span>
|
|
</div>
|
|
<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 className={`mt-3 text-sm font-semibold ${mainAvailable >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
|
|
{mainAvailable.toLocaleString()} {currencySymbol} {t('dashboard.remaining')}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ActiveCampaignsList({ campaigns, finance }) {
|
|
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="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.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
|
|
const pct = allocated > 0 ? (spent / allocated) * 100 : 0
|
|
const barColor = getBudgetBarColor(pct)
|
|
return (
|
|
<Link key={c._id || c.id} to={`/campaigns/${c._id || c.id}`} 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">{c.name}</p>
|
|
{allocated > 0 && (
|
|
<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()} {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)}%` }} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Link>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function MyTasksList({ tasks, currentUserId, navigate, t }) {
|
|
const myTasks = useMemo(() => tasks
|
|
.filter(task => {
|
|
const assignedId = task.assigned_to_id || task.assignedTo
|
|
return assignedId === currentUserId && task.status !== 'done'
|
|
})
|
|
.slice(0, 5), [tasks, currentUserId])
|
|
|
|
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 => (
|
|
<button
|
|
key={task._id || task.id}
|
|
onClick={() => navigate('/tasks')}
|
|
className="w-full flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors text-start"
|
|
>
|
|
<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>
|
|
)}
|
|
</button>
|
|
))
|
|
)}
|
|
</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>
|
|
)
|
|
}
|
|
|
|
function ActivityFeed({ posts, deadlines, navigate, t }) {
|
|
const [tab, setTab] = useState('posts')
|
|
const hasPosts = posts.length > 0
|
|
const hasDeadlines = deadlines.length > 0
|
|
|
|
return (
|
|
<div className="section-card">
|
|
<div className="section-card-header flex items-center justify-between">
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={() => setTab('posts')}
|
|
className={`px-3 py-1 text-sm font-medium rounded-md transition-colors ${
|
|
tab === 'posts' ? 'bg-brand-primary/10 text-brand-primary' : 'text-text-tertiary hover:text-text-secondary'
|
|
}`}
|
|
>
|
|
{t('dashboard.recentPosts')}
|
|
</button>
|
|
<button
|
|
onClick={() => setTab('deadlines')}
|
|
className={`px-3 py-1 text-sm font-medium rounded-md transition-colors ${
|
|
tab === 'deadlines' ? 'bg-brand-primary/10 text-brand-primary' : 'text-text-tertiary hover:text-text-secondary'
|
|
}`}
|
|
>
|
|
{t('dashboard.upcomingDeadlines')}
|
|
</button>
|
|
</div>
|
|
<Link to={tab === 'posts' ? '/posts' : '/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">
|
|
{tab === 'posts' ? (
|
|
!hasPosts ? (
|
|
<div className="py-10 text-center text-sm text-text-tertiary">{t('dashboard.noPostsYet')}</div>
|
|
) : (
|
|
posts.slice(0, 6).map(post => (
|
|
<button key={post._id} onClick={() => navigate('/posts')} className="w-full flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors text-start">
|
|
<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">
|
|
{post.brand && <BrandBadge brand={post.brand} />}
|
|
</div>
|
|
</div>
|
|
<StatusBadge status={post.status} size="xs" />
|
|
</button>
|
|
))
|
|
)
|
|
) : (
|
|
!hasDeadlines ? (
|
|
<div className="py-10 text-center text-sm text-text-tertiary">{t('dashboard.noUpcomingDeadlines')}</div>
|
|
) : (
|
|
deadlines.map(task => (
|
|
<button key={task._id} onClick={() => navigate('/tasks')} className="w-full flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors text-start">
|
|
<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>
|
|
<div className="flex items-center gap-2 mt-0.5">
|
|
<StatusBadge status={task.status} size="xs" />
|
|
{task.assignedName && <span className="text-xs text-text-tertiary truncate">{task.assignedName}</span>}
|
|
</div>
|
|
</div>
|
|
<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>
|
|
</button>
|
|
))
|
|
)
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function Dashboard() {
|
|
const { t, currencySymbol } = useLanguage()
|
|
const navigate = useNavigate()
|
|
const { currentUser } = useContext(AppContext)
|
|
const { hasModule } = useAuth()
|
|
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)
|
|
|
|
const [dateFrom, setDateFrom] = useState('')
|
|
const [dateTo, setDateTo] = useState('')
|
|
const [activePreset, setActivePreset] = useState('')
|
|
|
|
useEffect(() => {
|
|
loadData()
|
|
}, [])
|
|
|
|
const loadData = async () => {
|
|
try {
|
|
const fetches = []
|
|
if (hasModule('marketing')) {
|
|
fetches.push(api.get('/posts?limit=50&sort=-createdAt').then(r => ({ key: 'posts', data: Array.isArray(r) ? r : [] })))
|
|
fetches.push(api.get('/campaigns').then(r => ({ key: 'campaigns', data: Array.isArray(r) ? r : [] })))
|
|
}
|
|
if (hasModule('projects')) {
|
|
fetches.push(api.get('/tasks').then(r => ({ key: 'tasks', data: Array.isArray(r) ? r : [] })))
|
|
fetches.push(api.get('/projects').then(r => ({ key: 'projects', data: Array.isArray(r) ? r : [] })))
|
|
}
|
|
if (hasModule('finance')) {
|
|
fetches.push(api.get('/finance/summary').then(r => ({ key: 'finance', data: r || null })))
|
|
}
|
|
|
|
const results = await Promise.allSettled(fetches)
|
|
results.forEach(r => {
|
|
if (r.status !== 'fulfilled') return
|
|
const { key, data } = r.value
|
|
if (key === 'posts') setPosts(data)
|
|
else if (key === 'campaigns') setCampaigns(data)
|
|
else if (key === 'tasks') setTasks(data)
|
|
else if (key === 'projects') setProjects(data)
|
|
else if (key === 'finance') setFinance(data)
|
|
})
|
|
} catch (err) {
|
|
console.error('Dashboard load error:', err)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
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 = filteredTasks.filter(t =>
|
|
t.dueDate && new Date(t.dueDate) < new Date() && t.status !== 'done'
|
|
).length
|
|
|
|
const upcomingDeadlines = useMemo(() => filteredTasks
|
|
.filter(t => {
|
|
if (!t.dueDate || t.status === 'done') return false
|
|
const due = new Date(t.dueDate)
|
|
const now = new Date()
|
|
return isAfter(due, now) && isBefore(due, addDays(now, 7))
|
|
})
|
|
.sort((a, b) => new Date(a.dueDate) - new Date(b.dueDate))
|
|
.slice(0, 6), [filteredTasks])
|
|
|
|
// Inline stat values — no card component needed
|
|
const stats = []
|
|
if (hasModule('marketing')) {
|
|
stats.push({ label: t('dashboard.totalPosts'), value: filteredPosts.length, detail: `${filteredPosts.filter(p => p.status === 'published').length} ${t('dashboard.published')}`, icon: FileText, accent: 'text-indigo-600' })
|
|
stats.push({ label: t('dashboard.activeCampaigns'), value: activeCampaigns, detail: `${campaigns.length} ${t('dashboard.total')}`, icon: Megaphone, accent: 'text-pink-600' })
|
|
}
|
|
if (hasModule('projects')) {
|
|
stats.push({ label: t('dashboard.overdueTasks'), value: overdueTasks, detail: overdueTasks > 0 ? t('dashboard.needsAttention') : t('dashboard.allOnTrack'), icon: AlertTriangle, accent: overdueTasks > 0 ? 'text-red-600' : 'text-emerald-600' })
|
|
}
|
|
|
|
if (loading) return <SkeletonDashboard />
|
|
|
|
return (
|
|
<div className="space-y-6 animate-fade-in">
|
|
{/* Welcome + Date presets */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
|
<p className="text-lg font-medium text-text-primary">
|
|
{t('dashboard.welcomeBack')}, {currentUser?.name || 'there'}
|
|
</p>
|
|
<DatePresetPicker
|
|
activePreset={activePreset}
|
|
onSelect={(from, to, key) => { setDateFrom(from); setDateTo(to); setActivePreset(key) }}
|
|
onClear={() => { setDateFrom(''); setDateTo(''); setActivePreset('') }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Stats — compact inline row, no cards */}
|
|
{stats.length > 0 && (
|
|
<div className="flex flex-wrap gap-6">
|
|
{stats.map((s, i) => (
|
|
<div key={i} className="flex items-center gap-3">
|
|
<s.icon className={`w-5 h-5 ${s.accent}`} />
|
|
<div>
|
|
<span className="text-2xl font-bold text-text-primary">{s.value}</span>
|
|
<span className="text-sm text-text-tertiary ms-1.5">{s.label}</span>
|
|
<p className="text-xs text-text-tertiary">{s.detail}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* My Tasks + Project Progress */}
|
|
{hasModule('projects') && (
|
|
<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>
|
|
)}
|
|
|
|
{/* Budget + Active Campaigns */}
|
|
{(hasModule('finance') || hasModule('marketing')) && (
|
|
<div className={`grid grid-cols-1 ${hasModule('finance') && hasModule('marketing') ? 'lg:grid-cols-3' : ''} gap-6`}>
|
|
{hasModule('finance') && <BudgetSummary finance={finance} />}
|
|
{hasModule('marketing') && (
|
|
<div className={hasModule('finance') ? 'lg:col-span-2' : ''}>
|
|
<ActiveCampaignsList campaigns={campaigns} finance={finance} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Activity — merged posts + deadlines */}
|
|
{(hasModule('marketing') || hasModule('projects')) && (
|
|
<ActivityFeed
|
|
posts={hasModule('marketing') ? filteredPosts : []}
|
|
deadlines={hasModule('projects') ? upcomingDeadlines : []}
|
|
navigate={navigate}
|
|
t={t}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|