feat: slide panels, task calendar, team management, project editing, collapsible sections
- 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)
This commit is contained in:
@@ -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 (
|
||||
<AppContext.Provider value={{ currentUser: user, teamMembers, brands, loadTeam, getBrandName }}>
|
||||
<AppContext.Provider value={{ currentUser: user, teamMembers, brands, loadTeam, getBrandName, teams, loadTeams }}>
|
||||
{/* Profile completion prompt */}
|
||||
{showProfilePrompt && (
|
||||
<div className="fixed top-4 right-4 z-50 bg-amber-50 border-2 border-amber-400 rounded-xl shadow-lg p-4 max-w-md animate-fade-in">
|
||||
@@ -258,18 +270,23 @@ function AppContent() {
|
||||
<Route path="/login" element={user ? <Navigate to="/" replace /> : <Login />} />
|
||||
<Route path="/" element={user ? <Layout /> : <Navigate to="/login" replace />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="posts" element={<PostProduction />} />
|
||||
<Route path="assets" element={<Assets />} />
|
||||
<Route path="campaigns" element={<Campaigns />} />
|
||||
<Route path="campaigns/:id" element={<CampaignDetail />} />
|
||||
{(user?.role === 'superadmin' || user?.role === 'manager') && (
|
||||
{hasModule('marketing') && <>
|
||||
<Route path="posts" element={<PostProduction />} />
|
||||
<Route path="assets" element={<Assets />} />
|
||||
<Route path="campaigns" element={<Campaigns />} />
|
||||
<Route path="campaigns/:id" element={<CampaignDetail />} />
|
||||
<Route path="brands" element={<Brands />} />
|
||||
</>}
|
||||
{hasModule('finance') && (user?.role === 'superadmin' || user?.role === 'manager') && <>
|
||||
<Route path="finance" element={<Finance />} />
|
||||
)}
|
||||
<Route path="projects" element={<Projects />} />
|
||||
<Route path="projects/:id" element={<ProjectDetail />} />
|
||||
<Route path="tasks" element={<Tasks />} />
|
||||
<Route path="budgets" element={<Budgets />} />
|
||||
</>}
|
||||
{hasModule('projects') && <>
|
||||
<Route path="projects" element={<Projects />} />
|
||||
<Route path="projects/:id" element={<ProjectDetail />} />
|
||||
<Route path="tasks" element={<Tasks />} />
|
||||
</>}
|
||||
<Route path="team" element={<Team />} />
|
||||
<Route path="brands" element={<Brands />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
{user?.role === 'superadmin' && (
|
||||
<Route path="users" element={<Users />} />
|
||||
|
||||
20
client/src/components/CollapsibleSection.jsx
Normal file
20
client/src/components/CollapsibleSection.jsx
Normal file
@@ -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 (
|
||||
<div className={noBorder ? '' : 'border-b border-border'}>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="w-full flex items-center gap-2 px-5 py-3 text-sm font-semibold text-text-primary hover:bg-surface-secondary transition-colors"
|
||||
>
|
||||
{open ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
{title}
|
||||
{badge}
|
||||
</button>
|
||||
{open && children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
client/src/components/DatePresetPicker.jsx
Normal file
37
client/src/components/DatePresetPicker.jsx
Normal file
@@ -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 (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{DATE_PRESETS.map(preset => (
|
||||
<button
|
||||
key={preset.key}
|
||||
onClick={() => {
|
||||
const { from, to } = preset.getRange()
|
||||
onSelect(from, to, preset.key)
|
||||
}}
|
||||
className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${
|
||||
activePreset === preset.key
|
||||
? 'bg-brand-primary/10 border-brand-primary/30 text-brand-primary'
|
||||
: 'bg-white border-border text-text-tertiary hover:text-text-primary hover:border-border-dark'
|
||||
}`}
|
||||
>
|
||||
{t(preset.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
{activePreset && (
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="p-1 text-text-tertiary hover:text-text-primary transition-colors"
|
||||
title={t('dates.clearDates')}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -313,11 +313,15 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.assigneeName && (
|
||||
{item.thumbnailUrl ? (
|
||||
<div className="w-8 h-8 rounded overflow-hidden shrink-0">
|
||||
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
) : item.assigneeName ? (
|
||||
<div className="w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[9px] font-bold shrink-0">
|
||||
{getInitials(item.assigneeName)}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
<span className="text-sm font-semibold text-text-primary truncate">{item.label}</span>
|
||||
</div>
|
||||
{item.description && (
|
||||
@@ -333,11 +337,15 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{item.assigneeName && (
|
||||
{item.thumbnailUrl ? (
|
||||
<div className="w-6 h-6 rounded overflow-hidden shrink-0">
|
||||
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
) : item.assigneeName ? (
|
||||
<div className="w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[9px] font-bold shrink-0">
|
||||
{getInitials(item.assigneeName)}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
<span className="text-xs font-medium text-text-primary truncate">{item.label}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -59,6 +59,17 @@ export default function MemberCard({ member, onClick }) {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Teams */}
|
||||
{member.teams && member.teams.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 justify-center mt-2">
|
||||
{member.teams.map((team) => (
|
||||
<span key={team.id} className="text-[10px] px-2 py-0.5 rounded-full bg-blue-50 text-blue-600 font-medium">
|
||||
{team.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
582
client/src/components/PostDetailPanel.jsx
Normal file
582
client/src/components/PostDetailPanel.jsx
Normal file
@@ -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 = (
|
||||
<div className="px-5 py-4 border-b border-border shrink-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
value={form.title}
|
||||
onChange={e => 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')}
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${
|
||||
form.status === 'published' ? 'bg-emerald-100 text-emerald-700' :
|
||||
form.status === 'scheduled' ? 'bg-purple-100 text-purple-700' :
|
||||
form.status === 'approved' ? 'bg-blue-100 text-blue-700' :
|
||||
form.status === 'in_review' ? 'bg-amber-100 text-amber-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{statusOptions.find(s => s.value === form.status)?.label}
|
||||
</span>
|
||||
{brandName && (
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${getBrandColor(brandName).bg} ${getBrandColor(brandName).text}`}>
|
||||
{brandName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SlidePanel onClose={onClose} maxWidth="520px" header={header}>
|
||||
{/* Details Section */}
|
||||
<CollapsibleSection title={t('posts.details')}>
|
||||
<div className="px-5 pb-4 space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.description')}</label>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={e => update('description', e.target.value)}
|
||||
rows={3}
|
||||
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 resize-none"
|
||||
placeholder={t('posts.postDescPlaceholder')}
|
||||
/>
|
||||
</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={form.brand_id}
|
||||
onChange={e => update('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.selectBrand')}</option>
|
||||
{(brands || []).map(b => <option key={b._id || b.id} value={b._id || b.id}>{b.icon} {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={form.campaign_id}
|
||||
onChange={e => update('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="">{t('posts.noCampaign')}</option>
|
||||
{(campaigns || []).map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.assignTo')}</label>
|
||||
<select
|
||||
value={form.assigned_to}
|
||||
onChange={e => update('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 || m.id} value={m._id || m.id}>{m.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.status')}</label>
|
||||
<select
|
||||
value={form.status}
|
||||
onChange={e => update('status', 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"
|
||||
>
|
||||
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.scheduledDate')}</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={form.scheduled_date}
|
||||
onChange={e => update('scheduled_date', 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.notes')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.notes}
|
||||
onChange={e => update('notes', 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('posts.additionalNotes')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{publishError && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{publishError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!form.title || saving}
|
||||
className={`flex-1 px-4 py-2 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 ${saving ? 'btn-loading' : ''}`}
|
||||
>
|
||||
{isCreateMode ? t('posts.createPost') : t('posts.saveChanges')}
|
||||
</button>
|
||||
)}
|
||||
{onDelete && !isCreateMode && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Platforms & Links Section */}
|
||||
<CollapsibleSection title={t('posts.platformsLinks')}>
|
||||
<div className="px-5 pb-4 space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.platforms')}</label>
|
||||
<div className="flex flex-wrap gap-2 p-2 border border-border rounded-lg bg-white min-h-[38px]">
|
||||
{Object.entries(PLATFORMS).map(([k, v]) => {
|
||||
const checked = (form.platforms || []).includes(k)
|
||||
return (
|
||||
<label
|
||||
key={k}
|
||||
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-full cursor-pointer border transition-colors ${
|
||||
checked
|
||||
? 'bg-brand-primary/10 border-brand-primary/30 text-brand-primary font-medium'
|
||||
: 'bg-surface-tertiary border-transparent text-text-secondary hover:bg-surface-tertiary/80'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
update('platforms', checked
|
||||
? form.platforms.filter(p => p !== k)
|
||||
: [...(form.platforms || []), k]
|
||||
)
|
||||
}}
|
||||
className="sr-only"
|
||||
/>
|
||||
{v.label}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(form.platforms || []).length > 0 && (
|
||||
<div className="space-y-2 border border-border rounded-lg p-3 bg-surface-secondary">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-1">
|
||||
<Link2 className="w-3.5 h-3.5" />
|
||||
{t('posts.publicationLinks')}
|
||||
</div>
|
||||
{(form.platforms || []).map(platformKey => {
|
||||
const platformInfo = PLATFORMS[platformKey] || { label: platformKey }
|
||||
const existingLink = (form.publication_links || []).find(l => l.platform === platformKey)
|
||||
const linkUrl = existingLink?.url || ''
|
||||
return (
|
||||
<div key={platformKey} className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-text-secondary w-24 shrink-0 flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: platformInfo.color || '#888' }} />
|
||||
{platformInfo.label}
|
||||
</span>
|
||||
<input
|
||||
type="url"
|
||||
value={linkUrl}
|
||||
onChange={e => updatePublicationLink(platformKey, e.target.value)}
|
||||
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"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
{linkUrl && (
|
||||
<a href={linkUrl} target="_blank" rel="noopener noreferrer" className="p-1.5 text-text-tertiary hover:text-brand-primary">
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{form.status === 'published' && (form.platforms || []).some(p => {
|
||||
const link = (form.publication_links || []).find(l => l.platform === p)
|
||||
return !link || !link.url?.trim()
|
||||
}) && (
|
||||
<p className="text-xs text-amber-600 mt-1">{t('posts.publishRequired')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Attachments Section (hidden in create mode) */}
|
||||
{!isCreateMode && (
|
||||
<CollapsibleSection
|
||||
title={t('posts.attachments')}
|
||||
badge={attachments.length > 0 ? (
|
||||
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-1.5 py-0.5 rounded-full">
|
||||
{attachments.length}
|
||||
</span>
|
||||
) : null}
|
||||
>
|
||||
<div className="px-5 pb-4">
|
||||
{attachments.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
{attachments.map(att => {
|
||||
const isImage = att.mime_type?.startsWith('image/') || att.mimeType?.startsWith('image/')
|
||||
const attUrl = att.url || `/api/uploads/${att.filename}`
|
||||
const name = att.original_name || att.originalName || att.filename
|
||||
const attId = att.id || att._id
|
||||
return (
|
||||
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
|
||||
<div className="h-20 relative">
|
||||
{isImage ? (
|
||||
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="block h-full">
|
||||
<img src={attUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
|
||||
</a>
|
||||
) : (
|
||||
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="absolute inset-0 flex items-center gap-2 p-3">
|
||||
<FileText className="w-6 h-6 text-text-tertiary shrink-0" />
|
||||
<span className="text-xs text-text-secondary truncate">{name}</span>
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDeleteAttachment(attId)}
|
||||
className="absolute top-1 right-1 p-1 bg-black/50 hover:bg-red-500 rounded-full text-white opacity-0 group-hover/att:opacity-100 transition-opacity"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<X className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors ${
|
||||
dragActive ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/40'
|
||||
}`}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragEnter={e => { e.preventDefault(); setDragActive(true) }}
|
||||
onDragLeave={e => { e.preventDefault(); setDragActive(false) }}
|
||||
onDragOver={e => e.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={e => { handleFileUpload(e.target.files); e.target.value = '' }}
|
||||
/>
|
||||
<Upload className={`w-5 h-5 text-text-tertiary mx-auto mb-1 ${uploading ? 'animate-pulse' : ''}`} />
|
||||
<p className="text-xs text-text-secondary">
|
||||
{dragActive ? t('posts.dropFiles') : t('posts.uploadFiles')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
{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 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 || asset._id}
|
||||
onClick={() => handleAttachAsset(asset.id || 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={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>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Discussion Section (hidden in create mode) */}
|
||||
{!isCreateMode && (
|
||||
<CollapsibleSection title={t('posts.discussion')} noBorder>
|
||||
<div className="px-5 pb-5">
|
||||
<CommentsSection entityType="post" entityId={postId} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
</SlidePanel>
|
||||
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => setShowDeleteConfirm(false)}
|
||||
title={t('posts.deletePost')}
|
||||
isConfirm
|
||||
danger
|
||||
confirmText={t('posts.deletePost')}
|
||||
onConfirm={confirmDelete}
|
||||
>
|
||||
{t('posts.deleteConfirm')}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -16,11 +16,19 @@ export default function ProjectCard({ project }) {
|
||||
|
||||
const ownerName = typeof project.owner === 'object' ? project.owner?.name : project.owner
|
||||
|
||||
const thumbnailUrl = project.thumbnail_url || project.thumbnailUrl
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => navigate(`/projects/${project._id}`)}
|
||||
className="bg-white rounded-xl border border-border p-5 card-hover cursor-pointer"
|
||||
className="bg-white rounded-xl border border-border card-hover cursor-pointer overflow-hidden"
|
||||
>
|
||||
{thumbnailUrl ? (
|
||||
<div className="w-full h-32 overflow-hidden">
|
||||
<img src={thumbnailUrl} alt="" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="p-5">
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<h4 className="text-base font-semibold text-text-primary line-clamp-1">{project.name}</h4>
|
||||
<StatusBadge status={project.status} size="xs" />
|
||||
@@ -67,6 +75,7 @@ export default function ProjectCard({ project }) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>{/* end p-5 wrapper */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
304
client/src/components/ProjectEditPanel.jsx
Normal file
304
client/src/components/ProjectEditPanel.jsx
Normal file
@@ -0,0 +1,304 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { X, Trash2, Upload } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api, getBrandColor } from '../utils/api'
|
||||
import CommentsSection from './CommentsSection'
|
||||
import Modal from './Modal'
|
||||
import SlidePanel from './SlidePanel'
|
||||
import CollapsibleSection from './CollapsibleSection'
|
||||
|
||||
export default function ProjectEditPanel({ project, onClose, onSave, onDelete, brands, teamMembers }) {
|
||||
const { t, lang } = useLanguage()
|
||||
const thumbnailInputRef = useRef(null)
|
||||
const [form, setForm] = useState({})
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [thumbnailUploading, setThumbnailUploading] = useState(false)
|
||||
|
||||
const projectId = project?._id || project?.id
|
||||
if (!project) return null
|
||||
|
||||
useEffect(() => {
|
||||
if (project) {
|
||||
setForm({
|
||||
name: project.name || '',
|
||||
description: project.description || '',
|
||||
brand_id: project.brandId || project.brand_id || '',
|
||||
owner_id: project.ownerId || project.owner_id || '',
|
||||
status: project.status || 'active',
|
||||
start_date: project.startDate || project.start_date ? new Date(project.startDate || project.start_date).toISOString().slice(0, 10) : '',
|
||||
due_date: project.dueDate ? new Date(project.dueDate).toISOString().slice(0, 10) : '',
|
||||
})
|
||||
setDirty(false)
|
||||
}
|
||||
}, [project])
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'paused', label: 'Paused' },
|
||||
{ value: 'completed', label: 'Completed' },
|
||||
{ value: 'cancelled', label: 'Cancelled' },
|
||||
]
|
||||
|
||||
const update = (field, value) => {
|
||||
setForm(f => ({ ...f, [field]: value }))
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSave(projectId, {
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
brand_id: form.brand_id ? Number(form.brand_id) : null,
|
||||
owner_id: form.owner_id ? Number(form.owner_id) : null,
|
||||
status: form.status,
|
||||
start_date: form.start_date || null,
|
||||
due_date: form.due_date || null,
|
||||
})
|
||||
setDirty(false)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
setShowDeleteConfirm(false)
|
||||
await onDelete(projectId)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleThumbnailUpload = async (file) => {
|
||||
if (!file) return
|
||||
setThumbnailUploading(true)
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
await api.upload(`/projects/${projectId}/thumbnail`, fd)
|
||||
// Parent will reload
|
||||
onSave(projectId, form)
|
||||
} catch (err) {
|
||||
console.error('Thumbnail upload failed:', err)
|
||||
} finally {
|
||||
setThumbnailUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleThumbnailRemove = async () => {
|
||||
try {
|
||||
await api.delete(`/projects/${projectId}/thumbnail`)
|
||||
onSave(projectId, form)
|
||||
} catch (err) {
|
||||
console.error('Thumbnail remove failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
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 project.brand_name || project.brandName || null
|
||||
})()
|
||||
|
||||
const header = (
|
||||
<div className="px-5 py-4 border-b border-border shrink-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={e => update('name', 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('projects.name')}
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${
|
||||
form.status === 'active' ? 'bg-emerald-100 text-emerald-700' :
|
||||
form.status === 'paused' ? 'bg-amber-100 text-amber-700' :
|
||||
form.status === 'completed' ? 'bg-blue-100 text-blue-700' :
|
||||
form.status === 'cancelled' ? 'bg-red-100 text-red-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{statusOptions.find(s => s.value === form.status)?.label}
|
||||
</span>
|
||||
{brandName && (
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${getBrandColor(brandName).bg} ${getBrandColor(brandName).text}`}>
|
||||
{brandName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SlidePanel onClose={onClose} maxWidth="420px" header={header}>
|
||||
{/* Details Section */}
|
||||
<CollapsibleSection title={t('projects.details')}>
|
||||
<div className="px-5 pb-4 space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.description')}</label>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={e => update('description', e.target.value)}
|
||||
rows={3}
|
||||
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 resize-none"
|
||||
placeholder="Project description..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.brand')}</label>
|
||||
<select
|
||||
value={form.brand_id}
|
||||
onChange={e => update('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="">Select brand</option>
|
||||
{(brands || []).map(b => <option key={b._id || b.id} value={b._id || b.id}>{b.icon} {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('projects.status')}</label>
|
||||
<select
|
||||
value={form.status}
|
||||
onChange={e => update('status', 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"
|
||||
>
|
||||
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.owner')}</label>
|
||||
<select
|
||||
value={form.owner_id}
|
||||
onChange={e => update('owner_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('common.unassigned')}</option>
|
||||
{(teamMembers || []).map(m => <option key={m._id || m.id} value={m._id || m.id}>{m.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.startDate')}</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.start_date}
|
||||
onChange={e => update('start_date', 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.dueDate')}</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.due_date}
|
||||
onChange={e => update('due_date', 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Thumbnail */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.thumbnail')}</label>
|
||||
{(project.thumbnail_url || project.thumbnailUrl) ? (
|
||||
<div className="relative group rounded-lg overflow-hidden border border-border">
|
||||
<img src={project.thumbnail_url || project.thumbnailUrl} alt="" className="w-full h-24 object-cover" />
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center gap-2 opacity-0 group-hover:opacity-100">
|
||||
<button
|
||||
onClick={() => thumbnailInputRef.current?.click()}
|
||||
className="px-3 py-1.5 text-xs bg-white/90 hover:bg-white rounded-lg font-medium text-text-primary transition-colors"
|
||||
>
|
||||
{t('projects.changeThumbnail')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleThumbnailRemove}
|
||||
className="px-3 py-1.5 text-xs bg-red-500/90 hover:bg-red-500 rounded-lg font-medium text-white transition-colors"
|
||||
>
|
||||
{t('projects.removeThumbnail')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => thumbnailInputRef.current?.click()}
|
||||
disabled={thumbnailUploading}
|
||||
className="w-full border-2 border-dashed border-border rounded-lg p-3 text-center hover:border-brand-primary/40 transition-colors"
|
||||
>
|
||||
<Upload className={`w-5 h-5 text-text-tertiary mx-auto mb-1 ${thumbnailUploading ? 'animate-pulse' : ''}`} />
|
||||
<p className="text-xs text-text-secondary">
|
||||
{thumbnailUploading ? 'Uploading...' : t('projects.uploadThumbnail')}
|
||||
</p>
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
ref={thumbnailInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={e => { handleThumbnailUpload(e.target.files[0]); e.target.value = '' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!form.name || saving}
|
||||
className={`flex-1 px-4 py-2 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 ${saving ? 'btn-loading' : ''}`}
|
||||
>
|
||||
{t('tasks.saveChanges')}
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Discussion Section */}
|
||||
<CollapsibleSection title={t('projects.discussion')} noBorder>
|
||||
<div className="px-5 pb-5">
|
||||
<CommentsSection entityType="project" entityId={projectId} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</SlidePanel>
|
||||
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => setShowDeleteConfirm(false)}
|
||||
title={t('projects.deleteProject')}
|
||||
isConfirm
|
||||
danger
|
||||
confirmText={t('common.delete')}
|
||||
onConfirm={confirmDelete}
|
||||
>
|
||||
{t('projects.deleteConfirm')}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
19
client/src/components/SlidePanel.jsx
Normal file
19
client/src/components/SlidePanel.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
export default function SlidePanel({ onClose, maxWidth = '420px', header, children }) {
|
||||
return createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 bg-black/20 z-[9998]" onClick={onClose} />
|
||||
<div
|
||||
className="fixed top-0 right-0 h-full w-full bg-white shadow-2xl z-[9998] flex flex-col animate-slide-in-right overflow-hidden"
|
||||
style={{ maxWidth }}
|
||||
>
|
||||
{header}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
176
client/src/components/TaskCalendarView.jsx
Normal file
176
client/src/components/TaskCalendarView.jsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { PRIORITY_CONFIG } from '../utils/api'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
|
||||
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
|
||||
function getMonthData(year, month) {
|
||||
const firstDay = new Date(year, month, 1).getDay()
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
||||
const prevDays = new Date(year, month, 0).getDate()
|
||||
|
||||
const cells = []
|
||||
// Previous month trailing days
|
||||
for (let i = firstDay - 1; i >= 0; i--) {
|
||||
cells.push({ day: prevDays - i, current: false, date: new Date(year, month - 1, prevDays - i) })
|
||||
}
|
||||
// Current month
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
cells.push({ day: d, current: true, date: new Date(year, month, d) })
|
||||
}
|
||||
// Next month leading days
|
||||
const remaining = 42 - cells.length
|
||||
for (let d = 1; d <= remaining; d++) {
|
||||
cells.push({ day: d, current: false, date: new Date(year, month + 1, d) })
|
||||
}
|
||||
return cells
|
||||
}
|
||||
|
||||
function dateKey(d) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export default function TaskCalendarView({ tasks, onTaskClick }) {
|
||||
const { t } = useLanguage()
|
||||
const today = new Date()
|
||||
const [year, setYear] = useState(today.getFullYear())
|
||||
const [month, setMonth] = useState(today.getMonth())
|
||||
|
||||
const cells = getMonthData(year, month)
|
||||
const todayKey = dateKey(today)
|
||||
|
||||
// Group tasks by due_date
|
||||
const tasksByDate = {}
|
||||
const unscheduled = []
|
||||
for (const task of tasks) {
|
||||
const dd = task.due_date || task.dueDate
|
||||
if (dd) {
|
||||
const key = dd.slice(0, 10) // yyyy-mm-dd
|
||||
if (!tasksByDate[key]) tasksByDate[key] = []
|
||||
tasksByDate[key].push(task)
|
||||
} else {
|
||||
unscheduled.push(task)
|
||||
}
|
||||
}
|
||||
|
||||
const prevMonth = () => {
|
||||
if (month === 0) { setMonth(11); setYear(y => y - 1) }
|
||||
else setMonth(m => m - 1)
|
||||
}
|
||||
const nextMonth = () => {
|
||||
if (month === 11) { setMonth(0); setYear(y => y + 1) }
|
||||
else setMonth(m => m + 1)
|
||||
}
|
||||
const goToday = () => { setYear(today.getFullYear()); setMonth(today.getMonth()) }
|
||||
|
||||
const monthLabel = new Date(year, month).toLocaleString('default', { month: 'long', year: 'numeric' })
|
||||
|
||||
const getPillColor = (task) => {
|
||||
const p = task.priority || 'medium'
|
||||
if (p === 'urgent') return 'bg-red-500 text-white'
|
||||
if (p === 'high') return 'bg-orange-400 text-white'
|
||||
if (p === 'medium') return 'bg-amber-400 text-amber-900'
|
||||
return 'bg-gray-300 text-gray-700'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
{/* Calendar grid */}
|
||||
<div className="flex-1">
|
||||
{/* Nav */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={prevMonth} className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<h3 className="text-sm font-semibold text-text-primary min-w-[150px] text-center">{monthLabel}</h3>
|
||||
<button onClick={nextMonth} className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={goToday} className="px-3 py-1 text-xs font-medium text-brand-primary hover:bg-brand-primary/5 rounded-lg transition-colors">
|
||||
{t('tasks.today')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Day headers */}
|
||||
<div className="grid grid-cols-7 mb-1">
|
||||
{DAYS.map(d => (
|
||||
<div key={d} className="text-center text-[10px] font-medium text-text-tertiary uppercase py-1">
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Cells */}
|
||||
<div className="grid grid-cols-7 border-t border-l border-border">
|
||||
{cells.map((cell, i) => {
|
||||
const key = dateKey(cell.date)
|
||||
const isToday = key === todayKey
|
||||
const dayTasks = tasksByDate[key] || []
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`border-r border-b border-border min-h-[90px] p-1 ${
|
||||
cell.current ? 'bg-white' : 'bg-surface-secondary/50'
|
||||
}`}
|
||||
>
|
||||
<div className={`text-[11px] font-medium mb-0.5 w-6 h-6 flex items-center justify-center rounded-full ${
|
||||
isToday ? 'bg-brand-primary text-white' : cell.current ? 'text-text-primary' : 'text-text-tertiary'
|
||||
}`}>
|
||||
{cell.day}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{dayTasks.slice(0, 3).map(task => (
|
||||
<button
|
||||
key={task._id || task.id}
|
||||
onClick={() => onTaskClick(task)}
|
||||
className={`w-full text-left text-[10px] px-1.5 py-0.5 rounded truncate font-medium hover:opacity-80 transition-opacity ${
|
||||
task.status === 'done' ? 'bg-emerald-100 text-emerald-700 line-through' : getPillColor(task)
|
||||
}`}
|
||||
title={task.title}
|
||||
>
|
||||
{task.title}
|
||||
</button>
|
||||
))}
|
||||
{dayTasks.length > 3 && (
|
||||
<div className="text-[9px] text-text-tertiary text-center font-medium">
|
||||
+{dayTasks.length - 3} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Unscheduled sidebar */}
|
||||
{unscheduled.length > 0 && (
|
||||
<div className="w-48 shrink-0">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-2">{t('tasks.unscheduled')}</h4>
|
||||
<div className="space-y-1.5 max-h-[500px] overflow-y-auto">
|
||||
{unscheduled.map(task => {
|
||||
const priority = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
|
||||
return (
|
||||
<button
|
||||
key={task._id || task.id}
|
||||
onClick={() => onTaskClick(task)}
|
||||
className="w-full text-left bg-white border border-border rounded-lg p-2 hover:border-brand-primary/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`w-2 h-2 rounded-full ${priority.color} shrink-0`} />
|
||||
<span className={`text-xs font-medium truncate ${task.status === 'done' ? 'text-text-tertiary line-through' : 'text-text-primary'}`}>
|
||||
{task.title}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { format } from 'date-fns'
|
||||
import { ArrowRight, Clock, User, UserCheck } from 'lucide-react'
|
||||
import { PRIORITY_CONFIG } from '../utils/api'
|
||||
import { ArrowRight, Clock, User, UserCheck, MessageCircle } from 'lucide-react'
|
||||
import { PRIORITY_CONFIG, getBrandColor } from '../utils/api'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
|
||||
@@ -8,7 +8,8 @@ export default function TaskCard({ task, onMove, showProject = true }) {
|
||||
const { t } = useLanguage()
|
||||
const { user: authUser } = useAuth()
|
||||
const priority = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
|
||||
const projectName = typeof task.project === 'object' ? task.project?.name : task.projectName
|
||||
const projectName = task.project_name || (typeof task.project === 'object' ? task.project?.name : task.projectName)
|
||||
const brandName = task.brand_name || task.brandName
|
||||
|
||||
const nextStatus = {
|
||||
todo: 'in_progress',
|
||||
@@ -23,6 +24,7 @@ export default function TaskCard({ task, onMove, showProject = true }) {
|
||||
const dueDate = task.due_date || task.dueDate
|
||||
const isOverdue = dueDate && new Date(dueDate) < new Date() && task.status !== 'done'
|
||||
const creatorName = task.creator_user_name || task.creatorUserName
|
||||
const commentCount = task.comment_count || task.commentCount || 0
|
||||
|
||||
// Determine if this task was assigned by someone else
|
||||
const createdByUserId = task.created_by_user_id || task.createdByUserId
|
||||
@@ -56,6 +58,11 @@ export default function TaskCard({ task, onMove, showProject = true }) {
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
|
||||
{showProject && brandName && (
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${getBrandColor(brandName).bg} ${getBrandColor(brandName).text}`}>
|
||||
{brandName}
|
||||
</span>
|
||||
)}
|
||||
{showProject && projectName && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-tertiary">
|
||||
{projectName}
|
||||
@@ -67,6 +74,12 @@ export default function TaskCard({ task, onMove, showProject = true }) {
|
||||
{format(new Date(dueDate), 'MMM d')}
|
||||
</span>
|
||||
)}
|
||||
{commentCount > 0 && (
|
||||
<span className="text-[10px] flex items-center gap-0.5 text-text-tertiary">
|
||||
<MessageCircle className="w-3 h-3" />
|
||||
{commentCount}
|
||||
</span>
|
||||
)}
|
||||
{!isExternallyAssigned && creatorName && (
|
||||
<span className="text-[10px] flex items-center gap-1 text-text-tertiary">
|
||||
<User className="w-3 h-3" />
|
||||
@@ -81,7 +94,7 @@ export default function TaskCard({ task, onMove, showProject = true }) {
|
||||
{onMove && nextStatus[task.status] && (
|
||||
<div className="mt-2 pt-2 border-t border-border-light opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => onMove(task._id || task.id, nextStatus[task.status])}
|
||||
onClick={(e) => { e.stopPropagation(); onMove(task._id || task.id, nextStatus[task.status]) }}
|
||||
className="text-[11px] text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1"
|
||||
>
|
||||
{nextLabel[task.status]} <ArrowRight className="w-3 h-3" />
|
||||
|
||||
553
client/src/components/TaskDetailPanel.jsx
Normal file
553
client/src/components/TaskDetailPanel.jsx
Normal file
@@ -0,0 +1,553 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { X, Trash2, AlertCircle, Upload, FileText, Star } from 'lucide-react'
|
||||
import { PRIORITY_CONFIG, getBrandColor, api } from '../utils/api'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import CommentsSection from './CommentsSection'
|
||||
import Modal from './Modal'
|
||||
import SlidePanel from './SlidePanel'
|
||||
import CollapsibleSection from './CollapsibleSection'
|
||||
|
||||
const API_BASE = '/api'
|
||||
|
||||
export default function TaskDetailPanel({ task, onClose, onSave, onDelete, projects, users, brands }) {
|
||||
const { t } = useLanguage()
|
||||
const fileInputRef = useRef(null)
|
||||
const [form, setForm] = useState({
|
||||
title: '', description: '', project_id: '', assigned_to: '',
|
||||
priority: 'medium', status: 'todo', start_date: '', due_date: '',
|
||||
})
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
|
||||
// Attachments state
|
||||
const [attachments, setAttachments] = useState([])
|
||||
const [pendingFiles, setPendingFiles] = useState([]) // for create mode (no task ID yet)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [maxSizeMB, setMaxSizeMB] = useState(50)
|
||||
const [uploadError, setUploadError] = useState(null)
|
||||
const [currentThumbnail, setCurrentThumbnail] = useState(null)
|
||||
|
||||
const taskId = task?._id || task?.id
|
||||
const isCreateMode = !taskId
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/settings/app').then(s => setMaxSizeMB(s.uploadMaxSizeMB || 50)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const taskIdRef = useRef(taskId)
|
||||
useEffect(() => {
|
||||
// Only reset form when switching to a different task (or initial mount)
|
||||
const switched = taskIdRef.current !== taskId
|
||||
taskIdRef.current = taskId
|
||||
if (task && (switched || !form.title)) {
|
||||
setForm({
|
||||
title: task.title || '',
|
||||
description: task.description || '',
|
||||
project_id: task.project_id || task.projectId || '',
|
||||
assigned_to: task.assigned_to || task.assignedTo || '',
|
||||
priority: task.priority || 'medium',
|
||||
status: task.status || 'todo',
|
||||
start_date: task.start_date || task.startDate || '',
|
||||
due_date: task.due_date || task.dueDate || '',
|
||||
})
|
||||
setDirty(isCreateMode)
|
||||
if (switched) setPendingFiles([])
|
||||
setCurrentThumbnail(task.thumbnail || null)
|
||||
if (!isCreateMode) loadAttachments()
|
||||
}
|
||||
}, [task])
|
||||
|
||||
if (!task) return null
|
||||
|
||||
const dueDate = task.due_date || task.dueDate
|
||||
const isOverdue = dueDate && new Date(dueDate) < new Date() && task.status !== 'done'
|
||||
const creatorName = task.creator_user_name || task.creatorUserName
|
||||
const priority = PRIORITY_CONFIG[form.priority] || PRIORITY_CONFIG.medium
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'todo', label: t('tasks.todo') },
|
||||
{ value: 'in_progress', label: t('tasks.in_progress') },
|
||||
{ value: 'done', label: t('tasks.done') },
|
||||
]
|
||||
|
||||
const priorityOptions = [
|
||||
{ value: 'low', label: t('tasks.priority.low') },
|
||||
{ value: 'medium', label: t('tasks.priority.medium') },
|
||||
{ value: 'high', label: t('tasks.priority.high') },
|
||||
{ value: 'urgent', label: t('tasks.priority.urgent') },
|
||||
]
|
||||
|
||||
const update = (field, value) => {
|
||||
setForm(f => ({ ...f, [field]: value }))
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const data = {
|
||||
title: form.title,
|
||||
description: form.description,
|
||||
project_id: form.project_id || null,
|
||||
assigned_to: form.assigned_to || null,
|
||||
priority: form.priority,
|
||||
status: form.status,
|
||||
start_date: form.start_date || null,
|
||||
due_date: form.due_date || null,
|
||||
}
|
||||
await onSave(isCreateMode ? null : taskId, data, pendingFiles)
|
||||
setDirty(false)
|
||||
setPendingFiles([])
|
||||
if (isCreateMode) onClose()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
setShowDeleteConfirm(true)
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
onDelete(taskId)
|
||||
setShowDeleteConfirm(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
// ─── Attachments ──────────────────────────────
|
||||
async function loadAttachments() {
|
||||
if (!taskId) return
|
||||
try {
|
||||
const data = await api.get(`/tasks/${taskId}/attachments`)
|
||||
setAttachments(Array.isArray(data) ? data : (data.data || []))
|
||||
} catch {
|
||||
setAttachments([])
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileUpload = async (files) => {
|
||||
if (!files?.length) return
|
||||
setUploadError(null)
|
||||
const maxBytes = maxSizeMB * 1024 * 1024
|
||||
const tooBig = Array.from(files).find(f => f.size > maxBytes)
|
||||
if (tooBig) {
|
||||
setUploadError(t('tasks.fileTooLarge')
|
||||
.replace('{name}', tooBig.name)
|
||||
.replace('{size}', (tooBig.size / 1024 / 1024).toFixed(1))
|
||||
.replace('{max}', maxSizeMB))
|
||||
return
|
||||
}
|
||||
setUploading(true)
|
||||
for (const file of files) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
try {
|
||||
await api.upload(`/tasks/${taskId}/attachments`, fd)
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err)
|
||||
setUploadError(err.message || 'Upload failed')
|
||||
}
|
||||
}
|
||||
setUploading(false)
|
||||
loadAttachments()
|
||||
}
|
||||
|
||||
const handleDeleteAttachment = async (attId) => {
|
||||
try {
|
||||
await api.delete(`/task-attachments/${attId}`)
|
||||
loadAttachments()
|
||||
} catch (err) {
|
||||
console.error('Delete attachment failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSetThumbnail = async (attachment) => {
|
||||
try {
|
||||
const attId = attachment._id || attachment.id || attachment.Id
|
||||
await api.patch(`/tasks/${taskId}/thumbnail`, { attachment_id: attId })
|
||||
const url = attachment.url || `/api/uploads/${attachment.filename}`
|
||||
setCurrentThumbnail(url)
|
||||
} catch (err) {
|
||||
console.error('Set thumbnail failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveThumbnail = async () => {
|
||||
try {
|
||||
await api.patch(`/tasks/${taskId}/thumbnail`, { attachment_id: null })
|
||||
setCurrentThumbnail(null)
|
||||
} catch (err) {
|
||||
console.error('Remove thumbnail failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get brand for the selected project
|
||||
const selectedProject = projects?.find(p => String(p._id || p.id) === String(form.project_id))
|
||||
const brandName = selectedProject ? (selectedProject.brand_name || selectedProject.brandName) : (task.brand_name || task.brandName)
|
||||
|
||||
const header = (
|
||||
<div className="px-5 py-4 border-b border-border shrink-0">
|
||||
{/* Thumbnail banner */}
|
||||
{currentThumbnail && (
|
||||
<div className="relative -mx-5 -mt-4 mb-3 h-32 overflow-hidden">
|
||||
<img src={currentThumbnail} alt="" className="w-full h-full object-cover" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-white/80 to-transparent" />
|
||||
<button
|
||||
onClick={handleRemoveThumbnail}
|
||||
className="absolute top-2 right-2 p-1 bg-black/40 hover:bg-black/60 rounded-full text-white transition-colors"
|
||||
title={t('tasks.removeThumbnail')}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
value={form.title}
|
||||
onChange={e => 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('tasks.taskTitle')}
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className={`inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full font-medium ${priority.color === 'bg-gray-400' ? 'bg-gray-100 text-gray-600' : priority.color === 'bg-amber-400' ? 'bg-amber-100 text-amber-700' : priority.color === 'bg-orange-500' ? 'bg-orange-100 text-orange-700' : 'bg-red-100 text-red-700'}`}>
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${priority.color}`} />
|
||||
{priorityOptions.find(p => p.value === form.priority)?.label}
|
||||
</span>
|
||||
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${form.status === 'done' ? 'bg-emerald-100 text-emerald-700' : form.status === 'in_progress' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'}`}>
|
||||
{statusOptions.find(s => s.value === form.status)?.label}
|
||||
</span>
|
||||
{isOverdue && !isCreateMode && (
|
||||
<span className="text-[11px] px-2 py-0.5 rounded-full font-medium bg-red-100 text-red-600 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
{t('tasks.overdue')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SlidePanel onClose={onClose} maxWidth="420px" header={header}>
|
||||
{/* Details Section */}
|
||||
<CollapsibleSection title={t('tasks.details')}>
|
||||
<div className="px-5 pb-4 space-y-3">
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.description')}</label>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={e => update('description', e.target.value)}
|
||||
rows={3}
|
||||
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 resize-none"
|
||||
placeholder={t('posts.optionalDetails')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Project */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.project')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={form.project_id}
|
||||
onChange={e => update('project_id', e.target.value)}
|
||||
className="flex-1 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('tasks.noProject')}</option>
|
||||
{(projects || []).map(p => (
|
||||
<option key={p._id || p.id} value={p._id || p.id}>{p.name || p.title}</option>
|
||||
))}
|
||||
</select>
|
||||
{brandName && (
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded shrink-0 ${getBrandColor(brandName).bg} ${getBrandColor(brandName).text}`}>
|
||||
{brandName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Assignee */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.assignee')}</label>
|
||||
<select
|
||||
value={form.assigned_to}
|
||||
onChange={e => update('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>
|
||||
{(users || []).map(m => (
|
||||
<option key={m._id || m.team_member_id} value={m._id || m.team_member_id}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Priority & Status */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.priority')}</label>
|
||||
<select
|
||||
value={form.priority}
|
||||
onChange={e => update('priority', 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"
|
||||
>
|
||||
{priorityOptions.map(p => (
|
||||
<option key={p.value} value={p.value}>{p.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.status')}</label>
|
||||
<select
|
||||
value={form.status}
|
||||
onChange={e => update('status', 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"
|
||||
>
|
||||
{statusOptions.map(s => (
|
||||
<option key={s.value} value={s.value}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Start Date & Due Date */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.startDate')}</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.start_date}
|
||||
onChange={e => update('start_date', 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.dueDate')}</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.due_date}
|
||||
onChange={e => update('due_date', 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Created by (read-only) */}
|
||||
{creatorName && !isCreateMode && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.createdBy')}</label>
|
||||
<p className="text-sm text-text-secondary">{creatorName}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!form.title || saving}
|
||||
className={`flex-1 px-4 py-2 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 ${saving ? 'btn-loading' : ''}`}
|
||||
>
|
||||
{isCreateMode ? t('tasks.createTask') : t('tasks.saveChanges')}
|
||||
</button>
|
||||
)}
|
||||
{onDelete && !isCreateMode && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Attachments Section */}
|
||||
<CollapsibleSection
|
||||
title={t('tasks.attachments')}
|
||||
badge={(attachments.length + pendingFiles.length) > 0 ? (
|
||||
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-1.5 py-0.5 rounded-full">
|
||||
{attachments.length + pendingFiles.length}
|
||||
</span>
|
||||
) : null}
|
||||
>
|
||||
<div className="px-5 pb-4">
|
||||
{/* Existing attachment grid (edit mode) */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
{attachments.map(att => {
|
||||
const isImage = att.mime_type?.startsWith('image/') || att.mimeType?.startsWith('image/')
|
||||
const attUrl = att.url || `/api/uploads/${att.filename}`
|
||||
const name = att.original_name || att.originalName || att.filename
|
||||
const attId = att._id || att.id || att.Id
|
||||
const isThumbnail = currentThumbnail && attUrl === currentThumbnail
|
||||
|
||||
return (
|
||||
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
|
||||
<div className="h-20 relative">
|
||||
{isImage ? (
|
||||
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="block h-full">
|
||||
<img src={attUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
|
||||
</a>
|
||||
) : (
|
||||
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="absolute inset-0 flex items-center gap-2 p-3">
|
||||
<FileText className="w-6 h-6 text-text-tertiary shrink-0" />
|
||||
<span className="text-xs text-text-secondary truncate">{name}</span>
|
||||
</a>
|
||||
)}
|
||||
{isThumbnail && (
|
||||
<div className="absolute top-1 left-1 p-0.5 bg-amber-400 rounded-full text-white">
|
||||
<Star className="w-2.5 h-2.5 fill-current" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute top-1 right-1 flex items-center gap-0.5 opacity-0 group-hover/att:opacity-100 transition-opacity">
|
||||
{isImage && !isThumbnail && (
|
||||
<button
|
||||
onClick={() => handleSetThumbnail(att)}
|
||||
className="p-1 bg-black/50 hover:bg-amber-500 rounded-full text-white transition-colors"
|
||||
title={t('tasks.setAsThumbnail')}
|
||||
>
|
||||
<Star className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDeleteAttachment(attId)}
|
||||
className="p-1 bg-black/50 hover:bg-red-500 rounded-full text-white transition-colors"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<X className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">
|
||||
{name}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending files grid (create mode) */}
|
||||
{pendingFiles.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
{pendingFiles.map((file, i) => {
|
||||
const isImage = file.type?.startsWith('image/')
|
||||
const previewUrl = isImage ? URL.createObjectURL(file) : null
|
||||
|
||||
return (
|
||||
<div key={i} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
|
||||
<div className="h-20 relative">
|
||||
{isImage ? (
|
||||
<img src={previewUrl} alt={file.name} className="absolute inset-0 w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center gap-2 p-3">
|
||||
<FileText className="w-6 h-6 text-text-tertiary shrink-0" />
|
||||
<span className="text-xs text-text-secondary truncate">{file.name}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute top-1 right-1 flex items-center gap-0.5 opacity-0 group-hover/att:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
|
||||
className="p-1 bg-black/50 hover:bg-red-500 rounded-full text-white transition-colors"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<X className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">
|
||||
{file.name}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload area */}
|
||||
<div
|
||||
onClick={() => !uploading && fileInputRef.current?.click()}
|
||||
className={`border-2 border-dashed rounded-lg p-4 text-center transition-colors ${
|
||||
uploading ? 'cursor-not-allowed opacity-60 border-border' : 'cursor-pointer border-border hover:border-brand-primary/40'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={e => {
|
||||
setUploadError(null)
|
||||
const files = Array.from(e.target.files || [])
|
||||
const maxBytes = maxSizeMB * 1024 * 1024
|
||||
const tooBig = files.find(f => f.size > maxBytes)
|
||||
if (tooBig) {
|
||||
setUploadError(t('tasks.fileTooLarge')
|
||||
.replace('{name}', tooBig.name)
|
||||
.replace('{size}', (tooBig.size / 1024 / 1024).toFixed(1))
|
||||
.replace('{max}', maxSizeMB))
|
||||
e.target.value = ''
|
||||
return
|
||||
}
|
||||
if (isCreateMode) {
|
||||
if (files.length) setPendingFiles(files)
|
||||
} else {
|
||||
handleFileUpload(e.target.files)
|
||||
}
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<Upload className={`w-5 h-5 text-text-tertiary mx-auto mb-1 ${uploading ? 'animate-pulse' : ''}`} />
|
||||
<p className="text-xs text-text-secondary">
|
||||
{uploading ? t('posts.uploading') : t('tasks.dropOrClick')}
|
||||
</p>
|
||||
<p className="text-[10px] text-text-tertiary mt-0.5">
|
||||
{t('tasks.maxFileSize').replace('{size}', maxSizeMB)}
|
||||
</p>
|
||||
</div>
|
||||
{uploadError && (
|
||||
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded-lg text-xs text-red-600">
|
||||
{uploadError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Discussion Section (hidden in create mode) */}
|
||||
{!isCreateMode && (
|
||||
<CollapsibleSection title={t('tasks.discussion')} noBorder>
|
||||
<div className="px-5 pb-5">
|
||||
<CommentsSection entityType="task" entityId={taskId} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
</SlidePanel>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => setShowDeleteConfirm(false)}
|
||||
title={t('tasks.deleteTask')}
|
||||
isConfirm
|
||||
danger
|
||||
confirmText={t('tasks.deleteTask')}
|
||||
onConfirm={confirmDelete}
|
||||
>
|
||||
{t('tasks.deleteConfirm')}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
468
client/src/components/TeamMemberPanel.jsx
Normal file
468
client/src/components/TeamMemberPanel.jsx
Normal file
@@ -0,0 +1,468 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { X, Trash2, ChevronDown, Check } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api } from '../utils/api'
|
||||
import Modal from './Modal'
|
||||
import SlidePanel from './SlidePanel'
|
||||
import CollapsibleSection from './CollapsibleSection'
|
||||
import StatusBadge from './StatusBadge'
|
||||
|
||||
const 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' },
|
||||
]
|
||||
|
||||
const ALL_MODULES = ['marketing', 'projects', 'finance']
|
||||
const MODULE_LABELS = { marketing: 'Marketing', projects: 'Projects', finance: 'Finance' }
|
||||
const MODULE_COLORS = {
|
||||
marketing: { on: 'bg-emerald-100 text-emerald-700 border-emerald-300', off: 'bg-gray-100 text-gray-400 border-gray-200' },
|
||||
projects: { on: 'bg-blue-100 text-blue-700 border-blue-300', off: 'bg-gray-100 text-gray-400 border-gray-200' },
|
||||
finance: { on: 'bg-amber-100 text-amber-700 border-amber-300', off: 'bg-gray-100 text-gray-400 border-gray-200' },
|
||||
}
|
||||
|
||||
export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave, onDelete, canManageTeam, userRole, teams, brands: brandsList }) {
|
||||
const { t, lang } = useLanguage()
|
||||
const [form, setForm] = useState({})
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [showBrandsDropdown, setShowBrandsDropdown] = useState(false)
|
||||
const brandsDropdownRef = useRef(null)
|
||||
|
||||
// Workload state (loaded internally)
|
||||
const [memberTasks, setMemberTasks] = useState([])
|
||||
const [memberPosts, setMemberPosts] = useState([])
|
||||
const [loadingWorkload, setLoadingWorkload] = useState(false)
|
||||
|
||||
const memberId = member?._id || member?.id
|
||||
const isCreateMode = !memberId
|
||||
|
||||
useEffect(() => {
|
||||
if (member) {
|
||||
setForm({
|
||||
name: member.name || '',
|
||||
email: member.email || '',
|
||||
password: '',
|
||||
role: member.team_role || member.role || 'content_writer',
|
||||
brands: Array.isArray(member.brands) ? member.brands : [],
|
||||
phone: member.phone || '',
|
||||
modules: Array.isArray(member.modules) ? member.modules : ALL_MODULES,
|
||||
team_ids: Array.isArray(member.teams) ? member.teams.map(t => t.id) : [],
|
||||
})
|
||||
setDirty(isCreateMode)
|
||||
if (!isCreateMode) loadWorkload()
|
||||
}
|
||||
}, [member])
|
||||
|
||||
const loadWorkload = async () => {
|
||||
if (!memberId) return
|
||||
setLoadingWorkload(true)
|
||||
try {
|
||||
const [tasksRes, postsRes] = await Promise.allSettled([
|
||||
api.get(`/tasks?assignedTo=${memberId}`),
|
||||
api.get(`/posts?assignedTo=${memberId}`),
|
||||
])
|
||||
setMemberTasks(tasksRes.status === 'fulfilled' ? (tasksRes.value.data || tasksRes.value || []) : [])
|
||||
setMemberPosts(postsRes.status === 'fulfilled' ? (postsRes.value.data || postsRes.value || []) : [])
|
||||
} catch {
|
||||
setMemberTasks([])
|
||||
setMemberPosts([])
|
||||
} finally {
|
||||
setLoadingWorkload(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!member) return null
|
||||
|
||||
const update = (field, value) => {
|
||||
setForm(f => ({ ...f, [field]: value }))
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
// Close brands dropdown on outside click
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e) => {
|
||||
if (brandsDropdownRef.current && !brandsDropdownRef.current.contains(e.target)) {
|
||||
setShowBrandsDropdown(false)
|
||||
}
|
||||
}
|
||||
if (showBrandsDropdown) document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [showBrandsDropdown])
|
||||
|
||||
const toggleBrand = (brandName) => {
|
||||
const current = form.brands || []
|
||||
update('brands', current.includes(brandName)
|
||||
? current.filter(b => b !== brandName)
|
||||
: [...current, brandName]
|
||||
)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSave(isCreateMode ? null : memberId, {
|
||||
name: form.name,
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
role: form.role,
|
||||
brands: form.brands || [],
|
||||
phone: form.phone,
|
||||
modules: form.modules,
|
||||
team_ids: form.team_ids,
|
||||
}, isEditingSelf)
|
||||
setDirty(false)
|
||||
if (isCreateMode) onClose()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
setShowDeleteConfirm(false)
|
||||
await onDelete(memberId)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const initials = member.name?.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase() || '?'
|
||||
const roleName = (form.role || '').replace(/_/g, ' ')
|
||||
const todoCount = memberTasks.filter(t => t.status === 'todo').length
|
||||
const inProgressCount = memberTasks.filter(t => t.status === 'in_progress').length
|
||||
const doneCount = memberTasks.filter(t => t.status === 'done').length
|
||||
|
||||
const header = (
|
||||
<div className="px-5 py-4 border-b border-border shrink-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-indigo-400 to-purple-500 flex items-center justify-center text-white text-sm font-bold shrink-0">
|
||||
{initials}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={e => update('name', 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('team.fullName')}
|
||||
/>
|
||||
<span className="text-[11px] px-2 py-0.5 rounded-full font-medium bg-brand-primary/10 text-brand-primary capitalize">
|
||||
{roleName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SlidePanel onClose={onClose} maxWidth="420px" header={header}>
|
||||
{/* Details Section */}
|
||||
<CollapsibleSection title={t('team.details')}>
|
||||
<div className="px-5 pb-4 space-y-3">
|
||||
{!isEditingSelf && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.email')} *</label>
|
||||
<input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={e => update('email', 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="email@example.com"
|
||||
disabled={!isCreateMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isCreateMode && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.password')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={e => update('password', 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="••••••••"
|
||||
/>
|
||||
{!form.password && (
|
||||
<p className="text-xs text-text-tertiary mt-1">{t('team.defaultPassword')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.teamRole')}</label>
|
||||
{userRole === 'manager' && isCreateMode && !isEditingSelf ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value="Contributor"
|
||||
disabled
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface-tertiary text-text-tertiary cursor-not-allowed"
|
||||
/>
|
||||
<p className="text-xs text-text-tertiary mt-1">{t('team.fixedRole')}</p>
|
||||
</>
|
||||
) : (
|
||||
<select
|
||||
value={form.role}
|
||||
onChange={e => update('role', 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"
|
||||
>
|
||||
{ROLES.map(r => <option key={r.value} value={r.value}>{r.label}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.phone')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.phone}
|
||||
onChange={e => update('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"
|
||||
placeholder="+966 ..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={brandsDropdownRef} className="relative">
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.brands')}</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowBrandsDropdown(prev => !prev)}
|
||||
className="w-full flex items-center justify-between 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 bg-white text-left"
|
||||
>
|
||||
<span className={`flex-1 truncate ${(form.brands || []).length === 0 ? 'text-text-tertiary' : 'text-text-primary'}`}>
|
||||
{(form.brands || []).length === 0
|
||||
? t('team.selectBrands')
|
||||
: (form.brands || []).join(', ')
|
||||
}
|
||||
</span>
|
||||
<ChevronDown className={`w-4 h-4 text-text-tertiary shrink-0 transition-transform ${showBrandsDropdown ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* Selected brand chips */}
|
||||
{(form.brands || []).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{(form.brands || []).map(b => (
|
||||
<span
|
||||
key={b}
|
||||
className="inline-flex items-center gap-1 text-[10px] px-2 py-0.5 rounded-full bg-brand-primary/10 text-brand-primary font-medium"
|
||||
>
|
||||
{b}
|
||||
<button type="button" onClick={() => toggleBrand(b)} className="hover:text-red-500">
|
||||
<X className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dropdown */}
|
||||
{showBrandsDropdown && (
|
||||
<div className="absolute z-20 mt-1 w-full bg-white border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
|
||||
{brandsList && brandsList.length > 0 ? (
|
||||
brandsList.map(brand => {
|
||||
const name = lang === 'ar' && brand.name_ar ? brand.name_ar : brand.name
|
||||
const checked = (form.brands || []).includes(name)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={brand.id || brand._id}
|
||||
onClick={() => toggleBrand(name)}
|
||||
className={`w-full flex items-center gap-2.5 px-3 py-2 cursor-pointer hover:bg-surface-secondary transition-colors text-left ${checked ? 'bg-brand-primary/5' : ''}`}
|
||||
>
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors ${
|
||||
checked ? 'bg-brand-primary border-brand-primary' : 'border-border'
|
||||
}`}>
|
||||
{checked && <Check className="w-3 h-3 text-white" />}
|
||||
</div>
|
||||
<span className="text-sm text-text-primary">{brand.icon ? `${brand.icon} ` : ''}{name}</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<p className="px-3 py-3 text-xs text-text-tertiary text-center">{t('brands.noBrands')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modules toggle */}
|
||||
{!isEditingSelf && canManageTeam && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.modules')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ALL_MODULES.map(mod => {
|
||||
const active = (form.modules || []).includes(mod)
|
||||
const colors = MODULE_COLORS[mod]
|
||||
return (
|
||||
<button
|
||||
key={mod}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
update('modules', active
|
||||
? form.modules.filter(m => m !== mod)
|
||||
: [...(form.modules || []), mod]
|
||||
)
|
||||
}}
|
||||
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${active ? colors.on : colors.off}`}
|
||||
>
|
||||
{MODULE_LABELS[mod]}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Teams multi-select */}
|
||||
{teams && teams.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('teams.teams')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{teams.map(team => {
|
||||
const active = (form.team_ids || []).includes(team.id || team._id)
|
||||
const teamId = team.id || team._id
|
||||
return (
|
||||
<button
|
||||
key={teamId}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
update('team_ids', active
|
||||
? form.team_ids.filter(id => id !== teamId)
|
||||
: [...(form.team_ids || []), teamId]
|
||||
)
|
||||
}}
|
||||
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${
|
||||
active
|
||||
? 'bg-blue-100 text-blue-700 border-blue-300'
|
||||
: 'bg-gray-100 text-gray-400 border-gray-200'
|
||||
}`}
|
||||
>
|
||||
{team.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!form.name || (!isEditingSelf && isCreateMode && !form.email) || saving}
|
||||
className={`flex-1 px-4 py-2 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 ${saving ? 'btn-loading' : ''}`}
|
||||
>
|
||||
{isEditingSelf ? t('team.saveProfile') : (isCreateMode ? t('team.addMember') : t('team.saveChanges'))}
|
||||
</button>
|
||||
)}
|
||||
{!isCreateMode && !isEditingSelf && canManageTeam && onDelete && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title={t('team.remove')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Workload Section (hidden in create mode) */}
|
||||
{!isCreateMode && (
|
||||
<CollapsibleSection title={t('team.workload')} noBorder>
|
||||
<div className="px-5 pb-4 space-y-3">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div className="bg-surface-secondary rounded-lg p-2 text-center">
|
||||
<p className="text-lg font-bold text-text-primary">{memberTasks.length}</p>
|
||||
<p className="text-[10px] text-text-tertiary">{t('team.totalTasks')}</p>
|
||||
</div>
|
||||
<div className="bg-surface-secondary rounded-lg p-2 text-center">
|
||||
<p className="text-lg font-bold text-amber-500">{todoCount}</p>
|
||||
<p className="text-[10px] text-text-tertiary">{t('team.toDo')}</p>
|
||||
</div>
|
||||
<div className="bg-surface-secondary rounded-lg p-2 text-center">
|
||||
<p className="text-lg font-bold text-blue-500">{inProgressCount}</p>
|
||||
<p className="text-[10px] text-text-tertiary">{t('team.inProgress')}</p>
|
||||
</div>
|
||||
<div className="bg-surface-secondary rounded-lg p-2 text-center">
|
||||
<p className="text-lg font-bold text-emerald-500">{doneCount}</p>
|
||||
<p className="text-[10px] text-text-tertiary">{t('tasks.done')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent tasks */}
|
||||
{memberTasks.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-text-tertiary mb-2">{t('team.recentTasks')}</h4>
|
||||
<div className="space-y-1 max-h-40 overflow-y-auto">
|
||||
{memberTasks.slice(0, 8).map(task => (
|
||||
<div key={task._id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-surface-secondary">
|
||||
<span className={`text-xs flex-1 min-w-0 truncate ${task.status === 'done' ? 'text-text-tertiary line-through' : 'text-text-primary'}`}>
|
||||
{task.title}
|
||||
</span>
|
||||
<StatusBadge status={task.status} size="xs" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent posts */}
|
||||
{memberPosts.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-text-tertiary mb-2">{t('team.recentPosts')}</h4>
|
||||
<div className="space-y-1 max-h-40 overflow-y-auto">
|
||||
{memberPosts.slice(0, 8).map(post => (
|
||||
<div key={post._id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-surface-secondary">
|
||||
<span className="text-xs text-text-primary flex-1 min-w-0 truncate">{post.title}</span>
|
||||
<StatusBadge status={post.status} size="xs" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadingWorkload && (
|
||||
<p className="text-xs text-text-tertiary text-center py-2">{t('common.loading')}</p>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
</SlidePanel>
|
||||
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => setShowDeleteConfirm(false)}
|
||||
title={t('team.removeMember')}
|
||||
isConfirm
|
||||
danger
|
||||
confirmText={t('team.remove')}
|
||||
onConfirm={confirmDelete}
|
||||
>
|
||||
{t('team.removeConfirm', { name: member?.name })}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
199
client/src/components/TeamPanel.jsx
Normal file
199
client/src/components/TeamPanel.jsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, Trash2, Search } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { getInitials } from '../utils/api'
|
||||
import Modal from './Modal'
|
||||
import SlidePanel from './SlidePanel'
|
||||
import CollapsibleSection from './CollapsibleSection'
|
||||
|
||||
export default function TeamPanel({ team, onClose, onSave, onDelete, teamMembers }) {
|
||||
const { t } = useLanguage()
|
||||
const [form, setForm] = useState({ name: '', description: '', member_ids: [] })
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [memberSearch, setMemberSearch] = useState('')
|
||||
|
||||
const teamId = team?.id || team?._id
|
||||
const isCreateMode = !teamId
|
||||
|
||||
useEffect(() => {
|
||||
if (team) {
|
||||
setForm({
|
||||
name: team.name || '',
|
||||
description: team.description || '',
|
||||
member_ids: team.member_ids || [],
|
||||
})
|
||||
setDirty(isCreateMode)
|
||||
}
|
||||
}, [team])
|
||||
|
||||
if (!team) return null
|
||||
|
||||
const update = (field, value) => {
|
||||
setForm(f => ({ ...f, [field]: value }))
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const toggleMember = (userId) => {
|
||||
const ids = form.member_ids || []
|
||||
update('member_ids', ids.includes(userId)
|
||||
? ids.filter(id => id !== userId)
|
||||
: [...ids, userId]
|
||||
)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSave(isCreateMode ? null : teamId, {
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
member_ids: form.member_ids,
|
||||
})
|
||||
setDirty(false)
|
||||
if (isCreateMode) onClose()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
setShowDeleteConfirm(false)
|
||||
await onDelete(teamId)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const filteredMembers = (teamMembers || []).filter(m =>
|
||||
!memberSearch || m.name?.toLowerCase().includes(memberSearch.toLowerCase())
|
||||
)
|
||||
|
||||
const header = (
|
||||
<div className="px-5 py-4 border-b border-border shrink-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={e => update('name', 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('teams.name')}
|
||||
/>
|
||||
<span className="text-[11px] px-2 py-0.5 rounded-full font-medium bg-blue-100 text-blue-700">
|
||||
{(form.member_ids || []).length} {t('teams.members')}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SlidePanel onClose={onClose} maxWidth="420px" header={header}>
|
||||
<CollapsibleSection title={t('teams.details')}>
|
||||
<div className="px-5 pb-4 space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('teams.name')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={e => update('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('teams.name')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('teams.description')}</label>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={e => update('description', e.target.value)}
|
||||
rows={3}
|
||||
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 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!form.name || saving}
|
||||
className={`flex-1 px-4 py-2 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 ${saving ? 'btn-loading' : ''}`}
|
||||
>
|
||||
{isCreateMode ? t('teams.createTeam') : t('common.save')}
|
||||
</button>
|
||||
)}
|
||||
{!isCreateMode && onDelete && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title={t('teams.deleteTeam')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title={t('teams.members')} noBorder>
|
||||
<div className="px-5 pb-4">
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-tertiary" />
|
||||
<input
|
||||
type="text"
|
||||
value={memberSearch}
|
||||
onChange={e => setMemberSearch(e.target.value)}
|
||||
placeholder={t('teams.selectMembers')}
|
||||
className="w-full pl-9 pr-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"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 max-h-80 overflow-y-auto">
|
||||
{filteredMembers.map(m => {
|
||||
const uid = m.id || m._id
|
||||
const checked = (form.member_ids || []).includes(uid)
|
||||
return (
|
||||
<label
|
||||
key={uid}
|
||||
className={`flex items-center gap-3 p-2 rounded-lg cursor-pointer hover:bg-surface-secondary ${checked ? 'bg-blue-50' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleMember(uid)}
|
||||
className="rounded border-border text-brand-primary focus:ring-brand-primary"
|
||||
/>
|
||||
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-indigo-400 to-purple-500 flex items-center justify-center text-white text-[10px] font-bold shrink-0">
|
||||
{getInitials(m.name)}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-text-primary">{m.name}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
{filteredMembers.length === 0 && (
|
||||
<p className="text-xs text-text-tertiary text-center py-4">{t('common.noResults')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</SlidePanel>
|
||||
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => setShowDeleteConfirm(false)}
|
||||
title={t('teams.deleteTeam')}
|
||||
isConfirm
|
||||
danger
|
||||
confirmText={t('teams.deleteTeam')}
|
||||
onConfirm={confirmDelete}
|
||||
>
|
||||
{t('teams.deleteConfirm')}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -76,6 +76,15 @@ export function AuthProvider({ children }) {
|
||||
return false
|
||||
}
|
||||
|
||||
const ALL_MODULES = ['marketing', 'projects', 'finance']
|
||||
|
||||
const hasModule = (mod) => {
|
||||
if (!user) return false
|
||||
if (user.role === 'superadmin') return true
|
||||
const userModules = Array.isArray(user.modules) ? user.modules : ALL_MODULES
|
||||
return userModules.includes(mod)
|
||||
}
|
||||
|
||||
const canDeleteResource = (type, resource) => {
|
||||
if (!permissions) return false
|
||||
if (type === 'post') return permissions.canDeleteAnyPost || isOwner(resource) || isAssignedTo(resource)
|
||||
@@ -90,6 +99,7 @@ export function AuthProvider({ children }) {
|
||||
user, loading, permissions,
|
||||
login, logout, checkAuth,
|
||||
isOwner, canEditResource, canDeleteResource,
|
||||
hasModule,
|
||||
}}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
"nav.logout": "تسجيل الخروج",
|
||||
"nav.brands": "العلامات التجارية",
|
||||
"nav.collapse": "طي",
|
||||
|
||||
"common.save": "حفظ",
|
||||
"common.cancel": "إلغاء",
|
||||
"common.delete": "حذف",
|
||||
@@ -33,13 +32,11 @@
|
||||
"common.updateFailed": "فشل التحديث. حاول مجدداً.",
|
||||
"common.deleteFailed": "فشل الحذف. حاول مجدداً.",
|
||||
"common.clearFilters": "مسح الفلاتر",
|
||||
|
||||
"auth.login": "تسجيل الدخول",
|
||||
"auth.email": "البريد الإلكتروني",
|
||||
"auth.password": "كلمة المرور",
|
||||
"auth.loginBtn": "دخول",
|
||||
"auth.signingIn": "جاري تسجيل الدخول...",
|
||||
|
||||
"dashboard.title": "لوحة التحكم",
|
||||
"dashboard.welcomeBack": "مرحباً بعودتك",
|
||||
"dashboard.happeningToday": "إليك ما يحدث مع تسويقك اليوم.",
|
||||
@@ -70,7 +67,6 @@
|
||||
"dashboard.upcomingDeadlines": "المواعيد النهائية القادمة",
|
||||
"dashboard.noUpcomingDeadlines": "لا توجد مواعيد نهائية هذا الأسبوع. 🎉",
|
||||
"dashboard.loadingHub": "جاري تحميل المركز الرقمي...",
|
||||
|
||||
"posts.title": "إنتاج المحتوى",
|
||||
"posts.newPost": "منشور جديد",
|
||||
"posts.editPost": "تعديل المنشور",
|
||||
@@ -126,13 +122,11 @@
|
||||
"posts.periodFrom": "من",
|
||||
"posts.periodTo": "إلى",
|
||||
"posts.tryDifferentFilter": "جرب تعديل الفلاتر لرؤية المزيد من المنشورات.",
|
||||
|
||||
"posts.status.draft": "مسودة",
|
||||
"posts.status.in_review": "قيد المراجعة",
|
||||
"posts.status.approved": "مُعتمد",
|
||||
"posts.status.scheduled": "مجدول",
|
||||
"posts.status.published": "منشور",
|
||||
|
||||
"tasks.title": "المهام",
|
||||
"tasks.newTask": "مهمة جديدة",
|
||||
"tasks.editTask": "تعديل المهمة",
|
||||
@@ -163,7 +157,6 @@
|
||||
"tasks.task": "مهمة",
|
||||
"tasks.tasks": "مهام",
|
||||
"tasks.of": "من",
|
||||
|
||||
"tasks.priority.low": "منخفض",
|
||||
"tasks.priority.medium": "متوسط",
|
||||
"tasks.priority.high": "عالي",
|
||||
@@ -206,12 +199,10 @@
|
||||
"tasks.removeThumbnail": "إزالة الصورة المصغرة",
|
||||
"tasks.thumbnail": "الصورة المصغرة",
|
||||
"tasks.dropOrClick": "اسحب ملفاً أو انقر للرفع",
|
||||
|
||||
"projects.thumbnail": "الصورة المصغرة",
|
||||
"projects.uploadThumbnail": "رفع صورة مصغرة",
|
||||
"projects.changeThumbnail": "تغيير الصورة المصغرة",
|
||||
"projects.removeThumbnail": "إزالة الصورة المصغرة",
|
||||
|
||||
"team.title": "الفريق",
|
||||
"team.members": "أعضاء الفريق",
|
||||
"team.addMember": "إضافة عضو",
|
||||
@@ -243,15 +234,12 @@
|
||||
"team.noTasks": "لا توجد مهام",
|
||||
"team.toDo": "للتنفيذ",
|
||||
"team.inProgress": "قيد التنفيذ",
|
||||
|
||||
"campaigns.title": "الحملات",
|
||||
"campaigns.newCampaign": "حملة جديدة",
|
||||
"campaigns.noCampaigns": "لا توجد حملات",
|
||||
|
||||
"assets.title": "الأصول",
|
||||
"assets.upload": "رفع",
|
||||
"assets.noAssets": "لا توجد أصول",
|
||||
|
||||
"brands.title": "العلامات التجارية",
|
||||
"brands.addBrand": "إضافة علامة",
|
||||
"brands.editBrand": "تعديل العلامة",
|
||||
@@ -266,7 +254,6 @@
|
||||
"brands.uploadLogo": "رفع الشعار",
|
||||
"brands.changeLogo": "تغيير الشعار",
|
||||
"brands.manageBrands": "إدارة العلامات التجارية لمؤسستك",
|
||||
|
||||
"settings.title": "الإعدادات",
|
||||
"settings.language": "اللغة",
|
||||
"settings.english": "English",
|
||||
@@ -294,7 +281,6 @@
|
||||
"settings.currency": "العملة",
|
||||
"settings.currencyHint": "ستُستخدم هذه العملة في جميع الصفحات المالية.",
|
||||
"settings.preferences": "إدارة تفضيلاتك وإعدادات التطبيق",
|
||||
|
||||
"tutorial.skip": "تخطي",
|
||||
"tutorial.next": "التالي",
|
||||
"tutorial.prev": "السابق",
|
||||
@@ -317,12 +303,10 @@
|
||||
"tutorial.newPost.desc": "ابدأ إنشاء المحتوى من هنا. اختر علامتك التجارية والمنصات وأسنده لعضو فريق.",
|
||||
"tutorial.filters.title": "التصفية والتركيز",
|
||||
"tutorial.filters.desc": "استخدم الفلاتر للتركيز على علامات أو منصات أو أعضاء فريق محددين.",
|
||||
|
||||
"login.title": "المركز الرقمي",
|
||||
"login.subtitle": "سجل دخولك للمتابعة",
|
||||
"login.forgotPassword": "نسيت كلمة المرور؟",
|
||||
"login.defaultCreds": "بيانات الدخول الافتراضية:",
|
||||
|
||||
"comments.title": "النقاش",
|
||||
"comments.noComments": "لا توجد تعليقات بعد. ابدأ المحادثة.",
|
||||
"comments.placeholder": "اكتب تعليقاً...",
|
||||
@@ -330,12 +314,10 @@
|
||||
"comments.minutesAgo": "منذ {n} دقيقة",
|
||||
"comments.hoursAgo": "منذ {n} ساعة",
|
||||
"comments.daysAgo": "منذ {n} يوم",
|
||||
|
||||
"profile.completeYourProfile": "أكمل ملفك الشخصي",
|
||||
"profile.completeDesc": "يرجى إكمال ملفك الشخصي للوصول إلى جميع الميزات ومساعدة فريقك في العثور عليك.",
|
||||
"profile.completeProfileBtn": "إكمال الملف",
|
||||
"profile.later": "لاحقاً",
|
||||
|
||||
"timeline.title": "الجدول الزمني",
|
||||
"timeline.day": "يوم",
|
||||
"timeline.week": "أسبوع",
|
||||
@@ -347,11 +329,9 @@
|
||||
"timeline.addItems": "أضف عناصر بتواريخ لعرض الجدول الزمني",
|
||||
"timeline.tracks": "المسارات",
|
||||
"timeline.timeline": "الجدول الزمني",
|
||||
|
||||
"posts.details": "التفاصيل",
|
||||
"posts.platformsLinks": "المنصات والروابط",
|
||||
"posts.discussion": "النقاش",
|
||||
|
||||
"campaigns.details": "التفاصيل",
|
||||
"campaigns.performance": "الأداء",
|
||||
"campaigns.discussion": "النقاش",
|
||||
@@ -374,7 +354,6 @@
|
||||
"campaigns.editCampaign": "تعديل الحملة",
|
||||
"campaigns.deleteCampaign": "حذف الحملة؟",
|
||||
"campaigns.deleteConfirm": "هل أنت متأكد من حذف هذه الحملة؟ سيتم حذف جميع البيانات المرتبطة. لا يمكن التراجع.",
|
||||
|
||||
"tracks.details": "التفاصيل",
|
||||
"tracks.metrics": "المقاييس",
|
||||
"tracks.trackName": "اسم المسار",
|
||||
@@ -389,7 +368,6 @@
|
||||
"tracks.editTrack": "تعديل المسار",
|
||||
"tracks.deleteTrack": "حذف المسار؟",
|
||||
"tracks.deleteConfirm": "هل أنت متأكد من حذف هذا المسار؟ لا يمكن التراجع.",
|
||||
|
||||
"projects.details": "التفاصيل",
|
||||
"projects.discussion": "النقاش",
|
||||
"projects.name": "الاسم",
|
||||
@@ -402,7 +380,6 @@
|
||||
"projects.editProject": "تعديل المشروع",
|
||||
"projects.deleteProject": "حذف المشروع؟",
|
||||
"projects.deleteConfirm": "هل أنت متأكد من حذف هذا المشروع؟ لا يمكن التراجع.",
|
||||
|
||||
"team.details": "التفاصيل",
|
||||
"team.workload": "عبء العمل",
|
||||
"team.recentTasks": "المهام الأخيرة",
|
||||
@@ -412,11 +389,9 @@
|
||||
"team.gridView": "عرض الشبكة",
|
||||
"team.teamsView": "عرض الفرق",
|
||||
"team.unassigned": "غير مُعيّن",
|
||||
|
||||
"modules.marketing": "التسويق",
|
||||
"modules.projects": "المشاريع",
|
||||
"modules.finance": "المالية",
|
||||
|
||||
"teams.title": "الفرق",
|
||||
"teams.teams": "الفرق",
|
||||
"teams.createTeam": "إنشاء فريق",
|
||||
@@ -429,7 +404,6 @@
|
||||
"teams.details": "التفاصيل",
|
||||
"teams.noTeams": "لا توجد فرق بعد",
|
||||
"teams.selectMembers": "بحث عن أعضاء...",
|
||||
|
||||
"dates.today": "اليوم",
|
||||
"dates.yesterday": "أمس",
|
||||
"dates.thisWeek": "هذا الأسبوع",
|
||||
@@ -440,16 +414,13 @@
|
||||
"dates.thisYear": "هذا العام",
|
||||
"dates.customRange": "نطاق مخصص",
|
||||
"dates.clearDates": "مسح التواريخ",
|
||||
|
||||
"dashboard.myTasks": "مهامي",
|
||||
"dashboard.projectProgress": "تقدم المشاريع",
|
||||
"dashboard.noProjectsYet": "لا توجد مشاريع بعد",
|
||||
|
||||
"finance.project": "المشروع",
|
||||
"finance.projectBudget": "ميزانية المشروع",
|
||||
"finance.projectBreakdown": "توزيع المشاريع",
|
||||
"finance.budgetFor": "ميزانية لـ",
|
||||
|
||||
"budgets.title": "الميزانيات",
|
||||
"budgets.subtitle": "إضافة وإدارة سجلات الميزانية — تتبع المصدر والوجهة والتخصيص",
|
||||
"budgets.addEntry": "إضافة سجل",
|
||||
@@ -487,7 +458,13 @@
|
||||
"budgets.allTypes": "الكل",
|
||||
"budgets.net": "صافي",
|
||||
"budgets.dateExpensed": "التاريخ",
|
||||
|
||||
"dashboard.expenses": "المصروفات",
|
||||
"finance.expenses": "إجمالي المصروفات"
|
||||
}
|
||||
"finance.expenses": "إجمالي المصروفات",
|
||||
"settings.uploads": "الرفع",
|
||||
"settings.maxFileSize": "الحد الأقصى لحجم الملف",
|
||||
"settings.maxFileSizeHint": "الحد الأقصى المسموح لحجم المرفقات (١-٥٠٠ ميجابايت)",
|
||||
"settings.mb": "ميجابايت",
|
||||
"settings.saved": "تم حفظ الإعدادات!",
|
||||
"tasks.maxFileSize": "الحد الأقصى: {size} ميجابايت",
|
||||
"tasks.fileTooLarge": "الملف \"{name}\" كبير جداً ({size} ميجابايت). الحد المسموح: {max} ميجابايت."
|
||||
}
|
||||
@@ -16,7 +16,6 @@
|
||||
"nav.logout": "Logout",
|
||||
"nav.brands": "Brands",
|
||||
"nav.collapse": "Collapse",
|
||||
|
||||
"common.save": "Save",
|
||||
"common.cancel": "Cancel",
|
||||
"common.delete": "Delete",
|
||||
@@ -33,13 +32,11 @@
|
||||
"common.updateFailed": "Failed to update. Please try again.",
|
||||
"common.deleteFailed": "Failed to delete. Please try again.",
|
||||
"common.clearFilters": "Clear Filters",
|
||||
|
||||
"auth.login": "Sign In",
|
||||
"auth.email": "Email",
|
||||
"auth.password": "Password",
|
||||
"auth.loginBtn": "Sign In",
|
||||
"auth.signingIn": "Signing in...",
|
||||
|
||||
"dashboard.title": "Dashboard",
|
||||
"dashboard.welcomeBack": "Welcome back",
|
||||
"dashboard.happeningToday": "Here's what's happening with your marketing today.",
|
||||
@@ -70,7 +67,6 @@
|
||||
"dashboard.upcomingDeadlines": "Upcoming Deadlines",
|
||||
"dashboard.noUpcomingDeadlines": "No upcoming deadlines this week. 🎉",
|
||||
"dashboard.loadingHub": "Loading Digital Hub...",
|
||||
|
||||
"posts.title": "Post Production",
|
||||
"posts.newPost": "New Post",
|
||||
"posts.editPost": "Edit Post",
|
||||
@@ -126,13 +122,11 @@
|
||||
"posts.periodFrom": "From",
|
||||
"posts.periodTo": "To",
|
||||
"posts.tryDifferentFilter": "Try adjusting your filters to see more posts.",
|
||||
|
||||
"posts.status.draft": "Draft",
|
||||
"posts.status.in_review": "In Review",
|
||||
"posts.status.approved": "Approved",
|
||||
"posts.status.scheduled": "Scheduled",
|
||||
"posts.status.published": "Published",
|
||||
|
||||
"tasks.title": "Tasks",
|
||||
"tasks.newTask": "New Task",
|
||||
"tasks.editTask": "Edit Task",
|
||||
@@ -163,7 +157,6 @@
|
||||
"tasks.task": "task",
|
||||
"tasks.tasks": "tasks",
|
||||
"tasks.of": "of",
|
||||
|
||||
"tasks.priority.low": "Low",
|
||||
"tasks.priority.medium": "Medium",
|
||||
"tasks.priority.high": "High",
|
||||
@@ -206,12 +199,10 @@
|
||||
"tasks.removeThumbnail": "Remove thumbnail",
|
||||
"tasks.thumbnail": "Thumbnail",
|
||||
"tasks.dropOrClick": "Drop file or click to upload",
|
||||
|
||||
"projects.thumbnail": "Thumbnail",
|
||||
"projects.uploadThumbnail": "Upload Thumbnail",
|
||||
"projects.changeThumbnail": "Change Thumbnail",
|
||||
"projects.removeThumbnail": "Remove Thumbnail",
|
||||
|
||||
"team.title": "Team",
|
||||
"team.members": "Team Members",
|
||||
"team.addMember": "Add Member",
|
||||
@@ -243,15 +234,12 @@
|
||||
"team.noTasks": "No tasks",
|
||||
"team.toDo": "To Do",
|
||||
"team.inProgress": "In Progress",
|
||||
|
||||
"campaigns.title": "Campaigns",
|
||||
"campaigns.newCampaign": "New Campaign",
|
||||
"campaigns.noCampaigns": "No campaigns",
|
||||
|
||||
"assets.title": "Assets",
|
||||
"assets.upload": "Upload",
|
||||
"assets.noAssets": "No assets",
|
||||
|
||||
"brands.title": "Brands",
|
||||
"brands.addBrand": "Add Brand",
|
||||
"brands.editBrand": "Edit Brand",
|
||||
@@ -266,7 +254,6 @@
|
||||
"brands.uploadLogo": "Upload Logo",
|
||||
"brands.changeLogo": "Change Logo",
|
||||
"brands.manageBrands": "Manage your organization's brands",
|
||||
|
||||
"settings.title": "Settings",
|
||||
"settings.language": "Language",
|
||||
"settings.english": "English",
|
||||
@@ -294,7 +281,6 @@
|
||||
"settings.currency": "Currency",
|
||||
"settings.currencyHint": "This currency will be used across all financial pages.",
|
||||
"settings.preferences": "Manage your preferences and app settings",
|
||||
|
||||
"tutorial.skip": "Skip Tutorial",
|
||||
"tutorial.next": "Next",
|
||||
"tutorial.prev": "Back",
|
||||
@@ -317,12 +303,10 @@
|
||||
"tutorial.newPost.desc": "Start creating content here. Pick your brand, platforms, and assign it to a team member.",
|
||||
"tutorial.filters.title": "Filter & Focus",
|
||||
"tutorial.filters.desc": "Use filters to focus on specific brands, platforms, or team members.",
|
||||
|
||||
"login.title": "Digital Hub",
|
||||
"login.subtitle": "Sign in to continue",
|
||||
"login.forgotPassword": "Forgot password?",
|
||||
"login.defaultCreds": "Default credentials:",
|
||||
|
||||
"comments.title": "Discussion",
|
||||
"comments.noComments": "No comments yet. Start the conversation.",
|
||||
"comments.placeholder": "Write a comment...",
|
||||
@@ -330,12 +314,10 @@
|
||||
"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",
|
||||
"profile.later": "Later",
|
||||
|
||||
"timeline.title": "Timeline",
|
||||
"timeline.day": "Day",
|
||||
"timeline.week": "Week",
|
||||
@@ -347,11 +329,9 @@
|
||||
"timeline.addItems": "Add items with dates to see the timeline",
|
||||
"timeline.tracks": "Tracks",
|
||||
"timeline.timeline": "Timeline",
|
||||
|
||||
"posts.details": "Details",
|
||||
"posts.platformsLinks": "Platforms & Links",
|
||||
"posts.discussion": "Discussion",
|
||||
|
||||
"campaigns.details": "Details",
|
||||
"campaigns.performance": "Performance",
|
||||
"campaigns.discussion": "Discussion",
|
||||
@@ -374,7 +354,6 @@
|
||||
"campaigns.editCampaign": "Edit Campaign",
|
||||
"campaigns.deleteCampaign": "Delete Campaign?",
|
||||
"campaigns.deleteConfirm": "Are you sure you want to delete this campaign? All associated data will be removed. This action cannot be undone.",
|
||||
|
||||
"tracks.details": "Details",
|
||||
"tracks.metrics": "Metrics",
|
||||
"tracks.trackName": "Track Name",
|
||||
@@ -389,7 +368,6 @@
|
||||
"tracks.editTrack": "Edit Track",
|
||||
"tracks.deleteTrack": "Delete Track?",
|
||||
"tracks.deleteConfirm": "Are you sure you want to delete this track? This action cannot be undone.",
|
||||
|
||||
"projects.details": "Details",
|
||||
"projects.discussion": "Discussion",
|
||||
"projects.name": "Name",
|
||||
@@ -402,7 +380,6 @@
|
||||
"projects.editProject": "Edit Project",
|
||||
"projects.deleteProject": "Delete Project?",
|
||||
"projects.deleteConfirm": "Are you sure you want to delete this project? This action cannot be undone.",
|
||||
|
||||
"team.details": "Details",
|
||||
"team.workload": "Workload",
|
||||
"team.recentTasks": "Recent Tasks",
|
||||
@@ -412,11 +389,9 @@
|
||||
"team.gridView": "Grid View",
|
||||
"team.teamsView": "Teams View",
|
||||
"team.unassigned": "Unassigned",
|
||||
|
||||
"modules.marketing": "Marketing",
|
||||
"modules.projects": "Projects",
|
||||
"modules.finance": "Finance",
|
||||
|
||||
"teams.title": "Teams",
|
||||
"teams.teams": "Teams",
|
||||
"teams.createTeam": "Create Team",
|
||||
@@ -429,7 +404,6 @@
|
||||
"teams.details": "Details",
|
||||
"teams.noTeams": "No teams yet",
|
||||
"teams.selectMembers": "Search members...",
|
||||
|
||||
"dates.today": "Today",
|
||||
"dates.yesterday": "Yesterday",
|
||||
"dates.thisWeek": "This Week",
|
||||
@@ -440,16 +414,13 @@
|
||||
"dates.thisYear": "This Year",
|
||||
"dates.customRange": "Custom Range",
|
||||
"dates.clearDates": "Clear Dates",
|
||||
|
||||
"dashboard.myTasks": "My Tasks",
|
||||
"dashboard.projectProgress": "Project Progress",
|
||||
"dashboard.noProjectsYet": "No projects yet",
|
||||
|
||||
"finance.project": "Project",
|
||||
"finance.projectBudget": "Project Budget",
|
||||
"finance.projectBreakdown": "Project Breakdown",
|
||||
"finance.budgetFor": "Budget for",
|
||||
|
||||
"budgets.title": "Budgets",
|
||||
"budgets.subtitle": "Add and manage budget entries — track source, destination, and allocation",
|
||||
"budgets.addEntry": "Add Entry",
|
||||
@@ -487,7 +458,13 @@
|
||||
"budgets.allTypes": "All Types",
|
||||
"budgets.net": "Net",
|
||||
"budgets.dateExpensed": "Date",
|
||||
|
||||
"dashboard.expenses": "Expenses",
|
||||
"finance.expenses": "Total Expenses"
|
||||
}
|
||||
"finance.expenses": "Total Expenses",
|
||||
"settings.uploads": "Uploads",
|
||||
"settings.maxFileSize": "Maximum File Size",
|
||||
"settings.maxFileSizeHint": "Maximum allowed file size for attachments (1-500 MB)",
|
||||
"settings.mb": "MB",
|
||||
"settings.saved": "Settings saved!",
|
||||
"tasks.maxFileSize": "Max file size: {size} MB",
|
||||
"tasks.fileTooLarge": "File \"{name}\" is too large ({size} MB). Maximum allowed: {max} MB."
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import { useState, useEffect, useContext, useRef } from 'react'
|
||||
import { Plus, LayoutGrid, List, Search, Filter, Upload, X, Paperclip, Link2, FileText, Image as ImageIcon, Trash2, ExternalLink, FolderOpen } from 'lucide-react'
|
||||
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 StatusBadge from '../components/StatusBadge'
|
||||
import Modal from '../components/Modal'
|
||||
import CommentsSection from '../components/CommentsSection'
|
||||
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'
|
||||
@@ -22,29 +21,17 @@ const EMPTY_POST = {
|
||||
export default function PostProduction() {
|
||||
const { t, lang } = useLanguage()
|
||||
const { teamMembers, brands } = useContext(AppContext)
|
||||
const { canEditResource, canDeleteResource } = useAuth()
|
||||
const { canEditResource } = useAuth()
|
||||
const toast = useToast()
|
||||
const [posts, setPosts] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [view, setView] = useState('kanban')
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editingPost, setEditingPost] = useState(null)
|
||||
const [formData, setFormData] = useState(EMPTY_POST)
|
||||
const [panelPost, setPanelPost] = useState(null)
|
||||
const [campaigns, setCampaigns] = useState([])
|
||||
const [filters, setFilters] = useState({ brand: '', platform: '', assignedTo: '', campaign: '', periodFrom: '', periodTo: '' })
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [attachments, setAttachments] = useState([])
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const [publishError, setPublishError] = useState('')
|
||||
const [activePreset, setActivePreset] = 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(() => {
|
||||
loadPosts()
|
||||
@@ -62,61 +49,6 @@ export default function PostProduction() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setPublishError('')
|
||||
setSaving(true)
|
||||
try {
|
||||
const data = {
|
||||
title: formData.title,
|
||||
description: formData.description,
|
||||
brand_id: formData.brand_id ? Number(formData.brand_id) : null,
|
||||
assigned_to: formData.assigned_to ? Number(formData.assigned_to) : null,
|
||||
status: formData.status,
|
||||
platforms: formData.platforms || [],
|
||||
scheduled_date: formData.scheduled_date || null,
|
||||
notes: formData.notes,
|
||||
campaign_id: formData.campaign_id ? Number(formData.campaign_id) : null,
|
||||
publication_links: formData.publication_links || [],
|
||||
}
|
||||
|
||||
// Client-side validation: check publication links before publishing
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
if (editingPost) {
|
||||
await api.patch(`/posts/${editingPost._id}`, data)
|
||||
toast.success(t('posts.updated'))
|
||||
} else {
|
||||
await api.post('/posts', data)
|
||||
toast.success(t('posts.created'))
|
||||
}
|
||||
setShowModal(false)
|
||||
setEditingPost(null)
|
||||
setFormData(EMPTY_POST)
|
||||
setAttachments([])
|
||||
loadPosts()
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err)
|
||||
if (err.message?.includes('Cannot publish')) {
|
||||
setPublishError(err.message.replace(/.*: /, ''))
|
||||
} else {
|
||||
toast.error(t('common.saveFailed'))
|
||||
}
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMovePost = async (postId, newStatus) => {
|
||||
try {
|
||||
await api.patch(`/posts/${postId}`, { status: newStatus })
|
||||
@@ -134,123 +66,38 @@ export default function PostProduction() {
|
||||
}
|
||||
}
|
||||
|
||||
const loadAttachments = async (postId) => {
|
||||
try {
|
||||
const data = await api.get(`/posts/${postId}/attachments`)
|
||||
setAttachments(Array.isArray(data) ? data : (data.data || []))
|
||||
} catch (err) {
|
||||
console.error('Failed to load attachments:', err)
|
||||
setAttachments([])
|
||||
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 handleFileUpload = async (files) => {
|
||||
if (!editingPost || !files?.length) return
|
||||
setUploading(true)
|
||||
setUploadProgress(0)
|
||||
const postId = editingPost._id || editingPost.id
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', files[i])
|
||||
try {
|
||||
await api.upload(`/posts/${postId}/attachments`, fd)
|
||||
setUploadProgress(Math.round(((i + 1) / files.length) * 100))
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
setUploading(false)
|
||||
setUploadProgress(0)
|
||||
loadAttachments(postId)
|
||||
}
|
||||
|
||||
const handleDeleteAttachment = async (attachmentId) => {
|
||||
const handlePanelDelete = async (postId) => {
|
||||
try {
|
||||
await api.delete(`/attachments/${attachmentId}`)
|
||||
if (editingPost) loadAttachments(editingPost._id || editingPost.id)
|
||||
toast.success(t('posts.attachmentDeleted'))
|
||||
await api.delete(`/posts/${postId}`)
|
||||
toast.success(t('posts.deleted'))
|
||||
loadPosts()
|
||||
} catch (err) {
|
||||
console.error('Delete attachment failed:', err)
|
||||
console.error('Delete failed:', err)
|
||||
toast.error(t('common.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
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() }
|
||||
const handleDropFiles = (e) => {
|
||||
e.preventDefault(); e.stopPropagation(); setDragActive(false)
|
||||
if (e.dataTransfer.files?.length) handleFileUpload(e.dataTransfer.files)
|
||||
}
|
||||
|
||||
const updatePublicationLink = (platform, url) => {
|
||||
setFormData(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 }
|
||||
})
|
||||
}
|
||||
|
||||
const openEdit = (post) => {
|
||||
if (!canEditResource('post', post)) {
|
||||
alert('You can only edit your own posts')
|
||||
return
|
||||
}
|
||||
setEditingPost(post)
|
||||
setPublishError('')
|
||||
setFormData({
|
||||
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 || [],
|
||||
})
|
||||
loadAttachments(post._id || post.id)
|
||||
setShowModal(true)
|
||||
setPanelPost(post)
|
||||
}
|
||||
|
||||
const openNew = () => {
|
||||
setEditingPost(null)
|
||||
setFormData(EMPTY_POST)
|
||||
setAttachments([])
|
||||
setPublishError('')
|
||||
setShowModal(true)
|
||||
setPanelPost(EMPTY_POST)
|
||||
}
|
||||
|
||||
const filteredPosts = posts.filter(p => {
|
||||
@@ -277,7 +124,6 @@ export default function PostProduction() {
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Search */}
|
||||
<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
|
||||
@@ -289,7 +135,6 @@ export default function PostProduction() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div data-tutorial="filters" className="flex gap-3">
|
||||
<select
|
||||
value={filters.brand}
|
||||
@@ -318,12 +163,17 @@ export default function PostProduction() {
|
||||
{teamMembers.map(m => <option key={m._id} value={String(m._id)}>{m.name}</option>)}
|
||||
</select>
|
||||
|
||||
{/* Period filter */}
|
||||
<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 }))}
|
||||
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"
|
||||
/>
|
||||
@@ -331,14 +181,13 @@ export default function PostProduction() {
|
||||
<input
|
||||
type="date"
|
||||
value={filters.periodTo}
|
||||
onChange={e => setFilters(f => ({ ...f, periodTo: e.target.value }))}
|
||||
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>
|
||||
|
||||
{/* View toggle */}
|
||||
<div className="flex bg-surface-tertiary rounded-lg p-0.5 ml-auto">
|
||||
<button
|
||||
onClick={() => setView('kanban')}
|
||||
@@ -354,7 +203,6 @@ export default function PostProduction() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* New post */}
|
||||
<button
|
||||
data-tutorial="new-post"
|
||||
onClick={openNew}
|
||||
@@ -365,7 +213,6 @@ 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>
|
||||
@@ -375,7 +222,6 @@ export default function PostProduction() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{view === 'kanban' ? (
|
||||
<KanbanBoard posts={filteredPosts} onPostClick={openEdit} onMovePost={handleMovePost} />
|
||||
) : (
|
||||
@@ -415,409 +261,18 @@ export default function PostProduction() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => { setShowModal(false); setEditingPost(null) }}
|
||||
title={editingPost ? t('posts.editPost') : t('posts.createPost')}
|
||||
size="lg"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.postTitle')} *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={e => setFormData(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"
|
||||
placeholder={t('posts.postTitlePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.description')}</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={e => setFormData(f => ({ ...f, description: e.target.value }))}
|
||||
rows={4}
|
||||
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 resize-none"
|
||||
placeholder={t('posts.postDescPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Campaign */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.campaign')}</label>
|
||||
<select
|
||||
value={formData.campaign_id}
|
||||
onChange={e => setFormData(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="">{t('posts.noCampaign')}</option>
|
||||
{campaigns.map(c => <option key={c._id} value={c._id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.brand')}</label>
|
||||
<select
|
||||
value={formData.brand_id}
|
||||
onChange={e => setFormData(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.selectBrand')}</option>
|
||||
{brands.map(b => <option key={b._id} value={b._id}>{b.icon} {lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.platforms')}</label>
|
||||
<div className="flex flex-wrap gap-2 p-2 border border-border rounded-lg bg-white min-h-[42px]">
|
||||
{Object.entries(PLATFORMS).map(([k, v]) => {
|
||||
const checked = (formData.platforms || []).includes(k)
|
||||
return (
|
||||
<label
|
||||
key={k}
|
||||
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-full cursor-pointer border transition-colors ${
|
||||
checked
|
||||
? 'bg-brand-primary/10 border-brand-primary/30 text-brand-primary font-medium'
|
||||
: 'bg-surface-tertiary border-transparent text-text-secondary hover:bg-surface-tertiary/80'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
setFormData(f => ({
|
||||
...f,
|
||||
platforms: checked
|
||||
? f.platforms.filter(p => p !== k)
|
||||
: [...(f.platforms || []), k]
|
||||
}))
|
||||
}}
|
||||
className="sr-only"
|
||||
/>
|
||||
{v.label}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.status')}</label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={e => setFormData(f => ({ ...f, status: 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="draft">{t('posts.status.draft')}</option>
|
||||
<option value="in_review">{t('posts.status.in_review')}</option>
|
||||
<option value="approved">{t('posts.status.approved')}</option>
|
||||
<option value="scheduled">{t('posts.status.scheduled')}</option>
|
||||
<option value="published">{t('posts.status.published')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.assignTo')}</label>
|
||||
<select
|
||||
value={formData.assigned_to}
|
||||
onChange={e => setFormData(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={m._id}>{m.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.scheduledDate')}</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={formData.scheduled_date}
|
||||
onChange={e => setFormData(f => ({ ...f, scheduled_date: 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.notes')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.notes}
|
||||
onChange={e => setFormData(f => ({ ...f, notes: 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('posts.additionalNotes')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Publication Links */}
|
||||
{(formData.platforms || []).length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Link2 className="w-4 h-4" />
|
||||
{t('posts.publicationLinks')}
|
||||
</span>
|
||||
</label>
|
||||
<div className="space-y-2 border border-border rounded-lg p-3 bg-surface-secondary">
|
||||
{(formData.platforms || []).map(platformKey => {
|
||||
const platformInfo = PLATFORMS[platformKey] || { label: platformKey }
|
||||
const existingLink = (formData.publication_links || []).find(l => l.platform === platformKey)
|
||||
const linkUrl = existingLink?.url || ''
|
||||
return (
|
||||
<div key={platformKey} className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-text-secondary w-24 shrink-0 flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: platformInfo.color || '#888' }} />
|
||||
{platformInfo.label}
|
||||
</span>
|
||||
<input
|
||||
type="url"
|
||||
value={linkUrl}
|
||||
onChange={e => updatePublicationLink(platformKey, e.target.value)}
|
||||
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"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
{linkUrl && (
|
||||
<a href={linkUrl} target="_blank" rel="noopener noreferrer" className="p-1.5 text-text-tertiary hover:text-brand-primary">
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{formData.status === 'published' && (formData.platforms || []).some(p => {
|
||||
const link = (formData.publication_links || []).find(l => l.platform === p)
|
||||
return !link || !link.url?.trim()
|
||||
}) && (
|
||||
<p className="text-xs text-amber-600 mt-1">⚠️ {t('posts.publishRequired')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attachments (only for existing posts) */}
|
||||
{editingPost && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Paperclip className="w-4 h-4" />
|
||||
{t('posts.attachments')}
|
||||
<span className="text-xs text-text-tertiary font-normal">({attachments.length})</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Existing attachments */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mb-3">
|
||||
{attachments.map(att => {
|
||||
const isImage = att.mime_type?.startsWith('image/') || att.mimeType?.startsWith('image/')
|
||||
const attUrl = att.url || `/api/uploads/${att.filename}`
|
||||
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">
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload area */}
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors ${
|
||||
dragActive ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/40'
|
||||
}`}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeaveZone}
|
||||
onDragOver={handleDragOverZone}
|
||||
onDrop={handleDropFiles}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => handleFileUpload(e.target.files)}
|
||||
/>
|
||||
<Upload className="w-6 h-6 text-text-tertiary mx-auto mb-1" />
|
||||
<p className="text-xs text-text-secondary">
|
||||
{dragActive ? t('posts.dropFiles') : t('posts.uploadFiles')}
|
||||
</p>
|
||||
<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">
|
||||
<div className="flex items-center justify-between text-xs text-text-secondary mb-1">
|
||||
<span>{t('posts.uploading')}</span>
|
||||
<span>{uploadProgress}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-surface-tertiary rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-brand-primary h-1.5 rounded-full transition-all"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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">
|
||||
{publishError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||
{editingPost && canDeleteResource('post', editingPost) && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg mr-auto"
|
||||
>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setShowModal(false); setEditingPost(null) }}
|
||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!formData.title || saving}
|
||||
className={`px-5 py-2 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 ${saving ? 'btn-loading' : ''}`}
|
||||
>
|
||||
{editingPost ? t('posts.saveChanges') : t('posts.createPost')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => setShowDeleteConfirm(false)}
|
||||
title={t('posts.deletePost')}
|
||||
isConfirm
|
||||
danger
|
||||
confirmText={t('posts.deletePost')}
|
||||
onConfirm={async () => {
|
||||
if (editingPost) {
|
||||
try {
|
||||
await api.delete(`/posts/${editingPost._id || editingPost.id}`)
|
||||
toast.success(t('posts.deleted'))
|
||||
setShowModal(false)
|
||||
setEditingPost(null)
|
||||
loadPosts()
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err)
|
||||
toast.error(t('common.deleteFailed'))
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('posts.deleteConfirm')}
|
||||
</Modal>
|
||||
{/* Post Detail Panel */}
|
||||
{panelPost && (
|
||||
<PostDetailPanel
|
||||
post={panelPost}
|
||||
onClose={() => setPanelPost(null)}
|
||||
onSave={handlePanelSave}
|
||||
onDelete={handlePanelDelete}
|
||||
brands={brands}
|
||||
teamMembers={teamMembers}
|
||||
campaigns={campaigns}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { useState, useEffect, useContext, useRef } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
ArrowLeft, Plus, Check, Trash2, Edit3, LayoutGrid, List,
|
||||
GanttChart, Settings, Calendar, Clock, MessageCircle, X
|
||||
ArrowLeft, Plus, Check, Trash2, LayoutGrid, List,
|
||||
GanttChart, Settings, Calendar, Clock, MessageCircle, X,
|
||||
Image as ImageIcon
|
||||
} from 'lucide-react'
|
||||
import { format, differenceInDays, startOfDay, addDays, isAfter, isBefore } from 'date-fns'
|
||||
import { AppContext } from '../App'
|
||||
@@ -12,6 +13,8 @@ import StatusBadge from '../components/StatusBadge'
|
||||
import BrandBadge from '../components/BrandBadge'
|
||||
import Modal from '../components/Modal'
|
||||
import CommentsSection from '../components/CommentsSection'
|
||||
import ProjectEditPanel from '../components/ProjectEditPanel'
|
||||
import TaskDetailPanel from '../components/TaskDetailPanel'
|
||||
|
||||
const TASK_COLUMNS = [
|
||||
{ id: 'todo', label: 'To Do', color: 'bg-gray-400' },
|
||||
@@ -30,19 +33,16 @@ export default function ProjectDetail() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [assignableUsers, setAssignableUsers] = useState([])
|
||||
const [view, setView] = useState('kanban')
|
||||
const [showTaskModal, setShowTaskModal] = useState(false)
|
||||
const [showProjectModal, setShowProjectModal] = useState(false)
|
||||
const [editingTask, setEditingTask] = useState(null)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [taskToDelete, setTaskToDelete] = useState(null)
|
||||
const [taskForm, setTaskForm] = useState({
|
||||
title: '', description: '', priority: 'medium', assigned_to: '', start_date: '', due_date: '', status: 'todo'
|
||||
})
|
||||
const [projectForm, setProjectForm] = useState({
|
||||
name: '', description: '', brand_id: '', owner_id: '', status: 'active', start_date: '', due_date: ''
|
||||
})
|
||||
|
||||
const [showDiscussion, setShowDiscussion] = useState(false)
|
||||
const [thumbnailUploading, setThumbnailUploading] = useState(false)
|
||||
const thumbnailInputRef = useRef(null)
|
||||
|
||||
// Panel state
|
||||
const [panelProject, setPanelProject] = useState(null)
|
||||
const [panelTask, setPanelTask] = useState(null)
|
||||
|
||||
// Drag state for kanban
|
||||
const [draggedTask, setDraggedTask] = useState(null)
|
||||
@@ -66,32 +66,6 @@ export default function ProjectDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleTaskSave = async () => {
|
||||
try {
|
||||
const data = {
|
||||
title: taskForm.title,
|
||||
description: taskForm.description,
|
||||
priority: taskForm.priority,
|
||||
assigned_to: taskForm.assigned_to ? Number(taskForm.assigned_to) : null,
|
||||
start_date: taskForm.start_date || null,
|
||||
due_date: taskForm.due_date || null,
|
||||
status: taskForm.status,
|
||||
project_id: Number(id),
|
||||
}
|
||||
if (editingTask) {
|
||||
await api.patch(`/tasks/${editingTask._id}`, data)
|
||||
} else {
|
||||
await api.post('/tasks', data)
|
||||
}
|
||||
setShowTaskModal(false)
|
||||
setEditingTask(null)
|
||||
setTaskForm({ title: '', description: '', priority: 'medium', assigned_to: '', start_date: '', due_date: '', status: 'todo' })
|
||||
loadProject()
|
||||
} catch (err) {
|
||||
console.error('Task save failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTaskStatusChange = async (taskId, newStatus) => {
|
||||
try {
|
||||
await api.patch(`/tasks/${taskId}`, { status: newStatus })
|
||||
@@ -117,55 +91,67 @@ export default function ProjectDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
// Panel handlers
|
||||
const handleProjectPanelSave = async (projectId, data) => {
|
||||
await api.patch(`/projects/${projectId}`, data)
|
||||
loadProject()
|
||||
}
|
||||
|
||||
const handleProjectPanelDelete = async (projectId) => {
|
||||
await api.delete(`/projects/${projectId}`)
|
||||
navigate('/projects')
|
||||
}
|
||||
|
||||
const handleTaskPanelSave = async (taskId, data) => {
|
||||
if (taskId) {
|
||||
await api.patch(`/tasks/${taskId}`, data)
|
||||
} else {
|
||||
await api.post('/tasks', { ...data, project_id: Number(id) })
|
||||
}
|
||||
setPanelTask(null)
|
||||
loadProject()
|
||||
}
|
||||
|
||||
const handleTaskPanelDelete = async (taskId) => {
|
||||
await api.delete(`/tasks/${taskId}`)
|
||||
setPanelTask(null)
|
||||
loadProject()
|
||||
}
|
||||
|
||||
const openEditTask = (task) => {
|
||||
setEditingTask(task)
|
||||
setTaskForm({
|
||||
title: task.title || '',
|
||||
description: task.description || '',
|
||||
priority: task.priority || 'medium',
|
||||
assigned_to: task.assignedTo || task.assigned_to || '',
|
||||
start_date: task.startDate || task.start_date ? new Date(task.startDate || task.start_date).toISOString().slice(0, 10) : '',
|
||||
due_date: task.dueDate ? new Date(task.dueDate).toISOString().slice(0, 10) : '',
|
||||
status: task.status || 'todo',
|
||||
})
|
||||
setShowTaskModal(true)
|
||||
setPanelTask(task)
|
||||
}
|
||||
|
||||
const openNewTask = () => {
|
||||
setEditingTask(null)
|
||||
setTaskForm({ title: '', description: '', priority: 'medium', assigned_to: '', start_date: '', due_date: '', status: 'todo' })
|
||||
setShowTaskModal(true)
|
||||
setPanelTask({ title: '', status: 'todo', priority: 'medium', project_id: Number(id) })
|
||||
}
|
||||
|
||||
const openEditProject = () => {
|
||||
if (!project) return
|
||||
setProjectForm({
|
||||
name: project.name || '',
|
||||
description: project.description || '',
|
||||
brand_id: project.brandId || project.brand_id || '',
|
||||
owner_id: project.ownerId || project.owner_id || '',
|
||||
status: project.status || 'active',
|
||||
start_date: project.startDate || project.start_date ? new Date(project.startDate || project.start_date).toISOString().slice(0, 10) : '',
|
||||
due_date: project.dueDate ? new Date(project.dueDate).toISOString().slice(0, 10) : '',
|
||||
})
|
||||
setShowProjectModal(true)
|
||||
setPanelProject(project)
|
||||
}
|
||||
|
||||
const handleProjectSave = async () => {
|
||||
const handleThumbnailUpload = async (file) => {
|
||||
if (!file) return
|
||||
setThumbnailUploading(true)
|
||||
try {
|
||||
await api.patch(`/projects/${id}`, {
|
||||
name: projectForm.name,
|
||||
description: projectForm.description,
|
||||
brand_id: projectForm.brand_id ? Number(projectForm.brand_id) : null,
|
||||
owner_id: projectForm.owner_id ? Number(projectForm.owner_id) : null,
|
||||
status: projectForm.status,
|
||||
start_date: projectForm.start_date || null,
|
||||
due_date: projectForm.due_date || null,
|
||||
})
|
||||
setShowProjectModal(false)
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
await api.upload(`/projects/${id}/thumbnail`, fd)
|
||||
loadProject()
|
||||
} catch (err) {
|
||||
console.error('Project save failed:', err)
|
||||
console.error('Thumbnail upload failed:', err)
|
||||
} finally {
|
||||
setThumbnailUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleThumbnailRemove = async () => {
|
||||
try {
|
||||
await api.delete(`/projects/${id}/thumbnail`)
|
||||
loadProject()
|
||||
} catch (err) {
|
||||
console.error('Thumbnail remove failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,7 +223,38 @@ export default function ProjectDetail() {
|
||||
</button>
|
||||
|
||||
{/* Project header */}
|
||||
<div className="bg-white rounded-xl border border-border p-6">
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
{/* Thumbnail banner */}
|
||||
{(project.thumbnail_url || project.thumbnailUrl) && (
|
||||
<div className="relative w-full h-40 overflow-hidden">
|
||||
<img src={project.thumbnail_url || project.thumbnailUrl} alt="" className="w-full h-full object-cover" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/30 to-transparent" />
|
||||
{canEditProject && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => thumbnailInputRef.current?.click()}
|
||||
className="px-2 py-1 text-xs bg-black/40 hover:bg-black/60 rounded text-white transition-colors"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
<button
|
||||
onClick={handleThumbnailRemove}
|
||||
className="p-1 bg-black/40 hover:bg-red-500/80 rounded text-white transition-colors"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={thumbnailInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={e => { handleThumbnailUpload(e.target.files[0]); e.target.value = '' }}
|
||||
/>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
@@ -260,6 +277,16 @@ export default function ProjectDetail() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{canEditProject && !project.thumbnail_url && !project.thumbnailUrl && (
|
||||
<button
|
||||
onClick={() => thumbnailInputRef.current?.click()}
|
||||
disabled={thumbnailUploading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-text-secondary hover:text-text-primary hover:bg-surface-tertiary rounded-lg transition-colors"
|
||||
>
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
{thumbnailUploading ? 'Uploading...' : 'Thumbnail'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowDiscussion(prev => !prev)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
|
||||
@@ -299,7 +326,8 @@ export default function ProjectDetail() {
|
||||
</div>
|
||||
<p className="text-xs text-text-tertiary mt-1">{completedTasks} of {tasks.length} tasks completed</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>{/* end p-6 wrapper */}
|
||||
</div>{/* end project header card */}
|
||||
|
||||
{/* View switcher + Add Task */}
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -366,7 +394,7 @@ export default function ProjectDetail() {
|
||||
task={task}
|
||||
canEdit={canEditResource('task', task)}
|
||||
canDelete={canDeleteResource('task', task)}
|
||||
onEdit={() => openEditTask(task)}
|
||||
onClick={() => openEditTask(task)}
|
||||
onDelete={() => handleDeleteTask(task._id)}
|
||||
onStatusChange={handleTaskStatusChange}
|
||||
onDragStart={handleDragStart}
|
||||
@@ -393,26 +421,25 @@ export default function ProjectDetail() {
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Priority</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Assignee</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Due</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider w-16"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-light">
|
||||
{tasks.length === 0 ? (
|
||||
<tr><td colSpan={7} className="py-12 text-center text-sm text-text-tertiary">No tasks yet</td></tr>
|
||||
<tr><td colSpan={6} className="py-12 text-center text-sm text-text-tertiary">No tasks yet</td></tr>
|
||||
) : (
|
||||
tasks.map(task => {
|
||||
const prio = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
|
||||
const assigneeName = task.assignedName || task.assigned_name
|
||||
const isOverdue = task.dueDate && new Date(task.dueDate) < new Date() && task.status !== 'done'
|
||||
return (
|
||||
<tr key={task._id} className="hover:bg-surface-secondary group">
|
||||
<tr key={task._id} onClick={() => openEditTask(task)} className="hover:bg-surface-secondary cursor-pointer transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${prio.color}`} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button onClick={() => openEditTask(task)} className="text-sm font-medium text-text-primary hover:text-brand-primary text-left">
|
||||
<span className="text-sm font-medium text-text-primary">
|
||||
{task.title}
|
||||
</button>
|
||||
</span>
|
||||
{task.description && <p className="text-xs text-text-tertiary line-clamp-1 mt-0.5">{task.description}</p>}
|
||||
</td>
|
||||
<td className="px-4 py-3"><StatusBadge status={task.status} size="xs" /></td>
|
||||
@@ -421,20 +448,6 @@ export default function ProjectDetail() {
|
||||
<td className={`px-4 py-3 text-xs ${isOverdue ? 'text-red-500 font-medium' : 'text-text-tertiary'}`}>
|
||||
{task.dueDate ? format(new Date(task.dueDate), 'MMM d, yyyy') : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{canEditResource('task', task) && (
|
||||
<button onClick={() => openEditTask(task)} className="p-1 rounded hover:bg-surface-tertiary text-text-tertiary">
|
||||
<Edit3 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{canDeleteResource('task', task) && (
|
||||
<button onClick={() => handleDeleteTask(task._id)} className="p-1 rounded hover:bg-red-50 text-red-400">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
@@ -466,167 +479,6 @@ export default function ProjectDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── TASK MODAL ─── */}
|
||||
<Modal
|
||||
isOpen={showTaskModal}
|
||||
onClose={() => { setShowTaskModal(false); setEditingTask(null) }}
|
||||
title={editingTask ? 'Edit Task' : 'Add Task'}
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Title *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={taskForm.title}
|
||||
onChange={e => setTaskForm(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"
|
||||
placeholder="Task title"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Description</label>
|
||||
<textarea
|
||||
value={taskForm.description}
|
||||
onChange={e => setTaskForm(f => ({ ...f, description: e.target.value }))}
|
||||
rows={2}
|
||||
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 resize-none"
|
||||
placeholder="Optional description"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Priority</label>
|
||||
<select value={taskForm.priority} onChange={e => setTaskForm(f => ({ ...f, priority: 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="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="urgent">Urgent</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Status</label>
|
||||
<select value={taskForm.status} onChange={e => setTaskForm(f => ({ ...f, status: 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="todo">To Do</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="done">Done</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Assign To</label>
|
||||
<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>
|
||||
{assignableUsers.map(m => <option key={m._id || m.id} value={m._id || m.id}>{m.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Start Date</label>
|
||||
<input type="date" value={taskForm.start_date} onChange={e => setTaskForm(f => ({ ...f, start_date: 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" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Due Date</label>
|
||||
<input type="date" value={taskForm.due_date} onChange={e => setTaskForm(f => ({ ...f, due_date: 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" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||
{editingTask && canDeleteResource('task', editingTask) && (
|
||||
<button onClick={() => handleDeleteTask(editingTask._id)}
|
||||
className="px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg mr-auto">
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => { setShowTaskModal(false); setEditingTask(null) }}
|
||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg">
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={handleTaskSave} disabled={!taskForm.title}
|
||||
className="px-5 py-2 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">
|
||||
{editingTask ? 'Save Changes' : 'Add Task'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* ─── PROJECT EDIT MODAL ─── */}
|
||||
<Modal
|
||||
isOpen={showProjectModal}
|
||||
onClose={() => setShowProjectModal(false)}
|
||||
title="Edit Project"
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Name *</label>
|
||||
<input type="text" value={projectForm.name} onChange={e => setProjectForm(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="Project name" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Description</label>
|
||||
<textarea value={projectForm.description} onChange={e => setProjectForm(f => ({ ...f, description: e.target.value }))}
|
||||
rows={3} 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 resize-none"
|
||||
placeholder="Project description..." />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Brand</label>
|
||||
<select value={projectForm.brand_id} onChange={e => setProjectForm(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="">Select brand</option>
|
||||
{brands.map(b => <option key={b._id} value={b._id}>{b.icon} {b.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Status</label>
|
||||
<select value={projectForm.status} onChange={e => setProjectForm(f => ({ ...f, status: 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="active">Active</option>
|
||||
<option value="paused">Paused</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Owner</label>
|
||||
<select value={projectForm.owner_id} onChange={e => setProjectForm(f => ({ ...f, owner_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="">Unassigned</option>
|
||||
{teamMembers.map(m => <option key={m._id} value={m._id}>{m.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Start Date</label>
|
||||
<input type="date" value={projectForm.start_date} onChange={e => setProjectForm(f => ({ ...f, start_date: 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" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Due Date</label>
|
||||
<input type="date" value={projectForm.due_date} onChange={e => setProjectForm(f => ({ ...f, due_date: 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" />
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||
<button onClick={() => setShowProjectModal(false)}
|
||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg">
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={handleProjectSave} disabled={!projectForm.name}
|
||||
className="px-5 py-2 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">
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* ─── DELETE TASK CONFIRMATION ─── */}
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
@@ -639,22 +491,48 @@ export default function ProjectDetail() {
|
||||
>
|
||||
Are you sure you want to delete this task? This action cannot be undone.
|
||||
</Modal>
|
||||
|
||||
{/* Project Edit Panel */}
|
||||
{panelProject && (
|
||||
<ProjectEditPanel
|
||||
project={panelProject}
|
||||
onClose={() => setPanelProject(null)}
|
||||
onSave={handleProjectPanelSave}
|
||||
onDelete={handleProjectPanelDelete}
|
||||
brands={brands}
|
||||
teamMembers={teamMembers}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Task Detail Panel */}
|
||||
{panelTask && (
|
||||
<TaskDetailPanel
|
||||
task={panelTask}
|
||||
onClose={() => setPanelTask(null)}
|
||||
onSave={handleTaskPanelSave}
|
||||
onDelete={handleTaskPanelDelete}
|
||||
projects={project ? [project] : []}
|
||||
users={assignableUsers}
|
||||
brands={brands}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Task Kanban Card ───────────────────────────────
|
||||
function TaskKanbanCard({ task, canEdit, canDelete, onEdit, onDelete, onStatusChange, onDragStart, onDragEnd }) {
|
||||
function TaskKanbanCard({ task, canEdit, canDelete, onClick, onDelete, onStatusChange, onDragStart, onDragEnd }) {
|
||||
const priority = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
|
||||
const assigneeName = task.assignedName || task.assigned_name
|
||||
const isOverdue = task.dueDate && new Date(task.dueDate) < new Date() && task.status !== 'done'
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, task)}
|
||||
draggable={canEdit}
|
||||
onDragStart={(e) => canEdit && onDragStart(e, task)}
|
||||
onDragEnd={onDragEnd}
|
||||
className="bg-white rounded-lg border border-border p-3 group hover:border-brand-primary/30 hover:shadow-sm transition-all cursor-grab active:cursor-grabbing"
|
||||
onClick={onClick}
|
||||
className={`bg-white rounded-lg border border-border p-3 group hover:border-brand-primary/30 hover:shadow-sm transition-all cursor-pointer ${canEdit ? 'active:cursor-grabbing' : ''}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${priority.color} mt-1.5 shrink-0`} title={priority.label} />
|
||||
@@ -679,20 +557,14 @@ function TaskKanbanCard({ task, canEdit, canDelete, onEdit, onDelete, onStatusCh
|
||||
{(canEdit || canDelete) && (
|
||||
<div className="flex items-center gap-1 mt-2 pt-2 border-t border-border-light opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{canEdit && task.status !== 'done' && (
|
||||
<button onClick={() => onStatusChange(task._id, task.status === 'todo' ? 'in_progress' : 'done')}
|
||||
<button onClick={(e) => { e.stopPropagation(); onStatusChange(task._id, task.status === 'todo' ? 'in_progress' : 'done') }}
|
||||
className="text-[10px] text-brand-primary hover:bg-brand-primary/10 px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
<Check className="w-3 h-3" />
|
||||
{task.status === 'todo' ? 'Start' : 'Complete'}
|
||||
</button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button onClick={onEdit}
|
||||
className="text-[10px] text-text-tertiary hover:bg-surface-tertiary px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
<Edit3 className="w-3 h-3" /> Edit
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button onClick={onDelete}
|
||||
<button onClick={(e) => { e.stopPropagation(); onDelete() }}
|
||||
className="text-[10px] text-red-400 hover:bg-red-50 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
@@ -823,4 +695,3 @@ function GanttView({ tasks, project, onEditTask }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -143,6 +143,7 @@ export default function Projects() {
|
||||
status: project.status,
|
||||
priority: project.priority,
|
||||
assigneeName: project.ownerName || project.owner_name,
|
||||
thumbnailUrl: project.thumbnail_url || project.thumbnailUrl,
|
||||
tags: [project.status, project.priority].filter(Boolean),
|
||||
})}
|
||||
onDateChange={async (projectId, { startDate, endDate }) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { Settings as SettingsIcon, Play, CheckCircle, Languages, Coins } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Settings as SettingsIcon, Play, CheckCircle, Languages, Coins, Upload } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { CURRENCIES } from '../i18n/LanguageContext'
|
||||
@@ -8,6 +8,28 @@ export default function Settings() {
|
||||
const { t, lang, setLang, currency, setCurrency } = useLanguage()
|
||||
const [restarting, setRestarting] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [maxSizeMB, setMaxSizeMB] = useState(50)
|
||||
const [sizeSaving, setSizeSaving] = useState(false)
|
||||
const [sizeSaved, setSizeSaved] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/settings/app').then(s => setMaxSizeMB(s.uploadMaxSizeMB || 50)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleSaveMaxSize = async () => {
|
||||
setSizeSaving(true)
|
||||
setSizeSaved(false)
|
||||
try {
|
||||
const res = await api.patch('/settings/app', { uploadMaxSizeMB: maxSizeMB })
|
||||
setMaxSizeMB(res.uploadMaxSizeMB)
|
||||
setSizeSaved(true)
|
||||
setTimeout(() => setSizeSaved(false), 2000)
|
||||
} catch (err) {
|
||||
alert(err.message || 'Failed to save')
|
||||
} finally {
|
||||
setSizeSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestartTutorial = async () => {
|
||||
setRestarting(true)
|
||||
@@ -81,6 +103,44 @@ export default function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Uploads Section */}
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold text-text-primary flex items-center gap-2">
|
||||
<Upload className="w-5 h-5 text-brand-primary" />
|
||||
{t('settings.uploads')}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">
|
||||
{t('settings.maxFileSize')}
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="500"
|
||||
value={maxSizeMB}
|
||||
onChange={(e) => setMaxSizeMB(Number(e.target.value))}
|
||||
className="w-24 px-3 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
|
||||
/>
|
||||
<span className="text-sm text-text-secondary">{t('settings.mb')}</span>
|
||||
<button
|
||||
onClick={handleSaveMaxSize}
|
||||
disabled={sizeSaving}
|
||||
className="px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{sizeSaved ? (
|
||||
<span className="flex items-center gap-1.5"><CheckCircle className="w-4 h-4" />{t('settings.saved')}</span>
|
||||
) : sizeSaving ? '...' : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-text-tertiary mt-1.5">{t('settings.maxFileSizeHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tutorial Section */}
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-border">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { Plus, Users, ArrowLeft, User as UserIcon } from 'lucide-react'
|
||||
import { Plus, Users, ArrowLeft, User as UserIcon, Edit2, LayoutGrid, Network } from 'lucide-react'
|
||||
import { getInitials } from '../utils/api'
|
||||
import { AppContext } from '../App'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
@@ -7,110 +8,125 @@ import { api } from '../utils/api'
|
||||
import MemberCard from '../components/MemberCard'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import BrandBadge from '../components/BrandBadge'
|
||||
import Modal from '../components/Modal'
|
||||
|
||||
const EMPTY_MEMBER = {
|
||||
name: '', email: '', password: '', role: 'content_writer', brands: '', phone: '',
|
||||
}
|
||||
|
||||
const 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' },
|
||||
]
|
||||
import TeamMemberPanel from '../components/TeamMemberPanel'
|
||||
import TeamPanel from '../components/TeamPanel'
|
||||
|
||||
export default function Team() {
|
||||
const { t } = useLanguage()
|
||||
const { teamMembers, loadTeam, currentUser } = useContext(AppContext)
|
||||
const { teamMembers, loadTeam, currentUser, teams, loadTeams, brands } = useContext(AppContext)
|
||||
const { user } = useAuth()
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [editingMember, setEditingMember] = useState(null)
|
||||
const [isEditingSelf, setIsEditingSelf] = useState(false)
|
||||
const [formData, setFormData] = useState(EMPTY_MEMBER)
|
||||
const [panelMember, setPanelMember] = useState(null)
|
||||
const [panelIsEditingSelf, setPanelIsEditingSelf] = useState(false)
|
||||
const [selectedMember, setSelectedMember] = useState(null)
|
||||
const [memberTasks, setMemberTasks] = useState([])
|
||||
const [memberPosts, setMemberPosts] = useState([])
|
||||
const [loadingDetail, setLoadingDetail] = useState(false)
|
||||
const [panelTeam, setPanelTeam] = useState(null)
|
||||
const [teamFilter, setTeamFilter] = useState(null)
|
||||
const [viewMode, setViewMode] = useState('grid') // 'grid' | 'teams'
|
||||
|
||||
const canManageTeam = user?.role === 'superadmin' || user?.role === 'manager'
|
||||
|
||||
const openNew = () => {
|
||||
setEditingMember(null)
|
||||
setIsEditingSelf(false)
|
||||
setFormData(EMPTY_MEMBER)
|
||||
setShowModal(true)
|
||||
setPanelMember({ role: 'content_writer' })
|
||||
setPanelIsEditingSelf(false)
|
||||
}
|
||||
|
||||
const openEdit = (member) => {
|
||||
const isSelf = member._id === user?.id || member.id === user?.id
|
||||
setEditingMember(member)
|
||||
setIsEditingSelf(isSelf)
|
||||
setFormData({
|
||||
name: member.name || '',
|
||||
email: member.email || '',
|
||||
password: '',
|
||||
role: member.team_role || member.role || 'content_writer',
|
||||
brands: Array.isArray(member.brands) ? member.brands.join(', ') : (member.brands || ''),
|
||||
phone: member.phone || '',
|
||||
})
|
||||
setShowModal(true)
|
||||
setPanelMember(member)
|
||||
setPanelIsEditingSelf(isSelf)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
const handlePanelSave = async (memberId, data, isEditingSelf) => {
|
||||
try {
|
||||
const brands = typeof formData.brands === 'string'
|
||||
? formData.brands.split(',').map(b => b.trim()).filter(Boolean)
|
||||
: formData.brands
|
||||
|
||||
// If editing self, use self-service endpoint
|
||||
if (isEditingSelf) {
|
||||
const data = {
|
||||
name: formData.name,
|
||||
team_role: formData.role,
|
||||
brands,
|
||||
phone: formData.phone,
|
||||
}
|
||||
await api.patch('/users/me/profile', data)
|
||||
await api.patch('/users/me/profile', {
|
||||
name: data.name,
|
||||
team_role: data.role,
|
||||
brands: data.brands,
|
||||
phone: data.phone,
|
||||
})
|
||||
} else {
|
||||
// Manager/superadmin creating or editing other users
|
||||
const data = {
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
team_role: formData.role,
|
||||
brands,
|
||||
phone: formData.phone,
|
||||
const payload = {
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
team_role: data.role,
|
||||
brands: data.brands,
|
||||
phone: data.phone,
|
||||
modules: data.modules,
|
||||
}
|
||||
if (formData.password) {
|
||||
data.password = formData.password
|
||||
}
|
||||
|
||||
if (editingMember) {
|
||||
await api.patch(`/users/team/${editingMember._id}`, data)
|
||||
if (data.password) payload.password = data.password
|
||||
|
||||
if (memberId) {
|
||||
await api.patch(`/users/team/${memberId}`, payload)
|
||||
} else {
|
||||
await api.post('/users/team', data)
|
||||
const created = await api.post('/users/team', payload)
|
||||
memberId = created?.id || created?.Id
|
||||
}
|
||||
}
|
||||
|
||||
setShowModal(false)
|
||||
setEditingMember(null)
|
||||
setIsEditingSelf(false)
|
||||
setFormData(EMPTY_MEMBER)
|
||||
|
||||
// Sync team memberships if team_ids provided
|
||||
if (data.team_ids !== undefined && memberId && !isEditingSelf) {
|
||||
const member = teamMembers.find(m => (m.id || m._id) === memberId)
|
||||
const currentTeamIds = member?.teams ? member.teams.map(t => t.id) : []
|
||||
const targetTeamIds = data.team_ids || []
|
||||
|
||||
const toAdd = targetTeamIds.filter(id => !currentTeamIds.includes(id))
|
||||
const toRemove = currentTeamIds.filter(id => !targetTeamIds.includes(id))
|
||||
|
||||
for (const teamId of toAdd) {
|
||||
await api.post(`/teams/${teamId}/members`, { user_id: memberId })
|
||||
}
|
||||
for (const teamId of toRemove) {
|
||||
await api.delete(`/teams/${teamId}/members/${memberId}`)
|
||||
}
|
||||
}
|
||||
|
||||
loadTeam()
|
||||
loadTeams()
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err)
|
||||
alert(err.message || 'Failed to save')
|
||||
}
|
||||
}
|
||||
|
||||
const handleTeamSave = async (teamId, data) => {
|
||||
try {
|
||||
if (teamId) {
|
||||
await api.patch(`/teams/${teamId}`, data)
|
||||
} else {
|
||||
await api.post('/teams', data)
|
||||
}
|
||||
loadTeams()
|
||||
loadTeam()
|
||||
} catch (err) {
|
||||
console.error('Team save failed:', err)
|
||||
alert(err.message || 'Failed to save team')
|
||||
}
|
||||
}
|
||||
|
||||
const handleTeamDelete = async (teamId) => {
|
||||
try {
|
||||
await api.delete(`/teams/${teamId}`)
|
||||
setPanelTeam(null)
|
||||
if (teamFilter === teamId) setTeamFilter(null)
|
||||
loadTeams()
|
||||
loadTeam()
|
||||
} catch (err) {
|
||||
console.error('Team delete failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePanelDelete = async (memberId) => {
|
||||
await api.delete(`/users/team/${memberId}`)
|
||||
if (selectedMember?._id === memberId) {
|
||||
setSelectedMember(null)
|
||||
}
|
||||
setPanelMember(null)
|
||||
loadTeam()
|
||||
}
|
||||
|
||||
const openMemberDetail = async (member) => {
|
||||
setSelectedMember(member)
|
||||
setLoadingDetail(true)
|
||||
@@ -243,18 +259,67 @@ export default function Team() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team Member Panel */}
|
||||
{panelMember && (
|
||||
<TeamMemberPanel
|
||||
member={panelMember}
|
||||
isEditingSelf={panelIsEditingSelf}
|
||||
onClose={() => setPanelMember(null)}
|
||||
onSave={handlePanelSave}
|
||||
onDelete={canManageTeam ? handlePanelDelete : null}
|
||||
canManageTeam={canManageTeam}
|
||||
userRole={user?.role}
|
||||
teams={teams}
|
||||
brands={brands}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const displayedMembers = teamFilter
|
||||
? teamMembers.filter(m => m.teams?.some(t => t.id === teamFilter))
|
||||
: teamMembers
|
||||
|
||||
// Members not in any team
|
||||
const unassignedMembers = teamMembers.filter(m => !m.teams || m.teams.length === 0)
|
||||
|
||||
const avatarColors = [
|
||||
'from-indigo-400 to-purple-500',
|
||||
'from-pink-400 to-rose-500',
|
||||
'from-emerald-400 to-teal-500',
|
||||
'from-amber-400 to-orange-500',
|
||||
'from-cyan-400 to-blue-500',
|
||||
]
|
||||
|
||||
// Team grid
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-text-secondary">
|
||||
{teamMembers.length} {teamMembers.length !== 1 ? t('team.membersPlural') : t('team.member')}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-text-secondary">
|
||||
{displayedMembers.length} {displayedMembers.length !== 1 ? t('team.membersPlural') : t('team.member')}
|
||||
</p>
|
||||
{/* View toggle */}
|
||||
<div className="flex items-center bg-white border border-border rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`p-2 transition-colors ${viewMode === 'grid' ? 'bg-brand-primary text-white' : 'text-text-tertiary hover:text-text-primary'}`}
|
||||
title={t('team.gridView')}
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('teams')}
|
||||
className={`p-2 transition-colors ${viewMode === 'teams' ? 'bg-brand-primary text-white' : 'text-text-tertiary hover:text-text-primary'}`}
|
||||
title={t('team.teamsView')}
|
||||
>
|
||||
<Network className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{/* Edit own profile button */}
|
||||
<button
|
||||
@@ -267,7 +332,18 @@ export default function Team() {
|
||||
<UserIcon className="w-4 h-4" />
|
||||
{t('team.myProfile')}
|
||||
</button>
|
||||
|
||||
|
||||
{/* Create Team button (managers and superadmins only) */}
|
||||
{canManageTeam && (
|
||||
<button
|
||||
onClick={() => setPanelTeam({})}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
|
||||
>
|
||||
<Users className="w-4 h-4" />
|
||||
{t('teams.createTeam')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Add member button (managers and superadmins only) */}
|
||||
{canManageTeam && (
|
||||
<button
|
||||
@@ -281,168 +357,209 @@ export default function Team() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Member grid */}
|
||||
{teamMembers.length === 0 ? (
|
||||
<div className="py-20 text-center">
|
||||
<Users className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
|
||||
<p className="text-text-secondary font-medium">{t('team.noMembers')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 stagger-children">
|
||||
{teamMembers.map(member => (
|
||||
<MemberCard key={member._id} member={member} onClick={openMemberDetail} />
|
||||
))}
|
||||
</div>
|
||||
{/* Grid view: team filter pills + member cards */}
|
||||
{viewMode === 'grid' && (
|
||||
<>
|
||||
{/* Team filter pills */}
|
||||
{teams.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-medium text-text-tertiary">{t('teams.teams')}:</span>
|
||||
<button
|
||||
onClick={() => setTeamFilter(null)}
|
||||
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${
|
||||
!teamFilter ? 'bg-brand-primary text-white border-brand-primary' : 'bg-white text-text-secondary border-border hover:bg-surface-tertiary'
|
||||
}`}
|
||||
>
|
||||
{t('common.all')}
|
||||
</button>
|
||||
{teams.map(team => {
|
||||
const tid = team.id || team._id
|
||||
const active = teamFilter === tid
|
||||
return (
|
||||
<div key={tid} className="flex items-center gap-0.5">
|
||||
<button
|
||||
onClick={() => setTeamFilter(active ? null : tid)}
|
||||
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${
|
||||
active ? 'bg-blue-600 text-white border-blue-600' : 'bg-white text-text-secondary border-border hover:bg-surface-tertiary'
|
||||
}`}
|
||||
>
|
||||
{team.name} ({team.member_count || 0})
|
||||
</button>
|
||||
{canManageTeam && (
|
||||
<button
|
||||
onClick={() => setPanelTeam(team)}
|
||||
className="p-1 text-text-tertiary hover:text-text-primary rounded"
|
||||
title={t('teams.editTeam')}
|
||||
>
|
||||
<Edit2 className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Member grid */}
|
||||
{displayedMembers.length === 0 ? (
|
||||
<div className="py-20 text-center">
|
||||
<Users className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
|
||||
<p className="text-text-secondary font-medium">{t('team.noMembers')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 stagger-children">
|
||||
{displayedMembers.map(member => (
|
||||
<MemberCard key={member._id} member={member} onClick={openMemberDetail} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => { setShowModal(false); setEditingMember(null); setIsEditingSelf(false) }}
|
||||
title={isEditingSelf ? t('team.editProfile') : (editingMember ? t('team.editMember') : t('team.newMember'))}
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.name')} *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={e => setFormData(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')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isEditingSelf && (
|
||||
{/* Teams (org chart) view */}
|
||||
{viewMode === 'teams' && (
|
||||
<div className="space-y-6">
|
||||
{teams.length === 0 && unassignedMembers.length === 0 ? (
|
||||
<div className="py-20 text-center">
|
||||
<Users className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
|
||||
<p className="text-text-secondary font-medium">{t('team.noMembers')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.email')} *</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={e => setFormData(f => ({ ...f, email: 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="email@example.com"
|
||||
disabled={editingMember}
|
||||
/>
|
||||
</div>
|
||||
{teams.map(team => {
|
||||
const tid = team.id || team._id
|
||||
const members = teamMembers.filter(m => m.teams?.some(t => t.id === tid))
|
||||
return (
|
||||
<div key={tid} className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
{/* Team header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-600 flex items-center justify-center text-white">
|
||||
<Users className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-text-primary">{team.name}</h3>
|
||||
<p className="text-xs text-text-tertiary">
|
||||
{members.length} {members.length !== 1 ? t('team.membersPlural') : t('team.member')}
|
||||
{team.description && ` · ${team.description}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{canManageTeam && (
|
||||
<button
|
||||
onClick={() => setPanelTeam(team)}
|
||||
className="px-3 py-1.5 text-sm font-medium text-blue-600 hover:bg-blue-100 rounded-lg transition-colors"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!editingMember && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.password')} {editingMember && t('team.optional')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={e => setFormData(f => ({ ...f, password: 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="••••••••"
|
||||
/>
|
||||
{!formData.password && !editingMember && (
|
||||
<p className="text-xs text-text-tertiary mt-1">{t('team.defaultPassword')}</p>
|
||||
)}
|
||||
{/* Team members */}
|
||||
{members.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-text-tertiary">{t('team.noMembers')}</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border-light">
|
||||
{members.map(member => {
|
||||
const colorIndex = (member.name?.charCodeAt(0) || 0) % avatarColors.length
|
||||
return (
|
||||
<div
|
||||
key={member._id}
|
||||
onClick={() => openMemberDetail(member)}
|
||||
className="flex items-center gap-4 px-5 py-3 hover:bg-surface-secondary transition-colors cursor-pointer"
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-full bg-gradient-to-br ${avatarColors[colorIndex]} flex items-center justify-center text-white text-sm font-bold shrink-0`}>
|
||||
{getInitials(member.name)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary">{member.name}</p>
|
||||
<p className="text-xs text-text-tertiary capitalize">{(member.team_role || member.role)?.replace('_', ' ')}</p>
|
||||
</div>
|
||||
{member.brands && member.brands.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 shrink-0">
|
||||
{member.brands.slice(0, 3).map(b => <BrandBadge key={b} brand={b} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Unassigned members */}
|
||||
{unassignedMembers.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<div className="flex items-center gap-3 px-5 py-4 bg-gray-50 border-b border-border">
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-400 flex items-center justify-center text-white">
|
||||
<UserIcon className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-text-primary">{t('team.unassigned')}</h3>
|
||||
<p className="text-xs text-text-tertiary">
|
||||
{unassignedMembers.length} {unassignedMembers.length !== 1 ? t('team.membersPlural') : t('team.member')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-border-light">
|
||||
{unassignedMembers.map(member => {
|
||||
const colorIndex = (member.name?.charCodeAt(0) || 0) % avatarColors.length
|
||||
return (
|
||||
<div
|
||||
key={member._id}
|
||||
onClick={() => openMemberDetail(member)}
|
||||
className="flex items-center gap-4 px-5 py-3 hover:bg-surface-secondary transition-colors cursor-pointer"
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-full bg-gradient-to-br ${avatarColors[colorIndex]} flex items-center justify-center text-white text-sm font-bold shrink-0`}>
|
||||
{getInitials(member.name)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary">{member.name}</p>
|
||||
<p className="text-xs text-text-tertiary capitalize">{(member.team_role || member.role)?.replace('_', ' ')}</p>
|
||||
</div>
|
||||
{member.brands && member.brands.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 shrink-0">
|
||||
{member.brands.slice(0, 3).map(b => <BrandBadge key={b} brand={b} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.teamRole')}</label>
|
||||
{user?.role === 'manager' && !editingMember && !isEditingSelf ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value="Contributor"
|
||||
disabled
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface-tertiary text-text-tertiary cursor-not-allowed"
|
||||
/>
|
||||
<p className="text-xs text-text-tertiary mt-1">{t('team.fixedRole')}</p>
|
||||
</>
|
||||
) : (
|
||||
<select
|
||||
value={formData.role}
|
||||
onChange={e => setFormData(f => ({ ...f, role: 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"
|
||||
>
|
||||
{ROLES.map(r => <option key={r.value} value={r.value}>{r.label}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.phone')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.phone}
|
||||
onChange={e => setFormData(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"
|
||||
placeholder="+966 ..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.brands')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.brands}
|
||||
onChange={e => setFormData(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="Brand A, Brand B"
|
||||
/>
|
||||
<p className="text-xs text-text-tertiary mt-1">{t('team.brandsHelp')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||
{editingMember && !isEditingSelf && canManageTeam && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg mr-auto"
|
||||
>
|
||||
{t('team.remove')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setShowModal(false); setEditingMember(null); setIsEditingSelf(false) }}
|
||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!formData.name || (!isEditingSelf && !editingMember && !formData.email)}
|
||||
className="px-5 py-2 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"
|
||||
>
|
||||
{isEditingSelf ? t('team.saveProfile') : (editingMember ? t('team.saveChanges') : t('team.addMember'))}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => setShowDeleteConfirm(false)}
|
||||
title={t('team.removeMember')}
|
||||
isConfirm
|
||||
danger
|
||||
confirmText={t('team.remove')}
|
||||
onConfirm={async () => {
|
||||
if (editingMember) {
|
||||
await api.delete(`/users/team/${editingMember._id}`)
|
||||
setShowModal(false)
|
||||
setEditingMember(null)
|
||||
setIsEditingSelf(false)
|
||||
setShowDeleteConfirm(false)
|
||||
if (selectedMember?._id === editingMember._id) {
|
||||
setSelectedMember(null)
|
||||
}
|
||||
loadTeam()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('team.removeConfirm', { name: editingMember?.name })}
|
||||
</Modal>
|
||||
{/* Team Member Panel */}
|
||||
{panelMember && (
|
||||
<TeamMemberPanel
|
||||
member={panelMember}
|
||||
isEditingSelf={panelIsEditingSelf}
|
||||
onClose={() => setPanelMember(null)}
|
||||
onSave={handlePanelSave}
|
||||
onDelete={canManageTeam ? handlePanelDelete : null}
|
||||
canManageTeam={canManageTeam}
|
||||
userRole={user?.role}
|
||||
teams={teams}
|
||||
brands={brands}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Team Panel */}
|
||||
{panelTeam && (
|
||||
<TeamPanel
|
||||
team={panelTeam}
|
||||
onClose={() => setPanelTeam(null)}
|
||||
onSave={handleTeamSave}
|
||||
onDelete={canManageTeam ? handleTeamDelete : null}
|
||||
teamMembers={teamMembers}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ const normalize = (data) => {
|
||||
// Map assigned_name for display
|
||||
if (out.assignedName && !out.assignedToName) out.assignedToName = out.assignedName;
|
||||
// Parse JSON text fields from NocoDB (stored as LongText)
|
||||
for (const jsonField of ['platforms', 'brands', 'tags', 'publicationLinks', 'publication_links', 'goals']) {
|
||||
for (const jsonField of ['platforms', 'brands', 'tags', 'publicationLinks', 'publication_links', 'goals', 'modules']) {
|
||||
if (out[jsonField] && typeof out[jsonField] === 'string') {
|
||||
try { out[jsonField] = JSON.parse(out[jsonField]); } catch {}
|
||||
}
|
||||
|
||||
77
client/src/utils/datePresets.js
Normal file
77
client/src/utils/datePresets.js
Normal file
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
startOfDay, endOfDay, subDays,
|
||||
startOfWeek, endOfWeek, subWeeks,
|
||||
startOfMonth, endOfMonth, subMonths,
|
||||
startOfQuarter, endOfQuarter,
|
||||
startOfYear, endOfYear,
|
||||
format,
|
||||
} from 'date-fns'
|
||||
|
||||
const fmt = d => format(d, 'yyyy-MM-dd')
|
||||
|
||||
export const DATE_PRESETS = [
|
||||
{
|
||||
key: 'today',
|
||||
labelKey: 'dates.today',
|
||||
getRange: () => {
|
||||
const now = new Date()
|
||||
return { from: fmt(startOfDay(now)), to: fmt(endOfDay(now)) }
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'yesterday',
|
||||
labelKey: 'dates.yesterday',
|
||||
getRange: () => {
|
||||
const d = subDays(new Date(), 1)
|
||||
return { from: fmt(startOfDay(d)), to: fmt(endOfDay(d)) }
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'thisWeek',
|
||||
labelKey: 'dates.thisWeek',
|
||||
getRange: () => {
|
||||
const now = new Date()
|
||||
return { from: fmt(startOfWeek(now, { weekStartsOn: 0 })), to: fmt(endOfWeek(now, { weekStartsOn: 0 })) }
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'lastWeek',
|
||||
labelKey: 'dates.lastWeek',
|
||||
getRange: () => {
|
||||
const d = subWeeks(new Date(), 1)
|
||||
return { from: fmt(startOfWeek(d, { weekStartsOn: 0 })), to: fmt(endOfWeek(d, { weekStartsOn: 0 })) }
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'thisMonth',
|
||||
labelKey: 'dates.thisMonth',
|
||||
getRange: () => {
|
||||
const now = new Date()
|
||||
return { from: fmt(startOfMonth(now)), to: fmt(endOfMonth(now)) }
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'lastMonth',
|
||||
labelKey: 'dates.lastMonth',
|
||||
getRange: () => {
|
||||
const d = subMonths(new Date(), 1)
|
||||
return { from: fmt(startOfMonth(d)), to: fmt(endOfMonth(d)) }
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'thisQuarter',
|
||||
labelKey: 'dates.thisQuarter',
|
||||
getRange: () => {
|
||||
const now = new Date()
|
||||
return { from: fmt(startOfQuarter(now)), to: fmt(endOfQuarter(now)) }
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'thisYear',
|
||||
labelKey: 'dates.thisYear',
|
||||
getRange: () => {
|
||||
const now = new Date()
|
||||
return { from: fmt(startOfYear(now)), to: fmt(endOfYear(now)) }
|
||||
},
|
||||
},
|
||||
]
|
||||
Reference in New Issue
Block a user