mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
add mass updates to time view
This commit is contained in:
@@ -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: Test that project can be created in the time entry row
|
||||
|
||||
// TODO: Add Tests for Mass Update
|
||||
|
||||
@@ -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>
|
||||
@@ -57,25 +57,23 @@ async function createClient(
|
||||
return await useClientsStore().createClient(body);
|
||||
}
|
||||
|
||||
const description = ref<HTMLInputElement | null>(null);
|
||||
const descriptionInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const { handleApiRequestNotifications } = useNotificationsStore();
|
||||
|
||||
watch(show, (value) => {
|
||||
if (value) {
|
||||
nextTick(() => {
|
||||
description.value?.focus();
|
||||
descriptionInput.value?.focus();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const timeEntryUpdates = ref({
|
||||
description: '',
|
||||
project_id: null,
|
||||
task_id: null,
|
||||
tags: [] as string[],
|
||||
billable: null as boolean | null,
|
||||
});
|
||||
const description = ref<string>('');
|
||||
const taskId = ref<string | null | undefined>(undefined);
|
||||
const projectId = ref<string | null | undefined>(undefined);
|
||||
const billable = ref<boolean | undefined>(undefined);
|
||||
const selectedTags = ref<string[]>([]);
|
||||
|
||||
const { tags } = storeToRefs(useTagsStore());
|
||||
async function createTag(tag: string) {
|
||||
@@ -84,46 +82,46 @@ async function createTag(tag: string) {
|
||||
|
||||
const timeEntryBillable = computed({
|
||||
get: () => {
|
||||
if (timeEntryUpdates.value.billable === null) {
|
||||
if (billable.value) {
|
||||
return 'do-not-update';
|
||||
}
|
||||
return timeEntryUpdates.value.billable ? 'billable' : 'non-billable';
|
||||
return billable.value ? 'billable' : 'non-billable';
|
||||
},
|
||||
set: (value) => {
|
||||
if (value === 'do-not-update') {
|
||||
timeEntryUpdates.value.billable = null;
|
||||
billable.value = undefined;
|
||||
} else if (value === 'billable') {
|
||||
timeEntryUpdates.value.billable = true;
|
||||
billable.value = true;
|
||||
} else {
|
||||
timeEntryUpdates.value.billable = false;
|
||||
billable.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function submit() {
|
||||
async function submit() {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
saving.value = true;
|
||||
if (organizationId) {
|
||||
const timeEntryUpdatesBody = {} as UpdateMultipleTimeEntriesChangeset;
|
||||
if (timeEntryUpdates.value.description !== '') {
|
||||
timeEntryUpdatesBody.description =
|
||||
timeEntryUpdates.value.description;
|
||||
if (description.value && description.value !== '') {
|
||||
timeEntryUpdatesBody.description = description.value;
|
||||
}
|
||||
if (timeEntryUpdates.value.project_id) {
|
||||
timeEntryUpdatesBody.project_id = timeEntryUpdates.value.project_id;
|
||||
if (projectId.value !== undefined) {
|
||||
timeEntryUpdatesBody.project_id = projectId.value;
|
||||
timeEntryUpdatesBody.task_id = null;
|
||||
}
|
||||
if (timeEntryUpdates.value.task_id) {
|
||||
timeEntryUpdatesBody.task_id = timeEntryUpdates.value.task_id;
|
||||
if (taskId.value !== undefined) {
|
||||
timeEntryUpdatesBody.task_id = taskId.value;
|
||||
}
|
||||
if (timeEntryUpdates.value.billable !== null) {
|
||||
timeEntryUpdatesBody.billable = timeEntryUpdates.value.billable;
|
||||
if (billable.value !== undefined) {
|
||||
timeEntryUpdatesBody.billable = billable.value;
|
||||
}
|
||||
if (timeEntryUpdates.value.tags.length > 0) {
|
||||
timeEntryUpdatesBody.tags = timeEntryUpdates.value.tags;
|
||||
if (selectedTags.value.length > 0) {
|
||||
timeEntryUpdatesBody.tags = selectedTags.value;
|
||||
}
|
||||
|
||||
try {
|
||||
handleApiRequestNotifications(
|
||||
await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.updateMultipleTimeEntries(
|
||||
{
|
||||
@@ -145,13 +143,11 @@ function submit() {
|
||||
() => {
|
||||
show.value = false;
|
||||
emit('submit');
|
||||
timeEntryUpdates.value = {
|
||||
description: '',
|
||||
project_id: null,
|
||||
task_id: null,
|
||||
tags: [],
|
||||
billable: null,
|
||||
};
|
||||
description.value = '';
|
||||
projectId.value = undefined;
|
||||
taskId.value = undefined;
|
||||
selectedTags.value = [];
|
||||
billable.value = undefined;
|
||||
saving.value = false;
|
||||
}
|
||||
);
|
||||
@@ -176,8 +172,8 @@ function submit() {
|
||||
<InputLabel for="description" value="Description" />
|
||||
<TextInput
|
||||
id="description"
|
||||
ref="description"
|
||||
v-model="timeEntryUpdates.description"
|
||||
ref="descriptionInput"
|
||||
v-model="description"
|
||||
@keydown.enter="submit"
|
||||
type="text"
|
||||
class="mt-1 block w-full" />
|
||||
@@ -193,21 +189,16 @@ function submit() {
|
||||
size="xlarge"
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
v-model:project="timeEntryUpdates.project_id"
|
||||
v-model:task="
|
||||
timeEntryUpdates.task_id
|
||||
"></TimeTrackerProjectTaskDropdown>
|
||||
v-model:project="projectId"
|
||||
v-model:task="taskId"></TimeTrackerProjectTaskDropdown>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<InputLabel for="project" value="Tag" />
|
||||
<TagDropdown
|
||||
:createTag
|
||||
v-model="timeEntryUpdates.tags"
|
||||
:tags="tags">
|
||||
<TagDropdown :createTag v-model="selectedTags" :tags="tags">
|
||||
<template #trigger>
|
||||
<Badge size="xlarge">
|
||||
<span v-if="timeEntryUpdates.tags.length > 0">
|
||||
Set {{ timeEntryUpdates.tags.length }} tags
|
||||
<span v-if="selectedTags.length > 0">
|
||||
Set {{ selectedTags.length }} tags
|
||||
</span>
|
||||
<span v-else> Select Tags... </span>
|
||||
</Badge>
|
||||
@@ -236,13 +227,10 @@ function submit() {
|
||||
]">
|
||||
<template v-slot:trigger>
|
||||
<Badge tag="button" size="xlarge">
|
||||
<span v-if="timeEntryUpdates.billable === null">
|
||||
<span v-if="billable === undefined">
|
||||
Set billable status
|
||||
</span>
|
||||
<span
|
||||
v-else-if="
|
||||
timeEntryUpdates.billable === true
|
||||
">
|
||||
<span v-else-if="billable === true">
|
||||
Billable
|
||||
</span>
|
||||
<span v-else> Non Billable </span></Badge
|
||||
|
||||
@@ -12,8 +12,6 @@ import {
|
||||
ChevronDoubleLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronDoubleRightIcon,
|
||||
PencilSquareIcon,
|
||||
TrashIcon,
|
||||
ClockIcon,
|
||||
} from '@heroicons/vue/20/solid';
|
||||
import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
|
||||
@@ -66,7 +64,7 @@ import {
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
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>(
|
||||
'reporting-start-date',
|
||||
@@ -210,7 +208,10 @@ function deleteSelected() {
|
||||
deleteTimeEntries(selectedTimeEntries.value);
|
||||
}
|
||||
|
||||
const showMassUpdateModal = ref(false);
|
||||
async function clearSelectionAndState() {
|
||||
selectedTimeEntries.value = [];
|
||||
await updateFilteredTimeEntries();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -335,28 +336,10 @@ const showMassUpdateModal = ref(false);
|
||||
</div>
|
||||
</MainContainer>
|
||||
</div>
|
||||
<TimeEntryMassUpdateModal
|
||||
:time-entries="selectedTimeEntries"
|
||||
@submit="updateFilteredTimeEntries"
|
||||
v-model:show="showMassUpdateModal"></TimeEntryMassUpdateModal>
|
||||
<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>
|
||||
<TimeEntryMassActionRow
|
||||
:selected-time-entries="selectedTimeEntries"
|
||||
@submit="clearSelectionAndState"
|
||||
:delete-selected="deleteSelected"></TimeEntryMassActionRow>
|
||||
<div class="w-full relative">
|
||||
<div v-for="entry in timeEntries" :key="entry.id">
|
||||
<TimeEntryRow
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useTagsStore } from '@/utils/useTags';
|
||||
import { useClientsStore } from '@/utils/useClients';
|
||||
import TimeEntryCreateModal from '@/Components/Common/TimeEntry/TimeEntryCreateModal.vue';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import TimeEntryMassActionRow from '@/Components/Common/TimeEntry/TimeEntryMassActionRow.vue';
|
||||
|
||||
const timeEntriesStore = useTimeEntriesStore();
|
||||
const { timeEntries, allTimeEntriesLoaded } = storeToRefs(timeEntriesStore);
|
||||
@@ -97,6 +98,17 @@ async function createClient(
|
||||
): Promise<Client | undefined> {
|
||||
return await useClientsStore().createClient(body);
|
||||
}
|
||||
|
||||
const selectedTimeEntries = ref([] as TimeEntry[]);
|
||||
|
||||
async function clearSelectionAndState() {
|
||||
selectedTimeEntries.value = [];
|
||||
await fetchTimeEntries();
|
||||
}
|
||||
|
||||
function deleteSelected() {
|
||||
deleteTimeEntries(selectedTimeEntries.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -120,7 +132,12 @@ async function createClient(
|
||||
</div>
|
||||
</div>
|
||||
</MainContainer>
|
||||
<TimeEntryMassActionRow
|
||||
:selected-time-entries="selectedTimeEntries"
|
||||
@submit="clearSelectionAndState"
|
||||
:delete-selected="deleteSelected"></TimeEntryMassActionRow>
|
||||
<TimeEntryGroupedTable
|
||||
v-model:selected="selectedTimeEntries"
|
||||
:createProject
|
||||
:clients
|
||||
:createClient
|
||||
|
||||
@@ -38,6 +38,11 @@ const props = defineProps<{
|
||||
updateTimeEntry: (timeEntry: TimeEntry) => void;
|
||||
deleteTimeEntries: (timeEntries: TimeEntry[]) => void;
|
||||
currency: string;
|
||||
selectedTimeEntries: TimeEntry[];
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
selected: [TimeEntry[]];
|
||||
unselected: [TimeEntry[]];
|
||||
}>();
|
||||
|
||||
function updateTimeEntryDescription(description: string) {
|
||||
@@ -69,6 +74,14 @@ function updateProjectAndTask(projectId: string, taskId: string) {
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -80,6 +93,15 @@ const expanded = ref(false);
|
||||
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">
|
||||
<input
|
||||
@change="onSelectChange"
|
||||
:checked="
|
||||
timeEntry.timeEntries.every(
|
||||
(aggregateTimeEntry: TimeEntry) =>
|
||||
selectedTimeEntries.includes(
|
||||
aggregateTimeEntry
|
||||
)
|
||||
)
|
||||
"
|
||||
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="flex items-center min-w-0">
|
||||
@@ -153,6 +175,13 @@ const expanded = ref(false);
|
||||
<TimeEntryRow
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:selected="
|
||||
!!selectedTimeEntries.find(
|
||||
(filterEntry) => filterEntry.id === subEntry.id
|
||||
)
|
||||
"
|
||||
@selected="emit('selected', [subEntry])"
|
||||
@unselected="emit('unselected', [subEntry])"
|
||||
:createClient
|
||||
:clients
|
||||
:createProject
|
||||
|
||||
@@ -19,6 +19,10 @@ import TimeEntryRowHeading from '@/packages/ui/src/TimeEntry/TimeEntryRowHeading
|
||||
import TimeEntryRow from '@/packages/ui/src/TimeEntry/TimeEntryRow.vue';
|
||||
import type { TimeEntriesGroupedByType } from '@/types/time-entries';
|
||||
|
||||
const selectedTimeEntries = defineModel<TimeEntry[]>('selected', {
|
||||
default: [],
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
timeEntries: TimeEntry[];
|
||||
projects: Project[];
|
||||
@@ -124,6 +128,25 @@ function sumDuration(timeEntries: TimeEntry[]) {
|
||||
<template v-for="entry in value" :key="entry.id">
|
||||
<TimeEntryAggregateRow
|
||||
:createProject
|
||||
:selected-time-entries="selectedTimeEntries"
|
||||
@selected="
|
||||
(timeEntries) => {
|
||||
selectedTimeEntries = [
|
||||
...selectedTimeEntries,
|
||||
...timeEntries,
|
||||
];
|
||||
}
|
||||
"
|
||||
@unselected="
|
||||
(timeEntriesToUnselect) => {
|
||||
selectedTimeEntries = selectedTimeEntries.filter(
|
||||
(item) =>
|
||||
!timeEntriesToUnselect.find(
|
||||
(filterEntry) => filterEntry.id === item.id
|
||||
)
|
||||
);
|
||||
}
|
||||
"
|
||||
:createClient
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
@@ -141,6 +164,17 @@ function sumDuration(timeEntries: TimeEntry[]) {
|
||||
:createClient
|
||||
:createProject
|
||||
: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"
|
||||
:tags="tags"
|
||||
:clients
|
||||
|
||||
@@ -98,7 +98,7 @@ function onSelectChange(event: Event) {
|
||||
<div class="flex space-x-1 items-center min-w-0">
|
||||
<input
|
||||
@change="onSelectChange"
|
||||
:value="selected"
|
||||
:checked="selected"
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user