Files
marketing-app/client/src/components/TaskDetailPanel.jsx
fahed 4522edeea8 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)
2026-02-19 11:35:42 +03:00

554 lines
24 KiB
JavaScript

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