feat: bulk delete, team dispatch, calendar views, timeline colors
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:
fahed
2026-03-01 14:55:36 +03:00
parent 20d76dea8b
commit 42a5f17d0b
40 changed files with 3050 additions and 1625 deletions

View File

@@ -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