mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-16 20:22:15 +01:00
add reporting page, chart and filters
This commit is contained in:
165
resources/js/Components/Common/Reporting/ReportingChart.vue
Normal file
165
resources/js/Components/Common/Reporting/ReportingChart.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<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 { formatHumanReadableDuration } from '@/utils/time';
|
||||
import { use } from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { BarChart } from 'echarts/charts';
|
||||
import {
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
} from 'echarts/components';
|
||||
import type { AggregatedTimeEntries } from '@/utils/api';
|
||||
import { useCssVar } from '@vueuse/core';
|
||||
|
||||
use([
|
||||
CanvasRenderer,
|
||||
BarChart,
|
||||
TitleComponent,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
]);
|
||||
|
||||
provide(THEME_KEY, 'dark');
|
||||
|
||||
type GroupedData = AggregatedTimeEntries['grouped_data'];
|
||||
|
||||
const props = defineProps<{
|
||||
groupedData: GroupedData;
|
||||
}>();
|
||||
|
||||
const xAxisLabels = computed(() => {
|
||||
return props?.groupedData?.map((el) => el.key);
|
||||
});
|
||||
const accentColor = useCssVar('--color-accent-quaternary');
|
||||
|
||||
const seriesData = computed(() => {
|
||||
return props?.groupedData?.map((el) => {
|
||||
return {
|
||||
value: el.seconds,
|
||||
...{
|
||||
itemStyle: {
|
||||
borderColor: new LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: 'rgba(' + accentColor.value + ',0.7)',
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: 'rgba(' + accentColor.value + ',0.5)',
|
||||
},
|
||||
]),
|
||||
emphasis: {
|
||||
color: new LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: 'rgba(' + accentColor.value + ',0.9)',
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: 'rgba(' + accentColor.value + ',0.7)',
|
||||
},
|
||||
]),
|
||||
},
|
||||
borderRadius: [12, 12, 0, 0],
|
||||
color: new LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: 'rgba(' + accentColor.value + ',0.7)',
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: 'rgba(' + accentColor.value + ',0.5)',
|
||||
},
|
||||
]),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const option = ref({
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
},
|
||||
grid: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 50,
|
||||
left: 0,
|
||||
},
|
||||
backgroundColor: 'transparent',
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: xAxisLabels,
|
||||
markLine: {
|
||||
lineStyle: {
|
||||
color: 'rgba(125,156,188,0.1)',
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: 'transparent', // Set desired color here
|
||||
},
|
||||
},
|
||||
axisLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
margin: 24,
|
||||
fontFamily: 'Outfit, sans-serif',
|
||||
},
|
||||
axisTick: {
|
||||
lineStyle: {
|
||||
color: 'transparent', // Set desired color here
|
||||
},
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: 'rgba(125,156,188,0.2)', // Set desired color here
|
||||
},
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: seriesData,
|
||||
type: 'bar',
|
||||
tooltip: {
|
||||
valueFormatter: (value: number) => {
|
||||
return formatHumanReadableDuration(value);
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-[calc(100%-1px)]">
|
||||
<v-chart
|
||||
v-if="groupedData && groupedData?.length > 0"
|
||||
:autoresize="true"
|
||||
class="chart"
|
||||
:option="option" />
|
||||
<div class="chart flex flex-col items-center justify-center" v-else>
|
||||
<p class="text-lg text-white font-semibold">
|
||||
No time entries found
|
||||
</p>
|
||||
<p>Try to change the filters and time range</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chart {
|
||||
height: 300px;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import Badge from '@/Components/Common/Badge.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
icon: Component;
|
||||
title: string;
|
||||
count?: number;
|
||||
active?: boolean;
|
||||
}>();
|
||||
import { type Component, computed } from 'vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
const activeClass = computed(() => {
|
||||
if (props.active) {
|
||||
return 'border-accent-300/50 bg-accent-300/10 hover:bg-accent-300/20';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Badge
|
||||
size="large"
|
||||
:class="
|
||||
twMerge(
|
||||
'cursor-pointer hover:bg-card-background transition space-x-5 flex',
|
||||
activeClass
|
||||
)
|
||||
">
|
||||
<component :is="icon" class="h-4 text-muted"></component>
|
||||
<span> {{ title }} </span>
|
||||
<div
|
||||
v-if="count"
|
||||
class="bg-accent-300/20 w-5 h-5 font-medium rounded flex items-center transition justify-center">
|
||||
{{ count }}
|
||||
</div>
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { FolderIcon } from '@heroicons/vue/16/solid';
|
||||
import SelectDropdown from '@/Components/Common/SelectDropdown.vue';
|
||||
import Badge from '@/Components/Common/Badge.vue';
|
||||
import { computed } from 'vue';
|
||||
import { CheckCircleIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
|
||||
import BillableIcon from '@/Components/Common/Icons/BillableIcon.vue';
|
||||
|
||||
const groupByOptions = [
|
||||
{
|
||||
label: 'Members',
|
||||
value: 'user',
|
||||
icon: UserGroupIcon,
|
||||
},
|
||||
{
|
||||
label: 'Projects',
|
||||
value: 'project',
|
||||
icon: FolderIcon,
|
||||
},
|
||||
{
|
||||
label: 'Tasks',
|
||||
value: 'task',
|
||||
icon: CheckCircleIcon,
|
||||
},
|
||||
{
|
||||
label: 'Billable',
|
||||
value: 'billable',
|
||||
icon: BillableIcon,
|
||||
},
|
||||
];
|
||||
|
||||
const model = defineModel<string | null>({ default: null });
|
||||
|
||||
const icon = computed(() => {
|
||||
return groupByOptions.find((option) => option.value === model.value)?.icon;
|
||||
});
|
||||
const title = computed(() => {
|
||||
return groupByOptions.find((option) => option.value === model.value)?.label;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectDropdown
|
||||
v-model="model"
|
||||
:get-key-from-item="(item) => item.value"
|
||||
:get-name-for-item="(item) => item.label"
|
||||
:items="groupByOptions">
|
||||
<template v-slot:trigger>
|
||||
<Badge
|
||||
size="large"
|
||||
class="cursor-pointer hover:bg-card-background transition space-x-5 flex">
|
||||
<component :is="icon" class="h-4 text-muted"></component>
|
||||
<span> {{ title }} </span>
|
||||
</Badge>
|
||||
</template>
|
||||
</SelectDropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
105
resources/js/Components/Common/Reporting/ReportingRow.vue
Normal file
105
resources/js/Components/Common/Reporting/ReportingRow.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
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 { twMerge } from 'tailwind-merge';
|
||||
|
||||
type AggregatedGroupedData = GroupedData & {
|
||||
grouped_data?: GroupedData[] | null;
|
||||
};
|
||||
|
||||
type GroupedData = {
|
||||
key: string | null;
|
||||
seconds: number;
|
||||
cost: number;
|
||||
type: string;
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
entry: AggregatedGroupedData;
|
||||
indent?: boolean;
|
||||
}>();
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
}
|
||||
const expanded = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="contents text-white [&>*]:transition [&>*]:border-card-background-separator [&>*]:border-b [&>*]:h-[50px]">
|
||||
<div
|
||||
:class="
|
||||
twMerge(
|
||||
'pl-6 font-medium flex items-center space-x-3',
|
||||
props.indent ? 'pl-16' : ''
|
||||
)
|
||||
">
|
||||
<GroupedItemsCountButton
|
||||
:expanded="expanded"
|
||||
@click="expanded = !expanded"
|
||||
v-if="entry.grouped_data && entry.grouped_data?.length > 0">
|
||||
{{ entry.grouped_data?.length }}
|
||||
</GroupedItemsCountButton>
|
||||
<span>
|
||||
{{ entry.key ? getNameForKey(entry.key) : emptyPlaceholder }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="justify-end flex items-center">
|
||||
{{ formatHumanReadableDuration(entry.seconds) }}
|
||||
</div>
|
||||
<div class="justify-end pr-6 flex items-center">
|
||||
{{ formatMoney(entry.cost) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="col-span-3 grid bg-quaternary"
|
||||
style="grid-template-columns: 1fr 150px 150px"
|
||||
v-if="expanded && entry.grouped_data">
|
||||
<ReportingRow
|
||||
indent
|
||||
v-for="subEntry in entry.grouped_data"
|
||||
:key="subEntry.key ?? 'none'"
|
||||
:entry="subEntry"></ReportingRow>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
Reference in New Issue
Block a user