feat: post approval workflow, i18n completion, and multiple fixes
Deploy / deploy (push) Successful in 11s
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user