mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02:15 +01:00
[Added] - Introduced a tag filter feature allowing users to specify whether tags should be included or excluded in time entry filters. This supports 'contains' and 'not_contains' modes.
474 lines
20 KiB
Vue
474 lines
20 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
ChartBarIcon,
|
|
ArrowDownTrayIcon,
|
|
EllipsisVerticalIcon,
|
|
LockClosedIcon,
|
|
} from '@heroicons/vue/20/solid';
|
|
import { SaveIcon } from '@lucide/vue';
|
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
|
import {
|
|
formatReportingDuration,
|
|
getDayJsInstance,
|
|
getLocalizedDayJs,
|
|
} from '@/packages/ui/src/utils/time';
|
|
import { formatCents } from '@/packages/ui/src/utils/money';
|
|
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
|
|
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
|
|
import PageTitle from '@/Components/Common/PageTitle.vue';
|
|
import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue';
|
|
import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';
|
|
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
|
import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue';
|
|
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
|
|
import ReportingFilterBar from '@/Components/Common/Reporting/ReportingFilterBar.vue';
|
|
import { SecondaryButton } from '@/packages/ui/src';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '@/packages/ui/src';
|
|
import ReportCreateModal from '@/Components/Common/Report/ReportCreateModal.vue';
|
|
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
|
|
import { canCreateReports } from '@/utils/permissions';
|
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
|
import { computed, type ComputedRef, inject, ref, watch } from 'vue';
|
|
import { type GroupingOption, useReportingStore } from '@/utils/useReporting';
|
|
import {
|
|
type AggregatedTimeEntries,
|
|
type AggregatedTimeEntriesQueryParams,
|
|
api,
|
|
type CreateReportBodyProperties,
|
|
type Organization,
|
|
} from '@/packages/api/src';
|
|
import { getCurrentMembershipId, getCurrentOrganizationId, getCurrentRole } from '@/utils/useUser';
|
|
import { useSessionStorage, useStorage } from '@vueuse/core';
|
|
import { useNotificationsStore } from '@/utils/notification';
|
|
import type { ExportFormat } from '@/types/reporting';
|
|
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
|
|
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
|
import { useAggregatedTimeEntriesQuery } from '@/utils/useAggregatedTimeEntriesQuery';
|
|
import type { TagFilter } from '@/types/reporting';
|
|
|
|
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
|
|
|
|
const { handleApiRequestNotifications } = useNotificationsStore();
|
|
|
|
const startDate = useSessionStorage<string>(
|
|
'reporting-start-date',
|
|
getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format()
|
|
);
|
|
const endDate = useSessionStorage<string>(
|
|
'reporting-end-date',
|
|
getLocalizedDayJs(getDayJsInstance()().format()).format()
|
|
);
|
|
const selectedTags = ref<string[]>([]);
|
|
const selectedProjects = ref<string[]>([]);
|
|
const selectedMembers = ref<string[]>([]);
|
|
const selectedTasks = ref<string[]>([]);
|
|
const selectedClients = ref<string[]>([]);
|
|
const tagFilter = ref<TagFilter>('contains');
|
|
|
|
const billable = ref<'true' | 'false' | null>(null);
|
|
const roundingEnabled = ref<boolean>(false);
|
|
const roundingType = ref<TimeEntryRoundingType>('nearest');
|
|
const roundingMinutes = ref<number>(15);
|
|
|
|
const group = useStorage<GroupingOption>('reporting-group', 'project');
|
|
const subGroup = useStorage<GroupingOption>('reporting-sub-group', 'task');
|
|
|
|
const reportingStore = useReportingStore();
|
|
const { groupByOptions, getNameForReportingRowEntry, emptyPlaceholder } = reportingStore;
|
|
|
|
const organization = inject<ComputedRef<Organization>>('organization');
|
|
|
|
const showBillableRate = computed(() => {
|
|
return !!(
|
|
getCurrentRole() !== 'employee' || organization?.value?.employees_can_see_billable_rates
|
|
);
|
|
});
|
|
|
|
// Ensure sub-group falls back when it collides with group
|
|
watch(
|
|
group,
|
|
() => {
|
|
if (group.value === subGroup.value) {
|
|
const fallbackOption = groupByOptions.find((el) => el.value !== group.value);
|
|
if (fallbackOption?.value) {
|
|
subGroup.value = fallbackOption.value;
|
|
}
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
function getOptimalGroupingOption(start: string, end: string): 'day' | 'week' | 'month' {
|
|
const diffInDays = getDayJsInstance()(end).diff(getDayJsInstance()(start), 'd');
|
|
|
|
if (diffInDays <= 31) {
|
|
return 'day';
|
|
} else if (diffInDays <= 200) {
|
|
return 'week';
|
|
} else {
|
|
return 'month';
|
|
}
|
|
}
|
|
|
|
const filterParams = computed<AggregatedTimeEntriesQueryParams>(() => {
|
|
return {
|
|
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,
|
|
tag_filter: selectedTags.value.length > 0 ? tagFilter.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 graphQueryParams = computed<AggregatedTimeEntriesQueryParams>(() => {
|
|
return {
|
|
...filterParams.value,
|
|
fill_gaps_in_time_groups: 'true',
|
|
group: getOptimalGroupingOption(startDate.value, endDate.value),
|
|
};
|
|
});
|
|
|
|
const tableQueryParams = computed<AggregatedTimeEntriesQueryParams>(() => {
|
|
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 { billable: billableFilter, ...rest } = filterParams.value;
|
|
|
|
let billableValue: boolean | null = null;
|
|
if (billableFilter === 'true') {
|
|
billableValue = true;
|
|
} else if (billableFilter === 'false') {
|
|
billableValue = false;
|
|
}
|
|
|
|
return {
|
|
...rest,
|
|
billable: billableValue,
|
|
group: group.value,
|
|
sub_group: subGroup.value,
|
|
history_group: getOptimalGroupingOption(startDate.value, endDate.value),
|
|
} as CreateReportBodyProperties;
|
|
});
|
|
|
|
async function downloadExport(format: ExportFormat) {
|
|
const organizationId = getCurrentOrganizationId();
|
|
if (organizationId) {
|
|
const response = await handleApiRequestNotifications(
|
|
() =>
|
|
api.exportAggregatedTimeEntries({
|
|
params: {
|
|
organization: organizationId,
|
|
},
|
|
queries: {
|
|
...filterParams.value,
|
|
group: group.value,
|
|
sub_group: subGroup.value,
|
|
history_group: getOptimalGroupingOption(startDate.value, endDate.value),
|
|
format: format,
|
|
},
|
|
}),
|
|
'Export successful',
|
|
'Export failed'
|
|
);
|
|
|
|
if (response?.download_url) {
|
|
showExportModal.value = true;
|
|
exportUrl.value = response.download_url as string;
|
|
}
|
|
}
|
|
}
|
|
|
|
const { projects } = useProjectsQuery();
|
|
const showExportModal = ref(false);
|
|
const exportUrl = ref<string | null>(null);
|
|
const showCreateReportModal = ref(false);
|
|
const showPremiumModal = ref(false);
|
|
const exportLoading = ref(false);
|
|
|
|
function triggerExport(format: ExportFormat) {
|
|
if (format === 'pdf' && !isAllowedToPerformPremiumAction()) {
|
|
showPremiumModal.value = true;
|
|
return;
|
|
}
|
|
exportLoading.value = true;
|
|
downloadExport(format).finally(() => {
|
|
exportLoading.value = false;
|
|
});
|
|
}
|
|
|
|
function onSaveReportClick() {
|
|
if (isAllowedToPerformPremiumAction()) {
|
|
showCreateReportModal.value = true;
|
|
} else {
|
|
showPremiumModal.value = true;
|
|
}
|
|
}
|
|
|
|
const groupedPieChartData = computed(() => {
|
|
return (
|
|
aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
|
|
const name = getNameForReportingRowEntry(
|
|
entry.key,
|
|
aggregatedTableTimeEntries.value?.grouped_type ?? null
|
|
);
|
|
let color = getRandomColorWithSeed(entry.key ?? 'none');
|
|
if (
|
|
name &&
|
|
aggregatedTableTimeEntries.value?.grouped_type &&
|
|
emptyPlaceholder[aggregatedTableTimeEntries.value.grouped_type] === name
|
|
) {
|
|
color = '#CCCCCC';
|
|
} else if (aggregatedTableTimeEntries.value?.grouped_type === 'project') {
|
|
color =
|
|
projects.value?.find((project) => project.id === entry.key)?.color ?? '#CCCCCC';
|
|
}
|
|
return {
|
|
value: entry.seconds,
|
|
name:
|
|
getNameForReportingRowEntry(
|
|
entry.key,
|
|
aggregatedTableTimeEntries.value?.grouped_type ?? null
|
|
) ?? '',
|
|
color: color,
|
|
};
|
|
}) ?? []
|
|
);
|
|
});
|
|
|
|
const tableData = computed(() => {
|
|
return aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
|
|
return {
|
|
seconds: entry.seconds,
|
|
cost: entry.cost,
|
|
description: getNameForReportingRowEntry(
|
|
entry.key,
|
|
aggregatedTableTimeEntries.value?.grouped_type ?? null
|
|
),
|
|
grouped_data:
|
|
entry.grouped_data?.map((el) => {
|
|
return {
|
|
seconds: el.seconds,
|
|
cost: el.cost,
|
|
description: getNameForReportingRowEntry(el.key, entry.grouped_type),
|
|
};
|
|
}) ?? [],
|
|
};
|
|
});
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<ReportingExportModal
|
|
v-model:show="showExportModal"
|
|
:export-url="exportUrl"></ReportingExportModal>
|
|
<ReportCreateModal
|
|
v-model:show="showCreateReportModal"
|
|
:properties="reportProperties"></ReportCreateModal>
|
|
<UpgradeModal v-model:show="showPremiumModal">
|
|
This feature is only available in solidtime Professional.
|
|
</UpgradeModal>
|
|
<MainContainer
|
|
class="h-14 sm:h-16 border-b border-default-background-separator flex flex-wrap gap-y-3 justify-between items-center">
|
|
<div class="flex items-center space-x-3 sm:space-x-6">
|
|
<PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle>
|
|
<ReportingTabNavbar active="reporting" class="hidden sm:flex"></ReportingTabNavbar>
|
|
</div>
|
|
<div class="hidden sm:flex space-x-2">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger as-child>
|
|
<SecondaryButton :icon="ArrowDownTrayIcon" :loading="exportLoading">
|
|
Export
|
|
</SecondaryButton>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem @click="triggerExport('pdf')">
|
|
<div class="flex items-center space-x-2">
|
|
<span>Export as PDF</span>
|
|
<LockClosedIcon
|
|
v-if="!isAllowedToPerformPremiumAction()"
|
|
class="w-3.5 text-text-tertiary" />
|
|
</div>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem @click="triggerExport('xlsx')">
|
|
Export as Excel
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem @click="triggerExport('csv')">
|
|
Export as CSV
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem @click="triggerExport('ods')">
|
|
Export as ODS
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<SecondaryButton v-if="canCreateReports()" :icon="SaveIcon" @click="onSaveReportClick">
|
|
Save Report
|
|
</SecondaryButton>
|
|
</div>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger as-child class="sm:hidden">
|
|
<button
|
|
class="p-1.5 rounded-lg border border-border-tertiary text-text-secondary hover:text-text-primary hover:bg-secondary transition"
|
|
aria-label="More options">
|
|
<EllipsisVerticalIcon class="w-5 h-5" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem @click="triggerExport('pdf')">
|
|
<div class="flex items-center space-x-2">
|
|
<span>Export as PDF</span>
|
|
<LockClosedIcon
|
|
v-if="!isAllowedToPerformPremiumAction()"
|
|
class="w-3.5 text-text-tertiary" />
|
|
</div>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem @click="triggerExport('xlsx')">
|
|
Export as Excel
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem @click="triggerExport('csv')"> Export as CSV </DropdownMenuItem>
|
|
<DropdownMenuItem @click="triggerExport('ods')"> Export as ODS </DropdownMenuItem>
|
|
<DropdownMenuItem v-if="canCreateReports()" @click="onSaveReportClick">
|
|
Save Report
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</MainContainer>
|
|
<MainContainer class="sm:hidden py-2 border-b border-default-background-separator">
|
|
<ReportingTabNavbar active="reporting"></ReportingTabNavbar>
|
|
</MainContainer>
|
|
<ReportingFilterBar
|
|
v-model:selected-members="selectedMembers"
|
|
v-model:selected-projects="selectedProjects"
|
|
v-model:selected-tasks="selectedTasks"
|
|
v-model:selected-clients="selectedClients"
|
|
v-model:selected-tags="selectedTags"
|
|
v-model:tag-filter="tagFilter"
|
|
v-model:billable="billable"
|
|
v-model:rounding-enabled="roundingEnabled"
|
|
v-model:rounding-type="roundingType"
|
|
v-model:rounding-minutes="roundingMinutes"
|
|
v-model:start-date="startDate"
|
|
v-model:end-date="endDate" />
|
|
<MainContainer>
|
|
<div class="pt-10 w-full px-3 relative">
|
|
<ReportingChart
|
|
:grouped-type="aggregatedGraphTimeEntries?.grouped_type ?? null"
|
|
:grouped-data="aggregatedGraphTimeEntries?.grouped_data ?? null"></ReportingChart>
|
|
</div>
|
|
</MainContainer>
|
|
<MainContainer>
|
|
<div class="sm:grid grid-cols-4 pt-6 items-start">
|
|
<div class="col-span-3 bg-secondary rounded-lg border border-card-border pt-3">
|
|
<div
|
|
class="text-sm flex text-text-primary items-center space-x-3 font-medium px-6 border-b border-card-background-separator pb-3">
|
|
<span>Group by</span>
|
|
<ReportingGroupBySelect
|
|
v-model="group"
|
|
:group-by-options="groupByOptions"></ReportingGroupBySelect>
|
|
<span>and</span>
|
|
<ReportingGroupBySelect
|
|
v-model="subGroup"
|
|
:group-by-options="
|
|
groupByOptions.filter((el) => el.value !== group)
|
|
"></ReportingGroupBySelect>
|
|
</div>
|
|
<div
|
|
class="grid items-center"
|
|
:style="`grid-template-columns: 1fr 100px ${showBillableRate ? '150px' : ''}`">
|
|
<div
|
|
class="contents [&>*]:border-card-background-separator [&>*]:border-b [&>*]:bg-secondary [&>*]:pb-1.5 [&>*]:pt-1 text-text-tertiary text-sm">
|
|
<div class="pl-6">Name</div>
|
|
<div class="text-right" :class="!showBillableRate ? 'pr-6' : ''">
|
|
Duration
|
|
</div>
|
|
<div v-if="showBillableRate" class="text-right pr-6">Cost</div>
|
|
</div>
|
|
<template
|
|
v-if="
|
|
aggregatedTableTimeEntries?.grouped_data &&
|
|
aggregatedTableTimeEntries.grouped_data?.length > 0
|
|
">
|
|
<ReportingRow
|
|
v-for="entry in tableData"
|
|
:key="entry.description ?? 'none'"
|
|
:currency="getOrganizationCurrencyString()"
|
|
:type="aggregatedTableTimeEntries.grouped_type"
|
|
:show-cost="showBillableRate"
|
|
:entry="entry"></ReportingRow>
|
|
<div class="contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]">
|
|
<div class="flex items-center pl-6 font-medium">
|
|
<span>Total</span>
|
|
</div>
|
|
<div
|
|
class="justify-end flex items-center font-medium"
|
|
:class="!showBillableRate ? 'pr-6' : ''">
|
|
{{
|
|
formatReportingDuration(
|
|
aggregatedTableTimeEntries.seconds,
|
|
organization?.interval_format,
|
|
organization?.number_format
|
|
)
|
|
}}
|
|
</div>
|
|
<div
|
|
v-if="showBillableRate"
|
|
class="justify-end pr-6 flex items-center font-medium">
|
|
{{
|
|
aggregatedTableTimeEntries.cost
|
|
? formatCents(
|
|
aggregatedTableTimeEntries.cost,
|
|
getOrganizationCurrencyString(),
|
|
organization?.currency_format,
|
|
organization?.currency_symbol,
|
|
organization?.number_format
|
|
)
|
|
: '--'
|
|
}}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<div
|
|
v-else
|
|
class="chart flex flex-col items-center justify-center py-12"
|
|
:class="showBillableRate ? 'col-span-3' : 'col-span-2'">
|
|
<p class="text-lg text-text-primary font-medium">No time entries found</p>
|
|
<p>Try to change the filters and time range</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="px-2 lg:px-4">
|
|
<ReportingPieChart :data="groupedPieChartData"></ReportingPieChart>
|
|
</div>
|
|
</div>
|
|
</MainContainer>
|
|
</template>
|
|
|
|
<style scoped></style>
|