-
- {/* Team */}
- {teams.length > 0 && (
+ {/* Description */}
-
- {/* Resolution Summary (if resolved/declined) */}
- {(issueData.status === 'resolved' || issueData.status === 'declined') && issueData.resolution_summary && (
-
- )}
-
- {/* Status Actions */}
- {issueData.status !== 'resolved' && issueData.status !== 'declined' && (
-
-
- {t('issues.updatesTimeline')}
- ({updates.length})
-
+ {/* Actions Tab */}
+ {activeTab === 'actions' && (
+
+ {issueData.status !== 'resolved' && issueData.status !== 'declined' ? (
+
+ {issueData.status === 'new' && (
+
+ )}
+ {(issueData.status === 'new' || issueData.status === 'acknowledged') && (
+
+ )}
+
+
+
+ ) : (
+
+
+
+ {issueData.status === 'resolved' ? t('issues.issueResolved') || 'This issue has been resolved.' : t('issues.issueDeclined') || 'This issue has been declined.'}
+
+
+ )}
+
+ )}
+ {/* Updates Tab */}
+ {activeTab === 'updates' && (
+
{/* Add Update */}
-
+ )}
- {/* Attachments */}
-
-
- {t('issues.attachments')}
- ({attachments.length})
-
-
+ {/* Attachments Tab */}
+ {activeTab === 'attachments' && (
+
{/* Upload */}
-
-
-
+ )}
+
{/* Resolve Modal */}
{showResolveModal && (
diff --git a/client/src/components/PostDetailPanel.jsx b/client/src/components/PostDetailPanel.jsx
index adfef1a..4aba523 100644
--- a/client/src/components/PostDetailPanel.jsx
+++ b/client/src/components/PostDetailPanel.jsx
@@ -1,20 +1,30 @@
import { useState, useEffect, useRef } from 'react'
-import { X, Trash2, Upload, FileText, Link2, ExternalLink, FolderOpen, Image as ImageIcon, Music, Film, Send, CheckCircle2, XCircle, Copy, Check } from 'lucide-react'
+import { X, Trash2, Upload, FileText, Link2, ExternalLink, FolderOpen, Image as ImageIcon, Music, Film, Send, CheckCircle2, XCircle, Copy, Check, Plus, Globe, Clock, User, FileEdit, Layers, Share2, ShieldCheck, MessageSquare } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import { api, PLATFORMS, getBrandColor } from '../utils/api'
import ApproverMultiSelect from './ApproverMultiSelect'
import CommentsSection from './CommentsSection'
import Modal from './Modal'
-import SlidePanel from './SlidePanel'
-import CollapsibleSection from './CollapsibleSection'
+import TabbedModal from './TabbedModal'
import { useToast } from './ToastContainer'
+const AVAILABLE_LANGUAGES = [
+ { code: 'ar', label: 'Arabic' },
+ { code: 'en', label: 'English' },
+ { code: 'fr', label: 'French' },
+ { code: 'id', label: 'Bahasa Indonesia' },
+]
+
+const TABS = ['details', 'versions', 'platforms', 'approval', 'discussion']
+
export default function PostDetailPanel({ post, onClose, onSave, onDelete, brands, teamMembers, campaigns }) {
const { t, lang } = useLanguage()
const toast = useToast()
const imageInputRef = useRef(null)
const audioInputRef = useRef(null)
const videoInputRef = useRef(null)
+ const versionFileInputRef = useRef(null)
+ const [activeTab, setActiveTab] = useState('details')
const [form, setForm] = useState({})
const [dirty, setDirty] = useState(false)
const [saving, setSaving] = useState(false)
@@ -25,7 +35,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
const [submittingReview, setSubmittingReview] = useState(false)
const [copied, setCopied] = useState(false)
- // Attachments state
+ // Attachments state (non-versioned, legacy)
const [attachments, setAttachments] = useState([])
const [uploading, setUploading] = useState(false)
const [dragActive, setDragActive] = useState(false)
@@ -33,6 +43,21 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
const [availableAssets, setAvailableAssets] = useState([])
const [assetSearch, setAssetSearch] = useState('')
+ // Versions state
+ const [versions, setVersions] = useState([])
+ const [selectedVersion, setSelectedVersion] = useState(null)
+ const [versionData, setVersionData] = useState(null)
+ const [showNewVersionModal, setShowNewVersionModal] = useState(false)
+ const [newVersionNotes, setNewVersionNotes] = useState('')
+ const [copyFromPrevious, setCopyFromPrevious] = useState(false)
+ const [creatingVersion, setCreatingVersion] = useState(false)
+ const [showLanguageModal, setShowLanguageModal] = useState(false)
+ const [languageForm, setLanguageForm] = useState({ language_code: '', language_label: '', content: '' })
+ const [savingLanguage, setSavingLanguage] = useState(false)
+ const [confirmDeleteLangId, setConfirmDeleteLangId] = useState(null)
+ const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
+ const [uploadingVersionFile, setUploadingVersionFile] = useState(false)
+
const postId = post?._id || post?.id
const isCreateMode = !postId
const reviewUrl = post?.approval_token ? `${window.location.origin}/review-post/${post.approval_token}` : ''
@@ -54,7 +79,11 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
})
setDirty(isCreateMode)
setPublishError('')
- if (!isCreateMode) loadAttachments()
+ setActiveTab('details')
+ if (!isCreateMode) {
+ loadAttachments()
+ loadVersions()
+ }
}
}, [post])
@@ -145,14 +174,13 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
const handleSubmitReview = async () => {
if (!postId || submittingReview) return
- // Save pending changes first
if (dirty) await handleSave()
setSubmittingReview(true)
try {
await api.post(`/posts/${postId}/submit-review`)
setForm(f => ({ ...f, status: 'in_review' }))
toast.success(t('posts.submittedForReview'))
- onSave(postId, {}) // reload parent
+ onSave(postId, {})
} catch (err) {
toast.error(err.message || t('posts.failedSubmitReview'))
} finally {
@@ -173,7 +201,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
onClose()
}
- // ─── Attachments ──────────────────────────────
+ // ─── Legacy Attachments ──────────────────────────
async function loadAttachments() {
if (!postId) return
try {
@@ -236,6 +264,114 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
if (e.dataTransfer.files?.length) handleFileUpload(e.dataTransfer.files)
}
+ // ─── Versions ──────────────────────────
+ async function loadVersions() {
+ if (!postId) return
+ try {
+ const data = await api.get(`/posts/${postId}/versions`)
+ const vList = Array.isArray(data) ? data : []
+ setVersions(vList)
+ if (vList.length > 0) {
+ const latest = vList[vList.length - 1]
+ setSelectedVersion(latest)
+ loadVersionData(latest.Id || latest.id || latest._id)
+ } else {
+ setSelectedVersion(null)
+ setVersionData(null)
+ }
+ } catch {
+ setVersions([])
+ }
+ }
+
+ async function loadVersionData(versionId) {
+ if (!postId || !versionId) return
+ try {
+ const data = await api.get(`/posts/${postId}/versions/${versionId}`)
+ setVersionData(data)
+ } catch {
+ setVersionData(null)
+ }
+ }
+
+ const handleSelectVersion = (version) => {
+ setSelectedVersion(version)
+ loadVersionData(version.Id || version.id || version._id)
+ }
+
+ const handleCreateVersion = async () => {
+ setCreatingVersion(true)
+ try {
+ await api.post(`/posts/${postId}/versions`, {
+ notes: newVersionNotes || undefined,
+ copy_from_previous: copyFromPrevious,
+ })
+ setShowNewVersionModal(false)
+ setNewVersionNotes('')
+ setCopyFromPrevious(false)
+ loadVersions()
+ } catch (err) {
+ console.error('Create version failed:', err)
+ } finally {
+ setCreatingVersion(false)
+ }
+ }
+
+ const handleAddLanguage = async () => {
+ if (!selectedVersion || !languageForm.language_code || !languageForm.content) return
+ setSavingLanguage(true)
+ try {
+ const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id
+ await api.post(`/posts/${postId}/versions/${vId}/texts`, languageForm)
+ setShowLanguageModal(false)
+ setLanguageForm({ language_code: '', language_label: '', content: '' })
+ loadVersionData(vId)
+ } catch (err) {
+ console.error('Add language failed:', err)
+ } finally {
+ setSavingLanguage(false)
+ }
+ }
+
+ const handleDeleteLanguage = async (textId) => {
+ try {
+ await api.delete(`/post-version-texts/${textId}`)
+ setConfirmDeleteLangId(null)
+ const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id
+ loadVersionData(vId)
+ } catch (err) {
+ console.error('Delete language failed:', err)
+ }
+ }
+
+ const handleVersionFileUpload = async (files) => {
+ if (!selectedVersion || !files?.length) return
+ setUploadingVersionFile(true)
+ const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id
+ for (const file of files) {
+ const fd = new FormData()
+ fd.append('file', file)
+ try {
+ await api.upload(`/posts/${postId}/versions/${vId}/attachments`, fd)
+ } catch (err) {
+ console.error('Version upload failed:', err)
+ }
+ }
+ setUploadingVersionFile(false)
+ loadVersionData(vId)
+ }
+
+ const handleDeleteVersionAttachment = async (attId) => {
+ try {
+ await api.delete(`/attachments/${attId}`)
+ setConfirmDeleteAttId(null)
+ const vId = selectedVersion.Id || selectedVersion.id || selectedVersion._id
+ loadVersionData(vId)
+ } catch (err) {
+ console.error('Delete version attachment failed:', err)
+ }
+ }
+
const brandName = (() => {
if (form.brand_id) {
const b = brands?.find(b => String(b._id || b.id) === String(form.brand_id))
@@ -244,608 +380,629 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
return post.brand_name || post.brandName || null
})()
- const header = (
-
-
-
-
update('title', e.target.value)}
- className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0"
- placeholder={t('posts.postTitlePlaceholder')}
- />
-
-
- {statusOptions.find(s => s.value === form.status)?.label}
-
- {brandName && (
-
- {brandName}
-
- )}
- {post.creator_user_name && (
-
- {t('review.createdBy')} {post.creator_user_name}
-
- )}
-
-
-
+ const tabConfig = {
+ details: { label: t('posts.details'), icon: FileEdit },
+ versions: { label: t('posts.versions'), icon: Layers, badge: versions.length || null },
+ platforms: { label: t('posts.platformsLinks'), icon: Share2 },
+ approval: { label: t('posts.approval'), icon: ShieldCheck },
+ discussion: { label: t('posts.discussion'), icon: MessageSquare },
+ }
+
+ // Filter tabs: hide some in create mode
+ const visibleTabs = isCreateMode
+ ? ['details', 'platforms']
+ : TABS
+
+ const modalHeader = (
+ <>
+
update('title', e.target.value)}
+ className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0"
+ placeholder={t('posts.postTitlePlaceholder')}
+ />
+
+
+ {statusOptions.find(s => s.value === form.status)?.label}
+
+ {brandName && (
+
+ {brandName}
+
+ )}
+ {post.current_version && (
+
+ v{post.current_version}
+
+ )}
+ {post.creator_user_name && (
+
+ {t('review.createdBy')} {post.creator_user_name}
+
+ )}
-
+ >
+ )
+
+ const modalFooter = (
+ <>
+
+ {onDelete && !isCreateMode && (
+
+ )}
+
+
+ {dirty ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+ >
)
return (
<>
-
- {dirty ? (
- <>
-
- {!isCreateMode && (
-
- )}
- >
- ) : (
-
- )}
- {onDelete && !isCreateMode && (
-
- )}
-
- }>
- {/* Content Section */}
-
-
-
- {t('posts.description')}
-
+
({ key: tab, ...tabConfig[tab] }))}
+ activeTab={activeTab}
+ onTabChange={setActiveTab}
+ footer={modalFooter}
+ >
+ {/* ─── Details Tab ─── */}
+ {activeTab === 'details' && (
+
+ {/* Two-column layout for details */}
+
+ {/* Main content — left column */}
+
+
+
+ {t('posts.description')}
+
+
+ {t('posts.notes')}
+ update('notes', e.target.value)}
+ className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
+ placeholder={t('posts.additionalNotes')}
+ />
+
+
-
-
- {t('posts.brand')}
-
-
-
- {t('posts.campaign')}
-
-
-
+ {publishError && (
+
+
+ {publishError}
+
+ )}
-
- {t('posts.notes')}
- update('notes', e.target.value)}
- className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
- placeholder={t('posts.additionalNotes')}
- />
-
-
-
+ {/* Legacy Attachments (non-versioned) */}
+ {!isCreateMode && (
+
+
+
{t('posts.attachments')}
+ {attachments.length > 0 && (
+
+ {attachments.length}
+
+ )}
+
+ {renderAttachments()}
+
+ )}
+
- {/* Scheduling & Assignment Section */}
-
-
-
-
- {t('posts.status')}
-
-
-
- {t('posts.scheduledDate')}
- update('scheduled_date', e.target.value)}
- className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
- />
-
-
+ {/* Sidebar — right column */}
+
+
+
+ {t('posts.status')}
+
+
+
+ {t('posts.scheduledDate')}
+ update('scheduled_date', e.target.value)}
+ className="w-full px-3 py-2.5 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
+ />
+
+
+ {t('posts.assignTo')}
+
+
+
-
- {t('posts.assignTo')}
-
-
-
- {publishError && (
-
- {publishError}
-
- )}
-
-
-
- {/* Approval Section */}
-
-
-
-
{t('posts.approvers')}
-
update('approver_ids', ids)}
- />
-
-
- {!isCreateMode && (
- <>
- {/* Submit for Review */}
- {!reviewUrl && (
-
- )}
-
- {/* Review Link */}
- {reviewUrl && (
-
-
{t('posts.reviewLinkTitle')}
-
-
-
+
+
+ {t('posts.brand')}
+
+
+
+ {t('posts.campaign')}
+
+
- )}
-
- {/* Waiting for review */}
- {form.status === 'in_review' && (
-
-
{t('posts.awaitingReview')}
-
{t('posts.awaitingReviewDesc')}
-
- )}
-
- {/* Approved info */}
- {form.status === 'approved' && post.approved_by_name && (
-
-
-
- {t('posts.approvedBy')} {post.approved_by_name}
-
- {post.feedback &&
{post.feedback}
}
-
- )}
-
- {/* Rejected info */}
- {form.status === 'rejected' && post.approved_by_name && (
-
-
-
- {t('posts.rejectedBy')} {post.approved_by_name}
-
- {post.feedback &&
{post.feedback}
}
-
- )}
-
- {/* Schedule button after approval */}
- {form.status === 'approved' && (
-
- )}
- >
- )}
-
-
-
- {/* Platforms & Links Section */}
-
-
-
-
{t('posts.platforms')}
-
- {Object.entries(PLATFORMS).map(([k, v]) => {
- const checked = (form.platforms || []).includes(k)
- return (
-
- {
- update('platforms', checked
- ? form.platforms.filter(p => p !== k)
- : [...(form.platforms || []), k]
- )
- }}
- className="sr-only"
- />
- {v.label}
-
- )
- })}
-
-
-
- {(form.platforms || []).length > 0 && (
-
-
-
- {t('posts.publicationLinks')}
- {(form.platforms || []).map(platformKey => {
- const platformInfo = PLATFORMS[platformKey] || { label: platformKey }
- const existingLink = (form.publication_links || []).find(l => l.platform === platformKey)
- const linkUrl = existingLink?.url || ''
- return (
-
-
-
- {platformInfo.label}
-
-
updatePublicationLink(platformKey, e.target.value)}
- className="flex-1 px-3 py-1.5 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
- placeholder="https://..."
- />
- {linkUrl && (
-
-
-
- )}
-
- )
- })}
- {form.status === 'published' && (form.platforms || []).some(p => {
- const link = (form.publication_links || []).find(l => l.platform === p)
- return !link || !link.url?.trim()
- }) && (
-
{t('posts.publishRequired')}
- )}
)}
-
-
- {/* Attachments Section (hidden in create mode) */}
- {!isCreateMode && (
-
0 ? (
-
- {attachments.length}
-
- ) : null}
- >
-
- {/* Images */}
- {(() => {
- const images = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('image/'))
- return (
-
-
-
-
- {t('posts.images')}
- {images.length > 0 && ({images.length})}
-
-
-
- {t('posts.addImage')}
- { handleFileUpload(e.target.files); e.target.value = '' }} />
-
-
- {images.length > 0 && (
-
- {images.map(att => {
- const attUrl = att.url || `/api/uploads/${att.filename}`
- const name = att.original_name || att.originalName || att.filename
- const attId = att.id || att._id
- return (
-
-
-
-
-
-
-
-
{name}
-
- )
- })}
-
- )}
+ {/* ─── Versions Tab ─── */}
+ {activeTab === 'versions' && !isCreateMode && (
+
+ {/* Version Timeline (left sidebar) */}
+
+
+
{t('posts.versions')}
+
- )
- })()}
- {/* Audio */}
- {(() => {
- const audio = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('audio/'))
- return (
-
-
-
-
- {t('posts.audio')}
- {audio.length > 0 &&
({audio.length})}
+ {versions.length === 0 ? (
+
+
+
-
-
- {t('posts.addAudio')}
- { handleFileUpload(e.target.files); e.target.value = '' }} />
-
+
{t('posts.noVersions')}
- {audio.length > 0 && (
-
- {audio.map(att => {
- const attUrl = att.url || `/api/uploads/${att.filename}`
- const name = att.original_name || att.originalName || att.filename
- const attId = att.id || att._id
- return (
-
-
-
{name}
-
-
-
- )
- })}
-
- )}
-
- )
- })()}
-
- {/* Video */}
- {(() => {
- const videos = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('video/'))
- return (
-
-
-
-
- {t('posts.videos')}
- {videos.length > 0 && ({videos.length})}
-
-
-
- {t('posts.addVideo')}
- { handleFileUpload(e.target.files); e.target.value = '' }} />
-
-
- {videos.length > 0 && (
-
- {videos.map(att => {
- const attUrl = att.url || `/api/uploads/${att.filename}`
- const name = att.original_name || att.originalName || att.filename
- const attId = att.id || att._id
- return (
-
-
-
- {name}
-
-
-
- )
- })}
-
- )}
-
- )
- })()}
-
- {/* Other files */}
- {(() => {
- const others = attachments.filter(a => {
- const mime = a.mime_type || a.mimeType || ''
- return !mime.startsWith('image/') && !mime.startsWith('audio/') && !mime.startsWith('video/')
- })
- return others.length > 0 ? (
-
-
-
- {t('posts.otherFiles')}
- ({others.length})
-
-
- {others.map(att => {
- const attUrl = att.url || `/api/uploads/${att.filename}`
- const name = att.original_name || att.originalName || att.filename
- const attId = att.id || att._id
+ ) : (
+
+ {versions.map((version, idx) => {
+ const vId = version.Id || version.id || version._id
+ const isActive = vId === (selectedVersion?.Id || selectedVersion?.id || selectedVersion?._id)
+ const isLatest = idx === versions.length - 1
return (
-
-
-
- {name}
-
-
+
+ )
+ })}
+
+ )}
+
+
+ {/* Version Content (right side) */}
+
+ {selectedVersion && versionData ? (
+
+ {/* Languages */}
+
+
+
+
+
{t('posts.languages')}
+ {versionData.texts?.length > 0 && (
+
+ {versionData.texts.length}
+
+ )}
+
+
+
+
+ {versionData.texts && versionData.texts.length > 0 ? (
+
+ {versionData.texts.map(text => {
+ const tId = text.Id || text.id || text._id
+ return (
+
+
+
+ {text.language_code}
+ {text.language_label}
+
+
+
+
+ {text.content}
+
+
+ )
+ })}
+
+ ) : (
+
+
+
{t('posts.noLanguages')}
+
+
+ )}
+
+
+ {/* Media / Attachments for this version */}
+
+
+
+
+
{t('posts.media')}
+ {versionData.attachments?.length > 0 && (
+
+ {versionData.attachments.length}
+
+ )}
+
+
+
+ {uploadingVersionFile ? t('posts.uploading') : t('posts.addImage')}
+ { handleVersionFileUpload(e.target.files); e.target.value = '' }}
+ disabled={uploadingVersionFile}
+ />
+
+
+
+ {versionData.attachments && versionData.attachments.length > 0 ? (
+
+ {versionData.attachments.map(att => {
+ const attId = att.Id || att.id || att._id
+ const attUrl = att.url || `/api/uploads/${att.filename}`
+ const name = att.original_name || att.filename
+ const mime = att.mime_type || ''
+ const isImage = mime.startsWith('image/')
+ const isVideo = mime.startsWith('video/')
+ return (
+
+ {isImage ? (
+
+
+
+ ) : isVideo ? (
+
+ ) : (
+
+
+
+ )}
+
+ {name}
+
+
+
+ )
+ })}
+
+ ) : (
+
+
+
{t('posts.noMedia')}
+
+ )}
+
+
+ ) : versions.length > 0 ? (
+
+ ) : null}
+
+
+ )}
+
+ {/* ─── Platforms & Links Tab ─── */}
+ {activeTab === 'platforms' && (
+
+
+
+
+
{t('posts.platforms')}
+
+
+ {Object.entries(PLATFORMS).map(([k, v]) => {
+ const checked = (form.platforms || []).includes(k)
+ return (
+
+ {
+ update('platforms', checked
+ ? form.platforms.filter(p => p !== k)
+ : [...(form.platforms || []), k]
+ )
+ }}
+ className="sr-only"
+ />
+
+ {v.label}
+
+ )
+ })}
+
+
+
+ {(form.platforms || []).length > 0 && (
+
+
+
+
{t('posts.publicationLinks')}
+
+
+ {(form.platforms || []).map(platformKey => {
+ const platformInfo = PLATFORMS[platformKey] || { label: platformKey }
+ const existingLink = (form.publication_links || []).find(l => l.platform === platformKey)
+ const linkUrl = existingLink?.url || ''
+ return (
+
+
+
+ {platformInfo.label}
+
+
updatePublicationLink(platformKey, e.target.value)}
+ className="flex-1 px-3 py-2 text-sm border border-border rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
+ placeholder="https://..."
+ />
+ {linkUrl && (
+
+
+
+ )}
)
})}
+ {form.status === 'published' && (form.platforms || []).some(p => {
+ const link = (form.publication_links || []).find(l => l.platform === p)
+ return !link || !link.url?.trim()
+ }) && (
+
+
+ {t('posts.publishRequired')}
+
+ )}
- ) : null
- })()}
-
- {/* Drag and drop zone */}
-
{ e.preventDefault(); setDragActive(true) }}
- onDragLeave={e => { e.preventDefault(); setDragActive(false) }}
- onDragOver={e => e.preventDefault()}
- onDrop={handleDrop}
- >
-
-
- {dragActive ? t('posts.dropFiles') : t('posts.dragToUpload')}
-
+ )}
+ )}
-
-
- {showAssetPicker && (
-
-
-
{t('posts.selectAssets')}
-
-
-
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"
+ {/* ─── Approval Tab ─── */}
+ {activeTab === 'approval' && (
+
+
+
{t('posts.approvers')}
+
update('approver_ids', ids)}
/>
-
- {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 (
-
- )
- })}
-
- {availableAssets.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase())).length === 0 && (
- {t('posts.noAssetsFound')}
- )}
- )}
-
-
- )}
- {/* Discussion Section (hidden in create mode) */}
- {!isCreateMode && (
-
-
-
-
-
- )}
-
+ {!isCreateMode && (
+
+ {/* Approval status cards */}
+ {form.status === 'approved' && post.approved_by_name && (
+
+
+
+
+
+
{t('posts.approvedBy')} {post.approved_by_name}
+ {post.feedback &&
{post.feedback}
}
+
+
+ )}
+ {form.status === 'rejected' && post.approved_by_name && (
+
+
+
+
+
+
{t('posts.rejectedBy')} {post.approved_by_name}
+ {post.feedback &&
{post.feedback}
}
+
+
+ )}
+
+ {form.status === 'in_review' && (
+
+
+
+
+
{t('posts.awaitingReview')}
+
{t('posts.awaitingReviewDesc')}
+
+ )}
+
+ {/* Review link */}
+ {reviewUrl && (
+
+
{t('posts.reviewLinkTitle')}
+
+
+
+
+
+ )}
+
+ {/* Action buttons */}
+
+ {!reviewUrl && (
+
+ )}
+
+ {form.status === 'approved' && (
+
+ )}
+
+
+ )}
+
+ )}
+
+ {/* ─── Discussion Tab ─── */}
+ {activeTab === 'discussion' && !isCreateMode && (
+
+
+
+ )}
+
+
+ {/* Delete Confirmation */}
setShowDeleteConfirm(false)}
@@ -857,6 +1014,319 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
>
{t('posts.deleteConfirm')}
+
+ {/* New Version Modal */}
+
{ setShowNewVersionModal(false); setNewVersionNotes(''); setCopyFromPrevious(false) }}
+ title={t('posts.createNewVersion')}
+ size="sm"
+ >
+
+
+
+
+ {/* Add Language Modal */}
+
{ setShowLanguageModal(false); setLanguageForm({ language_code: '', language_label: '', content: '' }) }}
+ title={t('posts.addLanguage')}
+ size="md"
+ >
+
+
+
+
+
+ {/* Delete Language Confirmation */}
+
setConfirmDeleteLangId(null)}
+ title={t('posts.deleteLanguage')}
+ isConfirm
+ danger
+ confirmText={t('common.delete')}
+ onConfirm={() => handleDeleteLanguage(confirmDeleteLangId)}
+ >
+ {t('posts.deleteLanguageConfirm')}
+
+
+ {/* Delete Version Attachment Confirmation */}
+
setConfirmDeleteAttId(null)}
+ title={t('posts.deleteAttachment')}
+ isConfirm
+ danger
+ confirmText={t('common.delete')}
+ onConfirm={() => handleDeleteVersionAttachment(confirmDeleteAttId)}
+ >
+ {t('posts.deleteConfirm')}
+
>
)
+
+ // ─── Render legacy attachments helper ──────────────────────────
+ function renderAttachments() {
+ const images = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('image/'))
+ const audio = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('audio/'))
+ const videos = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('video/'))
+ const others = attachments.filter(a => {
+ const mime = a.mime_type || a.mimeType || ''
+ return !mime.startsWith('image/') && !mime.startsWith('audio/') && !mime.startsWith('video/')
+ })
+
+ return (
+
+ {/* Images */}
+
+
+
+
+ {t('posts.images')}
+ {images.length > 0 && ({images.length})}
+
+
+
+ {t('posts.addImage')}
+ { handleFileUpload(e.target.files); e.target.value = '' }} />
+
+
+ {images.length > 0 && (
+
+ {images.map(att => {
+ const attUrl = att.url || `/api/uploads/${att.filename}`
+ const name = att.original_name || att.originalName || att.filename
+ const attId = att.id || att._id
+ return (
+
+
+
+
+
+
+
+
{name}
+
+ )
+ })}
+
+ )}
+
+
+ {/* Audio */}
+ {audio.length > 0 && (
+
+
+
+ {t('posts.audio')} ({audio.length})
+
+
+ {audio.map(att => {
+ const attUrl = att.url || `/api/uploads/${att.filename}`
+ const name = att.original_name || att.originalName || att.filename
+ const attId = att.id || att._id
+ return (
+
+
+
{name}
+
+
+
+ )
+ })}
+
+
+ )}
+
+ {/* Videos */}
+ {videos.length > 0 && (
+
+
+
+ {t('posts.videos')} ({videos.length})
+
+
+ {videos.map(att => {
+ const attUrl = att.url || `/api/uploads/${att.filename}`
+ const name = att.original_name || att.originalName || att.filename
+ const attId = att.id || att._id
+ return (
+
+
+
+ {name}
+
+
+
+ )
+ })}
+
+
+ )}
+
+ {/* Other files */}
+ {others.length > 0 && (
+
+
+
+ {t('posts.otherFiles')} ({others.length})
+
+
+ {others.map(att => {
+ const attUrl = att.url || `/api/uploads/${att.filename}`
+ const name = att.original_name || att.originalName || att.filename
+ const attId = att.id || att._id
+ return (
+
+
+
+ {name}
+
+
+
+ )
+ })}
+
+
+ )}
+
+ {/* Drag and drop zone */}
+
{ e.preventDefault(); setDragActive(true) }}
+ onDragLeave={e => { e.preventDefault(); setDragActive(false) }}
+ onDragOver={e => e.preventDefault()}
+ onDrop={handleDrop}
+ >
+
+
+ {dragActive ? t('posts.dropFiles') : t('posts.dragToUpload')}
+
+
+
+
+
+ {showAssetPicker && (
+
+
+
{t('posts.selectAssets')}
+
+
+
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"
+ />
+
+ {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 (
+
+ )
+ })}
+
+ {availableAssets.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase())).length === 0 && (
+
{t('posts.noAssetsFound')}
+ )}
+
+ )}
+
+ )
+ }
}
diff --git a/client/src/components/ProjectEditPanel.jsx b/client/src/components/ProjectEditPanel.jsx
index 0ddef90..b9574bd 100644
--- a/client/src/components/ProjectEditPanel.jsx
+++ b/client/src/components/ProjectEditPanel.jsx
@@ -1,11 +1,10 @@
import { useState, useEffect, useRef, useContext } from 'react'
-import { X, Trash2, Upload } from 'lucide-react'
+import { Trash2, Upload, FileEdit, MessageSquare } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import { api, getBrandColor } from '../utils/api'
import CommentsSection from './CommentsSection'
import Modal from './Modal'
-import SlidePanel from './SlidePanel'
-import CollapsibleSection from './CollapsibleSection'
+import TabbedModal from './TabbedModal'
import { AppContext } from '../App'
export default function ProjectEditPanel({ project, onClose, onSave, onDelete, brands, teamMembers }) {
@@ -17,6 +16,7 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
const [saving, setSaving] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [thumbnailUploading, setThumbnailUploading] = useState(false)
+ const [activeTab, setActiveTab] = useState('details')
const projectId = project?._id || project?.id
if (!project) return null
@@ -107,10 +107,17 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
return project.brand_name || project.brandName || null
})()
- const header = (
-
-
-
+ const tabs = [
+ { key: 'details', label: t('projects.details'), icon: FileEdit },
+ { key: 'discussion', label: t('projects.discussion'), icon: MessageSquare },
+ ]
+
+ return (
+ <>
+
)}
-
-
-
-
- )
-
- return (
- <>
-
- {/* Details Section */}
-
-
+ >}
+ tabs={tabs}
+ activeTab={activeTab}
+ onTabChange={setActiveTab}
+ footer={<>
+
+ {onDelete && (
+
+ )}
+
+
+ {dirty && (
+
+ )}
+
+ >}
+ >
+ {activeTab === 'details' && (
+
{t('projects.description')}
-
-
- {dirty && (
-
- )}
- {onDelete && (
-
- )}
-
-
+ )}
- {/* Discussion Section */}
-
-
+ {activeTab === 'discussion' && (
+
-
-
+ )}
+
{
+ document.body.style.overflow = 'hidden'
+ return () => { document.body.style.overflow = '' }
+ }, [])
+
+ return createPortal(
+
+
+
+
+ {/* Header */}
+
+
+
+ {/* Tabs */}
+ {tabs.length > 0 && (
+
+ {tabs.map(tab => {
+ const TabIcon = tab.icon
+ return (
+
+ )
+ })}
+
+ )}
+
+
+ {/* Body */}
+
+ {children}
+
+
+ {/* Footer */}
+ {footer && (
+
+ {footer}
+
+ )}
+
+
,
+ document.body
+ )
+}
diff --git a/client/src/components/TaskDetailPanel.jsx b/client/src/components/TaskDetailPanel.jsx
index 4705556..2436960 100644
--- a/client/src/components/TaskDetailPanel.jsx
+++ b/client/src/components/TaskDetailPanel.jsx
@@ -1,17 +1,17 @@
import { useState, useEffect, useRef } from 'react'
-import { X, Trash2, AlertCircle, Upload, FileText, Star } from 'lucide-react'
+import { X, Trash2, AlertCircle, Upload, FileText, Star, FileEdit, Paperclip, MessageSquare } from 'lucide-react'
import { PRIORITY_CONFIG, getBrandColor, api } from '../utils/api'
import { useLanguage } from '../i18n/LanguageContext'
import CommentsSection from './CommentsSection'
import Modal from './Modal'
-import SlidePanel from './SlidePanel'
-import CollapsibleSection from './CollapsibleSection'
+import TabbedModal from './TabbedModal'
const API_BASE = '/api'
export default function TaskDetailPanel({ task, onClose, onSave, onDelete, projects, users, brands }) {
const { t } = useLanguage()
const fileInputRef = useRef(null)
+ const [activeTab, setActiveTab] = useState('details')
const [form, setForm] = useState({
title: '', description: '', project_id: '', assigned_to: '',
priority: 'medium', status: 'todo', start_date: '', due_date: '',
@@ -186,11 +186,19 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
const selectedProject = projects?.find(p => String(p._id || p.id) === String(form.project_id))
const brandName = selectedProject ? (selectedProject.brand_name || selectedProject.brandName) : (task.brand_name || task.brandName)
- const header = (
-
+ const attachmentCount = attachments.length + pendingFiles.length
+
+ const tabs = [
+ { key: 'details', label: t('tasks.details'), icon: FileEdit },
+ { key: 'attachments', label: t('tasks.attachments'), icon: Paperclip, badge: attachmentCount },
+ ...(!isCreateMode ? [{ key: 'discussion', label: t('tasks.discussion'), icon: MessageSquare }] : []),
+ ]
+
+ const headerContent = (
+ <>
{/* Thumbnail banner */}
{currentThumbnail && (
-
+
)}
-
-
-
update('title', e.target.value)}
- className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0"
- placeholder={t('tasks.taskTitle')}
- />
-
-
-
- {priorityOptions.find(p => p.value === form.priority)?.label}
-
-
- {statusOptions.find(s => s.value === form.status)?.label}
-
- {isOverdue && !isCreateMode && (
-
-
- {t('tasks.overdue')}
-
- )}
-
-
-
+
update('title', e.target.value)}
+ className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0"
+ placeholder={t('tasks.taskTitle')}
+ />
+
+
+
+ {priorityOptions.find(p => p.value === form.priority)?.label}
+
+
+ {statusOptions.find(s => s.value === form.status)?.label}
+
+ {isOverdue && !isCreateMode && (
+
+
+ {t('tasks.overdue')}
+
+ )}
-
+ >
+ )
+
+ const footerContent = (
+ <>
+
+ {onDelete && !isCreateMode && (
+
+ )}
+
+
+ {dirty && (
+
+ )}
+
+ >
)
return (
<>
-
- {/* Details Section */}
-
-
- {/* Description */}
-
- {t('tasks.description')}
-
-
- {/* Project */}
-
-
{t('tasks.project')}
-
-
- {brandName && (
-
- {brandName}
-
- )}
-
-
-
- {/* Assignee */}
-
- {t('tasks.assignee')}
-
-
-
- {/* Priority & Status */}
-
+
+ {/* Details Tab */}
+ {activeTab === 'details' && (
+
+
+ {/* Description */}
- {t('tasks.priority')}
-
-
-
- {t('tasks.status')}
-
-
-
-
- {/* Start Date & Due Date */}
-
-
- {t('tasks.startDate')}
- update('start_date', e.target.value)}
- className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
+ {t('tasks.description')}
+
+
+ {/* Project */}
-
{t('tasks.dueDate')}
-
update('due_date', e.target.value)}
+
{t('tasks.project')}
+
+
+ {brandName && (
+
+ {brandName}
+
+ )}
+
+
+
+ {/* Assignee */}
+
+ {t('tasks.assignee')}
+
-
-
- {/* Created by (read-only) */}
- {creatorName && !isCreateMode && (
-
-
{t('tasks.createdBy')}
-
{creatorName}
-
- )}
-
- {/* Action buttons */}
-
- {dirty && (
-
- )}
- {onDelete && !isCreateMode && (
-
+
+ {(users || []).map(m => (
+
+ ))}
+
+
+
+ {/* Priority & Status */}
+
+
+ {t('tasks.priority')}
+
+
+
+ {t('tasks.status')}
+
+
+
+
+ {/* Start Date & Due Date */}
+
+
+ {/* Created by (read-only) */}
+ {creatorName && !isCreateMode && (
+
+
{t('tasks.createdBy')}
+
{creatorName}
+
)}
-
+ )}
- {/* Attachments Section */}
-
0 ? (
-
- {attachments.length + pendingFiles.length}
-
- ) : null}
- >
-
+ {/* Attachments Tab */}
+ {activeTab === 'attachments' && (
+
{/* Existing attachment grid (edit mode) */}
{attachments.length > 0 && (
@@ -524,17 +530,15 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
)}
-
-
- {/* Discussion Section (hidden in create mode) */}
- {!isCreateMode && (
-
-
-
-
-
)}
-
+
+ {/* Discussion Tab */}
+ {activeTab === 'discussion' && !isCreateMode && (
+
+
+
+ )}
+
{/* Delete Confirmation */}
t.status === 'in_progress').length
const doneCount = memberTasks.filter(t => t.status === 'done').length
- const header = (
-
-
-
-
- {initials}
-
-
- update('name', e.target.value)}
- className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0"
- placeholder={t('team.fullName')}
- />
-
- {roleName}
-
-
-
-
-
-
- )
+ const showAdminTab = !isEditingSelf && userRole === 'superadmin'
+
+ const tabs = [
+ { key: 'details', label: t('team.details'), icon: FileEdit },
+ { key: 'workload', label: t('team.workload'), icon: BarChart3 },
+ ...(showAdminTab ? [{ key: 'admin', label: t('team.adminActions'), icon: ShieldAlert }] : []),
+ ]
return (
<>
-
- {/* Details Section */}
-
-
+
+
+ {initials}
+
+
+ update('name', e.target.value)}
+ className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0"
+ placeholder={t('team.fullName')}
+ />
+
+ {roleName}
+
+
+
+ }
+ tabs={tabs}
+ activeTab={activeTab}
+ onTabChange={setActiveTab}
+ footer={<>
+
+ {canManageTeam && onDelete && !isEditingSelf && (
+
+ )}
+
+
+ {dirty && (
+
+ )}
+
+ >}
+ >
+ {/* Details Tab */}
+ {activeTab === 'details' && (
+
{!isEditingSelf && (
{t('team.email')}
@@ -375,146 +403,119 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
)}
+
+ )}
- {dirty && (
-
+ {/* Workload Tab */}
+ {activeTab === 'workload' && (
+
+ {/* Stats */}
+
+
+
{memberTasks.length}
+
{t('team.totalTasks')}
+
+
+
{todoCount}
+
{t('team.toDo')}
+
+
+
{inProgressCount}
+
{t('team.inProgress')}
+
+
+
{doneCount}
+
{t('tasks.done')}
+
+
+
+ {/* Recent tasks */}
+ {memberTasks.length > 0 && (
+
+
{t('team.recentTasks')}
+
+ {memberTasks.slice(0, 8).map(task => (
+
+
+ {task.title}
+
+
+
+ ))}
+
+
+ )}
+
+ {/* Recent posts */}
+ {memberPosts.length > 0 && (
+
+
{t('team.recentPosts')}
+
+ {memberPosts.slice(0, 8).map(post => (
+
+ {post.title}
+
+
+ ))}
+
+
+ )}
+
+ {loadingWorkload && (
+
{t('common.loading')}
)}
-
+ )}
- {/* Workload Section */}
-
-
- {/* Stats */}
-
-
-
{memberTasks.length}
-
{t('team.totalTasks')}
-
-
-
{todoCount}
-
{t('team.toDo')}
-
-
-
{inProgressCount}
-
{t('team.inProgress')}
-
-
-
{doneCount}
-
{t('tasks.done')}
-
-
-
- {/* Recent tasks */}
- {memberTasks.length > 0 && (
-
-
{t('team.recentTasks')}
-
- {memberTasks.slice(0, 8).map(task => (
-
-
- {task.title}
-
-
-
- ))}
-
-
- )}
-
- {/* Recent posts */}
- {memberPosts.length > 0 && (
-
-
{t('team.recentPosts')}
-
- {memberPosts.slice(0, 8).map(post => (
-
- {post.title}
-
-
- ))}
-
-
- )}
-
- {loadingWorkload && (
-
{t('common.loading')}
- )}
-
-
-
- {/* Admin Actions Section (superadmin only, not self) */}
- {!isEditingSelf && userRole === 'superadmin' && (
-
{t('team.adminActions')}}
- defaultOpen={false}
- noBorder
- >
-
- {/* Change password */}
-
-
{t('team.password')}
-
- update('password', e.target.value)}
- className="w-full px-3 py-2 pe-9 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.newPassword')}
- autoComplete="new-password"
- />
-
-
-
-
-
{t('team.confirmPassword')}
+ {/* Admin Actions Tab */}
+ {activeTab === 'admin' && showAdminTab && (
+
+ {/* Change password */}
+
+
{t('team.password')}
+
setConfirmPassword(e.target.value)}
- className={`w-full px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary ${passwordMismatch ? 'border-red-400' : 'border-border'}`}
- placeholder={t('team.confirmPassword')}
+ value={form.password}
+ onChange={e => update('password', e.target.value)}
+ className="w-full px-3 py-2 pe-9 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.newPassword')}
autoComplete="new-password"
/>
- {passwordMismatch && (
-
{t('team.passwordsDoNotMatch')}
- )}
-
-
-
- {/* Delete member */}
- {canManageTeam && onDelete && (
+
+
+
+
{t('team.confirmPassword')}
+
setConfirmPassword(e.target.value)}
+ className={`w-full px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary ${passwordMismatch ? 'border-red-400' : 'border-border'}`}
+ placeholder={t('team.confirmPassword')}
+ autoComplete="new-password"
+ />
+ {passwordMismatch && (
+
{t('team.passwordsDoNotMatch')}
)}
-
+
+
)}
-
+
-
-
- update('name', e.target.value)}
- className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0"
- placeholder={t('teams.name')}
- />
-
- {(form.member_ids || []).length} {t('teams.members')}
-
-
-
-
-
- )
+ const memberCount = (form.member_ids || []).length
return (
<>
-
-
-
+
+ update('name', e.target.value)}
+ className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0"
+ placeholder={t('teams.name')}
+ />
+
+ {memberCount} {t('teams.members')}
+
+ >
+ }
+ tabs={[
+ { key: 'details', label: t('teams.details'), icon: FileEdit },
+ { key: 'members', label: t('teams.members'), icon: Users, badge: memberCount },
+ ]}
+ activeTab={activeTab}
+ onTabChange={setActiveTab}
+ footer={
+ <>
+
+ {!isCreateMode && onDelete && (
+
+ )}
+
+
+ {dirty && (
+
+ )}
+
+ >
+ }
+ >
+ {activeTab === 'details' && (
+
{t('teams.name')}
-
-
- {dirty && (
-
- )}
- {!isCreateMode && onDelete && (
-
- )}
-
-
+ )}
-
-
+ {activeTab === 'members' && (
+
-
-
+ )}
+
-
-
-
update('name', e.target.value)}
- className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0"
- placeholder={t('tracks.trackName')}
- />
-
-
- {typeInfo.label}
-
-
- {form.status?.charAt(0).toUpperCase() + form.status?.slice(1)}
-
-
-
-
-
-
- )
+ const tabs = isCreateMode
+ ? [{ key: 'details', label: t('tracks.details'), icon: FileEdit }]
+ : [
+ { key: 'details', label: t('tracks.details'), icon: FileEdit },
+ { key: 'metrics', label: t('tracks.metrics'), icon: BarChart3 },
+ ]
return (
<>
-
- {/* Details Section */}
-
-
+
+ update('name', e.target.value)}
+ className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0"
+ placeholder={t('tracks.trackName')}
+ />
+
+
+ {typeInfo.label}
+
+
+ {form.status?.charAt(0).toUpperCase() + form.status?.slice(1)}
+
+
+ >
+ }
+ tabs={tabs}
+ activeTab={activeTab}
+ onTabChange={setActiveTab}
+ footer={
+ <>
+
+ {onDelete && !isCreateMode && (
+
+ )}
+
+
+ {dirty && (
+
+ )}
+
+ >
+ }
+ >
+ {activeTab === 'details' && (
+
{t('tracks.type')}
@@ -190,106 +217,82 @@ export default function TrackDetailPanel({ track, campaignId, onClose, onSave, o
placeholder="Keywords, targeting details..."
/>
+
+ )}
-
- {dirty && (
-
- )}
- {onDelete && !isCreateMode && (
-
- )}
+ {activeTab === 'metrics' && !isCreateMode && (
+
+ {Number(form.budget_allocated) > 0 && (
+
+
+
+ {Number(form.clicks) > 0 && Number(form.budget_spent) > 0 && (
+
+ CPC: {(Number(form.budget_spent) / Number(form.clicks)).toFixed(2)} {currencySymbol}
+
+ )}
+ {Number(form.impressions) > 0 && Number(form.clicks) > 0 && (
+
+ CTR: {(Number(form.clicks) / Number(form.impressions) * 100).toFixed(2)}%
+
+ )}
+
+
+ )}
+
+
+
+
-
-
- {/* Metrics Section (hidden in create mode) */}
- {!isCreateMode && (
-
-
- {Number(form.budget_allocated) > 0 && (
-
-
-
- {Number(form.clicks) > 0 && Number(form.budget_spent) > 0 && (
-
- CPC: {(Number(form.budget_spent) / Number(form.clicks)).toFixed(2)} {currencySymbol}
-
- )}
- {Number(form.impressions) > 0 && Number(form.clicks) > 0 && (
-
- CTR: {(Number(form.clicks) / Number(form.impressions) * 100).toFixed(2)}%
-
- )}
-
-
- )}
-
-
-
-
-
-
)}
-
+
{
}
});
+// ─── POST VERSIONS ──────────────────────────────────────────────
+
+// List all versions for a post
+app.get('/api/posts/:id/versions', requireAuth, async (req, res) => {
+ try {
+ const versions = await nocodb.list('PostVersions', {
+ where: `(post_id,eq,${sanitizeWhereValue(req.params.id)})`,
+ sort: 'version_number',
+ limit: QUERY_LIMITS.large,
+ });
+ const enriched = [];
+ for (const v of versions) {
+ const creatorName = await getRecordName('Users', v.created_by_user_id);
+ enriched.push({ ...v, creator_name: creatorName });
+ }
+ res.json(enriched);
+ } catch (err) {
+ console.error('List post versions error:', err);
+ res.status(500).json({ error: 'Failed to load versions' });
+ }
+});
+
+// Create new version
+app.post('/api/posts/:id/versions', requireAuth, async (req, res) => {
+ const { notes, copy_from_previous } = req.body;
+ try {
+ const post = await nocodb.get('Posts', req.params.id);
+ if (!post) return res.status(404).json({ error: 'Post not found' });
+
+ if (req.session.userRole === 'contributor' && post.created_by_user_id !== req.session.userId && post.assigned_to_id !== req.session.userId) {
+ return res.status(403).json({ error: 'You can only create versions for your own posts' });
+ }
+
+ const versions = await nocodb.list('PostVersions', {
+ where: `(post_id,eq,${sanitizeWhereValue(req.params.id)})`,
+ sort: '-version_number',
+ limit: 1,
+ });
+ const newVersionNumber = versions.length > 0 ? versions[0].version_number + 1 : 1;
+
+ const created = await nocodb.create('PostVersions', {
+ post_id: Number(req.params.id),
+ version_number: newVersionNumber,
+ created_by_user_id: req.session.userId,
+ created_at: new Date().toISOString(),
+ notes: notes || `Version ${newVersionNumber}`,
+ });
+
+ await nocodb.update('Posts', req.params.id, { current_version: newVersionNumber });
+
+ // Copy texts from previous version if requested
+ if (copy_from_previous && versions.length > 0) {
+ const prevVersionId = versions[0].Id;
+ const prevTexts = await nocodb.list('PostVersionTexts', {
+ where: `(version_id,eq,${prevVersionId})`,
+ limit: QUERY_LIMITS.large,
+ });
+ for (const text of prevTexts) {
+ await nocodb.create('PostVersionTexts', {
+ version_id: created.Id,
+ language_code: text.language_code,
+ language_label: text.language_label,
+ content: text.content,
+ });
+ }
+ }
+
+ const version = await nocodb.get('PostVersions', created.Id);
+ const creatorName = await getRecordName('Users', version.created_by_user_id);
+ res.status(201).json({ ...version, creator_name: creatorName });
+ } catch (err) {
+ console.error('Create post version error:', err);
+ res.status(500).json({ error: 'Failed to create version' });
+ }
+});
+
+// Get specific version with texts and attachments
+app.get('/api/posts/:id/versions/:versionId', requireAuth, async (req, res) => {
+ try {
+ const version = await nocodb.get('PostVersions', req.params.versionId);
+ if (!version) return res.status(404).json({ error: 'Version not found' });
+ if (version.post_id !== Number(req.params.id)) {
+ return res.status(400).json({ error: 'Version does not belong to this post' });
+ }
+
+ const [texts, attachments] = await Promise.all([
+ nocodb.list('PostVersionTexts', {
+ where: `(version_id,eq,${sanitizeWhereValue(req.params.versionId)})`,
+ limit: QUERY_LIMITS.large,
+ }),
+ nocodb.list('PostAttachments', {
+ where: `(version_id,eq,${sanitizeWhereValue(req.params.versionId)})`,
+ limit: QUERY_LIMITS.large,
+ }),
+ ]);
+
+ const creatorName = await getRecordName('Users', version.created_by_user_id);
+
+ res.json({
+ ...version,
+ creator_name: creatorName,
+ texts,
+ attachments: attachments.map(a => ({
+ ...a,
+ url: a.url || `/api/uploads/${a.filename}`,
+ })),
+ });
+ } catch (err) {
+ console.error('Get post version error:', err);
+ res.status(500).json({ error: 'Failed to load version' });
+ }
+});
+
+// Add/update language text for a version
+app.post('/api/posts/:id/versions/:versionId/texts', requireAuth, async (req, res) => {
+ const { language_code, language_label, content } = req.body;
+ if (!language_code || !language_label || !content) {
+ return res.status(400).json({ error: 'language_code, language_label, and content are required' });
+ }
+
+ try {
+ const post = await nocodb.get('Posts', req.params.id);
+ if (!post) return res.status(404).json({ error: 'Post not found' });
+
+ if (req.session.userRole === 'contributor' && post.created_by_user_id !== req.session.userId && post.assigned_to_id !== req.session.userId) {
+ return res.status(403).json({ error: 'You can only manage texts for your own posts' });
+ }
+
+ const existing = await nocodb.list('PostVersionTexts', {
+ where: `(version_id,eq,${sanitizeWhereValue(req.params.versionId)})~and(language_code,eq,${sanitizeWhereValue(language_code)})`,
+ limit: 1,
+ });
+
+ let text;
+ if (existing.length > 0) {
+ await nocodb.update('PostVersionTexts', existing[0].Id, { language_label, content });
+ text = await nocodb.get('PostVersionTexts', existing[0].Id);
+ } else {
+ const created = await nocodb.create('PostVersionTexts', {
+ version_id: Number(req.params.versionId),
+ language_code,
+ language_label,
+ content,
+ });
+ text = await nocodb.get('PostVersionTexts', created.Id);
+ }
+
+ res.json(text);
+ } catch (err) {
+ console.error('Add/update post text error:', err);
+ res.status(500).json({ error: 'Failed to add/update text' });
+ }
+});
+
+// Delete language text
+app.delete('/api/post-version-texts/:id', requireAuth, async (req, res) => {
+ try {
+ const text = await nocodb.get('PostVersionTexts', req.params.id);
+ if (!text) return res.status(404).json({ error: 'Text not found' });
+
+ const version = await nocodb.get('PostVersions', text.version_id);
+ const post = await nocodb.get('Posts', version.post_id);
+
+ if (req.session.userRole === 'contributor' && post.created_by_user_id !== req.session.userId && post.assigned_to_id !== req.session.userId) {
+ return res.status(403).json({ error: 'You can only manage texts for your own posts' });
+ }
+
+ await nocodb.delete('PostVersionTexts', req.params.id);
+ res.json({ success: true });
+ } catch (err) {
+ console.error('Delete post text error:', err);
+ res.status(500).json({ error: 'Failed to delete text' });
+ }
+});
+
+// Upload attachment to specific version
+app.post('/api/posts/:id/versions/:versionId/attachments', requireAuth, dynamicUpload('file'), async (req, res) => {
+ try {
+ const post = await nocodb.get('Posts', req.params.id);
+ if (!post) return res.status(404).json({ error: 'Post not found' });
+
+ if (req.session.userRole === 'contributor' && post.created_by_user_id !== req.session.userId && post.assigned_to_id !== req.session.userId) {
+ if (req.file) fs.unlinkSync(path.join(uploadsDir, req.file.filename));
+ return res.status(403).json({ error: 'You can only manage attachments on your own posts' });
+ }
+
+ if (!req.file) {
+ return res.status(400).json({ error: 'File upload is required' });
+ }
+
+ const url = `/api/uploads/${req.file.filename}`;
+ const created = await nocodb.create('PostAttachments', {
+ filename: req.file.filename,
+ original_name: req.file.originalname,
+ mime_type: req.file.mimetype,
+ size: req.file.size,
+ url,
+ post_id: Number(req.params.id),
+ version_id: Number(req.params.versionId),
+ });
+
+ const attachment = await nocodb.get('PostAttachments', created.Id);
+ res.status(201).json(attachment);
+ } catch (err) {
+ console.error('Upload post version attachment error:', err);
+ res.status(500).json({ error: 'Failed to upload attachment' });
+ }
+});
+
// ─── ASSETS ─────────────────────────────────────────────────────
app.get('/api/assets', requireAuth, async (req, res) => {