mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-16 20:22:15 +01:00
add table group chart, adapt to api changes
This commit is contained in:
@@ -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.project_id === null &&
|
||||||
(await response.json()).data.description === '' &&
|
(await response.json()).data.description === '' &&
|
||||||
(await response.json()).data.task_id === null &&
|
(await response.json()).data.task_id === null &&
|
||||||
(await response.json()).data.duration === null &&
|
|
||||||
(await response.json()).data.user_id !== null &&
|
(await response.json()).data.user_id !== null &&
|
||||||
JSON.stringify((await response.json()).data.tags) ===
|
JSON.stringify((await response.json()).data.tags) ===
|
||||||
JSON.stringify([])
|
JSON.stringify([])
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ export function newTimeEntryResponse(
|
|||||||
(await response.json()).data.project_id === null &&
|
(await response.json()).data.project_id === null &&
|
||||||
(await response.json()).data.description === description &&
|
(await response.json()).data.description === description &&
|
||||||
(await response.json()).data.task_id === null &&
|
(await response.json()).data.task_id === null &&
|
||||||
(await response.json()).data.duration === null &&
|
|
||||||
(await response.json()).data.user_id !== null &&
|
(await response.json()).data.user_id !== null &&
|
||||||
JSON.stringify((await response.json()).data.tags) ===
|
JSON.stringify((await response.json()).data.tags) ===
|
||||||
JSON.stringify(tags)
|
JSON.stringify(tags)
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ const TaskResource = z
|
|||||||
const createTask_Body = z
|
const createTask_Body = z
|
||||||
.object({ name: z.string(), project_id: z.string() })
|
.object({ name: z.string(), project_id: z.string() })
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const before = z.union([z.string(), z.null()]).optional();
|
const start = z.union([z.string(), z.null()]).optional();
|
||||||
const TimeEntryResource = z
|
const TimeEntryResource = z
|
||||||
.object({
|
.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -163,22 +163,6 @@ const v1_time_entries_update_multiple_Body = z
|
|||||||
.passthrough(),
|
.passthrough(),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const group = z
|
|
||||||
.union([
|
|
||||||
z.enum([
|
|
||||||
'day',
|
|
||||||
'week',
|
|
||||||
'month',
|
|
||||||
'year',
|
|
||||||
'user',
|
|
||||||
'project',
|
|
||||||
'task',
|
|
||||||
'client',
|
|
||||||
'billable',
|
|
||||||
]),
|
|
||||||
z.null(),
|
|
||||||
])
|
|
||||||
.optional();
|
|
||||||
const updateTimeEntry_Body = z
|
const updateTimeEntry_Body = z
|
||||||
.object({
|
.object({
|
||||||
member_id: z.string().uuid().optional(),
|
member_id: z.string().uuid().optional(),
|
||||||
@@ -213,12 +197,11 @@ export const schemas = {
|
|||||||
TagCollection,
|
TagCollection,
|
||||||
TaskResource,
|
TaskResource,
|
||||||
createTask_Body,
|
createTask_Body,
|
||||||
before,
|
start,
|
||||||
TimeEntryResource,
|
TimeEntryResource,
|
||||||
TimeEntryCollection,
|
TimeEntryCollection,
|
||||||
createTimeEntry_Body,
|
createTimeEntry_Body,
|
||||||
v1_time_entries_update_multiple_Body,
|
v1_time_entries_update_multiple_Body,
|
||||||
group,
|
|
||||||
updateTimeEntry_Body,
|
updateTimeEntry_Body,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1776,14 +1759,14 @@ Users with the permission `time-entries:view:own` can only use this en
|
|||||||
schema: z.string().uuid().optional(),
|
schema: z.string().uuid().optional(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'before',
|
name: 'start',
|
||||||
type: 'Query',
|
type: 'Query',
|
||||||
schema: before,
|
schema: start,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'after',
|
name: 'end',
|
||||||
type: 'Query',
|
type: 'Query',
|
||||||
schema: before,
|
schema: start,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'active',
|
name: 'active',
|
||||||
@@ -2055,12 +2038,36 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
{
|
{
|
||||||
name: 'group',
|
name: 'group',
|
||||||
type: 'Query',
|
type: 'Query',
|
||||||
schema: group,
|
schema: z
|
||||||
|
.enum([
|
||||||
|
'day',
|
||||||
|
'week',
|
||||||
|
'month',
|
||||||
|
'year',
|
||||||
|
'user',
|
||||||
|
'project',
|
||||||
|
'task',
|
||||||
|
'client',
|
||||||
|
'billable',
|
||||||
|
])
|
||||||
|
.optional(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'sub_group',
|
name: 'sub_group',
|
||||||
type: 'Query',
|
type: 'Query',
|
||||||
schema: group,
|
schema: z
|
||||||
|
.enum([
|
||||||
|
'day',
|
||||||
|
'week',
|
||||||
|
'month',
|
||||||
|
'year',
|
||||||
|
'user',
|
||||||
|
'project',
|
||||||
|
'task',
|
||||||
|
'client',
|
||||||
|
'billable',
|
||||||
|
])
|
||||||
|
.optional(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'member_id',
|
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(),
|
schema: z.string().uuid().optional(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'before',
|
name: 'start',
|
||||||
type: 'Query',
|
type: 'Query',
|
||||||
schema: before,
|
schema: start,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'after',
|
name: 'end',
|
||||||
type: 'Query',
|
type: 'Query',
|
||||||
schema: before,
|
schema: start,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'active',
|
name: 'active',
|
||||||
@@ -2092,6 +2099,11 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
type: 'Query',
|
type: 'Query',
|
||||||
schema: z.enum(['true', 'false']).optional(),
|
schema: z.enum(['true', 'false']).optional(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'fill_gaps_in_time_groups',
|
||||||
|
type: 'Query',
|
||||||
|
schema: z.enum(['true', 'false']).optional(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'member_ids',
|
name: 'member_ids',
|
||||||
type: 'Query',
|
type: 'Query',
|
||||||
@@ -2117,19 +2129,22 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
.object({
|
.object({
|
||||||
data: z
|
data: z
|
||||||
.object({
|
.object({
|
||||||
|
grouped_type: z.union([z.string(), z.null()]),
|
||||||
grouped_data: z.union([
|
grouped_data: z.union([
|
||||||
z.array(
|
z.array(
|
||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
type: z.string(),
|
|
||||||
key: z.union([z.string(), z.null()]),
|
key: z.union([z.string(), z.null()]),
|
||||||
seconds: z.number().int(),
|
seconds: z.number().int(),
|
||||||
cost: z.number().int(),
|
cost: z.number().int(),
|
||||||
|
grouped_type: z.union([
|
||||||
|
z.string(),
|
||||||
|
z.null(),
|
||||||
|
]),
|
||||||
grouped_data: z.union([
|
grouped_data: z.union([
|
||||||
z.array(
|
z.array(
|
||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
type: z.string(),
|
|
||||||
key: z.union([
|
key: z.union([
|
||||||
z.string(),
|
z.string(),
|
||||||
z.null(),
|
z.null(),
|
||||||
@@ -2138,6 +2153,8 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
.number()
|
.number()
|
||||||
.int(),
|
.int(),
|
||||||
cost: z.number().int(),
|
cost: z.number().int(),
|
||||||
|
grouped_type: z.null(),
|
||||||
|
grouped_data: z.null(),
|
||||||
})
|
})
|
||||||
.passthrough()
|
.passthrough()
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
import VChart, { THEME_KEY } from 'vue-echarts';
|
import VChart, { THEME_KEY } from 'vue-echarts';
|
||||||
import { computed, provide, ref } from 'vue';
|
import { computed, provide, ref } from 'vue';
|
||||||
import LinearGradient from 'zrender/lib/graphic/LinearGradient';
|
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 { use } from 'echarts/core';
|
||||||
import { CanvasRenderer } from 'echarts/renderers';
|
import { CanvasRenderer } from 'echarts/renderers';
|
||||||
import { BarChart } from 'echarts/charts';
|
import { BarChart } from 'echarts/charts';
|
||||||
@@ -30,10 +34,14 @@ type GroupedData = AggregatedTimeEntries['grouped_data'];
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
groupedData: GroupedData;
|
groupedData: GroupedData;
|
||||||
|
groupedType: string | null;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const xAxisLabels = computed(() => {
|
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');
|
const accentColor = useCssVar('--color-accent-quaternary');
|
||||||
|
|
||||||
@@ -108,9 +116,10 @@ const option = ref({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
fontSize: 16,
|
fontSize: 12,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
margin: 24,
|
color: 'rgba(255,255,255,0.7)',
|
||||||
|
margin: 16,
|
||||||
fontFamily: 'Outfit, sans-serif',
|
fontFamily: 'Outfit, sans-serif',
|
||||||
},
|
},
|
||||||
axisTick: {
|
axisTick: {
|
||||||
|
|||||||
134
resources/js/Components/Common/Reporting/ReportingPieChart.vue
Normal file
134
resources/js/Components/Common/Reporting/ReportingPieChart.vue
Normal 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>
|
||||||
@@ -2,14 +2,13 @@
|
|||||||
import { formatHumanReadableDuration } from '@/utils/time';
|
import { formatHumanReadableDuration } from '@/utils/time';
|
||||||
import { formatMoney } from '@/utils/money';
|
import { formatMoney } from '@/utils/money';
|
||||||
import GroupedItemsCountButton from '@/Components/Common/GroupedItemsCountButton.vue';
|
import GroupedItemsCountButton from '@/Components/Common/GroupedItemsCountButton.vue';
|
||||||
import { computed, ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { useProjectsStore } from '@/utils/useProjects';
|
|
||||||
import { storeToRefs } from 'pinia';
|
|
||||||
import { useMembersStore } from '@/utils/useMembers';
|
|
||||||
import { useTasksStore } from '@/utils/useTasks';
|
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
import { useReportingStore } from '@/utils/useReporting';
|
||||||
|
const { getNameForReportingRowEntry } = useReportingStore();
|
||||||
|
|
||||||
type AggregatedGroupedData = GroupedData & {
|
type AggregatedGroupedData = GroupedData & {
|
||||||
|
grouped_type?: string | null;
|
||||||
grouped_data?: GroupedData[] | null;
|
grouped_data?: GroupedData[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -17,48 +16,16 @@ type GroupedData = {
|
|||||||
key: string | null;
|
key: string | null;
|
||||||
seconds: number;
|
seconds: number;
|
||||||
cost: number;
|
cost: number;
|
||||||
type: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
entry: AggregatedGroupedData;
|
entry: AggregatedGroupedData;
|
||||||
indent?: boolean;
|
indent?: boolean;
|
||||||
|
type: string | null;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emptyPlaceholder = computed(() => {
|
function getNameForKey(key: string | null) {
|
||||||
const emptyPlaceholder = {
|
return getNameForReportingRowEntry(key, props.type);
|
||||||
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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const expanded = ref(false);
|
const expanded = ref(false);
|
||||||
</script>
|
</script>
|
||||||
@@ -80,7 +47,7 @@ const expanded = ref(false);
|
|||||||
{{ entry.grouped_data?.length }}
|
{{ entry.grouped_data?.length }}
|
||||||
</GroupedItemsCountButton>
|
</GroupedItemsCountButton>
|
||||||
<span>
|
<span>
|
||||||
{{ entry.key ? getNameForKey(entry.key) : emptyPlaceholder }}
|
{{ getNameForKey(entry.key) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="justify-end flex items-center">
|
<div class="justify-end flex items-center">
|
||||||
@@ -97,6 +64,7 @@ const expanded = ref(false);
|
|||||||
<ReportingRow
|
<ReportingRow
|
||||||
indent
|
indent
|
||||||
v-for="subEntry in entry.grouped_data"
|
v-for="subEntry in entry.grouped_data"
|
||||||
|
:type="entry?.grouped_type ?? null"
|
||||||
:key="subEntry.key ?? 'none'"
|
:key="subEntry.key ?? 'none'"
|
||||||
:entry="subEntry"></ReportingRow>
|
:entry="subEntry"></ReportingRow>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const isRunningInDifferentOrganization = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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
|
<div
|
||||||
class="absolute w-full h-full backdrop-blur-sm z-10 flex items-center justify-center"
|
class="absolute w-full h-full backdrop-blur-sm z-10 flex items-center justify-center"
|
||||||
v-if="isRunningInDifferentOrganization">
|
v-if="isRunningInDifferentOrganization">
|
||||||
@@ -44,7 +44,7 @@ const isRunningInDifferentOrganization = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-muted font-extrabold text-xs">Current Timer</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 }}
|
{{ currentTime }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ const page = usePage<{
|
|||||||
<div class="border-b border-default-background-separator">
|
<div class="border-b border-default-background-separator">
|
||||||
<CurrentSidebarTimer></CurrentSidebarTimer>
|
<CurrentSidebarTimer></CurrentSidebarTimer>
|
||||||
</div>
|
</div>
|
||||||
<nav>
|
<nav class="pt-2">
|
||||||
<ul>
|
<ul>
|
||||||
<NavigationSidebarItem
|
<NavigationSidebarItem
|
||||||
title="Dashboard"
|
title="Dashboard"
|
||||||
|
|||||||
@@ -26,9 +26,10 @@ import SelectDropdown from '@/Components/Common/SelectDropdown.vue';
|
|||||||
import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';
|
import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';
|
||||||
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
|
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
|
||||||
import { formatMoney } from '@/utils/money';
|
import { formatMoney } from '@/utils/money';
|
||||||
|
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
|
||||||
|
|
||||||
const startDate = ref<string | null>(
|
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 endDate = ref<string | null>(getDayJsInstance()().format('YYYY-MM-DD'));
|
||||||
const selectedTags = ref<string[]>([]);
|
const selectedTags = ref<string[]>([]);
|
||||||
@@ -44,8 +45,8 @@ const subGroup = ref<GroupingOption>('task');
|
|||||||
|
|
||||||
function getFilterAttributes() {
|
function getFilterAttributes() {
|
||||||
let params: AggregatedTimeEntriesQueryParams = {
|
let params: AggregatedTimeEntriesQueryParams = {
|
||||||
after: getDayJsInstance()(startDate.value).utc().format(),
|
start: getDayJsInstance()(startDate.value).utc().format(),
|
||||||
before: getDayJsInstance()(endDate.value).endOf('day').utc().format(),
|
end: getDayJsInstance()(endDate.value).endOf('day').utc().format(),
|
||||||
};
|
};
|
||||||
if (selectedMembers.value.length > 0) {
|
if (selectedMembers.value.length > 0) {
|
||||||
params = {
|
params = {
|
||||||
@@ -86,6 +87,7 @@ function updateGraphReporting() {
|
|||||||
'd'
|
'd'
|
||||||
);
|
);
|
||||||
const params = getFilterAttributes();
|
const params = getFilterAttributes();
|
||||||
|
params.fill_gaps_in_time_groups = 'true';
|
||||||
params.group = getOptimalGroupingOption(diffInDays);
|
params.group = getOptimalGroupingOption(diffInDays);
|
||||||
useReportingStore().fetchGraphReporting(params);
|
useReportingStore().fetchGraphReporting(params);
|
||||||
}
|
}
|
||||||
@@ -130,13 +132,9 @@ onMounted(() => {
|
|||||||
<div class="flex items-center space-x-3 sm:space-x-6">
|
<div class="flex items-center space-x-3 sm:space-x-6">
|
||||||
<PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle>
|
<PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle>
|
||||||
</div>
|
</div>
|
||||||
<DateRangePicker
|
|
||||||
v-model:start="startDate"
|
|
||||||
v-model:end="endDate"
|
|
||||||
@submit="updateReporting"></DateRangePicker>
|
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
<div class="p-3 w-full border-b border-default-background-separator">
|
<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="flex items-center space-x-4">
|
||||||
<div class="text-sm font-medium">Filters</div>
|
<div class="text-sm font-medium">Filters</div>
|
||||||
<MemberMultiselectDropdown
|
<MemberMultiselectDropdown
|
||||||
@@ -214,18 +212,25 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</SelectDropdown>
|
</SelectDropdown>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<DateRangePicker
|
||||||
|
v-model:start="startDate"
|
||||||
|
v-model:end="endDate"
|
||||||
|
@submit="updateReporting"></DateRangePicker>
|
||||||
|
</div>
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
</div>
|
</div>
|
||||||
<MainContainer>
|
<MainContainer>
|
||||||
<div class="pt-10 w-full px-3 relative">
|
<div class="pt-10 w-full px-3 relative">
|
||||||
<ReportingChart
|
<ReportingChart
|
||||||
|
:groupedType="aggregatedGraphTimeEntries?.grouped_type"
|
||||||
:groupedData="
|
:groupedData="
|
||||||
aggregatedGraphTimeEntries?.grouped_data
|
aggregatedGraphTimeEntries?.grouped_data
|
||||||
"></ReportingChart>
|
"></ReportingChart>
|
||||||
</div>
|
</div>
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
<MainContainer>
|
<MainContainer>
|
||||||
<div class="grid grid-cols-4 pt-6">
|
<div class="grid grid-cols-4 pt-6 items-start">
|
||||||
<div
|
<div
|
||||||
class="col-span-3 bg-card-background rounded-lg border border-card-border pt-3">
|
class="col-span-3 bg-card-background rounded-lg border border-card-border pt-3">
|
||||||
<div
|
<div
|
||||||
@@ -257,20 +262,25 @@ onMounted(() => {
|
|||||||
<ReportingRow
|
<ReportingRow
|
||||||
v-for="entry in aggregatedTableTimeEntries.grouped_data"
|
v-for="entry in aggregatedTableTimeEntries.grouped_data"
|
||||||
:key="entry.key ?? 'none'"
|
:key="entry.key ?? 'none'"
|
||||||
:entry="entry"></ReportingRow>
|
:entry="entry"
|
||||||
|
:type="
|
||||||
|
aggregatedTableTimeEntries.grouped_type
|
||||||
|
"></ReportingRow>
|
||||||
<div
|
<div
|
||||||
class="contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]">
|
class="contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]">
|
||||||
<div class="flex items-center pl-6 font-medium">
|
<div class="flex items-center pl-6 font-medium">
|
||||||
<span>Total</span>
|
<span>Total</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="justify-end flex items-center">
|
<div
|
||||||
|
class="justify-end flex items-center font-medium">
|
||||||
{{
|
{{
|
||||||
formatHumanReadableDuration(
|
formatHumanReadableDuration(
|
||||||
aggregatedTableTimeEntries.seconds
|
aggregatedTableTimeEntries.seconds
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
<div class="justify-end pr-6 flex items-center">
|
<div
|
||||||
|
class="justify-end pr-6 flex items-center font-medium">
|
||||||
{{
|
{{
|
||||||
formatMoney(
|
formatMoney(
|
||||||
aggregatedTableTimeEntries.cost
|
aggregatedTableTimeEntries.cost
|
||||||
@@ -289,7 +299,13 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div></div>
|
<div>
|
||||||
|
<ReportingPieChart
|
||||||
|
:type="aggregatedTableTimeEntries?.grouped_type"
|
||||||
|
:data="
|
||||||
|
aggregatedTableTimeEntries?.grouped_data
|
||||||
|
"></ReportingPieChart>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|||||||
@@ -34,18 +34,18 @@ defineProps<{
|
|||||||
:available-roles="availableRoles"
|
:available-roles="availableRoles"
|
||||||
:user-permissions="permissions" />
|
:user-permissions="permissions" />
|
||||||
|
|
||||||
|
<SectionBorder />
|
||||||
|
|
||||||
|
<ImportData
|
||||||
|
v-if="canUpdateOrganization()"
|
||||||
|
:team="team"></ImportData>
|
||||||
|
|
||||||
<template
|
<template
|
||||||
v-if="permissions.canDeleteTeam && !team.personal_team">
|
v-if="permissions.canDeleteTeam && !team.personal_team">
|
||||||
<SectionBorder />
|
<SectionBorder />
|
||||||
|
|
||||||
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />
|
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<SectionBorder />
|
|
||||||
|
|
||||||
<ImportData
|
|
||||||
v-if="canUpdateOrganization()"
|
|
||||||
:team="team"></ImportData>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import Prando from '@/utils/random';
|
||||||
|
|
||||||
export const colors = [
|
export const colors = [
|
||||||
'#ef5350',
|
'#ef5350',
|
||||||
'#ec407a',
|
'#ec407a',
|
||||||
@@ -23,3 +25,9 @@ export const colors = [
|
|||||||
export function getRandomColor() {
|
export function getRandomColor() {
|
||||||
return colors[Math.floor(Math.random() * colors.length)];
|
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];
|
||||||
|
}
|
||||||
|
|||||||
201
resources/js/utils/random.ts
Normal file
201
resources/js/utils/random.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import isToday from 'dayjs/plugin/isToday';
|
|||||||
import isYesterday from 'dayjs/plugin/isYesterday';
|
import isYesterday from 'dayjs/plugin/isYesterday';
|
||||||
import utc from 'dayjs/plugin/utc';
|
import utc from 'dayjs/plugin/utc';
|
||||||
import timezone from 'dayjs/plugin/timezone';
|
import timezone from 'dayjs/plugin/timezone';
|
||||||
|
import weekOfYear from 'dayjs/plugin/weekOfYear';
|
||||||
import { getUserTimezone, getWeekStart } from '@/utils/useUser';
|
import { getUserTimezone, getWeekStart } from '@/utils/useUser';
|
||||||
import updateLocale from 'dayjs/plugin/updateLocale';
|
import updateLocale from 'dayjs/plugin/updateLocale';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
@@ -16,6 +17,7 @@ dayjs.extend(duration);
|
|||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
dayjs.extend(updateLocale);
|
dayjs.extend(updateLocale);
|
||||||
|
dayjs.extend(weekOfYear);
|
||||||
|
|
||||||
export function getDayJsInstance() {
|
export function getDayJsInstance() {
|
||||||
dayjs.updateLocale('en', {
|
dayjs.updateLocale('en', {
|
||||||
@@ -75,6 +77,10 @@ export function formatDate(date: string): string {
|
|||||||
return dayjs(date).format('DD.MM.YYYY');
|
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.
|
* Returns a human readable date format.
|
||||||
* @param date - date in the format of 'YYYY-MM-DD'
|
* @param date - date in the format of 'YYYY-MM-DD'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore, storeToRefs } from 'pinia';
|
||||||
import { api } from '../../../openapi.json.client';
|
import { api } from '../../../openapi.json.client';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import type {
|
import type {
|
||||||
@@ -8,6 +8,9 @@ import type {
|
|||||||
} from '@/utils/api';
|
} from '@/utils/api';
|
||||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
import { useNotificationsStore } from '@/utils/notification';
|
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', () => {
|
export const useReportingStore = defineStore('reporting', () => {
|
||||||
const reportingGraphResponse = ref<ReportingResponse | null>(null);
|
const reportingGraphResponse = ref<ReportingResponse | null>(null);
|
||||||
@@ -59,10 +62,54 @@ export const useReportingStore = defineStore('reporting', () => {
|
|||||||
return reportingTableResponse.value?.data as AggregatedTimeEntries;
|
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 {
|
return {
|
||||||
aggregatedGraphTimeEntries,
|
aggregatedGraphTimeEntries,
|
||||||
fetchGraphReporting,
|
fetchGraphReporting,
|
||||||
fetchTableReporting,
|
fetchTableReporting,
|
||||||
aggregatedTableTimeEntries,
|
aggregatedTableTimeEntries,
|
||||||
|
getNameForReportingRowEntry,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export const useTimeEntriesStore = defineStore('timeEntries', () => {
|
|||||||
queries: {
|
queries: {
|
||||||
only_full_dates: 'true',
|
only_full_dates: 'true',
|
||||||
member_id: getCurrentMembershipId(),
|
member_id: getCurrentMembershipId(),
|
||||||
before: dayjs(latestTimeEntry.start).utc().format(),
|
end: dayjs(latestTimeEntry.start).utc().format(),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
undefined,
|
undefined,
|
||||||
|
|||||||
Reference in New Issue
Block a user