feat: bulk delete, team dispatch, calendar views, timeline colors
Deploy / deploy (push) Successful in 11s
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:
@@ -50,15 +50,15 @@ export default function ProjectDetail() {
|
||||
|
||||
useEffect(() => { loadProject() }, [id])
|
||||
useEffect(() => {
|
||||
api.get('/users/assignable').then(res => setAssignableUsers(res.data || res || [])).catch(() => {})
|
||||
api.get('/users/assignable').then(res => setAssignableUsers(Array.isArray(res) ? res : [])).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const loadProject = async () => {
|
||||
try {
|
||||
const proj = await api.get(`/projects/${id}`)
|
||||
setProject(proj.data || proj)
|
||||
setProject(proj)
|
||||
const tasksRes = await api.get(`/tasks?project_id=${id}`)
|
||||
setTasks(Array.isArray(tasksRes) ? tasksRes : (tasksRes.data || []))
|
||||
setTasks(Array.isArray(tasksRes) ? tasksRes : [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load project:', err)
|
||||
} finally {
|
||||
@@ -458,7 +458,14 @@ export default function ProjectDetail() {
|
||||
)}
|
||||
|
||||
{/* ─── GANTT / TIMELINE VIEW ─── */}
|
||||
{view === 'gantt' && <GanttView tasks={tasks} project={project} onEditTask={openEditTask} />}
|
||||
{view === 'gantt' && <GanttView tasks={tasks} project={project} onEditTask={openEditTask} onTaskColorChange={async (taskId, color) => {
|
||||
try {
|
||||
await api.patch(`/tasks/${taskId}`, { color: color || '' })
|
||||
loadProject()
|
||||
} catch (err) {
|
||||
console.error('Task color update failed:', err)
|
||||
}
|
||||
}} />}
|
||||
</div>{/* end main content */}
|
||||
|
||||
{/* ─── DISCUSSION SIDEBAR ─── */}
|
||||
@@ -576,7 +583,35 @@ function TaskKanbanCard({ task, canEdit, canDelete, onClick, onDelete, onStatusC
|
||||
}
|
||||
|
||||
// ─── Gantt / Timeline View ──────────────────────────
|
||||
function GanttView({ tasks, project, onEditTask }) {
|
||||
const GANTT_ZOOM = [
|
||||
{ key: 'month', label: 'Month', pxPerDay: 8 },
|
||||
{ key: 'week', label: 'Week', pxPerDay: 20 },
|
||||
{ key: 'day', label: 'Day', pxPerDay: 48 },
|
||||
]
|
||||
|
||||
const GANTT_COLOR_PALETTE = [
|
||||
'#6366f1', '#ec4899', '#10b981', '#f59e0b',
|
||||
'#8b5cf6', '#06b6d4', '#f43f5e', '#14b8a6',
|
||||
'#3b82f6', '#ef4444', '#84cc16', '#a855f7',
|
||||
]
|
||||
|
||||
function GanttView({ tasks, project, onEditTask, onTaskColorChange }) {
|
||||
const [zoomIdx, setZoomIdx] = useState(0)
|
||||
const ganttRef = useRef(null)
|
||||
const [colorPicker, setColorPicker] = useState(null)
|
||||
const colorPickerRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!colorPicker) return
|
||||
const handleClick = (e) => {
|
||||
if (colorPickerRef.current && !colorPickerRef.current.contains(e.target)) {
|
||||
setColorPicker(null)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [colorPicker])
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-border py-16 text-center">
|
||||
@@ -590,17 +625,19 @@ function GanttView({ tasks, project, onEditTask }) {
|
||||
const today = startOfDay(new Date())
|
||||
|
||||
// Calculate range
|
||||
let earliest = today
|
||||
let latest = addDays(today, 21)
|
||||
let earliest = addDays(today, -7)
|
||||
let latest = addDays(today, 30)
|
||||
tasks.forEach(t => {
|
||||
const created = t.createdAt ? startOfDay(new Date(t.createdAt)) : today
|
||||
const start = t.startDate || t.start_date ? startOfDay(new Date(t.startDate || t.start_date)) : created
|
||||
const due = t.dueDate ? startOfDay(new Date(t.dueDate)) : null
|
||||
if (isBefore(created, earliest)) earliest = created
|
||||
if (due && isAfter(due, latest)) latest = addDays(due, 2)
|
||||
if (isBefore(start, earliest)) earliest = addDays(start, -3)
|
||||
if (isBefore(created, earliest)) earliest = addDays(created, -3)
|
||||
if (due && isAfter(due, latest)) latest = addDays(due, 7)
|
||||
})
|
||||
if (project.dueDate) {
|
||||
const pd = startOfDay(new Date(project.dueDate))
|
||||
if (isAfter(pd, latest)) latest = addDays(pd, 2)
|
||||
if (isAfter(pd, latest)) latest = addDays(pd, 7)
|
||||
}
|
||||
const totalDays = differenceInDays(latest, earliest) + 1
|
||||
|
||||
@@ -610,7 +647,7 @@ function GanttView({ tasks, project, onEditTask }) {
|
||||
days.push(addDays(earliest, i))
|
||||
}
|
||||
|
||||
const dayWidth = Math.max(36, Math.min(60, 800 / totalDays))
|
||||
const dayWidth = GANTT_ZOOM[zoomIdx].pxPerDay
|
||||
|
||||
const getBarStyle = (task) => {
|
||||
const start = task.startDate || task.start_date
|
||||
@@ -630,7 +667,38 @@ function GanttView({ tasks, project, onEditTask }) {
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
{/* Zoom toolbar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-surface-secondary">
|
||||
<div className="flex items-center gap-2">
|
||||
{GANTT_ZOOM.map((z, i) => (
|
||||
<button
|
||||
key={z.key}
|
||||
onClick={() => setZoomIdx(i)}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||
zoomIdx === i
|
||||
? 'bg-brand-primary text-white shadow-sm'
|
||||
: 'text-text-tertiary hover:text-text-primary hover:bg-surface-tertiary'
|
||||
}`}
|
||||
>
|
||||
{z.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (ganttRef.current) {
|
||||
const todayOff = differenceInDays(today, earliest) * dayWidth
|
||||
ganttRef.current.scrollTo({ left: Math.max(0, todayOff - 200), behavior: 'smooth' })
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-1 text-xs font-medium text-brand-primary hover:bg-brand-primary/10 rounded-md transition-colors"
|
||||
>
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
Today
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref={ganttRef} className="overflow-x-auto">
|
||||
<div style={{ minWidth: `${totalDays * dayWidth + 200}px` }}>
|
||||
{/* Day headers */}
|
||||
<div className="flex border-b border-border bg-surface-secondary sticky top-0 z-10">
|
||||
@@ -641,6 +709,8 @@ function GanttView({ tasks, project, onEditTask }) {
|
||||
{days.map((day, i) => {
|
||||
const isToday = differenceInDays(day, today) === 0
|
||||
const isWeekend = day.getDay() === 0 || day.getDay() === 6
|
||||
const isMonthStart = day.getDate() === 1
|
||||
const isWeekStart = day.getDay() === 1
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
@@ -648,10 +718,17 @@ function GanttView({ tasks, project, onEditTask }) {
|
||||
className={`text-center py-2 border-r border-border-light text-[10px] ${
|
||||
isToday ? 'bg-brand-primary/10 font-bold text-brand-primary' :
|
||||
isWeekend ? 'bg-surface-tertiary/50 text-text-tertiary' : 'text-text-tertiary'
|
||||
}`}
|
||||
} ${isMonthStart ? 'border-l-2 border-l-text-tertiary/30' : ''}`}
|
||||
>
|
||||
<div>{format(day, 'd')}</div>
|
||||
<div className="text-[8px] uppercase">{format(day, 'EEE')}</div>
|
||||
{dayWidth >= 30 && <div>{format(day, 'd')}</div>}
|
||||
{dayWidth >= 40 && <div className="text-[8px] uppercase">{format(day, 'EEE')}</div>}
|
||||
{dayWidth >= 15 && dayWidth < 30 && day.getDate() % 7 === 1 && <div>{format(day, 'd')}</div>}
|
||||
{dayWidth < 15 && isMonthStart && (
|
||||
<div className="text-[8px] font-semibold whitespace-nowrap">{format(day, 'MMM')}</div>
|
||||
)}
|
||||
{dayWidth < 15 && !isMonthStart && isWeekStart && day.getDate() % 14 <= 7 && (
|
||||
<div className="text-[8px]">{format(day, 'd')}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -665,7 +742,20 @@ function GanttView({ tasks, project, onEditTask }) {
|
||||
return (
|
||||
<div key={task._id} className="flex border-b border-border-light hover:bg-surface-secondary/50 group">
|
||||
<div className="w-[200px] shrink-0 px-4 py-3 border-r border-border flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${prio.color} shrink-0`} />
|
||||
{onTaskColorChange && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const taskId = task._id || task.id
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
setColorPicker(colorPicker?.taskId === taskId ? null : { taskId, x: rect.left, y: rect.bottom + 4 })
|
||||
}}
|
||||
className={`w-3.5 h-3.5 rounded-full border border-white shadow-sm shrink-0 hover:scale-125 transition-transform ${!task.color ? (statusColors[task.status] || 'bg-gray-300') : ''}`}
|
||||
style={task.color ? { backgroundColor: task.color } : undefined}
|
||||
title="Change color"
|
||||
/>
|
||||
)}
|
||||
{!onTaskColorChange && <div className={`w-2 h-2 rounded-full ${prio.color} shrink-0`} />}
|
||||
<button onClick={() => onEditTask(task)}
|
||||
className="text-xs font-medium text-text-primary truncate hover:text-brand-primary text-left">
|
||||
{task.title}
|
||||
@@ -681,8 +771,8 @@ function GanttView({ tasks, project, onEditTask }) {
|
||||
)}
|
||||
{/* Bar */}
|
||||
<div
|
||||
className={`absolute top-2.5 h-5 rounded-full ${statusColors[task.status] || 'bg-gray-300'} opacity-80 hover:opacity-100 transition-opacity cursor-pointer`}
|
||||
style={barStyle}
|
||||
className={`absolute top-2.5 h-5 rounded-full ${task.color ? '' : (statusColors[task.status] || 'bg-gray-300')} opacity-80 hover:opacity-100 transition-opacity cursor-pointer`}
|
||||
style={{ ...barStyle, ...(task.color ? { backgroundColor: task.color } : {}) }}
|
||||
onClick={() => onEditTask(task)}
|
||||
title={`${task.title}${task.dueDate ? ` — Due ${format(new Date(task.dueDate), 'MMM d')}` : ''}`}
|
||||
/>
|
||||
@@ -692,6 +782,38 @@ function GanttView({ tasks, project, onEditTask }) {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color Picker Popover */}
|
||||
{colorPicker && onTaskColorChange && (
|
||||
<div
|
||||
ref={colorPickerRef}
|
||||
className="fixed z-50 bg-white rounded-lg shadow-xl border border-border p-2"
|
||||
style={{ left: colorPicker.x, top: colorPicker.y }}
|
||||
>
|
||||
<div className="grid grid-cols-4 gap-1.5 mb-2">
|
||||
{GANTT_COLOR_PALETTE.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => {
|
||||
onTaskColorChange(colorPicker.taskId, c)
|
||||
setColorPicker(null)
|
||||
}}
|
||||
className="w-7 h-7 rounded-full border-2 border-transparent hover:border-gray-400 hover:scale-110 transition-all"
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
onTaskColorChange(colorPicker.taskId, null)
|
||||
setColorPicker(null)
|
||||
}}
|
||||
className="w-full text-[10px] text-text-tertiary hover:text-text-primary text-center py-1 hover:bg-surface-tertiary rounded transition-colors"
|
||||
>
|
||||
Reset to default
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user