diff --git a/client/src/App.jsx b/client/src/App.jsx index 7b9f9e9..32b7aa3 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -10,6 +10,7 @@ import Assets from './pages/Assets' import Campaigns from './pages/Campaigns' import CampaignDetail from './pages/CampaignDetail' import Finance from './pages/Finance' +import Budgets from './pages/Budgets' import Projects from './pages/Projects' import ProjectDetail from './pages/ProjectDetail' import Tasks from './pages/Tasks' @@ -40,10 +41,11 @@ const TEAM_ROLES = [ export const AppContext = createContext() function AppContent() { - const { user, loading: authLoading, checkAuth } = useAuth() + const { user, loading: authLoading, checkAuth, hasModule } = useAuth() const { t, lang } = useLanguage() const [teamMembers, setTeamMembers] = useState([]) const [brands, setBrands] = useState([]) + const [teams, setTeams] = useState([]) const [loading, setLoading] = useState(true) const [showTutorial, setShowTutorial] = useState(false) const [showProfilePrompt, setShowProfilePrompt] = useState(false) @@ -88,11 +90,21 @@ function AppContent() { } } + const loadTeams = async () => { + try { + const data = await api.get('/teams') + setTeams(Array.isArray(data) ? data : (data.data || [])) + } catch (err) { + console.error('Failed to load teams:', err) + } + } + const loadInitialData = async () => { try { const [, brandsData] = await Promise.all([ loadTeam(), api.get('/brands').then(d => Array.isArray(d) ? d : (d.data || [])).catch(() => []), + loadTeams(), ]) setBrands(brandsData) } catch (err) { @@ -123,7 +135,7 @@ function AppContent() { } return ( - + {/* Profile completion prompt */} {showProfilePrompt && (
@@ -258,18 +270,23 @@ function AppContent() { : } /> : }> } /> - } /> - } /> - } /> - } /> - {(user?.role === 'superadmin' || user?.role === 'manager') && ( + {hasModule('marketing') && <> + } /> + } /> + } /> + } /> + } /> + } + {hasModule('finance') && (user?.role === 'superadmin' || user?.role === 'manager') && <> } /> - )} - } /> - } /> - } /> + } /> + } + {hasModule('projects') && <> + } /> + } /> + } /> + } } /> - } /> } /> {user?.role === 'superadmin' && ( } /> diff --git a/client/src/components/CollapsibleSection.jsx b/client/src/components/CollapsibleSection.jsx new file mode 100644 index 0000000..b4db82a --- /dev/null +++ b/client/src/components/CollapsibleSection.jsx @@ -0,0 +1,20 @@ +import { useState } from 'react' +import { ChevronDown, ChevronRight } from 'lucide-react' + +export default function CollapsibleSection({ title, defaultOpen = true, badge, children, noBorder }) { + const [open, setOpen] = useState(defaultOpen) + + return ( +
+ + {open && children} +
+ ) +} diff --git a/client/src/components/DatePresetPicker.jsx b/client/src/components/DatePresetPicker.jsx new file mode 100644 index 0000000..1388def --- /dev/null +++ b/client/src/components/DatePresetPicker.jsx @@ -0,0 +1,37 @@ +import { X } from 'lucide-react' +import { useLanguage } from '../i18n/LanguageContext' +import { DATE_PRESETS } from '../utils/datePresets' + +export default function DatePresetPicker({ onSelect, activePreset, onClear }) { + const { t } = useLanguage() + + return ( +
+ {DATE_PRESETS.map(preset => ( + + ))} + {activePreset && ( + + )} +
+ ) +} diff --git a/client/src/components/InteractiveTimeline.jsx b/client/src/components/InteractiveTimeline.jsx index 1b17049..c0e3b85 100644 --- a/client/src/components/InteractiveTimeline.jsx +++ b/client/src/components/InteractiveTimeline.jsx @@ -313,11 +313,15 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange, {isExpanded ? ( <>
- {item.assigneeName && ( + {item.thumbnailUrl ? ( +
+ +
+ ) : item.assigneeName ? (
{getInitials(item.assigneeName)}
- )} + ) : null} {item.label}
{item.description && ( @@ -333,11 +337,15 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange, ) : ( <> - {item.assigneeName && ( + {item.thumbnailUrl ? ( +
+ +
+ ) : item.assigneeName ? (
{getInitials(item.assigneeName)}
- )} + ) : null} {item.label} )} diff --git a/client/src/components/MemberCard.jsx b/client/src/components/MemberCard.jsx index 948c1ea..4cae253 100644 --- a/client/src/components/MemberCard.jsx +++ b/client/src/components/MemberCard.jsx @@ -59,6 +59,17 @@ export default function MemberCard({ member, onClick }) { ))}
)} + + {/* Teams */} + {member.teams && member.teams.length > 0 && ( +
+ {member.teams.map((team) => ( + + {team.name} + + ))} +
+ )} ) } diff --git a/client/src/components/PostDetailPanel.jsx b/client/src/components/PostDetailPanel.jsx new file mode 100644 index 0000000..2ec508d --- /dev/null +++ b/client/src/components/PostDetailPanel.jsx @@ -0,0 +1,582 @@ +import { useState, useEffect, useRef } from 'react' +import { X, Trash2, Upload, FileText, Link2, ExternalLink, FolderOpen } from 'lucide-react' +import { useLanguage } from '../i18n/LanguageContext' +import { api, PLATFORMS, getBrandColor } from '../utils/api' +import CommentsSection from './CommentsSection' +import Modal from './Modal' +import SlidePanel from './SlidePanel' +import CollapsibleSection from './CollapsibleSection' + +export default function PostDetailPanel({ post, onClose, onSave, onDelete, brands, teamMembers, campaigns }) { + const { t, lang } = useLanguage() + const fileInputRef = useRef(null) + const [form, setForm] = useState({}) + const [dirty, setDirty] = useState(false) + const [saving, setSaving] = useState(false) + const [publishError, setPublishError] = useState('') + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + + // Attachments state + const [attachments, setAttachments] = useState([]) + const [uploading, setUploading] = useState(false) + const [dragActive, setDragActive] = useState(false) + const [showAssetPicker, setShowAssetPicker] = useState(false) + const [availableAssets, setAvailableAssets] = useState([]) + const [assetSearch, setAssetSearch] = useState('') + + const postId = post?._id || post?.id + const isCreateMode = !postId + + useEffect(() => { + if (post) { + setForm({ + title: post.title || '', + description: post.description || '', + brand_id: post.brandId || post.brand_id || '', + platforms: post.platforms || (post.platform ? [post.platform] : []), + status: post.status || 'draft', + assigned_to: post.assignedTo || post.assigned_to || '', + scheduled_date: post.scheduledDate ? new Date(post.scheduledDate).toISOString().slice(0, 16) : '', + notes: post.notes || '', + campaign_id: post.campaignId || post.campaign_id || '', + publication_links: post.publication_links || post.publicationLinks || [], + }) + setDirty(isCreateMode) + setPublishError('') + if (!isCreateMode) loadAttachments() + } + }, [post]) + + if (!post) return null + + const statusOptions = [ + { value: 'draft', label: t('posts.status.draft') }, + { value: 'in_review', label: t('posts.status.in_review') }, + { value: 'approved', label: t('posts.status.approved') }, + { value: 'scheduled', label: t('posts.status.scheduled') }, + { value: 'published', label: t('posts.status.published') }, + ] + + const update = (field, value) => { + setForm(f => ({ ...f, [field]: value })) + setDirty(true) + } + + const updatePublicationLink = (platform, url) => { + setForm(f => { + const links = [...(f.publication_links || [])] + const idx = links.findIndex(l => l.platform === platform) + if (idx >= 0) { + links[idx] = { ...links[idx], url } + } else { + links.push({ platform, url }) + } + return { ...f, publication_links: links } + }) + setDirty(true) + } + + const handleSave = async () => { + setPublishError('') + setSaving(true) + try { + const data = { + title: form.title, + description: form.description, + brand_id: form.brand_id ? Number(form.brand_id) : null, + assigned_to: form.assigned_to ? Number(form.assigned_to) : null, + status: form.status, + platforms: form.platforms || [], + scheduled_date: form.scheduled_date || null, + notes: form.notes, + campaign_id: form.campaign_id ? Number(form.campaign_id) : null, + publication_links: form.publication_links || [], + } + + if (data.status === 'published' && data.platforms.length > 0) { + const missingPlatforms = data.platforms.filter(platform => { + const link = (data.publication_links || []).find(l => l.platform === platform) + return !link || !link.url || !link.url.trim() + }) + if (missingPlatforms.length > 0) { + const names = missingPlatforms.map(p => PLATFORMS[p]?.label || p).join(', ') + setPublishError(`${t('posts.publishMissing')} ${names}`) + setSaving(false) + return + } + } + + await onSave(isCreateMode ? null : postId, data) + setDirty(false) + if (isCreateMode) onClose() + } catch (err) { + if (err.message?.includes('Cannot publish')) { + setPublishError(err.message.replace(/.*: /, '')) + } + } finally { + setSaving(false) + } + } + + const confirmDelete = async () => { + setShowDeleteConfirm(false) + await onDelete(postId) + onClose() + } + + // ─── Attachments ────────────────────────────── + async function loadAttachments() { + if (!postId) return + try { + const data = await api.get(`/posts/${postId}/attachments`) + setAttachments(Array.isArray(data) ? data : (data.data || [])) + } catch { + setAttachments([]) + } + } + + const handleFileUpload = async (files) => { + if (!postId || !files?.length) return + setUploading(true) + for (const file of files) { + const fd = new FormData() + fd.append('file', file) + try { + await api.upload(`/posts/${postId}/attachments`, fd) + } catch (err) { + console.error('Upload failed:', err) + } + } + setUploading(false) + loadAttachments() + } + + const handleDeleteAttachment = async (attId) => { + try { + await api.delete(`/attachments/${attId}`) + loadAttachments() + } catch (err) { + console.error('Delete attachment failed:', err) + } + } + + const openAssetPicker = async () => { + try { + const data = await api.get('/assets') + setAvailableAssets(Array.isArray(data) ? data : (data.data || [])) + } catch { + setAvailableAssets([]) + } + setAssetSearch('') + setShowAssetPicker(true) + } + + const handleAttachAsset = async (assetId) => { + if (!postId) return + try { + await api.post(`/posts/${postId}/attachments/from-asset`, { asset_id: assetId }) + loadAttachments() + setShowAssetPicker(false) + } catch (err) { + console.error('Attach asset failed:', err) + } + } + + const handleDrop = (e) => { + e.preventDefault(); e.stopPropagation(); setDragActive(false) + if (e.dataTransfer.files?.length) handleFileUpload(e.dataTransfer.files) + } + + const brandName = (() => { + if (form.brand_id) { + const b = brands?.find(b => String(b._id || b.id) === String(form.brand_id)) + return b ? (lang === 'ar' && b.name_ar ? b.name_ar : b.name) : null + } + return post.brand_name || post.brandName || null + })() + + const header = ( +
+
+
+ update('title', e.target.value)} + className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0" + placeholder={t('posts.postTitlePlaceholder')} + /> +
+ + {statusOptions.find(s => s.value === form.status)?.label} + + {brandName && ( + + {brandName} + + )} +
+
+ +
+
+ ) + + return ( + <> + + {/* Details Section */} + +
+
+ +