feat: post approval workflow, i18n completion, and multiple fixes
All checks were successful
Deploy / deploy (push) Successful in 11s
All checks were successful
Deploy / deploy (push) Successful in 11s
- Add approval process to posts (approver multi-select, rejected status column) - Reorganize PostDetailPanel into Content, Scheduling, Approval sections - Fix save button visibility: move to fixed footer via SlidePanel footer prop - Change date picker from datetime-local to date-only - Complete Arabic translations across all panels (Header, Issues, Artefacts) - Fix artefact versioning to start empty (copyFromPrevious defaults to false) - Separate media uploads by type (image, audio, video) in PostDetailPanel - Fix team membership save when editing own profile as superadmin - Server: add approver_ids column to Posts, enrich GET/POST/PATCH responses Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,12 +13,12 @@ import { SkeletonTable, SkeletonKanbanBoard } from '../components/SkeletonLoader
|
||||
import BulkSelectBar from '../components/BulkSelectBar'
|
||||
import Modal from '../components/Modal'
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'request', label: 'Request' },
|
||||
{ value: 'correction', label: 'Correction' },
|
||||
{ value: 'complaint', label: 'Complaint' },
|
||||
{ value: 'suggestion', label: 'Suggestion' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
const TYPE_OPTION_KEYS = [
|
||||
{ value: 'request', labelKey: 'issues.typeRequest' },
|
||||
{ value: 'correction', labelKey: 'issues.typeCorrection' },
|
||||
{ value: 'complaint', labelKey: 'issues.typeComplaint' },
|
||||
{ value: 'suggestion', labelKey: 'issues.typeSuggestion' },
|
||||
{ value: 'other', labelKey: 'issues.typeOther' },
|
||||
]
|
||||
|
||||
// Issue-specific status order for the kanban board
|
||||
@@ -148,7 +148,7 @@ export default function Issues() {
|
||||
toast.success(t('issues.statusUpdated'))
|
||||
} catch (err) {
|
||||
console.error('Move issue failed:', err)
|
||||
toast.error('Failed to update status')
|
||||
toast.error(t('issues.failedToUpdateStatus'))
|
||||
// Rollback on error
|
||||
setIssues(prev)
|
||||
}
|
||||
@@ -157,7 +157,7 @@ export default function Issues() {
|
||||
const handleBulkDelete = async () => {
|
||||
try {
|
||||
await api.post('/issues/bulk-delete', { ids: [...selectedIds] })
|
||||
toast.success('Issues deleted')
|
||||
toast.success(t('issues.issuesDeleted'))
|
||||
setSelectedIds(new Set())
|
||||
setShowBulkDeleteConfirm(false)
|
||||
loadData()
|
||||
@@ -215,9 +215,9 @@ export default function Issues() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-2">
|
||||
<AlertCircle className="w-7 h-7" />
|
||||
Issues
|
||||
{t('issues.title')}
|
||||
</h1>
|
||||
<p className="text-text-secondary mt-1">Track and manage issue submissions</p>
|
||||
<p className="text-text-secondary mt-1">{t('issues.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -279,7 +279,7 @@ export default function Issues() {
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search issues..."
|
||||
placeholder={t('issues.searchPlaceholder')}
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
|
||||
@@ -291,7 +291,7 @@ export default function Issues() {
|
||||
onChange={e => updateFilter('status', e.target.value)}
|
||||
className="px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="">{t('issues.allStatuses')}</option>
|
||||
{Object.entries(ISSUE_STATUS_CONFIG).map(([key, config]) => (
|
||||
<option key={key} value={key}>{config.label}</option>
|
||||
))}
|
||||
@@ -302,7 +302,7 @@ export default function Issues() {
|
||||
onChange={e => updateFilter('category', e.target.value)}
|
||||
className="px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
<option value="">{t('issues.allCategories')}</option>
|
||||
{categories.map(cat => <option key={cat} value={cat}>{cat}</option>)}
|
||||
</select>
|
||||
|
||||
@@ -311,8 +311,8 @@ export default function Issues() {
|
||||
onChange={e => updateFilter('type', e.target.value)}
|
||||
className="px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
{TYPE_OPTIONS.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
|
||||
<option value="">{t('issues.allTypes')}</option>
|
||||
{TYPE_OPTION_KEYS.map(opt => <option key={opt.value} value={opt.value}>{t(opt.labelKey)}</option>)}
|
||||
</select>
|
||||
|
||||
<select
|
||||
@@ -320,7 +320,7 @@ export default function Issues() {
|
||||
onChange={e => updateFilter('brand', e.target.value)}
|
||||
className="px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
|
||||
>
|
||||
<option value="">All Brands</option>
|
||||
<option value="">{t('issues.allBrands')}</option>
|
||||
{(brands || []).map(b => (
|
||||
<option key={b._id || b.Id} value={b._id || b.Id}>{b.name}</option>
|
||||
))}
|
||||
@@ -342,7 +342,7 @@ export default function Issues() {
|
||||
onChange={e => updateFilter('priority', e.target.value)}
|
||||
className="px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
|
||||
>
|
||||
<option value="">All Priorities</option>
|
||||
<option value="">{t('issues.allPriorities')}</option>
|
||||
{Object.entries(PRIORITY_CONFIG).map(([key, config]) => (
|
||||
<option key={key} value={key}>{config.label}</option>
|
||||
))}
|
||||
@@ -350,7 +350,7 @@ export default function Issues() {
|
||||
|
||||
{hasActiveFilters && (
|
||||
<button onClick={clearFilters} className="px-3 py-2 rounded-lg text-sm font-medium text-text-tertiary hover:text-text-primary">
|
||||
Clear All
|
||||
{t('issues.clearAll')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -360,8 +360,8 @@ export default function Issues() {
|
||||
filteredIssues.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="No issues found"
|
||||
description={hasActiveFilters ? 'Try adjusting your filters' : 'No issues have been submitted yet'}
|
||||
title={t('issues.noIssuesFound')}
|
||||
description={hasActiveFilters ? t('issues.tryAdjustingFilters') : t('issues.noIssuesSubmitted')}
|
||||
/>
|
||||
) : (
|
||||
<KanbanBoard
|
||||
@@ -394,8 +394,8 @@ export default function Issues() {
|
||||
sortedIssues.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="No issues found"
|
||||
description={hasActiveFilters ? 'Try adjusting your filters' : 'No issues have been submitted yet'}
|
||||
title={t('issues.noIssuesFound')}
|
||||
description={hasActiveFilters ? t('issues.tryAdjustingFilters') : t('issues.noIssuesSubmitted')}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-surface rounded-lg border border-border overflow-hidden">
|
||||
@@ -414,21 +414,21 @@ export default function Issues() {
|
||||
<input type="checkbox" checked={selectedIds.size === sortedIssues.length && sortedIssues.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('title')}>
|
||||
Title <SortIcon col="title" />
|
||||
{t('issues.tableTitle')} <SortIcon col="title" />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">Submitter</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">Brand</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">Category</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">Type</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableSubmitter')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableBrand')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableCategory')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableType')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('priority')}>
|
||||
Priority <SortIcon col="priority" />
|
||||
{t('issues.tablePriority')} <SortIcon col="priority" />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('status')}>
|
||||
Status <SortIcon col="status" />
|
||||
{t('issues.tableStatus')} <SortIcon col="status" />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">Assigned To</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('issues.tableAssignedTo')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('created_at')}>
|
||||
Created <SortIcon col="created_at" />
|
||||
{t('issues.tableCreated')} <SortIcon col="created_at" />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -454,7 +454,7 @@ export default function Issues() {
|
||||
<td className="px-4 py-3 text-sm text-text-secondary">{issue.category || '—'}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-surface-tertiary text-text-secondary">
|
||||
{TYPE_OPTIONS.find(t => t.value === issue.type)?.label || issue.type}
|
||||
{(() => { const opt = TYPE_OPTION_KEYS.find(o => o.value === issue.type); return opt ? t(opt.labelKey) : issue.type })()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
|
||||
@@ -79,14 +79,17 @@ export default function PostProduction() {
|
||||
}
|
||||
|
||||
const handlePanelSave = async (postId, data) => {
|
||||
let result
|
||||
if (postId) {
|
||||
await api.patch(`/posts/${postId}`, data)
|
||||
result = await api.patch(`/posts/${postId}`, data)
|
||||
toast.success(t('posts.updated'))
|
||||
} else {
|
||||
await api.post('/posts', data)
|
||||
result = await api.post('/posts', data)
|
||||
toast.success(t('posts.created'))
|
||||
}
|
||||
loadPosts()
|
||||
// Update panel with fresh server data so form stays in sync
|
||||
if (result && postId) setPanelPost(result)
|
||||
}
|
||||
|
||||
const handlePanelDelete = async (postId) => {
|
||||
@@ -308,6 +311,7 @@ export default function PostProduction() {
|
||||
{ id: 'draft', label: t('posts.status.draft'), color: 'bg-gray-400' },
|
||||
{ id: 'in_review', label: t('posts.status.in_review'), color: 'bg-amber-400' },
|
||||
{ id: 'approved', label: t('posts.status.approved'), color: 'bg-blue-400' },
|
||||
{ id: 'rejected', label: t('posts.status.rejected'), color: 'bg-red-400' },
|
||||
{ id: 'scheduled', label: t('posts.status.scheduled'), color: 'bg-purple-400' },
|
||||
{ id: 'published', label: t('posts.status.published'), color: 'bg-emerald-400' },
|
||||
]}
|
||||
|
||||
@@ -157,7 +157,7 @@ export default function Team() {
|
||||
}
|
||||
|
||||
// Sync team memberships if team_ids provided
|
||||
if (data.team_ids !== undefined && memberId && !isEditingSelf) {
|
||||
if (data.team_ids !== undefined && memberId) {
|
||||
const member = teamMembers.find(m => (m.id || m._id) === memberId)
|
||||
const currentTeamIds = member?.teams ? member.teams.map(t => t.id) : []
|
||||
const targetTeamIds = data.team_ids || []
|
||||
|
||||
Reference in New Issue
Block a user