mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-14 19:22:14 +01:00
add table group chart, adapt to api changes
This commit is contained in:
@@ -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: {
|
||||
|
||||
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 { 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user