adding brand management
This commit is contained in:
@@ -16,6 +16,7 @@ import Tasks from './pages/Tasks'
|
|||||||
import Team from './pages/Team'
|
import Team from './pages/Team'
|
||||||
import Users from './pages/Users'
|
import Users from './pages/Users'
|
||||||
import Settings from './pages/Settings'
|
import Settings from './pages/Settings'
|
||||||
|
import Brands from './pages/Brands'
|
||||||
import Login from './pages/Login'
|
import Login from './pages/Login'
|
||||||
import Tutorial from './components/Tutorial'
|
import Tutorial from './components/Tutorial'
|
||||||
import Modal from './components/Modal'
|
import Modal from './components/Modal'
|
||||||
@@ -40,7 +41,7 @@ export const AppContext = createContext()
|
|||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
const { user, loading: authLoading, checkAuth } = useAuth()
|
const { user, loading: authLoading, checkAuth } = useAuth()
|
||||||
const { t } = useLanguage()
|
const { t, lang } = useLanguage()
|
||||||
const [teamMembers, setTeamMembers] = useState([])
|
const [teamMembers, setTeamMembers] = useState([])
|
||||||
const [brands, setBrands] = useState([])
|
const [brands, setBrands] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -68,6 +69,13 @@ function AppContent() {
|
|||||||
}
|
}
|
||||||
}, [user, authLoading])
|
}, [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 () => {
|
const loadTeam = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await api.get('/users/team')
|
const data = await api.get('/users/team')
|
||||||
@@ -115,7 +123,7 @@ function AppContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppContext.Provider value={{ currentUser: user, teamMembers, brands, loadTeam }}>
|
<AppContext.Provider value={{ currentUser: user, teamMembers, brands, loadTeam, getBrandName }}>
|
||||||
{/* Profile completion prompt */}
|
{/* Profile completion prompt */}
|
||||||
{showProfilePrompt && (
|
{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="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="projects/:id" element={<ProjectDetail />} />
|
||||||
<Route path="tasks" element={<Tasks />} />
|
<Route path="tasks" element={<Tasks />} />
|
||||||
<Route path="team" element={<Team />} />
|
<Route path="team" element={<Team />} />
|
||||||
|
<Route path="brands" element={<Brands />} />
|
||||||
<Route path="settings" element={<Settings />} />
|
<Route path="settings" element={<Settings />} />
|
||||||
{user?.role === 'superadmin' && (
|
{user?.role === 'superadmin' && (
|
||||||
<Route path="users" element={<Users />} />
|
<Route path="users" element={<Users />} />
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
|
import { useContext } from 'react'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { ArrowRight } from 'lucide-react'
|
import { ArrowRight } from 'lucide-react'
|
||||||
import { getInitials } from '../utils/api'
|
import { getInitials } from '../utils/api'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
|
import { AppContext } from '../App'
|
||||||
import BrandBadge from './BrandBadge'
|
import BrandBadge from './BrandBadge'
|
||||||
import StatusBadge from './StatusBadge'
|
import StatusBadge from './StatusBadge'
|
||||||
import { PlatformIcons } from './PlatformIcon'
|
import { PlatformIcons } from './PlatformIcon'
|
||||||
|
|
||||||
export default function PostCard({ post, onClick, onMove, compact = false }) {
|
export default function PostCard({ post, onClick, onMove, compact = false }) {
|
||||||
const { t } = useLanguage()
|
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
|
// Support both single platform and platforms array
|
||||||
const platforms = post.platforms?.length > 0
|
const platforms = post.platforms?.length > 0
|
||||||
? post.platforms
|
? post.platforms
|
||||||
@@ -35,7 +39,7 @@ export default function PostCard({ post, onClick, onMove, compact = false }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{post.brand && <BrandBadge brand={post.brand} />}
|
{brandName && <BrandBadge brand={brandName} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between mt-3 pt-2 border-t border-border-light">
|
<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>
|
<span className="text-sm font-medium text-text-primary">{post.title}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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"><StatusBadge status={post.status} /></td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<PlatformIcons platforms={platforms} size={16} gap="gap-1.5" />
|
<PlatformIcons platforms={platforms} size={16} gap="gap-1.5" />
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
|
import { useContext } from 'react'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { AppContext } from '../App'
|
||||||
import StatusBadge from './StatusBadge'
|
import StatusBadge from './StatusBadge'
|
||||||
import BrandBadge from './BrandBadge'
|
import BrandBadge from './BrandBadge'
|
||||||
|
|
||||||
export default function ProjectCard({ project }) {
|
export default function ProjectCard({ project }) {
|
||||||
const navigate = useNavigate()
|
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 completedTasks = project.tasks?.filter(t => t.status === 'done').length || 0
|
||||||
const totalTasks = project.tasks?.length || 0
|
const totalTasks = project.tasks?.length || 0
|
||||||
@@ -22,9 +26,9 @@ export default function ProjectCard({ project }) {
|
|||||||
<StatusBadge status={project.status} size="xs" />
|
<StatusBadge status={project.status} size="xs" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{project.brand && (
|
{brandLabel && (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<BrandBadge brand={project.brand} />
|
<BrandBadge brand={brandLabel} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useContext } from 'react'
|
|||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, FileEdit, Image, Calendar, Wallet,
|
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'
|
} from 'lucide-react'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
@@ -16,6 +16,7 @@ const navItems = [
|
|||||||
{ to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
|
{ to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
|
||||||
{ to: '/tasks', icon: CheckSquare, labelKey: 'nav.tasks', tutorial: 'tasks' },
|
{ to: '/tasks', icon: CheckSquare, labelKey: 'nav.tasks', tutorial: 'tasks' },
|
||||||
{ to: '/team', icon: Users, labelKey: 'nav.team', tutorial: 'team' },
|
{ 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 }
|
const ROLE_LEVEL = { contributor: 0, manager: 1, superadmin: 2 }
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"nav.settings": "الإعدادات",
|
"nav.settings": "الإعدادات",
|
||||||
"nav.users": "المستخدمين",
|
"nav.users": "المستخدمين",
|
||||||
"nav.logout": "تسجيل الخروج",
|
"nav.logout": "تسجيل الخروج",
|
||||||
|
"nav.brands": "العلامات التجارية",
|
||||||
"nav.collapse": "طي",
|
"nav.collapse": "طي",
|
||||||
|
|
||||||
"common.save": "حفظ",
|
"common.save": "حفظ",
|
||||||
@@ -119,6 +120,8 @@
|
|||||||
"posts.statusUpdated": "تم تحديث حالة المنشور!",
|
"posts.statusUpdated": "تم تحديث حالة المنشور!",
|
||||||
"posts.attachmentDeleted": "تم حذف المرفق!",
|
"posts.attachmentDeleted": "تم حذف المرفق!",
|
||||||
"posts.createFirstPost": "أنشئ أول منشور لك للبدء بإنتاج المحتوى.",
|
"posts.createFirstPost": "أنشئ أول منشور لك للبدء بإنتاج المحتوى.",
|
||||||
|
"posts.periodFrom": "من",
|
||||||
|
"posts.periodTo": "إلى",
|
||||||
"posts.tryDifferentFilter": "جرب تعديل الفلاتر لرؤية المزيد من المنشورات.",
|
"posts.tryDifferentFilter": "جرب تعديل الفلاتر لرؤية المزيد من المنشورات.",
|
||||||
|
|
||||||
"posts.status.draft": "مسودة",
|
"posts.status.draft": "مسودة",
|
||||||
@@ -208,6 +211,21 @@
|
|||||||
"assets.upload": "رفع",
|
"assets.upload": "رفع",
|
||||||
"assets.noAssets": "لا توجد أصول",
|
"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.title": "الإعدادات",
|
||||||
"settings.language": "اللغة",
|
"settings.language": "اللغة",
|
||||||
"settings.english": "English",
|
"settings.english": "English",
|
||||||
@@ -219,6 +237,17 @@
|
|||||||
"settings.tutorialRestarted": "تم إعادة تشغيل الدليل!",
|
"settings.tutorialRestarted": "تم إعادة تشغيل الدليل!",
|
||||||
"settings.restarting": "جاري إعادة التشغيل...",
|
"settings.restarting": "جاري إعادة التشغيل...",
|
||||||
"settings.reloadingPage": "جاري إعادة تحميل الصفحة لبدء الدليل...",
|
"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.moreComingSoon": "المزيد من الإعدادات قريباً",
|
||||||
"settings.additionalSettings": "سيتم إضافة إعدادات إضافية للإشعارات وتفضيلات العرض والمزيد هنا.",
|
"settings.additionalSettings": "سيتم إضافة إعدادات إضافية للإشعارات وتفضيلات العرض والمزيد هنا.",
|
||||||
"settings.preferences": "إدارة تفضيلاتك وإعدادات التطبيق",
|
"settings.preferences": "إدارة تفضيلاتك وإعدادات التطبيق",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"nav.settings": "Settings",
|
"nav.settings": "Settings",
|
||||||
"nav.users": "Users",
|
"nav.users": "Users",
|
||||||
"nav.logout": "Logout",
|
"nav.logout": "Logout",
|
||||||
|
"nav.brands": "Brands",
|
||||||
"nav.collapse": "Collapse",
|
"nav.collapse": "Collapse",
|
||||||
|
|
||||||
"common.save": "Save",
|
"common.save": "Save",
|
||||||
@@ -119,6 +120,8 @@
|
|||||||
"posts.statusUpdated": "Post status updated!",
|
"posts.statusUpdated": "Post status updated!",
|
||||||
"posts.attachmentDeleted": "Attachment deleted!",
|
"posts.attachmentDeleted": "Attachment deleted!",
|
||||||
"posts.createFirstPost": "Create your first post to get started with content production.",
|
"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.tryDifferentFilter": "Try adjusting your filters to see more posts.",
|
||||||
|
|
||||||
"posts.status.draft": "Draft",
|
"posts.status.draft": "Draft",
|
||||||
@@ -208,6 +211,21 @@
|
|||||||
"assets.upload": "Upload",
|
"assets.upload": "Upload",
|
||||||
"assets.noAssets": "No assets",
|
"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.title": "Settings",
|
||||||
"settings.language": "Language",
|
"settings.language": "Language",
|
||||||
"settings.english": "English",
|
"settings.english": "English",
|
||||||
@@ -219,6 +237,17 @@
|
|||||||
"settings.tutorialRestarted": "Tutorial Restarted!",
|
"settings.tutorialRestarted": "Tutorial Restarted!",
|
||||||
"settings.restarting": "Restarting...",
|
"settings.restarting": "Restarting...",
|
||||||
"settings.reloadingPage": "Reloading page to start tutorial...",
|
"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.moreComingSoon": "More Settings Coming Soon",
|
||||||
"settings.additionalSettings": "Additional settings for notifications, display preferences, and more will be added here.",
|
"settings.additionalSettings": "Additional settings for notifications, display preferences, and more will be added here.",
|
||||||
"settings.preferences": "Manage your preferences and app settings",
|
"settings.preferences": "Manage your preferences and app settings",
|
||||||
|
|||||||
336
client/src/pages/Brands.jsx
Normal file
336
client/src/pages/Brands.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { ArrowLeft, Plus, Edit2, Trash2, DollarSign, Eye, MousePointer, Target,
|
|||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { AppContext } from '../App'
|
import { AppContext } from '../App'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
import { api, PLATFORMS, getInitials } from '../utils/api'
|
import { api, PLATFORMS, getInitials } from '../utils/api'
|
||||||
import PlatformIcon, { PlatformIcons } from '../components/PlatformIcon'
|
import PlatformIcon, { PlatformIcons } from '../components/PlatformIcon'
|
||||||
import StatusBadge from '../components/StatusBadge'
|
import StatusBadge from '../components/StatusBadge'
|
||||||
@@ -43,7 +44,8 @@ function MetricBox({ label, value, icon: Icon, color = 'text-text-primary' }) {
|
|||||||
export default function CampaignDetail() {
|
export default function CampaignDetail() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { brands } = useContext(AppContext)
|
const { brands, getBrandName } = useContext(AppContext)
|
||||||
|
const { lang } = useLanguage()
|
||||||
const { permissions, user } = useAuth()
|
const { permissions, user } = useAuth()
|
||||||
const isSuperadmin = user?.role === 'superadmin'
|
const isSuperadmin = user?.role === 'superadmin'
|
||||||
const [campaign, setCampaign] = useState(null)
|
const [campaign, setCampaign] = useState(null)
|
||||||
@@ -69,6 +71,7 @@ export default function CampaignDetail() {
|
|||||||
const [showDiscussion, setShowDiscussion] = useState(false)
|
const [showDiscussion, setShowDiscussion] = useState(false)
|
||||||
const [showEditModal, setShowEditModal] = useState(false)
|
const [showEditModal, setShowEditModal] = useState(false)
|
||||||
const [editForm, setEditForm] = useState({})
|
const [editForm, setEditForm] = useState({})
|
||||||
|
const [showDeleteCampaignConfirm, setShowDeleteCampaignConfirm] = useState(false)
|
||||||
|
|
||||||
const isCreator = campaign?.createdByUserId === user?.id || campaign?.created_by_user_id === user?.id
|
const isCreator = campaign?.createdByUserId === user?.id || campaign?.created_by_user_id === user?.id
|
||||||
const canManage = isSuperadmin || (permissions?.canEditCampaigns && isCreator)
|
const canManage = isSuperadmin || (permissions?.canEditCampaigns && isCreator)
|
||||||
@@ -215,6 +218,8 @@ export default function CampaignDetail() {
|
|||||||
goals: campaign.goals || '',
|
goals: campaign.goals || '',
|
||||||
platforms: campaign.platforms || [],
|
platforms: campaign.platforms || [],
|
||||||
notes: campaign.notes || '',
|
notes: campaign.notes || '',
|
||||||
|
brand_id: campaign.brand_id || '',
|
||||||
|
budget: campaign.budget || '',
|
||||||
})
|
})
|
||||||
setShowEditModal(true)
|
setShowEditModal(true)
|
||||||
}
|
}
|
||||||
@@ -230,6 +235,8 @@ export default function CampaignDetail() {
|
|||||||
goals: editForm.goals,
|
goals: editForm.goals,
|
||||||
platforms: editForm.platforms,
|
platforms: editForm.platforms,
|
||||||
notes: editForm.notes,
|
notes: editForm.notes,
|
||||||
|
brand_id: editForm.brand_id || null,
|
||||||
|
budget: editForm.budget ? Number(editForm.budget) : null,
|
||||||
})
|
})
|
||||||
setShowEditModal(false)
|
setShowEditModal(false)
|
||||||
loadAll()
|
loadAll()
|
||||||
@@ -284,7 +291,7 @@ export default function CampaignDetail() {
|
|||||||
<div className="flex items-center gap-3 mb-1">
|
<div className="flex items-center gap-3 mb-1">
|
||||||
<h1 className="text-xl font-bold text-text-primary">{campaign.name}</h1>
|
<h1 className="text-xl font-bold text-text-primary">{campaign.name}</h1>
|
||||||
<StatusBadge status={campaign.status} />
|
<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>
|
</div>
|
||||||
{campaign.description && <p className="text-sm text-text-secondary">{campaign.description}</p>}
|
{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">
|
<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"
|
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>
|
||||||
|
<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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Description</label>
|
<label className="block text-sm font-medium text-text-primary mb-1">Description</label>
|
||||||
<textarea
|
<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"
|
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>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Status</label>
|
<label className="block text-sm font-medium text-text-primary mb-1">Status</label>
|
||||||
<select
|
<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"
|
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>
|
||||||
|
<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>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -949,6 +980,14 @@ export default function CampaignDetail() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
<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
|
<button
|
||||||
onClick={() => setShowEditModal(false)}
|
onClick={() => setShowEditModal(false)}
|
||||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
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>
|
</div>
|
||||||
</Modal>
|
</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 */}
|
{/* Post Detail Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={!!selectedPost}
|
isOpen={!!selectedPost}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Plus, Search, TrendingUp, DollarSign, Eye, MousePointer, Target, BarCha
|
|||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { AppContext } from '../App'
|
import { AppContext } from '../App'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
import { api, PLATFORMS } from '../utils/api'
|
import { api, PLATFORMS } from '../utils/api'
|
||||||
import { PlatformIcons } from '../components/PlatformIcon'
|
import { PlatformIcons } from '../components/PlatformIcon'
|
||||||
import StatusBadge from '../components/StatusBadge'
|
import StatusBadge from '../components/StatusBadge'
|
||||||
@@ -40,7 +41,8 @@ function MetricCard({ icon: Icon, label, value, color = 'text-text-primary' }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Campaigns() {
|
export default function Campaigns() {
|
||||||
const { brands } = useContext(AppContext)
|
const { brands, getBrandName } = useContext(AppContext)
|
||||||
|
const { lang } = useLanguage()
|
||||||
const { permissions } = useAuth()
|
const { permissions } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [campaigns, setCampaigns] = useState([])
|
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"
|
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>
|
<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>
|
||||||
|
|
||||||
<select
|
<select
|
||||||
@@ -292,7 +294,7 @@ export default function Campaigns() {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<h4 className="text-sm font-semibold text-text-primary">{campaign.name}</h4>
|
<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} />
|
<ROIBadge revenue={campaign.revenue || 0} spent={spent} />
|
||||||
</div>
|
</div>
|
||||||
{campaign.description && (
|
{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"
|
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>
|
<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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const EMPTY_POST = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function PostProduction() {
|
export default function PostProduction() {
|
||||||
const { t } = useLanguage()
|
const { t, lang } = useLanguage()
|
||||||
const { teamMembers, brands } = useContext(AppContext)
|
const { teamMembers, brands } = useContext(AppContext)
|
||||||
const { canEditResource, canDeleteResource } = useAuth()
|
const { canEditResource, canDeleteResource } = useAuth()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -32,7 +32,7 @@ export default function PostProduction() {
|
|||||||
const [editingPost, setEditingPost] = useState(null)
|
const [editingPost, setEditingPost] = useState(null)
|
||||||
const [formData, setFormData] = useState(EMPTY_POST)
|
const [formData, setFormData] = useState(EMPTY_POST)
|
||||||
const [campaigns, setCampaigns] = useState([])
|
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 [searchTerm, setSearchTerm] = useState('')
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
const [attachments, setAttachments] = useState([])
|
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.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 (filters.campaign && String(p.campaignId || p.campaign_id) !== filters.campaign) return false
|
||||||
if (searchTerm && !p.title?.toLowerCase().includes(searchTerm.toLowerCase())) 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
|
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"
|
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>
|
<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>
|
||||||
|
|
||||||
<select
|
<select
|
||||||
@@ -310,6 +317,25 @@ export default function PostProduction() {
|
|||||||
<option value="">{t('posts.allPeople')}</option>
|
<option value="">{t('posts.allPeople')}</option>
|
||||||
{teamMembers.map(m => <option key={m._id} value={String(m._id)}>{m.name}</option>)}
|
{teamMembers.map(m => <option key={m._id} value={String(m._id)}>{m.name}</option>)}
|
||||||
</select>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* View toggle */}
|
{/* View toggle */}
|
||||||
@@ -363,7 +389,7 @@ export default function PostProduction() {
|
|||||||
onAction={posts.length === 0 ? openNew : null}
|
onAction={posts.length === 0 ? openNew : null}
|
||||||
secondaryActionLabel={posts.length > 0 ? t('common.clearFilters') : null}
|
secondaryActionLabel={posts.length > 0 ? t('common.clearFilters') : null}
|
||||||
onSecondaryAction={() => {
|
onSecondaryAction={() => {
|
||||||
setFilters({ brand: '', platform: '', assignedTo: '', campaign: '' })
|
setFilters({ brand: '', platform: '', assignedTo: '', campaign: '', periodFrom: '', periodTo: '' })
|
||||||
setSearchTerm('')
|
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"
|
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>
|
<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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -93,18 +93,6 @@ export default function Settings() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
372
server/db.js
372
server/db.js
@@ -1,372 +0,0 @@
|
|||||||
const Database = require('better-sqlite3');
|
|
||||||
const path = require('path');
|
|
||||||
const bcrypt = require('bcrypt');
|
|
||||||
|
|
||||||
const DB_PATH = path.join(__dirname, 'marketing.db');
|
|
||||||
|
|
||||||
const db = new Database(DB_PATH);
|
|
||||||
|
|
||||||
// Enable WAL mode and foreign keys
|
|
||||||
db.pragma('journal_mode = WAL');
|
|
||||||
db.pragma('foreign_keys = ON');
|
|
||||||
|
|
||||||
function initialize() {
|
|
||||||
// Create tables
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS team_members (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
email TEXT,
|
|
||||||
role TEXT,
|
|
||||||
avatar_url TEXT,
|
|
||||||
brands TEXT DEFAULT '[]',
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS brands (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
priority INTEGER DEFAULT 2,
|
|
||||||
color TEXT,
|
|
||||||
icon TEXT,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS posts (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
brand_id INTEGER REFERENCES brands(id),
|
|
||||||
assigned_to INTEGER REFERENCES team_members(id),
|
|
||||||
status TEXT DEFAULT 'draft',
|
|
||||||
platform TEXT,
|
|
||||||
content_type TEXT,
|
|
||||||
scheduled_date DATETIME,
|
|
||||||
published_date DATETIME,
|
|
||||||
notes TEXT,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS assets (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
filename TEXT NOT NULL,
|
|
||||||
original_name TEXT,
|
|
||||||
mime_type TEXT,
|
|
||||||
size INTEGER,
|
|
||||||
tags TEXT DEFAULT '[]',
|
|
||||||
brand_id INTEGER REFERENCES brands(id),
|
|
||||||
campaign_id INTEGER REFERENCES campaigns(id),
|
|
||||||
uploaded_by INTEGER REFERENCES team_members(id),
|
|
||||||
folder TEXT DEFAULT 'general',
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS campaigns (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
brand_id INTEGER REFERENCES brands(id),
|
|
||||||
start_date DATE NOT NULL,
|
|
||||||
end_date DATE NOT NULL,
|
|
||||||
status TEXT DEFAULT 'planning',
|
|
||||||
color TEXT,
|
|
||||||
budget REAL,
|
|
||||||
goals TEXT,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS projects (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
brand_id INTEGER REFERENCES brands(id),
|
|
||||||
owner_id INTEGER REFERENCES team_members(id),
|
|
||||||
status TEXT DEFAULT 'active',
|
|
||||||
priority TEXT DEFAULT 'medium',
|
|
||||||
due_date DATE,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS tasks (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
project_id INTEGER REFERENCES projects(id),
|
|
||||||
assigned_to INTEGER REFERENCES team_members(id),
|
|
||||||
created_by INTEGER REFERENCES team_members(id),
|
|
||||||
status TEXT DEFAULT 'todo',
|
|
||||||
priority TEXT DEFAULT 'medium',
|
|
||||||
due_date DATE,
|
|
||||||
is_personal BOOLEAN DEFAULT 0,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
completed_at DATETIME
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Budget entries table — tracks money received
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS budget_entries (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
label TEXT NOT NULL,
|
|
||||||
amount REAL NOT NULL,
|
|
||||||
source TEXT,
|
|
||||||
campaign_id INTEGER REFERENCES campaigns(id),
|
|
||||||
category TEXT DEFAULT 'marketing',
|
|
||||||
date_received DATE NOT NULL,
|
|
||||||
notes TEXT DEFAULT '',
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Users table for authentication
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
email TEXT NOT NULL UNIQUE,
|
|
||||||
password_hash TEXT NOT NULL,
|
|
||||||
role TEXT NOT NULL DEFAULT 'contributor',
|
|
||||||
avatar TEXT,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Campaign tracks table
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS campaign_tracks (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
campaign_id INTEGER NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
|
|
||||||
name TEXT,
|
|
||||||
type TEXT NOT NULL DEFAULT 'organic_social',
|
|
||||||
platform TEXT,
|
|
||||||
budget_allocated REAL DEFAULT 0,
|
|
||||||
budget_spent REAL DEFAULT 0,
|
|
||||||
revenue REAL DEFAULT 0,
|
|
||||||
impressions INTEGER DEFAULT 0,
|
|
||||||
clicks INTEGER DEFAULT 0,
|
|
||||||
conversions INTEGER DEFAULT 0,
|
|
||||||
notes TEXT DEFAULT '',
|
|
||||||
status TEXT DEFAULT 'planned',
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
|
|
||||||
// ─── Comments / discussion table ───
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS comments (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
entity_type TEXT NOT NULL,
|
|
||||||
entity_id INTEGER NOT NULL,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
|
|
||||||
// ─── Post attachments table ───
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS post_attachments (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
|
||||||
filename TEXT NOT NULL,
|
|
||||||
original_name TEXT,
|
|
||||||
mime_type TEXT,
|
|
||||||
size INTEGER,
|
|
||||||
url TEXT,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Campaign assignments (user-to-campaign junction table)
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS campaign_assignments (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
campaign_id INTEGER NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
assigned_by INTEGER REFERENCES users(id),
|
|
||||||
assigned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(campaign_id, user_id)
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
|
|
||||||
// ─── Column migrations ───
|
|
||||||
// Helper: adds a column to a table if it does not already exist.
|
|
||||||
function addColumnIfMissing(table, column, definition) {
|
|
||||||
const cols = db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name);
|
|
||||||
if (!cols.includes(column)) {
|
|
||||||
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
|
||||||
console.log(`Added ${column} column to ${table}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ownership columns (link to users table)
|
|
||||||
for (const table of ['posts', 'tasks', 'campaigns', 'projects']) {
|
|
||||||
addColumnIfMissing(table, 'created_by_user_id', 'INTEGER REFERENCES users(id)');
|
|
||||||
}
|
|
||||||
|
|
||||||
// team_members additions
|
|
||||||
addColumnIfMissing('team_members', 'phone', 'TEXT');
|
|
||||||
|
|
||||||
// campaigns additions
|
|
||||||
addColumnIfMissing('campaigns', 'platforms', "TEXT DEFAULT '[]'");
|
|
||||||
addColumnIfMissing('campaigns', 'budget_spent', 'REAL DEFAULT 0');
|
|
||||||
addColumnIfMissing('campaigns', 'revenue', 'REAL DEFAULT 0');
|
|
||||||
addColumnIfMissing('campaigns', 'impressions', 'INTEGER DEFAULT 0');
|
|
||||||
addColumnIfMissing('campaigns', 'clicks', 'INTEGER DEFAULT 0');
|
|
||||||
addColumnIfMissing('campaigns', 'conversions', 'INTEGER DEFAULT 0');
|
|
||||||
addColumnIfMissing('campaigns', 'cost_per_click', 'REAL DEFAULT 0');
|
|
||||||
addColumnIfMissing('campaigns', 'notes', "TEXT DEFAULT ''");
|
|
||||||
|
|
||||||
// posts additions
|
|
||||||
addColumnIfMissing('posts', 'track_id', 'INTEGER REFERENCES campaign_tracks(id)');
|
|
||||||
addColumnIfMissing('posts', 'campaign_id', 'INTEGER REFERENCES campaigns(id)');
|
|
||||||
addColumnIfMissing('posts', 'publication_links', "TEXT DEFAULT '[]'");
|
|
||||||
|
|
||||||
// posts.platforms with data migration from single platform field
|
|
||||||
const postCols = db.prepare("PRAGMA table_info(posts)").all().map(c => c.name);
|
|
||||||
if (!postCols.includes('platforms')) {
|
|
||||||
db.exec("ALTER TABLE posts ADD COLUMN platforms TEXT DEFAULT '[]'");
|
|
||||||
const rows = db.prepare("SELECT id, platform FROM posts WHERE platform IS NOT NULL AND platform != ''").all();
|
|
||||||
const migrate = db.prepare("UPDATE posts SET platforms = ? WHERE id = ?");
|
|
||||||
for (const row of rows) {
|
|
||||||
migrate.run(JSON.stringify([row.platform]), row.id);
|
|
||||||
}
|
|
||||||
console.log(`Added platforms column to posts, migrated ${rows.length} rows`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// assets additions
|
|
||||||
addColumnIfMissing('assets', 'campaign_id', 'INTEGER REFERENCES campaigns(id)');
|
|
||||||
|
|
||||||
// users additions
|
|
||||||
addColumnIfMissing('users', 'team_member_id', 'INTEGER REFERENCES team_members(id)');
|
|
||||||
addColumnIfMissing('users', 'team_role', 'TEXT');
|
|
||||||
addColumnIfMissing('users', 'brands', "TEXT DEFAULT '[]'");
|
|
||||||
addColumnIfMissing('users', 'phone', 'TEXT');
|
|
||||||
addColumnIfMissing('users', 'tutorial_completed', 'INTEGER DEFAULT 0');
|
|
||||||
|
|
||||||
// Migrate team_members to users (one-time migration)
|
|
||||||
const teamMembers = db.prepare('SELECT * FROM team_members').all();
|
|
||||||
const defaultPasswordHash = bcrypt.hashSync('changeme123', 10);
|
|
||||||
|
|
||||||
for (const tm of teamMembers) {
|
|
||||||
// Skip team_member id=9 (Fahed) - he's already user id=1
|
|
||||||
if (tm.id === 9) {
|
|
||||||
// Just update his team_role and brands
|
|
||||||
db.prepare('UPDATE users SET team_role = ?, brands = ?, team_member_id = ? WHERE id = 1')
|
|
||||||
.run(tm.role, tm.brands, tm.id);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user already exists with this team_member_id
|
|
||||||
const existingUser = db.prepare('SELECT id FROM users WHERE team_member_id = ?').get(tm.id);
|
|
||||||
if (existingUser) {
|
|
||||||
// User exists, just update team_role and brands
|
|
||||||
db.prepare('UPDATE users SET team_role = ?, brands = ?, phone = ? WHERE id = ?')
|
|
||||||
.run(tm.role, tm.brands, tm.phone || null, existingUser.id);
|
|
||||||
} else {
|
|
||||||
// Create new user for this team member
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO users (name, email, password_hash, role, team_role, brands, phone, team_member_id)
|
|
||||||
VALUES (?, ?, ?, 'contributor', ?, ?, ?, ?)
|
|
||||||
`).run(
|
|
||||||
tm.name,
|
|
||||||
tm.email,
|
|
||||||
defaultPasswordHash,
|
|
||||||
tm.role,
|
|
||||||
tm.brands,
|
|
||||||
tm.phone || null,
|
|
||||||
tm.id
|
|
||||||
);
|
|
||||||
console.log(`✅ Created user account for team member: ${tm.name}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seed data only if tables are empty
|
|
||||||
const memberCount = db.prepare('SELECT COUNT(*) as count FROM team_members').get().count;
|
|
||||||
if (memberCount === 0) {
|
|
||||||
seedData();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seed default superadmin if no users exist
|
|
||||||
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
|
|
||||||
if (userCount === 0) {
|
|
||||||
seedDefaultUser();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function seedData() {
|
|
||||||
const allBrands = JSON.stringify([
|
|
||||||
'Samaya Investment', 'Hira Cultural District', 'Holy Quran Museum',
|
|
||||||
'Al-Safiya Museum', 'Hayhala', 'Jabal Thawr', 'Coffee Chain', 'Taibah Gifts'
|
|
||||||
]);
|
|
||||||
const someBrands = JSON.stringify([
|
|
||||||
'Samaya Investment', 'Hira Cultural District', 'Holy Quran Museum', 'Al-Safiya Museum'
|
|
||||||
]);
|
|
||||||
const mostAccounts = JSON.stringify([
|
|
||||||
'Samaya Investment', 'Hira Cultural District', 'Holy Quran Museum',
|
|
||||||
'Al-Safiya Museum', 'Hayhala', 'Jabal Thawr', 'Coffee Chain'
|
|
||||||
]);
|
|
||||||
const religiousExhibitions = JSON.stringify([
|
|
||||||
'Holy Quran Museum', 'Al-Safiya Museum', 'Jabal Thawr'
|
|
||||||
]);
|
|
||||||
|
|
||||||
const insertMember = db.prepare(`
|
|
||||||
INSERT INTO team_members (name, email, role, brands) VALUES (?, ?, ?, ?)
|
|
||||||
`);
|
|
||||||
|
|
||||||
const members = [
|
|
||||||
['Dr. Muhammad Al-Sayed', 'muhammad.alsayed@samaya.sa', 'approver', allBrands],
|
|
||||||
['Dr. Fahd Al-Thumairi', 'fahd.thumairi@samaya.sa', 'approver', someBrands],
|
|
||||||
['Fahda Abdul Aziz', 'fahda@samaya.sa', 'publisher', mostAccounts],
|
|
||||||
['Sara Al-Zahrani', 'sara@samaya.sa', 'content_creator', JSON.stringify(['Samaya Investment', 'Hira Cultural District', 'Coffee Chain'])],
|
|
||||||
['Noura', 'noura@samaya.sa', 'content_creator', JSON.stringify(['Samaya Investment', 'Hira Cultural District', 'Hayhala', 'Taibah Gifts'])],
|
|
||||||
['Saeed Ghanem', 'saeed@samaya.sa', 'content_creator', religiousExhibitions],
|
|
||||||
['Anas Mater', 'anas@samaya.sa', 'producer', JSON.stringify(['Samaya Investment', 'Hira Cultural District'])],
|
|
||||||
['Muhammad Nu\'man', 'numan@samaya.sa', 'manager', JSON.stringify(['Google Maps'])],
|
|
||||||
['Fahed', 'fahed@samaya.sa', 'manager', allBrands],
|
|
||||||
];
|
|
||||||
|
|
||||||
const insertMembers = db.transaction(() => {
|
|
||||||
for (const m of members) {
|
|
||||||
insertMember.run(...m);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
insertMembers();
|
|
||||||
|
|
||||||
// Seed brands
|
|
||||||
const insertBrand = db.prepare(`
|
|
||||||
INSERT INTO brands (name, priority, color, icon) VALUES (?, ?, ?, ?)
|
|
||||||
`);
|
|
||||||
|
|
||||||
const brands = [
|
|
||||||
['Samaya Investment', 1, '#1E3A5F', '🏢'],
|
|
||||||
['Hira Cultural District', 1, '#8B4513', '🏛️'],
|
|
||||||
['Holy Quran Museum', 1, '#2E7D32', '📖'],
|
|
||||||
['Al-Safiya Museum', 1, '#6A1B9A', '🏺'],
|
|
||||||
['Hayhala', 1, '#C62828', '🎭'],
|
|
||||||
['Jabal Thawr', 1, '#4E342E', '⛰️'],
|
|
||||||
['Coffee Chain', 2, '#795548', '☕'],
|
|
||||||
['Taibah Gifts', 3, '#E65100', '🎁'],
|
|
||||||
];
|
|
||||||
|
|
||||||
const insertBrands = db.transaction(() => {
|
|
||||||
for (const b of brands) {
|
|
||||||
insertBrand.run(...b);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
insertBrands();
|
|
||||||
|
|
||||||
console.log('✅ Database seeded with team members and brands');
|
|
||||||
}
|
|
||||||
|
|
||||||
function seedDefaultUser() {
|
|
||||||
const passwordHash = bcrypt.hashSync('admin123', 10);
|
|
||||||
const insertUser = db.prepare(`
|
|
||||||
INSERT INTO users (name, email, password_hash, role) VALUES (?, ?, ?, ?)
|
|
||||||
`);
|
|
||||||
insertUser.run('Fahed Muhaidi', 'f.mahidi@samayainvest.com', passwordHash, 'superadmin');
|
|
||||||
console.log('✅ Default superadmin created (email: f.mahidi@samayainvest.com, password: admin123)');
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { db, initialize };
|
|
||||||
@@ -1,458 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* migrate-data.js — One-time migration from SQLite to NocoDB.
|
|
||||||
* Run: node migrate-data.js
|
|
||||||
*/
|
|
||||||
|
|
||||||
require('dotenv').config({ path: __dirname + '/.env' });
|
|
||||||
const Database = require('better-sqlite3');
|
|
||||||
const bcrypt = require('bcrypt');
|
|
||||||
const path = require('path');
|
|
||||||
const nocodb = require('./nocodb');
|
|
||||||
const { authDb } = require('./auth-db');
|
|
||||||
|
|
||||||
const sqliteDb = new Database(path.join(__dirname, 'marketing.db'), { readonly: true });
|
|
||||||
|
|
||||||
// ID mapping: { tableName: { oldId: newNocoDbId } }
|
|
||||||
const idMap = {};
|
|
||||||
function mapId(table, oldId, newId) {
|
|
||||||
if (!idMap[table]) idMap[table] = {};
|
|
||||||
idMap[table][oldId] = newId;
|
|
||||||
}
|
|
||||||
function getMappedId(table, oldId) {
|
|
||||||
if (!oldId) return null;
|
|
||||||
return idMap[table]?.[oldId] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build a lookup: team_member_id → user row
|
|
||||||
function buildTeamMemberToUserMap() {
|
|
||||||
const users = sqliteDb.prepare('SELECT id, team_member_id FROM users').all();
|
|
||||||
const map = {};
|
|
||||||
for (const u of users) {
|
|
||||||
if (u.team_member_id) map[u.team_member_id] = u.id;
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migrateBrands() {
|
|
||||||
console.log('Migrating Brands...');
|
|
||||||
const rows = sqliteDb.prepare('SELECT * FROM brands ORDER BY id').all();
|
|
||||||
for (const row of rows) {
|
|
||||||
const created = await nocodb.create('Brands', {
|
|
||||||
name: row.name,
|
|
||||||
priority: row.priority,
|
|
||||||
color: row.color,
|
|
||||||
icon: row.icon,
|
|
||||||
});
|
|
||||||
mapId('brands', row.id, created.Id);
|
|
||||||
}
|
|
||||||
console.log(` ✅ ${rows.length} brands migrated`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migrateUsers() {
|
|
||||||
console.log('Migrating Users...');
|
|
||||||
const rows = sqliteDb.prepare('SELECT * FROM users ORDER BY id').all();
|
|
||||||
for (const row of rows) {
|
|
||||||
const created = await nocodb.create('Users', {
|
|
||||||
name: row.name,
|
|
||||||
email: row.email,
|
|
||||||
role: row.role,
|
|
||||||
team_role: row.team_role || null,
|
|
||||||
brands: row.brands || '[]',
|
|
||||||
phone: row.phone || null,
|
|
||||||
avatar: row.avatar || null,
|
|
||||||
tutorial_completed: !!row.tutorial_completed,
|
|
||||||
});
|
|
||||||
mapId('users', row.id, created.Id);
|
|
||||||
|
|
||||||
// Create auth credentials entry
|
|
||||||
authDb.prepare(
|
|
||||||
'INSERT OR REPLACE INTO auth_credentials (email, password_hash, nocodb_user_id) VALUES (?, ?, ?)'
|
|
||||||
).run(row.email, row.password_hash, created.Id);
|
|
||||||
}
|
|
||||||
console.log(` ✅ ${rows.length} users migrated (+ auth_credentials)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migrateCampaigns() {
|
|
||||||
console.log('Migrating Campaigns...');
|
|
||||||
const rows = sqliteDb.prepare('SELECT * FROM campaigns ORDER BY id').all();
|
|
||||||
for (const row of rows) {
|
|
||||||
const data = {
|
|
||||||
name: row.name,
|
|
||||||
description: row.description,
|
|
||||||
start_date: row.start_date,
|
|
||||||
end_date: row.end_date,
|
|
||||||
status: row.status,
|
|
||||||
color: row.color,
|
|
||||||
budget: row.budget,
|
|
||||||
goals: row.goals,
|
|
||||||
platforms: row.platforms || '[]',
|
|
||||||
budget_spent: row.budget_spent || 0,
|
|
||||||
revenue: row.revenue || 0,
|
|
||||||
impressions: row.impressions || 0,
|
|
||||||
clicks: row.clicks || 0,
|
|
||||||
conversions: row.conversions || 0,
|
|
||||||
cost_per_click: row.cost_per_click || 0,
|
|
||||||
notes: row.notes || '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const created = await nocodb.create('Campaigns', data);
|
|
||||||
mapId('campaigns', row.id, created.Id);
|
|
||||||
|
|
||||||
// Link Brand
|
|
||||||
if (row.brand_id && getMappedId('brands', row.brand_id)) {
|
|
||||||
await linkRecord('Campaigns', created.Id, 'Brand', getMappedId('brands', row.brand_id));
|
|
||||||
}
|
|
||||||
// Link CreatedByUser
|
|
||||||
if (row.created_by_user_id && getMappedId('users', row.created_by_user_id)) {
|
|
||||||
await linkRecord('Campaigns', created.Id, 'CreatedByUser', getMappedId('users', row.created_by_user_id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(` ✅ ${rows.length} campaigns migrated`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migrateCampaignTracks() {
|
|
||||||
console.log('Migrating CampaignTracks...');
|
|
||||||
const rows = sqliteDb.prepare('SELECT * FROM campaign_tracks ORDER BY id').all();
|
|
||||||
for (const row of rows) {
|
|
||||||
const created = await nocodb.create('CampaignTracks', {
|
|
||||||
name: row.name,
|
|
||||||
type: row.type,
|
|
||||||
platform: row.platform,
|
|
||||||
budget_allocated: row.budget_allocated || 0,
|
|
||||||
budget_spent: row.budget_spent || 0,
|
|
||||||
revenue: row.revenue || 0,
|
|
||||||
impressions: row.impressions || 0,
|
|
||||||
clicks: row.clicks || 0,
|
|
||||||
conversions: row.conversions || 0,
|
|
||||||
notes: row.notes || '',
|
|
||||||
status: row.status,
|
|
||||||
});
|
|
||||||
mapId('campaign_tracks', row.id, created.Id);
|
|
||||||
|
|
||||||
if (row.campaign_id && getMappedId('campaigns', row.campaign_id)) {
|
|
||||||
await linkRecord('CampaignTracks', created.Id, 'Campaign', getMappedId('campaigns', row.campaign_id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(` ✅ ${rows.length} campaign tracks migrated`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migrateCampaignAssignments() {
|
|
||||||
console.log('Migrating CampaignAssignments...');
|
|
||||||
const rows = sqliteDb.prepare('SELECT * FROM campaign_assignments ORDER BY id').all();
|
|
||||||
for (const row of rows) {
|
|
||||||
const created = await nocodb.create('CampaignAssignments', {
|
|
||||||
assigned_at: row.assigned_at,
|
|
||||||
});
|
|
||||||
mapId('campaign_assignments', row.id, created.Id);
|
|
||||||
|
|
||||||
if (row.campaign_id && getMappedId('campaigns', row.campaign_id)) {
|
|
||||||
await linkRecord('CampaignAssignments', created.Id, 'Campaign', getMappedId('campaigns', row.campaign_id));
|
|
||||||
}
|
|
||||||
if (row.user_id && getMappedId('users', row.user_id)) {
|
|
||||||
await linkRecord('CampaignAssignments', created.Id, 'Member', getMappedId('users', row.user_id));
|
|
||||||
}
|
|
||||||
if (row.assigned_by && getMappedId('users', row.assigned_by)) {
|
|
||||||
await linkRecord('CampaignAssignments', created.Id, 'Assigner', getMappedId('users', row.assigned_by));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(` ✅ ${rows.length} campaign assignments migrated`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migrateProjects() {
|
|
||||||
console.log('Migrating Projects...');
|
|
||||||
const rows = sqliteDb.prepare('SELECT * FROM projects ORDER BY id').all();
|
|
||||||
const tmToUser = buildTeamMemberToUserMap();
|
|
||||||
for (const row of rows) {
|
|
||||||
const created = await nocodb.create('Projects', {
|
|
||||||
name: row.name,
|
|
||||||
description: row.description,
|
|
||||||
status: row.status,
|
|
||||||
priority: row.priority,
|
|
||||||
due_date: row.due_date,
|
|
||||||
});
|
|
||||||
mapId('projects', row.id, created.Id);
|
|
||||||
|
|
||||||
if (row.brand_id && getMappedId('brands', row.brand_id)) {
|
|
||||||
await linkRecord('Projects', created.Id, 'Brand', getMappedId('brands', row.brand_id));
|
|
||||||
}
|
|
||||||
// owner_id references team_members, map through to users
|
|
||||||
if (row.owner_id) {
|
|
||||||
const userId = tmToUser[row.owner_id];
|
|
||||||
if (userId && getMappedId('users', userId)) {
|
|
||||||
await linkRecord('Projects', created.Id, 'Owner', getMappedId('users', userId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (row.created_by_user_id && getMappedId('users', row.created_by_user_id)) {
|
|
||||||
await linkRecord('Projects', created.Id, 'CreatedByUser', getMappedId('users', row.created_by_user_id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(` ✅ ${rows.length} projects migrated`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migrateTasks() {
|
|
||||||
console.log('Migrating Tasks...');
|
|
||||||
const rows = sqliteDb.prepare('SELECT * FROM tasks ORDER BY id').all();
|
|
||||||
const tmToUser = buildTeamMemberToUserMap();
|
|
||||||
for (const row of rows) {
|
|
||||||
const created = await nocodb.create('Tasks', {
|
|
||||||
title: row.title,
|
|
||||||
description: row.description,
|
|
||||||
status: row.status,
|
|
||||||
priority: row.priority,
|
|
||||||
due_date: row.due_date,
|
|
||||||
is_personal: !!row.is_personal,
|
|
||||||
completed_at: row.completed_at,
|
|
||||||
});
|
|
||||||
mapId('tasks', row.id, created.Id);
|
|
||||||
|
|
||||||
if (row.project_id && getMappedId('projects', row.project_id)) {
|
|
||||||
await linkRecord('Tasks', created.Id, 'Project', getMappedId('projects', row.project_id));
|
|
||||||
}
|
|
||||||
// assigned_to references team_members
|
|
||||||
if (row.assigned_to) {
|
|
||||||
const userId = tmToUser[row.assigned_to];
|
|
||||||
if (userId && getMappedId('users', userId)) {
|
|
||||||
await linkRecord('Tasks', created.Id, 'AssignedTo', getMappedId('users', userId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (row.created_by_user_id && getMappedId('users', row.created_by_user_id)) {
|
|
||||||
await linkRecord('Tasks', created.Id, 'CreatedByUser', getMappedId('users', row.created_by_user_id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(` ✅ ${rows.length} tasks migrated`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migratePosts() {
|
|
||||||
console.log('Migrating Posts...');
|
|
||||||
const rows = sqliteDb.prepare('SELECT * FROM posts ORDER BY id').all();
|
|
||||||
const tmToUser = buildTeamMemberToUserMap();
|
|
||||||
for (const row of rows) {
|
|
||||||
const created = await nocodb.create('Posts', {
|
|
||||||
title: row.title,
|
|
||||||
description: row.description,
|
|
||||||
status: row.status,
|
|
||||||
platform: row.platform,
|
|
||||||
platforms: row.platforms || '[]',
|
|
||||||
content_type: row.content_type,
|
|
||||||
scheduled_date: row.scheduled_date,
|
|
||||||
published_date: row.published_date,
|
|
||||||
notes: row.notes,
|
|
||||||
publication_links: row.publication_links || '[]',
|
|
||||||
});
|
|
||||||
mapId('posts', row.id, created.Id);
|
|
||||||
|
|
||||||
if (row.brand_id && getMappedId('brands', row.brand_id)) {
|
|
||||||
await linkRecord('Posts', created.Id, 'Brand', getMappedId('brands', row.brand_id));
|
|
||||||
}
|
|
||||||
if (row.assigned_to) {
|
|
||||||
const userId = tmToUser[row.assigned_to];
|
|
||||||
if (userId && getMappedId('users', userId)) {
|
|
||||||
await linkRecord('Posts', created.Id, 'AssignedTo', getMappedId('users', userId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (row.campaign_id && getMappedId('campaigns', row.campaign_id)) {
|
|
||||||
await linkRecord('Posts', created.Id, 'Campaign', getMappedId('campaigns', row.campaign_id));
|
|
||||||
}
|
|
||||||
if (row.track_id && getMappedId('campaign_tracks', row.track_id)) {
|
|
||||||
await linkRecord('Posts', created.Id, 'Track', getMappedId('campaign_tracks', row.track_id));
|
|
||||||
}
|
|
||||||
if (row.created_by_user_id && getMappedId('users', row.created_by_user_id)) {
|
|
||||||
await linkRecord('Posts', created.Id, 'CreatedByUser', getMappedId('users', row.created_by_user_id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(` ✅ ${rows.length} posts migrated`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migratePostAttachments() {
|
|
||||||
console.log('Migrating PostAttachments...');
|
|
||||||
const rows = sqliteDb.prepare('SELECT * FROM post_attachments ORDER BY id').all();
|
|
||||||
for (const row of rows) {
|
|
||||||
const created = await nocodb.create('PostAttachments', {
|
|
||||||
filename: row.filename,
|
|
||||||
original_name: row.original_name,
|
|
||||||
mime_type: row.mime_type,
|
|
||||||
size: row.size,
|
|
||||||
url: row.url,
|
|
||||||
});
|
|
||||||
mapId('post_attachments', row.id, created.Id);
|
|
||||||
|
|
||||||
if (row.post_id && getMappedId('posts', row.post_id)) {
|
|
||||||
await linkRecord('PostAttachments', created.Id, 'Post', getMappedId('posts', row.post_id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(` ✅ ${rows.length} post attachments migrated`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migrateAssets() {
|
|
||||||
console.log('Migrating Assets...');
|
|
||||||
const rows = sqliteDb.prepare('SELECT * FROM assets ORDER BY id').all();
|
|
||||||
const tmToUser = buildTeamMemberToUserMap();
|
|
||||||
for (const row of rows) {
|
|
||||||
const created = await nocodb.create('Assets', {
|
|
||||||
filename: row.filename,
|
|
||||||
original_name: row.original_name,
|
|
||||||
mime_type: row.mime_type,
|
|
||||||
size: row.size,
|
|
||||||
tags: row.tags || '[]',
|
|
||||||
folder: row.folder,
|
|
||||||
});
|
|
||||||
mapId('assets', row.id, created.Id);
|
|
||||||
|
|
||||||
if (row.brand_id && getMappedId('brands', row.brand_id)) {
|
|
||||||
await linkRecord('Assets', created.Id, 'Brand', getMappedId('brands', row.brand_id));
|
|
||||||
}
|
|
||||||
if (row.campaign_id && getMappedId('campaigns', row.campaign_id)) {
|
|
||||||
await linkRecord('Assets', created.Id, 'Campaign', getMappedId('campaigns', row.campaign_id));
|
|
||||||
}
|
|
||||||
if (row.uploaded_by) {
|
|
||||||
const userId = tmToUser[row.uploaded_by];
|
|
||||||
if (userId && getMappedId('users', userId)) {
|
|
||||||
await linkRecord('Assets', created.Id, 'Uploader', getMappedId('users', userId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(` ✅ ${rows.length} assets migrated`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migrateComments() {
|
|
||||||
console.log('Migrating Comments...');
|
|
||||||
const rows = sqliteDb.prepare('SELECT * FROM comments ORDER BY id').all();
|
|
||||||
for (const row of rows) {
|
|
||||||
const created = await nocodb.create('Comments', {
|
|
||||||
entity_type: row.entity_type,
|
|
||||||
entity_id: row.entity_id,
|
|
||||||
content: row.content,
|
|
||||||
});
|
|
||||||
mapId('comments', row.id, created.Id);
|
|
||||||
|
|
||||||
if (row.user_id && getMappedId('users', row.user_id)) {
|
|
||||||
await linkRecord('Comments', created.Id, 'User', getMappedId('users', row.user_id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(` ✅ ${rows.length} comments migrated`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migrateBudgetEntries() {
|
|
||||||
console.log('Migrating BudgetEntries...');
|
|
||||||
const rows = sqliteDb.prepare('SELECT * FROM budget_entries ORDER BY id').all();
|
|
||||||
for (const row of rows) {
|
|
||||||
const created = await nocodb.create('BudgetEntries', {
|
|
||||||
label: row.label,
|
|
||||||
amount: row.amount,
|
|
||||||
source: row.source,
|
|
||||||
category: row.category,
|
|
||||||
date_received: row.date_received,
|
|
||||||
notes: row.notes || '',
|
|
||||||
});
|
|
||||||
mapId('budget_entries', row.id, created.Id);
|
|
||||||
|
|
||||||
if (row.campaign_id && getMappedId('campaigns', row.campaign_id)) {
|
|
||||||
await linkRecord('BudgetEntries', created.Id, 'Campaign', getMappedId('campaigns', row.campaign_id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(` ✅ ${rows.length} budget entries migrated`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: link a record to another via NocoDB link API
|
|
||||||
async function linkRecord(table, recordId, linkField, linkedRecordId) {
|
|
||||||
try {
|
|
||||||
const tableId = await nocodb.resolveTableId(table);
|
|
||||||
// Get columns to find the link column ID
|
|
||||||
const res = await fetch(`${nocodb.url}/api/v2/meta/tables/${tableId}`, {
|
|
||||||
headers: { 'xc-token': nocodb.token },
|
|
||||||
});
|
|
||||||
const tableMeta = await res.json();
|
|
||||||
const linkCol = tableMeta.columns.find(c => c.title === linkField && c.uidt === 'Links');
|
|
||||||
if (!linkCol) {
|
|
||||||
console.warn(` ⚠ Link column "${linkField}" not found in ${table}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await fetch(`${nocodb.url}/api/v2/tables/${tableId}/links/${linkCol.id}/records/${recordId}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'xc-token': nocodb.token, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify([{ Id: linkedRecordId }]),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(` ⚠ Failed to link ${table}.${linkField}: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache for link column metadata (avoid re-fetching per record)
|
|
||||||
const linkColCache = {};
|
|
||||||
async function getLinkColId(table, linkField) {
|
|
||||||
const key = `${table}.${linkField}`;
|
|
||||||
if (linkColCache[key]) return linkColCache[key];
|
|
||||||
const tableId = await nocodb.resolveTableId(table);
|
|
||||||
const res = await fetch(`${nocodb.url}/api/v2/meta/tables/${tableId}`, {
|
|
||||||
headers: { 'xc-token': nocodb.token },
|
|
||||||
});
|
|
||||||
const tableMeta = await res.json();
|
|
||||||
for (const c of tableMeta.columns) {
|
|
||||||
if (c.uidt === 'Links') {
|
|
||||||
linkColCache[`${table}.${c.title}`] = { colId: c.id, tableId };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return linkColCache[key] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optimized linkRecord using cache
|
|
||||||
const origLinkRecord = linkRecord;
|
|
||||||
linkRecord = async function(table, recordId, linkField, linkedRecordId) {
|
|
||||||
let cached = linkColCache[`${table}.${linkField}`];
|
|
||||||
if (!cached) {
|
|
||||||
// Populate cache for this table
|
|
||||||
const tableId = await nocodb.resolveTableId(table);
|
|
||||||
const res = await fetch(`${nocodb.url}/api/v2/meta/tables/${tableId}`, {
|
|
||||||
headers: { 'xc-token': nocodb.token },
|
|
||||||
});
|
|
||||||
const tableMeta = await res.json();
|
|
||||||
for (const c of tableMeta.columns) {
|
|
||||||
if (c.uidt === 'Links') {
|
|
||||||
linkColCache[`${table}.${c.title}`] = { colId: c.id, tableId };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cached = linkColCache[`${table}.${linkField}`];
|
|
||||||
}
|
|
||||||
if (!cached) {
|
|
||||||
console.warn(` ⚠ Link column "${linkField}" not found in ${table}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await fetch(`${nocodb.url}/api/v2/tables/${cached.tableId}/links/${cached.colId}/records/${recordId}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'xc-token': nocodb.token, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify([{ Id: linkedRecordId }]),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(` ⚠ Failed to link ${table}.${linkField}: ${err.message}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log('Starting migration from SQLite → NocoDB...\n');
|
|
||||||
|
|
||||||
await migrateBrands();
|
|
||||||
await migrateUsers();
|
|
||||||
await migrateCampaigns();
|
|
||||||
await migrateCampaignTracks();
|
|
||||||
await migrateCampaignAssignments();
|
|
||||||
await migrateProjects();
|
|
||||||
await migrateTasks();
|
|
||||||
await migratePosts();
|
|
||||||
await migratePostAttachments();
|
|
||||||
await migrateAssets();
|
|
||||||
await migrateComments();
|
|
||||||
await migrateBudgetEntries();
|
|
||||||
|
|
||||||
console.log('\n✅ Migration complete!');
|
|
||||||
console.log('ID mapping summary:');
|
|
||||||
for (const [table, map] of Object.entries(idMap)) {
|
|
||||||
console.log(` ${table}: ${Object.keys(map).length} records`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(err => {
|
|
||||||
console.error('Migration failed:', err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -591,10 +591,10 @@ app.get('/api/brands', requireAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/brands', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
|
app.post('/api/brands', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
|
||||||
const { name, priority, color, icon } = req.body;
|
const { name, name_ar, priority, color, icon } = req.body;
|
||||||
if (!name) return res.status(400).json({ error: 'Name is required' });
|
if (!name) return res.status(400).json({ error: 'Name is required' });
|
||||||
try {
|
try {
|
||||||
const created = await nocodb.create('Brands', { name, priority: priority || 2, color: color || null, icon: icon || null });
|
const created = await nocodb.create('Brands', { name, name_ar: name_ar || null, priority: priority || 2, color: color || null, icon: icon || null });
|
||||||
const brand = await nocodb.get('Brands', created.Id);
|
const brand = await nocodb.get('Brands', created.Id);
|
||||||
res.status(201).json(brand);
|
res.status(201).json(brand);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -607,7 +607,7 @@ app.patch('/api/brands/:id', requireAuth, requireRole('superadmin', 'manager'),
|
|||||||
const existing = await nocodb.get('Brands', req.params.id);
|
const existing = await nocodb.get('Brands', req.params.id);
|
||||||
if (!existing) return res.status(404).json({ error: 'Brand not found' });
|
if (!existing) return res.status(404).json({ error: 'Brand not found' });
|
||||||
const data = {};
|
const data = {};
|
||||||
for (const f of ['name', 'priority', 'color', 'icon']) {
|
for (const f of ['name', 'name_ar', 'priority', 'color', 'icon', 'logo']) {
|
||||||
if (req.body[f] !== undefined) data[f] = req.body[f];
|
if (req.body[f] !== undefined) data[f] = req.body[f];
|
||||||
}
|
}
|
||||||
if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' });
|
if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' });
|
||||||
@@ -619,6 +619,50 @@ app.patch('/api/brands/:id', requireAuth, requireRole('superadmin', 'manager'),
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/brands/:id/logo', requireAuth, requireRole('superadmin', 'manager'), upload.single('file'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const existing = await nocodb.get('Brands', req.params.id);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Brand not found' });
|
||||||
|
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||||
|
await nocodb.update('Brands', req.params.id, { logo: req.file.filename });
|
||||||
|
const brand = await nocodb.get('Brands', req.params.id);
|
||||||
|
res.json(brand);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Failed to upload logo' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/brands/:id', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const existing = await nocodb.get('Brands', req.params.id);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Brand not found' });
|
||||||
|
await nocodb.delete('Brands', req.params.id);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Failed to delete brand' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// One-time: copy Arabic names from "name" to "name_ar" for brands that have no name_ar yet
|
||||||
|
app.post('/api/brands/migrate-names', requireAuth, requireRole('superadmin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const brands = await nocodb.list('Brands', { limit: 200 });
|
||||||
|
const updates = [];
|
||||||
|
for (const b of brands) {
|
||||||
|
if (!b.name_ar && b.name) {
|
||||||
|
updates.push({ Id: b.Id, name_ar: b.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (updates.length > 0) {
|
||||||
|
await nocodb.bulkUpdate('Brands', updates);
|
||||||
|
}
|
||||||
|
res.json({ migrated: updates.length, message: `Copied ${updates.length} brand name(s) to name_ar. Now update the "name" field with English names in NocoDB.` });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Brand name migration failed:', err);
|
||||||
|
res.status(500).json({ error: 'Migration failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ─── POSTS ──────────────────────────────────────────────────────
|
// ─── POSTS ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/posts/stats', requireAuth, async (req, res) => {
|
app.get('/api/posts/stats', requireAuth, async (req, res) => {
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ async function main() {
|
|||||||
text('color'),
|
text('color'),
|
||||||
text('icon'),
|
text('icon'),
|
||||||
text('category'),
|
text('category'),
|
||||||
|
text('logo'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const campaigns = await createTable(baseId, 'Campaigns', [
|
const campaigns = await createTable(baseId, 'Campaigns', [
|
||||||
|
|||||||
Reference in New Issue
Block a user