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

@@ -10,7 +10,7 @@ defineProps({
leave-active-class="transition ease-in duration-1000"
leave-from-class="opacity-100"
leave-to-class="opacity-0">
<div v-show="on" class="text-sm text-gray-600 dark:text-gray-400">
<div v-show="on" class="text-sm text-muted">
<slot />
</div>
</transition>

View File

@@ -15,7 +15,7 @@ import SectionTitle from './SectionTitle.vue';
<div class="mt-5 md:mt-0 md:col-span-2">
<div
class="px-4 py-5 sm:p-6 bg-white dark:bg-gray-800 shadow sm:rounded-lg">
class="px-4 py-5 sm:p-6 bg-card-background shadow sm:rounded-lg">
<slot name="content" />
</div>
</div>

View File

@@ -48,12 +48,11 @@ const close = () => {
</div>
<div class="mt-3 text-center sm:mt-0 sm:ms-4 sm:text-start">
<h3
class="text-lg font-medium text-gray-900 dark:text-gray-100">
<h3 class="text-lg font-medium text-white">
<slot name="title" />
</h3>
<div class="mt-4 text-sm text-gray-600 dark:text-gray-400">
<div class="mt-4 text-sm text-muted">
<slot name="content" />
</div>
</div>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import TimeTrackerStartStop from '@/Components/common/TimeTrackerStartStop.vue';
</script>
<template>
<div class="py-4 px-2 flex justify-between items-center">
<div>
<div class="text-muted font-extrabold text-xs">Current Timer</div>
<div class="text-white font-medium text-lg py-1">1h 23min</div>
</div>
<TimeTrackerStartStop size="base"></TimeTrackerStartStop>
</div>
</template>

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>

View File

@@ -30,11 +30,11 @@ const close = () => {
:closeable="closeable"
@close="close">
<div class="px-6 py-4">
<div class="text-lg font-medium text-gray-900 dark:text-gray-100">
<div class="text-lg font-medium text-white">
<slot name="title" />
</div>
<div class="mt-4 text-sm text-gray-600 dark:text-gray-400">
<div class="mt-4 text-sm text-muted">
<slot name="content" />
</div>
</div>

View File

@@ -1,22 +1,28 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { computed, onMounted, onUnmounted } from 'vue';
const props = defineProps({
align: {
type: String,
default: 'right',
},
width: {
type: String,
default: '48',
},
contentClasses: {
type: Array,
default: () => ['py-1', 'bg-white dark:bg-gray-700'],
},
});
const props = withDefaults(
defineProps<{
align: string;
width: string;
contentClasses?: string[];
closeOnContentClick: boolean;
}>(),
{
align: 'right',
width: '48',
contentClasses: () => [
'overflow-none',
'bg-card-background',
'border',
'border-card-border',
],
closeOnContentClick: true,
}
);
const open = ref(false);
const emit = defineEmits(['open']);
const open = defineModel({ default: false });
const closeOnEscape = (e: KeyboardEvent) => {
if (open.value && e.key === 'Escape') {
@@ -27,6 +33,12 @@ const closeOnEscape = (e: KeyboardEvent) => {
onMounted(() => document.addEventListener('keydown', closeOnEscape));
onUnmounted(() => document.removeEventListener('keydown', closeOnEscape));
function onContentClick() {
if (props.closeOnContentClick === true) {
open.value = false;
}
}
const widthClass = computed(() => {
return {
48: 'w-48',
@@ -42,13 +54,24 @@ const alignmentClasses = computed(() => {
return 'ltr:origin-top-right rtl:origin-top-left end-0';
}
if (props.align === 'bottom-right') {
return 'bottom-[calc(100%+15px)] ltr:origin-top-right rtl:origin-top-left end-0';
}
return 'origin-top';
});
function toggleOpen() {
open.value = !open.value;
if (open.value === true) {
emit('open');
}
}
</script>
<template>
<div class="relative">
<div @click="open = !open">
<div @click="toggleOpen">
<slot name="trigger" />
</div>
@@ -67,9 +90,9 @@ const alignmentClasses = computed(() => {
class="absolute z-50 mt-2 rounded-md shadow-lg"
:class="[widthClass, alignmentClasses]"
style="display: none"
@click="open = false">
@click="onContentClick">
<div
class="rounded-md ring-1 ring-black ring-opacity-5"
class="rounded-lg ring-1 relative ring-black ring-opacity-5"
:class="contentClasses">
<slot name="content" />
</div>

View File

@@ -1,10 +1,10 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
defineProps({
href: String,
as: String,
});
defineProps<{
href?: string;
as?: string;
}>();
</script>
<template>
@@ -12,21 +12,21 @@ defineProps({
<button
v-if="as == 'button'"
type="submit"
class="block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-800 transition duration-150 ease-in-out">
v-bind="$attrs"
class="block w-full px-4 py-2 text-start text-sm leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<slot />
</button>
<a
v-else-if="as == 'a'"
:href="href"
class="block px-4 py-2 text-sm leading-5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-800 transition duration-150 ease-in-out">
class="block px-4 py-2 text-sm leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<slot />
</a>
<Link
v-else
:href="href ?? ''"
class="block px-4 py-2 text-sm leading-5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-800 transition duration-150 ease-in-out">
class="block px-4 py-2 text-sm leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<slot />
</Link>
</div>

View File

@@ -21,7 +21,7 @@ const hasActions = computed(() => !!useSlots().actions);
<div class="mt-5 md:mt-0 md:col-span-2">
<form @submit.prevent="$emit('submitted')">
<div
class="px-4 py-5 bg-white dark:bg-gray-800 sm:p-6 shadow"
class="px-4 py-5 bg-card-background sm:p-6 shadow"
:class="
hasActions
? 'sm:rounded-tl-md sm:rounded-tr-md'
@@ -34,7 +34,7 @@ const hasActions = computed(() => !!useSlots().actions);
<div
v-if="hasActions"
class="flex items-center justify-end px-4 py-3 bg-gray-50 dark:bg-gray-800 text-end sm:px-6 shadow sm:rounded-bl-md sm:rounded-br-md">
class="flex items-center justify-end px-4 py-3 bg-card-background border-t border-card-background-seperator text-end sm:px-6 shadow sm:rounded-bl-md sm:rounded-br-md">
<slot name="actions" />
</div>
</form>

View File

@@ -5,7 +5,7 @@ defineProps({
</script>
<template>
<label class="block font-medium text-sm text-gray-700 dark:text-gray-300">
<label class="block font-medium text-sm text-white">
<span v-if="value">{{ value }}</span>
<span v-else><slot /></span>
</label>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { Component } from 'vue';
defineProps<{
title: string;
icon: Component;
current?: boolean;
href: string;
}>();
</script>
<template>
<li>
<a
:href="href"
:class="[
current
? 'bg-menu-active text-white'
: 'text-indigo-200 hover:text-white hover:bg-menu-active',
'group flex gap-x-3 rounded-md px-3 py-2 transition leading-6 font-medium',
]">
<component
:is="icon"
:class="[
current
? 'text-icon-active'
: 'text-icon-default group-hover:text-icon-active',
'transition h-6 w-6 shrink-0',
]"
aria-hidden="true" />
{{ title }}
</a>
</li>
</template>

View File

@@ -0,0 +1,134 @@
<script setup lang="ts">
import { ChevronDownIcon } from '@heroicons/vue/20/solid';
import route from 'ziggy-js';
import Dropdown from '@/Components/Dropdown.vue';
import DropdownLink from '@/Components/DropdownLink.vue';
import { router, usePage } from '@inertiajs/vue3';
import type { Organization, User } from '@/types/models';
const page = usePage<{
jetstream: {
canCreateTeams: boolean;
hasTeamFeatures: boolean;
managesProfilePhotos: boolean;
hasApiFeatures: boolean;
};
auth: {
user: User & {
all_teams: Organization[];
};
};
}>();
const switchToTeam = (team: Organization) => {
router.put(
route('current-team.update'),
{
team_id: team.id,
},
{
preserveState: false,
}
);
};
</script>
<template>
<Dropdown
v-if="page.props.jetstream.hasTeamFeatures"
align="right"
width="60">
<template #trigger>
<div
data-testid="organization_switcher"
class="flex hover:bg-white/10 cursor-pointer transition px-2 py-1 rounded-lg w-full items-center justify-between font-medium">
<div class="flex flex-1 space-x-3 items-center w-4/5">
<div
class="rounded-lg bg-blue-900 font-semibold flex-shrink-0 text-white w-7 h-7 flex items-center justify-center">
{{
page.props.auth.user.current_team.name
.slice(0, 1)
.toUpperCase()
}}
</div>
<span class="text-lg flex-1 truncate font-semibold">
{{ page.props.auth.user.current_team.name }}
</span>
</div>
<div class="w-1/5">
<button
class="p-1 transition hover:bg-white/10 rounded-full flex items-center w-9 h-9">
<ChevronDownIcon
class="w-full mt-[1px]"></ChevronDownIcon>
</button>
</div>
</div>
</template>
<template #content>
<div class="w-60">
<!-- Organization Management -->
<div class="block px-4 py-2 text-xs text-muted">
Manage Team
</div>
<!-- Organization Settings -->
<DropdownLink
:href="
route(
'teams.show',
page.props.auth.user.current_team.id
)
">
Team Settings
</DropdownLink>
<DropdownLink
v-if="page.props.jetstream.canCreateTeams"
:href="route('teams.create')">
Create New Team
</DropdownLink>
<!-- Organization Switcher -->
<template v-if="page.props.auth.user.all_teams.length > 1">
<div class="border-t border-card-background-seperator" />
<div class="block px-4 py-2 text-xs text-muted">
Switch Teams
</div>
<template
v-for="team in page.props.auth.user.all_teams"
:key="team.id">
<form @submit.prevent="switchToTeam(team)">
<DropdownLink as="button">
<div class="flex items-center">
<svg
v-if="
team.id ==
page.props.auth.user.current_team_id
"
class="me-2 h-5 w-5 text-green-400"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
{{ team.name }}
</div>
</div>
</DropdownLink>
</form>
</template>
</template>
</div>
</template>
</Dropdown>
</template>

View File

@@ -11,7 +11,7 @@ const props = defineProps<{
const classes = computed(() => {
return props.active
? 'block w-full ps-3 pe-4 py-2 border-l-4 border-indigo-400 dark:border-indigo-600 text-start text-base font-medium text-indigo-700 dark:text-indigo-300 bg-indigo-50 dark:bg-indigo-900/50 focus:outline-none focus:text-indigo-800 dark:focus:text-indigo-200 focus:bg-indigo-100 dark:focus:bg-indigo-900 focus:border-indigo-700 dark:focus:border-indigo-300 transition duration-150 ease-in-out'
: 'block w-full ps-3 pe-4 py-2 border-l-4 border-transparent text-start text-base font-medium text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 hover:border-gray-300 dark:hover:border-gray-600 focus:outline-none focus:text-gray-800 dark:focus:text-gray-200 focus:bg-gray-50 dark:focus:bg-gray-700 focus:border-gray-300 dark:focus:border-gray-600 transition duration-150 ease-in-out';
: 'block w-full ps-3 pe-4 py-2 border-l-4 border-transparent text-start text-base font-medium text-muted hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 hover:border-gray-300 dark:hover:border-gray-600 focus:outline-none focus:text-gray-800 dark:focus:text-gray-200 focus:bg-gray-50 dark:focus:bg-gray-700 focus:border-gray-300 dark:focus:border-gray-600 transition duration-150 ease-in-out';
});
</script>

View File

@@ -14,8 +14,7 @@ withDefaults(
<template>
<button
:type="type"
ResponsiveNavLink.vue
class="inline-flex items-center px-4 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-500 rounded-md font-semibold text-xs text-gray-700 dark:text-gray-300 uppercase tracking-widest shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 disabled:opacity-25 transition ease-in-out duration-150">
class="inline-flex items-center px-4 py-2 bg-button-secondary-background border border-button-secondary-border hover:bg-button-secondary-background-hover rounded-md font-semibold text-xs text-white uppercase tracking-widest shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 disabled:opacity-25 transition ease-in-out duration-150">
<slot />
</button>
</template>

View File

@@ -1,7 +1,7 @@
<template>
<div class="hidden sm:block">
<div class="py-8">
<div class="border-t border-gray-200 dark:border-gray-700" />
<div class="border-t border-default-background-seperator" />
</div>
</div>
</template>

View File

@@ -1,11 +1,11 @@
<template>
<div class="md:col-span-1 flex justify-between">
<div class="px-4 sm:px-0">
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
<h3 class="text-lg font-medium text-white">
<slot name="title" />
</h3>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
<p class="mt-1 text-sm text-muted">
<slot name="description" />
</p>
</div>

View File

@@ -3,6 +3,7 @@ import { onMounted, ref } from 'vue';
defineProps({
modelValue: String,
name: String,
});
const input = ref<HTMLInputElement | null>(null);
@@ -27,7 +28,8 @@ function updateValue(event: Event) {
<template>
<input
ref="input"
class="border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 rounded-md shadow-sm"
class="border-input-border bg-input-background text-white focus:border-input-border-active rounded-md shadow-sm"
:value="modelValue"
:name="name"
@input="updateValue" />
</template>

View File

@@ -0,0 +1,178 @@
<script setup lang="ts">
import { ClockIcon } from '@heroicons/vue/20/solid';
import CardTitle from '@/Components/common/CardTitle.vue';
import BillableToggleButton from '@/Components/common/BillableToggleButton.vue';
import TimeTrackerStartStop from '@/Components/common/TimeTrackerStartStop.vue';
import TagDropdown from '@/Components/common/TagDropdown.vue';
import ProjectDropdown from '@/Components/common/ProjectDropdown.vue';
import { usePage } from '@inertiajs/vue3';
import { type User } from '@/types/models';
import { computed, onMounted, ref, watch } from 'vue';
import dayjs, { Dayjs } from 'dayjs';
import utc from 'dayjs/plugin/utc';
import duration from 'dayjs/plugin/duration';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { storeToRefs } from 'pinia';
import type { Project } from '@/utils/useProjects';
import parse from 'parse-duration';
const page = usePage<{
auth: {
user: User;
};
}>();
dayjs.extend(duration);
dayjs.extend(utc);
const currentTimeEntryStore = useCurrentTimeEntryStore();
const { currentTimeEntry, isActive } = storeToRefs(currentTimeEntryStore);
const now = ref<null | Dayjs>(null);
const interval = ref<ReturnType<typeof setInterval> | null>(null);
function startLiveTimer() {
stopLiveTimer();
now.value = dayjs().utc();
interval.value = setInterval(() => {
now.value = dayjs().utc();
}, 1000);
}
function stopLiveTimer() {
if (interval.value !== null) {
clearInterval(interval.value);
}
}
watch(isActive, () => {
if (isActive.value) {
startLiveTimer();
} else {
stopLiveTimer();
}
});
const temporaryCustomTimerEntry = ref<string>('');
const currentTime = computed({
get() {
if (temporaryCustomTimerEntry.value !== '') {
return temporaryCustomTimerEntry.value;
}
if (now.value && currentTimeEntry.value.start) {
const startTime = dayjs(currentTimeEntry.value.start);
const diff = now.value.diff(startTime);
return dayjs(diff).utc().format('HH:mm:ss');
}
return null;
},
// setter
set(newValue) {
if (newValue) {
temporaryCustomTimerEntry.value = newValue;
} else {
temporaryCustomTimerEntry.value = '';
}
},
});
onMounted(async () => {
if (page.props.auth.user.current_team_id) {
await currentTimeEntryStore.fetchCurrentTimeEntry();
now.value = dayjs().utc();
}
});
async function onToggleButtonPress(newState: boolean) {
if (page.props.auth.user.current_team_id) {
if (newState) {
startLiveTimer();
await useCurrentTimeEntryStore().startTimer();
} else {
stopLiveTimer();
await useCurrentTimeEntryStore().stopTimer();
}
}
}
const currentProject = ref<Project | null>(null);
watch(currentProject, () => {
if (currentProject.value) {
currentTimeEntry.value.project_id = currentProject.value.id;
if (isActive.value) {
useCurrentTimeEntryStore().updateTimer();
}
}
});
function updateTimeEntry() {
if (currentTimeEntry.value.id) {
useCurrentTimeEntryStore().updateTimer();
}
}
function pauseLiveTimerUpdate() {
stopLiveTimer();
}
function updateTimerAndStartLiveTimerUpdate() {
const time = parse(temporaryCustomTimerEntry.value, 's');
if (time && time > 0) {
const newStartDate = dayjs().subtract(time, 's');
currentTimeEntry.value.start = newStartDate.utc().format();
if (currentTimeEntry.value.id !== '') {
currentTimeEntryStore.updateTimer();
} else {
currentTimeEntryStore.startTimer();
}
}
now.value = dayjs().utc();
temporaryCustomTimerEntry.value = '';
startLiveTimer();
}
</script>
<template>
<CardTitle title="Time Tracker" :icon="ClockIcon"></CardTitle>
<div class="flex items-center" data-testid="dashboard_timer">
<div
class="flex w-full rounded-lg bg-card-background border-card-border border transition">
<div class="flex-1 flex items-center pr-6">
<input
placeholder="What are you working on?"
data-testid="time_entry_description"
v-model="currentTimeEntry.description"
@blur="updateTimeEntry"
class="w-full rounded-l-lg py-4 px-6 text-xl text-white focus:bg-card-background-active font-medium bg-transparent border-none placeholder-muted focus:ring-0 transition"
type="text" />
</div>
<div class="flex items-center">
<ProjectDropdown v-model="currentProject"></ProjectDropdown>
</div>
<div class="flex items-center space-x-2 px-4">
<TagDropdown
@changed="updateTimeEntry"
v-model="currentTimeEntry.tags"></TagDropdown>
<BillableToggleButton></BillableToggleButton>
</div>
<div class="border-l border-card-border">
<input
placeholder="00:00:00"
@focus="pauseLiveTimerUpdate"
data-testid="time_entry_time"
@blur="updateTimerAndStartLiveTimerUpdate"
@keydown.enter="updateTimerAndStartLiveTimerUpdate"
v-model="currentTime"
class="w-40 h-full text-white py-4 rounded-r-lg text-center px-4 text-xl font-bold bg-card-background border-none placeholder-muted focus:ring-0 transition focus:bg-card-background-active"
type="text" />
</div>
</div>
<div class="pl-6 pr-3">
<TimeTrackerStartStop
:active="isActive"
@changed="onToggleButtonPress"
size="large"></TimeTrackerStartStop>
</div>
</div>
</template>

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
import route from 'ziggy-js';
import { router, usePage } from '@inertiajs/vue3';
import type { Organization, User } from '@/types/models';
import DropdownLink from '@/Components/DropdownLink.vue';
import Dropdown from '@/Components/Dropdown.vue';
const page = usePage<{
jetstream: {
canCreateTeams: boolean;
hasTeamFeatures: boolean;
managesProfilePhotos: boolean;
hasApiFeatures: boolean;
};
auth: {
user: User & {
all_teams: Organization[];
};
};
}>();
const logout = () => {
router.post(route('logout'));
};
</script>
<template>
<div class="ms-3 relative">
<Dropdown align="bottom-right" width="48">
<template #trigger>
<button
v-if="page.props.jetstream.managesProfilePhotos"
data-testid="current_user_button"
class="flex text-sm border-2 border-transparent rounded-full focus:outline-none focus:border-gray-300 transition">
<img
class="h-8 w-8 rounded-full object-cover"
:src="page.props.auth.user.profile_photo_url"
:alt="page.props.auth.user.name" />
</button>
<span v-else class="inline-flex rounded-md">
<button
type="button"
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300 focus:outline-none focus:bg-gray-50 dark:focus:bg-gray-700 active:bg-gray-50 dark:active:bg-gray-700 transition ease-in-out duration-150">
{{ page.props.auth.user.name }}
<svg
class="ms-2 -me-0.5 h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button>
</span>
</template>
<template #content>
<!-- Account Management -->
<div class="block px-4 py-2 text-xs text-gray-400">
Manage Account
</div>
<DropdownLink :href="route('profile.show')">
Profile
</DropdownLink>
<DropdownLink
v-if="page.props.jetstream.hasApiFeatures"
:href="route('api-tokens.index')">
API Tokens
</DropdownLink>
<div class="border-t border-gray-200 dark:border-gray-600" />
<!-- Authentication -->
<form @submit.prevent="logout">
<DropdownLink as="button" data-testid="logout_button">
Log Out
</DropdownLink>
</form>
</template>
</Dropdown>
</div>
</template>

View File

@@ -1,177 +0,0 @@
<script setup lang="ts">
import ApplicationLogo from '@/Components/ApplicationLogo.vue';
</script>
<template>
<div>
<div
class="p-6 lg:p-8 bg-white dark:bg-gray-800 dark:bg-gradient-to-bl dark:from-gray-700/50 dark:via-transparent border-b border-gray-200 dark:border-gray-700">
<ApplicationLogo class="block h-12 w-auto" />
<h1 class="mt-8 text-2xl font-medium text-gray-900 dark:text-white">
Welcome to your Jetstream application!
</h1>
<p class="mt-6 text-gray-500 dark:text-gray-400 leading-relaxed">
Laravel Jetstream provides a beautiful, robust starting point
for your next Laravel application. Laravel is designed to help
you build your application using a development environment that
is simple, powerful, and enjoyable. We believe you should love
expressing your creativity through programming, so we have spent
time carefully crafting the Laravel ecosystem to be a breath of
fresh air. We hope you love it.
</p>
</div>
<div
class="bg-gray-200 dark:bg-gray-800 bg-opacity-25 grid grid-cols-1 md:grid-cols-2 gap-6 lg:gap-8 p-6 lg:p-8">
<div>
<div class="flex items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
class="w-6 h-6 stroke-gray-400">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
<h2
class="ms-3 text-xl font-semibold text-gray-900 dark:text-white">
<a href="https://laravel.com/docs">Documentation</a>
</h2>
</div>
<p
class="mt-4 text-gray-500 dark:text-gray-400 text-sm leading-relaxed">
Laravel has wonderful documentation covering every aspect of
the framework. Whether you're new to the framework or have
previous experience, we recommend reading all of the
documentation from beginning to end.
</p>
<p class="mt-4 text-sm">
<a
href="https://laravel.com/docs"
class="inline-flex items-center font-semibold text-indigo-700 dark:text-indigo-300">
Explore the documentation
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
class="ms-1 w-5 h-5 fill-indigo-500 dark:fill-indigo-200">
<path
fill-rule="evenodd"
d="M5 10a.75.75 0 01.75-.75h6.638L10.23 7.29a.75.75 0 111.04-1.08l3.5 3.25a.75.75 0 010 1.08l-3.5 3.25a.75.75 0 11-1.04-1.08l2.158-1.96H5.75A.75.75 0 015 10z"
clip-rule="evenodd" />
</svg>
</a>
</p>
</div>
<div>
<div class="flex items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
class="w-6 h-6 stroke-gray-400">
<path
stroke-linecap="round"
d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z" />
</svg>
<h2
class="ms-3 text-xl font-semibold text-gray-900 dark:text-white">
<a href="https://laracasts.com">Laracasts</a>
</h2>
</div>
<p
class="mt-4 text-gray-500 dark:text-gray-400 text-sm leading-relaxed">
Laracasts offers thousands of video tutorials on Laravel,
PHP, and JavaScript development. Check them out, see for
yourself, and massively level up your development skills in
the process.
</p>
<p class="mt-4 text-sm">
<a
href="https://laracasts.com"
class="inline-flex items-center font-semibold text-indigo-700 dark:text-indigo-300">
Start watching Laracasts
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
class="ms-1 w-5 h-5 fill-indigo-500 dark:fill-indigo-200">
<path
fill-rule="evenodd"
d="M5 10a.75.75 0 01.75-.75h6.638L10.23 7.29a.75.75 0 111.04-1.08l3.5 3.25a.75.75 0 010 1.08l-3.5 3.25a.75.75 0 11-1.04-1.08l2.158-1.96H5.75A.75.75 0 015 10z"
clip-rule="evenodd" />
</svg>
</a>
</p>
</div>
<div>
<div class="flex items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
class="w-6 h-6 stroke-gray-400">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
</svg>
<h2
class="ms-3 text-xl font-semibold text-gray-900 dark:text-white">
<a href="https://tailwindcss.com/">Tailwind</a>
</h2>
</div>
<p
class="mt-4 text-gray-500 dark:text-gray-400 text-sm leading-relaxed">
Laravel Jetstream is built with Tailwind, an amazing utility
first CSS framework that doesn't get in your way. You'll be
amazed how easily you can build and maintain fresh, modern
designs with this wonderful framework at your fingertips.
</p>
</div>
<div>
<div class="flex items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
class="w-6 h-6 stroke-gray-400">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
<h2
class="ms-3 text-xl font-semibold text-gray-900 dark:text-white">
Authentication
</h2>
</div>
<p
class="mt-4 text-gray-500 dark:text-gray-400 text-sm leading-relaxed">
Authentication and registration views are included with
Laravel Jetstream, as well as support for user email
verification and resetting forgotten passwords. So, you're
free to get started with what matters most: building your
application.
</p>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import { computed } from 'vue';
import { twMerge } from 'tailwind-merge';
const active = defineModel({ default: false });
function toggleBillable() {
active.value = !active.value;
}
const iconColorClasses = computed(() => {
if (active.value) {
return 'text-accent-200/80 focus:text-accent-200 hover:text-accent-200';
} else {
return 'text-icon-default focus:text-icon-active hover:text-icon-active';
}
});
</script>
<template>
<button
@click="toggleBillable"
:class="
twMerge(
iconColorClasses,
'flex-shrink-0 ring-0 focus:outline-none focus:ring-0 transition focus:bg-card-background-seperator hover:bg-card-background-seperator rounded-full w-11 h-11 flex items-center justify-center'
)
">
<svg
class="h-7"
viewBox="0 0 8 14"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M4 1V13M1 10.182L1.879 10.841C3.05 11.72 4.949 11.72 6.121 10.841C7.293 9.962 7.293 8.538 6.121 7.659C5.536 7.219 4.768 7 4 7C3.275 7 2.55 6.78 1.997 6.341C0.891 5.462 0.891 4.038 1.997 3.159C3.103 2.28 4.897 2.28 6.003 3.159L6.418 3.489"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</button>
</template>
<style scoped></style>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { Component } from 'vue';
defineProps<{
title: string;
icon?: Component;
}>();
</script>
<template>
<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>
</template>
<style scoped></style>

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import { twMerge } from 'tailwind-merge';
const props = withDefaults(
defineProps<{
name: string;
size: 'base' | 'large';
tag: string;
class?: string;
color: string;
}>(),
{
size: 'base',
tag: 'div',
color: 'var(--theme-color-icon-default)',
}
);
const indicatorClasses = {
base: 'w-2.5 h-2.5',
large: 'w-3 h-3',
};
const badgeClasses = {
base: 'py-1 px-2 space-x-1.5 text-sm',
large: 'py-1.5 px-3 space-x-2 text-base text-muted',
};
</script>
<template>
<component
:is="tag"
:class="
twMerge(
props.class,
badgeClasses[size],
'border-input-border border rounded inline-flex items-center font-semibold text-white'
)
">
<div
:style="{ backgroundColor: color }"
:class="
twMerge(indicatorClasses[size], 'inline-block rounded-full')
"></div>
<span>
{{ name }}
</span>
</component>
</template>
<style scoped></style>

View File

@@ -0,0 +1,162 @@
<script setup lang="ts">
import ProjectBadge from '@/Components/common/ProjectBadge.vue';
import { computed, nextTick, ref, watch } from 'vue';
import { type Project, useProjectsStore } from '@/utils/useProjects';
import Dropdown from '@/Components/Dropdown.vue';
import {
ComboboxAnchor,
ComboboxContent,
ComboboxInput,
ComboboxItem,
ComboboxRoot,
ComboboxViewport,
} from 'radix-vue';
import { PlusCircleIcon } from '@heroicons/vue/20/solid';
import ProjectDropdownItem from '@/Components/common/ProjectDropdownItem.vue';
import { storeToRefs } from 'pinia';
import { api } from '../../../../openapi.json.client';
import { usePage } from '@inertiajs/vue3';
import { getRandomColor } from '@/utils/color';
const searchValue = ref('');
const searchInput = ref<HTMLElement | null>(null);
const model = defineModel<Project | null>({
default: null,
});
const open = ref(false);
const projectsStore = useProjectsStore();
const { projects } = storeToRefs(projectsStore);
const projectDropdownTrigger = ref<HTMLElement | null>(null);
const shownProjects = computed(() => {
return projects.value.filter((project) => {
return project.name
.toLowerCase()
.includes(searchValue.value?.toLowerCase()?.trim() || '');
});
});
const page = usePage<{
auth: {
user: {
current_team_id: string;
};
};
}>();
async function addProjectIfNoneExists() {
if (searchValue.value.length > 0 && shownProjects.value.length === 0) {
const response = await api.createProject(
{
name: searchValue.value,
color: getRandomColor(),
},
{ params: { organization: page.props.auth.user.current_team_id } }
);
projects.value.unshift(response.data);
model.value = response.data;
searchValue.value = '';
open.value = false;
}
}
watch(open, (isOpen) => {
if (isOpen) {
nextTick(() => {
searchInput.value?.$el?.focus();
});
projects.value.sort((a) => {
return model.value === a ? -1 : 1;
});
}
});
function isProjectSelected(project: Project) {
return model.value?.id === project.id;
}
const selectedProjectName = computed(() => {
return model.value?.name || 'No Project';
});
const selectedProjectColor = computed(() => {
return model.value?.color || 'var(--theme-color-icon-default)';
});
</script>
<template>
<Dropdown v-model="open" align="right" width="60">
<template #trigger>
<ProjectBadge
ref="projectDropdownTrigger"
:color="selectedProjectColor"
size="large"
tag="button"
:name="selectedProjectName"
class="focus:border-input-border-active focus:outline-0 focus:bg-card-background-seperator hover:bg-card-background-seperator"></ProjectBadge>
</template>
<template #content>
<ComboboxRoot
:open="open"
v-model="model"
v-model:searchTerm="searchValue"
class="relative">
<ComboboxAnchor>
<ComboboxInput
@keydown.enter="addProjectIfNoneExists"
ref="searchInput"
class="bg-card-background border-0 placeholder-muted text-white py-2.5 focus:ring-0 border-b border-card-background-seperator focus:border-card-background-seperator w-full"
placeholder="Search for a project..." />
</ComboboxAnchor>
<ComboboxContent>
<ComboboxViewport ref="dropdownViewport" class="w-60">
<ComboboxItem
v-if="searchValue === ''"
class="data-[highlighted]:bg-card-background-active"
:data-project-id="null"
:value="{
id: null,
name: '',
}">
<ProjectDropdownItem
name="No Project"
color="var(--theme-color-icon-default)"
selected></ProjectDropdownItem>
</ComboboxItem>
<ComboboxItem
v-for="project in shownProjects"
:key="project.id"
:value="project"
class="data-[highlighted]:bg-card-background-active"
:data-project-id="project.id">
<ProjectDropdownItem
:selected="isProjectSelected(project)"
:color="project.color"
:name="project.name"></ProjectDropdownItem>
</ComboboxItem>
<div
v-if="
searchValue.length > 0 &&
shownProjects.length === 0
"
class="bg-card-background-active">
<div
class="flex space-x-3 items-center px-4 py-3 text-sm font-medium border-t rounded-b-lg border-card-background-seperator">
<PlusCircleIcon
class="w-5 flex-shrink-0"></PlusCircleIcon>
<span
>Add "{{ searchValue }}" as a new
Project</span
>
</div>
</div>
</ComboboxViewport>
</ComboboxContent>
</ComboboxRoot>
</template>
</Dropdown>
</template>
<style scoped></style>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
defineProps<{
name: string;
selected: boolean;
color: string;
}>();
</script>
<template>
<div
class="flex items-center space-x-3 w-full px-4 py-2.5 text-start text-base 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">
<div
:style="{ backgroundColor: color }"
class="w-4 h-4 rounded-full"></div>
<span>{{ name }}</span>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
defineProps<{
title: string;
value: string;
}>();
</script>
<template>
<div
class="rounded-lg bg-card-background border-card-border border px-4 py-3">
<dt class="font-bold text-muted">{{ title }}</dt>
<dd class="text-3xl text-white pt-1 font-bold">
{{ value }}
</dd>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,220 @@
<script setup lang="ts">
import { PlusCircleIcon, TagIcon } from '@heroicons/vue/20/solid';
import Dropdown from '@/Components/Dropdown.vue';
import {
type Component,
computed,
nextTick,
onMounted,
ref,
watch,
watchEffect,
} from 'vue';
import TagDropdownItem from '@/Components/common/TagDropdownItem.vue';
import { twMerge } from 'tailwind-merge';
import {
ComboboxAnchor,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxRoot,
ComboboxViewport,
} from 'radix-vue';
import { useTagsStore } from '@/utils/useTags';
import { storeToRefs } from 'pinia';
const tagsStore = useTagsStore();
const { tags } = storeToRefs(tagsStore);
const emit = defineEmits(['changed']);
const model = defineModel<string[]>({
default: [],
});
onMounted(async () => {
await tagsStore.fetchTags();
});
const searchInput = ref<Component | null>(null);
const open = ref(false);
const dropdownViewport = ref<Component | null>(null);
const searchValue = ref('');
function isTagSelected(id: string) {
return model.value.includes(id);
}
function addOrRemoveTagFromSelection(id: string) {
if (model.value.includes(id)) {
model.value = model.value.filter((tagId) => tagId !== id);
} else {
model.value.push(id);
}
emit('changed');
}
const iconColorClasses = computed(() => {
if (model.value.length > 0) {
return 'text-accent-200/80 focus:text-accent-200 hover:text-accent-200';
} else {
return 'text-icon-default hover:text-icon-active focus:text-icon-active';
}
});
watch(open, (isOpen) => {
if (isOpen) {
nextTick(() => {
// @ts-expect-error We need to access the actual HTML Element to focus as radix-vue does not support any other way right now
searchInput.value?.$el?.focus();
});
tags.value.sort((a) => {
return model.value.includes(a.id) ? -1 : 1;
});
}
});
const filteredTags = computed(() => {
return tags.value.filter((tag) => {
return tag.name
.toLowerCase()
.includes(searchValue.value?.toLowerCase()?.trim() || '');
});
});
const showAllTags = ref(false);
const shownTags = computed(() => {
if (showAllTags.value) {
return filteredTags.value;
} else {
return filteredTags.value.slice(0, 5);
}
});
const moreTagsAvailable = computed(() => {
return filteredTags.value.length - shownTags.value.length;
});
async function addTagIfNoneExists() {
if (searchValue.value.length > 0 && filteredTags.value.length === 0) {
const newTag = await tagsStore.createTag(searchValue.value);
addOrRemoveTagFromSelection(newTag.id);
searchValue.value = '';
}
}
function removeTagLimit() {
showAllTags.value = true;
}
watchEffect(() => {
if (searchValue.value === ' ') {
nextTick(() => {
searchValue.value = '';
const currentSelectedItem =
// @ts-expect-error We need to access the actual HTML Element to focus as radix-vue does not support any other way right now
dropdownViewport.value?.$el?.querySelector(
'[data-highlighted]'
);
const highlightedTagId = currentSelectedItem?.getAttribute(
'data-tag-id'
) as string;
if (highlightedTagId) {
const highlightedTag = tags.value.find(
(tag) => tag.id === highlightedTagId
);
if (highlightedTag) {
addOrRemoveTagFromSelection(highlightedTag.id);
}
}
});
}
});
function updateValue(e: string[]) {
model.value = e;
emit('changed');
}
</script>
<template>
<Dropdown width="120" v-model="open" :closeOnContentClick="false">
<template #trigger>
<button
data-testid="tag_dropdown"
:class="
twMerge(
iconColorClasses,
'flex-shrink-0 ring-0 focus:outline-none focus:ring-0 transition focus:bg-card-background-seperator hover:bg-card-background-seperator rounded-full w-11 h-11 flex items-center justify-center'
)
">
<TagIcon class="w-7 h-7"></TagIcon>
<div
v-if="model.length > 1"
class="font-extrabold absolute rounded-full text-xs w-3 h-3 block top-[15px] rotate-[45deg] right-[14px] text-card-background">
{{ model.length }}
</div>
</button>
</template>
<template #content>
<ComboboxRoot
multiple
:open="open"
@update:modelValue="updateValue"
v-model:searchTerm="searchValue"
class="relative">
<ComboboxAnchor>
<ComboboxInput
@keydown.enter="addTagIfNoneExists"
data-testid="tag_dropdown_search"
ref="searchInput"
class="bg-card-background border-0 placeholder-muted text-white py-2.5 focus:ring-0 border-b border-card-background-seperator focus:border-card-background-seperator w-full"
placeholder="Search for a tag..." />
</ComboboxAnchor>
<ComboboxContent>
<ComboboxViewport ref="dropdownViewport" class="w-60">
<ComboboxEmpty>
<div
v-if="searchValue.length > 0"
class="bg-card-background-active">
<div
class="flex space-x-3 items-center px-4 py-3 text-sm font-medium border-t rounded-b-lg border-card-background-seperator">
<PlusCircleIcon
class="w-5 flex-shrink-0"></PlusCircleIcon>
<span
>Add "{{ searchValue }}" as a new
Tag</span
>
</div>
</div>
<div v-else></div>
</ComboboxEmpty>
<ComboboxItem
v-for="tag in shownTags"
:key="tag.id"
:value="tag.id"
class="data-[highlighted]:bg-card-background-active"
data-testid="tag_dropdown_entries"
:data-tag-id="tag.id">
<TagDropdownItem
:selected="isTagSelected(tag.id)"
:name="tag.name"></TagDropdownItem>
</ComboboxItem>
</ComboboxViewport>
<button
@click="removeTagLimit"
v-if="moreTagsAvailable > 0"
class="border-t hover:text-white hover:bg-card-background-active px-2 text-center font-semibold py-2 border-t-card-background-seperator">
Show all
</button>
</ComboboxContent>
</ComboboxRoot>
</template>
</Dropdown>
</template>
<style scoped></style>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
import { computed } from 'vue';
import { twMerge } from 'tailwind-merge';
const props = defineProps<{
name: string;
selected: boolean;
}>();
const iconClasses = computed(() => {
if (props.selected) {
return 'text-accent-200';
} else {
return 'text-card-border';
}
});
</script>
<template>
<div
class="flex items-center space-x-3 w-full px-4 py-2.5 text-start text-base 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">
<CheckCircleIcon :class="twMerge(iconClasses, 'w-6')"></CheckCircleIcon>
<span>{{ name }}</span>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
import { twMerge } from 'tailwind-merge';
import { computed } from 'vue';
const emit = defineEmits(['changed']);
const props = withDefaults(
defineProps<{
size: 'base' | 'large';
active: boolean;
}>(),
{
size: 'base',
active: false,
}
);
const buttonSizeClasses = {
base: 'w-8 h-8 !bg-accent-200/30 ',
large: 'w-11 h-11 ring-accent-200/10 focus:ring-accent-200/20 ring-8 hover:scale-110',
};
const iconClass = {
base: 'w-3.5 h-3.5',
large: 'w-4 h-4',
};
const buttonColorClasses = computed(() => {
if (props.active) {
return 'bg-red-400/80 hover:bg-red-500/80 focus:bg-red-500/80';
} else {
return 'bg-accent-300/70 hover:bg-accent-400/80 focus:bg-accent-400/80';
}
});
function toggleState() {
emit('changed', !props.active);
}
</script>
<template>
<button
@click="toggleState"
data-testid="timer_button"
:class="
twMerge(
buttonSizeClasses[size],
buttonColorClasses,
'flex items-center justify-center py-1 transition focus:outline-0 rounded-full text-white '
)
">
<Transition name="fade" mode="out-in">
<svg
v-if="props.active"
:class="iconClass[size]"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M0.461426 2.74913C0.461426 1.48677 1.48666 0.461538 2.75076 0.461538H11.249C12.5131 0.461538 13.5383 1.48677 13.5383 2.75087V11.2491C13.5383 12.5132 12.5131 13.5385 11.249 13.5385H2.7525C2.4518 13.5387 2.154 13.4796 1.87614 13.3647C1.59828 13.2497 1.34582 13.0811 1.13319 12.8684C0.920559 12.6558 0.751936 12.4033 0.636968 12.1255C0.521999 11.8476 0.462941 11.5498 0.46317 11.2491V2.75262L0.461426 2.74913Z"
fill="currentColor" />
</svg>
<svg
v-else
:class="iconClass[size]"
viewBox="0 0 7 8"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M6.56167 3.18089C6.70764 3.26214 6.82926 3.38092 6.91393 3.52494C6.99859 3.66896 7.04324 3.83299 7.04324 4.00005C7.04324 4.16712 6.99859 4.33115 6.91393 4.47517C6.82926 4.61919 6.70764 4.73797 6.56167 4.81922L1.8925 7.41339C1.74982 7.49259 1.58895 7.53317 1.42578 7.53113C1.26261 7.52909 1.1028 7.48449 0.962147 7.40175C0.821497 7.31901 0.704879 7.20099 0.623826 7.05937C0.542772 6.91774 0.50009 6.7574 0.5 6.59422V1.40589C0.5 0.691721 1.2675 0.239221 1.8925 0.586721L6.56167 3.18089Z"
fill="currentColor" />
</svg>
</Transition>
</button>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>