487 lines
12 KiB
Vue
487 lines
12 KiB
Vue
<template>
|
|
<div class="gantt-chart">
|
|
<div class="filter-container">
|
|
<div class="items">
|
|
<x-button
|
|
@click.prevent.stop="showTaskFilter = !showTaskFilter"
|
|
type="secondary"
|
|
icon="filter"
|
|
>
|
|
Filters
|
|
</x-button>
|
|
</div>
|
|
<filter-popup
|
|
@change="loadTasks"
|
|
:visible="showTaskFilter"
|
|
v-model="params"
|
|
/>
|
|
</div>
|
|
<div class="dates">
|
|
<template v-for="(y, yk) in days">
|
|
<div :key="yk + 'year'" class="months">
|
|
<div
|
|
:key="mk + 'month'"
|
|
class="month"
|
|
v-for="(m, mk) in days[yk]"
|
|
>
|
|
{{
|
|
new Date(
|
|
new Date(yk).setMonth(mk)
|
|
).toLocaleString('en-us', {month: 'long'})
|
|
}},
|
|
{{ new Date(yk).getFullYear() }}
|
|
<div class="days">
|
|
<div
|
|
:class="{
|
|
today:
|
|
d.toDateString() === now.toDateString(),
|
|
}"
|
|
:key="dk + 'day'"
|
|
:style="{ width: dayWidth + 'px' }"
|
|
class="day"
|
|
v-for="(d, dk) in days[yk][mk]"
|
|
>
|
|
<span class="theday" v-if="dayWidth > 25">
|
|
{{ d.getDate() }}
|
|
</span>
|
|
<span class="weekday" v-if="dayWidth > 25">
|
|
{{
|
|
d.toLocaleString('en-us', {
|
|
weekday: 'short',
|
|
})
|
|
}}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<div :style="{ width: fullWidth + 'px' }" class="tasks">
|
|
<div
|
|
:key="t.id"
|
|
:style="{
|
|
background:
|
|
'repeating-linear-gradient(90deg, #ededed, #ededed 1px, ' +
|
|
(k % 2 === 0
|
|
? '#fafafa 1px, #fafafa '
|
|
: '#fff 1px, #fff ') +
|
|
dayWidth +
|
|
'px)',
|
|
}"
|
|
class="row"
|
|
v-for="(t, k) in theTasks"
|
|
>
|
|
<VueDragResize
|
|
:class="{
|
|
done: t.done,
|
|
'is-current-edit':
|
|
taskToEdit !== null && taskToEdit.id === t.id,
|
|
'has-light-text': !colorIsDark(t.getHexColor()),
|
|
'has-dark-text': colorIsDark(t.getHexColor()),
|
|
}"
|
|
:gridX="dayWidth"
|
|
:h="31"
|
|
:isActive="canWrite"
|
|
:minw="dayWidth"
|
|
:parentLimitation="true"
|
|
:parentW="fullWidth"
|
|
:snapToGrid="true"
|
|
:sticks="['mr', 'ml']"
|
|
:style="{
|
|
'border-color': t.getHexColor(),
|
|
'background-color': t.getHexColor(),
|
|
}"
|
|
:w="t.durationDays * dayWidth"
|
|
:x="t.offsetDays * dayWidth - 6"
|
|
:y="0"
|
|
@clicked="setTaskDragged(t)"
|
|
@dragstop="resizeTask"
|
|
@resizestop="resizeTask"
|
|
axis="x"
|
|
class="task"
|
|
>
|
|
<span
|
|
:class="{
|
|
'has-high-priority': t.priority >= priorities.HIGH,
|
|
'has-not-so-high-priority':
|
|
t.priority === priorities.HIGH,
|
|
'has-super-high-priority':
|
|
t.priority === priorities.DO_NOW,
|
|
}"
|
|
>{{ t.title }}</span
|
|
>
|
|
<priority-label :priority="t.priority"/>
|
|
<!-- using the key here forces vue to use the updated version model and not the response returned by the api -->
|
|
<a @click="editTask(theTasks[k])" class="edit-toggle">
|
|
<icon icon="pen"/>
|
|
</a>
|
|
</VueDragResize>
|
|
</div>
|
|
<template v-if="showTaskswithoutDates">
|
|
<div
|
|
:key="t.id"
|
|
:style="{
|
|
background:
|
|
'repeating-linear-gradient(90deg, #ededed, #ededed 1px, ' +
|
|
(k % 2 === 0
|
|
? '#fafafa 1px, #fafafa '
|
|
: '#fff 1px, #fff ') +
|
|
dayWidth +
|
|
'px)',
|
|
}"
|
|
class="row"
|
|
v-for="(t, k) in tasksWithoutDates"
|
|
>
|
|
<VueDragResize
|
|
:gridX="dayWidth"
|
|
:h="31"
|
|
:isActive="canWrite"
|
|
:minw="dayWidth"
|
|
:parentLimitation="true"
|
|
:parentW="fullWidth"
|
|
:snapToGrid="true"
|
|
:sticks="['mr', 'ml']"
|
|
:x="dayOffsetUntilToday * dayWidth - 6"
|
|
:y="0"
|
|
@clicked="setTaskDragged(t)"
|
|
@dragstop="resizeTask"
|
|
@resizestop="resizeTask"
|
|
axis="x"
|
|
class="task nodate"
|
|
v-tooltip="'This task has no dates set.'"
|
|
>
|
|
<span>{{ t.title }}</span>
|
|
</VueDragResize>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<form
|
|
@submit.prevent="addNewTask()"
|
|
class="add-new-task"
|
|
v-if="canWrite"
|
|
>
|
|
<transition name="width">
|
|
<input
|
|
@blur="hideCrateNewTask"
|
|
@keyup.esc="newTaskFieldActive = false"
|
|
class="input"
|
|
ref="newTaskTitleField"
|
|
type="text"
|
|
v-if="newTaskFieldActive"
|
|
v-model="newTaskTitle"
|
|
/>
|
|
</transition>
|
|
<x-button @click="showCreateNewTask" :shadow="false" icon="plus">
|
|
Add a new task
|
|
</x-button>
|
|
</form>
|
|
<transition name="fade">
|
|
<card
|
|
v-if="isTaskEdit"
|
|
class="taskedit"
|
|
title="Edit Task"
|
|
@close="() => {isTaskEdit = false;taskToEdit = null}"
|
|
:has-close="true"
|
|
>
|
|
<edit-task :task="taskToEdit"/>
|
|
</card>
|
|
</transition>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import VueDragResize from 'vue-drag-resize'
|
|
import EditTask from './edit-task'
|
|
|
|
import TaskService from '../../services/task'
|
|
import TaskModel from '../../models/task'
|
|
import priorities from '../../models/priorities'
|
|
import PriorityLabel from './partials/priorityLabel'
|
|
import TaskCollectionService from '../../services/taskCollection'
|
|
import {mapState} from 'vuex'
|
|
import Rights from '../../models/rights.json'
|
|
import FilterPopup from '@/components/list/partials/filter-popup'
|
|
|
|
export default {
|
|
name: 'GanttChart',
|
|
components: {
|
|
FilterPopup,
|
|
PriorityLabel,
|
|
EditTask,
|
|
VueDragResize,
|
|
},
|
|
props: {
|
|
listId: {
|
|
type: Number,
|
|
required: true,
|
|
},
|
|
showTaskswithoutDates: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
dateFrom: {
|
|
default: new Date(new Date().setDate(new Date().getDate() - 15)),
|
|
},
|
|
dateTo: {
|
|
default: new Date(new Date().setDate(new Date().getDate() + 30)),
|
|
},
|
|
// The width of a day in pixels, used to calculate all sorts of things.
|
|
dayWidth: {
|
|
type: Number,
|
|
default: 35,
|
|
},
|
|
},
|
|
data() {
|
|
return {
|
|
days: [],
|
|
startDate: null,
|
|
endDate: null,
|
|
theTasks: [], // Pretty much a copy of the prop, since we cant mutate the prop directly
|
|
tasksWithoutDates: [],
|
|
taskService: TaskService,
|
|
taskDragged: null, // Saves to currently dragged task to be able to update it
|
|
fullWidth: 0,
|
|
now: null,
|
|
dayOffsetUntilToday: 0,
|
|
isTaskEdit: false,
|
|
taskToEdit: null,
|
|
newTaskTitle: '',
|
|
newTaskFieldActive: false,
|
|
priorities: {},
|
|
taskCollectionService: TaskCollectionService,
|
|
showTaskFilter: false,
|
|
|
|
params: {
|
|
sort_by: ['done', 'id'],
|
|
order_by: ['asc', 'desc'],
|
|
filter_by: ['done'],
|
|
filter_value: ['false'],
|
|
filter_comparator: ['equals'],
|
|
filter_concat: 'and',
|
|
},
|
|
}
|
|
},
|
|
watch: {
|
|
dateFrom: 'buildTheGanttChart',
|
|
dateTo: 'buildTheGanttChart',
|
|
listId: 'parseTasks',
|
|
},
|
|
created() {
|
|
this.now = new Date()
|
|
this.taskCollectionService = new TaskCollectionService()
|
|
this.taskService = new TaskService()
|
|
this.priorities = priorities
|
|
},
|
|
mounted() {
|
|
this.buildTheGanttChart()
|
|
},
|
|
computed: mapState({
|
|
canWrite: (state) => state.currentList.maxRight > Rights.READ,
|
|
}),
|
|
methods: {
|
|
buildTheGanttChart() {
|
|
this.setDates()
|
|
this.prepareGanttDays()
|
|
this.parseTasks()
|
|
},
|
|
setDates() {
|
|
this.startDate = new Date(this.dateFrom)
|
|
this.endDate = new Date(this.dateTo)
|
|
|
|
this.dayOffsetUntilToday =
|
|
Math.floor((this.now - this.startDate) / 1000 / 60 / 60 / 24) +
|
|
1
|
|
},
|
|
prepareGanttDays() {
|
|
// Layout: years => [months => [days]]
|
|
let years = {}
|
|
for (
|
|
let d = this.startDate;
|
|
d <= this.endDate;
|
|
d.setDate(d.getDate() + 1)
|
|
) {
|
|
let date = new Date(d)
|
|
if (years[date.getFullYear() + ''] === undefined) {
|
|
years[date.getFullYear() + ''] = {}
|
|
}
|
|
if (
|
|
years[date.getFullYear() + ''][date.getMonth() + ''] ===
|
|
undefined
|
|
) {
|
|
years[date.getFullYear() + ''][date.getMonth() + ''] = []
|
|
}
|
|
years[date.getFullYear() + ''][date.getMonth() + ''].push(date)
|
|
this.fullWidth += this.dayWidth
|
|
}
|
|
this.$set(this, 'days', years)
|
|
},
|
|
parseTasks() {
|
|
this.setDates()
|
|
this.loadTasks()
|
|
},
|
|
loadTasks() {
|
|
this.$set(this, 'theTasks', [])
|
|
this.$set(this, 'tasksWithoutDates', [])
|
|
|
|
const getAllTasks = (page = 1) => {
|
|
return this.taskCollectionService
|
|
.getAll({listId: this.listId}, this.params, page)
|
|
.then((tasks) => {
|
|
if (page < this.taskCollectionService.totalPages) {
|
|
return getAllTasks(page + 1).then((nextTasks) => {
|
|
return tasks.concat(nextTasks)
|
|
})
|
|
} else {
|
|
return tasks
|
|
}
|
|
})
|
|
.catch((e) => {
|
|
return Promise.reject(e)
|
|
})
|
|
}
|
|
|
|
getAllTasks()
|
|
.then((tasks) => {
|
|
this.theTasks = tasks
|
|
.filter((t) => {
|
|
if (t.startDate === null && !t.done) {
|
|
this.tasksWithoutDates.push(t)
|
|
}
|
|
return (
|
|
t.startDate >= this.startDate &&
|
|
t.endDate <= this.endDate
|
|
)
|
|
})
|
|
.map((t) => {
|
|
return this.addGantAttributes(t)
|
|
})
|
|
.sort(function (a, b) {
|
|
if (a.startDate < b.startDate) return -1
|
|
if (a.startDate > b.startDate) return 1
|
|
return 0
|
|
})
|
|
})
|
|
.catch((e) => {
|
|
this.error(e, this)
|
|
})
|
|
},
|
|
addGantAttributes(t) {
|
|
t.endDate === null ? this.endDate : t.endDate
|
|
t.durationDays =
|
|
Math.floor((t.endDate - t.startDate) / 1000 / 60 / 60 / 24) + 1
|
|
t.offsetDays =
|
|
Math.floor(
|
|
(t.startDate - this.startDate) / 1000 / 60 / 60 / 24
|
|
) + 1
|
|
return t
|
|
},
|
|
setTaskDragged(t) {
|
|
this.taskDragged = t
|
|
},
|
|
resizeTask(newRect) {
|
|
// Timeout to definitly catch if the user clicked on taskedit
|
|
setTimeout(() => {
|
|
if (this.isTaskEdit) {
|
|
return
|
|
}
|
|
|
|
let didntHaveDates =
|
|
this.taskDragged.startDate === null ? true : false
|
|
|
|
let startDate = new Date(this.startDate)
|
|
startDate.setDate(
|
|
startDate.getDate() + newRect.left / this.dayWidth
|
|
)
|
|
startDate.setUTCHours(0)
|
|
startDate.setUTCMinutes(0)
|
|
startDate.setUTCSeconds(0)
|
|
startDate.setUTCMilliseconds(0)
|
|
this.taskDragged.startDate = startDate
|
|
let endDate = new Date(startDate)
|
|
endDate.setDate(
|
|
startDate.getDate() + newRect.width / this.dayWidth
|
|
)
|
|
this.taskDragged.startDate = startDate
|
|
this.taskDragged.endDate = endDate
|
|
|
|
// We take the task from the overall tasks array because the one in it has bad data after it was updated once.
|
|
// FIXME: This is a workaround. We should use a better mechanism to get the task or, even better,
|
|
// prevent it from containing outdated Data in the first place.
|
|
for (const tt in this.theTasks) {
|
|
if (this.theTasks[tt].id === this.taskDragged.id) {
|
|
this.$set(this, 'taskDragged', this.theTasks[tt])
|
|
break
|
|
}
|
|
}
|
|
|
|
this.taskService
|
|
.update(this.taskDragged)
|
|
.then((r) => {
|
|
// If the task didn't have dates before, we'll update the list
|
|
if (didntHaveDates) {
|
|
for (const t in this.tasksWithoutDates) {
|
|
if (this.tasksWithoutDates[t].id === r.id) {
|
|
this.tasksWithoutDates.splice(t, 1)
|
|
break
|
|
}
|
|
}
|
|
this.theTasks.push(this.addGantAttributes(r))
|
|
} else {
|
|
for (const tt in this.theTasks) {
|
|
if (this.theTasks[tt].id === r.id) {
|
|
this.$set(
|
|
this.theTasks,
|
|
tt,
|
|
this.addGantAttributes(r)
|
|
)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.catch((e) => {
|
|
this.error(e, this)
|
|
})
|
|
}, 100)
|
|
},
|
|
editTask(task) {
|
|
this.taskToEdit = task
|
|
this.isTaskEdit = true
|
|
},
|
|
showCreateNewTask() {
|
|
if (!this.newTaskFieldActive) {
|
|
// Timeout to not send the form if the field isn't even shown
|
|
setTimeout(() => {
|
|
this.newTaskFieldActive = true
|
|
this.$nextTick(() => this.$refs.newTaskTitleField.focus())
|
|
}, 100)
|
|
}
|
|
},
|
|
hideCrateNewTask() {
|
|
if (this.newTaskTitle === '') {
|
|
this.$nextTick(() => (this.newTaskFieldActive = false))
|
|
}
|
|
},
|
|
addNewTask() {
|
|
if (!this.newTaskFieldActive) {
|
|
return
|
|
}
|
|
let task = new TaskModel({
|
|
title: this.newTaskTitle,
|
|
listId: this.listId,
|
|
})
|
|
this.taskService
|
|
.create(task)
|
|
.then((r) => {
|
|
this.tasksWithoutDates.push(this.addGantAttributes(r))
|
|
this.newTaskTitle = ''
|
|
this.hideCrateNewTask()
|
|
})
|
|
.catch((e) => {
|
|
this.error(e, this)
|
|
})
|
|
},
|
|
},
|
|
}
|
|
</script>
|