mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-17 12:42:15 +01:00
add time overview page
This commit is contained in:
50
resources/js/Components/Common/Badge.vue
Normal file
50
resources/js/Components/Common/Badge.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
name: string;
|
||||
size: 'base' | 'large';
|
||||
tag: string;
|
||||
class?: string;
|
||||
color: string;
|
||||
border: boolean;
|
||||
}>(),
|
||||
{
|
||||
size: 'base',
|
||||
tag: 'div',
|
||||
color: 'var(--theme-color-icon-default)',
|
||||
border: true,
|
||||
}
|
||||
);
|
||||
|
||||
const badgeClasses = {
|
||||
base: 'py-1 px-2 space-x-1.5 text-xs',
|
||||
large: 'py-1.5 px-3 space-x-2 text-sm text-muted',
|
||||
};
|
||||
|
||||
const borderClasses = computed(() => {
|
||||
if (props.border) {
|
||||
return 'border-input-border border';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="tag"
|
||||
:class="
|
||||
twMerge(
|
||||
props.class,
|
||||
badgeClasses[size],
|
||||
borderClasses,
|
||||
'rounded inline-flex items-center font-semibold text-white'
|
||||
)
|
||||
">
|
||||
<slot></slot>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
42
resources/js/Components/Common/BillableToggleButton.vue
Normal file
42
resources/js/Components/Common/BillableToggleButton.vue
Normal 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>
|
||||
23
resources/js/Components/Common/CardTitle.vue
Normal file
23
resources/js/Components/Common/CardTitle.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<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-base 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>
|
||||
29
resources/js/Components/Common/DaySectionHeader.vue
Normal file
29
resources/js/Components/Common/DaySectionHeader.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { formatDate, formatHumanReadableDate } from '@/utils/time';
|
||||
|
||||
defineProps<{
|
||||
date: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center space-x-2">
|
||||
<svg class="w-5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none">
|
||||
<path
|
||||
d="m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427c-.002-.01-.009-.017-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093c.012.004.023 0 .029-.008l.004-.014l-.034-.614c-.003-.012-.01-.02-.02-.022m-.715.002a.023.023 0 0 0-.027.006l-.006.014l-.034.614c0 .012.007.02.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z" />
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7zm-5-9a1 1 0 0 1 1 1v1h2a2 2 0 0 1 2 2v3H3V7a2 2 0 0 1 2-2h2V4a1 1 0 0 1 2 0v1h6V4a1 1 0 0 1 1-1" />
|
||||
</g>
|
||||
</svg>
|
||||
<span class="font-semibold text-white">
|
||||
{{ formatHumanReadableDate(date) }}
|
||||
</span>
|
||||
<span class="font-semibold">
|
||||
{{ formatDate(date) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
41
resources/js/Components/Common/Project/ProjectBadge.vue
Normal file
41
resources/js/Components/Common/Project/ProjectBadge.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import Badge from '@/Components/Common/Badge.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
name: string;
|
||||
size: 'base' | 'large';
|
||||
tag: string;
|
||||
class?: string;
|
||||
color: string;
|
||||
border: boolean;
|
||||
}>(),
|
||||
{
|
||||
size: 'base',
|
||||
tag: 'div',
|
||||
color: 'var(--theme-color-icon-default)',
|
||||
border: true,
|
||||
}
|
||||
);
|
||||
|
||||
const indicatorClasses = {
|
||||
base: 'w-2.5 h-2.5',
|
||||
large: 'w-3 h-3',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Badge :name :size :tag :class="props.class" :color :border>
|
||||
<div
|
||||
:style="{ backgroundColor: props.color }"
|
||||
:class="
|
||||
twMerge(indicatorClasses[size], 'inline-block rounded-full')
|
||||
"></div>
|
||||
<span>
|
||||
{{ name }}
|
||||
</span>
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
187
resources/js/Components/Common/Project/ProjectDropdown.vue
Normal file
187
resources/js/Components/Common/Project/ProjectDropdown.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
import ProjectBadge from '@/Components/Common/Project/ProjectBadge.vue';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { 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/Project/ProjectDropdownItem.vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { api } from '../../../../../openapi.json.client';
|
||||
import { usePage } from '@inertiajs/vue3';
|
||||
import { getRandomColor } from '@/utils/color';
|
||||
import type { Project } from '@/utils/api';
|
||||
|
||||
const searchValue = ref('');
|
||||
const searchInput = ref<HTMLElement | null>(null);
|
||||
const model = defineModel<string | null>({
|
||||
default: null,
|
||||
});
|
||||
const open = ref(false);
|
||||
const projectsStore = useProjectsStore();
|
||||
const emit = defineEmits(['update:modelValue', 'changed']);
|
||||
|
||||
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() || '');
|
||||
});
|
||||
});
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
border: boolean;
|
||||
}>(),
|
||||
{
|
||||
border: true,
|
||||
}
|
||||
);
|
||||
|
||||
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.id;
|
||||
searchValue.value = '';
|
||||
open.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
projects.value.sort((iteratingProject) => {
|
||||
return model.value === iteratingProject.id ? -1 : 1;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const currentProject = computed(() => {
|
||||
return projects.value.find((project) => project.id === model.value);
|
||||
});
|
||||
|
||||
function isProjectSelected(project: Project) {
|
||||
return model.value === project.id;
|
||||
}
|
||||
|
||||
const selectedProjectName = computed(() => {
|
||||
return currentProject.value?.name || 'No Project';
|
||||
});
|
||||
|
||||
const selectedProjectColor = computed(() => {
|
||||
return currentProject.value?.color || 'var(--theme-color-icon-default)';
|
||||
});
|
||||
|
||||
function updateValue(project: Project) {
|
||||
model.value = project.id;
|
||||
emit('changed');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dropdown v-model="open" align="right" width="60">
|
||||
<template #trigger>
|
||||
<ProjectBadge
|
||||
ref="projectDropdownTrigger"
|
||||
:color="selectedProjectColor"
|
||||
size="large"
|
||||
:border
|
||||
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"
|
||||
:modelValue="currentProject"
|
||||
@update:modelValue="updateValue"
|
||||
@update:searchTerm="(e) => console.log(e)"
|
||||
:searchTerm="searchValue"
|
||||
class="relative">
|
||||
<ComboboxAnchor>
|
||||
<ComboboxInput
|
||||
@keydown.enter="addProjectIfNoneExists"
|
||||
ref="searchInput"
|
||||
class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-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: 'No Project',
|
||||
color: 'var(--theme-color-icon-default)',
|
||||
}">
|
||||
<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-xs 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>
|
||||
@@ -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-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
|
||||
<div
|
||||
:style="{ backgroundColor: color }"
|
||||
class="w-3 h-3 rounded-full"></div>
|
||||
<span>{{ name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
18
resources/js/Components/Common/StatCard.vue
Normal file
18
resources/js/Components/Common/StatCard.vue
Normal 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-3.5 py-2.5">
|
||||
<dt class="font-bold text-sm text-muted">{{ title }}</dt>
|
||||
<dd class="text-2xl text-white pt-1 font-bold">
|
||||
{{ value }}
|
||||
</dd>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
40
resources/js/Components/Common/Tag/TagBadge.vue
Normal file
40
resources/js/Components/Common/Tag/TagBadge.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import Badge from '@/Components/Common/Badge.vue';
|
||||
import { TagIcon } from '@heroicons/vue/20/solid';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
name: string;
|
||||
size: 'base' | 'large';
|
||||
tag: string;
|
||||
class?: string;
|
||||
color: string;
|
||||
border: boolean;
|
||||
}>(),
|
||||
{
|
||||
size: 'base',
|
||||
tag: 'div',
|
||||
color: 'var(--theme-color-icon-default)',
|
||||
border: true,
|
||||
}
|
||||
);
|
||||
|
||||
const indicatorClasses = {
|
||||
base: 'w-3 h-3',
|
||||
large: 'w-5 h-5',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Badge :name :size :tag :class="props.class" :color :border>
|
||||
<TagIcon
|
||||
:style="{ color: color }"
|
||||
:class="twMerge(indicatorClasses[size])"></TagIcon>
|
||||
<span>
|
||||
{{ name }}
|
||||
</span>
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
193
resources/js/Components/Common/Tag/TagDropdown.vue
Normal file
193
resources/js/Components/Common/Tag/TagDropdown.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<script setup lang="ts">
|
||||
import { PlusCircleIcon } from '@heroicons/vue/20/solid';
|
||||
import Dropdown from '@/Components/Dropdown.vue';
|
||||
import { type Component, computed, nextTick, ref, watch } from 'vue';
|
||||
import TagDropdownItem from '@/Components/Common/Tag/TagDropdownItem.vue';
|
||||
import { useTagsStore } from '@/utils/useTags';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
const tagsStore = useTagsStore();
|
||||
const { tags } = storeToRefs(tagsStore);
|
||||
|
||||
const model = defineModel<string[]>({
|
||||
default: [],
|
||||
});
|
||||
|
||||
const searchInput = ref<HTMLInputElement | 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');
|
||||
}
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
nextTick(() => {
|
||||
searchInput.value?.focus();
|
||||
});
|
||||
|
||||
// sort tags alphabetically
|
||||
tags.value.sort((a, b) => {
|
||||
const aIsSelected = model.value.includes(a.id);
|
||||
const bIsSelected = model.value.includes(b.id);
|
||||
if (aIsSelected === bIsSelected) {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
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() || '');
|
||||
});
|
||||
});
|
||||
|
||||
async function addTagIfNoneExists() {
|
||||
if (searchValue.value.length > 0 && filteredTags.value.length === 0) {
|
||||
const newTag = await tagsStore.createTag(searchValue.value);
|
||||
addOrRemoveTagFromSelection(newTag.id);
|
||||
searchValue.value = '';
|
||||
} else {
|
||||
if (highlightedItemId.value) {
|
||||
addOrRemoveTagFromSelection(highlightedItemId.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(filteredTags, () => {
|
||||
if (filteredTags.value.length > 0) {
|
||||
highlightedItemId.value = filteredTags.value[0].id;
|
||||
}
|
||||
});
|
||||
|
||||
function updateSearchValue(event: Event) {
|
||||
const newInput = (event.target as HTMLInputElement).value;
|
||||
if (newInput === ' ') {
|
||||
searchValue.value = '';
|
||||
const highlightedTagId = highlightedItemId.value;
|
||||
if (highlightedTagId) {
|
||||
const highlightedTag = tags.value.find(
|
||||
(tag) => tag.id === highlightedTagId
|
||||
);
|
||||
if (highlightedTag) {
|
||||
addOrRemoveTagFromSelection(highlightedTag.id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
searchValue.value = newInput;
|
||||
}
|
||||
}
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'changed']);
|
||||
|
||||
function toggleTag(newValue: string) {
|
||||
if (model.value.includes(newValue)) {
|
||||
model.value = model.value.filter((id) => id !== newValue);
|
||||
} else {
|
||||
model.value.push(newValue);
|
||||
}
|
||||
emit('changed');
|
||||
}
|
||||
|
||||
function moveHighlightUp() {
|
||||
if (highlightedItem.value) {
|
||||
const currentHightlightedIndex = filteredTags.value.indexOf(
|
||||
highlightedItem.value
|
||||
);
|
||||
if (currentHightlightedIndex === 0) {
|
||||
highlightedItemId.value =
|
||||
filteredTags.value[filteredTags.value.length - 1].id;
|
||||
} else {
|
||||
highlightedItemId.value =
|
||||
filteredTags.value[currentHightlightedIndex - 1].id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function moveHighlightDown() {
|
||||
if (highlightedItem.value) {
|
||||
const currentHightlightedIndex = filteredTags.value.indexOf(
|
||||
highlightedItem.value
|
||||
);
|
||||
if (currentHightlightedIndex === filteredTags.value.length - 1) {
|
||||
highlightedItemId.value = filteredTags.value[0].id;
|
||||
} else {
|
||||
highlightedItemId.value =
|
||||
filteredTags.value[currentHightlightedIndex + 1].id;
|
||||
}
|
||||
}
|
||||
console.log('move down');
|
||||
}
|
||||
|
||||
const highlightedItemId = ref<string | null>(null);
|
||||
const highlightedItem = computed(() => {
|
||||
return tags.value.find((tag) => tag.id === highlightedItemId.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dropdown width="120" v-model="open" :closeOnContentClick="false">
|
||||
<template #trigger>
|
||||
<slot name="trigger"></slot>
|
||||
</template>
|
||||
<template #content>
|
||||
<input
|
||||
:value="searchValue"
|
||||
@input="updateSearchValue"
|
||||
@keydown.enter="addTagIfNoneExists"
|
||||
data-testid="tag_dropdown_search"
|
||||
@keydown.up.prevent="moveHighlightUp"
|
||||
@keydown.down.prevent="moveHighlightDown"
|
||||
ref="searchInput"
|
||||
class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-seperator focus:border-card-background-seperator w-full"
|
||||
placeholder="Search for a tag..." />
|
||||
<div ref="dropdownViewport" class="w-60">
|
||||
<div
|
||||
v-if="searchValue.length > 0 && filteredTags.length === 0"
|
||||
class="bg-card-background-active">
|
||||
<div
|
||||
class="flex space-x-3 items-center px-4 py-3 text-xs 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>
|
||||
<div
|
||||
v-for="tag in filteredTags"
|
||||
:key="tag.id"
|
||||
role="option"
|
||||
:value="tag.id"
|
||||
:class="{
|
||||
'bg-card-background-active':
|
||||
tag.id === highlightedItemId,
|
||||
}"
|
||||
data-testid="tag_dropdown_entries"
|
||||
:data-tag-id="tag.id">
|
||||
<TagDropdownItem
|
||||
:selected="isTagSelected(tag.id)"
|
||||
@click="toggleTag(tag.id)"
|
||||
:name="tag.name"></TagDropdownItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
28
resources/js/Components/Common/Tag/TagDropdownItem.vue
Normal file
28
resources/js/Components/Common/Tag/TagDropdownItem.vue
Normal 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-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
|
||||
<CheckCircleIcon :class="twMerge(iconClasses, 'w-5')"></CheckCircleIcon>
|
||||
<span>{{ name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
const value = defineModel();
|
||||
const emit = defineEmits(['changed']);
|
||||
|
||||
function onChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
emit('changed', target.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<label class="input-sizer text-sm font-medium" :data-value="value">
|
||||
<input
|
||||
data-testid="time_entry_description"
|
||||
v-model="value"
|
||||
@blur="onChange"
|
||||
@keydown.enter="onChange"
|
||||
placeholder="Add a description"
|
||||
class="text-white placeholder-muted font-medium bg-transparent hover:bg-card-background rounded-lg border border-transparent hover:border-card-border" />
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="postcss">
|
||||
.input-sizer {
|
||||
display: inline-grid;
|
||||
vertical-align: top;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
|
||||
&.stacked {
|
||||
align-items: stretch;
|
||||
|
||||
&::after,
|
||||
input,
|
||||
textarea {
|
||||
grid-area: 2 / 1;
|
||||
}
|
||||
}
|
||||
|
||||
&::after,
|
||||
input,
|
||||
textarea {
|
||||
width: auto;
|
||||
min-width: 1em;
|
||||
grid-area: 1 / 2;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
resize: none;
|
||||
background: none;
|
||||
appearance: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: attr(data-value) ' ';
|
||||
visibility: hidden;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import Dropdown from '@/Components/Dropdown.vue';
|
||||
import { TrashIcon } from '@heroicons/vue/20/solid';
|
||||
const emit = defineEmits<{
|
||||
delete: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dropdown>
|
||||
<template #trigger>
|
||||
<svg
|
||||
data-testid="time_entry_actions"
|
||||
class="h-10 w-10 p-2 rounded-full hover:bg-card-background opacity-20 group-hover:opacity-100 transition"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" />
|
||||
</svg>
|
||||
</template>
|
||||
<template #content>
|
||||
<button
|
||||
@click="emit('delete')"
|
||||
data-testid="time_entry_delete"
|
||||
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
|
||||
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import Dropdown from '@/Components/Dropdown.vue';
|
||||
import { defineProps, ref, watch } from 'vue';
|
||||
import { formatTime } from '@/utils/time';
|
||||
import TimePicker from '@/Components/Common/TimePicker.vue';
|
||||
import { useFocusWithin } from '@vueuse/core';
|
||||
|
||||
const props = defineProps<{
|
||||
start: string;
|
||||
end: string | null;
|
||||
}>();
|
||||
|
||||
function formatStartEnd(start: string, end: string | null) {
|
||||
if (end) {
|
||||
return `${formatTime(start)} - ${formatTime(end)}`;
|
||||
} else {
|
||||
return `${formatTime(start)} - ...`;
|
||||
}
|
||||
}
|
||||
const emit = defineEmits(['changed']);
|
||||
const tempStart = ref(props.start);
|
||||
const tempEnd = ref(props.end || null);
|
||||
|
||||
watch(props, () => {
|
||||
tempStart.value = props.start;
|
||||
tempEnd.value = props.end;
|
||||
});
|
||||
function updateTimeEntry() {
|
||||
emit('changed', tempStart.value, tempEnd.value);
|
||||
}
|
||||
|
||||
const dropdownContent = ref();
|
||||
const { focused } = useFocusWithin(dropdownContent);
|
||||
|
||||
watch(focused, (newValue, oldValue) => {
|
||||
if (oldValue === true && newValue === false) {
|
||||
console.log(newValue, oldValue);
|
||||
updateTimeEntry();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<Dropdown
|
||||
align="right"
|
||||
:close-on-content-click="false"
|
||||
@submit="updateTimeEntry">
|
||||
<template #trigger>
|
||||
<button
|
||||
data-testid="time_entry_range_selector"
|
||||
class="text-muted w-[110px] px-2 py-2 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium">
|
||||
{{ formatStartEnd(start, end) }}
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<div
|
||||
ref="dropdownContent"
|
||||
class="grid grid-cols-2 divide-x divide-card-background-seperator text-center py-1">
|
||||
<div>
|
||||
<div class="font-bold text-white text-sm pb-1">
|
||||
Start
|
||||
</div>
|
||||
<TimePicker
|
||||
data-testid="time_entry_range_start"
|
||||
@updated="updateTimeEntry"
|
||||
v-model="tempStart"></TimePicker>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold text-white text-sm pb-1">End</div>
|
||||
<TimePicker
|
||||
data-testid="time_entry_range_end"
|
||||
@updated="updateTimeEntry"
|
||||
v-model="tempEnd"></TimePicker>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style></style>
|
||||
130
resources/js/Components/Common/TimeEntry/TimeEntryRow.vue
Normal file
130
resources/js/Components/Common/TimeEntry/TimeEntryRow.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import MainContainer from '@/Pages/MainContainer.vue';
|
||||
import TimeTrackerStartStop from '@/Components/Common/TimeTrackerStartStop.vue';
|
||||
import TimeEntryRangeSelector from '@/Components/Common/TimeEntry/TimeEntryRangeSelector.vue';
|
||||
import type { Project, TimeEntry } from '@/utils/api';
|
||||
import { computed } from 'vue';
|
||||
import { useProjectsStore } from '@/utils/useProjects';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import ProjectDropdown from '@/Components/Common/Project/ProjectDropdown.vue';
|
||||
import TimeEntryDescriptionInput from '@/Components/Common/TimeEntry/TimeEntryDescriptionInput.vue';
|
||||
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
|
||||
import TimeEntryRowTagDropdown from '@/Components/Common/TimeEntry/TimeEntryRowTagDropdown.vue';
|
||||
import TimeEntryRowDurationInput from '@/Components/Common/TimeEntry/TimeEntryRowDurationInput.vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
||||
import TimeEntryMoreOptionsDropdown from '@/Components/Common/TimeEntry/TimeEntryMoreOptionsDropdown.vue';
|
||||
|
||||
const projectsStore = useProjectsStore();
|
||||
const { projects } = storeToRefs(projectsStore);
|
||||
|
||||
const currentTimeEntryStore = useCurrentTimeEntryStore();
|
||||
const { stopTimer, updateTimer } = currentTimeEntryStore;
|
||||
const { currentTimeEntry } = storeToRefs(currentTimeEntryStore);
|
||||
|
||||
const props = defineProps<{
|
||||
timeEntry: TimeEntry;
|
||||
}>();
|
||||
|
||||
const { updateTimeEntry, createTimeEntry, fetchTimeEntries } =
|
||||
useTimeEntriesStore();
|
||||
|
||||
const timeEntryProject = computed<Project | undefined>(() => {
|
||||
return projects.value.find(
|
||||
(project) => project.id === props.timeEntry.project_id
|
||||
);
|
||||
});
|
||||
|
||||
async function updateStartEndTime(start: string, end: string | null) {
|
||||
if (currentTimeEntry.value.id === props.timeEntry.id) {
|
||||
currentTimeEntry.value.start = start;
|
||||
currentTimeEntry.value.end = end;
|
||||
await updateTimer();
|
||||
} else {
|
||||
await updateTimeEntry({ ...props.timeEntry, start, end });
|
||||
}
|
||||
await fetchTimeEntries();
|
||||
}
|
||||
|
||||
async function onStartStopClick() {
|
||||
if (props.timeEntry.start && !props.timeEntry.end) {
|
||||
await updateTimeEntry({
|
||||
...props.timeEntry,
|
||||
end: dayjs().utc().format(),
|
||||
});
|
||||
} else {
|
||||
if (currentTimeEntry.value.id) {
|
||||
await stopTimer();
|
||||
}
|
||||
await createTimeEntry({
|
||||
...props.timeEntry,
|
||||
start: dayjs().utc().format(),
|
||||
end: null,
|
||||
});
|
||||
}
|
||||
useCurrentTimeEntryStore().fetchCurrentTimeEntry();
|
||||
fetchTimeEntries();
|
||||
}
|
||||
|
||||
function deleteTimeEntry() {
|
||||
useTimeEntriesStore().deleteTimeEntry(props.timeEntry.id);
|
||||
fetchTimeEntries();
|
||||
}
|
||||
|
||||
function updateTimeEntryDescription(description: string) {
|
||||
updateTimeEntry({ ...props.timeEntry, description });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="border-b border-card-border transition"
|
||||
data-testid="time_entry_row">
|
||||
<MainContainer>
|
||||
<div class="flex py-1.5 items-center justify-between group">
|
||||
<div class="flex space-x-1 items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded bg-card-background border-input-border text-accent-500/80 focus:ring-accent-500/80" />
|
||||
<TimeEntryDescriptionInput
|
||||
@changed="updateTimeEntryDescription"
|
||||
:modelValue="
|
||||
timeEntry.description
|
||||
"></TimeEntryDescriptionInput>
|
||||
<ProjectDropdown
|
||||
:border="false"
|
||||
:value="timeEntryProject"></ProjectDropdown>
|
||||
</div>
|
||||
<div class="flex items-center font-medium space-x-2">
|
||||
<TimeEntryRowTagDropdown
|
||||
@changed="updateTimeEntry(timeEntry)"
|
||||
:modelValue="timeEntry.tags"></TimeEntryRowTagDropdown>
|
||||
<div>
|
||||
<TimeEntryRangeSelector
|
||||
:start="timeEntry.start"
|
||||
:end="timeEntry.end"
|
||||
@changed="
|
||||
updateStartEndTime
|
||||
"></TimeEntryRangeSelector>
|
||||
</div>
|
||||
<TimeEntryRowDurationInput
|
||||
:start="timeEntry.start"
|
||||
:end="timeEntry.end"
|
||||
@changed="
|
||||
updateStartEndTime
|
||||
"></TimeEntryRowDurationInput>
|
||||
<TimeTrackerStartStop
|
||||
@changed="onStartStopClick"
|
||||
:active="!!(timeEntry.start && !timeEntry.end)"
|
||||
class="opacity-20 group-hover:opacity-100"></TimeTrackerStartStop>
|
||||
<TimeEntryMoreOptionsDropdown
|
||||
@delete="
|
||||
deleteTimeEntry
|
||||
"></TimeEntryMoreOptionsDropdown>
|
||||
</div>
|
||||
</div>
|
||||
</MainContainer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { calculateDifference, formatHumanReadableDuration } from '@/utils/time';
|
||||
import { computed, defineProps, ref } from 'vue';
|
||||
import parse from 'parse-duration';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const props = defineProps<{
|
||||
start: string;
|
||||
end: string | null;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
changed: [start: string, end: string | null];
|
||||
}>();
|
||||
|
||||
const temporaryCustomTimerEntry = ref<string>('');
|
||||
|
||||
function updateTimerAndStartLiveTimerUpdate() {
|
||||
const time = parse(temporaryCustomTimerEntry.value, 's');
|
||||
if (time && time > 0) {
|
||||
let newEndDate = props.end;
|
||||
let newStartDate = props.start;
|
||||
if (props.end) {
|
||||
// only update end for time entries that are already finished
|
||||
newEndDate = dayjs(props.start).utc().add(time, 's').format();
|
||||
} else {
|
||||
newStartDate = dayjs().utc().subtract(time, 's').format();
|
||||
}
|
||||
emit('changed', newStartDate, newEndDate);
|
||||
}
|
||||
temporaryCustomTimerEntry.value = '';
|
||||
}
|
||||
|
||||
const currentTime = computed({
|
||||
get() {
|
||||
if (temporaryCustomTimerEntry.value !== '') {
|
||||
return temporaryCustomTimerEntry.value;
|
||||
}
|
||||
return formatHumanReadableDuration(
|
||||
calculateDifference(props.start, props.end)
|
||||
);
|
||||
},
|
||||
// setter
|
||||
set(newValue) {
|
||||
if (newValue) {
|
||||
temporaryCustomTimerEntry.value = newValue;
|
||||
} else {
|
||||
temporaryCustomTimerEntry.value = '';
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function selectInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
target.select();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
data-testid="time_entry_duration_input"
|
||||
class="text-white w-[100px] px-3 py-2 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-semibold"
|
||||
@focus="selectInput"
|
||||
@blur="updateTimerAndStartLiveTimerUpdate"
|
||||
@keydown.enter="updateTimerAndStartLiveTimerUpdate"
|
||||
v-model="currentTime" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import DaySectionHeader from '@/Components/Common/DaySectionHeader.vue';
|
||||
import MainContainer from '@/Pages/MainContainer.vue';
|
||||
defineProps<{
|
||||
date: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-card-background border-t border-b border-card-border py-1.5 text-sm">
|
||||
<MainContainer>
|
||||
<DaySectionHeader :date></DaySectionHeader>
|
||||
</MainContainer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import TagDropdown from '@/Components/Common/Tag/TagDropdown.vue';
|
||||
import { computed } from 'vue';
|
||||
import TagBadge from '@/Components/Common/Tag/TagBadge.vue';
|
||||
import type { Tag } from '@/utils/api';
|
||||
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: [],
|
||||
});
|
||||
|
||||
const timeEntryTags = computed<Tag[]>(() => {
|
||||
return tags.value.filter((tag) => model.value.includes(tag.id));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TagDropdown @changed="emit('changed')" v-model="model">
|
||||
<template #trigger>
|
||||
<button data-testid="time_entry_tag_dropdown">
|
||||
<TagBadge
|
||||
:border="false"
|
||||
size="large"
|
||||
class="border-0"
|
||||
:name="
|
||||
timeEntryTags.map((tag) => tag.name).join(', ')
|
||||
"></TagBadge>
|
||||
</button>
|
||||
</template>
|
||||
</TagDropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
58
resources/js/Components/Common/TimePicker.vue
Normal file
58
resources/js/Components/Common/TimePicker.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const model = defineModel<string | null>({
|
||||
default: null,
|
||||
});
|
||||
|
||||
const hours = computed(() => {
|
||||
return model.value ? dayjs(model.value).utc().hour() : null;
|
||||
});
|
||||
|
||||
const minutes = computed(() => {
|
||||
return model.value ? dayjs(model.value).utc().minute() : null;
|
||||
});
|
||||
|
||||
function updateMinutes(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const newValue = target.value;
|
||||
if (parseInt(newValue)) {
|
||||
model.value = dayjs(model.value)
|
||||
.utc()
|
||||
.set('minutes', parseInt(newValue))
|
||||
.format();
|
||||
}
|
||||
}
|
||||
|
||||
function updateHours(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const newValue = target.value;
|
||||
if (parseInt(newValue)) {
|
||||
model.value = dayjs(model.value)
|
||||
.utc()
|
||||
.set('hours', parseInt(newValue))
|
||||
.format();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-center">
|
||||
<input
|
||||
:value="hours"
|
||||
@input="updateHours"
|
||||
data-testid="time_picker_hour"
|
||||
type="text"
|
||||
class="bg-card-background border-none text-sm px-1 py-0.5 w-[30px] text-center focus:ring-0 focus:bg-card-background-active" />
|
||||
<span>:</span>
|
||||
<input
|
||||
:value="minutes"
|
||||
@input="updateMinutes"
|
||||
data-testid="time_picker_minute"
|
||||
type="text"
|
||||
class="bg-card-background border-none text-sm px-1 py-0.5 w-[30px] text-center focus:ring-0 focus:bg-card-background-active" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import TagDropdown from '@/Components/Common/Tag/TagDropdown.vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { TagIcon } from '@heroicons/vue/20/solid';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const emit = defineEmits(['changed']);
|
||||
const model = defineModel({
|
||||
default: [],
|
||||
});
|
||||
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';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TagDropdown @changed="emit('changed')" v-model="model">
|
||||
<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>
|
||||
<span
|
||||
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 }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</TagDropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
89
resources/js/Components/Common/TimeTrackerStartStop.vue
Normal file
89
resources/js/Components/Common/TimeTrackerStartStop.vue
Normal 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/40 hover:scale-110 hover:bg-accent-300/70 ring-accent-200/10 focus:ring-accent-200/10 hover:ring-4',
|
||||
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/50 hover:bg-accent-400/70 focus:bg-accent-400/70';
|
||||
}
|
||||
});
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user