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