Gantt Charts (#29)

This commit is contained in:
konrad 2019-04-29 21:41:39 +00:00 committed by Gitea
parent 0aa0a39620
commit d03f0211a3
23 changed files with 1454 additions and 565 deletions

View file

@ -12,7 +12,8 @@
"lodash": "^4.17.11", "lodash": "^4.17.11",
"v-tooltip": "^2.0.0-rc.33", "v-tooltip": "^2.0.0-rc.33",
"verte": "^0.0.10", "verte": "^0.0.10",
"vue": "^2.5.17" "vue": "^2.5.17",
"vue-drag-resize": "^1.3.2"
}, },
"devDependencies": { "devDependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.4", "@fortawesome/fontawesome-svg-core": "^1.2.4",

View file

@ -51,9 +51,7 @@ export default {
email: creds.email, email: creds.email,
password: creds.password password: creds.password
}) })
.then(response => { .then(() => {
// eslint-disable-next-line
console.log(response)
this.login(context, creds, redirect) this.login(context, creds, redirect)
}) })
.catch(e => { .catch(e => {

View file

@ -5,301 +5,14 @@
<icon icon="cog" size="2x"/> <icon icon="cog" size="2x"/>
</router-link> </router-link>
<h1>{{ list.title }}</h1> <h1>{{ list.title }}</h1>
</div> <div class="switch-view">
<form @submit.prevent="addTask()"> <router-link :to="{ name: 'showList', params: { id: list.id } }" :class="{'is-active': $route.params.type !== 'gantt'}">List</router-link>
<div class="field is-grouped"> <router-link :to="{ name: 'showListWithType', params: { id: list.id, type: 'gantt' } }" :class="{'is-active': $route.params.type === 'gantt'}">Gantt</router-link>
<p class="control has-icons-left is-expanded" :class="{ 'is-loading': taskService.loading}">
<input v-focus class="input" :class="{ 'disabled': taskService.loading}" v-model="newTask.text" type="text" placeholder="Add a new task...">
<span class="icon is-small is-left">
<icon icon="tasks"/>
</span>
</p>
<p class="control">
<button type="submit" class="button is-success">
<span class="icon is-small">
<icon icon="plus"/>
</span>
Add
</button>
</p>
</div>
</form>
<div class="columns">
<div class="column">
<div class="tasks" v-if="this.list.tasks && this.list.tasks.length > 0" :class="{'short': isTaskEdit}">
<div class="task" v-for="l in list.tasks" :key="l.id">
<label :for="l.id">
<div class="fancycheckbox">
<input @change="markAsDone" type="checkbox" :id="l.id" :checked="l.done" style="display: none;">
<label :for="l.id" class="check">
<svg width="18px" height="18px" viewBox="0 0 18 18">
<path d="M1,9 L1,3.5 C1,2 2,1 3.5,1 L14.5,1 C16,1 17,2 17,3.5 L17,14.5 C17,16 16,17 14.5,17 L3.5,17 C2,17 1,16 1,14.5 L1,9 Z"></path>
<polyline points="1 9 7 14 15 4"></polyline>
</svg>
</label>
</div>
<span class="tasktext" :class="{ 'done': l.done}">
{{l.text}}
<span class="tag" v-for="label in l.labels" :style="{'background': label.hex_color, 'color': label.textColor}" :key="label.id">
<span>{{ label.title }}</span>
</span>
<i v-if="l.dueDate > 0" :class="{'overdue': (l.dueDate <= new Date())}"> - Due on {{new Date(l.dueDate).toLocaleString()}}</i>
<span v-if="l.priority >= priorities.HIGH" class="high-priority" :class="{'not-so-high': l.priority === priorities.HIGH}">
<span class="icon">
<icon icon="exclamation"/>
</span>
<template v-if="l.priority === priorities.HIGH">High</template>
<template v-if="l.priority === priorities.URGENT">Urgent</template>
<template v-if="l.priority === priorities.DO_NOW">DO NOW</template>
<span class="icon" v-if="l.priority === priorities.DO_NOW">
<icon icon="exclamation"/>
</span>
</span>
</span>
</label>
<div @click="editTask(l.id)" class="icon settings">
<icon icon="cog"/>
</div>
</div>
</div>
</div>
<div class="column is-4" v-if="isTaskEdit">
<div class="card taskedit">
<header class="card-header">
<p class="card-header-title">
Edit Task
</p>
<a class="card-header-icon" @click="isTaskEdit = false">
<span class="icon">
<icon icon="angle-right"/>
</span>
</a>
</header>
<div class="card-content">
<div class="content">
<form @submit.prevent="editTaskSubmit()">
<div class="field">
<label class="label" for="tasktext">Task Text</label>
<div class="control">
<input v-focus :class="{ 'disabled': taskService.loading}" :disabled="taskService.loading" class="input" type="text" id="tasktext" placeholder="The task text is here..." v-model="taskEditTask.text">
</div>
</div>
<div class="field">
<label class="label" for="taskdescription">Description</label>
<div class="control">
<textarea :class="{ 'disabled': taskService.loading}" :disabled="taskService.loading" class="textarea" placeholder="The tasks description goes here..." id="taskdescription" v-model="taskEditTask.description"></textarea>
</div>
</div>
<b>Reminder Dates</b>
<div class="reminder-input" :class="{ 'overdue': (r < nowUnix && index !== (taskEditTask.reminderDates.length - 1))}" v-for="(r, index) in taskEditTask.reminderDates" :key="index">
<flat-pickr
:class="{ 'disabled': taskService.loading}"
:disabled="taskService.loading"
:v-model="taskEditTask.reminderDates"
:config="flatPickerConfig"
:id="'taskreminderdate' + index"
:value="r"
:data-index="index"
placeholder="Add a new reminder...">
</flat-pickr>
<a v-if="index !== (taskEditTask.reminderDates.length - 1)" @click="removeReminderByIndex(index)"><icon icon="times"></icon></a>
</div>
<div class="field">
<label class="label" for="taskduedate">Due Date</label>
<div class="control">
<flat-pickr
:class="{ 'disabled': taskService.loading}"
class="input"
:disabled="taskService.loading"
v-model="taskEditTask.dueDate"
:config="flatPickerConfig"
id="taskduedate"
placeholder="The tasks due date is here...">
</flat-pickr>
</div>
</div>
<div class="field">
<label class="label" for="">Duration</label>
<div class="control columns">
<div class="column">
<flat-pickr
:class="{ 'disabled': taskService.loading}"
class="input"
:disabled="taskService.loading"
v-model="taskEditTask.startDate"
:config="flatPickerConfig"
id="taskduedate"
placeholder="Start date">
</flat-pickr>
</div>
<div class="column">
<flat-pickr
:class="{ 'disabled': taskService.loading}"
class="input"
:disabled="taskService.loading"
v-model="taskEditTask.endDate"
:config="flatPickerConfig"
id="taskduedate"
placeholder="End date">
</flat-pickr>
</div>
</div>
</div>
<div class="field">
<label class="label" for="">Repeat after</label>
<div class="control repeat-after-input columns">
<div class="column">
<input class="input" placeholder="Specify an amount..." v-model="taskEditTask.repeatAfter.amount"/>
</div>
<div class="column is-3">
<div class="select">
<select v-model="taskEditTask.repeatAfter.type">
<option value="hours">Hours</option>
<option value="days">Days</option>
<option value="weeks">Weeks</option>
<option value="months">Months</option>
<option value="years">Years</option>
</select>
</div>
</div>
</div>
</div>
<div class="field">
<label class="label" for="">Priority</label>
<div class="control priority-select">
<div class="select">
<select v-model="taskEditTask.priority">
<option :value="priorities.UNSET">Unset</option>
<option :value="priorities.LOW">Low</option>
<option :value="priorities.MEDIUM">Medium</option>
<option :value="priorities.HIGH">High</option>
<option :value="priorities.URGENT">Urgent</option>
<option :value="priorities.DO_NOW">DO NOW</option>
</select>
</div>
</div>
</div>
<div class="field">
<label class="label" for="">Assignees</label>
<ul class="assingees">
<li v-for="(a, index) in taskEditTask.assignees" :key="a.id">
{{a.username}}
<a @click="deleteAssigneeByIndex(index)"><icon icon="times"/></a>
</li>
</ul>
</div>
<div class="field has-addons">
<div class="control is-expanded">
<multiselect
v-model="newAssignee"
:options="foundUsers"
:multiple="false"
:searchable="true"
:loading="userService.loading"
:internal-search="true"
@search-change="findUser"
placeholder="Type to search"
label="username"
track-by="id">
<template slot="clear" slot-scope="props">
<div class="multiselect__clear" v-if="newAssignee !== null && newAssignee.id !== 0" @mousedown.prevent.stop="clearAllFoundUsers(props.search)"></div>
</template>
<span slot="noResult">Oops! No user found. Consider changing the search query.</span>
</multiselect>
</div>
<div class="control">
<a @click="addAssignee" class="button is-primary fullheight">
<span class="icon is-small">
<icon icon="plus"/>
</span>
</a>
</div>
</div>
<div class="field">
<label class="label">Labels</label>
<div class="control">
<multiselect
:multiple="true"
:close-on-select="false"
:clear-on-select="true"
:options-limit="300"
:hide-selected="true"
v-model="taskEditTask.labels"
:options="foundLabels"
:searchable="true"
:loading="labelService.loading || labelTaskService.loading"
:internal-search="true"
@search-change="findLabel"
@select="addLabel"
placeholder="Type to search"
label="title"
track-by="id"
:taggable="true"
@tag="createAndAddLabel"
tag-placeholder="Add this as new label"
>
<template slot="tag" slot-scope="{ option, remove }">
<span class="tag" :style="{'background': option.hex_color, 'color': option.textColor}">
<span>{{ option.title }}</span>
<a class="delete is-small" @click="removeLabel(option)"></a>
</span>
</template>
<template slot="clear" slot-scope="props">
<div class="multiselect__clear" v-if="taskEditTask.labels.length" @mousedown.prevent.stop="clearAllLabels(props.search)"></div>
</template>
</multiselect>
</div>
</div>
<div class="field">
<label class="label" for="subtasks">Subtasks</label>
<div class="tasks noborder" v-if="taskEditTask.subtasks && taskEditTask.subtasks.length > 0">
<div class="task" v-for="s in taskEditTask.subtasks" :key="s.id">
<label :for="s.id">
<div class="fancycheckbox">
<input @change="markAsDone" type="checkbox" :id="s.id" :checked="s.done" style="display: none;">
<label :for="s.id" class="check">
<svg width="18px" height="18px" viewBox="0 0 18 18">
<path d="M1,9 L1,3.5 C1,2 2,1 3.5,1 L14.5,1 C16,1 17,2 17,3.5 L17,14.5 C17,16 16,17 14.5,17 L3.5,17 C2,17 1,16 1,14.5 L1,9 Z"></path>
<polyline points="1 9 7 14 15 4"></polyline>
</svg>
</label>
</div>
<span class="tasktext" :class="{ 'done': s.done}">
{{s.text}}
</span>
</label>
</div>
</div>
</div>
<div class="field has-addons">
<div class="control is-expanded">
<input @keyup.enter="addSubtask()" :class="{ 'disabled': taskService.loading}" :disabled="taskService.loading" class="input" type="text" id="tasktext" placeholder="New subtask" v-model="newTask.text"/>
</div>
<div class="control">
<a class="button is-primary" @click="addSubtask()"><icon icon="plus"></icon></a>
</div>
</div>
<button type="submit" class="button is-success is-fullwidth" :class="{ 'is-loading': taskService.loading}">
Save
</button>
</form>
</div>
</div>
</div>
</div> </div>
</div> </div>
<gantt :list="list" v-if="$route.params.type === 'gantt'"/>
<show-list-task :the-list="list" v-else/>
</div> </div>
</template> </template>
@ -307,76 +20,39 @@
import auth from '../../auth' import auth from '../../auth'
import router from '../../router' import router from '../../router'
import message from '../../message' import message from '../../message'
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import multiselect from 'vue-multiselect'
import {differenceWith} from 'lodash'
import ListService from '../../services/list' import ShowListTask from '../tasks/ShowListTasks'
import TaskService from '../../services/task' import Gantt from '../tasks/Gantt'
import TaskModel from '../../models/task'
import ListModel from '../../models/list' import ListModel from '../../models/list'
import UserModel from '../../models/user' import ListService from '../../services/list'
import UserService from '../../services/user'
import priorities from '../../models/priorities'
import LabelTaskService from '../../services/labelTask'
import LabelService from '../../services/label'
import LabelTaskModel from '../../models/labelTask'
import LabelModel from '../../models/label'
export default { export default {
data() { data() {
return { return {
listID: this.$route.params.id, listID: this.$route.params.id,
listService: ListService, listService: ListService,
taskService: TaskService, list: ListModel,
priorities: priorities,
list: {},
newTask: TaskModel,
isTaskEdit: false,
taskEditTask: {
subtasks: [],
},
lastReminder: 0,
nowUnix: new Date(),
flatPickerConfig:{
altFormat: 'j M Y H:i',
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
onOpen: this.updateLastReminderDate,
onClose: this.addReminderDate,
},
newAssignee: UserModel,
userService: UserService,
foundUsers: [],
labelService: LabelService,
labelTaskService: LabelTaskService,
foundLabels: [],
labelTimeout: null,
} }
}, },
components: { components: {
flatPickr, Gantt,
multiselect, ShowListTask,
}, },
beforeMount() { beforeMount() {
// Check if the user is already logged in, if so, redirect him to the homepage // Check if the user is already logged in, if so, redirect him to the homepage
if (!auth.user.authenticated) { if (!auth.user.authenticated) {
router.push({name: 'home'}) router.push({name: 'home'})
} }
// If the type is invalid, redirect the user
if (this.$route.params.type !== 'gantt' && this.$route.params.type !== '') {
router.push({name: 'showList', params: { id: this.$route.params.id }})
}
}, },
created() { created() {
this.listService = new ListService() this.listService = new ListService()
this.taskService = new TaskService() this.list = new ListModel()
this.newTask = new TaskModel()
this.userService = new UserService()
this.newAssignee = new UserModel()
this.labelService = new LabelService()
this.labelTaskService = new LabelTaskService()
this.loadList() this.loadList()
}, },
watch: { watch: {
@ -385,7 +61,6 @@
}, },
methods: { methods: {
loadList() { loadList() {
this.isTaskEdit = false
// We create an extra list object instead of creating it in this.list because that would trigger a ui update which would result in bad ux. // We create an extra list object instead of creating it in this.list because that would trigger a ui update which would result in bad ux.
let list = new ListModel({id: this.$route.params.id}) let list = new ListModel({id: this.$route.params.id})
this.listService.get(list) this.listService.get(list)
@ -396,195 +71,6 @@
message.error(e, this) message.error(e, this)
}) })
}, },
addTask() {
this.newTask.listID = this.$route.params.id
this.taskService.create(this.newTask)
.then(r => {
this.list.addTaskToList(r)
message.success({message: 'The task was successfully created.'}, this)
})
.catch(e => {
message.error(e, this)
})
this.newTask = {}
},
markAsDone(e) {
let updateFunc = () => {
// We get the task, update the 'done' property and then push it to the api.
let task = this.list.getTaskByID(e.target.id)
task.done = e.target.checked
this.taskService.update(task)
.then(r => {
this.updateTaskInList(r)
message.success({message: 'The task was successfully ' + (task.done ? '' : 'un-') + 'marked as done.'}, this)
})
.catch(e => {
message.error(e, this)
})
}
if (e.target.checked) {
setTimeout(updateFunc(), 300); // Delay it to show the animation when marking a task as done
} else {
updateFunc() // Don't delay it when un-marking it as it doesn't have an animation the other way around
}
},
editTask(id) {
// Find the selected task and set it to the current object
let theTask = this.list.getTaskByID(id) // Somehow this does not work if we directly assign this to this.taskEditTask
this.taskEditTask = theTask
this.isTaskEdit = true
},
editTaskSubmit() {
this.taskService.update(this.taskEditTask)
.then(r => {
this.updateTaskInList(r)
this.$set(this, 'taskEditTask', r)
message.success({message: 'The task was successfully updated.'}, this)
})
.catch(e => {
message.error(e, this)
})
},
addSubtask() {
this.newTask.parentTaskID = this.taskEditTask.id
this.addTask()
},
updateTaskInList(task) {
// We need to do the update here in the component, because there is no way of notifiying vue of the change otherwise.
for (const t in this.list.tasks) {
if (this.list.tasks[t].id === task.id) {
this.$set(this.list.tasks, t, task)
break
}
if (this.list.tasks[t].id === task.parentTaskID) {
for (const s in this.list.tasks[t].subtasks) {
if (this.list.tasks[t].subtasks[s].id === task.id) {
this.$set(this.list.tasks[t].subtasks, s, task)
break
}
}
}
}
this.list.sortTasks()
},
updateLastReminderDate(selectedDates) {
this.lastReminder = +new Date(selectedDates[0])
},
addReminderDate(selectedDates, dateStr, instance) {
let newDate = +new Date(selectedDates[0])
// Don't update if nothing changed
if (newDate === this.lastReminder) {
return
}
let index = parseInt(instance.input.dataset.index)
this.taskEditTask.reminderDates[index] = newDate
let lastIndex = this.taskEditTask.reminderDates.length - 1
// put a new null at the end if we changed something
if (lastIndex === index && !isNaN(newDate)) {
this.taskEditTask.reminderDates.push(null)
}
},
removeReminderByIndex(index) {
this.taskEditTask.reminderDates.splice(index, 1)
// Reset the last to 0 to have the "add reminder" button
this.taskEditTask.reminderDates[this.taskEditTask.reminderDates.length - 1] = null
},
addAssignee() {
this.taskEditTask.assignees.push(this.newAssignee)
},
deleteAssigneeByIndex(index) {
this.taskEditTask.assignees.splice(index, 1)
},
findUser(query) {
if(query === '') {
this.clearAllFoundUsers()
return
}
this.userService.getAll({}, {s: query})
.then(response => {
// Filter the results to not include users who are already assigned
this.$set(this, 'foundUsers', differenceWith(response, this.taskEditTask.assignees, (first, second) => {
return first.id === second.id
}))
})
.catch(e => {
message.error(e, this)
})
},
clearAllFoundUsers () {
this.$set(this, 'foundUsers', [])
},
findLabel(query) {
if(query === '') {
this.clearAllLabels()
return
}
if(this.labelTimeout !== null) {
clearTimeout(this.labelTimeout)
}
// Delay the search 300ms to not send a request on every keystroke
this.labelTimeout = setTimeout(() => {
this.labelService.getAll({}, {s: query})
.then(response => {
this.$set(this, 'foundLabels', differenceWith(response, this.taskEditTask.labels, (first, second) => {
return first.id === second.id
}))
this.labelTimeout = null
})
.catch(e => {
message.error(e, this)
})
}, 300)
},
clearAllLabels () {
this.$set(this, 'foundLabels', [])
},
addLabel(label) {
let labelTask = new LabelTaskModel({taskID: this.taskEditTask.id, label_id: label.id})
this.labelTaskService.create(labelTask)
.then(() => {
message.success({message: 'The label was successfully added.'}, this)
})
.catch(e => {
message.error(e, this)
})
},
removeLabel(label) {
let labelTask = new LabelTaskModel({taskID: this.taskEditTask.id, label_id: label.id})
this.labelTaskService.delete(labelTask)
.then(() => {
// Remove the label from the list
for (const l in this.taskEditTask.labels) {
if (this.taskEditTask.labels[l].id === label.id) {
this.taskEditTask.labels.splice(l, 1)
}
}
message.success({message: 'The label was successfully removed.'}, this)
})
.catch(e => {
message.error(e, this)
})
},
createAndAddLabel(title) {
let newLabel = new LabelModel({title: title})
this.labelService.create(newLabel)
.then(r => {
this.addLabel(r)
this.taskEditTask.labels.push(r)
})
.catch(e => {
message.error(e, this)
})
}
} }
} }
</script> </script>

