feat: add Translation Management with approval workflow
Deploy / deploy (push) Successful in 12s

- New Translations + TranslationTexts NocoDB tables (auto-created on restart)
- Full CRUD: list, create, update, delete, bulk-delete translations
- Translation texts per language (add/edit/delete inline)
- Review flow: submit-review generates public token link
- Public review page: shows source + all translations, approve/reject/revision
- Email notifications to approvers (registered users)
- Sidebar nav under Marketing category
- Bilingual i18n (80+ keys in en.json and ar.json)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
fahed
2026-03-11 14:49:04 +03:00
parent 14751c42e4
commit b17108b321
9 changed files with 1962 additions and 11 deletions
@@ -0,0 +1,558 @@
import { useState, useEffect, useContext } from 'react'
import { Plus, Copy, Check, ExternalLink, Trash2, Save, FileEdit, Languages, ShieldCheck, Globe } from 'lucide-react'
import { AppContext } from '../App'
import { useLanguage } from '../i18n/LanguageContext'
import { api } from '../utils/api'
import Modal from './Modal'
import TabbedModal from './TabbedModal'
import { useToast } from './ToastContainer'
import ApproverMultiSelect from './ApproverMultiSelect'
const STATUS_COLORS = {
draft: 'bg-surface-tertiary text-text-secondary',
pending_review: 'bg-amber-100 text-amber-700',
approved: 'bg-emerald-100 text-emerald-700',
rejected: 'bg-red-100 text-red-700',
revision_requested: 'bg-orange-100 text-orange-700',
}
const AVAILABLE_LANGUAGES = [
{ code: 'AR', label: 'العربية' },
{ code: 'EN', label: 'English' },
{ code: 'FR', label: 'Français' },
{ code: 'ID', label: 'Bahasa Indonesia' },
]
export default function TranslationDetailPanel({ translation, onClose, onUpdate, onDelete, assignableUsers = [] }) {
const { t } = useLanguage()
const { brands } = useContext(AppContext)
const toast = useToast()
const [editTitle, setEditTitle] = useState(translation.title || '')
const [editDescription, setEditDescription] = useState(translation.description || '')
const [editSourceContent, setEditSourceContent] = useState(translation.source_content || '')
const [editSourceLanguage, setEditSourceLanguage] = useState(translation.source_language || 'EN')
const [editApproverIds, setEditApproverIds] = useState(
translation.approvers?.map(a => String(a.id)) || (translation.approver_ids ? translation.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [])
)
const reviewUrl = translation.approval_token ? `${window.location.origin}/review-translation/${translation.approval_token}` : ''
const [activeTab, setActiveTab] = useState('details')
const [texts, setTexts] = useState([])
const [loading, setLoading] = useState(true)
const [savingDraft, setSavingDraft] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [copied, setCopied] = useState(false)
const [freshReviewUrl, setFreshReviewUrl] = useState('')
// Language add modal
const [showAddLang, setShowAddLang] = useState(false)
const [langForm, setLangForm] = useState({ language_code: '', content: '' })
const [savingLang, setSavingLang] = useState(false)
// Delete confirm
const [confirmDeleteTextId, setConfirmDeleteTextId] = useState(null)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
// Inline editing for translation texts
const [editingTextId, setEditingTextId] = useState(null)
const [editingContent, setEditingContent] = useState('')
useEffect(() => {
loadTexts()
}, [translation.Id])
useEffect(() => {
setEditTitle(translation.title || '')
setEditDescription(translation.description || '')
setEditSourceContent(translation.source_content || '')
setEditSourceLanguage(translation.source_language || 'EN')
setEditApproverIds(
translation.approvers?.map(a => String(a.id)) || (translation.approver_ids ? translation.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [])
)
}, [translation.Id])
const loadTexts = async () => {
try {
const res = await api.get(`/translations/${translation.Id}/texts`)
setTexts(Array.isArray(res) ? res : [])
} catch (err) {
console.error('Failed to load texts:', err)
} finally {
setLoading(false)
}
}
const handleSaveDraft = async () => {
if (!editTitle.trim()) {
toast.error(t('translations.titleRequired'))
return
}
setSavingDraft(true)
try {
await api.patch(`/translations/${translation.Id}`, {
title: editTitle,
description: editDescription,
source_content: editSourceContent,
source_language: editSourceLanguage,
approver_ids: editApproverIds.length > 0 ? editApproverIds.join(',') : null,
})
toast.success(t('translations.draftSaved'))
onUpdate()
} catch (err) {
toast.error(t('translations.failedSaveDraft'))
} finally {
setSavingDraft(false)
}
}
const handleFieldUpdate = async (field, value) => {
try {
await api.patch(`/translations/${translation.Id}`, { [field]: value || null })
toast.success(t('translations.updated'))
onUpdate()
} catch (err) {
toast.error(t('translations.failedUpdate'))
}
}
const handleAddTranslationText = async () => {
if (!langForm.language_code || !langForm.content) {
toast.error(t('translations.allFieldsRequired'))
return
}
setSavingLang(true)
try {
const lang = AVAILABLE_LANGUAGES.find(l => l.code === langForm.language_code)
await api.post(`/translations/${translation.Id}/texts`, {
language_code: langForm.language_code,
language_label: lang?.label || langForm.language_code,
content: langForm.content,
})
toast.success(t('translations.translationAdded'))
setShowAddLang(false)
setLangForm({ language_code: '', content: '' })
loadTexts()
} catch (err) {
toast.error(t('translations.failedAddTranslation'))
} finally {
setSavingLang(false)
}
}
const handleUpdateText = async (textId) => {
try {
const text = texts.find(t => t.Id === textId)
if (!text) return
await api.post(`/translations/${translation.Id}/texts`, {
language_code: text.language_code,
language_label: text.language_label,
content: editingContent,
})
toast.success(t('translations.updated'))
setEditingTextId(null)
loadTexts()
} catch (err) {
toast.error(t('translations.failedUpdate'))
}
}
const handleDeleteText = async (textId) => {
try {
await api.delete(`/translations/${translation.Id}/texts/${textId}`)
toast.success(t('translations.translationDeleted'))
loadTexts()
} catch (err) {
toast.error(t('translations.failedDeleteTranslation'))
}
}
const handleSubmitReview = async () => {
setSubmitting(true)
try {
const res = await api.post(`/translations/${translation.Id}/submit-review`)
setFreshReviewUrl(res.reviewUrl || res.data?.reviewUrl || '')
toast.success(t('translations.submittedForReview'))
onUpdate()
} catch (err) {
toast.error(t('translations.failedSubmitReview'))
} finally {
setSubmitting(false)
}
}
const copyReviewLink = () => {
const url = freshReviewUrl || reviewUrl
navigator.clipboard.writeText(url)
setCopied(true)
toast.success(t('translations.linkCopied'))
setTimeout(() => setCopied(false), 2000)
}
const handleDelete = async () => {
try {
await onDelete(translation.Id || translation.id || translation._id)
} catch (err) {
toast.error(t('translations.failedDelete'))
}
}
// Available languages for adding (exclude source + already added)
const usedCodes = new Set([translation.source_language, ...texts.map(t => t.language_code)])
const availableForAdd = AVAILABLE_LANGUAGES.filter(l => !usedCodes.has(l.code))
const tabs = [
{ key: 'details', label: t('translations.details'), icon: FileEdit },
{ key: 'translations', label: t('translations.translationTexts'), icon: Languages, badge: texts.length },
{ key: 'review', label: t('translations.review'), icon: ShieldCheck },
]
const currentReviewUrl = freshReviewUrl || reviewUrl
return (
<>
<TabbedModal
onClose={onClose}
size="xl"
header={
<>
<div className="flex items-center gap-3 mb-1">
<Languages className="w-5 h-5 text-brand-primary shrink-0" />
<input
type="text"
value={editTitle}
onChange={e => setEditTitle(e.target.value)}
className="text-lg font-bold text-text-primary bg-transparent border-none outline-none focus:ring-0 w-full"
placeholder={t('translations.titlePlaceholder')}
/>
</div>
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[translation.status] || 'bg-surface-tertiary text-text-secondary'}`}>
{translation.status?.replace('_', ' ')}
</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-50 text-blue-600 font-medium">
{AVAILABLE_LANGUAGES.find(l => l.code === editSourceLanguage)?.label || editSourceLanguage}
</span>
{translation.creator_name && (
<span className="text-xs text-text-tertiary">
{t('review.createdBy')} <strong className="text-text-primary">{translation.creator_name}</strong>
</span>
)}
</div>
</>
}
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
footer={
<div className="flex items-center gap-2 w-full justify-between">
<div className="flex items-center gap-2">
<button
onClick={() => setShowDeleteConfirm(true)}
className="p-2 rounded-lg text-red-500 hover:bg-red-50 transition-colors"
title={t('translations.deleteTranslation')}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<button
onClick={handleSaveDraft}
disabled={savingDraft}
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
title={t('translations.saveDraftTooltip')}
>
<Save className="w-4 h-4" />
{savingDraft ? t('translations.savingDraft') : t('translations.saveDraft')}
</button>
</div>
}
>
{/* Details Tab */}
{activeTab === 'details' && (
<div className="p-6 space-y-5">
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-2">{t('translations.sourceLanguage')}</h4>
<select
value={editSourceLanguage}
onChange={e => setEditSourceLanguage(e.target.value)}
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"
>
{AVAILABLE_LANGUAGES.map(l => <option key={l.code} value={l.code}>{l.label} ({l.code})</option>)}
</select>
</div>
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-2">{t('translations.sourceContent')}</h4>
<textarea
value={editSourceContent}
onChange={e => setEditSourceContent(e.target.value)}
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 min-h-[150px] resize-y"
placeholder={t('translations.sourceContentPlaceholder')}
/>
</div>
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-2">{t('translations.descriptionLabel')}</h4>
<textarea
value={editDescription}
onChange={e => setEditDescription(e.target.value)}
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 min-h-[80px] resize-y"
placeholder={t('translations.descriptionPlaceholder')}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('translations.brand')}</h4>
<select
value={translation.brand_id || ''}
onChange={e => handleFieldUpdate('brand_id', e.target.value)}
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"
>
<option value=""></option>
{brands.map(b => <option key={b._id} value={b._id}>{b.name}</option>)}
</select>
</div>
</div>
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('translations.approversLabel')}</h4>
<ApproverMultiSelect
selected={editApproverIds}
onChange={setEditApproverIds}
users={assignableUsers}
/>
</div>
</div>
)}
{/* Translations Tab */}
{activeTab === 'translations' && (
<div className="p-6 space-y-5">
{/* Source content reference */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<Globe className="w-4 h-4 text-blue-600" />
<h4 className="text-sm font-semibold text-blue-900">
{t('translations.sourceContent')} {AVAILABLE_LANGUAGES.find(l => l.code === translation.source_language)?.label || translation.source_language}
</h4>
</div>
<p className="text-sm text-blue-800 whitespace-pre-wrap">{translation.source_content}</p>
</div>
{/* Add translation button */}
<div className="flex items-center justify-between">
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('translations.translationTexts')}</h4>
{availableForAdd.length > 0 && (
<button
onClick={() => setShowAddLang(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"
>
<Plus className="w-3 h-3" />
{t('translations.addTranslation')}
</button>
)}
</div>
{/* Translation texts list */}
{texts.length > 0 ? (
<div className="space-y-3">
{texts.map(text => (
<div key={text.Id} className="bg-surface-secondary rounded-lg p-4 border border-border">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-text-primary">
{text.language_label || text.language_code}
<span className="text-xs text-text-tertiary ml-1">({text.language_code})</span>
</span>
<div className="flex items-center gap-1">
{editingTextId === text.Id ? (
<>
<button
onClick={() => handleUpdateText(text.Id)}
className="text-emerald-600 hover:text-emerald-700 p-1"
>
<Check className="w-4 h-4" />
</button>
<button
onClick={() => setEditingTextId(null)}
className="text-text-tertiary hover:text-text-secondary p-1"
>
</button>
</>
) : (
<>
<button
onClick={() => { setEditingTextId(text.Id); setEditingContent(text.content || '') }}
className="text-text-tertiary hover:text-text-secondary p-1"
>
<FileEdit className="w-4 h-4" />
</button>
<button
onClick={() => setConfirmDeleteTextId(text.Id)}
className="text-red-500 hover:text-red-600 p-1"
>
<Trash2 className="w-4 h-4" />
</button>
</>
)}
</div>
</div>
{editingTextId === text.Id ? (
<textarea
value={editingContent}
onChange={e => setEditingContent(e.target.value)}
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 min-h-[100px] resize-y"
autoFocus
/>
) : (
<p className="text-sm text-text-secondary whitespace-pre-wrap">{text.content}</p>
)}
</div>
))}
</div>
) : (
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
<Languages className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
<p className="text-sm text-text-secondary">{t('translations.noTranslationTexts')}</p>
</div>
)}
</div>
)}
{/* Review Tab */}
{activeTab === 'review' && (
<div className="p-6 space-y-5">
{['draft', 'revision_requested', 'rejected'].includes(translation.status) && (
<button
onClick={handleSubmitReview}
disabled={submitting}
className="w-full py-3 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light disabled:opacity-50 transition-colors shadow-sm"
>
{submitting ? t('translations.submitting') : t('translations.submitForReview')}
</button>
)}
{currentReviewUrl && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="text-sm font-semibold text-blue-900 mb-2">{t('translations.reviewLinkTitle')}</div>
<div className="flex items-center gap-2">
<input
type="text"
value={currentReviewUrl}
readOnly
className="flex-1 px-3 py-2 text-sm bg-white border border-blue-200 rounded-lg text-blue-800"
/>
<button
onClick={copyReviewLink}
className="p-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors"
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
<a
href={currentReviewUrl}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors"
>
<ExternalLink className="w-4 h-4" />
</a>
</div>
</div>
)}
{translation.feedback && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<h4 className="text-sm font-semibold text-amber-900 mb-2">{t('translations.feedbackTitle')}</h4>
<p className="text-sm text-amber-800 whitespace-pre-wrap">{translation.feedback}</p>
</div>
)}
{translation.status === 'approved' && translation.approved_by_name && (
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
<div className="font-medium text-emerald-900">{t('translations.approvedByLabel')} {translation.approved_by_name}</div>
{translation.approved_at && (
<div className="text-sm text-emerald-700 mt-1">
{new Date(translation.approved_at).toLocaleString()}
</div>
)}
</div>
)}
{!['draft', 'revision_requested', 'rejected'].includes(translation.status) && !currentReviewUrl && !translation.feedback && !(translation.status === 'approved' && translation.approved_by_name) && (
<p className="text-sm text-text-secondary text-center py-4">
{translation.status === 'pending_review'
? t('translations.pendingReviewInfo')
: t('translations.noReviewInfo')}
</p>
)}
</div>
)}
</TabbedModal>
{/* Add Translation Modal */}
<Modal isOpen={showAddLang} onClose={() => setShowAddLang(false)} title={t('translations.addTranslation')} size="md">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.languageLabel')} *</label>
<select
value={langForm.language_code}
onChange={e => setLangForm(f => ({ ...f, language_code: e.target.value }))}
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"
>
<option value="">{t('translations.selectLanguage')}</option>
{availableForAdd.map(l => <option key={l.code} value={l.code}>{l.label} ({l.code})</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.translatedContent')} *</label>
<textarea
value={langForm.content}
onChange={e => setLangForm(f => ({ ...f, content: e.target.value }))}
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 min-h-[150px] resize-y"
placeholder={t('translations.enterTranslatedContent')}
/>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<button onClick={() => setShowAddLang(false)} className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg">
{t('common.cancel')}
</button>
<button
onClick={handleAddTranslationText}
disabled={savingLang || !langForm.language_code || !langForm.content}
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"
>
{savingLang ? t('common.loading') : t('translations.addTranslation')}
</button>
</div>
</div>
</Modal>
{/* Delete translation text confirm */}
<Modal
isOpen={!!confirmDeleteTextId}
onClose={() => setConfirmDeleteTextId(null)}
title={t('translations.deleteTranslationText')}
isConfirm
danger
onConfirm={() => { handleDeleteText(confirmDeleteTextId); setConfirmDeleteTextId(null) }}
confirmText={t('common.delete')}
>
{t('translations.deleteTranslationTextDesc')}
</Modal>
{/* Delete translation confirm */}
<Modal
isOpen={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)}
title={t('translations.deleteTranslation')}
isConfirm
danger
onConfirm={() => { handleDelete(); setShowDeleteConfirm(false) }}
confirmText={t('common.delete')}
>
{t('translations.deleteTranslationDesc')}
</Modal>
</>
)
}