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:
175
client/src/App.jsx
Normal file
175
client/src/App.jsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useState, useEffect, createContext } from 'react'
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||
import { LanguageProvider } from './i18n/LanguageContext'
|
||||
import Layout from './components/Layout'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import PostProduction from './pages/PostProduction'
|
||||
import Assets from './pages/Assets'
|
||||
import Campaigns from './pages/Campaigns'
|
||||
import CampaignDetail from './pages/CampaignDetail'
|
||||
import Finance from './pages/Finance'
|
||||
import Projects from './pages/Projects'
|
||||
import ProjectDetail from './pages/ProjectDetail'
|
||||
import Tasks from './pages/Tasks'
|
||||
import Team from './pages/Team'
|
||||
import Users from './pages/Users'
|
||||
import Settings from './pages/Settings'
|
||||
import Login from './pages/Login'
|
||||
import Tutorial from './components/Tutorial'
|
||||
import { api } from './utils/api'
|
||||
import { useLanguage } from './i18n/LanguageContext'
|
||||
|
||||
export const AppContext = createContext()
|
||||
|
||||
function AppContent() {
|
||||
const { user, loading: authLoading } = useAuth()
|
||||
const { t } = useLanguage()
|
||||
const [teamMembers, setTeamMembers] = useState([])
|
||||
const [brands, setBrands] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showTutorial, setShowTutorial] = useState(false)
|
||||
const [showProfilePrompt, setShowProfilePrompt] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (user && !authLoading) {
|
||||
loadInitialData()
|
||||
// Check if tutorial should be shown
|
||||
if (user.tutorial_completed === 0) {
|
||||
setShowTutorial(true)
|
||||
}
|
||||
// Check if profile is incomplete
|
||||
if (!user.profileComplete && user.role !== 'superadmin') {
|
||||
setShowProfilePrompt(true)
|
||||
}
|
||||
} else if (!authLoading) {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [user, authLoading])
|
||||
|
||||
const loadTeam = async () => {
|
||||
try {
|
||||
const data = await api.get('/users/team')
|
||||
const members = Array.isArray(data) ? data : (data.data || [])
|
||||
setTeamMembers(members)
|
||||
return members
|
||||
} catch (err) {
|
||||
console.error('Failed to load team:', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const loadInitialData = async () => {
|
||||
try {
|
||||
const [members, brandsData] = await Promise.all([
|
||||
loadTeam(),
|
||||
api.get('/brands').then(d => Array.isArray(d) ? d : (d.data || [])).catch(() => []),
|
||||
])
|
||||
setTeamMembers(members)
|
||||
setBrands(brandsData)
|
||||
} catch (err) {
|
||||
console.error('Failed to load initial data:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTutorialComplete = async () => {
|
||||
try {
|
||||
await api.patch('/users/me/tutorial', { completed: true })
|
||||
setShowTutorial(false)
|
||||
} catch (err) {
|
||||
console.error('Failed to complete tutorial:', err)
|
||||
}
|
||||
}
|
||||
|
||||
if (authLoading || loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-surface-secondary">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 border-4 border-brand-primary border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
||||
<p className="text-text-secondary font-medium">{t('dashboard.loadingHub')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppContext.Provider value={{ currentUser: user, teamMembers, brands, loadTeam }}>
|
||||
{/* Profile completion prompt */}
|
||||
{showProfilePrompt && (
|
||||
<div className="fixed top-4 right-4 z-50 bg-amber-50 border-2 border-amber-400 rounded-xl shadow-lg p-4 max-w-md animate-fade-in">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-amber-400 flex items-center justify-center text-white shrink-0">
|
||||
⚠️
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-amber-900 mb-1">{t('profile.completeYourProfile')}</h3>
|
||||
<p className="text-sm text-amber-800 mb-3">
|
||||
{t('profile.completeDesc')}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<a
|
||||
href="/team"
|
||||
className="px-3 py-1.5 bg-amber-400 text-white text-sm font-medium rounded-lg hover:bg-amber-500 transition-colors"
|
||||
>
|
||||
{t('profile.completeProfileBtn')}
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setShowProfilePrompt(false)}
|
||||
className="px-3 py-1.5 text-sm font-medium text-amber-800 hover:bg-amber-100 rounded-lg transition-colors"
|
||||
>
|
||||
{t('profile.later')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowProfilePrompt(false)}
|
||||
className="text-amber-600 hover:text-amber-800 transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tutorial overlay */}
|
||||
{showTutorial && <Tutorial onComplete={handleTutorialComplete} />}
|
||||
|
||||
<Routes>
|
||||
<Route path="/login" element={user ? <Navigate to="/" replace /> : <Login />} />
|
||||
<Route path="/" element={user ? <Layout /> : <Navigate to="/login" replace />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="posts" element={<PostProduction />} />
|
||||
<Route path="assets" element={<Assets />} />
|
||||
<Route path="campaigns" element={<Campaigns />} />
|
||||
<Route path="campaigns/:id" element={<CampaignDetail />} />
|
||||
{(user?.role === 'superadmin' || user?.role === 'manager') && (
|
||||
<Route path="finance" element={<Finance />} />
|
||||
)}
|
||||
<Route path="projects" element={<Projects />} />
|
||||
<Route path="projects/:id" element={<ProjectDetail />} />
|
||||
<Route path="tasks" element={<Tasks />} />
|
||||
<Route path="team" element={<Team />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
{user?.role === 'superadmin' && (
|
||||
<Route path="users" element={<Users />} />
|
||||
)}
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</AppContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<LanguageProvider>
|
||||
<AuthProvider>
|
||||
<AppContent />
|
||||
</AuthProvider>
|
||||
</LanguageProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
86
client/src/components/AssetCard.jsx
Normal file
86
client/src/components/AssetCard.jsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { format } from 'date-fns'
|
||||
import { Image, FileText, Film, Music, File, Download } from 'lucide-react'
|
||||
|
||||
const typeIcons = {
|
||||
image: Image,
|
||||
document: FileText,
|
||||
video: Film,
|
||||
audio: Music,
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
if (!bytes) return '—'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export default function AssetCard({ asset, onClick }) {
|
||||
const TypeIcon = typeIcons[asset.type] || File
|
||||
const isImage = asset.type === 'image'
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick?.(asset)}
|
||||
className="bg-white rounded-xl border border-border overflow-hidden card-hover cursor-pointer group"
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="aspect-square bg-surface-tertiary flex items-center justify-center overflow-hidden relative">
|
||||
{isImage && asset.url ? (
|
||||
<img
|
||||
src={asset.url}
|
||||
alt={asset.name}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none'
|
||||
e.target.nextSibling.style.display = 'flex'
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center gap-2 ${isImage && asset.url ? 'hidden' : ''}`}
|
||||
style={{ display: isImage && asset.url ? 'none' : 'flex' }}
|
||||
>
|
||||
<TypeIcon className="w-10 h-10 text-text-tertiary" />
|
||||
<span className="text-xs text-text-tertiary uppercase font-medium">
|
||||
{asset.fileType || asset.type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Hover overlay */}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors flex items-center justify-center">
|
||||
<button className="opacity-0 group-hover:opacity-100 bg-white/90 backdrop-blur-sm rounded-full p-2 shadow-lg transition-opacity">
|
||||
<Download className="w-4 h-4 text-text-primary" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-3">
|
||||
<h5 className="text-sm font-medium text-text-primary truncate">{asset.name}</h5>
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<span className="text-xs text-text-tertiary">{formatFileSize(asset.size)}</span>
|
||||
{asset.createdAt && (
|
||||
<span className="text-xs text-text-tertiary">
|
||||
{format(new Date(asset.createdAt), 'MMM d')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{asset.tags && asset.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{asset.tags.slice(0, 3).map((tag) => (
|
||||
<span key={tag} className="text-[10px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-tertiary">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{asset.tags.length > 3 && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 text-text-tertiary">
|
||||
+{asset.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
12
client/src/components/BrandBadge.jsx
Normal file
12
client/src/components/BrandBadge.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { getBrandColor } from '../utils/api'
|
||||
|
||||
export default function BrandBadge({ brand }) {
|
||||
const color = getBrandColor(brand)
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 text-xs font-medium px-2 py-0.5 rounded-full ${color.bg} ${color.text}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${color.dot}`}></span>
|
||||
{brand}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
132
client/src/components/CampaignCalendar.jsx
Normal file
132
client/src/components/CampaignCalendar.jsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import {
|
||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||
eachDayOfInterval, format, isSameMonth, isToday,
|
||||
addMonths, subMonths, isBefore, isAfter, isSameDay
|
||||
} from 'date-fns'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { getBrandColor } from '../utils/api'
|
||||
|
||||
const CAMPAIGN_COLORS = [
|
||||
'bg-indigo-400', 'bg-pink-400', 'bg-emerald-400', 'bg-amber-400',
|
||||
'bg-purple-400', 'bg-cyan-400', 'bg-rose-400', 'bg-teal-400',
|
||||
]
|
||||
|
||||
export default function CampaignCalendar({ campaigns = [] }) {
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date())
|
||||
|
||||
const days = useMemo(() => {
|
||||
const monthStart = startOfMonth(currentMonth)
|
||||
const monthEnd = endOfMonth(currentMonth)
|
||||
const calStart = startOfWeek(monthStart, { weekStartsOn: 0 })
|
||||
const calEnd = endOfWeek(monthEnd, { weekStartsOn: 0 })
|
||||
return eachDayOfInterval({ start: calStart, end: calEnd })
|
||||
}, [currentMonth])
|
||||
|
||||
const getCampaignsForDay = (day) => {
|
||||
return campaigns.filter((c) => {
|
||||
const start = new Date(c.startDate)
|
||||
const end = new Date(c.endDate)
|
||||
return (isSameDay(day, start) || isAfter(day, start)) &&
|
||||
(isSameDay(day, end) || isBefore(day, end))
|
||||
})
|
||||
}
|
||||
|
||||
const isStartOfCampaign = (day, campaign) => {
|
||||
return isSameDay(day, new Date(campaign.startDate))
|
||||
}
|
||||
|
||||
const isEndOfCampaign = (day, campaign) => {
|
||||
return isSameDay(day, new Date(campaign.endDate))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<h3 className="text-lg font-semibold text-text-primary">
|
||||
{format(currentMonth, 'MMMM yyyy')}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-secondary"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentMonth(new Date())}
|
||||
className="px-3 py-1 text-sm font-medium rounded-lg hover:bg-surface-tertiary text-text-secondary"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-secondary"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Day names */}
|
||||
<div className="calendar-grid border-b border-border">
|
||||
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d) => (
|
||||
<div key={d} className="px-2 py-2 text-center text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Calendar grid */}
|
||||
<div className="calendar-grid">
|
||||
{days.map((day, i) => {
|
||||
const dayCampaigns = getCampaignsForDay(day)
|
||||
const inMonth = isSameMonth(day, currentMonth)
|
||||
const today = isToday(day)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`min-h-[80px] p-1 border-b border-r border-border-light relative ${
|
||||
!inMonth ? 'bg-surface-secondary/50' : ''
|
||||
}`}
|
||||
>
|
||||
<span className={`text-xs font-medium inline-flex items-center justify-center w-6 h-6 rounded-full ${
|
||||
today ? 'bg-brand-primary text-white' :
|
||||
inMonth ? 'text-text-primary' : 'text-text-tertiary'
|
||||
}`}>
|
||||
{format(day, 'd')}
|
||||
</span>
|
||||
|
||||
<div className="space-y-0.5 mt-0.5">
|
||||
{dayCampaigns.slice(0, 3).map((campaign, ci) => {
|
||||
const colorIndex = campaigns.indexOf(campaign) % CAMPAIGN_COLORS.length
|
||||
const isStart = isStartOfCampaign(day, campaign)
|
||||
const isEnd = isEndOfCampaign(day, campaign)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={campaign._id || ci}
|
||||
className={`h-5 text-[10px] font-medium text-white flex items-center px-1 truncate ${CAMPAIGN_COLORS[colorIndex]} ${
|
||||
isStart ? 'rounded-l-full ml-0' : '-ml-1'
|
||||
} ${isEnd ? 'rounded-r-full mr-0' : '-mr-1'}`}
|
||||
title={campaign.name}
|
||||
>
|
||||
{isStart ? campaign.name : ''}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{dayCampaigns.length > 3 && (
|
||||
<div className="text-[10px] text-text-tertiary px-1">
|
||||
+{dayCampaigns.length - 3} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
134
client/src/components/Header.jsx
Normal file
134
client/src/components/Header.jsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { Bell, ChevronDown, LogOut, Settings, User, Shield } from 'lucide-react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
const pageTitles = {
|
||||
'/': 'Dashboard',
|
||||
'/posts': 'Post Production',
|
||||
'/assets': 'Assets',
|
||||
'/campaigns': 'Campaigns',
|
||||
'/finance': 'Finance',
|
||||
'/projects': 'Projects',
|
||||
'/tasks': 'My Tasks',
|
||||
'/team': 'Team',
|
||||
'/users': 'User Management',
|
||||
}
|
||||
|
||||
const ROLE_INFO = {
|
||||
superadmin: { label: 'Superadmin', color: 'bg-purple-100 text-purple-700', icon: '👑' },
|
||||
manager: { label: 'Manager', color: 'bg-blue-100 text-blue-700', icon: '📊' },
|
||||
contributor: { label: 'Contributor', color: 'bg-green-100 text-green-700', icon: '✏️' },
|
||||
}
|
||||
|
||||
export default function Header() {
|
||||
const { user, logout } = useAuth()
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
const dropdownRef = useRef(null)
|
||||
const location = useLocation()
|
||||
|
||||
const pageTitle = pageTitles[location.pathname] ||
|
||||
(location.pathname.startsWith('/projects/') ? 'Project Details' :
|
||||
location.pathname.startsWith('/campaigns/') ? 'Campaign Details' : 'Page')
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
|
||||
setShowDropdown(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const getInitials = (name) => {
|
||||
if (!name) return '?'
|
||||
return name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
const roleInfo = ROLE_INFO[user?.role] || ROLE_INFO.contributor
|
||||
|
||||
return (
|
||||
<header className="h-16 bg-white border-b border-border flex items-center justify-between px-6 shrink-0 sticky top-0 z-20">
|
||||
{/* Page title */}
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-text-primary">{pageTitle}</h2>
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Notifications */}
|
||||
<button className="relative p-2 rounded-lg hover:bg-surface-tertiary text-text-secondary hover:text-text-primary transition-colors">
|
||||
<Bell className="w-5 h-5" />
|
||||
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
</button>
|
||||
|
||||
{/* User menu */}
|
||||
<div ref={dropdownRef} className="relative">
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-surface-tertiary transition-colors"
|
||||
>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-semibold ${
|
||||
user?.role === 'superadmin'
|
||||
? 'bg-gradient-to-br from-purple-500 to-pink-500'
|
||||
: 'bg-gradient-to-br from-blue-500 to-indigo-500'
|
||||
}`}>
|
||||
{getInitials(user?.name)}
|
||||
</div>
|
||||
<div className="text-left hidden sm:block">
|
||||
<p className="text-sm font-medium text-text-primary">
|
||||
{user?.name || 'User'}
|
||||
</p>
|
||||
<p className={`text-[10px] font-medium ${roleInfo.color.split(' ')[1]}`}>
|
||||
{roleInfo.icon} {roleInfo.label}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown className={`w-4 h-4 text-text-tertiary transition-transform ${showDropdown ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{showDropdown && (
|
||||
<div className="absolute right-0 top-full mt-2 w-64 bg-white rounded-xl shadow-lg border border-border overflow-hidden animate-scale-in">
|
||||
{/* User info */}
|
||||
<div className="px-4 py-3 border-b border-border-light bg-surface-secondary">
|
||||
<p className="text-sm font-semibold text-text-primary">{user?.name}</p>
|
||||
<p className="text-xs text-text-tertiary">{user?.email}</p>
|
||||
<div className={`inline-flex items-center gap-1 text-[10px] font-medium px-2 py-0.5 rounded-full mt-2 ${roleInfo.color}`}>
|
||||
<span>{roleInfo.icon}</span>
|
||||
{roleInfo.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Menu items */}
|
||||
<div className="py-2">
|
||||
{user?.role === 'superadmin' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDropdown(false)
|
||||
window.location.href = '/users'
|
||||
}}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-surface-secondary transition-colors text-left"
|
||||
>
|
||||
<Shield className="w-4 h-4 text-text-tertiary" />
|
||||
<span className="text-sm text-text-primary">User Management</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDropdown(false)
|
||||
logout()
|
||||
}}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-red-50 transition-colors text-left group"
|
||||
>
|
||||
<LogOut className="w-4 h-4 text-text-tertiary group-hover:text-red-500" />
|
||||
<span className="text-sm text-text-primary group-hover:text-red-500">Sign Out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
111
client/src/components/KanbanBoard.jsx
Normal file
111
client/src/components/KanbanBoard.jsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useState } from 'react'
|
||||
import PostCard from './PostCard'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
|
||||
const COLUMNS = [
|
||||
{ id: 'draft', labelKey: 'posts.status.draft', color: 'bg-gray-400' },
|
||||
{ id: 'in_review', labelKey: 'posts.status.in_review', color: 'bg-amber-400' },
|
||||
{ id: 'approved', labelKey: 'posts.status.approved', color: 'bg-blue-400' },
|
||||
{ id: 'scheduled', labelKey: 'posts.status.scheduled', color: 'bg-purple-400' },
|
||||
{ id: 'published', labelKey: 'posts.status.published', color: 'bg-emerald-400' },
|
||||
]
|
||||
|
||||
export default function KanbanBoard({ posts, onPostClick, onMovePost }) {
|
||||
const { t } = useLanguage()
|
||||
const [draggedPost, setDraggedPost] = useState(null)
|
||||
const [dragOverCol, setDragOverCol] = useState(null)
|
||||
|
||||
const handleDragStart = (e, post) => {
|
||||
setDraggedPost(post)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
// Make the drag image slightly transparent
|
||||
if (e.target) {
|
||||
setTimeout(() => e.target.style.opacity = '0.4', 0)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnd = (e) => {
|
||||
e.target.style.opacity = '1'
|
||||
setDraggedPost(null)
|
||||
setDragOverCol(null)
|
||||
}
|
||||
|
||||
const handleDragOver = (e, colId) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
setDragOverCol(colId)
|
||||
}
|
||||
|
||||
const handleDragLeave = (e, colId) => {
|
||||
// Only clear if we're actually leaving the column (not entering a child)
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||||
setDragOverCol(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (e, colId) => {
|
||||
e.preventDefault()
|
||||
setDragOverCol(null)
|
||||
if (draggedPost && draggedPost.status !== colId) {
|
||||
onMovePost(draggedPost._id, colId)
|
||||
}
|
||||
setDraggedPost(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||
{COLUMNS.map((col) => {
|
||||
const colPosts = posts.filter((p) => p.status === col.id)
|
||||
const isOver = dragOverCol === col.id && draggedPost?.status !== col.id
|
||||
|
||||
return (
|
||||
<div key={col.id} className="flex-shrink-0 w-72">
|
||||
{/* Column header */}
|
||||
<div className="flex items-center gap-2 mb-3 px-1">
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${col.color}`} />
|
||||
<h4 className="text-sm font-semibold text-text-primary">{t(col.labelKey)}</h4>
|
||||
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-2 py-0.5 rounded-full ml-auto">
|
||||
{colPosts.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Column body — drop zone */}
|
||||
<div
|
||||
className={`kanban-column rounded-xl p-2 space-y-2 border-2 transition-colors min-h-[120px] ${
|
||||
isOver
|
||||
? 'bg-brand-primary/5 border-brand-primary/40 border-dashed'
|
||||
: 'bg-surface-secondary border-border-light border-solid'
|
||||
}`}
|
||||
onDragOver={(e) => handleDragOver(e, col.id)}
|
||||
onDragLeave={(e) => handleDragLeave(e, col.id)}
|
||||
onDrop={(e) => handleDrop(e, col.id)}
|
||||
>
|
||||
{colPosts.length === 0 ? (
|
||||
<div className={`py-8 text-center text-xs ${isOver ? 'text-brand-primary font-medium' : 'text-text-tertiary'}`}>
|
||||
{isOver ? t('posts.dropHere') : t('posts.noPosts')}
|
||||
</div>
|
||||
) : (
|
||||
colPosts.map((post) => (
|
||||
<div
|
||||
key={post._id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, post)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className="cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<PostCard
|
||||
post={post}
|
||||
onClick={() => onPostClick(post)}
|
||||
onMove={onMovePost}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
client/src/components/Layout.jsx
Normal file
24
client/src/components/Layout.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useState } from 'react'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import Sidebar from './Sidebar'
|
||||
import Header from './Header'
|
||||
|
||||
export default function Layout() {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-secondary">
|
||||
<Sidebar collapsed={collapsed} setCollapsed={setCollapsed} />
|
||||
<div
|
||||
className={`transition-all duration-300 ${
|
||||
collapsed ? 'main-content-margin-collapsed' : 'main-content-margin'
|
||||
}`}
|
||||
>
|
||||
<Header />
|
||||
<main className="p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
68
client/src/components/MemberCard.jsx
Normal file
68
client/src/components/MemberCard.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import BrandBadge from './BrandBadge'
|
||||
|
||||
const ROLE_BADGES = {
|
||||
manager: { bg: 'bg-indigo-50', text: 'text-indigo-700', label: 'Manager' },
|
||||
approver: { bg: 'bg-emerald-50', text: 'text-emerald-700', label: 'Approver' },
|
||||
publisher: { bg: 'bg-blue-50', text: 'text-blue-700', label: 'Publisher' },
|
||||
content_creator: { bg: 'bg-amber-50', text: 'text-amber-700', label: 'Content Creator' },
|
||||
producer: { bg: 'bg-purple-50', text: 'text-purple-700', label: 'Producer' },
|
||||
designer: { bg: 'bg-pink-50', text: 'text-pink-700', label: 'Designer' },
|
||||
content_writer: { bg: 'bg-orange-50', text: 'text-orange-700', label: 'Content Writer' },
|
||||
social_media_manager: { bg: 'bg-teal-50', text: 'text-teal-700', label: 'Social Media Manager' },
|
||||
photographer: { bg: 'bg-cyan-50', text: 'text-cyan-700', label: 'Photographer' },
|
||||
videographer: { bg: 'bg-sky-50', text: 'text-sky-700', label: 'Videographer' },
|
||||
strategist: { bg: 'bg-rose-50', text: 'text-rose-700', label: 'Strategist' },
|
||||
default: { bg: 'bg-gray-50', text: 'text-gray-700', label: 'Team Member' },
|
||||
}
|
||||
|
||||
export default function MemberCard({ member, onClick }) {
|
||||
const getInitials = (name) => {
|
||||
if (!name) return '?'
|
||||
return name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
const role = ROLE_BADGES[member.team_role || member.role] || ROLE_BADGES.default
|
||||
|
||||
const avatarColors = [
|
||||
'from-indigo-400 to-purple-500',
|
||||
'from-pink-400 to-rose-500',
|
||||
'from-emerald-400 to-teal-500',
|
||||
'from-amber-400 to-orange-500',
|
||||
'from-cyan-400 to-blue-500',
|
||||
]
|
||||
const colorIndex = (member.name?.charCodeAt(0) || 0) % avatarColors.length
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick?.(member)}
|
||||
className="bg-white rounded-xl border border-border p-5 card-hover cursor-pointer text-center"
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div className={`w-16 h-16 rounded-full bg-gradient-to-br ${avatarColors[colorIndex]} flex items-center justify-center text-white text-xl font-bold mx-auto mb-3`}>
|
||||
{getInitials(member.name)}
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<h4 className="text-base font-semibold text-text-primary">{member.name}</h4>
|
||||
|
||||
{/* Role badge */}
|
||||
<span className={`inline-block text-xs font-medium px-2 py-0.5 rounded-full mt-1 ${role.bg} ${role.text}`}>
|
||||
{role.label}
|
||||
</span>
|
||||
|
||||
{/* Email */}
|
||||
{member.email && (
|
||||
<p className="text-xs text-text-tertiary mt-2">{member.email}</p>
|
||||
)}
|
||||
|
||||
{/* Brands */}
|
||||
{member.brands && member.brands.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 justify-center mt-3 pt-3 border-t border-border-light">
|
||||
{member.brands.map((brand) => (
|
||||
<BrandBadge key={brand} brand={brand} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
122
client/src/components/Modal.jsx
Normal file
122
client/src/components/Modal.jsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X, AlertTriangle } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
|
||||
export default function Modal({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
size = 'md',
|
||||
// Confirmation mode props
|
||||
isConfirm = false,
|
||||
confirmText,
|
||||
cancelText,
|
||||
onConfirm,
|
||||
danger = false,
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
// Default translations
|
||||
const finalConfirmText = confirmText || (danger ? t('common.delete') : t('common.save'))
|
||||
const finalCancelText = cancelText || t('common.cancel')
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
return () => { document.body.style.overflow = '' }
|
||||
}, [isOpen])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
}
|
||||
|
||||
// Confirmation dialog
|
||||
if (isConfirm) {
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center px-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal content */}
|
||||
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-md animate-scale-in">
|
||||
<div className="p-6">
|
||||
{danger && (
|
||||
<div className="w-12 h-12 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
|
||||
<AlertTriangle className="w-6 h-6 text-red-600" />
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-lg font-semibold text-text-primary text-center mb-2">{title}</h3>
|
||||
<div className="text-sm text-text-secondary text-center mb-6">
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2.5 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg transition-colors"
|
||||
>
|
||||
{finalCancelText}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onConfirm?.();
|
||||
onClose();
|
||||
}}
|
||||
className={`flex-1 px-4 py-2.5 text-sm font-medium text-white rounded-lg shadow-sm transition-colors ${
|
||||
danger
|
||||
? 'bg-red-600 hover:bg-red-700'
|
||||
: 'bg-brand-primary hover:bg-brand-primary-light'
|
||||
}`}
|
||||
>
|
||||
{finalConfirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
// Regular modal
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[10vh] px-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal content */}
|
||||
<div className={`relative bg-white rounded-2xl shadow-2xl w-full ${sizeClasses[size]} max-h-[80vh] flex flex-col animate-scale-in`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
|
||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-6 py-4 overflow-y-auto flex-1">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
30
client/src/components/PlatformIcon.jsx
Normal file
30
client/src/components/PlatformIcon.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { PLATFORMS } from '../utils/api'
|
||||
|
||||
export default function PlatformIcon({ platform, size = 16, showLabel = false, className = '' }) {
|
||||
const p = PLATFORMS[platform]
|
||||
if (!p) return <span className="text-[10px] text-text-tertiary capitalize">{platform}</span>
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 ${className}`} title={p.label}>
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill={p.color}
|
||||
className="shrink-0"
|
||||
>
|
||||
<path d={p.icon} />
|
||||
</svg>
|
||||
{showLabel && <span className="text-xs text-text-secondary">{p.label}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function PlatformIcons({ platforms = [], size = 14, gap = 'gap-1', className = '' }) {
|
||||
if (!platforms || platforms.length === 0) return null
|
||||
return (
|
||||
<span className={`inline-flex items-center ${gap} ${className}`}>
|
||||
{platforms.map(p => <PlatformIcon key={p} platform={p} size={size} />)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
121
client/src/components/PostCard.jsx
Normal file
121
client/src/components/PostCard.jsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { format } from 'date-fns'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import BrandBadge from './BrandBadge'
|
||||
import StatusBadge from './StatusBadge'
|
||||
import PlatformIcon, { PlatformIcons } from './PlatformIcon'
|
||||
|
||||
export default function PostCard({ post, onClick, onMove, compact = false }) {
|
||||
const { t } = useLanguage()
|
||||
// Support both single platform and platforms array
|
||||
const platforms = post.platforms?.length > 0
|
||||
? post.platforms
|
||||
: (post.platform ? [post.platform] : [])
|
||||
|
||||
const getInitials = (name) => {
|
||||
if (!name) return '?'
|
||||
return name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
const assigneeName = post.assignedToName || post.assignedName || post.assigned_name || (typeof post.assignedTo === 'object' ? post.assignedTo?.name : null)
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="bg-white rounded-lg p-3 border border-border hover:border-brand-primary/30 hover:shadow-md cursor-pointer transition-all group"
|
||||
>
|
||||
{post.thumbnail_url && (
|
||||
<div className="w-full h-32 -mx-3 -mt-3 mb-2 rounded-t-lg overflow-hidden">
|
||||
<img src={`http://localhost:3001${post.thumbnail_url}`} alt="" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<h5 className="text-sm font-medium text-text-primary line-clamp-2 leading-snug">{post.title}</h5>
|
||||
<div className="shrink-0 mt-0.5">
|
||||
<PlatformIcons platforms={platforms} size={14} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{post.brand && <BrandBadge brand={post.brand} />}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-3 pt-2 border-t border-border-light">
|
||||
{assigneeName ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-5 h-5 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-semibold">
|
||||
{getInitials(assigneeName)}
|
||||
</div>
|
||||
<span className="text-xs text-text-tertiary">{assigneeName}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-text-tertiary">{t('common.unassigned')}</span>
|
||||
)}
|
||||
|
||||
{post.scheduledDate && (
|
||||
<span className="text-[10px] text-text-tertiary">
|
||||
{format(new Date(post.scheduledDate), 'MMM d')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick move buttons */}
|
||||
{onMove && (
|
||||
<div className="hidden group-hover:flex items-center gap-1 mt-2 pt-2 border-t border-border-light">
|
||||
{post.status === 'draft' && (
|
||||
<button onClick={(e) => { e.stopPropagation(); onMove(post._id, 'in_review') }}
|
||||
className="text-[10px] text-amber-600 hover:bg-amber-50 px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
{t('posts.sendToReview')} <ArrowRight className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
{post.status === 'in_review' && (
|
||||
<button onClick={(e) => { e.stopPropagation(); onMove(post._id, 'approved') }}
|
||||
className="text-[10px] text-blue-600 hover:bg-blue-50 px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
{t('posts.approve')} <ArrowRight className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
{post.status === 'approved' && (
|
||||
<button onClick={(e) => { e.stopPropagation(); onMove(post._id, 'scheduled') }}
|
||||
className="text-[10px] text-purple-600 hover:bg-purple-50 px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
{t('posts.schedule')} <ArrowRight className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
{post.status === 'scheduled' && (
|
||||
<button onClick={(e) => { e.stopPropagation(); onMove(post._id, 'published') }}
|
||||
className="text-[10px] text-emerald-600 hover:bg-emerald-50 px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
{t('posts.publish')} <ArrowRight className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Table row view
|
||||
return (
|
||||
<tr onClick={onClick} className="hover:bg-surface-secondary cursor-pointer group">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="shrink-0">
|
||||
<PlatformIcons platforms={platforms} size={16} />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-text-primary">{post.title}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">{post.brand && <BrandBadge brand={post.brand} />}</td>
|
||||
<td className="px-4 py-3"><StatusBadge status={post.status} /></td>
|
||||
<td className="px-4 py-3">
|
||||
<PlatformIcons platforms={platforms} size={16} gap="gap-1.5" />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm text-text-secondary">{assigneeName || '—'}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-text-tertiary">
|
||||
{post.scheduledDate ? format(new Date(post.scheduledDate), 'MMM d, yyyy') : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
68
client/src/components/ProjectCard.jsx
Normal file
68
client/src/components/ProjectCard.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { format } from 'date-fns'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import StatusBadge from './StatusBadge'
|
||||
import BrandBadge from './BrandBadge'
|
||||
|
||||
export default function ProjectCard({ project }) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const completedTasks = project.tasks?.filter(t => t.status === 'done').length || 0
|
||||
const totalTasks = project.tasks?.length || 0
|
||||
const progress = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0
|
||||
|
||||
const ownerName = typeof project.owner === 'object' ? project.owner?.name : project.owner
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => navigate(`/projects/${project._id}`)}
|
||||
className="bg-white rounded-xl border border-border p-5 card-hover cursor-pointer"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<h4 className="text-base font-semibold text-text-primary line-clamp-1">{project.name}</h4>
|
||||
<StatusBadge status={project.status} size="xs" />
|
||||
</div>
|
||||
|
||||
{project.brand && (
|
||||
<div className="mb-3">
|
||||
<BrandBadge brand={project.brand} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{project.description && (
|
||||
<p className="text-sm text-text-secondary line-clamp-2 mb-4">{project.description}</p>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className="text-text-tertiary">Progress</span>
|
||||
<span className="font-medium text-text-secondary">{progress}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-surface-tertiary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-brand-primary rounded-full transition-all duration-500"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-text-tertiary mt-1">{completedTasks}/{totalTasks} tasks</p>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between pt-3 border-t border-border-light">
|
||||
{ownerName && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-5 h-5 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-semibold">
|
||||
{ownerName.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<span className="text-xs text-text-tertiary">{ownerName}</span>
|
||||
</div>
|
||||
)}
|
||||
{project.dueDate && (
|
||||
<span className="text-xs text-text-tertiary">
|
||||
Due {format(new Date(project.dueDate), 'MMM d')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
169
client/src/components/Sidebar.jsx
Normal file
169
client/src/components/Sidebar.jsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { useContext } from 'react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard, FileEdit, Image, Calendar, Wallet,
|
||||
FolderKanban, CheckSquare, Users, ChevronLeft, ChevronRight, Sparkles, Shield, LogOut, User, Settings, Languages
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', icon: LayoutDashboard, labelKey: 'nav.dashboard', end: true, tutorial: 'dashboard' },
|
||||
{ to: '/campaigns', icon: Calendar, labelKey: 'nav.campaigns', tutorial: 'campaigns' },
|
||||
{ to: '/finance', icon: Wallet, labelKey: 'nav.finance', minRole: 'manager' },
|
||||
{ to: '/posts', icon: FileEdit, labelKey: 'nav.posts', tutorial: 'posts' },
|
||||
{ to: '/assets', icon: Image, labelKey: 'nav.assets', tutorial: 'assets' },
|
||||
{ to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
|
||||
{ to: '/tasks', icon: CheckSquare, labelKey: 'nav.tasks', tutorial: 'tasks' },
|
||||
{ to: '/team', icon: Users, labelKey: 'nav.team', tutorial: 'team' },
|
||||
]
|
||||
|
||||
const ROLE_LEVEL = { contributor: 0, manager: 1, superadmin: 2 }
|
||||
|
||||
export default function Sidebar({ collapsed, setCollapsed }) {
|
||||
const { user: currentUser, logout } = useAuth()
|
||||
const { t, lang, setLang } = useLanguage()
|
||||
const userLevel = ROLE_LEVEL[currentUser?.role] ?? 0
|
||||
|
||||
const visibleItems = navItems.filter(item => {
|
||||
if (!item.minRole) return true
|
||||
return userLevel >= (ROLE_LEVEL[item.minRole] ?? 0)
|
||||
})
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`sidebar fixed top-0 h-screen bg-sidebar flex flex-col z-30 transition-all duration-300 ${
|
||||
collapsed ? 'w-[68px]' : 'w-[260px]'
|
||||
}`}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-3 px-4 h-16 border-b border-white/10 shrink-0">
|
||||
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-indigo-400 to-purple-500 flex items-center justify-center shrink-0">
|
||||
<Sparkles className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="animate-fade-in overflow-hidden">
|
||||
<h1 className="text-white font-bold text-sm leading-tight whitespace-nowrap">{t('app.name')}</h1>
|
||||
<p className="text-text-on-dark-muted text-xs whitespace-nowrap">{t('app.subtitle')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 py-4 px-3 space-y-1 overflow-y-auto">
|
||||
{visibleItems.map(({ to, icon: Icon, labelKey, end, tutorial }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={end}
|
||||
data-tutorial={tutorial}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 group ${
|
||||
isActive
|
||||
? 'bg-white/15 text-white shadow-sm'
|
||||
: 'text-text-on-dark-muted hover:bg-white/8 hover:text-white'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon className="w-5 h-5 shrink-0" />
|
||||
{!collapsed && <span className="animate-fade-in whitespace-nowrap">{t(labelKey)}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
{/* Superadmin Only: Users Management */}
|
||||
{currentUser?.role === 'superadmin' && (
|
||||
<NavLink
|
||||
to="/users"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 group ${
|
||||
isActive
|
||||
? 'bg-white/15 text-white shadow-sm'
|
||||
: 'text-text-on-dark-muted hover:bg-white/8 hover:text-white'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Shield className="w-5 h-5 shrink-0" />
|
||||
{!collapsed && <span className="animate-fade-in whitespace-nowrap">{t('nav.users')}</span>}
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
{/* Settings (visible to all) */}
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 group ${
|
||||
isActive
|
||||
? 'bg-white/15 text-white shadow-sm'
|
||||
: 'text-text-on-dark-muted hover:bg-white/8 hover:text-white'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Settings className="w-5 h-5 shrink-0" />
|
||||
{!collapsed && <span className="animate-fade-in whitespace-nowrap">{t('nav.settings')}</span>}
|
||||
</NavLink>
|
||||
</nav>
|
||||
|
||||
{/* Current User & Logout */}
|
||||
<div className="border-t border-white/10 shrink-0">
|
||||
{currentUser && !collapsed && (
|
||||
<div className="p-3 border-b border-white/10">
|
||||
<div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-white/5">
|
||||
<div className="w-8 h-8 rounded-full bg-brand-primary flex items-center justify-center shrink-0">
|
||||
{currentUser.avatar ? (
|
||||
<img src={currentUser.avatar} alt={currentUser.name} className="w-full h-full rounded-full object-cover" />
|
||||
) : (
|
||||
<User className="w-4 h-4 text-white" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 animate-fade-in">
|
||||
<p className="text-white text-sm font-medium truncate">{currentUser.name}</p>
|
||||
<p className="text-text-on-dark-muted text-xs truncate capitalize">{currentUser.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!collapsed && (
|
||||
<>
|
||||
{/* Language Toggle */}
|
||||
<div className="px-3 pb-3">
|
||||
<button
|
||||
onClick={() => setLang(lang === 'en' ? 'ar' : 'en')}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-text-on-dark-muted hover:bg-white/8 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
<Languages className="w-4 h-4" />
|
||||
<span className="animate-fade-in">{lang === 'en' ? 'عربي' : 'English'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
<div className="px-3 pb-3">
|
||||
<button
|
||||
onClick={logout}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-text-on-dark-muted hover:bg-white/8 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span className="animate-fade-in">{t('nav.logout')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Collapse toggle */}
|
||||
<div className="p-3 border-t border-white/10">
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-text-on-dark-muted hover:bg-white/8 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
{collapsed ? <ChevronRight className="w-4 h-4" /> : (
|
||||
<>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
<span className="animate-fade-in">{t('nav.collapse')}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
42
client/src/components/StatCard.jsx
Normal file
42
client/src/components/StatCard.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
export default function StatCard({ icon: Icon, label, value, subtitle, color = 'brand-primary', trend }) {
|
||||
const colorMap = {
|
||||
'brand-primary': 'from-indigo-500 to-indigo-600',
|
||||
'brand-secondary': 'from-pink-500 to-pink-600',
|
||||
'brand-tertiary': 'from-amber-500 to-amber-600',
|
||||
'brand-quaternary': 'from-emerald-500 to-emerald-600',
|
||||
}
|
||||
|
||||
const iconBgMap = {
|
||||
'brand-primary': 'bg-indigo-50 text-indigo-600',
|
||||
'brand-secondary': 'bg-pink-50 text-pink-600',
|
||||
'brand-tertiary': 'bg-amber-50 text-amber-600',
|
||||
'brand-quaternary': 'bg-emerald-50 text-emerald-600',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-border p-5 card-hover">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-tertiary">{label}</p>
|
||||
<p className="text-3xl font-bold text-text-primary mt-1">{value}</p>
|
||||
{subtitle && (
|
||||
<p className="text-xs text-text-tertiary mt-1">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={`w-11 h-11 rounded-xl flex items-center justify-center ${iconBgMap[color] || iconBgMap['brand-primary']}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
{trend && (
|
||||
<div className="mt-3 pt-3 border-t border-border-light">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={`text-xs font-medium ${trend > 0 ? 'text-emerald-600' : 'text-red-500'}`}>
|
||||
{trend > 0 ? '+' : ''}{trend}%
|
||||
</span>
|
||||
<span className="text-xs text-text-tertiary">vs last month</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
34
client/src/components/StatusBadge.jsx
Normal file
34
client/src/components/StatusBadge.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { getStatusConfig } from '../utils/api'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
|
||||
const STATUS_LABEL_MAP = {
|
||||
'draft': 'posts.status.draft',
|
||||
'in_review': 'posts.status.in_review',
|
||||
'approved': 'posts.status.approved',
|
||||
'scheduled': 'posts.status.scheduled',
|
||||
'published': 'posts.status.published',
|
||||
'todo': 'tasks.todo',
|
||||
'in_progress': 'tasks.in_progress',
|
||||
'done': 'tasks.done',
|
||||
}
|
||||
|
||||
export default function StatusBadge({ status, size = 'sm' }) {
|
||||
const { t } = useLanguage()
|
||||
const config = getStatusConfig(status)
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'text-[10px] px-1.5 py-0.5',
|
||||
sm: 'text-xs px-2 py-1',
|
||||
md: 'text-sm px-2.5 py-1',
|
||||
}
|
||||
|
||||
const labelKey = STATUS_LABEL_MAP[status]
|
||||
const label = labelKey ? t(labelKey) : config.label
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full font-medium ${config.bg} ${config.text} ${sizeClasses[size]}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${config.dot}`}></span>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
93
client/src/components/TaskCard.jsx
Normal file
93
client/src/components/TaskCard.jsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { format } from 'date-fns'
|
||||
import { ArrowRight, Clock, User, UserCheck } from 'lucide-react'
|
||||
import { PRIORITY_CONFIG } from '../utils/api'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
|
||||
export default function TaskCard({ task, onMove, showProject = true }) {
|
||||
const { t } = useLanguage()
|
||||
const { user: authUser } = useAuth()
|
||||
const priority = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
|
||||
const projectName = typeof task.project === 'object' ? task.project?.name : task.projectName
|
||||
|
||||
const nextStatus = {
|
||||
todo: 'in_progress',
|
||||
in_progress: 'done',
|
||||
}
|
||||
|
||||
const nextLabel = {
|
||||
todo: t('tasks.start'),
|
||||
in_progress: t('tasks.complete'),
|
||||
}
|
||||
|
||||
const dueDate = task.due_date || task.dueDate
|
||||
const isOverdue = dueDate && new Date(dueDate) < new Date() && task.status !== 'done'
|
||||
const creatorName = task.creator_user_name || task.creatorUserName
|
||||
|
||||
// Determine if this task was assigned by someone else
|
||||
const createdByUserId = task.created_by_user_id || task.createdByUserId
|
||||
const isExternallyAssigned = authUser && createdByUserId && createdByUserId !== authUser.id
|
||||
const assignedName = task.assigned_name || task.assignedName
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-lg border border-border p-3 card-hover group ${isExternallyAssigned ? 'border-l-[3px] border-l-blue-400' : ''}`}>
|
||||
<div className="flex items-start gap-2.5">
|
||||
{/* Priority dot */}
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${priority.color} mt-1.5 shrink-0`} title={priority.label} />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h5 className={`text-sm font-medium leading-snug ${task.status === 'done' ? 'text-text-tertiary line-through' : 'text-text-primary'}`}>
|
||||
{task.title}
|
||||
</h5>
|
||||
|
||||
{/* Assigned by label for externally-assigned tasks */}
|
||||
{isExternallyAssigned && creatorName && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<UserCheck className="w-3 h-3 text-blue-400" />
|
||||
<span className="text-[10px] text-blue-500 font-medium">{t('tasks.from')} {creatorName}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Assigned to label for tasks you delegated */}
|
||||
{!isExternallyAssigned && assignedName && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<User className="w-3 h-3 text-emerald-400" />
|
||||
<span className="text-[10px] text-emerald-500 font-medium">{t('tasks.assignedTo')} {assignedName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
|
||||
{showProject && projectName && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-tertiary">
|
||||
{projectName}
|
||||
</span>
|
||||
)}
|
||||
{dueDate && (
|
||||
<span className={`text-[10px] flex items-center gap-1 ${isOverdue ? 'text-red-500 font-medium' : 'text-text-tertiary'}`}>
|
||||
<Clock className="w-3 h-3" />
|
||||
{format(new Date(dueDate), 'MMM d')}
|
||||
</span>
|
||||
)}
|
||||
{!isExternallyAssigned && creatorName && (
|
||||
<span className="text-[10px] flex items-center gap-1 text-text-tertiary">
|
||||
<User className="w-3 h-3" />
|
||||
{creatorName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick action */}
|
||||
{onMove && nextStatus[task.status] && (
|
||||
<div className="mt-2 pt-2 border-t border-border-light opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => onMove(task._id || task.id, nextStatus[task.status])}
|
||||
className="text-[11px] text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1"
|
||||
>
|
||||
{nextLabel[task.status]} <ArrowRight className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
248
client/src/components/Tutorial.jsx
Normal file
248
client/src/components/Tutorial.jsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, ArrowLeft, ArrowRight } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
|
||||
const getTutorialSteps = (t) => [
|
||||
{
|
||||
target: '[data-tutorial="dashboard"]',
|
||||
title: t('tutorial.dashboard.title'),
|
||||
description: t('tutorial.dashboard.desc'),
|
||||
position: 'right',
|
||||
},
|
||||
{
|
||||
target: '[data-tutorial="campaigns"]',
|
||||
title: t('tutorial.campaigns.title'),
|
||||
description: t('tutorial.campaigns.desc'),
|
||||
position: 'right',
|
||||
},
|
||||
{
|
||||
target: '[data-tutorial="posts"]',
|
||||
title: t('tutorial.posts.title'),
|
||||
description: t('tutorial.posts.desc'),
|
||||
position: 'right',
|
||||
},
|
||||
{
|
||||
target: '[data-tutorial="tasks"]',
|
||||
title: t('tutorial.tasks.title'),
|
||||
description: t('tutorial.tasks.desc'),
|
||||
position: 'right',
|
||||
},
|
||||
{
|
||||
target: '[data-tutorial="team"]',
|
||||
title: t('tutorial.team.title'),
|
||||
description: t('tutorial.team.desc'),
|
||||
position: 'right',
|
||||
},
|
||||
{
|
||||
target: '[data-tutorial="assets"]',
|
||||
title: t('tutorial.assets.title'),
|
||||
description: t('tutorial.assets.desc'),
|
||||
position: 'right',
|
||||
},
|
||||
{
|
||||
target: '[data-tutorial="new-post"]',
|
||||
title: t('tutorial.newPost.title'),
|
||||
description: t('tutorial.newPost.desc'),
|
||||
position: 'bottom',
|
||||
},
|
||||
{
|
||||
target: '[data-tutorial="filters"]',
|
||||
title: t('tutorial.filters.title'),
|
||||
description: t('tutorial.filters.desc'),
|
||||
position: 'bottom',
|
||||
},
|
||||
]
|
||||
|
||||
export default function Tutorial({ onComplete }) {
|
||||
const { t } = useLanguage()
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [targetRect, setTargetRect] = useState(null)
|
||||
const [tooltipPosition, setTooltipPosition] = useState({ top: 0, left: 0 })
|
||||
|
||||
const TUTORIAL_STEPS = getTutorialSteps(t)
|
||||
const step = TUTORIAL_STEPS[currentStep]
|
||||
|
||||
useEffect(() => {
|
||||
updatePosition()
|
||||
window.addEventListener('resize', updatePosition)
|
||||
return () => window.removeEventListener('resize', updatePosition)
|
||||
}, [currentStep])
|
||||
|
||||
const updatePosition = () => {
|
||||
const target = document.querySelector(step.target)
|
||||
if (!target) {
|
||||
console.warn(`Tutorial target not found: ${step.target}`)
|
||||
return
|
||||
}
|
||||
|
||||
const rect = target.getBoundingClientRect()
|
||||
setTargetRect(rect)
|
||||
|
||||
// Calculate tooltip position
|
||||
const tooltipWidth = 360
|
||||
const tooltipHeight = 200
|
||||
const padding = 20
|
||||
|
||||
let top = rect.top
|
||||
let left = rect.right + padding
|
||||
|
||||
// Adjust based on position hint
|
||||
if (step.position === 'bottom') {
|
||||
top = rect.bottom + padding
|
||||
left = rect.left
|
||||
} else if (step.position === 'left') {
|
||||
top = rect.top
|
||||
left = rect.left - tooltipWidth - padding
|
||||
} else if (step.position === 'top') {
|
||||
top = rect.top - tooltipHeight - padding
|
||||
left = rect.left
|
||||
}
|
||||
|
||||
// Keep tooltip on screen
|
||||
if (left + tooltipWidth > window.innerWidth) {
|
||||
left = rect.left - tooltipWidth - padding
|
||||
}
|
||||
if (left < 0) {
|
||||
left = padding
|
||||
}
|
||||
if (top + tooltipHeight > window.innerHeight) {
|
||||
top = window.innerHeight - tooltipHeight - padding
|
||||
}
|
||||
if (top < 0) {
|
||||
top = padding
|
||||
}
|
||||
|
||||
setTooltipPosition({ top, left })
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep < TUTORIAL_STEPS.length - 1) {
|
||||
setCurrentStep(currentStep + 1)
|
||||
} else {
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
|
||||
const handlePrev = () => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep(currentStep - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSkip = () => {
|
||||
onComplete()
|
||||
}
|
||||
|
||||
if (!targetRect) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] pointer-events-none">
|
||||
{/* Dark overlay with spotlight cutout */}
|
||||
<svg className="absolute inset-0 w-full h-full pointer-events-auto" style={{ pointerEvents: 'auto' }}>
|
||||
<defs>
|
||||
<mask id="tutorial-mask">
|
||||
<rect x="0" y="0" width="100%" height="100%" fill="white" />
|
||||
<rect
|
||||
x={targetRect.left - 4}
|
||||
y={targetRect.top - 4}
|
||||
width={targetRect.width + 8}
|
||||
height={targetRect.height + 8}
|
||||
rx="8"
|
||||
fill="black"
|
||||
/>
|
||||
</mask>
|
||||
</defs>
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="100%"
|
||||
height="100%"
|
||||
fill="rgba(0, 0, 0, 0.7)"
|
||||
mask="url(#tutorial-mask)"
|
||||
onClick={handleSkip}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Highlight ring around target */}
|
||||
<div
|
||||
className="absolute border-4 border-brand-primary rounded-lg shadow-lg transition-all duration-300 pointer-events-none"
|
||||
style={{
|
||||
top: targetRect.top - 4,
|
||||
left: targetRect.left - 4,
|
||||
width: targetRect.width + 8,
|
||||
height: targetRect.height + 8,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Tooltip card */}
|
||||
<div
|
||||
className="absolute bg-white rounded-xl shadow-2xl border border-border p-6 animate-fade-in pointer-events-auto"
|
||||
style={{
|
||||
top: tooltipPosition.top,
|
||||
left: tooltipPosition.left,
|
||||
width: 360,
|
||||
maxWidth: 'calc(100vw - 40px)',
|
||||
}}
|
||||
>
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="absolute top-4 right-4 text-text-tertiary hover:text-text-primary transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-brand-primary mb-2">
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary text-xs font-bold">
|
||||
{currentStep + 1}
|
||||
</span>
|
||||
<span>{t('tutorial.step')} {currentStep + 1} {t('tutorial.of')} {TUTORIAL_STEPS.length}</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-text-primary mb-2">{step.title}</h3>
|
||||
<p className="text-sm text-text-secondary leading-relaxed">{step.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="w-full h-1.5 bg-surface-tertiary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-brand-primary to-purple-500 transition-all duration-300"
|
||||
style={{ width: `${((currentStep + 1) / TUTORIAL_STEPS.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between gap-3 pt-2">
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="text-sm font-medium text-text-tertiary hover:text-text-primary transition-colors"
|
||||
>
|
||||
{t('tutorial.skip')}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{currentStep > 0 && (
|
||||
<button
|
||||
onClick={handlePrev}
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t('tutorial.prev')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleNext}
|
||||
className="flex items-center gap-1.5 px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm transition-colors"
|
||||
>
|
||||
{currentStep < TUTORIAL_STEPS.length - 1 ? t('tutorial.next') : t('tutorial.finish')}
|
||||
{currentStep < TUTORIAL_STEPS.length - 1 && <ArrowRight className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
104
client/src/contexts/AuthContext.jsx
Normal file
104
client/src/contexts/AuthContext.jsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { createContext, useState, useEffect, useContext } from 'react'
|
||||
import { api } from '../utils/api'
|
||||
|
||||
const AuthContext = createContext(null)
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null)
|
||||
const [permissions, setPermissions] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth()
|
||||
}, [])
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const userData = await api.get('/auth/me')
|
||||
setUser(userData)
|
||||
const perms = await api.get('/auth/permissions')
|
||||
setPermissions(perms)
|
||||
} catch (err) {
|
||||
console.log('Not authenticated')
|
||||
setUser(null)
|
||||
setPermissions(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const login = async (email, password) => {
|
||||
const response = await api.post('/auth/login', { email, password })
|
||||
setUser(response.user)
|
||||
// Load permissions after login
|
||||
try {
|
||||
const perms = await api.get('/auth/permissions')
|
||||
setPermissions(perms)
|
||||
} catch (err) {
|
||||
console.error('Failed to load permissions:', err)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await api.post('/auth/logout')
|
||||
} catch (err) {
|
||||
console.error('Logout error:', err)
|
||||
} finally {
|
||||
setUser(null)
|
||||
setPermissions(null)
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
|
||||
// Check if current user owns a resource
|
||||
const isOwner = (resource) => {
|
||||
if (!user || !resource) return false
|
||||
return resource.created_by_user_id === user.id
|
||||
}
|
||||
|
||||
// Check if current user is assigned to a resource
|
||||
const isAssignedTo = (resource) => {
|
||||
if (!user || !resource) return false
|
||||
const teamMemberId = user.team_member_id || user.teamMemberId
|
||||
if (!teamMemberId) return false
|
||||
const assignedTo = resource.assigned_to || resource.assignedTo
|
||||
return assignedTo === teamMemberId
|
||||
}
|
||||
|
||||
// Check if user can edit a specific resource (owns it, assigned to it, or has role)
|
||||
const canEditResource = (type, resource) => {
|
||||
if (!permissions) return false
|
||||
if (type === 'post') return permissions.canEditAnyPost || isOwner(resource) || isAssignedTo(resource)
|
||||
if (type === 'task') return permissions.canEditAnyTask || isOwner(resource) || isAssignedTo(resource)
|
||||
return false
|
||||
}
|
||||
|
||||
const canDeleteResource = (type, resource) => {
|
||||
if (!permissions) return false
|
||||
if (type === 'post') return permissions.canDeleteAnyPost || isOwner(resource) || isAssignedTo(resource)
|
||||
if (type === 'task') return permissions.canDeleteAnyTask || isOwner(resource) || isAssignedTo(resource)
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{
|
||||
user, loading, permissions,
|
||||
login, logout, checkAuth,
|
||||
isOwner, canEditResource, canDeleteResource,
|
||||
}}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext)
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export default AuthContext
|
||||
65
client/src/i18n/LanguageContext.jsx
Normal file
65
client/src/i18n/LanguageContext.jsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react'
|
||||
import en from './en.json'
|
||||
import ar from './ar.json'
|
||||
|
||||
const translations = { en, ar }
|
||||
|
||||
const LanguageContext = createContext()
|
||||
|
||||
export function LanguageProvider({ children }) {
|
||||
const [lang, setLangState] = useState(() => {
|
||||
// Load from localStorage or default to 'en'
|
||||
return localStorage.getItem('samaya-lang') || 'en'
|
||||
})
|
||||
|
||||
const setLang = (newLang) => {
|
||||
if (newLang !== 'en' && newLang !== 'ar') return
|
||||
setLangState(newLang)
|
||||
localStorage.setItem('samaya-lang', newLang)
|
||||
}
|
||||
|
||||
const dir = lang === 'ar' ? 'rtl' : 'ltr'
|
||||
|
||||
// Update HTML dir attribute whenever language changes
|
||||
useEffect(() => {
|
||||
document.documentElement.dir = dir
|
||||
document.documentElement.lang = lang
|
||||
}, [dir, lang])
|
||||
|
||||
// Translation function
|
||||
const t = (key) => {
|
||||
const keys = key.split('.')
|
||||
let value = translations[lang]
|
||||
|
||||
for (const k of keys) {
|
||||
value = value?.[k]
|
||||
if (value === undefined) break
|
||||
}
|
||||
|
||||
// Fallback to English if translation not found
|
||||
if (value === undefined) {
|
||||
value = translations.en
|
||||
for (const k of keys) {
|
||||
value = value?.[k]
|
||||
if (value === undefined) break
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to key itself if still not found
|
||||
return value !== undefined ? value : key
|
||||
}
|
||||
|
||||
return (
|
||||
<LanguageContext.Provider value={{ lang, setLang, t, dir }}>
|
||||
{children}
|
||||
</LanguageContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useLanguage() {
|
||||
const context = useContext(LanguageContext)
|
||||
if (!context) {
|
||||
throw new Error('useLanguage must be used within a LanguageProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
239
client/src/i18n/ar.json
Normal file
239
client/src/i18n/ar.json
Normal file
@@ -0,0 +1,239 @@
|
||||
{
|
||||
"app.name": "سمايا",
|
||||
"app.subtitle": "مركز التسويق",
|
||||
"nav.dashboard": "لوحة التحكم",
|
||||
"nav.campaigns": "الحملات",
|
||||
"nav.finance": "المالية والعائد",
|
||||
"nav.posts": "إنتاج المحتوى",
|
||||
"nav.assets": "الأصول",
|
||||
"nav.projects": "المشاريع",
|
||||
"nav.tasks": "المهام",
|
||||
"nav.team": "الفريق",
|
||||
"nav.settings": "الإعدادات",
|
||||
"nav.users": "المستخدمين",
|
||||
"nav.logout": "تسجيل الخروج",
|
||||
"nav.collapse": "طي",
|
||||
|
||||
"common.save": "حفظ",
|
||||
"common.cancel": "إلغاء",
|
||||
"common.delete": "حذف",
|
||||
"common.edit": "تعديل",
|
||||
"common.create": "إنشاء",
|
||||
"common.search": "بحث...",
|
||||
"common.filter": "تصفية",
|
||||
"common.all": "الكل",
|
||||
"common.noResults": "لا توجد نتائج",
|
||||
"common.loading": "جاري التحميل...",
|
||||
"common.unassigned": "غير مُسند",
|
||||
"common.required": "مطلوب",
|
||||
|
||||
"auth.login": "تسجيل الدخول",
|
||||
"auth.email": "البريد الإلكتروني",
|
||||
"auth.password": "كلمة المرور",
|
||||
"auth.loginBtn": "دخول",
|
||||
"auth.signingIn": "جاري تسجيل الدخول...",
|
||||
|
||||
"dashboard.title": "لوحة التحكم",
|
||||
"dashboard.welcomeBack": "مرحباً بعودتك",
|
||||
"dashboard.happeningToday": "إليك ما يحدث مع تسويقك اليوم.",
|
||||
"dashboard.totalPosts": "إجمالي المنشورات",
|
||||
"dashboard.published": "منشور",
|
||||
"dashboard.activeCampaigns": "الحملات النشطة",
|
||||
"dashboard.total": "إجمالي",
|
||||
"dashboard.budgetSpent": "الميزانية المنفقة",
|
||||
"dashboard.of": "من",
|
||||
"dashboard.noBudget": "لا توجد ميزانية بعد",
|
||||
"dashboard.overdueTasks": "مهام متأخرة",
|
||||
"dashboard.needsAttention": "يحتاج اهتماماً",
|
||||
"dashboard.allOnTrack": "كل شيء على المسار الصحيح",
|
||||
"dashboard.budgetOverview": "نظرة عامة على الميزانية",
|
||||
"dashboard.details": "التفاصيل",
|
||||
"dashboard.noBudgetRecorded": "لم يتم تسجيل ميزانية بعد.",
|
||||
"dashboard.addBudget": "إضافة ميزانية",
|
||||
"dashboard.spent": "مُنفق",
|
||||
"dashboard.received": "مُستلم",
|
||||
"dashboard.remaining": "المتبقي",
|
||||
"dashboard.revenue": "الإيرادات",
|
||||
"dashboard.roi": "العائد على الاستثمار",
|
||||
"dashboard.recentPosts": "المنشورات الأخيرة",
|
||||
"dashboard.viewAll": "عرض الكل",
|
||||
"dashboard.sar": "ريال",
|
||||
"dashboard.noPostsYet": "لا توجد منشورات بعد. أنشئ منشورك الأول!",
|
||||
"dashboard.upcomingDeadlines": "المواعيد النهائية القادمة",
|
||||
"dashboard.noUpcomingDeadlines": "لا توجد مواعيد نهائية هذا الأسبوع. 🎉",
|
||||
"dashboard.loadingHub": "جاري تحميل مركز سمايا للتسويق...",
|
||||
|
||||
"posts.title": "إنتاج المحتوى",
|
||||
"posts.newPost": "منشور جديد",
|
||||
"posts.editPost": "تعديل المنشور",
|
||||
"posts.createPost": "إنشاء منشور",
|
||||
"posts.saveChanges": "حفظ التغييرات",
|
||||
"posts.postTitle": "العنوان",
|
||||
"posts.description": "الوصف",
|
||||
"posts.brand": "العلامة التجارية",
|
||||
"posts.platforms": "المنصات",
|
||||
"posts.status": "الحالة",
|
||||
"posts.assignTo": "إسناد إلى",
|
||||
"posts.scheduledDate": "تاريخ النشر المجدول",
|
||||
"posts.notes": "ملاحظات",
|
||||
"posts.campaign": "الحملة",
|
||||
"posts.noCampaign": "بدون حملة",
|
||||
"posts.publicationLinks": "روابط النشر",
|
||||
"posts.attachments": "المرفقات",
|
||||
"posts.uploadFiles": "انقر أو اسحب الملفات للرفع",
|
||||
"posts.dropFiles": "أسقط الملفات هنا",
|
||||
"posts.maxSize": "الحد الأقصى 50 ميجابايت للملف",
|
||||
"posts.allBrands": "جميع العلامات",
|
||||
"posts.allPlatforms": "جميع المنصات",
|
||||
"posts.allPeople": "جميع الأشخاص",
|
||||
"posts.searchPosts": "بحث في المنشورات...",
|
||||
"posts.deletePost": "حذف المنشور؟",
|
||||
"posts.deleteConfirm": "هل أنت متأكد من حذف هذا المنشور؟ لا يمكن التراجع.",
|
||||
"posts.publishMissing": "لا يمكن النشر: روابط النشر مفقودة لـ:",
|
||||
"posts.publishRequired": "جميع روابط النشر مطلوبة للنشر",
|
||||
"posts.noPostsFound": "لم يتم العثور على منشورات",
|
||||
"posts.selectBrand": "اختر العلامة التجارية",
|
||||
"posts.additionalNotes": "ملاحظات إضافية",
|
||||
"posts.uploading": "جاري الرفع...",
|
||||
"posts.deleteAttachment": "حذف المرفق",
|
||||
"posts.whatNeedsDone": "ما الذي يجب القيام به؟",
|
||||
"posts.optionalDetails": "تفاصيل اختيارية...",
|
||||
"posts.postTitlePlaceholder": "عنوان المنشور",
|
||||
"posts.postDescPlaceholder": "وصف المنشور...",
|
||||
"posts.dropHere": "أسقط هنا",
|
||||
"posts.noPosts": "لا توجد منشورات",
|
||||
"posts.sendToReview": "إرسال للمراجعة",
|
||||
"posts.approve": "اعتماد",
|
||||
"posts.schedule": "جدولة",
|
||||
"posts.publish": "نشر",
|
||||
|
||||
"posts.status.draft": "مسودة",
|
||||
"posts.status.in_review": "قيد المراجعة",
|
||||
"posts.status.approved": "مُعتمد",
|
||||
"posts.status.scheduled": "مجدول",
|
||||
"posts.status.published": "منشور",
|
||||
|
||||
"tasks.title": "المهام",
|
||||
"tasks.newTask": "مهمة جديدة",
|
||||
"tasks.editTask": "تعديل المهمة",
|
||||
"tasks.createTask": "إنشاء مهمة",
|
||||
"tasks.saveChanges": "حفظ التغييرات",
|
||||
"tasks.taskTitle": "العنوان",
|
||||
"tasks.description": "الوصف",
|
||||
"tasks.priority": "الأولوية",
|
||||
"tasks.dueDate": "تاريخ الاستحقاق",
|
||||
"tasks.assignTo": "إسناد إلى",
|
||||
"tasks.allTasks": "جميع المهام",
|
||||
"tasks.assignedToMe": "المُسندة إليّ",
|
||||
"tasks.createdByMe": "أنشأتها",
|
||||
"tasks.byTeamMember": "حسب عضو الفريق",
|
||||
"tasks.noTasks": "لا توجد مهام بعد",
|
||||
"tasks.noMatch": "لا توجد مهام تطابق هذا الفلتر",
|
||||
"tasks.createFirst": "أنشئ مهمة للبدء",
|
||||
"tasks.tryFilter": "جرب فلتر مختلف",
|
||||
"tasks.deleteTask": "حذف المهمة؟",
|
||||
"tasks.deleteConfirm": "هل أنت متأكد من حذف هذه المهمة؟ لا يمكن التراجع.",
|
||||
"tasks.todo": "للتنفيذ",
|
||||
"tasks.in_progress": "قيد التنفيذ",
|
||||
"tasks.done": "مكتمل",
|
||||
"tasks.start": "ابدأ",
|
||||
"tasks.complete": "أكمل",
|
||||
"tasks.from": "من:",
|
||||
"tasks.assignedTo": "مُسند إلى:",
|
||||
"tasks.task": "مهمة",
|
||||
"tasks.tasks": "مهام",
|
||||
"tasks.of": "من",
|
||||
|
||||
"tasks.priority.low": "منخفض",
|
||||
"tasks.priority.medium": "متوسط",
|
||||
"tasks.priority.high": "عالي",
|
||||
"tasks.priority.urgent": "عاجل",
|
||||
|
||||
"team.title": "الفريق",
|
||||
"team.members": "أعضاء الفريق",
|
||||
"team.addMember": "إضافة عضو",
|
||||
"team.newMember": "عضو جديد",
|
||||
"team.editMember": "تعديل العضو",
|
||||
"team.myProfile": "ملفي الشخصي",
|
||||
"team.editProfile": "تعديل ملفي",
|
||||
"team.name": "الاسم",
|
||||
"team.email": "البريد الإلكتروني",
|
||||
"team.password": "كلمة المرور",
|
||||
"team.teamRole": "الدور في الفريق",
|
||||
"team.phone": "الهاتف",
|
||||
"team.brands": "العلامات التجارية",
|
||||
"team.brandsHelp": "أسماء العلامات مفصولة بفاصلة",
|
||||
"team.removeMember": "إزالة عضو الفريق؟",
|
||||
"team.removeConfirm": "هل أنت متأكد من إزالة {name}؟ لا يمكن التراجع.",
|
||||
"team.noMembers": "لا يوجد أعضاء",
|
||||
"team.backToTeam": "العودة للفريق",
|
||||
"team.totalTasks": "إجمالي المهام",
|
||||
"team.saveProfile": "حفظ الملف",
|
||||
"team.saveChanges": "حفظ التغييرات",
|
||||
"team.member": "عضو فريق",
|
||||
"team.membersPlural": "أعضاء فريق",
|
||||
"team.fullName": "الاسم الكامل",
|
||||
"team.defaultPassword": "افتراضياً: changeme123",
|
||||
"team.optional": "(اختياري)",
|
||||
"team.fixedRole": "دور ثابت للمديرين",
|
||||
"team.remove": "إزالة",
|
||||
"team.noTasks": "لا توجد مهام",
|
||||
"team.toDo": "للتنفيذ",
|
||||
"team.inProgress": "قيد التنفيذ",
|
||||
|
||||
"campaigns.title": "الحملات",
|
||||
"campaigns.newCampaign": "حملة جديدة",
|
||||
"campaigns.noCampaigns": "لا توجد حملات",
|
||||
|
||||
"assets.title": "الأصول",
|
||||
"assets.upload": "رفع",
|
||||
"assets.noAssets": "لا توجد أصول",
|
||||
|
||||
"settings.title": "الإعدادات",
|
||||
"settings.language": "اللغة",
|
||||
"settings.english": "English",
|
||||
"settings.arabic": "عربي",
|
||||
"settings.restartTutorial": "إعادة تشغيل الدليل التعليمي",
|
||||
"settings.tutorialDesc": "هل تحتاج إلى تذكير؟ أعد تشغيل الدليل التفاعلي للتعرف على جميع ميزات مركز سمايا للتسويق.",
|
||||
"settings.general": "عام",
|
||||
"settings.onboardingTutorial": "الدليل التعليمي",
|
||||
"settings.tutorialRestarted": "تم إعادة تشغيل الدليل!",
|
||||
"settings.restarting": "جاري إعادة التشغيل...",
|
||||
"settings.reloadingPage": "جاري إعادة تحميل الصفحة لبدء الدليل...",
|
||||
"settings.moreComingSoon": "المزيد من الإعدادات قريباً",
|
||||
"settings.additionalSettings": "سيتم إضافة إعدادات إضافية للإشعارات وتفضيلات العرض والمزيد هنا.",
|
||||
"settings.preferences": "إدارة تفضيلاتك وإعدادات التطبيق",
|
||||
|
||||
"tutorial.skip": "تخطي",
|
||||
"tutorial.next": "التالي",
|
||||
"tutorial.prev": "السابق",
|
||||
"tutorial.finish": "إنهاء",
|
||||
"tutorial.of": "من",
|
||||
"tutorial.step": "الخطوة",
|
||||
"tutorial.dashboard.title": "لوحة التحكم",
|
||||
"tutorial.dashboard.desc": "مركز القيادة الخاص بك. شاهد أداء الحملات وتقدم المهام ونشاط الفريق في لمحة.",
|
||||
"tutorial.campaigns.title": "الحملات",
|
||||
"tutorial.campaigns.desc": "خطط وأدر الحملات التسويقية عبر جميع العلامات والمنصات.",
|
||||
"tutorial.posts.title": "إنتاج المحتوى",
|
||||
"tutorial.posts.desc": "أنشئ وراجع وانشر المحتوى. اسحب المنشورات عبر خط سير العمل.",
|
||||
"tutorial.tasks.title": "المهام",
|
||||
"tutorial.tasks.desc": "أسند وتتبع المهام. صفّ حسب من أسندها أو من أُسندت إليه.",
|
||||
"tutorial.team.title": "الفريق",
|
||||
"tutorial.team.desc": "دليل فريقك. أكمل ملفك الشخصي وشاهد من تعمل معه.",
|
||||
"tutorial.assets.title": "الأصول",
|
||||
"tutorial.assets.desc": "ارفع وأدر الأصول الإبداعية — الصور والفيديوهات والمستندات.",
|
||||
"tutorial.newPost.title": "إنشاء محتوى",
|
||||
"tutorial.newPost.desc": "ابدأ إنشاء المحتوى من هنا. اختر علامتك التجارية والمنصات وأسنده لعضو فريق.",
|
||||
"tutorial.filters.title": "التصفية والتركيز",
|
||||
"tutorial.filters.desc": "استخدم الفلاتر للتركيز على علامات أو منصات أو أعضاء فريق محددين.",
|
||||
|
||||
"login.title": "سمايا للتسويق",
|
||||
"login.subtitle": "سجل دخولك للمتابعة",
|
||||
"login.forgotPassword": "نسيت كلمة المرور؟",
|
||||
"login.defaultCreds": "بيانات الدخول الافتراضية:",
|
||||
|
||||
"profile.completeYourProfile": "أكمل ملفك الشخصي",
|
||||
"profile.completeDesc": "يرجى إكمال ملفك الشخصي للوصول إلى جميع الميزات ومساعدة فريقك في العثور عليك.",
|
||||
"profile.completeProfileBtn": "إكمال الملف",
|
||||
"profile.later": "لاحقاً"
|
||||
}
|
||||
239
client/src/i18n/en.json
Normal file
239
client/src/i18n/en.json
Normal file
@@ -0,0 +1,239 @@
|
||||
{
|
||||
"app.name": "Samaya",
|
||||
"app.subtitle": "Marketing Hub",
|
||||
"nav.dashboard": "Dashboard",
|
||||
"nav.campaigns": "Campaigns",
|
||||
"nav.finance": "Finance & ROI",
|
||||
"nav.posts": "Post Production",
|
||||
"nav.assets": "Assets",
|
||||
"nav.projects": "Projects",
|
||||
"nav.tasks": "Tasks",
|
||||
"nav.team": "Team",
|
||||
"nav.settings": "Settings",
|
||||
"nav.users": "Users",
|
||||
"nav.logout": "Logout",
|
||||
"nav.collapse": "Collapse",
|
||||
|
||||
"common.save": "Save",
|
||||
"common.cancel": "Cancel",
|
||||
"common.delete": "Delete",
|
||||
"common.edit": "Edit",
|
||||
"common.create": "Create",
|
||||
"common.search": "Search...",
|
||||
"common.filter": "Filter",
|
||||
"common.all": "All",
|
||||
"common.noResults": "No results",
|
||||
"common.loading": "Loading...",
|
||||
"common.unassigned": "Unassigned",
|
||||
"common.required": "Required",
|
||||
|
||||
"auth.login": "Sign In",
|
||||
"auth.email": "Email",
|
||||
"auth.password": "Password",
|
||||
"auth.loginBtn": "Sign In",
|
||||
"auth.signingIn": "Signing in...",
|
||||
|
||||
"dashboard.title": "Dashboard",
|
||||
"dashboard.welcomeBack": "Welcome back",
|
||||
"dashboard.happeningToday": "Here's what's happening with your marketing today.",
|
||||
"dashboard.totalPosts": "Total Posts",
|
||||
"dashboard.published": "published",
|
||||
"dashboard.activeCampaigns": "Active Campaigns",
|
||||
"dashboard.total": "total",
|
||||
"dashboard.budgetSpent": "Budget Spent",
|
||||
"dashboard.of": "of",
|
||||
"dashboard.noBudget": "No budget yet",
|
||||
"dashboard.overdueTasks": "Overdue Tasks",
|
||||
"dashboard.needsAttention": "Needs attention",
|
||||
"dashboard.allOnTrack": "All on track",
|
||||
"dashboard.budgetOverview": "Budget Overview",
|
||||
"dashboard.details": "Details",
|
||||
"dashboard.noBudgetRecorded": "No budget recorded yet.",
|
||||
"dashboard.addBudget": "Add budget",
|
||||
"dashboard.spent": "spent",
|
||||
"dashboard.received": "received",
|
||||
"dashboard.remaining": "Remaining",
|
||||
"dashboard.revenue": "Revenue",
|
||||
"dashboard.roi": "ROI",
|
||||
"dashboard.recentPosts": "Recent Posts",
|
||||
"dashboard.viewAll": "View all",
|
||||
"dashboard.sar": "SAR",
|
||||
"dashboard.noPostsYet": "No posts yet. Create your first post!",
|
||||
"dashboard.upcomingDeadlines": "Upcoming Deadlines",
|
||||
"dashboard.noUpcomingDeadlines": "No upcoming deadlines this week. 🎉",
|
||||
"dashboard.loadingHub": "Loading Samaya Marketing Hub...",
|
||||
|
||||
"posts.title": "Post Production",
|
||||
"posts.newPost": "New Post",
|
||||
"posts.editPost": "Edit Post",
|
||||
"posts.createPost": "Create Post",
|
||||
"posts.saveChanges": "Save Changes",
|
||||
"posts.postTitle": "Title",
|
||||
"posts.description": "Description",
|
||||
"posts.brand": "Brand",
|
||||
"posts.platforms": "Platforms",
|
||||
"posts.status": "Status",
|
||||
"posts.assignTo": "Assign To",
|
||||
"posts.scheduledDate": "Scheduled Date",
|
||||
"posts.notes": "Notes",
|
||||
"posts.campaign": "Campaign",
|
||||
"posts.noCampaign": "No campaign",
|
||||
"posts.publicationLinks": "Publication Links",
|
||||
"posts.attachments": "Attachments",
|
||||
"posts.uploadFiles": "Click or drag files to upload",
|
||||
"posts.dropFiles": "Drop files here",
|
||||
"posts.maxSize": "Max 50MB per file",
|
||||
"posts.allBrands": "All Brands",
|
||||
"posts.allPlatforms": "All Platforms",
|
||||
"posts.allPeople": "All People",
|
||||
"posts.searchPosts": "Search posts...",
|
||||
"posts.deletePost": "Delete Post?",
|
||||
"posts.deleteConfirm": "Are you sure you want to delete this post? This action cannot be undone.",
|
||||
"posts.publishMissing": "Cannot publish: missing publication links for:",
|
||||
"posts.publishRequired": "All publication links are required to publish",
|
||||
"posts.noPostsFound": "No posts found",
|
||||
"posts.selectBrand": "Select brand",
|
||||
"posts.additionalNotes": "Additional notes",
|
||||
"posts.uploading": "Uploading...",
|
||||
"posts.deleteAttachment": "Delete attachment",
|
||||
"posts.whatNeedsDone": "What needs to be done?",
|
||||
"posts.optionalDetails": "Optional details...",
|
||||
"posts.postTitlePlaceholder": "Post title",
|
||||
"posts.postDescPlaceholder": "Post description...",
|
||||
"posts.dropHere": "Drop here",
|
||||
"posts.noPosts": "No posts",
|
||||
"posts.sendToReview": "Send to Review",
|
||||
"posts.approve": "Approve",
|
||||
"posts.schedule": "Schedule",
|
||||
"posts.publish": "Publish",
|
||||
|
||||
"posts.status.draft": "Draft",
|
||||
"posts.status.in_review": "In Review",
|
||||
"posts.status.approved": "Approved",
|
||||
"posts.status.scheduled": "Scheduled",
|
||||
"posts.status.published": "Published",
|
||||
|
||||
"tasks.title": "Tasks",
|
||||
"tasks.newTask": "New Task",
|
||||
"tasks.editTask": "Edit Task",
|
||||
"tasks.createTask": "Create Task",
|
||||
"tasks.saveChanges": "Save Changes",
|
||||
"tasks.taskTitle": "Title",
|
||||
"tasks.description": "Description",
|
||||
"tasks.priority": "Priority",
|
||||
"tasks.dueDate": "Due Date",
|
||||
"tasks.assignTo": "Assign to",
|
||||
"tasks.allTasks": "All Tasks",
|
||||
"tasks.assignedToMe": "Assigned to Me",
|
||||
"tasks.createdByMe": "Created by Me",
|
||||
"tasks.byTeamMember": "By Team Member",
|
||||
"tasks.noTasks": "No tasks yet",
|
||||
"tasks.noMatch": "No tasks match this filter",
|
||||
"tasks.createFirst": "Create a task to get started",
|
||||
"tasks.tryFilter": "Try a different filter",
|
||||
"tasks.deleteTask": "Delete Task?",
|
||||
"tasks.deleteConfirm": "Are you sure you want to delete this task? This action cannot be undone.",
|
||||
"tasks.todo": "To Do",
|
||||
"tasks.in_progress": "In Progress",
|
||||
"tasks.done": "Done",
|
||||
"tasks.start": "Start",
|
||||
"tasks.complete": "Complete",
|
||||
"tasks.from": "From:",
|
||||
"tasks.assignedTo": "Assigned to:",
|
||||
"tasks.task": "task",
|
||||
"tasks.tasks": "tasks",
|
||||
"tasks.of": "of",
|
||||
|
||||
"tasks.priority.low": "Low",
|
||||
"tasks.priority.medium": "Medium",
|
||||
"tasks.priority.high": "High",
|
||||
"tasks.priority.urgent": "Urgent",
|
||||
|
||||
"team.title": "Team",
|
||||
"team.members": "Team Members",
|
||||
"team.addMember": "Add Member",
|
||||
"team.newMember": "New Team Member",
|
||||
"team.editMember": "Edit Team Member",
|
||||
"team.myProfile": "My Profile",
|
||||
"team.editProfile": "Edit My Profile",
|
||||
"team.name": "Name",
|
||||
"team.email": "Email",
|
||||
"team.password": "Password",
|
||||
"team.teamRole": "Team Role",
|
||||
"team.phone": "Phone",
|
||||
"team.brands": "Brands",
|
||||
"team.brandsHelp": "Comma-separated brand names",
|
||||
"team.removeMember": "Remove Team Member?",
|
||||
"team.removeConfirm": "Are you sure you want to remove {name}? This action cannot be undone.",
|
||||
"team.noMembers": "No team members",
|
||||
"team.backToTeam": "Back to Team",
|
||||
"team.totalTasks": "Total Tasks",
|
||||
"team.saveProfile": "Save Profile",
|
||||
"team.saveChanges": "Save Changes",
|
||||
"team.member": "team member",
|
||||
"team.membersPlural": "team members",
|
||||
"team.fullName": "Full name",
|
||||
"team.defaultPassword": "Default: changeme123",
|
||||
"team.optional": "(optional)",
|
||||
"team.fixedRole": "Fixed role for managers",
|
||||
"team.remove": "Remove",
|
||||
"team.noTasks": "No tasks",
|
||||
"team.toDo": "To Do",
|
||||
"team.inProgress": "In Progress",
|
||||
|
||||
"campaigns.title": "Campaigns",
|
||||
"campaigns.newCampaign": "New Campaign",
|
||||
"campaigns.noCampaigns": "No campaigns",
|
||||
|
||||
"assets.title": "Assets",
|
||||
"assets.upload": "Upload",
|
||||
"assets.noAssets": "No assets",
|
||||
|
||||
"settings.title": "Settings",
|
||||
"settings.language": "Language",
|
||||
"settings.english": "English",
|
||||
"settings.arabic": "Arabic",
|
||||
"settings.restartTutorial": "Restart Tutorial",
|
||||
"settings.tutorialDesc": "Need a refresher? Restart the interactive tutorial to learn about all the features of the Samaya Marketing Hub.",
|
||||
"settings.general": "General",
|
||||
"settings.onboardingTutorial": "Onboarding Tutorial",
|
||||
"settings.tutorialRestarted": "Tutorial Restarted!",
|
||||
"settings.restarting": "Restarting...",
|
||||
"settings.reloadingPage": "Reloading page to start tutorial...",
|
||||
"settings.moreComingSoon": "More Settings Coming Soon",
|
||||
"settings.additionalSettings": "Additional settings for notifications, display preferences, and more will be added here.",
|
||||
"settings.preferences": "Manage your preferences and app settings",
|
||||
|
||||
"tutorial.skip": "Skip Tutorial",
|
||||
"tutorial.next": "Next",
|
||||
"tutorial.prev": "Back",
|
||||
"tutorial.finish": "Finish",
|
||||
"tutorial.of": "of",
|
||||
"tutorial.step": "Step",
|
||||
"tutorial.dashboard.title": "Dashboard",
|
||||
"tutorial.dashboard.desc": "Your command center. See campaign performance, task progress, and team activity at a glance.",
|
||||
"tutorial.campaigns.title": "Campaigns",
|
||||
"tutorial.campaigns.desc": "Plan and manage marketing campaigns across all brands and platforms.",
|
||||
"tutorial.posts.title": "Post Production",
|
||||
"tutorial.posts.desc": "Create, review, and publish content. Drag posts through your workflow pipeline.",
|
||||
"tutorial.tasks.title": "Tasks",
|
||||
"tutorial.tasks.desc": "Assign and track tasks. Filter by who assigned them or who they're assigned to.",
|
||||
"tutorial.team.title": "Team",
|
||||
"tutorial.team.desc": "Your team directory. Complete your profile and see who you're working with.",
|
||||
"tutorial.assets.title": "Assets",
|
||||
"tutorial.assets.desc": "Upload and manage creative assets — images, videos, and documents.",
|
||||
"tutorial.newPost.title": "Create Content",
|
||||
"tutorial.newPost.desc": "Start creating content here. Pick your brand, platforms, and assign it to a team member.",
|
||||
"tutorial.filters.title": "Filter & Focus",
|
||||
"tutorial.filters.desc": "Use filters to focus on specific brands, platforms, or team members.",
|
||||
|
||||
"login.title": "Samaya Marketing",
|
||||
"login.subtitle": "Sign in to continue",
|
||||
"login.forgotPassword": "Forgot password?",
|
||||
"login.defaultCreds": "Default credentials:",
|
||||
|
||||
"profile.completeYourProfile": "Complete Your Profile",
|
||||
"profile.completeDesc": "Please complete your profile to access all features and help your team find you.",
|
||||
"profile.completeProfileBtn": "Complete Profile",
|
||||
"profile.later": "Later"
|
||||
}
|
||||
219
client/src/index.css
Normal file
219
client/src/index.css
Normal file
@@ -0,0 +1,219 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Inter', 'IBM Plex Sans Arabic', system-ui, -apple-system, sans-serif;
|
||||
--color-sidebar: #0f172a;
|
||||
--color-sidebar-hover: #1e293b;
|
||||
--color-sidebar-active: #020617;
|
||||
--color-brand-primary: #4f46e5;
|
||||
--color-brand-primary-light: #6366f1;
|
||||
--color-brand-secondary: #db2777;
|
||||
--color-brand-tertiary: #f59e0b;
|
||||
--color-brand-quaternary: #059669;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-secondary: #f9fafb;
|
||||
--color-surface-tertiary: #f3f4f6;
|
||||
--color-border: #e5e7eb;
|
||||
--color-border-light: #f3f4f6;
|
||||
--color-text-primary: #0f172a;
|
||||
--color-text-secondary: #475569;
|
||||
--color-text-tertiary: #94a3b8;
|
||||
--color-text-on-dark: #f8fafc;
|
||||
--color-text-on-dark-muted: #94a3b8;
|
||||
--color-status-draft: #9ca3af;
|
||||
--color-status-in-review: #f59e0b;
|
||||
--color-status-approved: #3b82f6;
|
||||
--color-status-scheduled: #8b5cf6;
|
||||
--color-status-published: #059669;
|
||||
--color-status-rejected: #dc2626;
|
||||
--color-status-todo: #9ca3af;
|
||||
--color-status-in-progress: #3b82f6;
|
||||
--color-status-done: #059669;
|
||||
--color-status-active: #059669;
|
||||
--color-status-paused: #f59e0b;
|
||||
--color-status-completed: #3b82f6;
|
||||
--color-status-cancelled: #dc2626;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
* {
|
||||
transition-property: background-color, border-color, color, opacity, box-shadow, transform;
|
||||
transition-duration: 200ms;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Arabic text support */
|
||||
[dir="rtl"] {
|
||||
font-family: 'IBM Plex Sans Arabic', 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Auto-detect text direction in inputs for mixed Arabic/English content */
|
||||
input[type="text"],
|
||||
input[type="search"],
|
||||
input[type="url"],
|
||||
input[type="email"],
|
||||
textarea {
|
||||
unicode-bidi: plaintext;
|
||||
}
|
||||
|
||||
/* Ensure text content areas handle Arabic properly */
|
||||
.line-clamp-2, .truncate, h1, h2, h3, h4, h5, p, span, label {
|
||||
unicode-bidi: plaintext;
|
||||
}
|
||||
|
||||
/* RTL-aware sidebar positioning */
|
||||
[dir="rtl"] .sidebar {
|
||||
right: 0;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
[dir="ltr"] .sidebar {
|
||||
left: 0;
|
||||
right: auto;
|
||||
}
|
||||
|
||||
/* RTL-aware main content margin */
|
||||
[dir="rtl"] .main-content-margin {
|
||||
margin-right: 260px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
[dir="ltr"] .main-content-margin {
|
||||
margin-left: 260px;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
[dir="rtl"] .main-content-margin-collapsed {
|
||||
margin-right: 68px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
[dir="ltr"] .main-content-margin-collapsed {
|
||||
margin-left: 68px;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
/* Enhanced sidebar with gradient */
|
||||
.sidebar {
|
||||
background: linear-gradient(180deg, #0f172a 0%, #020617 100%);
|
||||
box-shadow: 2px 0 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* Animation keyframes */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateX(-12px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slideIn 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scaleIn 0.2s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Stagger children */
|
||||
.stagger-children > * {
|
||||
opacity: 0;
|
||||
animation: fadeIn 0.3s ease-out forwards;
|
||||
}
|
||||
.stagger-children > *:nth-child(1) { animation-delay: 0ms; }
|
||||
.stagger-children > *:nth-child(2) { animation-delay: 50ms; }
|
||||
.stagger-children > *:nth-child(3) { animation-delay: 100ms; }
|
||||
.stagger-children > *:nth-child(4) { animation-delay: 150ms; }
|
||||
.stagger-children > *:nth-child(5) { animation-delay: 200ms; }
|
||||
.stagger-children > *:nth-child(6) { animation-delay: 250ms; }
|
||||
.stagger-children > *:nth-child(7) { animation-delay: 300ms; }
|
||||
.stagger-children > *:nth-child(8) { animation-delay: 350ms; }
|
||||
|
||||
/* Card hover effect - smooth and elegant */
|
||||
.card-hover {
|
||||
position: relative;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.card-hover:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 12px 28px -6px rgba(0, 0, 0, 0.12), 0 6px 16px -8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* Stat card accents - subtle colored top borders */
|
||||
.stat-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--color-brand-primary), var(--color-brand-primary-light));
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.stat-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Refined button styles */
|
||||
button {
|
||||
border-radius: 0.625rem;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
button:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Kanban column */
|
||||
.kanban-column {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* Calendar grid */
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
}
|
||||
13
client/src/main.jsx
Normal file
13
client/src/main.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
356
client/src/pages/Assets.jsx
Normal file
356
client/src/pages/Assets.jsx
Normal file
@@ -0,0 +1,356 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Plus, Upload, Search, FolderOpen, ChevronRight, Grid3X3, X } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
import AssetCard from '../components/AssetCard'
|
||||
import Modal from '../components/Modal'
|
||||
|
||||
export default function Assets() {
|
||||
const [assets, setAssets] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filters, setFilters] = useState({ brand: '', tag: '', folder: '', search: '' })
|
||||
const [showUpload, setShowUpload] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const [selectedAsset, setSelectedAsset] = useState(null)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [assetToDelete, setAssetToDelete] = useState(null)
|
||||
const fileRef = useRef(null)
|
||||
|
||||
useEffect(() => { loadAssets() }, [])
|
||||
|
||||
const loadAssets = async () => {
|
||||
try {
|
||||
const res = await api.get('/assets')
|
||||
const assetsData = res.data || res || []
|
||||
// Map assets to include URL for thumbnails
|
||||
const assetsWithUrls = assetsData.map(asset => ({
|
||||
...asset,
|
||||
_id: asset.id,
|
||||
name: asset.original_name || asset.filename,
|
||||
type: asset.mime_type?.startsWith('image') ? 'image' :
|
||||
asset.mime_type?.startsWith('video') ? 'video' :
|
||||
asset.mime_type?.startsWith('audio') ? 'audio' : 'document',
|
||||
url: `/api/uploads/${asset.filename}`,
|
||||
createdAt: asset.created_at,
|
||||
fileType: asset.mime_type?.split('/')[1]?.toUpperCase() || 'FILE',
|
||||
}))
|
||||
setAssets(assetsWithUrls)
|
||||
} catch (err) {
|
||||
console.error('Failed to load assets:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpload = async (files) => {
|
||||
if (!files || files.length === 0) return
|
||||
setUploading(true)
|
||||
setUploadProgress(0)
|
||||
|
||||
try {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('folder', 'general')
|
||||
formData.append('brand_id', '')
|
||||
formData.append('uploaded_by', '')
|
||||
|
||||
// Use XMLHttpRequest to track upload progress
|
||||
await new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const fileProgress = (e.loaded / e.total) * 100
|
||||
const totalProgress = ((i + fileProgress / 100) / files.length) * 100
|
||||
setUploadProgress(Math.round(totalProgress))
|
||||
}
|
||||
})
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(JSON.parse(xhr.responseText))
|
||||
} else {
|
||||
reject(new Error(`Upload failed with status ${xhr.status}`))
|
||||
}
|
||||
})
|
||||
|
||||
xhr.addEventListener('error', () => reject(new Error('Network error')))
|
||||
|
||||
xhr.open('POST', '/api/assets/upload')
|
||||
xhr.send(formData)
|
||||
})
|
||||
}
|
||||
|
||||
loadAssets()
|
||||
setShowUpload(false)
|
||||
setUploadProgress(0)
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err)
|
||||
alert('Upload failed: ' + err.message)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteAsset = async (asset) => {
|
||||
setAssetToDelete(asset)
|
||||
setShowDeleteConfirm(true)
|
||||
}
|
||||
|
||||
const confirmDeleteAsset = async () => {
|
||||
if (!assetToDelete) return
|
||||
try {
|
||||
await api.delete(`/assets/${assetToDelete.id || assetToDelete._id}`)
|
||||
setSelectedAsset(null)
|
||||
setAssetToDelete(null)
|
||||
loadAssets()
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err)
|
||||
alert('Failed to delete asset')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
handleUpload(e.dataTransfer.files)
|
||||
}
|
||||
|
||||
// Get unique values for filters
|
||||
const brands = [...new Set(assets.map(a => a.brand).filter(Boolean))]
|
||||
const allTags = [...new Set(assets.flatMap(a => a.tags || []))]
|
||||
const folders = [...new Set(assets.map(a => a.folder).filter(Boolean))]
|
||||
|
||||
const filteredAssets = assets.filter(a => {
|
||||
if (filters.brand && a.brand !== filters.brand) return false
|
||||
if (filters.tag && !(a.tags || []).includes(filters.tag)) return false
|
||||
if (filters.folder && a.folder !== filters.folder) return false
|
||||
if (filters.search && !a.name?.toLowerCase().includes(filters.search.toLowerCase())) return false
|
||||
return true
|
||||
})
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
<div className="h-12 bg-surface-tertiary rounded-xl mb-4"></div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{[...Array(10)].map((_, i) => (
|
||||
<div key={i} className="aspect-square bg-surface-tertiary rounded-xl"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search assets..."
|
||||
value={filters.search}
|
||||
onChange={e => setFilters(f => ({ ...f, search: e.target.value }))}
|
||||
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={filters.brand}
|
||||
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
|
||||
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
|
||||
>
|
||||
<option value="">All Brands</option>
|
||||
{brands.map(b => <option key={b} value={b}>{b}</option>)}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filters.tag}
|
||||
onChange={e => setFilters(f => ({ ...f, tag: e.target.value }))}
|
||||
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
|
||||
>
|
||||
<option value="">All Tags</option>
|
||||
{allTags.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={() => setShowUpload(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ml-auto"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
Upload
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Folder breadcrumbs */}
|
||||
{folders.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => setFilters(f => ({ ...f, folder: '' }))}
|
||||
className={`flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition-colors ${
|
||||
!filters.folder ? 'bg-brand-primary/10 text-brand-primary font-medium' : 'text-text-secondary hover:bg-surface-tertiary'
|
||||
}`}
|
||||
>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
All
|
||||
</button>
|
||||
{folders.map(folder => (
|
||||
<button
|
||||
key={folder}
|
||||
onClick={() => setFilters(f => ({ ...f, folder }))}
|
||||
className={`flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition-colors ${
|
||||
filters.folder === folder ? 'bg-brand-primary/10 text-brand-primary font-medium' : 'text-text-secondary hover:bg-surface-tertiary'
|
||||
}`}
|
||||
>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
{folder}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Asset grid */}
|
||||
{filteredAssets.length === 0 ? (
|
||||
<div className="py-20 text-center">
|
||||
<Grid3X3 className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
|
||||
<p className="text-text-secondary font-medium">No assets found</p>
|
||||
<p className="text-sm text-text-tertiary mt-1">Upload your first asset to get started</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{filteredAssets.map(asset => (
|
||||
<div key={asset._id || asset.id}>
|
||||
<AssetCard asset={asset} onClick={setSelectedAsset} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Modal */}
|
||||
<Modal isOpen={showUpload} onClose={() => !uploading && setShowUpload(false)} title="Upload Assets" size="md">
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => !uploading && fileRef.current?.click()}
|
||||
className={`border-2 border-dashed rounded-xl p-12 text-center transition-colors ${
|
||||
uploading ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'
|
||||
} ${
|
||||
dragOver ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/50'
|
||||
}`}
|
||||
>
|
||||
<Upload className={`w-10 h-10 mx-auto mb-3 ${uploading ? 'animate-pulse' : ''} ${dragOver ? 'text-brand-primary' : 'text-text-tertiary'}`} />
|
||||
<p className="text-sm font-medium text-text-primary">
|
||||
{uploading ? `Uploading... ${uploadProgress}%` : 'Drop files here or click to browse'}
|
||||
</p>
|
||||
<p className="text-xs text-text-tertiary mt-1">
|
||||
Images, videos, documents up to 50MB
|
||||
</p>
|
||||
{uploading && (
|
||||
<div className="mt-4 w-full bg-surface-tertiary rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-brand-primary transition-all duration-300"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
multiple
|
||||
disabled={uploading}
|
||||
className="hidden"
|
||||
onChange={e => handleUpload(e.target.files)}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Asset detail modal */}
|
||||
<Modal
|
||||
isOpen={!!selectedAsset}
|
||||
onClose={() => setSelectedAsset(null)}
|
||||
title={selectedAsset?.name || 'Asset Details'}
|
||||
size="lg"
|
||||
>
|
||||
{selectedAsset && (
|
||||
<div className="space-y-4">
|
||||
{selectedAsset.type === 'image' && selectedAsset.url && (
|
||||
<div className="rounded-lg overflow-hidden bg-surface-tertiary">
|
||||
<img src={selectedAsset.url} alt={selectedAsset.name} className="w-full max-h-[400px] object-contain" />
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-text-tertiary">Type</p>
|
||||
<p className="font-medium text-text-primary capitalize">{selectedAsset.type}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-text-tertiary">Size</p>
|
||||
<p className="font-medium text-text-primary">{selectedAsset.size ? `${(selectedAsset.size / 1024 / 1024).toFixed(2)} MB` : '—'}</p>
|
||||
</div>
|
||||
{selectedAsset.brand_name && (
|
||||
<div>
|
||||
<p className="text-text-tertiary">Brand</p>
|
||||
<p className="font-medium text-text-primary">{selectedAsset.brand_name}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedAsset.folder && (
|
||||
<div>
|
||||
<p className="text-text-tertiary">Folder</p>
|
||||
<p className="font-medium text-text-primary">{selectedAsset.folder}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedAsset.tags && selectedAsset.tags.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm text-text-tertiary mb-2">Tags</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedAsset.tags.map(tag => (
|
||||
<span key={tag} className="text-xs px-2 py-1 rounded-full bg-surface-tertiary text-text-secondary">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 pt-4 border-t border-border">
|
||||
<button
|
||||
onClick={() => handleDeleteAsset(selectedAsset)}
|
||||
className="px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg"
|
||||
>
|
||||
Delete Asset
|
||||
</button>
|
||||
<a
|
||||
href={selectedAsset.url}
|
||||
download={selectedAsset.name}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-auto px-4 py-2 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Delete Asset Confirmation */}
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => { setShowDeleteConfirm(false); setAssetToDelete(null) }}
|
||||
title="Delete Asset?"
|
||||
isConfirm
|
||||
danger
|
||||
confirmText="Delete Asset"
|
||||
onConfirm={confirmDeleteAsset}
|
||||
>
|
||||
Are you sure you want to delete this asset? This file will be permanently removed from the server. This action cannot be undone.
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
565
client/src/pages/CampaignDetail.jsx
Normal file
565
client/src/pages/CampaignDetail.jsx
Normal file
@@ -0,0 +1,565 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { ArrowLeft, Plus, Edit2, Trash2, DollarSign, Eye, MousePointer, Target, TrendingUp, FileText, Megaphone, Search, Globe, Pencil } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
import { AppContext } from '../App'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { api, PLATFORMS } from '../utils/api'
|
||||
import PlatformIcon, { PlatformIcons } from '../components/PlatformIcon'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import BrandBadge from '../components/BrandBadge'
|
||||
import Modal from '../components/Modal'
|
||||
|
||||
const TRACK_TYPES = {
|
||||
organic_social: { label: 'Organic Social', icon: Megaphone, color: 'text-green-600 bg-green-50', hasBudget: false },
|
||||
paid_social: { label: 'Paid Social', icon: DollarSign, color: 'text-blue-600 bg-blue-50', hasBudget: true },
|
||||
paid_search: { label: 'Paid Search (PPC)', icon: Search, color: 'text-amber-600 bg-amber-50', hasBudget: true },
|
||||
seo_content: { label: 'SEO / Content', icon: Globe, color: 'text-purple-600 bg-purple-50', hasBudget: false },
|
||||
production: { label: 'Production', icon: FileText, color: 'text-red-600 bg-red-50', hasBudget: true },
|
||||
}
|
||||
|
||||
const TRACK_STATUSES = ['planned', 'active', 'paused', 'completed']
|
||||
|
||||
const EMPTY_TRACK = {
|
||||
name: '', type: 'organic_social', platform: '', budget_allocated: '', status: 'planned', notes: '',
|
||||
}
|
||||
|
||||
const EMPTY_METRICS = {
|
||||
budget_spent: '', revenue: '', impressions: '', clicks: '', conversions: '', notes: '',
|
||||
}
|
||||
|
||||
function BudgetBar({ budget, spent }) {
|
||||
if (!budget || budget <= 0) return null
|
||||
const pct = Math.min((spent / budget) * 100, 100)
|
||||
const color = pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-amber-500' : 'bg-emerald-500'
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between text-[10px] text-text-tertiary mb-0.5">
|
||||
<span>{(spent || 0).toLocaleString()} spent</span>
|
||||
<span>{budget.toLocaleString()} SAR</span>
|
||||
</div>
|
||||
<div className="h-2 bg-surface-tertiary rounded-full overflow-hidden">
|
||||
<div className={`h-full ${color} rounded-full transition-all`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MetricBox({ label, value, icon: Icon, color = 'text-text-primary' }) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<Icon className={`w-4 h-4 mx-auto mb-0.5 ${color}`} />
|
||||
<div className={`text-base font-bold ${color}`}>{value ?? '—'}</div>
|
||||
<div className="text-[10px] text-text-tertiary">{label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CampaignDetail() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { brands } = useContext(AppContext)
|
||||
const { permissions } = useAuth()
|
||||
const canManage = permissions?.canEditCampaigns
|
||||
const [campaign, setCampaign] = useState(null)
|
||||
const [tracks, setTracks] = useState([])
|
||||
const [posts, setPosts] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showTrackModal, setShowTrackModal] = useState(false)
|
||||
const [editingTrack, setEditingTrack] = useState(null)
|
||||
const [trackForm, setTrackForm] = useState(EMPTY_TRACK)
|
||||
const [showMetricsModal, setShowMetricsModal] = useState(false)
|
||||
const [metricsTrack, setMetricsTrack] = useState(null)
|
||||
const [metricsForm, setMetricsForm] = useState(EMPTY_METRICS)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [trackToDelete, setTrackToDelete] = useState(null)
|
||||
|
||||
useEffect(() => { loadAll() }, [id])
|
||||
|
||||
const loadAll = async () => {
|
||||
try {
|
||||
const [campRes, tracksRes, postsRes] = await Promise.all([
|
||||
api.get(`/campaigns`),
|
||||
api.get(`/campaigns/${id}/tracks`),
|
||||
api.get(`/campaigns/${id}/posts`),
|
||||
])
|
||||
const allCampaigns = campRes.data || campRes || []
|
||||
const found = allCampaigns.find(c => String(c.id) === String(id) || String(c._id) === String(id))
|
||||
setCampaign(found || null)
|
||||
setTracks(tracksRes.data || tracksRes || [])
|
||||
setPosts(postsRes.data || postsRes || [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load campaign:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveTrack = async () => {
|
||||
try {
|
||||
const data = {
|
||||
name: trackForm.name,
|
||||
type: trackForm.type,
|
||||
platform: trackForm.platform || null,
|
||||
budget_allocated: trackForm.budget_allocated ? Number(trackForm.budget_allocated) : 0,
|
||||
status: trackForm.status,
|
||||
notes: trackForm.notes,
|
||||
}
|
||||
if (editingTrack) {
|
||||
await api.patch(`/tracks/${editingTrack.id}`, data)
|
||||
} else {
|
||||
await api.post(`/campaigns/${id}/tracks`, data)
|
||||
}
|
||||
setShowTrackModal(false)
|
||||
setEditingTrack(null)
|
||||
setTrackForm(EMPTY_TRACK)
|
||||
loadAll()
|
||||
} catch (err) {
|
||||
console.error('Save track failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteTrack = async (trackId) => {
|
||||
setTrackToDelete(trackId)
|
||||
setShowDeleteConfirm(true)
|
||||
}
|
||||
|
||||
const confirmDeleteTrack = async () => {
|
||||
if (!trackToDelete) return
|
||||
await api.delete(`/tracks/${trackToDelete}`)
|
||||
setTrackToDelete(null)
|
||||
loadAll()
|
||||
}
|
||||
|
||||
const saveMetrics = async () => {
|
||||
try {
|
||||
await api.patch(`/tracks/${metricsTrack.id}`, {
|
||||
budget_spent: metricsForm.budget_spent ? Number(metricsForm.budget_spent) : 0,
|
||||
revenue: metricsForm.revenue ? Number(metricsForm.revenue) : 0,
|
||||
impressions: metricsForm.impressions ? Number(metricsForm.impressions) : 0,
|
||||
clicks: metricsForm.clicks ? Number(metricsForm.clicks) : 0,
|
||||
conversions: metricsForm.conversions ? Number(metricsForm.conversions) : 0,
|
||||
notes: metricsForm.notes || '',
|
||||
})
|
||||
setShowMetricsModal(false)
|
||||
setMetricsTrack(null)
|
||||
loadAll()
|
||||
} catch (err) {
|
||||
console.error('Save metrics failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const openEditTrack = (track) => {
|
||||
setEditingTrack(track)
|
||||
setTrackForm({
|
||||
name: track.name || '',
|
||||
type: track.type || 'organic_social',
|
||||
platform: track.platform || '',
|
||||
budget_allocated: track.budget_allocated || '',
|
||||
status: track.status || 'planned',
|
||||
notes: track.notes || '',
|
||||
})
|
||||
setShowTrackModal(true)
|
||||
}
|
||||
|
||||
const openMetrics = (track) => {
|
||||
setMetricsTrack(track)
|
||||
setMetricsForm({
|
||||
budget_spent: track.budget_spent || '',
|
||||
revenue: track.revenue || '',
|
||||
impressions: track.impressions || '',
|
||||
clicks: track.clicks || '',
|
||||
conversions: track.conversions || '',
|
||||
notes: track.notes || '',
|
||||
})
|
||||
setShowMetricsModal(true)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="animate-pulse"><div className="h-64 bg-surface-tertiary rounded-xl"></div></div>
|
||||
}
|
||||
|
||||
if (!campaign) {
|
||||
return (
|
||||
<div className="text-center py-12 text-text-tertiary">
|
||||
Campaign not found. <button onClick={() => navigate('/campaigns')} className="text-brand-primary underline">Go back</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Aggregates from tracks
|
||||
const totalAllocated = tracks.reduce((s, t) => s + (t.budget_allocated || 0), 0)
|
||||
const totalSpent = tracks.reduce((s, t) => s + (t.budget_spent || 0), 0)
|
||||
const totalImpressions = tracks.reduce((s, t) => s + (t.impressions || 0), 0)
|
||||
const totalClicks = tracks.reduce((s, t) => s + (t.clicks || 0), 0)
|
||||
const totalConversions = tracks.reduce((s, t) => s + (t.conversions || 0), 0)
|
||||
const totalRevenue = tracks.reduce((s, t) => s + (t.revenue || 0), 0)
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-4">
|
||||
<button onClick={() => navigate('/campaigns')} className="mt-1 p-1.5 hover:bg-surface-tertiary rounded-lg">
|
||||
<ArrowLeft className="w-5 h-5 text-text-secondary" />
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<h1 className="text-xl font-bold text-text-primary">{campaign.name}</h1>
|
||||
<StatusBadge status={campaign.status} />
|
||||
{campaign.brand_name && <BrandBadge brand={campaign.brand_name} />}
|
||||
</div>
|
||||
{campaign.description && <p className="text-sm text-text-secondary">{campaign.description}</p>}
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-text-tertiary">
|
||||
{campaign.start_date && campaign.end_date && (
|
||||
<span>{format(new Date(campaign.start_date), 'MMM d')} – {format(new Date(campaign.end_date), 'MMM d, yyyy')}</span>
|
||||
)}
|
||||
{campaign.budget > 0 && <span>Budget: {campaign.budget.toLocaleString()} SAR</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Aggregate Metrics */}
|
||||
{tracks.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-border p-5">
|
||||
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">Campaign Totals (from tracks)</h3>
|
||||
<div className="grid grid-cols-3 md:grid-cols-6 gap-4">
|
||||
<MetricBox icon={DollarSign} label="Allocated" value={`${totalAllocated.toLocaleString()}`} color="text-blue-600" />
|
||||
<MetricBox icon={TrendingUp} label="Spent" value={`${totalSpent.toLocaleString()}`} color="text-amber-600" />
|
||||
<MetricBox icon={Eye} label="Impressions" value={totalImpressions.toLocaleString()} color="text-purple-600" />
|
||||
<MetricBox icon={MousePointer} label="Clicks" value={totalClicks.toLocaleString()} color="text-green-600" />
|
||||
<MetricBox icon={Target} label="Conversions" value={totalConversions.toLocaleString()} color="text-red-600" />
|
||||
<MetricBox icon={DollarSign} label="Revenue" value={`${totalRevenue.toLocaleString()}`} color="text-emerald-600" />
|
||||
</div>
|
||||
{totalAllocated > 0 && (
|
||||
<div className="mt-4">
|
||||
<BudgetBar budget={totalAllocated} spent={totalSpent} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tracks */}
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
||||
<h3 className="font-semibold text-text-primary">Tracks</h3>
|
||||
{canManage && (
|
||||
<button
|
||||
onClick={() => { setEditingTrack(null); setTrackForm(EMPTY_TRACK); setShowTrackModal(true) }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-brand-primary text-white rounded-lg text-xs font-medium hover:bg-brand-primary-light"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Add Track
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tracks.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-text-tertiary">
|
||||
No tracks yet. Add organic, paid, or SEO tracks to organize this campaign.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border-light">
|
||||
{tracks.map(track => {
|
||||
const typeInfo = TRACK_TYPES[track.type] || TRACK_TYPES.organic_social
|
||||
const TypeIcon = typeInfo.icon
|
||||
const trackPosts = posts.filter(p => p.track_id === track.id)
|
||||
return (
|
||||
<div key={track.id} className="px-5 py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`p-2 rounded-lg ${typeInfo.color}`}>
|
||||
<TypeIcon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<h4 className="text-sm font-semibold text-text-primary">
|
||||
{track.name || typeInfo.label}
|
||||
</h4>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-tertiary">
|
||||
{typeInfo.label}
|
||||
</span>
|
||||
{track.platform && (
|
||||
<PlatformIcon platform={track.platform} size={16} />
|
||||
)}
|
||||
<StatusBadge status={track.status} size="xs" />
|
||||
</div>
|
||||
|
||||
{/* Budget bar for paid tracks */}
|
||||
{track.budget_allocated > 0 && (
|
||||
<div className="w-48 mt-1.5">
|
||||
<BudgetBar budget={track.budget_allocated} spent={track.budget_spent || 0} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick metrics */}
|
||||
{(track.impressions > 0 || track.clicks > 0 || track.conversions > 0) && (
|
||||
<div className="flex items-center gap-3 mt-1.5 text-[10px] text-text-tertiary">
|
||||
{track.impressions > 0 && <span>👁 {track.impressions.toLocaleString()}</span>}
|
||||
{track.clicks > 0 && <span>🖱 {track.clicks.toLocaleString()}</span>}
|
||||
{track.conversions > 0 && <span>🎯 {track.conversions.toLocaleString()}</span>}
|
||||
{track.clicks > 0 && track.budget_spent > 0 && (
|
||||
<span>CPC: {(track.budget_spent / track.clicks).toFixed(2)} SAR</span>
|
||||
)}
|
||||
{track.impressions > 0 && track.clicks > 0 && (
|
||||
<span>CTR: {(track.clicks / track.impressions * 100).toFixed(2)}%</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Linked posts count */}
|
||||
{trackPosts.length > 0 && (
|
||||
<div className="text-[10px] text-text-tertiary mt-1">
|
||||
📝 {trackPosts.length} post{trackPosts.length !== 1 ? 's' : ''} linked
|
||||
</div>
|
||||
)}
|
||||
|
||||
{track.notes && (
|
||||
<p className="text-xs text-text-secondary mt-1 line-clamp-1">{track.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{canManage && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={() => openMetrics(track)}
|
||||
title="Update metrics"
|
||||
className="p-1.5 hover:bg-surface-tertiary rounded-lg text-text-tertiary hover:text-brand-primary"
|
||||
>
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEditTrack(track)}
|
||||
title="Edit track"
|
||||
className="p-1.5 hover:bg-surface-tertiary rounded-lg text-text-tertiary hover:text-text-primary"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteTrack(track.id)}
|
||||
title="Delete track"
|
||||
className="p-1.5 hover:bg-red-50 rounded-lg text-text-tertiary hover:text-red-500"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Linked Posts */}
|
||||
{posts.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-border">
|
||||
<h3 className="font-semibold text-text-primary">Linked Posts ({posts.length})</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-border-light">
|
||||
{posts.map(post => (
|
||||
<div key={post.id} className="flex items-center gap-3 px-5 py-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-medium text-text-primary">{post.title}</h4>
|
||||
<StatusBadge status={post.status} size="xs" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-text-tertiary">
|
||||
{post.track_name && <span className="px-1.5 py-0.5 rounded bg-surface-tertiary">{post.track_name}</span>}
|
||||
{post.assigned_name && <span>→ {post.assigned_name}</span>}
|
||||
{post.platforms && post.platforms.length > 0 && (
|
||||
<PlatformIcons platforms={post.platforms} size={14} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add/Edit Track Modal */}
|
||||
<Modal
|
||||
isOpen={showTrackModal}
|
||||
onClose={() => { setShowTrackModal(false); setEditingTrack(null) }}
|
||||
title={editingTrack ? 'Edit Track' : 'Add Track'}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Track Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={trackForm.name}
|
||||
onChange={e => setTrackForm(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="e.g., Instagram Paid Ads, Organic Wave, Google Search..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Type</label>
|
||||
<select
|
||||
value={trackForm.type}
|
||||
onChange={e => setTrackForm(f => ({ ...f, type: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
||||
>
|
||||
{Object.entries(TRACK_TYPES).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Platform</label>
|
||||
<select
|
||||
value={trackForm.platform}
|
||||
onChange={e => setTrackForm(f => ({ ...f, platform: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
||||
>
|
||||
<option value="">All / Multiple</option>
|
||||
{Object.entries(PLATFORMS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.label}</option>
|
||||
))}
|
||||
<option value="google_ads">Google Ads</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Budget Allocated (SAR)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={trackForm.budget_allocated}
|
||||
onChange={e => setTrackForm(f => ({ ...f, budget_allocated: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
||||
placeholder="0 for free/organic"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Status</label>
|
||||
<select
|
||||
value={trackForm.status}
|
||||
onChange={e => setTrackForm(f => ({ ...f, status: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
||||
>
|
||||
{TRACK_STATUSES.map(s => (
|
||||
<option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Notes</label>
|
||||
<textarea
|
||||
value={trackForm.notes}
|
||||
onChange={e => setTrackForm(f => ({ ...f, notes: e.target.value }))}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none resize-none"
|
||||
placeholder="Keywords, targeting details, content plan..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-border">
|
||||
<button onClick={() => setShowTrackModal(false)} className="px-4 py-2 text-sm text-text-secondary hover:bg-surface-tertiary rounded-lg">Cancel</button>
|
||||
<button onClick={saveTrack} className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm">
|
||||
{editingTrack ? 'Save' : 'Add Track'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Update Metrics Modal */}
|
||||
<Modal
|
||||
isOpen={showMetricsModal}
|
||||
onClose={() => { setShowMetricsModal(false); setMetricsTrack(null) }}
|
||||
title={`Update Metrics — ${metricsTrack?.name || ''}`}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<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={metricsForm.budget_spent}
|
||||
onChange={e => setMetricsForm(f => ({ ...f, budget_spent: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Revenue (SAR)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={metricsForm.revenue}
|
||||
onChange={e => setMetricsForm(f => ({ ...f, revenue: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
||||
/>
|
||||
</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={metricsForm.impressions}
|
||||
onChange={e => setMetricsForm(f => ({ ...f, impressions: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Clicks</label>
|
||||
<input
|
||||
type="number"
|
||||
value={metricsForm.clicks}
|
||||
onChange={e => setMetricsForm(f => ({ ...f, clicks: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Conversions</label>
|
||||
<input
|
||||
type="number"
|
||||
value={metricsForm.conversions}
|
||||
onChange={e => setMetricsForm(f => ({ ...f, conversions: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Notes</label>
|
||||
<textarea
|
||||
value={metricsForm.notes}
|
||||
onChange={e => setMetricsForm(f => ({ ...f, notes: e.target.value }))}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none resize-none"
|
||||
placeholder="What's working, what to adjust..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-border">
|
||||
<button onClick={() => setShowMetricsModal(false)} className="px-4 py-2 text-sm text-text-secondary hover:bg-surface-tertiary rounded-lg">Cancel</button>
|
||||
<button onClick={saveMetrics} className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm">
|
||||
Save Metrics
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Track Confirmation */}
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => { setShowDeleteConfirm(false); setTrackToDelete(null) }}
|
||||
title="Delete Track?"
|
||||
isConfirm
|
||||
danger
|
||||
confirmText="Delete Track"
|
||||
onConfirm={confirmDeleteTrack}
|
||||
>
|
||||
Are you sure you want to delete this campaign track? This action cannot be undone.
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
637
client/src/pages/Campaigns.jsx
Normal file
637
client/src/pages/Campaigns.jsx
Normal file
@@ -0,0 +1,637 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Plus, Search, TrendingUp, DollarSign, Eye, MousePointer, Target, BarChart3 } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
import { AppContext } from '../App'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { api, PLATFORMS } from '../utils/api'
|
||||
import { PlatformIcons } from '../components/PlatformIcon'
|
||||
import CampaignCalendar from '../components/CampaignCalendar'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import BrandBadge from '../components/BrandBadge'
|
||||
import Modal from '../components/Modal'
|
||||
|
||||
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: '',
|
||||
}
|
||||
|
||||
function BudgetBar({ budget, spent }) {
|
||||
if (!budget || budget <= 0) return null
|
||||
const pct = Math.min((spent / budget) * 100, 100)
|
||||
const color = pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-amber-500' : 'bg-emerald-500'
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between text-[10px] text-text-tertiary mb-0.5">
|
||||
<span>{spent?.toLocaleString() || 0} SAR spent</span>
|
||||
<span>{budget?.toLocaleString()} SAR</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-surface-tertiary rounded-full overflow-hidden">
|
||||
<div className={`h-full ${color} rounded-full transition-all`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ROIBadge({ revenue, spent }) {
|
||||
if (!spent || spent <= 0) return null
|
||||
const roi = ((revenue - spent) / spent * 100).toFixed(0)
|
||||
const color = roi >= 0 ? 'text-emerald-600 bg-emerald-50' : 'text-red-600 bg-red-50'
|
||||
return (
|
||||
<span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded ${color}`}>
|
||||
ROI {roi}%
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function MetricCard({ icon: Icon, label, value, color = 'text-text-primary' }) {
|
||||
return (
|
||||
<div className="bg-surface-secondary rounded-lg p-3 text-center">
|
||||
<Icon className={`w-4 h-4 mx-auto mb-1 ${color}`} />
|
||||
<div className={`text-sm font-bold ${color}`}>{value || '—'}</div>
|
||||
<div className="text-[10px] text-text-tertiary">{label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Campaigns() {
|
||||
const { brands } = useContext(AppContext)
|
||||
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 [filters, setFilters] = useState({ brand: '', status: '' })
|
||||
const [activeTab, setActiveTab] = useState('details') // details | performance
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
|
||||
useEffect(() => { loadCampaigns() }, [])
|
||||
|
||||
const loadCampaigns = async () => {
|
||||
try {
|
||||
const res = await api.get('/campaigns')
|
||||
setCampaigns(res.data || res || [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load campaigns:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
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 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 openNew = () => {
|
||||
setEditingCampaign(null)
|
||||
setFormData(EMPTY_CAMPAIGN)
|
||||
setActiveTab('details')
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const filtered = campaigns.filter(c => {
|
||||
if (filters.brand && String(c.brandId || c.brand_id) !== filters.brand) return false
|
||||
if (filters.status && c.status !== filters.status) return false
|
||||
return true
|
||||
})
|
||||
|
||||
// Aggregate stats
|
||||
const totalBudget = filtered.reduce((sum, c) => sum + (c.budget || 0), 0)
|
||||
const totalSpent = filtered.reduce((sum, c) => sum + (c.budgetSpent || c.budget_spent || 0), 0)
|
||||
const totalImpressions = filtered.reduce((sum, c) => sum + (c.impressions || 0), 0)
|
||||
const totalClicks = filtered.reduce((sum, c) => sum + (c.clicks || 0), 0)
|
||||
const totalConversions = filtered.reduce((sum, c) => sum + (c.conversions || 0), 0)
|
||||
const totalRevenue = filtered.reduce((sum, c) => sum + (c.revenue || 0), 0)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4 animate-pulse">
|
||||
<div className="h-12 bg-surface-tertiary rounded-xl"></div>
|
||||
<div className="h-[400px] bg-surface-tertiary rounded-xl"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Summary Cards */}
|
||||
{(totalBudget > 0 || totalSpent > 0) && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
<div className="bg-white rounded-xl border border-border p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<DollarSign className="w-4 h-4 text-blue-500" />
|
||||
<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>
|
||||
<div className="bg-white rounded-xl border border-border p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<TrendingUp className="w-4 h-4 text-amber-500" />
|
||||
<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>
|
||||
<div className="bg-white rounded-xl border border-border p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Eye className="w-4 h-4 text-purple-500" />
|
||||
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Impressions</span>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-text-primary">{totalImpressions.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-border p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<MousePointer className="w-4 h-4 text-green-500" />
|
||||
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Clicks</span>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-text-primary">{totalClicks.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-border p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Target className="w-4 h-4 text-red-500" />
|
||||
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Conversions</span>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-text-primary">{totalConversions.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-border p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<BarChart3 className="w-4 h-4 text-emerald-500" />
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<select
|
||||
value={filters.brand}
|
||||
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
|
||||
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
|
||||
>
|
||||
<option value="">All Brands</option>
|
||||
{brands.map(b => <option key={b.id || b._id} value={b.id || b._id}>{b.name}</option>)}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={e => setFilters(f => ({ ...f, status: e.target.value }))}
|
||||
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<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>
|
||||
|
||||
{permissions?.canCreateCampaigns && (
|
||||
<button
|
||||
onClick={openNew}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ml-auto"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
New Campaign
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Calendar */}
|
||||
<CampaignCalendar campaigns={filtered} />
|
||||
|
||||
{/* Campaign list */}
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-border">
|
||||
<h3 className="font-semibold text-text-primary">All Campaigns</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-border-light">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-text-tertiary">
|
||||
No campaigns found
|
||||
</div>
|
||||
) : (
|
||||
filtered.map(campaign => {
|
||||
const spent = campaign.budgetSpent || campaign.budget_spent || 0
|
||||
const budget = campaign.budget || 0
|
||||
return (
|
||||
<div
|
||||
key={campaign.id || campaign._id}
|
||||
onClick={() => permissions?.canEditCampaigns ? navigate(`/campaigns/${campaign.id || campaign._id}`) : navigate(`/campaigns/${campaign.id || campaign._id}`)}
|
||||
className="flex items-center gap-4 px-5 py-4 hover:bg-surface-secondary cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="text-sm font-semibold text-text-primary">{campaign.name}</h4>
|
||||
{campaign.brandName && <BrandBadge brand={campaign.brandName} />}
|
||||
<ROIBadge revenue={campaign.revenue || 0} spent={spent} />
|
||||
</div>
|
||||
{campaign.description && (
|
||||
<p className="text-xs text-text-secondary line-clamp-1">{campaign.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 mt-1.5">
|
||||
{campaign.platforms && campaign.platforms.length > 0 && (
|
||||
<PlatformIcons platforms={campaign.platforms} size={16} />
|
||||
)}
|
||||
{budget > 0 && (
|
||||
<div className="w-32">
|
||||
<BudgetBar budget={budget} spent={spent} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Quick metrics row */}
|
||||
{(campaign.impressions > 0 || campaign.clicks > 0) && (
|
||||
<div className="flex items-center gap-3 mt-1.5 text-[10px] text-text-tertiary">
|
||||
{campaign.impressions > 0 && <span>👁 {campaign.impressions.toLocaleString()}</span>}
|
||||
{campaign.clicks > 0 && <span>🖱 {campaign.clicks.toLocaleString()}</span>}
|
||||
{campaign.conversions > 0 && <span>🎯 {campaign.conversions.toLocaleString()}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<StatusBadge status={campaign.status} size="xs" />
|
||||
<div className="text-xs text-text-tertiary mt-1">
|
||||
{campaign.startDate && campaign.endDate ? (
|
||||
<>
|
||||
{format(new Date(campaign.startDate), 'MMM d')} – {format(new Date(campaign.endDate), 'MMM d, yyyy')}
|
||||
</>
|
||||
) : '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</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} {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)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.budget}
|
||||
onChange={e => setFormData(f => ({ ...f, budget: 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="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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
311
client/src/pages/Dashboard.jsx
Normal file
311
client/src/pages/Dashboard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
434
client/src/pages/Finance.jsx
Normal file
434
client/src/pages/Finance.jsx
Normal file
@@ -0,0 +1,434 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { Plus, DollarSign, TrendingUp, TrendingDown, Wallet, PiggyBank, Eye, MousePointer, Target, Edit2, Trash2 } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
import { AppContext } from '../App'
|
||||
import { api } from '../utils/api'
|
||||
import Modal from '../components/Modal'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: 'marketing', label: 'Marketing' },
|
||||
{ value: 'production', label: 'Production' },
|
||||
{ value: 'equipment', label: 'Equipment' },
|
||||
{ value: 'travel', label: 'Travel' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
]
|
||||
|
||||
const EMPTY_ENTRY = {
|
||||
label: '', amount: '', source: '', campaign_id: '', category: 'marketing', date_received: new Date().toISOString().slice(0, 10), notes: '',
|
||||
}
|
||||
|
||||
function StatCard({ icon: Icon, label, value, sub, color = 'text-text-primary', bgColor = 'bg-white' }) {
|
||||
return (
|
||||
<div className={`${bgColor} rounded-xl border border-border p-5`}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className={`p-2 rounded-lg ${color.replace('text-', 'bg-')}/10`}>
|
||||
<Icon className={`w-5 h-5 ${color}`} />
|
||||
</div>
|
||||
<span className="text-xs uppercase tracking-wider text-text-tertiary font-medium">{label}</span>
|
||||
</div>
|
||||
<div className={`text-2xl font-bold ${color}`}>{value}</div>
|
||||
{sub && <div className="text-xs text-text-tertiary mt-1">{sub}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressRing({ pct, size = 80, stroke = 8, color = '#10b981' }) {
|
||||
const r = (size - stroke) / 2
|
||||
const circ = 2 * Math.PI * r
|
||||
const offset = circ - (Math.min(pct, 100) / 100) * circ
|
||||
return (
|
||||
<svg width={size} height={size} className="transform -rotate-90">
|
||||
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="#f3f4f6" strokeWidth={stroke} />
|
||||
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke={color} strokeWidth={stroke}
|
||||
strokeDasharray={circ} strokeDashoffset={offset} strokeLinecap="round" className="transition-all duration-500" />
|
||||
<text x={size / 2} y={size / 2} textAnchor="middle" dominantBaseline="central"
|
||||
className="fill-text-primary text-sm font-bold" transform={`rotate(90 ${size / 2} ${size / 2})`}>
|
||||
{Math.round(pct)}%
|
||||
</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Finance() {
|
||||
const { brands } = useContext(AppContext)
|
||||
const [entries, setEntries] = useState([])
|
||||
const [summary, setSummary] = useState(null)
|
||||
const [campaigns, setCampaigns] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [form, setForm] = useState(EMPTY_ENTRY)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [entryToDelete, setEntryToDelete] = useState(null)
|
||||
|
||||
useEffect(() => { loadAll() }, [])
|
||||
|
||||
const loadAll = async () => {
|
||||
try {
|
||||
const [ent, sum, camp] = await Promise.all([
|
||||
api.get('/budget'),
|
||||
api.get('/finance/summary'),
|
||||
api.get('/campaigns'),
|
||||
])
|
||||
setEntries(ent.data || ent || [])
|
||||
setSummary(sum.data || sum || {})
|
||||
setCampaigns(camp.data || camp || [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load finance:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const data = {
|
||||
label: form.label,
|
||||
amount: Number(form.amount),
|
||||
source: form.source || null,
|
||||
campaign_id: form.campaign_id ? Number(form.campaign_id) : null,
|
||||
category: form.category,
|
||||
date_received: form.date_received,
|
||||
notes: form.notes,
|
||||
}
|
||||
if (editing) {
|
||||
await api.patch(`/budget/${editing._id || editing.id}`, data)
|
||||
} else {
|
||||
await api.post('/budget', data)
|
||||
}
|
||||
setShowModal(false)
|
||||
setEditing(null)
|
||||
setForm(EMPTY_ENTRY)
|
||||
loadAll()
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const openEdit = (entry) => {
|
||||
setEditing(entry)
|
||||
setForm({
|
||||
label: entry.label || '',
|
||||
amount: entry.amount || '',
|
||||
source: entry.source || '',
|
||||
campaign_id: entry.campaignId || entry.campaign_id || '',
|
||||
category: entry.category || 'marketing',
|
||||
date_received: entry.dateReceived || entry.date_received || '',
|
||||
notes: entry.notes || '',
|
||||
})
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
setEntryToDelete(id)
|
||||
setShowDeleteConfirm(true)
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!entryToDelete) return
|
||||
await api.delete(`/budget/${entryToDelete}`)
|
||||
setEntryToDelete(null)
|
||||
loadAll()
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4 animate-pulse">
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map(i => <div key={i} className="h-28 bg-surface-tertiary rounded-xl" />)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const s = summary || {}
|
||||
const totalReceived = s.totalReceived || 0
|
||||
const totalSpent = s.spent || 0
|
||||
const remaining = s.remaining || 0
|
||||
const totalRevenue = s.revenue || 0
|
||||
const roi = s.roi || 0
|
||||
const spendPct = totalReceived > 0 ? (totalSpent / totalReceived) * 100 : 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Top metrics */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<StatCard icon={Wallet} label="Total Received" value={`${totalReceived.toLocaleString()} SAR`} color="text-blue-600" />
|
||||
<StatCard icon={TrendingUp} label="Total Spent" value={`${totalSpent.toLocaleString()} SAR`} sub={`${spendPct.toFixed(1)}% of budget`} color="text-amber-600" />
|
||||
<StatCard icon={PiggyBank} label="Remaining" value={`${remaining.toLocaleString()} SAR`} color={remaining >= 0 ? 'text-emerald-600' : 'text-red-600'} />
|
||||
<StatCard icon={DollarSign} label="Revenue" value={`${totalRevenue.toLocaleString()} SAR`} color="text-purple-600" />
|
||||
<StatCard icon={roi >= 0 ? TrendingUp : TrendingDown} label="Global ROI"
|
||||
value={`${roi.toFixed(1)}%`}
|
||||
color={roi >= 0 ? 'text-emerald-600' : 'text-red-600'} />
|
||||
</div>
|
||||
|
||||
{/* Budget utilization + Global metrics */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{/* Utilization ring */}
|
||||
<div className="bg-white rounded-xl border border-border p-5 flex flex-col items-center justify-center">
|
||||
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">Budget Utilization</h3>
|
||||
<ProgressRing
|
||||
pct={spendPct}
|
||||
size={120}
|
||||
stroke={10}
|
||||
color={spendPct > 90 ? '#ef4444' : spendPct > 70 ? '#f59e0b' : '#10b981'}
|
||||
/>
|
||||
<div className="text-xs text-text-tertiary mt-3">
|
||||
{totalSpent.toLocaleString()} of {totalReceived.toLocaleString()} SAR
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Global performance */}
|
||||
<div className="bg-white rounded-xl border border-border p-5 lg:col-span-2">
|
||||
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">Global Performance</h3>
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<div className="text-center">
|
||||
<Eye className="w-5 h-5 mx-auto mb-1 text-purple-500" />
|
||||
<div className="text-xl font-bold text-text-primary">{(s.impressions || 0).toLocaleString()}</div>
|
||||
<div className="text-xs text-text-tertiary">Impressions</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<MousePointer className="w-5 h-5 mx-auto mb-1 text-blue-500" />
|
||||
<div className="text-xl font-bold text-text-primary">{(s.clicks || 0).toLocaleString()}</div>
|
||||
<div className="text-xs text-text-tertiary">Clicks</div>
|
||||
{s.clicks > 0 && s.spent > 0 && (
|
||||
<div className="text-[10px] text-text-tertiary mt-0.5">CPC: {(s.spent / s.clicks).toFixed(2)} SAR</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Target className="w-5 h-5 mx-auto mb-1 text-emerald-500" />
|
||||
<div className="text-xl font-bold text-text-primary">{(s.conversions || 0).toLocaleString()}</div>
|
||||
<div className="text-xs text-text-tertiary">Conversions</div>
|
||||
{s.conversions > 0 && s.spent > 0 && (
|
||||
<div className="text-[10px] text-text-tertiary mt-0.5">CPA: {(s.spent / s.conversions).toFixed(2)} SAR</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{s.impressions > 0 && s.clicks > 0 && (
|
||||
<div className="mt-4 pt-3 border-t border-border text-center">
|
||||
<span className="text-xs text-text-tertiary">
|
||||
CTR: {(s.clicks / s.impressions * 100).toFixed(2)}%
|
||||
{s.conversions > 0 && s.clicks > 0 && ` · Conv. Rate: ${(s.conversions / s.clicks * 100).toFixed(2)}%`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Per-campaign breakdown */}
|
||||
{s.campaigns && s.campaigns.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-border">
|
||||
<h3 className="font-semibold text-text-primary">Campaign Breakdown</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-secondary">
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-tertiary">Campaign</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Allocated</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Spent</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Revenue</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">ROI</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Impressions</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-text-tertiary">Clicks</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-light">
|
||||
{s.campaigns.map(c => {
|
||||
const cRoi = c.tracks_spent > 0 ? ((c.tracks_revenue - c.tracks_spent) / c.tracks_spent * 100) : 0
|
||||
return (
|
||||
<tr key={c.id} className="hover:bg-surface-secondary">
|
||||
<td className="px-4 py-3 font-medium text-text-primary">{c.name}</td>
|
||||
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_allocated.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_spent.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_revenue.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{c.tracks_spent > 0 ? (
|
||||
<span className={`text-xs font-semibold px-1.5 py-0.5 rounded ${cRoi >= 0 ? 'text-emerald-600 bg-emerald-50' : 'text-red-600 bg-red-50'}`}>
|
||||
{cRoi.toFixed(0)}%
|
||||
</span>
|
||||
) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_impressions.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-right text-text-secondary">{c.tracks_clicks.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-center"><StatusBadge status={c.status} size="xs" /></td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Budget entries */}
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
||||
<h3 className="font-semibold text-text-primary">Budget Received</h3>
|
||||
<button
|
||||
onClick={() => { setEditing(null); setForm(EMPTY_ENTRY); setShowModal(true) }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-brand-primary text-white rounded-lg text-xs font-medium hover:bg-brand-primary-light"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Add Entry
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-text-tertiary">
|
||||
No budget entries yet. Add your first received budget.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border-light">
|
||||
{entries.map(entry => (
|
||||
<div key={entry.id || entry._id} className="flex items-center gap-4 px-5 py-4 hover:bg-surface-secondary">
|
||||
<div className="p-2 rounded-lg bg-emerald-50">
|
||||
<DollarSign className="w-4 h-4 text-emerald-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<h4 className="text-sm font-semibold text-text-primary">{entry.label}</h4>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-tertiary capitalize">
|
||||
{entry.category}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-text-tertiary">
|
||||
{entry.source && <span>{entry.source} · </span>}
|
||||
{entry.campaign_name && <span>{entry.campaign_name} · </span>}
|
||||
{entry.date_received && format(new Date(entry.date_received), 'MMM d, yyyy')}
|
||||
</div>
|
||||
{entry.notes && <p className="text-xs text-text-secondary mt-0.5">{entry.notes}</p>}
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<div className="text-base font-bold text-emerald-600">{Number(entry.amount).toLocaleString()} SAR</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button onClick={() => openEdit(entry)} className="p-1.5 hover:bg-surface-tertiary rounded-lg text-text-tertiary hover:text-text-primary">
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(entry.id || entry._id)} className="p-1.5 hover:bg-red-50 rounded-lg text-text-tertiary hover:text-red-500">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add/Edit Modal */}
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => { setShowModal(false); setEditing(null) }}
|
||||
title={editing ? 'Edit Budget Entry' : 'Add Budget Entry'}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Label *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.label}
|
||||
onChange={e => setForm(f => ({ ...f, label: 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="e.g., Seerah Campaign Budget, Additional Q1 Funds..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Amount (SAR) *</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.amount}
|
||||
onChange={e => setForm(f => ({ ...f, amount: 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="50000"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Date Received *</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.date_received}
|
||||
onChange={e => setForm(f => ({ ...f, date_received: 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">Source</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.source}
|
||||
onChange={e => setForm(f => ({ ...f, source: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
||||
placeholder="e.g., CEO Approval, Annual Budget..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Category</label>
|
||||
<select
|
||||
value={form.category}
|
||||
onChange={e => setForm(f => ({ ...f, category: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
||||
>
|
||||
{CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Campaign (optional)</label>
|
||||
<select
|
||||
value={form.campaign_id}
|
||||
onChange={e => setForm(f => ({ ...f, campaign_id: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
||||
>
|
||||
<option value="">General / Not linked</option>
|
||||
{campaigns.map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Notes</label>
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={e => setForm(f => ({ ...f, notes: e.target.value }))}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none resize-none"
|
||||
placeholder="Any details about this budget entry..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-border">
|
||||
<button onClick={() => setShowModal(false)} className="px-4 py-2 text-sm text-text-secondary hover:bg-surface-tertiary rounded-lg">Cancel</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!form.label || !form.amount || !form.date_received}
|
||||
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"
|
||||
>
|
||||
{editing ? 'Save Changes' : 'Add Entry'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Budget Entry Confirmation */}
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => { setShowDeleteConfirm(false); setEntryToDelete(null) }}
|
||||
title="Delete Budget Entry?"
|
||||
isConfirm
|
||||
danger
|
||||
confirmText="Delete Entry"
|
||||
onConfirm={confirmDelete}
|
||||
>
|
||||
Are you sure you want to delete this budget entry? This action cannot be undone.
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
119
client/src/pages/Login.jsx
Normal file
119
client/src/pages/Login.jsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { Megaphone, Lock, Mail, AlertCircle } from 'lucide-react'
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate()
|
||||
const { login } = useAuth()
|
||||
const { t } = useLanguage()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
await login(email, password)
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Invalid email or password')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo & Title */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg">
|
||||
<Megaphone className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">{t('login.title')}</h1>
|
||||
<p className="text-slate-400">{t('login.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Login Card */}
|
||||
<div className="bg-slate-800/50 backdrop-blur-sm rounded-2xl border border-slate-700/50 p-8 shadow-2xl">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">
|
||||
{t('auth.email')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
dir="auto"
|
||||
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
placeholder="f.mahidi@samayainvest.com"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">
|
||||
{t('auth.password')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 shrink-0" />
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
{t('auth.signingIn')}
|
||||
</span>
|
||||
) : (
|
||||
t('auth.loginBtn')
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Default Credentials */}
|
||||
<div className="mt-6 pt-6 border-t border-slate-700/50">
|
||||
<p className="text-xs text-slate-500 text-center">
|
||||
{t('login.defaultCreds')} <span className="text-slate-400 font-medium">f.mahidi@samayainvest.com</span> / <span className="text-slate-400 font-medium">admin123</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
669
client/src/pages/PostProduction.jsx
Normal file
669
client/src/pages/PostProduction.jsx
Normal file
@@ -0,0 +1,669 @@
|
||||
import { useState, useEffect, useContext, useRef } from 'react'
|
||||
import { Plus, LayoutGrid, List, Search, Filter, Upload, X, Paperclip, Link2, FileText, Image as ImageIcon, Trash2, ExternalLink } from 'lucide-react'
|
||||
import { AppContext } from '../App'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api, PLATFORMS } from '../utils/api'
|
||||
import KanbanBoard from '../components/KanbanBoard'
|
||||
import PostCard from '../components/PostCard'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import Modal from '../components/Modal'
|
||||
|
||||
const EMPTY_POST = {
|
||||
title: '', description: '', brand_id: '', platforms: [],
|
||||
status: 'draft', assigned_to: '', scheduled_date: '', notes: '', campaign_id: '',
|
||||
publication_links: [],
|
||||
}
|
||||
|
||||
export default function PostProduction() {
|
||||
const { t } = useLanguage()
|
||||
const { teamMembers, brands } = useContext(AppContext)
|
||||
const { canEditResource, canDeleteResource } = useAuth()
|
||||
const [posts, setPosts] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [view, setView] = useState('kanban')
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editingPost, setEditingPost] = useState(null)
|
||||
const [formData, setFormData] = useState(EMPTY_POST)
|
||||
const [campaigns, setCampaigns] = useState([])
|
||||
const [filters, setFilters] = useState({ brand: '', platform: '', assignedTo: '', campaign: '' })
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [attachments, setAttachments] = useState([])
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const [publishError, setPublishError] = useState('')
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const fileInputRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadPosts()
|
||||
api.get('/campaigns').then(r => setCampaigns(Array.isArray(r) ? r : (r.data || []))).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const loadPosts = async () => {
|
||||
try {
|
||||
const res = await api.get('/posts')
|
||||
setPosts(res.data || res || [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load posts:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setPublishError('')
|
||||
try {
|
||||
const data = {
|
||||
title: formData.title,
|
||||
description: formData.description,
|
||||
brand_id: formData.brand_id ? Number(formData.brand_id) : null,
|
||||
assigned_to: formData.assigned_to ? Number(formData.assigned_to) : null,
|
||||
status: formData.status,
|
||||
platforms: formData.platforms || [],
|
||||
scheduled_date: formData.scheduled_date || null,
|
||||
notes: formData.notes,
|
||||
campaign_id: formData.campaign_id ? Number(formData.campaign_id) : null,
|
||||
publication_links: formData.publication_links || [],
|
||||
}
|
||||
|
||||
// Client-side validation: check publication links before publishing
|
||||
if (data.status === 'published' && data.platforms.length > 0) {
|
||||
const missingPlatforms = data.platforms.filter(platform => {
|
||||
const link = (data.publication_links || []).find(l => l.platform === platform)
|
||||
return !link || !link.url || !link.url.trim()
|
||||
})
|
||||
if (missingPlatforms.length > 0) {
|
||||
const names = missingPlatforms.map(p => PLATFORMS[p]?.label || p).join(', ')
|
||||
setPublishError(`${t('posts.publishMissing')} ${names}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (editingPost) {
|
||||
await api.patch(`/posts/${editingPost._id}`, data)
|
||||
} else {
|
||||
await api.post('/posts', data)
|
||||
}
|
||||
setShowModal(false)
|
||||
setEditingPost(null)
|
||||
setFormData(EMPTY_POST)
|
||||
setAttachments([])
|
||||
loadPosts()
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err)
|
||||
if (err.message?.includes('Cannot publish')) {
|
||||
setPublishError(err.message.replace(/.*: /, ''))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleMovePost = async (postId, newStatus) => {
|
||||
try {
|
||||
await api.patch(`/posts/${postId}`, { status: newStatus })
|
||||
loadPosts()
|
||||
} catch (err) {
|
||||
console.error('Move failed:', err)
|
||||
if (err.message?.includes('Cannot publish')) {
|
||||
alert('Cannot publish: all platform publication links must be filled first.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadAttachments = async (postId) => {
|
||||
try {
|
||||
const data = await api.get(`/posts/${postId}/attachments`)
|
||||
setAttachments(Array.isArray(data) ? data : (data.data || []))
|
||||
} catch (err) {
|
||||
console.error('Failed to load attachments:', err)
|
||||
setAttachments([])
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileUpload = async (files) => {
|
||||
if (!editingPost || !files?.length) return
|
||||
setUploading(true)
|
||||
setUploadProgress(0)
|
||||
const postId = editingPost._id || editingPost.id
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', files[i])
|
||||
try {
|
||||
await api.upload(`/posts/${postId}/attachments`, fd)
|
||||
setUploadProgress(Math.round(((i + 1) / files.length) * 100))
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
setUploading(false)
|
||||
setUploadProgress(0)
|
||||
loadAttachments(postId)
|
||||
}
|
||||
|
||||
const handleDeleteAttachment = async (attachmentId) => {
|
||||
try {
|
||||
await api.delete(`/attachments/${attachmentId}`)
|
||||
if (editingPost) loadAttachments(editingPost._id || editingPost.id)
|
||||
} catch (err) {
|
||||
console.error('Delete attachment failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnter = (e) => { e.preventDefault(); e.stopPropagation(); setDragActive(true) }
|
||||
const handleDragLeaveZone = (e) => { e.preventDefault(); e.stopPropagation(); setDragActive(false) }
|
||||
const handleDragOverZone = (e) => { e.preventDefault(); e.stopPropagation() }
|
||||
const handleDropFiles = (e) => {
|
||||
e.preventDefault(); e.stopPropagation(); setDragActive(false)
|
||||
if (e.dataTransfer.files?.length) handleFileUpload(e.dataTransfer.files)
|
||||
}
|
||||
|
||||
const updatePublicationLink = (platform, url) => {
|
||||
setFormData(f => {
|
||||
const links = [...(f.publication_links || [])]
|
||||
const idx = links.findIndex(l => l.platform === platform)
|
||||
if (idx >= 0) {
|
||||
links[idx] = { ...links[idx], url }
|
||||
} else {
|
||||
links.push({ platform, url })
|
||||
}
|
||||
return { ...f, publication_links: links }
|
||||
})
|
||||
}
|
||||
|
||||
const openEdit = (post) => {
|
||||
if (!canEditResource('post', post)) {
|
||||
alert('You can only edit your own posts')
|
||||
return
|
||||
}
|
||||
setEditingPost(post)
|
||||
setPublishError('')
|
||||
setFormData({
|
||||
title: post.title || '',
|
||||
description: post.description || '',
|
||||
brand_id: post.brandId || post.brand_id || '',
|
||||
platforms: post.platforms || (post.platform ? [post.platform] : []),
|
||||
status: post.status || 'draft',
|
||||
assigned_to: post.assignedTo || post.assigned_to || '',
|
||||
scheduled_date: post.scheduledDate ? new Date(post.scheduledDate).toISOString().slice(0, 16) : '',
|
||||
notes: post.notes || '',
|
||||
campaign_id: post.campaignId || post.campaign_id || '',
|
||||
publication_links: post.publication_links || post.publicationLinks || [],
|
||||
})
|
||||
loadAttachments(post._id || post.id)
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const openNew = () => {
|
||||
setEditingPost(null)
|
||||
setFormData(EMPTY_POST)
|
||||
setAttachments([])
|
||||
setPublishError('')
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const filteredPosts = posts.filter(p => {
|
||||
if (filters.brand && String(p.brandId || p.brand_id) !== filters.brand) return false
|
||||
if (filters.platform && !(p.platforms || []).includes(filters.platform) && p.platform !== filters.platform) return false
|
||||
if (filters.assignedTo && String(p.assignedTo || p.assigned_to) !== filters.assignedTo) return false
|
||||
if (filters.campaign && String(p.campaignId || p.campaign_id) !== filters.campaign) return false
|
||||
if (searchTerm && !p.title?.toLowerCase().includes(searchTerm.toLowerCase())) return false
|
||||
return true
|
||||
})
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4 animate-pulse">
|
||||
<div className="h-12 bg-surface-tertiary rounded-xl"></div>
|
||||
<div className="flex gap-4">
|
||||
{[...Array(5)].map((_, i) => <div key={i} className="w-72 h-96 bg-surface-tertiary rounded-xl"></div>)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('posts.searchPosts')}
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div data-tutorial="filters" className="flex gap-3">
|
||||
<select
|
||||
value={filters.brand}
|
||||
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
|
||||
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">{t('posts.allBrands')}</option>
|
||||
{brands.map(b => <option key={b._id} value={b._id}>{b.name}</option>)}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filters.platform}
|
||||
onChange={e => setFilters(f => ({ ...f, platform: e.target.value }))}
|
||||
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">{t('posts.allPlatforms')}</option>
|
||||
{Object.entries(PLATFORMS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filters.assignedTo}
|
||||
onChange={e => setFilters(f => ({ ...f, assignedTo: e.target.value }))}
|
||||
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">{t('posts.allPeople')}</option>
|
||||
{teamMembers.map(m => <option key={m._id} value={String(m._id)}>{m.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* View toggle */}
|
||||
<div className="flex bg-surface-tertiary rounded-lg p-0.5 ml-auto">
|
||||
<button
|
||||
onClick={() => setView('kanban')}
|
||||
className={`p-2 rounded-md ${view === 'kanban' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('list')}
|
||||
className={`p-2 rounded-md ${view === 'list' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* New post */}
|
||||
<button
|
||||
data-tutorial="new-post"
|
||||
onClick={openNew}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('posts.newPost')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{view === 'kanban' ? (
|
||||
<KanbanBoard posts={filteredPosts} onPostClick={openEdit} onMovePost={handleMovePost} />
|
||||
) : (
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-secondary">
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.postTitle')}</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.brand')}</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.status')}</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.platforms')}</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.assignTo')}</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.scheduledDate')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-light">
|
||||
{filteredPosts.map(post => (
|
||||
<PostCard key={post._id} post={post} onClick={() => openEdit(post)} />
|
||||
))}
|
||||
{filteredPosts.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-12 text-center text-sm text-text-tertiary">
|
||||
{t('posts.noPostsFound')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => { setShowModal(false); setEditingPost(null) }}
|
||||
title={editingPost ? t('posts.editPost') : t('posts.createPost')}
|
||||
size="lg"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.postTitle')} *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={e => setFormData(f => ({ ...f, title: 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={t('posts.postTitlePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.description')}</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={e => setFormData(f => ({ ...f, description: e.target.value }))}
|
||||
rows={4}
|
||||
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={t('posts.postDescPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Campaign */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.campaign')}</label>
|
||||
<select
|
||||
value={formData.campaign_id}
|
||||
onChange={e => setFormData(f => ({ ...f, campaign_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="">{t('posts.noCampaign')}</option>
|
||||
{campaigns.map(c => <option key={c._id} value={c._id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.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="">{t('posts.selectBrand')}</option>
|
||||
{brands.map(b => <option key={b._id} value={b._id}>{b.icon} {b.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.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>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.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="draft">{t('posts.status.draft')}</option>
|
||||
<option value="in_review">{t('posts.status.in_review')}</option>
|
||||
<option value="approved">{t('posts.status.approved')}</option>
|
||||
<option value="scheduled">{t('posts.status.scheduled')}</option>
|
||||
<option value="published">{t('posts.status.published')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.assignTo')}</label>
|
||||
<select
|
||||
value={formData.assigned_to}
|
||||
onChange={e => setFormData(f => ({ ...f, assigned_to: 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="">{t('common.unassigned')}</option>
|
||||
{teamMembers.map(m => <option key={m._id} value={m._id}>{m.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.scheduledDate')}</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={formData.scheduled_date}
|
||||
onChange={e => setFormData(f => ({ ...f, scheduled_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">{t('posts.notes')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.notes}
|
||||
onChange={e => setFormData(f => ({ ...f, notes: 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={t('posts.additionalNotes')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Publication Links */}
|
||||
{(formData.platforms || []).length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Link2 className="w-4 h-4" />
|
||||
{t('posts.publicationLinks')}
|
||||
</span>
|
||||
</label>
|
||||
<div className="space-y-2 border border-border rounded-lg p-3 bg-surface-secondary">
|
||||
{(formData.platforms || []).map(platformKey => {
|
||||
const platformInfo = PLATFORMS[platformKey] || { label: platformKey }
|
||||
const existingLink = (formData.publication_links || []).find(l => l.platform === platformKey)
|
||||
const linkUrl = existingLink?.url || ''
|
||||
return (
|
||||
<div key={platformKey} className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-text-secondary w-24 shrink-0 flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: platformInfo.color || '#888' }} />
|
||||
{platformInfo.label}
|
||||
</span>
|
||||
<input
|
||||
type="url"
|
||||
value={linkUrl}
|
||||
onChange={e => updatePublicationLink(platformKey, e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
{linkUrl && (
|
||||
<a href={linkUrl} target="_blank" rel="noopener noreferrer" className="p-1.5 text-text-tertiary hover:text-brand-primary">
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{formData.status === 'published' && (formData.platforms || []).some(p => {
|
||||
const link = (formData.publication_links || []).find(l => l.platform === p)
|
||||
return !link || !link.url?.trim()
|
||||
}) && (
|
||||
<p className="text-xs text-amber-600 mt-1">⚠️ {t('posts.publishRequired')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attachments (only for existing posts) */}
|
||||
{editingPost && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Paperclip className="w-4 h-4" />
|
||||
{t('posts.attachments')}
|
||||
<span className="text-xs text-text-tertiary font-normal">({attachments.length})</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Existing attachments */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mb-3">
|
||||
{attachments.map(att => {
|
||||
const isImage = att.mime_type?.startsWith('image/') || att.mimeType?.startsWith('image/')
|
||||
const attUrl = att.url || `/api/uploads/${att.filename}`
|
||||
const name = att.original_name || att.originalName || att.filename
|
||||
return (
|
||||
<div key={att.id || att._id} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
|
||||
{isImage ? (
|
||||
<a href={`http://localhost:3001${attUrl}`} target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
src={`http://localhost:3001${attUrl}`}
|
||||
alt={name}
|
||||
className="w-full h-24 object-cover"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<a href={`http://localhost:3001${attUrl}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 p-3 h-24">
|
||||
<FileText className="w-8 h-8 text-text-tertiary shrink-0" />
|
||||
<span className="text-xs text-text-secondary truncate">{name}</span>
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDeleteAttachment(att.id || att._id)}
|
||||
className="absolute top-1 right-1 p-1 bg-red-500 text-white rounded-full opacity-0 group-hover/att:opacity-100 transition-opacity shadow-sm"
|
||||
title={t('posts.deleteAttachment')}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
<div className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">
|
||||
{name}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload area */}
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors ${
|
||||
dragActive ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/40'
|
||||
}`}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeaveZone}
|
||||
onDragOver={handleDragOverZone}
|
||||
onDrop={handleDropFiles}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => handleFileUpload(e.target.files)}
|
||||
/>
|
||||
<Upload className="w-6 h-6 text-text-tertiary mx-auto mb-1" />
|
||||
<p className="text-xs text-text-secondary">
|
||||
{dragActive ? t('posts.dropFiles') : t('posts.uploadFiles')}
|
||||
</p>
|
||||
<p className="text-[10px] text-text-tertiary mt-0.5">{t('posts.maxSize')}</p>
|
||||
</div>
|
||||
|
||||
{/* Upload progress */}
|
||||
{uploading && (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center justify-between text-xs text-text-secondary mb-1">
|
||||
<span>{t('posts.uploading')}</span>
|
||||
<span>{uploadProgress}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-surface-tertiary rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-brand-primary h-1.5 rounded-full transition-all"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Publish validation error */}
|
||||
{publishError && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{publishError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||
{editingPost && canDeleteResource('post', editingPost) && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg mr-auto"
|
||||
>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setShowModal(false); setEditingPost(null) }}
|
||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!formData.title}
|
||||
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"
|
||||
>
|
||||
{editingPost ? t('posts.saveChanges') : t('posts.createPost')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => setShowDeleteConfirm(false)}
|
||||
title={t('posts.deletePost')}
|
||||
isConfirm
|
||||
danger
|
||||
confirmText={t('posts.deletePost')}
|
||||
onConfirm={async () => {
|
||||
if (editingPost) {
|
||||
try {
|
||||
await api.delete(`/posts/${editingPost._id || editingPost.id}`)
|
||||
setShowModal(false)
|
||||
setEditingPost(null)
|
||||
loadPosts()
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err)
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('posts.deleteConfirm')}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
777
client/src/pages/ProjectDetail.jsx
Normal file
777
client/src/pages/ProjectDetail.jsx
Normal file
@@ -0,0 +1,777 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
ArrowLeft, Plus, Check, Trash2, Edit3, LayoutGrid, List,
|
||||
GanttChart, Settings, Calendar, Clock
|
||||
} from 'lucide-react'
|
||||
import { format, differenceInDays, startOfDay, addDays, isAfter, isBefore, parseISO } from 'date-fns'
|
||||
import { AppContext } from '../App'
|
||||
import { api, PRIORITY_CONFIG } from '../utils/api'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import BrandBadge from '../components/BrandBadge'
|
||||
import Modal from '../components/Modal'
|
||||
|
||||
const TASK_COLUMNS = [
|
||||
{ id: 'todo', label: 'To Do', color: 'bg-gray-400' },
|
||||
{ id: 'in_progress', label: 'In Progress', color: 'bg-blue-400' },
|
||||
{ id: 'done', label: 'Done', color: 'bg-emerald-400' },
|
||||
]
|
||||
|
||||
export default function ProjectDetail() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { teamMembers, brands } = useContext(AppContext)
|
||||
const [project, setProject] = useState(null)
|
||||
const [tasks, setTasks] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [view, setView] = useState('kanban')
|
||||
const [showTaskModal, setShowTaskModal] = useState(false)
|
||||
const [showProjectModal, setShowProjectModal] = useState(false)
|
||||
const [editingTask, setEditingTask] = useState(null)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [taskToDelete, setTaskToDelete] = useState(null)
|
||||
const [taskForm, setTaskForm] = useState({
|
||||
title: '', description: '', priority: 'medium', assigned_to: '', due_date: '', status: 'todo'
|
||||
})
|
||||
const [projectForm, setProjectForm] = useState({
|
||||
name: '', description: '', brand_id: '', owner_id: '', status: 'active', due_date: ''
|
||||
})
|
||||
|
||||
// Drag state for kanban
|
||||
const [draggedTask, setDraggedTask] = useState(null)
|
||||
const [dragOverCol, setDragOverCol] = useState(null)
|
||||
|
||||
useEffect(() => { loadProject() }, [id])
|
||||
|
||||
const loadProject = async () => {
|
||||
try {
|
||||
const proj = await api.get(`/projects/${id}`)
|
||||
setProject(proj.data || proj)
|
||||
const tasksRes = await api.get(`/tasks?project_id=${id}`)
|
||||
setTasks(Array.isArray(tasksRes) ? tasksRes : (tasksRes.data || []))
|
||||
} catch (err) {
|
||||
console.error('Failed to load project:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTaskSave = async () => {
|
||||
try {
|
||||
const data = {
|
||||
title: taskForm.title,
|
||||
description: taskForm.description,
|
||||
priority: taskForm.priority,
|
||||
assigned_to: taskForm.assigned_to ? Number(taskForm.assigned_to) : null,
|
||||
due_date: taskForm.due_date || null,
|
||||
status: taskForm.status,
|
||||
project_id: Number(id),
|
||||
}
|
||||
if (editingTask) {
|
||||
await api.patch(`/tasks/${editingTask._id}`, data)
|
||||
} else {
|
||||
await api.post('/tasks', data)
|
||||
}
|
||||
setShowTaskModal(false)
|
||||
setEditingTask(null)
|
||||
setTaskForm({ title: '', description: '', priority: 'medium', assigned_to: '', due_date: '', status: 'todo' })
|
||||
loadProject()
|
||||
} catch (err) {
|
||||
console.error('Task save failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTaskStatusChange = async (taskId, newStatus) => {
|
||||
try {
|
||||
await api.patch(`/tasks/${taskId}`, { status: newStatus })
|
||||
loadProject()
|
||||
} catch (err) {
|
||||
console.error('Status change failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteTask = async (taskId) => {
|
||||
setTaskToDelete(taskId)
|
||||
setShowDeleteConfirm(true)
|
||||
}
|
||||
|
||||
const confirmDeleteTask = async () => {
|
||||
if (!taskToDelete) return
|
||||
try {
|
||||
await api.delete(`/tasks/${taskToDelete}`)
|
||||
loadProject()
|
||||
setTaskToDelete(null)
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const openEditTask = (task) => {
|
||||
setEditingTask(task)
|
||||
setTaskForm({
|
||||
title: task.title || '',
|
||||
description: task.description || '',
|
||||
priority: task.priority || 'medium',
|
||||
assigned_to: task.assignedTo || task.assigned_to || '',
|
||||
due_date: task.dueDate ? new Date(task.dueDate).toISOString().slice(0, 10) : '',
|
||||
status: task.status || 'todo',
|
||||
})
|
||||
setShowTaskModal(true)
|
||||
}
|
||||
|
||||
const openNewTask = () => {
|
||||
setEditingTask(null)
|
||||
setTaskForm({ title: '', description: '', priority: 'medium', assigned_to: '', due_date: '', status: 'todo' })
|
||||
setShowTaskModal(true)
|
||||
}
|
||||
|
||||
const openEditProject = () => {
|
||||
if (!project) return
|
||||
setProjectForm({
|
||||
name: project.name || '',
|
||||
description: project.description || '',
|
||||
brand_id: project.brandId || project.brand_id || '',
|
||||
owner_id: project.ownerId || project.owner_id || '',
|
||||
status: project.status || 'active',
|
||||
due_date: project.dueDate ? new Date(project.dueDate).toISOString().slice(0, 10) : '',
|
||||
})
|
||||
setShowProjectModal(true)
|
||||
}
|
||||
|
||||
const handleProjectSave = async () => {
|
||||
try {
|
||||
await api.patch(`/projects/${id}`, {
|
||||
name: projectForm.name,
|
||||
description: projectForm.description,
|
||||
brand_id: projectForm.brand_id ? Number(projectForm.brand_id) : null,
|
||||
owner_id: projectForm.owner_id ? Number(projectForm.owner_id) : null,
|
||||
status: projectForm.status,
|
||||
due_date: projectForm.due_date || null,
|
||||
})
|
||||
setShowProjectModal(false)
|
||||
loadProject()
|
||||
} catch (err) {
|
||||
console.error('Project save failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Drag handlers
|
||||
const handleDragStart = (e, task) => {
|
||||
setDraggedTask(task)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
setTimeout(() => { e.target.style.opacity = '0.4' }, 0)
|
||||
}
|
||||
const handleDragEnd = (e) => {
|
||||
e.target.style.opacity = '1'
|
||||
setDraggedTask(null)
|
||||
setDragOverCol(null)
|
||||
}
|
||||
const handleDragOver = (e, colId) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
setDragOverCol(colId)
|
||||
}
|
||||
const handleDragLeave = (e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) setDragOverCol(null)
|
||||
}
|
||||
const handleDrop = (e, colId) => {
|
||||
e.preventDefault()
|
||||
setDragOverCol(null)
|
||||
if (draggedTask && draggedTask.status !== colId) {
|
||||
handleTaskStatusChange(draggedTask._id, colId)
|
||||
}
|
||||
setDraggedTask(null)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-8 w-48 bg-surface-tertiary rounded-lg"></div>
|
||||
<div className="h-40 bg-surface-tertiary rounded-xl"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="py-20 text-center">
|
||||
<p className="text-text-secondary">Project not found</p>
|
||||
<button onClick={() => navigate('/projects')} className="mt-4 text-brand-primary hover:underline text-sm">
|
||||
Back to Projects
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const completedTasks = tasks.filter(t => t.status === 'done').length
|
||||
const progress = tasks.length > 0 ? Math.round((completedTasks / tasks.length) * 100) : 0
|
||||
const ownerName = project.ownerName || project.owner_name
|
||||
const brandName = project.brandName || project.brand_name
|
||||
|
||||
// Gantt chart helpers
|
||||
const getGanttRange = () => {
|
||||
const today = startOfDay(new Date())
|
||||
let earliest = today
|
||||
let latest = addDays(today, 14)
|
||||
|
||||
tasks.forEach(t => {
|
||||
if (t.createdAt) {
|
||||
const d = startOfDay(new Date(t.createdAt))
|
||||
if (isBefore(d, earliest)) earliest = d
|
||||
}
|
||||
if (t.dueDate) {
|
||||
const d = startOfDay(new Date(t.dueDate))
|
||||
if (isAfter(d, latest)) latest = addDays(d, 1)
|
||||
}
|
||||
})
|
||||
if (project.dueDate) {
|
||||
const d = startOfDay(new Date(project.dueDate))
|
||||
if (isAfter(d, latest)) latest = addDays(d, 1)
|
||||
}
|
||||
// Ensure minimum 14 days
|
||||
if (differenceInDays(latest, earliest) < 14) latest = addDays(earliest, 14)
|
||||
return { earliest, latest, totalDays: differenceInDays(latest, earliest) + 1 }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Back button */}
|
||||
<button
|
||||
onClick={() => navigate('/projects')}
|
||||
className="flex items-center gap-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to Projects
|
||||
</button>
|
||||
|
||||
{/* Project header */}
|
||||
<div className="bg-white rounded-xl border border-border p-6">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="text-2xl font-bold text-text-primary">{project.name}</h1>
|
||||
<StatusBadge status={project.status} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{brandName && <BrandBadge brand={brandName} />}
|
||||
{ownerName && (
|
||||
<span className="text-sm text-text-secondary">
|
||||
Owned by <span className="font-medium">{ownerName}</span>
|
||||
</span>
|
||||
)}
|
||||
{project.dueDate && (
|
||||
<span className="text-sm text-text-tertiary flex items-center gap-1">
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
Due {format(new Date(project.dueDate), 'MMMM d, yyyy')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={openEditProject}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-text-secondary hover:text-text-primary hover:bg-surface-tertiary rounded-lg transition-colors"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{project.description && (
|
||||
<p className="text-sm text-text-secondary mb-4">{project.description}</p>
|
||||
)}
|
||||
|
||||
{/* Progress */}
|
||||
<div className="max-w-md">
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className="text-text-secondary font-medium">Progress</span>
|
||||
<span className="font-semibold text-text-primary">{progress}%</span>
|
||||
</div>
|
||||
<div className="h-2.5 bg-surface-tertiary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-brand-primary to-brand-primary-light rounded-full transition-all duration-500"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-text-tertiary mt-1">{completedTasks} of {tasks.length} tasks completed</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* View switcher + Add Task */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 bg-surface-tertiary rounded-lg p-0.5">
|
||||
{[
|
||||
{ id: 'kanban', icon: LayoutGrid, label: 'Board' },
|
||||
{ id: 'list', icon: List, label: 'List' },
|
||||
{ id: 'gantt', icon: GanttChart, label: 'Timeline' },
|
||||
].map(v => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => setView(v.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
view === v.id ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
<v.icon className="w-4 h-4" />
|
||||
{v.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={openNewTask}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Task
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ─── KANBAN VIEW ─── */}
|
||||
{view === 'kanban' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{TASK_COLUMNS.map(col => {
|
||||
const colTasks = tasks.filter(t => t.status === col.id)
|
||||
const isOver = dragOverCol === col.id && draggedTask?.status !== col.id
|
||||
return (
|
||||
<div key={col.id}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${col.color}`} />
|
||||
<h4 className="text-sm font-semibold text-text-primary">{col.label}</h4>
|
||||
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-2 py-0.5 rounded-full">
|
||||
{colTasks.length}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`rounded-xl p-2 space-y-2 min-h-[150px] border-2 transition-colors ${
|
||||
isOver
|
||||
? 'bg-brand-primary/5 border-brand-primary/40 border-dashed'
|
||||
: 'bg-surface-secondary border-border-light border-solid'
|
||||
}`}
|
||||
onDragOver={(e) => handleDragOver(e, col.id)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, col.id)}
|
||||
>
|
||||
{colTasks.length === 0 ? (
|
||||
<div className={`py-8 text-center text-xs ${isOver ? 'text-brand-primary font-medium' : 'text-text-tertiary'}`}>
|
||||
{isOver ? 'Drop here' : 'No tasks'}
|
||||
</div>
|
||||
) : (
|
||||
colTasks.map(task => (
|
||||
<TaskKanbanCard
|
||||
key={task._id}
|
||||
task={task}
|
||||
onEdit={() => openEditTask(task)}
|
||||
onDelete={() => handleDeleteTask(task._id)}
|
||||
onStatusChange={handleTaskStatusChange}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── LIST VIEW ─── */}
|
||||
{view === 'list' && (
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-secondary">
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider w-8"></th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Task</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Status</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Priority</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Assignee</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Due</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider w-16"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-light">
|
||||
{tasks.length === 0 ? (
|
||||
<tr><td colSpan={7} className="py-12 text-center text-sm text-text-tertiary">No tasks yet</td></tr>
|
||||
) : (
|
||||
tasks.map(task => {
|
||||
const prio = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
|
||||
const assigneeName = task.assignedName || task.assigned_name
|
||||
const isOverdue = task.dueDate && new Date(task.dueDate) < new Date() && task.status !== 'done'
|
||||
return (
|
||||
<tr key={task._id} className="hover:bg-surface-secondary group">
|
||||
<td className="px-4 py-3">
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${prio.color}`} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button onClick={() => openEditTask(task)} className="text-sm font-medium text-text-primary hover:text-brand-primary text-left">
|
||||
{task.title}
|
||||
</button>
|
||||
{task.description && <p className="text-xs text-text-tertiary line-clamp-1 mt-0.5">{task.description}</p>}
|
||||
</td>
|
||||
<td className="px-4 py-3"><StatusBadge status={task.status} size="xs" /></td>
|
||||
<td className="px-4 py-3 text-xs font-medium text-text-secondary capitalize">{prio.label}</td>
|
||||
<td className="px-4 py-3 text-xs text-text-secondary">{assigneeName || '—'}</td>
|
||||
<td className={`px-4 py-3 text-xs ${isOverdue ? 'text-red-500 font-medium' : 'text-text-tertiary'}`}>
|
||||
{task.dueDate ? format(new Date(task.dueDate), 'MMM d, yyyy') : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={() => openEditTask(task)} className="p-1 rounded hover:bg-surface-tertiary text-text-tertiary">
|
||||
<Edit3 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => handleDeleteTask(task._id)} className="p-1 rounded hover:bg-red-50 text-red-400">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── GANTT / TIMELINE VIEW ─── */}
|
||||
{view === 'gantt' && <GanttView tasks={tasks} project={project} onEditTask={openEditTask} />}
|
||||
|
||||
{/* ─── TASK MODAL ─── */}
|
||||
<Modal
|
||||
isOpen={showTaskModal}
|
||||
onClose={() => { setShowTaskModal(false); setEditingTask(null) }}
|
||||
title={editingTask ? 'Edit Task' : 'Add Task'}
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Title *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={taskForm.title}
|
||||
onChange={e => setTaskForm(f => ({ ...f, title: 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="Task title"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Description</label>
|
||||
<textarea
|
||||
value={taskForm.description}
|
||||
onChange={e => setTaskForm(f => ({ ...f, description: 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="Optional description"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Priority</label>
|
||||
<select value={taskForm.priority} onChange={e => setTaskForm(f => ({ ...f, priority: 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="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="urgent">Urgent</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Status</label>
|
||||
<select value={taskForm.status} onChange={e => setTaskForm(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="todo">To Do</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="done">Done</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Assign To</label>
|
||||
<select value={taskForm.assigned_to} onChange={e => setTaskForm(f => ({ ...f, assigned_to: 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="">Unassigned</option>
|
||||
{teamMembers.map(m => <option key={m._id} value={m._id}>{m.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Due Date</label>
|
||||
<input type="date" value={taskForm.due_date} onChange={e => setTaskForm(f => ({ ...f, due_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="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||
{editingTask && (
|
||||
<button onClick={() => handleDeleteTask(editingTask._id)}
|
||||
className="px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg mr-auto">
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => { setShowTaskModal(false); setEditingTask(null) }}
|
||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg">
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={handleTaskSave} disabled={!taskForm.title}
|
||||
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">
|
||||
{editingTask ? 'Save Changes' : 'Add Task'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* ─── PROJECT EDIT MODAL ─── */}
|
||||
<Modal
|
||||
isOpen={showProjectModal}
|
||||
onClose={() => setShowProjectModal(false)}
|
||||
title="Edit Project"
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Name *</label>
|
||||
<input type="text" value={projectForm.name} onChange={e => setProjectForm(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="Project name" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Description</label>
|
||||
<textarea value={projectForm.description} onChange={e => setProjectForm(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="Project 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={projectForm.brand_id} onChange={e => setProjectForm(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} value={b._id}>{b.icon} {b.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Status</label>
|
||||
<select value={projectForm.status} onChange={e => setProjectForm(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="active">Active</option>
|
||||
<option value="paused">Paused</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Owner</label>
|
||||
<select value={projectForm.owner_id} onChange={e => setProjectForm(f => ({ ...f, owner_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="">Unassigned</option>
|
||||
{teamMembers.map(m => <option key={m._id} value={m._id}>{m.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Due Date</label>
|
||||
<input type="date" value={projectForm.due_date} onChange={e => setProjectForm(f => ({ ...f, due_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="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||
<button onClick={() => setShowProjectModal(false)}
|
||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg">
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={handleProjectSave} disabled={!projectForm.name}
|
||||
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">
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Task Kanban Card ───────────────────────────────
|
||||
function TaskKanbanCard({ task, onEdit, onDelete, onStatusChange, onDragStart, onDragEnd }) {
|
||||
const priority = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
|
||||
const assigneeName = task.assignedName || task.assigned_name
|
||||
const isOverdue = task.dueDate && new Date(task.dueDate) < new Date() && task.status !== 'done'
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, task)}
|
||||
onDragEnd={onDragEnd}
|
||||
className="bg-white rounded-lg border border-border p-3 group hover:border-brand-primary/30 hover:shadow-sm transition-all cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${priority.color} mt-1.5 shrink-0`} title={priority.label} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h5 className={`text-sm font-medium ${task.status === 'done' ? 'text-text-tertiary line-through' : 'text-text-primary'}`}>
|
||||
{task.title}
|
||||
</h5>
|
||||
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
|
||||
{assigneeName && (
|
||||
<span className="text-[10px] text-text-tertiary">{assigneeName}</span>
|
||||
)}
|
||||
{task.dueDate && (
|
||||
<span className={`text-[10px] flex items-center gap-0.5 ${isOverdue ? 'text-red-500 font-medium' : 'text-text-tertiary'}`}>
|
||||
<Clock className="w-3 h-3" />
|
||||
{format(new Date(task.dueDate), 'MMM d')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Actions on hover */}
|
||||
<div className="flex items-center gap-1 mt-2 pt-2 border-t border-border-light opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{task.status !== 'done' && (
|
||||
<button onClick={() => onStatusChange(task._id, task.status === 'todo' ? 'in_progress' : 'done')}
|
||||
className="text-[10px] text-brand-primary hover:bg-brand-primary/10 px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
<Check className="w-3 h-3" />
|
||||
{task.status === 'todo' ? 'Start' : 'Complete'}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onEdit}
|
||||
className="text-[10px] text-text-tertiary hover:bg-surface-tertiary px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
<Edit3 className="w-3 h-3" /> Edit
|
||||
</button>
|
||||
<button onClick={onDelete}
|
||||
className="text-[10px] text-red-400 hover:bg-red-50 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Gantt / Timeline View ──────────────────────────
|
||||
function GanttView({ tasks, project, onEditTask }) {
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-border py-16 text-center">
|
||||
<GanttChart className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
|
||||
<p className="text-text-secondary font-medium">No tasks to display</p>
|
||||
<p className="text-sm text-text-tertiary mt-1">Add tasks with due dates to see the timeline</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const today = startOfDay(new Date())
|
||||
|
||||
// Calculate range
|
||||
let earliest = today
|
||||
let latest = addDays(today, 21)
|
||||
tasks.forEach(t => {
|
||||
const created = t.createdAt ? startOfDay(new Date(t.createdAt)) : today
|
||||
const due = t.dueDate ? startOfDay(new Date(t.dueDate)) : null
|
||||
if (isBefore(created, earliest)) earliest = created
|
||||
if (due && isAfter(due, latest)) latest = addDays(due, 2)
|
||||
})
|
||||
if (project.dueDate) {
|
||||
const pd = startOfDay(new Date(project.dueDate))
|
||||
if (isAfter(pd, latest)) latest = addDays(pd, 2)
|
||||
}
|
||||
const totalDays = differenceInDays(latest, earliest) + 1
|
||||
|
||||
// Generate day headers
|
||||
const days = []
|
||||
for (let i = 0; i < totalDays; i++) {
|
||||
days.push(addDays(earliest, i))
|
||||
}
|
||||
|
||||
const dayWidth = Math.max(36, Math.min(60, 800 / totalDays))
|
||||
|
||||
const getBarStyle = (task) => {
|
||||
const start = task.createdAt ? startOfDay(new Date(task.createdAt)) : today
|
||||
const end = task.dueDate ? startOfDay(new Date(task.dueDate)) : addDays(start, 3)
|
||||
const left = differenceInDays(start, earliest) * dayWidth
|
||||
const width = Math.max(dayWidth, (differenceInDays(end, start) + 1) * dayWidth)
|
||||
return { left: `${left}px`, width: `${width}px` }
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
todo: 'bg-gray-300',
|
||||
in_progress: 'bg-blue-400',
|
||||
done: 'bg-emerald-400',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<div style={{ minWidth: `${totalDays * dayWidth + 200}px` }}>
|
||||
{/* Day headers */}
|
||||
<div className="flex border-b border-border bg-surface-secondary sticky top-0 z-10">
|
||||
<div className="w-[200px] shrink-0 px-4 py-2 text-xs font-semibold text-text-tertiary uppercase border-r border-border">
|
||||
Task
|
||||
</div>
|
||||
<div className="flex">
|
||||
{days.map((day, i) => {
|
||||
const isToday = differenceInDays(day, today) === 0
|
||||
const isWeekend = day.getDay() === 0 || day.getDay() === 6
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{ width: `${dayWidth}px` }}
|
||||
className={`text-center py-2 border-r border-border-light text-[10px] ${
|
||||
isToday ? 'bg-brand-primary/10 font-bold text-brand-primary' :
|
||||
isWeekend ? 'bg-surface-tertiary/50 text-text-tertiary' : 'text-text-tertiary'
|
||||
}`}
|
||||
>
|
||||
<div>{format(day, 'd')}</div>
|
||||
<div className="text-[8px] uppercase">{format(day, 'EEE')}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task rows */}
|
||||
{tasks.map(task => {
|
||||
const prio = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
|
||||
const barStyle = getBarStyle(task)
|
||||
return (
|
||||
<div key={task._id} className="flex border-b border-border-light hover:bg-surface-secondary/50 group">
|
||||
<div className="w-[200px] shrink-0 px-4 py-3 border-r border-border flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${prio.color} shrink-0`} />
|
||||
<button onClick={() => onEditTask(task)}
|
||||
className="text-xs font-medium text-text-primary truncate hover:text-brand-primary text-left">
|
||||
{task.title}
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative flex-1" style={{ height: '44px' }}>
|
||||
{/* Today line */}
|
||||
{differenceInDays(today, earliest) >= 0 && (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-px bg-brand-primary/30 z-10"
|
||||
style={{ left: `${differenceInDays(today, earliest) * dayWidth + dayWidth / 2}px` }}
|
||||
/>
|
||||
)}
|
||||
{/* Bar */}
|
||||
<div
|
||||
className={`absolute top-2.5 h-5 rounded-full ${statusColors[task.status] || 'bg-gray-300'} opacity-80 hover:opacity-100 transition-opacity cursor-pointer`}
|
||||
style={barStyle}
|
||||
onClick={() => onEditTask(task)}
|
||||
title={`${task.title}${task.dueDate ? ` — Due ${format(new Date(task.dueDate), 'MMM d')}` : ''}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Task Confirmation */}
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => { setShowDeleteConfirm(false); setTaskToDelete(null) }}
|
||||
title="Delete Task?"
|
||||
isConfirm
|
||||
danger
|
||||
confirmText="Delete Task"
|
||||
onConfirm={confirmDeleteTask}
|
||||
>
|
||||
Are you sure you want to delete this task? This action cannot be undone.
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
202
client/src/pages/Projects.jsx
Normal file
202
client/src/pages/Projects.jsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { Plus, Search, FolderKanban } from 'lucide-react'
|
||||
import { AppContext } from '../App'
|
||||
import { api } from '../utils/api'
|
||||
import ProjectCard from '../components/ProjectCard'
|
||||
import Modal from '../components/Modal'
|
||||
|
||||
const EMPTY_PROJECT = {
|
||||
name: '', description: '', brand_id: '', status: 'active',
|
||||
owner_id: '', due_date: '',
|
||||
}
|
||||
|
||||
export default function Projects() {
|
||||
const { teamMembers, brands } = useContext(AppContext)
|
||||
const [projects, setProjects] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [formData, setFormData] = useState(EMPTY_PROJECT)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
|
||||
useEffect(() => { loadProjects() }, [])
|
||||
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
const res = await api.get('/projects')
|
||||
setProjects(res.data || res || [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load projects:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
const data = {
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
brand_id: formData.brand_id ? Number(formData.brand_id) : null,
|
||||
owner_id: formData.owner_id ? Number(formData.owner_id) : null,
|
||||
status: formData.status,
|
||||
due_date: formData.due_date || null,
|
||||
}
|
||||
await api.post('/projects', data)
|
||||
setShowModal(false)
|
||||
setFormData(EMPTY_PROJECT)
|
||||
loadProjects()
|
||||
} catch (err) {
|
||||
console.error('Create failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = projects.filter(p => {
|
||||
if (searchTerm && !p.name?.toLowerCase().includes(searchTerm.toLowerCase())) return false
|
||||
return true
|
||||
})
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
<div className="h-12 bg-surface-tertiary rounded-xl mb-4"></div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[...Array(6)].map((_, i) => <div key={i} className="h-56 bg-surface-tertiary rounded-xl"></div>)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search projects..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ml-auto"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
New Project
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Project grid */}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-20 text-center">
|
||||
<FolderKanban className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
|
||||
<p className="text-text-secondary font-medium">No projects yet</p>
|
||||
<p className="text-sm text-text-tertiary mt-1">Create your first project to start organizing work</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 stagger-children">
|
||||
{filtered.map(project => (
|
||||
<ProjectCard key={project._id} project={project} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="Create New Project" size="md">
|
||||
<div className="space-y-4">
|
||||
<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="Project 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="Project 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} value={b._id}>{b.icon} {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="active">Active</option>
|
||||
<option value="paused">Paused</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Owner</label>
|
||||
<select
|
||||
value={formData.owner_id}
|
||||
onChange={e => setFormData(f => ({ ...f, owner_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="">Unassigned</option>
|
||||
{teamMembers.map(m => <option key={m._id} value={m._id}>{m.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Due Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.due_date}
|
||||
onChange={e => setFormData(f => ({ ...f, due_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="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={!formData.name}
|
||||
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"
|
||||
>
|
||||
Create Project
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
110
client/src/pages/Settings.jsx
Normal file
110
client/src/pages/Settings.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState } from 'react'
|
||||
import { Settings as SettingsIcon, Play, CheckCircle, Languages } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
|
||||
export default function Settings() {
|
||||
const { t, lang, setLang } = useLanguage()
|
||||
const [restarting, setRestarting] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
const handleRestartTutorial = async () => {
|
||||
setRestarting(true)
|
||||
setSuccess(false)
|
||||
try {
|
||||
await api.patch('/users/me/tutorial', { completed: false })
|
||||
setSuccess(true)
|
||||
setTimeout(() => {
|
||||
window.location.reload() // Reload to trigger tutorial
|
||||
}, 1500)
|
||||
} catch (err) {
|
||||
console.error('Failed to restart tutorial:', err)
|
||||
alert('Failed to restart tutorial')
|
||||
} finally {
|
||||
setRestarting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in max-w-3xl">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
|
||||
<SettingsIcon className="w-7 h-7 text-brand-primary" />
|
||||
{t('settings.title')}
|
||||
</h1>
|
||||
<p className="text-sm text-text-tertiary mt-1">{t('settings.preferences')}</p>
|
||||
</div>
|
||||
|
||||
{/* General Settings */}
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold text-text-primary">{t('settings.general')}</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Language Selector */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2 flex items-center gap-2">
|
||||
<Languages className="w-4 h-4" />
|
||||
{t('settings.language')}
|
||||
</label>
|
||||
<select
|
||||
value={lang}
|
||||
onChange={(e) => setLang(e.target.value)}
|
||||
className="w-full max-w-xs px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
|
||||
>
|
||||
<option value="en">{t('settings.english')}</option>
|
||||
<option value="ar">{t('settings.arabic')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tutorial Section */}
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold text-text-primary">{t('settings.onboardingTutorial')}</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<p className="text-sm text-text-secondary">
|
||||
{t('settings.tutorialDesc')}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleRestartTutorial}
|
||||
disabled={restarting || success}
|
||||
className="flex items-center gap-2 px-4 py-2.5 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 transition-colors"
|
||||
>
|
||||
{success ? (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
{t('settings.tutorialRestarted')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4" />
|
||||
{restarting ? t('settings.restarting') : t('settings.restartTutorial')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{success && (
|
||||
<p className="text-xs text-emerald-600 font-medium">
|
||||
{t('settings.reloadingPage')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* More settings can go here in the future */}
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden opacity-50">
|
||||
<div className="px-6 py-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold text-text-primary">{t('settings.moreComingSoon')}</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<p className="text-sm text-text-secondary">
|
||||
{t('settings.additionalSettings')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
423
client/src/pages/Tasks.jsx
Normal file
423
client/src/pages/Tasks.jsx
Normal file
@@ -0,0 +1,423 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { Plus, CheckSquare, Edit2, Trash2, Filter } from 'lucide-react'
|
||||
import { AppContext } from '../App'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api } from '../utils/api'
|
||||
import TaskCard from '../components/TaskCard'
|
||||
import Modal from '../components/Modal'
|
||||
|
||||
export default function Tasks() {
|
||||
const { t } = useLanguage()
|
||||
const { currentUser, teamMembers } = useContext(AppContext)
|
||||
const { user: authUser, canEditResource, canDeleteResource } = useAuth()
|
||||
const [tasks, setTasks] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editingTask, setEditingTask] = useState(null)
|
||||
const [draggedTask, setDraggedTask] = useState(null)
|
||||
const [dragOverCol, setDragOverCol] = useState(null)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [taskToDelete, setTaskToDelete] = useState(null)
|
||||
const [filterView, setFilterView] = useState('all') // 'all' | 'assigned_to_me' | 'created_by_me' | member id
|
||||
const [users, setUsers] = useState([]) // for superadmin member filter
|
||||
const [formData, setFormData] = useState({
|
||||
title: '', description: '', priority: 'medium', due_date: '', status: 'todo', assigned_to: ''
|
||||
})
|
||||
|
||||
const isSuperadmin = authUser?.role === 'superadmin'
|
||||
|
||||
useEffect(() => { loadTasks() }, [currentUser])
|
||||
useEffect(() => {
|
||||
if (isSuperadmin) {
|
||||
// Load team members for superadmin filter
|
||||
api.get('/team').then(res => setUsers(res.data || res || [])).catch(() => {})
|
||||
}
|
||||
}, [isSuperadmin])
|
||||
|
||||
const loadTasks = async () => {
|
||||
try {
|
||||
const res = await api.get('/tasks')
|
||||
setTasks(res.data || res || [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load tasks:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter tasks client-side based on selected view
|
||||
const filteredTasks = tasks.filter(task => {
|
||||
if (filterView === 'all') return true
|
||||
|
||||
if (filterView === 'assigned_to_me') {
|
||||
// Tasks where I'm the assignee (via team_member_id on my user record)
|
||||
const myTeamMemberId = authUser?.team_member_id
|
||||
return myTeamMemberId && task.assigned_to === myTeamMemberId
|
||||
}
|
||||
|
||||
if (filterView === 'created_by_me') {
|
||||
return task.created_by_user_id === authUser?.id
|
||||
}
|
||||
|
||||
// Superadmin filtering by specific team member (assigned_to = member id)
|
||||
if (isSuperadmin && !isNaN(Number(filterView))) {
|
||||
return task.assigned_to === Number(filterView)
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const data = {
|
||||
title: formData.title,
|
||||
description: formData.description,
|
||||
priority: formData.priority,
|
||||
due_date: formData.due_date || null,
|
||||
status: formData.status,
|
||||
assigned_to: formData.assigned_to || null,
|
||||
is_personal: false,
|
||||
}
|
||||
if (editingTask) {
|
||||
await api.patch(`/tasks/${editingTask._id || editingTask.id}`, data)
|
||||
} else {
|
||||
await api.post('/tasks', data)
|
||||
}
|
||||
setShowModal(false)
|
||||
setEditingTask(null)
|
||||
setFormData({ title: '', description: '', priority: 'medium', due_date: '', status: 'todo', assigned_to: '' })
|
||||
loadTasks()
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err)
|
||||
if (err.message?.includes('403') || err.message?.includes('modify your own')) {
|
||||
alert('You can only edit your own tasks')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleMove = async (taskId, newStatus) => {
|
||||
try {
|
||||
await api.patch(`/tasks/${taskId}`, { status: newStatus })
|
||||
loadTasks()
|
||||
} catch (err) {
|
||||
console.error('Move failed:', err)
|
||||
if (err.message?.includes('403')) {
|
||||
alert('You can only modify your own tasks')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const openEdit = (task) => {
|
||||
if (!canEditResource('task', task)) return
|
||||
setEditingTask(task)
|
||||
setFormData({
|
||||
title: task.title || '',
|
||||
description: task.description || '',
|
||||
priority: task.priority || 'medium',
|
||||
due_date: task.due_date || task.dueDate || '',
|
||||
status: task.status || 'todo',
|
||||
assigned_to: task.assigned_to || '',
|
||||
})
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const handleDelete = (task) => {
|
||||
if (!canDeleteResource('task', task)) return
|
||||
setTaskToDelete(task)
|
||||
setShowDeleteConfirm(true)
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!taskToDelete) return
|
||||
try {
|
||||
await api.delete(`/tasks/${taskToDelete._id || taskToDelete.id}`)
|
||||
setTaskToDelete(null)
|
||||
loadTasks()
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragStart = (e, task) => {
|
||||
setDraggedTask(task)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
if (e.target) {
|
||||
setTimeout(() => e.target.style.opacity = '0.4', 0)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnd = (e) => {
|
||||
e.target.style.opacity = '1'
|
||||
setDraggedTask(null)
|
||||
setDragOverCol(null)
|
||||
}
|
||||
|
||||
const handleDragOver = (e, colStatus) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
setDragOverCol(colStatus)
|
||||
}
|
||||
|
||||
const handleDragLeave = (e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||||
setDragOverCol(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (e, colStatus) => {
|
||||
e.preventDefault()
|
||||
setDragOverCol(null)
|
||||
if (draggedTask && draggedTask.status !== colStatus) {
|
||||
const taskId = draggedTask._id || draggedTask.id
|
||||
handleMove(taskId, colStatus)
|
||||
}
|
||||
setDraggedTask(null)
|
||||
}
|
||||
|
||||
const todoTasks = filteredTasks.filter(t => t.status === 'todo')
|
||||
const inProgressTasks = filteredTasks.filter(t => t.status === 'in_progress')
|
||||
const doneTasks = filteredTasks.filter(t => t.status === 'done')
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
<div className="h-12 bg-surface-tertiary rounded-xl mb-4"></div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{[...Array(3)].map((_, i) => <div key={i} className="h-64 bg-surface-tertiary rounded-xl"></div>)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ label: t('tasks.todo'), status: 'todo', items: todoTasks, color: 'bg-gray-400' },
|
||||
{ label: t('tasks.in_progress'), status: 'in_progress', items: inProgressTasks, color: 'bg-blue-400' },
|
||||
{ label: t('tasks.done'), status: 'done', items: doneTasks, color: 'bg-emerald-400' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-4 h-4 text-text-tertiary" />
|
||||
<select
|
||||
value={filterView}
|
||||
onChange={e => setFilterView(e.target.value)}
|
||||
className="px-3 py-1.5 text-sm border border-border rounded-lg bg-surface-primary text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
<option value="all">{t('tasks.allTasks')}</option>
|
||||
<option value="assigned_to_me">{t('tasks.assignedToMe')}</option>
|
||||
<option value="created_by_me">{t('tasks.createdByMe')}</option>
|
||||
{isSuperadmin && users.length > 0 && (
|
||||
<optgroup label={t('tasks.byTeamMember')}>
|
||||
{users.map(m => (
|
||||
<option key={m.id} value={m.id}>{m.name}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{filteredTasks.length} {filteredTasks.length !== 1 ? t('tasks.tasks') : t('tasks.task')}
|
||||
{filterView !== 'all' && tasks.length !== filteredTasks.length && (
|
||||
<span className="text-text-tertiary"> {t('tasks.of')} {tasks.length}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setEditingTask(null); setFormData({ title: '', description: '', priority: 'medium', due_date: '', status: 'todo', assigned_to: '' }); setShowModal(true) }}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('tasks.newTask')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Task columns */}
|
||||
{filteredTasks.length === 0 ? (
|
||||
<div className="py-20 text-center">
|
||||
<CheckSquare className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
|
||||
<p className="text-text-secondary font-medium">
|
||||
{tasks.length === 0 ? t('tasks.noTasks') : t('tasks.noMatch')}
|
||||
</p>
|
||||
<p className="text-sm text-text-tertiary mt-1">
|
||||
{tasks.length === 0 ? t('tasks.createFirst') : t('tasks.tryFilter')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{columns.map(col => {
|
||||
const isOver = dragOverCol === col.status && draggedTask?.status !== col.status
|
||||
|
||||
return (
|
||||
<div key={col.status}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${col.color}`} />
|
||||
<h4 className="text-sm font-semibold text-text-primary">{col.label}</h4>
|
||||
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-2 py-0.5 rounded-full">
|
||||
{col.items.length}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`kanban-column rounded-xl p-2 space-y-2 min-h-[200px] border-2 transition-colors ${
|
||||
isOver
|
||||
? 'bg-brand-primary/5 border-brand-primary/40 border-dashed'
|
||||
: 'bg-surface-secondary border-border-light border-solid'
|
||||
}`}
|
||||
onDragOver={(e) => handleDragOver(e, col.status)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, col.status)}
|
||||
>
|
||||
{col.items.length === 0 ? (
|
||||
<div className={`py-8 text-center text-xs ${isOver ? 'text-brand-primary font-medium' : 'text-text-tertiary'}`}>
|
||||
{isOver ? t('posts.dropHere') : t('tasks.noTasks')}
|
||||
</div>
|
||||
) : (
|
||||
col.items.map(task => {
|
||||
const canEdit = canEditResource('task', task)
|
||||
const canDelete = canDeleteResource('task', task)
|
||||
return (
|
||||
<div
|
||||
key={task._id || task.id}
|
||||
draggable={canEdit}
|
||||
onDragStart={(e) => canEdit && handleDragStart(e, task)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={canEdit ? 'cursor-grab active:cursor-grabbing' : ''}
|
||||
>
|
||||
<div className="relative group">
|
||||
<TaskCard task={task} onMove={canEdit ? handleMove : undefined} showProject />
|
||||
{/* Edit/Delete overlay */}
|
||||
{(canEdit || canDelete) && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); openEdit(task) }}
|
||||
className="p-1 hover:bg-surface-tertiary rounded text-text-tertiary hover:text-text-primary"
|
||||
title={t('tasks.editTask')}
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDelete(task) }}
|
||||
className="p-1 hover:bg-red-50 rounded text-text-tertiary hover:text-red-500"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Task Modal */}
|
||||
<Modal isOpen={showModal} onClose={() => { setShowModal(false); setEditingTask(null) }} title={editingTask ? t('tasks.editTask') : t('tasks.createTask')} size="md">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('tasks.taskTitle')} *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={e => setFormData(f => ({ ...f, title: 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={t('posts.whatNeedsDone')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('tasks.description')}</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={e => setFormData(f => ({ ...f, description: 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={t('posts.optionalDetails')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('tasks.assignTo')}</label>
|
||||
<select
|
||||
value={formData.assigned_to}
|
||||
onChange={e => setFormData(f => ({ ...f, assigned_to: 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="">{t('common.unassigned')}</option>
|
||||
{(teamMembers || []).map(m => (
|
||||
<option key={m.id || m._id} value={m.id || m._id}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('tasks.priority')}</label>
|
||||
<select
|
||||
value={formData.priority}
|
||||
onChange={e => setFormData(f => ({ ...f, priority: 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="low">{t('tasks.priority.low')}</option>
|
||||
<option value="medium">{t('tasks.priority.medium')}</option>
|
||||
<option value="high">{t('tasks.priority.high')}</option>
|
||||
<option value="urgent">{t('tasks.priority.urgent')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('tasks.dueDate')}</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.due_date}
|
||||
onChange={e => setFormData(f => ({ ...f, due_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="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||
<button
|
||||
onClick={() => { setShowModal(false); setEditingTask(null) }}
|
||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!formData.title}
|
||||
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"
|
||||
>
|
||||
{editingTask ? t('tasks.saveChanges') : t('tasks.createTask')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => { setShowDeleteConfirm(false); setTaskToDelete(null) }}
|
||||
title={t('tasks.deleteTask')}
|
||||
isConfirm
|
||||
danger
|
||||
confirmText={t('tasks.deleteTask')}
|
||||
onConfirm={confirmDelete}
|
||||
>
|
||||
{t('tasks.deleteConfirm')}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
448
client/src/pages/Team.jsx
Normal file
448
client/src/pages/Team.jsx
Normal file
@@ -0,0 +1,448 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { Plus, Users, ArrowLeft, User as UserIcon } from 'lucide-react'
|
||||
import { AppContext } from '../App'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api } from '../utils/api'
|
||||
import MemberCard from '../components/MemberCard'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import BrandBadge from '../components/BrandBadge'
|
||||
import Modal from '../components/Modal'
|
||||
|
||||
const EMPTY_MEMBER = {
|
||||
name: '', email: '', password: '', role: 'content_writer', brands: '', phone: '',
|
||||
}
|
||||
|
||||
const ROLES = [
|
||||
{ value: 'manager', label: 'Manager' },
|
||||
{ value: 'approver', label: 'Approver' },
|
||||
{ value: 'publisher', label: 'Publisher' },
|
||||
{ value: 'content_creator', label: 'Content Creator' },
|
||||
{ value: 'producer', label: 'Producer' },
|
||||
{ value: 'designer', label: 'Designer' },
|
||||
{ value: 'content_writer', label: 'Content Writer' },
|
||||
{ value: 'social_media_manager', label: 'Social Media Manager' },
|
||||
{ value: 'photographer', label: 'Photographer' },
|
||||
{ value: 'videographer', label: 'Videographer' },
|
||||
{ value: 'strategist', label: 'Strategist' },
|
||||
]
|
||||
|
||||
export default function Team() {
|
||||
const { t } = useLanguage()
|
||||
const { teamMembers, loadTeam, currentUser } = useContext(AppContext)
|
||||
const { user } = useAuth()
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [editingMember, setEditingMember] = useState(null)
|
||||
const [isEditingSelf, setIsEditingSelf] = useState(false)
|
||||
const [formData, setFormData] = useState(EMPTY_MEMBER)
|
||||
const [selectedMember, setSelectedMember] = useState(null)
|
||||
const [memberTasks, setMemberTasks] = useState([])
|
||||
const [memberPosts, setMemberPosts] = useState([])
|
||||
const [loadingDetail, setLoadingDetail] = useState(false)
|
||||
|
||||
const canManageTeam = user?.role === 'superadmin' || user?.role === 'manager'
|
||||
|
||||
const openNew = () => {
|
||||
setEditingMember(null)
|
||||
setIsEditingSelf(false)
|
||||
setFormData(EMPTY_MEMBER)
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const openEdit = (member) => {
|
||||
const isSelf = member._id === user?.id || member.id === user?.id
|
||||
setEditingMember(member)
|
||||
setIsEditingSelf(isSelf)
|
||||
setFormData({
|
||||
name: member.name || '',
|
||||
email: member.email || '',
|
||||
password: '',
|
||||
role: member.team_role || member.role || 'content_writer',
|
||||
brands: Array.isArray(member.brands) ? member.brands.join(', ') : (member.brands || ''),
|
||||
phone: member.phone || '',
|
||||
})
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const brands = typeof formData.brands === 'string'
|
||||
? formData.brands.split(',').map(b => b.trim()).filter(Boolean)
|
||||
: formData.brands
|
||||
|
||||
// If editing self, use self-service endpoint
|
||||
if (isEditingSelf) {
|
||||
const data = {
|
||||
name: formData.name,
|
||||
team_role: formData.role,
|
||||
brands,
|
||||
phone: formData.phone,
|
||||
}
|
||||
await api.patch('/users/me/profile', data)
|
||||
} else {
|
||||
// Manager/superadmin creating or editing other users
|
||||
const data = {
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
team_role: formData.role,
|
||||
brands,
|
||||
phone: formData.phone,
|
||||
}
|
||||
if (formData.password) {
|
||||
data.password = formData.password
|
||||
}
|
||||
|
||||
if (editingMember) {
|
||||
await api.patch(`/users/team/${editingMember._id}`, data)
|
||||
} else {
|
||||
await api.post('/users/team', data)
|
||||
}
|
||||
}
|
||||
|
||||
setShowModal(false)
|
||||
setEditingMember(null)
|
||||
setIsEditingSelf(false)
|
||||
setFormData(EMPTY_MEMBER)
|
||||
loadTeam()
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err)
|
||||
alert(err.message || 'Failed to save')
|
||||
}
|
||||
}
|
||||
|
||||
const openMemberDetail = async (member) => {
|
||||
setSelectedMember(member)
|
||||
setLoadingDetail(true)
|
||||
try {
|
||||
const [tasksRes, postsRes] = await Promise.allSettled([
|
||||
api.get(`/tasks?assignedTo=${member._id}`),
|
||||
api.get(`/posts?assignedTo=${member._id}`),
|
||||
])
|
||||
setMemberTasks(tasksRes.status === 'fulfilled' ? (tasksRes.value.data || tasksRes.value || []) : [])
|
||||
setMemberPosts(postsRes.status === 'fulfilled' ? (postsRes.value.data || postsRes.value || []) : [])
|
||||
} catch {
|
||||
setMemberTasks([])
|
||||
setMemberPosts([])
|
||||
} finally {
|
||||
setLoadingDetail(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Member detail view
|
||||
if (selectedMember) {
|
||||
const todoCount = memberTasks.filter(t => t.status === 'todo').length
|
||||
const inProgressCount = memberTasks.filter(t => t.status === 'in_progress').length
|
||||
const doneCount = memberTasks.filter(t => t.status === 'done').length
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<button
|
||||
onClick={() => setSelectedMember(null)}
|
||||
className="flex items-center gap-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t('team.backToTeam')}
|
||||
</button>
|
||||
|
||||
{/* Member profile */}
|
||||
<div className="bg-white rounded-xl border border-border p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`w-16 h-16 rounded-full bg-gradient-to-br from-indigo-400 to-purple-500 flex items-center justify-center text-white text-xl font-bold`}>
|
||||
{selectedMember.name?.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-bold text-text-primary">{selectedMember.name}</h2>
|
||||
<p className="text-sm text-text-secondary capitalize">{(selectedMember.team_role || selectedMember.role)?.replace('_', ' ')}</p>
|
||||
{selectedMember.email && (
|
||||
<p className="text-sm text-text-tertiary mt-1">{selectedMember.email}</p>
|
||||
)}
|
||||
{selectedMember.brands && selectedMember.brands.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{selectedMember.brands.map(b => <BrandBadge key={b} brand={b} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => openEdit(selectedMember)}
|
||||
className="px-3 py-1.5 text-sm font-medium text-brand-primary hover:bg-brand-primary/10 rounded-lg"
|
||||
>
|
||||
{t('common.edit')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workload stats */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-border p-4 text-center">
|
||||
<p className="text-2xl font-bold text-text-primary">{memberTasks.length}</p>
|
||||
<p className="text-xs text-text-tertiary">{t('team.totalTasks')}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-border p-4 text-center">
|
||||
<p className="text-2xl font-bold text-amber-500">{todoCount}</p>
|
||||
<p className="text-xs text-text-tertiary">{t('team.toDo')}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-border p-4 text-center">
|
||||
<p className="text-2xl font-bold text-blue-500">{inProgressCount}</p>
|
||||
<p className="text-xs text-text-tertiary">{t('team.inProgress')}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-border p-4 text-center">
|
||||
<p className="text-2xl font-bold text-emerald-500">{doneCount}</p>
|
||||
<p className="text-xs text-text-tertiary">{t('tasks.done')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tasks & Posts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Tasks */}
|
||||
<div className="bg-white rounded-xl border border-border">
|
||||
<div className="px-5 py-4 border-b border-border">
|
||||
<h3 className="font-semibold text-text-primary">{t('tasks.title')} ({memberTasks.length})</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-border-light max-h-[400px] overflow-y-auto">
|
||||
{loadingDetail ? (
|
||||
<div className="py-8 text-center text-sm text-text-tertiary">{t('common.loading')}</div>
|
||||
) : memberTasks.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-text-tertiary">{t('team.noTasks')}</div>
|
||||
) : (
|
||||
memberTasks.map(task => (
|
||||
<div key={task._id} className="flex items-center gap-3 px-5 py-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm font-medium ${task.status === 'done' ? 'text-text-tertiary line-through' : 'text-text-primary'}`}>
|
||||
{task.title}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={task.status} size="xs" />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Posts */}
|
||||
<div className="bg-white rounded-xl border border-border">
|
||||
<div className="px-5 py-4 border-b border-border">
|
||||
<h3 className="font-semibold text-text-primary">{t('nav.posts')} ({memberPosts.length})</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-border-light max-h-[400px] overflow-y-auto">
|
||||
{loadingDetail ? (
|
||||
<div className="py-8 text-center text-sm text-text-tertiary">{t('common.loading')}</div>
|
||||
) : memberPosts.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-text-tertiary">{t('posts.noPosts')}</div>
|
||||
) : (
|
||||
memberPosts.map(post => (
|
||||
<div key={post._id} className="flex items-center gap-3 px-5 py-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary truncate">{post.title}</p>
|
||||
{post.brand && <BrandBadge brand={post.brand} />}
|
||||
</div>
|
||||
<StatusBadge status={post.status} size="xs" />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Team grid
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-text-secondary">
|
||||
{teamMembers.length} {teamMembers.length !== 1 ? t('team.membersPlural') : t('team.member')}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{/* Edit own profile button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const self = teamMembers.find(m => m._id === user?.id || m.id === user?.id)
|
||||
if (self) openEdit(self)
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
|
||||
>
|
||||
<UserIcon className="w-4 h-4" />
|
||||
{t('team.myProfile')}
|
||||
</button>
|
||||
|
||||
{/* Add member button (managers and superadmins only) */}
|
||||
{canManageTeam && (
|
||||
<button
|
||||
onClick={openNew}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('team.addMember')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Member grid */}
|
||||
{teamMembers.length === 0 ? (
|
||||
<div className="py-20 text-center">
|
||||
<Users className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
|
||||
<p className="text-text-secondary font-medium">{t('team.noMembers')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 stagger-children">
|
||||
{teamMembers.map(member => (
|
||||
<MemberCard key={member._id} member={member} onClick={openMemberDetail} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => { setShowModal(false); setEditingMember(null); setIsEditingSelf(false) }}
|
||||
title={isEditingSelf ? t('team.editProfile') : (editingMember ? t('team.editMember') : t('team.newMember'))}
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.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={t('team.fullName')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isEditingSelf && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.email')} *</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={e => setFormData(f => ({ ...f, email: 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="email@example.com"
|
||||
disabled={editingMember}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!editingMember && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.password')} {editingMember && t('team.optional')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={e => setFormData(f => ({ ...f, password: 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="••••••••"
|
||||
/>
|
||||
{!formData.password && !editingMember && (
|
||||
<p className="text-xs text-text-tertiary mt-1">{t('team.defaultPassword')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.teamRole')}</label>
|
||||
{user?.role === 'manager' && !editingMember && !isEditingSelf ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value="Contributor"
|
||||
disabled
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface-tertiary text-text-tertiary cursor-not-allowed"
|
||||
/>
|
||||
<p className="text-xs text-text-tertiary mt-1">{t('team.fixedRole')}</p>
|
||||
</>
|
||||
) : (
|
||||
<select
|
||||
value={formData.role}
|
||||
onChange={e => setFormData(f => ({ ...f, role: 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"
|
||||
>
|
||||
{ROLES.map(r => <option key={r.value} value={r.value}>{r.label}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.phone')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.phone}
|
||||
onChange={e => setFormData(f => ({ ...f, phone: 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="+966 ..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.brands')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.brands}
|
||||
onChange={e => setFormData(f => ({ ...f, brands: 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="Samaya Investment, Hira Cultural District"
|
||||
/>
|
||||
<p className="text-xs text-text-tertiary mt-1">{t('team.brandsHelp')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||
{editingMember && !isEditingSelf && canManageTeam && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg mr-auto"
|
||||
>
|
||||
{t('team.remove')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setShowModal(false); setEditingMember(null); setIsEditingSelf(false) }}
|
||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!formData.name || (!isEditingSelf && !editingMember && !formData.email)}
|
||||
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"
|
||||
>
|
||||
{isEditingSelf ? t('team.saveProfile') : (editingMember ? t('team.saveChanges') : t('team.addMember'))}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => setShowDeleteConfirm(false)}
|
||||
title={t('team.removeMember')}
|
||||
isConfirm
|
||||
danger
|
||||
confirmText={t('team.remove')}
|
||||
onConfirm={async () => {
|
||||
if (editingMember) {
|
||||
await api.delete(`/users/team/${editingMember._id}`)
|
||||
setShowModal(false)
|
||||
setEditingMember(null)
|
||||
setIsEditingSelf(false)
|
||||
setShowDeleteConfirm(false)
|
||||
if (selectedMember?._id === editingMember._id) {
|
||||
setSelectedMember(null)
|
||||
}
|
||||
loadTeam()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('team.removeConfirm', { name: editingMember?.name })}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
315
client/src/pages/Users.jsx
Normal file
315
client/src/pages/Users.jsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plus, Shield, Edit2, Trash2, UserCheck } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
import Modal from '../components/Modal'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
const ROLES = [
|
||||
{ value: 'superadmin', label: 'Superadmin', color: 'bg-purple-100 text-purple-700', icon: '👑' },
|
||||
{ value: 'manager', label: 'Manager', color: 'bg-blue-100 text-blue-700', icon: '📊' },
|
||||
{ value: 'contributor', label: 'Contributor', color: 'bg-green-100 text-green-700', icon: '✏️' },
|
||||
]
|
||||
|
||||
const EMPTY_FORM = {
|
||||
name: '', email: '', password: '', role: 'contributor', avatar: '',
|
||||
}
|
||||
|
||||
function RoleBadge({ role }) {
|
||||
const roleInfo = ROLES.find(r => r.value === role) || ROLES[2]
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 text-xs font-medium px-2.5 py-1 rounded-full ${roleInfo.color}`}>
|
||||
<span>{roleInfo.icon}</span>
|
||||
{roleInfo.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Users() {
|
||||
const { user: currentUser } = useAuth()
|
||||
const [users, setUsers] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editingUser, setEditingUser] = useState(null)
|
||||
const [form, setForm] = useState(EMPTY_FORM)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [userToDelete, setUserToDelete] = useState(null)
|
||||
|
||||
useEffect(() => { loadUsers() }, [])
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const res = await api.get('/users')
|
||||
setUsers(res)
|
||||
} catch (err) {
|
||||
console.error('Failed to load users:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const data = {
|
||||
name: form.name,
|
||||
email: form.email,
|
||||
role: form.role,
|
||||
avatar: form.avatar || null,
|
||||
}
|
||||
if (form.password) data.password = form.password
|
||||
|
||||
if (editingUser) {
|
||||
await api.patch(`/users/${editingUser.id}`, data)
|
||||
} else {
|
||||
if (!form.password) {
|
||||
alert('Password is required for new users')
|
||||
return
|
||||
}
|
||||
data.password = form.password
|
||||
await api.post('/users', data)
|
||||
}
|
||||
setShowModal(false)
|
||||
setEditingUser(null)
|
||||
setForm(EMPTY_FORM)
|
||||
loadUsers()
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err)
|
||||
alert('Failed to save user: ' + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const openEdit = (user) => {
|
||||
setEditingUser(user)
|
||||
setForm({
|
||||
name: user.name || '',
|
||||
email: user.email || '',
|
||||
password: '',
|
||||
role: user.role || 'contributor',
|
||||
avatar: user.avatar || '',
|
||||
})
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const openNew = () => {
|
||||
setEditingUser(null)
|
||||
setForm(EMPTY_FORM)
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!userToDelete) return
|
||||
try {
|
||||
await api.delete(`/users/${userToDelete.id}`)
|
||||
loadUsers()
|
||||
setUserToDelete(null)
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err)
|
||||
alert('Failed to delete user')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4 animate-pulse">
|
||||
<div className="h-12 bg-surface-tertiary rounded-xl"></div>
|
||||
<div className="h-64 bg-surface-tertiary rounded-xl"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
|
||||
<Shield className="w-7 h-7 text-purple-600" />
|
||||
User Management
|
||||
</h1>
|
||||
<p className="text-sm text-text-tertiary mt-1">{users.length} user{users.length !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={openNew}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Users List */}
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-secondary">
|
||||
<th className="text-left px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">User</th>
|
||||
<th className="text-left px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Email</th>
|
||||
<th className="text-left px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Role</th>
|
||||
<th className="text-left px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Created</th>
|
||||
<th className="text-right px-5 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider w-24">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-light">
|
||||
{users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-12 text-center text-sm text-text-tertiary">
|
||||
No users found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
users.map(user => {
|
||||
const isCurrentUser = currentUser?.id === user.id
|
||||
const roleInfo = ROLES.find(r => r.value === user.role) || ROLES[2]
|
||||
return (
|
||||
<tr key={user.id} className="hover:bg-surface-secondary group">
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-full bg-gradient-to-br ${user.role === 'superadmin' ? 'from-purple-500 to-pink-500' : 'from-blue-500 to-indigo-500'} flex items-center justify-center text-white font-bold text-sm`}>
|
||||
{user.name?.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-text-primary">{user.name}</p>
|
||||
{isCurrentUser && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-700 font-medium">
|
||||
You
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-sm text-text-secondary">{user.email}</td>
|
||||
<td className="px-5 py-4">
|
||||
<RoleBadge role={user.role} />
|
||||
</td>
|
||||
<td className="px-5 py-4 text-sm text-text-tertiary">
|
||||
{user.createdAt ? new Date(user.createdAt).toLocaleDateString() : '—'}
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={() => openEdit(user)}
|
||||
className="p-1.5 hover:bg-surface-tertiary rounded-lg text-text-tertiary hover:text-text-primary"
|
||||
title="Edit user"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
{!isCurrentUser && (
|
||||
<button
|
||||
onClick={() => { setUserToDelete(user); setShowDeleteConfirm(true) }}
|
||||
className="p-1.5 hover:bg-red-50 rounded-lg text-text-tertiary hover:text-red-500"
|
||||
title="Delete user"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Add/Edit User Modal */}
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => { setShowModal(false); setEditingUser(null) }}
|
||||
title={editingUser ? 'Edit User' : 'Add New User'}
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={e => setForm(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="Full name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Email *</label>
|
||||
<input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={e => setForm(f => ({ ...f, email: 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="user@samayainvest.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">
|
||||
Password {editingUser && '(leave blank to keep current)'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={e => setForm(f => ({ ...f, password: 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="••••••••"
|
||||
required={!editingUser}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Role *</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{ROLES.map(r => (
|
||||
<button
|
||||
key={r.value}
|
||||
type="button"
|
||||
onClick={() => setForm(f => ({ ...f, role: r.value }))}
|
||||
className={`p-3 rounded-lg border-2 text-center transition-all ${
|
||||
form.role === r.value
|
||||
? 'border-brand-primary bg-brand-primary/5'
|
||||
: 'border-border hover:border-brand-primary/30'
|
||||
}`}
|
||||
>
|
||||
<div className="text-2xl mb-1">{r.icon}</div>
|
||||
<div className="text-xs font-medium text-text-primary">{r.label}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||
<button
|
||||
onClick={() => { setShowModal(false); setEditingUser(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={!form.name || !form.email || (!editingUser && !form.password)}
|
||||
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"
|
||||
>
|
||||
{editingUser ? 'Save Changes' : 'Add User'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => { setShowDeleteConfirm(false); setUserToDelete(null) }}
|
||||
title="Delete User?"
|
||||
isConfirm
|
||||
danger
|
||||
confirmText="Delete User"
|
||||
onConfirm={confirmDelete}
|
||||
>
|
||||
Are you sure you want to delete <strong>{userToDelete?.name}</strong>? This action cannot be undone.
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
125
client/src/utils/api.js
Normal file
125
client/src/utils/api.js
Normal file
@@ -0,0 +1,125 @@
|
||||
const API = '/api';
|
||||
|
||||
// Map SQLite fields to frontend-friendly format
|
||||
const toCamel = (s) => s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
||||
|
||||
const normalize = (data) => {
|
||||
if (Array.isArray(data)) return data.map(normalize);
|
||||
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
const camelKey = toCamel(k);
|
||||
out[camelKey] = v;
|
||||
if (camelKey !== k) out[k] = v;
|
||||
}
|
||||
// Add _id alias
|
||||
if (out.id !== undefined && out._id === undefined) out._id = out.id;
|
||||
// Map brand_name → brand (frontend expects post.brand as string)
|
||||
if (out.brandName && !out.brand) out.brand = out.brandName;
|
||||
// Map assigned_name for display
|
||||
if (out.assignedName && !out.assignedToName) out.assignedToName = out.assignedName;
|
||||
return out;
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const handleResponse = async (r, label) => {
|
||||
if (!r.ok) {
|
||||
if (r.status === 401 || r.status === 403) {
|
||||
// Unauthorized - redirect to login if not already there
|
||||
if (!window.location.pathname.includes('/login')) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
throw new Error(`${label} failed: ${r.status}`);
|
||||
}
|
||||
const json = await r.json();
|
||||
return normalize(json);
|
||||
};
|
||||
|
||||
export const api = {
|
||||
get: (path) => fetch(`${API}${path}`, {
|
||||
credentials: 'include'
|
||||
}).then(r => handleResponse(r, `GET ${path}`)),
|
||||
|
||||
post: (path, data) => fetch(`${API}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(data),
|
||||
}).then(r => handleResponse(r, `POST ${path}`)),
|
||||
|
||||
patch: (path, data) => fetch(`${API}${path}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(data),
|
||||
}).then(r => handleResponse(r, `PATCH ${path}`)),
|
||||
|
||||
delete: (path) => fetch(`${API}${path}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
}).then(r => handleResponse(r, `DELETE ${path}`)),
|
||||
|
||||
upload: (path, formData) => fetch(`${API}${path}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
}).then(r => handleResponse(r, `UPLOAD ${path}`)),
|
||||
};
|
||||
|
||||
// Brand colors map — matches Samaya brands from backend
|
||||
export const BRAND_COLORS = {
|
||||
'Samaya Investment': { bg: 'bg-indigo-100', text: 'text-indigo-700', dot: 'bg-indigo-500' },
|
||||
'Hira Cultural District': { bg: 'bg-amber-100', text: 'text-amber-700', dot: 'bg-amber-500' },
|
||||
'Holy Quran Museum': { bg: 'bg-emerald-100', text: 'text-emerald-700', dot: 'bg-emerald-500' },
|
||||
'Al-Safiya Museum': { bg: 'bg-purple-100', text: 'text-purple-700', dot: 'bg-purple-500' },
|
||||
'Hayhala': { bg: 'bg-red-100', text: 'text-red-700', dot: 'bg-red-500' },
|
||||
'Jabal Thawr': { bg: 'bg-stone-100', text: 'text-stone-700', dot: 'bg-stone-500' },
|
||||
'Coffee Chain': { bg: 'bg-orange-100', text: 'text-orange-700', dot: 'bg-orange-500' },
|
||||
'Taibah Gifts': { bg: 'bg-pink-100', text: 'text-pink-700', dot: 'bg-pink-500' },
|
||||
'Google Maps': { bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500' },
|
||||
'default': { bg: 'bg-gray-100', text: 'text-gray-700', dot: 'bg-gray-500' },
|
||||
};
|
||||
|
||||
export const getBrandColor = (brand) => BRAND_COLORS[brand] || BRAND_COLORS['default'];
|
||||
|
||||
// Platform icons helper — svg paths for inline icons
|
||||
export const PLATFORMS = {
|
||||
instagram: { label: 'Instagram', color: '#E4405F', icon: 'M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z' },
|
||||
twitter: { label: 'X', color: '#000000', icon: 'M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z' },
|
||||
facebook: { label: 'Facebook', color: '#1877F2', icon: 'M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z' },
|
||||
linkedin: { label: 'LinkedIn', color: '#0A66C2', icon: 'M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z' },
|
||||
tiktok: { label: 'TikTok', color: '#000000', icon: 'M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z' },
|
||||
youtube: { label: 'YouTube', color: '#FF0000', icon: 'M23.498 6.186a3.016 3.016 0 00-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 00.502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 002.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 002.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z' },
|
||||
snapchat: { label: 'Snapchat', color: '#FFFC00', icon: 'M12.017 0C5.396 0 .029 5.367.029 11.987c0 5.079 3.158 9.417 7.618 11.162-.105-.949-.199-2.403.041-3.439.219-.937 1.406-5.957 1.406-5.957s-.359-.72-.359-1.781c0-1.668.967-2.914 2.171-2.914 1.023 0 1.518.769 1.518 1.69 0 1.029-.655 2.568-.994 3.995-.283 1.194.599 2.169 1.777 2.169 2.133 0 3.772-2.249 3.772-5.495 0-2.873-2.064-4.882-5.012-4.882-3.414 0-5.418 2.561-5.418 5.207 0 1.031.397 2.138.893 2.738a.36.36 0 01.083.345l-.333 1.36c-.053.22-.174.267-.402.161-1.499-.698-2.436-2.889-2.436-4.649 0-3.785 2.75-7.262 7.929-7.262 4.163 0 7.398 2.967 7.398 6.931 0 4.136-2.607 7.464-6.227 7.464-1.216 0-2.359-.631-2.75-1.378l-.748 2.853c-.271 1.043-1.002 2.35-1.492 3.146C9.57 23.812 10.763 24 12.017 24c6.624 0 11.99-5.367 11.99-11.988C24.007 5.367 18.641 0 12.017 0z' },
|
||||
google_ads: { label: 'Google Ads', color: '#4285F4', icon: 'M12 0C5.372 0 0 5.373 0 12s5.372 12 12 12 12-5.373 12-12S18.628 0 12 0zm5.82 16.32l-2.16 1.25c-.37.21-.84.09-1.05-.28l-5.82-10.08c-.21-.37-.09-.84.28-1.05l2.16-1.25c.37-.21.84-.09 1.05.28l5.82 10.08c.21.37.09.84-.28 1.05z' },
|
||||
};
|
||||
|
||||
// Status config
|
||||
export const STATUS_CONFIG = {
|
||||
draft: { label: 'Draft', bg: 'bg-gray-100', text: 'text-gray-600', dot: 'bg-gray-400' },
|
||||
in_review: { label: 'In Review', bg: 'bg-amber-50', text: 'text-amber-700', dot: 'bg-amber-400' },
|
||||
approved: { label: 'Approved', bg: 'bg-blue-50', text: 'text-blue-700', dot: 'bg-blue-400' },
|
||||
scheduled: { label: 'Scheduled', bg: 'bg-purple-50', text: 'text-purple-700', dot: 'bg-purple-400' },
|
||||
published: { label: 'Published', bg: 'bg-emerald-50', text: 'text-emerald-700', dot: 'bg-emerald-400' },
|
||||
rejected: { label: 'Rejected', bg: 'bg-red-50', text: 'text-red-700', dot: 'bg-red-400' },
|
||||
todo: { label: 'To Do', bg: 'bg-gray-100', text: 'text-gray-600', dot: 'bg-gray-400' },
|
||||
in_progress: { label: 'In Progress', bg: 'bg-blue-50', text: 'text-blue-700', dot: 'bg-blue-400' },
|
||||
done: { label: 'Done', bg: 'bg-emerald-50', text: 'text-emerald-700', dot: 'bg-emerald-400' },
|
||||
active: { label: 'Active', bg: 'bg-emerald-50', text: 'text-emerald-700', dot: 'bg-emerald-400' },
|
||||
paused: { label: 'Paused', bg: 'bg-amber-50', text: 'text-amber-700', dot: 'bg-amber-400' },
|
||||
completed: { label: 'Completed', bg: 'bg-blue-50', text: 'text-blue-700', dot: 'bg-blue-400' },
|
||||
cancelled: { label: 'Cancelled', bg: 'bg-red-50', text: 'text-red-700', dot: 'bg-red-400' },
|
||||
planning: { label: 'Planning', bg: 'bg-gray-100', text: 'text-gray-600', dot: 'bg-gray-400' },
|
||||
};
|
||||
|
||||
export const getStatusConfig = (status) => STATUS_CONFIG[status] || STATUS_CONFIG['draft'];
|
||||
|
||||
// Priority config
|
||||
export const PRIORITY_CONFIG = {
|
||||
low: { label: 'Low', color: 'bg-gray-400' },
|
||||
medium: { label: 'Medium', color: 'bg-amber-400' },
|
||||
high: { label: 'High', color: 'bg-orange-500' },
|
||||
urgent: { label: 'Urgent', color: 'bg-red-500' },
|
||||
};
|
||||
Reference in New Issue
Block a user