View file

@ -0,0 +1,101 @@
<template>
<div>
<div class="gantt-options">
<div class="fancycheckbox is-block">
<input id="showTaskswithoutDates" type="checkbox" style="display: none;" v-model="showTaskswithoutDates">
<label for="showTaskswithoutDates" class="check">
<svg width="18px" height="18px" viewBox="0 0 18 18">
<path d="M1,9 L1,3.5 C1,2 2,1 3.5,1 L14.5,1 C16,1 17,2 17,3.5 L17,14.5 C17,16 16,17 14.5,17 L3.5,17 C2,17 1,16 1,14.5 L1,9 Z"></path>
<polyline points="1 9 7 14 15 4"></polyline>
</svg>
<span>
Show tasks which don't have dates set
</span>
</label>
</div>
<div class="range-picker">
<div class="field">
<label class="label" for="dayWidth">Size</label>
<div class="control">
<div class="select">
<select id="dayWidth" v-model.number="dayWidth">
<option value="35">Default</option>
<option value="10">Month</option>
<option value="80">Day</option>
</select>
</div>
</div>
</div>
<div class="field">
<label class="label" for="fromDate">From</label>
<div class="control">
<flat-pickr
class="input"
v-model="dateFrom"
:config="flatPickerConfig"
id="fromDate"
placeholder="From"
/>
</div>
</div>
<div class="field">
<label class="label" for="toDate">To</label>
<div class="control">
<flat-pickr
class="input"
v-model="dateTo"
:config="flatPickerConfig"
id="toDate"
placeholder="To"
/>
</div>
</div>
</div>
</div>
<gantt-chart
:list="list"
:show-taskswithout-dates="showTaskswithoutDates"
:date-from="dateFrom"
:date-to="dateTo"
:day-width="dayWidth"
/>
</div>
</template>
<script>
import GanttChart from './gantt-component'
import flatPickr from 'vue-flatpickr-component'
import ListModel from '../../models/list'
export default {
name: 'Gantt',
components: {
flatPickr,
GanttChart
},
data() {
return {
showTaskswithoutDates: false,
dayWidth: 35,
dateFrom: null,
dateTo: null,
flatPickerConfig:{
altFormat: 'j M Y',
altInput: true,
dateFormat: 'Y-m-d',
enableTime: false,
},
}
},
beforeMount() {
this.dateFrom = new Date((new Date()).setDate((new Date()).getDate() - 15))
this.dateTo = new Date((new Date()).setDate((new Date()).getDate() + 30))
},
props: {
list: {
type: ListModel,
required: true,
}
},
}
</script>

