- Add SlidePanel, TaskDetailPanel, PostDetailPanel, TeamPanel, TeamMemberPanel - Add ProjectEditPanel, CollapsibleSection, DatePresetPicker, TaskCalendarView - Update App, AuthContext, i18n (ar/en), PostProduction, ProjectDetail, Projects - Update Settings, Tasks, Team pages - Update InteractiveTimeline, MemberCard, ProjectCard, TaskCard components - Update server API utilities - Remove tracked server/node_modules (now properly gitignored)
279 lines
12 KiB
JavaScript
279 lines
12 KiB
JavaScript
import { useState, useEffect, useContext } from 'react'
|
||
import { Plus, LayoutGrid, List, Search, X, FileText } 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 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 { 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 } = 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('')
|
||
|
||
useEffect(() => {
|
||
loadPosts()
|
||
api.get('/campaigns').then(r => setCampaigns(Array.isArray(r) ? r : (r.data || []))).catch(() => {})
|
||
}, [])
|
||
|
||
const loadPosts = async () => {
|
||
try {
|
||
const res = await api.get('/posts')
|
||
setPosts(res.data || res || [])
|
||
} catch (err) {
|
||
console.error('Failed to load posts:', err)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const handleMovePost = async (postId, newStatus) => {
|
||
try {
|
||
await api.patch(`/posts/${postId}`, { status: newStatus })
|
||
toast.success(t('posts.statusUpdated'))
|
||
loadPosts()
|
||
} catch (err) {
|
||
console.error('Move failed:', err)
|
||
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 openEdit = (post) => {
|
||
if (!canEditResource('post', post)) {
|
||
alert('You can only edit your own posts')
|
||
return
|
||
}
|
||
setPanelPost(post)
|
||
}
|
||
|
||
const openNew = () => {
|
||
setPanelPost(EMPTY_POST)
|
||
}
|
||
|
||
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="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>
|
||
|
||
<div data-tutorial="filters" className="flex gap-3">
|
||
<select
|
||
value={filters.brand}
|
||
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
|
||
className="text-sm border border-border rounded-lg px-3 py-2 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-sm border border-border rounded-lg px-3 py-2 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-sm border border-border rounded-lg px-3 py-2 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-sm border border-border rounded-lg px-2 py-2 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-sm border border-border rounded-lg px-2 py-2 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<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>
|
||
|
||
{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 posts={filteredPosts} onPostClick={openEdit} onMovePost={handleMovePost} />
|
||
) : (
|
||
<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('')
|
||
}}
|
||
/>
|
||
) : (
|
||
<table className="w-full">
|
||
<thead>
|
||
<tr className="border-b border-border bg-surface-secondary">
|
||
<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 => (
|
||
<PostCard key={post._id} post={post} onClick={() => openEdit(post)} />
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Post Detail Panel */}
|
||
{panelPost && (
|
||
<PostDetailPanel
|
||
post={panelPost}
|
||
onClose={() => setPanelPost(null)}
|
||
onSave={handlePanelSave}
|
||
onDelete={handlePanelDelete}
|
||
brands={brands}
|
||
teamMembers={teamMembers}
|
||
campaigns={campaigns}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|