From 9b58e5e9aadb2155c6b5998092aa40909309c211 Mon Sep 17 00:00:00 2001 From: fahed Date: Sun, 8 Feb 2026 22:51:42 +0300 Subject: [PATCH] video preview version --- client/src/App.jsx | 119 +++- client/src/components/BudgetBar.jsx | 20 + client/src/components/CommentsSection.jsx | 134 +++++ client/src/components/Header.jsx | 18 +- client/src/components/MemberCard.jsx | 6 +- client/src/components/PostCard.jsx | 12 +- client/src/i18n/LanguageContext.jsx | 22 +- client/src/i18n/ar.json | 11 + client/src/i18n/en.json | 11 + client/src/pages/CampaignDetail.jsx | 28 +- client/src/pages/Campaigns.jsx | 18 +- client/src/pages/Dashboard.jsx | 55 +- client/src/pages/Finance.jsx | 12 +- client/src/pages/PostProduction.jsx | 152 ++++- client/src/pages/ProjectDetail.jsx | 64 +- client/src/pages/Tasks.jsx | 15 +- client/src/utils/api.js | 17 +- server/db.js | 172 ++---- .../build/Release/better_sqlite3.node | Bin 2245632 -> 2072760 bytes .../Release/obj.target/better_sqlite3.node | Bin 2245632 -> 2072760 bytes server/server.js | 548 ++++++++++-------- ...0680.jpeg => 1770578670280-822865878.jpeg} | Bin server/uploads/1770579473379-520949231.mp4 | Bin 0 -> 19971251 bytes 23 files changed, 890 insertions(+), 544 deletions(-) create mode 100644 client/src/components/BudgetBar.jsx create mode 100644 client/src/components/CommentsSection.jsx rename server/uploads/{1770566658044-979150680.jpeg => 1770578670280-822865878.jpeg} (100%) create mode 100644 server/uploads/1770579473379-520949231.mp4 diff --git a/client/src/App.jsx b/client/src/App.jsx index ea170b7..10b109b 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -17,19 +17,37 @@ import Users from './pages/Users' import Settings from './pages/Settings' import Login from './pages/Login' import Tutorial from './components/Tutorial' +import Modal from './components/Modal' import { api } from './utils/api' import { useLanguage } from './i18n/LanguageContext' +const TEAM_ROLES = [ + { value: 'manager', label: 'Manager' }, + { value: 'approver', label: 'Approver' }, + { value: 'publisher', label: 'Publisher' }, + { value: 'content_creator', label: 'Content Creator' }, + { value: 'producer', label: 'Producer' }, + { value: 'designer', label: 'Designer' }, + { value: 'content_writer', label: 'Content Writer' }, + { value: 'social_media_manager', label: 'Social Media Manager' }, + { value: 'photographer', label: 'Photographer' }, + { value: 'videographer', label: 'Videographer' }, + { value: 'strategist', label: 'Strategist' }, +] + export const AppContext = createContext() function AppContent() { - const { user, loading: authLoading } = useAuth() + const { user, loading: authLoading, checkAuth } = useAuth() const { t } = useLanguage() const [teamMembers, setTeamMembers] = useState([]) const [brands, setBrands] = useState([]) const [loading, setLoading] = useState(true) const [showTutorial, setShowTutorial] = useState(false) const [showProfilePrompt, setShowProfilePrompt] = useState(false) + const [showProfileModal, setShowProfileModal] = useState(false) + const [profileForm, setProfileForm] = useState({ name: '', team_role: '', phone: '', brands: '' }) + const [profileSaving, setProfileSaving] = useState(false) useEffect(() => { if (user && !authLoading) { @@ -61,11 +79,10 @@ function AppContent() { const loadInitialData = async () => { try { - const [members, brandsData] = await Promise.all([ + const [, brandsData] = await Promise.all([ loadTeam(), api.get('/brands').then(d => Array.isArray(d) ? d : (d.data || [])).catch(() => []), ]) - setTeamMembers(members) setBrands(brandsData) } catch (err) { console.error('Failed to load initial data:', err) @@ -109,12 +126,20 @@ function AppContent() { {t('profile.completeDesc')}

- { + setProfileForm({ + name: user?.name || '', + team_role: user?.teamRole || user?.team_role || '', + phone: user?.phone || '', + brands: Array.isArray(user?.brands) ? user.brands.join(', ') : '', + }) + setShowProfileModal(true) + }} className="px-3 py-1.5 bg-amber-400 text-white text-sm font-medium rounded-lg hover:bg-amber-500 transition-colors" > {t('profile.completeProfileBtn')} - +
)} + {/* Profile completion modal */} + setShowProfileModal(false)} title={t('profile.completeYourProfile')} size="md"> +
+
+ + setProfileForm(f => ({ ...f, name: 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" + placeholder={t('team.fullName')} + /> +
+
+ + +
+
+ + setProfileForm(f => ({ ...f, phone: 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" + /> +
+
+ + setProfileForm(f => ({ ...f, brands: 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" + placeholder={t('team.brandsHelp')} + /> +
+
+ + +
+
+
+ {/* Tutorial overlay */} {showTutorial && } diff --git a/client/src/components/BudgetBar.jsx b/client/src/components/BudgetBar.jsx new file mode 100644 index 0000000..65d9eac --- /dev/null +++ b/client/src/components/BudgetBar.jsx @@ -0,0 +1,20 @@ +export default function BudgetBar({ budget, spent, height = 'h-1.5' }) { + if (!budget || budget <= 0) return null + const pct = Math.min((spent / budget) * 100, 100) + + let color = 'bg-emerald-500' + if (pct > 90) color = 'bg-red-500' + else if (pct > 70) color = 'bg-amber-500' + + return ( +
+
+ {(spent || 0).toLocaleString()} SAR spent + {budget.toLocaleString()} SAR +
+
+
+
+
+ ) +} diff --git a/client/src/components/CommentsSection.jsx b/client/src/components/CommentsSection.jsx new file mode 100644 index 0000000..f9ba76a --- /dev/null +++ b/client/src/components/CommentsSection.jsx @@ -0,0 +1,134 @@ +import { useState, useEffect } from 'react' +import { Send, Trash2, MessageCircle } from 'lucide-react' +import { api, getInitials } from '../utils/api' +import { useAuth } from '../contexts/AuthContext' +import { useLanguage } from '../i18n/LanguageContext' + +function relativeTime(dateStr, t) { + const now = Date.now() + const then = new Date(dateStr).getTime() + const diffMs = now - then + const diffMin = Math.floor(diffMs / 60000) + if (diffMin < 1) return t('comments.justNow') + if (diffMin < 60) return t('comments.minutesAgo').replace('{n}', diffMin) + const diffHours = Math.floor(diffMin / 60) + if (diffHours < 24) return t('comments.hoursAgo').replace('{n}', diffHours) + const diffDays = Math.floor(diffHours / 24) + return t('comments.daysAgo').replace('{n}', diffDays) +} + +export default function CommentsSection({ entityType, entityId }) { + const { user } = useAuth() + const { t } = useLanguage() + const [comments, setComments] = useState([]) + const [newComment, setNewComment] = useState('') + const [sending, setSending] = useState(false) + + useEffect(() => { + if (entityType && entityId) loadComments() + }, [entityType, entityId]) + + const loadComments = async () => { + try { + const data = await api.get(`/comments/${entityType}/${entityId}`) + setComments(Array.isArray(data) ? data : (data.data || [])) + } catch (err) { + console.error('Failed to load comments:', err) + } + } + + const handleSend = async () => { + if (!newComment.trim() || sending) return + setSending(true) + try { + await api.post(`/comments/${entityType}/${entityId}`, { content: newComment.trim() }) + setNewComment('') + loadComments() + } catch (err) { + console.error('Failed to send comment:', err) + } finally { + setSending(false) + } + } + + const handleDelete = async (id) => { + try { + await api.delete(`/comments/${id}`) + loadComments() + } catch (err) { + console.error('Failed to delete comment:', err) + } + } + + const canDelete = (comment) => { + if (!user) return false + if (comment.user_id === user.id) return true + return user.role === 'superadmin' || user.role === 'manager' + } + + return ( +
+

+ + {t('comments.title')} + {comments.length > 0 && ( + + {comments.length} + + )} +

+ + {comments.length === 0 && ( +

{t('comments.noComments')}

+ )} + +
+ {comments.map(c => ( +
+
+ {c.user_avatar ? ( + + ) : ( + getInitials(c.user_name) + )} +
+
+
+ {c.user_name} + {relativeTime(c.created_at, t)} + {canDelete(c) && ( + + )} +
+

{c.content}

+
+
+ ))} +
+ + {/* Input */} +
+ setNewComment(e.target.value)} + onKeyDown={e => e.key === 'Enter' && !e.shiftKey && handleSend()} + placeholder={t('comments.placeholder')} + className="flex-1 px-3 py-1.5 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" + /> + +
+
+ ) +} diff --git a/client/src/components/Header.jsx b/client/src/components/Header.jsx index 9f8f61f..4962d22 100644 --- a/client/src/components/Header.jsx +++ b/client/src/components/Header.jsx @@ -1,7 +1,8 @@ import { useState, useRef, useEffect } from 'react' import { useLocation } from 'react-router-dom' -import { Bell, ChevronDown, LogOut, Settings, User, Shield } from 'lucide-react' +import { Bell, ChevronDown, LogOut, Shield } from 'lucide-react' import { useAuth } from '../contexts/AuthContext' +import { getInitials } from '../utils/api' const pageTitles = { '/': 'Dashboard', @@ -27,9 +28,13 @@ export default function Header() { const dropdownRef = useRef(null) const location = useLocation() - const pageTitle = pageTitles[location.pathname] || - (location.pathname.startsWith('/projects/') ? 'Project Details' : - location.pathname.startsWith('/campaigns/') ? 'Campaign Details' : 'Page') + function getPageTitle(pathname) { + if (pageTitles[pathname]) return pageTitles[pathname] + if (pathname.startsWith('/projects/')) return 'Project Details' + if (pathname.startsWith('/campaigns/')) return 'Campaign Details' + return 'Page' + } + const pageTitle = getPageTitle(location.pathname) useEffect(() => { const handleClickOutside = (e) => { @@ -41,11 +46,6 @@ export default function Header() { return () => document.removeEventListener('mousedown', handleClickOutside) }, []) - const getInitials = (name) => { - if (!name) return '?' - return name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase() - } - const roleInfo = ROLE_INFO[user?.role] || ROLE_INFO.contributor return ( diff --git a/client/src/components/MemberCard.jsx b/client/src/components/MemberCard.jsx index 874b3d4..948c1ea 100644 --- a/client/src/components/MemberCard.jsx +++ b/client/src/components/MemberCard.jsx @@ -1,3 +1,4 @@ +import { getInitials } from '../utils/api' import BrandBadge from './BrandBadge' const ROLE_BADGES = { @@ -16,11 +17,6 @@ const ROLE_BADGES = { } export default function MemberCard({ member, onClick }) { - const getInitials = (name) => { - if (!name) return '?' - return name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase() - } - const role = ROLE_BADGES[member.team_role || member.role] || ROLE_BADGES.default const avatarColors = [ diff --git a/client/src/components/PostCard.jsx b/client/src/components/PostCard.jsx index 370006f..6ccada0 100644 --- a/client/src/components/PostCard.jsx +++ b/client/src/components/PostCard.jsx @@ -1,9 +1,10 @@ import { format } from 'date-fns' import { ArrowRight } from 'lucide-react' +import { getInitials } from '../utils/api' import { useLanguage } from '../i18n/LanguageContext' import BrandBadge from './BrandBadge' import StatusBadge from './StatusBadge' -import PlatformIcon, { PlatformIcons } from './PlatformIcon' +import { PlatformIcons } from './PlatformIcon' export default function PostCard({ post, onClick, onMove, compact = false }) { const { t } = useLanguage() @@ -12,21 +13,16 @@ export default function PostCard({ post, onClick, onMove, compact = false }) { ? post.platforms : (post.platform ? [post.platform] : []) - const getInitials = (name) => { - if (!name) return '?' - return name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase() - } - const assigneeName = post.assignedToName || post.assignedName || post.assigned_name || (typeof post.assignedTo === 'object' ? post.assignedTo?.name : null) if (compact) { return (
{post.thumbnail_url && ( -
+
)} diff --git a/client/src/i18n/LanguageContext.jsx b/client/src/i18n/LanguageContext.jsx index 9e9ceb5..98418a3 100644 --- a/client/src/i18n/LanguageContext.jsx +++ b/client/src/i18n/LanguageContext.jsx @@ -26,27 +26,9 @@ export function LanguageProvider({ children }) { document.documentElement.lang = lang }, [dir, lang]) - // Translation function + // Translation function (flat dot-notation keys) const t = (key) => { - const keys = key.split('.') - let value = translations[lang] - - for (const k of keys) { - value = value?.[k] - if (value === undefined) break - } - - // Fallback to English if translation not found - if (value === undefined) { - value = translations.en - for (const k of keys) { - value = value?.[k] - if (value === undefined) break - } - } - - // Fallback to key itself if still not found - return value !== undefined ? value : key + return translations[lang]?.[key] ?? translations.en?.[key] ?? key } return ( diff --git a/client/src/i18n/ar.json b/client/src/i18n/ar.json index f50b09e..acf06f4 100644 --- a/client/src/i18n/ar.json +++ b/client/src/i18n/ar.json @@ -106,6 +106,9 @@ "posts.approve": "اعتماد", "posts.schedule": "جدولة", "posts.publish": "نشر", + "posts.attachFromAssets": "إرفاق من الأصول", + "posts.selectAssets": "اختر أصلاً لإرفاقه", + "posts.noAssetsFound": "لا توجد أصول", "posts.status.draft": "مسودة", "posts.status.in_review": "قيد المراجعة", @@ -232,6 +235,14 @@ "login.forgotPassword": "نسيت كلمة المرور؟", "login.defaultCreds": "بيانات الدخول الافتراضية:", + "comments.title": "النقاش", + "comments.noComments": "لا توجد تعليقات بعد. ابدأ المحادثة.", + "comments.placeholder": "اكتب تعليقاً...", + "comments.justNow": "الآن", + "comments.minutesAgo": "منذ {n} دقيقة", + "comments.hoursAgo": "منذ {n} ساعة", + "comments.daysAgo": "منذ {n} يوم", + "profile.completeYourProfile": "أكمل ملفك الشخصي", "profile.completeDesc": "يرجى إكمال ملفك الشخصي للوصول إلى جميع الميزات ومساعدة فريقك في العثور عليك.", "profile.completeProfileBtn": "إكمال الملف", diff --git a/client/src/i18n/en.json b/client/src/i18n/en.json index 727958a..d0653a1 100644 --- a/client/src/i18n/en.json +++ b/client/src/i18n/en.json @@ -106,6 +106,9 @@ "posts.approve": "Approve", "posts.schedule": "Schedule", "posts.publish": "Publish", + "posts.attachFromAssets": "Attach from Assets", + "posts.selectAssets": "Select an asset to attach", + "posts.noAssetsFound": "No assets found", "posts.status.draft": "Draft", "posts.status.in_review": "In Review", @@ -232,6 +235,14 @@ "login.forgotPassword": "Forgot password?", "login.defaultCreds": "Default credentials:", + "comments.title": "Discussion", + "comments.noComments": "No comments yet. Start the conversation.", + "comments.placeholder": "Write a comment...", + "comments.justNow": "Just now", + "comments.minutesAgo": "{n}m ago", + "comments.hoursAgo": "{n}h ago", + "comments.daysAgo": "{n}d ago", + "profile.completeYourProfile": "Complete Your Profile", "profile.completeDesc": "Please complete your profile to access all features and help your team find you.", "profile.completeProfileBtn": "Complete Profile", diff --git a/client/src/pages/CampaignDetail.jsx b/client/src/pages/CampaignDetail.jsx index db29f50..8ef3e88 100644 --- a/client/src/pages/CampaignDetail.jsx +++ b/client/src/pages/CampaignDetail.jsx @@ -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 ( -
-
- {(spent || 0).toLocaleString()} spent - {budget.toLocaleString()} SAR -
-
-
-
-
- ) -} - function MetricBox({ label, value, icon: Icon, color = 'text-text-primary' }) { return (
@@ -232,7 +217,7 @@ export default function CampaignDetail() {
{totalAllocated > 0 && (
- +
)}
@@ -285,7 +270,7 @@ export default function CampaignDetail() { {/* Budget bar for paid tracks */} {track.budget_allocated > 0 && (
- +
)} @@ -378,6 +363,11 @@ export default function CampaignDetail() {
)} + {/* Discussion */} +
+ +
+ {/* Add/Edit Track Modal */} 90 ? 'bg-red-500' : pct > 70 ? 'bg-amber-500' : 'bg-emerald-500' - return ( -
-
- {spent?.toLocaleString() || 0} SAR spent - {budget?.toLocaleString()} SAR -
-
-
-
-
- ) -} - function ROIBadge({ revenue, spent }) { if (!spent || spent <= 0) return null const roi = ((revenue - spent) / spent * 100).toFixed(0) diff --git a/client/src/pages/Dashboard.jsx b/client/src/pages/Dashboard.jsx index 4805731..f33230c 100644 --- a/client/src/pages/Dashboard.jsx +++ b/client/src/pages/Dashboard.jsx @@ -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 (
@@ -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 (
@@ -190,10 +197,10 @@ export default function Dashboard() { {/* Welcome */}

- Welcome back, {currentUser?.name || 'there'} 👋 + {t('dashboard.welcomeBack')}, {currentUser?.name || 'there'}

- Here's what's happening with your marketing today. + {t('dashboard.happeningToday')}

@@ -201,30 +208,30 @@ export default function Dashboard() {
p.status === 'published').length} published`} + subtitle={`${posts.filter(p => p.status === 'published').length} ${t('dashboard.published')}`} color="brand-primary" /> 0 ? 'Needs attention' : 'All on track'} + subtitle={overdueTasks > 0 ? t('dashboard.needsAttention') : t('dashboard.allOnTrack')} color="brand-quaternary" />
@@ -245,15 +252,15 @@ export default function Dashboard() { {/* Recent Posts */}
-

Recent Posts

+

{t('dashboard.recentPosts')}

- View all + {t('dashboard.viewAll')}
{posts.length === 0 ? (
- No posts yet. Create your first post! + {t('dashboard.noPostsYet')}
) : ( posts.slice(0, 8).map((post) => ( @@ -274,24 +281,20 @@ export default function Dashboard() { {/* Upcoming Deadlines */}
-

Upcoming Deadlines

+

{t('dashboard.upcomingDeadlines')}

- View all + {t('dashboard.viewAll')}
{upcomingDeadlines.length === 0 ? (
- No upcoming deadlines this week. 🎉 + {t('dashboard.noUpcomingDeadlines')}
) : ( upcomingDeadlines.map((task) => (
-
+

{task.title}

diff --git a/client/src/pages/Finance.jsx b/client/src/pages/Finance.jsx index 818191e..32991e4 100644 --- a/client/src/pages/Finance.jsx +++ b/client/src/pages/Finance.jsx @@ -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 (
@@ -154,11 +154,11 @@ export default function Finance() {
{/* Top metrics */}
- - - = 0 ? 'text-emerald-600' : 'text-red-600'} /> - - = 0 ? TrendingUp : TrendingDown} label="Global ROI" + + + = 0 ? 'text-emerald-600' : 'text-red-600'} /> + + = 0 ? TrendingUp : TrendingDown} label="Global ROI" value={`${roi.toFixed(1)}%`} color={roi >= 0 ? 'text-emerald-600' : 'text-red-600'} />
diff --git a/client/src/pages/PostProduction.jsx b/client/src/pages/PostProduction.jsx index 768c99a..890bc04 100644 --- a/client/src/pages/PostProduction.jsx +++ b/client/src/pages/PostProduction.jsx @@ -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() {
+ {/* Move error banner */} + {moveError && ( +
+ {moveError} + +
+ )} + {/* Content */} {view === 'kanban' ? ( @@ -534,27 +574,29 @@ export default function PostProduction() { const name = att.original_name || att.originalName || att.filename return (
- {isImage ? ( - - {name} - - ) : ( - - - {name} - - )} - +
+ {isImage ? ( + + {name} + + ) : ( + + + {name} + + )} + +
{name}
@@ -589,6 +631,65 @@ export default function PostProduction() {

{t('posts.maxSize')}

+ {/* Attach from Assets button */} + + + {/* Asset picker */} + {showAssetPicker && ( +
+
+

{t('posts.selectAssets')}

+ +
+ 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" + /> +
+ {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 ( + + ) + })} +
+ {availableAssets.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase())).length === 0 && ( +

{t('posts.noAssetsFound')}

+ )} +
+ )} + {/* Upload progress */} {uploading && (
@@ -607,6 +708,11 @@ export default function PostProduction() {
)} + {/* Comments (only for existing posts) */} + {editingPost && ( + + )} + {/* Publish validation error */} {publishError && (
diff --git a/client/src/pages/ProjectDetail.jsx b/client/src/pages/ProjectDetail.jsx index 41615b9..039fb69 100644 --- a/client/src/pages/ProjectDetail.jsx +++ b/client/src/pages/ProjectDetail.jsx @@ -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 (
{/* Back button */} @@ -296,6 +276,11 @@ export default function ProjectDetail() {
+ {/* Discussion */} +
+ +
+ {/* View switcher + Add Task */}
@@ -491,7 +476,7 @@ export default function ProjectDetail() {
@@ -586,6 +571,19 @@ export default function ProjectDetail() {
+ + {/* ─── DELETE TASK CONFIRMATION ─── */} + { 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. +
) } @@ -760,18 +758,6 @@ function GanttView({ tasks, project, onEditTask }) {
- {/* Delete Task Confirmation */} - { 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. -
) } diff --git a/client/src/pages/Tasks.jsx b/client/src/pages/Tasks.jsx index d46b89b..fafb8ea 100644 --- a/client/src/pages/Tasks.jsx +++ b/client/src/pages/Tasks.jsx @@ -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" > - {(teamMembers || []).map(m => ( - + {(assignableUsers || []).map(m => ( + ))}
@@ -388,6 +392,11 @@ export default function Tasks() {
+ {/* Comments (only for existing tasks) */} + {editingTask && ( + + )} +