add dashboard frontend

This commit is contained in:
Gregor Vostrak
2024-03-11 18:02:54 +01:00
parent e8912650c0
commit 20fc123c36
86 changed files with 5124 additions and 849 deletions

View File

@@ -0,0 +1,87 @@
<script lang="ts" setup>
import VChart, { THEME_KEY } from 'vue-echarts';
import { provide, ref } from 'vue';
import { use } from 'echarts/core';
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
import { BoltIcon } from '@heroicons/vue/20/solid';
import { HeatmapChart } from 'echarts/charts';
import {
CalendarComponent,
TitleComponent,
TooltipComponent,
VisualMapComponent,
} from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import dayjs from 'dayjs';
const props = defineProps<{
dailyHoursTracked: [string, number][];
}>();
use([
TitleComponent,
TooltipComponent,
VisualMapComponent,
CalendarComponent,
HeatmapChart,
CanvasRenderer,
]);
provide(THEME_KEY, 'dark');
const max = Math.max(...props.dailyHoursTracked.map((el) => el[1]));
const option = ref({
tooltip: {},
visualMap: {
min: 0,
max: max,
type: 'piecewise',
orient: 'horizontal',
left: 'center',
top: 'center',
inRange: {
color: ['#242940', '#2DBE45'],
},
show: false,
},
calendar: {
top: 40,
bottom: 20,
left: 40,
right: 10,
cellSize: [40, 40],
splitLine: {
show: false,
},
range: [
dayjs().format('YYYY-MM-DD'),
dayjs().subtract(50, 'day').startOf('week').format('YYYY-MM-DD'),
],
itemStyle: {
borderWidth: 8,
borderColor: '#13152B',
},
yearLabel: { show: false },
},
series: {
type: 'heatmap',
coordinateSystem: 'calendar',
data: props.dailyHoursTracked,
itemStyle: {
borderRadius: 5,
},
},
backgroundColor: 'transparent',
});
</script>
<template>
<DashboardCard title="Activity Graph" :icon="BoltIcon">
<div class="px-2">
<v-chart class="chart" :option="option" style="height: 310px" />
</div>
</DashboardCard>
</template>
<style></style>

View File

@@ -0,0 +1,25 @@
<template>
<section class="">
<h3
class="text-white font-bold pb-4 text-lg flex items-center space-x-2.5">
<component
v-if="icon"
:is="icon"
class="w-6 text-icon-default"></component>
<span>{{ title }}</span>
</h3>
<div
class="rounded-lg bg-card-background border border-card-border divide-y divide-card-background-seperator">
<slot></slot>
</div>
</section>
</template>
<script setup lang="ts">
import type { Component } from 'vue';
defineProps<{
title: string;
icon?: Component;
}>();
</script>

View File

