786 lines
22 KiB
Vue
786 lines
22 KiB
Vue
<template>
|
|
<Loading
|
|
v-if="(isLoading && !ganttBars.length) || dayjsLanguageLoading"
|
|
class="gantt-container"
|
|
/>
|
|
<div
|
|
v-else
|
|
ref="ganttContainer"
|
|
class="gantt-container"
|
|
role="application"
|
|
:aria-label="$t('project.gantt.chartLabel')"
|
|
>
|
|
<div class="gantt-chart-wrapper">
|
|
<GanttTimelineHeader
|
|
:timeline-data="timelineData"
|
|
:day-width-pixels="DAY_WIDTH_PIXELS"
|
|
/>
|
|
|
|
<GanttVerticalGridLines
|
|
:timeline-data="timelineData"
|
|
:total-width="totalWidth"
|
|
:height="ganttRows.length * 40"
|
|
:day-width-pixels="DAY_WIDTH_PIXELS"
|
|
/>
|
|
|
|
<GanttChartBody
|
|
ref="ganttChartBodyRef"
|
|
:rows="ganttRows"
|
|
:cells-by-row="cellsByRow"
|
|
@update:focused="handleFocusChange"
|
|
@enterPressed="handleEnterPressed"
|
|
>
|
|
<template #default="{ focusedRow, focusedCell }">
|
|
<div class="gantt-rows-container">
|
|
<!-- Group background bands for parent-child visual grouping -->
|
|
<div
|
|
v-for="(band, bandIndex) in parentGroupBands"
|
|
:key="`band-${bandIndex}`"
|
|
class="gantt-group-band"
|
|
:style="{
|
|
top: `${band.startIndex * ROW_HEIGHT}px`,
|
|
height: `${(band.endIndex - band.startIndex + 1) * ROW_HEIGHT}px`,
|
|
left: `${band.left}px`,
|
|
width: `${band.width}px`,
|
|
}"
|
|
/>
|
|
<div class="gantt-rows">
|
|
<GanttRow
|
|
v-for="(rowId, index) in ganttRows"
|
|
:id="rowId"
|
|
:key="rowId"
|
|
:index="index"
|
|
>
|
|
<div class="gantt-row-content">
|
|
<GanttRowBars
|
|
:bars="ganttBars[index] ?? []"
|
|
:total-width="totalWidth"
|
|
:date-from-date="dateFromDate"
|
|
:date-to-date="dateToDate"
|
|
:day-width-pixels="DAY_WIDTH_PIXELS"
|
|
:is-dragging="isDragging"
|
|
:is-resizing="isResizing"
|
|
:drag-state="dragState"
|
|
:focused-row="focusedRow ?? null"
|
|
:focused-cell="focusedCell"
|
|
:row-id="rowId"
|
|
:is-parent="ganttBars[index]?.[0]?.meta?.isParent ?? false"
|
|
:is-collapsed="collapsedTaskIds.has(Number(ganttBars[index]?.[0]?.id))"
|
|
@barPointerDown="handleBarPointerDown"
|
|
@startResize="startResize"
|
|
@updateTask="updateGanttTask"
|
|
@toggleCollapse="toggleCollapse(Number(ganttBars[index]?.[0]?.id))"
|
|
/>
|
|
</div>
|
|
</GanttRow>
|
|
</div>
|
|
<GanttRelationArrows
|
|
v-if="relationArrows.length > 0"
|
|
:arrows="relationArrows"
|
|
:width="totalWidth"
|
|
:height="totalHeight"
|
|
:row-height="ROW_HEIGHT"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</GanttChartBody>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import {computed, ref, watch, toRefs, onUnmounted} from 'vue'
|
|
import {useRouter} from 'vue-router'
|
|
import dayjs from 'dayjs'
|
|
import {useDayjsLanguageSync} from '@/i18n/useDayjsLanguageSync'
|
|
|
|
import {getHexColor} from '@/models/task'
|
|
import {buildGanttTaskTree, type GanttTaskTreeNode} from '@/helpers/ganttTaskTree'
|
|
import {buildRelationArrows, type GanttBarPosition, type GanttArrow} from '@/helpers/ganttRelationArrows'
|
|
|
|
import type {ITask, ITaskPartialWithId} from '@/modelTypes/ITask'
|
|
import type {DateISO} from '@/types/DateISO'
|
|
import type {GanttFilters} from '@/views/project/helpers/useGanttFilters'
|
|
import type {GanttBarModel, GanttBarDateType} from '@/composables/useGanttBar'
|
|
|
|
import GanttChartBody from '@/components/gantt/GanttChartBody.vue'
|
|
import GanttRow from '@/components/gantt/GanttRow.vue'
|
|
import GanttRowBars from '@/components/gantt/GanttRowBars.vue'
|
|
import GanttVerticalGridLines from '@/components/gantt/GanttVerticalGridLines.vue'
|
|
import GanttTimelineHeader from '@/components/gantt/GanttTimelineHeader.vue'
|
|
import GanttRelationArrows from '@/components/gantt/GanttRelationArrows.vue'
|
|
import Loading from '@/components/misc/Loading.vue'
|
|
|
|
import {MILLISECONDS_A_DAY} from '@/constants/date'
|
|
import {roundToNaturalDayBoundary} from '@/helpers/time/roundToNaturalDayBoundary'
|
|
|
|
const props = defineProps<{
|
|
isLoading: boolean,
|
|
filters: GanttFilters,
|
|
tasks: Map<ITask['id'], ITask>,
|
|
defaultTaskStartDate: DateISO
|
|
defaultTaskEndDate: DateISO
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'update:task', task: ITaskPartialWithId): void
|
|
}>()
|
|
|
|
const DAY_WIDTH_PIXELS = 30
|
|
|
|
const {tasks, filters} = toRefs(props)
|
|
|
|
const dayjsLanguageLoading = useDayjsLanguageSync(dayjs)
|
|
const ganttContainer = ref(null)
|
|
const ganttChartBodyRef = ref<InstanceType<typeof GanttChartBody> | null>(null)
|
|
const router = useRouter()
|
|
|
|
const isDragging = ref(false)
|
|
const isResizing = ref(false)
|
|
|
|
const currentFocusedRow = ref<string | null>(null)
|
|
const currentFocusedCell = ref<number | null>(null)
|
|
|
|
const dragState = ref<{
|
|
barId: string
|
|
startX: number
|
|
originalStart: Date
|
|
originalEnd: Date
|
|
currentDays: number
|
|
edge?: 'start' | 'end'
|
|
} | null>(null)
|
|
|
|
let dragMoveHandler: ((e: PointerEvent) => void) | null = null
|
|
let dragStopHandler: (() => void) | null = null
|
|
|
|
const dateFromDate = computed(() => dayjs(filters.value.dateFrom).startOf('day').toDate())
|
|
const dateToDate = computed(() => dayjs(filters.value.dateTo).endOf('day').toDate())
|
|
|
|
const totalWidth = computed(() => {
|
|
const dateDiff = Math.ceil((dateToDate.value.valueOf() - dateFromDate.value.valueOf()) / MILLISECONDS_A_DAY)
|
|
return dateDiff * DAY_WIDTH_PIXELS
|
|
})
|
|
|
|
const timelineData = computed(() => {
|
|
const dates: Date[] = []
|
|
const currentDate = new Date(dateFromDate.value)
|
|
|
|
while (currentDate <= dateToDate.value) {
|
|
dates.push(new Date(currentDate))
|
|
currentDate.setDate(currentDate.getDate() + 1)
|
|
}
|
|
|
|
return dates
|
|
})
|
|
|
|
const ganttBars = ref<GanttBarModel[][]>([])
|
|
const ganttRows = ref<string[]>([])
|
|
const cellsByRow = ref<Record<string, string[]>>({})
|
|
|
|
// Hierarchy state
|
|
const collapsedTaskIds = ref(new Set<number>())
|
|
const allNodes = ref<GanttTaskTreeNode[]>([])
|
|
|
|
const visibleNodes = computed(() => {
|
|
const result: GanttTaskTreeNode[] = []
|
|
const hiddenParents = new Set<number>()
|
|
|
|
for (const node of allNodes.value) {
|
|
const parents = node.task.relatedTasks?.parenttask ?? []
|
|
const isHidden = parents.some(p =>
|
|
collapsedTaskIds.value.has(p.id) || hiddenParents.has(p.id),
|
|
)
|
|
|
|
if (isHidden) {
|
|
hiddenParents.add(node.task.id)
|
|
continue
|
|
}
|
|
|
|
result.push(node)
|
|
}
|
|
|
|
return result
|
|
})
|
|
|
|
// Map hidden tasks to their visible ancestor for arrow re-routing
|
|
const hiddenToAncestor = computed(() => {
|
|
const map = new Map<number, number>()
|
|
const hiddenParents = new Set<number>()
|
|
|
|
for (const node of allNodes.value) {
|
|
const parents = node.task.relatedTasks?.parenttask ?? []
|
|
const collapsedParent = parents.find(p =>
|
|
collapsedTaskIds.value.has(p.id),
|
|
)
|
|
|
|
if (collapsedParent && tasks.value.has(collapsedParent.id)) {
|
|
map.set(node.task.id, collapsedParent.id)
|
|
hiddenParents.add(node.task.id)
|
|
} else {
|
|
const hiddenAncestor = parents.find(p => hiddenParents.has(p.id))
|
|
if (hiddenAncestor) {
|
|
const ancestorTarget = map.get(hiddenAncestor.id) ?? hiddenAncestor.id
|
|
map.set(node.task.id, ancestorTarget)
|
|
hiddenParents.add(node.task.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
return map
|
|
})
|
|
|
|
function toggleCollapse(taskId: number) {
|
|
const newSet = new Set(collapsedTaskIds.value)
|
|
if (newSet.has(taskId)) {
|
|
newSet.delete(taskId)
|
|
} else {
|
|
newSet.add(taskId)
|
|
}
|
|
collapsedTaskIds.value = newSet
|
|
}
|
|
|
|
function getRoundedDate(value: string | Date | undefined, fallback: Date | string, isStart: boolean) {
|
|
return roundToNaturalDayBoundary(value ? new Date(value) : new Date(fallback), isStart)
|
|
}
|
|
|
|
function transformTaskToGanttBar(node: GanttTaskTreeNode): GanttBarModel {
|
|
const t = node.task
|
|
const DEFAULT_SPAN_DAYS = 7
|
|
|
|
// Use derived dates for dateless parents
|
|
const effectiveEndDate = t.endDate || t.dueDate || (node.hasDerivedDates ? node.derivedEndDate : null)
|
|
const effectiveStartDate = t.startDate || (node.hasDerivedDates ? node.derivedStartDate : null)
|
|
|
|
let startDate: Date
|
|
let endDate: Date
|
|
let dateType: GanttBarDateType
|
|
|
|
if (effectiveStartDate && effectiveEndDate) {
|
|
startDate = getRoundedDate(effectiveStartDate, effectiveStartDate, true)
|
|
endDate = getRoundedDate(effectiveEndDate, effectiveEndDate, false)
|
|
dateType = 'both'
|
|
} else if (effectiveStartDate && !effectiveEndDate) {
|
|
startDate = getRoundedDate(effectiveStartDate, effectiveStartDate, true)
|
|
const defaultEnd = new Date(startDate)
|
|
defaultEnd.setDate(defaultEnd.getDate() + DEFAULT_SPAN_DAYS)
|
|
endDate = getRoundedDate(defaultEnd, defaultEnd, false)
|
|
dateType = 'startOnly'
|
|
} else if (!effectiveStartDate && effectiveEndDate) {
|
|
endDate = getRoundedDate(effectiveEndDate, effectiveEndDate, false)
|
|
const defaultStart = new Date(endDate)
|
|
defaultStart.setDate(defaultStart.getDate() - DEFAULT_SPAN_DAYS)
|
|
startDate = getRoundedDate(defaultStart, defaultStart, true)
|
|
dateType = 'endOnly'
|
|
} else {
|
|
startDate = getRoundedDate(undefined, props.defaultTaskStartDate, true)
|
|
endDate = getRoundedDate(undefined, props.defaultTaskEndDate, false)
|
|
dateType = 'both'
|
|
}
|
|
|
|
const taskColor = getHexColor(t.hexColor)
|
|
|
|
return {
|
|
id: String(t.id),
|
|
start: startDate,
|
|
end: endDate,
|
|
meta: {
|
|
label: t.title,
|
|
task: t,
|
|
color: taskColor,
|
|
hasActualDates: Boolean(t.startDate && (t.endDate || t.dueDate)),
|
|
dateType,
|
|
isDone: t.done,
|
|
isParent: node.isParent,
|
|
hasDerivedDates: node.hasDerivedDates,
|
|
indentLevel: node.indentLevel,
|
|
},
|
|
}
|
|
}
|
|
|
|
// Build the task tree when tasks change
|
|
watch(
|
|
[tasks, filters],
|
|
() => {
|
|
allNodes.value = buildGanttTaskTree(tasks.value)
|
|
},
|
|
{deep: true, immediate: true},
|
|
)
|
|
|
|
// Derive bars, rows, and cells from visible nodes
|
|
watch(
|
|
[visibleNodes, filters],
|
|
() => {
|
|
const bars: GanttBarModel[] = []
|
|
const rows: string[] = []
|
|
const cells: Record<string, string[]> = {}
|
|
|
|
visibleNodes.value.forEach((node, index) => {
|
|
const bar = transformTaskToGanttBar(node)
|
|
|
|
// Check if task is visible in the current date range
|
|
const hasAnyDate = Boolean(node.task.startDate || node.task.endDate || node.task.dueDate || node.hasDerivedDates)
|
|
if (!filters.value.showTasksWithoutDates && !hasAnyDate) {
|
|
return
|
|
}
|
|
if (bar.start > dateToDate.value || bar.end < dateFromDate.value) {
|
|
return
|
|
}
|
|
|
|
bars.push(bar)
|
|
|
|
const rowId = `row-${index}`
|
|
rows.push(rowId)
|
|
|
|
const rowCells: string[] = []
|
|
timelineData.value.forEach((_, dayIndex) => {
|
|
rowCells.push(`${rowId}-cell-${dayIndex}`)
|
|
})
|
|
cells[rowId] = rowCells
|
|
})
|
|
|
|
ganttBars.value = bars.map(bar => [bar])
|
|
ganttRows.value = rows
|
|
cellsByRow.value = cells
|
|
},
|
|
{deep: true, immediate: true},
|
|
)
|
|
|
|
// Compute bar positions for arrow rendering
|
|
const ROW_HEIGHT = 40
|
|
|
|
const barPositions = computed(() => {
|
|
const positions = new Map<number, GanttBarPosition>()
|
|
const ds = dragState.value
|
|
const dragPixelOffset = ds ? ds.currentDays * DAY_WIDTH_PIXELS : 0
|
|
|
|
ganttBars.value.forEach((rowBars, rowIndex) => {
|
|
for (const bar of rowBars) {
|
|
const taskId = Number(bar.id)
|
|
let x = computeBarX(bar.start)
|
|
let width = computeBarWidth(bar)
|
|
const y = rowIndex * ROW_HEIGHT + ROW_HEIGHT / 2
|
|
|
|
// Apply drag/resize offset for the active bar
|
|
if (ds && bar.id === ds.barId && dragPixelOffset !== 0) {
|
|
if (isDragging.value) {
|
|
x += dragPixelOffset
|
|
} else if (isResizing.value) {
|
|
if (ds.edge === 'start') {
|
|
x += dragPixelOffset
|
|
width -= dragPixelOffset
|
|
} else {
|
|
width += dragPixelOffset
|
|
}
|
|
}
|
|
}
|
|
|
|
positions.set(taskId, {x, y, width, rowIndex})
|
|
}
|
|
})
|
|
|
|
return positions
|
|
})
|
|
|
|
function computeBarX(date: Date): number {
|
|
const diff = Math.ceil(
|
|
(roundToNaturalDayBoundary(date, true).getTime() - dateFromDate.value.getTime()) /
|
|
MILLISECONDS_A_DAY,
|
|
)
|
|
return diff * DAY_WIDTH_PIXELS
|
|
}
|
|
|
|
function computeBarWidth(bar: GanttBarModel): number {
|
|
const diff = Math.ceil(
|
|
(roundToNaturalDayBoundary(bar.end).getTime() - roundToNaturalDayBoundary(bar.start, true).getTime()) /
|
|
MILLISECONDS_A_DAY,
|
|
)
|
|
return diff * DAY_WIDTH_PIXELS
|
|
}
|
|
|
|
// Compute relation arrows
|
|
const relationArrows = computed<GanttArrow[]>(() => {
|
|
return buildRelationArrows(tasks.value, barPositions.value, hiddenToAncestor.value)
|
|
})
|
|
|
|
const totalHeight = computed(() => ganttRows.value.length * ROW_HEIGHT)
|
|
|
|
// Compute parent-child group bands for visual grouping background
|
|
const GROUP_BAND_PADDING = 12
|
|
|
|
const parentGroupBands = computed(() => {
|
|
const bands: Array<{ startIndex: number; endIndex: number; left: number; width: number }> = []
|
|
const bars = ganttBars.value
|
|
const positions = barPositions.value
|
|
|
|
for (let i = 0; i < bars.length; i++) {
|
|
const bar = bars[i]?.[0]
|
|
if (!bar?.meta?.isParent) continue
|
|
|
|
const parentLevel = bar.meta.indentLevel ?? 0
|
|
let endIndex = i
|
|
|
|
// Find last consecutive child with deeper indent
|
|
for (let j = i + 1; j < bars.length; j++) {
|
|
const childBar = bars[j]?.[0]
|
|
const childLevel = childBar?.meta?.indentLevel ?? 0
|
|
if (childLevel <= parentLevel) break
|
|
endIndex = j
|
|
}
|
|
|
|
// Only create a band if there are actual children visible
|
|
if (endIndex > i) {
|
|
// Compute horizontal extent from bar positions
|
|
let minX = Infinity
|
|
let maxX = -Infinity
|
|
|
|
for (let k = i; k <= endIndex; k++) {
|
|
const taskId = Number(bars[k]?.[0]?.id)
|
|
const pos = positions.get(taskId)
|
|
if (!pos) continue
|
|
minX = Math.min(minX, pos.x)
|
|
maxX = Math.max(maxX, pos.x + pos.width)
|
|
}
|
|
|
|
if (minX < Infinity) {
|
|
bands.push({
|
|
startIndex: i,
|
|
endIndex,
|
|
left: minX - GROUP_BAND_PADDING,
|
|
width: maxX - minX + GROUP_BAND_PADDING * 2,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return bands
|
|
})
|
|
|
|
function updateGanttTask(id: string, newStart: Date, newEnd: Date) {
|
|
const task = tasks.value.get(Number(id))
|
|
if (!task) return
|
|
|
|
const update: ITaskPartialWithId = {
|
|
id: Number(id),
|
|
}
|
|
|
|
const hasStartDate = Boolean(task.startDate)
|
|
const hasEndDate = Boolean(task.endDate)
|
|
const hasDueDate = Boolean(task.dueDate)
|
|
|
|
if (hasStartDate && hasEndDate) {
|
|
// Both dates exist — update both
|
|
update.startDate = roundToNaturalDayBoundary(newStart, true)
|
|
update.endDate = roundToNaturalDayBoundary(newEnd)
|
|
} else if (hasStartDate && !hasEndDate && hasDueDate) {
|
|
// startDate + dueDate (no endDate) — treat as fully dated
|
|
update.startDate = roundToNaturalDayBoundary(newStart, true)
|
|
update.dueDate = roundToNaturalDayBoundary(newEnd)
|
|
} else if (hasStartDate && !hasEndDate) {
|
|
// startOnly — only update startDate, don't persist the synthetic end
|
|
update.startDate = roundToNaturalDayBoundary(newStart, true)
|
|
} else if (!hasStartDate && (hasEndDate || hasDueDate)) {
|
|
// endOnly / dueOnly — only update the end side
|
|
if (hasEndDate) {
|
|
update.endDate = roundToNaturalDayBoundary(newEnd)
|
|
}
|
|
if (hasDueDate) {
|
|
update.dueDate = roundToNaturalDayBoundary(newEnd)
|
|
}
|
|
} else {
|
|
// No dates at all — update both (existing behavior for dateless tasks)
|
|
update.startDate = roundToNaturalDayBoundary(newStart, true)
|
|
update.endDate = roundToNaturalDayBoundary(newEnd)
|
|
}
|
|
|
|
emit('update:task', update)
|
|
}
|
|
|
|
function openTask(bar: GanttBarModel) {
|
|
router.push({
|
|
name: 'task.detail',
|
|
params: {id: bar.id},
|
|
state: {backdropView: router.currentRoute.value.fullPath},
|
|
})
|
|
}
|
|
|
|
// Double-click and drag detection
|
|
let lastClickTime = 0
|
|
let dragStarted = false
|
|
|
|
const DOUBLE_CLICK_THRESHOLD_MS = 500
|
|
const DRAG_THRESHOLD_PIXELS = 5
|
|
|
|
function handleBarPointerDown(bar: GanttBarModel, event: PointerEvent) {
|
|
event.preventDefault()
|
|
|
|
const barIndex = ganttBars.value.findIndex(barGroup => barGroup.some(b => b.id === bar.id))
|
|
if (barIndex !== -1 && ganttRows.value[barIndex]) {
|
|
focusTaskBar(ganttRows.value[barIndex])
|
|
}
|
|
|
|
const currentTime = Date.now()
|
|
const timeDiff = currentTime - lastClickTime
|
|
|
|
if (timeDiff < DOUBLE_CLICK_THRESHOLD_MS) {
|
|
openTask(bar)
|
|
lastClickTime = 0
|
|
return
|
|
}
|
|
|
|
lastClickTime = currentTime
|
|
dragStarted = false
|
|
|
|
const startX = event.clientX
|
|
const startY = event.clientY
|
|
|
|
const handleMove = (e: PointerEvent) => {
|
|
const diffX = Math.abs(e.clientX - startX)
|
|
const diffY = Math.abs(e.clientY - startY)
|
|
|
|
// Start drag if mouse moved more than threshhold
|
|
if (!dragStarted && (diffX > DRAG_THRESHOLD_PIXELS || diffY > DRAG_THRESHOLD_PIXELS)) {
|
|
dragStarted = true
|
|
document.removeEventListener('pointermove', handleMove)
|
|
document.removeEventListener('pointerup', handleStop)
|
|
startDrag(bar, event)
|
|
}
|
|
}
|
|
|
|
const handleStop = () => {
|
|
document.removeEventListener('pointermove', handleMove)
|
|
document.removeEventListener('pointerup', handleStop)
|
|
// If no drag was started, this was just a click (do nothing)
|
|
}
|
|
|
|
document.addEventListener('pointermove', handleMove)
|
|
document.addEventListener('pointerup', handleStop)
|
|
}
|
|
|
|
function setCursor(cursor: string, barElement?: Element | null) {
|
|
document.body.style.setProperty('cursor', cursor, 'important')
|
|
if (barElement) {
|
|
(barElement as HTMLElement).style.setProperty('cursor', cursor, 'important')
|
|
}
|
|
}
|
|
|
|
function clearCursor(barElement?: Element | null) {
|
|
document.body.style.removeProperty('cursor')
|
|
if (barElement) {
|
|
(barElement as HTMLElement).style.removeProperty('cursor')
|
|
}
|
|
}
|
|
|
|
function startDrag(bar: GanttBarModel, event: PointerEvent) {
|
|
event.preventDefault()
|
|
|
|
isDragging.value = true
|
|
dragState.value = {
|
|
barId: bar.id,
|
|
startX: event.clientX,
|
|
originalStart: new Date(bar.start),
|
|
originalEnd: new Date(bar.end),
|
|
currentDays: 0,
|
|
}
|
|
|
|
const barGroup = (event.target as Element).closest('g')
|
|
const barElement = barGroup?.querySelector('.gantt-bar')
|
|
setCursor('grabbing', barElement)
|
|
|
|
const handleMove = (e: PointerEvent) => {
|
|
if (!dragState.value || !isDragging.value) return
|
|
|
|
const diff = e.clientX - dragState.value.startX
|
|
const days = Math.round(diff / DAY_WIDTH_PIXELS)
|
|
|
|
if (days !== dragState.value.currentDays) {
|
|
dragState.value.currentDays = days
|
|
}
|
|
}
|
|
|
|
const handleStop = () => {
|
|
if (dragMoveHandler) {
|
|
document.removeEventListener('pointermove', dragMoveHandler)
|
|
dragMoveHandler = null
|
|
}
|
|
if (dragStopHandler) {
|
|
document.removeEventListener('pointerup', dragStopHandler)
|
|
dragStopHandler = null
|
|
}
|
|
|
|
clearCursor(barElement)
|
|
|
|
if (dragState.value && dragState.value.currentDays !== 0) {
|
|
const newStart = new Date(dragState.value.originalStart)
|
|
newStart.setDate(newStart.getDate() + dragState.value.currentDays)
|
|
const newEnd = new Date(dragState.value.originalEnd)
|
|
newEnd.setDate(newEnd.getDate() + dragState.value.currentDays)
|
|
|
|
updateGanttTask(bar.id, newStart, newEnd)
|
|
}
|
|
|
|
isDragging.value = false
|
|
dragState.value = null
|
|
}
|
|
|
|
// Store handlers for cleanup
|
|
dragMoveHandler = handleMove
|
|
dragStopHandler = handleStop
|
|
|
|
document.addEventListener('pointermove', handleMove)
|
|
document.addEventListener('pointerup', handleStop)
|
|
}
|
|
|
|
function startResize(bar: GanttBarModel, edge: 'start' | 'end', event: PointerEvent) {
|
|
event.preventDefault()
|
|
event.stopPropagation() // Prevent drag from triggering
|
|
|
|
isResizing.value = true
|
|
dragState.value = {
|
|
barId: bar.id,
|
|
startX: event.clientX,
|
|
originalStart: new Date(bar.start),
|
|
originalEnd: new Date(bar.end),
|
|
currentDays: 0,
|
|
edge,
|
|
}
|
|
|
|
const barGroup = (event.target as Element).closest('g')
|
|
const barElement = barGroup?.querySelector('.gantt-bar')
|
|
setCursor('col-resize', barElement)
|
|
|
|
const handleMove = (e: PointerEvent) => {
|
|
if (!dragState.value || !isResizing.value) return
|
|
|
|
const diff = e.clientX - dragState.value.startX
|
|
const days = Math.round(diff / DAY_WIDTH_PIXELS)
|
|
|
|
if (edge === 'start') {
|
|
const newStart = new Date(dragState.value.originalStart)
|
|
newStart.setDate(newStart.getDate() + days)
|
|
if (newStart >= dragState.value.originalEnd) return
|
|
} else {
|
|
const newEnd = new Date(dragState.value.originalEnd)
|
|
newEnd.setDate(newEnd.getDate() + days)
|
|
if (newEnd <= dragState.value.originalStart) return
|
|
}
|
|
|
|
if (days !== dragState.value.currentDays) {
|
|
dragState.value.currentDays = days
|
|
}
|
|
}
|
|
|
|
const handleStop = () => {
|
|
if (dragMoveHandler) {
|
|
document.removeEventListener('pointermove', dragMoveHandler)
|
|
dragMoveHandler = null
|
|
}
|
|
if (dragStopHandler) {
|
|
document.removeEventListener('pointerup', dragStopHandler)
|
|
dragStopHandler = null
|
|
}
|
|
|
|
clearCursor(barElement)
|
|
|
|
if (dragState.value && dragState.value.currentDays !== 0) {
|
|
if (edge === 'start') {
|
|
const newStart = new Date(dragState.value.originalStart)
|
|
newStart.setDate(newStart.getDate() + dragState.value.currentDays)
|
|
|
|
// Ensure start doesn't go past end
|
|
if (newStart < dragState.value.originalEnd) {
|
|
updateGanttTask(bar.id, newStart, dragState.value.originalEnd)
|
|
}
|
|
} else {
|
|
const newEnd = new Date(dragState.value.originalEnd)
|
|
newEnd.setDate(newEnd.getDate() + dragState.value.currentDays)
|
|
|
|
// Ensure end doesn't go before start
|
|
if (newEnd > dragState.value.originalStart) {
|
|
updateGanttTask(bar.id, dragState.value.originalStart, newEnd)
|
|
}
|
|
}
|
|
}
|
|
|
|
isResizing.value = false
|
|
dragState.value = null
|
|
}
|
|
|
|
// Store handlers for cleanup
|
|
dragMoveHandler = handleMove
|
|
dragStopHandler = handleStop
|
|
|
|
document.addEventListener('pointermove', handleMove)
|
|
document.addEventListener('pointerup', handleStop)
|
|
}
|
|
|
|
function handleFocusChange(payload: { row: string | null; cell: number | null }) {
|
|
currentFocusedRow.value = payload.row
|
|
currentFocusedCell.value = payload.cell
|
|
}
|
|
|
|
function handleEnterPressed(payload: { row: string; cell: number }) {
|
|
const rowIndex = ganttRows.value.indexOf(payload.row)
|
|
if (rowIndex !== -1 && ganttBars.value[rowIndex]?.[0]) {
|
|
const bar = ganttBars.value[rowIndex][0]
|
|
openTask(bar)
|
|
}
|
|
}
|
|
|
|
function focusTaskBar(rowId: string) {
|
|
setTimeout(() => {
|
|
const taskBarElement = document.querySelector(`[data-row-id="${rowId}"] [role="slider"]`) as HTMLElement
|
|
if (taskBarElement) {
|
|
taskBarElement.focus()
|
|
}
|
|
}, 0)
|
|
}
|
|
|
|
onUnmounted(() => {
|
|
if (dragMoveHandler) {
|
|
document.removeEventListener('pointermove', dragMoveHandler)
|
|
dragMoveHandler = null
|
|
}
|
|
if (dragStopHandler) {
|
|
document.removeEventListener('pointerup', dragStopHandler)
|
|
dragStopHandler = null
|
|
}
|
|
document.body.style.removeProperty('cursor')
|
|
})
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
.gantt-container {
|
|
overflow-x: auto;
|
|
}
|
|
|
|
.gantt-chart-wrapper {
|
|
inline-size: max-content;
|
|
min-inline-size: 100%;
|
|
position: relative;
|
|
}
|
|
|
|
.gantt-rows-container {
|
|
position: relative;
|
|
}
|
|
|
|
.gantt-group-band {
|
|
position: absolute;
|
|
background: hsla(var(--primary-h), var(--primary-s), var(--primary-l), 0.06);
|
|
border: 1px solid hsla(var(--primary-h), var(--primary-s), var(--primary-l), 0.12);
|
|
border-radius: 6px;
|
|
pointer-events: none;
|
|
z-index: 1;
|
|
}
|
|
|
|
.gantt-rows {
|
|
position: relative;
|
|
z-index: 2;
|
|
}
|
|
|
|
.gantt-row-content {
|
|
position: relative;
|
|
min-block-size: 40px;
|
|
inline-size: 100%;
|
|
}
|
|
</style>
|