Skip to content

Commit

Permalink
add mass update to detailed reporting page
Browse files Browse the repository at this point in the history
  • Loading branch information
Onatcer committed Oct 2, 2024
1 parent 3fe593b commit 47c6afa
Show file tree
Hide file tree
Showing 15 changed files with 778 additions and 93 deletions.
285 changes: 271 additions & 14 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,18 @@
"@heroicons/vue": "^2.1.1",
"@rushstack/eslint-patch": "^1.7.0",
"@tailwindcss/container-queries": "^0.1.1",
"@tanstack/vue-query": "^5.56.2",
"@tanstack/vue-query-devtools": "^5.58.0",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^13.0.0",
"@vueuse/core": "^10.11.0",
"@vueuse/integrations": "^11.1.0",
"dayjs": "^1.11.11",
"echarts": "^5.5.0",
"focus-trap": "^7.6.0",
"parse-duration": "^1.1.0",
"pinia": "^2.1.7",
"radix-vue": "^1.5.2",
"radix-vue": "^1.9.6",
"tailwind-merge": "^2.2.1",
"vue-echarts": "^6.7.2"
}
Expand Down
268 changes: 268 additions & 0 deletions resources/js/Components/Common/TimeEntry/TimeEntryMassUpdateModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
<script setup lang="ts">
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { computed, nextTick, ref, watch } from 'vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import { storeToRefs } from 'pinia';
import { useTasksStore } from '@/utils/useTasks';
import { useProjectsStore } from '@/utils/useProjects';
import { useTagsStore } from '@/utils/useTags';
import {
type CreateClientBody,
type CreateProjectBody,
type Project,
type Client,
api,
type TimeEntry,
type UpdateMultipleTimeEntriesChangeset,
} from '@/packages/api/src';
import { useClientsStore } from '@/utils/useClients';
import { getOrganizationCurrencyString } from '@/utils/money';
import { Badge } from '@/packages/ui/src';
import SelectDropdown from '../../../packages/ui/src/Input/SelectDropdown.vue';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
const projectStore = useProjectsStore();
const { projects } = storeToRefs(projectStore);
const taskStore = useTasksStore();
const { tasks } = storeToRefs(taskStore);
const clientStore = useClientsStore();
const { clients } = storeToRefs(clientStore);
const show = defineModel('show', { default: false });
const saving = ref(false);
async function createProject(
project: CreateProjectBody
): Promise<Project | undefined> {
return await useProjectsStore().createProject(project);
}
const props = defineProps<{
timeEntries: TimeEntry[];
}>();
const emit = defineEmits<{
submit: [];
}>();
async function createClient(
body: CreateClientBody
): Promise<Client | undefined> {
return await useClientsStore().createClient(body);
}
const description = ref<HTMLInputElement | null>(null);
const { handleApiRequestNotifications } = useNotificationsStore();
watch(show, (value) => {
if (value) {
nextTick(() => {
description.value?.focus();
});
}
});
const timeEntryUpdates = ref({
description: '',
project_id: null,
task_id: null,
tags: [] as string[],
billable: null as boolean | null,
});
const { tags } = storeToRefs(useTagsStore());
async function createTag(tag: string) {
return await useTagsStore().createTag(tag);
}
const timeEntryBillable = computed({
get: () => {
if (timeEntryUpdates.value.billable === null) {
return 'do-not-update';
}
return timeEntryUpdates.value.billable ? 'billable' : 'non-billable';
},
set: (value) => {
if (value === 'do-not-update') {
timeEntryUpdates.value.billable = null;
} else if (value === 'billable') {
timeEntryUpdates.value.billable = true;
} else {
timeEntryUpdates.value.billable = false;
}
},
});
function submit() {
const organizationId = getCurrentOrganizationId();
saving.value = true;
if (organizationId) {
const timeEntryUpdatesBody = {} as UpdateMultipleTimeEntriesChangeset;
if (timeEntryUpdates.value.description !== '') {
timeEntryUpdatesBody.description =
timeEntryUpdates.value.description;
}
if (timeEntryUpdates.value.project_id) {
timeEntryUpdatesBody.project_id = timeEntryUpdates.value.project_id;
}
if (timeEntryUpdates.value.task_id) {
timeEntryUpdatesBody.task_id = timeEntryUpdates.value.task_id;
}
if (timeEntryUpdates.value.billable !== null) {
timeEntryUpdatesBody.billable = timeEntryUpdates.value.billable;
}
if (timeEntryUpdates.value.tags.length > 0) {
timeEntryUpdatesBody.tags = timeEntryUpdates.value.tags;
}
try {
handleApiRequestNotifications(
() =>
api.updateMultipleTimeEntries(
{
ids: props.timeEntries.map(
(timeEntry) => timeEntry.id
),
changes: {
...timeEntryUpdatesBody,
},
},
{
params: {
organization: organizationId,
},
}
),
'Time entries updated',
'Failed to update time entries',
() => {
show.value = false;
emit('submit');
timeEntryUpdates.value = {
description: '',
project_id: null,
task_id: null,
tags: [],
billable: null,
};
saving.value = false;
}
);
} catch (e) {
saving.value = false;
}
}
}
</script>

