feat: bulk delete, team dispatch, calendar views, timeline colors
Deploy / deploy (push) Successful in 11s

- Multi-select bulk delete in all 5 list views (Artefacts, Posts, Tasks,
  Issues, Assets) with cascade deletes and confirmation modals
- Team-based issue dispatch: team picker on public issue form, team filter
  on Issues page, copy public link from Team page and Issues header,
  team assignment in IssueDetailPanel
- Month/Week toggle on PostCalendar and TaskCalendarView
- Month/Week/Day zoom on project and campaign timelines (InteractiveTimeline)
  and ProjectDetail GanttView, with Month as default
- Custom timeline bar colors: clickable color dot with 12-color palette
  popover on project, campaign, and task timeline bars
- Artefacts default view changed to list
- BulkSelectBar reusable component
- i18n keys for all new features (en + ar)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
fahed
2026-03-01 14:55:36 +03:00
parent 20d76dea8b
commit 42a5f17d0b
40 changed files with 3050 additions and 1625 deletions
+59 -28
View File
@@ -1,28 +1,17 @@
import { useState, useEffect, useContext } from 'react'
import { X, Copy, Eye, Lock, Send, Upload, FileText, Trash2, Check, Clock, CheckCircle2, XCircle } from 'lucide-react'
import { api } from '../utils/api'
import { api, STATUS_CONFIG, PRIORITY_CONFIG } from '../utils/api'
import SlidePanel from './SlidePanel'
import FormInput from './FormInput'
import Modal from './Modal'
import { useToast } from './ToastContainer'
import { AppContext } from '../App'
import { useLanguage } from '../i18n/LanguageContext'
const STATUS_CONFIG = {
new: { label: 'New', bg: 'bg-purple-100', text: 'text-purple-700', dot: 'bg-purple-500' },
acknowledged: { label: 'Acknowledged', bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500' },
in_progress: { label: 'In Progress', bg: 'bg-amber-100', text: 'text-amber-700', dot: 'bg-amber-500' },
resolved: { label: 'Resolved', bg: 'bg-emerald-100', text: 'text-emerald-700', dot: 'bg-emerald-500' },
declined: { label: 'Declined', bg: 'bg-surface-tertiary', text: 'text-text-secondary', dot: 'bg-text-tertiary' },
}
const PRIORITY_CONFIG = {
low: { label: 'Low', bg: 'bg-surface-tertiary', text: 'text-text-secondary' },
medium: { label: 'Medium', bg: 'bg-blue-100', text: 'text-blue-700' },
high: { label: 'High', bg: 'bg-orange-100', text: 'text-orange-700' },
urgent: { label: 'Urgent', bg: 'bg-red-100', text: 'text-red-700' },
}
export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers }) {
export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers, teams = [] }) {
const { brands } = useContext(AppContext)
const toast = useToast()
const { t } = useLanguage()
const [issueData, setIssueData] = useState(null)
const [updates, setUpdates] = useState([])
const [attachments, setAttachments] = useState([])
@@ -32,6 +21,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
// Form state
const [assignedTo, setAssignedTo] = useState('')
const [teamId, setTeamId] = useState('')
const [internalNotes, setInternalNotes] = useState('')
const [resolutionSummary, setResolutionSummary] = useState('')
const [newUpdate, setNewUpdate] = useState('')
@@ -40,6 +30,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
// Modals
const [showResolveModal, setShowResolveModal] = useState(false)
const [showDeclineModal, setShowDeclineModal] = useState(false)
const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
const issueId = issue?.Id || issue?.id
@@ -54,6 +45,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
setUpdates(data.updates || [])
setAttachments(data.attachments || [])
setAssignedTo(data.assigned_to_id || '')
setTeamId(data.team_id || '')
setInternalNotes(data.internal_notes || '')
setResolutionSummary(data.resolution_summary || '')
} catch (err) {
@@ -72,7 +64,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
await loadIssueDetails()
} catch (err) {
console.error('Failed to update status:', err)
alert('Failed to update status')
toast.error(t('issues.failedToUpdateStatus'))
} finally {
setSaving(false)
}
@@ -88,7 +80,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
await loadIssueDetails()
} catch (err) {
console.error('Failed to resolve issue:', err)
alert('Failed to resolve issue')
toast.error(t('issues.failedToResolve'))
} finally {
setSaving(false)
}
@@ -104,7 +96,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
await loadIssueDetails()
} catch (err) {
console.error('Failed to decline issue:', err)
alert('Failed to decline issue')
toast.error(t('issues.failedToDecline'))
} finally {
setSaving(false)
}
@@ -117,7 +109,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
await onUpdate()
} catch (err) {
console.error('Failed to update assignment:', err)
alert('Failed to update assignment')
toast.error(t('issues.failedToUpdateAssignment'))
}
}
@@ -128,7 +120,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
await api.patch(`/issues/${issueId}`, { internal_notes: internalNotes })
} catch (err) {
console.error('Failed to save notes:', err)
alert('Failed to save notes')
toast.error(t('issues.failedToSaveNotes'))
} finally {
setSaving(false)
}
@@ -144,7 +136,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
await loadIssueDetails()
} catch (err) {
console.error('Failed to add update:', err)
alert('Failed to add update')
toast.error(t('issues.failedToAddUpdate'))
} finally {
setSaving(false)
}
@@ -162,27 +154,26 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
e.target.value = '' // Reset input
} catch (err) {
console.error('Failed to upload file:', err)
alert('Failed to upload file')
toast.error(t('issues.failedToUploadFile'))
} finally {
setUploadingFile(false)
}
}
const handleDeleteAttachment = async (attachmentId) => {
if (!confirm('Delete this attachment?')) return
try {
await api.delete(`/issue-attachments/${attachmentId}`)
await loadIssueDetails()
} catch (err) {
console.error('Failed to delete attachment:', err)
alert('Failed to delete attachment')
toast.error(t('issues.failedToDeleteAttachment'))
}
}
const copyTrackingLink = () => {
const url = `${window.location.origin}/track/${issueData.tracking_token}`
navigator.clipboard.writeText(url)
alert('Tracking link copied to clipboard!')
toast.success(t('issues.trackingLinkCopied'))
}
const formatDate = (dateStr) => {
@@ -283,6 +274,33 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
</select>
</div>
{/* Team */}
{teams.length > 0 && (
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.team')}</label>
<select
value={teamId}
onChange={async (e) => {
const val = e.target.value || null
setTeamId(val || '')
try {
await api.patch(`/issues/${issueId}`, { team_id: val })
await onUpdate()
await loadIssueDetails()
} catch (err) {
console.error('Failed to update team:', err)
}
}}
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"
>
<option value="">{t('issues.allTeams')}</option>
{teams.map((team) => (
<option key={team.id || team._id} value={team.id || team._id}>{team.name}</option>
))}
</select>
</div>
)}
{/* Brand */}
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">Brand</label>
@@ -504,7 +522,7 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
>
Download
</a>
<button onClick={() => handleDeleteAttachment(att.Id || att.id)} className="p-1 hover:bg-surface-tertiary rounded">
<button onClick={() => setConfirmDeleteAttId(att.Id || att.id)} className="p-1 hover:bg-surface-tertiary rounded">
<Trash2 className="w-4 h-4 text-red-600" />
</button>
</div>
@@ -579,6 +597,19 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
</div>
</Modal>
)}
{/* Delete Attachment Confirmation */}
<Modal
isOpen={!!confirmDeleteAttId}
onClose={() => setConfirmDeleteAttId(null)}
title={t('issues.deleteAttachment')}
isConfirm
danger
onConfirm={() => handleDeleteAttachment(confirmDeleteAttId)}
confirmText={t('common.delete')}
>
{t('issues.deleteAttachmentDesc')}
</Modal>
</>
)
}