mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-03-12 01:59:34 -05:00
feat(gantt): rebuild the gantt chart (#1001)
This commit is contained in:
@@ -13,7 +13,7 @@ describe('Project View Gantt', () => {
|
||||
const tasks = TaskFactory.create(1)
|
||||
cy.visit('/projects/1/2')
|
||||
|
||||
cy.get('.g-gantt-rows-container')
|
||||
cy.get('.gantt-rows')
|
||||
.should('not.contain', tasks[0].title)
|
||||
})
|
||||
|
||||
@@ -27,9 +27,9 @@ describe('Project View Gantt', () => {
|
||||
|
||||
cy.visit('/projects/1/2')
|
||||
|
||||
cy.get('.g-timeunits-container')
|
||||
.should('contain', dayjs(now).format('MMMM'))
|
||||
.should('contain', dayjs(nextMonth).format('MMMM'))
|
||||
cy.get('.gantt-timeline-months')
|
||||
.should('contain', dayjs(now).format('MMMM YYYY'))
|
||||
.should('contain', dayjs(nextMonth).format('MMMM YYYY'))
|
||||
})
|
||||
|
||||
it('Shows tasks with dates', () => {
|
||||
@@ -40,7 +40,7 @@ describe('Project View Gantt', () => {
|
||||
})
|
||||
cy.visit('/projects/1/2')
|
||||
|
||||
cy.get('.g-gantt-rows-container')
|
||||
cy.get('.gantt-rows')
|
||||
.should('not.be.empty')
|
||||
.should('contain', tasks[0].title)
|
||||
})
|
||||
@@ -56,7 +56,7 @@ describe('Project View Gantt', () => {
|
||||
.contains('Show tasks without dates')
|
||||
.click()
|
||||
|
||||
cy.get('.g-gantt-rows-container')
|
||||
cy.get('.gantt-rows')
|
||||
.should('not.be.empty')
|
||||
.should('contain', tasks[0].title)
|
||||
})
|
||||
@@ -71,11 +71,40 @@ describe('Project View Gantt', () => {
|
||||
})
|
||||
cy.visit('/projects/1/2')
|
||||
|
||||
cy.get('.g-gantt-rows-container .g-gantt-row .g-gantt-row-bars-container div .g-gantt-bar')
|
||||
cy.get('.gantt-rows .gantt-row-bars .gantt-bar')
|
||||
.first()
|
||||
.trigger('mousedown', {which: 1})
|
||||
.trigger('mousemove', {clientX: 500, clientY: 0})
|
||||
.trigger('mouseup', {force: true})
|
||||
.then($bar => {
|
||||
// Get the current position of the bar
|
||||
const rect = $bar[0].getBoundingClientRect()
|
||||
const startX = rect.left + rect.width / 2
|
||||
const startY = rect.top + rect.height / 2
|
||||
|
||||
// Trigger pointer events with proper coordinates and delays
|
||||
cy.wrap($bar)
|
||||
.trigger('pointerdown', {
|
||||
clientX: startX,
|
||||
clientY: startY,
|
||||
pointerId: 1,
|
||||
which: 1
|
||||
})
|
||||
.wait(100) // Wait to ensure double-click detection doesn't interfere
|
||||
.trigger('pointermove', {
|
||||
clientX: startX + 10, // Small initial movement to trigger drag
|
||||
clientY: startY,
|
||||
pointerId: 1
|
||||
})
|
||||
.trigger('pointermove', {
|
||||
clientX: startX + 150, // Move 150px to the right (about 5 days)
|
||||
clientY: startY,
|
||||
pointerId: 1
|
||||
})
|
||||
.trigger('pointerup', {
|
||||
clientX: startX + 150,
|
||||
clientY: startY,
|
||||
pointerId: 1,
|
||||
force: true
|
||||
})
|
||||
})
|
||||
cy.wait('@taskUpdate')
|
||||
})
|
||||
|
||||
@@ -101,7 +130,7 @@ describe('Project View Gantt', () => {
|
||||
it('Should change the date range based on date query parameters', () => {
|
||||
cy.visit('/projects/1/2?dateFrom=2022-09-25&dateTo=2022-11-05')
|
||||
|
||||
cy.get('.g-timeunits-container')
|
||||
cy.get('.gantt-timeline-months')
|
||||
.should('contain', 'September 2022')
|
||||
.should('contain', 'October 2022')
|
||||
.should('contain', 'November 2022')
|
||||
@@ -117,7 +146,7 @@ describe('Project View Gantt', () => {
|
||||
})
|
||||
cy.visit('/projects/1/2')
|
||||
|
||||
cy.get('.gantt-container .g-gantt-chart .g-gantt-row-bars-container .g-gantt-bar')
|
||||
cy.get('.gantt-container .gantt-row-bars .gantt-bar')
|
||||
.dblclick()
|
||||
|
||||
cy.url()
|
||||
|
||||
@@ -56,7 +56,6 @@
|
||||
"@fortawesome/free-solid-svg-icons": "7.0.0",
|
||||
"@fortawesome/vue-fontawesome": "3.1.1",
|
||||
"@github/hotkey": "3.1.1",
|
||||
"@infectoone/vue-ganttastic": "2.3.2",
|
||||
"@intlify/unplugin-vue-i18n": "6.0.8",
|
||||
"@kyvg/vue3-notification": "3.4.1",
|
||||
"@sentry/tracing": "7.120.4",
|
||||
|
||||
50
frontend/pnpm-lock.yaml
generated
50
frontend/pnpm-lock.yaml
generated
@@ -34,9 +34,6 @@ importers:
|
||||
'@github/hotkey':
|
||||
specifier: 3.1.1
|
||||
version: 3.1.1(patch_hash=145ab3233cbcd3bc934b4961cd8710e2b15e4ae5dd20862a8d1d6621d7f9d4a8)
|
||||
'@infectoone/vue-ganttastic':
|
||||
specifier: 2.3.2
|
||||
version: 2.3.2(dayjs@1.11.13)(vue@3.5.18(typescript@5.9.2))
|
||||
'@intlify/unplugin-vue-i18n':
|
||||
specifier: 6.0.8
|
||||
version: 6.0.8(@vue/compiler-dom@3.5.18)(eslint@9.33.0(jiti@2.4.2))(rollup@4.46.2)(typescript@5.9.2)(vue-i18n@11.1.11(vue@3.5.18(typescript@5.9.2)))(vue@3.5.18(typescript@5.9.2))
|
||||
@@ -1512,12 +1509,6 @@ packages:
|
||||
resolution: {integrity: sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==}
|
||||
engines: {node: '>=18.18'}
|
||||
|
||||
'@infectoone/vue-ganttastic@2.3.2':
|
||||
resolution: {integrity: sha512-krxHdlZvo4cdS4axQ99qb756RzwieI7LcyY2vAIehJ5Sxd/jz5Pu/vTplTC0Rxqj8T4v1knYPK9uvTMkQYWYng==}
|
||||
peerDependencies:
|
||||
dayjs: ^1.11.5
|
||||
vue: ^3.2.40
|
||||
|
||||
'@intlify/bundle-utils@10.0.1':
|
||||
resolution: {integrity: sha512-WkaXfSevtpgtUR4t8K2M6lbR7g03mtOxFeh+vXp5KExvPqS12ppaRj1QxzwRuRI5VUto54A22BjKoBMLyHILWQ==}
|
||||
engines: {node: '>= 18'}
|
||||
@@ -2335,9 +2326,6 @@ packages:
|
||||
'@types/unist@3.0.3':
|
||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||
|
||||
'@types/web-bluetooth@0.0.16':
|
||||
resolution: {integrity: sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==}
|
||||
|
||||
'@types/web-bluetooth@0.0.21':
|
||||
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
||||
|
||||
@@ -2660,15 +2648,9 @@ packages:
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
'@vueuse/core@9.13.0':
|
||||
resolution: {integrity: sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==}
|
||||
|
||||
'@vueuse/metadata@13.6.0':
|
||||
resolution: {integrity: sha512-rnIH7JvU7NjrpexTsl2Iwv0V0yAx9cw7+clymjKuLSXG0QMcLD0LDgdNmXic+qL0SGvgSVPEpM9IDO/wqo1vkQ==}
|
||||
|
||||
'@vueuse/metadata@9.13.0':
|
||||
resolution: {integrity: sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==}
|
||||
|
||||
'@vueuse/router@13.6.0':
|
||||
resolution: {integrity: sha512-iXRwR4K7nz4PReW0QudhnM9NtYGvN4KrskFgF9G7NouM43big3bpSNRRocJKFWK7iu97ww5y82B3QA2zz3S/vw==}
|
||||
peerDependencies:
|
||||
@@ -2680,9 +2662,6 @@ packages:
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
'@vueuse/shared@9.13.0':
|
||||
resolution: {integrity: sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==}
|
||||
|
||||
abbrev@2.0.0:
|
||||
resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==}
|
||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||
@@ -8368,14 +8347,6 @@ snapshots:
|
||||
|
||||
'@humanwhocodes/retry@0.4.2': {}
|
||||
|
||||
'@infectoone/vue-ganttastic@2.3.2(dayjs@1.11.13)(vue@3.5.18(typescript@5.9.2))':
|
||||
dependencies:
|
||||
'@vueuse/core': 9.13.0(vue@3.5.18(typescript@5.9.2))
|
||||
dayjs: 1.11.13
|
||||
vue: 3.5.18(typescript@5.9.2)
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
|
||||
'@intlify/bundle-utils@10.0.1(vue-i18n@11.1.11(vue@3.5.18(typescript@5.9.2)))':
|
||||
dependencies:
|
||||
'@intlify/message-compiler': 11.1.5
|
||||
@@ -9173,8 +9144,6 @@ snapshots:
|
||||
|
||||
'@types/unist@3.0.3': {}
|
||||
|
||||
'@types/web-bluetooth@0.0.16': {}
|
||||
|
||||
'@types/web-bluetooth@0.0.21': {}
|
||||
|
||||
'@types/whatwg-mimetype@3.0.2': {}
|
||||
@@ -9656,20 +9625,8 @@ snapshots:
|
||||
'@vueuse/shared': 13.6.0(vue@3.5.18(typescript@5.9.2))
|
||||
vue: 3.5.18(typescript@5.9.2)
|
||||
|
||||
'@vueuse/core@9.13.0(vue@3.5.18(typescript@5.9.2))':
|
||||
dependencies:
|
||||
'@types/web-bluetooth': 0.0.16
|
||||
'@vueuse/metadata': 9.13.0
|
||||
'@vueuse/shared': 9.13.0(vue@3.5.18(typescript@5.9.2))
|
||||
vue-demi: 0.14.10(vue@3.5.18(typescript@5.9.2))
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
|
||||
'@vueuse/metadata@13.6.0': {}
|
||||
|
||||
'@vueuse/metadata@9.13.0': {}
|
||||
|
||||
'@vueuse/router@13.6.0(vue-router@4.5.1(vue@3.5.18(typescript@5.9.2)))(vue@3.5.18(typescript@5.9.2))':
|
||||
dependencies:
|
||||
'@vueuse/shared': 13.6.0(vue@3.5.18(typescript@5.9.2))
|
||||
@@ -9680,13 +9637,6 @@ snapshots:
|
||||
dependencies:
|
||||
vue: 3.5.18(typescript@5.9.2)
|
||||
|
||||
'@vueuse/shared@9.13.0(vue@3.5.18(typescript@5.9.2))':
|
||||
dependencies:
|
||||
vue-demi: 0.14.10(vue@3.5.18(typescript@5.9.2))
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
|
||||
abbrev@2.0.0: {}
|
||||
|
||||
acorn-jsx@5.3.2(acorn@8.15.0):
|
||||
|
||||
507
frontend/src/components/gantt/GanttChart.vue
Normal file
507
frontend/src/components/gantt/GanttChart.vue
Normal file
@@ -0,0 +1,507 @@
|
||||
<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">
|
||||
<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"
|
||||
:focused-cell="focusedCell"
|
||||
:row-id="rowId"
|
||||
@barPointerDown="handleBarPointerDown"
|
||||
@startResize="startResize"
|
||||
@updateTask="updateGanttTask"
|
||||
/>
|
||||
</div>
|
||||
</GanttRow>
|
||||
</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 type {ITask, ITaskPartialWithId} from '@/modelTypes/ITask'
|
||||
import type {DateISO} from '@/types/DateISO'
|
||||
import type {GanttFilters} from '@/views/project/helpers/useGanttFilters'
|
||||
import type {GanttBarModel} 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 Loading from '@/components/misc/Loading.vue'
|
||||
|
||||
import {MILLISECONDS_A_DAY} from '@/constants/date'
|
||||
|
||||
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[]>>({})
|
||||
|
||||
function transformTaskToGanttBar(t: ITask): GanttBarModel {
|
||||
const startDate = t.startDate
|
||||
? new Date(t.startDate)
|
||||
: new Date(props.defaultTaskStartDate)
|
||||
const endDate = t.endDate
|
||||
? new Date(t.endDate)
|
||||
: new Date(props.defaultTaskEndDate)
|
||||
|
||||
const taskColor = getHexColor(t.hexColor)
|
||||
|
||||
const bar = {
|
||||
id: String(t.id),
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
meta: {
|
||||
label: t.title,
|
||||
task: t,
|
||||
color: taskColor,
|
||||
hasActualDates: Boolean(t.startDate && t.endDate),
|
||||
isDone: t.done,
|
||||
},
|
||||
}
|
||||
|
||||
return bar
|
||||
}
|
||||
|
||||
watch(
|
||||
[tasks, filters],
|
||||
() => {
|
||||
const bars: GanttBarModel[] = []
|
||||
const rows: string[] = []
|
||||
const cells: Record<string, string[]> = {}
|
||||
|
||||
const filteredTasks = Array.from(tasks.value.values()).filter(task => {
|
||||
if (!filters.value.showTasksWithoutDates && (!task.startDate || !task.endDate)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const taskStart = task.startDate
|
||||
? new Date(task.startDate)
|
||||
: new Date(props.defaultTaskStartDate)
|
||||
const taskEnd = task.endDate
|
||||
? new Date(task.endDate)
|
||||
: new Date(props.defaultTaskEndDate)
|
||||
|
||||
// Task is visible if it overlaps with the current date range
|
||||
return taskStart <= dateToDate.value
|
||||
&& taskEnd >= dateFromDate.value
|
||||
})
|
||||
|
||||
filteredTasks.forEach((t, index) => {
|
||||
const bar = transformTaskToGanttBar(t)
|
||||
bars.push(bar)
|
||||
|
||||
const rowId = `row-${index}`
|
||||
rows.push(rowId)
|
||||
|
||||
const rowCells: string[] = []
|
||||
timelineData.value.forEach((date, dayIndex) => {
|
||||
rowCells.push(`${rowId}-cell-${dayIndex}`)
|
||||
})
|
||||
cells[rowId] = rowCells
|
||||
})
|
||||
|
||||
// Group bars by rows (one bar per row for now)
|
||||
ganttBars.value = bars.map(bar => [bar])
|
||||
ganttRows.value = rows
|
||||
cellsByRow.value = cells
|
||||
|
||||
},
|
||||
{deep: true, immediate: true},
|
||||
)
|
||||
|
||||
function updateGanttTask(id: string, newStart: Date, newEnd: Date) {
|
||||
emit('update:task', {
|
||||
id: Number(id),
|
||||
startDate: dayjs(newStart).startOf('day').toDate(),
|
||||
endDate: dayjs(newEnd).endOf('day').toDate(),
|
||||
})
|
||||
}
|
||||
|
||||
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 {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.gantt-row-content {
|
||||
position: relative;
|
||||
min-block-size: 40px;
|
||||
inline-size: 100%;
|
||||
}
|
||||
</style>
|
||||
41
frontend/src/components/gantt/GanttChartBody.vue
Normal file
41
frontend/src/components/gantt/GanttChartBody.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<GanttChartPrimitive
|
||||
ref="primitiveRef"
|
||||
:rows="rows"
|
||||
:cells-by-row="cellsByRow"
|
||||
@update:focused="$emit('update:focused', $event)"
|
||||
@enterPressed="$emit('enterPressed', $event)"
|
||||
>
|
||||
<template #default="{ focusedRow, focusedCell }">
|
||||
<slot
|
||||
:focused-row="focusedRow"
|
||||
:focused-cell="focusedCell"
|
||||
/>
|
||||
</template>
|
||||
</GanttChartPrimitive>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
|
||||
import GanttChartPrimitive from '@/components/gantt/primitives/GanttChartPrimitive.vue'
|
||||
|
||||
defineProps<{
|
||||
rows: string[]
|
||||
cellsByRow: Record<string, string[]>
|
||||
}>()
|
||||
defineEmits<{
|
||||
'update:focused': [payload: { row: string | null; cell: number | null }],
|
||||
'enterPressed': [payload: { row: string; cell: number }],
|
||||
}>()
|
||||
|
||||
const primitiveRef = ref<InstanceType<typeof GanttChartPrimitive> | null>(null)
|
||||
|
||||
function setFocus(rowId: string, cellIndex: number = 0) {
|
||||
primitiveRef.value?.setFocus(rowId, cellIndex)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
setFocus,
|
||||
})
|
||||
</script>
|
||||
40
frontend/src/components/gantt/GanttRow.vue
Normal file
40
frontend/src/components/gantt/GanttRow.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<GanttRowPrimitive
|
||||
:id="id"
|
||||
@focus="onFocus"
|
||||
@select="onSelect"
|
||||
>
|
||||
<div
|
||||
class="w-full flex items-center"
|
||||
:class="index % 2 ? 'bg-row-alt' : 'bg-row'"
|
||||
role="presentation"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</GanttRowPrimitive>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import GanttRowPrimitive from '@/components/gantt/primitives/GanttRowPrimitive.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
id: string
|
||||
index: number
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'focus', id: string): void
|
||||
(e: 'select', id: string): void
|
||||
}>()
|
||||
const onFocus = () => emit('focus', props.id)
|
||||
const onSelect = () => emit('select', props.id)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bg-row {
|
||||
background: hsla(var(--white-h), var(--white-s), var(--white-l), .15);
|
||||
}
|
||||
|
||||
.bg-row-alt {
|
||||
background: hsla(var(--grey-100-hsl), .5);
|
||||
}
|
||||
</style>
|
||||
308
frontend/src/components/gantt/GanttRowBars.vue
Normal file
308
frontend/src/components/gantt/GanttRowBars.vue
Normal file
@@ -0,0 +1,308 @@
|
||||
<template>
|
||||
<svg
|
||||
class="gantt-row-bars"
|
||||
:width="totalWidth"
|
||||
height="40"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role="img"
|
||||
:aria-label="$t('project.gantt.taskBarsForRow', { rowId })"
|
||||
:data-row-id="rowId"
|
||||
>
|
||||
<GanttBarPrimitive
|
||||
v-for="bar in bars"
|
||||
:key="bar.id"
|
||||
:model="bar"
|
||||
:timeline-start="dateFromDate"
|
||||
:timeline-end="dateToDate"
|
||||
:on-update="(id, start, end) => emit('updateTask', id, start, end)"
|
||||
>
|
||||
<!-- Main bar -->
|
||||
<rect
|
||||
:x="getBarX(bar)"
|
||||
:y="4"
|
||||
:width="getBarWidth(bar)"
|
||||
:height="32"
|
||||
:rx="4"
|
||||
:fill="getBarFill(bar)"
|
||||
:stroke="getBarStroke(bar)"
|
||||
:stroke-width="getBarStrokeWidth(bar)"
|
||||
:stroke-dasharray="!bar.meta?.hasActualDates ? '5,5' : 'none'"
|
||||
class="gantt-bar"
|
||||
role="button"
|
||||
:aria-label="$t('project.gantt.taskBarLabel', {
|
||||
task: bar.meta?.label || bar.id,
|
||||
startDate: bar.start.toLocaleDateString(),
|
||||
endDate: bar.end.toLocaleDateString(),
|
||||
dateType: bar.meta?.hasActualDates ? $t('project.gantt.scheduledDates') : $t('project.gantt.estimatedDates')
|
||||
})"
|
||||
:aria-pressed="isRowFocused"
|
||||
@pointerdown="handleBarPointerDown(bar, $event)"
|
||||
/>
|
||||
|
||||
<!-- Left resize handle -->
|
||||
<rect
|
||||
:x="getBarX(bar) - RESIZE_HANDLE_OFFSET"
|
||||
:y="4"
|
||||
:width="6"
|
||||
:height="32"
|
||||
:rx="3"
|
||||
fill="var(--white)"
|
||||
stroke="var(--primary)"
|
||||
stroke-width="1"
|
||||
class="gantt-resize-handle gantt-resize-left"
|
||||
role="button"
|
||||
:aria-label="$t('project.gantt.resizeStartDate', { task: bar.meta?.label || bar.id })"
|
||||
@pointerdown="startResize(bar, 'start', $event)"
|
||||
/>
|
||||
|
||||
<!-- Right resize handle -->
|
||||
<rect
|
||||
:x="getBarX(bar) + getBarWidth(bar) - RESIZE_HANDLE_OFFSET"
|
||||
:y="4"
|
||||
:width="6"
|
||||
:height="32"
|
||||
:rx="3"
|
||||
fill="var(--white)"
|
||||
stroke="var(--primary)"
|
||||
stroke-width="1"
|
||||
class="gantt-resize-handle gantt-resize-right"
|
||||
role="button"
|
||||
:aria-label="$t('project.gantt.resizeEndDate', { task: bar.meta?.label || bar.id })"
|
||||
@pointerdown="startResize(bar, 'end', $event)"
|
||||
/>
|
||||
|
||||
<!-- Task label with clipping -->
|
||||
<defs>
|
||||
<clipPath :id="`clip-${bar.id}`">
|
||||
<rect
|
||||
:x="getBarX(bar) + 2"
|
||||
:y="4"
|
||||
:width="getBarWidth(bar) - 4"
|
||||
:height="32"
|
||||
:rx="4"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<text
|
||||
:x="getBarTextX(bar)"
|
||||
:y="24"
|
||||
class="gantt-bar-text"
|
||||
:fill="getBarTextColor(bar)"
|
||||
:clip-path="`url(#clip-${bar.id})`"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ bar.meta?.label || bar.id }}
|
||||
</text>
|
||||
</GanttBarPrimitive>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed} from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import type {GanttBarModel} from '@/composables/useGanttBar'
|
||||
import {getTextColor, LIGHT} from '@/helpers/color/getTextColor'
|
||||
import {MILLISECONDS_A_DAY} from '@/constants/date'
|
||||
|
||||
import GanttBarPrimitive from './primitives/GanttBarPrimitive.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
bars: GanttBarModel[]
|
||||
totalWidth: number
|
||||
dateFromDate: Date
|
||||
dateToDate: Date
|
||||
dayWidthPixels: number
|
||||
isDragging: boolean
|
||||
isResizing: boolean
|
||||
dragState: {
|
||||
barId: string
|
||||
startX: number
|
||||
originalStart: Date
|
||||
originalEnd: Date
|
||||
currentDays: number
|
||||
edge?: 'start' | 'end'
|
||||
} | null
|
||||
focusedRow: string | null
|
||||
focusedCell: number | null
|
||||
rowId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'barPointerDown', bar: GanttBarModel, event: PointerEvent): void
|
||||
(e: 'startResize', bar: GanttBarModel, edge: 'start' | 'end', event: PointerEvent): void
|
||||
(e: 'updateTask', id: string, newStart: Date, newEnd: Date): void
|
||||
}>()
|
||||
|
||||
const RESIZE_HANDLE_OFFSET = 3
|
||||
|
||||
function addDays(dateOrValue: Date | string | number, days: number): Date {
|
||||
const date = new Date(dateOrValue)
|
||||
const newDate = new Date(date)
|
||||
newDate.setDate(newDate.getDate() + days)
|
||||
return newDate
|
||||
}
|
||||
|
||||
const isRowFocused = computed(() => props.focusedRow === props.rowId)
|
||||
|
||||
function computeBarX(startDate: Date) {
|
||||
const daysDiff = dayjs(startDate).diff(dayjs(props.dateFromDate), 'day')
|
||||
const x = daysDiff * props.dayWidthPixels
|
||||
return x
|
||||
}
|
||||
|
||||
function getDaysDifference(startDate: Date, endDate: Date): number {
|
||||
return Math.floor((endDate.getTime() - startDate.getTime()) / MILLISECONDS_A_DAY)
|
||||
}
|
||||
|
||||
function computeBarWidth(bar: GanttBarModel) {
|
||||
const diff = getDaysDifference(bar.start, bar.end)
|
||||
const width = diff * props.dayWidthPixels
|
||||
return width
|
||||
}
|
||||
|
||||
const originalEndX = computed(() => props.dragState?.originalEnd
|
||||
? computeBarX(props.dragState.originalEnd)
|
||||
: 0)
|
||||
const originalStartX = computed(() => props.dragState?.originalStart
|
||||
? computeBarX(props.dragState.originalStart)
|
||||
: 0)
|
||||
|
||||
const getBarX = computed(() => (bar: GanttBarModel) => {
|
||||
if (props.isDragging && props.dragState?.barId === bar.id) {
|
||||
const offset = props.dragState.currentDays * props.dayWidthPixels
|
||||
return originalStartX.value + offset
|
||||
}
|
||||
|
||||
if (props.isResizing && props.dragState?.barId === bar.id && props.dragState.edge === 'start') {
|
||||
const newStart = addDays(props.dragState.originalStart, props.dragState.currentDays)
|
||||
return computeBarX(newStart)
|
||||
}
|
||||
return computeBarX(bar.start)
|
||||
})
|
||||
|
||||
const getBarWidth = computed(() => (bar: GanttBarModel) => {
|
||||
if (props.isResizing && props.dragState?.barId === bar.id) {
|
||||
if (props.dragState.edge === 'start') {
|
||||
const newStart = addDays(props.dragState.originalStart, props.dragState.currentDays)
|
||||
const newStartX = computeBarX(newStart)
|
||||
return Math.max(0, originalEndX.value - newStartX)
|
||||
} else {
|
||||
const newEnd = addDays(props.dragState.originalEnd, props.dragState.currentDays)
|
||||
const newEndX = computeBarX(newEnd)
|
||||
return Math.max(0, newEndX - originalStartX.value)
|
||||
}
|
||||
}
|
||||
return computeBarWidth(bar)
|
||||
})
|
||||
|
||||
const getBarTextX = computed(() => (bar: GanttBarModel) => {
|
||||
return getBarX.value(bar) + 8
|
||||
})
|
||||
|
||||
function getBarFill(bar: GanttBarModel) {
|
||||
if (bar.meta?.hasActualDates) {
|
||||
if (bar.meta?.color) {
|
||||
return bar.meta.color
|
||||
}
|
||||
return 'var(--primary)'
|
||||
}
|
||||
|
||||
return 'var(--grey-100)'
|
||||
}
|
||||
|
||||
function getBarStroke(bar: GanttBarModel) {
|
||||
if (!bar.meta?.hasActualDates) {
|
||||
return 'var(--grey-300)' // Gray for dashed border
|
||||
}
|
||||
return 'none'
|
||||
}
|
||||
|
||||
function getBarStrokeWidth(bar: GanttBarModel) {
|
||||
if (!bar.meta?.hasActualDates) {
|
||||
return '2'
|
||||
}
|
||||
return '0'
|
||||
}
|
||||
|
||||
function getBarTextColor(bar: GanttBarModel) {
|
||||
if (!bar.meta?.hasActualDates) {
|
||||
return 'var(--grey-800)'
|
||||
}
|
||||
|
||||
if (bar.meta?.color) {
|
||||
return getTextColor(bar.meta.color)
|
||||
}
|
||||
|
||||
return LIGHT
|
||||
}
|
||||
|
||||
function handleBarPointerDown(bar: GanttBarModel, event: PointerEvent) {
|
||||
emit('barPointerDown', bar, event)
|
||||
}
|
||||
|
||||
function startResize(bar: GanttBarModel, edge: 'start' | 'end', event: PointerEvent) {
|
||||
emit('startResize', bar, edge, event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.gantt-row-bars {
|
||||
position: absolute;
|
||||
inset-block-start: 0;
|
||||
inset-inline-start: 0;
|
||||
pointer-events: none;
|
||||
z-index: 4;
|
||||
|
||||
.gantt-bar {
|
||||
cursor: grab;
|
||||
pointer-events: all;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(text) {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.gantt-bar-text {
|
||||
font-size: .85rem;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
:deep(.gantt-resize-handle) {
|
||||
cursor: col-resize !important;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
pointer-events: all; // Ensure they receive pointer events
|
||||
}
|
||||
|
||||
// Show resize handles on bar hover
|
||||
:deep(g:hover) .gantt-resize-handle {
|
||||
opacity: 0.8;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
cursor: inherit; // Use the specific cursor defined above
|
||||
}
|
||||
}
|
||||
|
||||
// Focus styles for task bars
|
||||
:deep(g[role="slider"]:focus) {
|
||||
outline: none; // Remove default browser outline
|
||||
|
||||
.gantt-bar {
|
||||
stroke: var(--primary) !important;
|
||||
stroke-width: 3 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
157
frontend/src/components/gantt/GanttTimelineHeader.vue
Normal file
157
frontend/src/components/gantt/GanttTimelineHeader.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div
|
||||
class="gantt-timeline"
|
||||
role="columnheader"
|
||||
:aria-label="$t('project.gantt.timelineHeader')"
|
||||
>
|
||||
<!-- Upper timeunit for months -->
|
||||
<div
|
||||
class="gantt-timeline-months"
|
||||
role="row"
|
||||
:aria-label="$t('project.gantt.monthsRow')"
|
||||
>
|
||||
<div
|
||||
v-for="monthGroup in monthGroups"
|
||||
:key="monthGroup.key"
|
||||
class="timeunit-month"
|
||||
:style="{ width: `${monthGroup.width}px` }"
|
||||
role="columnheader"
|
||||
:aria-label="$t('project.gantt.monthLabel', {month: monthGroup.label})"
|
||||
>
|
||||
{{ monthGroup.label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lower timeunit for days -->
|
||||
<div
|
||||
class="gantt-timeline-days"
|
||||
role="row"
|
||||
:aria-label="$t('project.gantt.daysRow')"
|
||||
>
|
||||
<div
|
||||
v-for="date in timelineData"
|
||||
:key="date.toISOString()"
|
||||
class="timeunit"
|
||||
:style="{ width: `${dayWidthPixels}px` }"
|
||||
role="columnheader"
|
||||
:aria-label="dateIsToday(date)
|
||||
? $t('project.gantt.dayLabelToday', {
|
||||
date: date.toLocaleDateString(),
|
||||
weekday: weekDayFromDate(date)
|
||||
})
|
||||
: $t('project.gantt.dayLabel', {
|
||||
date: date.toLocaleDateString(),
|
||||
weekday: weekDayFromDate(date)
|
||||
})"
|
||||
>
|
||||
<div
|
||||
class="timeunit-wrapper"
|
||||
:class="{'today': dateIsToday(date)}"
|
||||
>
|
||||
<span>{{ date.getDate() }}</span>
|
||||
<span class="weekday">
|
||||
{{ weekDayFromDate(date) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed} from 'vue'
|
||||
import {useGlobalNow} from '@/composables/useGlobalNow'
|
||||
import {useWeekDayFromDate} from '@/helpers/time/formatDate'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const props = defineProps<{
|
||||
timelineData: Date[]
|
||||
dayWidthPixels: number
|
||||
}>()
|
||||
|
||||
const weekDayFromDate = useWeekDayFromDate()
|
||||
const { now: today } = useGlobalNow()
|
||||
|
||||
const dateIsToday = computed(() => {
|
||||
const todayStr = today.value.toDateString()
|
||||
return (date: Date) => date.toDateString() === todayStr
|
||||
})
|
||||
|
||||
const monthGroups = computed(() => {
|
||||
const groups = props.timelineData.reduce(
|
||||
(groups, date) => {
|
||||
const month = date.getMonth()
|
||||
const year = date.getFullYear()
|
||||
const key = `${year}-${month}`
|
||||
|
||||
const lastGroup = groups[groups.length - 1]
|
||||
if (lastGroup?.key === key) {
|
||||
lastGroup.width += props.dayWidthPixels
|
||||
} else {
|
||||
groups.push({
|
||||
key,
|
||||
label: dayjs(date).format('MMMM YYYY'),
|
||||
width: props.dayWidthPixels,
|
||||
})
|
||||
}
|
||||
|
||||
return groups
|
||||
},
|
||||
[] as Array<{key: string; label: string; width: number}>,
|
||||
)
|
||||
|
||||
return groups
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.gantt-timeline {
|
||||
background: var(--white);
|
||||
border-block-end: 1px solid var(--grey-200);
|
||||
position: sticky;
|
||||
inset-block-start: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.gantt-timeline-months {
|
||||
display: flex;
|
||||
|
||||
.timeunit-month {
|
||||
background: var(--white);
|
||||
font-family: $vikunja-font;
|
||||
font-weight: bold;
|
||||
border-inline-end: 1px solid var(--grey-200);
|
||||
padding: 0.5rem 0;
|
||||
text-align: center;
|
||||
font-size: 1rem;
|
||||
color: var(--grey-800);
|
||||
}
|
||||
}
|
||||
|
||||
.gantt-timeline-days {
|
||||
display: flex;
|
||||
|
||||
.timeunit {
|
||||
.timeunit-wrapper {
|
||||
padding: 0.5rem 0;
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
inline-size: 100%;
|
||||
font-family: $vikunja-font;
|
||||
|
||||
&.today {
|
||||
background: var(--primary);
|
||||
color: var(--white);
|
||||
border-radius: 5px 5px 0 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.weekday {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
45
frontend/src/components/gantt/GanttVerticalGridLines.vue
Normal file
45
frontend/src/components/gantt/GanttVerticalGridLines.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="gantt-grid-lines">
|
||||
<svg
|
||||
class="gantt-vertical-lines"
|
||||
:width="totalWidth"
|
||||
:height="height"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<line
|
||||
v-for="(date, index) in timelineData"
|
||||
:key="date.toISOString()"
|
||||
:x1="index * dayWidthPixels"
|
||||
:y1="0"
|
||||
:x2="index * dayWidthPixels"
|
||||
:y2="height"
|
||||
stroke="var(--grey-400)"
|
||||
stroke-width="0.5"
|
||||
opacity="0.6"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
timelineData: Date[]
|
||||
totalWidth: number
|
||||
height: number
|
||||
dayWidthPixels: number
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.gantt-grid-lines {
|
||||
position: absolute;
|
||||
inset-inline-start: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gantt-vertical-lines {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<component
|
||||
:is="as"
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
:aria-valuemin="ariaMin"
|
||||
:aria-valuemax="ariaMax"
|
||||
:aria-valuenow="ariaNow"
|
||||
:aria-valuetext="ariaValueText"
|
||||
:aria-label="ariaLabel"
|
||||
:data-state="dataState"
|
||||
v-bind="attrs"
|
||||
@dblclick="() => props.onDoubleClick?.(props.model)"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@keydown="onKeyDown"
|
||||
>
|
||||
<slot
|
||||
:dragging="dragging"
|
||||
:selected="selected"
|
||||
:focused="focused"
|
||||
/>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, useAttrs} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {useGanttBar, type GanttBarModel} from '@/composables/useGanttBar'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
model: GanttBarModel
|
||||
timelineStart: Date
|
||||
timelineEnd: Date
|
||||
onDoubleClick?: (model: GanttBarModel) => void
|
||||
onUpdate?: (id: string, newStart: Date, newEnd: Date) => void
|
||||
as?: string
|
||||
}>(),
|
||||
{
|
||||
as: 'g',
|
||||
onDoubleClick: undefined,
|
||||
onUpdate: undefined,
|
||||
},
|
||||
)
|
||||
const attrs = useAttrs()
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const {
|
||||
dragging,
|
||||
selected,
|
||||
focused,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
|
||||
} = useGanttBar({
|
||||
model: props.model,
|
||||
timelineStart: props.timelineStart,
|
||||
timelineEnd: props.timelineEnd,
|
||||
onUpdate: props.onUpdate,
|
||||
})
|
||||
const ariaMin = computed(() => props.timelineStart.valueOf())
|
||||
const ariaMax = computed(() => props.timelineEnd.valueOf())
|
||||
const ariaNow = computed(() => props.model.start.valueOf())
|
||||
const ariaValueText = computed(() => `${props.model.start.toLocaleString()} – ${props.model.end.toLocaleString()}`)
|
||||
const ariaLabel = computed(() =>
|
||||
props.model.meta?.label
|
||||
? t('project.gantt.taskAriaLabel', { task: props.model.meta.label })
|
||||
: t('project.gantt.taskAriaLabelById', { id: props.model.id }),
|
||||
)
|
||||
const dataState = computed(() =>
|
||||
[
|
||||
dragging.value && 'dragging',
|
||||
selected.value && 'selected',
|
||||
focused.value && 'focused',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div
|
||||
ref="chartRef"
|
||||
role="grid"
|
||||
tabindex="0"
|
||||
:aria-rowcount="rows.length"
|
||||
:aria-colcount="cellsCount"
|
||||
@keydown="onKeyDown"
|
||||
@click="initializeFocus"
|
||||
>
|
||||
<slot
|
||||
:focused-row="focusedRow"
|
||||
:focused-cell="focusedCellIndex"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
|
||||
const props = defineProps<{
|
||||
rows: string[]
|
||||
cellsByRow: Record<string, string[]>
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:focused', payload: { row: string | null; cell: number | null }): void
|
||||
(e: 'enterPressed', payload: { row: string; cell: number }): void
|
||||
}>()
|
||||
|
||||
const chartRef = ref<HTMLElement | null>(null)
|
||||
const focusedRowIndex = ref<number | null>(null)
|
||||
const focusedCellIndex = ref<number | null>(null)
|
||||
|
||||
const focusedRow = computed(() => focusedRowIndex.value === null
|
||||
? null
|
||||
: props.rows[focusedRowIndex.value])
|
||||
const cellsCount = computed(() => props.rows.length
|
||||
? props.cellsByRow[props.rows[0]].length
|
||||
: 0)
|
||||
|
||||
onClickOutside(chartRef, () => {
|
||||
focusedRowIndex.value = null
|
||||
focusedCellIndex.value = null
|
||||
emit('update:focused', { row: null, cell: null })
|
||||
})
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (focusedRowIndex.value === null || focusedCellIndex.value === null) return
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
emit('enterPressed', { row: focusedRow.value!, cell: focusedCellIndex.value })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function initializeFocus() {
|
||||
// Only initialize focus if not already set and there are rows
|
||||
if (focusedRowIndex.value === null && props.rows.length > 0) {
|
||||
focusedRowIndex.value = 0
|
||||
focusedCellIndex.value = 0
|
||||
emit('update:focused', { row: focusedRow.value, cell: focusedCellIndex.value })
|
||||
}
|
||||
}
|
||||
|
||||
function setFocus(rowId: string, cellIndex: number = 0) {
|
||||
const rowIndex = props.rows.indexOf(rowId)
|
||||
if (rowIndex !== -1) {
|
||||
focusedRowIndex.value = rowIndex
|
||||
focusedCellIndex.value = Math.max(0, Math.min(cellIndex, cellsCount.value - 1))
|
||||
emit('update:focused', { row: focusedRow.value, cell: focusedCellIndex.value })
|
||||
}
|
||||
}
|
||||
|
||||
// Expose methods for parent components
|
||||
defineExpose({
|
||||
setFocus,
|
||||
initializeFocus,
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div
|
||||
role="row"
|
||||
:aria-selected="selected"
|
||||
tabindex="-1"
|
||||
:data-state="selected ? 'selected' : null"
|
||||
@click="onSelect"
|
||||
@focus="onFocus"
|
||||
@keydown="onKeyDown"
|
||||
>
|
||||
<slot :selected="selected" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
id: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [id: string]
|
||||
focus: [id: string]
|
||||
}>()
|
||||
|
||||
const selected = ref(false)
|
||||
|
||||
function onSelect() {
|
||||
emit('select', props.id)
|
||||
}
|
||||
|
||||
function onFocus() {
|
||||
emit('focus', props.id)
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onSelect()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -114,6 +114,36 @@ export const KEYBOARD_SHORTCUTS: ShortcutGroup[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.gantt.title',
|
||||
available: (route) => route.name === 'project.view',
|
||||
shortcuts: [
|
||||
{
|
||||
title: 'keyboardShortcuts.gantt.moveTaskLeft',
|
||||
keys: ['←'],
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.gantt.moveTaskRight',
|
||||
keys: ['→'],
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.gantt.expandTaskLeft',
|
||||
keys: ['shift', '←'],
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.gantt.expandTaskRight',
|
||||
keys: ['shift', '→'],
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.gantt.shrinkTaskLeft',
|
||||
keys: [ctrl, '←'],
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.gantt.shrinkTaskRight',
|
||||
keys: [ctrl, '→'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.title',
|
||||
available: (route) => route.name === 'task.detail',
|
||||
|
||||
@@ -85,7 +85,7 @@ import ProjectWrapper from '@/components/project/ProjectWrapper.vue'
|
||||
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
|
||||
import TaskForm from '@/components/tasks/TaskForm.vue'
|
||||
|
||||
import {createAsyncComponent} from '@/helpers/createAsyncComponent'
|
||||
import GanttChart from '@/components/gantt/GanttChart.vue'
|
||||
import {useGanttFilters} from '../../../views/project/helpers/useGanttFilters'
|
||||
import {RIGHTS} from '@/constants/rights'
|
||||
|
||||
@@ -101,7 +101,6 @@ const props = defineProps<{
|
||||
viewId: IProjectView['id']
|
||||
}>()
|
||||
|
||||
const GanttChart = createAsyncComponent(() => import('@/components/tasks/GanttChart.vue'))
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const canWrite = computed(() => baseStore.currentProject?.maxRight > RIGHTS.READ)
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
<template>
|
||||
<Loading
|
||||
v-if="isLoading && !ganttBars.length || dayjsLanguageLoading"
|
||||
class="gantt-container"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
ref="ganttContainer"
|
||||
class="gantt-container"
|
||||
>
|
||||
<GGanttChart
|
||||
:date-format="DAYJS_ISO_DATE_FORMAT"
|
||||
:chart-start="isoToKebabDate(filters.dateFrom)"
|
||||
:chart-end="isoToKebabDate(filters.dateTo)"
|
||||
precision="day"
|
||||
bar-start="startDate"
|
||||
bar-end="endDate"
|
||||
:grid="true"
|
||||
:width="ganttChartWidth"
|
||||
:color-scheme="GANTT_COLOR_SCHEME"
|
||||
@dragendBar="updateGanttTask"
|
||||
@dblclickBar="openTask"
|
||||
>
|
||||
<template #timeunit="{date}">
|
||||
<div
|
||||
class="timeunit-wrapper"
|
||||
:class="{'today': dateIsToday(date)}"
|
||||
>
|
||||
<span>{{ date.getDate() }}</span>
|
||||
<span class="weekday">
|
||||
{{ weekDayFromDate(date) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<GGanttRow
|
||||
v-for="(bar, k) in ganttBars"
|
||||
:key="k"
|
||||
label=""
|
||||
:bars="bar"
|
||||
/>
|
||||
</GGanttChart>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, watch, toRefs} from 'vue'
|
||||
import {useRouter} from 'vue-router'
|
||||
|
||||
import { useGlobalNow } from '@/composables/useGlobalNow'
|
||||
|
||||
import {getHexColor} from '@/models/task'
|
||||
|
||||
import {colorIsDark} from '@/helpers/color/colorIsDark'
|
||||
import {isoToKebabDate} from '@/helpers/time/isoToKebabDate'
|
||||
|
||||
import type {ITask, ITaskPartialWithId} from '@/modelTypes/ITask'
|
||||
import type {DateISO} from '@/types/DateISO'
|
||||
import type {GanttFilters} from '@/views/project/helpers/useGanttFilters'
|
||||
|
||||
import {
|
||||
extendDayjs,
|
||||
GGanttChart,
|
||||
GGanttRow,
|
||||
type GanttBarObject, type ColorScheme,
|
||||
} from '@infectoone/vue-ganttastic'
|
||||
|
||||
import Loading from '@/components/misc/Loading.vue'
|
||||
import {MILLISECONDS_A_DAY} from '@/constants/date'
|
||||
import {useWeekDayFromDate} from '@/helpers/time/formatDate'
|
||||
import dayjs from 'dayjs'
|
||||
import {useDayjsLanguageSync} from '@/i18n/useDayjsLanguageSync'
|
||||
|
||||
export interface GanttChartProps {
|
||||
isLoading: boolean,
|
||||
filters: GanttFilters,
|
||||
tasks: Map<ITask['id'], ITask>,
|
||||
defaultTaskStartDate: DateISO
|
||||
defaultTaskEndDate: DateISO
|
||||
}
|
||||
|
||||
const props = defineProps<GanttChartProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:task', task: ITaskPartialWithId): void
|
||||
}>()
|
||||
|
||||
const DAYJS_ISO_DATE_FORMAT = 'YYYY-MM-DD'
|
||||
|
||||
const {tasks, filters} = toRefs(props)
|
||||
|
||||
// setup dayjs for vue-ganttastic
|
||||
// const dayjsLanguageLoading = ref(false)
|
||||
const dayjsLanguageLoading = useDayjsLanguageSync(dayjs)
|
||||
extendDayjs()
|
||||
|
||||
const ganttContainer = ref(null)
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const dateFromDate = computed(() => new Date(new Date(filters.value.dateFrom).setHours(0,0,0,0)))
|
||||
const dateToDate = computed(() => new Date(new Date(filters.value.dateTo).setHours(23,59,0,0)))
|
||||
|
||||
const DAY_WIDTH_PIXELS = 30
|
||||
const ganttChartWidth = computed(() => {
|
||||
|
||||
const ganttContainerReference = ganttContainer?.value
|
||||
const ganttContainerWidth = ganttContainerReference ? (ganttContainerReference['clientWidth'] ?? 0) : 0
|
||||
|
||||
const dateDiff = Math.floor((dateToDate.value.valueOf() - dateFromDate.value.valueOf()) / MILLISECONDS_A_DAY)
|
||||
const calculatedWidth = dateDiff * DAY_WIDTH_PIXELS
|
||||
|
||||
return (calculatedWidth > ganttContainerWidth) ? calculatedWidth + 'px' : '100%'
|
||||
|
||||
})
|
||||
|
||||
const ganttBars = ref<GanttBarObject[][]>([])
|
||||
|
||||
const GANTT_COLOR_SCHEME: ColorScheme = {
|
||||
primary: 'var(--grey-100)',
|
||||
secondary: 'var(--grey-300)',
|
||||
ternary: 'var(--grey-500)',
|
||||
quartenary: 'var(--grey-600)',
|
||||
hoverHighlight: 'var(--grey-700)',
|
||||
text: 'var(--grey-800)',
|
||||
background: 'var(--white)',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Update ganttBars when tasks change
|
||||
*/
|
||||
watch(
|
||||
tasks,
|
||||
() => {
|
||||
ganttBars.value = []
|
||||
tasks.value.forEach(t => ganttBars.value.push(transformTaskToGanttBar(t)))
|
||||
},
|
||||
{deep: true, immediate: true},
|
||||
)
|
||||
|
||||
function transformTaskToGanttBar(t: ITask) {
|
||||
const black = 'var(--grey-800)'
|
||||
|
||||
const taskColor = getHexColor(t.hexColor)
|
||||
|
||||
let textColor = black
|
||||
let backgroundColor = 'var(--grey-100)'
|
||||
if(t.startDate) {
|
||||
backgroundColor = taskColor ?? ''
|
||||
if(typeof taskColor === 'undefined') {
|
||||
textColor = 'white'
|
||||
backgroundColor = 'var(--primary)'
|
||||
} else if(colorIsDark(taskColor)) {
|
||||
textColor = black
|
||||
} else {
|
||||
textColor = 'white'
|
||||
}
|
||||
}
|
||||
|
||||
return [{
|
||||
startDate: isoToKebabDate(t.startDate ? t.startDate.toISOString() : props.defaultTaskStartDate),
|
||||
endDate: isoToKebabDate(t.endDate ? t.endDate.toISOString() : props.defaultTaskEndDate),
|
||||
ganttBarConfig: {
|
||||
id: String(t.id),
|
||||
label: t.title,
|
||||
hasHandles: true,
|
||||
style: {
|
||||
color: textColor,
|
||||
backgroundColor,
|
||||
border: t.startDate ? '' : '2px dashed var(--grey-300)',
|
||||
'text-decoration': t.done ? 'line-through' : null,
|
||||
},
|
||||
},
|
||||
} as GanttBarObject]
|
||||
}
|
||||
|
||||
async function updateGanttTask(e: {
|
||||
bar: GanttBarObject;
|
||||
e: MouseEvent;
|
||||
datetime?: string | undefined;
|
||||
}) {
|
||||
emit('update:task', {
|
||||
id: Number(e.bar.ganttBarConfig.id),
|
||||
startDate: new Date((new Date(e.bar.startDate)).setHours(0,0,0,0)),
|
||||
endDate: new Date((new Date(e.bar.endDate)).setHours(23,59,0,0)),
|
||||
})
|
||||
}
|
||||
|
||||
function openTask(e: {
|
||||
bar: GanttBarObject;
|
||||
e: MouseEvent;
|
||||
datetime?: string | undefined;
|
||||
}) {
|
||||
router.push({
|
||||
name: 'task.detail',
|
||||
params: {id: e.bar.ganttBarConfig.id},
|
||||
state: {backdropView: router.currentRoute.value.fullPath},
|
||||
})
|
||||
}
|
||||
|
||||
const weekDayFromDate = useWeekDayFromDate()
|
||||
|
||||
const {now: today} = useGlobalNow()
|
||||
const dateIsToday = computed(() => (date: Date) => {
|
||||
return (
|
||||
date.getDate() === today.value.getDate() &&
|
||||
date.getMonth() === today.value.getMonth() &&
|
||||
date.getFullYear() === today.value.getFullYear()
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.gantt-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
<style lang="scss">
|
||||
// Not scoped because we need to style the elements inside the gantt chart component
|
||||
.g-gantt-chart {
|
||||
inline-size: max-content;
|
||||
}
|
||||
|
||||
.g-gantt-row-label {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.g-upper-timeunit, .g-timeunit {
|
||||
background: var(--white) !important;
|
||||
font-family: $vikunja-font;
|
||||
}
|
||||
|
||||
.g-upper-timeunit {
|
||||
font-weight: bold;
|
||||
border-inline-end: 1px solid var(--grey-200);
|
||||
padding: .5rem 0;
|
||||
}
|
||||
|
||||
.g-timeunit .timeunit-wrapper {
|
||||
padding: 0.5rem 0;
|
||||
font-size: 1rem !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
inline-size: 100%;
|
||||
|
||||
&.today {
|
||||
background: var(--primary);
|
||||
color: var(--white);
|
||||
border-radius: 5px 5px 0 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.weekday {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.g-timeaxis {
|
||||
block-size: auto !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.g-gantt-row > .g-gantt-row-bars-container {
|
||||
border-inline-end: none !important;
|
||||
border-block-start: none !important;
|
||||
}
|
||||
|
||||
.g-gantt-row:nth-child(odd) {
|
||||
background: hsla(var(--grey-100-hsl), .5);
|
||||
}
|
||||
|
||||
.g-gantt-bar {
|
||||
border-radius: $radius * 1.5;
|
||||
overflow: visible;
|
||||
font-size: .85rem;
|
||||
|
||||
&-handle-left,
|
||||
&-handle-right {
|
||||
inline-size: 6px !important;
|
||||
block-size: 75% !important;
|
||||
opacity: .75 !important;
|
||||
border-radius: $radius !important;
|
||||
margin-block-start: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
108
frontend/src/composables/useGanttBar.ts
Normal file
108
frontend/src/composables/useGanttBar.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface GanttBarModel {
|
||||
id: string
|
||||
start: Date
|
||||
end: Date
|
||||
meta?: {
|
||||
label?: string
|
||||
color?: string
|
||||
hasActualDates?: boolean
|
||||
isDone?: boolean
|
||||
task?: unknown
|
||||
}
|
||||
}
|
||||
export interface UseGanttBarOptions {
|
||||
model: GanttBarModel
|
||||
timelineStart: Date
|
||||
timelineEnd: Date
|
||||
onUpdate?: (id: string, newStart: Date, newEnd: Date) => void
|
||||
}
|
||||
|
||||
export function useGanttBar(options: UseGanttBarOptions) {
|
||||
const dragging = ref(false)
|
||||
const selected = ref(false)
|
||||
const focused = ref(false)
|
||||
|
||||
function onFocus() {
|
||||
focused.value = true
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
focused.value = false
|
||||
}
|
||||
|
||||
function changeSize(direction: 'left' | 'right', modifier: -1 | 1) {
|
||||
const newStart = new Date(options.model.start)
|
||||
const newEnd = new Date(options.model.end)
|
||||
|
||||
if (direction === 'left') {
|
||||
// Shift+Left: Expand task to the left (move start date earlier)
|
||||
newStart.setDate(newStart.getDate() - 1 * modifier)
|
||||
} else {
|
||||
// Shift+Right: Expand task to the right (move end date later)
|
||||
newEnd.setDate(newEnd.getDate() + 1 * modifier)
|
||||
}
|
||||
|
||||
// Validate that start is before end (maintain minimum 1 day duration)
|
||||
if (newStart < newEnd) {
|
||||
options.model.start = newStart
|
||||
options.model.end = newEnd
|
||||
|
||||
if (options.onUpdate) {
|
||||
options.onUpdate(options.model.id, newStart, newEnd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
// task expanding
|
||||
if (e.shiftKey) {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault()
|
||||
changeSize('left', 1)
|
||||
}
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault()
|
||||
changeSize('right', 1)
|
||||
}
|
||||
}
|
||||
// task shrinking
|
||||
else if (e.ctrlKey) {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault()
|
||||
changeSize('left', -1)
|
||||
}
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault()
|
||||
changeSize('right', -1)
|
||||
}
|
||||
}
|
||||
// task movement
|
||||
else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||
e.preventDefault()
|
||||
|
||||
const dir = e.key === 'ArrowRight' ? 1 : -1
|
||||
const newStart = new Date(options.model.start)
|
||||
newStart.setDate(newStart.getDate() + dir)
|
||||
const newEnd = new Date(options.model.end)
|
||||
newEnd.setDate(newEnd.getDate() + dir)
|
||||
|
||||
options.model.start = newStart
|
||||
options.model.end = newEnd
|
||||
|
||||
if (options.onUpdate) {
|
||||
options.onUpdate(options.model.id, newStart, newEnd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
dragging,
|
||||
selected,
|
||||
focused,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
}
|
||||
}
|
||||
11
frontend/src/helpers/color/getTextColor.ts
Normal file
11
frontend/src/helpers/color/getTextColor.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { colorIsDark } from './colorIsDark'
|
||||
|
||||
export const LIGHT = 'hsl(220, 13%, 91%)' // grey-200
|
||||
export const DARK = 'hsl(215, 27.9%, 16.9%)' // grey-800
|
||||
|
||||
export function getTextColor(backgroundColor: string) {
|
||||
return colorIsDark(backgroundColor)
|
||||
// Fixed colors to avoid flipping in dark mode
|
||||
? DARK
|
||||
: LIGHT
|
||||
}
|
||||
@@ -366,7 +366,21 @@
|
||||
"day": "Day",
|
||||
"hour": "Hour",
|
||||
"range": "Date Range",
|
||||
"noDates": "This task has no dates set."
|
||||
"chartLabel": "Project Gantt Chart",
|
||||
"taskBarsForRow": "Task bars for row {rowId}",
|
||||
"taskBarLabel": "Task: {task}. From {startDate} to {endDate}. {dateType}. Click to edit, drag to move.",
|
||||
"scheduledDates": "Scheduled dates",
|
||||
"estimatedDates": "Estimated dates",
|
||||
"resizeStartDate": "Resize start date for task {task}",
|
||||
"resizeEndDate": "Resize end date for task {task}",
|
||||
"timelineHeader": "Timeline header with months and days",
|
||||
"monthsRow": "Months row",
|
||||
"daysRow": "Days row",
|
||||
"monthLabel": "Month: {month}",
|
||||
"dayLabel": "Day: {date}, {weekday}",
|
||||
"dayLabelToday": "Today: {date}, {weekday}",
|
||||
"taskAriaLabel": "Task: {task}",
|
||||
"taskAriaLabelById": "Task {id}"
|
||||
},
|
||||
"table": {
|
||||
"title": "Table",
|
||||
@@ -1101,6 +1115,15 @@
|
||||
"navigateDown": "Highlight next task",
|
||||
"navigateUp": "Highlight previous task",
|
||||
"open": "Open highlighted task"
|
||||
},
|
||||
"gantt": {
|
||||
"title": "Gantt Chart",
|
||||
"moveTaskLeft": "Move task to earlier date",
|
||||
"moveTaskRight": "Move task to later date",
|
||||
"expandTaskLeft": "Expand task start date earlier",
|
||||
"expandTaskRight": "Expand task end date later",
|
||||
"shrinkTaskLeft": "Shrink task from start date",
|
||||
"shrinkTaskRight": "Shrink task from end date"
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
|
||||
@@ -4,7 +4,7 @@ import UserModel from './user'
|
||||
import type {ILabel} from '@/modelTypes/ILabel'
|
||||
import type {IUser} from '@/modelTypes/IUser'
|
||||
|
||||
import {colorIsDark} from '@/helpers/color/colorIsDark'
|
||||
import {getTextColor} from '@/helpers/color/getTextColor'
|
||||
|
||||
export default class LabelModel extends AbstractModel<ILabel> implements ILabel {
|
||||
id = 0
|
||||
@@ -29,10 +29,7 @@ export default class LabelModel extends AbstractModel<ILabel> implements ILabel
|
||||
}
|
||||
|
||||
if (this.hexColor !== '') {
|
||||
this.textColor = colorIsDark(this.hexColor)
|
||||
// Fixed colors to avoid flipping in dark mode
|
||||
? 'hsl(215, 27.9%, 16.9%)' // grey-800
|
||||
: 'hsl(220, 13%, 91%)' // grey-200
|
||||
this.textColor = getTextColor(this.hexColor)
|
||||
}
|
||||
|
||||
this.createdBy = new UserModel(this.createdBy)
|
||||
|
||||
Reference in New Issue
Block a user