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

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

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

View File

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

View File

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

View 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>
</>
)
}

View File

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

View 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>
</>
)
}

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

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

View File

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

View 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>
</>
)
}

View 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>
</>
)
}

View 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>
</>
)
}