- 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)
554 lines
24 KiB
JavaScript
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>
|
|
</>
|
|
)
|
|
}
|