feat(time-tracking): add the task-detail time-tracking section

This commit is contained in:
kolaente 2026-06-08 15:16:47 +02:00 committed by kolaente
parent 8febfac742
commit 2ef898e89d
3 changed files with 117 additions and 1 deletions

View File

@ -0,0 +1,83 @@
<template>
<div class="task-time-tracking">
<XButton
v-if="entries.length > 0"
v-tooltip="$t('timeTracking.logTime')"
v-cy="'addTaskTimeEntry'"
class="is-pulled-right d-print-none"
:class="{'is-active': showForm}"
variant="secondary"
icon="plus"
:shadow="false"
@click="showForm = !showForm"
/>
<h3 class="title is-5">
{{ $t('timeTracking.title') }}
</h3>
<TimeEntryForm
v-if="formVisible"
:task-id="taskId"
:entry="editingEntry"
:recent-entries="entries"
@saved="onSaved"
@cancel="editingEntry = null"
/>
<TimeEntryList
class="mbs-4"
:entries="entries"
:card="false"
:empty-text="$t('timeTracking.list.emptyTask')"
hide-label-column
@edit="editingEntry = $event"
@delete="onDelete"
/>
</div>
</template>
<script setup lang="ts">
import {ref, computed, watch} from 'vue'
import TimeEntryForm from '@/components/time-tracking/TimeEntryForm.vue'
import TimeEntryList from '@/components/time-tracking/TimeEntryList.vue'
import {useTimeEntryService} from '@/services/timeEntry'
import {useTimeTrackingStore} from '@/stores/timeTracking'
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
const props = defineProps<{
taskId: number
}>()
const timeTrackingStore = useTimeTrackingStore()
const entries = ref<ITimeEntry[]>([])
const editingEntry = ref<ITimeEntry | null>(null)
const showForm = ref(false)
// Like related tasks: the form is implicit when empty, otherwise behind the +.
const formVisible = computed(() => entries.value.length === 0 || showForm.value || editingEntry.value !== null)
async function load() {
const {items} = await useTimeEntryService().getAll({
filter: `task_id = ${props.taskId}`,
perPage: 250,
})
entries.value = items
}
async function onSaved() {
editingEntry.value = null
showForm.value = false
await load()
}
async function onDelete(id: number) {
await timeTrackingStore.removeEntry(id)
await load()
}
watch(() => props.taskId, load, {immediate: true})
// The header badge can start/stop the timer without going through this form;
// reload so the row reflects the stop (its new end time).
watch(() => timeTrackingStore.activeTimer, load)
</script>

View File

@ -51,6 +51,7 @@ export interface ITask extends IAbstract {
reactions: IReactionPerEntity
comments: ITaskComment[]
commentCount?: number
timeEntriesCount?: number
createdBy: IUser
created: Date

View File

@ -366,6 +366,15 @@
/>
</div>
<!-- Time Tracking -->
<div
v-if="timeTrackingEnabled && activeFields.timeTracking"
:ref="e => setFieldRef('timeTracking', e)"
class="content time-tracking"
>
<TaskTimeTracking :task-id="task.id" />
</div>
<!-- Related Tasks -->
<div
v-if="activeFields.relatedTasks"
@ -537,6 +546,16 @@
<span class="action-heading">{{ $t('task.detail.dateAndTime') }}</span>
<XButton
v-if="timeTrackingEnabled"
v-cy="'taskTrackTimeAction'"
variant="secondary"
:icon="['far', 'clock']"
@click="setFieldActive('timeTracking')"
>
{{ $t('task.detail.actions.timeTracking') }}
</XButton>
<XButton
v-shortcut="'KeyD'"
variant="secondary"
@ -643,11 +662,13 @@ import type {IProject} from '@/modelTypes/IProject'
import {PRIORITIES, type Priority} from '@/constants/priorities'
import {PERMISSIONS} from '@/constants/permissions'
import {PRO_FEATURE} from '@/constants/proFeatures'
import BaseButton from '@/components/base/BaseButton.vue'
// partials
import Attachments from '@/components/tasks/partials/Attachments.vue'
import TaskTimeTracking from '@/components/time-tracking/TaskTimeTracking.vue'
import ChecklistSummary from '@/components/tasks/partials/ChecklistSummary.vue'
import ColorPicker from '@/components/input/ColorPicker.vue'
import Comments from '@/components/tasks/partials/Comments.vue'
@ -682,6 +703,7 @@ import {useKanbanStore} from '@/stores/kanban'
import {useProjectStore} from '@/stores/projects'
import {useAuthStore} from '@/stores/auth'
import {useBaseStore} from '@/stores/base'
import {useConfigStore} from '@/stores/config'
import {useTitle} from '@/composables/useTitle'
import {useTaskDetailShortcuts} from '@/composables/useTaskDetailShortcuts'
@ -704,6 +726,8 @@ const {t} = useI18n({useScope: 'global'})
const projectStore = useProjectStore()
const taskStore = useTaskStore()
const configStore = useConfigStore()
const timeTrackingEnabled = computed(() => configStore.isProFeatureEnabled(PRO_FEATURE.TIME_TRACKING))
const kanbanStore = useKanbanStore()
const authStore = useAuthStore()
const baseStore = useBaseStore()
@ -923,7 +947,12 @@ watch(
}
try {
const loaded = await taskService.get({id}, {expand: ['reactions', 'comments', 'is_unread', 'buckets']})
const expand = ['reactions', 'comments', 'is_unread', 'buckets']
if (timeTrackingEnabled.value) {
// Only request the (server-computed) count when the feature is on.
expand.push('time_entries_count')
}
const loaded = await taskService.get({id}, {expand})
Object.assign(task.value, loaded)
taskColor.value = task.value.hexColor
setActiveFields()
@ -967,6 +996,7 @@ type FieldType =
| 'reminders'
| 'repeatAfter'
| 'startDate'
| 'timeTracking'
const activeFields: { [type in FieldType]: boolean } = reactive({
assignees: false,
@ -982,6 +1012,7 @@ const activeFields: { [type in FieldType]: boolean } = reactive({
reminders: false,
repeatAfter: false,
startDate: false,
timeTracking: false,
})
function setActiveFields() {
@ -992,6 +1023,7 @@ function setActiveFields() {
// Set all active fields based on values in the model
activeFields.assignees = task.value.assignees.length > 0
activeFields.attachments = task.value.attachments.length > 0
activeFields.timeTracking = (task.value.timeEntriesCount ?? 0) > 0
activeFields.dueDate = task.value.dueDate !== null
activeFields.endDate = task.value.endDate !== null
activeFields.labels = task.value.labels.length > 0