mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02: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.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([])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
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 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'
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user