feat: post approval workflow, i18n completion, and multiple fixes
All checks were successful
Deploy / deploy (push) Successful in 11s

- Add approval process to posts (approver multi-select, rejected status column)
- Reorganize PostDetailPanel into Content, Scheduling, Approval sections
- Fix save button visibility: move to fixed footer via SlidePanel footer prop
- Change date picker from datetime-local to date-only
- Complete Arabic translations across all panels (Header, Issues, Artefacts)
- Fix artefact versioning to start empty (copyFromPrevious defaults to false)
- Separate media uploads by type (image, audio, video) in PostDetailPanel
- Fix team membership save when editing own profile as superadmin
- Server: add approver_ids column to Posts, enrich GET/POST/PATCH responses

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
fahed
2026-03-05 14:17:16 +03:00
parent daf2404bda
commit 82236ecffa
12 changed files with 882 additions and 309 deletions

View File

@@ -65,7 +65,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
// New version modal
const [showNewVersionModal, setShowNewVersionModal] = useState(false)
const [newVersionNotes, setNewVersionNotes] = useState('')
const [copyFromPrevious, setCopyFromPrevious] = useState(true)
const [copyFromPrevious, setCopyFromPrevious] = useState(false)
const [creatingVersion, setCreatingVersion] = useState(false)
// File upload (for design/video)
@@ -109,7 +109,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
}
} catch (err) {
console.error('Failed to load versions:', err)
toast.error('Failed to load versions')
toast.error(t('artefacts.failedLoadVersions'))
} finally {
setLoading(false)
}
@@ -126,7 +126,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
setComments(commentsRes.data || commentsRes || [])
} catch (err) {
console.error('Failed to load version data:', err)
toast.error('Failed to load version data')
toast.error(t('artefacts.failedLoadVersionData'))
}
}
@@ -143,15 +143,15 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
copy_from_previous: artefact.type === 'copy' ? copyFromPrevious : false,
})
toast.success('New version created')
toast.success(t('artefacts.versionCreated'))
setShowNewVersionModal(false)
setNewVersionNotes('')
setCopyFromPrevious(true)
setCopyFromPrevious(false)
loadVersions()
onUpdate()
} catch (err) {
console.error('Create version failed:', err)
toast.error('Failed to create version')
toast.error(t('artefacts.failedCreateVersion'))
} finally {
setCreatingVersion(false)
}
@@ -159,20 +159,20 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
const handleAddLanguage = async () => {
if (!languageForm.language_code || !languageForm.language_label || !languageForm.content) {
toast.error('All fields are required')
toast.error(t('artefacts.allFieldsRequired'))
return
}
setSavingLanguage(true)
try {
await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/texts`, languageForm)
toast.success('Language added')
toast.success(t('artefacts.languageAdded'))
setShowLanguageModal(false)
setLanguageForm({ language_code: '', language_label: '', content: '' })
loadVersionData(selectedVersion.Id)
} catch (err) {
console.error('Add language failed:', err)
toast.error('Failed to add language')
toast.error(t('artefacts.failedAddLanguage'))
} finally {
setSavingLanguage(false)
}
@@ -181,10 +181,10 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
const handleDeleteLanguage = async (textId) => {
try {
await api.delete(`/artefact-version-texts/${textId}`)
toast.success('Language deleted')
toast.success(t('artefacts.languageDeleted'))
loadVersionData(selectedVersion.Id)
} catch (err) {
toast.error('Failed to delete language')
toast.error(t('artefacts.failedDeleteLanguage'))
}
}
@@ -197,11 +197,11 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
const formData = new FormData()
formData.append('file', file)
await api.upload(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/attachments`, formData)
toast.success('File uploaded')
toast.success(t('artefacts.fileUploaded'))
loadVersionData(selectedVersion.Id)
} catch (err) {
console.error('Upload failed:', err)
toast.error('Upload failed')
toast.error(t('artefacts.uploadFailed'))
} finally {
setUploading(false)
}
@@ -209,7 +209,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
const handleAddDriveVideo = async () => {
if (!driveUrl.trim()) {
toast.error('Please enter a Google Drive URL')
toast.error(t('artefacts.enterDriveUrl'))
return
}
@@ -218,13 +218,13 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/attachments`, {
drive_url: driveUrl,
})
toast.success('Video link added')
toast.success(t('artefacts.videoLinkAdded'))
setShowVideoModal(false)
setDriveUrl('')
loadVersionData(selectedVersion.Id)
} catch (err) {
console.error('Add Drive link failed:', err)
toast.error('Failed to add video link')
toast.error(t('artefacts.failedAddVideoLink'))
} finally {
setUploading(false)
}
@@ -233,10 +233,10 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
const handleDeleteAttachment = async (attId) => {
try {
await api.delete(`/artefact-attachments/${attId}`)
toast.success('Attachment deleted')
toast.success(t('artefacts.attachmentDeleted'))
loadVersionData(selectedVersion.Id)
} catch (err) {
toast.error('Failed to delete attachment')
toast.error(t('artefacts.failedDeleteAttachment'))
}
}
@@ -245,10 +245,10 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
try {
const res = await api.post(`/artefacts/${artefact.Id}/submit-review`)
setReviewUrl(res.reviewUrl || res.data?.reviewUrl || '')
toast.success('Submitted for review!')
toast.success(t('artefacts.submittedForReview'))
onUpdate()
} catch (err) {
toast.error('Failed to submit for review')
toast.error(t('artefacts.failedSubmitReview'))
} finally {
setSubmitting(false)
}
@@ -257,7 +257,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
const copyReviewLink = () => {
navigator.clipboard.writeText(reviewUrl)
setCopied(true)
toast.success('Link copied to clipboard')
toast.success(t('artefacts.linkCopied'))
setTimeout(() => setCopied(false), 2000)
}
@@ -269,11 +269,11 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/comments`, {
content: newComment.trim(),
})
toast.success('Comment added')
toast.success(t('artefacts.commentAdded'))
setNewComment('')
loadVersionData(selectedVersion.Id)
} catch (err) {
toast.error('Failed to add comment')
toast.error(t('artefacts.failedAddComment'))
} finally {
setAddingComment(false)
}
@@ -282,16 +282,16 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
const handleUpdateField = async (field, value) => {
try {
await api.patch(`/artefacts/${artefact.Id}`, { [field]: value || null })
toast.success('Updated')
toast.success(t('artefacts.updated'))
onUpdate()
} catch (err) {
toast.error('Failed to update')
toast.error(t('artefacts.failedUpdate'))
}
}
const handleSaveDraft = async () => {
if (!editTitle.trim()) {
toast.error('Title is required')
toast.error(t('artefacts.titleRequired'))
return
}
setSavingDraft(true)
@@ -300,10 +300,10 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
title: editTitle.trim(),
description: editDescription.trim() || null,
})
toast.success('Draft saved')
toast.success(t('artefacts.draftSaved'))
onUpdate()
} catch (err) {
toast.error('Failed to save draft')
toast.error(t('artefacts.failedSaveDraft'))
} finally {
setSavingDraft(false)
}
@@ -314,7 +314,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
try {
await onDelete(artefact.Id || artefact.id || artefact._id)
} catch (err) {
toast.error('Failed to delete')
toast.error(t('artefacts.failedDelete'))
setDeleting(false)
}
}
@@ -377,17 +377,17 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
onClick={handleSaveDraft}
disabled={savingDraft}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light disabled:opacity-50 transition-colors"
title="Save draft"
title={t('artefacts.saveDraftTooltip')}
>
<Save className="w-3.5 h-3.5" />
{savingDraft ? 'Saving...' : 'Save'}
{savingDraft ? t('artefacts.savingDraft') : t('artefacts.saveDraft')}
</button>
{onDelete && (
<button
onClick={() => setShowDeleteArtefactConfirm(true)}
disabled={deleting}
className="p-1.5 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title="Delete artefact"
title={t('artefacts.deleteArtefactTooltip')}
>
<Trash2 className="w-4 h-4" />
</button>
@@ -399,13 +399,13 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
<div className="p-6 space-y-6">
{/* Description */}
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-2">Description</h4>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-2">{t('artefacts.descriptionLabel')}</h4>
<textarea
value={editDescription}
onChange={e => setEditDescription(e.target.value)}
rows={3}
className="w-full px-3 py-2 text-sm text-text-secondary bg-surface-secondary border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
placeholder="Add a description..."
placeholder={t('artefacts.descriptionFieldPlaceholder')}
/>
</div>
@@ -443,7 +443,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
{/* Approvers */}
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">Approvers</h4>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.approversLabel')}</h4>
<ApproverMultiSelect
users={assignableUsers}
selected={editApproverIds}
@@ -457,13 +457,13 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
{/* Version Timeline */}
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-semibold text-text-tertiary uppercase">Versions</h4>
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.versions')}</h4>
<button
onClick={() => setShowNewVersionModal(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" />
New Version
{t('artefacts.newVersion')}
</button>
</div>
<ArtefactVersionTimeline
@@ -481,13 +481,13 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
{artefact.type === 'copy' && (
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-semibold text-text-tertiary uppercase">Languages</h4>
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.languages')}</h4>
<button
onClick={() => setShowLanguageModal(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" />
Add Language
{t('artefacts.addLanguage')}
</button>
</div>
@@ -518,7 +518,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
) : (
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
<Globe className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
<p className="text-sm text-text-secondary">No languages added yet</p>
<p className="text-sm text-text-secondary">{t('artefacts.noLanguages')}</p>
</div>
)}
</div>
@@ -528,10 +528,10 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
{artefact.type === 'design' && (
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-semibold text-text-tertiary uppercase">Images</h4>
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.imagesLabel')}</h4>
<label 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 cursor-pointer">
<Upload className="w-3 h-3" />
{uploading ? 'Uploading...' : 'Upload Image'}
{uploading ? t('artefacts.uploading') : t('artefacts.uploadImage')}
<input
type="file"
className="hidden"
@@ -568,7 +568,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
) : (
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
<ImageIcon className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
<p className="text-sm text-text-secondary">No images uploaded yet</p>
<p className="text-sm text-text-secondary">{t('artefacts.noImages')}</p>
</div>
)}
</div>
@@ -578,13 +578,13 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
{artefact.type === 'video' && (
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-semibold text-text-tertiary uppercase">Videos</h4>
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.videosLabel')}</h4>
<button
onClick={() => setShowVideoModal(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" />
Add Video
{t('artefacts.addVideoBtn')}
</button>
</div>
@@ -595,7 +595,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
{att.drive_url ? (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-text-primary">Google Drive Video</span>
<span className="text-sm font-medium text-text-primary">{t('artefacts.googleDriveVideo')}</span>
<button
onClick={() => setConfirmDeleteAttId(att.Id)}
className="text-red-600 hover:text-red-700"
@@ -633,7 +633,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
) : (
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
<Film className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
<p className="text-sm text-text-secondary">No videos added yet</p>
<p className="text-sm text-text-secondary">{t('artefacts.noVideos')}</p>
</div>
)}
</div>
@@ -645,7 +645,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
{selectedVersion && (
<div className="border-t border-border pt-6">
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">
Comments ({comments.length})
{t('artefacts.comments')} ({comments.length})
</h4>
<div className="space-y-3 mb-4">
@@ -677,7 +677,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
value={newComment}
onChange={e => setNewComment(e.target.value)}
onKeyPress={e => e.key === 'Enter' && handleAddComment()}
placeholder="Add a comment..."
placeholder={t('artefacts.addCommentPlaceholder')}
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
<button
@@ -685,7 +685,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
disabled={addingComment || !newComment.trim()}
className="px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 transition-colors"
>
Send
{t('artefacts.sendComment')}
</button>
</div>
</div>
@@ -700,7 +700,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-amber-500 text-white rounded-lg hover:bg-amber-600 transition-colors font-medium disabled:opacity-50"
>
<ExternalLink className="w-4 h-4" />
{submitting ? 'Submitting...' : 'Submit for Review'}
{submitting ? t('artefacts.submitting') : t('artefacts.submitForReview')}
</button>
</div>
)}
@@ -708,7 +708,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
{/* Review Link */}
{reviewUrl && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="text-sm font-semibold text-blue-900 mb-2">Review Link (expires in 7 days)</div>
<div className="text-sm font-semibold text-blue-900 mb-2">{t('artefacts.reviewLinkTitle')}</div>
<div className="flex items-center gap-2">
<input
type="text"
@@ -729,7 +729,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
{/* Feedback */}
{artefact.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">Feedback</h4>
<h4 className="text-sm font-semibold text-amber-900 mb-2">{t('artefacts.feedbackTitle')}</h4>
<p className="text-sm text-amber-800 whitespace-pre-wrap">{artefact.feedback}</p>
</div>
)}
@@ -737,7 +737,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
{/* Approval Info */}
{artefact.status === 'approved' && artefact.approved_by_name && (
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
<div className="font-medium text-emerald-900">Approved by {artefact.approved_by_name}</div>
<div className="font-medium text-emerald-900">{t('artefacts.approvedByLabel')} {artefact.approved_by_name}</div>
{artefact.approved_at && (
<div className="text-sm text-emerald-700 mt-1">
{new Date(artefact.approved_at).toLocaleString()}
@@ -748,10 +748,10 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
</div>
{/* Language Modal */}
<Modal isOpen={showLanguageModal} onClose={() => setShowLanguageModal(false)} title="Add Language" size="md">
<Modal isOpen={showLanguageModal} onClose={() => setShowLanguageModal(false)} title={t('artefacts.addLanguage')} size="md">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Language *</label>
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.languageLabel')} *</label>
<select
value={languageForm.language_code}
onChange={e => {
@@ -761,7 +761,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
}}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">Select a language...</option>
<option value="">{t('artefacts.selectLanguage')}</option>
{AVAILABLE_LANGUAGES
.filter(lang => !(versionData?.texts || []).some(t => t.language_code === lang.code))
.map(lang => (
@@ -771,13 +771,13 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Content *</label>
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.contentLabel')} *</label>
<textarea
value={languageForm.content}
onChange={e => setLanguageForm(f => ({ ...f, content: e.target.value }))}
rows={8}
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 font-sans"
placeholder="Enter the content in this language..."
placeholder={t('artefacts.enterContent')}
/>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
@@ -785,30 +785,30 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
onClick={() => setShowLanguageModal(false)}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
>
Cancel
{t('common.cancel')}
</button>
<button
onClick={handleAddLanguage}
disabled={savingLanguage}
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"
>
{savingLanguage ? 'Saving...' : 'Save'}
{savingLanguage ? t('header.saving') : t('common.save')}
</button>
</div>
</div>
</Modal>
{/* New Version Modal */}
<Modal isOpen={showNewVersionModal} onClose={() => setShowNewVersionModal(false)} title="Create New Version" size="sm">
<Modal isOpen={showNewVersionModal} onClose={() => setShowNewVersionModal(false)} title={t('artefacts.createNewVersion')} size="sm">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Version Notes</label>
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.versionNotes')}</label>
<textarea
value={newVersionNotes}
onChange={e => setNewVersionNotes(e.target.value)}
rows={3}
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"
placeholder="What changed in this version?"
placeholder={t('artefacts.whatChanged')}
/>
</div>
{artefact.type === 'copy' && versions.length > 0 && (
@@ -819,7 +819,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
onChange={e => setCopyFromPrevious(e.target.checked)}
className="w-4 h-4 text-brand-primary border-border rounded focus:ring-brand-primary"
/>
<span className="text-sm text-text-secondary">Copy languages from previous version</span>
<span className="text-sm text-text-secondary">{t('artefacts.copyLanguages')}</span>
</label>
)}
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
@@ -827,21 +827,21 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
onClick={() => setShowNewVersionModal(false)}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
>
Cancel
{t('common.cancel')}
</button>
<button
onClick={handleCreateVersion}
disabled={creatingVersion}
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"
>
{creatingVersion ? 'Creating...' : 'Create Version'}
{creatingVersion ? t('artefacts.creatingVersion') : t('artefacts.createVersion')}
</button>
</div>
</div>
</Modal>
{/* Video Modal */}
<Modal isOpen={showVideoModal} onClose={() => setShowVideoModal(false)} title="Add Video" size="md">
<Modal isOpen={showVideoModal} onClose={() => setShowVideoModal(false)} title={t('artefacts.addVideoTitle')} size="md">
<div className="space-y-4">
<div className="flex items-center gap-2 border-b border-border pb-3">
<button
@@ -852,7 +852,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
: 'bg-surface-secondary text-text-secondary hover:bg-surface-tertiary'
}`}
>
Upload File
{t('artefacts.uploadFile')}
</button>
<button
onClick={() => setVideoMode('drive')}
@@ -862,7 +862,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
: 'bg-surface-secondary text-text-secondary hover:bg-surface-tertiary'
}`}
>
Google Drive Link
{t('artefacts.googleDriveLink')}
</button>
</div>
@@ -872,9 +872,9 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
<Upload className="w-8 h-8 text-text-tertiary" />
<div className="text-center">
<span className="text-sm font-medium text-text-primary">
{uploading ? 'Uploading...' : 'Choose video file'}
{uploading ? t('artefacts.uploading') : t('artefacts.chooseVideoFile')}
</span>
<p className="text-xs text-text-tertiary mt-1">MP4, MOV, AVI, etc.</p>
<p className="text-xs text-text-tertiary mt-1">{t('artefacts.videoFormats')}</p>
</div>
<input
type="file"
@@ -887,7 +887,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
</div>
) : (
<div>
<label className="block text-sm font-medium text-text-primary mb-2">Google Drive URL</label>
<label className="block text-sm font-medium text-text-primary mb-2">{t('artefacts.googleDriveUrl')}</label>
<input
type="text"
value={driveUrl}
@@ -896,21 +896,21 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
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"
/>
<p className="text-xs text-text-tertiary mt-2">
Paste a Google Drive share link. Make sure the file is publicly accessible.
{t('artefacts.publiclyAccessible')}
</p>
<div className="flex justify-end gap-3 mt-4">
<button
onClick={() => setShowVideoModal(false)}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
>
Cancel
{t('common.cancel')}
</button>
<button
onClick={handleAddDriveVideo}
disabled={uploading || !driveUrl.trim()}
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"
>
{uploading ? 'Adding...' : 'Add Link'}
{uploading ? t('artefacts.adding') : t('artefacts.addLink')}
</button>
</div>
</div>

View File

@@ -3,29 +3,31 @@ import { useLocation } from 'react-router-dom'
import { ChevronDown, LogOut, Shield, Lock, AlertCircle, CheckCircle } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { getInitials, api } from '../utils/api'
import { useLanguage } from '../i18n/LanguageContext'
import Modal from './Modal'
import ThemeToggle from './ThemeToggle'
const pageTitles = {
'/': 'Dashboard',
'/posts': 'Post Production',
'/assets': 'Assets',
'/campaigns': 'Campaigns',
'/finance': 'Finance',
'/projects': 'Projects',
'/tasks': 'My Tasks',
'/team': 'Team',
'/users': 'User Management',
const PAGE_TITLE_KEYS = {
'/': 'header.dashboard',
'/posts': 'header.posts',
'/assets': 'header.assets',
'/campaigns': 'header.campaigns',
'/finance': 'header.finance',
'/projects': 'header.projects',
'/tasks': 'header.tasks',
'/team': 'header.team',
'/users': 'header.users',
}
const ROLE_INFO = {
superadmin: { label: 'Superadmin', color: 'bg-purple-100 text-purple-700', icon: '👑' },
manager: { label: 'Manager', color: 'bg-blue-100 text-blue-700', icon: '📊' },
contributor: { label: 'Contributor', color: 'bg-green-100 text-green-700', icon: '✏️' },
superadmin: { labelKey: 'header.superadmin', color: 'bg-purple-100 text-purple-700', icon: '👑' },
manager: { labelKey: 'header.manager', color: 'bg-blue-100 text-blue-700', icon: '📊' },
contributor: { labelKey: 'header.contributor', color: 'bg-green-100 text-green-700', icon: '✏️' },
}
export default function Header() {
const { user, logout } = useAuth()
const { t } = useLanguage()
const [showDropdown, setShowDropdown] = useState(false)
const [showPasswordModal, setShowPasswordModal] = useState(false)
const [passwordForm, setPasswordForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' })
@@ -36,10 +38,10 @@ export default function Header() {
const location = useLocation()
function getPageTitle(pathname) {
if (pageTitles[pathname]) return pageTitles[pathname]
if (pathname.startsWith('/projects/')) return 'Project Details'
if (pathname.startsWith('/campaigns/')) return 'Campaign Details'
return 'Page'
if (PAGE_TITLE_KEYS[pathname]) return t(PAGE_TITLE_KEYS[pathname])
if (pathname.startsWith('/projects/')) return t('header.projectDetails')
if (pathname.startsWith('/campaigns/')) return t('header.campaignDetails')
return t('header.page')
}
const pageTitle = getPageTitle(location.pathname)
@@ -57,11 +59,11 @@ export default function Header() {
setPasswordError('')
setPasswordSuccess('')
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
setPasswordError('New passwords do not match')
setPasswordError(t('header.passwordMismatch'))
return
}
if (passwordForm.newPassword.length < 6) {
setPasswordError('New password must be at least 6 characters')
setPasswordError(t('header.passwordMinLength'))
return
}
setPasswordSaving(true)
@@ -70,11 +72,11 @@ export default function Header() {
currentPassword: passwordForm.currentPassword,
newPassword: passwordForm.newPassword,
})
setPasswordSuccess('Password updated successfully')
setPasswordSuccess(t('header.passwordUpdateSuccess'))
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' })
setTimeout(() => setShowPasswordModal(false), 1500)
} catch (err) {
setPasswordError(err.message || 'Failed to change password')
setPasswordError(err.message || t('header.passwordUpdateFailed'))
} finally {
setPasswordSaving(false)
}
@@ -121,7 +123,7 @@ export default function Header() {
{user?.name || 'User'}
</p>
<p className={`text-[10px] font-medium ${roleInfo.color.split(' ')[1]}`}>
{roleInfo.icon} {roleInfo.label}
{roleInfo.icon} {t(roleInfo.labelKey)}
</p>
</div>
<ChevronDown className={`w-4 h-4 text-text-tertiary transition-transform ${showDropdown ? 'rotate-180' : ''}`} />
@@ -135,7 +137,7 @@ export default function Header() {
<p className="text-xs text-text-tertiary">{user?.email}</p>
<div className={`inline-flex items-center gap-1 text-[10px] font-medium px-2 py-0.5 rounded-full mt-2 ${roleInfo.color}`}>
<span>{roleInfo.icon}</span>
{roleInfo.label}
{t(roleInfo.labelKey)}
</div>
</div>
@@ -150,7 +152,7 @@ export default function Header() {
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-surface-secondary transition-colors text-start"
>
<Shield className="w-4 h-4 text-text-tertiary" />
<span className="text-sm text-text-primary">User Management</span>
<span className="text-sm text-text-primary">{t('header.userManagement')}</span>
</button>
)}
@@ -159,7 +161,7 @@ export default function Header() {
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-surface-secondary transition-colors text-start"
>
<Lock className="w-4 h-4 text-text-tertiary" />
<span className="text-sm text-text-primary">Change Password</span>
<span className="text-sm text-text-primary">{t('header.changePassword')}</span>
</button>
<button
@@ -170,7 +172,7 @@ export default function Header() {
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-red-50 transition-colors text-left group"
>
<LogOut className="w-4 h-4 text-text-tertiary group-hover:text-red-500" />
<span className="text-sm text-text-primary group-hover:text-red-500">Sign Out</span>
<span className="text-sm text-text-primary group-hover:text-red-500">{t('header.signOut')}</span>
</button>
</div>
</div>
@@ -180,10 +182,10 @@ export default function Header() {
</header>
{/* Change Password Modal */}
<Modal isOpen={showPasswordModal} onClose={() => setShowPasswordModal(false)} title="Change Password" size="md">
<Modal isOpen={showPasswordModal} onClose={() => setShowPasswordModal(false)} title={t('header.changePassword')} size="md">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Current Password</label>
<label className="block text-sm font-medium text-text-primary mb-1">{t('header.currentPassword')}</label>
<input
type="password"
value={passwordForm.currentPassword}
@@ -193,7 +195,7 @@ export default function Header() {
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">New Password</label>
<label className="block text-sm font-medium text-text-primary mb-1">{t('header.newPassword')}</label>
<input
type="password"
value={passwordForm.newPassword}
@@ -204,7 +206,7 @@ export default function Header() {
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Confirm New Password</label>
<label className="block text-sm font-medium text-text-primary mb-1">{t('header.confirmNewPassword')}</label>
<input
type="password"
value={passwordForm.confirmPassword}
@@ -234,14 +236,14 @@ export default function Header() {
onClick={() => setShowPasswordModal(false)}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
>
Cancel
{t('common.cancel')}
</button>
<button
onClick={handlePasswordChange}
disabled={!passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword || passwordSaving}
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
>
{passwordSaving ? 'Saving...' : 'Update Password'}
{passwordSaving ? t('header.saving') : t('header.updatePassword')}
</button>
</div>
</div>

View File

@@ -1,20 +1,12 @@
const PRIORITY_CONFIG = {
low: { label: 'Low', dot: 'bg-text-tertiary' },
medium: { label: 'Medium', dot: 'bg-blue-500' },
high: { label: 'High', dot: 'bg-orange-500' },
urgent: { label: 'Urgent', dot: 'bg-red-500' },
}
const TYPE_LABELS = {
request: 'Request',
correction: 'Correction',
complaint: 'Complaint',
suggestion: 'Suggestion',
other: 'Other',
const PRIORITY_DOTS = {
low: 'bg-text-tertiary',
medium: 'bg-blue-500',
high: 'bg-orange-500',
urgent: 'bg-red-500',
}
export default function IssueCard({ issue, onClick }) {
const priority = PRIORITY_CONFIG[issue.priority] || PRIORITY_CONFIG.medium
const priority = { dot: PRIORITY_DOTS[issue.priority] || PRIORITY_DOTS.medium }
const formatDate = (dateStr) => {
if (!dateStr) return ''

View File

@@ -240,32 +240,32 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
<div className="p-4 space-y-6">
{/* Submitter Info */}
<div className="bg-surface-secondary rounded-lg p-4">
<h3 className="text-sm font-semibold text-text-primary mb-2">Submitter Information</h3>
<h3 className="text-sm font-semibold text-text-primary mb-2">{t('issues.submitterInfo')}</h3>
<div className="space-y-1 text-sm">
<div><span className="text-text-tertiary">Name:</span> <span className="text-text-primary font-medium">{issueData.submitter_name}</span></div>
<div><span className="text-text-tertiary">Email:</span> <span className="text-text-primary">{issueData.submitter_email}</span></div>
<div><span className="text-text-tertiary">{t('issues.nameLabel')}</span> <span className="text-text-primary font-medium">{issueData.submitter_name}</span></div>
<div><span className="text-text-tertiary">{t('issues.emailLabel')}</span> <span className="text-text-primary">{issueData.submitter_email}</span></div>
{issueData.submitter_phone && (
<div><span className="text-text-tertiary">Phone:</span> <span className="text-text-primary">{issueData.submitter_phone}</span></div>
<div><span className="text-text-tertiary">{t('issues.phoneLabel')}</span> <span className="text-text-primary">{issueData.submitter_phone}</span></div>
)}
<div><span className="text-text-tertiary">Submitted:</span> <span className="text-text-primary">{formatDate(issueData.created_at)}</span></div>
<div><span className="text-text-tertiary">{t('issues.submittedLabel')}</span> <span className="text-text-primary">{formatDate(issueData.created_at)}</span></div>
</div>
</div>
{/* Description */}
<div>
<h3 className="text-sm font-semibold text-text-primary mb-2">Description</h3>
<p className="text-sm text-text-secondary whitespace-pre-wrap">{issueData.description || 'No description provided'}</p>
<h3 className="text-sm font-semibold text-text-primary mb-2">{t('issues.description')}</h3>
<p className="text-sm text-text-secondary whitespace-pre-wrap">{issueData.description || t('issues.noDescription')}</p>
</div>
{/* Assigned To */}
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">Assigned To</label>
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.assignedTo')}</label>
<select
value={assignedTo}
onChange={(e) => handleAssignmentChange(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="">Unassigned</option>
<option value="">{t('issues.unassigned')}</option>
{teamMembers.map((member) => (
<option key={member.id || member._id} value={member.id || member._id}>
{member.name}
@@ -303,7 +303,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
{/* Brand */}
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">Brand</label>
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.brandLabel')}</label>
<select
value={issueData.brand_id || ''}
onChange={async (e) => {
@@ -316,7 +316,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
}}
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="">No brand</option>
<option value="">{t('issues.noBrand')}</option>
{(brands || []).map((b) => (
<option key={b._id || b.Id} value={b._id || b.Id}>{b.name}</option>
))}
@@ -327,14 +327,14 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
<div>
<label className="block text-sm font-semibold text-text-primary mb-2 flex items-center gap-2">
<Lock className="w-4 h-4" />
Internal Notes (Staff Only)
{t('issues.internalNotes')}
</label>
<textarea
value={internalNotes}
onChange={(e) => setInternalNotes(e.target.value)}
onBlur={handleNotesChange}
rows={4}
placeholder="Internal notes not visible to submitter..."
placeholder={t('issues.internalNotesPlaceholder')}
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"
/>
</div>
@@ -344,11 +344,11 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
<h3 className="text-sm font-semibold text-emerald-900 mb-2 flex items-center gap-2">
<CheckCircle2 className="w-4 h-4" />
Resolution Summary (Public)
{t('issues.resolutionSummary')}
</h3>
<p className="text-sm text-emerald-800 whitespace-pre-wrap">{issueData.resolution_summary}</p>
{issueData.resolved_at && (
<p className="text-xs text-emerald-600 mt-2">Resolved on {formatDate(issueData.resolved_at)}</p>
<p className="text-xs text-emerald-600 mt-2">{t('issues.resolvedOn')} {formatDate(issueData.resolved_at)}</p>
)}
</div>
)}
@@ -363,7 +363,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
>
<Check className="w-4 h-4 inline mr-1" />
Acknowledge
{t('issues.acknowledge')}
</button>
)}
{(issueData.status === 'new' || issueData.status === 'acknowledged') && (
@@ -373,7 +373,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
className="px-4 py-2 bg-amber-600 text-white rounded-lg text-sm font-medium hover:bg-amber-700 disabled:opacity-50"
>
<Clock className="w-4 h-4 inline mr-1" />
Start Work
{t('issues.startWork')}
</button>
)}
<button
@@ -382,7 +382,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
className="px-4 py-2 bg-emerald-600 text-white rounded-lg text-sm font-medium hover:bg-emerald-700 disabled:opacity-50"
>
<CheckCircle2 className="w-4 h-4 inline mr-1" />
Resolve
{t('issues.resolve')}
</button>
<button
onClick={() => setShowDeclineModal(true)}
@@ -390,14 +390,14 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
className="px-4 py-2 bg-gray-600 text-white rounded-lg text-sm font-medium hover:bg-gray-700 disabled:opacity-50"
>
<XCircle className="w-4 h-4 inline mr-1" />
Decline
{t('issues.decline')}
</button>
</div>
)}
{/* Tracking Link */}
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">Public Tracking Link</label>
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.publicTrackingLink')}</label>
<div className="flex gap-2">
<input
type="text"
@@ -417,7 +417,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
{/* Updates Timeline */}
<div>
<h3 className="text-sm font-semibold text-text-primary mb-3 flex items-center gap-2">
Updates Timeline
{t('issues.updatesTimeline')}
<span className="text-xs text-text-tertiary font-normal">({updates.length})</span>
</h3>
@@ -426,7 +426,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
<textarea
value={newUpdate}
onChange={(e) => setNewUpdate(e.target.value)}
placeholder="Add an update..."
placeholder={t('issues.addUpdatePlaceholder')}
rows={3}
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 mb-2"
/>
@@ -439,7 +439,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
className="rounded"
/>
<Eye className="w-4 h-4" />
Make public (visible to submitter)
{t('issues.makePublic')}
</label>
<button
onClick={handleAddUpdate}
@@ -447,7 +447,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
className="px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 transition-colors flex items-center gap-2"
>
<Send className="w-4 h-4" />
Add Update
{t('issues.addUpdate')}
</button>
</div>
</div>
@@ -477,7 +477,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
</div>
))}
{updates.length === 0 && (
<p className="text-sm text-text-tertiary text-center py-6">No updates yet</p>
<p className="text-sm text-text-tertiary text-center py-6">{t('issues.noUpdates')}</p>
)}
</div>
</div>
@@ -485,7 +485,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
{/* Attachments */}
<div>
<h3 className="text-sm font-semibold text-text-primary mb-3 flex items-center gap-2">
Attachments
{t('issues.attachments')}
<span className="text-xs text-text-tertiary font-normal">({attachments.length})</span>
</h3>
@@ -495,7 +495,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center cursor-pointer hover:bg-surface-secondary transition-colors">
<Upload className="w-6 h-6 mx-auto mb-2 text-text-tertiary" />
<p className="text-sm text-text-secondary">
{uploadingFile ? 'Uploading...' : 'Click to upload file'}
{uploadingFile ? t('issues.uploading') : t('issues.clickToUpload')}
</p>
</div>
</label>
@@ -520,7 +520,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
rel="noopener noreferrer"
className="text-xs text-brand-primary hover:underline"
>
Download
{t('issues.download')}
</a>
<button onClick={() => setConfirmDeleteAttId(att.Id || att.id)} className="p-1 hover:bg-surface-tertiary rounded">
<Trash2 className="w-4 h-4 text-red-600" />
@@ -529,7 +529,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
</div>
))}
{attachments.length === 0 && (
<p className="text-sm text-text-tertiary text-center py-4">No attachments</p>
<p className="text-sm text-text-tertiary text-center py-4">{t('issues.noAttachments')}</p>
)}
</div>
</div>
@@ -538,13 +538,13 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
{/* Resolve Modal */}
{showResolveModal && (
<Modal isOpen title="Resolve Issue" onClose={() => setShowResolveModal(false)}>
<Modal isOpen title={t('issues.resolveIssue')} onClose={() => setShowResolveModal(false)}>
<div className="space-y-4">
<p className="text-sm text-text-secondary">Provide a resolution summary that will be visible to the submitter.</p>
<p className="text-sm text-text-secondary">{t('issues.resolveSummaryHint')}</p>
<textarea
value={resolutionSummary}
onChange={(e) => setResolutionSummary(e.target.value)}
placeholder="Explain how this issue was resolved..."
placeholder={t('issues.resolutionPlaceholder')}
rows={5}
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"
/>
@@ -553,14 +553,14 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
onClick={() => setShowResolveModal(false)}
className="px-4 py-2 bg-surface-secondary text-text-primary rounded-lg text-sm font-medium hover:bg-surface-tertiary"
>
Cancel
{t('common.cancel')}
</button>
<button
onClick={handleResolve}
disabled={!resolutionSummary.trim() || saving}
className="px-4 py-2 bg-emerald-600 text-white rounded-lg text-sm font-medium hover:bg-emerald-700 disabled:opacity-50"
>
{saving ? 'Resolving...' : 'Mark as Resolved'}
{saving ? t('issues.resolving') : t('issues.markAsResolved')}
</button>
</div>
</div>
@@ -569,13 +569,13 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
{/* Decline Modal */}
{showDeclineModal && (
<Modal isOpen title="Decline Issue" onClose={() => setShowDeclineModal(false)}>
<Modal isOpen title={t('issues.declineIssue')} onClose={() => setShowDeclineModal(false)}>
<div className="space-y-4">
<p className="text-sm text-text-secondary">Provide a reason for declining this issue. This will be visible to the submitter.</p>
<p className="text-sm text-text-secondary">{t('issues.declineReasonHint')}</p>
<textarea
value={resolutionSummary}
onChange={(e) => setResolutionSummary(e.target.value)}
placeholder="Explain why this issue cannot be addressed..."
placeholder={t('issues.declinePlaceholder')}
rows={5}
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"
/>
@@ -584,14 +584,14 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
onClick={() => setShowDeclineModal(false)}
className="px-4 py-2 bg-surface-secondary text-text-primary rounded-lg text-sm font-medium hover:bg-surface-tertiary"
>
Cancel
{t('common.cancel')}
</button>
<button
onClick={handleDecline}
disabled={!resolutionSummary.trim() || saving}
className="px-4 py-2 bg-gray-600 text-white rounded-lg text-sm font-medium hover:bg-gray-700 disabled:opacity-50"
>
{saving ? 'Declining...' : 'Decline Issue'}
{saving ? t('issues.declining') : t('issues.declineIssue')}
</button>
</div>
</div>

View File

@@ -1,7 +1,8 @@
import { useState, useEffect, useRef } from 'react'
import { X, Trash2, Upload, FileText, Link2, ExternalLink, FolderOpen } from 'lucide-react'
import { X, Trash2, Upload, FileText, Link2, ExternalLink, FolderOpen, Image as ImageIcon, Music, Film } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import { api, PLATFORMS, getBrandColor } from '../utils/api'
import ApproverMultiSelect from './ApproverMultiSelect'
import CommentsSection from './CommentsSection'
import Modal from './Modal'
import SlidePanel from './SlidePanel'
@@ -9,7 +10,9 @@ import CollapsibleSection from './CollapsibleSection'
export default function PostDetailPanel({ post, onClose, onSave, onDelete, brands, teamMembers, campaigns }) {
const { t, lang } = useLanguage()
const fileInputRef = useRef(null)
const imageInputRef = useRef(null)
const audioInputRef = useRef(null)
const videoInputRef = useRef(null)
const [form, setForm] = useState({})
const [dirty, setDirty] = useState(false)
const [saving, setSaving] = useState(false)
@@ -36,10 +39,11 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
platforms: post.platforms || (post.platform ? [post.platform] : []),
status: post.status || 'draft',
assigned_to: post.assignedTo || post.assigned_to || '',
scheduled_date: post.scheduledDate ? new Date(post.scheduledDate).toISOString().slice(0, 16) : '',
scheduled_date: post.scheduledDate ? new Date(post.scheduledDate).toISOString().slice(0, 10) : (post.scheduled_date ? new Date(post.scheduled_date).toISOString().slice(0, 10) : ''),
notes: post.notes || '',
campaign_id: post.campaignId || post.campaign_id || '',
publication_links: post.publication_links || post.publicationLinks || [],
approver_ids: post.approvers?.map(a => String(a.id)) || (post.approver_ids ? post.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : []),
})
setDirty(isCreateMode)
setPublishError('')
@@ -53,6 +57,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
{ value: 'draft', label: t('posts.status.draft') },
{ value: 'in_review', label: t('posts.status.in_review') },
{ value: 'approved', label: t('posts.status.approved') },
{ value: 'rejected', label: t('posts.status.rejected') },
{ value: 'scheduled', label: t('posts.status.scheduled') },
{ value: 'published', label: t('posts.status.published') },
]
@@ -91,6 +96,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
notes: form.notes,
campaign_id: form.campaign_id ? Number(form.campaign_id) : null,
publication_links: form.publication_links || [],
approver_ids: (form.approver_ids || []).length > 0 ? form.approver_ids.join(',') : null,
}
if (data.status === 'published' && data.platforms.length > 0) {
@@ -212,6 +218,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
form.status === 'scheduled' ? 'bg-purple-100 text-purple-700' :
form.status === 'approved' ? 'bg-blue-100 text-blue-700' :
form.status === 'in_review' ? 'bg-amber-100 text-amber-700' :
form.status === 'rejected' ? 'bg-red-100 text-red-700' :
'bg-gray-100 text-gray-600'
}`}>
{statusOptions.find(s => s.value === form.status)?.label}
@@ -235,9 +242,47 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
return (
<>
<SlidePanel onClose={onClose} maxWidth="520px" header={header}>
{/* Details Section */}
<CollapsibleSection title={t('posts.details')}>
<SlidePanel onClose={onClose} maxWidth="520px" header={header} footer={
<div className="bg-surface border-t border-border px-5 py-3 flex items-center gap-2 shrink-0">
{dirty ? (
<>
<button
onClick={handleSave}
disabled={!form.title || saving}
className={`flex-1 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${saving ? 'btn-loading' : ''}`}
>
{isCreateMode ? t('posts.createPost') : t('posts.saveChanges')}
</button>
{!isCreateMode && (
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg transition-colors"
>
{t('common.cancel')}
</button>
)}
</>
) : (
<button
onClick={onClose}
className="flex-1 px-4 py-2 text-sm font-medium text-text-secondary border border-border rounded-lg hover:bg-surface-tertiary transition-colors"
>
{t('common.close')}
</button>
)}
{onDelete && !isCreateMode && (
<button
onClick={() => setShowDeleteConfirm(true)}
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title={t('common.delete')}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
}>
{/* Content Section */}
<CollapsibleSection title={t('posts.content')}>
<div className="px-5 pb-4 space-y-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.description')}</label>
@@ -275,40 +320,6 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.assignTo')}</label>
<select
value={form.assigned_to}
onChange={e => update('assigned_to', 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 focus:border-brand-primary"
>
<option value="">{t('common.unassigned')}</option>
{(teamMembers || []).map(m => <option key={m._id || m.id} value={m._id || m.id}>{m.name}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.status')}</label>
<select
value={form.status}
onChange={e => update('status', 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 focus:border-brand-primary"
>
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.scheduledDate')}</label>
<input
type="datetime-local"
value={form.scheduled_date}
onChange={e => update('scheduled_date', 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 focus:border-brand-primary"
/>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.notes')}</label>
<input
@@ -320,33 +331,62 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
/>
</div>
</div>
</CollapsibleSection>
{/* Scheduling & Assignment Section */}
<CollapsibleSection title={t('posts.scheduling')}>
<div className="px-5 pb-4 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.status')}</label>
<select
value={form.status}
onChange={e => update('status', 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 focus:border-brand-primary"
>
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.scheduledDate')}</label>
<input
type="date"
value={form.scheduled_date}
onChange={e => update('scheduled_date', 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 focus:border-brand-primary"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.assignTo')}</label>
<select
value={form.assigned_to}
onChange={e => update('assigned_to', 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 focus:border-brand-primary"
>
<option value="">{t('common.unassigned')}</option>
{(teamMembers || []).map(m => <option key={m._id || m.id} value={m._id || m.id}>{m.name}</option>)}
</select>
</div>
{publishError && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{publishError}
</div>
)}
<div className="flex items-center gap-2 pt-2">
{dirty && (
<button
onClick={handleSave}
disabled={!form.title || saving}
className={`flex-1 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${saving ? 'btn-loading' : ''}`}
>
{isCreateMode ? t('posts.createPost') : t('posts.saveChanges')}
</button>
)}
{onDelete && !isCreateMode && (
<button
onClick={() => setShowDeleteConfirm(true)}
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title={t('common.delete')}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</CollapsibleSection>
{/* Approval Section */}
<CollapsibleSection title={t('posts.approval')}>
<div className="px-5 pb-4">
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.approvers')}</label>
<ApproverMultiSelect
users={teamMembers || []}
selected={form.approver_ids || []}
onChange={ids => update('approver_ids', ids)}
/>
</div>
</CollapsibleSection>
@@ -437,34 +477,40 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
</span>
) : null}
>
<div className="px-5 pb-4">
{attachments.length > 0 && (
<div className="grid grid-cols-2 gap-2 mb-3">
{attachments.map(att => {
const isImage = att.mime_type?.startsWith('image/') || att.mimeType?.startsWith('image/')
<div className="px-5 pb-4 space-y-4">
{/* Images */}
{(() => {
const images = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('image/'))
return (
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary">
<ImageIcon className="w-3.5 h-3.5" />
{t('posts.images')}
{images.length > 0 && <span className="text-text-tertiary">({images.length})</span>}
</div>
<label className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-brand-primary hover:bg-brand-primary/5 rounded cursor-pointer transition-colors">
<Upload className="w-3 h-3" />
{t('posts.addImage')}
<input ref={imageInputRef} type="file" multiple accept="image/*" className="hidden"
onChange={e => { handleFileUpload(e.target.files); e.target.value = '' }} />
</label>
</div>
{images.length > 0 && (
<div className="grid grid-cols-2 gap-2">
{images.map(att => {
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
const attId = att.id || att._id
return (
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
<div className="h-20 relative">
{isImage ? (
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="block h-full">
<img src={attUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
</a>
) : (
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="absolute inset-0 flex items-center gap-2 p-3">
<FileText className="w-6 h-6 text-text-tertiary shrink-0" />
<span className="text-xs text-text-secondary truncate">{name}</span>
</a>
)}
<button
onClick={() => handleDeleteAttachment(attId)}
<button onClick={() => handleDeleteAttachment(attId)}
className="absolute top-1 right-1 p-1 bg-black/50 hover:bg-red-500 rounded-full text-white opacity-0 group-hover/att:opacity-100 transition-opacity"
title={t('common.delete')}
>
<X className="w-2.5 h-2.5" />
</button>
title={t('common.delete')}><X className="w-2.5 h-2.5" /></button>
</div>
<div className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
</div>
@@ -472,41 +518,155 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
})}
</div>
)}
</div>
)
})()}
{/* Audio */}
{(() => {
const audio = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('audio/'))
return (
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary">
<Music className="w-3.5 h-3.5" />
{t('posts.audio')}
{audio.length > 0 && <span className="text-text-tertiary">({audio.length})</span>}
</div>
<label className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-brand-primary hover:bg-brand-primary/5 rounded cursor-pointer transition-colors">
<Upload className="w-3 h-3" />
{t('posts.addAudio')}
<input ref={audioInputRef} type="file" multiple accept="audio/*" className="hidden"
onChange={e => { handleFileUpload(e.target.files); e.target.value = '' }} />
</label>
</div>
{audio.length > 0 && (
<div className="space-y-2">
{audio.map(att => {
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
const attId = att.id || att._id
return (
<div key={attId} className="flex items-center gap-2 border border-border rounded-lg p-2 bg-white group/att">
<Music className="w-4 h-4 text-text-tertiary shrink-0" />
<span className="text-xs text-text-secondary truncate flex-1">{name}</span>
<audio src={attUrl} controls className="h-7 max-w-[160px]" />
<button onClick={() => handleDeleteAttachment(attId)}
className="p-1 text-text-tertiary hover:text-red-500 opacity-0 group-hover/att:opacity-100 transition-opacity"
title={t('common.delete')}><X className="w-3 h-3" /></button>
</div>
)
})}
</div>
)}
</div>
)
})()}
{/* Video */}
{(() => {
const videos = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('video/'))
return (
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary">
<Film className="w-3.5 h-3.5" />
{t('posts.videos')}
{videos.length > 0 && <span className="text-text-tertiary">({videos.length})</span>}
</div>
<label className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-brand-primary hover:bg-brand-primary/5 rounded cursor-pointer transition-colors">
<Upload className="w-3 h-3" />
{t('posts.addVideo')}
<input ref={videoInputRef} type="file" multiple accept="video/*" className="hidden"
onChange={e => { handleFileUpload(e.target.files); e.target.value = '' }} />
</label>
</div>
{videos.length > 0 && (
<div className="space-y-2">
{videos.map(att => {
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
const attId = att.id || att._id
return (
<div key={attId} className="border border-border rounded-lg overflow-hidden bg-white group/att">
<video src={attUrl} controls className="w-full max-h-40" />
<div className="flex items-center justify-between px-2 py-1 border-t border-border-light">
<span className="text-[10px] text-text-tertiary truncate">{name}</span>
<button onClick={() => handleDeleteAttachment(attId)}
className="p-1 text-text-tertiary hover:text-red-500 opacity-0 group-hover/att:opacity-100 transition-opacity"
title={t('common.delete')}><X className="w-3 h-3" /></button>
</div>
</div>
)
})}
</div>
)}
</div>
)
})()}
{/* Other files */}
{(() => {
const others = attachments.filter(a => {
const mime = a.mime_type || a.mimeType || ''
return !mime.startsWith('image/') && !mime.startsWith('audio/') && !mime.startsWith('video/')
})
return others.length > 0 ? (
<div>
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-2">
<FileText className="w-3.5 h-3.5" />
{t('posts.otherFiles')}
<span className="text-text-tertiary">({others.length})</span>
</div>
<div className="grid grid-cols-2 gap-2">
{others.map(att => {
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
const attId = att.id || att._id
return (
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 p-3 h-16">
<FileText className="w-6 h-6 text-text-tertiary shrink-0" />
<span className="text-xs text-text-secondary truncate">{name}</span>
</a>
<button onClick={() => handleDeleteAttachment(attId)}
className="absolute top-1 right-1 p-1 bg-black/50 hover:bg-red-500 rounded-full text-white opacity-0 group-hover/att:opacity-100 transition-opacity"
title={t('common.delete')}><X className="w-2.5 h-2.5" /></button>
</div>
)
})}
</div>
</div>
) : null
})()}
{/* Drag and drop zone */}
<div
className={`border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors ${
className={`border-2 border-dashed rounded-lg p-3 text-center cursor-pointer transition-colors ${
dragActive ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/40'
}`}
onClick={() => fileInputRef.current?.click()}
onDragEnter={e => { e.preventDefault(); setDragActive(true) }}
onDragLeave={e => { e.preventDefault(); setDragActive(false) }}
onDragOver={e => e.preventDefault()}
onDrop={handleDrop}
>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={e => { handleFileUpload(e.target.files); e.target.value = '' }}
/>
<Upload className={`w-5 h-5 text-text-tertiary mx-auto mb-1 ${uploading ? 'animate-pulse' : ''}`} />
<p className="text-xs text-text-secondary">
{dragActive ? t('posts.dropFiles') : t('posts.uploadFiles')}
<Upload className={`w-4 h-4 text-text-tertiary mx-auto mb-1 ${uploading ? 'animate-pulse' : ''}`} />
<p className="text-[11px] text-text-secondary">
{dragActive ? t('posts.dropFiles') : t('posts.dragToUpload')}
</p>
</div>
<button
type="button"
onClick={openAssetPicker}
className="mt-2 flex items-center gap-2 px-3 py-2 text-sm text-text-secondary border border-border rounded-lg hover:bg-surface-tertiary transition-colors w-full justify-center"
className="flex items-center gap-2 px-3 py-2 text-sm text-text-secondary border border-border rounded-lg hover:bg-surface-tertiary transition-colors w-full justify-center"
>
<FolderOpen className="w-4 h-4" />
{t('posts.attachFromAssets')}
</button>
{showAssetPicker && (
<div className="mt-2 border border-border rounded-lg p-3 bg-surface-secondary">
<div className="border border-border rounded-lg p-3 bg-surface-secondary">
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-medium text-text-secondary">{t('posts.selectAssets')}</p>
<button onClick={() => setShowAssetPicker(false)} className="p-1 text-text-tertiary hover:text-text-primary">

View File

@@ -1,6 +1,6 @@
import { createPortal } from 'react-dom'
export default function SlidePanel({ onClose, maxWidth = '420px', header, children }) {
export default function SlidePanel({ onClose, maxWidth = '420px', header, footer, children }) {
return createPortal(
<>
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in z-[9998]" onClick={onClose} />
@@ -12,6 +12,7 @@ export default function SlidePanel({ onClose, maxWidth = '420px', header, childr
<div className="flex-1 overflow-y-auto">
{children}
</div>
{footer}
</div>
</>,
document.body

View File

@@ -30,6 +30,7 @@
"common.noResults": "لا توجد نتائج",
"common.loading": "جاري التحميل...",
"common.unassigned": "غير مُسند",
"common.close": "إغلاق",
"common.required": "مطلوب",
"common.saveFailed": "فشل الحفظ. حاول مجدداً.",
"common.updateFailed": "فشل التحديث. حاول مجدداً.",
@@ -130,6 +131,7 @@
"posts.status.approved": "مُعتمد",
"posts.status.scheduled": "مجدول",
"posts.status.published": "منشور",
"posts.status.rejected": "مرفوض",
"tasks.title": "المهام",
"tasks.newTask": "مهمة جديدة",
"tasks.editTask": "تعديل المهمة",
@@ -693,5 +695,198 @@
"settings.roleName": "اسم الدور",
"settings.roleColor": "اللون",
"settings.deleteRoleConfirm": "هل أنت متأكد من حذف هذا الدور؟",
"settings.noRoles": "لم يتم تحديد أدوار بعد. أضف أول دور."
"settings.noRoles": "لم يتم تحديد أدوار بعد. أضف أول دور.",
"header.dashboard": "لوحة التحكم",
"header.posts": "إنتاج المحتوى",
"header.assets": "الأصول",
"header.campaigns": "الحملات",
"header.finance": "المالية",
"header.projects": "المشاريع",
"header.tasks": "مهامي",
"header.team": "الفريق",
"header.users": "إدارة المستخدمين",
"header.projectDetails": "تفاصيل المشروع",
"header.campaignDetails": "تفاصيل الحملة",
"header.page": "الصفحة",
"header.superadmin": "مسؤول عام",
"header.manager": "مدير",
"header.contributor": "مساهم",
"header.passwordMismatch": "كلمتا المرور الجديدتان غير متطابقتين",
"header.passwordMinLength": "كلمة المرور الجديدة يجب أن تكون ٦ أحرف على الأقل",
"header.passwordUpdateSuccess": "تم تحديث كلمة المرور بنجاح",
"header.passwordUpdateFailed": "فشل في تغيير كلمة المرور",
"header.userManagement": "إدارة المستخدمين",
"header.changePassword": "تغيير كلمة المرور",
"header.signOut": "تسجيل الخروج",
"header.currentPassword": "كلمة المرور الحالية",
"header.newPassword": "كلمة المرور الجديدة",
"header.confirmNewPassword": "تأكيد كلمة المرور الجديدة",
"header.updatePassword": "تحديث كلمة المرور",
"header.saving": "جاري الحفظ...",
"issues.title": "المشاكل",
"issues.subtitle": "تتبع وإدارة البلاغات المقدمة",
"issues.searchPlaceholder": "البحث في المشاكل...",
"issues.allStatuses": "جميع الحالات",
"issues.allCategories": "جميع الفئات",
"issues.allTypes": "جميع الأنواع",
"issues.allBrands": "جميع العلامات",
"issues.allPriorities": "جميع الأولويات",
"issues.clearAll": "مسح الكل",
"issues.noIssuesFound": "لم يتم العثور على مشاكل",
"issues.tryAdjustingFilters": "جرّب تعديل الفلاتر",
"issues.noIssuesSubmitted": "لم يتم تقديم أي مشاكل بعد",
"issues.issuesDeleted": "تم حذف المشاكل",
"issues.tableTitle": "العنوان",
"issues.tableSubmitter": "مُقدّم البلاغ",
"issues.tableBrand": "العلامة التجارية",
"issues.tableCategory": "الفئة",
"issues.tableType": "النوع",
"issues.tablePriority": "الأولوية",
"issues.tableStatus": "الحالة",
"issues.tableAssignedTo": "مُسند إلى",
"issues.tableCreated": "تاريخ الإنشاء",
"issues.typeRequest": "طلب",
"issues.typeCorrection": "تصحيح",
"issues.typeComplaint": "شكوى",
"issues.typeSuggestion": "اقتراح",
"issues.typeOther": "أخرى",
"issues.priorityLow": "منخفض",
"issues.priorityMedium": "متوسط",
"issues.priorityHigh": "عالي",
"issues.priorityUrgent": "عاجل",
"issues.submitterInfo": "معلومات مُقدّم البلاغ",
"issues.nameLabel": "الاسم:",
"issues.emailLabel": "البريد الإلكتروني:",
"issues.phoneLabel": "الهاتف:",
"issues.submittedLabel": "تاريخ التقديم:",
"issues.description": "الوصف",
"issues.noDescription": "لا يوجد وصف",
"issues.assignedTo": "مُسند إلى",
"issues.unassigned": "غير مُسند",
"issues.brandLabel": "العلامة التجارية",
"issues.noBrand": "بدون علامة تجارية",
"issues.internalNotes": "ملاحظات داخلية (للموظفين فقط)",
"issues.internalNotesPlaceholder": "ملاحظات داخلية غير مرئية لمقدم البلاغ...",
"issues.resolutionSummary": "ملخص الحل (عام)",
"issues.resolvedOn": "تم الحل في",
"issues.acknowledge": "إقرار",
"issues.startWork": "بدء العمل",
"issues.resolve": "حل",
"issues.decline": "رفض",
"issues.publicTrackingLink": "رابط التتبع العام",
"issues.updatesTimeline": "الجدول الزمني للتحديثات",
"issues.addUpdatePlaceholder": "أضف تحديثاً...",
"issues.makePublic": "جعله عاماً (مرئي لمقدم البلاغ)",
"issues.addUpdate": "إضافة تحديث",
"issues.noUpdates": "لا توجد تحديثات بعد",
"issues.attachments": "المرفقات",
"issues.clickToUpload": "انقر لرفع ملف",
"issues.uploading": "جاري الرفع...",
"issues.download": "تحميل",
"issues.noAttachments": "لا توجد مرفقات",
"issues.resolveIssue": "حل المشكلة",
"issues.resolveSummaryHint": "قدّم ملخصاً للحل سيكون مرئياً لمقدم البلاغ.",
"issues.resolutionPlaceholder": "اشرح كيف تم حل هذه المشكلة...",
"issues.markAsResolved": "تحديد كمحلولة",
"issues.resolving": "جاري الحل...",
"issues.declineIssue": "رفض المشكلة",
"issues.declineReasonHint": "قدّم سبباً لرفض هذه المشكلة. سيكون مرئياً لمقدم البلاغ.",
"issues.declinePlaceholder": "اشرح لماذا لا يمكن معالجة هذه المشكلة...",
"issues.declining": "جاري الرفض...",
"artefacts.descriptionLabel": "الوصف",
"artefacts.descriptionFieldPlaceholder": "أضف وصفاً...",
"artefacts.approversLabel": "المعتمدون",
"artefacts.versions": "الإصدارات",
"artefacts.newVersion": "إصدار جديد",
"artefacts.languages": "اللغات",
"artefacts.addLanguage": "إضافة لغة",
"artefacts.noLanguages": "لم تتم إضافة لغات بعد",
"artefacts.imagesLabel": "الصور",
"artefacts.uploadImage": "رفع صورة",
"artefacts.uploading": "جاري الرفع...",
"artefacts.noImages": "لم يتم رفع صور بعد",
"artefacts.videosLabel": "الفيديوهات",
"artefacts.addVideoBtn": "إضافة فيديو",
"artefacts.noVideos": "لم تتم إضافة فيديوهات بعد",
"artefacts.comments": "التعليقات",
"artefacts.sendComment": "إرسال",
"artefacts.addCommentPlaceholder": "أضف تعليقاً...",
"artefacts.submitForReview": "إرسال للمراجعة",
"artefacts.submitting": "جاري الإرسال...",
"artefacts.reviewLinkTitle": "رابط المراجعة (ينتهي خلال ٧ أيام)",
"artefacts.feedbackTitle": "الملاحظات",
"artefacts.approvedByLabel": "تمت الموافقة بواسطة",
"artefacts.saveDraft": "حفظ",
"artefacts.savingDraft": "جاري الحفظ...",
"artefacts.versionNotes": "ملاحظات الإصدار",
"artefacts.whatChanged": "ما الذي تغير في هذا الإصدار؟",
"artefacts.copyLanguages": "نسخ اللغات من الإصدار السابق",
"artefacts.createVersion": "إنشاء إصدار",
"artefacts.creatingVersion": "جاري الإنشاء...",
"artefacts.languageLabel": "اللغة",
"artefacts.contentLabel": "المحتوى",
"artefacts.selectLanguage": "اختر لغة...",
"artefacts.enterContent": "أدخل المحتوى بهذه اللغة...",
"artefacts.addVideoTitle": "إضافة فيديو",
"artefacts.uploadFile": "رفع ملف",
"artefacts.chooseVideoFile": "اختر ملف فيديو",
"artefacts.videoFormats": "MP4، MOV، AVI، إلخ.",
"artefacts.googleDriveLink": "رابط Google Drive",
"artefacts.googleDriveUrl": "رابط Google Drive",
"artefacts.driveUrlPlaceholder": "https://drive.google.com/file/d/...",
"artefacts.publiclyAccessible": "الصق رابط مشاركة Google Drive. تأكد أن الملف متاح للعامة.",
"artefacts.addLink": "إضافة رابط",
"artefacts.adding": "جاري الإضافة...",
"artefacts.googleDriveVideo": "فيديو Google Drive",
"artefacts.deleteArtefactTooltip": "حذف المحتوى",
"artefacts.saveDraftTooltip": "حفظ المسودة",
"artefacts.createNewVersion": "إنشاء إصدار جديد",
"artefacts.failedLoadVersions": "فشل في تحميل الإصدارات",
"artefacts.failedLoadVersionData": "فشل في تحميل بيانات الإصدار",
"artefacts.versionCreated": "تم إنشاء الإصدار الجديد",
"artefacts.failedCreateVersion": "فشل في إنشاء الإصدار",
"artefacts.languageAdded": "تمت إضافة اللغة",
"artefacts.allFieldsRequired": "جميع الحقول مطلوبة",
"artefacts.failedAddLanguage": "فشل في إضافة اللغة",
"artefacts.languageDeleted": "تم حذف اللغة",
"artefacts.failedDeleteLanguage": "فشل في حذف اللغة",
"artefacts.fileUploaded": "تم رفع الملف",
"artefacts.uploadFailed": "فشل في الرفع",
"artefacts.videoLinkAdded": "تمت إضافة رابط الفيديو",
"artefacts.failedAddVideoLink": "فشل في إضافة رابط الفيديو",
"artefacts.enterDriveUrl": "يرجى إدخال رابط Google Drive",
"artefacts.attachmentDeleted": "تم حذف المرفق",
"artefacts.failedDeleteAttachment": "فشل في حذف المرفق",
"artefacts.submittedForReview": "تم الإرسال للمراجعة!",
"artefacts.failedSubmitReview": "فشل في الإرسال للمراجعة",
"artefacts.linkCopied": "تم نسخ الرابط",
"artefacts.commentAdded": "تمت إضافة التعليق",
"artefacts.failedAddComment": "فشل في إضافة التعليق",
"artefacts.updated": "تم التحديث",
"artefacts.failedUpdate": "فشل في التحديث",
"artefacts.draftSaved": "تم حفظ المسودة",
"artefacts.failedSaveDraft": "فشل في حفظ المسودة",
"artefacts.titleRequired": "العنوان مطلوب",
"artefacts.failedDelete": "فشل في الحذف",
"posts.images": "الصور",
"posts.audio": "الصوت",
"posts.videos": "الفيديوهات",
"posts.otherFiles": "ملفات أخرى",
"posts.addImage": "إضافة صورة",
"posts.addAudio": "إضافة صوت",
"posts.addVideo": "إضافة فيديو",
"posts.dragToUpload": "اسحب الملفات هنا للرفع",
"posts.assignedTo": "مُسند إلى",
"posts.approval": "الموافقة",
"posts.approvers": "المعتمدون",
"posts.selectApprovers": "اختر المعتمدين...",
"posts.scheduling": "الجدولة والتعيين",
"posts.content": "المحتوى"
}

View File

@@ -30,6 +30,7 @@
"common.noResults": "No results",
"common.loading": "Loading...",
"common.unassigned": "Unassigned",
"common.close": "Close",
"common.required": "Required",
"common.saveFailed": "Failed to save. Please try again.",
"common.updateFailed": "Failed to update. Please try again.",
@@ -130,6 +131,7 @@
"posts.status.approved": "Approved",
"posts.status.scheduled": "Scheduled",
"posts.status.published": "Published",
"posts.status.rejected": "Rejected",
"tasks.title": "Tasks",
"tasks.newTask": "New Task",
"tasks.editTask": "Edit Task",
@@ -693,5 +695,198 @@
"settings.roleName": "Role name",
"settings.roleColor": "Color",
"settings.deleteRoleConfirm": "Are you sure you want to delete this role?",
"settings.noRoles": "No roles defined yet. Add your first role."
"settings.noRoles": "No roles defined yet. Add your first role.",
"header.dashboard": "Dashboard",
"header.posts": "Post Production",
"header.assets": "Assets",
"header.campaigns": "Campaigns",
"header.finance": "Finance",
"header.projects": "Projects",
"header.tasks": "My Tasks",
"header.team": "Team",
"header.users": "User Management",
"header.projectDetails": "Project Details",
"header.campaignDetails": "Campaign Details",
"header.page": "Page",
"header.superadmin": "Superadmin",
"header.manager": "Manager",
"header.contributor": "Contributor",
"header.passwordMismatch": "New passwords do not match",
"header.passwordMinLength": "New password must be at least 6 characters",
"header.passwordUpdateSuccess": "Password updated successfully",
"header.passwordUpdateFailed": "Failed to change password",
"header.userManagement": "User Management",
"header.changePassword": "Change Password",
"header.signOut": "Sign Out",
"header.currentPassword": "Current Password",
"header.newPassword": "New Password",
"header.confirmNewPassword": "Confirm New Password",
"header.updatePassword": "Update Password",
"header.saving": "Saving...",
"issues.title": "Issues",
"issues.subtitle": "Track and manage issue submissions",
"issues.searchPlaceholder": "Search issues...",
"issues.allStatuses": "All Statuses",
"issues.allCategories": "All Categories",
"issues.allTypes": "All Types",
"issues.allBrands": "All Brands",
"issues.allPriorities": "All Priorities",
"issues.clearAll": "Clear All",
"issues.noIssuesFound": "No issues found",
"issues.tryAdjustingFilters": "Try adjusting your filters",
"issues.noIssuesSubmitted": "No issues have been submitted yet",
"issues.issuesDeleted": "Issues deleted",
"issues.tableTitle": "Title",
"issues.tableSubmitter": "Submitter",
"issues.tableBrand": "Brand",
"issues.tableCategory": "Category",
"issues.tableType": "Type",
"issues.tablePriority": "Priority",
"issues.tableStatus": "Status",
"issues.tableAssignedTo": "Assigned To",
"issues.tableCreated": "Created",
"issues.typeRequest": "Request",
"issues.typeCorrection": "Correction",
"issues.typeComplaint": "Complaint",
"issues.typeSuggestion": "Suggestion",
"issues.typeOther": "Other",
"issues.priorityLow": "Low",
"issues.priorityMedium": "Medium",
"issues.priorityHigh": "High",
"issues.priorityUrgent": "Urgent",
"issues.submitterInfo": "Submitter Information",
"issues.nameLabel": "Name:",
"issues.emailLabel": "Email:",
"issues.phoneLabel": "Phone:",
"issues.submittedLabel": "Submitted:",
"issues.description": "Description",
"issues.noDescription": "No description provided",
"issues.assignedTo": "Assigned To",
"issues.unassigned": "Unassigned",
"issues.brandLabel": "Brand",
"issues.noBrand": "No brand",
"issues.internalNotes": "Internal Notes (Staff Only)",
"issues.internalNotesPlaceholder": "Internal notes not visible to submitter...",
"issues.resolutionSummary": "Resolution Summary (Public)",
"issues.resolvedOn": "Resolved on",
"issues.acknowledge": "Acknowledge",
"issues.startWork": "Start Work",
"issues.resolve": "Resolve",
"issues.decline": "Decline",
"issues.publicTrackingLink": "Public Tracking Link",
"issues.updatesTimeline": "Updates Timeline",
"issues.addUpdatePlaceholder": "Add an update...",
"issues.makePublic": "Make public (visible to submitter)",
"issues.addUpdate": "Add Update",
"issues.noUpdates": "No updates yet",
"issues.attachments": "Attachments",
"issues.clickToUpload": "Click to upload file",
"issues.uploading": "Uploading...",
"issues.download": "Download",
"issues.noAttachments": "No attachments",
"issues.resolveIssue": "Resolve Issue",
"issues.resolveSummaryHint": "Provide a resolution summary that will be visible to the submitter.",
"issues.resolutionPlaceholder": "Explain how this issue was resolved...",
"issues.markAsResolved": "Mark as Resolved",
"issues.resolving": "Resolving...",
"issues.declineIssue": "Decline Issue",
"issues.declineReasonHint": "Provide a reason for declining this issue. This will be visible to the submitter.",
"issues.declinePlaceholder": "Explain why this issue cannot be addressed...",
"issues.declining": "Declining...",
"artefacts.descriptionLabel": "Description",
"artefacts.descriptionFieldPlaceholder": "Add a description...",
"artefacts.approversLabel": "Approvers",
"artefacts.versions": "Versions",
"artefacts.newVersion": "New Version",
"artefacts.languages": "Languages",
"artefacts.addLanguage": "Add Language",
"artefacts.noLanguages": "No languages added yet",
"artefacts.imagesLabel": "Images",
"artefacts.uploadImage": "Upload Image",
"artefacts.uploading": "Uploading...",
"artefacts.noImages": "No images uploaded yet",
"artefacts.videosLabel": "Videos",
"artefacts.addVideoBtn": "Add Video",
"artefacts.noVideos": "No videos added yet",
"artefacts.comments": "Comments",
"artefacts.sendComment": "Send",
"artefacts.addCommentPlaceholder": "Add a comment...",
"artefacts.submitForReview": "Submit for Review",
"artefacts.submitting": "Submitting...",
"artefacts.reviewLinkTitle": "Review Link (expires in 7 days)",
"artefacts.feedbackTitle": "Feedback",
"artefacts.approvedByLabel": "Approved by",
"artefacts.saveDraft": "Save",
"artefacts.savingDraft": "Saving...",
"artefacts.versionNotes": "Version Notes",
"artefacts.whatChanged": "What changed in this version?",
"artefacts.copyLanguages": "Copy languages from previous version",
"artefacts.createVersion": "Create Version",
"artefacts.creatingVersion": "Creating...",
"artefacts.languageLabel": "Language",
"artefacts.contentLabel": "Content",
"artefacts.selectLanguage": "Select a language...",
"artefacts.enterContent": "Enter the content in this language...",
"artefacts.addVideoTitle": "Add Video",
"artefacts.uploadFile": "Upload File",
"artefacts.chooseVideoFile": "Choose video file",
"artefacts.videoFormats": "MP4, MOV, AVI, etc.",
"artefacts.googleDriveLink": "Google Drive Link",
"artefacts.googleDriveUrl": "Google Drive URL",
"artefacts.driveUrlPlaceholder": "https://drive.google.com/file/d/...",
"artefacts.publiclyAccessible": "Paste a Google Drive share link. Make sure the file is publicly accessible.",
"artefacts.addLink": "Add Link",
"artefacts.adding": "Adding...",
"artefacts.googleDriveVideo": "Google Drive Video",
"artefacts.deleteArtefactTooltip": "Delete artefact",
"artefacts.saveDraftTooltip": "Save draft",
"artefacts.createNewVersion": "Create New Version",
"artefacts.failedLoadVersions": "Failed to load versions",
"artefacts.failedLoadVersionData": "Failed to load version data",
"artefacts.versionCreated": "New version created",
"artefacts.failedCreateVersion": "Failed to create version",
"artefacts.languageAdded": "Language added",
"artefacts.allFieldsRequired": "All fields are required",
"artefacts.failedAddLanguage": "Failed to add language",
"artefacts.languageDeleted": "Language deleted",
"artefacts.failedDeleteLanguage": "Failed to delete language",
"artefacts.fileUploaded": "File uploaded",
"artefacts.uploadFailed": "Upload failed",
"artefacts.videoLinkAdded": "Video link added",
"artefacts.failedAddVideoLink": "Failed to add video link",
"artefacts.enterDriveUrl": "Please enter a Google Drive URL",
"artefacts.attachmentDeleted": "Attachment deleted",
"artefacts.failedDeleteAttachment": "Failed to delete attachment",
"artefacts.submittedForReview": "Submitted for review!",
"artefacts.failedSubmitReview": "Failed to submit for review",
"artefacts.linkCopied": "Link copied to clipboard",
"artefacts.commentAdded": "Comment added",
"artefacts.failedAddComment": "Failed to add comment",
"artefacts.updated": "Updated",
"artefacts.failedUpdate": "Failed to update",
"artefacts.draftSaved": "Draft saved",
"artefacts.failedSaveDraft": "Failed to save draft",
"artefacts.titleRequired": "Title is required",
"artefacts.failedDelete": "Failed to delete",
"posts.images": "Images",
"posts.audio": "Audio",
"posts.videos": "Videos",
"posts.otherFiles": "Other Files",
"posts.addImage": "Add Image",
"posts.addAudio": "Add Audio",
"posts.addVideo": "Add Video",
"posts.dragToUpload": "Drag files here to upload",
"posts.assignedTo": "Assigned To",
"posts.approval": "Approval",
"posts.approvers": "Approvers",
"posts.selectApprovers": "Select approvers...",
"posts.scheduling": "Scheduling & Assignment",
"posts.content": "Content"
}

View File

@@ -13,12 +13,12 @@ import { SkeletonTable, SkeletonKanbanBoard } from '../components/SkeletonLoader
import BulkSelectBar from '../components/BulkSelectBar'
import Modal from '../components/Modal'
const TYPE_OPTIONS = [
{ value: 'request', label: 'Request' },
{ value: 'correction', label: 'Correction' },
{ value: 'complaint', label: 'Complaint' },
{ value: 'suggestion', label: 'Suggestion' },
{ value: 'other', label: 'Other' },
const TYPE_OPTION_KEYS = [
{ value: 'request', labelKey: 'issues.typeRequest' },
{ value: 'correction', labelKey: 'issues.typeCorrection' },
{ value: 'complaint', labelKey: 'issues.typeComplaint' },
{ value: 'suggestion', labelKey: 'issues.typeSuggestion' },
{ value: 'other', labelKey: 'issues.typeOther' },
]
// Issue-specific status order for the kanban board
@@ -148,7 +148,7 @@ export default function Issues() {
toast.success(t('issues.statusUpdated'))
} catch (err) {
console.error('Move issue failed:', err)
toast.error('Failed to update status')
toast.error(t('issues.failedToUpdateStatus'))
// Rollback on error
setIssues(prev)
}
@@ -157,7 +157,7 @@ export default function Issues() {
const handleBulkDelete = async () => {
try {
await api.post('/issues/bulk-delete', { ids: [...selectedIds] })
toast.success('Issues deleted')
toast.success(t('issues.issuesDeleted'))
setSelectedIds(new Set())
setShowBulkDeleteConfirm(false)
loadData()
@@ -215,9 +215,9 @@ export default function Issues() {
<div>
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-2">
<AlertCircle className="w-7 h-7" />
Issues
{t('issues.title')}
</h1>
<p className="text-text-secondary mt-1">Track and manage issue submissions</p>
<p className="text-text-secondary mt-1">{t('issues.subtitle')}</p>
</div>
<div className="flex items-center gap-3">
@@ -279,7 +279,7 @@ export default function Issues() {
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
placeholder="Search issues..."
placeholder={t('issues.searchPlaceholder')}
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
@@ -291,7 +291,7 @@ export default function Issues() {
onChange={e => updateFilter('status', e.target.value)}
className="px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
>
<option value="">All Statuses</option>
<option value="">{t('issues.allStatuses')}</option>
{Object.entries(ISSUE_STATUS_CONFIG).map(([key, config]) => (
<option key={key} value={key}>{config.label}</option>
))}
@@ -302,7 +302,7 @@ export default function Issues() {
onChange={e => updateFilter('category', e.target.value)}
className="px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
>
<option value="">All Categories</option>
<option value="">{t('issues.allCategories')}</option>
{categories.map(cat => <option key={cat} value={cat}>{cat}</option>)}
</select>
@@ -311,8 +311,8 @@ export default function Issues() {
onChange={e => updateFilter('type', e.target.value)}
className="px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
>
<option value="">All Types</option>
{TYPE_OPTIONS.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
<option value="">{t('issues.allTypes')}</option>
{TYPE_OPTION_KEYS.map(opt => <option key={opt.value} value={opt.value}>{t(opt.labelKey)}</option>)}
</select>
<select
@@ -320,7 +320,7 @@ export default function Issues() {
onChange={e => updateFilter('brand', e.target.value)}
className="px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
>
<option value="">All Brands</option>
<option value="">{t('issues.allBrands')}</option>
{(brands || []).map(b => (
<option key={b._id || b.Id} value={b._id || b.Id}>{b.name}</option>
))}
@@ -342,7 +342,7 @@ export default function Issues() {
onChange={e => updateFilter('priority', e.target.value)}
className="px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
>
<option value="">All Priorities</option>
<option value="">{t('issues.allPriorities')}</option>
{Object.entries(PRIORITY_CONFIG).map(([key, config]) => (
<option key={key} value={key}>{config.label}</option>
))}
@@ -350,7 +350,7 @@ export default function Issues() {
{hasActiveFilters && (
<button onClick={clearFilters} className="px-3 py-2 rounded-lg text-sm font-medium text-text-tertiary hover:text-text-primary">
Clear All
{t('issues.clearAll')}
</button>
)}
</div>
@@ -360,8 +360,8 @@ export default function Issues() {
filteredIssues.length === 0 ? (
<EmptyState
icon={AlertCircle}
title="No issues found"
description={hasActiveFilters ? 'Try adjusting your filters' : 'No issues have been submitted yet'}
title={t('issues.noIssuesFound')}
description={hasActiveFilters ? t('issues.tryAdjustingFilters') : t('issues.noIssuesSubmitted')}
/>
) : (
<KanbanBoard
@@ -394,8 +394,8 @@ export default function Issues() {
sortedIssues.length === 0 ? (
<EmptyState
icon={AlertCircle}
title="No issues found"
description={hasActiveFilters ? 'Try adjusting your filters' : 'No issues have been submitted yet'}
title={t('issues.noIssuesFound')}
description={hasActiveFilters ? t('issues.tryAdjustingFilters') : t('issues.noIssuesSubmitted')}
/>
) : (
<div className="bg-surface rounded-lg border border-border overflow-hidden">
@@ -414,21 +414,21 @@ export default function Issues() {
<input type="checkbox" checked={selectedIds.size === sortedIssues.length && sortedIssues.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('title')}>
Title <SortIcon col="title" />
{t('issues.tableTitle')} <SortIcon col="title" />
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">Submitter</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">Brand</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">Category</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">Type</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableSubmitter')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableBrand')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableCategory')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableType')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('priority')}>
Priority <SortIcon col="priority" />
{t('issues.tablePriority')} <SortIcon col="priority" />
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('status')}>
Status <SortIcon col="status" />
{t('issues.tableStatus')} <SortIcon col="status" />
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">Assigned To</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableAssignedTo')}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('created_at')}>
Created <SortIcon col="created_at" />
{t('issues.tableCreated')} <SortIcon col="created_at" />
</th>
</tr>
</thead>
@@ -454,7 +454,7 @@ export default function Issues() {
<td className="px-4 py-3 text-sm text-text-secondary">{issue.category || '—'}</td>
<td className="px-4 py-3 text-sm">
<span className="text-xs px-2 py-1 rounded-full bg-surface-tertiary text-text-secondary">
{TYPE_OPTIONS.find(t => t.value === issue.type)?.label || issue.type}
{(() => { const opt = TYPE_OPTION_KEYS.find(o => o.value === issue.type); return opt ? t(opt.labelKey) : issue.type })()}
</span>
</td>
<td className="px-4 py-3 text-sm">

View File

@@ -79,14 +79,17 @@ export default function PostProduction() {
}
const handlePanelSave = async (postId, data) => {
let result
if (postId) {
await api.patch(`/posts/${postId}`, data)
result = await api.patch(`/posts/${postId}`, data)
toast.success(t('posts.updated'))
} else {
await api.post('/posts', data)
result = await api.post('/posts', data)
toast.success(t('posts.created'))
}
loadPosts()
// Update panel with fresh server data so form stays in sync
if (result && postId) setPanelPost(result)
}
const handlePanelDelete = async (postId) => {
@@ -308,6 +311,7 @@ export default function PostProduction() {
{ id: 'draft', label: t('posts.status.draft'), color: 'bg-gray-400' },
{ id: 'in_review', label: t('posts.status.in_review'), color: 'bg-amber-400' },
{ id: 'approved', label: t('posts.status.approved'), color: 'bg-blue-400' },
{ id: 'rejected', label: t('posts.status.rejected'), color: 'bg-red-400' },
{ id: 'scheduled', label: t('posts.status.scheduled'), color: 'bg-purple-400' },
{ id: 'published', label: t('posts.status.published'), color: 'bg-emerald-400' },
]}

View File

@@ -157,7 +157,7 @@ export default function Team() {
}
// Sync team memberships if team_ids provided
if (data.team_ids !== undefined && memberId && !isEditingSelf) {
if (data.team_ids !== undefined && memberId) {
const member = teamMembers.find(m => (m.id || m._id) === memberId)
const currentTeamIds = member?.teams ? member.teams.map(t => t.id) : []
const targetTeamIds = data.team_ids || []

View File

@@ -462,6 +462,7 @@ const TEXT_COLUMNS = {
Comments: [{ name: 'version_number', uidt: 'Number' }],
Issues: [{ name: 'thumbnail', uidt: 'SingleLineText' }],
Artefacts: [{ name: 'approver_ids', uidt: 'SingleLineText' }],
Posts: [{ name: 'approver_ids', uidt: 'SingleLineText' }],
};
async function ensureTextColumns() {
@@ -1161,6 +1162,11 @@ app.get('/api/posts', requireAuth, async (req, res) => {
if (p.assigned_to_id) userIds.add(p.assigned_to_id);
if (p.created_by_user_id) userIds.add(p.created_by_user_id);
if (p.campaign_id) campaignIds.add(p.campaign_id);
if (p.approver_ids) {
for (const id of p.approver_ids.split(',').map(s => s.trim()).filter(Boolean)) {
userIds.add(Number(id));
}
}
}
const names = await batchResolveNames({
brand: { table: 'Brands', ids: [...brandIds] },
@@ -1168,7 +1174,9 @@ app.get('/api/posts', requireAuth, async (req, res) => {
campaign: { table: 'Campaigns', ids: [...campaignIds] },
});
res.json(filtered.map(p => ({
res.json(filtered.map(p => {
const approverIdList = p.approver_ids ? p.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
return {
...p,
brand_id: p.brand_id,
assigned_to: p.assigned_to_id,
@@ -1179,7 +1187,9 @@ app.get('/api/posts', requireAuth, async (req, res) => {
campaign_name: names[`campaign:${p.campaign_id}`] || null,
creator_user_name: names[`user:${p.created_by_user_id}`] || null,
thumbnail_url: thumbMap[p.Id] || null,
})));
approvers: approverIdList.map(id => ({ id: Number(id), name: names[`user:${Number(id)}`] || null })),
};
}));
} catch (err) {
console.error('GET /posts error:', err);
res.status(500).json({ error: 'Failed to load posts' });
@@ -1187,7 +1197,7 @@ app.get('/api/posts', requireAuth, async (req, res) => {
});
app.post('/api/posts', requireAuth, async (req, res) => {
const { title, description, brand_id, assigned_to, status, platform, platforms, content_type, scheduled_date, notes, campaign_id } = req.body;
const { title, description, brand_id, assigned_to, status, platform, platforms, content_type, scheduled_date, notes, campaign_id, approver_ids } = req.body;
if (!title) return res.status(400).json({ error: 'Title is required' });
const platformsArr = platforms || (platform ? [platform] : []);
@@ -1204,10 +1214,16 @@ app.post('/api/posts', requireAuth, async (req, res) => {
brand_id: brand_id ? Number(brand_id) : null,
assigned_to_id: assigned_to ? Number(assigned_to) : null,
campaign_id: campaign_id ? Number(campaign_id) : null,
approver_ids: approver_ids || null,
created_by_user_id: req.session.userId,
});
const post = await nocodb.get('Posts', created.Id);
const approverIdList = post.approver_ids ? post.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
const approverNames = {};
for (const id of approverIdList) {
approverNames[id] = await getRecordName('Users', Number(id));
}
res.status(201).json({
...post,
assigned_to: post.assigned_to_id,
@@ -1215,6 +1231,7 @@ app.post('/api/posts', requireAuth, async (req, res) => {
assigned_name: await getRecordName('Users', post.assigned_to_id),
campaign_name: await getRecordName('Campaigns', post.campaign_id),
creator_user_name: await getRecordName('Users', post.created_by_user_id),
approvers: approverIdList.map(id => ({ id: Number(id), name: approverNames[id] || null })),
});
} catch (err) {
console.error('Create post error:', err);
@@ -1260,6 +1277,7 @@ app.patch('/api/posts/:id', requireAuth, requireOwnerOrRole('posts', 'superadmin
if (req.body.brand_id !== undefined) data.brand_id = req.body.brand_id ? Number(req.body.brand_id) : null;
if (req.body.assigned_to !== undefined) data.assigned_to_id = req.body.assigned_to ? Number(req.body.assigned_to) : null;
if (req.body.campaign_id !== undefined) data.campaign_id = req.body.campaign_id ? Number(req.body.campaign_id) : null;
if (req.body.approver_ids !== undefined) data.approver_ids = req.body.approver_ids || null;
// Publish validation
if (req.body.status === 'published') {
@@ -1279,6 +1297,11 @@ app.patch('/api/posts/:id', requireAuth, requireOwnerOrRole('posts', 'superadmin
await nocodb.update('Posts', id, data);
const post = await nocodb.get('Posts', id);
const approverIdList = post.approver_ids ? post.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
const approverNames = {};
for (const aid of approverIdList) {
approverNames[aid] = await getRecordName('Users', Number(aid));
}
res.json({
...post,
assigned_to: post.assigned_to_id,
@@ -1286,6 +1309,7 @@ app.patch('/api/posts/:id', requireAuth, requireOwnerOrRole('posts', 'superadmin
assigned_name: await getRecordName('Users', post.assigned_to_id),
campaign_name: await getRecordName('Campaigns', post.campaign_id),
creator_user_name: await getRecordName('Users', post.created_by_user_id),
approvers: approverIdList.map(id => ({ id: Number(id), name: approverNames[id] || null })),
});
} catch (err) {
console.error('Update post error:', err);