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:
fahed
2026-02-19 11:35:42 +03:00
parent e76be78498
commit 4522edeea8
2207 changed files with 3767 additions and 831225 deletions

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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 }) => {

View File

@@ -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

View File

@@ -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>
)
}