View file

@ -0,0 +1,177 @@
<template>
<div class="loader-container" :class="{ 'is-loading': listService.loading}">
<form @submit.prevent="addTask()">
<div class="field is-grouped">
<p class="control has-icons-left is-expanded" :class="{ 'is-loading': taskService.loading}">
<input v-focus class="input" :class="{ 'disabled': taskService.loading}" v-model="newTaskText" type="text" placeholder="Add a new task...">
<span class="icon is-small is-left">
<icon icon="tasks"/>
</span>
</p>
<p class="control">
<button type="submit" class="button is-success">
<span class="icon is-small">
<icon icon="plus"/>
</span>
Add
</button>
</p>
</div>
</form>
<div class="columns">
<div class="column">
<div class="tasks" v-if="this.list.tasks && this.list.tasks.length > 0" :class="{'short': isTaskEdit}">
<div class="task" v-for="l in list.tasks" :key="l.id">
<label :for="l.id">
<div class="fancycheckbox">
<input @change="markAsDone" type="checkbox" :id="l.id" :checked="l.done" style="display: none;">
<label :for="l.id" class="check">
<svg width="18px" height="18px" viewBox="0 0 18 18">
<path d="M1,9 L1,3.5 C1,2 2,1 3.5,1 L14.5,1 C16,1 17,2 17,3.5 L17,14.5 C17,16 16,17 14.5,17 L3.5,17 C2,17 1,16 1,14.5 L1,9 Z"></path>
<polyline points="1 9 7 14 15 4"></polyline>
</svg>
</label>
</div>
<span class="tasktext" :class="{ 'done': l.done}">
{{l.text}}
<span class="tag" v-for="label in l.labels" :style="{'background': label.hex_color, 'color': label.textColor}" :key="label.id">
<span>{{ label.title }}</span>
</span>
<i v-if="l.dueDate > 0" :class="{'overdue': (l.dueDate <= new Date())}"> - Due on {{new Date(l.dueDate).toLocaleString()}}</i>
<span v-if="l.priority >= priorities.HIGH" class="high-priority" :class="{'not-so-high': l.priority === priorities.HIGH}">
<span class="icon">
<icon icon="exclamation"/>
</span>
<template v-if="l.priority === priorities.HIGH">High</template>
<template v-if="l.priority === priorities.URGENT">Urgent</template>
<template v-if="l.priority === priorities.DO_NOW">DO NOW</template>
<span class="icon" v-if="l.priority === priorities.DO_NOW">
<icon icon="exclamation"/>
</span>
</span>
</span>
</label>
<div @click="editTask(l.id)" class="icon settings">
<icon icon="cog"/>
</div>
</div>
</div>
</div>
<div class="column is-4" v-if="isTaskEdit">
<div class="card taskedit">
<header class="card-header">
<p class="card-header-title">
Edit Task
</p>
<a class="card-header-icon" @click="isTaskEdit = false">
<span class="icon">
<icon icon="angle-right"/>
</span>
</a>
</header>
<div class="card-content">
<div class="content">
<edit-task :task="taskEditTask"/>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import auth from '../../auth'
import router from '../../router'
import message from '../../message'
import ListService from '../../services/list'
import TaskService from '../../services/task'
import ListModel from '../../models/list'
import EditTask from './edit-task'
import TaskModel from '../../models/task'
import priorities from '../../models/priorities'
export default {
data() {
return {
listID: this.$route.params.id,
listService: ListService,
taskService: TaskService,
list: {},
isTaskEdit: false,
taskEditTask: TaskModel,
newTaskText: '',
priorities: {},
}
},
components: {
EditTask,
},
props: {
theList: {
type: ListModel,
required: true,
}
},
watch: {
theList() {
this.list = this.theList
}
},
beforeMount() {
// Check if the user is already logged in, if so, redirect him to the homepage
if (!auth.user.authenticated) {
router.push({name: 'home'})
}
},
created() {
this.listService = new ListService()
this.taskService = new TaskService()
this.priorities = priorities
this.taskEditTask = null
this.isTaskEdit = false
},
methods: {
addTask() {
let task = new TaskModel({text: this.newTaskText, listID: this.$route.params.id})
this.taskService.create(task)
.then(r => {
this.list.addTaskToList(r)
message.success({message: 'The task was successfully created.'}, this)
})
.catch(e => {
message.error(e, this)
})
},
markAsDone(e) {
let updateFunc = () => {
// We get the task, update the 'done' property and then push it to the api.
let task = this.list.getTaskByID(e.target.id)
task.done = e.target.checked
this.taskService.update(task)
.then(() => {
this.list.sortTasks()
message.success({message: 'The task was successfully ' + (task.done ? '' : 'un-') + 'marked as done.'}, this)
})
.catch(e => {
message.error(e, this)
})
}
if (e.target.checked) {
setTimeout(updateFunc(), 300); // Delay it to show the animation when marking a task as done
} else {
updateFunc() // Don't delay it when un-marking it as it doesn't have an animation the other way around
}
},
editTask(id) {
// Find the selected task and set it to the current object
let theTask = this.list.getTaskByID(id) // Somehow this does not work if we directly assign this to this.taskEditTask
this.taskEditTask = theTask
this.isTaskEdit = true
},
}
}
</script>

