feat: artefact version auto-advance, post_id on artefacts, test-email endpoint
Deploy / deploy (push) Successful in 13s
Deploy / deploy (push) Successful in 13s
- Auto-advance artefact to next working version on rejection/revision - Add post_id field to artefact creation - Add request timeout (20s) to NocoDB client - Add POST /api/admin/test-email for diagnosing SMTP issues - Fix FK column creation logging Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,10 @@ const TYPE_ICONS = {
|
|||||||
other: Sparkles,
|
other: Sparkles,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parseApproverIds = (a) =>
|
||||||
|
a.approvers?.map(u => String(u.id)) ||
|
||||||
|
(a.approver_ids ? a.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [])
|
||||||
|
|
||||||
export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDelete, assignableUsers = [] }) {
|
export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDelete, assignableUsers = [] }) {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const { brands } = useContext(AppContext)
|
const { brands } = useContext(AppContext)
|
||||||
@@ -35,14 +39,12 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
|||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [freshReviewUrl, setFreshReviewUrl] = useState('')
|
const [freshReviewUrl, setFreshReviewUrl] = useState('')
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
const [activeTab, setActiveTab] = useState('details')
|
const [activeTab, setActiveTab] = useState(artefact.type === 'copy' ? 'versions' : 'details')
|
||||||
|
|
||||||
// Editable fields
|
// Editable fields — seeded from artefact prop; component is keyed by artefact._id at call site
|
||||||
const [editTitle, setEditTitle] = useState(artefact.title || '')
|
const [editTitle, setEditTitle] = useState(artefact.title || '')
|
||||||
const [editDescription, setEditDescription] = useState(artefact.description || '')
|
const [editDescription, setEditDescription] = useState(artefact.description || '')
|
||||||
const [editApproverIds, setEditApproverIds] = useState(
|
const [editApproverIds, setEditApproverIds] = useState(() => parseApproverIds(artefact))
|
||||||
artefact.approvers?.map(a => String(a.id)) || (artefact.approver_ids ? artefact.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [])
|
|
||||||
)
|
|
||||||
const reviewUrl = freshReviewUrl || (artefact.approval_token ? `${window.location.origin}/review/${artefact.approval_token}` : '')
|
const reviewUrl = freshReviewUrl || (artefact.approval_token ? `${window.location.origin}/review/${artefact.approval_token}` : '')
|
||||||
const [savingDraft, setSavingDraft] = useState(false)
|
const [savingDraft, setSavingDraft] = useState(false)
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
@@ -61,14 +63,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
|||||||
loadVersions()
|
loadVersions()
|
||||||
}, [artefact.Id])
|
}, [artefact.Id])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setEditTitle(artefact.title || '')
|
|
||||||
setEditDescription(artefact.description || '')
|
|
||||||
setEditApproverIds(
|
|
||||||
artefact.approvers?.map(a => String(a.id)) || (artefact.approver_ids ? artefact.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [])
|
|
||||||
)
|
|
||||||
}, [artefact.Id])
|
|
||||||
|
|
||||||
const loadVersions = async () => {
|
const loadVersions = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get(`/artefacts/${artefact.Id}/versions`)
|
const res = await api.get(`/artefacts/${artefact.Id}/versions`)
|
||||||
@@ -109,13 +103,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
|||||||
loadVersionData(version.Id)
|
loadVersionData(version.Id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreateVersion = async ({ notes, copy_from_previous }) => {
|
|
||||||
await api.post(`/artefacts/${artefact.Id}/versions`, { notes, copy_from_previous })
|
|
||||||
toast.success(t('artefacts.versionCreated'))
|
|
||||||
loadVersions()
|
|
||||||
onUpdate()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAddLanguage = async (languageForm) => {
|
const handleAddLanguage = async (languageForm) => {
|
||||||
await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/texts`, languageForm)
|
await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/texts`, languageForm)
|
||||||
toast.success(t('artefacts.languageAdded'))
|
toast.success(t('artefacts.languageAdded'))
|
||||||
@@ -249,6 +236,12 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleUpdateLanguage = async (textId, content) => {
|
||||||
|
await api.patch(`/artefact-version-texts/${textId}`, { content })
|
||||||
|
toast.success(t('artefacts.languageAdded'))
|
||||||
|
loadVersionData(selectedVersion.Id)
|
||||||
|
}
|
||||||
|
|
||||||
const handleDeleteArtefact = async () => {
|
const handleDeleteArtefact = async () => {
|
||||||
setDeleting(true)
|
setDeleting(true)
|
||||||
try {
|
try {
|
||||||
@@ -282,10 +275,10 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
|||||||
const TypeIcon = TYPE_ICONS[artefact.type] || Sparkles
|
const TypeIcon = TYPE_ICONS[artefact.type] || Sparkles
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ key: 'details', label: t('artefacts.details') || 'Details', icon: FileEdit },
|
{ key: 'details', label: t('artefacts.details'), icon: FileEdit },
|
||||||
{ key: 'versions', label: t('artefacts.versions') || 'Versions', icon: Layers, badge: versions.length },
|
{ key: 'versions', label: t('artefacts.versions'), icon: Layers, badge: versions.length },
|
||||||
{ key: 'discussion', label: t('artefacts.comments') || 'Discussion', icon: MessageSquare, badge: comments.length },
|
{ key: 'discussion', label: t('artefacts.comments'), icon: MessageSquare, badge: comments.length },
|
||||||
{ key: 'review', label: t('artefacts.review') || 'Review', icon: ShieldCheck },
|
{ key: 'review', label: t('artefacts.review'), icon: ShieldCheck },
|
||||||
]
|
]
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -304,32 +297,30 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
size="xl"
|
size="xl"
|
||||||
header={
|
header={
|
||||||
<>
|
<div className="flex items-start gap-3">
|
||||||
<div className="flex items-start gap-3">
|
<div className="w-10 h-10 rounded-lg bg-brand-primary/10 flex items-center justify-center shrink-0">
|
||||||
<div className="w-10 h-10 rounded-lg bg-brand-primary/10 flex items-center justify-center shrink-0">
|
<TypeIcon className="w-5 h-5 text-brand-primary" />
|
||||||
<TypeIcon className="w-5 h-5 text-brand-primary" />
|
</div>
|
||||||
</div>
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex-1 min-w-0">
|
<input
|
||||||
<input
|
type="text"
|
||||||
type="text"
|
value={editTitle}
|
||||||
value={editTitle}
|
onChange={e => setEditTitle(e.target.value)}
|
||||||
onChange={e => setEditTitle(e.target.value)}
|
className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 border-b border-transparent hover:border-border focus:border-brand-primary focus:outline-none focus:ring-0 px-0 py-0.5 transition-colors"
|
||||||
className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 border-b border-transparent hover:border-border focus:border-brand-primary focus:outline-none focus:ring-0 px-0 py-0.5 transition-colors"
|
/>
|
||||||
/>
|
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[artefact.status] || 'bg-surface-tertiary text-text-secondary'}`}>
|
||||||
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[artefact.status] || 'bg-surface-tertiary text-text-secondary'}`}>
|
{artefact.status?.replace('_', ' ')}
|
||||||
{artefact.status?.replace('_', ' ')}
|
</span>
|
||||||
|
<span className="text-xs text-text-tertiary capitalize">{artefact.type}</span>
|
||||||
|
{artefact.creator_name && (
|
||||||
|
<span className="text-xs text-text-secondary font-medium">
|
||||||
|
{t('review.createdBy')} <strong className="text-text-primary">{artefact.creator_name}</strong>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-text-tertiary capitalize">{artefact.type}</span>
|
)}
|
||||||
{artefact.creator_name && (
|
|
||||||
<span className="text-xs text-text-secondary font-medium">
|
|
||||||
{t('review.createdBy')} <strong className="text-text-primary">{artefact.creator_name}</strong>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
}
|
}
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
@@ -349,15 +340,17 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
{activeTab === 'details' && (
|
||||||
onClick={handleSaveDraft}
|
<button
|
||||||
disabled={savingDraft}
|
onClick={handleSaveDraft}
|
||||||
className="flex items-center gap-1.5 px-4 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light disabled:opacity-50 transition-colors"
|
disabled={savingDraft}
|
||||||
title={t('artefacts.saveDraftTooltip')}
|
className="flex items-center gap-1.5 px-4 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light disabled:opacity-50 transition-colors"
|
||||||
>
|
title={t('artefacts.saveDraftTooltip')}
|
||||||
<Save className="w-3.5 h-3.5" />
|
>
|
||||||
{savingDraft ? t('artefacts.savingDraft') : t('artefacts.saveDraft')}
|
<Save className="w-3.5 h-3.5" />
|
||||||
</button>
|
{savingDraft ? t('artefacts.savingDraft') : t('artefacts.saveDraft')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -376,6 +369,32 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata row */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 pt-1">
|
||||||
|
{/* Brand */}
|
||||||
|
{(artefact.brand_id || artefact.brandId) && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1">{t('posts.brand')}</h4>
|
||||||
|
<p className="text-sm text-text-primary">
|
||||||
|
{brands.find(b => String(b._id) === String(artefact.brand_id || artefact.brandId))?.name || `#${artefact.brand_id || artefact.brandId}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Created date */}
|
||||||
|
{artefact.CreatedAt && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1">{t('common.created')}</h4>
|
||||||
|
<p className="text-sm text-text-secondary">{new Date(artefact.CreatedAt).toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Linked post */}
|
||||||
|
{(artefact.post_id || artefact.postId) && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1">{t('artefacts.linkedPost')}</h4>
|
||||||
|
<p className="text-sm text-text-secondary">{t('artefacts.post')} #{artefact.post_id || artefact.postId}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -389,8 +408,8 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
|||||||
uploading={uploading}
|
uploading={uploading}
|
||||||
uploadProgress={uploadProgress}
|
uploadProgress={uploadProgress}
|
||||||
onSelectVersion={handleSelectVersion}
|
onSelectVersion={handleSelectVersion}
|
||||||
onCreateVersion={handleCreateVersion}
|
|
||||||
onAddLanguage={handleAddLanguage}
|
onAddLanguage={handleAddLanguage}
|
||||||
|
onUpdateLanguage={handleUpdateLanguage}
|
||||||
onDeleteLanguage={handleDeleteLanguage}
|
onDeleteLanguage={handleDeleteLanguage}
|
||||||
onFileUpload={handleFileUpload}
|
onFileUpload={handleFileUpload}
|
||||||
onDeleteAttachment={handleDeleteAttachment}
|
onDeleteAttachment={handleDeleteAttachment}
|
||||||
@@ -451,7 +470,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8 text-sm text-text-tertiary">
|
<div className="text-center py-8 text-sm text-text-tertiary">
|
||||||
{t('artefacts.selectVersionFirst') || 'Select a version first to view comments.'}
|
{t('artefacts.selectVersionFirst')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -530,12 +549,10 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty state when no review actions available */}
|
{/* Empty state: pending_review or unknown status with no review info */}
|
||||||
{!['draft', 'revision_requested', 'rejected'].includes(artefact.status) && !reviewUrl && !artefact.feedback && !(artefact.status === 'approved' && artefact.approved_by_name) && (
|
{artefact.status === 'pending_review' && !reviewUrl && !artefact.feedback && (
|
||||||
<div className="text-center py-8 text-sm text-text-tertiary">
|
<div className="text-center py-8 text-sm text-text-tertiary">
|
||||||
{artefact.status === 'pending_review'
|
{t('artefacts.pendingReviewInfo')}
|
||||||
? t('artefacts.pendingReviewInfo') || 'This artefact is currently pending review.'
|
|
||||||
: t('artefacts.noReviewInfo') || 'No review information available.'}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Plus, Trash2, Globe, Image as ImageIcon } from 'lucide-react'
|
import { Trash2, Globe, Image as ImageIcon, Pencil } from 'lucide-react'
|
||||||
import PortalSelect from './PortalSelect'
|
import PortalSelect from './PortalSelect'
|
||||||
import UploadZone from './UploadZone'
|
import UploadZone from './UploadZone'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
@@ -21,8 +21,8 @@ export function ArtefactDetailVersionsTab({
|
|||||||
uploading,
|
uploading,
|
||||||
uploadProgress,
|
uploadProgress,
|
||||||
onSelectVersion,
|
onSelectVersion,
|
||||||
onCreateVersion,
|
|
||||||
onAddLanguage,
|
onAddLanguage,
|
||||||
|
onUpdateLanguage,
|
||||||
onDeleteLanguage,
|
onDeleteLanguage,
|
||||||
onFileUpload,
|
onFileUpload,
|
||||||
onDeleteAttachment,
|
onDeleteAttachment,
|
||||||
@@ -31,11 +31,6 @@ export function ArtefactDetailVersionsTab({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
|
||||||
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 [showLanguageModal, setShowLanguageModal] = useState(false)
|
||||||
const [languageForm, setLanguageForm] = useState({ language_code: '', language_label: '', content: '' })
|
const [languageForm, setLanguageForm] = useState({ language_code: '', language_label: '', content: '' })
|
||||||
const [savingLanguage, setSavingLanguage] = useState(false)
|
const [savingLanguage, setSavingLanguage] = useState(false)
|
||||||
@@ -43,24 +38,13 @@ export function ArtefactDetailVersionsTab({
|
|||||||
const [confirmDeleteLangId, setConfirmDeleteLangId] = useState(null)
|
const [confirmDeleteLangId, setConfirmDeleteLangId] = useState(null)
|
||||||
const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
|
const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
|
||||||
|
|
||||||
|
const [editingLangText, setEditingLangText] = useState(null) // { Id, language_code, language_label, content }
|
||||||
|
const [editLangContent, setEditLangContent] = useState('')
|
||||||
|
const [savingEditLang, setSavingEditLang] = useState(false)
|
||||||
|
|
||||||
const [dragOver, setDragOver] = useState(false)
|
const [dragOver, setDragOver] = useState(false)
|
||||||
const [driveUrl, setDriveUrl] = useState('')
|
const [driveUrl, setDriveUrl] = useState('')
|
||||||
|
|
||||||
const handleCreateVersion = async () => {
|
|
||||||
setCreatingVersion(true)
|
|
||||||
try {
|
|
||||||
await onCreateVersion({
|
|
||||||
notes: newVersionNotes || `Version ${(versions[versions.length - 1]?.version_number || 0) + 1}`,
|
|
||||||
copy_from_previous: artefact.type === 'copy' ? copyFromPrevious : false,
|
|
||||||
})
|
|
||||||
setShowNewVersionModal(false)
|
|
||||||
setNewVersionNotes('')
|
|
||||||
setCopyFromPrevious(false)
|
|
||||||
} finally {
|
|
||||||
setCreatingVersion(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAddLanguage = async () => {
|
const handleAddLanguage = async () => {
|
||||||
if (!languageForm.language_code || !languageForm.language_label || !languageForm.content) return
|
if (!languageForm.language_code || !languageForm.language_label || !languageForm.content) return
|
||||||
setSavingLanguage(true)
|
setSavingLanguage(true)
|
||||||
@@ -83,6 +67,17 @@ export function ArtefactDetailVersionsTab({
|
|||||||
setConfirmDeleteAttId(null)
|
setConfirmDeleteAttId(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSaveEditLang = async () => {
|
||||||
|
if (!editingLangText || !editLangContent.trim()) return
|
||||||
|
setSavingEditLang(true)
|
||||||
|
try {
|
||||||
|
await onUpdateLanguage(editingLangText.Id, editLangContent)
|
||||||
|
setEditingLangText(null)
|
||||||
|
} finally {
|
||||||
|
setSavingEditLang(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleVideoDrop = (e) => {
|
const handleVideoDrop = (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setDragOver(false)
|
setDragOver(false)
|
||||||
@@ -101,37 +96,18 @@ export function ArtefactDetailVersionsTab({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="p-6 space-y-5">
|
<div className="p-6 space-y-5">
|
||||||
{/* Version Timeline */}
|
{/* Version Timeline — only shown when there are multiple rounds */}
|
||||||
<div>
|
{versions.length > 1 && (
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div>
|
||||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.versions')}</h4>
|
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">{t('artefacts.versions')}</h4>
|
||||||
{artefact.status === 'rejected' && (
|
<ArtefactVersionTimeline
|
||||||
<button
|
versions={versions}
|
||||||
onClick={() => setShowNewVersionModal(true)}
|
activeVersionId={selectedVersion?.Id}
|
||||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
|
onSelectVersion={onSelectVersion}
|
||||||
>
|
artefactType={artefact.type}
|
||||||
<Plus className="w-3 h-3" />
|
/>
|
||||||
{t('artefacts.newVersion')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{artefact.status === 'rejected' && (
|
)}
|
||||||
<div className="mb-3 px-3 py-2 bg-red-50 border border-red-200 rounded-lg text-xs text-red-700">
|
|
||||||
{t('artefacts.rejectedMustCreateNewVersion')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{artefact.status === 'revision_requested' && (
|
|
||||||
<div className="mb-3 px-3 py-2 bg-amber-50 border border-amber-200 rounded-lg text-xs text-amber-700">
|
|
||||||
{t('artefacts.revisionEditCurrentVersion')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<ArtefactVersionTimeline
|
|
||||||
versions={versions}
|
|
||||||
activeVersionId={selectedVersion?.Id}
|
|
||||||
onSelectVersion={onSelectVersion}
|
|
||||||
artefactType={artefact.type}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Type-specific content */}
|
{/* Type-specific content */}
|
||||||
{versionData && selectedVersion && (
|
{versionData && selectedVersion && (
|
||||||
@@ -145,7 +121,7 @@ export function ArtefactDetailVersionsTab({
|
|||||||
onClick={() => setShowLanguageModal(true)}
|
onClick={() => setShowLanguageModal(true)}
|
||||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
|
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
|
||||||
>
|
>
|
||||||
<Plus className="w-3 h-3" />
|
<Globe className="w-3 h-3" />
|
||||||
{t('artefacts.addLanguage')}
|
{t('artefacts.addLanguage')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -161,12 +137,21 @@ export function ArtefactDetailVersionsTab({
|
|||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-medium text-text-primary">{text.language_label}</span>
|
<span className="text-sm font-medium text-text-primary">{text.language_label}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-1">
|
||||||
onClick={() => setConfirmDeleteLangId(text.Id)}
|
<button
|
||||||
className="text-red-600 hover:text-red-700"
|
onClick={() => { setEditingLangText(text); setEditLangContent(text.content || '') }}
|
||||||
>
|
className="p-1 text-text-tertiary hover:text-brand-primary rounded transition-colors"
|
||||||
<Trash2 className="w-4 h-4" />
|
title={t('common.edit')}
|
||||||
</button>
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDeleteLangId(text.Id)}
|
||||||
|
className="p-1 text-red-500 hover:text-red-700 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-surface rounded border border-border p-3 text-sm text-text-primary whitespace-pre-wrap font-sans">
|
<div className="bg-surface rounded border border-border p-3 text-sm text-text-primary whitespace-pre-wrap font-sans">
|
||||||
{text.content}
|
{text.content}
|
||||||
@@ -345,43 +330,39 @@ export function ArtefactDetailVersionsTab({
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* New Version Modal */}
|
{/* Edit Language Modal */}
|
||||||
<Modal isOpen={showNewVersionModal} onClose={() => setShowNewVersionModal(false)} title={t('artefacts.createNewVersion')} size="sm">
|
<Modal isOpen={!!editingLangText} onClose={() => setEditingLangText(null)} title={t('artefacts.editLanguage')} size="md">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{editingLangText && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="px-2 py-0.5 bg-surface border border-border rounded text-xs font-mono font-medium">
|
||||||
|
{editingLangText.language_code}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-text-primary">{editingLangText.language_label}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.versionNotes')}</label>
|
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.contentLabel')}</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={newVersionNotes}
|
value={editLangContent}
|
||||||
onChange={e => setNewVersionNotes(e.target.value)}
|
onChange={e => setEditLangContent(e.target.value)}
|
||||||
rows={3}
|
rows={8}
|
||||||
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"
|
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 font-sans"
|
||||||
placeholder={t('artefacts.whatChanged')}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{artefact.type === 'copy' && versions.length > 0 && (
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={copyFromPrevious}
|
|
||||||
onChange={e => setCopyFromPrevious(e.target.checked)}
|
|
||||||
className="w-4 h-4 text-brand-primary border-border rounded focus:ring-brand-primary"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-text-secondary">{t('artefacts.copyLanguages')}</span>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<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={() => setShowNewVersionModal(false)}
|
onClick={() => setEditingLangText(null)}
|
||||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
||||||
>
|
>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleCreateVersion}
|
onClick={handleSaveEditLang}
|
||||||
disabled={creatingVersion}
|
disabled={savingEditLang}
|
||||||
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
|
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
|
||||||
>
|
>
|
||||||
{creatingVersion ? t('artefacts.creatingVersion') : t('artefacts.createVersion')}
|
{savingEditLang ? t('header.saving') : t('common.save')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"common.loading": "جاري التحميل...",
|
"common.loading": "جاري التحميل...",
|
||||||
"common.unassigned": "غير مُسند",
|
"common.unassigned": "غير مُسند",
|
||||||
"common.close": "إغلاق",
|
"common.close": "إغلاق",
|
||||||
|
"common.created": "تاريخ الإنشاء",
|
||||||
"common.required": "مطلوب",
|
"common.required": "مطلوب",
|
||||||
"common.saveFailed": "فشل الحفظ. حاول مجدداً.",
|
"common.saveFailed": "فشل الحفظ. حاول مجدداً.",
|
||||||
"common.updateFailed": "فشل التحديث. حاول مجدداً.",
|
"common.updateFailed": "فشل التحديث. حاول مجدداً.",
|
||||||
@@ -761,6 +762,9 @@
|
|||||||
"issues.trackingLinkCopied": "تم نسخ رابط التتبع!",
|
"issues.trackingLinkCopied": "تم نسخ رابط التتبع!",
|
||||||
"issues.deleteAttachment": "حذف المرفق؟",
|
"issues.deleteAttachment": "حذف المرفق؟",
|
||||||
"issues.deleteAttachmentDesc": "لا يمكن التراجع عن هذا الإجراء.",
|
"issues.deleteAttachmentDesc": "لا يمكن التراجع عن هذا الإجراء.",
|
||||||
|
"artefacts.editLanguage": "تعديل اللغة",
|
||||||
|
"artefacts.linkedPost": "المنشور المرتبط",
|
||||||
|
"artefacts.post": "منشور",
|
||||||
"artefacts.deleteLanguage": "حذف هذه اللغة؟",
|
"artefacts.deleteLanguage": "حذف هذه اللغة؟",
|
||||||
"artefacts.deleteLanguageDesc": "سيتم إزالة المحتوى لهذه اللغة.",
|
"artefacts.deleteLanguageDesc": "سيتم إزالة المحتوى لهذه اللغة.",
|
||||||
"artefacts.deleteAttachment": "حذف هذا المرفق؟",
|
"artefacts.deleteAttachment": "حذف هذا المرفق؟",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"common.loading": "Loading...",
|
"common.loading": "Loading...",
|
||||||
"common.unassigned": "Unassigned",
|
"common.unassigned": "Unassigned",
|
||||||
"common.close": "Close",
|
"common.close": "Close",
|
||||||
|
"common.created": "Created",
|
||||||
"common.required": "Required",
|
"common.required": "Required",
|
||||||
"common.saveFailed": "Failed to save. Please try again.",
|
"common.saveFailed": "Failed to save. Please try again.",
|
||||||
"common.updateFailed": "Failed to update. Please try again.",
|
"common.updateFailed": "Failed to update. Please try again.",
|
||||||
@@ -761,6 +762,9 @@
|
|||||||
"issues.trackingLinkCopied": "Tracking link copied to clipboard!",
|
"issues.trackingLinkCopied": "Tracking link copied to clipboard!",
|
||||||
"issues.deleteAttachment": "Delete attachment?",
|
"issues.deleteAttachment": "Delete attachment?",
|
||||||
"issues.deleteAttachmentDesc": "This action cannot be undone.",
|
"issues.deleteAttachmentDesc": "This action cannot be undone.",
|
||||||
|
"artefacts.editLanguage": "Edit Language",
|
||||||
|
"artefacts.linkedPost": "Linked Post",
|
||||||
|
"artefacts.post": "Post",
|
||||||
"artefacts.deleteLanguage": "Delete this language?",
|
"artefacts.deleteLanguage": "Delete this language?",
|
||||||
"artefacts.deleteLanguageDesc": "The content for this language will be removed.",
|
"artefacts.deleteLanguageDesc": "The content for this language will be removed.",
|
||||||
"artefacts.deleteAttachment": "Delete this attachment?",
|
"artefacts.deleteAttachment": "Delete this attachment?",
|
||||||
|
|||||||
+110
-114
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useContext, useCallback } from 'react'
|
import { useState, useEffect, useContext, useCallback, useRef } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { ArrowLeft, Save, FileText, Image as ImageIcon, Film, Type, Search, Link2, Unlink, Plus, CheckCircle, Clock, X, ExternalLink } from 'lucide-react'
|
import { ArrowLeft, Save, FileText, Image as ImageIcon, Film, Type, Search, Link2, Unlink, Plus, CheckCircle, Clock, X, ExternalLink } from 'lucide-react'
|
||||||
import { AppContext } from '../App'
|
import { AppContext } from '../App'
|
||||||
@@ -14,6 +14,20 @@ import { useToast } from '../components/ToastContainer'
|
|||||||
|
|
||||||
const STATUS_OPTS = ['draft', 'in_review', 'approved', 'rejected', 'scheduled', 'published']
|
const STATUS_OPTS = ['draft', 'in_review', 'approved', 'rejected', 'scheduled', 'published']
|
||||||
|
|
||||||
|
// Maps asset type key → composition field name
|
||||||
|
const PIECE_MAP = { caption: 'caption', body: 'body_copy', design: 'design', video: 'video' }
|
||||||
|
// Maps asset type key → i18n label key
|
||||||
|
const LABEL_KEYS = {
|
||||||
|
caption: 'postDetail.captionCopy',
|
||||||
|
body: 'postDetail.bodyCopy',
|
||||||
|
design: 'postDetail.design',
|
||||||
|
video: 'postDetail.video',
|
||||||
|
}
|
||||||
|
const ASSET_ICONS = { caption: Type, body: FileText, design: ImageIcon, video: Film }
|
||||||
|
const ASSET_TYPES = ['caption', 'body', 'design', 'video']
|
||||||
|
// Maps server-generated waiting_on labels → asset type key
|
||||||
|
const WAITING_TYPE_MAP = { Caption: 'caption', Copy: 'body', Design: 'design', Video: 'video' }
|
||||||
|
|
||||||
export default function PostDetail() {
|
export default function PostDetail() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -37,21 +51,18 @@ export default function PostDetail() {
|
|||||||
const [platforms, setPlatforms] = useState([])
|
const [platforms, setPlatforms] = useState([])
|
||||||
const [scheduledDate, setScheduledDate] = useState('')
|
const [scheduledDate, setScheduledDate] = useState('')
|
||||||
|
|
||||||
// Link pickers
|
// Link pickers / create
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
const [activePicker, setActivePicker] = useState(null) // 'caption' | 'body' | 'design' | 'video'
|
const [activePicker, setActivePicker] = useState(null) // 'caption' | 'body' | 'design' | 'video'
|
||||||
const [pickerSearch, setPickerSearch] = useState('')
|
const [pickerSearch, setPickerSearch] = useState('')
|
||||||
const [linkCandidates, setLinkCandidates] = useState([])
|
const [linkCandidates, setLinkCandidates] = useState([])
|
||||||
const [linking, setLinking] = useState(false)
|
const [linking, setLinking] = useState(false)
|
||||||
|
const allArtefactsRef = useRef(null)
|
||||||
|
|
||||||
// Sub-panels
|
// Sub-panels
|
||||||
const [openArtefact, setOpenArtefact] = useState(null)
|
const [openArtefact, setOpenArtefact] = useState(null)
|
||||||
|
|
||||||
useEffect(() => {
|
const loadPost = useCallback(async () => {
|
||||||
loadPost()
|
|
||||||
api.get('/campaigns').then(r => setCampaigns(Array.isArray(r) ? r : [])).catch(() => {})
|
|
||||||
}, [id])
|
|
||||||
|
|
||||||
const loadPost = async () => {
|
|
||||||
try {
|
try {
|
||||||
const [p, comp] = await Promise.all([
|
const [p, comp] = await Promise.all([
|
||||||
api.get(`/posts/${id}`),
|
api.get(`/posts/${id}`),
|
||||||
@@ -73,7 +84,12 @@ export default function PostDetail() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}, [id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadPost()
|
||||||
|
api.get('/campaigns').then(r => setCampaigns(Array.isArray(r) ? r : [])).catch(() => {})
|
||||||
|
}, [loadPost])
|
||||||
|
|
||||||
const loadComposition = useCallback(async () => {
|
const loadComposition = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -96,7 +112,8 @@ export default function PostDetail() {
|
|||||||
scheduled_date: scheduledDate || null,
|
scheduled_date: scheduledDate || null,
|
||||||
})
|
})
|
||||||
toast.success(t('posts.updated'))
|
toast.success(t('posts.updated'))
|
||||||
loadPost()
|
// Update local post state — composition is unaffected by metadata changes
|
||||||
|
setPost(p => ({ ...p, title, status, brand_id: brandId, campaign_id: campaignId, assigned_to: assignedTo, platforms, scheduled_date: scheduledDate || null }))
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('common.saveFailed'))
|
toast.error(t('common.saveFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -110,20 +127,22 @@ export default function PostDetail() {
|
|||||||
|
|
||||||
// ─── Link / Unlink / Create ───
|
// ─── Link / Unlink / Create ───
|
||||||
|
|
||||||
|
const TYPE_FILTERS = {
|
||||||
|
caption: a => a.type === 'copy' && a.copy_type === 'caption',
|
||||||
|
body: a => a.type === 'copy' && (a.copy_type === 'body' || !a.copy_type),
|
||||||
|
video: a => a.type === 'video',
|
||||||
|
design: a => (a.type || 'design') === 'design',
|
||||||
|
}
|
||||||
|
|
||||||
const openLinkPicker = async (type) => {
|
const openLinkPicker = async (type) => {
|
||||||
setActivePicker(type)
|
setActivePicker(type)
|
||||||
setPickerSearch('')
|
setPickerSearch('')
|
||||||
try {
|
try {
|
||||||
const all = await api.get('/artefacts')
|
if (!allArtefactsRef.current) allArtefactsRef.current = await api.get('/artefacts')
|
||||||
let typeFilter
|
const all = Array.isArray(allArtefactsRef.current) ? allArtefactsRef.current : []
|
||||||
if (type === 'caption') typeFilter = a => a.type === 'copy' && a.copy_type === 'caption'
|
setLinkCandidates(all.filter(a => {
|
||||||
else if (type === 'body') typeFilter = a => a.type === 'copy' && (a.copy_type === 'body' || !a.copy_type)
|
|
||||||
else if (type === 'video') typeFilter = a => a.type === 'video'
|
|
||||||
else typeFilter = a => (a.type || 'design') === 'design'
|
|
||||||
|
|
||||||
setLinkCandidates((Array.isArray(all) ? all : []).filter(a => {
|
|
||||||
const linkedTo = a.post_id || a.postId
|
const linkedTo = a.post_id || a.postId
|
||||||
return typeFilter(a) && (!linkedTo || String(linkedTo) !== String(id))
|
return TYPE_FILTERS[type](a) && (!linkedTo || String(linkedTo) !== String(id))
|
||||||
}))
|
}))
|
||||||
} catch {
|
} catch {
|
||||||
setLinkCandidates([])
|
setLinkCandidates([])
|
||||||
@@ -135,6 +154,7 @@ export default function PostDetail() {
|
|||||||
setLinking(true)
|
setLinking(true)
|
||||||
try {
|
try {
|
||||||
await api.patch(`/artefacts/${itemId}`, { post_id: Number(id) })
|
await api.patch(`/artefacts/${itemId}`, { post_id: Number(id) })
|
||||||
|
allArtefactsRef.current = null
|
||||||
toast.success(t('posts.updated'))
|
toast.success(t('posts.updated'))
|
||||||
setActivePicker(null)
|
setActivePicker(null)
|
||||||
loadComposition()
|
loadComposition()
|
||||||
@@ -146,13 +166,11 @@ export default function PostDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleUnlink = async (type) => {
|
const handleUnlink = async (type) => {
|
||||||
const piece = type === 'caption' ? composition?.caption
|
const piece = composition?.[PIECE_MAP[type]]
|
||||||
: type === 'body' ? composition?.body_copy
|
|
||||||
: type === 'design' ? composition?.design
|
|
||||||
: composition?.video
|
|
||||||
if (!piece) return
|
if (!piece) return
|
||||||
try {
|
try {
|
||||||
await api.patch(`/artefacts/${piece.id}`, { post_id: null })
|
await api.patch(`/artefacts/${piece.id}`, { post_id: null })
|
||||||
|
allArtefactsRef.current = null
|
||||||
toast.success(t('posts.updated'))
|
toast.success(t('posts.updated'))
|
||||||
loadComposition()
|
loadComposition()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -161,10 +179,7 @@ export default function PostDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleOpenPiece = async (type) => {
|
const handleOpenPiece = async (type) => {
|
||||||
const piece = type === 'caption' ? composition?.caption
|
const piece = composition?.[PIECE_MAP[type]]
|
||||||
: type === 'body' ? composition?.body_copy
|
|
||||||
: type === 'design' ? composition?.design
|
|
||||||
: composition?.video
|
|
||||||
if (!piece) return
|
if (!piece) return
|
||||||
try {
|
try {
|
||||||
const full = await api.get(`/artefacts/${piece.id}`)
|
const full = await api.get(`/artefacts/${piece.id}`)
|
||||||
@@ -173,21 +188,22 @@ export default function PostDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleCreate = async (type) => {
|
const handleCreate = async (type) => {
|
||||||
const label = type === 'caption' ? t('postDetail.captionCopy')
|
if (creating) return
|
||||||
: type === 'body' ? t('postDetail.bodyCopy')
|
setCreating(true)
|
||||||
: type === 'design' ? t('postDetail.design')
|
|
||||||
: t('postDetail.video')
|
|
||||||
try {
|
try {
|
||||||
const created = await api.post('/artefacts', {
|
const created = await api.post('/artefacts', {
|
||||||
title: `${label} — ${title}`,
|
title: title.trim() ? `${t(LABEL_KEYS[type])} — ${title.trim()}` : t(LABEL_KEYS[type]),
|
||||||
type: type === 'caption' || type === 'body' ? 'copy' : type,
|
type: type === 'caption' || type === 'body' ? 'copy' : type,
|
||||||
copy_type: type === 'caption' ? 'caption' : type === 'body' ? 'body' : undefined,
|
copy_type: type === 'caption' ? 'caption' : type === 'body' ? 'body' : undefined,
|
||||||
post_id: Number(id),
|
post_id: Number(id),
|
||||||
})
|
})
|
||||||
|
allArtefactsRef.current = null
|
||||||
setOpenArtefact(created)
|
setOpenArtefact(created)
|
||||||
loadComposition()
|
loadComposition()
|
||||||
} catch (err) {
|
} catch {
|
||||||
toast.error(t('common.saveFailed'))
|
toast.error(t('common.saveFailed'))
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,6 +240,16 @@ export default function PostDetail() {
|
|||||||
return (c.title || '').toLowerCase().includes(pickerSearch.toLowerCase())
|
return (c.title || '').toLowerCase().includes(pickerSearch.toLowerCase())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isDirty = Boolean(post) && (
|
||||||
|
title !== (post.title || '') ||
|
||||||
|
status !== (post.status || 'draft') ||
|
||||||
|
String(brandId) !== String(post.brand_id || post.brandId || '') ||
|
||||||
|
String(campaignId) !== String(post.campaign_id || post.campaignId || '') ||
|
||||||
|
String(assignedTo) !== String(post.assigned_to || post.assignedTo || '') ||
|
||||||
|
JSON.stringify(platforms) !== JSON.stringify(Array.isArray(post.platforms) ? post.platforms : (post.platform ? [post.platform] : [])) ||
|
||||||
|
scheduledDate !== ((post.scheduled_date || post.scheduledDate) ? new Date(post.scheduled_date || post.scheduledDate).toISOString().slice(0, 10) : '')
|
||||||
|
)
|
||||||
|
|
||||||
const waitingOn = composition?.waiting_on || []
|
const waitingOn = composition?.waiting_on || []
|
||||||
const piecesReady = composition?.pieces_ready || false
|
const piecesReady = composition?.pieces_ready || false
|
||||||
const hasPieces = composition?.caption || composition?.body_copy || composition?.design || composition?.video
|
const hasPieces = composition?.caption || composition?.body_copy || composition?.design || composition?.video
|
||||||
@@ -316,88 +342,42 @@ export default function PostDetail() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="flex items-center gap-1.5 px-4 py-1.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm disabled:opacity-50"
|
className={`flex items-center gap-1.5 px-4 py-1.5 rounded-lg text-sm font-medium shadow-sm disabled:opacity-50 transition-colors ${
|
||||||
|
isDirty ? 'bg-amber-500 hover:bg-amber-600 text-white' : 'bg-brand-primary hover:bg-brand-primary-light text-white'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<Save className="w-4 h-4" />
|
<Save className="w-4 h-4" />
|
||||||
{saving ? t('common.loading') : t('common.save')}
|
{saving ? t('common.loading') : t('common.save')}
|
||||||
|
{isDirty && !saving && <span className="w-1.5 h-1.5 rounded-full bg-white/70 ms-0.5" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ─── ASSET CARDS ─── */}
|
{/* ─── ASSET CARDS ─── */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<AssetCard
|
{ASSET_TYPES.map(type => (
|
||||||
type="caption"
|
<AssetCard
|
||||||
label={t('postDetail.captionCopy')}
|
key={type}
|
||||||
icon={Type}
|
id={`asset-${type}`}
|
||||||
piece={composition?.caption}
|
type={type}
|
||||||
onCreate={() => handleCreate('caption')}
|
label={t(LABEL_KEYS[type])}
|
||||||
onOpen={() => handleOpenPiece('caption')}
|
icon={ASSET_ICONS[type]}
|
||||||
onUnlink={() => handleUnlink('caption')}
|
piece={composition?.[PIECE_MAP[type]]}
|
||||||
onOpenPicker={() => openLinkPicker('caption')}
|
onCreate={() => handleCreate(type)}
|
||||||
activePicker={activePicker}
|
creating={creating}
|
||||||
pickerSearch={pickerSearch}
|
onOpen={() => handleOpenPiece(type)}
|
||||||
filteredCandidates={filteredCandidates}
|
onUnlink={() => handleUnlink(type)}
|
||||||
linking={linking}
|
onOpenPicker={() => openLinkPicker(type)}
|
||||||
onLink={handleLink}
|
activePicker={activePicker}
|
||||||
onPickerSearchChange={setPickerSearch}
|
pickerSearch={pickerSearch}
|
||||||
onClosePicker={() => setActivePicker(null)}
|
filteredCandidates={filteredCandidates}
|
||||||
t={t}
|
linking={linking}
|
||||||
/>
|
onLink={handleLink}
|
||||||
<AssetCard
|
onPickerSearchChange={setPickerSearch}
|
||||||
type="body"
|
onClosePicker={() => setActivePicker(null)}
|
||||||
label={t('postDetail.bodyCopy')}
|
t={t}
|
||||||
icon={FileText}
|
/>
|
||||||
piece={composition?.body_copy}
|
))}
|
||||||
onCreate={() => handleCreate('body')}
|
|
||||||
onOpen={() => handleOpenPiece('body')}
|
|
||||||
onUnlink={() => handleUnlink('body')}
|
|
||||||
onOpenPicker={() => openLinkPicker('body')}
|
|
||||||
activePicker={activePicker}
|
|
||||||
pickerSearch={pickerSearch}
|
|
||||||
filteredCandidates={filteredCandidates}
|
|
||||||
linking={linking}
|
|
||||||
onLink={handleLink}
|
|
||||||
onPickerSearchChange={setPickerSearch}
|
|
||||||
onClosePicker={() => setActivePicker(null)}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
<AssetCard
|
|
||||||
type="design"
|
|
||||||
label={t('postDetail.design')}
|
|
||||||
icon={ImageIcon}
|
|
||||||
piece={composition?.design}
|
|
||||||
onCreate={() => handleCreate('design')}
|
|
||||||
onOpen={() => handleOpenPiece('design')}
|
|
||||||
onUnlink={() => handleUnlink('design')}
|
|
||||||
onOpenPicker={() => openLinkPicker('design')}
|
|
||||||
activePicker={activePicker}
|
|
||||||
pickerSearch={pickerSearch}
|
|
||||||
filteredCandidates={filteredCandidates}
|
|
||||||
linking={linking}
|
|
||||||
onLink={handleLink}
|
|
||||||
onPickerSearchChange={setPickerSearch}
|
|
||||||
onClosePicker={() => setActivePicker(null)}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
<AssetCard
|
|
||||||
type="video"
|
|
||||||
label={t('postDetail.video')}
|
|
||||||
icon={Film}
|
|
||||||
piece={composition?.video}
|
|
||||||
onCreate={() => handleCreate('video')}
|
|
||||||
onOpen={() => handleOpenPiece('video')}
|
|
||||||
onUnlink={() => handleUnlink('video')}
|
|
||||||
onOpenPicker={() => openLinkPicker('video')}
|
|
||||||
activePicker={activePicker}
|
|
||||||
pickerSearch={pickerSearch}
|
|
||||||
filteredCandidates={filteredCandidates}
|
|
||||||
linking={linking}
|
|
||||||
onLink={handleLink}
|
|
||||||
onPickerSearchChange={setPickerSearch}
|
|
||||||
onClosePicker={() => setActivePicker(null)}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ─── READINESS ─── */}
|
{/* ─── READINESS ─── */}
|
||||||
@@ -411,9 +391,23 @@ export default function PostDetail() {
|
|||||||
<span className="text-sm font-medium">{t('postDetail.allPiecesApproved')}</span>
|
<span className="text-sm font-medium">{t('postDetail.allPiecesApproved')}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2 text-amber-600">
|
<div className="flex items-start gap-2 text-amber-600">
|
||||||
<Clock className="w-5 h-5" />
|
<Clock className="w-5 h-5 shrink-0 mt-0.5" />
|
||||||
<span className="text-sm font-medium">{t('postDetail.waitingOn')}: {waitingOn.join(', ')}</span>
|
<div className="flex flex-wrap gap-1.5 items-center">
|
||||||
|
<span className="text-sm font-medium">{t('postDetail.waitingOn')}:</span>
|
||||||
|
{waitingOn.map(label => {
|
||||||
|
const type = WAITING_TYPE_MAP[label]
|
||||||
|
return type ? (
|
||||||
|
<button
|
||||||
|
key={label}
|
||||||
|
onClick={() => document.getElementById(`asset-${type}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })}
|
||||||
|
className="text-xs px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 hover:bg-amber-200 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
) : <span key={label} className="text-sm">{label}</span>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -427,6 +421,7 @@ export default function PostDetail() {
|
|||||||
{/* ─── SUB-PANELS (they render their own SlidePanel internally) ─── */}
|
{/* ─── SUB-PANELS (they render their own SlidePanel internally) ─── */}
|
||||||
{openArtefact && (
|
{openArtefact && (
|
||||||
<ArtefactDetailPanel
|
<ArtefactDetailPanel
|
||||||
|
key={openArtefact._id}
|
||||||
artefact={openArtefact}
|
artefact={openArtefact}
|
||||||
onClose={() => { setOpenArtefact(null); loadComposition() }}
|
onClose={() => { setOpenArtefact(null); loadComposition() }}
|
||||||
onUpdate={loadComposition}
|
onUpdate={loadComposition}
|
||||||
@@ -441,8 +436,8 @@ export default function PostDetail() {
|
|||||||
// ─── Asset Card Component ───
|
// ─── Asset Card Component ───
|
||||||
|
|
||||||
function AssetCard({
|
function AssetCard({
|
||||||
type, label, icon: Icon, piece,
|
id, type, label, icon: Icon, piece,
|
||||||
onCreate, onOpen, onUnlink,
|
onCreate, creating, onOpen, onUnlink,
|
||||||
onOpenPicker, activePicker, pickerSearch, filteredCandidates, linking,
|
onOpenPicker, activePicker, pickerSearch, filteredCandidates, linking,
|
||||||
onLink, onPickerSearchChange, onClosePicker, t,
|
onLink, onPickerSearchChange, onClosePicker, t,
|
||||||
}) {
|
}) {
|
||||||
@@ -453,7 +448,7 @@ function AssetCard({
|
|||||||
const isApproved = piece?.status === 'approved'
|
const isApproved = piece?.status === 'approved'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-surface rounded-xl border border-border p-4 flex flex-col">
|
<div id={id} className="bg-surface rounded-xl border border-border p-4 flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<Icon className="w-4 h-4 text-text-tertiary" />
|
<Icon className="w-4 h-4 text-text-tertiary" />
|
||||||
@@ -561,10 +556,11 @@ function AssetCard({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onCreate}
|
onClick={onCreate}
|
||||||
className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-brand-primary hover:bg-brand-primary/10 rounded-lg transition-colors"
|
disabled={creating}
|
||||||
|
className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-brand-primary hover:bg-brand-primary/10 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<Plus className="w-3.5 h-3.5" />
|
<Plus className="w-3.5 h-3.5" />
|
||||||
{t('postDetail.createNew')}
|
{creating ? t('common.loading') : t('postDetail.createNew')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+27
-12
@@ -40,29 +40,44 @@ function buildWhere(conditions) {
|
|||||||
.join('~and');
|
.join('~and');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REQUEST_TIMEOUT_MS = 20_000;
|
||||||
|
|
||||||
async function request(method, url, body) {
|
async function request(method, url, body) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||||
|
|
||||||
const opts = {
|
const opts = {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
'xc-token': NOCODB_TOKEN,
|
'xc-token': NOCODB_TOKEN,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
|
signal: controller.signal,
|
||||||
};
|
};
|
||||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||||
|
|
||||||
const res = await fetch(url, opts);
|
try {
|
||||||
if (!res.ok) {
|
const res = await fetch(url, opts);
|
||||||
let details;
|
clearTimeout(timer);
|
||||||
try { details = await res.json(); } catch {}
|
if (!res.ok) {
|
||||||
throw new NocoDBError(
|
let details;
|
||||||
`NocoDB ${method} ${url} failed: ${res.status}`,
|
try { details = await res.json(); } catch {}
|
||||||
res.status,
|
throw new NocoDBError(
|
||||||
details
|
`NocoDB ${method} ${url} failed: ${res.status}`,
|
||||||
);
|
res.status,
|
||||||
|
details
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// DELETE returns empty or {msg}
|
||||||
|
const text = await res.text();
|
||||||
|
return text ? JSON.parse(text) : {};
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
throw new NocoDBError(`NocoDB ${method} ${url} timed out after ${REQUEST_TIMEOUT_MS}ms`, 408);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
// DELETE returns empty or {msg}
|
|
||||||
const text = await res.text();
|
|
||||||
return text ? JSON.parse(text) : {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Link Resolution ─────────────────────────────────────────
|
// ─── Link Resolution ─────────────────────────────────────────
|
||||||
|
|||||||
+103
-5
@@ -591,12 +591,17 @@ async function ensureFKColumns() {
|
|||||||
|
|
||||||
for (const col of columns) {
|
for (const col of columns) {
|
||||||
if (!existingCols.has(col)) {
|
if (!existingCols.has(col)) {
|
||||||
console.log(` Adding column ${table}.${col}...`);
|
console.log(` Adding FK column ${table}.${col}...`);
|
||||||
await fetch(`${nocodb.url}/api/v2/meta/tables/${tableId}/columns`, {
|
const colRes = await fetch(`${nocodb.url}/api/v2/meta/tables/${tableId}/columns`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'xc-token': nocodb.token, 'Content-Type': 'application/json' },
|
headers: { 'xc-token': nocodb.token, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ title: col, uidt: 'Number' }),
|
body: JSON.stringify({ title: col, uidt: 'Number' }),
|
||||||
});
|
});
|
||||||
|
if (colRes.ok) {
|
||||||
|
console.log(` ✓ Created ${table}.${col}`);
|
||||||
|
} else {
|
||||||
|
console.warn(` ⚠ Failed to create ${table}.${col}: ${colRes.status}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -668,6 +673,28 @@ app.get('/api/health', async (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── EMAIL TEST ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.post('/api/admin/test-email', requireAuth, async (req, res) => {
|
||||||
|
if (req.session.userRole !== 'superadmin') return res.status(403).json({ error: 'Superadmin only' });
|
||||||
|
const to = req.body.to || req.session.userEmail;
|
||||||
|
if (!to) return res.status(400).json({ error: 'No recipient — pass { "to": "email@example.com" }' });
|
||||||
|
const { sendMail, getSmtpConfig } = require('./mail');
|
||||||
|
const config = getSmtpConfig();
|
||||||
|
if (!config) return res.status(503).json({ error: 'SMTP not configured', env: { server: !!process.env.CLOUDRON_MAIL_SMTP_SERVER || !!process.env.MAIL_SMTP_SERVER } });
|
||||||
|
try {
|
||||||
|
const info = await sendMail({
|
||||||
|
to,
|
||||||
|
subject: 'Rawaj — Test Email',
|
||||||
|
html: '<p>If you received this, email delivery is working correctly.</p>',
|
||||||
|
text: 'If you received this, email delivery is working correctly.',
|
||||||
|
});
|
||||||
|
res.json({ success: true, to, messageId: info?.messageId, smtp: { host: config.host, port: config.port, from: config.from } });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ success: false, error: err.message, code: err.code, smtp: { host: config.host, port: config.port } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ─── SETUP ROUTES ───────────────────────────────────────────────
|
// ─── SETUP ROUTES ───────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/setup/status', async (req, res) => {
|
app.get('/api/setup/status', async (req, res) => {
|
||||||
@@ -4007,7 +4034,7 @@ app.get('/api/artefacts/:id', requireAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/artefacts', requireAuth, async (req, res) => {
|
app.post('/api/artefacts', requireAuth, async (req, res) => {
|
||||||
const { title, description, type, brand_id, content, project_id, campaign_id, approver_ids, copy_type } = req.body;
|
const { title, description, type, brand_id, content, project_id, campaign_id, approver_ids, copy_type, post_id } = req.body;
|
||||||
if (!title) return res.status(400).json({ error: 'Title is required' });
|
if (!title) return res.status(400).json({ error: 'Title is required' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -4018,6 +4045,7 @@ app.post('/api/artefacts', requireAuth, async (req, res) => {
|
|||||||
status: 'draft',
|
status: 'draft',
|
||||||
content: content || null,
|
content: content || null,
|
||||||
brand_id: brand_id ? Number(brand_id) : null,
|
brand_id: brand_id ? Number(brand_id) : null,
|
||||||
|
post_id: post_id ? Number(post_id) : null,
|
||||||
project_id: project_id ? Number(project_id) : null,
|
project_id: project_id ? Number(project_id) : null,
|
||||||
campaign_id: campaign_id ? Number(campaign_id) : null,
|
campaign_id: campaign_id ? Number(campaign_id) : null,
|
||||||
approver_ids: approver_ids || null,
|
approver_ids: approver_ids || null,
|
||||||
@@ -4025,7 +4053,7 @@ app.post('/api/artefacts', requireAuth, async (req, res) => {
|
|||||||
created_by_user_id: req.session.userId,
|
created_by_user_id: req.session.userId,
|
||||||
current_version: 1,
|
current_version: 1,
|
||||||
};
|
};
|
||||||
console.log('[POST /artefacts] Creating with:', JSON.stringify({ approver_ids: createData.approver_ids, project_id: createData.project_id, campaign_id: createData.campaign_id }));
|
console.log('[POST /artefacts] Creating with:', JSON.stringify({ post_id: createData.post_id, type: createData.type, copy_type: createData.copy_type, approver_ids: createData.approver_ids }));
|
||||||
const created = await nocodb.create('Artefacts', createData);
|
const created = await nocodb.create('Artefacts', createData);
|
||||||
console.log('[POST /artefacts] NocoDB returned:', JSON.stringify({ Id: created.Id, approver_ids: created.approver_ids }));
|
console.log('[POST /artefacts] NocoDB returned:', JSON.stringify({ Id: created.Id, approver_ids: created.approver_ids }));
|
||||||
|
|
||||||
@@ -4039,7 +4067,7 @@ app.post('/api/artefacts', requireAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const artefact = await nocodb.get('Artefacts', created.Id);
|
const artefact = await nocodb.get('Artefacts', created.Id);
|
||||||
console.log('[POST /artefacts] After re-read:', JSON.stringify({ Id: artefact.Id, approver_ids: artefact.approver_ids }));
|
console.log('[POST /artefacts] After re-read:', JSON.stringify({ Id: artefact.Id, post_id: artefact.post_id, copy_type: artefact.copy_type, approver_ids: artefact.approver_ids }));
|
||||||
const approverIdList = artefact.approver_ids ? artefact.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
|
const approverIdList = artefact.approver_ids ? artefact.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
|
||||||
const approvers = [];
|
const approvers = [];
|
||||||
for (const id of approverIdList) {
|
for (const id of approverIdList) {
|
||||||
@@ -4267,6 +4295,45 @@ app.post('/api/artefacts/:id/link-post', requireAuth, async (req, res) => {
|
|||||||
|
|
||||||
// ─── ARTEFACT VERSIONS ──────────────────────────────────────────
|
// ─── ARTEFACT VERSIONS ──────────────────────────────────────────
|
||||||
|
|
||||||
|
// Creates the next working version automatically after rejection/revision.
|
||||||
|
// For copy artefacts, texts are copied from the last version so the creator
|
||||||
|
// doesn't start with a blank slate.
|
||||||
|
async function autoAdvanceArtefactVersion(artefact, nocodb, QUERY_LIMITS) {
|
||||||
|
const versions = await nocodb.list('ArtefactVersions', {
|
||||||
|
where: `(artefact_id,eq,${artefact.Id})`,
|
||||||
|
sort: '-version_number',
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
const latest = versions[0];
|
||||||
|
if (!latest) return;
|
||||||
|
|
||||||
|
const nextNumber = latest.version_number + 1;
|
||||||
|
|
||||||
|
const created = await nocodb.create('ArtefactVersions', {
|
||||||
|
artefact_id: artefact.Id,
|
||||||
|
version_number: nextNumber,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
notes: `Round ${nextNumber}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (artefact.type === 'copy') {
|
||||||
|
const prevTexts = await nocodb.list('ArtefactVersionTexts', {
|
||||||
|
where: `(version_id,eq,${latest.Id})`,
|
||||||
|
limit: QUERY_LIMITS.large,
|
||||||
|
});
|
||||||
|
for (const text of prevTexts) {
|
||||||
|
await nocodb.create('ArtefactVersionTexts', {
|
||||||
|
version_id: created.Id,
|
||||||
|
language_code: text.language_code,
|
||||||
|
language_label: text.language_label,
|
||||||
|
content: text.content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await nocodb.update('Artefacts', artefact.Id, { current_version: nextNumber });
|
||||||
|
}
|
||||||
|
|
||||||
// List all versions for an artefact
|
// List all versions for an artefact
|
||||||
app.get('/api/artefacts/:id/versions', requireAuth, async (req, res) => {
|
app.get('/api/artefacts/:id/versions', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -4466,6 +4533,31 @@ app.post('/api/artefacts/:id/versions/:versionId/texts', requireAuth, async (req
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update language entry content
|
||||||
|
app.patch('/api/artefact-version-texts/:id', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const text = await nocodb.get('ArtefactVersionTexts', req.params.id);
|
||||||
|
if (!text) return res.status(404).json({ error: 'Text not found' });
|
||||||
|
|
||||||
|
const version = await nocodb.get('ArtefactVersions', text.version_id);
|
||||||
|
const artefact = await nocodb.get('Artefacts', version.artefact_id);
|
||||||
|
|
||||||
|
if (req.session.userRole === 'contributor' && artefact.created_by_user_id !== req.session.userId) {
|
||||||
|
return res.status(403).json({ error: 'You can only manage texts for your own artefacts' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { content } = req.body;
|
||||||
|
if (content === undefined) return res.status(400).json({ error: 'content is required' });
|
||||||
|
|
||||||
|
await nocodb.update('ArtefactVersionTexts', req.params.id, { content });
|
||||||
|
const updated = await nocodb.get('ArtefactVersionTexts', req.params.id);
|
||||||
|
res.json(updated);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Update text error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to update text' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Delete language entry
|
// Delete language entry
|
||||||
app.delete('/api/artefact-version-texts/:id', requireAuth, async (req, res) => {
|
app.delete('/api/artefact-version-texts/:id', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -4823,6 +4915,9 @@ app.post('/api/public/review/:token/reject', async (req, res) => {
|
|||||||
feedback: feedback || '',
|
feedback: feedback || '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-advance to next working version
|
||||||
|
await autoAdvanceArtefactVersion(artefact, nocodb, QUERY_LIMITS);
|
||||||
|
|
||||||
res.json({ success: true, message: 'Artefact rejected' });
|
res.json({ success: true, message: 'Artefact rejected' });
|
||||||
notify.notifyRejected({ type: 'artefact', record: artefact, approverName: approved_by_name, feedback });
|
notify.notifyRejected({ type: 'artefact', record: artefact, approverName: approved_by_name, feedback });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -4853,6 +4948,9 @@ app.post('/api/public/review/:token/revision', async (req, res) => {
|
|||||||
feedback: feedback || '',
|
feedback: feedback || '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-advance to next working version
|
||||||
|
await autoAdvanceArtefactVersion(artefact, nocodb, QUERY_LIMITS);
|
||||||
|
|
||||||
res.json({ success: true, message: 'Revision requested' });
|
res.json({ success: true, message: 'Revision requested' });
|
||||||
notify.notifyRevisionRequested({ record: artefact, approverName: approved_by_name, feedback });
|
notify.notifyRevisionRequested({ record: artefact, approverName: approved_by_name, feedback });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
Reference in New Issue
Block a user