adding brand management

This commit is contained in:
fahed
2026-02-10 21:03:36 +03:00
parent 334727b232
commit f3e6fc848d
15 changed files with 568 additions and 864 deletions

View File

@@ -16,6 +16,7 @@ import Tasks from './pages/Tasks'
import Team from './pages/Team'
import Users from './pages/Users'
import Settings from './pages/Settings'
import Brands from './pages/Brands'
import Login from './pages/Login'
import Tutorial from './components/Tutorial'
import Modal from './components/Modal'
@@ -40,7 +41,7 @@ export const AppContext = createContext()
function AppContent() {
const { user, loading: authLoading, checkAuth } = useAuth()
const { t } = useLanguage()
const { t, lang } = useLanguage()
const [teamMembers, setTeamMembers] = useState([])
const [brands, setBrands] = useState([])
const [loading, setLoading] = useState(true)
@@ -68,6 +69,13 @@ function AppContent() {
}
}, [user, authLoading])
const getBrandName = (brandId) => {
if (!brandId) return null
const brand = brands.find(b => String(b._id || b.id) === String(brandId))
if (!brand) return null
return lang === 'ar' && brand.name_ar ? brand.name_ar : brand.name
}
const loadTeam = async () => {
try {
const data = await api.get('/users/team')
@@ -115,7 +123,7 @@ function AppContent() {
}
return (
<AppContext.Provider value={{ currentUser: user, teamMembers, brands, loadTeam }}>
<AppContext.Provider value={{ currentUser: user, teamMembers, brands, loadTeam, getBrandName }}>
{/* 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">
@@ -261,6 +269,7 @@ function AppContent() {
<Route path="projects/:id" element={<ProjectDetail />} />
<Route path="tasks" element={<Tasks />} />
<Route path="team" element={<Team />} />
<Route path="brands" element={<Brands />} />
<Route path="settings" element={<Settings />} />
{user?.role === 'superadmin' && (
<Route path="users" element={<Users />} />

View File

@@ -1,13 +1,17 @@
import { useContext } from 'react'
import { format } from 'date-fns'
import { ArrowRight } from 'lucide-react'
import { getInitials } from '../utils/api'
import { useLanguage } from '../i18n/LanguageContext'
import { AppContext } from '../App'
import BrandBadge from './BrandBadge'
import StatusBadge from './StatusBadge'
import { PlatformIcons } from './PlatformIcon'
export default function PostCard({ post, onClick, onMove, compact = false }) {
const { t } = useLanguage()
const { getBrandName } = useContext(AppContext)
const brandName = getBrandName(post.brand_id || post.brandId) || post.brand_name || post.brand
// Support both single platform and platforms array
const platforms = post.platforms?.length > 0
? post.platforms
@@ -35,7 +39,7 @@ export default function PostCard({ post, onClick, onMove, compact = false }) {
</div>
<div className="flex items-center gap-2 flex-wrap">
{post.brand && <BrandBadge brand={post.brand} />}
{brandName && <BrandBadge brand={brandName} />}
</div>
<div className="flex items-center justify-between mt-3 pt-2 border-t border-border-light">
@@ -101,7 +105,7 @@ export default function PostCard({ post, onClick, onMove, compact = false }) {
<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">{brandName && <BrandBadge brand={brandName} />}</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" />

View File

@@ -1,10 +1,14 @@
import { useContext } from 'react'
import { format } from 'date-fns'
import { useNavigate } from 'react-router-dom'
import { AppContext } from '../App'
import StatusBadge from './StatusBadge'
import BrandBadge from './BrandBadge'
export default function ProjectCard({ project }) {
const navigate = useNavigate()
const { getBrandName } = useContext(AppContext)
const brandLabel = getBrandName(project.brand_id) || project.brand
const completedTasks = project.tasks?.filter(t => t.status === 'done').length || 0
const totalTasks = project.tasks?.length || 0
@@ -22,9 +26,9 @@ export default function ProjectCard({ project }) {
<StatusBadge status={project.status} size="xs" />
</div>
{project.brand && (
{brandLabel && (
<div className="mb-3">
<BrandBadge brand={project.brand} />
<BrandBadge brand={brandLabel} />
</div>
)}

View File

@@ -2,7 +2,7 @@ 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
FolderKanban, CheckSquare, Users, ChevronLeft, ChevronRight, Sparkles, Shield, LogOut, User, Settings, Languages, Tag
} from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
@@ -16,6 +16,7 @@ const navItems = [
{ 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' },
{ to: '/brands', icon: Tag, labelKey: 'nav.brands' },
]
const ROLE_LEVEL = { contributor: 0, manager: 1, superadmin: 2 }

View File

@@ -12,6 +12,7 @@
"nav.settings": "الإعدادات",
"nav.users": "المستخدمين",
"nav.logout": "تسجيل الخروج",
"nav.brands": "العلامات التجارية",
"nav.collapse": "طي",
"common.save": "حفظ",
@@ -119,6 +120,8 @@
"posts.statusUpdated": "تم تحديث حالة المنشور!",
"posts.attachmentDeleted": "تم حذف المرفق!",
"posts.createFirstPost": "أنشئ أول منشور لك للبدء بإنتاج المحتوى.",
"posts.periodFrom": "من",
"posts.periodTo": "إلى",
"posts.tryDifferentFilter": "جرب تعديل الفلاتر لرؤية المزيد من المنشورات.",
"posts.status.draft": "مسودة",
@@ -208,6 +211,21 @@
"assets.upload": "رفع",
"assets.noAssets": "لا توجد أصول",
"brands.title": "العلامات التجارية",
"brands.addBrand": "إضافة علامة",
"brands.editBrand": "تعديل العلامة",
"brands.deleteBrand": "حذف العلامة؟",
"brands.deleteBrandConfirm": "هل أنت متأكد من حذف هذه العلامة؟ ستفقد المنشورات والحملات المرتبطة بها ارتباطها بالعلامة.",
"brands.noBrands": "لا توجد علامات تجارية",
"brands.brandName": "الاسم (إنجليزي)",
"brands.brandNameAr": "الاسم (عربي)",
"brands.brandPriority": "الأولوية",
"brands.brandIcon": "الأيقونة",
"brands.logo": "الشعار",
"brands.uploadLogo": "رفع الشعار",
"brands.changeLogo": "تغيير الشعار",
"brands.manageBrands": "إدارة العلامات التجارية لمؤسستك",
"settings.title": "الإعدادات",
"settings.language": "اللغة",
"settings.english": "English",
@@ -219,6 +237,17 @@
"settings.tutorialRestarted": "تم إعادة تشغيل الدليل!",
"settings.restarting": "جاري إعادة التشغيل...",
"settings.reloadingPage": "جاري إعادة تحميل الصفحة لبدء الدليل...",
"settings.brands": "العلامات التجارية",
"settings.manageBrands": "إدارة العلامات التجارية وأسماء العرض",
"settings.addBrand": "إضافة علامة",
"settings.editBrand": "تعديل العلامة",
"settings.brandName": "الاسم (إنجليزي)",
"settings.brandNameAr": "الاسم (عربي)",
"settings.brandPriority": "الأولوية",
"settings.brandIcon": "الأيقونة",
"settings.deleteBrand": "حذف العلامة؟",
"settings.deleteBrandConfirm": "هل أنت متأكد من حذف هذه العلامة؟ ستفقد المنشورات والحملات المرتبطة بها ارتباطها بالعلامة.",
"settings.noBrands": "لا توجد علامات بعد. أضف أول علامة تجارية.",
"settings.moreComingSoon": "المزيد من الإعدادات قريباً",
"settings.additionalSettings": "سيتم إضافة إعدادات إضافية للإشعارات وتفضيلات العرض والمزيد هنا.",
"settings.preferences": "إدارة تفضيلاتك وإعدادات التطبيق",

View File

@@ -12,6 +12,7 @@
"nav.settings": "Settings",
"nav.users": "Users",
"nav.logout": "Logout",
"nav.brands": "Brands",
"nav.collapse": "Collapse",
"common.save": "Save",
@@ -119,6 +120,8 @@
"posts.statusUpdated": "Post status updated!",
"posts.attachmentDeleted": "Attachment deleted!",
"posts.createFirstPost": "Create your first post to get started with content production.",
"posts.periodFrom": "From",
"posts.periodTo": "To",
"posts.tryDifferentFilter": "Try adjusting your filters to see more posts.",
"posts.status.draft": "Draft",
@@ -208,6 +211,21 @@
"assets.upload": "Upload",
"assets.noAssets": "No assets",
"brands.title": "Brands",
"brands.addBrand": "Add Brand",
"brands.editBrand": "Edit Brand",
"brands.deleteBrand": "Delete Brand?",
"brands.deleteBrandConfirm": "Are you sure you want to delete this brand? Posts and campaigns linked to it will lose their brand association.",
"brands.noBrands": "No brands yet",
"brands.brandName": "Name (English)",
"brands.brandNameAr": "Name (Arabic)",
"brands.brandPriority": "Priority",
"brands.brandIcon": "Icon",
"brands.logo": "Logo",
"brands.uploadLogo": "Upload Logo",
"brands.changeLogo": "Change Logo",
"brands.manageBrands": "Manage your organization's brands",
"settings.title": "Settings",
"settings.language": "Language",
"settings.english": "English",
@@ -219,6 +237,17 @@
"settings.tutorialRestarted": "Tutorial Restarted!",
"settings.restarting": "Restarting...",
"settings.reloadingPage": "Reloading page to start tutorial...",
"settings.brands": "Brands",
"settings.manageBrands": "Manage your brands and their display names",
"settings.addBrand": "Add Brand",
"settings.editBrand": "Edit Brand",
"settings.brandName": "Name (English)",
"settings.brandNameAr": "Name (Arabic)",
"settings.brandPriority": "Priority",
"settings.brandIcon": "Icon",
"settings.deleteBrand": "Delete Brand?",
"settings.deleteBrandConfirm": "Are you sure you want to delete this brand? Posts and campaigns linked to it will lose their brand association.",
"settings.noBrands": "No brands yet. Add your first brand.",
"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",

336
client/src/pages/Brands.jsx Normal file
View File

@@ -0,0 +1,336 @@
import { useState, useEffect, useContext, useRef } from 'react'
import { Tag, Plus, Edit2, Trash2, Upload, Image } from 'lucide-react'
import { api } from '../utils/api'
import { useLanguage } from '../i18n/LanguageContext'
import { useAuth } from '../contexts/AuthContext'
import { AppContext } from '../App'
import Modal from '../components/Modal'
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'
const EMPTY_BRAND = { name: '', name_ar: '', priority: 2, icon: '' }
export default function Brands() {
const { t, lang } = useLanguage()
const { user } = useAuth()
const { getBrandName } = useContext(AppContext)
const isSuperadminOrManager = user?.role === 'superadmin' || user?.role === 'manager'
const [brands, setBrands] = useState([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingBrand, setEditingBrand] = useState(null)
const [brandForm, setBrandForm] = useState(EMPTY_BRAND)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [brandToDelete, setBrandToDelete] = useState(null)
const [uploading, setUploading] = useState(false)
const fileInputRef = useRef(null)
useEffect(() => {
loadBrands()
}, [])
const loadBrands = async () => {
try {
const data = await api.get('/brands')
setBrands(Array.isArray(data) ? data : (data.data || []))
} catch (err) {
console.error('Failed to load brands:', err)
} finally {
setLoading(false)
}
}
const openNewBrand = () => {
setEditingBrand(null)
setBrandForm(EMPTY_BRAND)
setShowModal(true)
}
const openEditBrand = (brand) => {
setEditingBrand(brand)
setBrandForm({
name: brand.name || '',
name_ar: brand.name_ar || '',
priority: brand.priority ?? 2,
icon: brand.icon || '',
})
setShowModal(true)
}
const saveBrand = async () => {
try {
const data = {
name: brandForm.name,
name_ar: brandForm.name_ar || null,
priority: brandForm.priority ? Number(brandForm.priority) : 2,
icon: brandForm.icon || null,
}
if (editingBrand) {
await api.patch(`/brands/${editingBrand.Id || editingBrand._id || editingBrand.id}`, data)
} else {
await api.post('/brands', data)
}
setShowModal(false)
setEditingBrand(null)
loadBrands()
} catch (err) {
console.error('Failed to save brand:', err)
}
}
const confirmDelete = async () => {
if (!brandToDelete) return
try {
await api.delete(`/brands/${brandToDelete.Id || brandToDelete._id || brandToDelete.id}`)
setBrandToDelete(null)
setShowDeleteModal(false)
loadBrands()
} catch (err) {
console.error('Failed to delete brand:', err)
}
}
const handleLogoUpload = async (brand, file) => {
const brandId = brand.Id || brand._id || brand.id
setUploading(true)
try {
const formData = new FormData()
formData.append('file', file)
const res = await fetch(`${API_BASE}/brands/${brandId}/logo`, {
method: 'POST',
body: formData,
credentials: 'include',
})
if (!res.ok) throw new Error('Upload failed')
loadBrands()
} catch (err) {
console.error('Failed to upload logo:', err)
} finally {
setUploading(false)
}
}
const getBrandId = (brand) => brand.Id || brand._id || brand.id
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="w-8 h-8 border-3 border-brand-primary border-t-transparent rounded-full animate-spin" />
</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">
<Tag className="w-7 h-7 text-brand-primary" />
{t('brands.title')}
</h1>
<p className="text-sm text-text-tertiary mt-1">{t('brands.manageBrands')}</p>
</div>
{isSuperadminOrManager && (
<button
onClick={openNewBrand}
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 shadow-sm transition-colors"
>
<Plus className="w-4 h-4" />
{t('brands.addBrand')}
</button>
)}
</div>
{/* Brand Cards Grid */}
{brands.length === 0 ? (
<div className="bg-white rounded-xl border border-border py-16 text-center">
<Tag className="w-12 h-12 text-text-quaternary mx-auto mb-3" />
<p className="text-sm text-text-tertiary">{t('brands.noBrands')}</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{brands.map(brand => {
const displayName = lang === 'ar' && brand.name_ar ? brand.name_ar : brand.name
return (
<div
key={getBrandId(brand)}
className={`bg-white rounded-xl border border-border overflow-hidden hover:shadow-md transition-all ${isSuperadminOrManager ? 'cursor-pointer' : ''}`}
onClick={() => isSuperadminOrManager && openEditBrand(brand)}
>
{/* Logo area */}
<div className="h-32 bg-surface-secondary flex items-center justify-center relative">
{brand.logo ? (
<img
src={`${API_BASE}/uploads/${brand.logo}`}
alt={displayName}
className="w-full h-full object-contain p-4"
/>
) : (
<div className="text-4xl">
{brand.icon || <Image className="w-12 h-12 text-text-quaternary" />}
</div>
)}
{isSuperadminOrManager && (
<div className="absolute top-2 right-2 flex gap-1" onClick={e => e.stopPropagation()}>
<button
onClick={() => openEditBrand(brand)}
className="p-1.5 bg-white/90 hover:bg-white rounded-lg text-text-tertiary hover:text-text-primary shadow-sm"
title={t('common.edit')}
>
<Edit2 className="w-3.5 h-3.5" />
</button>
<button
onClick={() => { setBrandToDelete(brand); setShowDeleteModal(true) }}
className="p-1.5 bg-white/90 hover:bg-white rounded-lg text-text-tertiary hover:text-red-500 shadow-sm"
title={t('common.delete')}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
{/* Card body */}
<div className="p-4">
<div className="flex items-center gap-2 mb-1">
{brand.icon && <span className="text-lg">{brand.icon}</span>}
<h3 className="text-sm font-semibold text-text-primary truncate">{displayName}</h3>
</div>
<div className="flex items-center gap-3 text-[11px] text-text-tertiary">
{brand.name && <span>EN: {brand.name}</span>}
{brand.name_ar && <span>AR: {brand.name_ar}</span>}
<span>Priority: {brand.priority ?? '—'}</span>
</div>
</div>
</div>
)
})}
</div>
)}
{/* Create/Edit Modal */}
<Modal
isOpen={showModal}
onClose={() => { setShowModal(false); setEditingBrand(null) }}
title={editingBrand ? t('brands.editBrand') : t('brands.addBrand')}
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('brands.brandName')} *</label>
<input
type="text"
value={brandForm.name}
onChange={e => setBrandForm(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="Brand name in English"
dir="ltr"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('brands.brandNameAr')}</label>
<input
type="text"
value={brandForm.name_ar}
onChange={e => setBrandForm(f => ({ ...f, name_ar: 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="اسم العلامة بالعربي"
dir="rtl"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('brands.brandPriority')}</label>
<input
type="number"
value={brandForm.priority}
onChange={e => setBrandForm(f => ({ ...f, priority: e.target.value }))}
min="1"
max="10"
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('brands.brandIcon')}</label>
<input
type="text"
value={brandForm.icon}
onChange={e => setBrandForm(f => ({ ...f, icon: 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="emoji"
/>
</div>
</div>
{/* Logo upload — only for existing brands */}
{editingBrand && (
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('brands.logo')}</label>
{editingBrand.logo && (
<div className="mb-2 p-2 bg-surface-secondary rounded-lg inline-block">
<img
src={`${API_BASE}/uploads/${editingBrand.logo}`}
alt="Logo"
className="h-16 object-contain"
/>
</div>
)}
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={e => {
const file = e.target.files?.[0]
if (file) handleLogoUpload(editingBrand, file)
e.target.value = ''
}}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border border-border rounded-lg hover:bg-surface-secondary transition-colors disabled:opacity-50"
>
<Upload className="w-3.5 h-3.5" />
{uploading ? t('common.loading') : editingBrand.logo ? t('brands.changeLogo') : t('brands.uploadLogo')}
</button>
</div>
</div>
)}
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<button
onClick={() => { setShowModal(false); setEditingBrand(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={saveBrand}
disabled={!brandForm.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"
>
{t('common.save')}
</button>
</div>
</div>
</Modal>
{/* Delete Confirmation */}
<Modal
isOpen={showDeleteModal}
onClose={() => { setShowDeleteModal(false); setBrandToDelete(null) }}
title={t('brands.deleteBrand')}
isConfirm
danger
confirmText={t('common.delete')}
onConfirm={confirmDelete}
>
{t('brands.deleteBrandConfirm')}
</Modal>
</div>
)
}

View File

@@ -4,6 +4,7 @@ import { ArrowLeft, Plus, Edit2, Trash2, DollarSign, Eye, MousePointer, Target,
import { format } from 'date-fns'
import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
import { api, PLATFORMS, getInitials } from '../utils/api'
import PlatformIcon, { PlatformIcons } from '../components/PlatformIcon'
import StatusBadge from '../components/StatusBadge'
@@ -43,7 +44,8 @@ function MetricBox({ label, value, icon: Icon, color = 'text-text-primary' }) {
export default function CampaignDetail() {
const { id } = useParams()
const navigate = useNavigate()
const { brands } = useContext(AppContext)
const { brands, getBrandName } = useContext(AppContext)
const { lang } = useLanguage()
const { permissions, user } = useAuth()
const isSuperadmin = user?.role === 'superadmin'
const [campaign, setCampaign] = useState(null)
@@ -69,6 +71,7 @@ export default function CampaignDetail() {
const [showDiscussion, setShowDiscussion] = useState(false)
const [showEditModal, setShowEditModal] = useState(false)
const [editForm, setEditForm] = useState({})
const [showDeleteCampaignConfirm, setShowDeleteCampaignConfirm] = useState(false)
const isCreator = campaign?.createdByUserId === user?.id || campaign?.created_by_user_id === user?.id
const canManage = isSuperadmin || (permissions?.canEditCampaigns && isCreator)
@@ -215,6 +218,8 @@ export default function CampaignDetail() {
goals: campaign.goals || '',
platforms: campaign.platforms || [],
notes: campaign.notes || '',
brand_id: campaign.brand_id || '',
budget: campaign.budget || '',
})
setShowEditModal(true)
}
@@ -230,6 +235,8 @@ export default function CampaignDetail() {
goals: editForm.goals,
platforms: editForm.platforms,
notes: editForm.notes,
brand_id: editForm.brand_id || null,
budget: editForm.budget ? Number(editForm.budget) : null,
})
setShowEditModal(false)
loadAll()
@@ -284,7 +291,7 @@ export default function CampaignDetail() {
<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} />}
{(campaign.brand_id || campaign.brand_name) && <BrandBadge brand={getBrandName(campaign.brand_id) || campaign.brand_name} />}
</div>
{campaign.description && <p className="text-sm text-text-secondary">{campaign.description}</p>}
<div className="flex items-center gap-3 mt-2 text-xs text-text-tertiary">
@@ -852,6 +859,19 @@ export default function CampaignDetail() {
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">Brand</label>
<select
value={editForm.brand_id || ''}
onChange={e => setEditForm(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="">No brand</option>
{brands.map(b => (
<option key={b.id} value={b.id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Description</label>
<textarea
@@ -861,7 +881,7 @@ export default function CampaignDetail() {
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"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Status</label>
<select
@@ -885,6 +905,17 @@ export default function CampaignDetail() {
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">Budget (SAR)</label>
<input
type="number"
value={editForm.budget || ''}
onChange={e => setEditForm(f => ({ ...f, budget: e.target.value }))}
min="0"
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="0"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
@@ -949,6 +980,14 @@ export default function CampaignDetail() {
/>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
{permissions?.canDeleteCampaigns && (
<button
onClick={() => setShowDeleteCampaignConfirm(true)}
className="px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg mr-auto"
>
Delete Campaign
</button>
)}
<button
onClick={() => setShowEditModal(false)}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
@@ -966,6 +1005,28 @@ export default function CampaignDetail() {
</div>
</Modal>
{/* Delete Campaign Confirmation */}
<Modal
isOpen={showDeleteCampaignConfirm}
onClose={() => setShowDeleteCampaignConfirm(false)}
title="Delete Campaign?"
isConfirm
danger
confirmText="Delete Campaign"
onConfirm={async () => {
try {
await api.delete(`/campaigns/${id}`)
setShowDeleteCampaignConfirm(false)
setShowEditModal(false)
navigate('/campaigns')
} catch (err) {
console.error('Failed to delete campaign:', err)
}
}}
>
Are you sure you want to delete this campaign? All tracks and linked data will be permanently removed. This action cannot be undone.
</Modal>
{/* Post Detail Modal */}
<Modal
isOpen={!!selectedPost}

View File

@@ -4,6 +4,7 @@ import { Plus, Search, TrendingUp, DollarSign, Eye, MousePointer, Target, BarCha
import { format } from 'date-fns'
import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
import { api, PLATFORMS } from '../utils/api'
import { PlatformIcons } from '../components/PlatformIcon'
import StatusBadge from '../components/StatusBadge'
@@ -40,7 +41,8 @@ function MetricCard({ icon: Icon, label, value, color = 'text-text-primary' }) {
}
export default function Campaigns() {
const { brands } = useContext(AppContext)
const { brands, getBrandName } = useContext(AppContext)
const { lang } = useLanguage()
const { permissions } = useAuth()
const navigate = useNavigate()
const [campaigns, setCampaigns] = useState([])
@@ -163,7 +165,7 @@ export default function Campaigns() {
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>)}
{brands.map(b => <option key={b.id || b._id} value={b.id || b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
</select>
<select
@@ -292,7 +294,7 @@ export default function Campaigns() {
<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} />}
{(campaign.brand_id || campaign.brandName) && <BrandBadge brand={getBrandName(campaign.brand_id) || campaign.brandName} />}
<ROIBadge revenue={campaign.revenue || 0} spent={spent} />
</div>
{campaign.description && (
@@ -399,7 +401,7 @@ export default function Campaigns() {
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>)}
{brands.map(b => <option key={b.id || b._id} value={b.id || b._id}>{b.icon} {lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
</select>
</div>
<div>

View File

@@ -20,7 +20,7 @@ const EMPTY_POST = {
}
export default function PostProduction() {
const { t } = useLanguage()
const { t, lang } = useLanguage()
const { teamMembers, brands } = useContext(AppContext)
const { canEditResource, canDeleteResource } = useAuth()
const toast = useToast()
@@ -32,7 +32,7 @@ export default function PostProduction() {
const [editingPost, setEditingPost] = useState(null)
const [formData, setFormData] = useState(EMPTY_POST)
const [campaigns, setCampaigns] = useState([])
const [filters, setFilters] = useState({ brand: '', platform: '', assignedTo: '', campaign: '' })
const [filters, setFilters] = useState({ brand: '', platform: '', assignedTo: '', campaign: '', periodFrom: '', periodTo: '' })
const [searchTerm, setSearchTerm] = useState('')
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [attachments, setAttachments] = useState([])
@@ -259,6 +259,13 @@ export default function PostProduction() {
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
if (filters.periodFrom || filters.periodTo) {
const postDate = p.scheduledDate || p.scheduled_date || p.published_date || p.publishedDate
if (!postDate) return false
const d = new Date(postDate).toISOString().slice(0, 10)
if (filters.periodFrom && d < filters.periodFrom) return false
if (filters.periodTo && d > filters.periodTo) return false
}
return true
})
@@ -290,7 +297,7 @@ export default function PostProduction() {
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>)}
{brands.map(b => <option key={b._id} value={b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
</select>
<select
@@ -310,6 +317,25 @@ export default function PostProduction() {
<option value="">{t('posts.allPeople')}</option>
{teamMembers.map(m => <option key={m._id} value={String(m._id)}>{m.name}</option>)}
</select>
{/* Period filter */}
<div className="flex items-center gap-1.5">
<input
type="date"
value={filters.periodFrom}
onChange={e => setFilters(f => ({ ...f, periodFrom: e.target.value }))}
title={t('posts.periodFrom')}
className="text-sm border border-border rounded-lg px-2 py-2 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/>
<span className="text-xs text-text-tertiary"></span>
<input
type="date"
value={filters.periodTo}
onChange={e => setFilters(f => ({ ...f, periodTo: e.target.value }))}
title={t('posts.periodTo')}
className="text-sm border border-border rounded-lg px-2 py-2 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/>
</div>
</div>
{/* View toggle */}
@@ -363,7 +389,7 @@ export default function PostProduction() {
onAction={posts.length === 0 ? openNew : null}
secondaryActionLabel={posts.length > 0 ? t('common.clearFilters') : null}
onSecondaryAction={() => {
setFilters({ brand: '', platform: '', assignedTo: '', campaign: '' })
setFilters({ brand: '', platform: '', assignedTo: '', campaign: '', periodFrom: '', periodTo: '' })
setSearchTerm('')
}}
/>
@@ -441,7 +467,7 @@ export default function PostProduction() {
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>)}
{brands.map(b => <option key={b._id} value={b._id}>{b.icon} {lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
</select>
</div>
<div>

View File

@@ -93,18 +93,6 @@ export default function Settings() {
)}
</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>
)
}