add manual time entry feature

This commit is contained in:
Gregor Vostrak
2024-04-24 22:13:22 +02:00
parent 8136f630c9
commit f52a629bac
15 changed files with 261 additions and 28 deletions

View File

@@ -461,3 +461,5 @@ test('test that load more works when the end of page is reached', async ({
// TODO: Test for resume button click works with project / task // TODO: Test for resume button click works with project / task
// TODO: Test that time entries are loaded at the end of the page // TODO: Test that time entries are loaded at the end of the page
// TODO: Test manual time entries

View File

@@ -4,20 +4,21 @@
:root{ :root{
--theme-color-default-background: #040618; --theme-color-default-background: #0b0d1c;
--theme-color-icon-default: #42466C; --theme-color-icon-default: #42466C;
--theme-color-card-background: #13152B; --theme-color-card-background: #13152B;
--theme-color-card-background-active: #1C1E34; --theme-color-card-background-active: #1C1E34;
--theme-color-card-border: #242940; --theme-color-card-background-separator: #1c2033;
--theme-color-card-border: #1c2033;
--theme-color-card-border-active: #2A3461; --theme-color-card-border-active: #2A3461;
--theme-color-default-background-separator: #141a2f;
--theme-color-tab-background: var(--theme-color-card-background); --theme-color-tab-background: var(--theme-color-card-background);
--theme-color-tab-background-active: var(--theme-color-card-background-active); --theme-color-tab-background-active: var(--theme-color-card-background-active);
--theme-color-tab-border: var(--theme-color-card-border); --theme-color-tab-border: var(--theme-color-card-border);
--theme-color-row-separator-background: var(--theme-color-card-border); --theme-color-row-separator-background: var(--theme-color-default-background-separator);
--theme-color-row-heading-background: var(--theme-color-card-background); --theme-color-row-heading-background: var(--theme-color-card-background);
--theme-color-row-border: var(--theme-color-card-border); --theme-color-row-border: var(--theme-color-card-border);
--theme-color-row-heading-border: var(--theme-color-card-border); --theme-color-row-heading-border: var(--theme-color-card-border);
} }
*{ *{

View File

@@ -4,7 +4,7 @@ import { computed } from 'vue';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
size: 'base' | 'large'; size: 'base' | 'large' | 'xlarge';
tag: string; tag: string;
class?: string; class?: string;
color: string; color: string;
@@ -21,6 +21,7 @@ const props = withDefaults(
const badgeClasses = { const badgeClasses = {
base: 'py-1 px-2 space-x-1.5 text-xs', base: 'py-1 px-2 space-x-1.5 text-xs',
large: 'py-1 sm:py-1.5 px-2 sm:px-3 space-x-1.5 sm:space-x-2 text-xs sm:text-sm text-muted', large: 'py-1 sm:py-1.5 px-2 sm:px-3 space-x-1.5 sm:space-x-2 text-xs sm:text-sm text-muted',
xlarge: 'py-2 sm:py-2.5 px-3 sm:px-3.5 space-x-2 sm:space-x-3 text-sm sm:text-sm text-muted',
}; };
const borderClasses = computed(() => { const borderClasses = computed(() => {

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { getLocalizedDayJs } from '@/utils/time';
import dayjs from 'dayjs';
const model = defineModel<string | null>({
default: null,
});
function updateDate(event: Event) {
const target = event.target as HTMLInputElement;
const newValue = target.value;
const newDate = dayjs(newValue);
if (newDate) {
console.log('old', model.value);
model.value = getLocalizedDayJs(model.value)
.set('year', newDate.year())
.set('day', newDate.day())
.set('month', newDate.month())
.utc()
.format();
}
}
const date = computed(() => {
return model.value
? getLocalizedDayJs(model.value).format('YYYY-MM-DD')
: null;
});
const datePicker = ref<HTMLInputElement | null>(null);
</script>
<template>
<div class="flex items-center justify-center text-muted">
<input
ref="datePicker"
@change="updateDate"
class="bg-input-background border text-white border-input-border rounded-md"
type="date"
id="start"
name="trip-start"
:value="date" />
</div>
</template>
<style scoped>
input::-webkit-calendar-picker-indicator {
filter: invert(1);
opacity: 0.2;
}
</style>

View File

@@ -5,7 +5,7 @@ import Badge from '@/Components/Common/Badge.vue';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
name: string; name: string;
size: 'base' | 'large'; size: 'base' | 'large' | 'xlarge';
tag: string; tag: string;
class?: string; class?: string;
color: string; color: string;
@@ -23,6 +23,7 @@ const props = withDefaults(
const indicatorClasses = { const indicatorClasses = {
base: 'w-2.5 h-2.5', base: 'w-2.5 h-2.5',
large: 'w-2 sm:w-3 h-2 sm:h-3', large: 'w-2 sm:w-3 h-2 sm:h-3',
xlarge: 'w-3 sm:w-4 h-3 sm:h-4',
}; };
</script> </script>

View File

@@ -0,0 +1,118 @@
<script setup lang="ts">
import TextInput from '@/Components/TextInput.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import DialogModal from '@/Components/DialogModal.vue';
import { ref } from 'vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import { useFocus } from '@vueuse/core';
import TimeTrackerTagDropdown from '@/Components/Common/TimeTracker/TimeTrackerTagDropdown.vue';
import TimeTrackerProjectTaskDropdown from '@/Components/Common/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import BillableToggleButton from '@/Components/Common/BillableToggleButton.vue';
import { getCurrentUserId } from '@/utils/useUser';
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
import InputLabel from '@/Components/InputLabel.vue';
import TimePicker from '@/Components/Common/TimePicker.vue';
import DatePicker from '@/Components/Common/DatePicker.vue';
import { getDayJsInstance } from '@/utils/time';
const { createTimeEntry } = useTimeEntriesStore();
const show = defineModel('show', { default: false });
const saving = ref(false);
const timeEntryDefaultValues = {
description: '',
project_id: null,
task_id: null,
tags: [],
billable: false,
start: getDayJsInstance().utc().format(),
end: getDayJsInstance().utc().format(),
user_id: getCurrentUserId(),
};
const timeEntry = ref(timeEntryDefaultValues);
async function submit() {
await createTimeEntry(timeEntry.value);
show.value = false;
timeEntry.value = timeEntryDefaultValues;
}
const projectNameInput = ref<HTMLInputElement | null>(null);
useFocus(projectNameInput, { initialValue: true });
</script>
<template>
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Create manual time entry </span>
</div>
</template>
<template #content>
<div class="sm:flex items-end space-y-2 sm:space-y-0 sm:space-x-4">
<div class="flex-1">
<InputLabel for="description" value="Description" />
<TextInput
id="description"
v-model="timeEntry.description"
@keydown.enter="submit"
type="text"
class="mt-1 block w-full"
autofocus />
</div>
<div class="flex items-center justify-between">
<div>
<TimeTrackerProjectTaskDropdown
class="mt-1"
size="xlarge"
v-model:project="timeEntry.project_id"
v-model:task="
timeEntry.task_id
"></TimeTrackerProjectTaskDropdown>
</div>
<div class="flex items-center space-x-2 px-4">
<TimeTrackerTagDropdown
v-model="timeEntry.tags"></TimeTrackerTagDropdown>
<BillableToggleButton
v-model="timeEntry.billable"></BillableToggleButton>
</div>
</div>
</div>
<div class="flex pt-4">
<div class="flex-1">
<InputLabel>Start</InputLabel>
<div class="flex items-center space-x-4 mt-1">
<DatePicker v-model="timeEntry.start"></DatePicker>
<TimePicker
size="large"
v-model="timeEntry.start"></TimePicker>
</div>
</div>
<div class="flex-1">
<InputLabel>End</InputLabel>
<div class="flex items-center space-x-4 mt-1">
<DatePicker v-model="timeEntry.end"></DatePicker>
<TimePicker
size="large"
v-model="timeEntry.end"></TimePicker>
</div>
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': saving }"
:disabled="saving"
@click="submit">
Create Time Entry
</PrimaryButton>
</template>
</DialogModal>
</template>
<style scoped></style>

View File

@@ -80,7 +80,7 @@ function updateProjectAndTask(projectId: string, taskId: string) {
<template> <template>
<div <div
class="border-b border-card-border transition" class="border-b border-default-background-separator transition"
data-testid="time_entry_row"> data-testid="time_entry_row">
<MainContainer> <MainContainer>
<div class="sm:flex py-1.5 items-center justify-between group"> <div class="sm:flex py-1.5 items-center justify-between group">

View File

@@ -1,11 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { getLocalizedDayJs } from '@/utils/time'; import { getLocalizedDayJs } from '@/utils/time';
import { twMerge } from 'tailwind-merge';
const model = defineModel<string | null>({ const model = defineModel<string | null>({
default: null, default: null,
}); });
const props = withDefaults(
defineProps<{
size: 'base' | 'large';
}>(),
{
size: 'base',
}
);
const hours = computed(() => { const hours = computed(() => {
return model.value ? getLocalizedDayJs(model.value).hour() : null; return model.value ? getLocalizedDayJs(model.value).hour() : null;
}); });
@@ -38,20 +48,38 @@ function updateHours(event: Event) {
</script> </script>
<template> <template>
<div class="flex items-center justify-center text-muted"> <div class="flex items-center justify-center text-white">
<input <div
:value="hours" :class="
@input="updateHours" twMerge(
data-testid="time_picker_hour" 'border bg-input-background rounded-md border-input-border overflow-hidden',
type="text" props.size === 'large' ? 'py-1.5 px-2' : ''
class="bg-card-background border-none text-sm px-1 py-0.5 w-[30px] text-center focus:ring-0 focus:bg-card-background-active" /> )
<span>:</span> ">
<input <input
:value="minutes" :value="hours"
@input="updateMinutes" @input="updateHours"
data-testid="time_picker_minute" data-testid="time_picker_hour"
type="text" type="text"
class="bg-card-background border-none text-sm px-1 py-0.5 w-[30px] text-center focus:ring-0 focus:bg-card-background-active" /> :class="
twMerge(
'border-none bg-transparent px-1 py-0.5 w-[30px] text-center focus:ring-0 focus:bg-card-background-active',
props.size === 'large' ? 'text-base' : 'text-sm'
)
" />
<span>:</span>
<input
:value="minutes"
@input="updateMinutes"
data-testid="time_picker_minute"
type="text"
:class="
twMerge(
'border-none bg-transparent px-1 py-0.5 w-[30px] text-center focus:ring-0 focus:bg-card-background-active',
props.size === 'large' ? 'text-base' : 'text-sm'
)
" />
</div>
</div> </div>
</template> </template>

View File

@@ -45,9 +45,11 @@ type ProjectWithTasks = {
withDefaults( withDefaults(
defineProps<{ defineProps<{
showBadgeBorder: boolean; showBadgeBorder: boolean;
size: 'base' | 'large' | 'xlarge';
}>(), }>(),
{ {
showBadgeBorder: true, showBadgeBorder: true,
size: 'large',
} }
); );
@@ -298,7 +300,7 @@ function selectProject(projectId: string) {
<ProjectBadge <ProjectBadge
ref="projectDropdownTrigger" ref="projectDropdownTrigger"
:color="selectedProjectColor" :color="selectedProjectColor"
size="large" :size="size"
:border="showBadgeBorder" :border="showBadgeBorder"
tag="button" tag="button"
:name="selectedProjectName" :name="selectedProjectName"

View File

@@ -42,7 +42,7 @@ const props = defineProps<{
</div> </div>
<div <div
v-if="props.latestTasks.length === 1" v-if="props.latestTasks.length === 1"
class="text-center flex flex-1 justify-center items-center"> class="text-center flex flex-1 justify-center items-center text-sm">
<div> <div>
<PlusCircleIcon <PlusCircleIcon
class="w-8 text-icon-default inline pb-2"></PlusCircleIcon> class="w-8 text-icon-default inline pb-2"></PlusCircleIcon>

View File

@@ -1,7 +1,7 @@
<template> <template>
<div <div
aria-live="assertive" aria-live="assertive"
class="pointer-events-none fixed inset-0 flex items-end px-4 py-6 sm:items-end sm:p-6"> class="pointer-events-none fixed inset-0 flex items-end px-4 py-6 sm:items-end sm:p-6 sm:pb-24 z-[70]">
<div class="flex w-full flex-col items-center space-y-4 sm:items-end"> <div class="flex w-full flex-col items-center space-y-4 sm:items-end">
<Notification <Notification
v-for="notification in notifications" v-for="notification in notifications"

View File

@@ -11,6 +11,9 @@ import TimeEntryRow from '@/Components/Common/TimeEntry/TimeEntryRow.vue';
import { useElementVisibility } from '@vueuse/core'; import { useElementVisibility } from '@vueuse/core';
import { ClockIcon } from '@heroicons/vue/20/solid'; import { ClockIcon } from '@heroicons/vue/20/solid';
import { getLocalizedDateFromTimestamp } from '@/utils/time'; import { getLocalizedDateFromTimestamp } from '@/utils/time';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import { PlusIcon } from '@heroicons/vue/16/solid';
import TimeEntryCreateModal from '@/Components/Common/TimeEntry/TimeEntryCreateModal.vue';
const timeEntriesStore = useTimeEntriesStore(); const timeEntriesStore = useTimeEntriesStore();
const { timeEntries, allTimeEntriesLoaded } = storeToRefs(timeEntriesStore); const { timeEntries, allTimeEntriesLoaded } = storeToRefs(timeEntriesStore);
@@ -44,13 +47,33 @@ const groupedTimeEntries = computed(() => {
} }
return groupedEntries; return groupedEntries;
}); });
const showManualTimeEntryModal = ref(false);
</script> </script>
<template> <template>
<TimeEntryCreateModal
v-model:show="showManualTimeEntryModal"></TimeEntryCreateModal>
<AppLayout title="Dashboard" data-testid="time_view"> <AppLayout title="Dashboard" data-testid="time_view">
<MainContainer <MainContainer
class="pt-5 sm:pt-8 pb-4 sm:pb-6 border-b border-default-background-separator"> class="pt-5 sm:pt-8 pb-4 sm:pb-6 border-b border-default-background-separator">
<TimeTracker></TimeTracker> <div
class="flex items-end divide-x divide-default-background-separator space-x-2">
<div class="flex-1">
<TimeTracker></TimeTracker>
</div>
<div class="pb-2 pl-4 flex justify-center">
<SecondaryButton
@click="showManualTimeEntryModal = true"
:icon="PlusIcon"
>Manual time entry</SecondaryButton
>
</div>
</div>
</MainContainer>
<MainContainer>
<div class="flex justify-between py-2 items-center">
<div class="text-sm">0 selected</div>
</div>
</MainContainer> </MainContainer>
<div v-for="(value, key) in groupedTimeEntries" :key="key"> <div v-for="(value, key) in groupedTimeEntries" :key="key">
<TimeEntryRowHeading :date="key"></TimeEntryRowHeading> <TimeEntryRowHeading :date="key"></TimeEntryRowHeading>

View File

@@ -22,6 +22,11 @@ export type TimeEntryResponse = ZodiosResponseByAlias<
>; >;
export type TimeEntry = TimeEntryResponse['data'][0]; export type TimeEntry = TimeEntryResponse['data'][0];
export type CreateTimeEntryBody = ZodiosBodyByAlias<
SolidTimeApi,
'createTimeEntry'
>;
export type ProjectResponse = ZodiosResponseByAlias< export type ProjectResponse = ZodiosResponseByAlias<
SolidTimeApi, SolidTimeApi,
'getProjects' 'getProjects'

View File

@@ -71,7 +71,7 @@ export const useNotificationsStore = defineStore('notifications', () => {
} }
} }
} }
return null; throw new Error('Failed to handle API request');
} }
return { addNotification, notifications, handleApiRequestNotifications }; return { addNotification, notifications, handleApiRequestNotifications };

View File

@@ -2,7 +2,7 @@ import { defineStore } from 'pinia';
import { getCurrentOrganizationId, getCurrentUserId } from '@/utils/useUser'; import { getCurrentOrganizationId, getCurrentUserId } from '@/utils/useUser';
import { api } from '../../../openapi.json.client'; import { api } from '../../../openapi.json.client';
import { reactive, ref } from 'vue'; import { reactive, ref } from 'vue';
import type { TimeEntry } from '@/utils/api'; import type { CreateTimeEntryBody, TimeEntry } from '@/utils/api';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useNotificationsStore } from '@/utils/notification'; import { useNotificationsStore } from '@/utils/notification';
@@ -83,7 +83,7 @@ export const useTimeEntriesStore = defineStore('timeEntries', () => {
} }
} }
async function createTimeEntry(timeEntry: TimeEntry) { async function createTimeEntry(timeEntry: CreateTimeEntryBody) {
const organizationId = getCurrentOrganizationId(); const organizationId = getCurrentOrganizationId();
if (organizationId) { if (organizationId) {
await handleApiRequestNotifications( await handleApiRequestNotifications(