diff --git a/.playwright-mcp/console-2026-03-16T09-27-00-418Z.log b/.playwright-mcp/console-2026-03-16T09-27-00-418Z.log index f7b0012..a2af043 100644 --- a/.playwright-mcp/console-2026-03-16T09-27-00-418Z.log +++ b/.playwright-mcp/console-2026-03-16T09-27-00-418Z.log @@ -26,3 +26,36 @@ at renderRootSync (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:12408:13) at performWorkOnRoot (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11827:37) {componentStack: at ArtefactDetailVersionsTab (http://localhos…vite/deps/react-router-dom.js?v=50a373cd:10250:3)} @ http://localhost:5173/src/components/ErrorBoundary.jsx:12 +[ 7975521ms] [ERROR] Failed to load team: TypeError: Failed to fetch + at Object.get (http://localhost:5173/src/utils/api.js:59:18) + at loadTeam (http://localhost:5173/src/App.jsx?t=1773661195572:114:30) + at loadInitialData (http://localhost:5173/src/App.jsx?t=1773661195572:143:11) + at http://localhost:5173/src/App.jsx?t=1773661195572:93:7 + at Object.react_stack_bottom_frame (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:18567:20) + at runWithFiberInDEV (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:997:72) + at commitHookEffectListMount (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:9411:163) + at commitHookPassiveMountEffects (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:9465:60) + at commitPassiveMountOnFiber (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11040:29) + at recursivelyTraversePassiveMountEffects (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11010:13) @ http://localhost:5173/src/App.jsx?t=1773661195572:118 +[ 7975522ms] [ERROR] Failed to load teams: TypeError: Failed to fetch + at Object.get (http://localhost:5173/src/utils/api.js:59:18) + at loadTeams (http://localhost:5173/src/App.jsx?t=1773661195572:125:30) + at loadInitialData (http://localhost:5173/src/App.jsx?t=1773661195572:145:11) + at http://localhost:5173/src/App.jsx?t=1773661195572:93:7 + at Object.react_stack_bottom_frame (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:18567:20) + at runWithFiberInDEV (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:997:72) + at commitHookEffectListMount (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:9411:163) + at commitHookPassiveMountEffects (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:9465:60) + at commitPassiveMountOnFiber (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11040:29) + at recursivelyTraversePassiveMountEffects (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11010:13) @ http://localhost:5173/src/App.jsx?t=1773661195572:127 +[ 7975522ms] [ERROR] Failed to load roles: TypeError: Failed to fetch + at Object.get (http://localhost:5173/src/utils/api.js:59:18) + at loadRoles (http://localhost:5173/src/App.jsx?t=1773661195572:133:30) + at loadInitialData (http://localhost:5173/src/App.jsx?t=1773661195572:146:11) + at http://localhost:5173/src/App.jsx?t=1773661195572:93:7 + at Object.react_stack_bottom_frame (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:18567:20) + at runWithFiberInDEV (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:997:72) + at commitHookEffectListMount (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:9411:163) + at commitHookPassiveMountEffects (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:9465:60) + at commitPassiveMountOnFiber (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11040:29) + at recursivelyTraversePassiveMountEffects (http://localhost:5173/node_modules/.vite/deps/react-dom_client.js?v=50a373cd:11010:13) @ http://localhost:5173/src/App.jsx?t=1773661195572:135 diff --git a/client/src/i18n/ar.json b/client/src/i18n/ar.json index 1a78aa5..202d712 100644 --- a/client/src/i18n/ar.json +++ b/client/src/i18n/ar.json @@ -1185,6 +1185,20 @@ "postDetail.createNew": "إنشاء جديد", "postDetail.open": "فتح", "postDetail.unlink": "إلغاء الربط", + "postDetail.viewDetails": "عرض التفاصيل", + "postDetail.reviewer": "المراجع", + "postDetail.selectReviewer": "اختر المراجع", + "postDetail.submitForReview": "إرسال للمراجعة", + "postDetail.pendingReviewBy": "بانتظار مراجعة", + "postDetail.approved": "تمت الموافقة", + "postDetail.sourceLanguage": "اللغة المصدر", + "postDetail.content": "المحتوى", + "postDetail.contentPlaceholder": "اكتب النص...", + "postDetail.files": "الملفات", + "postDetail.dragDropFiles": "اسحب وأفلت أو انقر للرفع", + "postDetail.addMoreFiles": "إضافة ملفات أخرى", + "postDetail.createAndSubmit": "إنشاء وإرسال للمراجعة", + "postDetail.create": "إنشاء", "finance.campaign": "الحملة", "finance.budgetAssigned": "الميزانية المخصصة", "finance.trackAllocated": "المسار المخصص", diff --git a/client/src/i18n/en.json b/client/src/i18n/en.json index ec3bdb6..90e12fc 100644 --- a/client/src/i18n/en.json +++ b/client/src/i18n/en.json @@ -1185,6 +1185,20 @@ "postDetail.createNew": "Create new", "postDetail.open": "Open", "postDetail.unlink": "Unlink", + "postDetail.viewDetails": "View details", + "postDetail.reviewer": "Reviewer", + "postDetail.selectReviewer": "Select reviewer", + "postDetail.submitForReview": "Submit for Review", + "postDetail.pendingReviewBy": "Pending review by", + "postDetail.approved": "Approved", + "postDetail.sourceLanguage": "Source Language", + "postDetail.content": "Content", + "postDetail.contentPlaceholder": "Write the copy text...", + "postDetail.files": "Files", + "postDetail.dragDropFiles": "Drag & drop or click to upload", + "postDetail.addMoreFiles": "Add more files", + "postDetail.createAndSubmit": "Create & Submit for Review", + "postDetail.create": "Create", "finance.campaign": "Campaign", "finance.budgetAssigned": "Budget Assigned", "finance.trackAllocated": "Track Allocated", diff --git a/client/src/pages/PostDetail.jsx b/client/src/pages/PostDetail.jsx index 5ba5578..7d05b67 100644 --- a/client/src/pages/PostDetail.jsx +++ b/client/src/pages/PostDetail.jsx @@ -1,6 +1,6 @@ import { useState, useEffect, useContext, useCallback } from 'react' import { useParams, useNavigate } from 'react-router-dom' -import { ArrowLeft, Save, FileText, Image as ImageIcon, Film, Type, Search, Link2, Unlink, Plus, CheckCircle, Clock, X } 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 { useAuth } from '../contexts/AuthContext' import { useLanguage } from '../i18n/LanguageContext' @@ -172,46 +172,6 @@ export default function PostDetail() { } } - const handleCreate = async (type) => { - try { - if (type === 'caption') { - await api.post('/translations', { - post_id: Number(id), - copy_type: 'caption', - title: (title || 'Post') + ' - Caption', - source_language: 'EN', - source_content: ' ', - }) - } else if (type === 'body') { - await api.post('/translations', { - post_id: Number(id), - copy_type: 'body', - title: (title || 'Post') + ' - Copy', - source_language: 'EN', - source_content: ' ', - }) - } else if (type === 'design') { - await api.post('/artefacts', { - post_id: Number(id), - type: 'design', - title: (title || 'Post') + ' - Design', - status: 'draft', - }) - } else if (type === 'video') { - await api.post('/artefacts', { - post_id: Number(id), - type: 'video', - title: (title || 'Post') + ' - Video', - status: 'draft', - }) - } - toast.success(t('posts.created')) - loadComposition() - } catch { - toast.error(t('common.saveFailed')) - } - } - const handleOpenPiece = async (type) => { const piece = type === 'caption' ? composition?.caption : type === 'body' ? composition?.body_copy @@ -231,6 +191,32 @@ export default function PostDetail() { } } + const handleCreate = async (type) => { + const isCopy = type === 'caption' || type === 'body' + 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) + } + loadComposition() + } catch (err) { + toast.error(t('common.saveFailed')) + } + } + // ─── Rendering ─── if (loading) { @@ -371,14 +357,14 @@ export default function PostDetail() { label={t('postDetail.captionCopy')} icon={Type} piece={composition?.caption} + onCreate={() => handleCreate('caption')} + onOpen={() => handleOpenPiece('caption')} + onUnlink={() => handleUnlink('caption')} + onOpenPicker={() => openLinkPicker('caption')} activePicker={activePicker} pickerSearch={pickerSearch} filteredCandidates={filteredCandidates} linking={linking} - onOpen={() => handleOpenPiece('caption')} - onUnlink={() => handleUnlink('caption')} - onOpenPicker={() => openLinkPicker('caption')} - onCreate={() => handleCreate('caption')} onLink={handleLink} onPickerSearchChange={setPickerSearch} onClosePicker={() => setActivePicker(null)} @@ -389,14 +375,14 @@ export default function PostDetail() { label={t('postDetail.bodyCopy')} 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} - onOpen={() => handleOpenPiece('body')} - onUnlink={() => handleUnlink('body')} - onOpenPicker={() => openLinkPicker('body')} - onCreate={() => handleCreate('body')} onLink={handleLink} onPickerSearchChange={setPickerSearch} onClosePicker={() => setActivePicker(null)} @@ -407,14 +393,14 @@ export default function PostDetail() { 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} - onOpen={() => handleOpenPiece('design')} - onUnlink={() => handleUnlink('design')} - onOpenPicker={() => openLinkPicker('design')} - onCreate={() => handleCreate('design')} onLink={handleLink} onPickerSearchChange={setPickerSearch} onClosePicker={() => setActivePicker(null)} @@ -425,14 +411,14 @@ export default function PostDetail() { 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} - onOpen={() => handleOpenPiece('video')} - onUnlink={() => handleUnlink('video')} - onOpenPicker={() => openLinkPicker('video')} - onCreate={() => handleCreate('video')} onLink={handleLink} onPickerSearchChange={setPickerSearch} onClosePicker={() => setActivePicker(null)} @@ -494,21 +480,26 @@ export default function PostDetail() { function AssetCard({ type, label, icon: Icon, piece, - activePicker, pickerSearch, filteredCandidates, linking, - onOpen, onUnlink, onOpenPicker, onCreate, onLink, - onPickerSearchChange, onClosePicker, t, + onCreate, onOpen, onUnlink, + onOpenPicker, activePicker, pickerSearch, filteredCandidates, linking, + onLink, onPickerSearchChange, onClosePicker, t, }) { const isPickerOpen = activePicker === type const isCopy = type === 'caption' || type === 'body' + const isPending = piece?.status === 'pending_review' + const isApproved = piece?.status === 'approved' + return (
{piece.content_preview}
)} @@ -553,14 +544,32 @@ function AssetCard({ {!isCopy && piece.current_version && (v{piece.current_version}
)} + + {/* Approval info */} +
+
+
{t('postDetail.notLinked')}
@@ -597,7 +609,7 @@ function AssetCard({ > )} - {/* Inline picker */} + {/* Inline link picker */} {isPickerOpen && ({c.source_language && {c.source_language} · } {(c.source_content || '').slice(0, 60)}
)} - {/* Artefact: show type */} {!isCopy && c.type && ({c.type}
)} diff --git a/server/post-composition.js b/server/post-composition.js index b648f6f..999c0d7 100644 --- a/server/post-composition.js +++ b/server/post-composition.js @@ -56,11 +56,29 @@ async function getPostComposition(postId) { video ? (video.thumbnail_url || getFirstAttachment(video.Id)) : null, ]); + // Resolve approver names for each piece + const resolveApprover = async (record) => { + if (!record || !record.approver_ids) return { approver_ids: null, approver_name: null }; + const ids = record.approver_ids.split(',').map(s => s.trim()).filter(Boolean); + if (ids.length === 0) return { approver_ids: null, approver_name: null }; + try { + const user = await nocodb.get('Users', Number(ids[0])); + return { approver_ids: record.approver_ids, approver_name: user ? (user.display_name || user.name || user.email) : null }; + } catch { return { approver_ids: record.approver_ids, approver_name: null }; } + }; + + const [captionApprover, bodyApprover, designApprover, videoApprover] = await Promise.all([ + resolveApprover(caption), + resolveApprover(bodyCopy), + resolveApprover(design), + resolveApprover(video), + ]); + 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 } : 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 } : null, - design: design ? { id: design.Id, title: design.title, status: design.status, thumbnail_url: designThumb, current_version: design.current_version } : null, - video: video ? { id: video.Id, title: video.title, status: video.status, thumbnail_url: videoThumb, current_version: video.current_version } : null, + 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, + 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, pieces_ready: piecesReady, waiting_on: waitingOn,