mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-15 19:52:15 +01:00
add reporting page, chart and filters
This commit is contained in:
5
e2e/reporting.spec.ts
Normal file
5
e2e/reporting.spec.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// TODO: Test filter
|
||||||
|
|
||||||
|
// TODO: Test date range
|
||||||
|
|
||||||
|
// TODO: Test grouping and sub-grouping
|
||||||
39
resources/js/Components/Common/DateRangePicker.vue
Normal file
39
resources/js/Components/Common/DateRangePicker.vue
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { CalendarIcon } from '@heroicons/vue/20/solid';
|
||||||
|
import Dropdown from '@/Components/Dropdown.vue';
|
||||||
|
import DatePicker from '@/Components/Common/DatePicker.vue';
|
||||||
|
import { formatDate } from '../../utils/time';
|
||||||
|
|
||||||
|
const start = defineModel('start', { default: '' });
|
||||||
|
const end = defineModel('end', { default: '' });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dropdown :close-on-content-click="false" align="bottom-end">
|
||||||
|
<template #trigger>
|
||||||
|
<button
|
||||||
|
class="px-3 py-1.5 bg-input-background border border-input-border font-medium rounded-lg flex items-center space-x-2">
|
||||||
|
<CalendarIcon class="w-5"></CalendarIcon>
|
||||||
|
<div class="text-white">
|
||||||
|
{{ formatDate(start) }}
|
||||||
|
<span class="px-1.5 text-muted">-</span>
|
||||||
|
{{ formatDate(end) }}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template #content>
|
||||||
|
<div class="overflow-hidden w-[280px] px-3 py-1.5">
|
||||||
|
<div class="flex space-x-3 items-center justify-between">
|
||||||
|
<div class="text-sm font-medium text-muted">Start Date</div>
|
||||||
|
<DatePicker v-model="start"></DatePicker>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex space-x-3 items-center justify-between">
|
||||||
|
<div class="text-sm font-medium text-muted">End Date</div>
|
||||||
|
<DatePicker v-model="end"></DatePicker>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dropdown>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
191
resources/js/Components/Common/MultiselectDropdown.vue
Normal file
191
resources/js/Components/Common/MultiselectDropdown.vue
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
<script setup lang="ts" generic="T">
|
||||||
|
import Dropdown from '@/Components/Dropdown.vue';
|
||||||
|
import { type Component, computed, nextTick, ref, watch } from 'vue';
|
||||||
|
import MultiselectDropdownItem from '@/Components/Common/MultiselectDropdownItem.vue';
|
||||||
|
|
||||||
|
const model = defineModel<string[]>({
|
||||||
|
default: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
items: T[];
|
||||||
|
searchPlaceholder: string;
|
||||||
|
getKeyFromItem: (item: T) => string;
|
||||||
|
getNameForItem: (item: T) => string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const searchInput = ref<HTMLInputElement | null>(null);
|
||||||
|
const open = ref(false);
|
||||||
|
const dropdownViewport = ref<Component | null>(null);
|
||||||
|
|
||||||
|
const searchValue = ref('');
|
||||||
|
|
||||||
|
function isItemSelected(id: string) {
|
||||||
|
return model.value.includes(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOrRemoveItemFromSelection(id: string) {
|
||||||
|
if (model.value.includes(id)) {
|
||||||
|
model.value = model.value.filter((itemId) => itemId !== id);
|
||||||
|
} else {
|
||||||
|
model.value.push(id);
|
||||||
|
}
|
||||||
|
emit('changed');
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(open, (isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
nextTick(() => {
|
||||||
|
searchInput.value?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// sort tags alphabetically
|
||||||
|
[...props.items].sort((a, b) => {
|
||||||
|
const aIsSelected = model.value.includes(props.getKeyFromItem(a));
|
||||||
|
const bIsSelected = model.value.includes(props.getKeyFromItem(b));
|
||||||
|
if (aIsSelected === bIsSelected) {
|
||||||
|
return props
|
||||||
|
.getNameForItem(a)
|
||||||
|
.localeCompare(props.getNameForItem(b));
|
||||||
|
}
|
||||||
|
return model.value.includes(props.getKeyFromItem(a)) ? -1 : 1;
|
||||||
|
});
|
||||||
|
nextTick(() => {
|
||||||
|
if (filteredItems.value.length > 0) {
|
||||||
|
highlightedItemId.value = props.getKeyFromItem(
|
||||||
|
filteredItems.value[0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredItems = computed<T[]>(() => {
|
||||||
|
return props.items.filter((item: T) => {
|
||||||
|
return props
|
||||||
|
.getNameForItem(item)
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchValue.value?.toLowerCase()?.trim() || '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(filteredItems, () => {
|
||||||
|
if (filteredItems.value.length > 0) {
|
||||||
|
highlightedItemId.value = props.getKeyFromItem(filteredItems.value[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateSearchValue(event: Event) {
|
||||||
|
const newInput = (event.target as HTMLInputElement).value;
|
||||||
|
if (newInput === ' ') {
|
||||||
|
searchValue.value = '';
|
||||||
|
const highlightedTagId = highlightedItemId.value;
|
||||||
|
if (highlightedTagId) {
|
||||||
|
const highlightedItem = props.items.find(
|
||||||
|
(item) => props.getKeyFromItem(item) === highlightedTagId
|
||||||
|
);
|
||||||
|
if (highlightedItem) {
|
||||||
|
addOrRemoveItemFromSelection(
|
||||||
|
props.getKeyFromItem(highlightedItem)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
searchValue.value = newInput;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'changed']);
|
||||||
|
|
||||||
|
function toggleItem(newValue: string | null) {
|
||||||
|
if (newValue !== null) {
|
||||||
|
if (model.value.includes(newValue)) {
|
||||||
|
model.value = [...model.value].filter((id) => id !== newValue);
|
||||||
|
} else {
|
||||||
|
model.value = [...model.value, newValue];
|
||||||
|
}
|
||||||
|
emit('changed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveHighlightUp() {
|
||||||
|
if (highlightedItem.value) {
|
||||||
|
const currentHightlightedIndex = filteredItems.value.indexOf(
|
||||||
|
highlightedItem.value
|
||||||
|
);
|
||||||
|
if (currentHightlightedIndex === 0) {
|
||||||
|
highlightedItemId.value = props.getKeyFromItem(
|
||||||
|
filteredItems.value[filteredItems.value.length - 1]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
highlightedItemId.value = props.getKeyFromItem(
|
||||||
|
filteredItems.value[currentHightlightedIndex - 1]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveHighlightDown() {
|
||||||
|
if (highlightedItem.value) {
|
||||||
|
const currentHightlightedIndex = filteredItems.value.indexOf(
|
||||||
|
highlightedItem.value
|
||||||
|
);
|
||||||
|
if (currentHightlightedIndex === filteredItems.value.length - 1) {
|
||||||
|
highlightedItemId.value = props.getKeyFromItem(
|
||||||
|
filteredItems.value[0]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
highlightedItemId.value = props.getKeyFromItem(
|
||||||
|
filteredItems.value[currentHightlightedIndex + 1]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlightedItemId = ref<string | null>(null);
|
||||||
|
const highlightedItem = computed(() => {
|
||||||
|
return props.items.find(
|
||||||
|
(item) => props.getKeyFromItem(item) === highlightedItemId.value
|
||||||
|
);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dropdown v-model="open" align="bottom-start" :closeOnContentClick="false">
|
||||||
|
<template #trigger>
|
||||||
|
<slot name="trigger"></slot>
|
||||||
|
</template>
|
||||||
|
<template #content>
|
||||||
|
<input
|
||||||
|
:value="searchValue"
|
||||||
|
@input="updateSearchValue"
|
||||||
|
@keydown.up.prevent="moveHighlightUp"
|
||||||
|
@keydown.down.prevent="moveHighlightDown"
|
||||||
|
@keydown.enter="toggleItem(highlightedItemId)"
|
||||||
|
ref="searchInput"
|
||||||
|
class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
||||||
|
:placeholder="searchPlaceholder" />
|
||||||
|
<div ref="dropdownViewport" class="w-60">
|
||||||
|
<div
|
||||||
|
v-for="item in filteredItems"
|
||||||
|
:key="props.getKeyFromItem(item)"
|
||||||
|
role="option"
|
||||||
|
:value="props.getKeyFromItem(item)"
|
||||||
|
:class="{
|
||||||
|
'bg-card-background-active':
|
||||||
|
props.getKeyFromItem(item) === highlightedItemId,
|
||||||
|
}"
|
||||||
|
:data-item-id="props.getKeyFromItem(item)">
|
||||||
|
<MultiselectDropdownItem
|
||||||
|
:selected="isItemSelected(props.getKeyFromItem(item))"
|
||||||
|
@click="toggleItem(props.getKeyFromItem(item))"
|
||||||
|
:name="
|
||||||
|
props.getNameForItem(item)
|
||||||
|
"></MultiselectDropdownItem>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dropdown>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -12,7 +12,7 @@ const iconClasses = computed(() => {
|
|||||||
if (props.selected) {
|
if (props.selected) {
|
||||||
return 'text-accent-200';
|
return 'text-accent-200';
|
||||||
} else {
|
} else {
|
||||||
return 'text-card-border';
|
return 'text-white/10';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
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>
|
||||||
142
resources/js/Components/Common/SelectDropdown.vue
Normal file
142
resources/js/Components/Common/SelectDropdown.vue
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<script setup lang="ts" generic="T">
|
||||||
|
import Dropdown from '@/Components/Dropdown.vue';
|
||||||
|
import { type Component, computed, ref, watch } from 'vue';
|
||||||
|
import SelectDropdownItem from '@/Components/Common/SelectDropdownItem.vue';
|
||||||
|
import { onKeyStroke } from '@vueuse/core';
|
||||||
|
|
||||||
|
const model = defineModel<string | null>({
|
||||||
|
default: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
items: T[];
|
||||||
|
getKeyFromItem: (item: T) => string | null;
|
||||||
|
getNameForItem: (item: T) => string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const open = ref(false);
|
||||||
|
const dropdownViewport = ref<Component | null>(null);
|
||||||
|
|
||||||
|
const searchValue = ref('');
|
||||||
|
|
||||||
|
// DropdownMultiselect
|
||||||
|
const filteredItems = computed<T[]>(() => {
|
||||||
|
return props.items.filter((item: T) => {
|
||||||
|
return props
|
||||||
|
.getNameForItem(item)
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchValue.value?.toLowerCase()?.trim() || '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(filteredItems, () => {
|
||||||
|
if (filteredItems.value.length > 0) {
|
||||||
|
highlightedItemId.value = props.getKeyFromItem(filteredItems.value[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'changed']);
|
||||||
|
|
||||||
|
function setItem(newValue: string | null) {
|
||||||
|
model.value = newValue;
|
||||||
|
emit('changed');
|
||||||
|
open.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveHighlightUp() {
|
||||||
|
if (highlightedItem.value) {
|
||||||
|
const currentHightlightedIndex = filteredItems.value.indexOf(
|
||||||
|
highlightedItem.value
|
||||||
|
);
|
||||||
|
if (currentHightlightedIndex === 0) {
|
||||||
|
highlightedItemId.value = props.getKeyFromItem(
|
||||||
|
filteredItems.value[filteredItems.value.length - 1]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
highlightedItemId.value = props.getKeyFromItem(
|
||||||
|
filteredItems.value[currentHightlightedIndex - 1]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveHighlightDown() {
|
||||||
|
if (highlightedItem.value) {
|
||||||
|
const currentHightlightedIndex = filteredItems.value.indexOf(
|
||||||
|
highlightedItem.value
|
||||||
|
);
|
||||||
|
if (currentHightlightedIndex === filteredItems.value.length - 1) {
|
||||||
|
highlightedItemId.value = props.getKeyFromItem(
|
||||||
|
filteredItems.value[0]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
highlightedItemId.value = props.getKeyFromItem(
|
||||||
|
filteredItems.value[currentHightlightedIndex + 1]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlightedItemId = ref<string | null>(null);
|
||||||
|
const highlightedItem = computed(() => {
|
||||||
|
return props.items.find(
|
||||||
|
(item) => props.getKeyFromItem(item) === highlightedItemId.value
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
onKeyStroke('ArrowDown', (e) => {
|
||||||
|
if (open.value === true) {
|
||||||
|
moveHighlightDown();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onKeyStroke('ArrowUp', (e) => {
|
||||||
|
if (open.value === true) {
|
||||||
|
moveHighlightUp();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onKeyStroke('Enter', (e) => {
|
||||||
|
if (open.value === true) {
|
||||||
|
setItem(highlightedItemId.value);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(open, () => {
|
||||||
|
if (open.value === true) {
|
||||||
|
highlightedItemId.value = model.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dropdown v-model="open" align="bottom-start" :closeOnContentClick="false">
|
||||||
|
<template #trigger>
|
||||||
|
<slot name="trigger"></slot>
|
||||||
|
</template>
|
||||||
|
<template #content>
|
||||||
|
<div ref="dropdownViewport" class="w-60">
|
||||||
|
<div
|
||||||
|
v-for="item in filteredItems"
|
||||||
|
:key="props.getKeyFromItem(item) ?? 'none'"
|
||||||
|
role="option"
|
||||||
|
:value="props.getKeyFromItem(item)"
|
||||||
|
:class="{
|
||||||
|
'bg-card-background-active':
|
||||||
|
props.getKeyFromItem(item) === highlightedItemId,
|
||||||
|
}"
|
||||||
|
:data-item-id="props.getKeyFromItem(item)">
|
||||||
|
<SelectDropdownItem
|
||||||
|
:selected="props.getKeyFromItem(item) === model"
|
||||||
|
@click="setItem(props.getKeyFromItem(item))"
|
||||||
|
:name="props.getNameForItem(item)"></SelectDropdownItem>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dropdown>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
23
resources/js/Components/Common/SelectDropdownItem.vue
Normal file
23
resources/js/Components/Common/SelectDropdownItem.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
name: string;
|
||||||
|
selected: boolean;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="
|
||||||
|
twMerge(
|
||||||
|
'flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out',
|
||||||
|
|
||||||
|
props.selected ? 'bg-accent-300/20' : 'hover:bg-card-background'
|
||||||
|
)
|
||||||
|
">
|
||||||
|
<span>{{ name }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -2,9 +2,10 @@
|
|||||||
import { PlusCircleIcon } from '@heroicons/vue/20/solid';
|
import { PlusCircleIcon } from '@heroicons/vue/20/solid';
|
||||||
import Dropdown from '@/Components/Dropdown.vue';
|
import Dropdown from '@/Components/Dropdown.vue';
|
||||||
import { type Component, computed, nextTick, ref, watch } from 'vue';
|
import { type Component, computed, nextTick, ref, watch } from 'vue';
|
||||||
import TagDropdownItem from '@/Components/Common/Tag/TagDropdownItem.vue';
|
|
||||||
import { useTagsStore } from '@/utils/useTags';
|
import { useTagsStore } from '@/utils/useTags';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
|
import TagCreateModal from '@/Components/Common/Tag/TagCreateModal.vue';
|
||||||
|
import MultiselectDropdownItem from '@/Components/Common/MultiselectDropdownItem.vue';
|
||||||
|
|
||||||
const tagsStore = useTagsStore();
|
const tagsStore = useTagsStore();
|
||||||
const { tags } = storeToRefs(tagsStore);
|
const { tags } = storeToRefs(tagsStore);
|
||||||
@@ -47,6 +48,11 @@ watch(open, (isOpen) => {
|
|||||||
}
|
}
|
||||||
return model.value.includes(a.id) ? -1 : 1;
|
return model.value.includes(a.id) ? -1 : 1;
|
||||||
});
|
});
|
||||||
|
nextTick(() => {
|
||||||
|
if (filteredTags.value.length > 0) {
|
||||||
|
highlightedItemId.value = filteredTags.value[0].id;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -96,17 +102,15 @@ function updateSearchValue(event: Event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'changed']);
|
const emit = defineEmits(['update:modelValue', 'changed', 'submit']);
|
||||||
|
|
||||||
function toggleTag(newValue: string) {
|
function toggleTag(newValue: string) {
|
||||||
if (model.value.includes(newValue)) {
|
if (model.value.includes(newValue)) {
|
||||||
model.value = model.value.filter((id) => id !== newValue);
|
model.value = [...model.value].filter((id) => id !== newValue);
|
||||||
} else {
|
} else {
|
||||||
model.value.push(newValue);
|
model.value = [...model.value, newValue];
|
||||||
}
|
}
|
||||||
nextTick(() => {
|
emit('changed');
|
||||||
emit('changed');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveHighlightUp() {
|
function moveHighlightUp() {
|
||||||
@@ -142,10 +146,17 @@ const highlightedItemId = ref<string | null>(null);
|
|||||||
const highlightedItem = computed(() => {
|
const highlightedItem = computed(() => {
|
||||||
return tags.value.find((tag) => tag.id === highlightedItemId.value);
|
return tags.value.find((tag) => tag.id === highlightedItemId.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const showCreateTagModal = ref(false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dropdown width="120" v-model="open" :closeOnContentClick="false">
|
<TagCreateModal v-model:show="showCreateTagModal"></TagCreateModal>
|
||||||
|
<Dropdown
|
||||||
|
@submit="emit('submit')"
|
||||||
|
v-model="open"
|
||||||
|
align="bottom-start"
|
||||||
|
:closeOnContentClick="false">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<slot name="trigger"></slot>
|
<slot name="trigger"></slot>
|
||||||
</template>
|
</template>
|
||||||
@@ -159,20 +170,20 @@ const highlightedItem = computed(() => {
|
|||||||
@keydown.down.prevent="moveHighlightDown"
|
@keydown.down.prevent="moveHighlightDown"
|
||||||
ref="searchInput"
|
ref="searchInput"
|
||||||
class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
||||||
placeholder="Search for a tag..." />
|
placeholder="Search for a Tag..." />
|
||||||
<div ref="dropdownViewport" class="w-60">
|
<div ref="dropdownViewport" class="w-60">
|
||||||
<div
|
<div
|
||||||
v-if="searchValue.length > 0 && filteredTags.length === 0"
|
v-if="searchValue.length > 0 && filteredTags.length === 0"
|
||||||
class="bg-card-background-active">
|
class="bg-card-background-active rounded-b-lg">
|
||||||
<div
|
<div
|
||||||
@click="addTagIfNoneExists"
|
@click="addTagIfNoneExists"
|
||||||
class="text-white flex space-x-3 items-center px-4 py-3 text-xs font-medium border-t rounded-b-lg border-card-background-separator">
|
class="text-white flex space-x-3 items-center px-4 py-3 text-xs font-medium border-t border-card-background-separator">
|
||||||
<PlusCircleIcon
|
<PlusCircleIcon
|
||||||
class="w-5 flex-shrink-0"></PlusCircleIcon>
|
class="w-5 flex-shrink-0"></PlusCircleIcon>
|
||||||
<span>Add "{{ searchValue }}" as a new Tag</span>
|
<span>Add "{{ searchValue }}" as a new Tag</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else></div>
|
|
||||||
<div
|
<div
|
||||||
v-for="tag in filteredTags"
|
v-for="tag in filteredTags"
|
||||||
:key="tag.id"
|
:key="tag.id"
|
||||||
@@ -184,10 +195,22 @@ const highlightedItem = computed(() => {
|
|||||||
}"
|
}"
|
||||||
data-testid="tag_dropdown_entries"
|
data-testid="tag_dropdown_entries"
|
||||||
:data-tag-id="tag.id">
|
:data-tag-id="tag.id">
|
||||||
<TagDropdownItem
|
<MultiselectDropdownItem
|
||||||
:selected="isTagSelected(tag.id)"
|
:selected="isTagSelected(tag.id)"
|
||||||
@click="toggleTag(tag.id)"
|
@click="toggleTag(tag.id)"
|
||||||
:name="tag.name"></TagDropdownItem>
|
:name="tag.name"></MultiselectDropdownItem>
|
||||||
|
</div>
|
||||||
|
<div class="hover:bg-card-background-active rounded-b-lg">
|
||||||
|
<button
|
||||||
|
@click="
|
||||||
|
open = false;
|
||||||
|
showCreateTagModal = true;
|
||||||
|
"
|
||||||
|
class="text-white flex space-x-3 items-center px-4 py-3 text-xs font-semibold border-t border-card-background-separator">
|
||||||
|
<PlusCircleIcon
|
||||||
|
class="w-5 flex-shrink-0 text-icon-default"></PlusCircleIcon>
|
||||||
|
<span>Create new Tag</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import MultiselectDropdown from '@/Components/Common/MultiselectDropdown.vue';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import type { Task } from '@/utils/api';
|
||||||
|
import { useTasksStore } from '@/utils/useTasks';
|
||||||
|
|
||||||
|
const tasksStore = useTasksStore();
|
||||||
|
const { tasks } = storeToRefs(tasksStore);
|
||||||
|
|
||||||
|
function getKeyFromItem(item: Task) {
|
||||||
|
return item.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNameForItem(item: Task) {
|
||||||
|
return item.name;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<MultiselectDropdown
|
||||||
|
searchPlaceholder="Search for a Task..."
|
||||||
|
:items="tasks"
|
||||||
|
:get-key-from-item="getKeyFromItem"
|
||||||
|
:get-name-for-item="getNameForItem">
|
||||||
|
<template #trigger>
|
||||||
|
<slot name="trigger"></slot>
|
||||||
|
</template>
|
||||||
|
</MultiselectDropdown>
|
||||||
|
</template>
|
||||||
@@ -1,13 +1,296 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import MainContainer from '@/Pages/MainContainer.vue';
|
import MainContainer from '@/Pages/MainContainer.vue';
|
||||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||||
|
import { FolderIcon } from '@heroicons/vue/16/solid';
|
||||||
|
import PageTitle from '@/Components/Common/PageTitle.vue';
|
||||||
|
import {
|
||||||
|
ChartBarIcon,
|
||||||
|
UserGroupIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
TagIcon,
|
||||||
|
} from '@heroicons/vue/20/solid';
|
||||||
|
import DateRangePicker from '@/Components/Common/DateRangePicker.vue';
|
||||||
|
import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue';
|
||||||
|
import BillableIcon from '@/Components/Common/Icons/BillableIcon.vue';
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { formatHumanReadableDuration, getDayJsInstance } from '@/utils/time';
|
||||||
|
import { useReportingStore } from '@/utils/useReporting';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import TagDropdown from '@/Components/Common/Tag/TagDropdown.vue';
|
||||||
|
import type { AggregatedTimeEntriesQueryParams } from '@/utils/api';
|
||||||
|
import ReportingFilterBadge from '@/Components/Common/Reporting/ReportingFilterBadge.vue';
|
||||||
|
import ProjectMultiselectDropdown from '@/Components/Common/Project/ProjectMultiselectDropdown.vue';
|
||||||
|
import MemberMultiselectDropdown from '@/Components/Common/Member/MemberMultiselectDropdown.vue';
|
||||||
|
import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';
|
||||||
|
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';
|
||||||
|
|
||||||
|
const startDate = ref<string | null>(
|
||||||
|
getDayJsInstance()().subtract(31, 'd').format('YYYY-MM-DD')
|
||||||
|
);
|
||||||
|
const endDate = ref<string | null>(getDayJsInstance()().format('YYYY-MM-DD'));
|
||||||
|
const selectedTags = ref<string[]>([]);
|
||||||
|
const selectedProjects = ref<string[]>([]);
|
||||||
|
const selectedMembers = ref<string[]>([]);
|
||||||
|
const selectedTasks = ref<string[]>([]);
|
||||||
|
const billable = ref<'true' | 'false' | null>(null);
|
||||||
|
|
||||||
|
type GroupingOption = 'project' | 'task' | 'user' | 'billable' | 'client';
|
||||||
|
|
||||||
|
const group = ref<GroupingOption>('project');
|
||||||
|
const subGroup = ref<GroupingOption>('task');
|
||||||
|
|
||||||
|
function getFilterAttributes() {
|
||||||
|
let params: AggregatedTimeEntriesQueryParams = {
|
||||||
|
after: getDayJsInstance()(startDate.value).utc().format(),
|
||||||
|
before: getDayJsInstance()(endDate.value).endOf('day').utc().format(),
|
||||||
|
};
|
||||||
|
if (selectedMembers.value.length > 0) {
|
||||||
|
params = {
|
||||||
|
...params,
|
||||||
|
member_ids: selectedMembers.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (selectedProjects.value.length > 0) {
|
||||||
|
params = {
|
||||||
|
...params,
|
||||||
|
project_ids: selectedProjects.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (selectedTasks.value.length > 0) {
|
||||||
|
params = {
|
||||||
|
...params,
|
||||||
|
task_ids: selectedTasks.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (selectedTags.value.length > 0) {
|
||||||
|
params = {
|
||||||
|
...params,
|
||||||
|
tag_ids: selectedTags.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (billable.value !== null) {
|
||||||
|
params = {
|
||||||
|
...params,
|
||||||
|
billable: billable.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGraphReporting() {
|
||||||
|
const diffInDays = getDayJsInstance()(endDate.value).diff(
|
||||||
|
getDayJsInstance()(startDate.value),
|
||||||
|
'd'
|
||||||
|
);
|
||||||
|
const params = getFilterAttributes();
|
||||||
|
params.group = getOptimalGroupingOption(diffInDays);
|
||||||
|
useReportingStore().fetchGraphReporting(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTableReporting() {
|
||||||
|
const params = getFilterAttributes();
|
||||||
|
params.group = group.value;
|
||||||
|
params.sub_group = subGroup.value;
|
||||||
|
useReportingStore().fetchTableReporting(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateReporting() {
|
||||||
|
updateGraphReporting();
|
||||||
|
updateTableReporting();
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportingStore = useReportingStore();
|
||||||
|
|
||||||
|
const { aggregatedGraphTimeEntries, aggregatedTableTimeEntries } =
|
||||||
|
storeToRefs(reportingStore);
|
||||||
|
|
||||||
|
function getOptimalGroupingOption(diff: number): 'day' | 'week' | 'month' {
|
||||||
|
if (diff <= 31) {
|
||||||
|
return 'day';
|
||||||
|
} else if (diff <= 365) {
|
||||||
|
return 'week';
|
||||||
|
} else {
|
||||||
|
return 'month';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateGraphReporting();
|
||||||
|
updateTableReporting();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout title="Reporting" data-testid="reporting_view">
|
<AppLayout title="Reporting" data-testid="reporting_view">
|
||||||
<MainContainer
|
<MainContainer
|
||||||
class="py-8 border-b border-default-background-separator">
|
class="py-3 sm:py-5 border-b border-default-background-separator flex justify-between items-center">
|
||||||
Reporting is coming soon™
|
<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>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<div class="text-sm font-medium">Filters</div>
|
||||||
|
<MemberMultiselectDropdown
|
||||||
|
@submit="updateReporting"
|
||||||
|
v-model="selectedMembers">
|
||||||
|
<template v-slot:trigger>
|
||||||
|
<ReportingFilterBadge
|
||||||
|
:count="selectedMembers.length"
|
||||||
|
:active="selectedMembers.length > 0"
|
||||||
|
title="Members"
|
||||||
|
:icon="UserGroupIcon"></ReportingFilterBadge>
|
||||||
|
</template>
|
||||||
|
</MemberMultiselectDropdown>
|
||||||
|
<ProjectMultiselectDropdown
|
||||||
|
@submit="updateReporting"
|
||||||
|
v-model="selectedProjects">
|
||||||
|
<template v-slot:trigger>
|
||||||
|
<ReportingFilterBadge
|
||||||
|
:count="selectedProjects.length"
|
||||||
|
:active="selectedProjects.length > 0"
|
||||||
|
title="Projects"
|
||||||
|
:icon="FolderIcon"></ReportingFilterBadge>
|
||||||
|
</template>
|
||||||
|
</ProjectMultiselectDropdown>
|
||||||
|
<TaskMultiselectDropdown
|
||||||
|
@submit="updateReporting"
|
||||||
|
v-model="selectedTasks">
|
||||||
|
<template v-slot:trigger>
|
||||||
|
<ReportingFilterBadge
|
||||||
|
:count="selectedTasks.length"
|
||||||
|
:active="selectedTasks.length > 0"
|
||||||
|
title="Tasks"
|
||||||
|
:icon="CheckCircleIcon"></ReportingFilterBadge>
|
||||||
|
</template>
|
||||||
|
</TaskMultiselectDropdown>
|
||||||
|
<TagDropdown
|
||||||
|
@submit="updateReporting"
|
||||||
|
v-model="selectedTags">
|
||||||
|
<template v-slot:trigger>
|
||||||
|
<ReportingFilterBadge
|
||||||
|
:count="selectedTags.length"
|
||||||
|
:active="selectedTags.length > 0"
|
||||||
|
title="Tags"
|
||||||
|
:icon="TagIcon"></ReportingFilterBadge>
|
||||||
|
</template>
|
||||||
|
</TagDropdown>
|
||||||
|
|
||||||
|
<SelectDropdown
|
||||||
|
v-model="billable"
|
||||||
|
:get-key-from-item="(item) => item.value"
|
||||||
|
:get-name-for-item="(item) => item.label"
|
||||||
|
:items="[
|
||||||
|
{
|
||||||
|
label: 'Both',
|
||||||
|
value: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Billable',
|
||||||
|
value: 'true',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Non Billable',
|
||||||
|
value: 'false',
|
||||||
|
},
|
||||||
|
]">
|
||||||
|
<template v-slot:trigger>
|
||||||
|
<ReportingFilterBadge
|
||||||
|
:active="billable !== null"
|
||||||
|
:title="
|
||||||
|
billable === 'false'
|
||||||
|
? 'Non Billable'
|
||||||
|
: 'Billable'
|
||||||
|
"
|
||||||
|
:icon="BillableIcon"></ReportingFilterBadge>
|
||||||
|
</template>
|
||||||
|
</SelectDropdown>
|
||||||
|
</div>
|
||||||
|
</MainContainer>
|
||||||
|
</div>
|
||||||
|
<MainContainer>
|
||||||
|
<div class="pt-10 w-full px-3 relative">
|
||||||
|
<ReportingChart
|
||||||
|
:groupedData="
|
||||||
|
aggregatedGraphTimeEntries?.grouped_data
|
||||||
|
"></ReportingChart>
|
||||||
|
</div>
|
||||||
|
</MainContainer>
|
||||||
|
<MainContainer>
|
||||||
|
<div class="grid grid-cols-4 pt-6">
|
||||||
|
<div
|
||||||
|
class="col-span-3 bg-card-background rounded-lg border border-card-border pt-3">
|
||||||
|
<div
|
||||||
|
class="text-sm flex text-white items-center space-x-3 font-medium px-6 border-b border-card-background-separator pb-3">
|
||||||
|
<span>Group by</span>
|
||||||
|
<ReportingGroupBySelect
|
||||||
|
@changed="updateTableReporting"
|
||||||
|
v-model="group"></ReportingGroupBySelect>
|
||||||
|
<span>and</span>
|
||||||
|
<ReportingGroupBySelect
|
||||||
|
@changed="updateTableReporting"
|
||||||
|
v-model="subGroup"></ReportingGroupBySelect>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="grid items-center"
|
||||||
|
style="grid-template-columns: 1fr 100px 150px">
|
||||||
|
<div
|
||||||
|
class="contents [&>*]:border-card-background-separator [&>*]:border-b [&>*]:bg-tertiary [&>*]:pb-1.5 [&>*]:pt-1 text-muted text-sm">
|
||||||
|
<div class="pl-6">Name</div>
|
||||||
|
<div class="text-right">Duration</div>
|
||||||
|
<div class="text-right pr-6">Cost</div>
|
||||||
|
</div>
|
||||||
|
<template
|
||||||
|
v-if="
|
||||||
|
aggregatedTableTimeEntries?.grouped_data &&
|
||||||
|
aggregatedTableTimeEntries.grouped_data
|
||||||
|
?.length > 0
|
||||||
|
">
|
||||||
|
<ReportingRow
|
||||||
|
v-for="entry in aggregatedTableTimeEntries.grouped_data"
|
||||||
|
:key="entry.key ?? 'none'"
|
||||||
|
:entry="entry"></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">
|
||||||
|
{{
|
||||||
|
formatHumanReadableDuration(
|
||||||
|
aggregatedTableTimeEntries.seconds
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div class="justify-end pr-6 flex items-center">
|
||||||
|
{{
|
||||||
|
formatMoney(
|
||||||
|
aggregatedTableTimeEntries.cost
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div
|
||||||
|
class="chart flex flex-col items-center justify-center py-12 col-span-3"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
ApiOf,
|
ApiOf,
|
||||||
ZodiosResponseByAlias,
|
ZodiosResponseByAlias,
|
||||||
ZodiosBodyByAlias,
|
ZodiosBodyByAlias,
|
||||||
|
ZodiosQueryParamsByAlias,
|
||||||
} from '@zodios/core';
|
} from '@zodios/core';
|
||||||
import { api } from '../../../openapi.json.client';
|
import { api } from '../../../openapi.json.client';
|
||||||
|
|
||||||
@@ -84,3 +85,16 @@ export type ImportType = ZodiosResponseByAlias<
|
|||||||
'getImporters'
|
'getImporters'
|
||||||
>['data'][0];
|
>['data'][0];
|
||||||
export type ImportReport = ZodiosResponseByAlias<SolidTimeApi, 'importData'>;
|
export type ImportReport = ZodiosResponseByAlias<SolidTimeApi, 'importData'>;
|
||||||
|
|
||||||
|
export type ReportingResponse = ZodiosResponseByAlias<
|
||||||
|
SolidTimeApi,
|
||||||
|
'getAggregatedTimeEntries'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type AggregatedTimeEntries = ReportingResponse['data'];
|
||||||
|
export type GroupedDataEntries = ReportingResponse['data']['grouped_data'];
|
||||||
|
|
||||||
|
export type AggregatedTimeEntriesQueryParams = ZodiosQueryParamsByAlias<
|
||||||
|
SolidTimeApi,
|
||||||
|
'getAggregatedTimeEntries'
|
||||||
|
>;
|
||||||
|
|||||||
68
resources/js/utils/useReporting.ts
Normal file
68
resources/js/utils/useReporting.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { api } from '../../../openapi.json.client';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import type {
|
||||||
|
AggregatedTimeEntries,
|
||||||
|
AggregatedTimeEntriesQueryParams,
|
||||||
|
ReportingResponse,
|
||||||
|
} from '@/utils/api';
|
||||||
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
|
import { useNotificationsStore } from '@/utils/notification';
|
||||||
|
|
||||||
|
export const useReportingStore = defineStore('reporting', () => {
|
||||||
|
const reportingGraphResponse = ref<ReportingResponse | null>(null);
|
||||||
|
const reportingTableResponse = ref<ReportingResponse | null>(null);
|
||||||
|
|
||||||
|
const { handleApiRequestNotifications } = useNotificationsStore();
|
||||||
|
|
||||||
|
async function fetchGraphReporting(
|
||||||
|
params: AggregatedTimeEntriesQueryParams
|
||||||
|
) {
|
||||||
|
const organization = getCurrentOrganizationId();
|
||||||
|
if (organization) {
|
||||||
|
reportingGraphResponse.value = await handleApiRequestNotifications(
|
||||||
|
api.getAggregatedTimeEntries({
|
||||||
|
params: {
|
||||||
|
organization: organization,
|
||||||
|
},
|
||||||
|
queries: params,
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
'Failed to fetch reporting data'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTableReporting(
|
||||||
|
params: AggregatedTimeEntriesQueryParams
|
||||||
|
) {
|
||||||
|
const organization = getCurrentOrganizationId();
|
||||||
|
if (organization) {
|
||||||
|
reportingTableResponse.value = await handleApiRequestNotifications(
|
||||||
|
api.getAggregatedTimeEntries({
|
||||||
|
params: {
|
||||||
|
organization: organization,
|
||||||
|
},
|
||||||
|
queries: params,
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
'Failed to fetch reporting data'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const aggregatedGraphTimeEntries = computed<AggregatedTimeEntries>(() => {
|
||||||
|
return reportingGraphResponse.value?.data as AggregatedTimeEntries;
|
||||||
|
});
|
||||||
|
|
||||||
|
const aggregatedTableTimeEntries = computed<AggregatedTimeEntries>(() => {
|
||||||
|
return reportingTableResponse.value?.data as AggregatedTimeEntries;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
aggregatedGraphTimeEntries,
|
||||||
|
fetchGraphReporting,
|
||||||
|
fetchTableReporting,
|
||||||
|
aggregatedTableTimeEntries,
|
||||||
|
};
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user