feat: post approval workflow, i18n completion, and multiple fixes
All checks were successful
Deploy / deploy (push) Successful in 11s
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:
@@ -65,7 +65,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
// New version modal
|
||||
const [showNewVersionModal, setShowNewVersionModal] = useState(false)
|
||||
const [newVersionNotes, setNewVersionNotes] = useState('')
|
||||
const [copyFromPrevious, setCopyFromPrevious] = useState(true)
|
||||
const [copyFromPrevious, setCopyFromPrevious] = useState(false)
|
||||
const [creatingVersion, setCreatingVersion] = useState(false)
|
||||
|
||||
// File upload (for design/video)
|
||||
@@ -109,7 +109,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load versions:', err)
|
||||
toast.error('Failed to load versions')
|
||||
toast.error(t('artefacts.failedLoadVersions'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -126,7 +126,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
setComments(commentsRes.data || commentsRes || [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load version data:', err)
|
||||
toast.error('Failed to load version data')
|
||||
toast.error(t('artefacts.failedLoadVersionData'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,15 +143,15 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
copy_from_previous: artefact.type === 'copy' ? copyFromPrevious : false,
|
||||
})
|
||||
|
||||
toast.success('New version created')
|
||||
toast.success(t('artefacts.versionCreated'))
|
||||
setShowNewVersionModal(false)
|
||||
setNewVersionNotes('')
|
||||
setCopyFromPrevious(true)
|
||||
setCopyFromPrevious(false)
|
||||
loadVersions()
|
||||
onUpdate()
|
||||
} catch (err) {
|
||||
console.error('Create version failed:', err)
|
||||
toast.error('Failed to create version')
|
||||
toast.error(t('artefacts.failedCreateVersion'))
|
||||
} finally {
|
||||
setCreatingVersion(false)
|
||||
}
|
||||
@@ -159,20 +159,20 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
|
||||
const handleAddLanguage = async () => {
|
||||
if (!languageForm.language_code || !languageForm.language_label || !languageForm.content) {
|
||||
toast.error('All fields are required')
|
||||
toast.error(t('artefacts.allFieldsRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
setSavingLanguage(true)
|
||||
try {
|
||||
await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/texts`, languageForm)
|
||||
toast.success('Language added')
|
||||
toast.success(t('artefacts.languageAdded'))
|
||||
setShowLanguageModal(false)
|
||||
setLanguageForm({ language_code: '', language_label: '', content: '' })
|
||||
loadVersionData(selectedVersion.Id)
|
||||
} catch (err) {
|
||||
console.error('Add language failed:', err)
|
||||
toast.error('Failed to add language')
|
||||
toast.error(t('artefacts.failedAddLanguage'))
|
||||
} finally {
|
||||
setSavingLanguage(false)
|
||||
}
|
||||
@@ -181,10 +181,10 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
const handleDeleteLanguage = async (textId) => {
|
||||
try {
|
||||
await api.delete(`/artefact-version-texts/${textId}`)
|
||||
toast.success('Language deleted')
|
||||
toast.success(t('artefacts.languageDeleted'))
|
||||
loadVersionData(selectedVersion.Id)
|
||||
} catch (err) {
|
||||
toast.error('Failed to delete language')
|
||||
toast.error(t('artefacts.failedDeleteLanguage'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,11 +197,11 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
await api.upload(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/attachments`, formData)
|
||||
toast.success('File uploaded')
|
||||
toast.success(t('artefacts.fileUploaded'))
|
||||
loadVersionData(selectedVersion.Id)
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err)
|
||||
toast.error('Upload failed')
|
||||
toast.error(t('artefacts.uploadFailed'))
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
@@ -209,7 +209,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
|
||||
const handleAddDriveVideo = async () => {
|
||||
if (!driveUrl.trim()) {
|
||||
toast.error('Please enter a Google Drive URL')
|
||||
toast.error(t('artefacts.enterDriveUrl'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -218,13 +218,13 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/attachments`, {
|
||||
drive_url: driveUrl,
|
||||
})
|
||||
toast.success('Video link added')
|
||||
toast.success(t('artefacts.videoLinkAdded'))
|
||||
setShowVideoModal(false)
|
||||
setDriveUrl('')
|
||||
loadVersionData(selectedVersion.Id)
|
||||
} catch (err) {
|
||||
console.error('Add Drive link failed:', err)
|
||||
toast.error('Failed to add video link')
|
||||
toast.error(t('artefacts.failedAddVideoLink'))
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
@@ -233,10 +233,10 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
const handleDeleteAttachment = async (attId) => {
|
||||
try {
|
||||
await api.delete(`/artefact-attachments/${attId}`)
|
||||
toast.success('Attachment deleted')
|
||||
toast.success(t('artefacts.attachmentDeleted'))
|
||||
loadVersionData(selectedVersion.Id)
|
||||
} catch (err) {
|
||||
toast.error('Failed to delete attachment')
|
||||
toast.error(t('artefacts.failedDeleteAttachment'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,10 +245,10 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
try {
|
||||
const res = await api.post(`/artefacts/${artefact.Id}/submit-review`)
|
||||
setReviewUrl(res.reviewUrl || res.data?.reviewUrl || '')
|
||||
toast.success('Submitted for review!')
|
||||
toast.success(t('artefacts.submittedForReview'))
|
||||
onUpdate()
|
||||
} catch (err) {
|
||||
toast.error('Failed to submit for review')
|
||||
toast.error(t('artefacts.failedSubmitReview'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -257,7 +257,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
const copyReviewLink = () => {
|
||||
navigator.clipboard.writeText(reviewUrl)
|
||||
setCopied(true)
|
||||
toast.success('Link copied to clipboard')
|
||||
toast.success(t('artefacts.linkCopied'))
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
@@ -269,11 +269,11 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/comments`, {
|
||||
content: newComment.trim(),
|
||||
})
|
||||
toast.success('Comment added')
|
||||
toast.success(t('artefacts.commentAdded'))
|
||||
setNewComment('')
|
||||
loadVersionData(selectedVersion.Id)
|
||||
} catch (err) {
|
||||
toast.error('Failed to add comment')
|
||||
toast.error(t('artefacts.failedAddComment'))
|
||||
} finally {
|
||||
setAddingComment(false)
|
||||
}
|
||||
@@ -282,16 +282,16 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
const handleUpdateField = async (field, value) => {
|
||||
try {
|
||||
await api.patch(`/artefacts/${artefact.Id}`, { [field]: value || null })
|
||||
toast.success('Updated')
|
||||
toast.success(t('artefacts.updated'))
|
||||
onUpdate()
|
||||
} catch (err) {
|
||||
toast.error('Failed to update')
|
||||
toast.error(t('artefacts.failedUpdate'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveDraft = async () => {
|
||||
if (!editTitle.trim()) {
|
||||
toast.error('Title is required')
|
||||
toast.error(t('artefacts.titleRequired'))
|
||||
return
|
||||
}
|
||||
setSavingDraft(true)
|
||||
@@ -300,10 +300,10 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
title: editTitle.trim(),
|
||||
description: editDescription.trim() || null,
|
||||
})
|
||||
toast.success('Draft saved')
|
||||
toast.success(t('artefacts.draftSaved'))
|
||||
onUpdate()
|
||||
} catch (err) {
|
||||
toast.error('Failed to save draft')
|
||||
toast.error(t('artefacts.failedSaveDraft'))
|
||||
} finally {
|
||||
setSavingDraft(false)
|
||||
}
|
||||
@@ -314,7 +314,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
try {
|
||||
await onDelete(artefact.Id || artefact.id || artefact._id)
|
||||
} catch (err) {
|
||||
toast.error('Failed to delete')
|
||||
toast.error(t('artefacts.failedDelete'))
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
@@ -377,17 +377,17 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
onClick={handleSaveDraft}
|
||||
disabled={savingDraft}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light disabled:opacity-50 transition-colors"
|
||||
title="Save draft"
|
||||
title={t('artefacts.saveDraftTooltip')}
|
||||
>
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
{savingDraft ? 'Saving...' : 'Save'}
|
||||
{savingDraft ? t('artefacts.savingDraft') : t('artefacts.saveDraft')}
|
||||
</button>
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={() => setShowDeleteArtefactConfirm(true)}
|
||||
disabled={deleting}
|
||||
className="p-1.5 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Delete artefact"
|
||||
title={t('artefacts.deleteArtefactTooltip')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -399,13 +399,13 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Description */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-2">Description</h4>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-2">{t('artefacts.descriptionLabel')}</h4>
|
||||
<textarea
|
||||
value={editDescription}
|
||||
onChange={e => setEditDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm text-text-secondary bg-surface-secondary border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
|
||||
placeholder="Add a description..."
|
||||
placeholder={t('artefacts.descriptionFieldPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -443,7 +443,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
|
||||
{/* Approvers */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">Approvers</h4>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.approversLabel')}</h4>
|
||||
<ApproverMultiSelect
|
||||
users={assignableUsers}
|
||||
selected={editApproverIds}
|
||||
@@ -457,13 +457,13 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
{/* Version Timeline */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase">Versions</h4>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.versions')}</h4>
|
||||
<button
|
||||
onClick={() => setShowNewVersionModal(true)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
New Version
|
||||
{t('artefacts.newVersion')}
|
||||
</button>
|
||||
</div>
|
||||
<ArtefactVersionTimeline
|
||||
@@ -481,13 +481,13 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
{artefact.type === 'copy' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase">Languages</h4>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.languages')}</h4>
|
||||
<button
|
||||
onClick={() => setShowLanguageModal(true)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
Add Language
|
||||
{t('artefacts.addLanguage')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -518,7 +518,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
) : (
|
||||
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
|
||||
<Globe className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
|
||||
<p className="text-sm text-text-secondary">No languages added yet</p>
|
||||
<p className="text-sm text-text-secondary">{t('artefacts.noLanguages')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -528,10 +528,10 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
{artefact.type === 'design' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase">Images</h4>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.imagesLabel')}</h4>
|
||||
<label className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors cursor-pointer">
|
||||
<Upload className="w-3 h-3" />
|
||||
{uploading ? 'Uploading...' : 'Upload Image'}
|
||||
{uploading ? t('artefacts.uploading') : t('artefacts.uploadImage')}
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
@@ -568,7 +568,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
) : (
|
||||
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
|
||||
<ImageIcon className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
|
||||
<p className="text-sm text-text-secondary">No images uploaded yet</p>
|
||||
<p className="text-sm text-text-secondary">{t('artefacts.noImages')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -578,13 +578,13 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
{artefact.type === 'video' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase">Videos</h4>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.videosLabel')}</h4>
|
||||
<button
|
||||
onClick={() => setShowVideoModal(true)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
Add Video
|
||||
{t('artefacts.addVideoBtn')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -595,7 +595,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
{att.drive_url ? (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-text-primary">Google Drive Video</span>
|
||||
<span className="text-sm font-medium text-text-primary">{t('artefacts.googleDriveVideo')}</span>
|
||||
<button
|
||||
onClick={() => setConfirmDeleteAttId(att.Id)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
@@ -633,7 +633,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
) : (
|
||||
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
|
||||
<Film className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
|
||||
<p className="text-sm text-text-secondary">No videos added yet</p>
|
||||
<p className="text-sm text-text-secondary">{t('artefacts.noVideos')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -645,7 +645,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
{selectedVersion && (
|
||||
<div className="border-t border-border pt-6">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">
|
||||
Comments ({comments.length})
|
||||
{t('artefacts.comments')} ({comments.length})
|
||||
</h4>
|
||||
|
||||
<div className="space-y-3 mb-4">
|
||||
@@ -677,7 +677,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
value={newComment}
|
||||
onChange={e => setNewComment(e.target.value)}
|
||||
onKeyPress={e => e.key === 'Enter' && handleAddComment()}
|
||||
placeholder="Add a comment..."
|
||||
placeholder={t('artefacts.addCommentPlaceholder')}
|
||||
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
/>
|
||||
<button
|
||||
@@ -685,7 +685,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
disabled={addingComment || !newComment.trim()}
|
||||
className="px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Send
|
||||
{t('artefacts.sendComment')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -700,7 +700,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-amber-500 text-white rounded-lg hover:bg-amber-600 transition-colors font-medium disabled:opacity-50"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{submitting ? 'Submitting...' : 'Submit for Review'}
|
||||
{submitting ? t('artefacts.submitting') : t('artefacts.submitForReview')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -708,7 +708,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
{/* Review Link */}
|
||||
{reviewUrl && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="text-sm font-semibold text-blue-900 mb-2">Review Link (expires in 7 days)</div>
|
||||
<div className="text-sm font-semibold text-blue-900 mb-2">{t('artefacts.reviewLinkTitle')}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
@@ -729,7 +729,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
{/* Feedback */}
|
||||
{artefact.feedback && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<h4 className="text-sm font-semibold text-amber-900 mb-2">Feedback</h4>
|
||||
<h4 className="text-sm font-semibold text-amber-900 mb-2">{t('artefacts.feedbackTitle')}</h4>
|
||||
<p className="text-sm text-amber-800 whitespace-pre-wrap">{artefact.feedback}</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -737,7 +737,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
{/* Approval Info */}
|
||||
{artefact.status === 'approved' && artefact.approved_by_name && (
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
|
||||
<div className="font-medium text-emerald-900">Approved by {artefact.approved_by_name}</div>
|
||||
<div className="font-medium text-emerald-900">{t('artefacts.approvedByLabel')} {artefact.approved_by_name}</div>
|
||||
{artefact.approved_at && (
|
||||
<div className="text-sm text-emerald-700 mt-1">
|
||||
{new Date(artefact.approved_at).toLocaleString()}
|
||||
@@ -748,10 +748,10 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
</div>
|
||||
|
||||
{/* Language Modal */}
|
||||
<Modal isOpen={showLanguageModal} onClose={() => setShowLanguageModal(false)} title="Add Language" size="md">
|
||||
<Modal isOpen={showLanguageModal} onClose={() => setShowLanguageModal(false)} title={t('artefacts.addLanguage')} size="md">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Language *</label>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.languageLabel')} *</label>
|
||||
<select
|
||||
value={languageForm.language_code}
|
||||
onChange={e => {
|
||||
@@ -761,7 +761,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">Select a language...</option>
|
||||
<option value="">{t('artefacts.selectLanguage')}</option>
|
||||
{AVAILABLE_LANGUAGES
|
||||
.filter(lang => !(versionData?.texts || []).some(t => t.language_code === lang.code))
|
||||
.map(lang => (
|
||||
@@ -771,13 +771,13 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Content *</label>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.contentLabel')} *</label>
|
||||
<textarea
|
||||
value={languageForm.content}
|
||||
onChange={e => setLanguageForm(f => ({ ...f, content: e.target.value }))}
|
||||
rows={8}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 font-sans"
|
||||
placeholder="Enter the content in this language..."
|
||||
placeholder={t('artefacts.enterContent')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||
@@ -785,30 +785,30 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
onClick={() => setShowLanguageModal(false)}
|
||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddLanguage}
|
||||
disabled={savingLanguage}
|
||||
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
|
||||
>
|
||||
{savingLanguage ? 'Saving...' : 'Save'}
|
||||
{savingLanguage ? t('header.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* New Version Modal */}
|
||||
<Modal isOpen={showNewVersionModal} onClose={() => setShowNewVersionModal(false)} title="Create New Version" size="sm">
|
||||
<Modal isOpen={showNewVersionModal} onClose={() => setShowNewVersionModal(false)} title={t('artefacts.createNewVersion')} size="sm">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Version Notes</label>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.versionNotes')}</label>
|
||||
<textarea
|
||||
value={newVersionNotes}
|
||||
onChange={e => setNewVersionNotes(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
placeholder="What changed in this version?"
|
||||
placeholder={t('artefacts.whatChanged')}
|
||||
/>
|
||||
</div>
|
||||
{artefact.type === 'copy' && versions.length > 0 && (
|
||||
@@ -819,7 +819,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
onChange={e => setCopyFromPrevious(e.target.checked)}
|
||||
className="w-4 h-4 text-brand-primary border-border rounded focus:ring-brand-primary"
|
||||
/>
|
||||
<span className="text-sm text-text-secondary">Copy languages from previous version</span>
|
||||
<span className="text-sm text-text-secondary">{t('artefacts.copyLanguages')}</span>
|
||||
</label>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||
@@ -827,21 +827,21 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
onClick={() => setShowNewVersionModal(false)}
|
||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateVersion}
|
||||
disabled={creatingVersion}
|
||||
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
|
||||
>
|
||||
{creatingVersion ? 'Creating...' : 'Create Version'}
|
||||
{creatingVersion ? t('artefacts.creatingVersion') : t('artefacts.createVersion')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Video Modal */}
|
||||
<Modal isOpen={showVideoModal} onClose={() => setShowVideoModal(false)} title="Add Video" size="md">
|
||||
<Modal isOpen={showVideoModal} onClose={() => setShowVideoModal(false)} title={t('artefacts.addVideoTitle')} size="md">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 border-b border-border pb-3">
|
||||
<button
|
||||
@@ -852,7 +852,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
: 'bg-surface-secondary text-text-secondary hover:bg-surface-tertiary'
|
||||
}`}
|
||||
>
|
||||
Upload File
|
||||
{t('artefacts.uploadFile')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setVideoMode('drive')}
|
||||
@@ -862,7 +862,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
: 'bg-surface-secondary text-text-secondary hover:bg-surface-tertiary'
|
||||
}`}
|
||||
>
|
||||
Google Drive Link
|
||||
{t('artefacts.googleDriveLink')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -872,9 +872,9 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
<Upload className="w-8 h-8 text-text-tertiary" />
|
||||
<div className="text-center">
|
||||
<span className="text-sm font-medium text-text-primary">
|
||||
{uploading ? 'Uploading...' : 'Choose video file'}
|
||||
{uploading ? t('artefacts.uploading') : t('artefacts.chooseVideoFile')}
|
||||
</span>
|
||||
<p className="text-xs text-text-tertiary mt-1">MP4, MOV, AVI, etc.</p>
|
||||
<p className="text-xs text-text-tertiary mt-1">{t('artefacts.videoFormats')}</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
@@ -887,7 +887,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">Google Drive URL</label>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">{t('artefacts.googleDriveUrl')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={driveUrl}
|
||||
@@ -896,21 +896,21 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
/>
|
||||
<p className="text-xs text-text-tertiary mt-2">
|
||||
Paste a Google Drive share link. Make sure the file is publicly accessible.
|
||||
{t('artefacts.publiclyAccessible')}
|
||||
</p>
|
||||
<div className="flex justify-end gap-3 mt-4">
|
||||
<button
|
||||
onClick={() => setShowVideoModal(false)}
|
||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddDriveVideo}
|
||||
disabled={uploading || !driveUrl.trim()}
|
||||
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
|
||||
>
|
||||
{uploading ? 'Adding...' : 'Add Link'}
|
||||
{uploading ? t('artefacts.adding') : t('artefacts.addLink')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,29 +3,31 @@ import { useLocation } from 'react-router-dom'
|
||||
import { ChevronDown, LogOut, Shield, Lock, AlertCircle, CheckCircle } from 'lucide-react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { getInitials, api } from '../utils/api'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import Modal from './Modal'
|
||||
import ThemeToggle from './ThemeToggle'
|
||||
|
||||
const pageTitles = {
|
||||
'/': 'Dashboard',
|
||||
'/posts': 'Post Production',
|
||||
'/assets': 'Assets',
|
||||
'/campaigns': 'Campaigns',
|
||||
'/finance': 'Finance',
|
||||
'/projects': 'Projects',
|
||||
'/tasks': 'My Tasks',
|
||||
'/team': 'Team',
|
||||
'/users': 'User Management',
|
||||
const PAGE_TITLE_KEYS = {
|
||||
'/': 'header.dashboard',
|
||||
'/posts': 'header.posts',
|
||||
'/assets': 'header.assets',
|
||||
'/campaigns': 'header.campaigns',
|
||||
'/finance': 'header.finance',
|
||||
'/projects': 'header.projects',
|
||||
'/tasks': 'header.tasks',
|
||||
'/team': 'header.team',
|
||||
'/users': 'header.users',
|
||||
}
|
||||
|
||||
const ROLE_INFO = {
|
||||
superadmin: { label: 'Superadmin', color: 'bg-purple-100 text-purple-700', icon: '👑' },
|
||||
manager: { label: 'Manager', color: 'bg-blue-100 text-blue-700', icon: '📊' },
|
||||
contributor: { label: 'Contributor', color: 'bg-green-100 text-green-700', icon: '✏️' },
|
||||
superadmin: { labelKey: 'header.superadmin', color: 'bg-purple-100 text-purple-700', icon: '👑' },
|
||||
manager: { labelKey: 'header.manager', color: 'bg-blue-100 text-blue-700', icon: '📊' },
|
||||
contributor: { labelKey: 'header.contributor', color: 'bg-green-100 text-green-700', icon: '✏️' },
|
||||
}
|
||||
|
||||
export default function Header() {
|
||||
const { user, logout } = useAuth()
|
||||
const { t } = useLanguage()
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false)
|
||||
const [passwordForm, setPasswordForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' })
|
||||
@@ -36,10 +38,10 @@ export default function Header() {
|
||||
const location = useLocation()
|
||||
|
||||
function getPageTitle(pathname) {
|
||||
if (pageTitles[pathname]) return pageTitles[pathname]
|
||||
if (pathname.startsWith('/projects/')) return 'Project Details'
|
||||
if (pathname.startsWith('/campaigns/')) return 'Campaign Details'
|
||||
return 'Page'
|
||||
if (PAGE_TITLE_KEYS[pathname]) return t(PAGE_TITLE_KEYS[pathname])
|
||||
if (pathname.startsWith('/projects/')) return t('header.projectDetails')
|
||||
if (pathname.startsWith('/campaigns/')) return t('header.campaignDetails')
|
||||
return t('header.page')
|
||||
}
|
||||
const pageTitle = getPageTitle(location.pathname)
|
||||
|
||||
@@ -57,11 +59,11 @@ export default function Header() {
|
||||
setPasswordError('')
|
||||
setPasswordSuccess('')
|
||||
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
|
||||
setPasswordError('New passwords do not match')
|
||||
setPasswordError(t('header.passwordMismatch'))
|
||||
return
|
||||
}
|
||||
if (passwordForm.newPassword.length < 6) {
|
||||
setPasswordError('New password must be at least 6 characters')
|
||||
setPasswordError(t('header.passwordMinLength'))
|
||||
return
|
||||
}
|
||||
setPasswordSaving(true)
|
||||
@@ -70,11 +72,11 @@ export default function Header() {
|
||||
currentPassword: passwordForm.currentPassword,
|
||||
newPassword: passwordForm.newPassword,
|
||||
})
|
||||
setPasswordSuccess('Password updated successfully')
|
||||
setPasswordSuccess(t('header.passwordUpdateSuccess'))
|
||||
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' })
|
||||
setTimeout(() => setShowPasswordModal(false), 1500)
|
||||
} catch (err) {
|
||||
setPasswordError(err.message || 'Failed to change password')
|
||||
setPasswordError(err.message || t('header.passwordUpdateFailed'))
|
||||
} finally {
|
||||
setPasswordSaving(false)
|
||||
}
|
||||
@@ -121,7 +123,7 @@ export default function Header() {
|
||||
{user?.name || 'User'}
|
||||
</p>
|
||||
<p className={`text-[10px] font-medium ${roleInfo.color.split(' ')[1]}`}>
|
||||
{roleInfo.icon} {roleInfo.label}
|
||||
{roleInfo.icon} {t(roleInfo.labelKey)}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown className={`w-4 h-4 text-text-tertiary transition-transform ${showDropdown ? 'rotate-180' : ''}`} />
|
||||
@@ -135,7 +137,7 @@ export default function Header() {
|
||||
<p className="text-xs text-text-tertiary">{user?.email}</p>
|
||||
<div className={`inline-flex items-center gap-1 text-[10px] font-medium px-2 py-0.5 rounded-full mt-2 ${roleInfo.color}`}>
|
||||
<span>{roleInfo.icon}</span>
|
||||
{roleInfo.label}
|
||||
{t(roleInfo.labelKey)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -150,7 +152,7 @@ export default function Header() {
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-surface-secondary transition-colors text-start"
|
||||
>
|
||||
<Shield className="w-4 h-4 text-text-tertiary" />
|
||||
<span className="text-sm text-text-primary">User Management</span>
|
||||
<span className="text-sm text-text-primary">{t('header.userManagement')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -159,7 +161,7 @@ export default function Header() {
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-surface-secondary transition-colors text-start"
|
||||
>
|
||||
<Lock className="w-4 h-4 text-text-tertiary" />
|
||||
<span className="text-sm text-text-primary">Change Password</span>
|
||||
<span className="text-sm text-text-primary">{t('header.changePassword')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -170,7 +172,7 @@ export default function Header() {
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-red-50 transition-colors text-left group"
|
||||
>
|
||||
<LogOut className="w-4 h-4 text-text-tertiary group-hover:text-red-500" />
|
||||
<span className="text-sm text-text-primary group-hover:text-red-500">Sign Out</span>
|
||||
<span className="text-sm text-text-primary group-hover:text-red-500">{t('header.signOut')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -180,10 +182,10 @@ export default function Header() {
|
||||
</header>
|
||||
|
||||
{/* Change Password Modal */}
|
||||
<Modal isOpen={showPasswordModal} onClose={() => setShowPasswordModal(false)} title="Change Password" size="md">
|
||||
<Modal isOpen={showPasswordModal} onClose={() => setShowPasswordModal(false)} title={t('header.changePassword')} size="md">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Current Password</label>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('header.currentPassword')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={passwordForm.currentPassword}
|
||||
@@ -193,7 +195,7 @@ export default function Header() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">New Password</label>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('header.newPassword')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={passwordForm.newPassword}
|
||||
@@ -204,7 +206,7 @@ export default function Header() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Confirm New Password</label>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('header.confirmNewPassword')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={passwordForm.confirmPassword}
|
||||
@@ -234,14 +236,14 @@ export default function Header() {
|
||||
onClick={() => setShowPasswordModal(false)}
|
||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePasswordChange}
|
||||
disabled={!passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword || passwordSaving}
|
||||
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
||||
>
|
||||
{passwordSaving ? 'Saving...' : 'Update Password'}
|
||||
{passwordSaving ? t('header.saving') : t('header.updatePassword')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
const PRIORITY_CONFIG = {
|
||||
low: { label: 'Low', dot: 'bg-text-tertiary' },
|
||||
medium: { label: 'Medium', dot: 'bg-blue-500' },
|
||||
high: { label: 'High', dot: 'bg-orange-500' },
|
||||
urgent: { label: 'Urgent', dot: 'bg-red-500' },
|
||||
}
|
||||
|
||||
const TYPE_LABELS = {
|
||||
request: 'Request',
|
||||
correction: 'Correction',
|
||||
complaint: 'Complaint',
|
||||
suggestion: 'Suggestion',
|
||||
other: 'Other',
|
||||
const PRIORITY_DOTS = {
|
||||
low: 'bg-text-tertiary',
|
||||
medium: 'bg-blue-500',
|
||||
high: 'bg-orange-500',
|
||||
urgent: 'bg-red-500',
|
||||
}
|
||||
|
||||
export default function IssueCard({ issue, onClick }) {
|
||||
const priority = PRIORITY_CONFIG[issue.priority] || PRIORITY_CONFIG.medium
|
||||
const priority = { dot: PRIORITY_DOTS[issue.priority] || PRIORITY_DOTS.medium }
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return ''
|
||||
|
||||
@@ -240,32 +240,32 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
<div className="p-4 space-y-6">
|
||||
{/* Submitter Info */}
|
||||
<div className="bg-surface-secondary rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-2">Submitter Information</h3>
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-2">{t('issues.submitterInfo')}</h3>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div><span className="text-text-tertiary">Name:</span> <span className="text-text-primary font-medium">{issueData.submitter_name}</span></div>
|
||||
<div><span className="text-text-tertiary">Email:</span> <span className="text-text-primary">{issueData.submitter_email}</span></div>
|
||||
<div><span className="text-text-tertiary">{t('issues.nameLabel')}</span> <span className="text-text-primary font-medium">{issueData.submitter_name}</span></div>
|
||||
<div><span className="text-text-tertiary">{t('issues.emailLabel')}</span> <span className="text-text-primary">{issueData.submitter_email}</span></div>
|
||||
{issueData.submitter_phone && (
|
||||
<div><span className="text-text-tertiary">Phone:</span> <span className="text-text-primary">{issueData.submitter_phone}</span></div>
|
||||
<div><span className="text-text-tertiary">{t('issues.phoneLabel')}</span> <span className="text-text-primary">{issueData.submitter_phone}</span></div>
|
||||
)}
|
||||
<div><span className="text-text-tertiary">Submitted:</span> <span className="text-text-primary">{formatDate(issueData.created_at)}</span></div>
|
||||
<div><span className="text-text-tertiary">{t('issues.submittedLabel')}</span> <span className="text-text-primary">{formatDate(issueData.created_at)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-2">Description</h3>
|
||||
<p className="text-sm text-text-secondary whitespace-pre-wrap">{issueData.description || 'No description provided'}</p>
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-2">{t('issues.description')}</h3>
|
||||
<p className="text-sm text-text-secondary whitespace-pre-wrap">{issueData.description || t('issues.noDescription')}</p>
|
||||
</div>
|
||||
|
||||
{/* Assigned To */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-primary mb-2">Assigned To</label>
|
||||
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.assignedTo')}</label>
|
||||
<select
|
||||
value={assignedTo}
|
||||
onChange={(e) => handleAssignmentChange(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
<option value="">{t('issues.unassigned')}</option>
|
||||
{teamMembers.map((member) => (
|
||||
<option key={member.id || member._id} value={member.id || member._id}>
|
||||
{member.name}
|
||||
@@ -303,7 +303,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
|
||||
{/* Brand */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-primary mb-2">Brand</label>
|
||||
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.brandLabel')}</label>
|
||||
<select
|
||||
value={issueData.brand_id || ''}
|
||||
onChange={async (e) => {
|
||||
@@ -316,7 +316,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">No brand</option>
|
||||
<option value="">{t('issues.noBrand')}</option>
|
||||
{(brands || []).map((b) => (
|
||||
<option key={b._id || b.Id} value={b._id || b.Id}>{b.name}</option>
|
||||
))}
|
||||
@@ -327,14 +327,14 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-primary mb-2 flex items-center gap-2">
|
||||
<Lock className="w-4 h-4" />
|
||||
Internal Notes (Staff Only)
|
||||
{t('issues.internalNotes')}
|
||||
</label>
|
||||
<textarea
|
||||
value={internalNotes}
|
||||
onChange={(e) => setInternalNotes(e.target.value)}
|
||||
onBlur={handleNotesChange}
|
||||
rows={4}
|
||||
placeholder="Internal notes not visible to submitter..."
|
||||
placeholder={t('issues.internalNotesPlaceholder')}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
/>
|
||||
</div>
|
||||
@@ -344,11 +344,11 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-emerald-900 mb-2 flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
Resolution Summary (Public)
|
||||
{t('issues.resolutionSummary')}
|
||||
</h3>
|
||||
<p className="text-sm text-emerald-800 whitespace-pre-wrap">{issueData.resolution_summary}</p>
|
||||
{issueData.resolved_at && (
|
||||
<p className="text-xs text-emerald-600 mt-2">Resolved on {formatDate(issueData.resolved_at)}</p>
|
||||
<p className="text-xs text-emerald-600 mt-2">{t('issues.resolvedOn')} {formatDate(issueData.resolved_at)}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -363,7 +363,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
<Check className="w-4 h-4 inline mr-1" />
|
||||
Acknowledge
|
||||
{t('issues.acknowledge')}
|
||||
</button>
|
||||
)}
|
||||
{(issueData.status === 'new' || issueData.status === 'acknowledged') && (
|
||||
@@ -373,7 +373,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
className="px-4 py-2 bg-amber-600 text-white rounded-lg text-sm font-medium hover:bg-amber-700 disabled:opacity-50"
|
||||
>
|
||||
<Clock className="w-4 h-4 inline mr-1" />
|
||||
Start Work
|
||||
{t('issues.startWork')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -382,7 +382,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
className="px-4 py-2 bg-emerald-600 text-white rounded-lg text-sm font-medium hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 inline mr-1" />
|
||||
Resolve
|
||||
{t('issues.resolve')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeclineModal(true)}
|
||||
@@ -390,14 +390,14 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded-lg text-sm font-medium hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
<XCircle className="w-4 h-4 inline mr-1" />
|
||||
Decline
|
||||
{t('issues.decline')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tracking Link */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-primary mb-2">Public Tracking Link</label>
|
||||
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.publicTrackingLink')}</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
@@ -417,7 +417,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
{/* Updates Timeline */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-3 flex items-center gap-2">
|
||||
Updates Timeline
|
||||
{t('issues.updatesTimeline')}
|
||||
<span className="text-xs text-text-tertiary font-normal">({updates.length})</span>
|
||||
</h3>
|
||||
|
||||
@@ -426,7 +426,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
<textarea
|
||||
value={newUpdate}
|
||||
onChange={(e) => setNewUpdate(e.target.value)}
|
||||
placeholder="Add an update..."
|
||||
placeholder={t('issues.addUpdatePlaceholder')}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 mb-2"
|
||||
/>
|
||||
@@ -439,7 +439,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
className="rounded"
|
||||
/>
|
||||
<Eye className="w-4 h-4" />
|
||||
Make public (visible to submitter)
|
||||
{t('issues.makePublic')}
|
||||
</label>
|
||||
<button
|
||||
onClick={handleAddUpdate}
|
||||
@@ -447,7 +447,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
className="px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
Add Update
|
||||
{t('issues.addUpdate')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -477,7 +477,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
</div>
|
||||
))}
|
||||
{updates.length === 0 && (
|
||||
<p className="text-sm text-text-tertiary text-center py-6">No updates yet</p>
|
||||
<p className="text-sm text-text-tertiary text-center py-6">{t('issues.noUpdates')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -485,7 +485,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
{/* Attachments */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-3 flex items-center gap-2">
|
||||
Attachments
|
||||
{t('issues.attachments')}
|
||||
<span className="text-xs text-text-tertiary font-normal">({attachments.length})</span>
|
||||
</h3>
|
||||
|
||||
@@ -495,7 +495,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center cursor-pointer hover:bg-surface-secondary transition-colors">
|
||||
<Upload className="w-6 h-6 mx-auto mb-2 text-text-tertiary" />
|
||||
<p className="text-sm text-text-secondary">
|
||||
{uploadingFile ? 'Uploading...' : 'Click to upload file'}
|
||||
{uploadingFile ? t('issues.uploading') : t('issues.clickToUpload')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
@@ -520,7 +520,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-brand-primary hover:underline"
|
||||
>
|
||||
Download
|
||||
{t('issues.download')}
|
||||
</a>
|
||||
<button onClick={() => setConfirmDeleteAttId(att.Id || att.id)} className="p-1 hover:bg-surface-tertiary rounded">
|
||||
<Trash2 className="w-4 h-4 text-red-600" />
|
||||
@@ -529,7 +529,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
</div>
|
||||
))}
|
||||
{attachments.length === 0 && (
|
||||
<p className="text-sm text-text-tertiary text-center py-4">No attachments</p>
|
||||
<p className="text-sm text-text-tertiary text-center py-4">{t('issues.noAttachments')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -538,13 +538,13 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
|
||||
{/* Resolve Modal */}
|
||||
{showResolveModal && (
|
||||
<Modal isOpen title="Resolve Issue" onClose={() => setShowResolveModal(false)}>
|
||||
<Modal isOpen title={t('issues.resolveIssue')} onClose={() => setShowResolveModal(false)}>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-text-secondary">Provide a resolution summary that will be visible to the submitter.</p>
|
||||
<p className="text-sm text-text-secondary">{t('issues.resolveSummaryHint')}</p>
|
||||
<textarea
|
||||
value={resolutionSummary}
|
||||
onChange={(e) => setResolutionSummary(e.target.value)}
|
||||
placeholder="Explain how this issue was resolved..."
|
||||
placeholder={t('issues.resolutionPlaceholder')}
|
||||
rows={5}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
/>
|
||||
@@ -553,14 +553,14 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
onClick={() => setShowResolveModal(false)}
|
||||
className="px-4 py-2 bg-surface-secondary text-text-primary rounded-lg text-sm font-medium hover:bg-surface-tertiary"
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleResolve}
|
||||
disabled={!resolutionSummary.trim() || saving}
|
||||
className="px-4 py-2 bg-emerald-600 text-white rounded-lg text-sm font-medium hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Resolving...' : 'Mark as Resolved'}
|
||||
{saving ? t('issues.resolving') : t('issues.markAsResolved')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -569,13 +569,13 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
|
||||
{/* Decline Modal */}
|
||||
{showDeclineModal && (
|
||||
<Modal isOpen title="Decline Issue" onClose={() => setShowDeclineModal(false)}>
|
||||
<Modal isOpen title={t('issues.declineIssue')} onClose={() => setShowDeclineModal(false)}>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-text-secondary">Provide a reason for declining this issue. This will be visible to the submitter.</p>
|
||||
<p className="text-sm text-text-secondary">{t('issues.declineReasonHint')}</p>
|
||||
<textarea
|
||||
value={resolutionSummary}
|
||||
onChange={(e) => setResolutionSummary(e.target.value)}
|
||||
placeholder="Explain why this issue cannot be addressed..."
|
||||
placeholder={t('issues.declinePlaceholder')}
|
||||
rows={5}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
/>
|
||||
@@ -584,14 +584,14 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
|
||||
onClick={() => setShowDeclineModal(false)}
|
||||
className="px-4 py-2 bg-surface-secondary text-text-primary rounded-lg text-sm font-medium hover:bg-surface-tertiary"
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDecline}
|
||||
disabled={!resolutionSummary.trim() || saving}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded-lg text-sm font-medium hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Declining...' : 'Decline Issue'}
|
||||
{saving ? t('issues.declining') : t('issues.declineIssue')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { X, Trash2, Upload, FileText, Link2, ExternalLink, FolderOpen } from 'lucide-react'
|
||||
import { X, Trash2, Upload, FileText, Link2, ExternalLink, FolderOpen, Image as ImageIcon, Music, Film } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api, PLATFORMS, getBrandColor } from '../utils/api'
|
||||
import ApproverMultiSelect from './ApproverMultiSelect'
|
||||
import CommentsSection from './CommentsSection'
|
||||
import Modal from './Modal'
|
||||
import SlidePanel from './SlidePanel'
|
||||
@@ -9,7 +10,9 @@ import CollapsibleSection from './CollapsibleSection'
|
||||
|
||||
export default function PostDetailPanel({ post, onClose, onSave, onDelete, brands, teamMembers, campaigns }) {
|
||||
const { t, lang } = useLanguage()
|
||||
const fileInputRef = useRef(null)
|
||||
const imageInputRef = useRef(null)
|
||||
const audioInputRef = useRef(null)
|
||||
const videoInputRef = useRef(null)
|
||||
const [form, setForm] = useState({})
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -36,10 +39,11 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
||||
platforms: post.platforms || (post.platform ? [post.platform] : []),
|
||||
status: post.status || 'draft',
|
||||
assigned_to: post.assignedTo || post.assigned_to || '',
|
||||
scheduled_date: post.scheduledDate ? new Date(post.scheduledDate).toISOString().slice(0, 16) : '',
|
||||
scheduled_date: post.scheduledDate ? new Date(post.scheduledDate).toISOString().slice(0, 10) : (post.scheduled_date ? new Date(post.scheduled_date).toISOString().slice(0, 10) : ''),
|
||||
notes: post.notes || '',
|
||||
campaign_id: post.campaignId || post.campaign_id || '',
|
||||
publication_links: post.publication_links || post.publicationLinks || [],
|
||||
approver_ids: post.approvers?.map(a => String(a.id)) || (post.approver_ids ? post.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : []),
|
||||
})
|
||||
setDirty(isCreateMode)
|
||||
setPublishError('')
|
||||
@@ -53,6 +57,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
||||
{ value: 'draft', label: t('posts.status.draft') },
|
||||
{ value: 'in_review', label: t('posts.status.in_review') },
|
||||
{ value: 'approved', label: t('posts.status.approved') },
|
||||
{ value: 'rejected', label: t('posts.status.rejected') },
|
||||
{ value: 'scheduled', label: t('posts.status.scheduled') },
|
||||
{ value: 'published', label: t('posts.status.published') },
|
||||
]
|
||||
@@ -91,6 +96,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
||||
notes: form.notes,
|
||||
campaign_id: form.campaign_id ? Number(form.campaign_id) : null,
|
||||
publication_links: form.publication_links || [],
|
||||
approver_ids: (form.approver_ids || []).length > 0 ? form.approver_ids.join(',') : null,
|
||||
}
|
||||
|
||||
if (data.status === 'published' && data.platforms.length > 0) {
|
||||
@@ -212,6 +218,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
||||
form.status === 'scheduled' ? 'bg-purple-100 text-purple-700' :
|
||||
form.status === 'approved' ? 'bg-blue-100 text-blue-700' :
|
||||
form.status === 'in_review' ? 'bg-amber-100 text-amber-700' :
|
||||
form.status === 'rejected' ? 'bg-red-100 text-red-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{statusOptions.find(s => s.value === form.status)?.label}
|
||||
@@ -235,9 +242,47 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
||||
|
||||
return (
|
||||
<>
|
||||
<SlidePanel onClose={onClose} maxWidth="520px" header={header}>
|
||||
{/* Details Section */}
|
||||
<CollapsibleSection title={t('posts.details')}>
|
||||
<SlidePanel onClose={onClose} maxWidth="520px" header={header} footer={
|
||||
<div className="bg-surface border-t border-border px-5 py-3 flex items-center gap-2 shrink-0">
|
||||
{dirty ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!form.title || saving}
|
||||
className={`flex-1 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${saving ? 'btn-loading' : ''}`}
|
||||
>
|
||||
{isCreateMode ? t('posts.createPost') : t('posts.saveChanges')}
|
||||
</button>
|
||||
{!isCreateMode && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg transition-colors"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-text-secondary border border-border rounded-lg hover:bg-surface-tertiary transition-colors"
|
||||
>
|
||||
{t('common.close')}
|
||||
</button>
|
||||
)}
|
||||
{onDelete && !isCreateMode && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
}>
|
||||
{/* Content Section */}
|
||||
<CollapsibleSection title={t('posts.content')}>
|
||||
<div className="px-5 pb-4 space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.description')}</label>
|
||||
@@ -275,18 +320,23 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
||||
</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>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.assignTo')}</label>
|
||||
<select
|
||||
value={form.assigned_to}
|
||||
onChange={e => update('assigned_to', e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
<option value="">{t('common.unassigned')}</option>
|
||||
{(teamMembers || []).map(m => <option key={m._id || m.id} value={m._id || m.id}>{m.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.status')}</label>
|
||||
<select
|
||||
@@ -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>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.scheduledDate')}</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
type="date"
|
||||
value={form.scheduled_date}
|
||||
onChange={e => update('scheduled_date', e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.assignTo')}</label>
|
||||
<select
|
||||
value={form.assigned_to}
|
||||
onChange={e => update('assigned_to', e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
<option value="">{t('common.unassigned')}</option>
|
||||
{(teamMembers || []).map(m => <option key={m._id || m.id} value={m._id || m.id}>{m.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{publishError && (
|
||||
@@ -326,27 +375,18 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
||||
{publishError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!form.title || saving}
|
||||
className={`flex-1 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${saving ? 'btn-loading' : ''}`}
|
||||
>
|
||||
{isCreateMode ? t('posts.createPost') : t('posts.saveChanges')}
|
||||
</button>
|
||||
)}
|
||||
{onDelete && !isCreateMode && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Approval Section */}
|
||||
<CollapsibleSection title={t('posts.approval')}>
|
||||
<div className="px-5 pb-4">
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.approvers')}</label>
|
||||
<ApproverMultiSelect
|
||||
users={teamMembers || []}
|
||||
selected={form.approver_ids || []}
|
||||
onChange={ids => update('approver_ids', ids)}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
@@ -437,76 +477,196 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
|
||||
</span>
|
||||
) : null}
|
||||
>
|
||||
<div className="px-5 pb-4">
|
||||
{attachments.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
{attachments.map(att => {
|
||||
const isImage = att.mime_type?.startsWith('image/') || att.mimeType?.startsWith('image/')
|
||||
const attUrl = att.url || `/api/uploads/${att.filename}`
|
||||
const name = att.original_name || att.originalName || att.filename
|
||||
const attId = att.id || att._id
|
||||
return (
|
||||
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
|
||||
<div className="h-20 relative">
|
||||
{isImage ? (
|
||||
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="block h-full">
|
||||
<img src={attUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
|
||||
</a>
|
||||
) : (
|
||||
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="absolute inset-0 flex items-center gap-2 p-3">
|
||||
<div className="px-5 pb-4 space-y-4">
|
||||
{/* Images */}
|
||||
{(() => {
|
||||
const images = attachments.filter(a => (a.mime_type || a.mimeType || '').startsWith('image/'))
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary">
|
||||
<ImageIcon className="w-3.5 h-3.5" />
|
||||
{t('posts.images')}
|
||||
{images.length > 0 && <span className="text-text-tertiary">({images.length})</span>}
|
||||
</div>
|
||||
<label className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-brand-primary hover:bg-brand-primary/5 rounded cursor-pointer transition-colors">
|
||||
<Upload className="w-3 h-3" />
|
||||
{t('posts.addImage')}
|
||||
<input ref={imageInputRef} type="file" multiple accept="image/*" className="hidden"
|
||||
onChange={e => { handleFileUpload(e.target.files); e.target.value = '' }} />
|
||||
</label>
|
||||
</div>
|
||||
{images.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{images.map(att => {
|
||||
const attUrl = att.url || `/api/uploads/${att.filename}`
|
||||
const name = att.original_name || att.originalName || att.filename
|
||||
const attId = att.id || att._id
|
||||
return (
|
||||
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
|
||||
<div className="h-20 relative">
|
||||
<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" />
|
||||
<span className="text-xs text-text-secondary truncate">{name}</span>
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDeleteAttachment(attId)}
|
||||
className="absolute top-1 right-1 p-1 bg-black/50 hover:bg-red-500 rounded-full text-white opacity-0 group-hover/att:opacity-100 transition-opacity"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<X className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<button onClick={() => handleDeleteAttachment(attId)}
|
||||
className="absolute top-1 right-1 p-1 bg-black/50 hover:bg-red-500 rounded-full text-white opacity-0 group-hover/att:opacity-100 transition-opacity"
|
||||
title={t('common.delete')}><X className="w-2.5 h-2.5" /></button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
})()}
|
||||
|
||||
{/* Drag and drop zone */}
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors ${
|
||||
className={`border-2 border-dashed rounded-lg p-3 text-center cursor-pointer transition-colors ${
|
||||
dragActive ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/40'
|
||||
}`}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragEnter={e => { e.preventDefault(); setDragActive(true) }}
|
||||
onDragLeave={e => { e.preventDefault(); setDragActive(false) }}
|
||||
onDragOver={e => e.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={e => { handleFileUpload(e.target.files); e.target.value = '' }}
|
||||
/>
|
||||
<Upload className={`w-5 h-5 text-text-tertiary mx-auto mb-1 ${uploading ? 'animate-pulse' : ''}`} />
|
||||
<p className="text-xs text-text-secondary">
|
||||
{dragActive ? t('posts.dropFiles') : t('posts.uploadFiles')}
|
||||
<Upload className={`w-4 h-4 text-text-tertiary mx-auto mb-1 ${uploading ? 'animate-pulse' : ''}`} />
|
||||
<p className="text-[11px] text-text-secondary">
|
||||
{dragActive ? t('posts.dropFiles') : t('posts.dragToUpload')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAssetPicker}
|
||||
className="mt-2 flex items-center gap-2 px-3 py-2 text-sm text-text-secondary border border-border rounded-lg hover:bg-surface-tertiary transition-colors w-full justify-center"
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm text-text-secondary border border-border rounded-lg hover:bg-surface-tertiary transition-colors w-full justify-center"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
{t('posts.attachFromAssets')}
|
||||
</button>
|
||||
|
||||
{showAssetPicker && (
|
||||
<div className="mt-2 border border-border rounded-lg p-3 bg-surface-secondary">
|
||||
<div className="border border-border rounded-lg p-3 bg-surface-secondary">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-xs font-medium text-text-secondary">{t('posts.selectAssets')}</p>
|
||||
<button onClick={() => setShowAssetPicker(false)} className="p-1 text-text-tertiary hover:text-text-primary">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
export default function SlidePanel({ onClose, maxWidth = '420px', header, children }) {
|
||||
export default function SlidePanel({ onClose, maxWidth = '420px', header, footer, children }) {
|
||||
return createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in z-[9998]" onClick={onClose} />
|
||||
@@ -12,6 +12,7 @@ export default function SlidePanel({ onClose, maxWidth = '420px', header, childr
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{children}
|
||||
</div>
|
||||
{footer}
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"common.noResults": "لا توجد نتائج",
|
||||
"common.loading": "جاري التحميل...",
|
||||
"common.unassigned": "غير مُسند",
|
||||
"common.close": "إغلاق",
|
||||
"common.required": "مطلوب",
|
||||
"common.saveFailed": "فشل الحفظ. حاول مجدداً.",
|
||||
"common.updateFailed": "فشل التحديث. حاول مجدداً.",
|
||||
@@ -130,6 +131,7 @@
|
||||
"posts.status.approved": "مُعتمد",
|
||||
"posts.status.scheduled": "مجدول",
|
||||
"posts.status.published": "منشور",
|
||||
"posts.status.rejected": "مرفوض",
|
||||
"tasks.title": "المهام",
|
||||
"tasks.newTask": "مهمة جديدة",
|
||||
"tasks.editTask": "تعديل المهمة",
|
||||
@@ -693,5 +695,198 @@
|
||||
"settings.roleName": "اسم الدور",
|
||||
"settings.roleColor": "اللون",
|
||||
"settings.deleteRoleConfirm": "هل أنت متأكد من حذف هذا الدور؟",
|
||||
"settings.noRoles": "لم يتم تحديد أدوار بعد. أضف أول دور."
|
||||
"settings.noRoles": "لم يتم تحديد أدوار بعد. أضف أول دور.",
|
||||
|
||||
"header.dashboard": "لوحة التحكم",
|
||||
"header.posts": "إنتاج المحتوى",
|
||||
"header.assets": "الأصول",
|
||||
"header.campaigns": "الحملات",
|
||||
"header.finance": "المالية",
|
||||
"header.projects": "المشاريع",
|
||||
"header.tasks": "مهامي",
|
||||
"header.team": "الفريق",
|
||||
"header.users": "إدارة المستخدمين",
|
||||
"header.projectDetails": "تفاصيل المشروع",
|
||||
"header.campaignDetails": "تفاصيل الحملة",
|
||||
"header.page": "الصفحة",
|
||||
"header.superadmin": "مسؤول عام",
|
||||
"header.manager": "مدير",
|
||||
"header.contributor": "مساهم",
|
||||
"header.passwordMismatch": "كلمتا المرور الجديدتان غير متطابقتين",
|
||||
"header.passwordMinLength": "كلمة المرور الجديدة يجب أن تكون ٦ أحرف على الأقل",
|
||||
"header.passwordUpdateSuccess": "تم تحديث كلمة المرور بنجاح",
|
||||
"header.passwordUpdateFailed": "فشل في تغيير كلمة المرور",
|
||||
"header.userManagement": "إدارة المستخدمين",
|
||||
"header.changePassword": "تغيير كلمة المرور",
|
||||
"header.signOut": "تسجيل الخروج",
|
||||
"header.currentPassword": "كلمة المرور الحالية",
|
||||
"header.newPassword": "كلمة المرور الجديدة",
|
||||
"header.confirmNewPassword": "تأكيد كلمة المرور الجديدة",
|
||||
"header.updatePassword": "تحديث كلمة المرور",
|
||||
"header.saving": "جاري الحفظ...",
|
||||
|
||||
"issues.title": "المشاكل",
|
||||
"issues.subtitle": "تتبع وإدارة البلاغات المقدمة",
|
||||
"issues.searchPlaceholder": "البحث في المشاكل...",
|
||||
"issues.allStatuses": "جميع الحالات",
|
||||
"issues.allCategories": "جميع الفئات",
|
||||
"issues.allTypes": "جميع الأنواع",
|
||||
"issues.allBrands": "جميع العلامات",
|
||||
"issues.allPriorities": "جميع الأولويات",
|
||||
"issues.clearAll": "مسح الكل",
|
||||
"issues.noIssuesFound": "لم يتم العثور على مشاكل",
|
||||
"issues.tryAdjustingFilters": "جرّب تعديل الفلاتر",
|
||||
"issues.noIssuesSubmitted": "لم يتم تقديم أي مشاكل بعد",
|
||||
"issues.issuesDeleted": "تم حذف المشاكل",
|
||||
"issues.tableTitle": "العنوان",
|
||||
"issues.tableSubmitter": "مُقدّم البلاغ",
|
||||
"issues.tableBrand": "العلامة التجارية",
|
||||
"issues.tableCategory": "الفئة",
|
||||
"issues.tableType": "النوع",
|
||||
"issues.tablePriority": "الأولوية",
|
||||
"issues.tableStatus": "الحالة",
|
||||
"issues.tableAssignedTo": "مُسند إلى",
|
||||
"issues.tableCreated": "تاريخ الإنشاء",
|
||||
|
||||
"issues.typeRequest": "طلب",
|
||||
"issues.typeCorrection": "تصحيح",
|
||||
"issues.typeComplaint": "شكوى",
|
||||
"issues.typeSuggestion": "اقتراح",
|
||||
"issues.typeOther": "أخرى",
|
||||
|
||||
"issues.priorityLow": "منخفض",
|
||||
"issues.priorityMedium": "متوسط",
|
||||
"issues.priorityHigh": "عالي",
|
||||
"issues.priorityUrgent": "عاجل",
|
||||
|
||||
"issues.submitterInfo": "معلومات مُقدّم البلاغ",
|
||||
"issues.nameLabel": "الاسم:",
|
||||
"issues.emailLabel": "البريد الإلكتروني:",
|
||||
"issues.phoneLabel": "الهاتف:",
|
||||
"issues.submittedLabel": "تاريخ التقديم:",
|
||||
"issues.description": "الوصف",
|
||||
"issues.noDescription": "لا يوجد وصف",
|
||||
"issues.assignedTo": "مُسند إلى",
|
||||
"issues.unassigned": "غير مُسند",
|
||||
"issues.brandLabel": "العلامة التجارية",
|
||||
"issues.noBrand": "بدون علامة تجارية",
|
||||
"issues.internalNotes": "ملاحظات داخلية (للموظفين فقط)",
|
||||
"issues.internalNotesPlaceholder": "ملاحظات داخلية غير مرئية لمقدم البلاغ...",
|
||||
"issues.resolutionSummary": "ملخص الحل (عام)",
|
||||
"issues.resolvedOn": "تم الحل في",
|
||||
"issues.acknowledge": "إقرار",
|
||||
"issues.startWork": "بدء العمل",
|
||||
"issues.resolve": "حل",
|
||||
"issues.decline": "رفض",
|
||||
"issues.publicTrackingLink": "رابط التتبع العام",
|
||||
"issues.updatesTimeline": "الجدول الزمني للتحديثات",
|
||||
"issues.addUpdatePlaceholder": "أضف تحديثاً...",
|
||||
"issues.makePublic": "جعله عاماً (مرئي لمقدم البلاغ)",
|
||||
"issues.addUpdate": "إضافة تحديث",
|
||||
"issues.noUpdates": "لا توجد تحديثات بعد",
|
||||
"issues.attachments": "المرفقات",
|
||||
"issues.clickToUpload": "انقر لرفع ملف",
|
||||
"issues.uploading": "جاري الرفع...",
|
||||
"issues.download": "تحميل",
|
||||
"issues.noAttachments": "لا توجد مرفقات",
|
||||
"issues.resolveIssue": "حل المشكلة",
|
||||
"issues.resolveSummaryHint": "قدّم ملخصاً للحل سيكون مرئياً لمقدم البلاغ.",
|
||||
"issues.resolutionPlaceholder": "اشرح كيف تم حل هذه المشكلة...",
|
||||
"issues.markAsResolved": "تحديد كمحلولة",
|
||||
"issues.resolving": "جاري الحل...",
|
||||
"issues.declineIssue": "رفض المشكلة",
|
||||
"issues.declineReasonHint": "قدّم سبباً لرفض هذه المشكلة. سيكون مرئياً لمقدم البلاغ.",
|
||||
"issues.declinePlaceholder": "اشرح لماذا لا يمكن معالجة هذه المشكلة...",
|
||||
"issues.declining": "جاري الرفض...",
|
||||
|
||||
"artefacts.descriptionLabel": "الوصف",
|
||||
"artefacts.descriptionFieldPlaceholder": "أضف وصفاً...",
|
||||
"artefacts.approversLabel": "المعتمدون",
|
||||
"artefacts.versions": "الإصدارات",
|
||||
"artefacts.newVersion": "إصدار جديد",
|
||||
"artefacts.languages": "اللغات",
|
||||
"artefacts.addLanguage": "إضافة لغة",
|
||||
"artefacts.noLanguages": "لم تتم إضافة لغات بعد",
|
||||
"artefacts.imagesLabel": "الصور",
|
||||
"artefacts.uploadImage": "رفع صورة",
|
||||
"artefacts.uploading": "جاري الرفع...",
|
||||
"artefacts.noImages": "لم يتم رفع صور بعد",
|
||||
"artefacts.videosLabel": "الفيديوهات",
|
||||
"artefacts.addVideoBtn": "إضافة فيديو",
|
||||
"artefacts.noVideos": "لم تتم إضافة فيديوهات بعد",
|
||||
"artefacts.comments": "التعليقات",
|
||||
"artefacts.sendComment": "إرسال",
|
||||
"artefacts.addCommentPlaceholder": "أضف تعليقاً...",
|
||||
"artefacts.submitForReview": "إرسال للمراجعة",
|
||||
"artefacts.submitting": "جاري الإرسال...",
|
||||
"artefacts.reviewLinkTitle": "رابط المراجعة (ينتهي خلال ٧ أيام)",
|
||||
"artefacts.feedbackTitle": "الملاحظات",
|
||||
"artefacts.approvedByLabel": "تمت الموافقة بواسطة",
|
||||
"artefacts.saveDraft": "حفظ",
|
||||
"artefacts.savingDraft": "جاري الحفظ...",
|
||||
"artefacts.versionNotes": "ملاحظات الإصدار",
|
||||
"artefacts.whatChanged": "ما الذي تغير في هذا الإصدار؟",
|
||||
"artefacts.copyLanguages": "نسخ اللغات من الإصدار السابق",
|
||||
"artefacts.createVersion": "إنشاء إصدار",
|
||||
"artefacts.creatingVersion": "جاري الإنشاء...",
|
||||
"artefacts.languageLabel": "اللغة",
|
||||
"artefacts.contentLabel": "المحتوى",
|
||||
"artefacts.selectLanguage": "اختر لغة...",
|
||||
"artefacts.enterContent": "أدخل المحتوى بهذه اللغة...",
|
||||
"artefacts.addVideoTitle": "إضافة فيديو",
|
||||
"artefacts.uploadFile": "رفع ملف",
|
||||
"artefacts.chooseVideoFile": "اختر ملف فيديو",
|
||||
"artefacts.videoFormats": "MP4، MOV، AVI، إلخ.",
|
||||
"artefacts.googleDriveLink": "رابط Google Drive",
|
||||
"artefacts.googleDriveUrl": "رابط Google Drive",
|
||||
"artefacts.driveUrlPlaceholder": "https://drive.google.com/file/d/...",
|
||||
"artefacts.publiclyAccessible": "الصق رابط مشاركة Google Drive. تأكد أن الملف متاح للعامة.",
|
||||
"artefacts.addLink": "إضافة رابط",
|
||||
"artefacts.adding": "جاري الإضافة...",
|
||||
"artefacts.googleDriveVideo": "فيديو Google Drive",
|
||||
"artefacts.deleteArtefactTooltip": "حذف المحتوى",
|
||||
"artefacts.saveDraftTooltip": "حفظ المسودة",
|
||||
"artefacts.createNewVersion": "إنشاء إصدار جديد",
|
||||
"artefacts.failedLoadVersions": "فشل في تحميل الإصدارات",
|
||||
"artefacts.failedLoadVersionData": "فشل في تحميل بيانات الإصدار",
|
||||
"artefacts.versionCreated": "تم إنشاء الإصدار الجديد",
|
||||
"artefacts.failedCreateVersion": "فشل في إنشاء الإصدار",
|
||||
"artefacts.languageAdded": "تمت إضافة اللغة",
|
||||
"artefacts.allFieldsRequired": "جميع الحقول مطلوبة",
|
||||
"artefacts.failedAddLanguage": "فشل في إضافة اللغة",
|
||||
"artefacts.languageDeleted": "تم حذف اللغة",
|
||||
"artefacts.failedDeleteLanguage": "فشل في حذف اللغة",
|
||||
"artefacts.fileUploaded": "تم رفع الملف",
|
||||
"artefacts.uploadFailed": "فشل في الرفع",
|
||||
"artefacts.videoLinkAdded": "تمت إضافة رابط الفيديو",
|
||||
"artefacts.failedAddVideoLink": "فشل في إضافة رابط الفيديو",
|
||||
"artefacts.enterDriveUrl": "يرجى إدخال رابط Google Drive",
|
||||
"artefacts.attachmentDeleted": "تم حذف المرفق",
|
||||
"artefacts.failedDeleteAttachment": "فشل في حذف المرفق",
|
||||
"artefacts.submittedForReview": "تم الإرسال للمراجعة!",
|
||||
"artefacts.failedSubmitReview": "فشل في الإرسال للمراجعة",
|
||||
"artefacts.linkCopied": "تم نسخ الرابط",
|
||||
"artefacts.commentAdded": "تمت إضافة التعليق",
|
||||
"artefacts.failedAddComment": "فشل في إضافة التعليق",
|
||||
"artefacts.updated": "تم التحديث",
|
||||
"artefacts.failedUpdate": "فشل في التحديث",
|
||||
"artefacts.draftSaved": "تم حفظ المسودة",
|
||||
"artefacts.failedSaveDraft": "فشل في حفظ المسودة",
|
||||
"artefacts.titleRequired": "العنوان مطلوب",
|
||||
"artefacts.failedDelete": "فشل في الحذف",
|
||||
|
||||
"posts.images": "الصور",
|
||||
"posts.audio": "الصوت",
|
||||
"posts.videos": "الفيديوهات",
|
||||
"posts.otherFiles": "ملفات أخرى",
|
||||
"posts.addImage": "إضافة صورة",
|
||||
"posts.addAudio": "إضافة صوت",
|
||||
"posts.addVideo": "إضافة فيديو",
|
||||
"posts.dragToUpload": "اسحب الملفات هنا للرفع",
|
||||
"posts.assignedTo": "مُسند إلى",
|
||||
"posts.approval": "الموافقة",
|
||||
"posts.approvers": "المعتمدون",
|
||||
"posts.selectApprovers": "اختر المعتمدين...",
|
||||
"posts.scheduling": "الجدولة والتعيين",
|
||||
"posts.content": "المحتوى"
|
||||
}
|
||||
@@ -30,6 +30,7 @@
|
||||
"common.noResults": "No results",
|
||||
"common.loading": "Loading...",
|
||||
"common.unassigned": "Unassigned",
|
||||
"common.close": "Close",
|
||||
"common.required": "Required",
|
||||
"common.saveFailed": "Failed to save. Please try again.",
|
||||
"common.updateFailed": "Failed to update. Please try again.",
|
||||
@@ -130,6 +131,7 @@
|
||||
"posts.status.approved": "Approved",
|
||||
"posts.status.scheduled": "Scheduled",
|
||||
"posts.status.published": "Published",
|
||||
"posts.status.rejected": "Rejected",
|
||||
"tasks.title": "Tasks",
|
||||
"tasks.newTask": "New Task",
|
||||
"tasks.editTask": "Edit Task",
|
||||
@@ -693,5 +695,198 @@
|
||||
"settings.roleName": "Role name",
|
||||
"settings.roleColor": "Color",
|
||||
"settings.deleteRoleConfirm": "Are you sure you want to delete this role?",
|
||||
"settings.noRoles": "No roles defined yet. Add your first role."
|
||||
"settings.noRoles": "No roles defined yet. Add your first role.",
|
||||
|
||||
"header.dashboard": "Dashboard",
|
||||
"header.posts": "Post Production",
|
||||
"header.assets": "Assets",
|
||||
"header.campaigns": "Campaigns",
|
||||
"header.finance": "Finance",
|
||||
"header.projects": "Projects",
|
||||
"header.tasks": "My Tasks",
|
||||
"header.team": "Team",
|
||||
"header.users": "User Management",
|
||||
"header.projectDetails": "Project Details",
|
||||
"header.campaignDetails": "Campaign Details",
|
||||
"header.page": "Page",
|
||||
"header.superadmin": "Superadmin",
|
||||
"header.manager": "Manager",
|
||||
"header.contributor": "Contributor",
|
||||
"header.passwordMismatch": "New passwords do not match",
|
||||
"header.passwordMinLength": "New password must be at least 6 characters",
|
||||
"header.passwordUpdateSuccess": "Password updated successfully",
|
||||
"header.passwordUpdateFailed": "Failed to change password",
|
||||
"header.userManagement": "User Management",
|
||||
"header.changePassword": "Change Password",
|
||||
"header.signOut": "Sign Out",
|
||||
"header.currentPassword": "Current Password",
|
||||
"header.newPassword": "New Password",
|
||||
"header.confirmNewPassword": "Confirm New Password",
|
||||
"header.updatePassword": "Update Password",
|
||||
"header.saving": "Saving...",
|
||||
|
||||
"issues.title": "Issues",
|
||||
"issues.subtitle": "Track and manage issue submissions",
|
||||
"issues.searchPlaceholder": "Search issues...",
|
||||
"issues.allStatuses": "All Statuses",
|
||||
"issues.allCategories": "All Categories",
|
||||
"issues.allTypes": "All Types",
|
||||
"issues.allBrands": "All Brands",
|
||||
"issues.allPriorities": "All Priorities",
|
||||
"issues.clearAll": "Clear All",
|
||||
"issues.noIssuesFound": "No issues found",
|
||||
"issues.tryAdjustingFilters": "Try adjusting your filters",
|
||||
"issues.noIssuesSubmitted": "No issues have been submitted yet",
|
||||
"issues.issuesDeleted": "Issues deleted",
|
||||
"issues.tableTitle": "Title",
|
||||
"issues.tableSubmitter": "Submitter",
|
||||
"issues.tableBrand": "Brand",
|
||||
"issues.tableCategory": "Category",
|
||||
"issues.tableType": "Type",
|
||||
"issues.tablePriority": "Priority",
|
||||
"issues.tableStatus": "Status",
|
||||
"issues.tableAssignedTo": "Assigned To",
|
||||
"issues.tableCreated": "Created",
|
||||
|
||||
"issues.typeRequest": "Request",
|
||||
"issues.typeCorrection": "Correction",
|
||||
"issues.typeComplaint": "Complaint",
|
||||
"issues.typeSuggestion": "Suggestion",
|
||||
"issues.typeOther": "Other",
|
||||
|
||||
"issues.priorityLow": "Low",
|
||||
"issues.priorityMedium": "Medium",
|
||||
"issues.priorityHigh": "High",
|
||||
"issues.priorityUrgent": "Urgent",
|
||||
|
||||
"issues.submitterInfo": "Submitter Information",
|
||||
"issues.nameLabel": "Name:",
|
||||
"issues.emailLabel": "Email:",
|
||||
"issues.phoneLabel": "Phone:",
|
||||
"issues.submittedLabel": "Submitted:",
|
||||
"issues.description": "Description",
|
||||
"issues.noDescription": "No description provided",
|
||||
"issues.assignedTo": "Assigned To",
|
||||
"issues.unassigned": "Unassigned",
|
||||
"issues.brandLabel": "Brand",
|
||||
"issues.noBrand": "No brand",
|
||||
"issues.internalNotes": "Internal Notes (Staff Only)",
|
||||
"issues.internalNotesPlaceholder": "Internal notes not visible to submitter...",
|
||||
"issues.resolutionSummary": "Resolution Summary (Public)",
|
||||
"issues.resolvedOn": "Resolved on",
|
||||
"issues.acknowledge": "Acknowledge",
|
||||
"issues.startWork": "Start Work",
|
||||
"issues.resolve": "Resolve",
|
||||
"issues.decline": "Decline",
|
||||
"issues.publicTrackingLink": "Public Tracking Link",
|
||||
"issues.updatesTimeline": "Updates Timeline",
|
||||
"issues.addUpdatePlaceholder": "Add an update...",
|
||||
"issues.makePublic": "Make public (visible to submitter)",
|
||||
"issues.addUpdate": "Add Update",
|
||||
"issues.noUpdates": "No updates yet",
|
||||
"issues.attachments": "Attachments",
|
||||
"issues.clickToUpload": "Click to upload file",
|
||||
"issues.uploading": "Uploading...",
|
||||
"issues.download": "Download",
|
||||
"issues.noAttachments": "No attachments",
|
||||
"issues.resolveIssue": "Resolve Issue",
|
||||
"issues.resolveSummaryHint": "Provide a resolution summary that will be visible to the submitter.",
|
||||
"issues.resolutionPlaceholder": "Explain how this issue was resolved...",
|
||||
"issues.markAsResolved": "Mark as Resolved",
|
||||
"issues.resolving": "Resolving...",
|
||||
"issues.declineIssue": "Decline Issue",
|
||||
"issues.declineReasonHint": "Provide a reason for declining this issue. This will be visible to the submitter.",
|
||||
"issues.declinePlaceholder": "Explain why this issue cannot be addressed...",
|
||||
"issues.declining": "Declining...",
|
||||
|
||||
"artefacts.descriptionLabel": "Description",
|
||||
"artefacts.descriptionFieldPlaceholder": "Add a description...",
|
||||
"artefacts.approversLabel": "Approvers",
|
||||
"artefacts.versions": "Versions",
|
||||
"artefacts.newVersion": "New Version",
|
||||
"artefacts.languages": "Languages",
|
||||
"artefacts.addLanguage": "Add Language",
|
||||
"artefacts.noLanguages": "No languages added yet",
|
||||
"artefacts.imagesLabel": "Images",
|
||||
"artefacts.uploadImage": "Upload Image",
|
||||
"artefacts.uploading": "Uploading...",
|
||||
"artefacts.noImages": "No images uploaded yet",
|
||||
"artefacts.videosLabel": "Videos",
|
||||
"artefacts.addVideoBtn": "Add Video",
|
||||
"artefacts.noVideos": "No videos added yet",
|
||||
"artefacts.comments": "Comments",
|
||||
"artefacts.sendComment": "Send",
|
||||
"artefacts.addCommentPlaceholder": "Add a comment...",
|
||||
"artefacts.submitForReview": "Submit for Review",
|
||||
"artefacts.submitting": "Submitting...",
|
||||
"artefacts.reviewLinkTitle": "Review Link (expires in 7 days)",
|
||||
"artefacts.feedbackTitle": "Feedback",
|
||||
"artefacts.approvedByLabel": "Approved by",
|
||||
"artefacts.saveDraft": "Save",
|
||||
"artefacts.savingDraft": "Saving...",
|
||||
"artefacts.versionNotes": "Version Notes",
|
||||
"artefacts.whatChanged": "What changed in this version?",
|
||||
"artefacts.copyLanguages": "Copy languages from previous version",
|
||||
"artefacts.createVersion": "Create Version",
|
||||
"artefacts.creatingVersion": "Creating...",
|
||||
"artefacts.languageLabel": "Language",
|
||||
"artefacts.contentLabel": "Content",
|
||||
"artefacts.selectLanguage": "Select a language...",
|
||||
"artefacts.enterContent": "Enter the content in this language...",
|
||||
"artefacts.addVideoTitle": "Add Video",
|
||||
"artefacts.uploadFile": "Upload File",
|
||||
"artefacts.chooseVideoFile": "Choose video file",
|
||||
"artefacts.videoFormats": "MP4, MOV, AVI, etc.",
|
||||
"artefacts.googleDriveLink": "Google Drive Link",
|
||||
"artefacts.googleDriveUrl": "Google Drive URL",
|
||||
"artefacts.driveUrlPlaceholder": "https://drive.google.com/file/d/...",
|
||||
"artefacts.publiclyAccessible": "Paste a Google Drive share link. Make sure the file is publicly accessible.",
|
||||
"artefacts.addLink": "Add Link",
|
||||
"artefacts.adding": "Adding...",
|
||||
"artefacts.googleDriveVideo": "Google Drive Video",
|
||||
"artefacts.deleteArtefactTooltip": "Delete artefact",
|
||||
"artefacts.saveDraftTooltip": "Save draft",
|
||||
"artefacts.createNewVersion": "Create New Version",
|
||||
"artefacts.failedLoadVersions": "Failed to load versions",
|
||||
"artefacts.failedLoadVersionData": "Failed to load version data",
|
||||
"artefacts.versionCreated": "New version created",
|
||||
"artefacts.failedCreateVersion": "Failed to create version",
|
||||
"artefacts.languageAdded": "Language added",
|
||||
"artefacts.allFieldsRequired": "All fields are required",
|
||||
"artefacts.failedAddLanguage": "Failed to add language",
|
||||
"artefacts.languageDeleted": "Language deleted",
|
||||
"artefacts.failedDeleteLanguage": "Failed to delete language",
|
||||
"artefacts.fileUploaded": "File uploaded",
|
||||
"artefacts.uploadFailed": "Upload failed",
|
||||
"artefacts.videoLinkAdded": "Video link added",
|
||||
"artefacts.failedAddVideoLink": "Failed to add video link",
|
||||
"artefacts.enterDriveUrl": "Please enter a Google Drive URL",
|
||||
"artefacts.attachmentDeleted": "Attachment deleted",
|
||||
"artefacts.failedDeleteAttachment": "Failed to delete attachment",
|
||||
"artefacts.submittedForReview": "Submitted for review!",
|
||||
"artefacts.failedSubmitReview": "Failed to submit for review",
|
||||
"artefacts.linkCopied": "Link copied to clipboard",
|
||||
"artefacts.commentAdded": "Comment added",
|
||||
"artefacts.failedAddComment": "Failed to add comment",
|
||||
"artefacts.updated": "Updated",
|
||||
"artefacts.failedUpdate": "Failed to update",
|
||||
"artefacts.draftSaved": "Draft saved",
|
||||
"artefacts.failedSaveDraft": "Failed to save draft",
|
||||
"artefacts.titleRequired": "Title is required",
|
||||
"artefacts.failedDelete": "Failed to delete",
|
||||
|
||||
"posts.images": "Images",
|
||||
"posts.audio": "Audio",
|
||||
"posts.videos": "Videos",
|
||||
"posts.otherFiles": "Other Files",
|
||||
"posts.addImage": "Add Image",
|
||||
"posts.addAudio": "Add Audio",
|
||||
"posts.addVideo": "Add Video",
|
||||
"posts.dragToUpload": "Drag files here to upload",
|
||||
"posts.assignedTo": "Assigned To",
|
||||
"posts.approval": "Approval",
|
||||
"posts.approvers": "Approvers",
|
||||
"posts.selectApprovers": "Select approvers...",
|
||||
"posts.scheduling": "Scheduling & Assignment",
|
||||
"posts.content": "Content"
|
||||
}
|
||||
@@ -13,12 +13,12 @@ import { SkeletonTable, SkeletonKanbanBoard } from '../components/SkeletonLoader
|
||||
import BulkSelectBar from '../components/BulkSelectBar'
|
||||
import Modal from '../components/Modal'
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'request', label: 'Request' },
|
||||
{ value: 'correction', label: 'Correction' },
|
||||
{ value: 'complaint', label: 'Complaint' },
|
||||
{ value: 'suggestion', label: 'Suggestion' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
const TYPE_OPTION_KEYS = [
|
||||
{ value: 'request', labelKey: 'issues.typeRequest' },
|
||||
{ value: 'correction', labelKey: 'issues.typeCorrection' },
|
||||
{ value: 'complaint', labelKey: 'issues.typeComplaint' },
|
||||
{ value: 'suggestion', labelKey: 'issues.typeSuggestion' },
|
||||
{ value: 'other', labelKey: 'issues.typeOther' },
|
||||
]
|
||||
|
||||
// Issue-specific status order for the kanban board
|
||||
@@ -148,7 +148,7 @@ export default function Issues() {
|
||||
toast.success(t('issues.statusUpdated'))
|
||||
} catch (err) {
|
||||
console.error('Move issue failed:', err)
|
||||
toast.error('Failed to update status')
|
||||
toast.error(t('issues.failedToUpdateStatus'))
|
||||
// Rollback on error
|
||||
setIssues(prev)
|
||||
}
|
||||
@@ -157,7 +157,7 @@ export default function Issues() {
|
||||
const handleBulkDelete = async () => {
|
||||
try {
|
||||
await api.post('/issues/bulk-delete', { ids: [...selectedIds] })
|
||||
toast.success('Issues deleted')
|
||||
toast.success(t('issues.issuesDeleted'))
|
||||
setSelectedIds(new Set())
|
||||
setShowBulkDeleteConfirm(false)
|
||||
loadData()
|
||||
@@ -215,9 +215,9 @@ export default function Issues() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-2">
|
||||
<AlertCircle className="w-7 h-7" />
|
||||
Issues
|
||||
{t('issues.title')}
|
||||
</h1>
|
||||
<p className="text-text-secondary mt-1">Track and manage issue submissions</p>
|
||||
<p className="text-text-secondary mt-1">{t('issues.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -279,7 +279,7 @@ export default function Issues() {
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search issues..."
|
||||
placeholder={t('issues.searchPlaceholder')}
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
|
||||
@@ -291,7 +291,7 @@ export default function Issues() {
|
||||
onChange={e => updateFilter('status', e.target.value)}
|
||||
className="px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="">{t('issues.allStatuses')}</option>
|
||||
{Object.entries(ISSUE_STATUS_CONFIG).map(([key, config]) => (
|
||||
<option key={key} value={key}>{config.label}</option>
|
||||
))}
|
||||
@@ -302,7 +302,7 @@ export default function Issues() {
|
||||
onChange={e => updateFilter('category', e.target.value)}
|
||||
className="px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
<option value="">{t('issues.allCategories')}</option>
|
||||
{categories.map(cat => <option key={cat} value={cat}>{cat}</option>)}
|
||||
</select>
|
||||
|
||||
@@ -311,8 +311,8 @@ export default function Issues() {
|
||||
onChange={e => updateFilter('type', e.target.value)}
|
||||
className="px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
{TYPE_OPTIONS.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
|
||||
<option value="">{t('issues.allTypes')}</option>
|
||||
{TYPE_OPTION_KEYS.map(opt => <option key={opt.value} value={opt.value}>{t(opt.labelKey)}</option>)}
|
||||
</select>
|
||||
|
||||
<select
|
||||
@@ -320,7 +320,7 @@ export default function Issues() {
|
||||
onChange={e => updateFilter('brand', e.target.value)}
|
||||
className="px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
|
||||
>
|
||||
<option value="">All Brands</option>
|
||||
<option value="">{t('issues.allBrands')}</option>
|
||||
{(brands || []).map(b => (
|
||||
<option key={b._id || b.Id} value={b._id || b.Id}>{b.name}</option>
|
||||
))}
|
||||
@@ -342,7 +342,7 @@ export default function Issues() {
|
||||
onChange={e => updateFilter('priority', e.target.value)}
|
||||
className="px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
|
||||
>
|
||||
<option value="">All Priorities</option>
|
||||
<option value="">{t('issues.allPriorities')}</option>
|
||||
{Object.entries(PRIORITY_CONFIG).map(([key, config]) => (
|
||||
<option key={key} value={key}>{config.label}</option>
|
||||
))}
|
||||
@@ -350,7 +350,7 @@ export default function Issues() {
|
||||
|
||||
{hasActiveFilters && (
|
||||
<button onClick={clearFilters} className="px-3 py-2 rounded-lg text-sm font-medium text-text-tertiary hover:text-text-primary">
|
||||
Clear All
|
||||
{t('issues.clearAll')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -360,8 +360,8 @@ export default function Issues() {
|
||||
filteredIssues.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="No issues found"
|
||||
description={hasActiveFilters ? 'Try adjusting your filters' : 'No issues have been submitted yet'}
|
||||
title={t('issues.noIssuesFound')}
|
||||
description={hasActiveFilters ? t('issues.tryAdjustingFilters') : t('issues.noIssuesSubmitted')}
|
||||
/>
|
||||
) : (
|
||||
<KanbanBoard
|
||||
@@ -394,8 +394,8 @@ export default function Issues() {
|
||||
sortedIssues.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="No issues found"
|
||||
description={hasActiveFilters ? 'Try adjusting your filters' : 'No issues have been submitted yet'}
|
||||
title={t('issues.noIssuesFound')}
|
||||
description={hasActiveFilters ? t('issues.tryAdjustingFilters') : t('issues.noIssuesSubmitted')}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-surface rounded-lg border border-border overflow-hidden">
|
||||
@@ -414,21 +414,21 @@ export default function Issues() {
|
||||
<input type="checkbox" checked={selectedIds.size === sortedIssues.length && sortedIssues.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('title')}>
|
||||
Title <SortIcon col="title" />
|
||||
{t('issues.tableTitle')} <SortIcon col="title" />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">Submitter</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">Brand</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">Category</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">Type</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableSubmitter')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableBrand')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableCategory')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableType')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('priority')}>
|
||||
Priority <SortIcon col="priority" />
|
||||
{t('issues.tablePriority')} <SortIcon col="priority" />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('status')}>
|
||||
Status <SortIcon col="status" />
|
||||
{t('issues.tableStatus')} <SortIcon col="status" />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">Assigned To</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableAssignedTo')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('created_at')}>
|
||||
Created <SortIcon col="created_at" />
|
||||
{t('issues.tableCreated')} <SortIcon col="created_at" />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -454,7 +454,7 @@ export default function Issues() {
|
||||
<td className="px-4 py-3 text-sm text-text-secondary">{issue.category || '—'}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-surface-tertiary text-text-secondary">
|
||||
{TYPE_OPTIONS.find(t => t.value === issue.type)?.label || issue.type}
|
||||
{(() => { const opt = TYPE_OPTION_KEYS.find(o => o.value === issue.type); return opt ? t(opt.labelKey) : issue.type })()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
|
||||
@@ -79,14 +79,17 @@ export default function PostProduction() {
|
||||
}
|
||||
|
||||
const handlePanelSave = async (postId, data) => {
|
||||
let result
|
||||
if (postId) {
|
||||
await api.patch(`/posts/${postId}`, data)
|
||||
result = await api.patch(`/posts/${postId}`, data)
|
||||
toast.success(t('posts.updated'))
|
||||
} else {
|
||||
await api.post('/posts', data)
|
||||
result = await api.post('/posts', data)
|
||||
toast.success(t('posts.created'))
|
||||
}
|
||||
loadPosts()
|
||||
// Update panel with fresh server data so form stays in sync
|
||||
if (result && postId) setPanelPost(result)
|
||||
}
|
||||
|
||||
const handlePanelDelete = async (postId) => {
|
||||
@@ -308,6 +311,7 @@ export default function PostProduction() {
|
||||
{ id: 'draft', label: t('posts.status.draft'), color: 'bg-gray-400' },
|
||||
{ id: 'in_review', label: t('posts.status.in_review'), color: 'bg-amber-400' },
|
||||
{ id: 'approved', label: t('posts.status.approved'), color: 'bg-blue-400' },
|
||||
{ id: 'rejected', label: t('posts.status.rejected'), color: 'bg-red-400' },
|
||||
{ id: 'scheduled', label: t('posts.status.scheduled'), color: 'bg-purple-400' },
|
||||
{ id: 'published', label: t('posts.status.published'), color: 'bg-emerald-400' },
|
||||
]}
|
||||
|
||||
@@ -157,7 +157,7 @@ export default function Team() {
|
||||
}
|
||||
|
||||
// Sync team memberships if team_ids provided
|
||||
if (data.team_ids !== undefined && memberId && !isEditingSelf) {
|
||||
if (data.team_ids !== undefined && memberId) {
|
||||
const member = teamMembers.find(m => (m.id || m._id) === memberId)
|
||||
const currentTeamIds = member?.teams ? member.teams.map(t => t.id) : []
|
||||
const targetTeamIds = data.team_ids || []
|
||||
|
||||
Reference in New Issue
Block a user