feat(time-tracking): add the time-entry form
This commit is contained in:
parent
8c34676946
commit
ab8189e927
|
|
@ -0,0 +1,353 @@
|
|||
<template>
|
||||
<form
|
||||
ref="formEl"
|
||||
v-cy="'timeEntryForm'"
|
||||
class="time-entry-form"
|
||||
@submit.prevent="saveEntry"
|
||||
>
|
||||
<div
|
||||
v-if="taskId === undefined"
|
||||
class="field-columns"
|
||||
>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('task.attributes.project') }}</label>
|
||||
<ProjectSearch v-model="selectedProject" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('timeTracking.form.task') }}</label>
|
||||
<Multiselect
|
||||
v-model="selectedTask"
|
||||
:placeholder="$t('timeTracking.form.taskSearch')"
|
||||
:loading="taskService.loading"
|
||||
:search-results="foundTasks"
|
||||
label="title"
|
||||
@search="findTasks"
|
||||
>
|
||||
<template #searchResult="{option}">
|
||||
{{ option.title }}
|
||||
</template>
|
||||
</Multiselect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('task.comment.comment') }}</label>
|
||||
<input
|
||||
v-model="comment"
|
||||
v-cy="'timeEntryComment'"
|
||||
class="input"
|
||||
type="text"
|
||||
:placeholder="$t('timeTracking.form.commentPlaceholder')"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="field is-grouped from-to-row">
|
||||
<div class="control is-expanded">
|
||||
<label class="label">{{ $t('input.datepickerRange.from') }}</label>
|
||||
<Datepicker
|
||||
v-model="from"
|
||||
:show-shortcuts="false"
|
||||
/>
|
||||
</div>
|
||||
<div class="control is-expanded">
|
||||
<label class="label">{{ $t('input.datepickerRange.to') }}</label>
|
||||
<Datepicker
|
||||
v-model="to"
|
||||
:show-shortcuts="false"
|
||||
:empty-label="$t('misc.notSet')"
|
||||
/>
|
||||
</div>
|
||||
<div class="control">
|
||||
<BaseButton
|
||||
v-tooltip="$t('timeTracking.form.smartFill')"
|
||||
v-cy="'smartFill'"
|
||||
class="smart-fill"
|
||||
:aria-label="$t('timeTracking.form.smartFill')"
|
||||
@click="smartFill"
|
||||
>
|
||||
<Icon :icon="['far', 'clock']" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field form-actions">
|
||||
<template v-if="isEditing">
|
||||
<XButton
|
||||
v-cy="'updateTimeEntry'"
|
||||
:disabled="!canSubmit"
|
||||
:loading="isSaving"
|
||||
@click="saveEntry"
|
||||
>
|
||||
{{ $t('timeTracking.form.update') }}
|
||||
</XButton>
|
||||
<XButton
|
||||
variant="secondary"
|
||||
:disabled="isSaving"
|
||||
@click="cancelEdit"
|
||||
>
|
||||
{{ $t('misc.cancel') }}
|
||||
</XButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<XButton
|
||||
v-cy="'saveTimeEntry'"
|
||||
:disabled="!canSubmit"
|
||||
:loading="isSaving"
|
||||
@click="saveEntry"
|
||||
>
|
||||
{{ $t('timeTracking.form.save') }}
|
||||
</XButton>
|
||||
<XButton
|
||||
v-cy="'startTimer'"
|
||||
variant="secondary"
|
||||
:disabled="!canSubmit"
|
||||
:loading="isSaving"
|
||||
@click="startTimer"
|
||||
>
|
||||
{{ $t('timeTracking.form.startTimer') }}
|
||||
</XButton>
|
||||
</template>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, shallowReactive, watch, nextTick} from 'vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import Multiselect from '@/components/input/Multiselect.vue'
|
||||
import Datepicker from '@/components/input/Datepicker.vue'
|
||||
import ProjectSearch from '@/components/tasks/partials/ProjectSearch.vue'
|
||||
|
||||
import TaskService from '@/services/task'
|
||||
import TaskModel from '@/models/task'
|
||||
import {smartFillStart} from '@/helpers/time/smartFillStart'
|
||||
import {useTimeTrackingStore} from '@/stores/timeTracking'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
// When set, the entry is locked to this task and the project/task pickers are hidden.
|
||||
taskId?: number
|
||||
// When set, the form edits this entry (Update + Cancel) instead of creating.
|
||||
entry?: ITimeEntry | null
|
||||
// Entries the smart-clock looks at to continue from the last one's end.
|
||||
recentEntries?: ITimeEntry[]
|
||||
}>(), {
|
||||
taskId: undefined,
|
||||
entry: undefined,
|
||||
recentEntries: () => [],
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
saved: []
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const timeTrackingStore = useTimeTrackingStore()
|
||||
const authStore = useAuthStore()
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
const isEditing = computed(() => props.entry != null)
|
||||
|
||||
const formEl = ref<HTMLFormElement | null>(null)
|
||||
const selectedProject = ref<IProject | null>(null)
|
||||
const selectedTask = ref<ITask | null>(null)
|
||||
const from = ref<Date | null>(new Date())
|
||||
const to = ref<Date | null>(null)
|
||||
const comment = ref('')
|
||||
const isSaving = ref(false)
|
||||
|
||||
// Task and project are mutually exclusive (XOR) — selecting one clears the other,
|
||||
// so applyTarget never picks a stale target the user has since changed.
|
||||
watch(selectedTask, task => {
|
||||
if (task !== null) {
|
||||
selectedProject.value = null
|
||||
}
|
||||
})
|
||||
watch(selectedProject, project => {
|
||||
if (project !== null) {
|
||||
selectedTask.value = null
|
||||
}
|
||||
})
|
||||
|
||||
const taskService = shallowReactive(new TaskService())
|
||||
const foundTasks = ref<ITask[]>([])
|
||||
async function findTasks(query: string) {
|
||||
if (query === '') {
|
||||
foundTasks.value = []
|
||||
return
|
||||
}
|
||||
const result = await taskService.getAll({}, {s: query, sort_by: 'done'}) as ITask[]
|
||||
foundTasks.value = selectedProject.value === null
|
||||
? result
|
||||
: result.filter(task => task.projectId === selectedProject.value?.id)
|
||||
}
|
||||
|
||||
const canSubmit = computed(() =>
|
||||
// In edit mode the entry already has a valid container; an update that sends
|
||||
// neither keeps it, so don't block submit if the prefill lookup failed.
|
||||
isEditing.value || props.taskId !== undefined || selectedTask.value !== null || selectedProject.value !== null,
|
||||
)
|
||||
|
||||
function smartFill() {
|
||||
from.value = smartFillStart(
|
||||
props.recentEntries,
|
||||
authStore.settings.frontendSettings.timeTrackingDefaultStart ?? '09:00',
|
||||
new Date(),
|
||||
)
|
||||
to.value = new Date()
|
||||
}
|
||||
|
||||
// Whichever of task / project is set lands on the payload (XOR — enforced by canSubmit).
|
||||
function applyTarget(payload: Partial<ITimeEntry>) {
|
||||
if (props.taskId !== undefined) {
|
||||
payload.taskId = props.taskId
|
||||
} else if (selectedTask.value !== null) {
|
||||
payload.taskId = selectedTask.value.id
|
||||
} else if (selectedProject.value !== null) {
|
||||
payload.projectId = selectedProject.value.id
|
||||
}
|
||||
}
|
||||
|
||||
function buildPayload(includeEnd: boolean): Partial<ITimeEntry> {
|
||||
const payload: Partial<ITimeEntry> = {
|
||||
comment: comment.value,
|
||||
startTime: from.value ?? new Date(),
|
||||
}
|
||||
applyTarget(payload)
|
||||
// Saving a manual entry always has an end (an empty "To" means "until now");
|
||||
// only the Start-timer path omits it to create a running timer.
|
||||
if (includeEnd) {
|
||||
payload.endTime = to.value ?? new Date()
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
function reset() {
|
||||
selectedTask.value = null
|
||||
selectedProject.value = null
|
||||
comment.value = ''
|
||||
from.value = new Date()
|
||||
to.value = null
|
||||
}
|
||||
|
||||
// Prefill from the entry being edited; a null entry returns the form to create mode.
|
||||
watch(() => props.entry, async entry => {
|
||||
if (entry == null) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
comment.value = entry.comment
|
||||
from.value = entry.startTime
|
||||
to.value = entry.endTime
|
||||
// Bring the form into view — the edit button may be far down the list.
|
||||
await nextTick()
|
||||
formEl.value?.scrollIntoView({behavior: 'smooth', block: 'center'})
|
||||
if (props.taskId !== undefined) {
|
||||
return
|
||||
}
|
||||
if (entry.taskId > 0) {
|
||||
selectedProject.value = null
|
||||
try {
|
||||
selectedTask.value = await taskService.get(new TaskModel({id: entry.taskId})) as ITask
|
||||
} catch {
|
||||
selectedTask.value = null
|
||||
}
|
||||
} else if (entry.projectId > 0) {
|
||||
selectedTask.value = null
|
||||
selectedProject.value = (projectStore.projects[entry.projectId] as IProject) ?? null
|
||||
}
|
||||
}, {immediate: true})
|
||||
|
||||
async function submit(includeEnd: boolean) {
|
||||
if (!canSubmit.value) {
|
||||
return
|
||||
}
|
||||
isSaving.value = true
|
||||
try {
|
||||
const payload = buildPayload(includeEnd)
|
||||
// A started timer begins now (click time), not when the form first loaded.
|
||||
if (!includeEnd) {
|
||||
payload.startTime = new Date()
|
||||
}
|
||||
await timeTrackingStore.createEntry(payload)
|
||||
reset()
|
||||
emit('saved')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitUpdate() {
|
||||
const entry = props.entry
|
||||
if (!canSubmit.value || entry == null) {
|
||||
return
|
||||
}
|
||||
isSaving.value = true
|
||||
try {
|
||||
const payload: Partial<ITimeEntry> & {id: number} = {
|
||||
id: entry.id,
|
||||
comment: comment.value,
|
||||
startTime: from.value ?? entry.startTime,
|
||||
// A running entry stays running (null); a completed one can't be reopened,
|
||||
// so keep its end if "To" was cleared (the API rejects clearing it).
|
||||
endTime: entry.endTime === null ? to.value : (to.value ?? entry.endTime),
|
||||
taskId: 0,
|
||||
projectId: 0,
|
||||
}
|
||||
applyTarget(payload)
|
||||
await timeTrackingStore.updateEntry(payload)
|
||||
emit('saved')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveEntry = () => (isEditing.value ? submitUpdate() : submit(true))
|
||||
const startTimer = () => submit(false)
|
||||
function cancelEdit() {
|
||||
emit('cancel')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.field-columns {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
|
||||
> .field {
|
||||
flex: 1;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.from-to-row {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.smart-fill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
block-size: 2.5em;
|
||||
inline-size: 2.5em;
|
||||
border-radius: $radius;
|
||||
color: var(--primary);
|
||||
transition: background-color $transition;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--grey-100);
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue