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();
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([
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
);
}),
page.getByTestId('tag_dropdown_search').press('Enter'),
page.getByRole('button', { name: 'Create Tag' }).click(),
]);
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(page.getByRole('option', { name: newTagName })).toBeVisible();
await expect(newTimeEntry.getByText(newTagName)).toBeVisible();
});
// 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 }) => {
const newTagName = 'New Tag' + Math.floor(Math.random() * 10000);
await goToDashboard(page);
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([
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();
});
@@ -249,14 +251,16 @@ test('test that adding a new tag when the timer is running', async ({
]);
await assertThatTimerHasStarted(page);
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([
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;
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 page.getByTestId('tag_dropdown_search').press('Escape');
await page.waitForTimeout(1000);

View File

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

View File

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

View File

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

View File

@@ -2,13 +2,13 @@
import { PlusCircleIcon } from '@heroicons/vue/20/solid';
import Dropdown from '@/Components/Dropdown.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 MultiselectDropdownItem from '@/Components/Common/MultiselectDropdownItem.vue';
import type { Tag } from '@/utils/api';
const tagsStore = useTagsStore();
const { tags } = storeToRefs(tagsStore);
const props = defineProps<{
tags: Tag[];
}>();
const model = defineModel<string[]>({
default: [],
@@ -33,6 +33,8 @@ function addOrRemoveTagFromSelection(id: string) {
emit('changed');
}
const sortedTags = ref(props.tags);
watch(open, (isOpen) => {
if (isOpen) {
nextTick(() => {
@@ -40,7 +42,7 @@ watch(open, (isOpen) => {
});
// sort tags alphabetically
tags.value.sort((a, b) => {
sortedTags.value = [...props.tags].sort((a, b) => {
const aIsSelected = model.value.includes(a.id);
const bIsSelected = model.value.includes(b.id);
if (aIsSelected === bIsSelected) {
@@ -57,24 +59,26 @@ watch(open, (isOpen) => {
});
const filteredTags = computed(() => {
return tags.value.filter((tag) => {
return sortedTags.value.filter((tag) => {
return tag.name
.toLowerCase()
.includes(searchValue.value?.toLowerCase()?.trim() || '');
});
});
async function addTagIfNoneExists() {
if (searchValue.value.length > 0 && filteredTags.value.length === 0) {
const newTag = await tagsStore.createTag(searchValue.value);
function createTag(name: string, callback: (tag: Tag) => void) {
emit('createTag', name, (newTag: Tag) => {
if (newTag) {
addOrRemoveTagFromSelection(newTag.id);
}
searchValue.value = '';
} else {
if (highlightedItemId.value) {
addOrRemoveTagFromSelection(highlightedItemId.value);
}
callback(newTag);
});
}
async function addTagIfNoneExists() {
if (highlightedItemId.value) {
addOrRemoveTagFromSelection(highlightedItemId.value);
}
}
@@ -90,7 +94,7 @@ function updateSearchValue(event: Event) {
searchValue.value = '';
const highlightedTagId = highlightedItemId.value;
if (highlightedTagId) {
const highlightedTag = tags.value.find(
const highlightedTag = props.tags.find(
(tag) => tag.id === highlightedTagId
);
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) {
if (model.value.includes(newValue)) {
@@ -144,14 +152,16 @@ function moveHighlightDown() {
const highlightedItemId = ref<string | null>(null);
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);
</script>
<template>
<TagCreateModal v-model:show="showCreateTagModal"></TagCreateModal>
<TagCreateModal
@createTag="createTag"
v-model:show="showCreateTagModal"></TagCreateModal>
<Dropdown
@submit="emit('submit')"
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"
placeholder="Search for a Tag..." />
<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
v-for="tag in filteredTags"
:key="tag.id"

View File

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

View File

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

View File

@@ -25,7 +25,7 @@ const displaysPlaceholder = computed(() => {
<template>
<div>
<div class="relative text-sm font-medium p">
<div class="relative text-sm font-medium">
<div
:class="[
'opacity-0 py-2 text-base whitespace-pre pl-3 pr-1',
@@ -40,7 +40,7 @@ const displaysPlaceholder = computed(() => {
@input="onInput"
@keydown.enter="onChange"
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>
</template>

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ defineProps<{
<template>
<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>
<DaySectionHeader :date></DaySectionHeader>
</MainContainer>

View File

@@ -3,23 +3,30 @@ import TagDropdown from '@/Components/Common/Tag/TagDropdown.vue';
import { computed } from 'vue';
import TagBadge from '@/Components/Common/Tag/TagBadge.vue';
import type { Tag } from '@/utils/api';
import { useTagsStore } from '@/utils/useTags';
import { storeToRefs } from 'pinia';
const tagsStore = useTagsStore();
const { tags } = storeToRefs(tagsStore);
const emit = defineEmits(['changed']);
const props = defineProps<{
tags: Tag[];
}>();
const emit = defineEmits<{
createTag: [name: string, callback: (tag: Tag) => void];
changed: [model: string[]];
}>();
const model = defineModel<string[]>({
default: [],
});
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>
<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>
<button
data-testid="time_entry_tag_dropdown"

View File

@@ -339,8 +339,8 @@ async function createProject(project: CreateProjectBody, callback: () => void) {
tag="button"
:name="selectedProjectName"
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">
<span>
<div class="flex nowrap items-center space-x-1">
<span class="whitespace-nowrap text-xs lg:text-sm">
{{ selectedProjectName }}
</span>
<ChevronRightIcon

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ defineProps<{
<Dropdown align="bottom-end">
<template #trigger>
<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">
<svg
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 { useProjectsStore } from '@/utils/useProjects';
import { useTasksStore } from '@/utils/useTasks';
import { useTagsStore } from '@/utils/useTags';
import type { Tag } from '@/utils/api';
const page = usePage<{
auth: {
@@ -103,6 +105,14 @@ function switchToTimeEntryOrganization() {
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>
<template>
@@ -150,6 +160,8 @@ function switchToTimeEntryOrganization() {
<div class="flex items-center space-x-2 px-4">
<TimeTrackerTagDropdown
@changed="updateTimeEntry"
@createTag="createTag"
:tags="tags"
v-model="
currentTimeEntry.tags
"></TimeTrackerTagDropdown>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"></script>
<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>
</div>
</template>

View File

@@ -21,7 +21,7 @@ import {
import { type GroupingOption, useReportingStore } from '@/utils/useReporting';
import { storeToRefs } from 'pinia';
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 ProjectMultiselectDropdown from '@/Components/Common/Project/ProjectMultiselectDropdown.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 { getCurrentMembershipId, getCurrentRole } from '@/utils/useUser';
import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue';
import { useTagsStore } from '@/utils/useTags';
const startDate = ref<string | null>(
getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format()
@@ -136,6 +137,14 @@ onMounted(() => {
updateGraphReporting();
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>
<template>
@@ -196,7 +205,9 @@ onMounted(() => {
</ClientMultiselectDropdown>
<TagDropdown
@submit="updateReporting"
v-model="selectedTags">
@createTag="createTag"
v-model="selectedTags"
:tags="tags">
<template v-slot:trigger>
<ReportingFilterBadge
: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 PageTitle from '@/Components/Common/PageTitle.vue';
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>
<template>
@@ -21,10 +29,12 @@ const createTag = ref(false);
<SecondaryButton
v-if="canCreateTags()"
:icon="PlusIcon"
@click="createTag = true"
@click="showCreateTagModal = true"
>Create Tag</SecondaryButton
>
<TagCreateModal v-model:show="createTag"></TagCreateModal>
<TagCreateModal
@createTag="createTag"
v-model:show="showCreateTagModal"></TagCreateModal>
</MainContainer>
<TagTable></TagTable>
</AppLayout>

View File

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

View File

@@ -1,11 +1,12 @@
import './bootstrap';
import '../css/app.css';
import type { DefineComponent } from 'vue';
import { type DefineComponent } 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 { ZiggyVue } from '../../vendor/tightenco/ziggy';
import { createPinia } from 'pinia';
import type { User } from '@/types/models';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
const pinia = createPinia();
@@ -24,6 +25,22 @@ createInertiaApp({
if (window.vueAppSetupHook) {
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);
},

View File

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

View File

@@ -11,7 +11,14 @@ function getCurrentUserId() {
}
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() {
@@ -31,7 +38,13 @@ function getCurrentRole() {
}
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 {