View file

@ -0,0 +1,446 @@
<template>
<form @submit.prevent="editTaskSubmit()">
<div class="field">
<label class="label" for="tasktext">Task Text</label>
<div class="control">
<input v-focus :class="{ 'disabled': taskService.loading}" :disabled="taskService.loading" class="input" type="text" id="tasktext" placeholder="The task text is here..." v-model="taskEditTask.text">
</div>
</div>
<div class="field">
<label class="label" for="taskdescription">Description</label>
<div class="control">
<textarea :class="{ 'disabled': taskService.loading}" :disabled="taskService.loading" class="textarea" placeholder="The tasks description goes here..." id="taskdescription" v-model="taskEditTask.description"></textarea>
</div>
</div>
<b>Reminder Dates</b>
<div class="reminder-input" :class="{ 'overdue': (r < nowUnix && index !== (taskEditTask.reminderDates.length - 1))}" v-for="(r, index) in taskEditTask.reminderDates" :key="index">
<flat-pickr
:class="{ 'disabled': taskService.loading}"
:disabled="taskService.loading"
:v-model="taskEditTask.reminderDates"
:config="flatPickerConfig"
:id="'taskreminderdate' + index"
:value="r"
:data-index="index"
placeholder="Add a new reminder...">
</flat-pickr>
<a v-if="index !== (taskEditTask.reminderDates.length - 1)" @click="removeReminderByIndex(index)"><icon icon="times"></icon></a>
</div>
<div class="field">
<label class="label" for="taskduedate">Due Date</label>
<div class="control">
<flat-pickr
:class="{ 'disabled': taskService.loading}"
class="input"
:disabled="taskService.loading"
v-model="taskEditTask.dueDate"
:config="flatPickerConfig"
id="taskduedate"
placeholder="The tasks due date is here...">
</flat-pickr>
</div>
</div>
<div class="field">
<label class="label" for="">Duration</label>
<div class="control columns">
<div class="column">
<flat-pickr
:class="{ 'disabled': taskService.loading}"
class="input"
:disabled="taskService.loading"
v-model="taskEditTask.startDate"
:config="flatPickerConfig"
id="taskduedate"
placeholder="Start date">
</flat-pickr>
</div>
<div class="column">
<flat-pickr
:class="{ 'disabled': taskService.loading}"
class="input"
:disabled="taskService.loading"
v-model="taskEditTask.endDate"
:config="flatPickerConfig"
id="taskduedate"
placeholder="End date">
</flat-pickr>
</div>
</div>
</div>
<div class="field">
<label class="label" for="">Repeat after</label>
<div class="control repeat-after-input columns">
<div class="column">
<input class="input" placeholder="Specify an amount..." v-model="taskEditTask.repeatAfter.amount"/>
</div>
<div class="column is-3">
<div class="select">
<select v-model="taskEditTask.repeatAfter.type">
<option value="hours">Hours</option>
<option value="days">Days</option>
<option value="weeks">Weeks</option>
<option value="months">Months</option>
<option value="years">Years</option>
</select>
</div>
</div>
</div>
</div>
<div class="field">
<label class="label" for="">Priority</label>
<div class="control priority-select">
<div class="select">
<select v-model="taskEditTask.priority">
<option :value="priorities.UNSET">Unset</option>
<option :value="priorities.LOW">Low</option>
<option :value="priorities.MEDIUM">Medium</option>
<option :value="priorities.HIGH">High</option>
<option :value="priorities.URGENT">Urgent</option>
<option :value="priorities.DO_NOW">DO NOW</option>
</select>
</div>
</div>
</div>
<div class="field">
<label class="label" for="">Assignees</label>
<ul class="assingees">
<li v-for="(a, index) in taskEditTask.assignees" :key="a.id">
{{a.username}}
<a @click="deleteAssigneeByIndex(index)"><icon icon="times"/></a>
</li>
</ul>
</div>
<div class="field has-addons">
<div class="control is-expanded">
<multiselect
v-model="newAssignee"
:options="foundUsers"
:multiple="false"
:searchable="true"
:loading="userService.loading"
:internal-search="true"
@search-change="findUser"
placeholder="Type to search"
label="username"
track-by="id">
<template slot="clear" slot-scope="props">
<div class="multiselect__clear" v-if="newAssignee !== null && newAssignee.id !== 0" @mousedown.prevent.stop="clearAllFoundUsers(props.search)"></div>
</template>
<span slot="noResult">Oops! No user found. Consider changing the search query.</span>
</multiselect>
</div>
<div class="control">
<a @click="addAssignee" class="button is-primary fullheight">
<span class="icon is-small">
<icon icon="plus"/>
</span>
</a>
</div>
</div>
<div class="field">
<label class="label">Labels</label>
<div class="control">
<multiselect
:multiple="true"
:close-on-select="false"
:clear-on-select="true"
:options-limit="300"
:hide-selected="true"
v-model="taskEditTask.labels"
:options="foundLabels"
:searchable="true"
:loading="labelService.loading || labelTaskService.loading"
:internal-search="true"
@search-change="findLabel"
@select="addLabel"
placeholder="Type to search"
label="title"
track-by="id"
:taggable="true"
@tag="createAndAddLabel"
tag-placeholder="Add this as new label"
>
<template slot="tag" slot-scope="{ option, remove }">
<span class="tag" :style="{'background': option.hex_color, 'color': option.textColor}">
<span>{{ option.title }}</span>
<a class="delete is-small" @click="removeLabel(option)"></a>
</span>
</template>
<template slot="clear" slot-scope="props">
<div class="multiselect__clear" v-if="taskEditTask.labels.length" @mousedown.prevent.stop="clearAllLabels(props.search)"></div>
</template>
</multiselect>
</div>
</div>
<div class="field">
<label class="label" for="subtasks">Subtasks</label>
<div class="tasks noborder" v-if="taskEditTask.subtasks && taskEditTask.subtasks.length > 0">
<div class="task" v-for="s in taskEditTask.subtasks" :key="s.id">
<label :for="s.id">
<div class="fancycheckbox">
<input @change="markAsDone" type="checkbox" :id="s.id" :checked="s.done" style="display: none;">
<label :for="s.id" class="check">
<svg width="18px" height="18px" viewBox="0 0 18 18">
<path d="M1,9 L1,3.5 C1,2 2,1 3.5,1 L14.5,1 C16,1 17,2 17,3.5 L17,14.5 C17,16 16,17 14.5,17 L3.5,17 C2,17 1,16 1,14.5 L1,9 Z"></path>
<polyline points="1 9 7 14 15 4"></polyline>
</svg>
</label>
</div>
<span class="tasktext" :class="{ 'done': s.done}">
{{s.text}}
</span>
</label>
</div>
</div>
</div>
<div class="field has-addons">
<div class="control is-expanded">
<input @keyup.enter="addSubtask()" :class="{ 'disabled': taskService.loading}" :disabled="taskService.loading" class="input" type="text" id="tasktext" placeholder="New subtask" v-model="newTask.text"/>
</div>
<div class="control">
<a class="button is-primary" @click="addSubtask()"><icon icon="plus"></icon></a>
</div>
</div>
<button type="submit" class="button is-success is-fullwidth" :class="{ 'is-loading': taskService.loading}">
Save
</button>
</form>
</template>
<script>
import message from '../../message'
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import multiselect from 'vue-multiselect'
import {differenceWith} from 'lodash'
import ListService from '../../services/list'
import TaskService from '../../services/task'
import TaskModel from '../../models/task'
import UserModel from '../../models/user'
import UserService from '../../services/user'
import priorities from '../../models/priorities'
import LabelTaskService from '../../services/labelTask'
import LabelService from '../../services/label'
import LabelTaskModel from '../../models/labelTask'
import LabelModel from '../../models/label'
export default {
name: 'edit-task',
data() {
return {
listID: this.$route.params.id,
listService: ListService,
taskService: TaskService,
priorities: priorities,
list: {},
newTask: TaskModel,
isTaskEdit: false,
taskEditTask: TaskModel,
lastReminder: 0,
nowUnix: new Date(),
flatPickerConfig:{
altFormat: 'j M Y H:i',
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
onOpen: this.updateLastReminderDate,
onClose: this.addReminderDate,
},
newAssignee: UserModel,
userService: UserService,
foundUsers: [],
labelService: LabelService,
labelTaskService: LabelTaskService,
foundLabels: [],
labelTimeout: null,
}
},
components: {
flatPickr,
multiselect,
},
props: {
task: {
type: TaskModel,
required: true,
}
},
watch: {
task() {
this.taskEditTask = this.task
}
},
created() {
this.listService = new ListService()
this.taskService = new TaskService()
this.newTask = new TaskModel()
this.userService = new UserService()
this.newAssignee = new UserModel()
this.labelService = new LabelService()
this.labelTaskService = new LabelTaskService()
this.taskEditTask = this.task
},
methods: {
editTaskSubmit() {
this.taskService.update(this.taskEditTask)
.then(r => {
this.$set(this, 'taskEditTask', r)
message.success({message: 'The task was successfully updated.'}, this)
})
.catch(e => {
message.error(e, this)
})
},
addSubtask() {
this.newTask.parentTaskID = this.taskEditTask.id
this.newTask.listID = this.$route.params.id
this.taskService.create(this.newTask)
.then(r => {
this.list.addTaskToList(r)
message.success({message: 'The task was successfully created.'}, this)
})
.catch(e => {
message.error(e, this)
})
this.newTask = {}
},
updateLastReminderDate(selectedDates) {
this.lastReminder = +new Date(selectedDates[0])
},
addReminderDate(selectedDates, dateStr, instance) {
let newDate = +new Date(selectedDates[0])
// Don't update if nothing changed
if (newDate === this.lastReminder) {
return
}
let index = parseInt(instance.input.dataset.index)
this.taskEditTask.reminderDates[index] = newDate
let lastIndex = this.taskEditTask.reminderDates.length - 1
// put a new null at the end if we changed something
if (lastIndex === index && !isNaN(newDate)) {
this.taskEditTask.reminderDates.push(null)
}
},
removeReminderByIndex(index) {
this.taskEditTask.reminderDates.splice(index, 1)
// Reset the last to 0 to have the "add reminder" button
this.taskEditTask.reminderDates[this.taskEditTask.reminderDates.length - 1] = null
},
addAssignee() {
this.taskEditTask.assignees.push(this.newAssignee)
},
deleteAssigneeByIndex(index) {
this.taskEditTask.assignees.splice(index, 1)
},
findUser(query) {
if(query === '') {
this.clearAllFoundUsers()
return
}
this.userService.getAll({}, {s: query})
.then(response => {
// Filter the results to not include users who are already assigned
this.$set(this, 'foundUsers', differenceWith(response, this.taskEditTask.assignees, (first, second) => {
return first.id === second.id
}))
})
.catch(e => {
message.error(e, this)
})
},
clearAllFoundUsers () {
this.$set(this, 'foundUsers', [])
},
findLabel(query) {
if(query === '') {
this.clearAllLabels()
return
}
if(this.labelTimeout !== null) {
clearTimeout(this.labelTimeout)
}
// Delay the search 300ms to not send a request on every keystroke
this.labelTimeout = setTimeout(() => {
this.labelService.getAll({}, {s: query})
.then(response => {
this.$set(this, 'foundLabels', differenceWith(response, this.taskEditTask.labels, (first, second) => {
return first.id === second.id
}))
this.labelTimeout = null
})
.catch(e => {
message.error(e, this)
})
}, 300)
},
clearAllLabels () {
this.$set(this, 'foundLabels', [])
},
addLabel(label) {
let labelTask = new LabelTaskModel({taskID: this.taskEditTask.id, label_id: label.id})
this.labelTaskService.create(labelTask)
.then(() => {
message.success({message: 'The label was successfully added.'}, this)
})
.catch(e => {
message.error(e, this)
})
},
removeLabel(label) {
let labelTask = new LabelTaskModel({taskID: this.taskEditTask.id, label_id: label.id})
this.labelTaskService.delete(labelTask)
.then(() => {
// Remove the label from the list
for (const l in this.taskEditTask.labels) {
if (this.taskEditTask.labels[l].id === label.id) {
this.taskEditTask.labels.splice(l, 1)
}
}
message.success({message: 'The label was successfully removed.'}, this)
})
.catch(e => {
message.error(e, this)
})
},
createAndAddLabel(title) {
let newLabel = new LabelModel({title: title})
this.labelService.create(newLabel)
.then(r => {
this.addLabel(r)
this.taskEditTask.labels.push(r)
})
.catch(e => {
message.error(e, this)
})
}
},
}
</script>
<style scoped>
form {
margin-bottom: 1em;
}
</style>

