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 // New version modal
const [showNewVersionModal, setShowNewVersionModal] = useState(false) const [showNewVersionModal, setShowNewVersionModal] = useState(false)
const [newVersionNotes, setNewVersionNotes] = useState('') const [newVersionNotes, setNewVersionNotes] = useState('')
const [copyFromPrevious, setCopyFromPrevious] = useState(true) const [copyFromPrevious, setCopyFromPrevious] = useState(false)
const [creatingVersion, setCreatingVersion] = useState(false) const [creatingVersion, setCreatingVersion] = useState(false)
// File upload (for design/video) // File upload (for design/video)
@@ -109,7 +109,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
} }
} catch (err) { } catch (err) {
console.error('Failed to load versions:', err) console.error('Failed to load versions:', err)
toast.error('Failed to load versions') toast.error(t('artefacts.failedLoadVersions'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -126,7 +126,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
setComments(commentsRes.data || commentsRes || []) setComments(commentsRes.data || commentsRes || [])
} catch (err) { } catch (err) {
console.error('Failed to load version data:', 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, copy_from_previous: artefact.type === 'copy' ? copyFromPrevious : false,
}) })
toast.success('New version created') toast.success(t('artefacts.versionCreated'))
setShowNewVersionModal(false) setShowNewVersionModal(false)
setNewVersionNotes('') setNewVersionNotes('')
setCopyFromPrevious(true) setCopyFromPrevious(false)
loadVersions() loadVersions()
onUpdate() onUpdate()
} catch (err) { } catch (err) {
console.error('Create version failed:', err) console.error('Create version failed:', err)
toast.error('Failed to create version') toast.error(t('artefacts.failedCreateVersion'))
} finally { } finally {
setCreatingVersion(false) setCreatingVersion(false)
} }
@@ -159,20 +159,20 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
const handleAddLanguage = async () => { const handleAddLanguage = async () => {
if (!languageForm.language_code || !languageForm.language_label || !languageForm.content) { if (!languageForm.language_code || !languageForm.language_label || !languageForm.content) {
toast.error('All fields are required') toast.error(t('artefacts.allFieldsRequired'))
return return
} }
setSavingLanguage(true) setSavingLanguage(true)
try { try {
await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/texts`, languageForm) await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/texts`, languageForm)
toast.success('Language added') toast.success(t('artefacts.languageAdded'))
setShowLanguageModal(false) setShowLanguageModal(false)
setLanguageForm({ language_code: '', language_label: '', content: '' }) setLanguageForm({ language_code: '', language_label: '', content: '' })
loadVersionData(selectedVersion.Id) loadVersionData(selectedVersion.Id)
} catch (err) { } catch (err) {
console.error('Add language failed:', err) console.error('Add language failed:', err)
toast.error('Failed to add language') toast.error(t('artefacts.failedAddLanguage'))
} finally { } finally {
setSavingLanguage(false) setSavingLanguage(false)
} }
@@ -181,10 +181,10 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
const handleDeleteLanguage = async (textId) => { const handleDeleteLanguage = async (textId) => {
try { try {
await api.delete(`/artefact-version-texts/${textId}`) await api.delete(`/artefact-version-texts/${textId}`)
toast.success('Language deleted') toast.success(t('artefacts.languageDeleted'))
loadVersionData(selectedVersion.Id) loadVersionData(selectedVersion.Id)
} catch (err) { } 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() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
await api.upload(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/attachments`, formData) await api.upload(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/attachments`, formData)
toast.success('File uploaded') toast.success(t('artefacts.fileUploaded'))
loadVersionData(selectedVersion.Id) loadVersionData(selectedVersion.Id)
} catch (err) { } catch (err) {
console.error('Upload failed:', err) console.error('Upload failed:', err)
toast.error('Upload failed') toast.error(t('artefacts.uploadFailed'))
} finally { } finally {
setUploading(false) setUploading(false)
} }
@@ -209,7 +209,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
const handleAddDriveVideo = async () => { const handleAddDriveVideo = async () => {
if (!driveUrl.trim()) { if (!driveUrl.trim()) {
toast.error('Please enter a Google Drive URL') toast.error(t('artefacts.enterDriveUrl'))
return return
} }
@@ -218,13 +218,13 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/attachments`, { await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/attachments`, {
drive_url: driveUrl, drive_url: driveUrl,
}) })
toast.success('Video link added') toast.success(t('artefacts.videoLinkAdded'))
setShowVideoModal(false) setShowVideoModal(false)
setDriveUrl('') setDriveUrl('')
loadVersionData(selectedVersion.Id) loadVersionData(selectedVersion.Id)
} catch (err) { } catch (err) {
console.error('Add Drive link failed:', err) console.error('Add Drive link failed:', err)
toast.error('Failed to add video link') toast.error(t('artefacts.failedAddVideoLink'))
} finally { } finally {
setUploading(false) setUploading(false)
} }
@@ -233,10 +233,10 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
const handleDeleteAttachment = async (attId) => { const handleDeleteAttachment = async (attId) => {
try { try {
await api.delete(`/artefact-attachments/${attId}`) await api.delete(`/artefact-attachments/${attId}`)
toast.success('Attachment deleted') toast.success(t('artefacts.attachmentDeleted'))
loadVersionData(selectedVersion.Id) loadVersionData(selectedVersion.Id)
} catch (err) { } 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 { try {
const res = await api.post(`/artefacts/${artefact.Id}/submit-review`) const res = await api.post(`/artefacts/${artefact.Id}/submit-review`)
setReviewUrl(res.reviewUrl || res.data?.reviewUrl || '') setReviewUrl(res.reviewUrl || res.data?.reviewUrl || '')
toast.success('Submitted for review!') toast.success(t('artefacts.submittedForReview'))
onUpdate() onUpdate()
} catch (err) { } catch (err) {
toast.error('Failed to submit for review') toast.error(t('artefacts.failedSubmitReview'))
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
@@ -257,7 +257,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
const copyReviewLink = () => { const copyReviewLink = () => {
navigator.clipboard.writeText(reviewUrl) navigator.clipboard.writeText(reviewUrl)
setCopied(true) setCopied(true)
toast.success('Link copied to clipboard') toast.success(t('artefacts.linkCopied'))
setTimeout(() => setCopied(false), 2000) 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`, { await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/comments`, {
content: newComment.trim(), content: newComment.trim(),
}) })
toast.success('Comment added') toast.success(t('artefacts.commentAdded'))
setNewComment('') setNewComment('')
loadVersionData(selectedVersion.Id) loadVersionData(selectedVersion.Id)
} catch (err) { } catch (err) {
toast.error('Failed to add comment') toast.error(t('artefacts.failedAddComment'))
} finally { } finally {
setAddingComment(false) setAddingComment(false)
} }
@@ -282,16 +282,16 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
const handleUpdateField = async (field, value) => { const handleUpdateField = async (field, value) => {
try { try {
await api.patch(`/artefacts/${artefact.Id}`, { [field]: value || null }) await api.patch(`/artefacts/${artefact.Id}`, { [field]: value || null })
toast.success('Updated') toast.success(t('artefacts.updated'))
onUpdate() onUpdate()
} catch (err) { } catch (err) {
toast.error('Failed to update') toast.error(t('artefacts.failedUpdate'))
} }
} }
const handleSaveDraft = async () => { const handleSaveDraft = async () => {
if (!editTitle.trim()) { if (!editTitle.trim()) {
toast.error('Title is required') toast.error(t('artefacts.titleRequired'))
return return
} }
setSavingDraft(true) setSavingDraft(true)
@@ -300,10 +300,10 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
title: editTitle.trim(), title: editTitle.trim(),
description: editDescription.trim() || null, description: editDescription.trim() || null,
}) })
toast.success('Draft saved') toast.success(t('artefacts.draftSaved'))
onUpdate() onUpdate()
} catch (err) { } catch (err) {
toast.error('Failed to save draft') toast.error(t('artefacts.failedSaveDraft'))
} finally { } finally {
setSavingDraft(false) setSavingDraft(false)
} }
@@ -314,7 +314,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
try { try {
await onDelete(artefact.Id || artefact.id || artefact._id) await onDelete(artefact.Id || artefact.id || artefact._id)
} catch (err) { } catch (err) {
toast.error('Failed to delete') toast.error(t('artefacts.failedDelete'))
setDeleting(false) setDeleting(false)
} }
} }
@@ -377,17 +377,17 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
onClick={handleSaveDraft} onClick={handleSaveDraft}
disabled={savingDraft} 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" 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" /> <Save className="w-3.5 h-3.5" />
{savingDraft ? 'Saving...' : 'Save'} {savingDraft ? t('artefacts.savingDraft') : t('artefacts.saveDraft')}
</button> </button>
{onDelete && ( {onDelete && (
<button <button
onClick={() => setShowDeleteArtefactConfirm(true)} onClick={() => setShowDeleteArtefactConfirm(true)}
disabled={deleting} disabled={deleting}
className="p-1.5 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors" 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" /> <Trash2 className="w-4 h-4" />
</button> </button>
@@ -399,13 +399,13 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
{/* Description */} {/* Description */}
<div> <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 <textarea
value={editDescription} value={editDescription}
onChange={e => setEditDescription(e.target.value)} onChange={e => setEditDescription(e.target.value)}
rows={3} 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" 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> </div>
@@ -443,7 +443,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
{/* Approvers */} {/* Approvers */}
<div> <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 <ApproverMultiSelect
users={assignableUsers} users={assignableUsers}
selected={editApproverIds} selected={editApproverIds}
@@ -457,13 +457,13 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
{/* Version Timeline */} {/* Version Timeline */}
<div> <div>
<div className="flex items-center justify-between mb-3"> <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 <button
onClick={() => setShowNewVersionModal(true)} 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" 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" /> <Plus className="w-3 h-3" />
New Version {t('artefacts.newVersion')}
</button> </button>
</div> </div>
<ArtefactVersionTimeline <ArtefactVersionTimeline
@@ -481,13 +481,13 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
{artefact.type === 'copy' && ( {artefact.type === 'copy' && (
<div> <div>
<div className="flex items-center justify-between mb-3"> <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 <button
onClick={() => setShowLanguageModal(true)} 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" 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" /> <Plus className="w-3 h-3" />
Add Language {t('artefacts.addLanguage')}
</button> </button>
</div> </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"> <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" /> <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>
)} )}
</div> </div>
@@ -528,10 +528,10 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
{artefact.type === 'design' && ( {artefact.type === 'design' && (
<div> <div>
<div className="flex items-center justify-between mb-3"> <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"> <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" /> <Upload className="w-3 h-3" />
{uploading ? 'Uploading...' : 'Upload Image'} {uploading ? t('artefacts.uploading') : t('artefacts.uploadImage')}
<input <input
type="file" type="file"
className="hidden" 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"> <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" /> <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>
)} )}
</div> </div>
@@ -578,13 +578,13 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
{artefact.type === 'video' && ( {artefact.type === 'video' && (
<div> <div>
<div className="flex items-center justify-between mb-3"> <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 <button
onClick={() => setShowVideoModal(true)} 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" 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" /> <Plus className="w-3 h-3" />
Add Video {t('artefacts.addVideoBtn')}
</button> </button>
</div> </div>
@@ -595,7 +595,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
{att.drive_url ? ( {att.drive_url ? (
<div> <div>
<div className="flex items-center justify-between mb-2"> <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 <button
onClick={() => setConfirmDeleteAttId(att.Id)} onClick={() => setConfirmDeleteAttId(att.Id)}
className="text-red-600 hover:text-red-700" 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"> <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" /> <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>
)} )}
</div> </div>
@@ -645,7 +645,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
{selectedVersion && ( {selectedVersion && (
<div className="border-t border-border pt-6"> <div className="border-t border-border pt-6">
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3"> <h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">
Comments ({comments.length}) {t('artefacts.comments')} ({comments.length})
</h4> </h4>
<div className="space-y-3 mb-4"> <div className="space-y-3 mb-4">
@@ -677,7 +677,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
value={newComment} value={newComment}
onChange={e => setNewComment(e.target.value)} onChange={e => setNewComment(e.target.value)}
onKeyPress={e => e.key === 'Enter' && handleAddComment()} 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" 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 <button
@@ -685,7 +685,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
disabled={addingComment || !newComment.trim()} 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" 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> </button>
</div> </div>
</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" 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" /> <ExternalLink className="w-4 h-4" />
{submitting ? 'Submitting...' : 'Submit for Review'} {submitting ? t('artefacts.submitting') : t('artefacts.submitForReview')}
</button> </button>
</div> </div>
)} )}
@@ -708,7 +708,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
{/* Review Link */} {/* Review Link */}
{reviewUrl && ( {reviewUrl && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4"> <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"> <div className="flex items-center gap-2">
<input <input
type="text" type="text"
@@ -729,7 +729,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
{/* Feedback */} {/* Feedback */}
{artefact.feedback && ( {artefact.feedback && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4"> <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> <p className="text-sm text-amber-800 whitespace-pre-wrap">{artefact.feedback}</p>
</div> </div>
)} )}
@@ -737,7 +737,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
{/* Approval Info */} {/* Approval Info */}
{artefact.status === 'approved' && artefact.approved_by_name && ( {artefact.status === 'approved' && artefact.approved_by_name && (
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4"> <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 && ( {artefact.approved_at && (
<div className="text-sm text-emerald-700 mt-1"> <div className="text-sm text-emerald-700 mt-1">
{new Date(artefact.approved_at).toLocaleString()} {new Date(artefact.approved_at).toLocaleString()}
@@ -748,10 +748,10 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
</div> </div>
{/* Language Modal */} {/* 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 className="space-y-4">
<div> <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 <select
value={languageForm.language_code} value={languageForm.language_code}
onChange={e => { 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" 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 {AVAILABLE_LANGUAGES
.filter(lang => !(versionData?.texts || []).some(t => t.language_code === lang.code)) .filter(lang => !(versionData?.texts || []).some(t => t.language_code === lang.code))
.map(lang => ( .map(lang => (
@@ -771,13 +771,13 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
</select> </select>
</div> </div>
<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 <textarea
value={languageForm.content} value={languageForm.content}
onChange={e => setLanguageForm(f => ({ ...f, content: e.target.value }))} onChange={e => setLanguageForm(f => ({ ...f, content: e.target.value }))}
rows={8} 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" 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>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border"> <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)} onClick={() => setShowLanguageModal(false)}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg" className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
> >
Cancel {t('common.cancel')}
</button> </button>
<button <button
onClick={handleAddLanguage} onClick={handleAddLanguage}
disabled={savingLanguage} 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" 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> </button>
</div> </div>
</div> </div>
</Modal> </Modal>
{/* New Version 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 className="space-y-4">
<div> <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 <textarea
value={newVersionNotes} value={newVersionNotes}
onChange={e => setNewVersionNotes(e.target.value)} onChange={e => setNewVersionNotes(e.target.value)}
rows={3} 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" 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> </div>
{artefact.type === 'copy' && versions.length > 0 && ( {artefact.type === 'copy' && versions.length > 0 && (
@@ -819,7 +819,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
onChange={e => setCopyFromPrevious(e.target.checked)} onChange={e => setCopyFromPrevious(e.target.checked)}
className="w-4 h-4 text-brand-primary border-border rounded focus:ring-brand-primary" 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> </label>
)} )}
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border"> <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)} onClick={() => setShowNewVersionModal(false)}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg" className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
> >
Cancel {t('common.cancel')}
</button> </button>
<button <button
onClick={handleCreateVersion} onClick={handleCreateVersion}
disabled={creatingVersion} 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" 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> </button>
</div> </div>
</div> </div>
</Modal> </Modal>
{/* Video 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="space-y-4">
<div className="flex items-center gap-2 border-b border-border pb-3"> <div className="flex items-center gap-2 border-b border-border pb-3">
<button <button
@@ -852,7 +852,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
: 'bg-surface-secondary text-text-secondary hover:bg-surface-tertiary' : 'bg-surface-secondary text-text-secondary hover:bg-surface-tertiary'
}`} }`}
> >
Upload File {t('artefacts.uploadFile')}
</button> </button>
<button <button
onClick={() => setVideoMode('drive')} 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' : 'bg-surface-secondary text-text-secondary hover:bg-surface-tertiary'
}`} }`}
> >
Google Drive Link {t('artefacts.googleDriveLink')}
</button> </button>
</div> </div>
@@ -872,9 +872,9 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
<Upload className="w-8 h-8 text-text-tertiary" /> <Upload className="w-8 h-8 text-text-tertiary" />
<div className="text-center"> <div className="text-center">
<span className="text-sm font-medium text-text-primary"> <span className="text-sm font-medium text-text-primary">
{uploading ? 'Uploading...' : 'Choose video file'} {uploading ? t('artefacts.uploading') : t('artefacts.chooseVideoFile')}
</span> </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> </div>
<input <input
type="file" type="file"
@@ -887,7 +887,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
</div> </div>
) : ( ) : (
<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 <input
type="text" type="text"
value={driveUrl} 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" 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"> <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> </p>
<div className="flex justify-end gap-3 mt-4"> <div className="flex justify-end gap-3 mt-4">
<button <button
onClick={() => setShowVideoModal(false)} onClick={() => setShowVideoModal(false)}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg" className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
> >
Cancel {t('common.cancel')}
</button> </button>
<button <button
onClick={handleAddDriveVideo} onClick={handleAddDriveVideo}
disabled={uploading || !driveUrl.trim()} 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" 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> </button>
</div> </div>
</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 { ChevronDown, LogOut, Shield, Lock, AlertCircle, CheckCircle } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { getInitials, api } from '../utils/api' import { getInitials, api } from '../utils/api'
import { useLanguage } from '../i18n/LanguageContext'
import Modal from './Modal' import Modal from './Modal'
import ThemeToggle from './ThemeToggle' import ThemeToggle from './ThemeToggle'
const pageTitles = { const PAGE_TITLE_KEYS = {
'/': 'Dashboard', '/': 'header.dashboard',
'/posts': 'Post Production', '/posts': 'header.posts',
'/assets': 'Assets', '/assets': 'header.assets',
'/campaigns': 'Campaigns', '/campaigns': 'header.campaigns',
'/finance': 'Finance', '/finance': 'header.finance',
'/projects': 'Projects', '/projects': 'header.projects',
'/tasks': 'My Tasks', '/tasks': 'header.tasks',
'/team': 'Team', '/team': 'header.team',
'/users': 'User Management', '/users': 'header.users',
} }
const ROLE_INFO = { const ROLE_INFO = {
superadmin: { label: 'Superadmin', color: 'bg-purple-100 text-purple-700', icon: '👑' }, superadmin: { labelKey: 'header.superadmin', color: 'bg-purple-100 text-purple-700', icon: '👑' },
manager: { label: 'Manager', color: 'bg-blue-100 text-blue-700', icon: '📊' }, manager: { labelKey: 'header.manager', color: 'bg-blue-100 text-blue-700', icon: '📊' },
contributor: { label: 'Contributor', color: 'bg-green-100 text-green-700', icon: '✏️' }, contributor: { labelKey: 'header.contributor', color: 'bg-green-100 text-green-700', icon: '✏️' },
} }
export default function Header() { export default function Header() {
const { user, logout } = useAuth() const { user, logout } = useAuth()
const { t } = useLanguage()
const [showDropdown, setShowDropdown] = useState(false) const [showDropdown, setShowDropdown] = useState(false)
const [showPasswordModal, setShowPasswordModal] = useState(false) const [showPasswordModal, setShowPasswordModal] = useState(false)
const [passwordForm, setPasswordForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' }) const [passwordForm, setPasswordForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' })
@@ -36,10 +38,10 @@ export default function Header() {
const location = useLocation() const location = useLocation()
function getPageTitle(pathname) { function getPageTitle(pathname) {
if (pageTitles[pathname]) return pageTitles[pathname] if (PAGE_TITLE_KEYS[pathname]) return t(PAGE_TITLE_KEYS[pathname])
if (pathname.startsWith('/projects/')) return 'Project Details' if (pathname.startsWith('/projects/')) return t('header.projectDetails')
if (pathname.startsWith('/campaigns/')) return 'Campaign Details' if (pathname.startsWith('/campaigns/')) return t('header.campaignDetails')
return 'Page' return t('header.page')
} }
const pageTitle = getPageTitle(location.pathname) const pageTitle = getPageTitle(location.pathname)
@@ -57,11 +59,11 @@ export default function Header() {
setPasswordError('') setPasswordError('')
setPasswordSuccess('') setPasswordSuccess('')
if (passwordForm.newPassword !== passwordForm.confirmPassword) { if (passwordForm.newPassword !== passwordForm.confirmPassword) {
setPasswordError('New passwords do not match') setPasswordError(t('header.passwordMismatch'))
return return
} }
if (passwordForm.newPassword.length < 6) { if (passwordForm.newPassword.length < 6) {
setPasswordError('New password must be at least 6 characters') setPasswordError(t('header.passwordMinLength'))
return return
} }
setPasswordSaving(true) setPasswordSaving(true)
@@ -70,11 +72,11 @@ export default function Header() {
currentPassword: passwordForm.currentPassword, currentPassword: passwordForm.currentPassword,
newPassword: passwordForm.newPassword, newPassword: passwordForm.newPassword,
}) })
setPasswordSuccess('Password updated successfully') setPasswordSuccess(t('header.passwordUpdateSuccess'))
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' }) setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' })
setTimeout(() => setShowPasswordModal(false), 1500) setTimeout(() => setShowPasswordModal(false), 1500)
} catch (err) { } catch (err) {
setPasswordError(err.message || 'Failed to change password') setPasswordError(err.message || t('header.passwordUpdateFailed'))
} finally { } finally {
setPasswordSaving(false) setPasswordSaving(false)
} }
@@ -121,7 +123,7 @@ export default function Header() {
{user?.name || 'User'} {user?.name || 'User'}
</p> </p>
<p className={`text-[10px] font-medium ${roleInfo.color.split(' ')[1]}`}> <p className={`text-[10px] font-medium ${roleInfo.color.split(' ')[1]}`}>
{roleInfo.icon} {roleInfo.label} {roleInfo.icon} {t(roleInfo.labelKey)}
</p> </p>
</div> </div>
<ChevronDown className={`w-4 h-4 text-text-tertiary transition-transform ${showDropdown ? 'rotate-180' : ''}`} /> <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> <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}`}> <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> <span>{roleInfo.icon}</span>
{roleInfo.label} {t(roleInfo.labelKey)}
</div> </div>
</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" 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" /> <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> </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" 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" /> <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>
<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" 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" /> <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> </button>
</div> </div>
</div> </div>
@@ -180,10 +182,10 @@ export default function Header() {
</header> </header>
{/* Change Password Modal */} {/* 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 className="space-y-4">
<div> <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 <input
type="password" type="password"
value={passwordForm.currentPassword} value={passwordForm.currentPassword}
@@ -193,7 +195,7 @@ export default function Header() {
/> />
</div> </div>
<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 <input
type="password" type="password"
value={passwordForm.newPassword} value={passwordForm.newPassword}
@@ -204,7 +206,7 @@ export default function Header() {
/> />
</div> </div>
<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 <input
type="password" type="password"
value={passwordForm.confirmPassword} value={passwordForm.confirmPassword}
@@ -234,14 +236,14 @@ export default function Header() {
onClick={() => setShowPasswordModal(false)} onClick={() => setShowPasswordModal(false)}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg" className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
> >
Cancel {t('common.cancel')}
</button> </button>
<button <button
onClick={handlePasswordChange} onClick={handlePasswordChange}
disabled={!passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword || passwordSaving} 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" 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> </button>
</div> </div>
</div> </div>

View File

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

View File

@@ -240,32 +240,32 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
<div className="p-4 space-y-6"> <div className="p-4 space-y-6">
{/* Submitter Info */} {/* Submitter Info */}
<div className="bg-surface-secondary rounded-lg p-4"> <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 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">{t('issues.nameLabel')}</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.emailLabel')}</span> <span className="text-text-primary">{issueData.submitter_email}</span></div>
{issueData.submitter_phone && ( {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>
</div> </div>
{/* Description */} {/* Description */}
<div> <div>
<h3 className="text-sm font-semibold text-text-primary mb-2">Description</h3> <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 || 'No description provided'}</p> <p className="text-sm text-text-secondary whitespace-pre-wrap">{issueData.description || t('issues.noDescription')}</p>
</div> </div>
{/* Assigned To */} {/* Assigned To */}
<div> <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 <select
value={assignedTo} value={assignedTo}
onChange={(e) => handleAssignmentChange(e.target.value)} 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" 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) => ( {teamMembers.map((member) => (
<option key={member.id || member._id} value={member.id || member._id}> <option key={member.id || member._id} value={member.id || member._id}>
{member.name} {member.name}
@@ -303,7 +303,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
{/* Brand */} {/* Brand */}
<div> <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 <select
value={issueData.brand_id || ''} value={issueData.brand_id || ''}
onChange={async (e) => { 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" 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) => ( {(brands || []).map((b) => (
<option key={b._id || b.Id} value={b._id || b.Id}>{b.name}</option> <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> <div>
<label className="block text-sm font-semibold text-text-primary mb-2 flex items-center gap-2"> <label className="block text-sm font-semibold text-text-primary mb-2 flex items-center gap-2">
<Lock className="w-4 h-4" /> <Lock className="w-4 h-4" />
Internal Notes (Staff Only) {t('issues.internalNotes')}
</label> </label>
<textarea <textarea
value={internalNotes} value={internalNotes}
onChange={(e) => setInternalNotes(e.target.value)} onChange={(e) => setInternalNotes(e.target.value)}
onBlur={handleNotesChange} onBlur={handleNotesChange}
rows={4} 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" 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> </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"> <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"> <h3 className="text-sm font-semibold text-emerald-900 mb-2 flex items-center gap-2">
<CheckCircle2 className="w-4 h-4" /> <CheckCircle2 className="w-4 h-4" />
Resolution Summary (Public) {t('issues.resolutionSummary')}
</h3> </h3>
<p className="text-sm text-emerald-800 whitespace-pre-wrap">{issueData.resolution_summary}</p> <p className="text-sm text-emerald-800 whitespace-pre-wrap">{issueData.resolution_summary}</p>
{issueData.resolved_at && ( {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> </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" 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" /> <Check className="w-4 h-4 inline mr-1" />
Acknowledge {t('issues.acknowledge')}
</button> </button>
)} )}
{(issueData.status === 'new' || issueData.status === 'acknowledged') && ( {(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" 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" /> <Clock className="w-4 h-4 inline mr-1" />
Start Work {t('issues.startWork')}
</button> </button>
)} )}
<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" 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" /> <CheckCircle2 className="w-4 h-4 inline mr-1" />
Resolve {t('issues.resolve')}
</button> </button>
<button <button
onClick={() => setShowDeclineModal(true)} 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" 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" /> <XCircle className="w-4 h-4 inline mr-1" />
Decline {t('issues.decline')}
</button> </button>
</div> </div>
)} )}
{/* Tracking Link */} {/* Tracking Link */}
<div> <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"> <div className="flex gap-2">
<input <input
type="text" type="text"
@@ -417,7 +417,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
{/* Updates Timeline */} {/* Updates Timeline */}
<div> <div>
<h3 className="text-sm font-semibold text-text-primary mb-3 flex items-center gap-2"> <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> <span className="text-xs text-text-tertiary font-normal">({updates.length})</span>
</h3> </h3>
@@ -426,7 +426,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
<textarea <textarea
value={newUpdate} value={newUpdate}
onChange={(e) => setNewUpdate(e.target.value)} onChange={(e) => setNewUpdate(e.target.value)}
placeholder="Add an update..." placeholder={t('issues.addUpdatePlaceholder')}
rows={3} 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" 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" className="rounded"
/> />
<Eye className="w-4 h-4" /> <Eye className="w-4 h-4" />
Make public (visible to submitter) {t('issues.makePublic')}
</label> </label>
<button <button
onClick={handleAddUpdate} 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" 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" /> <Send className="w-4 h-4" />
Add Update {t('issues.addUpdate')}
</button> </button>
</div> </div>
</div> </div>
@@ -477,7 +477,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
</div> </div>
))} ))}
{updates.length === 0 && ( {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>
</div> </div>
@@ -485,7 +485,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
{/* Attachments */} {/* Attachments */}
<div> <div>
<h3 className="text-sm font-semibold text-text-primary mb-3 flex items-center gap-2"> <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> <span className="text-xs text-text-tertiary font-normal">({attachments.length})</span>
</h3> </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"> <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" /> <Upload className="w-6 h-6 mx-auto mb-2 text-text-tertiary" />
<p className="text-sm text-text-secondary"> <p className="text-sm text-text-secondary">
{uploadingFile ? 'Uploading...' : 'Click to upload file'} {uploadingFile ? t('issues.uploading') : t('issues.clickToUpload')}
</p> </p>
</div> </div>
</label> </label>
@@ -520,7 +520,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-xs text-brand-primary hover:underline" className="text-xs text-brand-primary hover:underline"
> >
Download {t('issues.download')}
</a> </a>
<button onClick={() => setConfirmDeleteAttId(att.Id || att.id)} className="p-1 hover:bg-surface-tertiary rounded"> <button onClick={() => setConfirmDeleteAttId(att.Id || att.id)} className="p-1 hover:bg-surface-tertiary rounded">
<Trash2 className="w-4 h-4 text-red-600" /> <Trash2 className="w-4 h-4 text-red-600" />
@@ -529,7 +529,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
</div> </div>
))} ))}
{attachments.length === 0 && ( {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>
</div> </div>
@@ -538,13 +538,13 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
{/* Resolve Modal */} {/* Resolve Modal */}
{showResolveModal && ( {showResolveModal && (
<Modal isOpen title="Resolve Issue" onClose={() => setShowResolveModal(false)}> <Modal isOpen title={t('issues.resolveIssue')} onClose={() => setShowResolveModal(false)}>
<div className="space-y-4"> <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 <textarea
value={resolutionSummary} value={resolutionSummary}
onChange={(e) => setResolutionSummary(e.target.value)} onChange={(e) => setResolutionSummary(e.target.value)}
placeholder="Explain how this issue was resolved..." placeholder={t('issues.resolutionPlaceholder')}
rows={5} 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" 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)} onClick={() => setShowResolveModal(false)}
className="px-4 py-2 bg-surface-secondary text-text-primary rounded-lg text-sm font-medium hover:bg-surface-tertiary" 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>
<button <button
onClick={handleResolve} onClick={handleResolve}
disabled={!resolutionSummary.trim() || saving} 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" 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> </button>
</div> </div>
</div> </div>
@@ -569,13 +569,13 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
{/* Decline Modal */} {/* Decline Modal */}
{showDeclineModal && ( {showDeclineModal && (
<Modal isOpen title="Decline Issue" onClose={() => setShowDeclineModal(false)}> <Modal isOpen title={t('issues.declineIssue')} onClose={() => setShowDeclineModal(false)}>
<div className="space-y-4"> <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 <textarea
value={resolutionSummary} value={resolutionSummary}
onChange={(e) => setResolutionSummary(e.target.value)} onChange={(e) => setResolutionSummary(e.target.value)}
placeholder="Explain why this issue cannot be addressed..." placeholder={t('issues.declinePlaceholder')}
rows={5} 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" 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)} onClick={() => setShowDeclineModal(false)}
className="px-4 py-2 bg-surface-secondary text-text-primary rounded-lg text-sm font-medium hover:bg-surface-tertiary" 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>
<button <button
onClick={handleDecline} onClick={handleDecline}
disabled={!resolutionSummary.trim() || saving} 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" 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> </button>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,8 @@
import { useState, useEffect, useRef } from 'react' 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 { useLanguage } from '../i18n/LanguageContext'
import { api, PLATFORMS, getBrandColor } from '../utils/api' import { api, PLATFORMS, getBrandColor } from '../utils/api'
import ApproverMultiSelect from './ApproverMultiSelect'
import CommentsSection from './CommentsSection' import CommentsSection from './CommentsSection'
import Modal from './Modal' import Modal from './Modal'
import SlidePanel from './SlidePanel' import SlidePanel from './SlidePanel'
@@ -9,7 +10,9 @@ import CollapsibleSection from './CollapsibleSection'
export default function PostDetailPanel({ post, onClose, onSave, onDelete, brands, teamMembers, campaigns }) { export default function PostDetailPanel({ post, onClose, onSave, onDelete, brands, teamMembers, campaigns }) {
const { t, lang } = useLanguage() 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 [form, setForm] = useState({})
const [dirty, setDirty] = useState(false) const [dirty, setDirty] = useState(false)
const [saving, setSaving] = 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] : []), platforms: post.platforms || (post.platform ? [post.platform] : []),
status: post.status || 'draft', status: post.status || 'draft',
assigned_to: post.assignedTo || post.assigned_to || '', 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 || '', notes: post.notes || '',
campaign_id: post.campaignId || post.campaign_id || '', campaign_id: post.campaignId || post.campaign_id || '',
publication_links: post.publication_links || post.publicationLinks || [], 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) setDirty(isCreateMode)
setPublishError('') setPublishError('')
@@ -53,6 +57,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
{ value: 'draft', label: t('posts.status.draft') }, { value: 'draft', label: t('posts.status.draft') },
{ value: 'in_review', label: t('posts.status.in_review') }, { value: 'in_review', label: t('posts.status.in_review') },
{ value: 'approved', label: t('posts.status.approved') }, { value: 'approved', label: t('posts.status.approved') },
{ value: 'rejected', label: t('posts.status.rejected') },
{ value: 'scheduled', label: t('posts.status.scheduled') }, { value: 'scheduled', label: t('posts.status.scheduled') },
{ value: 'published', label: t('posts.status.published') }, { value: 'published', label: t('posts.status.published') },
] ]
@@ -91,6 +96,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
notes: form.notes, notes: form.notes,
campaign_id: form.campaign_id ? Number(form.campaign_id) : null, campaign_id: form.campaign_id ? Number(form.campaign_id) : null,
publication_links: form.publication_links || [], 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) { 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 === 'scheduled' ? 'bg-purple-100 text-purple-700' :
form.status === 'approved' ? 'bg-blue-100 text-blue-700' : form.status === 'approved' ? 'bg-blue-100 text-blue-700' :
form.status === 'in_review' ? 'bg-amber-100 text-amber-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' 'bg-gray-100 text-gray-600'
}`}> }`}>
{statusOptions.find(s => s.value === form.status)?.label} {statusOptions.find(s => s.value === form.status)?.label}
@@ -235,9 +242,47 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
return ( return (
<> <>
<SlidePanel onClose={onClose} maxWidth="520px" header={header}> <SlidePanel onClose={onClose} maxWidth="520px" header={header} footer={
{/* Details Section */} <div className="bg-surface border-t border-border px-5 py-3 flex items-center gap-2 shrink-0">
<CollapsibleSection title={t('posts.details')}> {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 className="px-5 pb-4 space-y-3">
<div> <div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.description')}</label> <label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.description')}</label>
@@ -275,18 +320,23 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
</div> </div>
</div> </div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.notes')}</label>
<input
type="text"
value={form.notes}
onChange={e => update('notes', 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"
placeholder={t('posts.additionalNotes')}
/>
</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 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> <div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.status')}</label> <label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.status')}</label>
<select <select
@@ -297,28 +347,27 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)} {statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select> </select>
</div> </div>
</div>
<div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.scheduledDate')}</label> <label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.scheduledDate')}</label>
<input <input
type="datetime-local" type="date"
value={form.scheduled_date} value={form.scheduled_date}
onChange={e => update('scheduled_date', e.target.value)} 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" 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> </div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.notes')}</label>
<input <div>
type="text" <label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.assignTo')}</label>
value={form.notes} <select
onChange={e => update('notes', e.target.value)} value={form.assigned_to}
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" onChange={e => update('assigned_to', e.target.value)}
placeholder={t('posts.additionalNotes')} 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> <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>
{publishError && ( {publishError && (
@@ -326,27 +375,18 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
{publishError} {publishError}
</div> </div>
)} )}
</div>
</CollapsibleSection>
<div className="flex items-center gap-2 pt-2"> {/* Approval Section */}
{dirty && ( <CollapsibleSection title={t('posts.approval')}>
<button <div className="px-5 pb-4">
onClick={handleSave} <label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.approvers')}</label>
disabled={!form.title || saving} <ApproverMultiSelect
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' : ''}`} users={teamMembers || []}
> selected={form.approver_ids || []}
{isCreateMode ? t('posts.createPost') : t('posts.saveChanges')} onChange={ids => update('approver_ids', ids)}
</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>
</div> </div>
</CollapsibleSection> </CollapsibleSection>
@@ -437,76 +477,196 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
</span> </span>
) : null} ) : null}
> >
<div className="px-5 pb-4"> <div className="px-5 pb-4 space-y-4">
{attachments.length > 0 && ( {/* Images */}
<div className="grid grid-cols-2 gap-2 mb-3"> {(() => {
{attachments.map(att => { const images = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('image/'))
const isImage = att.mime_type?.startsWith('image/') || att.mimeType?.startsWith('image/') return (
const attUrl = att.url || `/api/uploads/${att.filename}` <div>
const name = att.original_name || att.originalName || att.filename <div className="flex items-center justify-between mb-2">
const attId = att.id || att._id <div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary">
return ( <ImageIcon className="w-3.5 h-3.5" />
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-white"> {t('posts.images')}
<div className="h-20 relative"> {images.length > 0 && <span className="text-text-tertiary">({images.length})</span>}
{isImage ? ( </div>
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="block h-full"> <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">
<img src={attUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" /> <Upload className="w-3 h-3" />
</a> {t('posts.addImage')}
) : ( <input ref={imageInputRef} type="file" multiple accept="image/*" className="hidden"
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="absolute inset-0 flex items-center gap-2 p-3"> 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">
<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>
<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 className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
</div>
)
})}
</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" /> <FileText className="w-6 h-6 text-text-tertiary shrink-0" />
<span className="text-xs text-text-secondary truncate">{name}</span> <span className="text-xs text-text-secondary truncate">{name}</span>
</a> </a>
)} <button onClick={() => handleDeleteAttachment(attId)}
<button 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"
onClick={() => handleDeleteAttachment(attId)} title={t('common.delete')}><X className="w-2.5 h-2.5" /></button>
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" </div>
title={t('common.delete')} )
> })}
<X className="w-2.5 h-2.5" /> </div>
</button> </div>
</div> ) : null
<div className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div> })()}
</div>
)
})}
</div>
)}
{/* Drag and drop zone */}
<div <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' 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) }} onDragEnter={e => { e.preventDefault(); setDragActive(true) }}
onDragLeave={e => { e.preventDefault(); setDragActive(false) }} onDragLeave={e => { e.preventDefault(); setDragActive(false) }}
onDragOver={e => e.preventDefault()} onDragOver={e => e.preventDefault()}
onDrop={handleDrop} onDrop={handleDrop}
> >
<input <Upload className={`w-4 h-4 text-text-tertiary mx-auto mb-1 ${uploading ? 'animate-pulse' : ''}`} />
ref={fileInputRef} <p className="text-[11px] text-text-secondary">
type="file" {dragActive ? t('posts.dropFiles') : t('posts.dragToUpload')}
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')}
</p> </p>
</div> </div>
<button <button
type="button" type="button"
onClick={openAssetPicker} 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" /> <FolderOpen className="w-4 h-4" />
{t('posts.attachFromAssets')} {t('posts.attachFromAssets')}
</button> </button>
{showAssetPicker && ( {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"> <div className="flex items-center justify-between mb-2">
<p className="text-xs font-medium text-text-secondary">{t('posts.selectAssets')}</p> <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"> <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' 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( return createPortal(
<> <>
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in z-[9998]" onClick={onClose} /> <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"> <div className="flex-1 overflow-y-auto">
{children} {children}
</div> </div>
{footer}
</div> </div>
</>, </>,
document.body document.body

View File

@@ -30,6 +30,7 @@
"common.noResults": "لا توجد نتائج", "common.noResults": "لا توجد نتائج",
"common.loading": "جاري التحميل...", "common.loading": "جاري التحميل...",
"common.unassigned": "غير مُسند", "common.unassigned": "غير مُسند",
"common.close": "إغلاق",
"common.required": "مطلوب", "common.required": "مطلوب",
"common.saveFailed": "فشل الحفظ. حاول مجدداً.", "common.saveFailed": "فشل الحفظ. حاول مجدداً.",
"common.updateFailed": "فشل التحديث. حاول مجدداً.", "common.updateFailed": "فشل التحديث. حاول مجدداً.",
@@ -130,6 +131,7 @@
"posts.status.approved": "مُعتمد", "posts.status.approved": "مُعتمد",
"posts.status.scheduled": "مجدول", "posts.status.scheduled": "مجدول",
"posts.status.published": "منشور", "posts.status.published": "منشور",
"posts.status.rejected": "مرفوض",
"tasks.title": "المهام", "tasks.title": "المهام",
"tasks.newTask": "مهمة جديدة", "tasks.newTask": "مهمة جديدة",
"tasks.editTask": "تعديل المهمة", "tasks.editTask": "تعديل المهمة",
@@ -693,5 +695,198 @@
"settings.roleName": "اسم الدور", "settings.roleName": "اسم الدور",
"settings.roleColor": "اللون", "settings.roleColor": "اللون",
"settings.deleteRoleConfirm": "هل أنت متأكد من حذف هذا الدور؟", "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.noResults": "No results",
"common.loading": "Loading...", "common.loading": "Loading...",
"common.unassigned": "Unassigned", "common.unassigned": "Unassigned",
"common.close": "Close",
"common.required": "Required", "common.required": "Required",
"common.saveFailed": "Failed to save. Please try again.", "common.saveFailed": "Failed to save. Please try again.",
"common.updateFailed": "Failed to update. Please try again.", "common.updateFailed": "Failed to update. Please try again.",
@@ -130,6 +131,7 @@
"posts.status.approved": "Approved", "posts.status.approved": "Approved",
"posts.status.scheduled": "Scheduled", "posts.status.scheduled": "Scheduled",
"posts.status.published": "Published", "posts.status.published": "Published",
"posts.status.rejected": "Rejected",
"tasks.title": "Tasks", "tasks.title": "Tasks",
"tasks.newTask": "New Task", "tasks.newTask": "New Task",
"tasks.editTask": "Edit Task", "tasks.editTask": "Edit Task",
@@ -693,5 +695,198 @@
"settings.roleName": "Role name", "settings.roleName": "Role name",
"settings.roleColor": "Color", "settings.roleColor": "Color",
"settings.deleteRoleConfirm": "Are you sure you want to delete this role?", "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 BulkSelectBar from '../components/BulkSelectBar'
import Modal from '../components/Modal' import Modal from '../components/Modal'
const TYPE_OPTIONS = [ const TYPE_OPTION_KEYS = [
{ value: 'request', label: 'Request' }, { value: 'request', labelKey: 'issues.typeRequest' },
{ value: 'correction', label: 'Correction' }, { value: 'correction', labelKey: 'issues.typeCorrection' },
{ value: 'complaint', label: 'Complaint' }, { value: 'complaint', labelKey: 'issues.typeComplaint' },
{ value: 'suggestion', label: 'Suggestion' }, { value: 'suggestion', labelKey: 'issues.typeSuggestion' },
{ value: 'other', label: 'Other' }, { value: 'other', labelKey: 'issues.typeOther' },
] ]
// Issue-specific status order for the kanban board // Issue-specific status order for the kanban board
@@ -148,7 +148,7 @@ export default function Issues() {
toast.success(t('issues.statusUpdated')) toast.success(t('issues.statusUpdated'))
} catch (err) { } catch (err) {
console.error('Move issue failed:', err) console.error('Move issue failed:', err)
toast.error('Failed to update status') toast.error(t('issues.failedToUpdateStatus'))
// Rollback on error // Rollback on error
setIssues(prev) setIssues(prev)
} }
@@ -157,7 +157,7 @@ export default function Issues() {
const handleBulkDelete = async () => { const handleBulkDelete = async () => {
try { try {
await api.post('/issues/bulk-delete', { ids: [...selectedIds] }) await api.post('/issues/bulk-delete', { ids: [...selectedIds] })
toast.success('Issues deleted') toast.success(t('issues.issuesDeleted'))
setSelectedIds(new Set()) setSelectedIds(new Set())
setShowBulkDeleteConfirm(false) setShowBulkDeleteConfirm(false)
loadData() loadData()
@@ -215,9 +215,9 @@ export default function Issues() {
<div> <div>
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-2"> <h1 className="text-2xl font-bold text-text-primary flex items-center gap-2">
<AlertCircle className="w-7 h-7" /> <AlertCircle className="w-7 h-7" />
Issues {t('issues.title')}
</h1> </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>
<div className="flex items-center gap-3"> <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" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input <input
type="text" type="text"
placeholder="Search issues..." placeholder={t('issues.searchPlaceholder')}
value={searchTerm} value={searchTerm}
onChange={e => setSearchTerm(e.target.value)} 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" 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)} 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" 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]) => ( {Object.entries(ISSUE_STATUS_CONFIG).map(([key, config]) => (
<option key={key} value={key}>{config.label}</option> <option key={key} value={key}>{config.label}</option>
))} ))}
@@ -302,7 +302,7 @@ export default function Issues() {
onChange={e => updateFilter('category', e.target.value)} 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" 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>)} {categories.map(cat => <option key={cat} value={cat}>{cat}</option>)}
</select> </select>
@@ -311,8 +311,8 @@ export default function Issues() {
onChange={e => updateFilter('type', e.target.value)} 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" 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> <option value="">{t('issues.allTypes')}</option>
{TYPE_OPTIONS.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)} {TYPE_OPTION_KEYS.map(opt => <option key={opt.value} value={opt.value}>{t(opt.labelKey)}</option>)}
</select> </select>
<select <select
@@ -320,7 +320,7 @@ export default function Issues() {
onChange={e => updateFilter('brand', e.target.value)} 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" 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 => ( {(brands || []).map(b => (
<option key={b._id || b.Id} value={b._id || b.Id}>{b.name}</option> <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)} 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" 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]) => ( {Object.entries(PRIORITY_CONFIG).map(([key, config]) => (
<option key={key} value={key}>{config.label}</option> <option key={key} value={key}>{config.label}</option>
))} ))}
@@ -350,7 +350,7 @@ export default function Issues() {
{hasActiveFilters && ( {hasActiveFilters && (
<button onClick={clearFilters} className="px-3 py-2 rounded-lg text-sm font-medium text-text-tertiary hover:text-text-primary"> <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> </button>
)} )}
</div> </div>
@@ -360,8 +360,8 @@ export default function Issues() {
filteredIssues.length === 0 ? ( filteredIssues.length === 0 ? (
<EmptyState <EmptyState
icon={AlertCircle} icon={AlertCircle}
title="No issues found" title={t('issues.noIssuesFound')}
description={hasActiveFilters ? 'Try adjusting your filters' : 'No issues have been submitted yet'} description={hasActiveFilters ? t('issues.tryAdjustingFilters') : t('issues.noIssuesSubmitted')}
/> />
) : ( ) : (
<KanbanBoard <KanbanBoard
@@ -394,8 +394,8 @@ export default function Issues() {
sortedIssues.length === 0 ? ( sortedIssues.length === 0 ? (
<EmptyState <EmptyState
icon={AlertCircle} icon={AlertCircle}
title="No issues found" title={t('issues.noIssuesFound')}
description={hasActiveFilters ? 'Try adjusting your filters' : 'No issues have been submitted yet'} description={hasActiveFilters ? t('issues.tryAdjustingFilters') : t('issues.noIssuesSubmitted')}
/> />
) : ( ) : (
<div className="bg-surface rounded-lg border border-border overflow-hidden"> <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" /> <input type="checkbox" checked={selectedIds.size === sortedIssues.length && sortedIssues.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
</th> </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')}> <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>
<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">{t('issues.tableSubmitter')}</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">{t('issues.tableBrand')}</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">{t('issues.tableCategory')}</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.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')}> <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>
<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')}> <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>
<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')}> <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> </th>
</tr> </tr>
</thead> </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 text-text-secondary">{issue.category || '—'}</td>
<td className="px-4 py-3 text-sm"> <td className="px-4 py-3 text-sm">
<span className="text-xs px-2 py-1 rounded-full bg-surface-tertiary text-text-secondary"> <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> </span>
</td> </td>
<td className="px-4 py-3 text-sm"> <td className="px-4 py-3 text-sm">

View File

@@ -79,14 +79,17 @@ export default function PostProduction() {
} }
const handlePanelSave = async (postId, data) => { const handlePanelSave = async (postId, data) => {
let result
if (postId) { if (postId) {
await api.patch(`/posts/${postId}`, data) result = await api.patch(`/posts/${postId}`, data)
toast.success(t('posts.updated')) toast.success(t('posts.updated'))
} else { } else {
await api.post('/posts', data) result = await api.post('/posts', data)
toast.success(t('posts.created')) toast.success(t('posts.created'))
} }
loadPosts() loadPosts()
// Update panel with fresh server data so form stays in sync
if (result && postId) setPanelPost(result)
} }
const handlePanelDelete = async (postId) => { const handlePanelDelete = async (postId) => {
@@ -308,6 +311,7 @@ export default function PostProduction() {
{ id: 'draft', label: t('posts.status.draft'), color: 'bg-gray-400' }, { 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: 'in_review', label: t('posts.status.in_review'), color: 'bg-amber-400' },
{ id: 'approved', label: t('posts.status.approved'), color: 'bg-blue-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: 'scheduled', label: t('posts.status.scheduled'), color: 'bg-purple-400' },
{ id: 'published', label: t('posts.status.published'), color: 'bg-emerald-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 // 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 member = teamMembers.find(m => (m.id || m._id) === memberId)
const currentTeamIds = member?.teams ? member.teams.map(t => t.id) : [] const currentTeamIds = member?.teams ? member.teams.map(t => t.id) : []
const targetTeamIds = data.team_ids || [] const targetTeamIds = data.team_ids || []

View File

@@ -462,6 +462,7 @@ const TEXT_COLUMNS = {
Comments: [{ name: 'version_number', uidt: 'Number' }], Comments: [{ name: 'version_number', uidt: 'Number' }],
Issues: [{ name: 'thumbnail', uidt: 'SingleLineText' }], Issues: [{ name: 'thumbnail', uidt: 'SingleLineText' }],
Artefacts: [{ name: 'approver_ids', uidt: 'SingleLineText' }], Artefacts: [{ name: 'approver_ids', uidt: 'SingleLineText' }],
Posts: [{ name: 'approver_ids', uidt: 'SingleLineText' }],
}; };
async function ensureTextColumns() { 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.assigned_to_id) userIds.add(p.assigned_to_id);
if (p.created_by_user_id) userIds.add(p.created_by_user_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.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({ const names = await batchResolveNames({
brand: { table: 'Brands', ids: [...brandIds] }, brand: { table: 'Brands', ids: [...brandIds] },
@@ -1168,18 +1174,22 @@ app.get('/api/posts', requireAuth, async (req, res) => {
campaign: { table: 'Campaigns', ids: [...campaignIds] }, campaign: { table: 'Campaigns', ids: [...campaignIds] },
}); });
res.json(filtered.map(p => ({ res.json(filtered.map(p => {
...p, const approverIdList = p.approver_ids ? p.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
brand_id: p.brand_id, return {
assigned_to: p.assigned_to_id, ...p,
campaign_id: p.campaign_id, brand_id: p.brand_id,
created_by_user_id: p.created_by_user_id, assigned_to: p.assigned_to_id,
brand_name: names[`brand:${p.brand_id}`] || null, campaign_id: p.campaign_id,
assigned_name: names[`user:${p.assigned_to_id}`] || null, created_by_user_id: p.created_by_user_id,
campaign_name: names[`campaign:${p.campaign_id}`] || null, brand_name: names[`brand:${p.brand_id}`] || null,
creator_user_name: names[`user:${p.created_by_user_id}`] || null, assigned_name: names[`user:${p.assigned_to_id}`] || null,
thumbnail_url: thumbMap[p.Id] || null, 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) { } catch (err) {
console.error('GET /posts error:', err); console.error('GET /posts error:', err);
res.status(500).json({ error: 'Failed to load posts' }); 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) => { 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' }); if (!title) return res.status(400).json({ error: 'Title is required' });
const platformsArr = platforms || (platform ? [platform] : []); 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, brand_id: brand_id ? Number(brand_id) : null,
assigned_to_id: assigned_to ? Number(assigned_to) : null, assigned_to_id: assigned_to ? Number(assigned_to) : null,
campaign_id: campaign_id ? Number(campaign_id) : null, campaign_id: campaign_id ? Number(campaign_id) : null,
approver_ids: approver_ids || null,
created_by_user_id: req.session.userId, created_by_user_id: req.session.userId,
}); });
const post = await nocodb.get('Posts', created.Id); 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({ res.status(201).json({
...post, ...post,
assigned_to: post.assigned_to_id, 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), assigned_name: await getRecordName('Users', post.assigned_to_id),
campaign_name: await getRecordName('Campaigns', post.campaign_id), campaign_name: await getRecordName('Campaigns', post.campaign_id),
creator_user_name: await getRecordName('Users', post.created_by_user_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) { } catch (err) {
console.error('Create post error:', 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.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.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.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 // Publish validation
if (req.body.status === 'published') { if (req.body.status === 'published') {
@@ -1279,6 +1297,11 @@ app.patch('/api/posts/:id', requireAuth, requireOwnerOrRole('posts', 'superadmin
await nocodb.update('Posts', id, data); await nocodb.update('Posts', id, data);
const post = await nocodb.get('Posts', id); 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({ res.json({
...post, ...post,
assigned_to: post.assigned_to_id, 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), assigned_name: await getRecordName('Users', post.assigned_to_id),
campaign_name: await getRecordName('Campaigns', post.campaign_id), campaign_name: await getRecordName('Campaigns', post.campaign_id),
creator_user_name: await getRecordName('Users', post.created_by_user_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) { } catch (err) {
console.error('Update post error:', err); console.error('Update post error:', err);