add reporting page, chart and filters

This commit is contained in:
Gregor Vostrak
2024-05-21 01:54:45 +02:00
parent 8b12dec546
commit c67c5e46e1
15 changed files with 1203 additions and 17 deletions

View 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>

View 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>

View File

@@ -12,7 +12,7 @@ const iconClasses = computed(() => {
if (props.selected) {
return 'text-accent-200';
} else {
return 'text-card-border';
return 'text-white/10';
}
});
</script>

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View 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>

View File

@@ -2,9 +2,10 @@
import { PlusCircleIcon } from '@heroicons/vue/20/solid';
import Dropdown from '@/Components/Dropdown.vue';
import { type Component, computed, nextTick, ref, watch } from 'vue';
import TagDropdownItem from '@/Components/Common/Tag/TagDropdownItem.vue';
import { useTagsStore } from '@/utils/useTags';
import { storeToRefs } from 'pinia';
import TagCreateModal from '@/Components/Common/Tag/TagCreateModal.vue';
import MultiselectDropdownItem from '@/Components/Common/MultiselectDropdownItem.vue';
const tagsStore = useTagsStore();
const { tags } = storeToRefs(tagsStore);
@@ -47,6 +48,11 @@ watch(open, (isOpen) => {
}
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) {
if (model.value.includes(newValue)) {
model.value = model.value.filter((id) => id !== newValue);
model.value = [...model.value].filter((id) => id !== newValue);
} else {
model.value.push(newValue);
model.value = [...model.value, newValue];
}
nextTick(() => {
emit('changed');
});
emit('changed');
}
function moveHighlightUp() {
@@ -142,10 +146,17 @@ const highlightedItemId = ref<string | null>(null);
const highlightedItem = computed(() => {
return tags.value.find((tag) => tag.id === highlightedItemId.value);
});
const showCreateTagModal = ref(false);
</script>
<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>
<slot name="trigger"></slot>
</template>
@@ -159,20 +170,20 @@ const highlightedItem = computed(() => {
@keydown.down.prevent="moveHighlightDown"
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="Search for a tag..." />
placeholder="Search for a Tag..." />
<div ref="dropdownViewport" class="w-60">
<div
v-if="searchValue.length > 0 && filteredTags.length === 0"
class="bg-card-background-active">
class="bg-card-background-active rounded-b-lg">
<div
@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
class="w-5 flex-shrink-0"></PlusCircleIcon>
<span>Add "{{ searchValue }}" as a new Tag</span>
</div>
</div>
<div v-else></div>
<div
v-for="tag in filteredTags"
:key="tag.id"
@@ -184,10 +195,22 @@ const highlightedItem = computed(() => {
}"
data-testid="tag_dropdown_entries"
:data-tag-id="tag.id">
<TagDropdownItem
<MultiselectDropdownItem
:selected="isTagSelected(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>
</template>

View File

@@ -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>