mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-17 12:42:15 +01:00
add dashboard frontend
This commit is contained in:
87
resources/js/Components/Dashboard/ActivityGraphCard.vue
Normal file
87
resources/js/Components/Dashboard/ActivityGraphCard.vue
Normal 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>
|
||||
25
resources/js/Components/Dashboard/DashboardCard.vue
Normal file
25
resources/js/Components/Dashboard/DashboardCard.vue
Normal 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>
|
||||
103
resources/js/Components/Dashboard/DayOverviewCardEntry.vue
Normal file
103
resources/js/Components/Dashboard/DayOverviewCardEntry.vue
Normal 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>
|
||||
22
resources/js/Components/Dashboard/LastSevenDaysCard.vue
Normal file
22
resources/js/Components/Dashboard/LastSevenDaysCard.vue
Normal 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>
|
||||
105
resources/js/Components/Dashboard/ProjectsChartCard.vue
Normal file
105
resources/js/Components/Dashboard/ProjectsChartCard.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
27
resources/js/Components/Dashboard/TeamActivityCard.vue
Normal file
27
resources/js/Components/Dashboard/TeamActivityCard.vue
Normal 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>
|
||||
31
resources/js/Components/Dashboard/TeamActivityCardEntry.vue
Normal file
31
resources/js/Components/Dashboard/TeamActivityCardEntry.vue
Normal 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>
|
||||
171
resources/js/Components/Dashboard/ThisWeekOverview.vue
Normal file
171
resources/js/Components/Dashboard/ThisWeekOverview.vue
Normal 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>
|
||||
Reference in New Issue
Block a user