mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02:15 +01:00
refactor tag components and tagCreate events, change global week_start and timezone settings, fix pie charts
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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="
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
2
resources/js/types/global.d.ts
vendored
2
resources/js/types/global.d.ts
vendored
@@ -9,6 +9,8 @@ declare global {
|
||||
axios: AxiosInstance;
|
||||
initialDataLoaded: boolean;
|
||||
vueAppSetupHook?: (app: App) => void;
|
||||
getWeekStartSetting: () => string;
|
||||
getTimezoneSetting: () => string;
|
||||
}
|
||||
|
||||
let route: typeof ziggyRoute;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user