View file

@ -0,0 +1,350 @@
<template>
<div class="gantt-chart box">
<div class="dates">
<template v-for="(y, yk) in days">
<div class="months" :key="yk + 'year'">
<div class="month" v-for="(m, mk) in days[yk]" :key="mk + 'month'">
{{new Date((new Date(yk)).setMonth(mk)).toLocaleString('en-us', { month: 'long' })}}, {{(new Date(yk)).getFullYear()}}
<div class="days">
<div
class="day"
v-for="(d, dk) in days[yk][mk]"
:key="dk + 'day'"
:style="{'width': dayWidth + 'px'}"
:class="{'today': d.toDateString() === now.toDateString()}">
<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 class="tasks" :style="{'width': fullWidth + 'px'}">
<div class="row" v-for="(t, k) in theTasks" :key="t.id" :style="{background: 'repeating-linear-gradient(90deg, #ededed, #ededed 1px, ' + (k % 2 === 0 ? '#fafafa 1px, #fafafa ' : '#fff 1px, #fff ') + dayWidth + 'px)'}">
<VueDragResize
class="task"
:class="{'done': t.done, 'is-current-edit': taskToEdit !== null && taskToEdit.id === t.id}"
:isActive="true"
:x="t.offsetDays * dayWidth - 6"
:y="0"
:w="t.durationDays * dayWidth"
:h="31"
:minw="dayWidth"
:snapToGrid="true"
:gridX="dayWidth"
:sticks="['mr', 'ml']"
axis="x"
:parentLimitation="true"
:parentW="fullWidth"
@resizestop="resizeTask"
@dragstop="resizeTask"
@clicked="taskDragged = t"
>
<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.text}}</span>
<span v-if="t.priority >= priorities.HIGH" class="high-priority" :class="{'not-so-high': t.priority === priorities.HIGH}">
<span class="icon">
<icon icon="exclamation"/>
</span>
<template v-if="t.priority === priorities.HIGH">High</template>
<template v-if="t.priority === priorities.URGENT">Urgent</template>
<template v-if="t.priority === priorities.DO_NOW">DO NOW</template>
<span class="icon" v-if="t.priority === priorities.DO_NOW">
<icon icon="exclamation"/>
</span>
</span>
<!-- 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 class="row" v-for="t in tasksWithoutDates" :key="t.id">
<VueDragResize
class="task nodate"
:isActive="true"
:x="dayOffsetUntilToday * dayWidth - 6"
:y="0"
:h="31"
:minw="dayWidth"
:snapToGrid="true"
:gridX="dayWidth"
:sticks="['mr', 'ml']"
axis="x"
:parentLimitation="true"
:parentW="fullWidth"
@resizestop="resizeTask"
@dragstop="resizeTask"
@clicked="taskDragged = t"
v-tooltip="'This task has no dates set.'"
>
<span>{{t.text}}</span>
</VueDragResize>
</div>
</template>
</div>
<form @submit.prevent="addNewTask()" class="add-new-task">
<transition name="width">
<input
type="text"
v-model="newTaskTitle"
class="input"
v-if="newTaskFieldActive"
ref="newTaskTitleField"
@keyup.esc="newTaskFieldActive = false"
@blur="hideCrateNewTask"
/>
</transition>
<button class="button is-primary noshadow" @click="showCreateNewTask">
<span class="icon is-small">
<icon icon="plus"/>
</span>
Add a new task
</button>
</form>
<transition name="fade">
<div class="card taskedit" v-if="isTaskEdit">
<header class="card-header">
<p class="card-header-title">
Edit Task
</p>
<a class="card-header-icon" @click="isTaskEdit = false;taskToEdit = null">
<span class="icon">
<icon icon="times"/>
</span>
</a>
</header>
<div class="card-content">
<div class="content">
<edit-task :task="taskToEdit"/>
</div>
</div>
</div>
</transition>
</div>
</template>
<script>
import VueDragResize from 'vue-drag-resize'
import message from '../../message'
import EditTask from './edit-task'
import TaskService from '../../services/task'
import TaskModel from '../../models/task'
import ListModel from '../../models/list'
import priorities from '../../models/priorities'
export default {
name: 'GanttChart',
components: {
EditTask,
VueDragResize,
},
props: {
list: {
type: ListModel,
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: {},
}
},
watch: {
list() {
this.parseTasks()
},
dateFrom() {
this.buildTheGanttChart()
},
dateTo() {
this.buildTheGanttChart()
},
},
beforeMount() {
this.now = new Date()
this.taskService = new TaskService()
this.priorities = priorities
this.buildTheGanttChart()
},
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.prepareTasks()
},
prepareTasks() {
this.theTasks = this.list.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
})
},
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
},
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
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)
}
}
this.theTasks.push(this.addGantAttributes(r))
} else {
for (const tt in this.theTasks) {
if (this.theTasks[tt].id === r.id) {
this.theTasks[tt] = this.addGantAttributes(r)
}
}
}
message.success({message: 'The task was successfully updated.'}, this)
})
.catch(e => {
message.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({text: this.newTaskTitle, listID: this.list.id})
this.taskService.create(task)
.then(r => {
this.tasksWithoutDates.push(this.addGantAttributes(r))
this.newTaskTitle = ''
this.hideCrateNewTask()
message.success({message: 'The task was successfully created.'}, this)
})
.catch(e => {
message.error(e, this)
})
},
},
}
</script>

