add table group chart, adapt to api changes

This commit is contained in:
Gregor Vostrak
2024-05-21 18:40:03 +02:00
parent 2e710795ec
commit 867d6eff18
15 changed files with 506 additions and 102 deletions

View File

@@ -115,7 +115,6 @@ test('test that starting and updating the time while running works', async ({
(await response.json()).data.project_id === null &&
(await response.json()).data.description === '' &&
(await response.json()).data.task_id === null &&
(await response.json()).data.duration === null &&
(await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify([])

View File

@@ -27,7 +27,6 @@ export function newTimeEntryResponse(
(await response.json()).data.project_id === null &&
(await response.json()).data.description === description &&
(await response.json()).data.task_id === null &&
(await response.json()).data.duration === null &&
(await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify(tags)

View File

@@ -118,7 +118,7 @@ const TaskResource = z
const createTask_Body = z
.object({ name: z.string(), project_id: z.string() })
.passthrough();
const before = z.union([z.string(), z.null()]).optional();
const start = z.union([z.string(), z.null()]).optional();
const TimeEntryResource = z
.object({
id: z.string(),
@@ -163,22 +163,6 @@ const v1_time_entries_update_multiple_Body = z
.passthrough(),
})
.passthrough();
const group = z
.union([
z.enum([
'day',
'week',
'month',
'year',
'user',
'project',
'task',
'client',
'billable',
]),
z.null(),
])
.optional();
const updateTimeEntry_Body = z
.object({
member_id: z.string().uuid().optional(),
@@ -213,12 +197,11 @@ export const schemas = {
TagCollection,
TaskResource,
createTask_Body,
before,
start,
TimeEntryResource,
TimeEntryCollection,
createTimeEntry_Body,
v1_time_entries_update_multiple_Body,
group,
updateTimeEntry_Body,
};
@@ -1776,14 +1759,14 @@ Users with the permission `time-entries:view:own` can only use this en
schema: z.string().uuid().optional(),
},
{
name: 'before',
name: 'start',
type: 'Query',
schema: before,
schema: start,
},
{
name: 'after',
name: 'end',
type: 'Query',
schema: before,
schema: start,
},
{
name: 'active',
@@ -2055,12 +2038,36 @@ If the group parameters are all set to `null` or are all missing, the
{
name: 'group',
type: 'Query',
schema: group,
schema: z
.enum([
'day',
'week',
'month',
'year',
'user',
'project',
'task',
'client',
'billable',
])
.optional(),
},
{
name: 'sub_group',
type: 'Query',
schema: group,
schema: z
.enum([
'day',
'week',
'month',
'year',
'user',
'project',
'task',
'client',
'billable',
])
.optional(),
},
{
name: 'member_id',
@@ -2073,14 +2080,14 @@ If the group parameters are all set to `null` or are all missing, the
schema: z.string().uuid().optional(),
},
{
name: 'before',
name: 'start',
type: 'Query',
schema: before,
schema: start,
},
{
name: 'after',
name: 'end',
type: 'Query',
schema: before,
schema: start,
},
{
name: 'active',
@@ -2092,6 +2099,11 @@ If the group parameters are all set to `null` or are all missing, the
type: 'Query',
schema: z.enum(['true', 'false']).optional(),
},
{
name: 'fill_gaps_in_time_groups',
type: 'Query',
schema: z.enum(['true', 'false']).optional(),
},
{
name: 'member_ids',
type: 'Query',
@@ -2117,19 +2129,22 @@ If the group parameters are all set to `null` or are all missing, the
.object({
data: z
.object({
grouped_type: z.union([z.string(), z.null()]),
grouped_data: z.union([
z.array(
z
.object({
type: z.string(),
key: z.union([z.string(), z.null()]),
seconds: z.number().int(),
cost: z.number().int(),
grouped_type: z.union([
z.string(),
z.null(),
]),
grouped_data: z.union([
z.array(
z
.object({
type: z.string(),
key: z.union([
z.string(),
z.null(),
@@ -2138,6 +2153,8 @@ If the group parameters are all set to `null` or are all missing, the
.number()
.int(),
cost: z.number().int(),
grouped_type: z.null(),
grouped_data: z.null(),
})
.passthrough()
),

View File

@@ -2,7 +2,11 @@
import VChart, { THEME_KEY } from 'vue-echarts';
import { computed, provide, ref } from 'vue';
import LinearGradient from 'zrender/lib/graphic/LinearGradient';
import { formatHumanReadableDuration } from '@/utils/time';
import {
formatDate,
formatHumanReadableDuration,
formatWeek,
} from '@/utils/time';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { BarChart } from 'echarts/charts';
@@ -30,10 +34,14 @@ type GroupedData = AggregatedTimeEntries['grouped_data'];
const props = defineProps<{
groupedData: GroupedData;
groupedType: string | null;
}>();
const xAxisLabels = computed(() => {
return props?.groupedData?.map((el) => el.key);
if (props.groupedType === 'week') {
return props?.groupedData?.map((el) => formatWeek(el.key));
}
return props?.groupedData?.map((el) => formatDate(el.key ?? ''));
});
const accentColor = useCssVar('--color-accent-quaternary');
@@ -108,9 +116,10 @@ const option = ref({
},
},
axisLabel: {
fontSize: 16,
fontSize: 12,
fontWeight: 600,
margin: 24,
color: 'rgba(255,255,255,0.7)',
margin: 16,
fontFamily: 'Outfit, sans-serif',
},
axisTick: {

View File

@@ -0,0 +1,134 @@
<script setup lang="ts">
import VChart, { THEME_KEY } from 'vue-echarts';
import { computed, provide, ref } from 'vue';
import LinearGradient from 'zrender/lib/graphic/LinearGradient';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { PieChart } from 'echarts/charts';
import {
GridComponent,
LegendComponent,
TitleComponent,
TooltipComponent,
} from 'echarts/components';
import { useCssVar } from '@vueuse/core';
import { formatHumanReadableDuration } from '@/utils/time';
import { getRandomColorWithSeed } from '@/utils/color';
import type { GroupedDataEntries } from '@/utils/api';
import { useReportingStore } from '@/utils/useReporting';
use([
CanvasRenderer,
PieChart,
TitleComponent,
GridComponent,
TooltipComponent,
LegendComponent,
]);
provide(THEME_KEY, 'dark');
const backgroundColor = useCssVar('--theme-color-default-background');
function hexToRGBA(hex: string, opacity = 1) {
// Remove the hash at the start if it's there
hex = hex.replace(/^#/, '');
// Parse the hex color
let r, g, b;
if (hex.length === 3) {
r = parseInt(hex.charAt(0) + hex.charAt(0), 16);
g = parseInt(hex.charAt(1) + hex.charAt(1), 16);
b = parseInt(hex.charAt(2) + hex.charAt(2), 16);
} else if (hex.length === 6) {
r = parseInt(hex.substring(0, 2), 16);
g = parseInt(hex.substring(2, 4), 16);
b = parseInt(hex.substring(4, 6), 16);
} else {
throw new Error('Invalid HEX color.');
}
// Return the RGBA color string
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
}
const props = defineProps<{
data: GroupedDataEntries | null;
type: string | null;
}>();
const { getNameForReportingRowEntry } = useReportingStore();
const groupChartData = computed(() => {
return (
props?.data?.map((entry) => {
return {
value: entry.seconds,
name: getNameForReportingRowEntry(entry.key, props.type),
color: getRandomColorWithSeed(entry.key ?? 'none'),
};
}) ?? []
);
});
const seriesData = computed(() => {
return groupChartData.value.map((el) => {
return {
...el,
...{
itemStyle: {
borderRadius: 15,
// TODO: Fix dynamic color
borderColor: backgroundColor.value,
borderWidth: 18,
color: new LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: hexToRGBA(el.color, 0.8),
},
{
offset: 1,
color: hexToRGBA(el.color, 0.4),
},
]),
},
},
};
});
});
const option = ref({
tooltip: {
trigger: 'item',
},
legend: {
orient: 'vertical',
bottom: 'bottom',
},
backgroundColor: 'transparent',
series: [
{
label: {
show: false,
},
tooltip: {
valueFormatter: (value: number) => {
return formatHumanReadableDuration(value);
},
},
data: seriesData,
radius: ['30%', '65%'],
type: 'pie',
},
],
});
</script>
<template>
<v-chart class="chart" :autoresize="true" :option="option" />
</template>
<style scoped>
.chart {
height: 300px;
background: transparent;
}
</style>

View File

@@ -2,14 +2,13 @@
import { formatHumanReadableDuration } from '@/utils/time';
import { formatMoney } from '@/utils/money';
import GroupedItemsCountButton from '@/Components/Common/GroupedItemsCountButton.vue';
import { computed, ref } from 'vue';
import { useProjectsStore } from '@/utils/useProjects';
import { storeToRefs } from 'pinia';
import { useMembersStore } from '@/utils/useMembers';
import { useTasksStore } from '@/utils/useTasks';
import { ref } from 'vue';
import { twMerge } from 'tailwind-merge';
import { useReportingStore } from '@/utils/useReporting';
const { getNameForReportingRowEntry } = useReportingStore();
type AggregatedGroupedData = GroupedData & {
grouped_type?: string | null;
grouped_data?: GroupedData[] | null;
};
@@ -17,48 +16,16 @@ type GroupedData = {
key: string | null;
seconds: number;
cost: number;
type: string;
};
const props = defineProps<{
entry: AggregatedGroupedData;
indent?: boolean;
type: string | null;
}>();
const emptyPlaceholder = computed(() => {
const emptyPlaceholder = {
user: 'No User',
project: 'No Project',
task: 'No Task',
billable: 'Non-Billable',
};
return emptyPlaceholder[props.entry.type as keyof typeof emptyPlaceholder];
});
function getNameForKey(key: string) {
if (props.entry.type === 'project') {
const projectsStore = useProjectsStore();
const { projects } = storeToRefs(projectsStore);
return projects.value.find((project) => project.id === key)?.name;
}
if (props.entry.type === 'user') {
const memberStore = useMembersStore();
const { members } = storeToRefs(memberStore);
return members.value.find((member) => member.user_id === key)?.name;
}
if (props.entry.type === 'task') {
const taskStore = useTasksStore();
const { tasks } = storeToRefs(taskStore);
return tasks.value.find((task) => task.id === key)?.name;
}
if (props.entry.type === 'billable') {
if (key === '0') {
return 'Non-Billable';
} else {
return 'Billable';
}
}
function getNameForKey(key: string | null) {
return getNameForReportingRowEntry(key, props.type);
}
const expanded = ref(false);
</script>
@@ -80,7 +47,7 @@ const expanded = ref(false);
{{ entry.grouped_data?.length }}
</GroupedItemsCountButton>
<span>
{{ entry.key ? getNameForKey(entry.key) : emptyPlaceholder }}
{{ getNameForKey(entry.key) }}
</span>
</div>
<div class="justify-end flex items-center">
@@ -97,6 +64,7 @@ const expanded = ref(false);
<ReportingRow
indent
v-for="subEntry in entry.grouped_data"
:type="entry?.grouped_type ?? null"
:key="subEntry.key ?? 'none'"
:entry="subEntry"></ReportingRow>
</div>

View File

@@ -30,7 +30,7 @@ const isRunningInDifferentOrganization = computed(() => {
</script>
<template>
<div class="py-4 px-2 flex justify-between items-center relative">
<div class="pt-3 pb-2.5 px-2 flex justify-between items-center relative">
<div
class="absolute w-full h-full backdrop-blur-sm z-10 flex items-center justify-center"
v-if="isRunningInDifferentOrganization">
@@ -44,7 +44,7 @@ const isRunningInDifferentOrganization = computed(() => {
</div>
<div>
<div class="text-muted font-extrabold text-xs">Current Timer</div>
<div class="text-white font-medium text-lg py-1">
<div class="text-white font-medium text-lg">
{{ currentTime }}
</div>
</div>

View File

@@ -74,7 +74,7 @@ const page = usePage<{
<div class="border-b border-default-background-separator">
<CurrentSidebarTimer></CurrentSidebarTimer>
</div>
<nav>
<nav class="pt-2">
<ul>
<NavigationSidebarItem
title="Dashboard"

View File

@@ -26,9 +26,10 @@ import SelectDropdown from '@/Components/Common/SelectDropdown.vue';
import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
import { formatMoney } from '@/utils/money';
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
const startDate = ref<string | null>(
getDayJsInstance()().subtract(31, 'd').format('YYYY-MM-DD')
getDayJsInstance()().subtract(14, 'd').format('YYYY-MM-DD')
);
const endDate = ref<string | null>(getDayJsInstance()().format('YYYY-MM-DD'));
const selectedTags = ref<string[]>([]);
@@ -44,8 +45,8 @@ const subGroup = ref<GroupingOption>('task');
function getFilterAttributes() {
let params: AggregatedTimeEntriesQueryParams = {
after: getDayJsInstance()(startDate.value).utc().format(),
before: getDayJsInstance()(endDate.value).endOf('day').utc().format(),
start: getDayJsInstance()(startDate.value).utc().format(),
end: getDayJsInstance()(endDate.value).endOf('day').utc().format(),
};
if (selectedMembers.value.length > 0) {
params = {
@@ -86,6 +87,7 @@ function updateGraphReporting() {
'd'
);
const params = getFilterAttributes();
params.fill_gaps_in_time_groups = 'true';
params.group = getOptimalGroupingOption(diffInDays);
useReportingStore().fetchGraphReporting(params);
}
@@ -130,13 +132,9 @@ onMounted(() => {
<div class="flex items-center space-x-3 sm:space-x-6">
<PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle>
</div>
<DateRangePicker
v-model:start="startDate"
v-model:end="endDate"
@submit="updateReporting"></DateRangePicker>
</MainContainer>
<div class="p-3 w-full border-b border-default-background-separator">
<MainContainer>
<MainContainer class="flex justify-between">
<div class="flex items-center space-x-4">
<div class="text-sm font-medium">Filters</div>
<MemberMultiselectDropdown
@@ -214,18 +212,25 @@ onMounted(() => {
</template>
</SelectDropdown>
</div>
<div>
<DateRangePicker
v-model:start="startDate"
v-model:end="endDate"
@submit="updateReporting"></DateRangePicker>
</div>
</MainContainer>
</div>
<MainContainer>
<div class="pt-10 w-full px-3 relative">
<ReportingChart
:groupedType="aggregatedGraphTimeEntries?.grouped_type"
:groupedData="
aggregatedGraphTimeEntries?.grouped_data
"></ReportingChart>
</div>
</MainContainer>
<MainContainer>
<div class="grid grid-cols-4 pt-6">
<div class="grid grid-cols-4 pt-6 items-start">
<div
class="col-span-3 bg-card-background rounded-lg border border-card-border pt-3">
<div
@@ -257,20 +262,25 @@ onMounted(() => {
<ReportingRow
v-for="entry in aggregatedTableTimeEntries.grouped_data"
:key="entry.key ?? 'none'"
:entry="entry"></ReportingRow>
:entry="entry"
:type="
aggregatedTableTimeEntries.grouped_type
"></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">
<div
class="justify-end flex items-center font-medium">
{{
formatHumanReadableDuration(
aggregatedTableTimeEntries.seconds
)
}}
</div>
<div class="justify-end pr-6 flex items-center">
<div
class="justify-end pr-6 flex items-center font-medium">
{{
formatMoney(
aggregatedTableTimeEntries.cost
@@ -289,7 +299,13 @@ onMounted(() => {
</div>
</div>
</div>
<div></div>
<div>
<ReportingPieChart
:type="aggregatedTableTimeEntries?.grouped_type"
:data="
aggregatedTableTimeEntries?.grouped_data
"></ReportingPieChart>
</div>
</div>
</MainContainer>
</AppLayout>

View File

@@ -34,18 +34,18 @@ defineProps<{
:available-roles="availableRoles"
:user-permissions="permissions" />
<SectionBorder />
<ImportData
v-if="canUpdateOrganization()"
:team="team"></ImportData>
<template
v-if="permissions.canDeleteTeam && !team.personal_team">
<SectionBorder />
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />
</template>
<SectionBorder />
<ImportData
v-if="canUpdateOrganization()"
:team="team"></ImportData>
</div>
</div>
</AppLayout>

View File

@@ -1,3 +1,5 @@
import Prando from '@/utils/random';
export const colors = [
'#ef5350',
'#ec407a',
@@ -23,3 +25,9 @@ export const colors = [
export function getRandomColor() {
return colors[Math.floor(Math.random() * colors.length)];
}
export function getRandomColorWithSeed(seed: string) {
const pseudoRandom = new Prando(seed);
const index = pseudoRandom.nextInt(0, colors.length - 1);
return colors[index];
}

View File

@@ -0,0 +1,201 @@
/**
* This is a hardfork of Prando, a pseudo-random number generator.
* @source https://github.com/zeh/prando
*/
export default class Prando {
private static readonly MIN: number = -2147483648; // Int32 min
private static readonly MAX: number = 2147483647; // Int32 max
private _seed: number;
private _value = NaN;
// ================================================================================================================
// CONSTRUCTOR ----------------------------------------------------------------------------------------------------
/**
* Generate a new Prando pseudo-random number generator.
*
* @param seed - A number or string seed that determines which pseudo-random number sequence will be created. Defaults to a random seed based on `Math.random()`.
*/
constructor(seed?: number | string) {
if (typeof seed === 'string') {
// String seed
this._seed = this.hashCode(seed);
} else if (typeof seed === 'number') {
// Numeric seed
this._seed = this.getSafeSeed(seed);
} else {
// Pseudo-random seed
this._seed = this.getSafeSeed(
Prando.MIN +
Math.floor((Prando.MAX - Prando.MIN) * Math.random())
);
}
this.reset();
}
// ================================================================================================================
// PUBLIC INTERFACE -----------------------------------------------------------------------------------------------
/**
* Generates a pseudo-random number between a lower (inclusive) and a higher (exclusive) bounds.
*
* @param min - The minimum number that can be randomly generated.
* @param pseudoMax - The maximum number that can be randomly generated (exclusive).
* @return The generated pseudo-random number.
*/
public next(min = 0, pseudoMax = 1): number {
this.recalculate();
return this.map(this._value, Prando.MIN, Prando.MAX, min, pseudoMax);
}
/**
* Generates a pseudo-random integer number in a range (inclusive).
*
* @param min - The minimum number that can be randomly generated.
* @param max - The maximum number that can be randomly generated.
* @return The generated pseudo-random number.
*/
public nextInt(min = 10, max = 100): number {
this.recalculate();
return Math.floor(
this.map(this._value, Prando.MIN, Prando.MAX, min, max + 1)
);
}
/**
* Generates a pseudo-random string sequence of a particular length from a specific character range.
*
* Note: keep in mind that creating a random string sequence does not guarantee uniqueness; there is always a
* 1 in (char_length^string_length) chance of collision. For real unique string ids, always check for
* pre-existing ids, or employ a robust GUID/UUID generator.
*
* @param length - Length of the string to be generated.
* @param chars - Characters that are used when creating the random string. Defaults to all alphanumeric chars (A-Z, a-z, 0-9).
* @return The generated string sequence.
*/
public nextString(
length = 16,
chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
): string {
let str = '';
while (str.length < length) {
str += this.nextChar(chars);
}
return str;
}
/**
* Generates a pseudo-random string of 1 character specific character range.
*
* @param chars - Characters that are used when creating the random string. Defaults to all alphanumeric chars (A-Z, a-z, 0-9).
* @return The generated character.
*/
public nextChar(
chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
): string {
return chars.substr(this.nextInt(0, chars.length - 1), 1);
}
/**
* Picks a pseudo-random item from an array. The array is left unmodified.
*
* Note: keep in mind that while the returned item will be random enough, picking one item from the array at a time
* does not guarantee nor imply that a sequence of random non-repeating items will be picked. If you want to
* *pick items in a random order* from an array, instead of *pick one random item from an array*, it's best to
* apply a *shuffle* transformation to the array instead, then read it linearly.
*
* @param array - Array of any type containing one or more candidates for random picking.
* @return An item from the array.
*/
public nextArrayItem<T>(array: T[]): T {
return array[this.nextInt(0, array.length - 1)];
}
/**
* Generates a pseudo-random boolean.
*
* @return A value of true or false.
*/
public nextBoolean(): boolean {
this.recalculate();
return this._value > 0.5;
}
/**
* Skips ahead in the sequence of numbers that are being generated. This is equivalent to
* calling next() a specified number of times, but faster since it doesn't need to map the
* new random numbers to a range and return it.
*
* @param iterations - The number of items to skip ahead.
*/
public skip(iterations = 1): void {
while (iterations-- > 0) {
this.recalculate();
}
}
/**
* Reset the pseudo-random number sequence back to its starting seed. Further calls to next()
* will then produce the same sequence of numbers it had produced before. This is equivalent to
* creating a new Prando instance with the same seed as another Prando instance.
*
* Example:
* let rng = new Prando(12345678);
* console.log(rng.next()); // 0.6177754114889017
* console.log(rng.next()); // 0.5784605181725837
* rng.reset();
* console.log(rng.next()); // 0.6177754114889017 again
* console.log(rng.next()); // 0.5784605181725837 again
*/
public reset(): void {
this._value = this._seed;
}
// ================================================================================================================
// PRIVATE INTERFACE ----------------------------------------------------------------------------------------------
private recalculate(): void {
this._value = this.xorshift(this._value);
}
private xorshift(value: number): number {
// Xorshift*32
// Based on George Marsaglia's work: http://www.jstatsoft.org/v08/i14/paper
value ^= value << 13;
value ^= value >> 17;
value ^= value << 5;
return value;
}
private map(
val: number,
minFrom: number,
maxFrom: number,
minTo: number,
maxTo: number
): number {
return (
((val - minFrom) / (maxFrom - minFrom)) * (maxTo - minTo) + minTo
);
}
private hashCode(str: string): number {
let hash = 0;
if (str) {
const l = str.length;
for (let i = 0; i < l; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash |= 0;
hash = this.xorshift(hash);
}
}
return this.getSafeSeed(hash);
}
private getSafeSeed(seed: number): number {
if (seed === 0) return 1;
return seed;
}
}

View File

@@ -5,6 +5,7 @@ import isToday from 'dayjs/plugin/isToday';
import isYesterday from 'dayjs/plugin/isYesterday';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import weekOfYear from 'dayjs/plugin/weekOfYear';
import { getUserTimezone, getWeekStart } from '@/utils/useUser';
import updateLocale from 'dayjs/plugin/updateLocale';
import { computed } from 'vue';
@@ -16,6 +17,7 @@ dayjs.extend(duration);
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(updateLocale);
dayjs.extend(weekOfYear);
export function getDayJsInstance() {
dayjs.updateLocale('en', {
@@ -75,6 +77,10 @@ export function formatDate(date: string): string {
return dayjs(date).format('DD.MM.YYYY');
}
export function formatWeek(date: string | null): string {
return 'Week ' + dayjs(date).week();
}
/*
* Returns a human readable date format.
* @param date - date in the format of 'YYYY-MM-DD'

View File

@@ -1,4 +1,4 @@
import { defineStore } from 'pinia';
import { defineStore, storeToRefs } from 'pinia';
import { api } from '../../../openapi.json.client';
import { computed, ref } from 'vue';
import type {
@@ -8,6 +8,9 @@ import type {
} from '@/utils/api';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
import { useProjectsStore } from '@/utils/useProjects';
import { useMembersStore } from '@/utils/useMembers';
import { useTasksStore } from '@/utils/useTasks';
export const useReportingStore = defineStore('reporting', () => {
const reportingGraphResponse = ref<ReportingResponse | null>(null);
@@ -59,10 +62,54 @@ export const useReportingStore = defineStore('reporting', () => {
return reportingTableResponse.value?.data as AggregatedTimeEntries;
});
function getNameForReportingRowEntry(
key: string | null,
type: string | null
) {
if (type === null) {
return null;
}
if (key === null) {
const emptyPlaceholder = {
user: 'No User',
project: 'No Project',
task: 'No Task',
billable: 'Non-Billable',
};
return emptyPlaceholder[type as keyof typeof emptyPlaceholder];
}
if (type === 'project') {
const projectsStore = useProjectsStore();
const { projects } = storeToRefs(projectsStore);
return projects.value.find((project) => project.id === key)?.name;
}
if (type === 'user') {
const memberStore = useMembersStore();
const { members } = storeToRefs(memberStore);
return members.value.find((member) => member.user_id === key)?.name;
}
if (type === 'task') {
const taskStore = useTasksStore();
const { tasks } = storeToRefs(taskStore);
return tasks.value.find((task) => task.id === key)?.name;
}
if (type === 'billable') {
if (key === '0') {
return 'Non-Billable';
} else {
return 'Billable';
}
}
return key;
}
return {
aggregatedGraphTimeEntries,
fetchGraphReporting,
fetchTableReporting,
aggregatedTableTimeEntries,
getNameForReportingRowEntry,
};
});

View File

@@ -54,7 +54,7 @@ export const useTimeEntriesStore = defineStore('timeEntries', () => {
queries: {
only_full_dates: 'true',
member_id: getCurrentMembershipId(),
before: dayjs(latestTimeEntry.start).utc().format(),
end: dayjs(latestTimeEntry.start).utc().format(),
},
}),
undefined,