All checks were successful
Deploy / deploy (push) Successful in 11s
- Campaigns: add create modal (name, brand, team, dates, budget) - PostProduction: add create modal (title, brand, campaign, assignee), auto-opens detail panel after creation - Tasks: add create modal (title, project, priority, assignee), auto-opens detail panel after creation - Fix profileComplete check: use !!user.name instead of !!user.team_role in /api/auth/me (was always showing profile prompt since team_role is now deprecated in favor of role_id) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
456 lines
20 KiB
JavaScript
456 lines
20 KiB
JavaScript
import { useState, useEffect, useContext } from 'react'
|
||
import { Plus, LayoutGrid, List, Search, X, FileText, Filter } from 'lucide-react'
|
||
import { AppContext } from '../App'
|
||
import { useAuth } from '../contexts/AuthContext'
|
||
import { useLanguage } from '../i18n/LanguageContext'
|
||
import { api, PLATFORMS } from '../utils/api'
|
||
import KanbanBoard from '../components/KanbanBoard'
|
||
import KanbanCard from '../components/KanbanCard'
|
||
import PostCard from '../components/PostCard'
|
||
import PostDetailPanel from '../components/PostDetailPanel'
|
||
import DatePresetPicker from '../components/DatePresetPicker'
|
||
import { SkeletonKanbanBoard, SkeletonTable } from '../components/SkeletonLoader'
|
||
import EmptyState from '../components/EmptyState'
|
||
import BulkSelectBar from '../components/BulkSelectBar'
|
||
import Modal from '../components/Modal'
|
||
import { useToast } from '../components/ToastContainer'
|
||
|
||
const EMPTY_POST = {
|
||
title: '', description: '', brand_id: '', platforms: [],
|
||
status: 'draft', assigned_to: '', scheduled_date: '', notes: '', campaign_id: '',
|
||
publication_links: [],
|
||
}
|
||
|
||
export default function PostProduction() {
|
||
const { t, lang } = useLanguage()
|
||
const { teamMembers, brands, getBrandName } = useContext(AppContext)
|
||
const { canEditResource } = useAuth()
|
||
const toast = useToast()
|
||
const [posts, setPosts] = useState([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [view, setView] = useState('kanban')
|
||
const [panelPost, setPanelPost] = useState(null)
|
||
const [campaigns, setCampaigns] = useState([])
|
||
const [filters, setFilters] = useState({ brand: '', platform: '', assignedTo: '', campaign: '', periodFrom: '', periodTo: '' })
|
||
const [searchTerm, setSearchTerm] = useState('')
|
||
const [activePreset, setActivePreset] = useState('')
|
||
const [moveError, setMoveError] = useState('')
|
||
const [selectedIds, setSelectedIds] = useState(new Set())
|
||
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
|
||
const [showFilters, setShowFilters] = useState(false)
|
||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||
const [createForm, setCreateForm] = useState({ ...EMPTY_POST })
|
||
const [createSaving, setCreateSaving] = useState(false)
|
||
|
||
useEffect(() => {
|
||
loadPosts()
|
||
api.get('/campaigns').then(r => setCampaigns(Array.isArray(r) ? r : [])).catch(() => {})
|
||
}, [])
|
||
|
||
const loadPosts = async () => {
|
||
try {
|
||
const res = await api.get('/posts')
|
||
setPosts(Array.isArray(res) ? res : [])
|
||
} catch (err) {
|
||
console.error('Failed to load posts:', err)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const handleMovePost = async (postId, newStatus) => {
|
||
// Optimistic update — move the card instantly
|
||
const prev = posts
|
||
setPosts(posts.map(p => p._id === postId ? { ...p, status: newStatus } : p))
|
||
try {
|
||
await api.patch(`/posts/${postId}`, { status: newStatus })
|
||
toast.success(t('posts.statusUpdated'))
|
||
} catch (err) {
|
||
console.error('Move failed:', err)
|
||
setPosts(prev)
|
||
if (err.message?.includes('Cannot publish')) {
|
||
setMoveError(t('posts.publishRequired'))
|
||
setTimeout(() => setMoveError(''), 5000)
|
||
toast.error(t('posts.publishRequired'))
|
||
} else {
|
||
toast.error(t('common.updateFailed'))
|
||
}
|
||
}
|
||
}
|
||
|
||
const handlePanelSave = async (postId, data) => {
|
||
if (postId) {
|
||
await api.patch(`/posts/${postId}`, data)
|
||
toast.success(t('posts.updated'))
|
||
} else {
|
||
await api.post('/posts', data)
|
||
toast.success(t('posts.created'))
|
||
}
|
||
loadPosts()
|
||
}
|
||
|
||
const handlePanelDelete = async (postId) => {
|
||
try {
|
||
await api.delete(`/posts/${postId}`)
|
||
toast.success(t('posts.deleted'))
|
||
loadPosts()
|
||
} catch (err) {
|
||
console.error('Delete failed:', err)
|
||
toast.error(t('common.deleteFailed'))
|
||
}
|
||
}
|
||
|
||
const handleBulkDelete = async () => {
|
||
try {
|
||
await api.post('/posts/bulk-delete', { ids: [...selectedIds] })
|
||
toast.success(t('posts.deleted'))
|
||
setSelectedIds(new Set())
|
||
setShowBulkDeleteConfirm(false)
|
||
loadPosts()
|
||
} catch (err) {
|
||
console.error('Bulk delete failed:', err)
|
||
toast.error(t('common.deleteFailed'))
|
||
}
|
||
}
|
||
|
||
const toggleSelect = (id) => {
|
||
setSelectedIds(prev => {
|
||
const next = new Set(prev)
|
||
if (next.has(id)) next.delete(id)
|
||
else next.add(id)
|
||
return next
|
||
})
|
||
}
|
||
|
||
const toggleSelectAll = () => {
|
||
if (selectedIds.size === filteredPosts.length) setSelectedIds(new Set())
|
||
else setSelectedIds(new Set(filteredPosts.map(p => p._id || p.id || p.Id)))
|
||
}
|
||
|
||
const openEdit = (post) => {
|
||
if (!canEditResource('post', post)) {
|
||
toast.error(t('posts.canOnlyEditOwn'))
|
||
return
|
||
}
|
||
setPanelPost(post)
|
||
}
|
||
|
||
const openNew = () => {
|
||
setCreateForm({ ...EMPTY_POST })
|
||
setShowCreateModal(true)
|
||
}
|
||
|
||
const handleCreate = async () => {
|
||
setCreateSaving(true)
|
||
try {
|
||
const data = {
|
||
title: createForm.title,
|
||
brand_id: createForm.brand_id ? Number(createForm.brand_id) : null,
|
||
campaign_id: createForm.campaign_id ? Number(createForm.campaign_id) : null,
|
||
assigned_to: createForm.assigned_to ? Number(createForm.assigned_to) : null,
|
||
status: 'draft',
|
||
}
|
||
const created = await api.post('/posts', data)
|
||
setShowCreateModal(false)
|
||
toast.success(t('posts.created'))
|
||
loadPosts()
|
||
// Open the detail panel for further editing
|
||
if (created) setPanelPost(created)
|
||
} catch (err) {
|
||
console.error('Create post failed:', err)
|
||
toast.error(t('common.saveFailed'))
|
||
} finally {
|
||
setCreateSaving(false)
|
||
}
|
||
}
|
||
|
||
const filteredPosts = posts.filter(p => {
|
||
if (filters.brand && String(p.brandId || p.brand_id) !== filters.brand) return false
|
||
if (filters.platform && !(p.platforms || []).includes(filters.platform) && p.platform !== filters.platform) return false
|
||
if (filters.assignedTo && String(p.assignedTo || p.assigned_to) !== filters.assignedTo) return false
|
||
if (filters.campaign && String(p.campaignId || p.campaign_id) !== filters.campaign) return false
|
||
if (searchTerm && !p.title?.toLowerCase().includes(searchTerm.toLowerCase())) return false
|
||
if (filters.periodFrom || filters.periodTo) {
|
||
const postDate = p.scheduledDate || p.scheduled_date || p.published_date || p.publishedDate
|
||
if (!postDate) return false
|
||
const d = new Date(postDate).toISOString().slice(0, 10)
|
||
if (filters.periodFrom && d < filters.periodFrom) return false
|
||
if (filters.periodTo && d > filters.periodTo) return false
|
||
}
|
||
return true
|
||
})
|
||
|
||
if (loading) {
|
||
return view === 'kanban' ? <SkeletonKanbanBoard /> : <SkeletonTable rows={8} cols={6} />
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4 animate-fade-in">
|
||
{/* Toolbar */}
|
||
<div className="space-y-2">
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||
<input
|
||
type="text"
|
||
placeholder={t('posts.searchPosts')}
|
||
value={searchTerm}
|
||
onChange={e => setSearchTerm(e.target.value)}
|
||
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
|
||
/>
|
||
</div>
|
||
|
||
<button
|
||
data-tutorial="filters"
|
||
onClick={() => setShowFilters(f => !f)}
|
||
className={`relative flex items-center gap-1.5 px-3 py-2 text-sm border rounded-lg transition-colors ${showFilters ? 'border-brand-primary bg-brand-primary/5 text-brand-primary' : 'border-border bg-white text-text-secondary hover:border-brand-primary/40'}`}
|
||
>
|
||
<Filter className="w-4 h-4" />
|
||
{t('common.filter')}
|
||
{(filters.brand || filters.platform || filters.assignedTo || filters.periodFrom || filters.periodTo) && (
|
||
<span className="w-1.5 h-1.5 rounded-full bg-brand-primary" />
|
||
)}
|
||
</button>
|
||
|
||
<div className="flex bg-surface-tertiary rounded-lg p-0.5 ml-auto">
|
||
<button
|
||
onClick={() => setView('kanban')}
|
||
className={`p-2 rounded-md ${view === 'kanban' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||
>
|
||
<LayoutGrid className="w-4 h-4" />
|
||
</button>
|
||
<button
|
||
onClick={() => setView('list')}
|
||
className={`p-2 rounded-md ${view === 'list' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||
>
|
||
<List className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
|
||
<button
|
||
data-tutorial="new-post"
|
||
onClick={openNew}
|
||
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
{t('posts.newPost')}
|
||
</button>
|
||
</div>
|
||
|
||
{showFilters && (
|
||
<div className="flex items-center gap-2 flex-wrap animate-fade-in">
|
||
<select
|
||
value={filters.brand}
|
||
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
|
||
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||
>
|
||
<option value="">{t('posts.allBrands')}</option>
|
||
{brands.map(b => <option key={b._id} value={b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
|
||
</select>
|
||
|
||
<select
|
||
value={filters.platform}
|
||
onChange={e => setFilters(f => ({ ...f, platform: e.target.value }))}
|
||
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||
>
|
||
<option value="">{t('posts.allPlatforms')}</option>
|
||
{Object.entries(PLATFORMS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
||
</select>
|
||
|
||
<select
|
||
value={filters.assignedTo}
|
||
onChange={e => setFilters(f => ({ ...f, assignedTo: e.target.value }))}
|
||
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||
>
|
||
<option value="">{t('posts.allPeople')}</option>
|
||
{teamMembers.map(m => <option key={m._id} value={String(m._id)}>{m.name}</option>)}
|
||
</select>
|
||
|
||
<DatePresetPicker
|
||
activePreset={activePreset}
|
||
onSelect={(from, to, key) => { setFilters(f => ({ ...f, periodFrom: from, periodTo: to })); setActivePreset(key) }}
|
||
onClear={() => { setFilters(f => ({ ...f, periodFrom: '', periodTo: '' })); setActivePreset('') }}
|
||
/>
|
||
|
||
<div className="flex items-center gap-1.5">
|
||
<input
|
||
type="date"
|
||
value={filters.periodFrom}
|
||
onChange={e => { setFilters(f => ({ ...f, periodFrom: e.target.value })); setActivePreset('') }}
|
||
title={t('posts.periodFrom')}
|
||
className="text-xs border border-border rounded-lg px-2 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||
/>
|
||
<span className="text-xs text-text-tertiary">–</span>
|
||
<input
|
||
type="date"
|
||
value={filters.periodTo}
|
||
onChange={e => { setFilters(f => ({ ...f, periodTo: e.target.value })); setActivePreset('') }}
|
||
title={t('posts.periodTo')}
|
||
className="text-xs border border-border rounded-lg px-2 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{moveError && (
|
||
<div className="p-3 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-700 flex items-center justify-between">
|
||
<span>{moveError}</span>
|
||
<button onClick={() => setMoveError('')} className="p-0.5 hover:bg-amber-100 rounded">
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{view === 'kanban' ? (
|
||
<KanbanBoard
|
||
columns={[
|
||
{ 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: 'scheduled', label: t('posts.status.scheduled'), color: 'bg-purple-400' },
|
||
{ id: 'published', label: t('posts.status.published'), color: 'bg-emerald-400' },
|
||
]}
|
||
items={filteredPosts}
|
||
getItemId={(p) => p._id}
|
||
onMove={(id, status) => handleMovePost(id, status)}
|
||
renderCard={(post) => {
|
||
const brandName = getBrandName(post.brand_id || post.brandId) || post.brand_name || post.brand
|
||
const assignee = post.assignedToName || post.assignedName || post.assigned_name
|
||
return (
|
||
<KanbanCard
|
||
title={post.title}
|
||
thumbnail={post.thumbnail_url}
|
||
brandName={brandName}
|
||
assigneeName={assignee}
|
||
date={post.scheduledDate}
|
||
onClick={() => openEdit(post)}
|
||
/>
|
||
)
|
||
}}
|
||
/>
|
||
) : (
|
||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||
{filteredPosts.length === 0 ? (
|
||
<EmptyState
|
||
icon={FileText}
|
||
title={posts.length === 0 ? t('posts.noPosts') : t('posts.noPostsFound')}
|
||
description={posts.length === 0 ? t('posts.createFirstPost') : t('posts.tryDifferentFilter')}
|
||
actionLabel={posts.length === 0 ? t('posts.createPost') : null}
|
||
onAction={posts.length === 0 ? openNew : null}
|
||
secondaryActionLabel={posts.length > 0 ? t('common.clearFilters') : null}
|
||
onSecondaryAction={() => {
|
||
setFilters({ brand: '', platform: '', assignedTo: '', campaign: '', periodFrom: '', periodTo: '' })
|
||
setSearchTerm('')
|
||
}}
|
||
/>
|
||
) : (
|
||
<>
|
||
{selectedIds.size > 0 && (
|
||
<div className="px-4 pt-3">
|
||
<BulkSelectBar selectedCount={selectedIds.size} onDelete={() => setShowBulkDeleteConfirm(true)} onClear={() => setSelectedIds(new Set())} />
|
||
</div>
|
||
)}
|
||
<table className="w-full">
|
||
<thead>
|
||
<tr className="border-b border-border bg-surface-secondary">
|
||
<th className="w-10 px-3 py-3" onClick={e => e.stopPropagation()}>
|
||
<input type="checkbox" checked={selectedIds.size === filteredPosts.length && filteredPosts.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
|
||
</th>
|
||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.postTitle')}</th>
|
||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.brand')}</th>
|
||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.status')}</th>
|
||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.platforms')}</th>
|
||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.assignTo')}</th>
|
||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.scheduledDate')}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-border-light">
|
||
{filteredPosts.map(post => {
|
||
const postId = post._id || post.id || post.Id
|
||
return (
|
||
<PostCard
|
||
key={postId}
|
||
post={post}
|
||
onClick={() => openEdit(post)}
|
||
checkboxSlot={<input type="checkbox" checked={selectedIds.has(postId)} onChange={() => toggleSelect(postId)} className="rounded border-border" />}
|
||
/>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Bulk Delete Confirmation */}
|
||
<Modal
|
||
isOpen={showBulkDeleteConfirm}
|
||
onClose={() => setShowBulkDeleteConfirm(false)}
|
||
title={t('common.bulkDeleteConfirm').replace('{count}', selectedIds.size)}
|
||
isConfirm
|
||
danger
|
||
confirmText={t('common.deleteSelected')}
|
||
onConfirm={handleBulkDelete}
|
||
>
|
||
{t('common.bulkDeleteDesc')}
|
||
</Modal>
|
||
|
||
{/* Create Post Modal */}
|
||
<Modal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} title={t('posts.newPost')} size="md">
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.postTitle')} *</label>
|
||
<input type="text" value={createForm.title} onChange={e => setCreateForm(f => ({ ...f, title: 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" autoFocus />
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.brand')}</label>
|
||
<select value={createForm.brand_id} onChange={e => setCreateForm(f => ({ ...f, brand_id: 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('posts.allBrands')}</option>
|
||
{brands.map(b => <option key={b._id} value={b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.campaign')}</label>
|
||
<select value={createForm.campaign_id} onChange={e => setCreateForm(f => ({ ...f, campaign_id: 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="">—</option>
|
||
{campaigns.map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.assignedTo')}</label>
|
||
<select value={createForm.assigned_to} onChange={e => setCreateForm(f => ({ ...f, 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} value={String(m._id)}>{m.name}</option>)}
|
||
</select>
|
||
</div>
|
||
<button onClick={handleCreate} disabled={!createForm.title || createSaving}
|
||
className={`w-full px-4 py-2.5 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 ${createSaving ? 'btn-loading' : ''}`}>
|
||
{t('posts.newPost')}
|
||
</button>
|
||
</div>
|
||
</Modal>
|
||
|
||
{/* Post Detail Panel (edit only) */}
|
||
{panelPost && (
|
||
<PostDetailPanel
|
||
post={panelPost}
|
||
onClose={() => setPanelPost(null)}
|
||
onSave={handlePanelSave}
|
||
onDelete={handlePanelDelete}
|
||
brands={brands}
|
||
teamMembers={teamMembers}
|
||
campaigns={campaigns}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|