video preview version
This commit is contained in:
@@ -9,6 +9,8 @@ import PlatformIcon, { PlatformIcons } from '../components/PlatformIcon'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import BrandBadge from '../components/BrandBadge'
|
||||
import Modal from '../components/Modal'
|
||||
import BudgetBar from '../components/BudgetBar'
|
||||
import CommentsSection from '../components/CommentsSection'
|
||||
|
||||
const TRACK_TYPES = {
|
||||
organic_social: { label: 'Organic Social', icon: Megaphone, color: 'text-green-600 bg-green-50', hasBudget: false },
|
||||
@@ -28,23 +30,6 @@ const EMPTY_METRICS = {
|
||||
budget_spent: '', revenue: '', impressions: '', clicks: '', conversions: '', notes: '',
|
||||
}
|
||||
|
||||
function BudgetBar({ budget, spent }) {
|
||||
if (!budget || budget <= 0) return null
|
||||
const pct = Math.min((spent / budget) * 100, 100)
|
||||
const color = pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-amber-500' : 'bg-emerald-500'
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between text-[10px] text-text-tertiary mb-0.5">
|
||||
<span>{(spent || 0).toLocaleString()} spent</span>
|
||||
<span>{budget.toLocaleString()} SAR</span>
|
||||
</div>
|
||||
<div className="h-2 bg-surface-tertiary rounded-full overflow-hidden">
|
||||
<div className={`h-full ${color} rounded-full transition-all`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MetricBox({ label, value, icon: Icon, color = 'text-text-primary' }) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
@@ -232,7 +217,7 @@ export default function CampaignDetail() {
|
||||
</div>
|
||||
{totalAllocated > 0 && (
|
||||
<div className="mt-4">
|
||||
<BudgetBar budget={totalAllocated} spent={totalSpent} />
|
||||
<BudgetBar budget={totalAllocated} spent={totalSpent} height="h-2" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -285,7 +270,7 @@ export default function CampaignDetail() {
|
||||
{/* Budget bar for paid tracks */}
|
||||
{track.budget_allocated > 0 && (
|
||||
<div className="w-48 mt-1.5">
|
||||
<BudgetBar budget={track.budget_allocated} spent={track.budget_spent || 0} />
|
||||
<BudgetBar budget={track.budget_allocated} spent={track.budget_spent || 0} height="h-2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -378,6 +363,11 @@ export default function CampaignDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Discussion */}
|
||||
<div className="bg-white rounded-xl border border-border p-6">
|
||||
<CommentsSection entityType="campaign" entityId={Number(id)} />
|
||||
</div>
|
||||
|
||||
{/* Add/Edit Track Modal */}
|
||||
<Modal
|
||||
isOpen={showTrackModal}
|
||||
|
||||
@@ -10,6 +10,7 @@ import CampaignCalendar from '../components/CampaignCalendar'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import BrandBadge from '../components/BrandBadge'
|
||||
import Modal from '../components/Modal'
|
||||
import BudgetBar from '../components/BudgetBar'
|
||||
|
||||
const EMPTY_CAMPAIGN = {
|
||||
name: '', description: '', brand_id: '', status: 'planning',
|
||||
@@ -17,23 +18,6 @@ const EMPTY_CAMPAIGN = {
|
||||
budget_spent: '', revenue: '', impressions: '', clicks: '', conversions: '', cost_per_click: '', notes: '',
|
||||
}
|
||||
|
||||
function BudgetBar({ budget, spent }) {
|
||||
if (!budget || budget <= 0) return null
|
||||
const pct = Math.min((spent / budget) * 100, 100)
|
||||
const color = pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-amber-500' : 'bg-emerald-500'
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between text-[10px] text-text-tertiary mb-0.5">
|
||||
<span>{spent?.toLocaleString() || 0} SAR spent</span>
|
||||
<span>{budget?.toLocaleString()} SAR</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-surface-tertiary rounded-full overflow-hidden">
|
||||
<div className={`h-full ${color} rounded-full transition-all`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ROIBadge({ revenue, spent }) {
|
||||
if (!spent || spent <= 0) return null
|
||||
const roi = ((revenue - spent) / spent * 100).toFixed(0)
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { useContext, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { format, isAfter, isBefore, addDays } from 'date-fns'
|
||||
import { FileText, Megaphone, AlertTriangle, Users, ArrowRight, Clock, Wallet, TrendingUp, DollarSign, PiggyBank } from 'lucide-react'
|
||||
import { FileText, Megaphone, AlertTriangle, ArrowRight, Clock, Wallet, TrendingUp, DollarSign, PiggyBank } from 'lucide-react'
|
||||
import { AppContext } from '../App'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api } from '../utils/api'
|
||||
import { api, PRIORITY_CONFIG } from '../utils/api'
|
||||
import StatCard from '../components/StatCard'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import BrandBadge from '../components/BrandBadge'
|
||||
|
||||
function getBudgetBarColor(percentage) {
|
||||
if (percentage > 90) return 'bg-red-500'
|
||||
if (percentage > 70) return 'bg-amber-500'
|
||||
return 'bg-emerald-500'
|
||||
}
|
||||
|
||||
function FinanceMini({ finance }) {
|
||||
const { t } = useLanguage()
|
||||
if (!finance) return null
|
||||
@@ -17,7 +23,7 @@ function FinanceMini({ finance }) {
|
||||
const remaining = finance.remaining || 0
|
||||
const roi = finance.roi || 0
|
||||
const pct = totalReceived > 0 ? (spent / totalReceived) * 100 : 0
|
||||
const barColor = pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-amber-500' : 'bg-emerald-500'
|
||||
const barColor = getBudgetBarColor(pct)
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-border p-5">
|
||||
@@ -74,6 +80,7 @@ function FinanceMini({ finance }) {
|
||||
}
|
||||
|
||||
function ActiveCampaignsList({ campaigns, finance }) {
|
||||
const { t } = useLanguage()
|
||||
const active = campaigns.filter(c => c.status === 'active')
|
||||
const campaignData = (finance?.campaigns || []).filter(c => c.status === 'active')
|
||||
|
||||
@@ -93,7 +100,7 @@ function ActiveCampaignsList({ campaigns, finance }) {
|
||||
const spent = cd.tracks_spent || 0
|
||||
const allocated = cd.tracks_allocated || 0
|
||||
const pct = allocated > 0 ? (spent / allocated) * 100 : 0
|
||||
const barColor = pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-amber-500' : 'bg-emerald-500'
|
||||
const barColor = getBudgetBarColor(pct)
|
||||
return (
|
||||
<Link key={c._id || c.id} to={`/campaigns/${c._id || c.id}`} className="flex items-center gap-4 px-5 py-3 hover:bg-surface-secondary transition-colors">
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -190,10 +197,10 @@ export default function Dashboard() {
|
||||
{/* Welcome */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">
|
||||
Welcome back, {currentUser?.name || 'there'} 👋
|
||||
{t('dashboard.welcomeBack')}, {currentUser?.name || 'there'}
|
||||
</h1>
|
||||
<p className="text-text-secondary mt-1">
|
||||
Here's what's happening with your marketing today.
|
||||
{t('dashboard.happeningToday')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -201,30 +208,30 @@ export default function Dashboard() {
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 stagger-children">
|
||||
<StatCard
|
||||
icon={FileText}
|
||||
label="Total Posts"
|
||||
label={t('dashboard.totalPosts')}
|
||||
value={posts.length || 0}
|
||||
subtitle={`${posts.filter(p => p.status === 'published').length} published`}
|
||||
subtitle={`${posts.filter(p => p.status === 'published').length} ${t('dashboard.published')}`}
|
||||
color="brand-primary"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Megaphone}
|
||||
label="Active Campaigns"
|
||||
label={t('dashboard.activeCampaigns')}
|
||||
value={activeCampaigns}
|
||||
subtitle={`${campaigns.length} total`}
|
||||
subtitle={`${campaigns.length} ${t('dashboard.total')}`}
|
||||
color="brand-secondary"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Wallet}
|
||||
label="Budget Spent"
|
||||
value={`${((finance?.spent || 0)).toLocaleString()}`}
|
||||
subtitle={finance?.totalReceived ? `of ${finance.totalReceived.toLocaleString()} SAR` : 'No budget yet'}
|
||||
label={t('dashboard.budgetSpent')}
|
||||
value={`${(finance?.spent || 0).toLocaleString()}`}
|
||||
subtitle={finance?.totalReceived ? `${t('dashboard.of')} ${finance.totalReceived.toLocaleString()} ${t('dashboard.sar')}` : t('dashboard.noBudget')}
|
||||
color="brand-tertiary"
|
||||
/>
|
||||
<StatCard
|
||||
icon={AlertTriangle}
|
||||
label="Overdue Tasks"
|
||||
label={t('dashboard.overdueTasks')}
|
||||
value={overdueTasks}
|
||||
subtitle={overdueTasks > 0 ? 'Needs attention' : 'All on track'}
|
||||
subtitle={overdueTasks > 0 ? t('dashboard.needsAttention') : t('dashboard.allOnTrack')}
|
||||
color="brand-quaternary"
|
||||
/>
|
||||
</div>
|
||||
@@ -245,15 +252,15 @@ export default function Dashboard() {
|
||||
{/* Recent Posts */}
|
||||
<div className="bg-white rounded-xl border border-border">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
||||
<h3 className="font-semibold text-text-primary">Recent Posts</h3>
|
||||
<h3 className="font-semibold text-text-primary">{t('dashboard.recentPosts')}</h3>
|
||||
<Link to="/posts" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
|
||||
View all <ArrowRight className="w-3.5 h-3.5" />
|
||||
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="divide-y divide-border-light">
|
||||
{posts.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-text-tertiary">
|
||||
No posts yet. Create your first post!
|
||||
{t('dashboard.noPostsYet')}
|
||||
</div>
|
||||
) : (
|
||||
posts.slice(0, 8).map((post) => (
|
||||
@@ -274,24 +281,20 @@ export default function Dashboard() {
|
||||
{/* Upcoming Deadlines */}
|
||||
<div className="bg-white rounded-xl border border-border">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
||||
<h3 className="font-semibold text-text-primary">Upcoming Deadlines</h3>
|
||||
<h3 className="font-semibold text-text-primary">{t('dashboard.upcomingDeadlines')}</h3>
|
||||
<Link to="/tasks" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
|
||||
View all <ArrowRight className="w-3.5 h-3.5" />
|
||||
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="divide-y divide-border-light">
|
||||
{upcomingDeadlines.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-text-tertiary">
|
||||
No upcoming deadlines this week. 🎉
|
||||
{t('dashboard.noUpcomingDeadlines')}
|
||||
</div>
|
||||
) : (
|
||||
upcomingDeadlines.map((task) => (
|
||||
<div key={task._id} className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
task.priority === 'urgent' ? 'bg-red-500' :
|
||||
task.priority === 'high' ? 'bg-orange-500' :
|
||||
task.priority === 'medium' ? 'bg-amber-400' : 'bg-gray-400'
|
||||
}`} />
|
||||
<div className={`w-2 h-2 rounded-full ${(PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium).color}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary truncate">{task.title}</p>
|
||||
<StatusBadge status={task.status} size="xs" />
|
||||
|
||||
@@ -18,7 +18,7 @@ const EMPTY_ENTRY = {
|
||||
label: '', amount: '', source: '', campaign_id: '', category: 'marketing', date_received: new Date().toISOString().slice(0, 10), notes: '',
|
||||
}
|
||||
|
||||
function StatCard({ icon: Icon, label, value, sub, color = 'text-text-primary', bgColor = 'bg-white' }) {
|
||||
function FinanceStatCard({ icon: Icon, label, value, sub, color = 'text-text-primary', bgColor = 'bg-white' }) {
|
||||
return (
|
||||
<div className={`${bgColor} rounded-xl border border-border p-5`}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
@@ -154,11 +154,11 @@ export default function Finance() {
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Top metrics */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<StatCard icon={Wallet} label="Total Received" value={`${totalReceived.toLocaleString()} SAR`} color="text-blue-600" />
|
||||
<StatCard icon={TrendingUp} label="Total Spent" value={`${totalSpent.toLocaleString()} SAR`} sub={`${spendPct.toFixed(1)}% of budget`} color="text-amber-600" />
|
||||
<StatCard icon={PiggyBank} label="Remaining" value={`${remaining.toLocaleString()} SAR`} color={remaining >= 0 ? 'text-emerald-600' : 'text-red-600'} />
|
||||
<StatCard icon={DollarSign} label="Revenue" value={`${totalRevenue.toLocaleString()} SAR`} color="text-purple-600" />
|
||||
<StatCard icon={roi >= 0 ? TrendingUp : TrendingDown} label="Global ROI"
|
||||
<FinanceStatCard icon={Wallet} label="Total Received" value={`${totalReceived.toLocaleString()} SAR`} color="text-blue-600" />
|
||||
<FinanceStatCard icon={TrendingUp} label="Total Spent" value={`${totalSpent.toLocaleString()} SAR`} sub={`${spendPct.toFixed(1)}% of budget`} color="text-amber-600" />
|
||||
<FinanceStatCard icon={PiggyBank} label="Remaining" value={`${remaining.toLocaleString()} SAR`} color={remaining >= 0 ? 'text-emerald-600' : 'text-red-600'} />
|
||||
<FinanceStatCard icon={DollarSign} label="Revenue" value={`${totalRevenue.toLocaleString()} SAR`} color="text-purple-600" />
|
||||
<FinanceStatCard icon={roi >= 0 ? TrendingUp : TrendingDown} label="Global ROI"
|
||||
value={`${roi.toFixed(1)}%`}
|
||||
color={roi >= 0 ? 'text-emerald-600' : 'text-red-600'} />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useContext, useRef } from 'react'
|
||||
import { Plus, LayoutGrid, List, Search, Filter, Upload, X, Paperclip, Link2, FileText, Image as ImageIcon, Trash2, ExternalLink } from 'lucide-react'
|
||||
import { Plus, LayoutGrid, List, Search, Filter, Upload, X, Paperclip, Link2, FileText, Image as ImageIcon, Trash2, ExternalLink, FolderOpen } from 'lucide-react'
|
||||
import { AppContext } from '../App'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
@@ -8,6 +8,7 @@ import KanbanBoard from '../components/KanbanBoard'
|
||||
import PostCard from '../components/PostCard'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import Modal from '../components/Modal'
|
||||
import CommentsSection from '../components/CommentsSection'
|
||||
|
||||
const EMPTY_POST = {
|
||||
title: '', description: '', brand_id: '', platforms: [],
|
||||
@@ -33,7 +34,11 @@ export default function PostProduction() {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const [publishError, setPublishError] = useState('')
|
||||
const [moveError, setMoveError] = useState('')
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const [showAssetPicker, setShowAssetPicker] = useState(false)
|
||||
const [availableAssets, setAvailableAssets] = useState([])
|
||||
const [assetSearch, setAssetSearch] = useState('')
|
||||
const fileInputRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -106,7 +111,8 @@ export default function PostProduction() {
|
||||
} catch (err) {
|
||||
console.error('Move failed:', err)
|
||||
if (err.message?.includes('Cannot publish')) {
|
||||
alert('Cannot publish: all platform publication links must be filled first.')
|
||||
setMoveError(t('posts.publishRequired'))
|
||||
setTimeout(() => setMoveError(''), 5000)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,6 +158,30 @@ export default function PostProduction() {
|
||||
}
|
||||
}
|
||||
|
||||
const openAssetPicker = async () => {
|
||||
try {
|
||||
const data = await api.get('/assets')
|
||||
setAvailableAssets(Array.isArray(data) ? data : (data.data || []))
|
||||
} catch (err) {
|
||||
console.error('Failed to load assets:', err)
|
||||
setAvailableAssets([])
|
||||
}
|
||||
setAssetSearch('')
|
||||
setShowAssetPicker(true)
|
||||
}
|
||||
|
||||
const handleAttachAsset = async (assetId) => {
|
||||
if (!editingPost) return
|
||||
const postId = editingPost._id || editingPost.id
|
||||
try {
|
||||
await api.post(`/posts/${postId}/attachments/from-asset`, { asset_id: assetId })
|
||||
loadAttachments(postId)
|
||||
setShowAssetPicker(false)
|
||||
} catch (err) {
|
||||
console.error('Attach asset failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnter = (e) => { e.preventDefault(); e.stopPropagation(); setDragActive(true) }
|
||||
const handleDragLeaveZone = (e) => { e.preventDefault(); e.stopPropagation(); setDragActive(false) }
|
||||
const handleDragOverZone = (e) => { e.preventDefault(); e.stopPropagation() }
|
||||
@@ -297,6 +327,16 @@ export default function PostProduction() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Move error banner */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{view === 'kanban' ? (
|
||||
<KanbanBoard posts={filteredPosts} onPostClick={openEdit} onMovePost={handleMovePost} />
|
||||
@@ -534,27 +574,29 @@ export default function PostProduction() {
|
||||
const name = att.original_name || att.originalName || att.filename
|
||||
return (
|
||||
<div key={att.id || att._id} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
|
||||
{isImage ? (
|
||||
<a href={`http://localhost:3001${attUrl}`} target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
src={`http://localhost:3001${attUrl}`}
|
||||
alt={name}
|
||||
className="w-full h-24 object-cover"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<a href={`http://localhost:3001${attUrl}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 p-3 h-24">
|
||||
<FileText className="w-8 h-8 text-text-tertiary shrink-0" />
|
||||
<span className="text-xs text-text-secondary truncate">{name}</span>
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDeleteAttachment(att.id || att._id)}
|
||||
className="absolute top-1 right-1 p-1 bg-red-500 text-white rounded-full opacity-0 group-hover/att:opacity-100 transition-opacity shadow-sm"
|
||||
title={t('posts.deleteAttachment')}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
<div className="h-24 relative">
|
||||
{isImage ? (
|
||||
<a href={`http://localhost:3001${attUrl}`} target="_blank" rel="noopener noreferrer" className="block h-full">
|
||||
<img
|
||||
src={`http://localhost:3001${attUrl}`}
|
||||
alt={name}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<a href={`http://localhost:3001${attUrl}`} target="_blank" rel="noopener noreferrer" className="absolute inset-0 flex items-center gap-2 p-3">
|
||||
<FileText className="w-8 h-8 text-text-tertiary shrink-0" />
|
||||
<span className="text-xs text-text-secondary truncate">{name}</span>
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDeleteAttachment(att.id || att._id)}
|
||||
className="absolute top-1 right-1 p-1 bg-red-500 text-white rounded-full opacity-0 group-hover/att:opacity-100 transition-opacity shadow-sm z-10"
|
||||
title={t('posts.deleteAttachment')}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">
|
||||
{name}
|
||||
</div>
|
||||
@@ -589,6 +631,65 @@ export default function PostProduction() {
|
||||
<p className="text-[10px] text-text-tertiary mt-0.5">{t('posts.maxSize')}</p>
|
||||
</div>
|
||||
|
||||
{/* Attach from Assets button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAssetPicker}
|
||||
className="mt-2 flex items-center gap-2 px-3 py-2 text-sm text-text-secondary border border-border rounded-lg hover:bg-surface-tertiary transition-colors w-full justify-center"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
{t('posts.attachFromAssets')}
|
||||
</button>
|
||||
|
||||
{/* Asset picker */}
|
||||
{showAssetPicker && (
|
||||
<div className="mt-2 border border-border rounded-lg p-3 bg-surface-secondary">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-xs font-medium text-text-secondary">{t('posts.selectAssets')}</p>
|
||||
<button onClick={() => setShowAssetPicker(false)} className="p-1 text-text-tertiary hover:text-text-primary">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={assetSearch}
|
||||
onChange={e => setAssetSearch(e.target.value)}
|
||||
placeholder={t('common.search')}
|
||||
className="w-full px-3 py-1.5 text-xs border border-border rounded-lg mb-2 focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
/>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2 max-h-48 overflow-y-auto">
|
||||
{availableAssets
|
||||
.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase()))
|
||||
.map(asset => {
|
||||
const isImage = asset.mime_type?.startsWith('image/')
|
||||
const assetUrl = `/api/uploads/${asset.filename}`
|
||||
const name = asset.original_name || asset.filename
|
||||
return (
|
||||
<button
|
||||
key={asset.id}
|
||||
onClick={() => handleAttachAsset(asset.id)}
|
||||
className="block w-full border border-border rounded-lg overflow-hidden bg-white hover:border-brand-primary hover:ring-2 hover:ring-brand-primary/20 transition-all text-left"
|
||||
>
|
||||
<div className="aspect-square relative">
|
||||
{isImage ? (
|
||||
<img src={`http://localhost:3001${assetUrl}`} alt={name} className="absolute inset-0 w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-surface-tertiary">
|
||||
<FileText className="w-6 h-6 text-text-tertiary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-1.5 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{availableAssets.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase())).length === 0 && (
|
||||
<p className="text-xs text-text-tertiary text-center py-4">{t('posts.noAssetsFound')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload progress */}
|
||||
{uploading && (
|
||||
<div className="mt-2">
|
||||
@@ -607,6 +708,11 @@ export default function PostProduction() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comments (only for existing posts) */}
|
||||
{editingPost && (
|
||||
<CommentsSection entityType="post" entityId={editingPost._id || editingPost.id} />
|
||||
)}
|
||||
|
||||
{/* Publish validation error */}
|
||||
{publishError && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
|
||||
@@ -4,12 +4,13 @@ import {
|
||||
ArrowLeft, Plus, Check, Trash2, Edit3, LayoutGrid, List,
|
||||
GanttChart, Settings, Calendar, Clock
|
||||
} from 'lucide-react'
|
||||
import { format, differenceInDays, startOfDay, addDays, isAfter, isBefore, parseISO } from 'date-fns'
|
||||
import { format, differenceInDays, startOfDay, addDays, isAfter, isBefore } from 'date-fns'
|
||||
import { AppContext } from '../App'
|
||||
import { api, PRIORITY_CONFIG } from '../utils/api'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import BrandBadge from '../components/BrandBadge'
|
||||
import Modal from '../components/Modal'
|
||||
import CommentsSection from '../components/CommentsSection'
|
||||
|
||||
const TASK_COLUMNS = [
|
||||
{ id: 'todo', label: 'To Do', color: 'bg-gray-400' },
|
||||
@@ -24,6 +25,7 @@ export default function ProjectDetail() {
|
||||
const [project, setProject] = useState(null)
|
||||
const [tasks, setTasks] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [assignableUsers, setAssignableUsers] = useState([])
|
||||
const [view, setView] = useState('kanban')
|
||||
const [showTaskModal, setShowTaskModal] = useState(false)
|
||||
const [showProjectModal, setShowProjectModal] = useState(false)
|
||||
@@ -42,6 +44,9 @@ export default function ProjectDetail() {
|
||||
const [dragOverCol, setDragOverCol] = useState(null)
|
||||
|
||||
useEffect(() => { loadProject() }, [id])
|
||||
useEffect(() => {
|
||||
api.get('/users/assignable').then(res => setAssignableUsers(res.data || res || [])).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const loadProject = async () => {
|
||||
try {
|
||||
@@ -208,31 +213,6 @@ export default function ProjectDetail() {
|
||||
const ownerName = project.ownerName || project.owner_name
|
||||
const brandName = project.brandName || project.brand_name
|
||||
|
||||
// Gantt chart helpers
|
||||
const getGanttRange = () => {
|
||||
const today = startOfDay(new Date())
|
||||
let earliest = today
|
||||
let latest = addDays(today, 14)
|
||||
|
||||
tasks.forEach(t => {
|
||||
if (t.createdAt) {
|
||||
const d = startOfDay(new Date(t.createdAt))
|
||||
if (isBefore(d, earliest)) earliest = d
|
||||
}
|
||||
if (t.dueDate) {
|
||||
const d = startOfDay(new Date(t.dueDate))
|
||||
if (isAfter(d, latest)) latest = addDays(d, 1)
|
||||
}
|
||||
})
|
||||
if (project.dueDate) {
|
||||
const d = startOfDay(new Date(project.dueDate))
|
||||
if (isAfter(d, latest)) latest = addDays(d, 1)
|
||||
}
|
||||
// Ensure minimum 14 days
|
||||
if (differenceInDays(latest, earliest) < 14) latest = addDays(earliest, 14)
|
||||
return { earliest, latest, totalDays: differenceInDays(latest, earliest) + 1 }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Back button */}
|
||||
@@ -296,6 +276,11 @@ export default function ProjectDetail() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Discussion */}
|
||||
<div className="bg-white rounded-xl border border-border p-6">
|
||||
<CommentsSection entityType="project" entityId={Number(id)} />
|
||||
</div>
|
||||
|
||||
{/* View switcher + Add Task */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 bg-surface-tertiary rounded-lg p-0.5">
|
||||
@@ -491,7 +476,7 @@ export default function ProjectDetail() {
|
||||
<select value={taskForm.assigned_to} onChange={e => setTaskForm(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="">Unassigned</option>
|
||||
{teamMembers.map(m => <option key={m._id} value={m._id}>{m.name}</option>)}
|
||||
{assignableUsers.map(m => <option key={m._id || m.team_member_id} value={m._id || m.team_member_id}>{m.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -586,6 +571,19 @@ export default function ProjectDetail() {
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* ─── DELETE TASK CONFIRMATION ─── */}
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => { setShowDeleteConfirm(false); setTaskToDelete(null) }}
|
||||
title="Delete Task?"
|
||||
isConfirm
|
||||
danger
|
||||
confirmText="Delete Task"
|
||||
onConfirm={confirmDeleteTask}
|
||||
>
|
||||
Are you sure you want to delete this task? This action cannot be undone.
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -760,18 +758,6 @@ function GanttView({ tasks, project, onEditTask }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Task Confirmation */}
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => { setShowDeleteConfirm(false); setTaskToDelete(null) }}
|
||||
title="Delete Task?"
|
||||
isConfirm
|
||||
danger
|
||||
confirmText="Delete Task"
|
||||
onConfirm={confirmDeleteTask}
|
||||
>
|
||||
Are you sure you want to delete this task? This action cannot be undone.
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,10 +6,11 @@ import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api } from '../utils/api'
|
||||
import TaskCard from '../components/TaskCard'
|
||||
import Modal from '../components/Modal'
|
||||
import CommentsSection from '../components/CommentsSection'
|
||||
|
||||
export default function Tasks() {
|
||||
const { t } = useLanguage()
|
||||
const { currentUser, teamMembers } = useContext(AppContext)
|
||||
const { currentUser } = useContext(AppContext)
|
||||
const { user: authUser, canEditResource, canDeleteResource } = useAuth()
|
||||
const [tasks, setTasks] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -21,6 +22,7 @@ export default function Tasks() {
|
||||
const [taskToDelete, setTaskToDelete] = useState(null)
|
||||
const [filterView, setFilterView] = useState('all') // 'all' | 'assigned_to_me' | 'created_by_me' | member id
|
||||
const [users, setUsers] = useState([]) // for superadmin member filter
|
||||
const [assignableUsers, setAssignableUsers] = useState([])
|
||||
const [formData, setFormData] = useState({
|
||||
title: '', description: '', priority: 'medium', due_date: '', status: 'todo', assigned_to: ''
|
||||
})
|
||||
@@ -29,6 +31,8 @@ export default function Tasks() {
|
||||
|
||||
useEffect(() => { loadTasks() }, [currentUser])
|
||||
useEffect(() => {
|
||||
// Load assignable users for the assignment dropdown
|
||||
api.get('/users/assignable').then(res => setAssignableUsers(res.data || res || [])).catch(() => {})
|
||||
if (isSuperadmin) {
|
||||
// Load team members for superadmin filter
|
||||
api.get('/team').then(res => setUsers(res.data || res || [])).catch(() => {})
|
||||
@@ -357,8 +361,8 @@ export default function Tasks() {
|
||||
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 || m._id} value={m.id || m._id}>{m.name}</option>
|
||||
{(assignableUsers || []).map(m => (
|
||||
<option key={m._id || m.team_member_id} value={m._id || m.team_member_id}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
@@ -388,6 +392,11 @@ export default function Tasks() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comments (only for existing tasks) */}
|
||||
{editingTask && (
|
||||
<CommentsSection entityType="task" entityId={editingTask._id || editingTask.id} />
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||
<button
|
||||
onClick={() => { setShowModal(false); setEditingTask(null) }}
|
||||
|
||||
Reference in New Issue
Block a user