feat: bulk delete, team dispatch, calendar views, timeline colors
All checks were successful
Deploy / deploy (push) Successful in 11s
All checks were successful
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:
@@ -33,8 +33,15 @@ const PRIORITY_BORDER = {
|
||||
}
|
||||
|
||||
const ZOOM_LEVELS = [
|
||||
{ key: 'day', label: 'Day', pxPerDay: 48 },
|
||||
{ key: 'month', label: 'Month', pxPerDay: 8 },
|
||||
{ key: 'week', label: 'Week', pxPerDay: 20 },
|
||||
{ key: 'day', label: 'Day', pxPerDay: 48 },
|
||||
]
|
||||
|
||||
const COLOR_PALETTE = [
|
||||
'#6366f1', '#ec4899', '#10b981', '#f59e0b',
|
||||
'#8b5cf6', '#06b6d4', '#f43f5e', '#14b8a6',
|
||||
'#3b82f6', '#ef4444', '#84cc16', '#a855f7',
|
||||
]
|
||||
|
||||
function getInitials(name) {
|
||||
@@ -42,7 +49,7 @@ function getInitials(name) {
|
||||
return name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2)
|
||||
}
|
||||
|
||||
export default function InteractiveTimeline({ items = [], mapItem, onDateChange, onItemClick, readOnly = false }) {
|
||||
export default function InteractiveTimeline({ items = [], mapItem, onDateChange, onColorChange, onItemClick, readOnly = false }) {
|
||||
const containerRef = useRef(null)
|
||||
const didDragRef = useRef(false)
|
||||
const optimisticRef = useRef({}) // { [itemId]: { startDate, endDate } } — keeps bar in place until fresh data arrives
|
||||
@@ -51,10 +58,24 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
const [tooltip, setTooltip] = useState(null)
|
||||
const [dragState, setDragState] = useState(null) // { itemId, mode: 'move'|'resize-left'|'resize-right', startX, origStart, origEnd }
|
||||
const dragStateRef = useRef(null)
|
||||
const [colorPicker, setColorPicker] = useState(null) // { itemId, x, y }
|
||||
const colorPickerRef = useRef(null)
|
||||
|
||||
const pxPerDay = ZOOM_LEVELS[zoomIdx].pxPerDay
|
||||
const today = useMemo(() => startOfDay(new Date()), [])
|
||||
|
||||
// Close color picker on outside click
|
||||
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])
|
||||
|
||||
// Clear optimistic overrides when fresh data arrives
|
||||
useEffect(() => {
|
||||
optimisticRef.current = {}
|
||||
@@ -273,6 +294,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
const isToday = differenceInDays(day, today) === 0
|
||||
const isWeekend = day.getDay() === 0 || day.getDay() === 6
|
||||
const isMonthStart = day.getDate() === 1
|
||||
const isWeekStart = day.getDay() === 1 // Monday
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
@@ -285,7 +307,13 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
>
|
||||
{pxPerDay >= 30 && <div>{format(day, 'd')}</div>}
|
||||
{pxPerDay >= 40 && <div className="text-[8px] uppercase">{format(day, 'EEE')}</div>}
|
||||
{pxPerDay < 30 && day.getDate() % 7 === 1 && <div>{format(day, 'd')}</div>}
|
||||
{pxPerDay >= 15 && pxPerDay < 30 && day.getDate() % 7 === 1 && <div>{format(day, 'd')}</div>}
|
||||
{pxPerDay < 15 && isMonthStart && (
|
||||
<div className="text-[8px] font-semibold whitespace-nowrap">{format(day, 'MMM')}</div>
|
||||
)}
|
||||
{pxPerDay < 15 && !isMonthStart && isWeekStart && day.getDate() % 14 <= 7 && (
|
||||
<div className="text-[8px]">{format(day, 'd')}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -295,7 +323,8 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
{/* Rows */}
|
||||
{mapped.map((item, idx) => {
|
||||
const { left, width } = getBarPosition(item)
|
||||
const statusColor = STATUS_COLORS[item.status] || STATUS_COLORS[item.type] || 'bg-gray-400'
|
||||
const hasCustomColor = !!item.color
|
||||
const statusColor = hasCustomColor ? '' : (STATUS_COLORS[item.status] || STATUS_COLORS[item.type] || 'bg-gray-400')
|
||||
const priorityRing = PRIORITY_BORDER[item.priority] || ''
|
||||
const isDragging = dragState?.itemId === item.id
|
||||
|
||||
@@ -313,6 +342,18 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{onColorChange && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
setColorPicker(colorPicker?.itemId === item.id ? null : { itemId: item.id, x: rect.left, y: rect.bottom + 4 })
|
||||
}}
|
||||
className={`w-5 h-5 rounded-full border-2 border-white shadow-sm shrink-0 hover:scale-110 transition-transform ${!item.color ? (STATUS_COLORS[item.status] || 'bg-gray-400') : ''}`}
|
||||
style={item.color ? { backgroundColor: item.color } : undefined}
|
||||
title="Change color"
|
||||
/>
|
||||
)}
|
||||
{item.thumbnailUrl ? (
|
||||
<div className="w-8 h-8 rounded overflow-hidden shrink-0">
|
||||
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" />
|
||||
@@ -337,6 +378,18 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{onColorChange && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
setColorPicker(colorPicker?.itemId === item.id ? null : { itemId: item.id, x: rect.left, y: rect.bottom + 4 })
|
||||
}}
|
||||
className={`w-4 h-4 rounded-full border-2 border-white shadow-sm shrink-0 hover:scale-110 transition-transform ${!item.color ? (STATUS_COLORS[item.status] || 'bg-gray-400') : ''}`}
|
||||
style={item.color ? { backgroundColor: item.color } : undefined}
|
||||
title="Change color"
|
||||
/>
|
||||
)}
|
||||
{item.thumbnailUrl ? (
|
||||
<div className="w-6 h-6 rounded overflow-hidden shrink-0">
|
||||
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" />
|
||||
@@ -377,6 +430,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
width: `${width}px`,
|
||||
height: `${barHeight}px`,
|
||||
top: isExpanded ? '8px' : '8px',
|
||||
...(hasCustomColor ? { backgroundColor: item.color } : {}),
|
||||
}}
|
||||
onMouseDown={(e) => handleMouseDown(e, item, 'move')}
|
||||
onClick={(e) => {
|
||||
@@ -476,6 +530,38 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color Picker Popover */}
|
||||
{colorPicker && onColorChange && (
|
||||
<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">
|
||||
{COLOR_PALETTE.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => {
|
||||
onColorChange(colorPicker.itemId, 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={() => {
|
||||
onColorChange(colorPicker.itemId, 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>
|
||||
)}
|
||||
|
||||
{/* Tooltip */}
|
||||
{tooltip && !dragState && (
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user