View file

@ -8,7 +8,7 @@ import Modal from './components/modal/Modal'
Vue.component('modal', Modal) Vue.component('modal', Modal)
// Register the task overview component // Register the task overview component
import TaskOverview from './components/lists/ShowTasks' import TaskOverview from './components/tasks/ShowTasks'
Vue.component('TaskOverview', TaskOverview) Vue.component('TaskOverview', TaskOverview)
// Add CSS // Add CSS
@ -43,6 +43,7 @@ import { faCalendarWeek } from '@fortawesome/free-solid-svg-icons'
import { faExclamation } from '@fortawesome/free-solid-svg-icons' import { faExclamation } from '@fortawesome/free-solid-svg-icons'
import { faTags } from '@fortawesome/free-solid-svg-icons' import { faTags } from '@fortawesome/free-solid-svg-icons'
import { faChevronDown } from '@fortawesome/free-solid-svg-icons' import { faChevronDown } from '@fortawesome/free-solid-svg-icons'
import { faCheck } from '@fortawesome/free-solid-svg-icons'
import { faTimesCircle } from '@fortawesome/free-regular-svg-icons' import { faTimesCircle } from '@fortawesome/free-regular-svg-icons'
import { faCalendarAlt } from '@fortawesome/free-regular-svg-icons' import { faCalendarAlt } from '@fortawesome/free-regular-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
@ -70,6 +71,7 @@ library.add(faCalendarAlt)
library.add(faExclamation) library.add(faExclamation)
library.add(faTags) library.add(faTags)
library.add(faChevronDown) library.add(faChevronDown)
library.add(faCheck)
Vue.component('icon', FontAwesomeIcon) Vue.component('icon', FontAwesomeIcon)

