From b17108b321719f57736329c2a76ddea68ceebb27 Mon Sep 17 00:00:00 2001 From: fahed Date: Wed, 11 Mar 2026 14:49:04 +0300 Subject: [PATCH] feat: add Translation Management with approval workflow - 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 --- client/src/App.jsx | 4 + client/src/components/Sidebar.jsx | 1 + .../src/components/TranslationDetailPanel.jsx | 558 ++++++++++++++++++ client/src/i18n/ar.json | 85 ++- client/src/i18n/en.json | 85 ++- client/src/pages/PublicTranslationReview.jsx | 292 +++++++++ client/src/pages/Translations.jsx | 503 ++++++++++++++++ server/notifications.js | 22 +- server/server.js | 423 +++++++++++++ 9 files changed, 1962 insertions(+), 11 deletions(-) create mode 100644 client/src/components/TranslationDetailPanel.jsx create mode 100644 client/src/pages/PublicTranslationReview.jsx create mode 100644 client/src/pages/Translations.jsx diff --git a/client/src/App.jsx b/client/src/App.jsx index 9ad83e4..f6acf08 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -35,6 +35,8 @@ const PublicPostReview = lazy(() => import('./pages/PublicPostReview')) const Issues = lazy(() => import('./pages/Issues')) const PublicIssueSubmit = lazy(() => import('./pages/PublicIssueSubmit')) const PublicIssueTracker = lazy(() => import('./pages/PublicIssueTracker')) +const Translations = lazy(() => import('./pages/Translations')) +const PublicTranslationReview = lazy(() => import('./pages/PublicTranslationReview')) const ForgotPassword = lazy(() => import('./pages/ForgotPassword')) const ResetPassword = lazy(() => import('./pages/ResetPassword')) @@ -295,6 +297,7 @@ function AppContent() { } /> } /> } /> + } /> : }> } /> {hasModule('marketing') && <> @@ -305,6 +308,7 @@ function AppContent() { } /> } /> } /> + } /> } {hasModule('finance') && (user?.role === 'superadmin' || user?.role === 'manager') && <> } /> diff --git a/client/src/components/Sidebar.jsx b/client/src/components/Sidebar.jsx index 4654af9..5545478 100644 --- a/client/src/components/Sidebar.jsx +++ b/client/src/components/Sidebar.jsx @@ -26,6 +26,7 @@ const moduleGroups = [ { to: '/artefacts', icon: Palette, labelKey: 'nav.artefacts' }, { to: '/assets', icon: Image, labelKey: 'nav.assets', tutorial: 'assets' }, { to: '/brands', icon: Tag, labelKey: 'nav.brands' }, + { to: '/translations', icon: Languages, labelKey: 'nav.translations' }, ], }, { diff --git a/client/src/components/TranslationDetailPanel.jsx b/client/src/components/TranslationDetailPanel.jsx new file mode 100644 index 0000000..39a9ea3 --- /dev/null +++ b/client/src/components/TranslationDetailPanel.jsx @@ -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 ( + <> + +
+ + 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')} + /> +
+
+ + {translation.status?.replace('_', ' ')} + + + {AVAILABLE_LANGUAGES.find(l => l.code === editSourceLanguage)?.label || editSourceLanguage} + + {translation.creator_name && ( + + {t('review.createdBy')} {translation.creator_name} + + )} +
+ + } + tabs={tabs} + activeTab={activeTab} + onTabChange={setActiveTab} + footer={ +
+
+ +
+ +
+ } + > + {/* Details Tab */} + {activeTab === 'details' && ( +
+
+

{t('translations.sourceLanguage')}

+ +
+ +
+

{t('translations.sourceContent')}

+