diff --git a/client/src/components/ArtefactDetailVersionsTab.jsx b/client/src/components/ArtefactDetailVersionsTab.jsx
index 301e1ce..e5356f2 100644
--- a/client/src/components/ArtefactDetailVersionsTab.jsx
+++ b/client/src/components/ArtefactDetailVersionsTab.jsx
@@ -105,14 +105,26 @@ export function ArtefactDetailVersionsTab({
{t('artefacts.versions')}
-
+ {artefact.status === 'rejected' && (
+
+ )}
+ {artefact.status === 'rejected' && (
+
+ {t('artefacts.rejectedMustCreateNewVersion')}
+
+ )}
+ {artefact.status === 'revision_requested' && (
+
+ {t('artefacts.revisionEditCurrentVersion')}
+
+ )}
{
@@ -116,22 +114,17 @@ export default function PostDetail() {
setActivePicker(type)
setPickerSearch('')
try {
- if (type === 'caption' || type === 'body') {
- const all = await api.get('/translations')
- // Show all translations not already linked to THIS post
- setLinkCandidates((Array.isArray(all) ? all : []).filter(t => {
- const linkedTo = t.post_id || t.postId
- return !linkedTo || String(linkedTo) !== String(id)
- }))
- } else {
- const all = await api.get('/artefacts')
- const at = type === 'video' ? 'video' : 'design'
- setLinkCandidates((Array.isArray(all) ? all : []).filter(a => {
- const linkedTo = a.post_id || a.postId
- const matchesType = (a.type || 'design') === at
- return matchesType && (!linkedTo || String(linkedTo) !== String(id))
- }))
- }
+ const all = await api.get('/artefacts')
+ let typeFilter
+ if (type === 'caption') typeFilter = a => a.type === 'copy' && a.copy_type === 'caption'
+ 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
+ return typeFilter(a) && (!linkedTo || String(linkedTo) !== String(id))
+ }))
} catch {
setLinkCandidates([])
toast.error(t('common.error'))
@@ -141,12 +134,7 @@ export default function PostDetail() {
const handleLink = async (itemId) => {
setLinking(true)
try {
- const copyType = activePicker === 'caption' || activePicker === 'body' ? activePicker : null
- if (activePicker === 'caption' || activePicker === 'body') {
- await api.patch(`/translations/${itemId}`, { post_id: Number(id), copy_type: copyType })
- } else {
- await api.patch(`/artefacts/${itemId}`, { post_id: Number(id) })
- }
+ await api.patch(`/artefacts/${itemId}`, { post_id: Number(id) })
toast.success(t('posts.updated'))
setActivePicker(null)
loadComposition()
@@ -164,8 +152,7 @@ export default function PostDetail() {
: composition?.video
if (!piece) return
try {
- const endpoint = (type === 'caption' || type === 'body') ? `/translations/${piece.id}` : `/artefacts/${piece.id}`
- await api.patch(endpoint, { post_id: null })
+ await api.patch(`/artefacts/${piece.id}`, { post_id: null })
toast.success(t('posts.updated'))
loadComposition()
} catch {
@@ -179,39 +166,25 @@ export default function PostDetail() {
: type === 'design' ? composition?.design
: composition?.video
if (!piece) return
- if (type === 'caption' || type === 'body') {
- try {
- const full = await api.get(`/translations/${piece.id}`)
- setOpenTranslation(full)
- } catch { toast.error(t('common.saveFailed')) }
- } else {
- try {
- const full = await api.get(`/artefacts/${piece.id}`)
- setOpenArtefact(full)
- } catch { toast.error(t('common.saveFailed')) }
- }
+ try {
+ const full = await api.get(`/artefacts/${piece.id}`)
+ setOpenArtefact(full)
+ } catch { toast.error(t('common.saveFailed')) }
}
const handleCreate = async (type) => {
- const isCopy = type === 'caption' || type === 'body'
+ const label = type === 'caption' ? t('postDetail.captionCopy')
+ : type === 'body' ? t('postDetail.bodyCopy')
+ : type === 'design' ? t('postDetail.design')
+ : t('postDetail.video')
try {
- if (isCopy) {
- const created = await api.post('/translations', {
- title: `${title} — ${type === 'caption' ? t('postDetail.captionCopy') : t('postDetail.bodyCopy')}`,
- source_language: 'AR',
- source_content: '',
- post_id: Number(id),
- copy_type: type,
- })
- setOpenTranslation(created)
- } else {
- const created = await api.post('/artefacts', {
- title: `${type === 'design' ? t('postDetail.design') : t('postDetail.video')} — ${title}`,
- type: type,
- post_id: Number(id),
- })
- setOpenArtefact(created)
- }
+ const created = await api.post('/artefacts', {
+ title: `${label} — ${title}`,
+ type: type === 'caption' || type === 'body' ? 'copy' : type,
+ copy_type: type === 'caption' ? 'caption' : type === 'body' ? 'body' : undefined,
+ post_id: Number(id),
+ })
+ setOpenArtefact(created)
loadComposition()
} catch (err) {
toast.error(t('common.saveFailed'))
@@ -452,16 +425,6 @@ export default function PostDetail() {
{/* ─── SUB-PANELS (they render their own SlidePanel internally) ─── */}
- {openTranslation && (
- { setOpenTranslation(null); loadComposition() }}
- onUpdate={loadComposition}
- onDelete={() => { setOpenTranslation(null); loadComposition() }}
- assignableUsers={teamMembers}
- />
- )}
-
{openArtefact && (
t.copy_type === 'caption') || null;
- const bodyCopy = translations.find(t => t.copy_type === 'body' || !t.copy_type) || null;
-
const artefacts = await nocodb.list('Artefacts', {
where: `(post_id,eq,${postId})`, limit: 100,
});
+
+ const caption = artefacts.find(a => a.type === 'copy' && a.copy_type === 'caption') || null;
+ const bodyCopy = artefacts.find(a => a.type === 'copy' && (a.copy_type === 'body' || !a.copy_type)) || null;
const design = artefacts.find(a => (a.type || 'design') === 'design') || null;
const video = artefacts.find(a => a.type === 'video') || null;
@@ -28,16 +25,21 @@ async function getPostComposition(postId) {
const hasPieces = caption || bodyCopy || design || video;
const piecesReady = hasPieces && waitingOn.length === 0;
- // Get translation texts for languages preview
- const getTexts = async (translationId) => {
+ // Get texts from ArtefactVersionTexts for copy artefacts (content preview + languages)
+ const getTexts = async (artefactId) => {
try {
- const texts = await nocodb.list('TranslationTexts', { where: `(translation_id,eq,${translationId})`, limit: 20 });
- return texts.map(tt => ({ language: tt.language_code || tt.language, status: tt.status || 'draft' }));
- } catch { return []; }
+ const versions = await nocodb.list('ArtefactVersions', { where: `(artefact_id,eq,${artefactId})`, sort: '-version_number', limit: 1 });
+ if (versions.length === 0) return { texts: [], contentPreview: '' };
+ const texts = await nocodb.list('ArtefactVersionTexts', { where: `(version_id,eq,${versions[0].Id})`, limit: 20 });
+ const languages = texts.map(tt => ({ language: tt.language_code || tt.language, status: tt.status || 'draft' }));
+ const contentPreview = texts.length > 0 ? (texts[0].content || '').slice(0, 120) : '';
+ return { texts: languages, contentPreview };
+ } catch { return { texts: [], contentPreview: '' }; }
};
+
const [captionTexts, bodyTexts] = await Promise.all([
- caption ? getTexts(caption.Id) : [],
- bodyCopy ? getTexts(bodyCopy.Id) : [],
+ caption ? getTexts(caption.Id) : { texts: [], contentPreview: '' },
+ bodyCopy ? getTexts(bodyCopy.Id) : { texts: [], contentPreview: '' },
]);
// Get first attachment for design/video thumbnail
@@ -75,8 +77,8 @@ async function getPostComposition(postId) {
]);
return {
- caption: caption ? { id: caption.Id, title: caption.title, status: caption.status, language: caption.source_language, content_preview: (caption.source_content || '').slice(0, 120), languages: captionTexts, ...captionApprover } : null,
- body_copy: bodyCopy ? { id: bodyCopy.Id, title: bodyCopy.title, status: bodyCopy.status, language: bodyCopy.source_language, content_preview: (bodyCopy.source_content || '').slice(0, 120), languages: bodyTexts, ...bodyApprover } : null,
+ caption: caption ? { id: caption.Id, title: caption.title, status: caption.status, content_preview: captionTexts.contentPreview, languages: captionTexts.texts, ...captionApprover } : null,
+ body_copy: bodyCopy ? { id: bodyCopy.Id, title: bodyCopy.title, status: bodyCopy.status, content_preview: bodyTexts.contentPreview, languages: bodyTexts.texts, ...bodyApprover } : null,
design: design ? { id: design.Id, title: design.title, status: design.status, thumbnail_url: designThumb, current_version: design.current_version, ...designApprover } : null,
video: video ? { id: video.Id, title: video.title, status: video.status, thumbnail_url: videoThumb, current_version: video.current_version, ...videoApprover } : null,
platforms,
diff --git a/server/server.js b/server/server.js
index ef2fda2..f07c524 100644
--- a/server/server.js
+++ b/server/server.js
@@ -521,6 +521,7 @@ const TEXT_COLUMNS = {
Artefacts: [
{ name: 'approver_ids', uidt: 'SingleLineText' },
{ name: 'thumbnail_url', uidt: 'SingleLineText' },
+ { name: 'copy_type', uidt: 'SingleLineText' },
],
Posts: [
{ name: 'approver_ids', uidt: 'SingleLineText' },
@@ -4019,7 +4020,7 @@ app.get('/api/artefacts/:id', 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 } = req.body;
+ const { title, description, type, brand_id, content, project_id, campaign_id, approver_ids, copy_type } = req.body;
if (!title) return res.status(400).json({ error: 'Title is required' });
try {
@@ -4033,6 +4034,7 @@ app.post('/api/artefacts', requireAuth, async (req, res) => {
project_id: project_id ? Number(project_id) : null,
campaign_id: campaign_id ? Number(campaign_id) : null,
approver_ids: approver_ids || null,
+ copy_type: copy_type || null,
created_by_user_id: req.session.userId,
current_version: 1,
};
@@ -4112,7 +4114,7 @@ app.patch('/api/artefacts/:id', requireAuth, async (req, res) => {
}
const data = {};
- for (const f of ['title', 'description', 'type', 'status', 'content', 'feedback']) {
+ for (const f of ['title', 'description', 'type', 'status', 'content', 'feedback', 'copy_type']) {
if (req.body[f] !== undefined) data[f] = req.body[f];
}
if (req.body.brand_id !== undefined) data.brand_id = req.body.brand_id ? Number(req.body.brand_id) : null;
@@ -4311,12 +4313,45 @@ app.post('/api/artefacts/:id/versions', requireAuth, async (req, res) => {
return res.status(403).json({ error: 'You can only create versions for your own artefacts' });
}
- // Get current max version number
+ // Get current max version
const versions = await nocodb.list('ArtefactVersions', {
where: `(artefact_id,eq,${sanitizeWhereValue(req.params.id)})`,
sort: '-version_number',
limit: 1,
});
+
+ // Validate previous version before allowing new one
+ if (versions.length > 0) {
+ const prevVersion = versions[0];
+
+ // Check if previous version has content
+ if (artefact.type === 'design' || artefact.type === 'video') {
+ const attachments = await nocodb.list('ArtefactAttachments', {
+ where: `(version_id,eq,${prevVersion.Id})`, limit: 1,
+ });
+ if (attachments.length === 0) {
+ return res.status(400).json({ error: 'Upload content to the current version before creating a new one' });
+ }
+ } else if (artefact.type === 'copy') {
+ const texts = await nocodb.list('ArtefactVersionTexts', {
+ where: `(version_id,eq,${prevVersion.Id})`, limit: 1,
+ });
+ if (texts.length === 0) {
+ return res.status(400).json({ error: 'Add text to the current version before creating a new one' });
+ }
+ }
+
+ // Can't create new version if artefact hasn't been submitted for review yet
+ if (artefact.status === 'draft') {
+ return res.status(400).json({ error: 'Submit the current version for review before creating a new one' });
+ }
+
+ // If revision_requested, user should edit the current version, not create a new one
+ if (artefact.status === 'revision_requested') {
+ return res.status(400).json({ error: 'Edit the current version and resubmit instead of creating a new one' });
+ }
+ }
+
const newVersionNumber = versions.length > 0 ? versions[0].version_number + 1 : 1;
const created = await nocodb.create('ArtefactVersions', {
@@ -5084,21 +5119,6 @@ app.patch('/api/translations/:id', requireAuth, async (req, res) => {
await nocodb.update('Translations', req.params.id, data);
- // Auto-update linked post stage (both old and new post if post_id changed)
- const oldTransPostId = existing.post_id ? Number(existing.post_id) : null;
- const updated = await nocodb.get('Translations', Number(req.params.id));
- const newTransPostId = updated?.post_id ? Number(updated.post_id) : null;
- const transPostIds = [...new Set([oldTransPostId, newTransPostId].filter(Boolean))];
- for (const pid of transPostIds) {
- try {
- const { getPostComposition, computeStage } = require('./post-composition');
- const composition = await getPostComposition(pid);
- if (composition) {
- await nocodb.update('Posts', pid, { stage: computeStage(composition) });
- }
- } catch (e) { console.error('Post stage update error:', e); }
- }
-
const record = await nocodb.get('Translations', req.params.id);
const approverIdList = record.approver_ids ? record.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
const approvers = [];