@@ -0,0 +1,103 @@
<script setup lang="ts">
import dayjs from 'dayjs';
defineProps<{
date: string;
duration: number;
}>();
import relativeTime from 'dayjs/plugin/relativeTime';
import isToday from 'dayjs/plugin/isToday';
import isYesterday from 'dayjs/plugin/isYesterday';
import { formatHumanReadableDuration } from '@/utils/time';
dayjs.extend(relativeTime);
dayjs.extend(isToday);
dayjs.extend(isYesterday);
function dayFormat(date: string) {
if (dayjs(date).isToday()) {
return 'Today';
} else if (dayjs(date).isYesterday()) {
return 'Yesterday';
}
return dayjs(date).fromNow();
}
</script>
<template>
<div class="px-4 py-2 grid grid-cols-3">
<div class="flex items-center">
<p class="font-semibold text-white pb-1">
{{ dayFormat(date) }}
</p>
</div>
<div class="flex items-center">
<svg
class="w-20"
viewBox="0 0 42 10"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<rect
y="9.28572"
width="4.18367"
height="0.714286"
rx="0.357143"
fill="#B0D7FF"
fill-opacity="0.9" />
<rect
x="5.37939"
y="7.85715"
width="4.18367"
height="2.14286"
rx="0.714286"
fill="#B0D7FF"
fill-opacity="0.9" />
<rect
x="10.7578"
y="4.28572"
width="4.18367"
height="5.71429"
rx="0.714286"
fill="#B0D7FF"
fill-opacity="0.9" />
<rect
x="16.1372"
width="4.18367"
height="10"
rx="0.714286"
fill="#B0D7FF"
fill-opacity="0.9" />
<rect
width="4.18367"
height="6.42857"
rx="0.714286"
transform="matrix(1 0 0 -1 21.5161 10)"
fill="#B0D7FF"
fill-opacity="0.9" />
<rect
width="4.18367"
height="5"
rx="0.714286"
transform="matrix(1 0 0 -1 26.8955 10)"
fill="#B0D7FF"
fill-opacity="0.9" />
<rect
width="4.18367"
height="0.714286"
rx="0.357143"
transform="matrix(1 0 0 -1 32.2739 10)"
fill="#B0D7FF"
fill-opacity="0.9" />
<rect
width="4.18367"
height="0.714286"
rx="0.357143"
transform="matrix(1 0 0 -1 37.6533 10)"
fill="#B0D7FF"
fill-opacity="0.9" />
</svg>
</div>
<div class="flex items-center justify-center text-muted font-semibold">
{{ formatHumanReadableDuration(duration) }}
</div>
</div>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
import DayOverviewCardEntry from '@/Components/Dashboard/DayOverviewCardEntry.vue';
import { CalendarIcon } from '@heroicons/vue/20/solid';
defineProps<{
last7Days: {
date: string;
duration: number; // Total duration in seconds
history: number[]; // Array representing the duration in seconds of the 3h windows for the day
}[];
}>();
</script>
<template>
<DashboardCard title="Last 7 Days" :icon="CalendarIcon">
<DayOverviewCardEntry
v-for="day in last7Days"
:key="day.date"
:date="day.date"
:duration="day.duration"></DayOverviewCardEntry>
</DashboardCard>
</template>

View File

