video preview version

This commit is contained in:
fahed
2026-02-08 22:51:42 +03:00
parent 5f7d922f92
commit 9b58e5e9aa
23 changed files with 890 additions and 544 deletions

View File

@@ -17,19 +17,37 @@ import Users from './pages/Users'
import Settings from './pages/Settings' import Settings from './pages/Settings'
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 { api } from './utils/api' import { api } from './utils/api'
import { useLanguage } from './i18n/LanguageContext' import { useLanguage } from './i18n/LanguageContext'
const TEAM_ROLES = [
{ value: 'manager', label: 'Manager' },
{ value: 'approver', label: 'Approver' },
{ value: 'publisher', label: 'Publisher' },
{ value: 'content_creator', label: 'Content Creator' },
{ value: 'producer', label: 'Producer' },
{ value: 'designer', label: 'Designer' },
{ value: 'content_writer', label: 'Content Writer' },
{ value: 'social_media_manager', label: 'Social Media Manager' },
{ value: 'photographer', label: 'Photographer' },
{ value: 'videographer', label: 'Videographer' },
{ value: 'strategist', label: 'Strategist' },
]
export const AppContext = createContext() export const AppContext = createContext()
function AppContent() { function AppContent() {
const { user, loading: authLoading } = useAuth() const { user, loading: authLoading, checkAuth } = useAuth()
const { t } = useLanguage() const { t } = 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)
const [showTutorial, setShowTutorial] = useState(false) const [showTutorial, setShowTutorial] = useState(false)
const [showProfilePrompt, setShowProfilePrompt] = useState(false) const [showProfilePrompt, setShowProfilePrompt] = useState(false)
const [showProfileModal, setShowProfileModal] = useState(false)
const [profileForm, setProfileForm] = useState({ name: '', team_role: '', phone: '', brands: '' })
const [profileSaving, setProfileSaving] = useState(false)
useEffect(() => { useEffect(() => {
if (user && !authLoading) { if (user && !authLoading) {
@@ -61,11 +79,10 @@ function AppContent() {
const loadInitialData = async () => { const loadInitialData = async () => {
try { try {
const [members, brandsData] = await Promise.all([ const [, brandsData] = await Promise.all([
loadTeam(), loadTeam(),
api.get('/brands').then(d => Array.isArray(d) ? d : (d.data || [])).catch(() => []), api.get('/brands').then(d => Array.isArray(d) ? d : (d.data || [])).catch(() => []),
]) ])
setTeamMembers(members)
setBrands(brandsData) setBrands(brandsData)
} catch (err) { } catch (err) {
console.error('Failed to load initial data:', err) console.error('Failed to load initial data:', err)
@@ -109,12 +126,20 @@ function AppContent() {
{t('profile.completeDesc')} {t('profile.completeDesc')}
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
<a <button
href="/team" onClick={() => {
setProfileForm({
name: user?.name || '',
team_role: user?.teamRole || user?.team_role || '',
phone: user?.phone || '',
brands: Array.isArray(user?.brands) ? user.brands.join(', ') : '',
})
setShowProfileModal(true)
}}
className="px-3 py-1.5 bg-amber-400 text-white text-sm font-medium rounded-lg hover:bg-amber-500 transition-colors" className="px-3 py-1.5 bg-amber-400 text-white text-sm font-medium rounded-lg hover:bg-amber-500 transition-colors"
> >
{t('profile.completeProfileBtn')} {t('profile.completeProfileBtn')}
</a> </button>
<button <button
onClick={() => setShowProfilePrompt(false)} onClick={() => setShowProfilePrompt(false)}
className="px-3 py-1.5 text-sm font-medium text-amber-800 hover:bg-amber-100 rounded-lg transition-colors" className="px-3 py-1.5 text-sm font-medium text-amber-800 hover:bg-amber-100 rounded-lg transition-colors"
@@ -133,6 +158,88 @@ function AppContent() {
</div> </div>
)} )}
{/* Profile completion modal */}
<Modal isOpen={showProfileModal} onClose={() => setShowProfileModal(false)} title={t('profile.completeYourProfile')} size="md">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.name')}</label>
<input
type="text"
value={profileForm.name}
onChange={e => setProfileForm(f => ({ ...f, name: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder={t('team.fullName')}
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.teamRole')}</label>
<select
value={profileForm.team_role}
onChange={e => setProfileForm(f => ({ ...f, team_role: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
>
<option value=""></option>
{TEAM_ROLES.map(r => <option key={r.value} value={r.value}>{r.label}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.phone')} {t('team.optional')}</label>
<input
type="text"
value={profileForm.phone}
onChange={e => setProfileForm(f => ({ ...f, phone: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.brands')}</label>
<input
type="text"
value={profileForm.brands}
onChange={e => setProfileForm(f => ({ ...f, brands: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder={t('team.brandsHelp')}
/>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<button
onClick={() => setShowProfileModal(false)}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
>
{t('common.cancel')}
</button>
<button
onClick={async () => {
setProfileSaving(true)
try {
const brandsArr = profileForm.brands
.split(',')
.map(b => b.trim())
.filter(Boolean)
await api.patch('/users/me/profile', {
name: profileForm.name,
team_role: profileForm.team_role,
phone: profileForm.phone || null,
brands: brandsArr,
})
await checkAuth()
setShowProfileModal(false)
setShowProfilePrompt(false)
} catch (err) {
console.error('Profile save failed:', err)
} finally {
setProfileSaving(false)
}
}}
disabled={!profileForm.name || !profileForm.team_role || profileSaving}
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"
>
{profileSaving ? t('common.loading') : t('team.saveProfile')}
</button>
</div>
</div>
</Modal>
{/* Tutorial overlay */} {/* Tutorial overlay */}
{showTutorial && <Tutorial onComplete={handleTutorialComplete} />} {showTutorial && <Tutorial onComplete={handleTutorialComplete} />}

View File

@@ -0,0 +1,20 @@
export default function BudgetBar({ budget, spent, height = 'h-1.5' }) {
if (!budget || budget <= 0) return null
const pct = Math.min((spent / budget) * 100, 100)
let color = 'bg-emerald-500'
if (pct > 90) color = 'bg-red-500'
else if (pct > 70) color = 'bg-amber-500'
return (
<div className="w-full">
<div className="flex justify-between text-[10px] text-text-tertiary mb-0.5">
<span>{(spent || 0).toLocaleString()} SAR spent</span>
<span>{budget.toLocaleString()} SAR</span>
</div>
<div className={`${height} bg-surface-tertiary rounded-full overflow-hidden`}>
<div className={`h-full ${color} rounded-full transition-all`} style={{ width: `${pct}%` }} />
</div>
</div>
)
}

View File

@@ -0,0 +1,134 @@
import { useState, useEffect } from 'react'
import { Send, Trash2, MessageCircle } from 'lucide-react'
import { api, getInitials } from '../utils/api'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
function relativeTime(dateStr, t) {
const now = Date.now()
const then = new Date(dateStr).getTime()
const diffMs = now - then
const diffMin = Math.floor(diffMs / 60000)
if (diffMin < 1) return t('comments.justNow')
if (diffMin < 60) return t('comments.minutesAgo').replace('{n}', diffMin)
const diffHours = Math.floor(diffMin / 60)
if (diffHours < 24) return t('comments.hoursAgo').replace('{n}', diffHours)
const diffDays = Math.floor(diffHours / 24)
return t('comments.daysAgo').replace('{n}', diffDays)
}
export default function CommentsSection({ entityType, entityId }) {
const { user } = useAuth()
const { t } = useLanguage()
const [comments, setComments] = useState([])
const [newComment, setNewComment] = useState('')
const [sending, setSending] = useState(false)
useEffect(() => {
if (entityType && entityId) loadComments()
}, [entityType, entityId])
const loadComments = async () => {
try {
const data = await api.get(`/comments/${entityType}/${entityId}`)
setComments(Array.isArray(data) ? data : (data.data || []))
} catch (err) {
console.error('Failed to load comments:', err)
}
}
const handleSend = async () => {
if (!newComment.trim() || sending) return
setSending(true)
try {
await api.post(`/comments/${entityType}/${entityId}`, { content: newComment.trim() })
setNewComment('')
loadComments()
} catch (err) {
console.error('Failed to send comment:', err)
} finally {
setSending(false)
}
}
const handleDelete = async (id) => {
try {
await api.delete(`/comments/${id}`)
loadComments()
} catch (err) {
console.error('Failed to delete comment:', err)
}
}
const canDelete = (comment) => {
if (!user) return false
if (comment.user_id === user.id) return true
return user.role === 'superadmin' || user.role === 'manager'
}
return (
<div className="space-y-3">
<h4 className="text-sm font-semibold text-text-primary flex items-center gap-1.5">
<MessageCircle className="w-4 h-4" />
{t('comments.title')}
{comments.length > 0 && (
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-1.5 py-0.5 rounded-full">
{comments.length}
</span>
)}
</h4>
{comments.length === 0 && (
<p className="text-xs text-text-tertiary py-2">{t('comments.noComments')}</p>
)}
<div className="space-y-2 max-h-64 overflow-y-auto">
{comments.map(c => (
<div key={c.id} className="flex items-start gap-2 group">
<div className="w-7 h-7 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-bold shrink-0 mt-0.5">
{c.user_avatar ? (
<img src={c.user_avatar} className="w-full h-full rounded-full object-cover" alt="" />
) : (
getInitials(c.user_name)
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-text-primary">{c.user_name}</span>
<span className="text-[10px] text-text-tertiary">{relativeTime(c.created_at, t)}</span>
{canDelete(c) && (
<button
onClick={() => handleDelete(c.id)}
className="p-0.5 rounded text-text-tertiary hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity ml-auto"
>
<Trash2 className="w-3 h-3" />
</button>
)}
</div>
<p className="text-xs text-text-secondary whitespace-pre-wrap break-words">{c.content}</p>
</div>
</div>
))}
</div>
{/* Input */}
<div className="flex items-center gap-2">
<input
type="text"
value={newComment}
onChange={e => setNewComment(e.target.value)}
onKeyDown={e => e.key === 'Enter' && !e.shiftKey && handleSend()}
placeholder={t('comments.placeholder')}
className="flex-1 px-3 py-1.5 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
<button
onClick={handleSend}
disabled={!newComment.trim() || sending}
className="p-2 bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Send className="w-4 h-4" />
</button>
</div>
</div>
)
}

View File

@@ -1,7 +1,8 @@
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { Bell, ChevronDown, LogOut, Settings, User, Shield } from 'lucide-react' import { Bell, ChevronDown, LogOut, Shield } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { getInitials } from '../utils/api'
const pageTitles = { const pageTitles = {
'/': 'Dashboard', '/': 'Dashboard',
@@ -27,9 +28,13 @@ export default function Header() {
const dropdownRef = useRef(null) const dropdownRef = useRef(null)
const location = useLocation() const location = useLocation()
const pageTitle = pageTitles[location.pathname] || function getPageTitle(pathname) {
(location.pathname.startsWith('/projects/') ? 'Project Details' : if (pageTitles[pathname]) return pageTitles[pathname]
location.pathname.startsWith('/campaigns/') ? 'Campaign Details' : 'Page') if (pathname.startsWith('/projects/')) return 'Project Details'
if (pathname.startsWith('/campaigns/')) return 'Campaign Details'
return 'Page'
}
const pageTitle = getPageTitle(location.pathname)
useEffect(() => { useEffect(() => {
const handleClickOutside = (e) => { const handleClickOutside = (e) => {
@@ -41,11 +46,6 @@ export default function Header() {
return () => document.removeEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside)
}, []) }, [])
const getInitials = (name) => {
if (!name) return '?'
return name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
}
const roleInfo = ROLE_INFO[user?.role] || ROLE_INFO.contributor const roleInfo = ROLE_INFO[user?.role] || ROLE_INFO.contributor
return ( return (

View File

@@ -1,3 +1,4 @@
import { getInitials } from '../utils/api'
import BrandBadge from './BrandBadge' import BrandBadge from './BrandBadge'
const ROLE_BADGES = { const ROLE_BADGES = {
@@ -16,11 +17,6 @@ const ROLE_BADGES = {
} }
export default function MemberCard({ member, onClick }) { export default function MemberCard({ member, onClick }) {
const getInitials = (name) => {
if (!name) return '?'
return name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
}
const role = ROLE_BADGES[member.team_role || member.role] || ROLE_BADGES.default const role = ROLE_BADGES[member.team_role || member.role] || ROLE_BADGES.default
const avatarColors = [ const avatarColors = [

View File

@@ -1,9 +1,10 @@
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 { useLanguage } from '../i18n/LanguageContext' import { useLanguage } from '../i18n/LanguageContext'
import BrandBadge from './BrandBadge' import BrandBadge from './BrandBadge'
import StatusBadge from './StatusBadge' import StatusBadge from './StatusBadge'
import PlatformIcon, { 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()
@@ -12,21 +13,16 @@ export default function PostCard({ post, onClick, onMove, compact = false }) {
? post.platforms ? post.platforms
: (post.platform ? [post.platform] : []) : (post.platform ? [post.platform] : [])
const getInitials = (name) => {
if (!name) return '?'
return name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
}
const assigneeName = post.assignedToName || post.assignedName || post.assigned_name || (typeof post.assignedTo === 'object' ? post.assignedTo?.name : null) const assigneeName = post.assignedToName || post.assignedName || post.assigned_name || (typeof post.assignedTo === 'object' ? post.assignedTo?.name : null)
if (compact) { if (compact) {
return ( return (
<div <div
onClick={onClick} onClick={onClick}
className="bg-white rounded-lg p-3 border border-border hover:border-brand-primary/30 hover:shadow-md cursor-pointer transition-all group" className="bg-white rounded-lg p-3 border border-border hover:border-brand-primary/30 hover:shadow-md cursor-pointer transition-all group overflow-hidden"
> >
{post.thumbnail_url && ( {post.thumbnail_url && (
<div className="w-full h-32 -mx-3 -mt-3 mb-2 rounded-t-lg overflow-hidden"> <div className="w-[calc(100%+1.5rem)] h-32 -mx-3 -mt-3 mb-2 rounded-t-lg overflow-hidden">
<img src={`http://localhost:3001${post.thumbnail_url}`} alt="" className="w-full h-full object-cover" /> <img src={`http://localhost:3001${post.thumbnail_url}`} alt="" className="w-full h-full object-cover" />
</div> </div>
)} )}

View File

@@ -26,27 +26,9 @@ export function LanguageProvider({ children }) {
document.documentElement.lang = lang document.documentElement.lang = lang
}, [dir, lang]) }, [dir, lang])
// Translation function // Translation function (flat dot-notation keys)
const t = (key) => { const t = (key) => {
const keys = key.split('.') return translations[lang]?.[key] ?? translations.en?.[key] ?? key
let value = translations[lang]
for (const k of keys) {
value = value?.[k]
if (value === undefined) break
}
// Fallback to English if translation not found
if (value === undefined) {
value = translations.en
for (const k of keys) {
value = value?.[k]
if (value === undefined) break
}
}
// Fallback to key itself if still not found
return value !== undefined ? value : key
} }
return ( return (

View File

@@ -106,6 +106,9 @@
"posts.approve": "اعتماد", "posts.approve": "اعتماد",
"posts.schedule": "جدولة", "posts.schedule": "جدولة",
"posts.publish": "نشر", "posts.publish": "نشر",
"posts.attachFromAssets": "إرفاق من الأصول",
"posts.selectAssets": "اختر أصلاً لإرفاقه",
"posts.noAssetsFound": "لا توجد أصول",
"posts.status.draft": "مسودة", "posts.status.draft": "مسودة",
"posts.status.in_review": "قيد المراجعة", "posts.status.in_review": "قيد المراجعة",
@@ -232,6 +235,14 @@
"login.forgotPassword": "نسيت كلمة المرور؟", "login.forgotPassword": "نسيت كلمة المرور؟",
"login.defaultCreds": "بيانات الدخول الافتراضية:", "login.defaultCreds": "بيانات الدخول الافتراضية:",
"comments.title": "النقاش",
"comments.noComments": "لا توجد تعليقات بعد. ابدأ المحادثة.",
"comments.placeholder": "اكتب تعليقاً...",
"comments.justNow": "الآن",
"comments.minutesAgo": "منذ {n} دقيقة",
"comments.hoursAgo": "منذ {n} ساعة",
"comments.daysAgo": "منذ {n} يوم",
"profile.completeYourProfile": "أكمل ملفك الشخصي", "profile.completeYourProfile": "أكمل ملفك الشخصي",
"profile.completeDesc": "يرجى إكمال ملفك الشخصي للوصول إلى جميع الميزات ومساعدة فريقك في العثور عليك.", "profile.completeDesc": "يرجى إكمال ملفك الشخصي للوصول إلى جميع الميزات ومساعدة فريقك في العثور عليك.",
"profile.completeProfileBtn": "إكمال الملف", "profile.completeProfileBtn": "إكمال الملف",

View File

@@ -106,6 +106,9 @@
"posts.approve": "Approve", "posts.approve": "Approve",
"posts.schedule": "Schedule", "posts.schedule": "Schedule",
"posts.publish": "Publish", "posts.publish": "Publish",
"posts.attachFromAssets": "Attach from Assets",
"posts.selectAssets": "Select an asset to attach",
"posts.noAssetsFound": "No assets found",
"posts.status.draft": "Draft", "posts.status.draft": "Draft",
"posts.status.in_review": "In Review", "posts.status.in_review": "In Review",
@@ -232,6 +235,14 @@
"login.forgotPassword": "Forgot password?", "login.forgotPassword": "Forgot password?",
"login.defaultCreds": "Default credentials:", "login.defaultCreds": "Default credentials:",
"comments.title": "Discussion",
"comments.noComments": "No comments yet. Start the conversation.",
"comments.placeholder": "Write a comment...",
"comments.justNow": "Just now",
"comments.minutesAgo": "{n}m ago",
"comments.hoursAgo": "{n}h ago",
"comments.daysAgo": "{n}d ago",
"profile.completeYourProfile": "Complete Your Profile", "profile.completeYourProfile": "Complete Your Profile",
"profile.completeDesc": "Please complete your profile to access all features and help your team find you.", "profile.completeDesc": "Please complete your profile to access all features and help your team find you.",
"profile.completeProfileBtn": "Complete Profile", "profile.completeProfileBtn": "Complete Profile",

View File

@@ -9,6 +9,8 @@ import PlatformIcon, { PlatformIcons } from '../components/PlatformIcon'
import StatusBadge from '../components/StatusBadge' import StatusBadge from '../components/StatusBadge'
import BrandBadge from '../components/BrandBadge' import BrandBadge from '../components/BrandBadge'
import Modal from '../components/Modal' import Modal from '../components/Modal'
import BudgetBar from '../components/BudgetBar'
import CommentsSection from '../components/CommentsSection'
const TRACK_TYPES = { const TRACK_TYPES = {
organic_social: { label: 'Organic Social', icon: Megaphone, color: 'text-green-600 bg-green-50', hasBudget: false }, organic_social: { label: 'Organic Social', icon: Megaphone, color: 'text-green-600 bg-green-50', hasBudget: false },
@@ -28,23 +30,6 @@ const EMPTY_METRICS = {
budget_spent: '', revenue: '', impressions: '', clicks: '', conversions: '', notes: '', budget_spent: '', revenue: '', impressions: '', clicks: '', conversions: '', notes: '',
} }
function BudgetBar({ budget, spent }) {
if (!budget || budget <= 0) return null
const pct = Math.min((spent / budget) * 100, 100)
const color = pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-amber-500' : 'bg-emerald-500'
return (
<div className="w-full">
<div className="flex justify-between text-[10px] text-text-tertiary mb-0.5">
<span>{(spent || 0).toLocaleString()} spent</span>
<span>{budget.toLocaleString()} SAR</span>
</div>
<div className="h-2 bg-surface-tertiary rounded-full overflow-hidden">
<div className={`h-full ${color} rounded-full transition-all`} style={{ width: `${pct}%` }} />
</div>
</div>
)
}
function MetricBox({ label, value, icon: Icon, color = 'text-text-primary' }) { function MetricBox({ label, value, icon: Icon, color = 'text-text-primary' }) {
return ( return (
<div className="text-center"> <div className="text-center">
@@ -232,7 +217,7 @@ export default function CampaignDetail() {
</div> </div>
{totalAllocated > 0 && ( {totalAllocated > 0 && (
<div className="mt-4"> <div className="mt-4">
<BudgetBar budget={totalAllocated} spent={totalSpent} /> <BudgetBar budget={totalAllocated} spent={totalSpent} height="h-2" />
</div> </div>
)} )}
</div> </div>
@@ -285,7 +270,7 @@ export default function CampaignDetail() {
{/* Budget bar for paid tracks */} {/* Budget bar for paid tracks */}
{track.budget_allocated > 0 && ( {track.budget_allocated > 0 && (
<div className="w-48 mt-1.5"> <div className="w-48 mt-1.5">
<BudgetBar budget={track.budget_allocated} spent={track.budget_spent || 0} /> <BudgetBar budget={track.budget_allocated} spent={track.budget_spent || 0} height="h-2" />
</div> </div>
)} )}
@@ -378,6 +363,11 @@ export default function CampaignDetail() {
</div> </div>
)} )}
{/* Discussion */}
<div className="bg-white rounded-xl border border-border p-6">
<CommentsSection entityType="campaign" entityId={Number(id)} />
</div>
{/* Add/Edit Track Modal */} {/* Add/Edit Track Modal */}
<Modal <Modal
isOpen={showTrackModal} isOpen={showTrackModal}

View File

@@ -10,6 +10,7 @@ import CampaignCalendar from '../components/CampaignCalendar'
import StatusBadge from '../components/StatusBadge' import StatusBadge from '../components/StatusBadge'
import BrandBadge from '../components/BrandBadge' import BrandBadge from '../components/BrandBadge'
import Modal from '../components/Modal' import Modal from '../components/Modal'
import BudgetBar from '../components/BudgetBar'
const EMPTY_CAMPAIGN = { const EMPTY_CAMPAIGN = {
name: '', description: '', brand_id: '', status: 'planning', name: '', description: '', brand_id: '', status: 'planning',
@@ -17,23 +18,6 @@ const EMPTY_CAMPAIGN = {
budget_spent: '', revenue: '', impressions: '', clicks: '', conversions: '', cost_per_click: '', notes: '', budget_spent: '', revenue: '', impressions: '', clicks: '', conversions: '', cost_per_click: '', notes: '',
} }
function BudgetBar({ budget, spent }) {
if (!budget || budget <= 0) return null
const pct = Math.min((spent / budget) * 100, 100)
const color = pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-amber-500' : 'bg-emerald-500'
return (
<div className="w-full">
<div className="flex justify-between text-[10px] text-text-tertiary mb-0.5">
<span>{spent?.toLocaleString() || 0} SAR spent</span>
<span>{budget?.toLocaleString()} SAR</span>
</div>
<div className="h-1.5 bg-surface-tertiary rounded-full overflow-hidden">
<div className={`h-full ${color} rounded-full transition-all`} style={{ width: `${pct}%` }} />
</div>
</div>
)
}
function ROIBadge({ revenue, spent }) { function ROIBadge({ revenue, spent }) {
if (!spent || spent <= 0) return null if (!spent || spent <= 0) return null
const roi = ((revenue - spent) / spent * 100).toFixed(0) const roi = ((revenue - spent) / spent * 100).toFixed(0)

View File

@@ -1,14 +1,20 @@
import { useContext, useEffect, useState } from 'react' import { useContext, useEffect, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { format, isAfter, isBefore, addDays } from 'date-fns' import { format, isAfter, isBefore, addDays } from 'date-fns'
import { FileText, Megaphone, AlertTriangle, Users, ArrowRight, Clock, Wallet, TrendingUp, DollarSign, PiggyBank } from 'lucide-react' import { FileText, Megaphone, AlertTriangle, ArrowRight, Clock, Wallet, TrendingUp, DollarSign, PiggyBank } from 'lucide-react'
import { AppContext } from '../App' import { AppContext } from '../App'
import { useLanguage } from '../i18n/LanguageContext' import { useLanguage } from '../i18n/LanguageContext'
import { api } from '../utils/api' import { api, PRIORITY_CONFIG } from '../utils/api'
import StatCard from '../components/StatCard' import StatCard from '../components/StatCard'
import StatusBadge from '../components/StatusBadge' import StatusBadge from '../components/StatusBadge'
import BrandBadge from '../components/BrandBadge' import BrandBadge from '../components/BrandBadge'
function getBudgetBarColor(percentage) {
if (percentage > 90) return 'bg-red-500'
if (percentage > 70) return 'bg-amber-500'
return 'bg-emerald-500'
}
function FinanceMini({ finance }) { function FinanceMini({ finance }) {
const { t } = useLanguage() const { t } = useLanguage()
if (!finance) return null if (!finance) return null
@@ -17,7 +23,7 @@ function FinanceMini({ finance }) {
const remaining = finance.remaining || 0 const remaining = finance.remaining || 0
const roi = finance.roi || 0 const roi = finance.roi || 0
const pct = totalReceived > 0 ? (spent / totalReceived) * 100 : 0 const pct = totalReceived > 0 ? (spent / totalReceived) * 100 : 0
const barColor = pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-amber-500' : 'bg-emerald-500' const barColor = getBudgetBarColor(pct)
return ( return (
<div className="bg-white rounded-xl border border-border p-5"> <div className="bg-white rounded-xl border border-border p-5">
@@ -74,6 +80,7 @@ function FinanceMini({ finance }) {
} }
function ActiveCampaignsList({ campaigns, finance }) { function ActiveCampaignsList({ campaigns, finance }) {
const { t } = useLanguage()
const active = campaigns.filter(c => c.status === 'active') const active = campaigns.filter(c => c.status === 'active')
const campaignData = (finance?.campaigns || []).filter(c => c.status === 'active') const campaignData = (finance?.campaigns || []).filter(c => c.status === 'active')
@@ -93,7 +100,7 @@ function ActiveCampaignsList({ campaigns, finance }) {
const spent = cd.tracks_spent || 0 const spent = cd.tracks_spent || 0
const allocated = cd.tracks_allocated || 0 const allocated = cd.tracks_allocated || 0
const pct = allocated > 0 ? (spent / allocated) * 100 : 0 const pct = allocated > 0 ? (spent / allocated) * 100 : 0
const barColor = pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-amber-500' : 'bg-emerald-500' const barColor = getBudgetBarColor(pct)
return ( return (
<Link key={c._id || c.id} to={`/campaigns/${c._id || c.id}`} className="flex items-center gap-4 px-5 py-3 hover:bg-surface-secondary transition-colors"> <Link key={c._id || c.id} to={`/campaigns/${c._id || c.id}`} className="flex items-center gap-4 px-5 py-3 hover:bg-surface-secondary transition-colors">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -190,10 +197,10 @@ export default function Dashboard() {
{/* Welcome */} {/* Welcome */}
<div> <div>
<h1 className="text-2xl font-bold text-text-primary"> <h1 className="text-2xl font-bold text-text-primary">
Welcome back, {currentUser?.name || 'there'} 👋 {t('dashboard.welcomeBack')}, {currentUser?.name || 'there'}
</h1> </h1>
<p className="text-text-secondary mt-1"> <p className="text-text-secondary mt-1">
Here's what's happening with your marketing today. {t('dashboard.happeningToday')}
</p> </p>
</div> </div>
@@ -201,30 +208,30 @@ export default function Dashboard() {
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 stagger-children"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 stagger-children">
<StatCard <StatCard
icon={FileText} icon={FileText}
label="Total Posts" label={t('dashboard.totalPosts')}
value={posts.length || 0} value={posts.length || 0}
subtitle={`${posts.filter(p => p.status === 'published').length} published`} subtitle={`${posts.filter(p => p.status === 'published').length} ${t('dashboard.published')}`}
color="brand-primary" color="brand-primary"
/> />
<StatCard <StatCard
icon={Megaphone} icon={Megaphone}
label="Active Campaigns" label={t('dashboard.activeCampaigns')}
value={activeCampaigns} value={activeCampaigns}
subtitle={`${campaigns.length} total`} subtitle={`${campaigns.length} ${t('dashboard.total')}`}
color="brand-secondary" color="brand-secondary"
/> />
<StatCard <StatCard
icon={Wallet} icon={Wallet}
label="Budget Spent" label={t('dashboard.budgetSpent')}
value={`${((finance?.spent || 0)).toLocaleString()}`} value={`${(finance?.spent || 0).toLocaleString()}`}
subtitle={finance?.totalReceived ? `of ${finance.totalReceived.toLocaleString()} SAR` : 'No budget yet'} subtitle={finance?.totalReceived ? `${t('dashboard.of')} ${finance.totalReceived.toLocaleString()} ${t('dashboard.sar')}` : t('dashboard.noBudget')}
color="brand-tertiary" color="brand-tertiary"
/> />
<StatCard <StatCard
icon={AlertTriangle} icon={AlertTriangle}
label="Overdue Tasks" label={t('dashboard.overdueTasks')}
value={overdueTasks} value={overdueTasks}
subtitle={overdueTasks > 0 ? 'Needs attention' : 'All on track'} subtitle={overdueTasks > 0 ? t('dashboard.needsAttention') : t('dashboard.allOnTrack')}
color="brand-quaternary" color="brand-quaternary"
/> />
</div> </div>
@@ -245,15 +252,15 @@ export default function Dashboard() {
{/* Recent Posts */} {/* Recent Posts */}
<div className="bg-white rounded-xl border border-border"> <div className="bg-white rounded-xl border border-border">
<div className="flex items-center justify-between px-5 py-4 border-b border-border"> <div className="flex items-center justify-between px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">Recent Posts</h3> <h3 className="font-semibold text-text-primary">{t('dashboard.recentPosts')}</h3>
<Link to="/posts" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1"> <Link to="/posts" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
View all <ArrowRight className="w-3.5 h-3.5" /> {t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
</Link> </Link>
</div> </div>
<div className="divide-y divide-border-light"> <div className="divide-y divide-border-light">
{posts.length === 0 ? ( {posts.length === 0 ? (
<div className="py-12 text-center text-sm text-text-tertiary"> <div className="py-12 text-center text-sm text-text-tertiary">
No posts yet. Create your first post! {t('dashboard.noPostsYet')}
</div> </div>
) : ( ) : (
posts.slice(0, 8).map((post) => ( posts.slice(0, 8).map((post) => (
@@ -274,24 +281,20 @@ export default function Dashboard() {
{/* Upcoming Deadlines */} {/* Upcoming Deadlines */}
<div className="bg-white rounded-xl border border-border"> <div className="bg-white rounded-xl border border-border">
<div className="flex items-center justify-between px-5 py-4 border-b border-border"> <div className="flex items-center justify-between px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">Upcoming Deadlines</h3> <h3 className="font-semibold text-text-primary">{t('dashboard.upcomingDeadlines')}</h3>
<Link to="/tasks" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1"> <Link to="/tasks" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
View all <ArrowRight className="w-3.5 h-3.5" /> {t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
</Link> </Link>
</div> </div>
<div className="divide-y divide-border-light"> <div className="divide-y divide-border-light">
{upcomingDeadlines.length === 0 ? ( {upcomingDeadlines.length === 0 ? (
<div className="py-12 text-center text-sm text-text-tertiary"> <div className="py-12 text-center text-sm text-text-tertiary">
No upcoming deadlines this week. 🎉 {t('dashboard.noUpcomingDeadlines')}
</div> </div>
) : ( ) : (
upcomingDeadlines.map((task) => ( upcomingDeadlines.map((task) => (
<div key={task._id} className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors"> <div key={task._id} className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors">
<div className={`w-2 h-2 rounded-full ${ <div className={`w-2 h-2 rounded-full ${(PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium).color}`} />
task.priority === 'urgent' ? 'bg-red-500' :
task.priority === 'high' ? 'bg-orange-500' :
task.priority === 'medium' ? 'bg-amber-400' : 'bg-gray-400'
}`} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{task.title}</p> <p className="text-sm font-medium text-text-primary truncate">{task.title}</p>
<StatusBadge status={task.status} size="xs" /> <StatusBadge status={task.status} size="xs" />

View File

@@ -18,7 +18,7 @@ const EMPTY_ENTRY = {
label: '', amount: '', source: '', campaign_id: '', category: 'marketing', date_received: new Date().toISOString().slice(0, 10), notes: '', label: '', amount: '', source: '', campaign_id: '', category: 'marketing', date_received: new Date().toISOString().slice(0, 10), notes: '',
} }
function StatCard({ icon: Icon, label, value, sub, color = 'text-text-primary', bgColor = 'bg-white' }) { function FinanceStatCard({ icon: Icon, label, value, sub, color = 'text-text-primary', bgColor = 'bg-white' }) {
return ( return (
<div className={`${bgColor} rounded-xl border border-border p-5`}> <div className={`${bgColor} rounded-xl border border-border p-5`}>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
@@ -154,11 +154,11 @@ export default function Finance() {
<div className="space-y-6 animate-fade-in"> <div className="space-y-6 animate-fade-in">
{/* Top metrics */} {/* Top metrics */}
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
<StatCard icon={Wallet} label="Total Received" value={`${totalReceived.toLocaleString()} SAR`} color="text-blue-600" /> <FinanceStatCard icon={Wallet} label="Total Received" value={`${totalReceived.toLocaleString()} SAR`} color="text-blue-600" />
<StatCard icon={TrendingUp} label="Total Spent" value={`${totalSpent.toLocaleString()} SAR`} sub={`${spendPct.toFixed(1)}% of budget`} color="text-amber-600" /> <FinanceStatCard icon={TrendingUp} label="Total Spent" value={`${totalSpent.toLocaleString()} SAR`} sub={`${spendPct.toFixed(1)}% of budget`} color="text-amber-600" />
<StatCard icon={PiggyBank} label="Remaining" value={`${remaining.toLocaleString()} SAR`} color={remaining >= 0 ? 'text-emerald-600' : 'text-red-600'} /> <FinanceStatCard icon={PiggyBank} label="Remaining" value={`${remaining.toLocaleString()} SAR`} color={remaining >= 0 ? 'text-emerald-600' : 'text-red-600'} />
<StatCard icon={DollarSign} label="Revenue" value={`${totalRevenue.toLocaleString()} SAR`} color="text-purple-600" /> <FinanceStatCard icon={DollarSign} label="Revenue" value={`${totalRevenue.toLocaleString()} SAR`} color="text-purple-600" />
<StatCard icon={roi >= 0 ? TrendingUp : TrendingDown} label="Global ROI" <FinanceStatCard icon={roi >= 0 ? TrendingUp : TrendingDown} label="Global ROI"
value={`${roi.toFixed(1)}%`} value={`${roi.toFixed(1)}%`}
color={roi >= 0 ? 'text-emerald-600' : 'text-red-600'} /> color={roi >= 0 ? 'text-emerald-600' : 'text-red-600'} />
</div> </div>

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useContext, useRef } from 'react' import { useState, useEffect, useContext, useRef } from 'react'
import { Plus, LayoutGrid, List, Search, Filter, Upload, X, Paperclip, Link2, FileText, Image as ImageIcon, Trash2, ExternalLink } from 'lucide-react' import { Plus, LayoutGrid, List, Search, Filter, Upload, X, Paperclip, Link2, FileText, Image as ImageIcon, Trash2, ExternalLink, FolderOpen } from 'lucide-react'
import { AppContext } from '../App' import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext' import { useLanguage } from '../i18n/LanguageContext'
@@ -8,6 +8,7 @@ import KanbanBoard from '../components/KanbanBoard'
import PostCard from '../components/PostCard' import PostCard from '../components/PostCard'
import StatusBadge from '../components/StatusBadge' import StatusBadge from '../components/StatusBadge'
import Modal from '../components/Modal' import Modal from '../components/Modal'
import CommentsSection from '../components/CommentsSection'
const EMPTY_POST = { const EMPTY_POST = {
title: '', description: '', brand_id: '', platforms: [], title: '', description: '', brand_id: '', platforms: [],
@@ -33,7 +34,11 @@ export default function PostProduction() {
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0) const [uploadProgress, setUploadProgress] = useState(0)
const [publishError, setPublishError] = useState('') const [publishError, setPublishError] = useState('')
const [moveError, setMoveError] = useState('')
const [dragActive, setDragActive] = useState(false) const [dragActive, setDragActive] = useState(false)
const [showAssetPicker, setShowAssetPicker] = useState(false)
const [availableAssets, setAvailableAssets] = useState([])
const [assetSearch, setAssetSearch] = useState('')
const fileInputRef = useRef(null) const fileInputRef = useRef(null)
useEffect(() => { useEffect(() => {
@@ -106,7 +111,8 @@ export default function PostProduction() {
} catch (err) { } catch (err) {
console.error('Move failed:', err) console.error('Move failed:', err)
if (err.message?.includes('Cannot publish')) { if (err.message?.includes('Cannot publish')) {
alert('Cannot publish: all platform publication links must be filled first.') setMoveError(t('posts.publishRequired'))
setTimeout(() => setMoveError(''), 5000)
} }
} }
} }
@@ -152,6 +158,30 @@ export default function PostProduction() {
} }
} }
const openAssetPicker = async () => {
try {
const data = await api.get('/assets')
setAvailableAssets(Array.isArray(data) ? data : (data.data || []))
} catch (err) {
console.error('Failed to load assets:', err)
setAvailableAssets([])
}
setAssetSearch('')
setShowAssetPicker(true)
}
const handleAttachAsset = async (assetId) => {
if (!editingPost) return
const postId = editingPost._id || editingPost.id
try {
await api.post(`/posts/${postId}/attachments/from-asset`, { asset_id: assetId })
loadAttachments(postId)
setShowAssetPicker(false)
} catch (err) {
console.error('Attach asset failed:', err)
}
}
const handleDragEnter = (e) => { e.preventDefault(); e.stopPropagation(); setDragActive(true) } const handleDragEnter = (e) => { e.preventDefault(); e.stopPropagation(); setDragActive(true) }
const handleDragLeaveZone = (e) => { e.preventDefault(); e.stopPropagation(); setDragActive(false) } const handleDragLeaveZone = (e) => { e.preventDefault(); e.stopPropagation(); setDragActive(false) }
const handleDragOverZone = (e) => { e.preventDefault(); e.stopPropagation() } const handleDragOverZone = (e) => { e.preventDefault(); e.stopPropagation() }
@@ -297,6 +327,16 @@ export default function PostProduction() {
</button> </button>
</div> </div>
{/* Move error banner */}
{moveError && (
<div className="p-3 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-700 flex items-center justify-between">
<span>{moveError}</span>
<button onClick={() => setMoveError('')} className="p-0.5 hover:bg-amber-100 rounded">
<X className="w-4 h-4" />
</button>
</div>
)}
{/* Content */} {/* Content */}
{view === 'kanban' ? ( {view === 'kanban' ? (
<KanbanBoard posts={filteredPosts} onPostClick={openEdit} onMovePost={handleMovePost} /> <KanbanBoard posts={filteredPosts} onPostClick={openEdit} onMovePost={handleMovePost} />
@@ -534,27 +574,29 @@ export default function PostProduction() {
const name = att.original_name || att.originalName || att.filename const name = att.original_name || att.originalName || att.filename
return ( return (
<div key={att.id || att._id} className="relative group/att border border-border rounded-lg overflow-hidden bg-white"> <div key={att.id || att._id} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
{isImage ? ( <div className="h-24 relative">
<a href={`http://localhost:3001${attUrl}`} target="_blank" rel="noopener noreferrer"> {isImage ? (
<img <a href={`http://localhost:3001${attUrl}`} target="_blank" rel="noopener noreferrer" className="block h-full">
src={`http://localhost:3001${attUrl}`} <img
alt={name} src={`http://localhost:3001${attUrl}`}
className="w-full h-24 object-cover" alt={name}
/> className="absolute inset-0 w-full h-full object-cover"
</a> />
) : ( </a>
<a href={`http://localhost:3001${attUrl}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 p-3 h-24"> ) : (
<FileText className="w-8 h-8 text-text-tertiary shrink-0" /> <a href={`http://localhost:3001${attUrl}`} target="_blank" rel="noopener noreferrer" className="absolute inset-0 flex items-center gap-2 p-3">
<span className="text-xs text-text-secondary truncate">{name}</span> <FileText className="w-8 h-8 text-text-tertiary shrink-0" />
</a> <span className="text-xs text-text-secondary truncate">{name}</span>
)} </a>
<button )}
onClick={() => handleDeleteAttachment(att.id || att._id)} <button
className="absolute top-1 right-1 p-1 bg-red-500 text-white rounded-full opacity-0 group-hover/att:opacity-100 transition-opacity shadow-sm" onClick={() => handleDeleteAttachment(att.id || att._id)}
title={t('posts.deleteAttachment')} className="absolute top-1 right-1 p-1 bg-red-500 text-white rounded-full opacity-0 group-hover/att:opacity-100 transition-opacity shadow-sm z-10"
> title={t('posts.deleteAttachment')}
<X className="w-3 h-3" /> >
</button> <X className="w-3 h-3" />
</button>
</div>
<div className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light"> <div className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">
{name} {name}
</div> </div>
@@ -589,6 +631,65 @@ export default function PostProduction() {
<p className="text-[10px] text-text-tertiary mt-0.5">{t('posts.maxSize')}</p> <p className="text-[10px] text-text-tertiary mt-0.5">{t('posts.maxSize')}</p>
</div> </div>
{/* Attach from Assets button */}
<button
type="button"
onClick={openAssetPicker}
className="mt-2 flex items-center gap-2 px-3 py-2 text-sm text-text-secondary border border-border rounded-lg hover:bg-surface-tertiary transition-colors w-full justify-center"
>
<FolderOpen className="w-4 h-4" />
{t('posts.attachFromAssets')}
</button>
{/* Asset picker */}
{showAssetPicker && (
<div className="mt-2 border border-border rounded-lg p-3 bg-surface-secondary">
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-medium text-text-secondary">{t('posts.selectAssets')}</p>
<button onClick={() => setShowAssetPicker(false)} className="p-1 text-text-tertiary hover:text-text-primary">
<X className="w-3.5 h-3.5" />
</button>
</div>
<input
type="text"
value={assetSearch}
onChange={e => setAssetSearch(e.target.value)}
placeholder={t('common.search')}
className="w-full px-3 py-1.5 text-xs border border-border rounded-lg mb-2 focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2 max-h-48 overflow-y-auto">
{availableAssets
.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase()))
.map(asset => {
const isImage = asset.mime_type?.startsWith('image/')
const assetUrl = `/api/uploads/${asset.filename}`
const name = asset.original_name || asset.filename
return (
<button
key={asset.id}
onClick={() => handleAttachAsset(asset.id)}
className="block w-full border border-border rounded-lg overflow-hidden bg-white hover:border-brand-primary hover:ring-2 hover:ring-brand-primary/20 transition-all text-left"
>
<div className="aspect-square relative">
{isImage ? (
<img src={`http://localhost:3001${assetUrl}`} alt={name} className="absolute inset-0 w-full h-full object-cover" />
) : (
<div className="absolute inset-0 flex items-center justify-center bg-surface-tertiary">
<FileText className="w-6 h-6 text-text-tertiary" />
</div>
)}
</div>
<div className="px-1.5 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
</button>
)
})}
</div>
{availableAssets.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase())).length === 0 && (
<p className="text-xs text-text-tertiary text-center py-4">{t('posts.noAssetsFound')}</p>
)}
</div>
)}
{/* Upload progress */} {/* Upload progress */}
{uploading && ( {uploading && (
<div className="mt-2"> <div className="mt-2">
@@ -607,6 +708,11 @@ export default function PostProduction() {
</div> </div>
)} )}
{/* Comments (only for existing posts) */}
{editingPost && (
<CommentsSection entityType="post" entityId={editingPost._id || editingPost.id} />
)}
{/* Publish validation error */} {/* Publish validation error */}
{publishError && ( {publishError && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700"> <div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">

View File

@@ -4,12 +4,13 @@ import {
ArrowLeft, Plus, Check, Trash2, Edit3, LayoutGrid, List, ArrowLeft, Plus, Check, Trash2, Edit3, LayoutGrid, List,
GanttChart, Settings, Calendar, Clock GanttChart, Settings, Calendar, Clock
} from 'lucide-react' } from 'lucide-react'
import { format, differenceInDays, startOfDay, addDays, isAfter, isBefore, parseISO } from 'date-fns' import { format, differenceInDays, startOfDay, addDays, isAfter, isBefore } from 'date-fns'
import { AppContext } from '../App' import { AppContext } from '../App'
import { api, PRIORITY_CONFIG } from '../utils/api' import { api, PRIORITY_CONFIG } from '../utils/api'
import StatusBadge from '../components/StatusBadge' import StatusBadge from '../components/StatusBadge'
import BrandBadge from '../components/BrandBadge' import BrandBadge from '../components/BrandBadge'
import Modal from '../components/Modal' import Modal from '../components/Modal'
import CommentsSection from '../components/CommentsSection'
const TASK_COLUMNS = [ const TASK_COLUMNS = [
{ id: 'todo', label: 'To Do', color: 'bg-gray-400' }, { id: 'todo', label: 'To Do', color: 'bg-gray-400' },
@@ -24,6 +25,7 @@ export default function ProjectDetail() {
const [project, setProject] = useState(null) const [project, setProject] = useState(null)
const [tasks, setTasks] = useState([]) const [tasks, setTasks] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [assignableUsers, setAssignableUsers] = useState([])
const [view, setView] = useState('kanban') const [view, setView] = useState('kanban')
const [showTaskModal, setShowTaskModal] = useState(false) const [showTaskModal, setShowTaskModal] = useState(false)
const [showProjectModal, setShowProjectModal] = useState(false) const [showProjectModal, setShowProjectModal] = useState(false)
@@ -42,6 +44,9 @@ export default function ProjectDetail() {
const [dragOverCol, setDragOverCol] = useState(null) const [dragOverCol, setDragOverCol] = useState(null)
useEffect(() => { loadProject() }, [id]) useEffect(() => { loadProject() }, [id])
useEffect(() => {
api.get('/users/assignable').then(res => setAssignableUsers(res.data || res || [])).catch(() => {})
}, [])
const loadProject = async () => { const loadProject = async () => {
try { try {
@@ -208,31 +213,6 @@ export default function ProjectDetail() {
const ownerName = project.ownerName || project.owner_name const ownerName = project.ownerName || project.owner_name
const brandName = project.brandName || project.brand_name const brandName = project.brandName || project.brand_name
// Gantt chart helpers
const getGanttRange = () => {
const today = startOfDay(new Date())
let earliest = today
let latest = addDays(today, 14)
tasks.forEach(t => {
if (t.createdAt) {
const d = startOfDay(new Date(t.createdAt))
if (isBefore(d, earliest)) earliest = d
}
if (t.dueDate) {
const d = startOfDay(new Date(t.dueDate))
if (isAfter(d, latest)) latest = addDays(d, 1)
}
})
if (project.dueDate) {
const d = startOfDay(new Date(project.dueDate))
if (isAfter(d, latest)) latest = addDays(d, 1)
}
// Ensure minimum 14 days
if (differenceInDays(latest, earliest) < 14) latest = addDays(earliest, 14)
return { earliest, latest, totalDays: differenceInDays(latest, earliest) + 1 }
}
return ( return (
<div className="space-y-6 animate-fade-in"> <div className="space-y-6 animate-fade-in">
{/* Back button */} {/* Back button */}
@@ -296,6 +276,11 @@ export default function ProjectDetail() {
</div> </div>
</div> </div>
{/* Discussion */}
<div className="bg-white rounded-xl border border-border p-6">
<CommentsSection entityType="project" entityId={Number(id)} />
</div>
{/* View switcher + Add Task */} {/* View switcher + Add Task */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-1 bg-surface-tertiary rounded-lg p-0.5"> <div className="flex items-center gap-1 bg-surface-tertiary rounded-lg p-0.5">
@@ -491,7 +476,7 @@ export default function ProjectDetail() {
<select value={taskForm.assigned_to} onChange={e => setTaskForm(f => ({ ...f, assigned_to: e.target.value }))} <select value={taskForm.assigned_to} onChange={e => setTaskForm(f => ({ ...f, assigned_to: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"> className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
<option value="">Unassigned</option> <option value="">Unassigned</option>
{teamMembers.map(m => <option key={m._id} value={m._id}>{m.name}</option>)} {assignableUsers.map(m => <option key={m._id || m.team_member_id} value={m._id || m.team_member_id}>{m.name}</option>)}
</select> </select>
</div> </div>
<div> <div>
@@ -586,6 +571,19 @@ export default function ProjectDetail() {
</div> </div>
</div> </div>
</Modal> </Modal>
{/* ─── DELETE TASK CONFIRMATION ─── */}
<Modal
isOpen={showDeleteConfirm}
onClose={() => { setShowDeleteConfirm(false); setTaskToDelete(null) }}
title="Delete Task?"
isConfirm
danger
confirmText="Delete Task"
onConfirm={confirmDeleteTask}
>
Are you sure you want to delete this task? This action cannot be undone.
</Modal>
</div> </div>
) )
} }
@@ -760,18 +758,6 @@ function GanttView({ tasks, project, onEditTask }) {
</div> </div>
</div> </div>
{/* Delete Task Confirmation */}
<Modal
isOpen={showDeleteConfirm}
onClose={() => { setShowDeleteConfirm(false); setTaskToDelete(null) }}
title="Delete Task?"
isConfirm
danger
confirmText="Delete Task"
onConfirm={confirmDeleteTask}
>
Are you sure you want to delete this task? This action cannot be undone.
</Modal>
</div> </div>
) )
} }

View File

@@ -6,10 +6,11 @@ import { useLanguage } from '../i18n/LanguageContext'
import { api } from '../utils/api' import { api } from '../utils/api'
import TaskCard from '../components/TaskCard' import TaskCard from '../components/TaskCard'
import Modal from '../components/Modal' import Modal from '../components/Modal'
import CommentsSection from '../components/CommentsSection'
export default function Tasks() { export default function Tasks() {
const { t } = useLanguage() const { t } = useLanguage()
const { currentUser, teamMembers } = useContext(AppContext) const { currentUser } = useContext(AppContext)
const { user: authUser, canEditResource, canDeleteResource } = useAuth() const { user: authUser, canEditResource, canDeleteResource } = useAuth()
const [tasks, setTasks] = useState([]) const [tasks, setTasks] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -21,6 +22,7 @@ export default function Tasks() {
const [taskToDelete, setTaskToDelete] = useState(null) const [taskToDelete, setTaskToDelete] = useState(null)
const [filterView, setFilterView] = useState('all') // 'all' | 'assigned_to_me' | 'created_by_me' | member id const [filterView, setFilterView] = useState('all') // 'all' | 'assigned_to_me' | 'created_by_me' | member id
const [users, setUsers] = useState([]) // for superadmin member filter const [users, setUsers] = useState([]) // for superadmin member filter
const [assignableUsers, setAssignableUsers] = useState([])
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
title: '', description: '', priority: 'medium', due_date: '', status: 'todo', assigned_to: '' title: '', description: '', priority: 'medium', due_date: '', status: 'todo', assigned_to: ''
}) })
@@ -29,6 +31,8 @@ export default function Tasks() {
useEffect(() => { loadTasks() }, [currentUser]) useEffect(() => { loadTasks() }, [currentUser])
useEffect(() => { useEffect(() => {
// Load assignable users for the assignment dropdown
api.get('/users/assignable').then(res => setAssignableUsers(res.data || res || [])).catch(() => {})
if (isSuperadmin) { if (isSuperadmin) {
// Load team members for superadmin filter // Load team members for superadmin filter
api.get('/team').then(res => setUsers(res.data || res || [])).catch(() => {}) api.get('/team').then(res => setUsers(res.data || res || [])).catch(() => {})
@@ -357,8 +361,8 @@ export default function Tasks() {
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('common.unassigned')}</option> <option value="">{t('common.unassigned')}</option>
{(teamMembers || []).map(m => ( {(assignableUsers || []).map(m => (
<option key={m.id || m._id} value={m.id || m._id}>{m.name}</option> <option key={m._id || m.team_member_id} value={m._id || m.team_member_id}>{m.name}</option>
))} ))}
</select> </select>
</div> </div>
@@ -388,6 +392,11 @@ export default function Tasks() {
</div> </div>
</div> </div>
{/* Comments (only for existing tasks) */}
{editingTask && (
<CommentsSection entityType="task" entityId={editingTask._id || editingTask.id} />
)}
<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">
<button <button
onClick={() => { setShowModal(false); setEditingTask(null) }} onClick={() => { setShowModal(false); setEditingTask(null) }}

View File

@@ -25,13 +25,18 @@ const normalize = (data) => {
const handleResponse = async (r, label) => { const handleResponse = async (r, label) => {
if (!r.ok) { if (!r.ok) {
if (r.status === 401 || r.status === 403) { if (r.status === 401) {
// Unauthorized - redirect to login if not already there // Unauthorized (not logged in) - redirect to login if not already there
if (!window.location.pathname.includes('/login')) { if (!window.location.pathname.includes('/login')) {
window.location.href = '/login'; window.location.href = '/login';
} }
} }
throw new Error(`${label} failed: ${r.status}`); let serverMsg = '';
try {
const body = await r.json();
serverMsg = body.error || '';
} catch {}
throw new Error(serverMsg || `${label} failed: ${r.status}`);
} }
const json = await r.json(); const json = await r.json();
return normalize(json); return normalize(json);
@@ -123,3 +128,9 @@ export const PRIORITY_CONFIG = {
high: { label: 'High', color: 'bg-orange-500' }, high: { label: 'High', color: 'bg-orange-500' },
urgent: { label: 'Urgent', color: 'bg-red-500' }, urgent: { label: 'Urgent', color: 'bg-red-500' },
}; };
// Shared helper: extract initials from a name string
export function getInitials(name) {
if (!name) return '?';
return name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
}

View File

@@ -152,98 +152,17 @@ function initialize() {
); );
`); `);
// ─── Ownership columns (link to users table) ─── // ─── Comments / discussion table ───
const addOwnership = (table, column) => { db.exec(`
const cols = db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name); CREATE TABLE IF NOT EXISTS comments (
if (!cols.includes(column)) { id INTEGER PRIMARY KEY AUTOINCREMENT,
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} INTEGER REFERENCES users(id)`); entity_type TEXT NOT NULL,
console.log(`✅ Added ${column} column to ${table}`); entity_id INTEGER NOT NULL,
} user_id INTEGER NOT NULL REFERENCES users(id),
}; content TEXT NOT NULL,
addOwnership('posts', 'created_by_user_id'); created_at DATETIME DEFAULT CURRENT_TIMESTAMP
addOwnership('tasks', 'created_by_user_id'); );
addOwnership('campaigns', 'created_by_user_id'); `);
addOwnership('projects', 'created_by_user_id');
// Add phone column to team_members if missing
const teamMemberCols = db.prepare("PRAGMA table_info(team_members)").all().map(c => c.name);
if (!teamMemberCols.includes('phone')) {
db.exec("ALTER TABLE team_members ADD COLUMN phone TEXT");
console.log('✅ Added phone column to team_members');
}
// Migrations — add columns if they don't exist
const campaignCols = db.prepare("PRAGMA table_info(campaigns)").all().map(c => c.name);
if (!campaignCols.includes('platforms')) {
db.exec("ALTER TABLE campaigns ADD COLUMN platforms TEXT DEFAULT '[]'");
console.log('✅ Added platforms column to campaigns');
}
// Campaign performance tracking columns
if (!campaignCols.includes('budget_spent')) {
db.exec("ALTER TABLE campaigns ADD COLUMN budget_spent REAL DEFAULT 0");
console.log('✅ Added budget_spent column to campaigns');
}
if (!campaignCols.includes('revenue')) {
db.exec("ALTER TABLE campaigns ADD COLUMN revenue REAL DEFAULT 0");
console.log('✅ Added revenue column to campaigns');
}
if (!campaignCols.includes('impressions')) {
db.exec("ALTER TABLE campaigns ADD COLUMN impressions INTEGER DEFAULT 0");
console.log('✅ Added impressions column to campaigns');
}
if (!campaignCols.includes('clicks')) {
db.exec("ALTER TABLE campaigns ADD COLUMN clicks INTEGER DEFAULT 0");
console.log('✅ Added clicks column to campaigns');
}
if (!campaignCols.includes('conversions')) {
db.exec("ALTER TABLE campaigns ADD COLUMN conversions INTEGER DEFAULT 0");
console.log('✅ Added conversions column to campaigns');
}
if (!campaignCols.includes('cost_per_click')) {
db.exec("ALTER TABLE campaigns ADD COLUMN cost_per_click REAL DEFAULT 0");
console.log('✅ Added cost_per_click column to campaigns');
}
if (!campaignCols.includes('notes')) {
db.exec("ALTER TABLE campaigns ADD COLUMN notes TEXT DEFAULT ''");
console.log('✅ Added notes column to campaigns');
}
// Add track_id to posts
const postCols = db.prepare("PRAGMA table_info(posts)").all().map(c => c.name);
if (!postCols.includes('track_id')) {
db.exec("ALTER TABLE posts ADD COLUMN track_id INTEGER REFERENCES campaign_tracks(id)");
console.log('✅ Added track_id column to posts');
}
if (!postCols.includes('campaign_id')) {
db.exec("ALTER TABLE posts ADD COLUMN campaign_id INTEGER REFERENCES campaigns(id)");
console.log('✅ Added campaign_id column to posts');
}
if (!postCols.includes('platforms')) {
// Add platforms column, migrate existing platform values
db.exec("ALTER TABLE posts ADD COLUMN platforms TEXT DEFAULT '[]'");
// Migrate: copy single platform value into platforms JSON array
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`);
}
// Add campaign_id to assets
const assetCols = db.prepare("PRAGMA table_info(assets)").all().map(c => c.name);
if (!assetCols.includes('campaign_id')) {
db.exec("ALTER TABLE assets ADD COLUMN campaign_id INTEGER REFERENCES campaigns(id)");
console.log('✅ Added campaign_id column to assets');
}
// ─── Link users to team_members ───
const userCols = db.prepare("PRAGMA table_info(users)").all().map(c => c.name);
if (!userCols.includes('team_member_id')) {
db.exec("ALTER TABLE users ADD COLUMN team_member_id INTEGER REFERENCES team_members(id)");
console.log('✅ Added team_member_id column to users');
}
// ─── Post attachments table ─── // ─── Post attachments table ───
db.exec(` db.exec(`
@@ -259,30 +178,61 @@ function initialize() {
); );
`); `);
// ─── Publication links column on posts ─── // ─── Column migrations ───
if (!postCols.includes('publication_links')) { // Helper: adds a column to a table if it does not already exist.
db.exec("ALTER TABLE posts ADD COLUMN publication_links TEXT DEFAULT '[]'"); function addColumnIfMissing(table, column, definition) {
console.log('✅ Added publication_links column to posts'); 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}`);
}
} }
// ─── Merge team_members into users ─── // Ownership columns (link to users table)
if (!userCols.includes('team_role')) { for (const table of ['posts', 'tasks', 'campaigns', 'projects']) {
db.exec("ALTER TABLE users ADD COLUMN team_role TEXT"); addColumnIfMissing(table, 'created_by_user_id', 'INTEGER REFERENCES users(id)');
console.log('✅ Added team_role column to users');
} }
if (!userCols.includes('brands')) {
db.exec("ALTER TABLE users ADD COLUMN brands TEXT DEFAULT '[]'"); // team_members additions
console.log('✅ Added brands column to users'); addColumnIfMissing('team_members', 'phone', 'TEXT');
}
if (!userCols.includes('phone')) { // campaigns additions
db.exec("ALTER TABLE users ADD COLUMN phone TEXT"); addColumnIfMissing('campaigns', 'platforms', "TEXT DEFAULT '[]'");
console.log('✅ Added phone column to users'); addColumnIfMissing('campaigns', 'budget_spent', 'REAL DEFAULT 0');
} addColumnIfMissing('campaigns', 'revenue', 'REAL DEFAULT 0');
if (!userCols.includes('tutorial_completed')) { addColumnIfMissing('campaigns', 'impressions', 'INTEGER DEFAULT 0');
db.exec("ALTER TABLE users ADD COLUMN tutorial_completed INTEGER DEFAULT 0"); addColumnIfMissing('campaigns', 'clicks', 'INTEGER DEFAULT 0');
console.log('✅ Added tutorial_completed column to users'); 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) // Migrate team_members to users (one-time migration)
const teamMembers = db.prepare('SELECT * FROM team_members').all(); const teamMembers = db.prepare('SELECT * FROM team_members').all();
const defaultPasswordHash = bcrypt.hashSync('changeme123', 10); const defaultPasswordHash = bcrypt.hashSync('changeme123', 10);

View File

@@ -11,6 +11,60 @@ const { db, initialize } = require('./db');
const app = express(); const app = express();
const PORT = 3001; const PORT = 3001;
// ─── SHARED HELPERS ─────────────────────────────────────────────
// Builds a dynamic UPDATE clause from request body fields.
// Returns { clause, values } where clause is "field1 = ?, field2 = ?" and values is the corresponding array.
// `jsonFields` are serialized with JSON.stringify before binding.
// `extraClauses` are appended as-is (e.g., 'updated_at = CURRENT_TIMESTAMP').
function buildUpdate(body, allowedFields, { jsonFields = [], extraClauses = [] } = {}) {
const clauses = [...extraClauses];
const values = [];
for (const field of allowedFields) {
if (body[field] !== undefined) {
clauses.push(`${field} = ?`);
values.push(jsonFields.includes(field) ? JSON.stringify(body[field]) : body[field]);
}
}
return { clauses, values, hasUpdates: clauses.length > 0 };
}
// Reusable SQL fragments for joined queries
const POST_SELECT_SQL = `SELECT p.*, b.name as brand_name, t.name as assigned_name, c.name as campaign_name, u.name as creator_user_name
FROM posts p
LEFT JOIN brands b ON p.brand_id = b.id
LEFT JOIN team_members t ON p.assigned_to = t.id
LEFT JOIN campaigns c ON p.campaign_id = c.id
LEFT JOIN users u ON p.created_by_user_id = u.id`;
const TASK_SELECT_SQL = `SELECT t.*,
p.name as project_name,
a.name as assigned_name,
c.name as creator_name,
u.name as creator_user_name
FROM tasks t
LEFT JOIN projects p ON t.project_id = p.id
LEFT JOIN team_members a ON t.assigned_to = a.id
LEFT JOIN team_members c ON t.created_by = c.id
LEFT JOIN users u ON t.created_by_user_id = u.id`;
const PROJECT_SELECT_SQL = `SELECT p.*, b.name as brand_name, t.name as owner_name
FROM projects p
LEFT JOIN brands b ON p.brand_id = b.id
LEFT JOIN team_members t ON p.owner_id = t.id`;
const CAMPAIGN_SELECT_SQL = `SELECT c.*, b.name as brand_name
FROM campaigns c
LEFT JOIN brands b ON c.brand_id = b.id`;
function parsePostJson(post) {
return {
...post,
platforms: JSON.parse(post.platforms || '[]'),
publication_links: JSON.parse(post.publication_links || '[]'),
};
}
// Ensure uploads directory exists // Ensure uploads directory exists
const uploadsDir = path.join(__dirname, 'uploads'); const uploadsDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadsDir)) { if (!fs.existsSync(uploadsDir)) {
@@ -52,9 +106,12 @@ app.use(session({
app.use('/api/uploads', express.static(uploadsDir)); app.use('/api/uploads', express.static(uploadsDir));
// Multer config // Multer config
const decodeOriginalName = (name) => Buffer.from(name, 'latin1').toString('utf8');
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, uploadsDir), destination: (req, file, cb) => cb(null, uploadsDir),
filename: (req, file, cb) => { filename: (req, file, cb) => {
file.originalname = decodeOriginalName(file.originalname);
const uniqueName = `${Date.now()}-${Math.round(Math.random() * 1e9)}${path.extname(file.originalname)}`; const uniqueName = `${Date.now()}-${Math.round(Math.random() * 1e9)}${path.extname(file.originalname)}`;
cb(null, uniqueName); cb(null, uniqueName);
} }
@@ -86,7 +143,12 @@ function requireRole(...roles) {
} }
// Ownership check: contributors can only modify their own resources (or resources assigned to them) // Ownership check: contributors can only modify their own resources (or resources assigned to them)
const VALID_OWNER_TABLES = new Set(['posts', 'tasks']);
function requireOwnerOrRole(table, ...allowedRoles) { function requireOwnerOrRole(table, ...allowedRoles) {
if (!VALID_OWNER_TABLES.has(table)) {
throw new Error(`requireOwnerOrRole: invalid table "${table}"`);
}
return (req, res, next) => { return (req, res, next) => {
if (!req.session.userId) { if (!req.session.userId) {
return res.status(401).json({ error: 'Authentication required' }); return res.status(401).json({ error: 'Authentication required' });
@@ -186,31 +248,22 @@ app.get('/api/users/me/profile', requireAuth, (req, res) => {
}); });
app.patch('/api/users/me/profile', requireAuth, async (req, res) => { app.patch('/api/users/me/profile', requireAuth, async (req, res) => {
const { name, team_role, brands, phone } = req.body; const { clauses, values, hasUpdates } = buildUpdate(
const updates = []; req.body, ['name', 'team_role', 'phone', 'brands'], { jsonFields: ['brands'] }
const values = []; );
if (name !== undefined) { updates.push('name = ?'); values.push(name); } if (!hasUpdates) {
if (team_role !== undefined) { updates.push('team_role = ?'); values.push(team_role); }
if (phone !== undefined) { updates.push('phone = ?'); values.push(phone); }
if (brands !== undefined) {
updates.push('brands = ?');
values.push(JSON.stringify(brands));
}
if (updates.length === 0) {
return res.status(400).json({ error: 'No fields to update' }); return res.status(400).json({ error: 'No fields to update' });
} }
try { try {
values.push(req.session.userId); values.push(req.session.userId);
db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...values); db.prepare(`UPDATE users SET ${clauses.join(', ')} WHERE id = ?`).run(...values);
// Also update session if name changed if (req.body.name !== undefined) {
if (name !== undefined) { req.session.userName = req.body.name;
req.session.userName = name;
} }
const user = db.prepare('SELECT id, name, email, role, team_role, brands, phone, avatar, tutorial_completed FROM users WHERE id = ?').get(req.session.userId); const user = db.prepare('SELECT id, name, email, role, team_role, brands, phone, avatar, tutorial_completed FROM users WHERE id = ?').get(req.session.userId);
res.json({ ...user, brands: JSON.parse(user.brands || '[]') }); res.json({ ...user, brands: JSON.parse(user.brands || '[]') });
} catch (err) { } catch (err) {
@@ -274,35 +327,26 @@ app.patch('/api/users/:id', requireAuth, requireRole('superadmin'), async (req,
return res.status(404).json({ error: 'User not found' }); return res.status(404).json({ error: 'User not found' });
} }
const { name, email, password, role, avatar } = req.body; if (req.body.role !== undefined && !['superadmin', 'manager', 'contributor'].includes(req.body.role)) {
const updates = []; return res.status(400).json({ error: 'Invalid role' });
const values = [];
if (name !== undefined) { updates.push('name = ?'); values.push(name); }
if (email !== undefined) { updates.push('email = ?'); values.push(email); }
if (role !== undefined) {
if (!['superadmin', 'manager', 'contributor'].includes(role)) {
return res.status(400).json({ error: 'Invalid role' });
}
updates.push('role = ?');
values.push(role);
}
if (avatar !== undefined) { updates.push('avatar = ?'); values.push(avatar); }
if (req.body.team_member_id !== undefined) { updates.push('team_member_id = ?'); values.push(req.body.team_member_id || null); }
if (password) {
const passwordHash = await bcrypt.hash(password, 10);
updates.push('password_hash = ?');
values.push(passwordHash);
} }
if (updates.length === 0) { const { clauses, values, hasUpdates } = buildUpdate(
req.body, ['name', 'email', 'role', 'avatar', 'team_member_id']
);
if (req.body.password) {
clauses.push('password_hash = ?');
values.push(await bcrypt.hash(req.body.password, 10));
}
if (!hasUpdates && !req.body.password) {
return res.status(400).json({ error: 'No fields to update' }); return res.status(400).json({ error: 'No fields to update' });
} }
try { try {
values.push(id); values.push(id);
db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...values); db.prepare(`UPDATE users SET ${clauses.join(', ')} WHERE id = ?`).run(...values);
const user = db.prepare('SELECT id, name, email, role, avatar, team_member_id, created_at FROM users WHERE id = ?').get(id); const user = db.prepare('SELECT id, name, email, role, avatar, team_member_id, created_at FROM users WHERE id = ?').get(id);
res.json(user); res.json(user);
} catch (err) { } catch (err) {
@@ -352,6 +396,18 @@ app.get('/api/auth/permissions', requireAuth, (req, res) => {
}); });
}); });
// ─── ASSIGNABLE USERS (no brand filtering) ──────────────────────
app.get('/api/users/assignable', requireAuth, (req, res) => {
const users = db.prepare(`
SELECT u.id, u.name, u.team_role, u.avatar, u.team_member_id
FROM users u
WHERE u.team_member_id IS NOT NULL
ORDER BY u.name
`).all();
res.json(users.map(u => ({ ...u, _id: u.team_member_id })));
});
// ─── TEAM (Users with team info) ──────────────────────────────── // ─── TEAM (Users with team info) ────────────────────────────────
app.get('/api/users/team', requireAuth, (req, res) => { app.get('/api/users/team', requireAuth, (req, res) => {
@@ -432,38 +488,26 @@ app.patch('/api/users/team/:id', requireAuth, requireRole('superadmin', 'manager
const existing = db.prepare('SELECT * FROM users WHERE id = ?').get(id); const existing = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
if (!existing) return res.status(404).json({ error: 'User not found' }); if (!existing) return res.status(404).json({ error: 'User not found' });
const updates = []; const { clauses, values, hasUpdates } = buildUpdate(
const values = []; req.body, ['name', 'email', 'team_role', 'phone', 'brands'], { jsonFields: ['brands'] }
);
if (req.body.name !== undefined) { updates.push('name = ?'); values.push(req.body.name); } if (!hasUpdates) return res.status(400).json({ error: 'No fields to update' });
if (req.body.email !== undefined) { updates.push('email = ?'); values.push(req.body.email); }
if (req.body.team_role !== undefined) { updates.push('team_role = ?'); values.push(req.body.team_role); }
if (req.body.phone !== undefined) { updates.push('phone = ?'); values.push(req.body.phone); }
if (req.body.brands !== undefined) {
updates.push('brands = ?');
values.push(JSON.stringify(req.body.brands));
}
if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' });
// Also update team_members table for backward compatibility // Also update team_members table for backward compatibility
if (existing.team_member_id) { if (existing.team_member_id) {
const tmUpdates = []; // Map user fields to team_member fields (team_role -> role)
const tmValues = []; const tmBody = { ...req.body };
if (req.body.name !== undefined) { tmUpdates.push('name = ?'); tmValues.push(req.body.name); } if (tmBody.team_role !== undefined) { tmBody.role = tmBody.team_role; delete tmBody.team_role; }
if (req.body.email !== undefined) { tmUpdates.push('email = ?'); tmValues.push(req.body.email); } const tm = buildUpdate(tmBody, ['name', 'email', 'role', 'phone', 'brands'], { jsonFields: ['brands'] });
if (req.body.team_role !== undefined) { tmUpdates.push('role = ?'); tmValues.push(req.body.team_role); } if (tm.hasUpdates) {
if (req.body.phone !== undefined) { tmUpdates.push('phone = ?'); tmValues.push(req.body.phone); } tm.values.push(existing.team_member_id);
if (req.body.brands !== undefined) { tmUpdates.push('brands = ?'); tmValues.push(JSON.stringify(req.body.brands)); } db.prepare(`UPDATE team_members SET ${tm.clauses.join(', ')} WHERE id = ?`).run(...tm.values);
if (tmUpdates.length > 0) {
tmValues.push(existing.team_member_id);
db.prepare(`UPDATE team_members SET ${tmUpdates.join(', ')} WHERE id = ?`).run(...tmValues);
} }
} }
values.push(id); values.push(id);
db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...values); db.prepare(`UPDATE users SET ${clauses.join(', ')} WHERE id = ?`).run(...values);
const user = db.prepare('SELECT id, name, email, role, team_role, brands, phone, avatar, team_member_id FROM users WHERE id = ?').get(id); const user = db.prepare('SELECT id, name, email, role, team_role, brands, phone, avatar, team_member_id FROM users WHERE id = ?').get(id);
res.json({ ...user, _id: user.id, brands: JSON.parse(user.brands || '[]') }); res.json({ ...user, _id: user.id, brands: JSON.parse(user.brands || '[]') });
@@ -508,25 +552,14 @@ app.patch('/api/team/:id', requireAuth, requireRole('superadmin', 'manager'), (r
const existing = db.prepare('SELECT * FROM team_members WHERE id = ?').get(id); const existing = db.prepare('SELECT * FROM team_members WHERE id = ?').get(id);
if (!existing) return res.status(404).json({ error: 'Member not found' }); if (!existing) return res.status(404).json({ error: 'Member not found' });
const fields = ['name', 'email', 'role', 'avatar_url']; const { clauses, values, hasUpdates } = buildUpdate(
const updates = []; req.body, ['name', 'email', 'role', 'avatar_url', 'brands'], { jsonFields: ['brands'] }
const values = []; );
for (const field of fields) { if (!hasUpdates) return res.status(400).json({ error: 'No fields to update' });
if (req.body[field] !== undefined) {
updates.push(`${field} = ?`);
values.push(req.body[field]);
}
}
if (req.body.brands !== undefined) {
updates.push('brands = ?');
values.push(JSON.stringify(req.body.brands));
}
if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' });
values.push(id); values.push(id);
db.prepare(`UPDATE team_members SET ${updates.join(', ')} WHERE id = ?`).run(...values); db.prepare(`UPDATE team_members SET ${clauses.join(', ')} WHERE id = ?`).run(...values);
const member = db.prepare('SELECT * FROM team_members WHERE id = ?').get(id); const member = db.prepare('SELECT * FROM team_members WHERE id = ?').get(id);
res.json({ ...member, brands: JSON.parse(member.brands || '[]') }); res.json({ ...member, brands: JSON.parse(member.brands || '[]') });
@@ -604,21 +637,11 @@ app.patch('/api/brands/:id', requireAuth, requireRole('superadmin', 'manager'),
const existing = db.prepare('SELECT * FROM brands WHERE id = ?').get(id); const existing = db.prepare('SELECT * FROM brands WHERE id = ?').get(id);
if (!existing) return res.status(404).json({ error: 'Brand not found' }); if (!existing) return res.status(404).json({ error: 'Brand not found' });
const fields = ['name', 'priority', 'color', 'icon']; const { clauses, values, hasUpdates } = buildUpdate(req.body, ['name', 'priority', 'color', 'icon']);
const updates = []; if (!hasUpdates) return res.status(400).json({ error: 'No fields to update' });
const values = [];
for (const field of fields) {
if (req.body[field] !== undefined) {
updates.push(`${field} = ?`);
values.push(req.body[field]);
}
}
if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' });
values.push(id); values.push(id);
db.prepare(`UPDATE brands SET ${updates.join(', ')} WHERE id = ?`).run(...values); db.prepare(`UPDATE brands SET ${clauses.join(', ')} WHERE id = ?`).run(...values);
const brand = db.prepare('SELECT * FROM brands WHERE id = ?').get(id); const brand = db.prepare('SELECT * FROM brands WHERE id = ?').get(id);
res.json(brand); res.json(brand);
@@ -642,7 +665,7 @@ app.get('/api/posts/stats', requireAuth, (req, res) => {
app.get('/api/posts', requireAuth, (req, res) => { app.get('/api/posts', requireAuth, (req, res) => {
const { status, brand_id, assigned_to, platform } = req.query; const { status, brand_id, assigned_to, platform } = req.query;
let sql = 'SELECT p.*, b.name as brand_name, t.name as assigned_name, c.name as campaign_name, u.name as creator_user_name FROM posts p LEFT JOIN brands b ON p.brand_id = b.id LEFT JOIN team_members t ON p.assigned_to = t.id LEFT JOIN campaigns c ON p.campaign_id = c.id LEFT JOIN users u ON p.created_by_user_id = u.id'; let sql = POST_SELECT_SQL;
const conditions = []; const conditions = [];
const values = []; const values = [];
@@ -670,17 +693,12 @@ app.get('/api/posts', requireAuth, (req, res) => {
const posts = db.prepare(sql).all(...values); const posts = db.prepare(sql).all(...values);
// Add thumbnail for each post const thumbnailStmt = db.prepare("SELECT url FROM post_attachments WHERE post_id = ? AND mime_type LIKE 'image/%' ORDER BY created_at ASC LIMIT 1");
const postsWithThumbs = posts.map(p => { const postsWithThumbs = posts.map(p => ({
const thumb = db.prepare("SELECT url, mime_type FROM post_attachments WHERE post_id = ? AND mime_type LIKE 'image/%' ORDER BY created_at ASC LIMIT 1").get(p.id); ...parsePostJson(p),
return { thumbnail_url: thumbnailStmt.get(p.id)?.url || null,
...p, }));
platforms: JSON.parse(p.platforms || '[]'),
publication_links: JSON.parse(p.publication_links || '[]'),
thumbnail_url: thumb?.url || null
};
});
res.json(postsWithThumbs); res.json(postsWithThumbs);
}); });
@@ -696,8 +714,8 @@ app.post('/api/posts', requireAuth, (req, res) => {
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(title, description || null, brand_id || null, assigned_to || null, status || 'draft', platformsArr[0] || null, JSON.stringify(platformsArr), content_type || null, scheduled_date || null, notes || null, campaign_id || null, req.session.userId); `).run(title, description || null, brand_id || null, assigned_to || null, status || 'draft', platformsArr[0] || null, JSON.stringify(platformsArr), content_type || null, scheduled_date || null, notes || null, campaign_id || null, req.session.userId);
const post = db.prepare('SELECT p.*, b.name as brand_name, t.name as assigned_name, c.name as campaign_name, u.name as creator_user_name FROM posts p LEFT JOIN brands b ON p.brand_id = b.id LEFT JOIN team_members t ON p.assigned_to = t.id LEFT JOIN campaigns c ON p.campaign_id = c.id LEFT JOIN users u ON p.created_by_user_id = u.id WHERE p.id = ?').get(result.lastInsertRowid); const post = db.prepare(`${POST_SELECT_SQL} WHERE p.id = ?`).get(result.lastInsertRowid);
res.status(201).json({ ...post, platforms: JSON.parse(post.platforms || '[]'), publication_links: JSON.parse(post.publication_links || '[]') }); res.status(201).json(parsePostJson(post));
}); });
app.patch('/api/posts/:id', requireAuth, requireOwnerOrRole('posts', 'superadmin', 'manager'), (req, res) => { app.patch('/api/posts/:id', requireAuth, requireOwnerOrRole('posts', 'superadmin', 'manager'), (req, res) => {
@@ -705,63 +723,50 @@ app.patch('/api/posts/:id', requireAuth, requireOwnerOrRole('posts', 'superadmin
const existing = db.prepare('SELECT * FROM posts WHERE id = ?').get(id); const existing = db.prepare('SELECT * FROM posts WHERE id = ?').get(id);
if (!existing) return res.status(404).json({ error: 'Post not found' }); if (!existing) return res.status(404).json({ error: 'Post not found' });
const fields = ['title', 'description', 'brand_id', 'assigned_to', 'status', 'platform', 'content_type', 'scheduled_date', 'published_date', 'notes', 'campaign_id']; const postFields = ['title', 'description', 'brand_id', 'assigned_to', 'status', 'platform', 'content_type', 'scheduled_date', 'published_date', 'notes', 'campaign_id'];
const updates = ['updated_at = CURRENT_TIMESTAMP']; const { clauses, values } = buildUpdate(req.body, postFields, {
const values = []; jsonFields: ['platforms', 'publication_links'],
extraClauses: ['updated_at = CURRENT_TIMESTAMP'],
});
for (const field of fields) { // Handle JSON array fields
if (req.body[field] !== undefined) {
updates.push(`${field} = ?`);
values.push(req.body[field]);
}
}
if (req.body.platforms !== undefined) { if (req.body.platforms !== undefined) {
updates.push('platforms = ?'); clauses.push('platforms = ?');
values.push(JSON.stringify(req.body.platforms)); values.push(JSON.stringify(req.body.platforms));
// Also keep platform field in sync (first platform)
if (!req.body.platform) { if (!req.body.platform) {
updates.push('platform = ?'); clauses.push('platform = ?');
values.push(req.body.platforms[0] || null); values.push(req.body.platforms[0] || null);
} }
} }
if (req.body.publication_links !== undefined) { if (req.body.publication_links !== undefined) {
updates.push('publication_links = ?'); clauses.push('publication_links = ?');
values.push(JSON.stringify(req.body.publication_links)); values.push(JSON.stringify(req.body.publication_links));
} }
// Validate publication links when publishing // Validate publication links when publishing
if (req.body.status === 'published') { if (req.body.status === 'published') {
const currentPlatforms = req.body.platforms const currentPlatforms = req.body.platforms || JSON.parse(existing.platforms || '[]');
? req.body.platforms const currentLinks = req.body.publication_links || JSON.parse(existing.publication_links || '[]');
: JSON.parse(existing.platforms || '[]'); const missingPlatforms = currentPlatforms.filter(platform => {
const currentLinks = req.body.publication_links const link = currentLinks.find(l => l.platform === platform);
? req.body.publication_links return !link || !link.url || !link.url.trim();
: JSON.parse(existing.publication_links || '[]'); });
if (missingPlatforms.length > 0) {
if (currentPlatforms.length > 0) { return res.status(400).json({
const missingPlatforms = currentPlatforms.filter(platform => { error: `Cannot publish: missing publication links for: ${missingPlatforms.join(', ')}`,
const link = currentLinks.find(l => l.platform === platform); missingPlatforms,
return !link || !link.url || !link.url.trim();
}); });
if (missingPlatforms.length > 0) { }
return res.status(400).json({ if (!req.body.published_date) {
error: `Cannot publish: missing publication links for: ${missingPlatforms.join(', ')}`, clauses.push('published_date = CURRENT_TIMESTAMP');
missingPlatforms
});
}
} }
} }
// Auto-set published_date when status changes to published
if (req.body.status === 'published' && !req.body.published_date) {
updates.push('published_date = CURRENT_TIMESTAMP');
}
values.push(id); values.push(id);
db.prepare(`UPDATE posts SET ${updates.join(', ')} WHERE id = ?`).run(...values); db.prepare(`UPDATE posts SET ${clauses.join(', ')} WHERE id = ?`).run(...values);
const post = db.prepare('SELECT p.*, b.name as brand_name, t.name as assigned_name, c.name as campaign_name, u.name as creator_user_name FROM posts p LEFT JOIN brands b ON p.brand_id = b.id LEFT JOIN team_members t ON p.assigned_to = t.id LEFT JOIN campaigns c ON p.campaign_id = c.id LEFT JOIN users u ON p.created_by_user_id = u.id WHERE p.id = ?').get(id); const post = db.prepare(`${POST_SELECT_SQL} WHERE p.id = ?`).get(id);
res.json({ ...post, platforms: JSON.parse(post.platforms || '[]'), publication_links: JSON.parse(post.publication_links || '[]') }); res.json(parsePostJson(post));
}); });
app.delete('/api/posts/:id', requireAuth, requireOwnerOrRole('posts', 'superadmin', 'manager'), (req, res) => { app.delete('/api/posts/:id', requireAuth, requireOwnerOrRole('posts', 'superadmin', 'manager'), (req, res) => {
@@ -804,6 +809,35 @@ app.post('/api/posts/:id/attachments', requireAuth, upload.single('file'), (req,
res.status(201).json(attachment); res.status(201).json(attachment);
}); });
app.post('/api/posts/:id/attachments/from-asset', requireAuth, (req, res) => {
const { asset_id } = req.body;
if (!asset_id) return res.status(400).json({ error: 'asset_id is required' });
const postId = req.params.id;
const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
if (!post) return res.status(404).json({ error: 'Post not found' });
// Contributors can only add to their own posts
if (req.session.userRole === 'contributor' && post.created_by_user_id !== req.session.userId) {
const currentUser = db.prepare('SELECT team_member_id FROM users WHERE id = ?').get(req.session.userId);
if (!currentUser?.team_member_id || post.assigned_to !== currentUser.team_member_id) {
return res.status(403).json({ error: 'You can only manage attachments on your own posts' });
}
}
const asset = db.prepare('SELECT * FROM assets WHERE id = ?').get(asset_id);
if (!asset) return res.status(404).json({ error: 'Asset not found' });
const url = `/api/uploads/${asset.filename}`;
const result = db.prepare(`
INSERT INTO post_attachments (post_id, filename, original_name, mime_type, size, url)
VALUES (?, ?, ?, ?, ?, ?)
`).run(postId, asset.filename, asset.original_name, asset.mime_type, asset.size, url);
const attachment = db.prepare('SELECT * FROM post_attachments WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(attachment);
});
app.delete('/api/attachments/:id', requireAuth, (req, res) => { app.delete('/api/attachments/:id', requireAuth, (req, res) => {
const attachment = db.prepare('SELECT pa.*, p.created_by_user_id, p.assigned_to FROM post_attachments pa JOIN posts p ON pa.post_id = p.id WHERE pa.id = ?').get(req.params.id); const attachment = db.prepare('SELECT pa.*, p.created_by_user_id, p.assigned_to FROM post_attachments pa JOIN posts p ON pa.post_id = p.id WHERE pa.id = ?').get(req.params.id);
if (!attachment) return res.status(404).json({ error: 'Attachment not found' }); if (!attachment) return res.status(404).json({ error: 'Attachment not found' });
@@ -816,10 +850,14 @@ app.delete('/api/attachments/:id', requireAuth, (req, res) => {
} }
} }
// Delete file from disk // Only delete file from disk if no asset or other attachment references it
const filePath = path.join(uploadsDir, attachment.filename); const otherRefs = db.prepare('SELECT COUNT(*) as cnt FROM post_attachments WHERE filename = ? AND id != ?').get(attachment.filename, req.params.id);
if (fs.existsSync(filePath)) { const assetRef = db.prepare('SELECT COUNT(*) as cnt FROM assets WHERE filename = ?').get(attachment.filename);
fs.unlinkSync(filePath); if (otherRefs.cnt === 0 && assetRef.cnt === 0) {
const filePath = path.join(uploadsDir, attachment.filename);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} }
db.prepare('DELETE FROM post_attachments WHERE id = ?').run(req.params.id); db.prepare('DELETE FROM post_attachments WHERE id = ?').run(req.params.id);
@@ -874,10 +912,13 @@ app.delete('/api/assets/:id', requireAuth, requireRole('superadmin', 'manager'),
const asset = db.prepare('SELECT * FROM assets WHERE id = ?').get(req.params.id); const asset = db.prepare('SELECT * FROM assets WHERE id = ?').get(req.params.id);
if (!asset) return res.status(404).json({ error: 'Asset not found' }); if (!asset) return res.status(404).json({ error: 'Asset not found' });
// Delete file from disk // Only delete file from disk if no post attachment references it
const filePath = path.join(uploadsDir, asset.filename); const attachmentRef = db.prepare('SELECT COUNT(*) as cnt FROM post_attachments WHERE filename = ?').get(asset.filename);
if (fs.existsSync(filePath)) { if (attachmentRef.cnt === 0) {
fs.unlinkSync(filePath); const filePath = path.join(uploadsDir, asset.filename);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} }
db.prepare('DELETE FROM assets WHERE id = ?').run(req.params.id); db.prepare('DELETE FROM assets WHERE id = ?').run(req.params.id);
@@ -888,7 +929,7 @@ app.delete('/api/assets/:id', requireAuth, requireRole('superadmin', 'manager'),
app.get('/api/campaigns', requireAuth, (req, res) => { app.get('/api/campaigns', requireAuth, (req, res) => {
const { brand_id, status, start_date, end_date } = req.query; const { brand_id, status, start_date, end_date } = req.query;
let sql = 'SELECT c.*, b.name as brand_name FROM campaigns c LEFT JOIN brands b ON c.brand_id = b.id'; let sql = CAMPAIGN_SELECT_SQL;
const conditions = []; const conditions = [];
const values = []; const values = [];
@@ -914,7 +955,7 @@ app.post('/api/campaigns', requireAuth, requireRole('superadmin', 'manager'), (r
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(name, description || null, brand_id || null, start_date, end_date, status || 'planning', color || null, budget || null, goals || null, JSON.stringify(platforms || []), req.session.userId); `).run(name, description || null, brand_id || null, start_date, end_date, status || 'planning', color || null, budget || null, goals || null, JSON.stringify(platforms || []), req.session.userId);
const campaign = db.prepare('SELECT c.*, b.name as brand_name FROM campaigns c LEFT JOIN brands b ON c.brand_id = b.id WHERE c.id = ?').get(result.lastInsertRowid); const campaign = db.prepare(`${CAMPAIGN_SELECT_SQL} WHERE c.id = ?`).get(result.lastInsertRowid);
res.status(201).json({ ...campaign, platforms: JSON.parse(campaign.platforms || '[]') }); res.status(201).json({ ...campaign, platforms: JSON.parse(campaign.platforms || '[]') });
}); });
@@ -923,28 +964,18 @@ app.patch('/api/campaigns/:id', requireAuth, requireRole('superadmin', 'manager'
const existing = db.prepare('SELECT * FROM campaigns WHERE id = ?').get(id); const existing = db.prepare('SELECT * FROM campaigns WHERE id = ?').get(id);
if (!existing) return res.status(404).json({ error: 'Campaign not found' }); if (!existing) return res.status(404).json({ error: 'Campaign not found' });
const fields = ['name', 'description', 'brand_id', 'start_date', 'end_date', 'status', 'color', 'budget', 'goals', const campaignFields = [
'budget_spent', 'revenue', 'impressions', 'clicks', 'conversions', 'cost_per_click', 'notes']; 'name', 'description', 'brand_id', 'start_date', 'end_date', 'status', 'color', 'budget', 'goals',
const updates = []; 'budget_spent', 'revenue', 'impressions', 'clicks', 'conversions', 'cost_per_click', 'notes', 'platforms',
const values = []; ];
const { clauses, values, hasUpdates } = buildUpdate(req.body, campaignFields, { jsonFields: ['platforms'] });
for (const field of fields) { if (!hasUpdates) return res.status(400).json({ error: 'No fields to update' });
if (req.body[field] !== undefined) {
updates.push(`${field} = ?`);
values.push(req.body[field]);
}
}
if (req.body.platforms !== undefined) {
updates.push('platforms = ?');
values.push(JSON.stringify(req.body.platforms));
}
if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' });
values.push(id); values.push(id);
db.prepare(`UPDATE campaigns SET ${updates.join(', ')} WHERE id = ?`).run(...values); db.prepare(`UPDATE campaigns SET ${clauses.join(', ')} WHERE id = ?`).run(...values);
const campaign = db.prepare('SELECT c.*, b.name as brand_name FROM campaigns c LEFT JOIN brands b ON c.brand_id = b.id WHERE c.id = ?').get(id); const campaign = db.prepare(`${CAMPAIGN_SELECT_SQL} WHERE c.id = ?`).get(id);
res.json({ ...campaign, platforms: JSON.parse(campaign.platforms || '[]') }); res.json({ ...campaign, platforms: JSON.parse(campaign.platforms || '[]') });
}); });
@@ -1001,20 +1032,13 @@ app.patch('/api/budget/:id', requireAuth, requireRole('superadmin', 'manager'),
const existing = db.prepare('SELECT * FROM budget_entries WHERE id = ?').get(req.params.id); const existing = db.prepare('SELECT * FROM budget_entries WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Budget entry not found' }); if (!existing) return res.status(404).json({ error: 'Budget entry not found' });
const fields = ['label', 'amount', 'source', 'campaign_id', 'category', 'date_received', 'notes']; const { clauses, values, hasUpdates } = buildUpdate(
const updates = []; req.body, ['label', 'amount', 'source', 'campaign_id', 'category', 'date_received', 'notes']
const values = []; );
if (!hasUpdates) return res.status(400).json({ error: 'No fields to update' });
for (const field of fields) {
if (req.body[field] !== undefined) {
updates.push(`${field} = ?`);
values.push(req.body[field]);
}
}
if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' });
values.push(req.params.id); values.push(req.params.id);
db.prepare(`UPDATE budget_entries SET ${updates.join(', ')} WHERE id = ?`).run(...values); db.prepare(`UPDATE budget_entries SET ${clauses.join(', ')} WHERE id = ?`).run(...values);
const entry = db.prepare('SELECT be.*, c.name as campaign_name FROM budget_entries be LEFT JOIN campaigns c ON be.campaign_id = c.id WHERE be.id = ?').get(req.params.id); const entry = db.prepare('SELECT be.*, c.name as campaign_name FROM budget_entries be LEFT JOIN campaigns c ON be.campaign_id = c.id WHERE be.id = ?').get(req.params.id);
res.json(entry); res.json(entry);
@@ -1088,22 +1112,14 @@ app.patch('/api/tracks/:id', requireAuth, requireRole('superadmin', 'manager'),
const existing = db.prepare('SELECT * FROM campaign_tracks WHERE id = ?').get(req.params.id); const existing = db.prepare('SELECT * FROM campaign_tracks WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Track not found' }); if (!existing) return res.status(404).json({ error: 'Track not found' });
const fields = ['name', 'type', 'platform', 'budget_allocated', 'budget_spent', 'revenue', const { clauses, values, hasUpdates } = buildUpdate(req.body, [
'impressions', 'clicks', 'conversions', 'notes', 'status']; 'name', 'type', 'platform', 'budget_allocated', 'budget_spent', 'revenue',
const updates = []; 'impressions', 'clicks', 'conversions', 'notes', 'status',
const values = []; ]);
if (!hasUpdates) return res.status(400).json({ error: 'No fields to update' });
for (const field of fields) {
if (req.body[field] !== undefined) {
updates.push(`${field} = ?`);
values.push(req.body[field]);
}
}
if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' });
values.push(req.params.id); values.push(req.params.id);
db.prepare(`UPDATE campaign_tracks SET ${updates.join(', ')} WHERE id = ?`).run(...values); db.prepare(`UPDATE campaign_tracks SET ${clauses.join(', ')} WHERE id = ?`).run(...values);
const track = db.prepare('SELECT * FROM campaign_tracks WHERE id = ?').get(req.params.id); const track = db.prepare('SELECT * FROM campaign_tracks WHERE id = ?').get(req.params.id);
res.json(track); res.json(track);
@@ -1126,14 +1142,14 @@ app.get('/api/campaigns/:id/posts', requireAuth, (req, res) => {
WHERE p.campaign_id = ? WHERE p.campaign_id = ?
ORDER BY p.created_at DESC ORDER BY p.created_at DESC
`).all(req.params.id); `).all(req.params.id);
res.json(posts.map(p => ({ ...p, platforms: JSON.parse(p.platforms || '[]'), publication_links: JSON.parse(p.publication_links || '[]') }))); res.json(posts.map(parsePostJson));
}); });
// ─── PROJECTS ─────────────────────────────────────────────────── // ─── PROJECTS ───────────────────────────────────────────────────
app.get('/api/projects', requireAuth, (req, res) => { app.get('/api/projects', requireAuth, (req, res) => {
const { brand_id, owner_id, status } = req.query; const { brand_id, owner_id, status } = req.query;
let sql = 'SELECT p.*, b.name as brand_name, t.name as owner_name FROM projects p LEFT JOIN brands b ON p.brand_id = b.id LEFT JOIN team_members t ON p.owner_id = t.id'; let sql = PROJECT_SELECT_SQL;
const conditions = []; const conditions = [];
const values = []; const values = [];
@@ -1148,7 +1164,7 @@ app.get('/api/projects', requireAuth, (req, res) => {
}); });
app.get('/api/projects/:id', requireAuth, (req, res) => { app.get('/api/projects/:id', requireAuth, (req, res) => {
const project = db.prepare('SELECT p.*, b.name as brand_name, t.name as owner_name FROM projects p LEFT JOIN brands b ON p.brand_id = b.id LEFT JOIN team_members t ON p.owner_id = t.id WHERE p.id = ?').get(req.params.id); const project = db.prepare(`${PROJECT_SELECT_SQL} WHERE p.id = ?`).get(req.params.id);
if (!project) return res.status(404).json({ error: 'Project not found' }); if (!project) return res.status(404).json({ error: 'Project not found' });
res.json(project); res.json(project);
}); });
@@ -1162,7 +1178,7 @@ app.post('/api/projects', requireAuth, requireRole('superadmin', 'manager'), (re
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(name, description || null, brand_id || null, owner_id || null, status || 'active', priority || 'medium', due_date || null, req.session.userId); `).run(name, description || null, brand_id || null, owner_id || null, status || 'active', priority || 'medium', due_date || null, req.session.userId);
const project = db.prepare('SELECT p.*, b.name as brand_name, t.name as owner_name FROM projects p LEFT JOIN brands b ON p.brand_id = b.id LEFT JOIN team_members t ON p.owner_id = t.id WHERE p.id = ?').get(result.lastInsertRowid); const project = db.prepare(`${PROJECT_SELECT_SQL} WHERE p.id = ?`).get(result.lastInsertRowid);
res.status(201).json(project); res.status(201).json(project);
}); });
@@ -1171,23 +1187,15 @@ app.patch('/api/projects/:id', requireAuth, requireRole('superadmin', 'manager')
const existing = db.prepare('SELECT * FROM projects WHERE id = ?').get(id); const existing = db.prepare('SELECT * FROM projects WHERE id = ?').get(id);
if (!existing) return res.status(404).json({ error: 'Project not found' }); if (!existing) return res.status(404).json({ error: 'Project not found' });
const fields = ['name', 'description', 'brand_id', 'owner_id', 'status', 'priority', 'due_date']; const { clauses, values, hasUpdates } = buildUpdate(
const updates = []; req.body, ['name', 'description', 'brand_id', 'owner_id', 'status', 'priority', 'due_date']
const values = []; );
if (!hasUpdates) return res.status(400).json({ error: 'No fields to update' });
for (const field of fields) {
if (req.body[field] !== undefined) {
updates.push(`${field} = ?`);
values.push(req.body[field]);
}
}
if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' });
values.push(id); values.push(id);
db.prepare(`UPDATE projects SET ${updates.join(', ')} WHERE id = ?`).run(...values); db.prepare(`UPDATE projects SET ${clauses.join(', ')} WHERE id = ?`).run(...values);
const project = db.prepare('SELECT p.*, b.name as brand_name, t.name as owner_name FROM projects p LEFT JOIN brands b ON p.brand_id = b.id LEFT JOIN team_members t ON p.owner_id = t.id WHERE p.id = ?').get(id); const project = db.prepare(`${PROJECT_SELECT_SQL} WHERE p.id = ?`).get(id);
res.json(project); res.json(project);
}); });
@@ -1201,16 +1209,7 @@ app.delete('/api/projects/:id', requireAuth, requireRole('superadmin', 'manager'
app.get('/api/tasks', requireAuth, (req, res) => { app.get('/api/tasks', requireAuth, (req, res) => {
const { project_id, assigned_to, status, is_personal } = req.query; const { project_id, assigned_to, status, is_personal } = req.query;
let sql = `SELECT t.*, let sql = TASK_SELECT_SQL;
p.name as project_name,
a.name as assigned_name,
c.name as creator_name,
u.name as creator_user_name
FROM tasks t
LEFT JOIN projects p ON t.project_id = p.id
LEFT JOIN team_members a ON t.assigned_to = a.id
LEFT JOIN team_members c ON t.created_by = c.id
LEFT JOIN users u ON t.created_by_user_id = u.id`;
const conditions = []; const conditions = [];
const values = []; const values = [];
@@ -1219,8 +1218,8 @@ app.get('/api/tasks', requireAuth, (req, res) => {
if (status) { conditions.push('t.status = ?'); values.push(status); } if (status) { conditions.push('t.status = ?'); values.push(status); }
if (is_personal !== undefined) { conditions.push('t.is_personal = ?'); values.push(is_personal === 'true' || is_personal === '1' ? 1 : 0); } if (is_personal !== undefined) { conditions.push('t.is_personal = ?'); values.push(is_personal === 'true' || is_personal === '1' ? 1 : 0); }
// Visibility filtering: non-superadmins only see tasks they created or are assigned to // Visibility filtering: contributors only see tasks they created or are assigned to
if (req.session.userRole !== 'superadmin') { if (req.session.userRole === 'contributor') {
const currentUser = db.prepare('SELECT team_member_id FROM users WHERE id = ?').get(req.session.userId); const currentUser = db.prepare('SELECT team_member_id FROM users WHERE id = ?').get(req.session.userId);
const teamMemberId = currentUser?.team_member_id; const teamMemberId = currentUser?.team_member_id;
if (teamMemberId) { if (teamMemberId) {
@@ -1259,8 +1258,7 @@ app.post('/api/tasks', requireAuth, (req, res) => {
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(title, description || null, project_id || null, assigned_to || null, created_by || null, status || 'todo', priority || 'medium', due_date || null, is_personal ? 1 : 0, req.session.userId); `).run(title, description || null, project_id || null, assigned_to || null, created_by || null, status || 'todo', priority || 'medium', due_date || null, is_personal ? 1 : 0, req.session.userId);
const task = db.prepare(`SELECT t.*, p.name as project_name, a.name as assigned_name, c.name as creator_name, u.name as creator_user_name const task = db.prepare(`${TASK_SELECT_SQL} WHERE t.id = ?`).get(result.lastInsertRowid);
FROM tasks t LEFT JOIN projects p ON t.project_id = p.id LEFT JOIN team_members a ON t.assigned_to = a.id LEFT JOIN team_members c ON t.created_by = c.id LEFT JOIN users u ON t.created_by_user_id = u.id WHERE t.id = ?`).get(result.lastInsertRowid);
res.status(201).json(task); res.status(201).json(task);
}); });
@@ -1269,31 +1267,30 @@ app.patch('/api/tasks/:id', requireAuth, requireOwnerOrRole('tasks', 'superadmin
const existing = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id); const existing = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
if (!existing) return res.status(404).json({ error: 'Task not found' }); if (!existing) return res.status(404).json({ error: 'Task not found' });
const fields = ['title', 'description', 'project_id', 'assigned_to', 'created_by', 'status', 'priority', 'due_date', 'is_personal']; // Pre-process is_personal to a SQLite-compatible integer
const updates = []; const body = { ...req.body };
const values = []; if (body.is_personal !== undefined) {
body.is_personal = body.is_personal ? 1 : 0;
for (const field of fields) {
if (req.body[field] !== undefined) {
updates.push(`${field} = ?`);
values.push(field === 'is_personal' ? (req.body[field] ? 1 : 0) : req.body[field]);
}
} }
// Auto-set completed_at when status changes to done const extraClauses = [];
if (req.body.status === 'done' && existing.status !== 'done') { if (body.status === 'done' && existing.status !== 'done') {
updates.push('completed_at = CURRENT_TIMESTAMP'); extraClauses.push('completed_at = CURRENT_TIMESTAMP');
} else if (req.body.status && req.body.status !== 'done' && existing.status === 'done') { } else if (body.status && body.status !== 'done' && existing.status === 'done') {
updates.push('completed_at = NULL'); extraClauses.push('completed_at = NULL');
} }
if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' }); const { clauses, values, hasUpdates } = buildUpdate(
body,
['title', 'description', 'project_id', 'assigned_to', 'created_by', 'status', 'priority', 'due_date', 'is_personal'],
{ extraClauses }
);
if (!hasUpdates) return res.status(400).json({ error: 'No fields to update' });
values.push(id); values.push(id);
db.prepare(`UPDATE tasks SET ${updates.join(', ')} WHERE id = ?`).run(...values); db.prepare(`UPDATE tasks SET ${clauses.join(', ')} WHERE id = ?`).run(...values);
const task = db.prepare(`SELECT t.*, p.name as project_name, a.name as assigned_name, c.name as creator_name, u.name as creator_user_name const task = db.prepare(`${TASK_SELECT_SQL} WHERE t.id = ?`).get(id);
FROM tasks t LEFT JOIN projects p ON t.project_id = p.id LEFT JOIN team_members a ON t.assigned_to = a.id LEFT JOIN team_members c ON t.created_by = c.id LEFT JOIN users u ON t.created_by_user_id = u.id WHERE t.id = ?`).get(id);
res.json(task); res.json(task);
}); });
@@ -1379,6 +1376,59 @@ app.get('/api/dashboard', requireAuth, (req, res) => {
}); });
}); });
// ─── COMMENTS / DISCUSSIONS ─────────────────────────────────
const COMMENT_ENTITY_TYPES = new Set(['post', 'task', 'project', 'campaign', 'asset']);
app.get('/api/comments/:entityType/:entityId', requireAuth, (req, res) => {
const { entityType, entityId } = req.params;
if (!COMMENT_ENTITY_TYPES.has(entityType)) {
return res.status(400).json({ error: 'Invalid entity type' });
}
const comments = db.prepare(`
SELECT c.*, u.name as user_name, u.avatar as user_avatar
FROM comments c
LEFT JOIN users u ON c.user_id = u.id
WHERE c.entity_type = ? AND c.entity_id = ?
ORDER BY c.created_at ASC
`).all(entityType, entityId);
res.json(comments);
});
app.post('/api/comments/:entityType/:entityId', requireAuth, (req, res) => {
const { entityType, entityId } = req.params;
const { content } = req.body;
if (!COMMENT_ENTITY_TYPES.has(entityType)) {
return res.status(400).json({ error: 'Invalid entity type' });
}
if (!content || !content.trim()) {
return res.status(400).json({ error: 'Content is required' });
}
const result = db.prepare(
'INSERT INTO comments (entity_type, entity_id, user_id, content) VALUES (?, ?, ?, ?)'
).run(entityType, entityId, req.session.userId, content.trim());
const comment = db.prepare(`
SELECT c.*, u.name as user_name, u.avatar as user_avatar
FROM comments c
LEFT JOIN users u ON c.user_id = u.id
WHERE c.id = ?
`).get(result.lastInsertRowid);
res.status(201).json(comment);
});
app.delete('/api/comments/:id', requireAuth, (req, res) => {
const comment = db.prepare('SELECT * FROM comments WHERE id = ?').get(req.params.id);
if (!comment) return res.status(404).json({ error: 'Comment not found' });
// Only the comment author, managers, or superadmins can delete
if (comment.user_id !== req.session.userId && req.session.userRole !== 'superadmin' && req.session.userRole !== 'manager') {
return res.status(403).json({ error: 'You can only delete your own comments' });
}
db.prepare('DELETE FROM comments WHERE id = ?').run(req.params.id);
res.json({ success: true });
});
// ─── ERROR HANDLING ───────────────────────────────────────────── // ─── ERROR HANDLING ─────────────────────────────────────────────
// Global Express error handler // Global Express error handler

View File

Before

Width:  |  Height:  |  Size: 826 KiB

After

Width:  |  Height:  |  Size: 826 KiB

Binary file not shown.