View file

@ -11,7 +11,7 @@ import GetPasswordResetComponent from '@/components/user/RequestPasswordReset'
import ShowListComponent from '@/components/lists/ShowList' import ShowListComponent from '@/components/lists/ShowList'
import NewListComponent from '@/components/lists/NewList' import NewListComponent from '@/components/lists/NewList'
import EditListComponent from '@/components/lists/EditList' import EditListComponent from '@/components/lists/EditList'
import ShowTasksInRangeComponent from '@/components/lists/ShowTasksInRange' import ShowTasksInRangeComponent from '@/components/tasks/ShowTasksInRange'
// Namespace Handling // Namespace Handling
import NewNamespaceComponent from '@/components/namespaces/NewNamespace' import NewNamespaceComponent from '@/components/namespaces/NewNamespace'
import EditNamespaceComponent from '@/components/namespaces/EditNamespace' import EditNamespaceComponent from '@/components/namespaces/EditNamespace'
@ -62,6 +62,11 @@ export default new Router({
name: 'editList', name: 'editList',
component: EditListComponent component: EditListComponent
}, },
{
path: '/lists/:id/:type',
name: 'showListWithType',
component: ShowListComponent,
},
{ {
path: '/namespaces/:id/list', path: '/namespaces/:id/list',
name: 'newList', name: 'newList',

View file

@ -16,10 +16,21 @@ export default class TaskService extends AbstractService {
} }
beforeUpdate(model) { beforeUpdate(model) {
return this.processModel(model)
}
beforeCreate(model) {
return this.processModel(model)
}
processModel(model) {
// Ensure the listID is an int
model.listID = Number(model.listID)
// Convert the date in a unix timestamp // Convert the date in a unix timestamp
model.dueDate = +new Date(model.dueDate) / 1000 model.dueDate = Math.round(+new Date(model.dueDate) / 1000)
model.startDate = +new Date(model.startDate) / 1000 model.startDate = Math.round(+new Date(model.startDate) / 1000)
model.endDate = +new Date(model.endDate) / 1000 model.endDate = Math.round(+new Date(model.endDate) / 1000)
// remove all nulls, these would create empty reminders // remove all nulls, these would create empty reminders
for (const index in model.reminderDates) { for (const index in model.reminderDates) {
@ -29,9 +40,11 @@ export default class TaskService extends AbstractService {
} }
// Make normal timestamps from js dates // Make normal timestamps from js dates
model.reminderDates = model.reminderDates.map(r => { if(model.reminderDates.length > 0) {
return Math.round(+new Date(r) / 1000) model.reminderDates = model.reminderDates.map(r => {
}) return Math.round(+new Date(r) / 1000)
})
}
// Make the repeating amount to seconds // Make the repeating amount to seconds
let repeatAfterSeconds = 0 let repeatAfterSeconds = 0

View file

@ -17,6 +17,7 @@ $dropdown-content-shadow: none;
$navbar-dropdown-boxed-shadow: $dropdown-content-shadow; $navbar-dropdown-boxed-shadow: $dropdown-content-shadow;
$bulmaswatch-import-font: false !default; $bulmaswatch-import-font: false !default;
$light-background: #F1F5F8; $light-background: #F1F5F8;
$transition-duration: 100ms;
$vikunja-font: 'Quicksand', sans-serif; $vikunja-font: 'Quicksand', sans-serif;
$vikunja-light-text: darken(#fff, 10%); $vikunja-light-text: darken(#fff, 10%);

View file

@ -5,6 +5,10 @@
padding-right: 5px; padding-right: 5px;
padding-top: 3px; padding-top: 3px;
&.is-block {
margin: .5em .2em;
}
.check { .check {
cursor: pointer; cursor: pointer;
position: relative; position: relative;
@ -18,6 +22,11 @@
stroke: $primary; stroke: $primary;
} }
span {
font-size: 0.8em;
vertical-align: top;
}
svg { svg {
position: relative; position: relative;
z-index: 1; z-index: 1;

215
src/styles/gantt.scss Normal file
View file

@ -0,0 +1,215 @@
$gantt-border: 1px solid $grey-lighter;
$gantt-vertical-border-color: lighten($grey, 45);
.gantt-chart {
padding: 5px 0;
overflow-x: auto;
.dates {
display: flex;
text-align: center;
border-bottom: $gantt-border;
.months {
display: flex;
.month {
padding: 0.5em 0 0;
border-right: $gantt-border;
font-family: $vikunja-font;
font-weight: bold;
&:last-child {
border-right: none;
}
.days {
display: flex;
.day {
padding: 0.5em 0;
font-weight: normal;
&.today {
background: $primary;
color: $white;
border-radius: 5px 5px 0 0;
font-weight: bold;
}
.theday {
padding: 0 .5em;
width: 100%;
display: block;
}
.weekday {
font-size: 0.8em;
}
}
}
}
}
}
.tasks {
max-width: 100%;
margin: 0;
.row {
height: 45px;
/*background: repeating-linear-gradient(90deg, $gantt-vertical-border-color, $gantt-vertical-border-color 1px, lighten($grey, 50) 1px, lighten($grey, 50) 35px);
&:nth-child(even) {
background: repeating-linear-gradient(90deg, $gantt-vertical-border-color, $gantt-vertical-border-color 1px, lighten($grey, 55) 1px, lighten($grey, 55) 35px);
}*/
.task {
display: inline-block;
border: 2px solid $primary;
background: lighten($primary, 40);
font-size: 0.85em;
margin: 0.5em;
border-radius: 6px;
padding: 0.25em 0.5em;
cursor: grab;
position: relative;
-webkit-touch-callout: none; // iOS Safari
-webkit-user-select: none; // Safari
-khtml-user-select: none; // Konqueror HTML
-moz-user-select: none; // Firefox
-ms-user-select: none; // Internet Explorer/Edge
user-select: none; // Non-prefixed version, currently supported by Chrome and Opera
&.is-current-edit {
border-color: $orange;
background: lighten($orange, 40);
}
&.done span {
position: relative;
&:after {
content: '';
border-top: 1px solid $dark;
position: absolute;
right: 0;
left: 0;
top: 57%;
}
}
span:not(.high-priority) {
max-width: calc(100% - 20px);
display: inline-block;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
&.has-high-priority {
max-width: calc(100% - 90px);
}
&.has-not-so-high-priority {
max-width: calc(100% - 70px);
}
&.has-super-high-priority {
max-width: calc(100% - 111px);
}
&.icon {
width: 10px;
text-align: center;
}
}
.high-priority{
margin: 0 0 0 .5em;
vertical-align: bottom;
}
.edit-toggle {
float: right;
cursor: pointer;
margin-right: 4px;
}
&.nodate {
border: 2px dashed $grey-light;
background: lighten($grey-light, 40);
}
&:active {
cursor: grabbing;
}
}
}
}
.taskedit {
position: fixed;
min-height: 0;
top: 10vh;
right: 10vw;
.card-content {
max-height: 60vh;
overflow-y: auto;
}
}
.add-new-task {
padding: 1em .7em .4em .7em;
display: flex;
max-width: 450px;
.input {
margin-right: .7em;
font-size: .8em;
}
.button {
font-size: .68em;
}
}
}
.gantt-options {
display: flex;
justify-content: space-between;
align-items: center;
.range-picker {
display: flex;
margin-bottom: 1em;
.field {
margin: 0 0 0 .5em;
max-width: 100px;
&, .input {
font-size: .8em;
}
.select {
height: auto;
}
select {
height: auto;
font-size: 0.75em;
}
.label {
font-size: .9em;
padding-left: .4em;
}
}
}
}
.vdr.active::before {
display: none;
}

View file

@ -194,13 +194,6 @@
} }
} }
.fade-enter-active, .fade-leave-active {
transition: opacity 150ms;
}
.fade-enter, .fade-leave-to {
opacity: 0;
}
.dropdown-trigger .button { .dropdown-trigger .button {
background: none; background: none;

View file

@ -0,0 +1,14 @@
//
//::-webkit-scrollbar {
// width: 8px;
//}
//
//::-webkit-scrollbar-track {
// -webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.3);
// border-radius: 10px;
//}
//
//::-webkit-scrollbar-thumb {
// border-radius: 10px;
// -webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.5);
//}

View file

@ -55,23 +55,23 @@
} }
} }
.high-priority{
color: $red;
.icon {
vertical-align: middle;
}
&.not-so-high {
color: $orange;
}
}
.tag { .tag {
margin: 0 0.5em; margin: 0 0.5em;
} }
} }
.high-priority{
color: $red;
.icon {
vertical-align: middle;
}
&.not-so-high {
color: $orange;
}
}
input[type="checkbox"] { input[type="checkbox"] {
vertical-align: middle; vertical-align: middle;
} }

