refactor: PostDetail cards as status dashboard, panel for creation

Reverted inline asset creation — the copy workflow (original → approve →
translate → approve translations) needs the full panel, not a card form.

PostDetail cards now:
- "Create new" → creates asset (type pre-set, post linked) → opens panel
- "Open" → opens panel for editing/reviewing
- Card shows: title, status, preview, approval info (status dashboard)

Panel handles: write copy, add translations, upload files, select reviewer,
submit for review — the full workflow in its proper workspace.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
fahed
2026-03-16 15:10:40 +03:00
parent 49e1a796ed
commit eb23931ce0
5 changed files with 164 additions and 76 deletions
+14
View File
@@ -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": "المسار المخصص",
+14
View File
@@ -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",
+81 -72
View File
@@ -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 (
<div className="bg-surface rounded-xl border border-border p-4 flex flex-col">
{/* Header */}
<div className="flex items-center gap-2 mb-3">
<Icon className="w-4 h-4 text-text-tertiary" />
<h4 className="text-xs uppercase tracking-wider text-text-tertiary font-medium">{label}</h4>
<h4 className="text-xs uppercase tracking-wider text-text-tertiary font-medium flex-1">{label}</h4>
</div>
{piece ? (
{/* ─── State 2: Linked ─── */}
{piece && (
<>
<div className="flex-1">
{/* Thumbnail for design/video */}
@@ -528,7 +519,7 @@ function AssetCard({
<StatusBadge status={piece.status} size="xs" />
</div>
{/* Copy: show content preview + languages */}
{/* Copy: content preview + languages */}
{isCopy && piece.content_preview && (
<p className="text-xs text-text-secondary mt-1 line-clamp-2">{piece.content_preview}</p>
)}
@@ -553,14 +544,32 @@ function AssetCard({
{!isCopy && piece.current_version && (
<p className="text-xs text-text-tertiary mt-1">v{piece.current_version}</p>
)}
{/* Approval info */}
<div className="mt-3 space-y-2">
{isPending && piece.approver_name && (
<p className="text-xs text-amber-600 flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" />
{t('postDetail.pendingReviewBy')} {piece.approver_name}
</p>
)}
{isApproved && (
<p className="text-xs text-emerald-600 flex items-center gap-1.5">
<CheckCircle className="w-3.5 h-3.5" />
{t('postDetail.approved')}{piece.approver_name ? `${piece.approver_name}` : ''}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border-light">
{/* Open + Unlink */}
<div className="flex items-center justify-between mt-3 pt-3 border-t border-border-light">
<button
onClick={onOpen}
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"
className="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium text-brand-primary hover:bg-brand-primary/10 rounded-lg transition-colors"
>
<Link2 className="w-3.5 h-3.5" />
{t('postDetail.open')}
<ExternalLink className="w-3.5 h-3.5" />
{t('postDetail.viewDetails')}
</button>
<button
onClick={onUnlink}
@@ -571,7 +580,10 @@ function AssetCard({
</button>
</div>
</>
) : (
)}
{/* ─── State 1: Empty (no asset) ─── */}
{!piece && (
<>
<div className="flex-1 flex items-center justify-center py-4">
<p className="text-sm text-text-tertiary">{t('postDetail.notLinked')}</p>
@@ -597,7 +609,7 @@ function AssetCard({
</>
)}
{/* Inline picker */}
{/* Inline link picker */}
{isPickerOpen && (
<div className="mt-3 pt-3 border-t border-border-light animate-fade-in">
<div className="flex items-center gap-2 mb-2">
@@ -627,7 +639,6 @@ function AssetCard({
disabled={linking}
className="w-full text-start px-2 py-2 text-xs rounded-lg hover:bg-surface-secondary transition-colors flex items-start gap-2 disabled:opacity-50"
>
{/* Thumbnail for artefacts */}
{!isCopy && (c.thumbnail_url || c.file_url) && (
<img src={c.thumbnail_url || c.file_url} alt="" className="w-10 h-10 rounded object-cover shrink-0" loading="lazy" />
)}
@@ -636,14 +647,12 @@ function AssetCard({
<span className="truncate text-text-primary font-medium">{c.title || t('common.untitled')}</span>
<StatusBadge status={c.status} size="xs" />
</div>
{/* Copy: show source language + content preview */}
{isCopy && (
<p className="text-text-tertiary mt-0.5 truncate">
{c.source_language && <span className="uppercase">{c.source_language} · </span>}
{(c.source_content || '').slice(0, 60)}
</p>
)}
{/* Artefact: show type */}
{!isCopy && c.type && (
<p className="text-text-tertiary mt-0.5 capitalize">{c.type}</p>
)}