Files
marketing-app/client/src/pages/PostProduction.jsx
fahed 643d004dc7
All checks were successful
Deploy / deploy (push) Successful in 11s
feat: use modals for creation across all pages + fix profile prompt
- 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>
2026-03-04 16:44:22 +03:00

456 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}