Compare commits
50 commits
main
...
feature/ga
Author | SHA1 | Date | |
---|---|---|---|
|
1d495b8603 | ||
|
0f1c5e9394 | ||
|
7745f16893 | ||
|
97a6b2f3d1 | ||
|
62dadc791c | ||
|
bb280b811c | ||
|
6db97d1ba1 | ||
|
1bbdd3b117 | ||
|
6cb8d7a3f5 | ||
|
c7a3c18972 | ||
|
a1e280e47b | ||
|
879dfb74b6 | ||
|
bd06f725be | ||
|
7ab4ff2d9e | ||
|
c80e3b57e4 | ||
|
daaa7d3864 | ||
|
cf67edb4a6 | ||
|
79e332e518 | ||
|
7c9e98fdf6 | ||
|
5dac96a2d5 | ||
|
f5c7b5be82 | ||
|
f3bb23cf14 | ||
|
9431c13a7f | ||
|
c4d5d409d4 | ||
|
77ed7a5d91 | ||
|
ede3dec1d6 | ||
|
09bdf76aa4 | ||
|
fb56e890e6 | ||
|
3a32501064 | ||
|
7d61635182 | ||
|
0a914e37ed | ||
|
f142d72da1 | ||
|
4e0c69d751 | ||
|
c39e9d5c62 | ||
|
af529eef0a | ||
|
f77f14a91f | ||
|
edaead1d8f | ||
|
90a06019ce | ||
|
da853a914c | ||
|
ecaa09285b | ||
|
40f7871f1b | ||
|
c9c9056baf | ||
|
7f3c754389 | ||
|
ed338cefcd | ||
|
b6c776592b | ||
|
e711df758c | ||
|
3f4509a6f9 | ||
|
5e3e79c01b | ||
|
9501592719 | ||
|
a0e6b9643b |
12 changed files with 2022 additions and 2272 deletions
|
@ -11,7 +11,7 @@ describe('List View Gantt', () => {
|
||||||
const tasks = TaskFactory.create(1)
|
const tasks = TaskFactory.create(1)
|
||||||
cy.visit('/lists/1/gantt')
|
cy.visit('/lists/1/gantt')
|
||||||
|
|
||||||
cy.get('.gantt-chart .tasks')
|
cy.get('.g-gantt-rows-container')
|
||||||
.should('not.contain', tasks[0].title)
|
.should('not.contain', tasks[0].title)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -25,7 +25,7 @@ describe('List View Gantt', () => {
|
||||||
|
|
||||||
cy.visit('/lists/1/gantt')
|
cy.visit('/lists/1/gantt')
|
||||||
|
|
||||||
cy.get('.gantt-chart .months')
|
cy.get('.g-timeunits-container')
|
||||||
.should('contain', format(now, 'MMMM'))
|
.should('contain', format(now, 'MMMM'))
|
||||||
.should('contain', format(nextMonth, 'MMMM'))
|
.should('contain', format(nextMonth, 'MMMM'))
|
||||||
})
|
})
|
||||||
|
@ -38,14 +38,13 @@ describe('List View Gantt', () => {
|
||||||
})
|
})
|
||||||
cy.visit('/lists/1/gantt')
|
cy.visit('/lists/1/gantt')
|
||||||
|
|
||||||
cy.get('.gantt-chart .tasks')
|
cy.get('.g-gantt-rows-container')
|
||||||
.should('not.be.empty')
|
.should('not.be.empty')
|
||||||
cy.get('.gantt-chart .tasks')
|
|
||||||
.should('contain', tasks[0].title)
|
.should('contain', tasks[0].title)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Shows tasks with no dates after enabling them', () => {
|
it('Shows tasks with no dates after enabling them', () => {
|
||||||
TaskFactory.create(1, {
|
const tasks = TaskFactory.create(1, {
|
||||||
start_date: null,
|
start_date: null,
|
||||||
end_date: null,
|
end_date: null,
|
||||||
})
|
})
|
||||||
|
@ -55,13 +54,15 @@ describe('List View Gantt', () => {
|
||||||
.contains('Show tasks which don\'t have dates set')
|
.contains('Show tasks which don\'t have dates set')
|
||||||
.click()
|
.click()
|
||||||
|
|
||||||
cy.get('.gantt-chart .tasks')
|
cy.get('.g-gantt-rows-container')
|
||||||
.should('not.be.empty')
|
.should('not.be.empty')
|
||||||
cy.get('.gantt-chart .tasks .task.nodate')
|
.should('contain', tasks[0].title)
|
||||||
.should('exist')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Drags a task around', () => {
|
it('Drags a task around', () => {
|
||||||
|
cy.intercept('**/api/v1/tasks/*')
|
||||||
|
.as('taskUpdate')
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
TaskFactory.create(1, {
|
TaskFactory.create(1, {
|
||||||
start_date: formatISO(now),
|
start_date: formatISO(now),
|
||||||
|
@ -69,10 +70,11 @@ describe('List View Gantt', () => {
|
||||||
})
|
})
|
||||||
cy.visit('/lists/1/gantt')
|
cy.visit('/lists/1/gantt')
|
||||||
|
|
||||||
cy.get('.gantt-chart .tasks .task')
|
cy.get('.g-gantt-rows-container .g-gantt-row .g-gantt-row-bars-container div .g-gantt-bar')
|
||||||
.first()
|
.first()
|
||||||
.trigger('mousedown', {which: 1})
|
.trigger('mousedown', {which: 1})
|
||||||
.trigger('mousemove', {clientX: 500, clientY: 0})
|
.trigger('mousemove', {clientX: 500, clientY: 0})
|
||||||
.trigger('mouseup', {force: true})
|
.trigger('mouseup', {force: true})
|
||||||
|
cy.wait('@taskUpdate')
|
||||||
})
|
})
|
||||||
})
|
})
|
|
@ -23,6 +23,7 @@
|
||||||
"@fortawesome/free-solid-svg-icons": "6.2.0",
|
"@fortawesome/free-solid-svg-icons": "6.2.0",
|
||||||
"@fortawesome/vue-fontawesome": "3.0.1",
|
"@fortawesome/vue-fontawesome": "3.0.1",
|
||||||
"@github/hotkey": "2.0.1",
|
"@github/hotkey": "2.0.1",
|
||||||
|
"@infectoone/vue-ganttastic": "./vendor/infectoone-vue-ganttastic-2.1.1.tgz",
|
||||||
"@kyvg/vue3-notification": "2.4.1",
|
"@kyvg/vue3-notification": "2.4.1",
|
||||||
"@sentry/tracing": "7.14.1",
|
"@sentry/tracing": "7.14.1",
|
||||||
"@sentry/vue": "7.14.1",
|
"@sentry/vue": "7.14.1",
|
||||||
|
|
3108
pnpm-lock.yaml
3108
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
78
src/components/tasks/TaskForm.vue
Normal file
78
src/components/tasks/TaskForm.vue
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
<template>
|
||||||
|
<form
|
||||||
|
@submit.prevent="createTask"
|
||||||
|
class="add-new-task"
|
||||||
|
>
|
||||||
|
<transition name="width">
|
||||||
|
<input
|
||||||
|
v-if="newTaskFieldActive"
|
||||||
|
v-model="newTaskTitle"
|
||||||
|
@blur="hideCreateNewTask"
|
||||||
|
@keyup.esc="newTaskFieldActive = false"
|
||||||
|
class="input"
|
||||||
|
ref="newTaskTitleField"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</transition>
|
||||||
|
<x-button @click="showCreateTaskOrCreate" :shadow="false" icon="plus">
|
||||||
|
{{ $t('task.new') }}
|
||||||
|
</x-button>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {nextTick, ref} from 'vue'
|
||||||
|
import type {ITask} from '@/models/task'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'create-task', title: string): Promise<ITask>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const newTaskFieldActive = ref(false)
|
||||||
|
const newTaskTitleField = ref()
|
||||||
|
const newTaskTitle = ref('')
|
||||||
|
|
||||||
|
function showCreateTaskOrCreate() {
|
||||||
|
if (!newTaskFieldActive.value) {
|
||||||
|
// Timeout to not send the form if the field isn't even shown
|
||||||
|
setTimeout(() => {
|
||||||
|
newTaskFieldActive.value = true
|
||||||
|
nextTick(() => newTaskTitleField.value.focus())
|
||||||
|
}, 100)
|
||||||
|
} else {
|
||||||
|
createTask()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideCreateNewTask() {
|
||||||
|
if (newTaskTitle.value === '') {
|
||||||
|
nextTick(() => (newTaskFieldActive.value = false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTask() {
|
||||||
|
if (!newTaskFieldActive.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await emit('create-task', newTaskTitle.value)
|
||||||
|
newTaskTitle.value = ''
|
||||||
|
hideCreateNewTask()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.add-new-task {
|
||||||
|
padding: 1rem .7rem .4rem .7rem;
|
||||||
|
display: flex;
|
||||||
|
max-width: 450px;
|
||||||
|
|
||||||
|
.input {
|
||||||
|
margin-right: .7rem;
|
||||||
|
font-size: .8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
font-size: .68rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
319
src/components/tasks/gantt-chart.vue
Normal file
319
src/components/tasks/gantt-chart.vue
Normal file
|
@ -0,0 +1,319 @@
|
||||||
|
<template>
|
||||||
|
<Loading class="gantt-container" v-if="taskService.loading || taskCollectionService.loading"/>
|
||||||
|
<div class="gantt-container" v-else>
|
||||||
|
<GGanttChart
|
||||||
|
:chart-start="`${dateFrom} 00:00`"
|
||||||
|
:chart-end="`${dateTo} 23:59`"
|
||||||
|
:precision="PRECISION"
|
||||||
|
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" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {computed, ref, watch, watchEffect, shallowReactive, type PropType} from 'vue'
|
||||||
|
import {useRouter} from 'vue-router'
|
||||||
|
import {format, parse} from 'date-fns'
|
||||||
|
|
||||||
|
import TaskCollectionService from '@/services/taskCollection'
|
||||||
|
import TaskService from '@/services/task'
|
||||||
|
import TaskModel, { getHexColor } from '@/models/task'
|
||||||
|
|
||||||
|
import type ListModel from '@/models/list'
|
||||||
|
import {colorIsDark} from '@/helpers/color/colorIsDark'
|
||||||
|
import {RIGHTS} from '@/constants/rights'
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
||||||
|
extendDayjs()
|
||||||
|
|
||||||
|
const PRECISION = 'day' as const
|
||||||
|
const DATE_FORMAT = 'yyyy-LL-dd HH:mm'
|
||||||
|
|
||||||
|
const baseStore = useBaseStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
listId: {
|
||||||
|
type: Number as PropType<ListModel['id']>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
dateFrom: {
|
||||||
|
type: String as PropType<any>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
dateTo: {
|
||||||
|
type: String as PropType<any>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
showTasksWithoutDates: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const taskCollectionService = shallowReactive(new TaskCollectionService())
|
||||||
|
const taskService = shallowReactive(new TaskService())
|
||||||
|
|
||||||
|
const dateFromDate = computed(() => parse(props.dateFrom, 'yyyy-LL-dd', new Date()))
|
||||||
|
const dateToDate = computed(() => parse(props.dateTo, 'yyyy-LL-dd', new Date()))
|
||||||
|
|
||||||
|
const DAY_WIDTH_PIXELS = 30
|
||||||
|
const ganttChartWidth = computed(() => {
|
||||||
|
const dateDiff = Math.floor((dateToDate.value - dateFromDate.value) / (1000 * 60 * 60 * 24))
|
||||||
|
|
||||||
|
return dateDiff * DAY_WIDTH_PIXELS
|
||||||
|
})
|
||||||
|
|
||||||
|
const canWrite = computed(() => baseStore.currentList.maxRight > RIGHTS.READ)
|
||||||
|
|
||||||
|
const tasks = ref<Map<TaskModel['id'], TaskModel>>(new Map())
|
||||||
|
const ganttBars = ref<GanttBarObject[][]>([])
|
||||||
|
|
||||||
|
watch(
|
||||||
|
tasks,
|
||||||
|
// We need a "real" ref object for the gantt bars to instantly update the tasks when they are dragged on the chart.
|
||||||
|
// A computed won't work directly.
|
||||||
|
// function mapGanttBars() {
|
||||||
|
() => {
|
||||||
|
ganttBars.value = []
|
||||||
|
tasks.value.forEach(t => ganttBars.value.push(transformTaskToGanttBar(t)))
|
||||||
|
},
|
||||||
|
{deep: true}
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultStartDate = format(new Date(), DATE_FORMAT)
|
||||||
|
const defaultEndDate = format(new Date((new Date()).setDate((new Date()).getDate() + 7)), DATE_FORMAT)
|
||||||
|
|
||||||
|
function transformTaskToGanttBar(t: TaskModel) {
|
||||||
|
const black = 'var(--grey-800)'
|
||||||
|
return [{
|
||||||
|
startDate: t.startDate ? format(t.startDate, DATE_FORMAT) : defaultStartDate,
|
||||||
|
endDate: t.endDate ? format(t.endDate, DATE_FORMAT) : defaultEndDate,
|
||||||
|
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<TaskModel[]> {
|
||||||
|
const tasks = await taskCollectionService.getAll({listId: props.listId}, params, page) as TaskModel[]
|
||||||
|
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 = {
|
||||||
|
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: [dateFrom, 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: TaskModel['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) {
|
||||||
|
const task = tasks.value.get(e.bar.ganttBarConfig.id)
|
||||||
|
|
||||||
|
if (!task) return
|
||||||
|
|
||||||
|
task.startDate = e.bar.startDate
|
||||||
|
task.endDate = e.bar.endDate
|
||||||
|
const updatedTask = await taskService.update(task)
|
||||||
|
ganttBars.value.map(gantBar => {
|
||||||
|
return gantBar[0].ganttBarConfig.id === task.id
|
||||||
|
? transformTaskToGanttBar(updatedTask)
|
||||||
|
: gantBar
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTask(e) {
|
||||||
|
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>
|
|
@ -1,642 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="gantt-chart">
|
|
||||||
<div class="filter-container">
|
|
||||||
<div class="items">
|
|
||||||
<filter-popup
|
|
||||||
v-model="params"
|
|
||||||
@update:modelValue="loadTasks()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="dates">
|
|
||||||
<template v-for="(y, yk) in days" :key="yk + 'year'">
|
|
||||||
<div class="months">
|
|
||||||
<div
|
|
||||||
:key="mk + 'month'"
|
|
||||||
class="month"
|
|
||||||
v-for="(m, mk) in days[yk]"
|
|
||||||
>
|
|
||||||
{{ formatMonthAndYear(yk, parseInt(mk) + 1) }}
|
|
||||||
<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
|
|
||||||
v-for="(t, k) in theTasks"
|
|
||||||
:key="t ? t.id : 0"
|
|
||||||
:style="{
|
|
||||||
background:
|
|
||||||
'repeating-linear-gradient(90deg, #ededed, #ededed 1px, ' +
|
|
||||||
(k % 2 === 0
|
|
||||||
? '#fafafa 1px, #fafafa '
|
|
||||||
: '#fff 1px, #fff ') +
|
|
||||||
dayWidth +
|
|
||||||
'px)',
|
|
||||||
}"
|
|
||||||
class="row"
|
|
||||||
>
|
|
||||||
<VueDragResize
|
|
||||||
:class="{
|
|
||||||
done: t ? t.done : false,
|
|
||||||
'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"
|
|
||||||
@dragstop="(e) => resizeTask(t, e)"
|
|
||||||
@resizestop="(e) => resizeTask(t, e)"
|
|
||||||
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" :done="t.done"/>
|
|
||||||
<!-- using the key here forces vue to use the updated version model and not the response returned by the api -->
|
|
||||||
<!-- FIXME: add label -->
|
|
||||||
<BaseButton @click="editTask(theTasks[k])" class="edit-toggle">
|
|
||||||
<icon icon="pen"/>
|
|
||||||
</BaseButton>
|
|
||||||
</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"
|
|
||||||
@dragstop="(e) => resizeTask(t, e)"
|
|
||||||
@resizestop="(e) => resizeTask(t, e)"
|
|
||||||
axis="x"
|
|
||||||
class="task nodate"
|
|
||||||
v-tooltip="$t('list.gantt.noDates')"
|
|
||||||
>
|
|
||||||
<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">
|
|
||||||
{{ $t('list.list.newTaskCta') }}
|
|
||||||
</x-button>
|
|
||||||
</form>
|
|
||||||
<transition name="fade">
|
|
||||||
<edit-task
|
|
||||||
v-if="isTaskEdit"
|
|
||||||
class="taskedit"
|
|
||||||
:title="$t('list.list.editTask')"
|
|
||||||
@close="() => {isTaskEdit = false;taskToEdit = null}"
|
|
||||||
:task="taskToEdit"
|
|
||||||
/>
|
|
||||||
</transition>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import {defineComponent} from 'vue'
|
|
||||||
import {mapState} from 'pinia'
|
|
||||||
|
|
||||||
import VueDragResize from 'vue-drag-resize'
|
|
||||||
import EditTask from './edit-task.vue'
|
|
||||||
|
|
||||||
import TaskService from '../../services/task'
|
|
||||||
import TaskModel from '../../models/task'
|
|
||||||
import {PRIORITIES as priorities} from '@/constants/priorities'
|
|
||||||
import PriorityLabel from './partials/priorityLabel.vue'
|
|
||||||
import TaskCollectionService from '../../services/taskCollection'
|
|
||||||
import {RIGHTS as Rights} from '@/constants/rights'
|
|
||||||
import FilterPopup from '@/components/list/partials/filter-popup.vue'
|
|
||||||
import BaseButton from '@/components/base/BaseButton.vue'
|
|
||||||
|
|
||||||
import {colorIsDark} from '@/helpers/color/colorIsDark'
|
|
||||||
import {formatDate} from '@/helpers/time/formatDate'
|
|
||||||
|
|
||||||
import {useBaseStore} from '@/stores/base'
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
name: 'GanttChart',
|
|
||||||
components: {
|
|
||||||
BaseButton,
|
|
||||||
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: new TaskService(),
|
|
||||||
fullWidth: 0,
|
|
||||||
now: new Date(),
|
|
||||||
dayOffsetUntilToday: 0,
|
|
||||||
isTaskEdit: false,
|
|
||||||
taskToEdit: null,
|
|
||||||
newTaskTitle: '',
|
|
||||||
newTaskFieldActive: false,
|
|
||||||
priorities: priorities,
|
|
||||||
taskCollectionService: new TaskCollectionService(),
|
|
||||||
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.buildTheGanttChart()
|
|
||||||
},
|
|
||||||
computed: mapState(useBaseStore, {
|
|
||||||
canWrite: (state) => state.currentList.maxRight > Rights.READ,
|
|
||||||
}),
|
|
||||||
methods: {
|
|
||||||
colorIsDark,
|
|
||||||
buildTheGanttChart() {
|
|
||||||
this.setDates()
|
|
||||||
this.prepareGanttDays()
|
|
||||||
this.parseTasks()
|
|
||||||
},
|
|
||||||
setDates() {
|
|
||||||
this.startDate = new Date(this.dateFrom)
|
|
||||||
this.endDate = new Date(this.dateTo)
|
|
||||||
console.debug('setDates; start date: ', this.startDate, 'end date:', this.endDate, 'date from:', this.dateFrom, 'date to:', this.dateTo)
|
|
||||||
|
|
||||||
this.dayOffsetUntilToday = Math.floor((this.now - this.startDate) / 1000 / 60 / 60 / 24) + 1
|
|
||||||
},
|
|
||||||
prepareGanttDays() {
|
|
||||||
console.debug('prepareGanttDays; start date: ', this.startDate, 'end date:', this.endDate)
|
|
||||||
// Layout: years => [months => [days]]
|
|
||||||
const years = {}
|
|
||||||
for (
|
|
||||||
let d = this.startDate;
|
|
||||||
d <= this.endDate;
|
|
||||||
d.setDate(d.getDate() + 1)
|
|
||||||
) {
|
|
||||||
const 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
|
|
||||||
}
|
|
||||||
console.debug('prepareGanttDays; years:', years)
|
|
||||||
this.days = years
|
|
||||||
},
|
|
||||||
|
|
||||||
parseTasks() {
|
|
||||||
this.setDates()
|
|
||||||
this.loadTasks()
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadTasks() {
|
|
||||||
this.theTasks = []
|
|
||||||
this.tasksWithoutDates = []
|
|
||||||
|
|
||||||
const getAllTasks = async (page = 1) => {
|
|
||||||
const tasks = await this.taskCollectionService.getAll({listId: this.listId}, this.params, page)
|
|
||||||
if (page < this.taskCollectionService.totalPages) {
|
|
||||||
const nextTasks = await getAllTasks(page + 1)
|
|
||||||
return tasks.concat(nextTasks)
|
|
||||||
}
|
|
||||||
return tasks
|
|
||||||
}
|
|
||||||
|
|
||||||
const tasks = await getAllTasks()
|
|
||||||
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) => 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) {
|
|
||||||
if (typeof t.durationDays !== 'undefined' && typeof t.offsetDays !== 'undefined') {
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|
||||||
t.endDate === null ? this.endDate : t.endDate
|
|
||||||
t.durationDays = Math.floor((t.endDate - t.startDate) / 1000 / 60 / 60 / 24)
|
|
||||||
t.offsetDays = Math.floor((t.startDate - this.startDate) / 1000 / 60 / 60 / 24)
|
|
||||||
return t
|
|
||||||
},
|
|
||||||
async resizeTask(taskDragged, newRect) {
|
|
||||||
if (this.isTaskEdit) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let newTask = {...taskDragged}
|
|
||||||
|
|
||||||
const didntHaveDates = newTask.startDate === null ? true : false
|
|
||||||
|
|
||||||
const 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)
|
|
||||||
newTask.startDate = startDate
|
|
||||||
const endDate = new Date(startDate)
|
|
||||||
endDate.setDate(
|
|
||||||
startDate.getDate() + newRect.width / this.dayWidth,
|
|
||||||
)
|
|
||||||
newTask.startDate = startDate
|
|
||||||
newTask.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 === newTask.id) {
|
|
||||||
newTask = this.theTasks[tt]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ganttData = {
|
|
||||||
endDate: newTask.endDate,
|
|
||||||
durationDays: newTask.durationDays,
|
|
||||||
offsetDays: newTask.offsetDays,
|
|
||||||
}
|
|
||||||
|
|
||||||
const r = await this.taskService.update(newTask)
|
|
||||||
r.endDate = ganttData.endDate
|
|
||||||
r.durationDays = ganttData.durationDays
|
|
||||||
r.offsetDays = ganttData.offsetDays
|
|
||||||
|
|
||||||
// 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.theTasks[tt] = this.addGantAttributes(r)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async addNewTask() {
|
|
||||||
if (!this.newTaskFieldActive) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const task = new TaskModel({
|
|
||||||
title: this.newTaskTitle,
|
|
||||||
listId: this.listId,
|
|
||||||
})
|
|
||||||
const r = await this.taskService.create(task)
|
|
||||||
this.tasksWithoutDates.push(this.addGantAttributes(r))
|
|
||||||
this.newTaskTitle = ''
|
|
||||||
this.hideCrateNewTask()
|
|
||||||
},
|
|
||||||
formatMonthAndYear(year, month) {
|
|
||||||
month = month < 10 ? '0' + month : month
|
|
||||||
const date = new Date(`${year}-${month}-01`)
|
|
||||||
return formatDate(date, 'MMMM, yyyy')
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
$gantt-border: 1px solid var(--grey-200);
|
|
||||||
$gantt-vertical-border-color: var(--grey-100);
|
|
||||||
|
|
||||||
.gantt-chart {
|
|
||||||
overflow-x: auto;
|
|
||||||
border-top: 1px solid var(--grey-200);
|
|
||||||
|
|
||||||
.dates {
|
|
||||||
display: flex;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
.months {
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
.month {
|
|
||||||
padding: 0.5rem 0 0;
|
|
||||||
border-right: $gantt-border;
|
|
||||||
font-family: $vikunja-font;
|
|
||||||
font-weight: bold;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
border-right: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.days {
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
.day {
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
font-weight: normal;
|
|
||||||
|
|
||||||
&.today {
|
|
||||||
background: var(--primary);
|
|
||||||
color: var(--white);
|
|
||||||
border-radius: 5px 5px 0 0;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theday {
|
|
||||||
padding: 0 .5rem;
|
|
||||||
width: 100%;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weekday {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tasks {
|
|
||||||
max-width: unset !important;
|
|
||||||
border-top: $gantt-border;
|
|
||||||
|
|
||||||
.row {
|
|
||||||
height: 45px;
|
|
||||||
|
|
||||||
.task {
|
|
||||||
display: inline-block;
|
|
||||||
border: 2px solid var(--primary);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
margin: 0.5rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
cursor: grab;
|
|
||||||
position: relative;
|
|
||||||
height: 31px !important;
|
|
||||||
|
|
||||||
-webkit-touch-callout: none; // iOS Safari
|
|
||||||
user-select: none; // Non-prefixed version
|
|
||||||
|
|
||||||
&.is-current-edit {
|
|
||||||
border-color: var(--warning) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.has-light-text {
|
|
||||||
color: var(--grey-100);
|
|
||||||
|
|
||||||
&.done span:after {
|
|
||||||
border-top: 1px solid var(--grey-100);
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-toggle {
|
|
||||||
color: var(--grey-100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.has-dark-text {
|
|
||||||
color: var(--text);
|
|
||||||
|
|
||||||
&.done span:after {
|
|
||||||
border-top: 1px solid var(--dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-toggle {
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.done span {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
content: '';
|
|
||||||
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 .5rem;
|
|
||||||
vertical-align: bottom;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-toggle {
|
|
||||||
float: right;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-right: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.nodate {
|
|
||||||
border: 2px dashed var(--grey-300);
|
|
||||||
background: var(--grey-100);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.taskedit {
|
|
||||||
position: fixed;
|
|
||||||
top: 10vh;
|
|
||||||
right: 10vw;
|
|
||||||
z-index: 5;
|
|
||||||
|
|
||||||
// FIXME: should be an option of the card, e.g. overflow
|
|
||||||
:deep(.card-content) {
|
|
||||||
max-height: 60vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-new-task {
|
|
||||||
padding: 1rem .7rem .4rem .7rem;
|
|
||||||
display: flex;
|
|
||||||
max-width: 450px;
|
|
||||||
|
|
||||||
.input {
|
|
||||||
margin-right: .7rem;
|
|
||||||
font-size: .8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button {
|
|
||||||
font-size: .68rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -4,7 +4,7 @@
|
||||||
* @param color
|
* @param color
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function colorFromHex(color) {
|
export function colorFromHex(color: string) {
|
||||||
if (color.substring(0, 1) === '#') {
|
if (color.substring(0, 1) === '#') {
|
||||||
color = color.substring(1, 7)
|
color = color.substring(1, 7)
|
||||||
}
|
}
|
||||||
|
|
|
@ -285,8 +285,8 @@
|
||||||
"default": "Default",
|
"default": "Default",
|
||||||
"month": "Month",
|
"month": "Month",
|
||||||
"day": "Day",
|
"day": "Day",
|
||||||
"from": "From",
|
"hour": "Hour",
|
||||||
"to": "To",
|
"range": "Range",
|
||||||
"noDates": "This task has no dates set."
|
"noDates": "This task has no dates set."
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
// FIXME: should be a component <FilterContainer>
|
// FIXME: should be a component <FilterContainer>
|
||||||
// used in
|
// used in
|
||||||
// - gantt-component.vue
|
|
||||||
// - Kanban.vue
|
// - Kanban.vue
|
||||||
// - List.vue
|
// - List.vue
|
||||||
// - Table.vue
|
// - Table.vue
|
||||||
|
|
|
@ -46,7 +46,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: is only used where <edit-task> is used aswell:
|
// FIXME: is only used where <edit-task> is used aswell:
|
||||||
// - gantt-component.vue
|
|
||||||
// - List.vue
|
// - List.vue
|
||||||
// -> Move the <card> wrapper including this class definition inside <edit-task>
|
// -> Move the <card> wrapper including this class definition inside <edit-task>
|
||||||
.is-max-width-desktop .tasks .task {
|
.is-max-width-desktop .tasks .task {
|
||||||
|
|
|
@ -1,47 +1,23 @@
|
||||||
<template>
|
<template>
|
||||||
<ListWrapper class="list-gantt" :list-id="props.listId" viewName="gantt">
|
<ListWrapper class="list-gantt" :list-id="props.listId" viewName="gantt">
|
||||||
<template #header>
|
<template #header>
|
||||||
<card class="gantt-options">
|
<card>
|
||||||
<fancycheckbox class="is-block" v-model="showTaskswithoutDates">
|
<div class="gantt-options">
|
||||||
{{ $t('list.gantt.showTasksWithoutDates') }}
|
|
||||||
</fancycheckbox>
|
|
||||||
<div class="range-picker">
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="dayWidth">{{ $t('list.gantt.size') }}</label>
|
<label class="label" for="range">{{ $t('list.gantt.range') }}</label>
|
||||||
<div class="control">
|
|
||||||
<div class="select">
|
|
||||||
<select id="dayWidth" v-model.number="dayWidth">
|
|
||||||
<option value="35">{{ $t('list.gantt.default') }}</option>
|
|
||||||
<option value="10">{{ $t('list.gantt.month') }}</option>
|
|
||||||
<option value="80">{{ $t('list.gantt.day') }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label" for="fromDate">{{ $t('list.gantt.from') }}</label>
|
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<flat-pickr
|
<flat-pickr
|
||||||
:config="flatPickerConfig"
|
:config="flatPickerConfig"
|
||||||
class="input"
|
class="input"
|
||||||
id="fromDate"
|
id="range"
|
||||||
:placeholder="$t('list.gantt.from')"
|
:placeholder="$t('list.gantt.range')"
|
||||||
v-model="dateFrom"
|
v-model="range"
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label" for="toDate">{{ $t('list.gantt.to') }}</label>
|
|
||||||
<div class="control">
|
|
||||||
<flat-pickr
|
|
||||||
:config="flatPickerConfig"
|
|
||||||
class="input"
|
|
||||||
id="toDate"
|
|
||||||
:placeholder="$t('list.gantt.to')"
|
|
||||||
v-model="dateTo"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<fancycheckbox class="is-block" v-model="showTasksWithoutDates">
|
||||||
|
{{ $t('list.gantt.showTasksWithoutDates') }}
|
||||||
|
</fancycheckbox>
|
||||||
</div>
|
</div>
|
||||||
</card>
|
</card>
|
||||||
</template>
|
</template>
|
||||||
|
@ -53,9 +29,8 @@
|
||||||
<gantt-chart
|
<gantt-chart
|
||||||
:date-from="dateFrom"
|
:date-from="dateFrom"
|
||||||
:date-to="dateTo"
|
:date-to="dateTo"
|
||||||
:day-width="dayWidth"
|
|
||||||
:list-id="props.listId"
|
:list-id="props.listId"
|
||||||
:show-taskswithout-dates="showTaskswithoutDates"
|
:show-tasks-without-dates="showTasksWithoutDates"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</card>
|
</card>
|
||||||
|
@ -72,8 +47,9 @@ import {useI18n} from 'vue-i18n'
|
||||||
import {useAuthStore} from '@/stores/auth'
|
import {useAuthStore} from '@/stores/auth'
|
||||||
|
|
||||||
import ListWrapper from './ListWrapper.vue'
|
import ListWrapper from './ListWrapper.vue'
|
||||||
import GanttChart from '@/components/tasks/gantt-component.vue'
|
import GanttChart from '@/components/tasks/gantt-chart.vue'
|
||||||
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
|
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
|
||||||
|
import {format} from 'date-fns'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
listId: {
|
listId: {
|
||||||
|
@ -82,14 +58,16 @@ const props = defineProps({
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const DEFAULT_DAY_COUNT = 35
|
const showTasksWithoutDates = ref(false)
|
||||||
|
|
||||||
const showTaskswithoutDates = ref(false)
|
const now = new Date()
|
||||||
const dayWidth = ref(DEFAULT_DAY_COUNT)
|
const defaultFrom = format(new Date((new Date()).setDate(now.getDate() - 15)), 'yyyy-LL-dd')
|
||||||
|
const defaultTo = format(new Date((new Date()).setDate(now.getDate() + 55)), 'yyyy-LL-dd')
|
||||||
|
const range = ref(`${defaultFrom} to ${defaultTo}`)
|
||||||
|
|
||||||
const now = ref(new Date())
|
// TODO: only update once both dates are available (maybe use a watcher + refs instead?)
|
||||||
const dateFrom = ref(new Date((new Date()).setDate(now.value.getDate() - 15)))
|
const dateFrom = computed(() => range.value?.split(' to ')[0] ?? defaultFrom)
|
||||||
const dateTo = ref(new Date((new Date()).setDate(now.value.getDate() + 30)))
|
const dateTo = computed(() => range.value?.split(' to ')[1] ?? defaultTo)
|
||||||
|
|
||||||
const {t} = useI18n({useScope: 'global'})
|
const {t} = useI18n({useScope: 'global'})
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
@ -98,6 +76,7 @@ const flatPickerConfig = computed(() => ({
|
||||||
altInput: true,
|
altInput: true,
|
||||||
dateFormat: 'Y-m-d',
|
dateFormat: 'Y-m-d',
|
||||||
enableTime: false,
|
enableTime: false,
|
||||||
|
mode: 'range',
|
||||||
locale: {
|
locale: {
|
||||||
firstDayOfWeek: authStore.settings.weekStart,
|
firstDayOfWeek: authStore.settings.weekStart,
|
||||||
},
|
},
|
||||||
|
@ -119,46 +98,35 @@ const flatPickerConfig = computed(() => ({
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.range-picker {
|
.field {
|
||||||
display: flex;
|
margin-bottom: 0;
|
||||||
margin-bottom: 1rem;
|
width: 33%;
|
||||||
width: 50%;
|
|
||||||
|
|
||||||
@media screen and (max-width: $tablet) {
|
&:not(:last-child) {
|
||||||
flex-direction: column;
|
padding-right: .5rem;
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.field {
|
@media screen and (max-width: $tablet) {
|
||||||
margin-bottom: 0;
|
width: 100%;
|
||||||
width: 33%;
|
max-width: 100%;
|
||||||
|
margin-top: .5rem;
|
||||||
|
padding-right: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
&:not(:last-child) {
|
&, .input {
|
||||||
padding-right: .5rem;
|
font-size: .8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: $tablet) {
|
.select, .select select {
|
||||||
width: 100%;
|
height: auto;
|
||||||
max-width: 100%;
|
width: 100%;
|
||||||
margin-top: .5rem;
|
font-size: .8rem;
|
||||||
padding-right: 0 !important;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
&, .input {
|
|
||||||
font-size: .8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select, .select select {
|
|
||||||
height: auto;
|
|
||||||
width: 100%;
|
|
||||||
font-size: .8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: .9rem;
|
font-size: .9rem;
|
||||||
padding-left: .4rem;
|
padding-left: .4rem;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
BIN
vendor/infectoone-vue-ganttastic-2.1.1.tgz
vendored
Normal file
BIN
vendor/infectoone-vue-ganttastic-2.1.1.tgz
vendored
Normal file
Binary file not shown.
Loading…
Reference in a new issue