refactor: extract ReportingFilterBar and migrate reporting to TanStack Query

This commit is contained in:
Gregor Vostrak
2026-02-02 01:03:00 +01:00
parent 756b423295
commit bc562bf76f
6 changed files with 139 additions and 404 deletions

View File

@@ -1,7 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { SecondaryButton } from '@/packages/ui/src'; import { SecondaryButton } from '@/packages/ui/src';
import { ArrowDownTrayIcon, LockClosedIcon } from '@heroicons/vue/20/solid'; import { ArrowDownTrayIcon, LockClosedIcon } from '@heroicons/vue/20/solid';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue'; import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
import type { ExportFormat } from '@/types/reporting'; import type { ExportFormat } from '@/types/reporting';
import { ref } from 'vue'; import { ref } from 'vue';
import { isAllowedToPerformPremiumAction } from '@/utils/billing'; import { isAllowedToPerformPremiumAction } from '@/utils/billing';
@@ -25,32 +30,24 @@ function triggerDownload(format: ExportFormat) {
</script> </script>
<template> <template>
<Dropdown align="end"> <DropdownMenu>
<template #trigger> <DropdownMenuTrigger as-child>
<SecondaryButton :icon="ArrowDownTrayIcon" :loading> Export </SecondaryButton> <SecondaryButton :icon="ArrowDownTrayIcon" :loading> Export </SecondaryButton>
</template> </DropdownMenuTrigger>
<template #content> <DropdownMenuContent align="end">
<div class="flex flex-col space-y-1 p-1.5"> <DropdownMenuItem @click="triggerDownload('pdf')">
<SecondaryButton class="border-0 px-2" @click="triggerDownload('pdf')"> <div class="flex items-center space-x-2">
<div class="flex items-center space-x-2"> <span>Export as PDF</span>
<span> Export as PDF </span> <LockClosedIcon
<LockClosedIcon v-if="!isAllowedToPerformPremiumAction()"
v-if="!isAllowedToPerformPremiumAction()" class="w-3.5 text-text-tertiary" />
class="w-3.5 text-text-tertiary"></LockClosedIcon> </div>
</div> </DropdownMenuItem>
</SecondaryButton> <DropdownMenuItem @click="triggerDownload('xlsx')"> Export as Excel </DropdownMenuItem>
<SecondaryButton class="border-0 px-2" @click="triggerDownload('xlsx')" <DropdownMenuItem @click="triggerDownload('csv')"> Export as CSV </DropdownMenuItem>
>Export as Excel</SecondaryButton <DropdownMenuItem @click="triggerDownload('ods')"> Export as ODS </DropdownMenuItem>
> </DropdownMenuContent>
<SecondaryButton class="border-0 px-2" @click="triggerDownload('csv')" </DropdownMenu>
>Export as CSV</SecondaryButton
>
<SecondaryButton class="border-0 px-2" @click="triggerDownload('ods')"
>Export as ODS
</SecondaryButton>
</div>
</template>
</Dropdown>
<UpgradeModal v-model:show="showPremiumModal"> <UpgradeModal v-model:show="showPremiumModal">
<strong>PDF Reports</strong> are only available in solidtime Professional. <strong>PDF Reports</strong> are only available in solidtime Professional.
</UpgradeModal> </UpgradeModal>

View File

@@ -1,12 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import SelectDropdown from '@/packages/ui/src/Input/SelectDropdown.vue'; import {
import Badge from '@/packages/ui/src/Badge.vue'; Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import { type Component, computed } from 'vue'; import { type Component, computed } from 'vue';
const model = defineModel<string | null>({ default: null }); const model = defineModel<string | null>({ default: null });
const props = defineProps<{ const props = defineProps<{
groupByOptions: { value: string; label: string; icon: Component }[]; groupByOptions: { value: string; label: string; icon: Component }[];
}>(); }>();
const emit = defineEmits<{
changed: [];
}>();
const icon = computed(() => { const icon = computed(() => {
return props.groupByOptions.find((option) => option.value === model.value)?.icon; return props.groupByOptions.find((option) => option.value === model.value)?.icon;
}); });
@@ -16,21 +24,19 @@ const title = computed(() => {
</script> </script>
<template> <template>
<SelectDropdown <Select v-model="model" @update:model-value="emit('changed')">
v-model="model" <SelectTrigger size="small" :show-chevron="false">
:get-key-from-item="(item) => item.value" <SelectValue class="flex items-center gap-2">
:get-name-for-item="(item) => item.label" <component :is="icon" class="h-4 text-icon-default" />
:items="groupByOptions"> <span>{{ title }}</span>
<template #trigger> </SelectValue>
<Badge </SelectTrigger>
size="large" <SelectContent>
tag="button" <SelectItem v-for="option in groupByOptions" :key="option.value" :value="option.value">
class="cursor-pointer hover:bg-card-background transition space-x-5 flex"> {{ option.label }}
<component :is="icon" class="h-4 text-text-secondary"></component> </SelectItem>
<span> {{ title }} </span> </SelectContent>
</Badge> </Select>
</template>
</SelectDropdown>
</template> </template>
<style scoped></style> <style scoped></style>

View File

@@ -1,7 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ChartBarIcon, CheckCircleIcon, TagIcon, UserGroupIcon } from '@heroicons/vue/20/solid'; import { ChartBarIcon } from '@heroicons/vue/20/solid';
import { FolderIcon } from '@heroicons/vue/16/solid';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { getOrganizationCurrencyString } from '@/utils/money'; import { getOrganizationCurrencyString } from '@/utils/money';
import { import {
formatHumanReadableDuration, formatHumanReadableDuration,
@@ -11,43 +9,33 @@ import {
import { formatCents } from '@/packages/ui/src/utils/money'; import { formatCents } from '@/packages/ui/src/utils/money';
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue'; import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
import ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.vue'; import ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.vue';
import ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.vue';
import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';
import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue';
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue'; import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
import MemberMultiselectDropdown from '@/Components/Common/Member/MemberMultiselectDropdown.vue';
import ReportingFilterBadge from '@/Components/Common/Reporting/ReportingFilterBadge.vue';
import PageTitle from '@/Components/Common/PageTitle.vue'; import PageTitle from '@/Components/Common/PageTitle.vue';
import ProjectMultiselectDropdown from '@/Components/Common/Project/ProjectMultiselectDropdown.vue';
import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue'; import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue';
import SelectDropdown from '../../../packages/ui/src/Input/SelectDropdown.vue';
import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue'; import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';
import MainContainer from '@/packages/ui/src/MainContainer.vue'; import MainContainer from '@/packages/ui/src/MainContainer.vue';
import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue'; import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue';
import ReportSaveButton from '@/Components/Common/Report/ReportSaveButton.vue'; import ReportSaveButton from '@/Components/Common/Report/ReportSaveButton.vue';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue'; import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
import ReportingFilterBar from '@/Components/Common/Reporting/ReportingFilterBar.vue';
import { computed, type ComputedRef, inject, onMounted, ref, watch } from 'vue'; import { computed, type ComputedRef, inject, ref, watch } from 'vue';
import { type GroupingOption, useReportingStore } from '@/utils/useReporting'; import { type GroupingOption, useReportingStore } from '@/utils/useReporting';
import { storeToRefs } from 'pinia';
import { import {
type AggregatedTimeEntries,
type AggregatedTimeEntriesQueryParams, type AggregatedTimeEntriesQueryParams,
api, api,
type CreateReportBodyProperties, type CreateReportBodyProperties,
type Organization, type Organization,
} from '@/packages/api/src'; } from '@/packages/api/src';
import { getCurrentMembershipId, getCurrentOrganizationId, getCurrentRole } from '@/utils/useUser'; import { getCurrentMembershipId, getCurrentOrganizationId, getCurrentRole } from '@/utils/useUser';
import { useTagsQuery } from '@/utils/useTagsQuery';
import { useTagsStore } from '@/utils/useTags';
import { useSessionStorage, useStorage } from '@vueuse/core'; import { useSessionStorage, useStorage } from '@vueuse/core';
import { useNotificationsStore } from '@/utils/notification'; import { useNotificationsStore } from '@/utils/notification';
import type { ExportFormat } from '@/types/reporting'; import type { ExportFormat } from '@/types/reporting';
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color'; import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
import { useProjectsQuery } from '@/utils/useProjectsQuery'; import { useProjectsQuery } from '@/utils/useProjectsQuery';
import { useAggregatedTimeEntriesQuery } from '@/utils/useAggregatedTimeEntriesQuery';
// TimeEntryRoundingType is now defined in ReportingRoundingControls component
type TimeEntryRoundingType = 'up' | 'down' | 'nearest'; type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
const { handleApiRequestNotifications } = useNotificationsStore(); const { handleApiRequestNotifications } = useNotificationsStore();
@@ -75,71 +63,26 @@ const group = useStorage<GroupingOption>('reporting-group', 'project');
const subGroup = useStorage<GroupingOption>('reporting-sub-group', 'task'); const subGroup = useStorage<GroupingOption>('reporting-sub-group', 'task');
const reportingStore = useReportingStore(); const reportingStore = useReportingStore();
const { groupByOptions, getNameForReportingRowEntry, emptyPlaceholder } = reportingStore;
const { aggregatedGraphTimeEntries, aggregatedTableTimeEntries } = storeToRefs(reportingStore);
const { groupByOptions } = reportingStore;
const organization = inject<ComputedRef<Organization>>('organization'); const organization = inject<ComputedRef<Organization>>('organization');
// Watch rounding enabled state to trigger updates // Ensure sub-group falls back when it collides with group
watch(roundingEnabled, () => { watch(
updateReporting(); group,
}); () => {
if (group.value === subGroup.value) {
function getFilterAttributes(): AggregatedTimeEntriesQueryParams { const fallbackOption = groupByOptions.find((el) => el.value !== group.value);
let params: AggregatedTimeEntriesQueryParams = { if (fallbackOption?.value) {
start: getLocalizedDayJs(startDate.value).startOf('day').utc().format(), subGroup.value = fallbackOption.value;
end: getLocalizedDayJs(endDate.value).endOf('day').utc().format(), }
};
params = {
...params,
member_ids: selectedMembers.value.length > 0 ? selectedMembers.value : undefined,
project_ids: selectedProjects.value.length > 0 ? selectedProjects.value : undefined,
task_ids: selectedTasks.value.length > 0 ? selectedTasks.value : undefined,
client_ids: selectedClients.value.length > 0 ? selectedClients.value : undefined,
tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,
billable: billable.value !== null ? billable.value : undefined,
member_id: getCurrentRole() === 'employee' ? getCurrentMembershipId() : undefined,
rounding_type: roundingEnabled.value ? roundingType.value : undefined,
rounding_minutes: roundingEnabled.value ? roundingMinutes.value : undefined,
};
return params;
}
function updateGraphReporting() {
const params = getFilterAttributes();
if (getCurrentRole() === 'employee') {
params.member_id = getCurrentMembershipId();
}
params.fill_gaps_in_time_groups = 'true';
params.group = getOptimalGroupingOption(startDate.value, endDate.value);
useReportingStore().fetchGraphReporting(params);
}
function updateTableReporting() {
const params = getFilterAttributes();
if (group.value === subGroup.value) {
const fallbackOption = groupByOptions.find((el) => el.value !== group.value);
if (fallbackOption?.value) {
subGroup.value = fallbackOption.value;
} }
} },
if (getCurrentRole() === 'employee') { { immediate: true }
params.member_id = getCurrentMembershipId(); );
}
params.group = group.value;
params.sub_group = subGroup.value;
useReportingStore().fetchTableReporting(params);
}
function updateReporting() { function getOptimalGroupingOption(start: string, end: string): 'day' | 'week' | 'month' {
updateGraphReporting(); const diffInDays = getDayJsInstance()(end).diff(getDayJsInstance()(start), 'd');
updateTableReporting();
}
function getOptimalGroupingOption(startDate: string, endDate: string): 'day' | 'week' | 'month' {
const diffInDays = getDayJsInstance()(endDate).diff(getDayJsInstance()(startDate), 'd');
if (diffInDays <= 31) { if (diffInDays <= 31) {
return 'day'; return 'day';
@@ -150,20 +93,52 @@ function getOptimalGroupingOption(startDate: string, endDate: string): 'day' | '
} }
} }
onMounted(() => { const filterParams = computed<AggregatedTimeEntriesQueryParams>(() => {
updateGraphReporting(); return {
updateTableReporting(); start: getLocalizedDayJs(startDate.value).startOf('day').utc().format(),
end: getLocalizedDayJs(endDate.value).endOf('day').utc().format(),
member_ids: selectedMembers.value.length > 0 ? selectedMembers.value : undefined,
project_ids: selectedProjects.value.length > 0 ? selectedProjects.value : undefined,
task_ids: selectedTasks.value.length > 0 ? selectedTasks.value : undefined,
client_ids: selectedClients.value.length > 0 ? selectedClients.value : undefined,
tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,
billable: billable.value !== null ? billable.value : undefined,
member_id: getCurrentRole() === 'employee' ? getCurrentMembershipId() : undefined,
rounding_type: roundingEnabled.value ? roundingType.value : undefined,
rounding_minutes: roundingEnabled.value ? roundingMinutes.value : undefined,
};
}); });
const { tags } = useTagsQuery(); const graphQueryParams = computed<AggregatedTimeEntriesQueryParams>(() => {
return {
...filterParams.value,
fill_gaps_in_time_groups: 'true',
group: getOptimalGroupingOption(startDate.value, endDate.value),
};
});
async function createTag(tag: string) { const tableQueryParams = computed<AggregatedTimeEntriesQueryParams>(() => {
return await useTagsStore().createTag(tag); return {
} ...filterParams.value,
group: group.value,
sub_group: subGroup.value,
};
});
const { data: graphResponse } = useAggregatedTimeEntriesQuery('graph', graphQueryParams);
const { data: tableResponse } = useAggregatedTimeEntriesQuery('table', tableQueryParams);
const aggregatedGraphTimeEntries = computed<AggregatedTimeEntries | undefined>(() => {
return graphResponse.value?.data as AggregatedTimeEntries | undefined;
});
const aggregatedTableTimeEntries = computed<AggregatedTimeEntries | undefined>(() => {
return tableResponse.value?.data as AggregatedTimeEntries | undefined;
});
const reportProperties = computed(() => { const reportProperties = computed(() => {
return { return {
...getFilterAttributes(), ...filterParams.value,
group: group.value, group: group.value,
sub_group: subGroup.value, sub_group: subGroup.value,
history_group: getOptimalGroupingOption(startDate.value, endDate.value), history_group: getOptimalGroupingOption(startDate.value, endDate.value),
@@ -180,7 +155,7 @@ async function downloadExport(format: ExportFormat) {
organization: organizationId, organization: organizationId,
}, },
queries: { queries: {
...getFilterAttributes(), ...filterParams.value,
group: group.value, group: group.value,
sub_group: subGroup.value, sub_group: subGroup.value,
history_group: getOptimalGroupingOption(startDate.value, endDate.value), history_group: getOptimalGroupingOption(startDate.value, endDate.value),
@@ -198,8 +173,6 @@ async function downloadExport(format: ExportFormat) {
} }
} }
const { getNameForReportingRowEntry, emptyPlaceholder } = useReportingStore();
const { projects } = useProjectsQuery(); const { projects } = useProjectsQuery();
const showExportModal = ref(false); const showExportModal = ref(false);
const exportUrl = ref<string | null>(null); const exportUrl = ref<string | null>(null);
@@ -272,100 +245,19 @@ const tableData = computed(() => {
<ReportSaveButton :report-properties="reportProperties"></ReportSaveButton> <ReportSaveButton :report-properties="reportProperties"></ReportSaveButton>
</div> </div>
</MainContainer> </MainContainer>
<div class="py-2.5 w-full border-b border-default-background-separator"> <ReportingFilterBar
<MainContainer class="sm:flex space-y-4 sm:space-y-0 justify-between"> v-model:selected-members="selectedMembers"
<div class="flex flex-wrap items-center space-y-2 sm:space-y-0 space-x-3"> v-model:selected-projects="selectedProjects"
<div class="text-sm font-medium">Filters</div> v-model:selected-tasks="selectedTasks"
<MemberMultiselectDropdown v-model="selectedMembers" @submit="updateReporting"> v-model:selected-clients="selectedClients"
<template #trigger> v-model:selected-tags="selectedTags"
<ReportingFilterBadge v-model:billable="billable"
:count="selectedMembers.length" v-model:rounding-enabled="roundingEnabled"
:active="selectedMembers.length > 0" v-model:rounding-type="roundingType"
title="Members" v-model:rounding-minutes="roundingMinutes"
:icon="UserGroupIcon"></ReportingFilterBadge> v-model:start-date="startDate"
</template> v-model:end-date="endDate"
</MemberMultiselectDropdown> />
<ProjectMultiselectDropdown v-model="selectedProjects" @submit="updateReporting">
<template #trigger>
<ReportingFilterBadge
:count="selectedProjects.length"
:active="selectedProjects.length > 0"
title="Projects"
:icon="FolderIcon"></ReportingFilterBadge>
</template>
</ProjectMultiselectDropdown>
<TaskMultiselectDropdown v-model="selectedTasks" @submit="updateReporting">
<template #trigger>
<ReportingFilterBadge
:count="selectedTasks.length"
:active="selectedTasks.length > 0"
title="Tasks"
:icon="CheckCircleIcon"></ReportingFilterBadge>
</template>
</TaskMultiselectDropdown>
<ClientMultiselectDropdown v-model="selectedClients" @submit="updateReporting">
<template #trigger>
<ReportingFilterBadge
:count="selectedClients.length"
:active="selectedClients.length > 0"
title="Clients"
:icon="FolderIcon"></ReportingFilterBadge>
</template>
</ClientMultiselectDropdown>
<TagDropdown
v-model="selectedTags"
:create-tag
:tags="tags"
@submit="updateReporting">
<template #trigger>
<ReportingFilterBadge
:count="selectedTags.length"
:active="selectedTags.length > 0"
title="Tags"
:icon="TagIcon"></ReportingFilterBadge>
</template>
</TagDropdown>
<SelectDropdown
v-model="billable"
:get-key-from-item="(item) => item.value"
:get-name-for-item="(item) => item.label"
:items="[
{
label: 'Both',
value: null,
},
{
label: 'Billable',
value: 'true',
},
{
label: 'Non Billable',
value: 'false',
},
]"
@changed="updateReporting">
<template #trigger>
<ReportingFilterBadge
:active="billable !== null"
:title="billable === 'false' ? 'Non Billable' : 'Billable'"
:icon="BillableIcon"></ReportingFilterBadge>
</template>
</SelectDropdown>
<ReportingRoundingControls
v-model:enabled="roundingEnabled"
v-model:type="roundingType"
v-model:minutes="roundingMinutes"
@change="updateReporting"></ReportingRoundingControls>
</div>
<div>
<DateRangePicker
v-model:start="startDate"
v-model:end="endDate"
@submit="updateReporting"></DateRangePicker>
</div>
</MainContainer>
</div>
<MainContainer> <MainContainer>
<div class="pt-10 w-full px-3 relative"> <div class="pt-10 w-full px-3 relative">
<ReportingChart <ReportingChart
@@ -382,12 +274,12 @@ const tableData = computed(() => {
<ReportingGroupBySelect <ReportingGroupBySelect
v-model="group" v-model="group"
:group-by-options="groupByOptions" :group-by-options="groupByOptions"
@changed="updateTableReporting"></ReportingGroupBySelect> ></ReportingGroupBySelect>
<span>and</span> <span>and</span>
<ReportingGroupBySelect <ReportingGroupBySelect
v-model="subGroup" v-model="subGroup"
:group-by-options="groupByOptions.filter((el) => el.value !== group)" :group-by-options="groupByOptions.filter((el) => el.value !== group)"
@changed="updateTableReporting"></ReportingGroupBySelect> ></ReportingGroupBySelect>
</div> </div>
<div class="grid items-center" style="grid-template-columns: 1fr 100px 150px"> <div class="grid items-center" style="grid-template-columns: 1fr 100px 150px">
<div <div

View File

@@ -22,7 +22,7 @@ import {
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue'; import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
import UserSettingsIcon from '@/Components/UserSettingsIcon.vue'; import UserSettingsIcon from '@/Components/UserSettingsIcon.vue';
import MainContainer from '@/packages/ui/src/MainContainer.vue'; import MainContainer from '@/packages/ui/src/MainContainer.vue';
import { computed, onMounted, provide, ref } from 'vue'; import { onMounted, provide, ref } from 'vue';
import NotificationContainer from '@/Components/NotificationContainer.vue'; import NotificationContainer from '@/Components/NotificationContainer.vue';
import { initializeStores } from '@/utils/init'; import { initializeStores } from '@/utils/init';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry'; import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';

View File

@@ -1,26 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import MainContainer from '@/packages/ui/src/MainContainer.vue'; import MainContainer from '@/packages/ui/src/MainContainer.vue';
import AppLayout from '@/Layouts/AppLayout.vue'; import AppLayout from '@/Layouts/AppLayout.vue';
import { FolderIcon } from '@heroicons/vue/16/solid';
import PageTitle from '@/Components/Common/PageTitle.vue'; import PageTitle from '@/Components/Common/PageTitle.vue';
import { import {
ChartBarIcon, ChartBarIcon,
UserGroupIcon,
CheckCircleIcon,
TagIcon,
ChevronLeftIcon, ChevronLeftIcon,
ChevronDoubleLeftIcon, ChevronDoubleLeftIcon,
ChevronRightIcon, ChevronRightIcon,
ChevronDoubleRightIcon, ChevronDoubleRightIcon,
ClockIcon, ClockIcon,
} from '@heroicons/vue/20/solid'; } from '@heroicons/vue/20/solid';
import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.vue';
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time'; import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import { import {
api, api,
type Client, type Client,
@@ -29,12 +21,6 @@ import {
type Project, type Project,
type TimeEntry, type TimeEntry,
} from '@/packages/api/src'; } from '@/packages/api/src';
import ReportingFilterBadge from '@/Components/Common/Reporting/ReportingFilterBadge.vue';
import ProjectMultiselectDropdown from '@/Components/Common/Project/ProjectMultiselectDropdown.vue';
import MemberMultiselectDropdown from '@/Components/Common/Member/MemberMultiselectDropdown.vue';
import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';
import SelectDropdown from '@/packages/ui/src/Input/SelectDropdown.vue';
import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue';
import { useTagsQuery } from '@/utils/useTagsQuery'; import { useTagsQuery } from '@/utils/useTagsQuery';
import { useTagsStore } from '@/utils/useTags'; import { useTagsStore } from '@/utils/useTags';
import { useSessionStorage } from '@vueuse/core'; import { useSessionStorage } from '@vueuse/core';
@@ -67,6 +53,7 @@ import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassAct
import { isAllowedToPerformPremiumAction } from '@/utils/billing'; import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { canCreateProjects, canViewAllTimeEntries } from '@/utils/permissions'; import { canCreateProjects, canViewAllTimeEntries } from '@/utils/permissions';
import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue'; import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue';
import ReportingFilterBar from '@/Components/Common/Reporting/ReportingFilterBar.vue';
import { useTimeEntriesReportQuery } from '@/utils/useTimeEntriesReportQuery'; import { useTimeEntriesReportQuery } from '@/utils/useTimeEntriesReportQuery';
import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations'; import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
@@ -258,108 +245,19 @@ async function downloadExport(format: ExportFormat) {
<ReportingExportButton :download="downloadExport"></ReportingExportButton> <ReportingExportButton :download="downloadExport"></ReportingExportButton>
</MainContainer> </MainContainer>
<div class="py-2.5 w-full border-b border-default-background-separator"> <ReportingFilterBar
<MainContainer class="sm:flex space-y-4 sm:space-y-0 justify-between"> v-model:selected-members="selectedMembers"
<div class="flex flex-wrap items-center space-y-2 sm:space-y-0 space-x-3"> v-model:selected-projects="selectedProjects"
<div class="text-sm font-medium">Filters</div> v-model:selected-tasks="selectedTasks"
<MemberMultiselectDropdown v-model:selected-clients="selectedClients"
v-model="selectedMembers" v-model:selected-tags="selectedTags"
@submit="updateFilteredTimeEntries"> v-model:billable="billable"
<template #trigger> v-model:rounding-enabled="roundingEnabled"
<ReportingFilterBadge v-model:rounding-type="roundingType"
:count="selectedMembers.length" v-model:rounding-minutes="roundingMinutes"
:active="selectedMembers.length > 0" v-model:start-date="startDate"
title="Members" v-model:end-date="endDate"
:icon="UserGroupIcon"></ReportingFilterBadge> @submit="updateFilteredTimeEntries" />
</template>
</MemberMultiselectDropdown>
<ProjectMultiselectDropdown
v-model="selectedProjects"
@submit="updateFilteredTimeEntries">
<template #trigger>
<ReportingFilterBadge
:count="selectedProjects.length"
:active="selectedProjects.length > 0"
title="Projects"
:icon="FolderIcon"></ReportingFilterBadge>
</template>
</ProjectMultiselectDropdown>
<TaskMultiselectDropdown
v-model="selectedTasks"
@submit="updateFilteredTimeEntries">
<template #trigger>
<ReportingFilterBadge
:count="selectedTasks.length"
:active="selectedTasks.length > 0"
title="Tasks"
:icon="CheckCircleIcon"></ReportingFilterBadge>
</template>
</TaskMultiselectDropdown>
<ClientMultiselectDropdown
v-model="selectedClients"
@submit="updateFilteredTimeEntries">
<template #trigger>
<ReportingFilterBadge
:count="selectedClients.length"
:active="selectedClients.length > 0"
title="Clients"
:icon="FolderIcon"></ReportingFilterBadge>
</template>
</ClientMultiselectDropdown>
<TagDropdown
v-model="selectedTags"
:create-tag
:tags="tags"
@submit="updateFilteredTimeEntries">
<template #trigger>
<ReportingFilterBadge
:count="selectedTags.length"
:active="selectedTags.length > 0"
title="Tags"
:icon="TagIcon"></ReportingFilterBadge>
</template>
</TagDropdown>
<SelectDropdown
v-model="billable"
:get-key-from-item="(item) => item.value"
:get-name-for-item="(item) => item.label"
:items="[
{
label: 'Both',
value: null,
},
{
label: 'Billable',
value: 'true',
},
{
label: 'Non Billable',
value: 'false',
},
]"
@changed="updateFilteredTimeEntries">
<template #trigger>
<ReportingFilterBadge
:active="billable !== null"
:title="billable === 'false' ? 'Non Billable' : 'Billable'"
:icon="BillableIcon"></ReportingFilterBadge>
</template>
</SelectDropdown>
<ReportingRoundingControls
v-model:enabled="roundingEnabled"
v-model:type="roundingType"
v-model:minutes="roundingMinutes"
@change="updateFilteredTimeEntries" />
</div>
<div>
<DateRangePicker
v-model:start="startDate"
v-model:end="endDate"
@submit="updateFilteredTimeEntries"></DateRangePicker>
</div>
</MainContainer>
</div>
<TimeEntryMassActionRow <TimeEntryMassActionRow
:selected-time-entries="selectedTimeEntries" :selected-time-entries="selectedTimeEntries"
:can-create-project="canCreateProjects()" :can-create-project="canCreateProjects()"

View File

@@ -1,13 +1,6 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { api } from '@/packages/api/src'; import { type Component } from 'vue';
import { type Component, computed, ref } from 'vue'; import { getCurrentRole, getCurrentUser } from '@/utils/useUser';
import type {
AggregatedTimeEntries,
AggregatedTimeEntriesQueryParams,
ReportingResponse,
} from '@/packages/api/src';
import { getCurrentOrganizationId, getCurrentRole, getCurrentUser } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
import { useProjectsQuery } from '@/utils/useProjectsQuery'; import { useProjectsQuery } from '@/utils/useProjectsQuery';
import { useMembersQuery } from '@/utils/useMembersQuery'; import { useMembersQuery } from '@/utils/useMembersQuery';
import { useTasksQuery } from '@/utils/useTasksQuery'; import { useTasksQuery } from '@/utils/useTasksQuery';
@@ -27,11 +20,6 @@ export type GroupingOption =
| 'tag'; | 'tag';
export const useReportingStore = defineStore('reporting', () => { export const useReportingStore = defineStore('reporting', () => {
const reportingGraphResponse = ref<ReportingResponse | null>(null);
const reportingTableResponse = ref<ReportingResponse | null>(null);
const { handleApiRequestNotifications } = useNotificationsStore();
// Cache query composables to avoid creating new subscriptions on every call // Cache query composables to avoid creating new subscriptions on every call
const { projects } = useProjectsQuery(); const { projects } = useProjectsQuery();
const { members } = useMembersQuery(); const { members } = useMembersQuery();
@@ -39,48 +27,6 @@ export const useReportingStore = defineStore('reporting', () => {
const { clients } = useClientsQuery(); const { clients } = useClientsQuery();
const { tags } = useTagsQuery(); const { tags } = useTagsQuery();
async function fetchGraphReporting(params: AggregatedTimeEntriesQueryParams) {
const organization = getCurrentOrganizationId();
if (organization) {
reportingGraphResponse.value = await handleApiRequestNotifications(
() =>
api.getAggregatedTimeEntries({
params: {
organization: organization,
},
queries: params,
}),
undefined,
'Failed to fetch reporting data'
);
}
}
async function fetchTableReporting(params: AggregatedTimeEntriesQueryParams) {
const organization = getCurrentOrganizationId();
if (organization) {
reportingTableResponse.value = await handleApiRequestNotifications(
() =>
api.getAggregatedTimeEntries({
params: {
organization: organization,
},
queries: params,
}),
undefined,
'Failed to fetch reporting data'
);
}
}
const aggregatedGraphTimeEntries = computed<AggregatedTimeEntries>(() => {
return reportingGraphResponse.value?.data as AggregatedTimeEntries;
});
const aggregatedTableTimeEntries = computed<AggregatedTimeEntries>(() => {
return reportingTableResponse.value?.data as AggregatedTimeEntries;
});
const emptyPlaceholder = { const emptyPlaceholder = {
user: 'No User', user: 'No User',
project: 'No Project', project: 'No Project',
@@ -170,10 +116,6 @@ export const useReportingStore = defineStore('reporting', () => {
]; ];
return { return {
aggregatedGraphTimeEntries,
fetchGraphReporting,
fetchTableReporting,
aggregatedTableTimeEntries,
getNameForReportingRowEntry, getNameForReportingRowEntry,
groupByOptions, groupByOptions,
emptyPlaceholder, emptyPlaceholder,