@@ -0,0 +1,105 @@
<script setup lang="ts">
import VChart, { THEME_KEY } from 'vue-echarts';
import { 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';
use([
CanvasRenderer,
PieChart,
TitleComponent,
GridComponent,
TooltipComponent,
LegendComponent,
]);
provide(THEME_KEY, 'dark');
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<{
weeklyProjectOverview: {
value: number;
name: string;
color: string;
}[];
}>();
const seriesData = props.weeklyProjectOverview.map((el) => {
return {
...el,
...{
itemStyle: {
borderRadius: 15,
// TODO: Fix dynamic color
borderColor: '#040618',
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({
backgroundColor: 'transparent',
series: [
{
label: {
// TODO: Muted color make dynamic
color: '#D9DCFB',
fontWeight: 'bold',
},
data: seriesData,
radius: ['30%', '65%'],
type: 'pie',
},
],
});
</script>
<template>
<v-chart class="chart" :option="option" />
</template>
<style scoped>
.chart {
height: 300px;
background: transparent;
}
</style>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import RecentlyTrackedTasksCardEntry from '@/Components/Dashboard/RecentlyTrackedTasksCardEntry.vue';
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
const props = defineProps<{
latestTasks: {
id: string;
name: string;
project_name: string;
project_id: string;
}[];
}>();
</script>
<template>
<DashboardCard title="Recently Tracked Tasks" :icon="CheckCircleIcon">
<RecentlyTrackedTasksCardEntry
v-for="lastTask in props.latestTasks"
:key="lastTask.id"
:project="lastTask.project_name"
:title="lastTask.name"></RecentlyTrackedTasksCardEntry>
</DashboardCard>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import ProjectBadge from '@/Components/common/ProjectBadge.vue';
import TimeTrackerStartStop from '@/Components/common/TimeTrackerStartStop.vue';
defineProps<{
title: string;
project: string;
}>();
</script>
<template>
<div class="px-4 py-2.5 grid grid-cols-5">
<div class="col-span-4">
<p class="font-semibold text-white pb-1">
{{ title }}
</p>
<ProjectBadge :name="project"></ProjectBadge>
</div>
<div class="flex items-center justify-center">
<TimeTrackerStartStop></TimeTrackerStartStop>
</div>
</div>
</template>

View File

@@ -0,0 +1,27 @@
<script lang="ts" setup>
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
import TeamActivityCardEntry from '@/Components/Dashboard/TeamActivityCardEntry.vue';
import { UserGroupIcon } from '@heroicons/vue/20/solid';
defineProps<{
latestTeamActivity: {
user_id: string;
name: string;
description: string;
time_entry_id: string;
task_id: string;
status: boolean;
}[];
}>();
</script>
<template>
<DashboardCard title="Team Activity" :icon="UserGroupIcon">
<TeamActivityCardEntry
v-for="activity in latestTeamActivity"
:key="activity.user_id"
:name="activity.name"
:description="activity.description"
:working="activity.status"></TeamActivityCardEntry>
</DashboardCard>
</template>

View File

@@ -0,0 +1,31 @@
<script lang="ts" setup>
defineProps<{
name: string;
description: string;
working?: boolean;
}>();
</script>
<template>
<div class="px-4 py-3 grid grid-cols-3">
<div class="col-span-2">
<p class="font-semibold text-white pb-1">
{{ name }}
</p>
<div class="text-muted font-medium">
{{ description }}
</div>
</div>
<div v-if="working" class="flex space-x-1.5 items-center justify-end">
<span class="relative flex h-3 w-3 justify-center items-center">
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
<span
class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
<span class="text-green-500 font-medium block pb-0.5">
working
</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,171 @@
<script setup lang="ts">
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { BarChart } from 'echarts/charts';
import {
GridComponent,
LegendComponent,
TitleComponent,
TooltipComponent,
} from 'echarts/components';
import VChart, { THEME_KEY } from 'vue-echarts';
import { provide, ref } from 'vue';
import StatCard from '@/Components/common/StatCard.vue';
import { ClockIcon } from '@heroicons/vue/20/solid';
import CardTitle from '@/Components/common/CardTitle.vue';
import LinearGradient from 'zrender/lib/graphic/LinearGradient';
import ProjectsChartCard from '@/Components/Dashboard/ProjectsChartCard.vue';
import { formatHumanReadableDuration } from '@/utils/time';
import { formatMoney } from '@/utils/money';
use([
CanvasRenderer,
BarChart,
TitleComponent,
GridComponent,
TooltipComponent,
LegendComponent,
]);
provide(THEME_KEY, 'dark');
const props = defineProps<{
weeklyProjectOverview: {
value: number;
name: string;
color: string;
}[];
totalWeeklyTime: number;
totalWeeklyBillableTime: number;
totalWeeklyBillableAmount: {
value: number;
currency: string;
};
weeklyHistory: {
date: string;
duration: number;
}[];
}>();
const seriesData = props.weeklyHistory.map((el) => {
return {
value: el.duration,
...{
itemStyle: {
borderColor: new LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: 'rgba(125,156,188,1)',
},
{
offset: 1,
color: 'rgba(125,156,188,0.7)',
},
]),
borderWidth: 3,
borderRadius: [12, 12, 0, 0],
color: new LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: 'rgba(125,156,188,0.9)',
},
{
offset: 1,
color: 'rgba(125,156,188,0.4)',
},
]),
},
},
};
});
const option = ref({
grid: {
top: 0,
right: 0,
bottom: 50,
left: 0,
},
backgroundColor: 'transparent',
xAxis: {
type: 'category',
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
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',
},
],
});
</script>
<template>
<div class="grid gap-x-6 grid-cols-4">
<div class="col-span-3">
<CardTitle
title="This Week"
class="pb-8"
:icon="ClockIcon"></CardTitle>
<v-chart class="chart" :option="option" />
</div>
<div class="space-y-6">
<StatCard
title="Total Time"
:value="formatHumanReadableDuration(props.totalWeeklyTime)" />
<StatCard
title="Billable Time"
:value="
formatHumanReadableDuration(props.totalWeeklyBillableTime)
" />
<StatCard
title="Billable Amount"
:value="
formatMoney(
props.totalWeeklyBillableAmount.value,
props.totalWeeklyBillableAmount.currency
)
" />
<ProjectsChartCard
:weekly-project-overview="
props.weeklyProjectOverview
"></ProjectsChartCard>
</div>
</div>
</template>
<style scoped>
.chart {
height: 300px;
background: transparent;
}
</style>