375 lines
9.0 KiB
Vue
375 lines
9.0 KiB
Vue
<template>
|
|
<div>
|
|
<Loading
|
|
v-if="taskCollectionService.loading || dayjsLanguageLoading"
|
|
class="gantt-container"
|
|
/>
|
|
<div class="gantt-container" v-else>
|
|
<GGanttChart
|
|
:date-format="DAYJS_ISO_DATE_FORMAT"
|
|
:chart-start="isoToKebabDate(props.dateFrom)"
|
|
:chart-end="isoToKebabDate(props.dateTo)"
|
|
precision="day"
|
|
bar-start="startDate"
|
|
bar-end="endDate"
|
|
:grid="true"
|
|
@dragend-bar="updateTask"
|
|
@dblclick-bar="openTask"
|
|
:width="ganttChartWidth + 'px'"
|
|
>
|
|
<template #timeunit="{label, value}">
|
|
<div
|
|
class="timeunit-wrapper"
|
|
:class="{'today': dayIsToday(label)}"
|
|
>
|
|
<span>{{ value }}</span>
|
|
<span class="weekday">
|
|
{{ weekdayFromTimeLabel(label) }}
|
|
</span>
|
|
</div>
|
|
</template>
|
|
<GGanttRow
|
|
v-for="(bar, k) in ganttBars"
|
|
:key="k"
|
|
label=""
|
|
:bars="bar"
|
|
/>
|
|
</GGanttChart>
|
|
</div>
|
|
<TaskForm v-if="canWrite" @create-task="createTask" />
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import {computed, ref, watch, watchEffect, shallowReactive, type CSSProperties} from 'vue'
|
|
import {useRouter} from 'vue-router'
|
|
import {format, parse} from 'date-fns'
|
|
import dayjs from 'dayjs'
|
|
import isToday from 'dayjs/plugin/isToday'
|
|
import cloneDeep from 'lodash.clonedeep'
|
|
|
|
import {useDayjsLanguageSync} from '@/i18n'
|
|
import TaskCollectionService from '@/services/taskCollection'
|
|
import TaskService from '@/services/task'
|
|
import TaskModel, { getHexColor } from '@/models/task'
|
|
|
|
import {colorIsDark} from '@/helpers/color/colorIsDark'
|
|
import {RIGHTS} from '@/constants/rights'
|
|
|
|
import type {ITask} from '@/modelTypes/ITask'
|
|
import type {IList} from '@/modelTypes/IList'
|
|
|
|
import {
|
|
extendDayjs,
|
|
GGanttChart,
|
|
GGanttRow,
|
|
type GanttBarObject,
|
|
} from '@infectoone/vue-ganttastic'
|
|
|
|
import Loading from '@/components/misc/loading.vue'
|
|
import TaskForm from '@/components/tasks/TaskForm.vue'
|
|
|
|
import {useBaseStore} from '@/stores/base'
|
|
import { error, success } from '@/message'
|
|
|
|
export type DateRange = {
|
|
dateFrom: string,
|
|
dateTo: string,
|
|
}
|
|
|
|
// export interface GanttChartProps extends DateRange {
|
|
export interface GanttChartProps {
|
|
listId: IList['id']
|
|
showTasksWithoutDates: boolean
|
|
dateFrom: string,
|
|
dateTo: string,
|
|
}
|
|
|
|
// export const DATE_FORMAT = 'yyyy-LL-dd HH:mm'
|
|
|
|
const DAYJS_ISO_DATE_FORMAT = 'YYYY-MM-DD'
|
|
|
|
const props = withDefaults(defineProps<GanttChartProps>(), {
|
|
showTasksWithoutDates: false,
|
|
})
|
|
|
|
// setup dayjs for vue-ganttastic
|
|
const dayjsLanguageLoading = ref(false)
|
|
// const dayjsLanguageLoading = useDayjsLanguageSync(dayjs)
|
|
dayjs.extend(isToday)
|
|
extendDayjs()
|
|
|
|
const baseStore = useBaseStore()
|
|
const router = useRouter()
|
|
|
|
const taskCollectionService = shallowReactive(new TaskCollectionService())
|
|
const taskService = shallowReactive(new TaskService())
|
|
|
|
const dateFromDate = computed(() => new Date(new Date(props.dateFrom).setHours(0,0,0,0)))
|
|
const dateToDate = computed(() => new Date(new Date(props.dateTo).setHours(23,59,0,0)))
|
|
|
|
const DAY_WIDTH_PIXELS = 30
|
|
const ganttChartWidth = computed(() => {
|
|
const dateDiff = Math.floor((dateToDate.value.valueOf() - dateFromDate.value.valueOf()) / (1000 * 60 * 60 * 24))
|
|
|
|
return dateDiff * DAY_WIDTH_PIXELS
|
|
})
|
|
|
|
const canWrite = computed(() => baseStore.currentList.maxRight > RIGHTS.READ)
|
|
|
|
const tasks = ref<Map<ITask['id'], ITask>>(new Map())
|
|
const ganttBars = ref<GanttBarObject[][]>([])
|
|
|
|
watch(
|
|
tasks,
|
|
() => {
|
|
ganttBars.value = []
|
|
tasks.value.forEach(t => ganttBars.value.push(transformTaskToGanttBar(t)))
|
|
},
|
|
{deep: true}
|
|
)
|
|
|
|
type DateKebab = `${string}-${string}-${string}`
|
|
type DateISO = string
|
|
const DATE_FORMAT_KEBAB = 'yyyy-LL-dd'
|
|
function isoToKebabDate(isoDate: DateISO) {
|
|
return format(new Date(isoDate), DATE_FORMAT_KEBAB) as DateKebab
|
|
}
|
|
|
|
const now = new Date()
|
|
const defaultStartDate = new Date(now)
|
|
const defaultEndDate = new Date(now.setDate(now.getDate() + 7))
|
|
|
|
function transformTaskToGanttBar(t: ITask) {
|
|
const black = 'var(--grey-800)'
|
|
console.log(t)
|
|
return [{
|
|
startDate: isoToKebabDate(t.startDate ? t.startDate.toISOString() : defaultStartDate.toISOString()),
|
|
endDate: isoToKebabDate(t.endDate ? t.endDate.toISOString() : defaultEndDate.toISOString()),
|
|
ganttBarConfig: {
|
|
id: String(t.id),
|
|
label: t.title,
|
|
hasHandles: true,
|
|
style: {
|
|
color: t.startDate ? (colorIsDark(getHexColor(t.hexColor)) ? black : 'white') : black,
|
|
backgroundColor: t.startDate ? getHexColor(t.hexColor) : 'var(--grey-100)',
|
|
border: t.startDate ? '' : '2px dashed var(--grey-300)',
|
|
'text-decoration': t.done ? 'line-through' : null,
|
|
},
|
|
},
|
|
} as GanttBarObject]
|
|
}
|
|
|
|
// FIXME: unite with other filter params types
|
|
interface GetAllTasksParams {
|
|
sort_by: ('start_date' | 'done' | 'id')[],
|
|
order_by: ('asc' | 'asc' | 'desc')[],
|
|
filter_by: 'start_date'[],
|
|
filter_comparator: ('greater_equals' | 'less_equals')[],
|
|
filter_value: [string, string] // [dateFrom, dateTo],
|
|
filter_concat: 'and',
|
|
filter_include_nulls: boolean,
|
|
}
|
|
|
|
async function getAllTasks(params: GetAllTasksParams, page = 1): Promise<ITask[]> {
|
|
const tasks = await taskCollectionService.getAll({listId: props.listId}, params, page) as ITask[]
|
|
if (page < taskCollectionService.totalPages) {
|
|
const nextTasks = await getAllTasks(params, page + 1)
|
|
return tasks.concat(nextTasks)
|
|
}
|
|
return tasks
|
|
}
|
|
|
|
async function loadTasks({
|
|
dateTo,
|
|
dateFrom,
|
|
showTasksWithoutDates,
|
|
}: {
|
|
dateTo: string;
|
|
dateFrom: string;
|
|
showTasksWithoutDates: boolean;
|
|
}) {
|
|
tasks.value = new Map()
|
|
|
|
const params: GetAllTasksParams = {
|
|
sort_by: ['start_date', 'done', 'id'],
|
|
order_by: ['asc', 'asc', 'desc'],
|
|
filter_by: ['start_date', 'start_date'],
|
|
filter_comparator: ['greater_equals', 'less_equals'],
|
|
filter_value: [isoToKebabDate(dateFrom), isoToKebabDate(dateTo)],
|
|
filter_concat: 'and',
|
|
filter_include_nulls: showTasksWithoutDates,
|
|
}
|
|
|
|
const loadedTasks = await getAllTasks(params)
|
|
|
|
loadedTasks.forEach(t => tasks.value.set(t.id, t))
|
|
}
|
|
|
|
watchEffect(() => loadTasks({
|
|
dateTo: props.dateTo,
|
|
dateFrom: props.dateFrom,
|
|
showTasksWithoutDates: props.showTasksWithoutDates,
|
|
}))
|
|
|
|
async function createTask(title: ITask['title']) {
|
|
const newTask = await taskService.create(new TaskModel({
|
|
title,
|
|
listId: props.listId,
|
|
startDate: defaultStartDate,
|
|
endDate: defaultEndDate,
|
|
}))
|
|
tasks.value.set(newTask.id, newTask)
|
|
|
|
return newTask
|
|
}
|
|
|
|
async function updateTask(e: {
|
|
bar: GanttBarObject;
|
|
e: MouseEvent;
|
|
datetime?: string | undefined;
|
|
}) {
|
|
const task = tasks.value.get(Number(e.bar.ganttBarConfig.id))
|
|
|
|
if (!task) return
|
|
|
|
|
|
const startDate = parse(e.bar.startDate, 'yyyy-MM-dd', new Date())
|
|
const endDate = parse(e.bar.endDate, 'yyyy-MM-dd', new Date())
|
|
|
|
const oldTask = cloneDeep(task)
|
|
|
|
const newTask: ITask = {
|
|
...task,
|
|
startDate,
|
|
endDate,
|
|
}
|
|
|
|
const newTaskCopy = cloneDeep(task)
|
|
|
|
tasks.value.set(newTask.id, newTask)
|
|
|
|
// try {
|
|
const updatedTask = await taskService.update(newTask)
|
|
// tasks.value.set(updatedTask.id, updatedTask)
|
|
success('Saved')
|
|
// } catch(e: any) {
|
|
// error('Saved')
|
|
// tasks.value.set(task.id, oldTask)
|
|
// }
|
|
|
|
|
|
// for (let [idStr, currentTask] of tasks.value) {
|
|
// const id: ITask['id'] = Number(idStr)
|
|
// }
|
|
|
|
// ganttBars.value.map(gantBar => {
|
|
// return Number(gantBar[0].ganttBarConfig.id) === task.id
|
|
// ? transformTaskToGanttBar(updatedTask)
|
|
// : gantBar
|
|
// })
|
|
}
|
|
|
|
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},
|
|
})
|
|
}
|
|
|
|
function weekdayFromTimeLabel(label: string): string {
|
|
const parsed = parse(label, 'dd.MMM', dateFromDate.value)
|
|
return format(parsed, 'E')
|
|
}
|
|
|
|
function dayIsToday(label: string): boolean {
|
|
const parsed = parse(label, 'dd.MMM', dateFromDate.value)
|
|
const today = new Date()
|
|
return parsed.getDate() === today.getDate() &&
|
|
parsed.getMonth() === today.getMonth() &&
|
|
parsed.getFullYear() === today.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 {
|
|
width: 2000px;
|
|
}
|
|
|
|
.g-gantt-row-label {
|
|
display: none;
|
|
}
|
|
|
|
.g-upper-timeunit, .g-timeunit {
|
|
background: var(--white);
|
|
font-family: $vikunja-font;
|
|
}
|
|
|
|
.g-upper-timeunit {
|
|
font-weight: bold;
|
|
border-right: 1px solid var(--grey-200);
|
|
padding: .5rem 0;
|
|
}
|
|
|
|
.g-timeunit .timeunit-wrapper {
|
|
padding: 0.5rem 0;
|
|
font-size: 1rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
width: 100%;
|
|
|
|
&.today {
|
|
background: var(--primary);
|
|
color: var(--white);
|
|
border-radius: 5px 5px 0 0;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.weekday {
|
|
font-size: 0.8rem;
|
|
}
|
|
}
|
|
|
|
.g-timeaxis {
|
|
height: auto;
|
|
box-shadow: none;
|
|
}
|
|
|
|
.g-gantt-row > .g-gantt-row-bars-container {
|
|
border-bottom: none;
|
|
border-top: none;
|
|
}
|
|
|
|
.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 {
|
|
width: 6px;
|
|
height: 75%;
|
|
opacity: .75;
|
|
border-radius: $radius;
|
|
margin-top: 4px;
|
|
}
|
|
}
|
|
</style> |