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:
553
client/src/components/TaskDetailPanel.jsx
Normal file
553
client/src/components/TaskDetailPanel.jsx
Normal file
@@ -0,0 +1,553 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { X, Trash2, AlertCircle, Upload, FileText, Star } from 'lucide-react'
|
||||
import { PRIORITY_CONFIG, getBrandColor, api } from '../utils/api'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import CommentsSection from './CommentsSection'
|
||||
import Modal from './Modal'
|
||||
import SlidePanel from './SlidePanel'
|
||||
import CollapsibleSection from './CollapsibleSection'
|
||||
|
||||
const API_BASE = '/api'
|
||||
|
||||
export default function TaskDetailPanel({ task, onClose, onSave, onDelete, projects, users, brands }) {
|
||||
const { t } = useLanguage()
|
||||
const fileInputRef = useRef(null)
|
||||
const [form, setForm] = useState({
|
||||
title: '', description: '', project_id: '', assigned_to: '',
|
||||
priority: 'medium', status: 'todo', start_date: '', due_date: '',
|
||||
})
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
|
||||
// Attachments state
|
||||
const [attachments, setAttachments] = useState([])
|
||||
const [pendingFiles, setPendingFiles] = useState([]) // for create mode (no task ID yet)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [maxSizeMB, setMaxSizeMB] = useState(50)
|
||||
const [uploadError, setUploadError] = useState(null)
|
||||
const [currentThumbnail, setCurrentThumbnail] = useState(null)
|
||||
|
||||
const taskId = task?._id || task?.id
|
||||
const isCreateMode = !taskId
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/settings/app').then(s => setMaxSizeMB(s.uploadMaxSizeMB || 50)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const taskIdRef = useRef(taskId)
|
||||
useEffect(() => {
|
||||
// Only reset form when switching to a different task (or initial mount)
|
||||
const switched = taskIdRef.current !== taskId
|
||||
taskIdRef.current = taskId
|
||||
if (task && (switched || !form.title)) {
|
||||
setForm({
|
||||
title: task.title || '',
|
||||
description: task.description || '',
|
||||
project_id: task.project_id || task.projectId || '',
|
||||
assigned_to: task.assigned_to || task.assignedTo || '',
|
||||
priority: task.priority || 'medium',
|
||||
status: task.status || 'todo',
|
||||
start_date: task.start_date || task.startDate || '',
|
||||
due_date: task.due_date || task.dueDate || '',
|
||||
})
|
||||
setDirty(isCreateMode)
|
||||
if (switched) setPendingFiles([])
|
||||
setCurrentThumbnail(task.thumbnail || null)
|
||||
if (!isCreateMode) loadAttachments()
|
||||
}
|
||||
}, [task])
|
||||
|
||||
if (!task) return null
|
||||
|
||||
const dueDate = task.due_date || task.dueDate
|
||||
const isOverdue = dueDate && new Date(dueDate) < new Date() && task.status !== 'done'
|
||||
const creatorName = task.creator_user_name || task.creatorUserName
|
||||
const priority = PRIORITY_CONFIG[form.priority] || PRIORITY_CONFIG.medium
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'todo', label: t('tasks.todo') },
|
||||
{ value: 'in_progress', label: t('tasks.in_progress') },
|
||||
{ value: 'done', label: t('tasks.done') },
|
||||
]
|
||||
|
||||
const priorityOptions = [
|
||||
{ value: 'low', label: t('tasks.priority.low') },
|
||||
{ value: 'medium', label: t('tasks.priority.medium') },
|
||||
{ value: 'high', label: t('tasks.priority.high') },
|
||||
{ value: 'urgent', label: t('tasks.priority.urgent') },
|
||||
]
|
||||
|
||||
const update = (field, value) => {
|
||||
setForm(f => ({ ...f, [field]: value }))
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const data = {
|
||||
title: form.title,
|
||||
description: form.description,
|
||||
project_id: form.project_id || null,
|
||||
assigned_to: form.assigned_to || null,
|
||||
priority: form.priority,
|
||||
status: form.status,
|
||||
start_date: form.start_date || null,
|
||||
due_date: form.due_date || null,
|
||||
}
|
||||
await onSave(isCreateMode ? null : taskId, data, pendingFiles)
|
||||
setDirty(false)
|
||||
setPendingFiles([])
|
||||
if (isCreateMode) onClose()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
setShowDeleteConfirm(true)
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
onDelete(taskId)
|
||||
setShowDeleteConfirm(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
// ─── Attachments ──────────────────────────────
|
||||
async function loadAttachments() {
|
||||
if (!taskId) return
|
||||
try {
|
||||
const data = await api.get(`/tasks/${taskId}/attachments`)
|
||||
setAttachments(Array.isArray(data) ? data : (data.data || []))
|
||||
} catch {
|
||||
setAttachments([])
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileUpload = async (files) => {
|
||||
if (!files?.length) return
|
||||
setUploadError(null)
|
||||
const maxBytes = maxSizeMB * 1024 * 1024
|
||||
const tooBig = Array.from(files).find(f => f.size > maxBytes)
|
||||
if (tooBig) {
|
||||
setUploadError(t('tasks.fileTooLarge')
|
||||
.replace('{name}', tooBig.name)
|
||||
.replace('{size}', (tooBig.size / 1024 / 1024).toFixed(1))
|
||||
.replace('{max}', maxSizeMB))
|
||||
return
|
||||
}
|
||||
setUploading(true)
|
||||
for (const file of files) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
try {
|
||||
await api.upload(`/tasks/${taskId}/attachments`, fd)
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err)
|
||||
setUploadError(err.message || 'Upload failed')
|
||||
}
|
||||
}
|
||||
setUploading(false)
|
||||
loadAttachments()
|
||||
}
|
||||
|
||||
const handleDeleteAttachment = async (attId) => {
|
||||
try {
|
||||
await api.delete(`/task-attachments/${attId}`)
|
||||
loadAttachments()
|
||||
} catch (err) {
|
||||
console.error('Delete attachment failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSetThumbnail = async (attachment) => {
|
||||
try {
|
||||
const attId = attachment._id || attachment.id || attachment.Id
|
||||
await api.patch(`/tasks/${taskId}/thumbnail`, { attachment_id: attId })
|
||||
const url = attachment.url || `/api/uploads/${attachment.filename}`
|
||||
setCurrentThumbnail(url)
|
||||
} catch (err) {
|
||||
console.error('Set thumbnail failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveThumbnail = async () => {
|
||||
try {
|
||||
await api.patch(`/tasks/${taskId}/thumbnail`, { attachment_id: null })
|
||||
setCurrentThumbnail(null)
|
||||
} catch (err) {
|
||||
console.error('Remove thumbnail failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get brand for the selected project
|
||||
const selectedProject = projects?.find(p => String(p._id || p.id) === String(form.project_id))
|
||||
const brandName = selectedProject ? (selectedProject.brand_name || selectedProject.brandName) : (task.brand_name || task.brandName)
|
||||
|
||||
const header = (
|
||||
<div className="px-5 py-4 border-b border-border shrink-0">
|
||||
{/* Thumbnail banner */}
|
||||
{currentThumbnail && (
|
||||
<div className="relative -mx-5 -mt-4 mb-3 h-32 overflow-hidden">
|
||||
<img src={currentThumbnail} alt="" className="w-full h-full object-cover" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-white/80 to-transparent" />
|
||||
<button
|
||||
onClick={handleRemoveThumbnail}
|
||||
className="absolute top-2 right-2 p-1 bg-black/40 hover:bg-black/60 rounded-full text-white transition-colors"
|
||||
title={t('tasks.removeThumbnail')}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
value={form.title}
|
||||
onChange={e => update('title', e.target.value)}
|
||||
className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0"
|
||||
placeholder={t('tasks.taskTitle')}
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className={`inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full font-medium ${priority.color === 'bg-gray-400' ? 'bg-gray-100 text-gray-600' : priority.color === 'bg-amber-400' ? 'bg-amber-100 text-amber-700' : priority.color === 'bg-orange-500' ? 'bg-orange-100 text-orange-700' : 'bg-red-100 text-red-700'}`}>
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${priority.color}`} />
|
||||
{priorityOptions.find(p => p.value === form.priority)?.label}
|
||||
</span>
|
||||
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${form.status === 'done' ? 'bg-emerald-100 text-emerald-700' : form.status === 'in_progress' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'}`}>
|
||||
{statusOptions.find(s => s.value === form.status)?.label}
|
||||
</span>
|
||||
{isOverdue && !isCreateMode && (
|
||||
<span className="text-[11px] px-2 py-0.5 rounded-full font-medium bg-red-100 text-red-600 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
{t('tasks.overdue')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SlidePanel onClose={onClose} maxWidth="420px" header={header}>
|
||||
{/* Details Section */}
|
||||
<CollapsibleSection title={t('tasks.details')}>
|
||||
<div className="px-5 pb-4 space-y-3">
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.description')}</label>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={e => update('description', e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
|
||||
placeholder={t('posts.optionalDetails')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Project */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.project')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={form.project_id}
|
||||
onChange={e => update('project_id', e.target.value)}
|
||||
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
<option value="">{t('tasks.noProject')}</option>
|
||||
{(projects || []).map(p => (
|
||||
<option key={p._id || p.id} value={p._id || p.id}>{p.name || p.title}</option>
|
||||
))}
|
||||
</select>
|
||||
{brandName && (
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded shrink-0 ${getBrandColor(brandName).bg} ${getBrandColor(brandName).text}`}>
|
||||
{brandName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Assignee */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.assignee')}</label>
|
||||
<select
|
||||
value={form.assigned_to}
|
||||
onChange={e => update('assigned_to', e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
<option value="">{t('common.unassigned')}</option>
|
||||
{(users || []).map(m => (
|
||||
<option key={m._id || m.team_member_id} value={m._id || m.team_member_id}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Priority & Status */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.priority')}</label>
|
||||
<select
|
||||
value={form.priority}
|
||||
onChange={e => update('priority', e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
{priorityOptions.map(p => (
|
||||
<option key={p.value} value={p.value}>{p.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.status')}</label>
|
||||
<select
|
||||
value={form.status}
|
||||
onChange={e => update('status', e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
{statusOptions.map(s => (
|
||||
<option key={s.value} value={s.value}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Start Date & Due Date */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.startDate')}</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.start_date}
|
||||
onChange={e => update('start_date', e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.dueDate')}</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.due_date}
|
||||
onChange={e => update('due_date', e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Created by (read-only) */}
|
||||
{creatorName && !isCreateMode && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.createdBy')}</label>
|
||||
<p className="text-sm text-text-secondary">{creatorName}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!form.title || saving}
|
||||
className={`flex-1 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${saving ? 'btn-loading' : ''}`}
|
||||
>
|
||||
{isCreateMode ? t('tasks.createTask') : t('tasks.saveChanges')}
|
||||
</button>
|
||||
)}
|
||||
{onDelete && !isCreateMode && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Attachments Section */}
|
||||
<CollapsibleSection
|
||||
title={t('tasks.attachments')}
|
||||
badge={(attachments.length + pendingFiles.length) > 0 ? (
|
||||
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-1.5 py-0.5 rounded-full">
|
||||
{attachments.length + pendingFiles.length}
|
||||
</span>
|
||||
) : null}
|
||||
>
|
||||
<div className="px-5 pb-4">
|
||||
{/* Existing attachment grid (edit mode) */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
{attachments.map(att => {
|
||||
const isImage = att.mime_type?.startsWith('image/') || att.mimeType?.startsWith('image/')
|
||||
const attUrl = att.url || `/api/uploads/${att.filename}`
|
||||
const name = att.original_name || att.originalName || att.filename
|
||||
const attId = att._id || att.id || att.Id
|
||||
const isThumbnail = currentThumbnail && attUrl === currentThumbnail
|
||||
|
||||
return (
|
||||
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
|
||||
<div className="h-20 relative">
|
||||
{isImage ? (
|
||||
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="block h-full">
|
||||
<img src={attUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
|
||||
</a>
|
||||
) : (
|
||||
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="absolute inset-0 flex items-center gap-2 p-3">
|
||||
<FileText className="w-6 h-6 text-text-tertiary shrink-0" />
|
||||
<span className="text-xs text-text-secondary truncate">{name}</span>
|
||||
</a>
|
||||
)}
|
||||
{isThumbnail && (
|
||||
<div className="absolute top-1 left-1 p-0.5 bg-amber-400 rounded-full text-white">
|
||||
<Star className="w-2.5 h-2.5 fill-current" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute top-1 right-1 flex items-center gap-0.5 opacity-0 group-hover/att:opacity-100 transition-opacity">
|
||||
{isImage && !isThumbnail && (
|
||||
<button
|
||||
onClick={() => handleSetThumbnail(att)}
|
||||
className="p-1 bg-black/50 hover:bg-amber-500 rounded-full text-white transition-colors"
|
||||
title={t('tasks.setAsThumbnail')}
|
||||
>
|
||||
<Star className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDeleteAttachment(attId)}
|
||||
className="p-1 bg-black/50 hover:bg-red-500 rounded-full text-white transition-colors"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<X className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">
|
||||
{name}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending files grid (create mode) */}
|
||||
{pendingFiles.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
{pendingFiles.map((file, i) => {
|
||||
const isImage = file.type?.startsWith('image/')
|
||||
const previewUrl = isImage ? URL.createObjectURL(file) : null
|
||||
|
||||
return (
|
||||
<div key={i} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
|
||||
<div className="h-20 relative">
|
||||
{isImage ? (
|
||||
<img src={previewUrl} alt={file.name} className="absolute inset-0 w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center gap-2 p-3">
|
||||
<FileText className="w-6 h-6 text-text-tertiary shrink-0" />
|
||||
<span className="text-xs text-text-secondary truncate">{file.name}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute top-1 right-1 flex items-center gap-0.5 opacity-0 group-hover/att:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
|
||||
className="p-1 bg-black/50 hover:bg-red-500 rounded-full text-white transition-colors"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<X className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">
|
||||
{file.name}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload area */}
|
||||
<div
|
||||
onClick={() => !uploading && fileInputRef.current?.click()}
|
||||
className={`border-2 border-dashed rounded-lg p-4 text-center transition-colors ${
|
||||
uploading ? 'cursor-not-allowed opacity-60 border-border' : 'cursor-pointer border-border hover:border-brand-primary/40'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={e => {
|
||||
setUploadError(null)
|
||||
const files = Array.from(e.target.files || [])
|
||||
const maxBytes = maxSizeMB * 1024 * 1024
|
||||
const tooBig = files.find(f => f.size > maxBytes)
|
||||
if (tooBig) {
|
||||
setUploadError(t('tasks.fileTooLarge')
|
||||
.replace('{name}', tooBig.name)
|
||||
.replace('{size}', (tooBig.size / 1024 / 1024).toFixed(1))
|
||||
.replace('{max}', maxSizeMB))
|
||||
e.target.value = ''
|
||||
return
|
||||
}
|
||||
if (isCreateMode) {
|
||||
if (files.length) setPendingFiles(files)
|
||||
} else {
|
||||
handleFileUpload(e.target.files)
|
||||
}
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<Upload className={`w-5 h-5 text-text-tertiary mx-auto mb-1 ${uploading ? 'animate-pulse' : ''}`} />
|
||||
<p className="text-xs text-text-secondary">
|
||||
{uploading ? t('posts.uploading') : t('tasks.dropOrClick')}
|
||||
</p>
|
||||
<p className="text-[10px] text-text-tertiary mt-0.5">
|
||||
{t('tasks.maxFileSize').replace('{size}', maxSizeMB)}
|
||||
</p>
|
||||
</div>
|
||||
{uploadError && (
|
||||
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded-lg text-xs text-red-600">
|
||||
{uploadError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Discussion Section (hidden in create mode) */}
|
||||
{!isCreateMode && (
|
||||
<CollapsibleSection title={t('tasks.discussion')} noBorder>
|
||||
<div className="px-5 pb-5">
|
||||
<CommentsSection entityType="task" entityId={taskId} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
</SlidePanel>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => setShowDeleteConfirm(false)}
|
||||
title={t('tasks.deleteTask')}
|
||||
isConfirm
|
||||
danger
|
||||
confirmText={t('tasks.deleteTask')}
|
||||
onConfirm={confirmDelete}
|
||||
>
|
||||
{t('tasks.deleteConfirm')}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user