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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
import { useState, useEffect, useRef } from 'react'
import { X, Trash2, Upload, FileText, Link2, ExternalLink, FolderOpen } from 'lucide-react'
import { X, Trash2, Upload, FileText, Link2, ExternalLink, FolderOpen, Image as ImageIcon, Music, Film } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import { api, PLATFORMS, getBrandColor } from '../utils/api'
import ApproverMultiSelect from './ApproverMultiSelect'
import CommentsSection from './CommentsSection'
import Modal from './Modal'
import SlidePanel from './SlidePanel'
@@ -9,7 +10,9 @@ import CollapsibleSection from './CollapsibleSection'
export default function PostDetailPanel({ post, onClose, onSave, onDelete, brands, teamMembers, campaigns }) {
const { t, lang } = useLanguage()
const fileInputRef = useRef(null)
const imageInputRef = useRef(null)
const audioInputRef = useRef(null)
const videoInputRef = useRef(null)
const [form, setForm] = useState({})
const [dirty, setDirty] = useState(false)
const [saving, setSaving] = useState(false)
@@ -36,10 +39,11 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
platforms: post.platforms || (post.platform ? [post.platform] : []),
status: post.status || 'draft',
assigned_to: post.assignedTo || post.assigned_to || '',
scheduled_date: post.scheduledDate ? new Date(post.scheduledDate).toISOString().slice(0, 16) : '',
scheduled_date: post.scheduledDate ? new Date(post.scheduledDate).toISOString().slice(0, 10) : (post.scheduled_date ? new Date(post.scheduled_date).toISOString().slice(0, 10) : ''),
notes: post.notes || '',
campaign_id: post.campaignId || post.campaign_id || '',
publication_links: post.publication_links || post.publicationLinks || [],
approver_ids: post.approvers?.map(a => String(a.id)) || (post.approver_ids ? post.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : []),
})
setDirty(isCreateMode)
setPublishError('')
@@ -53,6 +57,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
{ value: 'draft', label: t('posts.status.draft') },
{ value: 'in_review', label: t('posts.status.in_review') },
{ value: 'approved', label: t('posts.status.approved') },
{ value: 'rejected', label: t('posts.status.rejected') },
{ value: 'scheduled', label: t('posts.status.scheduled') },
{ value: 'published', label: t('posts.status.published') },
]
@@ -91,6 +96,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
notes: form.notes,
campaign_id: form.campaign_id ? Number(form.campaign_id) : null,
publication_links: form.publication_links || [],
approver_ids: (form.approver_ids || []).length > 0 ? form.approver_ids.join(',') : null,
}
if (data.status === 'published' && data.platforms.length > 0) {
@@ -212,6 +218,7 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
form.status === 'scheduled' ? 'bg-purple-100 text-purple-700' :
form.status === 'approved' ? 'bg-blue-100 text-blue-700' :
form.status === 'in_review' ? 'bg-amber-100 text-amber-700' :
form.status === 'rejected' ? 'bg-red-100 text-red-700' :
'bg-gray-100 text-gray-600'
}`}>
{statusOptions.find(s => s.value === form.status)?.label}
@@ -235,9 +242,47 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
return (
<>
<SlidePanel onClose={onClose} maxWidth="520px" header={header}>
{/* Details Section */}
<CollapsibleSection title={t('posts.details')}>
<SlidePanel onClose={onClose} maxWidth="520px" header={header} footer={
<div className="bg-surface border-t border-border px-5 py-3 flex items-center gap-2 shrink-0">
{dirty ? (
<>
<button
onClick={handleSave}
disabled={!form.title || saving}
className={`flex-1 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${saving ? 'btn-loading' : ''}`}
>
{isCreateMode ? t('posts.createPost') : t('posts.saveChanges')}
</button>
{!isCreateMode && (
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg transition-colors"
>
{t('common.cancel')}
</button>
)}
</>
) : (
<button
onClick={onClose}
className="flex-1 px-4 py-2 text-sm font-medium text-text-secondary border border-border rounded-lg hover:bg-surface-tertiary transition-colors"
>
{t('common.close')}
</button>
)}
{onDelete && !isCreateMode && (
<button
onClick={() => setShowDeleteConfirm(true)}
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title={t('common.delete')}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
}>
{/* Content Section */}
<CollapsibleSection title={t('posts.content')}>
<div className="px-5 pb-4 space-y-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.description')}</label>
@@ -275,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">

View File

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