refactor tag components and tagCreate events, change global week_start and timezone settings, fix pie charts

This commit is contained in:
Gregor Vostrak
2024-07-15 17:20:07 +02:00
parent 10d8540e6c
commit 655723db49
25 changed files with 255 additions and 133 deletions

View File

@@ -145,7 +145,8 @@ test('test that adding a new tag to an existing time entry works', async ({
const newTagName = Math.floor(Math.random() * 1000000).toString(); const newTagName = Math.floor(Math.random() * 1000000).toString();
await newTimeEntry.getByTestId('time_entry_tag_dropdown').click(); await newTimeEntry.getByTestId('time_entry_tag_dropdown').click();
await page.getByTestId('tag_dropdown_search').fill(newTagName); await page.getByText('Create new tag').click();
await page.getByPlaceholder('Tag Name').fill(newTagName);
const [tagReponse] = await Promise.all([ const [tagReponse] = await Promise.all([
page.waitForResponse(async (response) => { page.waitForResponse(async (response) => {
@@ -156,7 +157,7 @@ test('test that adding a new tag to an existing time entry works', async ({
(await response.json()).data.name === newTagName (await response.json()).data.name === newTagName
); );
}), }),
page.getByTestId('tag_dropdown_search').press('Enter'), page.getByRole('button', { name: 'Create Tag' }).click(),
]); ]);
await page.waitForResponse(async (response) => { await page.waitForResponse(async (response) => {
@@ -172,8 +173,7 @@ test('test that adding a new tag to an existing time entry works', async ({
); );
}); });
await expect(page.getByTestId('tag_dropdown_search')).toHaveValue(''); await expect(newTimeEntry.getByText(newTagName)).toBeVisible();
await expect(page.getByRole('option', { name: newTagName })).toBeVisible();
}); });
// Test that Start / End Time Update Works // Test that Start / End Time Update Works

View File

@@ -226,15 +226,17 @@ test('test that entering a time starts the timer on enter', async ({
test('test that adding a new tag works', async ({ page }) => { test('test that adding a new tag works', async ({ page }) => {
const newTagName = 'New Tag' + Math.floor(Math.random() * 10000); const newTagName = 'New Tag' + Math.floor(Math.random() * 10000);
await goToDashboard(page); await goToDashboard(page);
await page.getByTestId('tag_dropdown').click(); await page.getByTestId('tag_dropdown').click();
await page.getByTestId('tag_dropdown_search').fill(newTagName); await page.getByText('Create new tag').click();
await page.getByPlaceholder('Tag Name').fill(newTagName);
await Promise.all([ await Promise.all([
newTagResponse(page, { name: newTagName }), newTagResponse(page, { name: newTagName }),
page.getByTestId('tag_dropdown_search').press('Enter'), page.getByRole('button', { name: 'Create Tag' }).click(),
]); ]);
await expect(page.getByTestId('tag_dropdown_search')).toHaveValue(''); await page.getByTestId('tag_dropdown').click();
await expect(page.getByRole('option', { name: newTagName })).toBeVisible(); await expect(page.getByRole('option', { name: newTagName })).toBeVisible();
}); });
@@ -249,14 +251,16 @@ test('test that adding a new tag when the timer is running', async ({
]); ]);
await assertThatTimerHasStarted(page); await assertThatTimerHasStarted(page);
await page.getByTestId('tag_dropdown').click(); await page.getByTestId('tag_dropdown').click();
await page.getByTestId('tag_dropdown_search').fill(newTagName); await page.getByText('Create new tag').click();
await page.getByPlaceholder('Tag Name').fill(newTagName);
const [tagCreateResponse] = await Promise.all([ const [tagCreateResponse] = await Promise.all([
newTagResponse(page, { name: newTagName }), newTagResponse(page, { name: newTagName }),
page.getByTestId('tag_dropdown_search').press('Enter'), page.getByRole('button', { name: 'Create Tag' }).click(),
]); ]);
const tagId = (await tagCreateResponse.json()).data.id; const tagId = (await tagCreateResponse.json()).data.id;
await newTimeEntryResponse(page, { status: 200, tags: [tagId] }); await newTimeEntryResponse(page, { status: 200, tags: [tagId] });
await expect(page.getByTestId('tag_dropdown_search')).toHaveValue(''); await page.getByTestId('tag_dropdown').click();
await expect(page.getByRole('option', { name: newTagName })).toBeVisible(); await expect(page.getByRole('option', { name: newTagName })).toBeVisible();
await page.getByTestId('tag_dropdown_search').press('Escape'); await page.getByTestId('tag_dropdown_search').press('Escape');
await page.waitForTimeout(1000); await page.waitForTimeout(1000);

View File

@@ -9,7 +9,7 @@ defineProps<{
<template> <template>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<svg <svg
class="w-4 sm:w-5" class="w-4 sm:w-5 text-muted"
viewBox="0 0 24 24" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"> xmlns="http://www.w3.org/2000/svg">
<g fill="none"> <g fill="none">
@@ -23,7 +23,7 @@ defineProps<{
<span class="font-semibold text-white"> <span class="font-semibold text-white">
{{ formatHumanReadableDate(date) }} {{ formatHumanReadableDate(date) }}
</span> </span>
<span class="font-semibold"> <span class="font-semibold text-muted">
{{ formatDate(date) }} {{ formatDate(date) }}
</span> </span>
</div> </div>

View File

@@ -11,7 +11,6 @@ import {
TitleComponent, TitleComponent,
TooltipComponent, TooltipComponent,
} from 'echarts/components'; } from 'echarts/components';
import { useCssVar } from '@vueuse/core';
import { formatHumanReadableDuration } from '@/utils/time'; import { formatHumanReadableDuration } from '@/utils/time';
import { getRandomColorWithSeed } from '@/utils/color'; import { getRandomColorWithSeed } from '@/utils/color';
import type { GroupedDataEntries } from '@/utils/api'; import type { GroupedDataEntries } from '@/utils/api';
@@ -28,8 +27,6 @@ use([
provide(THEME_KEY, 'dark'); provide(THEME_KEY, 'dark');
const backgroundColor = useCssVar('--theme-color-default-background');
function hexToRGBA(hex: string, opacity = 1) { function hexToRGBA(hex: string, opacity = 1) {
// Remove the hash at the start if it's there // Remove the hash at the start if it's there
hex = hex.replace(/^#/, ''); hex = hex.replace(/^#/, '');
@@ -76,10 +73,6 @@ const seriesData = computed(() => {
...el, ...el,
...{ ...{
itemStyle: { itemStyle: {
borderRadius: 15,
// TODO: Fix dynamic color
borderColor: backgroundColor.value,
borderWidth: 18,
color: new LinearGradient(0, 0, 0, 1, [ color: new LinearGradient(0, 0, 0, 1, [
{ {
offset: 0, offset: 0,

View File

@@ -5,20 +5,22 @@ import DialogModal from '@/Components/DialogModal.vue';
import { ref } from 'vue'; import { ref } from 'vue';
import PrimaryButton from '@/Components/PrimaryButton.vue'; import PrimaryButton from '@/Components/PrimaryButton.vue';
import { useFocus } from '@vueuse/core'; import { useFocus } from '@vueuse/core';
import type { CreateTagBody } from '@/utils/api'; import type { CreateTagBody, Tag } from '@/utils/api';
import { useTagsStore } from '@/utils/useTags';
const show = defineModel('show', { default: false }); const show = defineModel('show', { default: false });
const saving = ref(false); const saving = ref(false);
const { createTag } = useTagsStore();
const tag = ref<CreateTagBody>({ const tag = ref<CreateTagBody>({
name: '', name: '',
}); });
const emit = defineEmits<{
createTag: [name: string, callback: (tag: Tag) => void];
}>();
async function submit() { async function submit() {
await createTag(tag.value.name); emit('createTag', tag.value.name, () => {
show.value = false; show.value = false;
});
} }
const tagNameInput = ref<HTMLInputElement | null>(null); const tagNameInput = ref<HTMLInputElement | null>(null);

View File

@@ -2,13 +2,13 @@
import { PlusCircleIcon } from '@heroicons/vue/20/solid'; import { PlusCircleIcon } from '@heroicons/vue/20/solid';
import Dropdown from '@/Components/Dropdown.vue'; import Dropdown from '@/Components/Dropdown.vue';
import { type Component, computed, nextTick, ref, watch } from 'vue'; import { type Component, computed, nextTick, ref, watch } from 'vue';
import { useTagsStore } from '@/utils/useTags';
import { storeToRefs } from 'pinia';
import TagCreateModal from '@/Components/Common/Tag/TagCreateModal.vue'; import TagCreateModal from '@/Components/Common/Tag/TagCreateModal.vue';
import MultiselectDropdownItem from '@/Components/Common/MultiselectDropdownItem.vue'; import MultiselectDropdownItem from '@/Components/Common/MultiselectDropdownItem.vue';
import type { Tag } from '@/utils/api';
const tagsStore = useTagsStore(); const props = defineProps<{
const { tags } = storeToRefs(tagsStore); tags: Tag[];
}>();
const model = defineModel<string[]>({ const model = defineModel<string[]>({
default: [], default: [],
@@ -33,6 +33,8 @@ function addOrRemoveTagFromSelection(id: string) {
emit('changed'); emit('changed');
} }
const sortedTags = ref(props.tags);
watch(open, (isOpen) => { watch(open, (isOpen) => {
if (isOpen) { if (isOpen) {
nextTick(() => { nextTick(() => {
@@ -40,7 +42,7 @@ watch(open, (isOpen) => {
}); });
// sort tags alphabetically // sort tags alphabetically
tags.value.sort((a, b) => { sortedTags.value = [...props.tags].sort((a, b) => {
const aIsSelected = model.value.includes(a.id); const aIsSelected = model.value.includes(a.id);
const bIsSelected = model.value.includes(b.id); const bIsSelected = model.value.includes(b.id);
if (aIsSelected === bIsSelected) { if (aIsSelected === bIsSelected) {
@@ -57,24 +59,26 @@ watch(open, (isOpen) => {
}); });
const filteredTags = computed(() => { const filteredTags = computed(() => {
return tags.value.filter((tag) => { return sortedTags.value.filter((tag) => {
return tag.name return tag.name
.toLowerCase() .toLowerCase()
.includes(searchValue.value?.toLowerCase()?.trim() || ''); .includes(searchValue.value?.toLowerCase()?.trim() || '');
}); });
}); });
async function addTagIfNoneExists() { function createTag(name: string, callback: (tag: Tag) => void) {
if (searchValue.value.length > 0 && filteredTags.value.length === 0) { emit('createTag', name, (newTag: Tag) => {
const newTag = await tagsStore.createTag(searchValue.value);
if (newTag) { if (newTag) {
addOrRemoveTagFromSelection(newTag.id); addOrRemoveTagFromSelection(newTag.id);
} }
searchValue.value = ''; searchValue.value = '';
} else { callback(newTag);
if (highlightedItemId.value) { });
addOrRemoveTagFromSelection(highlightedItemId.value); }
}
async function addTagIfNoneExists() {
if (highlightedItemId.value) {
addOrRemoveTagFromSelection(highlightedItemId.value);
} }
} }
@@ -90,7 +94,7 @@ function updateSearchValue(event: Event) {
searchValue.value = ''; searchValue.value = '';
const highlightedTagId = highlightedItemId.value; const highlightedTagId = highlightedItemId.value;
if (highlightedTagId) { if (highlightedTagId) {
const highlightedTag = tags.value.find( const highlightedTag = props.tags.find(
(tag) => tag.id === highlightedTagId (tag) => tag.id === highlightedTagId
); );
if (highlightedTag) { if (highlightedTag) {
@@ -102,7 +106,11 @@ function updateSearchValue(event: Event) {
} }
} }
const emit = defineEmits(['update:modelValue', 'changed', 'submit']); const emit = defineEmits<{
changed: [];
submit: [];
createTag: [name: string, callback: (tag: Tag) => void];
}>();
function toggleTag(newValue: string) { function toggleTag(newValue: string) {
if (model.value.includes(newValue)) { if (model.value.includes(newValue)) {
@@ -144,14 +152,16 @@ function moveHighlightDown() {
const highlightedItemId = ref<string | null>(null); const highlightedItemId = ref<string | null>(null);
const highlightedItem = computed(() => { const highlightedItem = computed(() => {
return tags.value.find((tag) => tag.id === highlightedItemId.value); return props.tags.find((tag) => tag.id === highlightedItemId.value);
}); });
const showCreateTagModal = ref(false); const showCreateTagModal = ref(false);
</script> </script>
<template> <template>
<TagCreateModal v-model:show="showCreateTagModal"></TagCreateModal> <TagCreateModal
@createTag="createTag"
v-model:show="showCreateTagModal"></TagCreateModal>
<Dropdown <Dropdown
@submit="emit('submit')" @submit="emit('submit')"
v-model="open" v-model="open"
@@ -172,18 +182,6 @@ const showCreateTagModal = ref(false);
class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full" class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
placeholder="Search for a Tag..." /> placeholder="Search for a Tag..." />
<div ref="dropdownViewport" class="w-60"> <div ref="dropdownViewport" class="w-60">
<div
v-if="searchValue.length > 0 && filteredTags.length === 0"
class="bg-card-background-active rounded-b-lg">
<div
@click="addTagIfNoneExists"
class="text-white flex space-x-3 items-center px-4 py-3 text-xs font-medium border-t border-card-background-separator">
<PlusCircleIcon
class="w-5 flex-shrink-0"></PlusCircleIcon>
<span>Add "{{ searchValue }}" as a new Tag</span>
</div>
</div>
<div <div
v-for="tag in filteredTags" v-for="tag in filteredTags"
:key="tag.id" :key="tag.id"

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import MainContainer from '@/Pages/MainContainer.vue'; import MainContainer from '@/Pages/MainContainer.vue';
import TimeTrackerStartStop from '@/Components/Common/TimeTrackerStartStop.vue'; import TimeTrackerStartStop from '@/Components/Common/TimeTrackerStartStop.vue';
import type { Project, Task, TimeEntry } from '@/utils/api'; import type { Project, Tag, Task, TimeEntry } from '@/utils/api';
import TimeEntryDescriptionInput from '@/Components/Common/TimeEntry/TimeEntryDescriptionInput.vue'; import TimeEntryDescriptionInput from '@/Components/Common/TimeEntry/TimeEntryDescriptionInput.vue';
import { type TimeEntriesGroupedByType } from '@/utils/useTimeEntries'; import { type TimeEntriesGroupedByType } from '@/utils/useTimeEntries';
import TimeEntryRowTagDropdown from '@/Components/Common/TimeEntry/TimeEntryRowTagDropdown.vue'; import TimeEntryRowTagDropdown from '@/Components/Common/TimeEntry/TimeEntryRowTagDropdown.vue';
@@ -20,45 +20,42 @@ const props = defineProps<{
timeEntry: TimeEntriesGroupedByType; timeEntry: TimeEntriesGroupedByType;
projects: Project[]; projects: Project[];
tasks: Task[]; tasks: Task[];
tags: Tag[];
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
onStartStopClick: [timeEntry: TimeEntry]; onStartStopClick: [timeEntry: TimeEntry];
updateTimeEntries: [timeEntries: TimeEntry[]]; updateTimeEntries: [timeEntries: TimeEntry[]];
deleteTimeEntries: [timeEntries: TimeEntry[]]; deleteTimeEntries: [timeEntries: TimeEntry[]];
createTag: [name: string, callback: (tag: Tag) => void];
}>(); }>();
function updateTimeEntryDescription(description: string) { function updateTimeEntryDescription(description: string) {
const timeEntries = props.timeEntry.timeEntries; const updatedTimeEntries = props.timeEntry.timeEntries.map((entry) => {
timeEntries.forEach((entry) => { return { ...entry, description };
entry.description = description;
}); });
emit('updateTimeEntries', timeEntries); emit('updateTimeEntries', updatedTimeEntries);
} }
function updateTimeEntryTags(tags: string[]) { function updateTimeEntryTags(tags: string[]) {
const timeEntries = props.timeEntry.timeEntries as TimeEntry[]; const updatedTimeEntries = props.timeEntry.timeEntries.map((entry) => {
timeEntries.forEach((entry) => { return { ...entry, tags };
entry.tags = tags;
}); });
emit('updateTimeEntries', timeEntries); emit('updateTimeEntries', updatedTimeEntries);
} }
function updateTimeEntryBillable(billable: boolean) { function updateTimeEntryBillable(billable: boolean) {
const timeEntries = props.timeEntry.timeEntries as TimeEntry[]; const updatedTimeEntries = props.timeEntry.timeEntries.map((entry) => {
timeEntries.forEach((entry) => { return { ...entry, billable };
entry.billable = billable;
}); });
emit('updateTimeEntries', timeEntries); emit('updateTimeEntries', updatedTimeEntries);
} }
function updateProjectAndTask(projectId: string, taskId: string) { function updateProjectAndTask(projectId: string, taskId: string) {
const timeEntries = props.timeEntry.timeEntries as TimeEntry[]; const updatedTimeEntries = props.timeEntry.timeEntries.map((entry) => {
timeEntries.forEach((entry) => { return { ...entry, project_id: projectId, task_id: taskId };
entry.project_id = projectId;
entry.task_id = taskId;
}); });
emit('updateTimeEntries', timeEntries); emit('updateTimeEntries', updatedTimeEntries);
} }
const expanded = ref(false); const expanded = ref(false);
@@ -96,8 +93,10 @@ const expanded = ref(false);
timeEntry.task_id timeEntry.task_id
"></TimeTrackerProjectTaskDropdown> "></TimeTrackerProjectTaskDropdown>
</div> </div>
<div class="flex items-center font-medium space-x-2"> <div class="flex items-center font-medium lg:space-x-2">
<TimeEntryRowTagDropdown <TimeEntryRowTagDropdown
@createTag="(...args) => emit('createTag', ...args)"
:tags="tags"
@changed="updateTimeEntryTags" @changed="updateTimeEntryTags"
:modelValue="timeEntry.tags"></TimeEntryRowTagDropdown> :modelValue="timeEntry.tags"></TimeEntryRowTagDropdown>
<BillableToggleButton <BillableToggleButton
@@ -109,7 +108,7 @@ const expanded = ref(false);
<div class="flex-1"> <div class="flex-1">
<button <button
@click="expanded = !expanded" @click="expanded = !expanded"
class="text-muted w-[110px] px-2 py-2 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium"> class="hidden lg:block text-muted w-[110px] px-2 py-2 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium">
{{ formatStartEnd(timeEntry.start, timeEntry.end) }} {{ formatStartEnd(timeEntry.start, timeEntry.end) }}
</button> </button>
</div> </div>
@@ -138,12 +137,12 @@ const expanded = ref(false);
<TimeEntryRow <TimeEntryRow
:projects="projects" :projects="projects"
:tasks="tasks" :tasks="tasks"
:tags="tags"
indent indent
@updateTimeEntry=" @updateTimeEntry="(arg) => emit('updateTimeEntries', [arg])"
(timeEntry) => emit('updateTimeEntries', [timeEntry])
"
@onStartStopClick="emit('onStartStopClick', subEntry)" @onStartStopClick="emit('onStartStopClick', subEntry)"
@deleteTimeEntry="emit('deleteTimeEntries', [subEntry])" @deleteTimeEntry="emit('deleteTimeEntries', [subEntry])"
@createTag="(...args) => emit('createTag', ...args)"
:key="subEntry.id" :key="subEntry.id"
v-for="subEntry in timeEntry.timeEntries" v-for="subEntry in timeEntry.timeEntries"
:time-entry="subEntry"></TimeEntryRow> :time-entry="subEntry"></TimeEntryRow>

View File

@@ -16,6 +16,8 @@ import { getDayJsInstance, getLocalizedDayJs } from '@/utils/time';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useTasksStore } from '@/utils/useTasks'; import { useTasksStore } from '@/utils/useTasks';
import { useProjectsStore } from '@/utils/useProjects'; import { useProjectsStore } from '@/utils/useProjects';
import { useTagsStore } from '@/utils/useTags';
import type { Tag } from '@/utils/api';
const projectStore = useProjectsStore(); const projectStore = useProjectsStore();
const { projects } = storeToRefs(projectStore); const { projects } = storeToRefs(projectStore);
const taskStore = useTasksStore(); const taskStore = useTasksStore();
@@ -72,6 +74,13 @@ async function submit() {
localEnd.value = getLocalizedDayJs(timeEntryDefaultValues.end).format(); localEnd.value = getLocalizedDayJs(timeEntryDefaultValues.end).format();
show.value = false; show.value = false;
} }
const { tags } = storeToRefs(useTagsStore());
async function createTag(tag: string, callback: (tag: Tag) => void) {
const newTag = await useTagsStore().createTag(tag);
if (newTag !== undefined) {
callback(newTag);
}
}
</script> </script>
<template> <template>
@@ -108,6 +117,8 @@ async function submit() {
</div> </div>
<div class="flex items-center space-x-2 px-4"> <div class="flex items-center space-x-2 px-4">
<TimeTrackerTagDropdown <TimeTrackerTagDropdown
:tags="tags"
@createTag="createTag"
v-model="timeEntry.tags"></TimeTrackerTagDropdown> v-model="timeEntry.tags"></TimeTrackerTagDropdown>
<BillableToggleButton <BillableToggleButton
v-model="timeEntry.billable"></BillableToggleButton> v-model="timeEntry.billable"></BillableToggleButton>

View File

@@ -25,7 +25,7 @@ const displaysPlaceholder = computed(() => {
<template> <template>
<div> <div>
<div class="relative text-sm font-medium p"> <div class="relative text-sm font-medium">
<div <div
:class="[ :class="[
'opacity-0 py-2 text-base whitespace-pre pl-3 pr-1', 'opacity-0 py-2 text-base whitespace-pre pl-3 pr-1',
@@ -40,7 +40,7 @@ const displaysPlaceholder = computed(() => {
@input="onInput" @input="onInput"
@keydown.enter="onChange" @keydown.enter="onChange"
placeholder="Add a description" placeholder="Add a description"
class="absolute px-0 h-full pl-3 pr-1 left-0 top-0 w-full text-white font-medium bg-transparent focus-visible:ring-0 rounded-lg border-0" /> class="absolute px-0 h-full pl-3 pr-1 left-0 top-0 w-full text-sm lg:text-base text-white font-medium bg-transparent focus-visible:ring-0 rounded-lg border-0" />
</div> </div>
</div> </div>
</template> </template>

View File

@@ -1,23 +1,32 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import type { Project, Task, TimeEntry } from '@/utils/api'; import type {
CreateTimeEntryBody,
Project,
Tag,
Task,
TimeEntry,
} from '@/utils/api';
import { getDayJsInstance, getLocalizedDateFromTimestamp } from '@/utils/time'; import { getDayJsInstance, getLocalizedDateFromTimestamp } from '@/utils/time';
import type { TimeEntriesGroupedByType } from '@/utils/useTimeEntries'; import type { TimeEntriesGroupedByType } from '@/utils/useTimeEntries';
import TimeEntryAggregateRow from '@/Components/Common/TimeEntry/TimeEntryAggregateRow.vue'; import TimeEntryAggregateRow from '@/Components/Common/TimeEntry/TimeEntryAggregateRow.vue';
import TimeEntryRowHeading from '@/Components/Common/TimeEntry/TimeEntryRowHeading.vue'; import TimeEntryRowHeading from '@/Components/Common/TimeEntry/TimeEntryRowHeading.vue';
import TimeEntryRow from '@/Components/Common/TimeEntry/TimeEntryRow.vue'; import TimeEntryRow from '@/Components/Common/TimeEntry/TimeEntryRow.vue';
import dayjs from 'dayjs';
const props = defineProps<{ const props = defineProps<{
timeEntries: TimeEntry[]; timeEntries: TimeEntry[];
projects: Project[]; projects: Project[];
tasks: Task[]; tasks: Task[];
tags: Tag[];
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
updateTimeEntry: [entry: TimeEntry]; updateTimeEntry: [entry: TimeEntry];
updateTimeEntries: [entries: TimeEntry[]]; updateTimeEntries: [entries: TimeEntry[]];
deleteTimeEntries: [entries: TimeEntry[]]; deleteTimeEntries: [entries: TimeEntry[]];
onStartStopClick: [entry: TimeEntry]; createTimeEntry: [entry: Omit<CreateTimeEntryBody, 'member_id'>];
createTag: [name: string, callback: (tag: Tag) => void];
}>(); }>();
const groupedTimeEntries = computed(() => { const groupedTimeEntries = computed(() => {
@@ -85,6 +94,17 @@ const groupedTimeEntries = computed(() => {
} }
return groupedEntriesByDayAndType; return groupedEntriesByDayAndType;
}); });
function startTimeEntryFromExisting(entry: TimeEntry) {
emit('createTimeEntry', {
project_id: entry.project_id,
task_id: entry.task_id,
start: dayjs().utc().format(),
end: null,
billable: entry.billable,
description: entry.description,
});
}
</script> </script>
<template> <template>
@@ -94,16 +114,20 @@ const groupedTimeEntries = computed(() => {
<TimeEntryAggregateRow <TimeEntryAggregateRow
:projects="projects" :projects="projects"
:tasks="tasks" :tasks="tasks"
@onStartStopClick="(arg) => emit('onStartStopClick', arg)" :tags="tags"
@onStartStopClick="startTimeEntryFromExisting(entry)"
@updateTimeEntries="(arg) => emit('updateTimeEntries', arg)" @updateTimeEntries="(arg) => emit('updateTimeEntries', arg)"
@deleteTimeEntries="(arg) => emit('deleteTimeEntries', arg)" @deleteTimeEntries="(arg) => emit('deleteTimeEntries', arg)"
@createTag="(...args) => emit('createTag', ...args)"
v-if="'timeEntries' in entry && entry.timeEntries.length > 1" v-if="'timeEntries' in entry && entry.timeEntries.length > 1"
:time-entry="entry"></TimeEntryAggregateRow> :time-entry="entry"></TimeEntryAggregateRow>
<TimeEntryRow <TimeEntryRow
:projects="projects" :projects="projects"
:tasks="tasks" :tasks="tasks"
:tags="tags"
@createTag="(...args) => emit('createTag', ...args)"
@updateTimeEntry="(arg) => emit('updateTimeEntry', arg)" @updateTimeEntry="(arg) => emit('updateTimeEntry', arg)"
@onStartStopClick="() => emit('onStartStopClick', entry)" @onStartStopClick="startTimeEntryFromExisting(entry)"
@deleteTimeEntry="() => emit('deleteTimeEntries', [entry])" @deleteTimeEntry="() => emit('deleteTimeEntries', [entry])"
v-else v-else
:time-entry="entry"></TimeEntryRow> :time-entry="entry"></TimeEntryRow>

View File

@@ -2,7 +2,7 @@
import MainContainer from '@/Pages/MainContainer.vue'; import MainContainer from '@/Pages/MainContainer.vue';
import TimeTrackerStartStop from '@/Components/Common/TimeTrackerStartStop.vue'; import TimeTrackerStartStop from '@/Components/Common/TimeTrackerStartStop.vue';
import TimeEntryRangeSelector from '@/Components/Common/TimeEntry/TimeEntryRangeSelector.vue'; import TimeEntryRangeSelector from '@/Components/Common/TimeEntry/TimeEntryRangeSelector.vue';
import type { Project, Task, TimeEntry } from '@/utils/api'; import type { Project, Tag, Task, TimeEntry } from '@/utils/api';
import TimeEntryDescriptionInput from '@/Components/Common/TimeEntry/TimeEntryDescriptionInput.vue'; import TimeEntryDescriptionInput from '@/Components/Common/TimeEntry/TimeEntryDescriptionInput.vue';
import TimeEntryRowTagDropdown from '@/Components/Common/TimeEntry/TimeEntryRowTagDropdown.vue'; import TimeEntryRowTagDropdown from '@/Components/Common/TimeEntry/TimeEntryRowTagDropdown.vue';
import TimeEntryRowDurationInput from '@/Components/Common/TimeEntry/TimeEntryRowDurationInput.vue'; import TimeEntryRowDurationInput from '@/Components/Common/TimeEntry/TimeEntryRowDurationInput.vue';
@@ -15,12 +15,14 @@ const props = defineProps<{
indent?: boolean; indent?: boolean;
projects: Project[]; projects: Project[];
tasks: Task[]; tasks: Task[];
tags: Tag[];
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
onStartStopClick: []; onStartStopClick: [];
deleteTimeEntry: []; deleteTimeEntry: [];
updateTimeEntry: [timeEntry: TimeEntry]; updateTimeEntry: [timeEntry: TimeEntry];
createTag: [name: string, callback: (tag: Tag) => void];
}>(); }>();
function updateTimeEntryDescription(description: string) { function updateTimeEntryDescription(description: string) {
@@ -53,13 +55,15 @@ function updateProjectAndTask(projectId: string, taskId: string) {
class="border-b border-default-background-separator 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 lg:py-1.5 items-center justify-between group">
<div class="flex space-x-1 items-center"> <div class="flex space-x-1 items-center">
<input <input
type="checkbox" type="checkbox"
class="h-4 w-4 rounded bg-card-background border-input-border text-accent-500/80 focus:ring-accent-500/80" /> class="h-4 w-4 rounded bg-card-background border-input-border text-accent-500/80 focus:ring-accent-500/80" />
<div class="w-7 h-7" v-if="indent === true"></div> <div class="w-7 h-7" v-if="indent === true"></div>
<TimeEntryDescriptionInput <TimeEntryDescriptionInput
class="flex-1 max-w-[220px] md:max-w-[400px] text-ellipsis overflow-ellipsis"
@changed="updateTimeEntryDescription" @changed="updateTimeEntryDescription"
:modelValue=" :modelValue="
timeEntry.description timeEntry.description
@@ -74,9 +78,11 @@ function updateProjectAndTask(projectId: string, taskId: string) {
timeEntry.task_id timeEntry.task_id
"></TimeTrackerProjectTaskDropdown> "></TimeTrackerProjectTaskDropdown>
</div> </div>
<div class="flex items-center font-medium space-x-2"> <div class="flex items-center font-medium lg:space-x-2">
<TimeEntryRowTagDropdown <TimeEntryRowTagDropdown
@changed="updateTimeEntryTags" @changed="updateTimeEntryTags"
@createTag="(...args) => emit('createTag', ...args)"
:tags="tags"
:modelValue="timeEntry.tags"></TimeEntryRowTagDropdown> :modelValue="timeEntry.tags"></TimeEntryRowTagDropdown>
<BillableToggleButton <BillableToggleButton
:modelValue="timeEntry.billable" :modelValue="timeEntry.billable"
@@ -86,6 +92,7 @@ function updateProjectAndTask(projectId: string, taskId: string) {
"></BillableToggleButton> "></BillableToggleButton>
<div class="flex-1"> <div class="flex-1">
<TimeEntryRangeSelector <TimeEntryRangeSelector
class="hidden lg:block"
:start="timeEntry.start" :start="timeEntry.start"
:end="timeEntry.end" :end="timeEntry.end"
@changed=" @changed="

View File

@@ -8,7 +8,7 @@ defineProps<{
<template> <template>
<div <div
class="bg-card-background border-t border-b border-card-border py-1.5 text-xs sm:text-sm"> class="bg-card-background border-t border-b border-card-border py-1 lg:py-1.5 text-xs sm:text-sm">
<MainContainer> <MainContainer>
<DaySectionHeader :date></DaySectionHeader> <DaySectionHeader :date></DaySectionHeader>
</MainContainer> </MainContainer>

View File

@@ -3,23 +3,30 @@ import TagDropdown from '@/Components/Common/Tag/TagDropdown.vue';
import { computed } from 'vue'; import { computed } from 'vue';
import TagBadge from '@/Components/Common/Tag/TagBadge.vue'; import TagBadge from '@/Components/Common/Tag/TagBadge.vue';
import type { Tag } from '@/utils/api'; import type { Tag } from '@/utils/api';
import { useTagsStore } from '@/utils/useTags';
import { storeToRefs } from 'pinia';
const tagsStore = useTagsStore(); const props = defineProps<{
const { tags } = storeToRefs(tagsStore); tags: Tag[];
const emit = defineEmits(['changed']); }>();
const emit = defineEmits<{
createTag: [name: string, callback: (tag: Tag) => void];
changed: [model: string[]];
}>();
const model = defineModel<string[]>({ const model = defineModel<string[]>({
default: [], default: [],
}); });
const timeEntryTags = computed<Tag[]>(() => { const timeEntryTags = computed<Tag[]>(() => {
return tags.value.filter((tag) => model.value.includes(tag.id)); return props.tags.filter((tag) => model.value.includes(tag.id));
}); });
</script> </script>
<template> <template>
<TagDropdown @changed="emit('changed', model)" v-model="model"> <TagDropdown
:tags="tags"
@createTag="(...args) => emit('createTag', ...args)"
@changed="emit('changed', model)"
v-model="model">
<template #trigger> <template #trigger>
<button <button
data-testid="time_entry_tag_dropdown" data-testid="time_entry_tag_dropdown"

View File

@@ -339,8 +339,8 @@ async function createProject(project: CreateProjectBody, callback: () => void) {
tag="button" tag="button"
:name="selectedProjectName" :name="selectedProjectName"
class="focus:border-border-tertiary focus:outline-0 focus:bg-card-background-separator hover:bg-card-background-separator"> class="focus:border-border-tertiary focus:outline-0 focus:bg-card-background-separator hover:bg-card-background-separator">
<div class="flex items-center space-x-1"> <div class="flex nowrap items-center space-x-1">
<span> <span class="whitespace-nowrap text-xs lg:text-sm">
{{ selectedProjectName }} {{ selectedProjectName }}
</span> </span>
<ChevronRightIcon <ChevronRightIcon

View File

@@ -3,8 +3,13 @@ import TagDropdown from '@/Components/Common/Tag/TagDropdown.vue';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { TagIcon } from '@heroicons/vue/20/solid'; import { TagIcon } from '@heroicons/vue/20/solid';
import { computed } from 'vue'; import { computed } from 'vue';
import type { Tag } from '@/utils/api';
const emit = defineEmits<{
changed: [];
createTag: [name: string, callback: (tag: Tag) => void];
}>();
const emit = defineEmits(['changed']);
const model = defineModel({ const model = defineModel({
default: [], default: [],
}); });
@@ -15,10 +20,17 @@ const iconColorClasses = computed(() => {
return 'text-icon-default hover:text-icon-active focus:text-icon-active'; return 'text-icon-default hover:text-icon-active focus:text-icon-active';
} }
}); });
defineProps<{
tags: Tag[];
}>();
</script> </script>
<template> <template>
<TagDropdown @changed="emit('changed')" v-model="model"> <TagDropdown
@createTag="(...args) => $emit('createTag', ...args)"
@changed="emit('changed')"
v-model="model"
:tags="tags">
<template #trigger> <template #trigger>
<button <button
data-testid="tag_dropdown" data-testid="tag_dropdown"

View File

@@ -11,7 +11,6 @@ import {
TitleComponent, TitleComponent,
TooltipComponent, TooltipComponent,
} from 'echarts/components'; } from 'echarts/components';
import { useCssVar } from '@vueuse/core';
import { formatHumanReadableDuration } from '@/utils/time'; import { formatHumanReadableDuration } from '@/utils/time';
use([ use([
@@ -25,8 +24,6 @@ use([
provide(THEME_KEY, 'dark'); provide(THEME_KEY, 'dark');
const backgroundColor = useCssVar('--theme-color-default-background');
function hexToRGBA(hex: string, opacity = 1) { function hexToRGBA(hex: string, opacity = 1) {
// Remove the hash at the start if it's there // Remove the hash at the start if it's there
hex = hex.replace(/^#/, ''); hex = hex.replace(/^#/, '');
@@ -62,10 +59,6 @@ const seriesData = props.weeklyProjectOverview.map((el) => {
...el, ...el,
...{ ...{
itemStyle: { itemStyle: {
borderRadius: 15,
// TODO: Fix dynamic color
borderColor: backgroundColor.value,
borderWidth: 18,
color: new LinearGradient(0, 0, 0, 1, [ color: new LinearGradient(0, 0, 0, 1, [
{ {
offset: 0, offset: 0,

View File

@@ -10,7 +10,7 @@ defineProps<{
<Dropdown align="bottom-end"> <Dropdown align="bottom-end">
<template #trigger> <template #trigger>
<button <button
class="focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-1 focus-visible:ring-input-border-active focus-visible:opacity-100 hover:bg-card-background group-hover:opacity-100 opacity-20 transition-opacity" class="focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-1 focus-visible:ring-input-border-active focus-visible:opacity-100 hover:bg-card-background group-hover:opacity-100 opacity-20 transition-opacity text-muted"
:aria-label="label"> :aria-label="label">
<svg <svg
class="h-10 w-10 p-2 rounded-full" class="h-10 w-10 p-2 rounded-full"

View File

@@ -20,6 +20,8 @@ import SecondaryButton from '@/Components/SecondaryButton.vue';
import TimeTrackerRangeSelector from '@/Components/Common/TimeTracker/TimeTrackerRangeSelector.vue'; import TimeTrackerRangeSelector from '@/Components/Common/TimeTracker/TimeTrackerRangeSelector.vue';
import { useProjectsStore } from '@/utils/useProjects'; import { useProjectsStore } from '@/utils/useProjects';
import { useTasksStore } from '@/utils/useTasks'; import { useTasksStore } from '@/utils/useTasks';
import { useTagsStore } from '@/utils/useTags';
import type { Tag } from '@/utils/api';
const page = usePage<{ const page = usePage<{
auth: { auth: {
@@ -103,6 +105,14 @@ function switchToTimeEntryOrganization() {
switchOrganization(currentTimeEntry.value.organization_id); switchOrganization(currentTimeEntry.value.organization_id);
} }
} }
async function createTag(tag: string, callback: (tag: Tag) => void) {
const newTag = await useTagsStore().createTag(tag);
if (newTag !== undefined) {
callback(newTag);
}
}
const { tags } = storeToRefs(useTagsStore());
</script> </script>
<template> <template>
@@ -150,6 +160,8 @@ function switchToTimeEntryOrganization() {
<div class="flex items-center space-x-2 px-4"> <div class="flex items-center space-x-2 px-4">
<TimeTrackerTagDropdown <TimeTrackerTagDropdown
@changed="updateTimeEntry" @changed="updateTimeEntry"
@createTag="createTag"
:tags="tags"
v-model=" v-model="
currentTimeEntry.tags currentTimeEntry.tags
"></TimeTrackerTagDropdown> "></TimeTrackerTagDropdown>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"></script> <script setup lang="ts"></script>
<template> <template>
<div class="px-3 sm:px-6 lg:px-8 3xl:px-12 mx-auto"> <div class="px-3 sm:px-4 lg:px-8 3xl:px-12 mx-auto">
<slot></slot> <slot></slot>
</div> </div>
</template> </template>

View File

@@ -21,7 +21,7 @@ import {
import { type GroupingOption, useReportingStore } from '@/utils/useReporting'; import { type GroupingOption, useReportingStore } from '@/utils/useReporting';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import TagDropdown from '@/Components/Common/Tag/TagDropdown.vue'; import TagDropdown from '@/Components/Common/Tag/TagDropdown.vue';
import type { AggregatedTimeEntriesQueryParams } from '@/utils/api'; import type { AggregatedTimeEntriesQueryParams, Tag } from '@/utils/api';
import ReportingFilterBadge from '@/Components/Common/Reporting/ReportingFilterBadge.vue'; import ReportingFilterBadge from '@/Components/Common/Reporting/ReportingFilterBadge.vue';
import ProjectMultiselectDropdown from '@/Components/Common/Project/ProjectMultiselectDropdown.vue'; import ProjectMultiselectDropdown from '@/Components/Common/Project/ProjectMultiselectDropdown.vue';
import MemberMultiselectDropdown from '@/Components/Common/Member/MemberMultiselectDropdown.vue'; import MemberMultiselectDropdown from '@/Components/Common/Member/MemberMultiselectDropdown.vue';
@@ -33,6 +33,7 @@ import { formatCents } from '@/utils/money';
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue'; import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
import { getCurrentMembershipId, getCurrentRole } from '@/utils/useUser'; import { getCurrentMembershipId, getCurrentRole } from '@/utils/useUser';
import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue'; import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue';
import { useTagsStore } from '@/utils/useTags';
const startDate = ref<string | null>( const startDate = ref<string | null>(
getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format() getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format()
@@ -136,6 +137,14 @@ onMounted(() => {
updateGraphReporting(); updateGraphReporting();
updateTableReporting(); updateTableReporting();
}); });
const { tags } = storeToRefs(useTagsStore());
async function createTag(tag: string, callback: (tag: Tag) => void) {
const newTag = await useTagsStore().createTag(tag);
if (newTag !== undefined) {
callback(newTag);
}
}
</script> </script>
<template> <template>
@@ -196,7 +205,9 @@ onMounted(() => {
</ClientMultiselectDropdown> </ClientMultiselectDropdown>
<TagDropdown <TagDropdown
@submit="updateReporting" @submit="updateReporting"
v-model="selectedTags"> @createTag="createTag"
v-model="selectedTags"
:tags="tags">
<template v-slot:trigger> <template v-slot:trigger>
<ReportingFilterBadge <ReportingFilterBadge
:count="selectedTags.length" :count="selectedTags.length"

View File

@@ -8,7 +8,15 @@ import TagTable from '@/Components/Common/Tag/TagTable.vue';
import TagCreateModal from '@/Components/Common/Tag/TagCreateModal.vue'; import TagCreateModal from '@/Components/Common/Tag/TagCreateModal.vue';
import PageTitle from '@/Components/Common/PageTitle.vue'; import PageTitle from '@/Components/Common/PageTitle.vue';
import { canCreateTags } from '@/utils/permissions'; import { canCreateTags } from '@/utils/permissions';
const createTag = ref(false); import { useTagsStore } from '@/utils/useTags';
import type { Tag } from '@/utils/api';
const showCreateTagModal = ref(false);
async function createTag(tag: string, callback: (tag: Tag) => void) {
const newTag = await useTagsStore().createTag(tag);
if (newTag !== undefined) {
callback(newTag);
}
}
</script> </script>
<template> <template>
@@ -21,10 +29,12 @@ const createTag = ref(false);
<SecondaryButton <SecondaryButton
v-if="canCreateTags()" v-if="canCreateTags()"
:icon="PlusIcon" :icon="PlusIcon"
@click="createTag = true" @click="showCreateTagModal = true"
>Create Tag</SecondaryButton >Create Tag</SecondaryButton
> >
<TagCreateModal v-model:show="createTag"></TagCreateModal> <TagCreateModal
@createTag="createTag"
v-model:show="showCreateTagModal"></TagCreateModal>
</MainContainer> </MainContainer>
<TagTable></TagTable> <TagTable></TagTable>
</AppLayout> </AppLayout>

View File

@@ -5,18 +5,18 @@ import { onMounted, ref, watch } from 'vue';
import MainContainer from '@/Pages/MainContainer.vue'; import MainContainer from '@/Pages/MainContainer.vue';
import { useTimeEntriesStore } from '@/utils/useTimeEntries'; import { useTimeEntriesStore } from '@/utils/useTimeEntries';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import type { TimeEntry } from '@/utils/api'; import type { CreateTimeEntryBody, Tag, TimeEntry } from '@/utils/api';
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 SecondaryButton from '@/Components/SecondaryButton.vue'; import SecondaryButton from '@/Components/SecondaryButton.vue';
import { PlusIcon } from '@heroicons/vue/16/solid'; import { PlusIcon } from '@heroicons/vue/16/solid';
import TimeEntryCreateModal from '@/Components/Common/TimeEntry/TimeEntryCreateModal.vue'; import TimeEntryCreateModal from '@/Components/Common/TimeEntry/TimeEntryCreateModal.vue';
import LoadingSpinner from '@/Components/LoadingSpinner.vue'; import LoadingSpinner from '@/Components/LoadingSpinner.vue';
import dayjs from 'dayjs';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry'; import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { useTasksStore } from '@/utils/useTasks'; import { useTasksStore } from '@/utils/useTasks';
import { useProjectsStore } from '@/utils/useProjects'; import { useProjectsStore } from '@/utils/useProjects';
import TimeEntryGroupedTable from '@/Components/Common/TimeEntry/TimeEntryGroupedTable.vue'; import TimeEntryGroupedTable from '@/Components/Common/TimeEntry/TimeEntryGroupedTable.vue';
import { useTagsStore } from '@/utils/useTags';
const timeEntriesStore = useTimeEntriesStore(); const timeEntriesStore = useTimeEntriesStore();
const { timeEntries, allTimeEntriesLoaded } = storeToRefs(timeEntriesStore); const { timeEntries, allTimeEntriesLoaded } = storeToRefs(timeEntriesStore);
@@ -33,20 +33,18 @@ function updateTimeEntries(timeEntries: TimeEntry[]) {
const loading = ref(false); const loading = ref(false);
const loadMoreContainer = ref<HTMLDivElement | null>(null); const loadMoreContainer = ref<HTMLDivElement | null>(null);
const isLoadMoreVisible = useElementVisibility(loadMoreContainer); const isLoadMoreVisible = useElementVisibility(loadMoreContainer);
const currentTimeEntryStore = useCurrentTimeEntryStore();
const { currentTimeEntry } = storeToRefs(currentTimeEntryStore);
const { stopTimer } = currentTimeEntryStore;
const { tags } = storeToRefs(useTagsStore());
async function onStartStopClick(timeEntry: TimeEntry) { async function startTimeEntry(
if (timeEntry.start && !timeEntry.end) { timeEntry: Omit<CreateTimeEntryBody, 'member_id'>
await updateTimeEntry({ ) {
...timeEntry, if (currentTimeEntry.value.id) {
end: dayjs().utc().format(), await stopTimer();
});
} else {
await createTimeEntry({
...timeEntry,
start: dayjs().utc().format(),
end: null,
});
} }
await createTimeEntry(timeEntry);
fetchTimeEntries(); fetchTimeEntries();
useCurrentTimeEntryStore().fetchCurrentTimeEntry(); useCurrentTimeEntryStore().fetchCurrentTimeEntry();
} }
@@ -78,6 +76,13 @@ const projectStore = useProjectsStore();
const { projects } = storeToRefs(projectStore); const { projects } = storeToRefs(projectStore);
const taskStore = useTasksStore(); const taskStore = useTasksStore();
const { tasks } = storeToRefs(taskStore); const { tasks } = storeToRefs(taskStore);
async function createTag(name: string, callback: (tag: Tag) => void) {
const newTag = await useTagsStore().createTag(name);
if (newTag !== undefined) {
callback(newTag);
}
}
</script> </script>
<template> <template>
@@ -105,10 +110,12 @@ const { tasks } = storeToRefs(taskStore);
@updateTimeEntry="updateTimeEntry" @updateTimeEntry="updateTimeEntry"
@updateTimeEntries="updateTimeEntries" @updateTimeEntries="updateTimeEntries"
@deleteTimeEntries="deleteTimeEntries" @deleteTimeEntries="deleteTimeEntries"
@onStartStopClick="onStartStopClick" @createTimeEntry="startTimeEntry"
@createTag="createTag"
:projects="projects" :projects="projects"
:tasks="tasks" :tasks="tasks"
:timeEntries="timeEntries"></TimeEntryGroupedTable> :timeEntries="timeEntries"
:tags="tags"></TimeEntryGroupedTable>
<div v-if="timeEntries.length === 0" class="text-center pt-12"> <div v-if="timeEntries.length === 0" class="text-center pt-12">
<ClockIcon class="w-8 text-icon-default inline pb-2"></ClockIcon> <ClockIcon class="w-8 text-icon-default inline pb-2"></ClockIcon>
<h3 class="text-white font-semibold">No time entries found</h3> <h3 class="text-white font-semibold">No time entries found</h3>

View File

@@ -1,11 +1,12 @@
import './bootstrap'; import './bootstrap';
import '../css/app.css'; import '../css/app.css';
import type { DefineComponent } from 'vue'; import { type DefineComponent } from 'vue';
import { createApp, h } from 'vue'; import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3'; import { createInertiaApp, usePage } from '@inertiajs/vue3';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'; import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import { ZiggyVue } from '../../vendor/tightenco/ziggy'; import { ZiggyVue } from '../../vendor/tightenco/ziggy';
import { createPinia } from 'pinia'; import { createPinia } from 'pinia';
import type { User } from '@/types/models';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
const pinia = createPinia(); const pinia = createPinia();
@@ -24,6 +25,22 @@ createInertiaApp({
if (window.vueAppSetupHook) { if (window.vueAppSetupHook) {
window.vueAppSetupHook(app); window.vueAppSetupHook(app);
} }
window.getWeekStartSetting = function () {
const page = usePage<{
auth: {
user: User;
};
}>();
return page.props.auth.user.week_start;
};
window.getTimezoneSetting = function () {
const page = usePage<{
auth: {
user: User;
};
}>();
return page.props.auth.user.timezone;
};
app.use(plugin).use(pinia).use(ZiggyVue).mount(el); app.use(plugin).use(pinia).use(ZiggyVue).mount(el);
}, },

View File

@@ -9,6 +9,8 @@ declare global {
axios: AxiosInstance; axios: AxiosInstance;
initialDataLoaded: boolean; initialDataLoaded: boolean;
vueAppSetupHook?: (app: App) => void; vueAppSetupHook?: (app: App) => void;
getWeekStartSetting: () => string;
getTimezoneSetting: () => string;
} }
let route: typeof ziggyRoute; let route: typeof ziggyRoute;

View File

@@ -11,7 +11,14 @@ function getCurrentUserId() {
} }
function getWeekStart() { function getWeekStart() {
return page.props.auth.user.week_start; const weekStart = window?.getWeekStartSetting() as string;
if (!weekStart) {
throw new Error(
'Please make sure to provide the current user week start setting as a vue inject (week_start)'
);
}
return weekStart;
} }
function getCurrentOrganizationId() { function getCurrentOrganizationId() {
@@ -31,7 +38,13 @@ function getCurrentRole() {
} }
function getUserTimezone() { function getUserTimezone() {
return page.props.auth.user.timezone; const timezone = window?.getTimezoneSetting() as string;
if (!timezone) {
throw new Error(
'Please make sure to provide the current user timezone as a vue inject (timezone)'
);
}
return timezone;
} }
export { export {