View file

@ -240,4 +240,44 @@ h1,h2,h3,h4,h5,h6{
.navbar.main-theme { .navbar.main-theme {
background: $light-background; background: $light-background;
} }
.switch-view {
background: $white;
display: inline-block;
margin: 1em 0;
border-radius: $radius;
font-size: .8em;
box-shadow: 0.3em 0.3em 0.8em darken($light, 6);
a {
padding: .5em;
display: inline-block;
margin: .4em;
border-radius: $radius;
-webkit-transition: all 100ms;
-moz-transition: all 100ms;
-ms-transition: all 100ms;
-o-transition: all 100ms;
transition: all 100ms;
&:not(:last-child) {
margin-right: 0;
}
&.is-active, &:hover {
color: $white;
}
&.is-active {
background: $primary;
font-weight: bold;
box-shadow: 0.3em 0.3em 0.8em darken($light, 6);
}
&:hover {
background: lighten($primary, 5);
}
}
}

View file

@ -0,0 +1,13 @@
.fade-enter-active, .fade-leave-active {
transition: opacity $transition-duration;
}
.fade-enter, .fade-leave-to {
opacity: 0;
}
.width-enter-active, .width-leave-active {
transition: width $transition-duration;
}
.width-enter, .width-leave-to {
width: 0;
}

View file

@ -1,5 +1,7 @@
@import 'styles/theme'; @import 'styles/theme';
@import 'styles/scrollbars';
@import 'styles/general'; @import 'styles/general';
@import 'styles/transitions';
@import 'styles/tasks'; @import 'styles/tasks';
@import 'styles/teams'; @import 'styles/teams';
@ -8,5 +10,6 @@
@import 'styles/fancycheckbox'; @import 'styles/fancycheckbox';
@import 'styles/tooltip'; @import 'styles/tooltip';
@import 'styles/gantt';
@import 'styles/multiselect'; @import 'styles/multiselect';

17
todo.md
View file

@ -36,6 +36,7 @@
* [x] Btns für Teams und neuer Namespace nach oben in die Leiste verschieben * [x] Btns für Teams und neuer Namespace nach oben in die Leiste verschieben
* [ ] Card-like overview of all lists with the first 3-5 tasks, undone first * [ ] Card-like overview of all lists with the first 3-5 tasks, undone first
* [ ] Be able to collapse all lists in a namespace by clicking on the menu entry * [ ] Be able to collapse all lists in a namespace by clicking on the menu entry
* [ ] Fancy Scrollbars
## Funktionales ## Funktionales
@ -49,6 +50,22 @@
* [x] Overdue rot anzeigen * [x] Overdue rot anzeigen
* [x] Beim Team bearbeiten Nutzer suchen einbauen * [x] Beim Team bearbeiten Nutzer suchen einbauen
* [ ] Keyboard shortcuts * [ ] Keyboard shortcuts
* [ ] Gantt chart
* [x] Basics
* [x] Add tasks without dates set
* [x] Edit tasks with a kind of popup when clicking on them - needs refactoring edit task into an own component
* [x] Add a new task with a button or so
* [x] Be able to choose the range for the chart
* [x] Show task priority
* [x] Show task done or not done
* [ ] Colors - needs api change before
* [x] More view modes
* [x] Month: "The big picture"
* [x] Day: 3-hour slices of a day
* [ ] Table view (list view, bit with more details)
* [ ] Calender view
* [ ] Kanaban
* [ ] Group list view by almost all fields
## Funktionen aus der API ## Funktionen aus der API

View file

@ -8973,6 +8973,11 @@ vm-browserify@0.0.4:
dependencies: dependencies:
indexof "0.0.1" indexof "0.0.1"
vue-drag-resize@^1.3.2:
version "1.3.2"
resolved "https://registry.yarnpkg.com/vue-drag-resize/-/vue-drag-resize-1.3.2.tgz#99132a99746c878e4596fad08d36c98f604930a3"
integrity sha512-XiSEep3PPh9IPQqa4vIy/YENBpYch2SIPNipcPAEGhaSa0V8A8gSq9s7JQ66p/hiINdnR7f5ZqAkOdm6zU/4Gw==
vue-eslint-parser@^2.0.3: vue-eslint-parser@^2.0.3:
version "2.0.3" version "2.0.3"
resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-2.0.3.tgz#c268c96c6d94cfe3d938a5f7593959b0ca3360d1" resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-2.0.3.tgz#c268c96c6d94cfe3d938a5f7593959b0ca3360d1"