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

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

View File

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

View File

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