add mass updates to time view

This commit is contained in:
Gregor Vostrak
2024-10-08 21:28:23 +02:00
parent d5a4df738f
commit f147fb9725
8 changed files with 184 additions and 78 deletions

View File

@@ -365,3 +365,5 @@ test.skip('test that load more works when the end of page is reached', async ({
// TODO: Add Test for Date Update // TODO: Add Test for Date Update
// TODO: Test that project can be created in the time entry row // TODO: Test that project can be created in the time entry row
// TODO: Add Tests for Mass Update

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import { PencilSquareIcon, TrashIcon } from '@heroicons/vue/20/solid';
import TimeEntryMassUpdateModal from '@/Components/Common/TimeEntry/TimeEntryMassUpdateModal.vue';
import type { TimeEntry } from '@/packages/api/src';
import { ref } from 'vue';
import { twMerge } from 'tailwind-merge';
const props = defineProps<{
selectedTimeEntries: TimeEntry[];
deleteSelected: () => void;
class?: string;
}>();
const emit = defineEmits<{
submit: [];
}>();
const showMassUpdateModal = ref(false);
</script>
<template>
<TimeEntryMassUpdateModal
:time-entries="selectedTimeEntries"
@submit="emit('submit')"
v-model:show="showMassUpdateModal"></TimeEntryMassUpdateModal>
<MainContainer
v-if="selectedTimeEntries.length > 0"
:class="
twMerge(
props.class,
'text-sm py-1.5 font-medium border-b border-t border-border-primary flex items-center space-x-3'
)
">
<div>{{ selectedTimeEntries.length }} selected</div>
<button
class="text-text-tertiary flex space-x-1 items-center hover:text-text-secondary transition focus-visible:ring-2 outline-0 focus-visible:text-text-primary focus-visible:ring-white/80 rounded h-full px-2"
@click="showMassUpdateModal = true"
v-if="selectedTimeEntries.length">
<PencilSquareIcon class="w-4"></PencilSquareIcon>
<span> Edit </span>
</button>
<button
class="text-red-400 h-full px-2 space-x-1 items-center flex hover:text-red-500 transition focus-visible:ring-2 outline-0 focus-visible:text-red-500 focus-visible:ring-white/80 rounded"
@click="deleteSelected"
v-if="selectedTimeEntries.length">
<TrashIcon class="w-3.5"></TrashIcon>
<span> Delete </span>
</button>
</MainContainer>
</template>
<style scoped></style>

View File

@@ -57,25 +57,23 @@ async function createClient(
return await useClientsStore().createClient(body); return await useClientsStore().createClient(body);
} }
const description = ref<HTMLInputElement | null>(null); const descriptionInput = ref<HTMLInputElement | null>(null);
const { handleApiRequestNotifications } = useNotificationsStore(); const { handleApiRequestNotifications } = useNotificationsStore();
watch(show, (value) => { watch(show, (value) => {
if (value) { if (value) {
nextTick(() => { nextTick(() => {
description.value?.focus(); descriptionInput.value?.focus();
}); });
} }
}); });
const timeEntryUpdates = ref({ const description = ref<string>('');
description: '', const taskId = ref<string | null | undefined>(undefined);
project_id: null, const projectId = ref<string | null | undefined>(undefined);
task_id: null, const billable = ref<boolean | undefined>(undefined);
tags: [] as string[], const selectedTags = ref<string[]>([]);
billable: null as boolean | null,
});
const { tags } = storeToRefs(useTagsStore()); const { tags } = storeToRefs(useTagsStore());
async function createTag(tag: string) { async function createTag(tag: string) {
@@ -84,46 +82,46 @@ async function createTag(tag: string) {
const timeEntryBillable = computed({ const timeEntryBillable = computed({
get: () => { get: () => {
if (timeEntryUpdates.value.billable === null) { if (billable.value) {
return 'do-not-update'; return 'do-not-update';
} }
return timeEntryUpdates.value.billable ? 'billable' : 'non-billable'; return billable.value ? 'billable' : 'non-billable';
}, },
set: (value) => { set: (value) => {
if (value === 'do-not-update') { if (value === 'do-not-update') {
timeEntryUpdates.value.billable = null; billable.value = undefined;
} else if (value === 'billable') { } else if (value === 'billable') {
timeEntryUpdates.value.billable = true; billable.value = true;
} else { } else {
timeEntryUpdates.value.billable = false; billable.value = false;
} }
}, },
}); });
function submit() { async function submit() {
const organizationId = getCurrentOrganizationId(); const organizationId = getCurrentOrganizationId();
saving.value = true; saving.value = true;
if (organizationId) { if (organizationId) {
const timeEntryUpdatesBody = {} as UpdateMultipleTimeEntriesChangeset; const timeEntryUpdatesBody = {} as UpdateMultipleTimeEntriesChangeset;
if (timeEntryUpdates.value.description !== '') { if (description.value && description.value !== '') {
timeEntryUpdatesBody.description = timeEntryUpdatesBody.description = description.value;
timeEntryUpdates.value.description;
} }
if (timeEntryUpdates.value.project_id) { if (projectId.value !== undefined) {
timeEntryUpdatesBody.project_id = timeEntryUpdates.value.project_id; timeEntryUpdatesBody.project_id = projectId.value;
timeEntryUpdatesBody.task_id = null;
} }
if (timeEntryUpdates.value.task_id) { if (taskId.value !== undefined) {
timeEntryUpdatesBody.task_id = timeEntryUpdates.value.task_id; timeEntryUpdatesBody.task_id = taskId.value;
} }
if (timeEntryUpdates.value.billable !== null) { if (billable.value !== undefined) {
timeEntryUpdatesBody.billable = timeEntryUpdates.value.billable; timeEntryUpdatesBody.billable = billable.value;
} }
if (timeEntryUpdates.value.tags.length > 0) { if (selectedTags.value.length > 0) {
timeEntryUpdatesBody.tags = timeEntryUpdates.value.tags; timeEntryUpdatesBody.tags = selectedTags.value;
} }
try { try {
handleApiRequestNotifications( await handleApiRequestNotifications(
() => () =>
api.updateMultipleTimeEntries( api.updateMultipleTimeEntries(
{ {
@@ -145,13 +143,11 @@ function submit() {
() => { () => {
show.value = false; show.value = false;
emit('submit'); emit('submit');
timeEntryUpdates.value = { description.value = '';
description: '', projectId.value = undefined;
project_id: null, taskId.value = undefined;
task_id: null, selectedTags.value = [];
tags: [], billable.value = undefined;
billable: null,
};
saving.value = false; saving.value = false;
} }
); );
@@ -176,8 +172,8 @@ function submit() {
<InputLabel for="description" value="Description" /> <InputLabel for="description" value="Description" />
<TextInput <TextInput
id="description" id="description"
ref="description" ref="descriptionInput"
v-model="timeEntryUpdates.description" v-model="description"
@keydown.enter="submit" @keydown.enter="submit"
type="text" type="text"
class="mt-1 block w-full" /> class="mt-1 block w-full" />
@@ -193,21 +189,16 @@ function submit() {
size="xlarge" size="xlarge"
:projects="projects" :projects="projects"
:tasks="tasks" :tasks="tasks"
v-model:project="timeEntryUpdates.project_id" v-model:project="projectId"
v-model:task=" v-model:task="taskId"></TimeTrackerProjectTaskDropdown>
timeEntryUpdates.task_id
"></TimeTrackerProjectTaskDropdown>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<InputLabel for="project" value="Tag" /> <InputLabel for="project" value="Tag" />
<TagDropdown <TagDropdown :createTag v-model="selectedTags" :tags="tags">
:createTag
v-model="timeEntryUpdates.tags"
:tags="tags">
<template #trigger> <template #trigger>
<Badge size="xlarge"> <Badge size="xlarge">
<span v-if="timeEntryUpdates.tags.length > 0"> <span v-if="selectedTags.length > 0">
Set {{ timeEntryUpdates.tags.length }} tags Set {{ selectedTags.length }} tags
</span> </span>
<span v-else> Select Tags... </span> <span v-else> Select Tags... </span>
</Badge> </Badge>
@@ -236,13 +227,10 @@ function submit() {
]"> ]">
<template v-slot:trigger> <template v-slot:trigger>
<Badge tag="button" size="xlarge"> <Badge tag="button" size="xlarge">
<span v-if="timeEntryUpdates.billable === null"> <span v-if="billable === undefined">
Set billable status Set billable status
</span> </span>
<span <span v-else-if="billable === true">
v-else-if="
timeEntryUpdates.billable === true
">
Billable Billable
</span> </span>
<span v-else> Non Billable </span></Badge <span v-else> Non Billable </span></Badge

View File

@@ -12,8 +12,6 @@ import {
ChevronDoubleLeftIcon, ChevronDoubleLeftIcon,
ChevronRightIcon, ChevronRightIcon,
ChevronDoubleRightIcon, ChevronDoubleRightIcon,
PencilSquareIcon,
TrashIcon,
ClockIcon, ClockIcon,
} from '@heroicons/vue/20/solid'; } from '@heroicons/vue/20/solid';
import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue'; import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
@@ -66,7 +64,7 @@ import {
import { useQuery, useQueryClient } from '@tanstack/vue-query'; import { useQuery, useQueryClient } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
import { useTimeEntriesStore } from '@/utils/useTimeEntries'; import { useTimeEntriesStore } from '@/utils/useTimeEntries';
import TimeEntryMassUpdateModal from '@/Components/Common/TimeEntry/TimeEntryMassUpdateModal.vue'; import TimeEntryMassActionRow from '@/Components/Common/TimeEntry/TimeEntryMassActionRow.vue';
const startDate = useSessionStorage<string>( const startDate = useSessionStorage<string>(
'reporting-start-date', 'reporting-start-date',
@@ -210,7 +208,10 @@ function deleteSelected() {
deleteTimeEntries(selectedTimeEntries.value); deleteTimeEntries(selectedTimeEntries.value);
} }
const showMassUpdateModal = ref(false); async function clearSelectionAndState() {
selectedTimeEntries.value = [];
await updateFilteredTimeEntries();
}
</script> </script>
<template> <template>
@@ -335,28 +336,10 @@ const showMassUpdateModal = ref(false);
</div> </div>
</MainContainer> </MainContainer>
</div> </div>
<TimeEntryMassUpdateModal <TimeEntryMassActionRow
:time-entries="selectedTimeEntries" :selected-time-entries="selectedTimeEntries"
@submit="updateFilteredTimeEntries" @submit="clearSelectionAndState"
v-model:show="showMassUpdateModal"></TimeEntryMassUpdateModal> :delete-selected="deleteSelected"></TimeEntryMassActionRow>
<MainContainer
class="text-sm py-1.5 font-medium border-b border-t bg-secondary border-border-tertiary flex items-center space-x-3">
<div>{{ selectedTimeEntries.length }} selected</div>
<button
class="text-text-tertiary flex space-x-1 items-center hover:text-text-secondary transition focus-visible:ring-2 outline-0 focus-visible:text-text-primary focus-visible:ring-white/80 rounded h-full px-2"
@click="showMassUpdateModal = true"
v-if="selectedTimeEntries.length">
<PencilSquareIcon class="w-4"></PencilSquareIcon>
<span> Edit </span>
</button>
<button
class="text-red-400 h-full px-2 space-x-1 items-center flex hover:text-red-500 transition focus-visible:ring-2 outline-0 focus-visible:text-red-500 focus-visible:ring-white/80 rounded"
@click="deleteSelected"
v-if="selectedTimeEntries.length">
<TrashIcon class="w-3.5"></TrashIcon>
<span> Delete </span>
</button>
</MainContainer>
<div class="w-full relative"> <div class="w-full relative">
<div v-for="entry in timeEntries" :key="entry.id"> <div v-for="entry in timeEntries" :key="entry.id">
<TimeEntryRow <TimeEntryRow

View File

@@ -26,6 +26,7 @@ import { useTagsStore } from '@/utils/useTags';
import { useClientsStore } from '@/utils/useClients'; import { useClientsStore } from '@/utils/useClients';
import TimeEntryCreateModal from '@/Components/Common/TimeEntry/TimeEntryCreateModal.vue'; import TimeEntryCreateModal from '@/Components/Common/TimeEntry/TimeEntryCreateModal.vue';
import { getOrganizationCurrencyString } from '@/utils/money'; import { getOrganizationCurrencyString } from '@/utils/money';
import TimeEntryMassActionRow from '@/Components/Common/TimeEntry/TimeEntryMassActionRow.vue';
const timeEntriesStore = useTimeEntriesStore(); const timeEntriesStore = useTimeEntriesStore();
const { timeEntries, allTimeEntriesLoaded } = storeToRefs(timeEntriesStore); const { timeEntries, allTimeEntriesLoaded } = storeToRefs(timeEntriesStore);
@@ -97,6 +98,17 @@ async function createClient(
): Promise<Client | undefined> { ): Promise<Client | undefined> {
return await useClientsStore().createClient(body); return await useClientsStore().createClient(body);
} }
const selectedTimeEntries = ref([] as TimeEntry[]);
async function clearSelectionAndState() {
selectedTimeEntries.value = [];
await fetchTimeEntries();
}
function deleteSelected() {
deleteTimeEntries(selectedTimeEntries.value);
}
</script> </script>
<template> <template>
@@ -120,7 +132,12 @@ async function createClient(
</div> </div>
</div> </div>
</MainContainer> </MainContainer>
<TimeEntryMassActionRow
:selected-time-entries="selectedTimeEntries"
@submit="clearSelectionAndState"
:delete-selected="deleteSelected"></TimeEntryMassActionRow>
<TimeEntryGroupedTable <TimeEntryGroupedTable
v-model:selected="selectedTimeEntries"
:createProject :createProject
:clients :clients
:createClient :createClient

View File

@@ -38,6 +38,11 @@ const props = defineProps<{
updateTimeEntry: (timeEntry: TimeEntry) => void; updateTimeEntry: (timeEntry: TimeEntry) => void;
deleteTimeEntries: (timeEntries: TimeEntry[]) => void; deleteTimeEntries: (timeEntries: TimeEntry[]) => void;
currency: string; currency: string;
selectedTimeEntries: TimeEntry[];
}>();
const emit = defineEmits<{
selected: [TimeEntry[]];
unselected: [TimeEntry[]];
}>(); }>();
function updateTimeEntryDescription(description: string) { function updateTimeEntryDescription(description: string) {
@@ -69,6 +74,14 @@ function updateProjectAndTask(projectId: string, taskId: string) {
} }
const expanded = ref(false); const expanded = ref(false);
function onSelectChange(event: Event) {
const target = event.target as HTMLInputElement;
if (target.checked) {
emit('selected', [...props.timeEntry.timeEntries]);
} else {
emit('unselected', [...props.timeEntry.timeEntries]);
}
}
</script> </script>
<template> <template>
@@ -80,6 +93,15 @@ const expanded = ref(false);
class="sm:flex py-1.5 items-center min-w-0 justify-between group"> class="sm:flex py-1.5 items-center min-w-0 justify-between group">
<div class="flex space-x-3 items-center min-w-0"> <div class="flex space-x-3 items-center min-w-0">
<input <input
@change="onSelectChange"
:checked="
timeEntry.timeEntries.every(
(aggregateTimeEntry: TimeEntry) =>
selectedTimeEntries.includes(
aggregateTimeEntry
)
)
"
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="flex items-center min-w-0"> <div class="flex items-center min-w-0">
@@ -153,6 +175,13 @@ const expanded = ref(false);
<TimeEntryRow <TimeEntryRow
:projects="projects" :projects="projects"
:tasks="tasks" :tasks="tasks"
:selected="
!!selectedTimeEntries.find(
(filterEntry) => filterEntry.id === subEntry.id
)
"
@selected="emit('selected', [subEntry])"
@unselected="emit('unselected', [subEntry])"
:createClient :createClient
:clients :clients
:createProject :createProject

View File

@@ -19,6 +19,10 @@ import TimeEntryRowHeading from '@/packages/ui/src/TimeEntry/TimeEntryRowHeading
import TimeEntryRow from '@/packages/ui/src/TimeEntry/TimeEntryRow.vue'; import TimeEntryRow from '@/packages/ui/src/TimeEntry/TimeEntryRow.vue';
import type { TimeEntriesGroupedByType } from '@/types/time-entries'; import type { TimeEntriesGroupedByType } from '@/types/time-entries';
const selectedTimeEntries = defineModel<TimeEntry[]>('selected', {
default: [],
});
const props = defineProps<{ const props = defineProps<{
timeEntries: TimeEntry[]; timeEntries: TimeEntry[];
projects: Project[]; projects: Project[];
@@ -124,6 +128,25 @@ function sumDuration(timeEntries: TimeEntry[]) {
<template v-for="entry in value" :key="entry.id"> <template v-for="entry in value" :key="entry.id">
<TimeEntryAggregateRow <TimeEntryAggregateRow
:createProject :createProject
:selected-time-entries="selectedTimeEntries"
@selected="
(timeEntries) => {
selectedTimeEntries = [
...selectedTimeEntries,
...timeEntries,
];
}
"
@unselected="
(timeEntriesToUnselect) => {
selectedTimeEntries = selectedTimeEntries.filter(
(item) =>
!timeEntriesToUnselect.find(
(filterEntry) => filterEntry.id === item.id
)
);
}
"
:createClient :createClient
:projects="projects" :projects="projects"
:tasks="tasks" :tasks="tasks"
@@ -141,6 +164,17 @@ function sumDuration(timeEntries: TimeEntry[]) {
:createClient :createClient
:createProject :createProject
:projects="projects" :projects="projects"
:selected="
!!selectedTimeEntries.find(
(filterEntry) => filterEntry.id === entry.id
)
"
@selected="selectedTimeEntries.push(entry)"
@unselected="
selectedTimeEntries = selectedTimeEntries.filter(
(item) => item.id !== entry.id
)
"
:tasks="tasks" :tasks="tasks"
:tags="tags" :tags="tags"
:clients :clients

View File

@@ -98,7 +98,7 @@ function onSelectChange(event: Event) {
<div class="flex space-x-1 items-center min-w-0"> <div class="flex space-x-1 items-center min-w-0">
<input <input
@change="onSelectChange" @change="onSelectChange"
:value="selected" :checked="selected"
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>