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 = [];