Marketing Hub: RBAC, i18n (AR/EN), tasks overhaul, team/user merge, tutorial

Features:
- Full RBAC with 3 roles (superadmin/manager/contributor)
- Ownership tracking on posts, tasks, campaigns, projects
- Task system: assign to anyone, filter combobox, visibility scoping
- Team members merged into users table (single source of truth)
- Post thumbnails on kanban cards from attachments
- Publication link validation before publishing
- Interactive onboarding tutorial with Settings restart
- Full Arabic/English i18n with RTL layout support
- Language toggle in sidebar, IBM Plex Sans Arabic font
- Brand-based visibility filtering for non-superadmins
- Manager can only create contributors
- Profile completion flow for new users
- Cookie-based sessions (express-session + SQLite)
This commit is contained in:
fahed
2026-02-08 20:46:58 +03:00
commit 35d84b6bff
2240 changed files with 846749 additions and 0 deletions
+311
View File
@@ -0,0 +1,311 @@
import { useContext, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { format, isAfter, isBefore, addDays } from 'date-fns'
import { FileText, Megaphone, AlertTriangle, Users, ArrowRight, Clock, Wallet, TrendingUp, DollarSign, PiggyBank } from 'lucide-react'
import { AppContext } from '../App'
import { useLanguage } from '../i18n/LanguageContext'
import { api } from '../utils/api'
import StatCard from '../components/StatCard'
import StatusBadge from '../components/StatusBadge'
import BrandBadge from '../components/BrandBadge'
function FinanceMini({ finance }) {
const { t } = 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 pct = totalReceived > 0 ? (spent / totalReceived) * 100 : 0
const barColor = pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-amber-500' : 'bg-emerald-500'
return (
<div className="bg-white 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="/finance" className="text-brand-primary underline">{t('dashboard.addBudget')}</Link>
</div>
) : (
<>
{/* Budget bar */}
<div className="mb-4">
<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>
</div>
<div className="h-3 bg-surface-tertiary rounded-full overflow-hidden">
<div className={`h-full ${barColor} rounded-full transition-all`} style={{ width: `${Math.min(pct, 100)}%` }} />
</div>
</div>
{/* Key numbers */}
<div className="grid grid-cols-3 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" />
<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>
<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'}`}>
{roi.toFixed(0)}%
</div>
<div className="text-[10px] text-text-tertiary">{t('dashboard.roi')}</div>
</div>
</div>
</>
)}
</div>
)
}
function ActiveCampaignsList({ campaigns, finance }) {
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">
<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 => {
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 = pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-amber-500' : 'bg-emerald-500'
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()} SAR</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>
<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()}
</div>
)}
</div>
</Link>
)
})}
</div>
</div>
)
}
export default function Dashboard() {
const { t } = useLanguage()
const { currentUser, teamMembers } = useContext(AppContext)
const [posts, setPosts] = useState([])
const [campaigns, setCampaigns] = useState([])
const [tasks, setTasks] = useState([])
const [finance, setFinance] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
try {
const [postsRes, campaignsRes, tasksRes, financeRes] = await Promise.allSettled([
api.get('/posts?limit=10&sort=-createdAt'),
api.get('/campaigns'),
api.get('/tasks'),
api.get('/finance/summary'),
])
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)
} catch (err) {
console.error('Dashboard load error:', err)
} finally {
setLoading(false)
}
}
const activeCampaigns = campaigns.filter(c => c.status === 'active').length
const overdueTasks = tasks.filter(t =>
t.dueDate && new Date(t.dueDate) < new Date() && t.status !== 'done'
).length
const upcomingDeadlines = tasks
.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, 8)
if (loading) {
return (
<div className="space-y-6 animate-pulse">
<div className="h-8 w-64 bg-surface-tertiary rounded-lg"></div>
<div className="grid grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-28 bg-surface-tertiary rounded-xl"></div>
))}
</div>
</div>
)
}
return (
<div className="space-y-6 animate-fade-in">
{/* Welcome */}
<div>
<h1 className="text-2xl font-bold text-text-primary">
Welcome back, {currentUser?.name || 'there'} 👋
</h1>
<p className="text-text-secondary mt-1">
Here's what's happening with your marketing today.
</p>
</div>
{/* Stats */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 stagger-children">
<StatCard
icon={FileText}
label="Total Posts"
value={posts.length || 0}
subtitle={`${posts.filter(p => p.status === 'published').length} published`}
color="brand-primary"
/>
<StatCard
icon={Megaphone}
label="Active Campaigns"
value={activeCampaigns}
subtitle={`${campaigns.length} total`}
color="brand-secondary"
/>
<StatCard
icon={Wallet}
label="Budget Spent"
value={`${((finance?.spent || 0)).toLocaleString()}`}
subtitle={finance?.totalReceived ? `of ${finance.totalReceived.toLocaleString()} SAR` : 'No budget yet'}
color="brand-tertiary"
/>
<StatCard
icon={AlertTriangle}
label="Overdue Tasks"
value={overdueTasks}
subtitle={overdueTasks > 0 ? 'Needs attention' : 'All on track'}
color="brand-quaternary"
/>
</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} />
{/* Active Campaigns with budget bars */}
<div className="lg:col-span-2">
<ActiveCampaignsList campaigns={campaigns} finance={finance} />
</div>
</div>
{/* Two columns */}
<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">
<h3 className="font-semibold text-text-primary">Recent Posts</h3>
<Link to="/posts" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
View all <ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
<div className="divide-y divide-border-light">
{posts.length === 0 ? (
<div className="py-12 text-center text-sm text-text-tertiary">
No posts yet. Create your first post!
</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">
<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" />
</div>
))
)}
</div>
</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">
<h3 className="font-semibold text-text-primary">Upcoming Deadlines</h3>
<Link to="/tasks" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
View all <ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
<div className="divide-y divide-border-light">
{upcomingDeadlines.length === 0 ? (
<div className="py-12 text-center text-sm text-text-tertiary">
No upcoming deadlines this week. 🎉
</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 className={`w-2 h-2 rounded-full ${
task.priority === 'urgent' ? 'bg-red-500' :
task.priority === 'high' ? 'bg-orange-500' :
task.priority === 'medium' ? 'bg-amber-400' : 'bg-gray-400'
}`} />
<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>
<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>
</div>
</div>
)
}