<template>
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Update {{ timeEntries.length }} time entries </span>
</div>
</template>

<template #content>
<div class="space-y-4">
<div class="space-y-2">
<InputLabel for="description" value="Description" />
<TextInput
id="description"
ref="description"
v-model="timeEntryUpdates.description"
@keydown.enter="submit"
type="text"
class="mt-1 block w-full" />
</div>
<div class="space-y-2">
<InputLabel for="project" value="Project" />
<TimeTrackerProjectTaskDropdown
:clients
:createProject
:createClient
:currency="getOrganizationCurrencyString()"
class="mt-1"
size="xlarge"
:projects="projects"
:tasks="tasks"
v-model:project="timeEntryUpdates.project_id"
v-model:task="
timeEntryUpdates.task_id
"></TimeTrackerProjectTaskDropdown>
</div>
<div class="space-y-2">
<InputLabel for="project" value="Tag" />
<TagDropdown
:createTag
v-model="timeEntryUpdates.tags"
:tags="tags">
<template #trigger>
<Badge size="xlarge">
<span v-if="timeEntryUpdates.tags.length > 0">
Set {{ timeEntryUpdates.tags.length }} tags
</span>
<span v-else> Select Tags... </span>
</Badge>
</template>
</TagDropdown>
</div>
<div class="space-y-2">
<InputLabel for="project" value="Billable" />
<SelectDropdown
v-model="timeEntryBillable"
:get-key-from-item="(item) => item.value"
:get-name-for-item="(item) => item.label"
:items="[
{
label: 'Keep current billable status',
value: 'do-not-update',
},
{
label: 'Billable',
value: 'billable',
},
{
label: 'Non Billable',
value: 'non-billable',
},
]">
<template v-slot:trigger>
<Badge tag="button" size="xlarge">
<span v-if="timeEntryUpdates.billable === null">
Set billable status
</span>
<span
v-else-if="
timeEntryUpdates.billable === true
">
Billable
</span>
<span v-else> Non Billable </span></Badge
>
</template>
</SelectDropdown>
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': saving }"
:disabled="saving"
@click="submit">
Update Time Entries
</PrimaryButton>
</template>
</DialogModal>
</template>

<style scoped></style>
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const { setActiveState } = useCurrentTimeEntryStore();
async function startTaskTimer() {
if (currentTimeEntry.value.id) {
await setActiveState(true);
await setActiveState(false);
}
currentTimeEntry.value.project_id = props.project_id;
currentTimeEntry.value.task_id = props.task_id;
Expand Down
Loading

0 comments on commit 47c6afa

Please sign in to comment.