mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-13 10:42:16 +01:00
refactor: extract ReportingFilterBar and migrate reporting to TanStack Query
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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()"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user