move ui and api to seperate packages and add npm actions for them

This commit is contained in:
Gregor Vostrak
2024-08-21 14:28:31 +02:00
parent b7c9aa6f28
commit 635954f81d
185 changed files with 2755 additions and 712 deletions

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import { twMerge } from 'tailwind-merge';
import { computed } from 'vue';
const props = withDefaults(
defineProps<{
size: 'base' | 'large' | 'xlarge';
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 sm:py-1.5 px-2 sm:px-3 space-x-1.5 sm:space-x-2 text-xs sm:text-sm text-muted',
xlarge: 'py-2 sm:py-2.5 px-3 sm:px-3.5 space-x-2 sm:space-x-3 text-sm sm:text-sm text-muted',
};
const borderClasses = computed(() => {
if (props.border) {
return 'border-input-border border';
}
return '';
});
</script>
<template>
<component
:is="tag"
:class="
twMerge(
badgeClasses[size],
borderClasses,
'rounded inline-flex items-center font-semibold text-white',
props.class
)
">
<slot></slot>
</component>
</template>
<style scoped></style>

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import PrimaryButton from './Buttons/PrimaryButton.vue';
import DialogModal from './DialogModal.vue';
import SecondaryButton from './Buttons/SecondaryButton.vue';
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid';
const show = defineModel('show', { default: false });
const saving = defineModel('saving', { default: false });
const emit = defineEmits<{
submit: [];
}>();
defineProps<{
title: string;
}>();
</script>
<template>
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex justify-center">
<span> {{ title }} </span>
</div>
</template>
<template #content>
<div class="flex items-center space-x-4">
<div class="col-span-6 sm:col-span-4 flex-1">
<slot></slot>
<div class="space-x-3 pt-5 pb-2 flex justify-center">
<PrimaryButton
:class="{ 'opacity-25': saving }"
:disabled="saving"
@click="emit('submit')">
Yes, update existing time entries
</PrimaryButton>
</div>
<p class="text-center pt-3 pb-1">
Learn more about the
<a
target="_blank"
href="https://docs.solidtime.io/user-guide/billable-rates"
class="text-blue-400 hover:text-blue-500 transition"
>billable rate logic
<ArrowTopRightOnSquareIcon
class="w-4 -mt-0.5 inline-block"></ArrowTopRightOnSquareIcon
></a>
</p>
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="show = false"> Cancel </SecondaryButton>
</template>
</DialogModal>
</template>
<style scoped></style>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { HtmlButtonType } from '@/types/dom';
withDefaults(
defineProps<{
type?: HtmlButtonType;
}>(),
{
type: 'button',
}
);
</script>
Modal.vue
<template>
<button
:type="type"
class="inline-flex items-center justify-center px-4 py-2 bg-red-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-red-500 active:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 transition ease-in-out duration-150">
<slot />
</button>
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { HtmlButtonType } from '@/types/dom';
import LoadingSpinner from '../LoadingSpinner.vue';
withDefaults(
defineProps<{
type: HtmlButtonType;
loading: boolean;
}>(),
{
type: 'submit',
loading: false,
}
);
</script>
<template>
<button
:type="type"
:disabled="loading"
class="inline-flex items-center px-2 sm:px-3 py-1 sm:py-2 bg-accent-300/10 border border-accent-300/20 rounded-md font-medium text-xs sm:text-sm text-white hover:bg-accent-300/20 active:bg-accent-300/20 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition ease-in-out duration-150">
<LoadingSpinner v-if="loading"></LoadingSpinner>
<slot />
</button>
</template>

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import type { HtmlButtonType } from '@/types/dom';
import { twMerge } from 'tailwind-merge';
import { type Component } from 'vue';
const props = withDefaults(
defineProps<{
type: HtmlButtonType;
icon?: Component;
size: 'small' | 'base';
}>(),
{
type: 'button',
size: 'base',
}
);
const sizeClasses = {
small: 'text-xs px-2 sm:px-2.5 py-1 sm:py-1.5',
base: 'text-xs sm:text-sm px-2 sm:px-3 py-1 sm:py-2',
};
</script>
<template>
<button
:type="type"
:class="
twMerge(
'bg-button-secondary-background border border-button-secondary-border hover:bg-button-secondary-background-hover shadow-sm transition text-white rounded-lg font-semibold inline-flex items-center space-x-1.5 focus-visible:border-input-border-active focus:outline-none focus:ring-0 disabled:opacity-25 ease-in-out',
sizeClasses[props.size]
)
">
<span
:class="
twMerge('flex items-center ', props.icon ? 'space-x-1.5' : '')
">
<component
v-if="props.icon"
:is="props.icon"
class="w-4 sm:w-5 h-4 sm:h-5 -ml-0.5 sm:-ml-1"></component>
<span>
<slot />
</span>
</span>
</button>
</template>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { Component } from 'vue';
defineProps<{
title: string;
icon?: Component;
}>();
</script>
<template>
<div class="flex w-full items-center justify-between pb-2.5 lg:pb-4">
<h3
class="text-white font-bold text-sm lg:text-base flex items-center space-x-2 lg:space-x-2.5">
<component
v-if="icon"
:is="icon"
class="w-5 lg:w-6 text-icon-default"></component>
<span>
{{ title }}
</span>
</h3>
<div class="flex-1 flex justify-end items-center">
<slot name="actions"></slot>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,181 @@
<script setup lang="ts">
import { PlusCircleIcon } from '@heroicons/vue/20/solid';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { type Component, computed, nextTick, ref, watch } from 'vue';
import ClientDropdownItem from '@/packages/ui/src/Client/ClientDropdownItem.vue';
import type { CreateClientBody, Client } from '@/packages/api/src';
const model = defineModel<string | null>({
default: null,
});
const props = defineProps<{
clients: Client[];
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
}>();
const searchInput = ref<HTMLInputElement | null>(null);
const open = ref(false);
const dropdownViewport = ref<Component | null>(null);
const searchValue = ref('');
function isClientSelected(id: string) {
return model.value === id;
}
watch(open, (isOpen) => {
if (isOpen) {
nextTick(() => {
searchInput.value?.focus();
});
}
});
const filteredClients = computed(() => {
return props.clients.filter((client) => {
return client.name
.toLowerCase()
.includes(searchValue.value?.toLowerCase()?.trim() || '');
});
});
async function addClientIfNoneExists() {
if (searchValue.value.length > 0 && filteredClients.value.length === 0) {
const newClient = await props.createClient({
name: searchValue.value,
});
if (newClient) {
model.value = newClient.id;
searchValue.value = '';
}
} else {
if (highlightedItemId.value) {
model.value = highlightedItemId.value;
}
}
}
watch(filteredClients, () => {
if (filteredClients.value.length > 0) {
highlightedItemId.value = filteredClients.value[0].id;
}
});
function updateSearchValue(event: Event) {
const newInput = (event.target as HTMLInputElement).value;
if (newInput === ' ') {
searchValue.value = '';
const highlightedClientId = highlightedItemId.value;
if (highlightedClientId) {
const highlightedClient = props.clients.find(
(client) => client.id === highlightedClientId
);
if (highlightedClient) {
model.value = highlightedClient.id;
}
}
} else {
searchValue.value = newInput;
}
}
const emit = defineEmits(['update:modelValue', 'changed']);
function updateClient(newValue: string) {
model.value = newValue;
nextTick(() => {
emit('changed');
});
}
function moveHighlightUp() {
if (highlightedItem.value) {
const currentHightlightedIndex = filteredClients.value.indexOf(
highlightedItem.value
);
if (currentHightlightedIndex === 0) {
highlightedItemId.value =
filteredClients.value[filteredClients.value.length - 1].id;
} else {
highlightedItemId.value =
filteredClients.value[currentHightlightedIndex - 1].id;
}
}
}
function moveHighlightDown() {
if (highlightedItem.value) {
const currentHightlightedIndex = filteredClients.value.indexOf(
highlightedItem.value
);
if (currentHightlightedIndex === filteredClients.value.length - 1) {
highlightedItemId.value = filteredClients.value[0].id;
} else {
highlightedItemId.value =
filteredClients.value[currentHightlightedIndex + 1].id;
}
}
}
const highlightedItemId = ref<string | null>(null);
const highlightedItem = computed(() => {
return props.clients.find(
(client) => client.id === highlightedItemId.value
);
});
</script>
<template>
<Dropdown width="120" v-model="open" :closeOnContentClick="true">
<template #trigger>
<slot name="trigger"></slot>
</template>
<template #content>
<input
:value="searchValue"
@input="updateSearchValue"
@keydown.enter="addClientIfNoneExists"
data-testid="client_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-separator focus:border-card-background-separator w-full"
placeholder="Search for a client..." />
<div ref="dropdownViewport" class="w-60">
<div
v-if="
searchValue.length > 0 && filteredClients.length === 0
"
@click="addClientIfNoneExists"
class="bg-card-background-active">
<div
class="flex space-x-3 items-center px-4 py-3 text-xs text-white font-medium border-t rounded-b-lg border-card-background-separator">
<PlusCircleIcon
class="w-5 flex-shrink-0"></PlusCircleIcon>
<span>Add "{{ searchValue }}" as a new Client</span>
</div>
</div>
<div v-else></div>
<div
v-for="client in filteredClients"
:key="client.id"
role="option"
:value="client.id"
:class="{
'bg-card-background-active':
client.id === highlightedItemId,
}"
data-testid="client_dropdown_entries"
:data-client-id="client.id">
<ClientDropdownItem
:selected="isClientSelected(client.id)"
@click="updateClient(client.id)"
:name="client.name"></ClientDropdownItem>
</div>
</div>
</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-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>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import {
formatDate,
formatHumanReadableDate,
} from '@/packages/ui/src/utils/time';
defineProps<{
date: string;
}>();
</script>
<template>
<div class="flex items-center space-x-2">
<svg
class="w-4 sm:w-5 text-muted"
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 text-muted">
{{ formatDate(date) }}
</span>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import Modal from './Modal.vue';
const emit = defineEmits(['close']);
defineProps({
show: {
type: Boolean,
default: false,
},
maxWidth: {
type: String,
default: '2xl',
},
closeable: {
type: Boolean,
default: true,
},
});
const close = () => {
emit('close');
};
</script>
<template>
<Modal
:show="show"
:max-width="maxWidth"
:closeable="closeable"
@close="close">
<div class="px-6 py-4">
<div class="text-lg font-medium text-white" role="heading">
<slot name="title" />
</div>
<div class="mt-4 text-sm text-muted">
<slot name="content" />
</div>
</div>
<div
class="flex flex-row justify-end px-6 py-4 border-t border-card-background-separator bg-default-background rounded-b-2xl text-end">
<slot name="footer" />
</div>
</Modal>
</template>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import { twMerge } from 'tailwind-merge';
import { computed } from 'vue';
const props = withDefaults(
defineProps<{
expanded?: boolean;
size: string;
}>(),
{
expanded: false,
size: 'w-7 h-7',
}
);
const expandedStatusClasses = computed(() => {
if (props.expanded) {
return 'border-card-border border bg-card-background-active text-white';
}
return 'border-card-border border bg-card-background text-muted';
});
</script>
<template>
<button
:class="
twMerge(
'font-medium rounded flex items-center transition justify-center',
expandedStatusClasses,
props.size
)
">
<slot></slot>
</button>
</template>
<style scoped></style>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts"></script>
<template>
<svg 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>
</template>
<style scoped></style>

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import {
formatCents,
getOrganizationCurrencySymbol,
} from '@/packages/ui/src/utils/money';
import { ref, watch } from 'vue';
import { useFocus } from '@vueuse/core';
const props = defineProps<{
name: string;
focus?: boolean;
currency: string;
}>();
const model = defineModel<number | null>({
default: null,
});
const billableRateInput = ref<HTMLInputElement | null>(null);
useFocus(billableRateInput, { initialValue: props.focus });
function cleanUpDecimalValue(value: string) {
value = value.replace(/,/g, '');
value = value.replace(props.currency, '');
return value.replace(/\./g, '');
}
function updateRate(value: string) {
value = value.trim();
if (value.includes(',')) {
const parts = value.split(',');
const lastPart = (parts[parts.length - 1] = parts[parts.length - 1]);
if (lastPart.length === 2) {
// we detected a decimal number with 2 digits after the comma
value = cleanUpDecimalValue(value);
model.value = parseInt(value);
}
} else if (value.includes('.')) {
const parts = value.split('.');
const lastPart = (parts[parts.length - 1] = parts[parts.length - 1]);
if (lastPart.length === 2) {
value = cleanUpDecimalValue(value);
model.value = parseInt(value);
}
} else if (value === '') {
model.value = 0;
} else {
// if it doesn't contain a comma or a dot, it's probably a whole number so let's convert it to cents
const parsedValue = parseInt(cleanUpDecimalValue(value)) * 100;
if (parsedValue) {
model.value = parsedValue;
} else {
model.value = 0;
}
}
inputValue.value = formatValue(model.value);
}
function formatValue(modelValue: number | null) {
const formattedValue = formatCents(modelValue ?? 0, props.currency);
return formattedValue
.replace(getOrganizationCurrencySymbol(props.currency), '')
.trim();
}
watch(model, (newValue) => {
inputValue.value = formatValue(newValue);
});
const inputValue = ref(formatValue(model.value));
</script>
<template>
<div class="relative">
<TextInput
:id="name"
ref="billableRateInput"
v-model="inputValue"
@blur="updateRate($event.target.value)"
@keydown.enter="updateRate($event.target.value)"
type="text"
:name="name"
placeholder="Billable Rate"
class="mt-2 block w-full"
autocomplete="teamMemberRate" />
<div
class="absolute top-0 right-0 h-full flex items-center px-4 font-medium pointer-events-none">
<span>
{{ currency }}
</span>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import { computed } from 'vue';
import { twMerge } from 'tailwind-merge';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
const active = defineModel({ default: false });
const emit = defineEmits(['changed']);
function toggleBillable() {
active.value = !active.value;
emit('changed', active.value);
}
const props = withDefaults(
defineProps<{
size: 'small' | 'base';
}>(),
{
size: 'base',
}
);
const iconColorClasses = computed(() => {
if (active.value) {
return 'text-accent-300 focus:text-accent-200 hover:text-accent-200';
} else {
return 'text-icon-default focus:text-icon-active hover:text-icon-active';
}
});
const iconSizeClasses = computed(() => {
if (props.size === 'small') {
return 'w-5 h-5';
} else {
return 'w-5 lg:w-6 h-5 lg:h-6';
}
});
const iconSizeWrapperClasses =
props.size === 'small' ? 'w-6 sm:w-8 h-6 sm:h-8' : 'w-11 h-11';
</script>
<template>
<button
@click="toggleBillable"
:class="
twMerge(
iconColorClasses,
iconSizeWrapperClasses,
'flex-shrink-0 ring-0 focus:outline-none focus:ring-0 transition focus:bg-card-background-separator hover:bg-card-background-separator rounded-full flex items-center justify-center'
)
">
<BillableIcon :class="iconSizeClasses"></BillableIcon>
</button>
</template>
<style scoped></style>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
import { computed } from 'vue';
const emit = defineEmits(['update:checked']);
const props = defineProps({
checked: {
type: [Array, Boolean],
default: false,
},
value: {
type: String,
default: null,
},
});
const proxyChecked = computed({
get() {
return props.checked;
},
set(val) {
emit('update:checked', val);
},
});
</script>
<template>
<input
v-model="proxyChecked"
type="checkbox"
:value="value"
class="rounded bg-input-background border-input-border text-indigo-600 shadow-sm focus:ring-indigo-500" />
</template>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import {
getDayJsInstance,
getLocalizedDayJs,
} from '@/packages/ui/src/utils/time';
import { twMerge } from 'tailwind-merge';
const props = defineProps<{
class?: string;
}>();
// This has to be a localized timestamp, not UTC
const model = defineModel<string | null>({
default: null,
});
const tempDate = ref(getLocalizedDayJs(model.value).format('YYYY-MM-DD'));
watch(model, (value) => {
tempDate.value = getLocalizedDayJs(value).format('YYYY-MM-DD');
});
function updateDate(event: Event) {
const target = event.target as HTMLInputElement;
const newValue = target.value;
const newDate = getDayJsInstance()(newValue);
if (newDate.isValid()) {
model.value = getLocalizedDayJs(model.value)
.set('year', newDate.year())
.set('month', newDate.month())
.set('date', newDate.date())
.format();
emit('changed', model.value);
}
}
const datePicker = ref<HTMLInputElement | null>(null);
function updateTempValue(event: Event) {
const target = event.target as HTMLInputElement;
tempDate.value = target.value;
}
const emit = defineEmits(['changed']);
</script>
<template>
<div class="flex items-center justify-center text-muted">
<input
ref="datePicker"
@change="updateTempValue"
@blur="updateDate"
@keydown.enter="updateDate"
:class="
twMerge(
'bg-input-background border text-white border-input-border rounded-md',
props.class
)
"
type="date"
id="start"
name="trip-start"
:value="tempDate" />
</div>
</template>
<style scoped>
input::-webkit-calendar-picker-indicator {
filter: invert(1);
opacity: 0.2;
}
</style>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import { CalendarIcon } from '@heroicons/vue/20/solid';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
import { formatDate } from '@/packages/ui/src/utils/time';
const start = defineModel('start', { default: '' });
const end = defineModel('end', { default: '' });
</script>
<template>
<Dropdown :close-on-content-click="false" align="bottom-end">
<template #trigger>
<button
class="px-3 py-1.5 bg-input-background border border-input-border font-medium rounded-lg flex items-center space-x-2">
<CalendarIcon class="w-5"></CalendarIcon>
<div class="text-white">
{{ formatDate(start) }}
<span class="px-1.5 text-muted">-</span>
{{ formatDate(end) }}
</div>
</button>
</template>
<template #content>
<div class="overflow-hidden w-[280px] px-3 py-1.5">
<div class="flex space-x-3 items-center justify-between">
<div class="text-sm font-medium text-muted">Start Date</div>
<DatePicker v-model="start"></DatePicker>
</div>
<div class="mt-2 flex space-x-3 items-center justify-between">
<div class="text-sm font-medium text-muted">End Date</div>
<DatePicker v-model="end"></DatePicker>
</div>
</div>
</template>
</Dropdown>
</template>
<style scoped></style>

View File

@@ -0,0 +1,99 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import {
flip,
type Placement,
type ReferenceElement,
useFloating,
} from '@floating-ui/vue';
import { offset } from '@floating-ui/vue';
import { autoUpdate } from '@floating-ui/vue';
const props = withDefaults(
defineProps<{
align: Placement;
closeOnContentClick: boolean;
}>(),
{
align: 'bottom-start',
closeOnContentClick: true,
}
);
const emit = defineEmits(['open', 'submit']);
const open = defineModel({ default: false });
const closeOnEscape = (e: KeyboardEvent) => {
if (open.value && e.key === 'Escape') {
open.value = false;
}
if (open.value && e.key === 'Enter') {
emit('submit');
if (props.closeOnContentClick) open.value = false;
}
};
onMounted(() => document.addEventListener('keydown', closeOnEscape));
onUnmounted(() => document.removeEventListener('keydown', closeOnEscape));
function onContentClick() {
if (props.closeOnContentClick === true) {
open.value = false;
}
}
function toggleOpen() {
open.value = !open.value;
if (open.value === true) {
emit('open');
}
}
function onBackgroundClick() {
emit('submit');
open.value = false;
}
const reference = ref<null | ReferenceElement>(null);
const floating = ref(null);
const { floatingStyles } = useFloating(reference, floating, {
placement: props.align,
whileElementsMounted: autoUpdate,
middleware: [flip(), offset(10)],
});
</script>
<template>
<div class="min-w-0">
<div @click.prevent="toggleOpen" ref="reference" class="min-w-0">
<slot name="trigger" />
</div>
<!-- Full Screen Dropdown Overlay -->
<Teleport to="body">
<div
v-show="open"
class="fixed inset-0 z-40"
@click.prevent="onBackgroundClick" />
<transition
enter-active-class="transition-opacity ease-out duration-200"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition-opacity ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95">
<div
v-if="open"
class="z-50"
ref="floating"
:style="floatingStyles"
@click="onContentClick">
<div
class="rounded-lg ring-1 relative ring-black ring-opacity-5 border border-card-border overflow-none shadow-dropdown bg-card-background">
<slot name="content" />
</div>
</div>
</transition>
</Teleport>
</div>
</template>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
defineProps({
message: String,
});
</script>
<template>
<div v-show="message">
<p class="text-sm text-red-400">
{{ message }}
</p>
</div>
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
defineProps({
value: String,
});
</script>
<template>
<label class="block font-medium text-sm text-white">
<span v-if="value">{{ value }}</span>
<span v-else><slot /></span>
</label>
</template>

View File

@@ -0,0 +1,149 @@
<script setup lang="ts" generic="T">
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { type Component, computed, ref, watch } from 'vue';
import SelectDropdownItem from '@/packages/ui/src/Input/SelectDropdownItem.vue';
import { onKeyStroke } from '@vueuse/core';
import { type Placement } from '@floating-ui/vue';
const model = defineModel<string | null>({
default: null,
});
const props = withDefaults(
defineProps<{
items: T[];
getKeyFromItem: (item: T) => string | null;
getNameForItem: (item: T) => string;
align?: Placement;
}>(),
{
align: 'bottom-start',
}
);
const open = ref(false);
const dropdownViewport = ref<Component | null>(null);
const searchValue = ref('');
// DropdownMultiselect
const filteredItems = computed<T[]>(() => {
return props.items.filter((item: T) => {
return props
.getNameForItem(item)
.toLowerCase()
.includes(searchValue.value?.toLowerCase()?.trim() || '');
});
});
watch(filteredItems, () => {
if (filteredItems.value.length > 0) {
highlightedItemId.value = props.getKeyFromItem(filteredItems.value[0]);
}
});
const emit = defineEmits(['update:modelValue', 'changed']);
function setItem(newValue: string | null) {
model.value = newValue;
emit('changed');
open.value = false;
}
function moveHighlightUp() {
if (highlightedItem.value) {
const currentHightlightedIndex = filteredItems.value.indexOf(
highlightedItem.value
);
if (currentHightlightedIndex === 0) {
highlightedItemId.value = props.getKeyFromItem(
filteredItems.value[filteredItems.value.length - 1]
);
} else {
highlightedItemId.value = props.getKeyFromItem(
filteredItems.value[currentHightlightedIndex - 1]
);
}
}
}
function moveHighlightDown() {
if (highlightedItem.value) {
const currentHightlightedIndex = filteredItems.value.indexOf(
highlightedItem.value
);
if (currentHightlightedIndex === filteredItems.value.length - 1) {
highlightedItemId.value = props.getKeyFromItem(
filteredItems.value[0]
);
} else {
highlightedItemId.value = props.getKeyFromItem(
filteredItems.value[currentHightlightedIndex + 1]
);
}
}
}
const highlightedItemId = ref<string | null>(null);
const highlightedItem = computed(() => {
return props.items.find(
(item) => props.getKeyFromItem(item) === highlightedItemId.value
);
});
onKeyStroke('ArrowDown', (e) => {
if (open.value === true) {
moveHighlightDown();
e.preventDefault();
}
});
onKeyStroke('ArrowUp', (e) => {
if (open.value === true) {
moveHighlightUp();
e.preventDefault();
}
});
onKeyStroke('Enter', (e) => {
if (open.value === true) {
setItem(highlightedItemId.value);
e.preventDefault();
}
});
watch(open, () => {
if (open.value === true) {
highlightedItemId.value = model.value;
}
});
</script>
<template>
<Dropdown v-model="open" :align="align" :closeOnContentClick="false">
<template #trigger>
<slot name="trigger"> </slot>
</template>
<template #content>
<div ref="dropdownViewport" class="w-60">
<div
v-for="item in filteredItems"
:key="props.getKeyFromItem(item) ?? 'none'"
role="option"
:value="props.getKeyFromItem(item)"
:class="{
'bg-card-background-active':
props.getKeyFromItem(item) === highlightedItemId,
}"
:data-item-id="props.getKeyFromItem(item)">
<SelectDropdownItem
:selected="props.getKeyFromItem(item) === model"
@click="setItem(props.getKeyFromItem(item))"
:name="props.getNameForItem(item)"></SelectDropdownItem>
</div>
</div>
</template>
</Dropdown>
</template>
<style scoped></style>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import { twMerge } from 'tailwind-merge';
const props = defineProps<{
name: string;
selected: boolean;
}>();
</script>
<template>
<div
:class="
twMerge(
'flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out cursor-pointer ',
props.selected
? 'bg-accent-300/20'
: 'hover:bg-card-background-active'
)
">
<span>{{ name }}</span>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
defineProps<{
name?: string;
}>();
const input = ref<HTMLInputElement | null>(null);
onMounted(() => {
if (input.value?.hasAttribute('autofocus')) {
input.value?.focus();
}
});
defineExpose({ focus: () => input.value?.focus() });
const model = defineModel();
</script>
<template>
<input
ref="input"
class="border-input-border border bg-input-background text-white focus:ring-input-border-active focus:ring-0 focus-visible:border-input-border-active rounded-md shadow-sm"
v-model="model"
:name="name" />
</template>

View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import {
getDayJsInstance,
getLocalizedDayJs,
} from '@/packages/ui/src/utils/time';
import { twMerge } from 'tailwind-merge';
import { useFocus } from '@vueuse/core';
// This has to be a localized timestamp, not UTC
const model = defineModel<string | null>({
default: null,
});
const props = withDefaults(
defineProps<{
size: 'base' | 'large';
focus: boolean;
}>(),
{
size: 'base',
focus: false,
}
);
const hours = ref(
model.value ? getLocalizedDayJs(model.value).format('HH') : null
);
const minutes = ref(
model.value ? getLocalizedDayJs(model.value).format('mm') : null
);
watch(
() => model.value,
() => {
hours.value = model.value
? getLocalizedDayJs(model.value).format('HH')
: null;
minutes.value = model.value
? getLocalizedDayJs(model.value).format('mm')
: null;
}
);
function updateMinutes(event: Event) {
const target = event.target as HTMLInputElement;
const newValue = target.value;
if (!isNaN(parseInt(newValue))) {
model.value = getDayJsInstance()(model.value)
.set('minutes', Math.min(parseInt(newValue), 59))
.format();
}
minutes.value = model.value
? getLocalizedDayJs(model.value).format('mm')
: null;
}
function updateHours(event: Event) {
const target = event.target as HTMLInputElement;
const newValue = target.value;
if (newValue.endsWith(':')) {
minutesInput.value?.focus();
} else if (!isNaN(parseInt(newValue))) {
model.value = getLocalizedDayJs(model.value)
.set('hours', Math.min(parseInt(newValue), 23))
.format();
}
hours.value = model.value
? getLocalizedDayJs(model.value).format('HH')
: null;
}
const hoursInput = ref<HTMLInputElement | null>(null);
const minutesInput = ref<HTMLInputElement | null>(null);
const emit = defineEmits(['changed']);
useFocus(hoursInput, { initialValue: props.focus });
</script>
<template>
<div class="flex items-center justify-center text-white">
<div
:class="
twMerge(
'border bg-input-background rounded-md border-input-border overflow-hidden',
props.size === 'large' ? 'py-1.5 px-2' : ''
)
">
<input
v-model="hours"
ref="hoursInput"
@input="updateHours"
@keydown.enter="emit('changed')"
@focus="($event.target as HTMLInputElement).select()"
data-testid="time_picker_hour"
type="text"
:class="
twMerge(
'border-none bg-transparent px-1 py-0.5 w-[30px] text-center focus:ring-0 focus:bg-card-background-active',
props.size === 'large' ? 'text-base' : 'text-sm'
)
" />
<span>:</span>
<input
v-model="minutes"
ref="minutesInput"
@keydown.enter="emit('changed')"
@input="updateMinutes"
@focus="($event.target as HTMLInputElement).select()"
data-testid="time_picker_minute"
type="text"
:class="
twMerge(
'border-none bg-transparent px-1 py-1 w-[30px] text-center focus:ring-0 focus:bg-card-background-active',
props.size === 'large' ? 'text-base' : 'text-sm'
)
" />
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
import { defineProps, ref, watch } from 'vue';
import TimePicker from '@/packages/ui/src/Input/TimePicker.vue';
import { useFocusWithin } from '@vueuse/core';
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
import {
getDayJsInstance,
getLocalizedDayJs,
} from '@/packages/ui/src/utils/time';
import dayjs from 'dayjs';
const props = defineProps<{
start: string;
end: string | null;
focus?: boolean;
}>();
// The timestamps for the changed event are UTC
const emit = defineEmits(['changed']);
const tempStart = ref(
props.start ? getLocalizedDayJs(props.start).format() : dayjs().format()
);
const tempEnd = ref(props.end ? getLocalizedDayJs(props.end).format() : null);
watch(props, () => {
tempStart.value = getLocalizedDayJs(props.start).format();
tempEnd.value = getLocalizedDayJs(props.end).format();
});
function updateTimeEntry() {
const tempStartUtc = getDayJsInstance()(tempStart.value).utc().format();
const tempEndUtc = tempEnd.value
? getDayJsInstance()(tempEnd.value).utc().format()
: null;
if (tempStartUtc !== props.start || tempEndUtc !== props.end) {
emit(
'changed',
getDayJsInstance()(tempStart.value).utc().format(),
getDayJsInstance()(tempEnd.value).utc().format()
);
}
}
const dropdownContent = ref();
const { focused } = useFocusWithin(dropdownContent);
watch(focused, (newValue, oldValue) => {
if (oldValue === true && newValue === false) {
updateTimeEntry();
}
});
</script>
<template>
<div
ref="dropdownContent"
class="grid grid-cols-2 divide-x divide-card-background-separator text-center py-2">
<div class="px-2">
<div class="font-bold text-white text-sm pb-2">Start</div>
<div class="space-y-1">
<TimePicker
data-testid="time_entry_range_start"
:focus
@changed="updateTimeEntry"
v-model="tempStart"></TimePicker>
<DatePicker
class="text-sm px-2 py-1"
@changed="updateTimeEntry"
v-model="tempStart"></DatePicker>
</div>
</div>
<div class="px-2">
<div class="font-bold text-white text-sm pb-2">End</div>
<div v-if="tempEnd !== null" class="space-y-1">
<TimePicker
data-testid="time_entry_range_end"
@changed="updateTimeEntry"
v-model="tempEnd"></TimePicker>
<DatePicker
class="text-sm px-2 py-1"
@changed="updateTimeEntry"
v-model="tempEnd"></DatePicker>
</div>
<div class="text-muted" v-else>-- : --</div>
</div>
</div>
</template>
<style></style>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { twMerge } from 'tailwind-merge';
const props = defineProps<{
class?: string;
}>();
</script>
<template>
<svg
:class="
twMerge('animate-spin -ml-1 mr-3 h-5 w-5 text-white', props.class)
"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</template>
<style scoped></style>

View File

@@ -0,0 +1,103 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, watch } from 'vue';
const props = defineProps({
show: {
type: Boolean,
default: false,
},
maxWidth: {
type: String,
default: '2xl',
},
closeable: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['close']);
watch(
() => props.show,
() => {
if (props.show) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'visible';
}
}
);
const close = () => {
if (props.closeable) {
emit('close');
}
};
const closeOnEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && props.show) {
close();
}
};
onMounted(() => document.addEventListener('keydown', closeOnEscape));
onUnmounted(() => {
document.removeEventListener('keydown', closeOnEscape);
document.body.style.overflow = 'visible';
});
const maxWidthClass = computed(() => {
return {
sm: 'sm:max-w-sm',
md: 'sm:max-w-md',
lg: 'sm:max-w-lg',
xl: 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl',
}[props.maxWidth];
});
</script>
<template>
<teleport to="body">
<transition leave-active-class="duration-200">
<div
v-show="show"
class="fixed inset-0 overflow-y-auto px-4 py-32 sm:px-0 z-50"
scroll-region>
<transition
enter-active-class="ease-out duration-300"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="ease-in duration-200"
leave-from-class="opacity-100"
leave-to-class="opacity-0">
<div
v-show="show"
class="fixed inset-0 transform transition-all backdrop-blur-sm"
@click="close">
<div
class="absolute inset-0 bg-default-background opacity-30" />
</div>
</transition>
<transition
enter-active-class="ease-out duration-300"
enter-from-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to-class="opacity-100 translate-y-0 sm:scale-100"
leave-active-class="ease-in duration-200"
leave-from-class="opacity-100 translate-y-0 sm:scale-100"
leave-to-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<div
v-show="show"
role="dialog"
class="mb-6 bg-default-background border border-card-border rounded-lg shadow-xl transform transition-all sm:w-full sm:mx-auto"
:class="maxWidthClass">
<slot v-if="show" />
</div>
</transition>
</div>
</transition>
</teleport>
</template>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
defineProps<{
label: string;
}>();
</script>
<template>
<Dropdown align="bottom-end">
<template #trigger>
<button
class="focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-1 focus-visible:ring-input-border-active focus-visible:opacity-100 hover:bg-card-background group-hover:opacity-100 opacity-20 transition-opacity text-muted"
:aria-label="label">
<svg
class="h-10 w-10 p-2 rounded-full"
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>
</button>
</template>
<template #content>
<slot></slot>
</template>
</Dropdown>
</template>
<style scoped></style>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import { twMerge } from 'tailwind-merge';
import Badge from '@/packages/ui/src/Badge.vue';
const props = withDefaults(
defineProps<{
name: string;
size: 'base' | 'large' | 'xlarge';
tag: string;
class?: string;
color: string;
border: boolean;
}>(),
{
name: '',
size: 'base',
tag: 'div',
color: 'var(--theme-color-icon-default)',
border: true,
}
);
const indicatorClasses = {
base: 'w-2.5 h-2.5',
large: 'w-2 sm:w-3 h-2 sm:h-3',
xlarge: 'w-3 sm:w-4 h-3 sm:h-4',
};
</script>
<template>
<Badge :name :size :tag :class="props.class" :color :border>
<div
:style="{ backgroundColor: props.color }"
:class="
twMerge(
indicatorClasses[size],
'inline-block rounded-full shrink-0'
)
"></div>
<div class="min-w-0">
<slot>
{{ name }}
</slot>
</div>
</Badge>
</template>
<style scoped></style>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { formatCents } from '@/packages/ui/src/utils/money';
import BillableRateModal from '@/packages/ui/src/BillableRateModal.vue';
const show = defineModel('show', { default: false });
const saving = defineModel('saving', { default: false });
defineProps<{
newBillableRate?: number | null;
projectName: string;
currency: string;
}>();
defineEmits<{
submit: [];
}>();
</script>
<template>
<BillableRateModal
@submit="$emit('submit')"
v-model:show="show"
v-model:saving="saving"
title="Update Project Billable Rate">
<p class="py-1 text-center">
The billable rate of {{ projectName }} will be updated to
<strong>{{
newBillableRate
? formatCents(newBillableRate, currency)
: ' the default rate of the organization member'
}}</strong
>.
</p>
<p class="py-1 text-center font-semibold max-w-md mx-auto">
Do you want to update all existing time entries, where the project
billable rate applies as well?
</p>
</BillableRateModal>
</template>
<style scoped></style>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import SelectDropdown from '@/packages/ui/src/Input/SelectDropdown.vue';
import Badge from '@/packages/ui/src/Badge.vue';
import { ChevronDownIcon } from '@heroicons/vue/20/solid';
import type { BillableKey } from '@/types/projects';
const model = defineModel<BillableKey>({
default: 'non-billable',
});
type Option = { key: BillableKey; name: string };
const options: Option[] = [
{
key: 'non-billable',
name: 'Non-billable',
},
{
key: 'default-rate',
name: 'Default Rate',
},
{
key: 'custom-rate',
name: 'Custom Rate',
},
];
function getKeyFromItem(item: Option) {
return item.key;
}
function getNameFromItem(item: Option) {
return item.name;
}
function getNameForKey(key: BillableKey | undefined) {
const item = options.find((item) => getKeyFromItem(item) === key);
if (item) {
return getNameFromItem(item);
}
return '';
}
</script>
<template>
<SelectDropdown
v-model="model"
:get-key-from-item="getKeyFromItem"
:get-name-for-item="getNameFromItem"
:items="options">
<template #trigger>
<Badge size="xlarge" class="bg-input-background cursor-pointer">
<span>
{{ getNameForKey(model) }}
</span>
<ChevronDownIcon class="text-muted w-5"></ChevronDownIcon>
</Badge>
</template>
</SelectDropdown>
</template>
<style scoped></style>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { colors } from '@/packages/ui/src/utils/color';
const model = defineModel<string>({ default: '' });
</script>
<template>
<div>
<Dropdown align="bottom">
<template #trigger>
<button
class="p-2 bg-input-background hover:bg-tertiary transition rounded-full border border-input-border">
<div
:style="{
backgroundColor: model,
}"
class="w-6 h-6 rounded-full cursor-pointer"></div>
</button>
</template>
<template #content>
<div class="text-white grid grid-cols-6 gap-3 px-3 py-3">
<div
v-for="color in colors"
:key="color"
@click="model = color"
:style="{
backgroundColor: color,
boxShadow: `var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) ${color}30`,
}"
class="w-4 h-4 rounded-full cursor-pointer"></div>
</div>
</template>
</Dropdown>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,142 @@
<script setup lang="ts">
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { computed, ref } from 'vue';
import type {
CreateClientBody,
CreateProjectBody,
Project,
} from '@/packages/api/src';
import { getRandomColor } from '@/packages/ui/src/utils/color';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { useFocus } from '@vueuse/core';
import ClientDropdown from '@/packages/ui/src/Client/ClientDropdown.vue';
import Badge from '@/packages/ui/src/Badge.vue';
import ProjectColorSelector from '@/packages/ui/src/Project/ProjectColorSelector.vue';
import { UserCircleIcon } from '@heroicons/vue/20/solid';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import ProjectEditBillableSection from '@/packages/ui/src/Project/ProjectEditBillableSection.vue';
import type { Client } from '@/packages/api/src';
const show = defineModel('show', { default: false });
const saving = ref(false);
const props = defineProps<{
clients: Client[];
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
currency: string;
}>();
const project = ref<CreateProjectBody>({
name: '',
color: getRandomColor(),
client_id: null,
billable_rate: null,
is_billable: false,
});
async function submit() {
await props.createProject(project.value);
show.value = false;
project.value = {
name: '',
color: getRandomColor(),
client_id: null,
billable_rate: null,
is_billable: false,
};
}
const projectNameInput = ref<HTMLInputElement | null>(null);
useFocus(projectNameInput, { initialValue: true });
const currentClientName = computed(() => {
if (project.value.client_id) {
return props.clients.find(
(client) => client.id === project.value.client_id
)?.name;
}
return 'No Client';
});
</script>
<template>
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Create Project </span>
</div>
</template>
<template #content>
<div
class="sm:flex items-center space-y-2 sm:space-y-0 sm:space-x-4">
<div class="flex-1 flex items-center">
<div class="text-center pr-5">
<InputLabel for="color" value="Color" />
<ProjectColorSelector
class="mt-2.5"
v-model="project.color"></ProjectColorSelector>
</div>
<div class="w-full">
<InputLabel for="projectName" value="Project name" />
<TextInput
id="projectName"
name="projectName"
ref="projectNameInput"
v-model="project.name"
type="text"
placeholder="The next big thing"
@keydown.enter="submit()"
class="mt-2 block w-full"
required
autocomplete="projectName" />
</div>
</div>
<div>
<InputLabel for="client" value="Client" />
<ClientDropdown
:createClient="createClient"
:clients="clients"
class="mt-2"
v-model="project.client_id">
<template #trigger>
<Badge
class="bg-input-background cursor-pointer hover:bg-tertiary"
size="xlarge">
<div class="flex items-center space-x-2">
<UserCircleIcon
class="w-5 text-icon-default"></UserCircleIcon>
<span>
{{ currentClientName }}
</span>
</div>
</Badge>
</template>
</ClientDropdown>
</div>
</div>
<ProjectEditBillableSection
:currency="currency"
v-model:isBillable="project.is_billable"
v-model:billableRate="
project.billable_rate
"></ProjectEditBillableSection>
</template>
<template #footer>
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': saving }"
:disabled="saving"
@click="submit">
Create Project
</PrimaryButton>
</template>
</DialogModal>
</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-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>

View File

@@ -0,0 +1,83 @@
<script setup lang="ts">
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import BillableRateInput from '@/packages/ui/src/Input/BillableRateInput.vue';
import ProjectBillableSelect from '@/packages/ui/src/Project/ProjectBillableSelect.vue';
import { computed, onMounted, ref, watch } from 'vue';
import type { BillableKey } from '@/types/projects';
defineProps<{
currency: string;
}>();
const billableRateSelect = ref<BillableKey>('non-billable');
const billableRate = defineModel<number | null>('billableRate');
const isBillable = defineModel<boolean>('isBillable');
onMounted(() => {
if (isBillable.value === true) {
if (billableRate.value) {
billableRateSelect.value = 'custom-rate';
} else {
billableRateSelect.value = 'default-rate';
}
}
});
watch(billableRateSelect, () => {
if (billableRateSelect.value === 'non-billable') {
isBillable.value = false;
billableRate.value = null;
} else if (billableRateSelect.value === 'default-rate') {
isBillable.value = true;
billableRate.value = null;
} else {
isBillable.value = true;
}
});
billableRateSelect.value = 'non-billable';
const billableOptionInfoTexts: { [key in BillableKey]: string } = {
'non-billable':
'New time entries for this project will not be marked billable by default.',
'default-rate':
'New time entries for this project will be billable at the default rate by default.',
'custom-rate':
'New time entries for this project will be billable at a custom rate by default.',
};
const billableOptionInfoText = computed(() => {
return billableOptionInfoTexts[billableRateSelect.value];
});
const emit = defineEmits(['submit']);
</script>
<template>
<div class="sm:flex items-center space-y-2 sm:space-y-0 sm:space-x-4 pt-6">
<div>
<InputLabel for="billable" value="Billable Default" />
<ProjectBillableSelect
v-model="billableRateSelect"
class="mt-2"></ProjectBillableSelect>
</div>
<div
class="sm:max-w-[120px]"
v-if="billableRateSelect === 'custom-rate'">
<InputLabel for="billableRate" value="Billable Rate" />
<BillableRateInput
@keydown.enter="emit('submit')"
:currency="currency"
v-model="billableRate"
name="billableRate" />
</div>
</div>
<div class="flex items-center text-muted pt-2 pl-1">
<span>
<span class="font-semibold"> Info: </span>
{{ billableOptionInfoText }}
</span>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import { twMerge } from 'tailwind-merge';
import Badge from '../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>

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { ref } from 'vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { useFocus } from '@vueuse/core';
import type { CreateTagBody, Tag } from '@/packages/api/src';
const show = defineModel('show', { default: false });
const saving = ref(false);
const tag = ref<CreateTagBody>({
name: '',
});
const props = defineProps<{
createTag: (name: string) => Promise<Tag | undefined>;
}>();
async function submit() {
const newTag = props.createTag(tag.value.name);
if (newTag !== undefined) {
show.value = false;
}
}
const tagNameInput = ref<HTMLInputElement | null>(null);
useFocus(tagNameInput, { initialValue: true });
</script>
<template>
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Create Tags </span>
</div>
</template>
<template #content>
<div class="flex items-center space-x-4">
<div class="col-span-6 sm:col-span-4 flex-1">
<TextInput
id="tagName"
ref="tagNameInput"
v-model="tag.name"
@keydown.enter="submit"
type="text"
placeholder="Tag Name"
class="mt-1 block w-full"
required
autocomplete="tagName" />
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="show = false"> Cancel </SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': saving }"
:disabled="saving"
@click="submit">
Create Tag
</PrimaryButton>
</template>
</DialogModal>
</template>
<style scoped></style>

View File

@@ -0,0 +1,224 @@
<script setup lang="ts">
import { PlusCircleIcon } from '@heroicons/vue/20/solid';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { type Component, computed, nextTick, ref, watch } from 'vue';
import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
import MultiselectDropdownItem from '@/Components/Common/MultiselectDropdownItem.vue';
import type { Tag } from '@/packages/api/src';
import type { Placement } from '@floating-ui/vue';
const props = withDefaults(
defineProps<{
tags: Tag[];
createTag: (name: string) => Promise<Tag | undefined>;
align: Placement;
}>(),
{
align: 'bottom-start',
}
);
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 = [...model.value, id];
}
emit('changed');
}
const sortedTags = ref(props.tags);
watch(open, (isOpen) => {
if (isOpen) {
nextTick(() => {
searchInput.value?.focus();
});
// sort tags alphabetically
sortedTags.value = [...props.tags].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;
});
nextTick(() => {
if (filteredTags.value.length > 0) {
highlightedItemId.value = filteredTags.value[0].id;
}
});
}
});
const filteredTags = computed(() => {
return sortedTags.value.filter((tag) => {
return tag.name
.toLowerCase()
.includes(searchValue.value?.toLowerCase()?.trim() || '');
});
});
async function createAndAddTag(name: string) {
const newTag = await props.createTag(name);
if (newTag) {
addOrRemoveTagFromSelection(newTag.id);
}
searchValue.value = '';
return newTag;
}
async function addTagIfNoneExists() {
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 = props.tags.find(
(tag) => tag.id === highlightedTagId
);
if (highlightedTag) {
addOrRemoveTagFromSelection(highlightedTag.id);
}
}
} else {
searchValue.value = newInput;
}
}
const emit = defineEmits<{
changed: [];
submit: [];
}>();
function toggleTag(newValue: string) {
if (model.value.includes(newValue)) {
model.value = [...model.value].filter((id) => id !== newValue);
} else {
model.value = [...model.value, 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;
}
}
}
const highlightedItemId = ref<string | null>(null);
const highlightedItem = computed(() => {
return props.tags.find((tag) => tag.id === highlightedItemId.value);
});
const showCreateTagModal = ref(false);
</script>
<template>
<TagCreateModal
:createTag="createAndAddTag"
v-model:show="showCreateTagModal"></TagCreateModal>
<Dropdown
@submit="emit('submit')"
v-model="open"
:align="align"
: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-separator focus:border-card-background-separator w-full"
placeholder="Search for a Tag..." />
<div ref="dropdownViewport" class="w-60 max-h-48 overflow-y-scroll">
<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">
<MultiselectDropdownItem
:selected="isTagSelected(tag.id)"
@click="toggleTag(tag.id)"
:name="tag.name"></MultiselectDropdownItem>
</div>
</div>
<div class="hover:bg-card-background-active rounded-b-lg">
<button
@click="
open = false;
showCreateTagModal = true;
"
class="text-white w-full flex space-x-3 items-center px-4 py-3 text-xs font-semibold border-t border-card-background-separator">
<PlusCircleIcon
class="w-5 flex-shrink-0 text-icon-default"></PlusCircleIcon>
<span>Create new Tag</span>
</button>
</div>
</template>
</Dropdown>
</template>
<style scoped></style>

View File

@@ -0,0 +1,170 @@
<script setup lang="ts">
import MainContainer from '@/Pages/MainContainer.vue';
import TimeTrackerStartStop from '../TimeTrackerStartStop.vue';
import type {
CreateClientBody,
CreateProjectBody,
Project,
Tag,
Task,
TimeEntry,
Client,
} from '@/packages/api/src';
import TimeEntryDescriptionInput from '@/packages/ui/src/TimeEntry/TimeEntryDescriptionInput.vue';
import TimeEntryRowTagDropdown from '@/packages/ui/src/TimeEntry/TimeEntryRowTagDropdown.vue';
import TimeEntryMoreOptionsDropdown from '@/packages/ui/src/TimeEntry/TimeEntryMoreOptionsDropdown.vue';
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';
import { ref } from 'vue';
import {
formatHumanReadableDuration,
formatStartEnd,
} from '@/packages/ui/src/utils/time';
import TimeEntryRow from '@/packages/ui/src/TimeEntry/TimeEntryRow.vue';
import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';
import type { TimeEntriesGroupedByType } from '@/types/time-entries';
const props = defineProps<{
timeEntry: TimeEntriesGroupedByType;
projects: Project[];
tasks: Task[];
tags: Tag[];
clients: Client[];
createTag: (name: string) => Promise<Tag | undefined>;
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
onStartStopClick: (timeEntry: TimeEntry) => void;
updateTimeEntries: (timeEntries: TimeEntry[]) => void;
deleteTimeEntries: (timeEntries: TimeEntry[]) => void;
currency: string;
}>();
function updateTimeEntryDescription(description: string) {
const updatedTimeEntries = props.timeEntry.timeEntries.map((entry) => {
return { ...entry, description };
});
props.updateTimeEntries(updatedTimeEntries);
}
function updateTimeEntryTags(tags: string[]) {
const updatedTimeEntries = props.timeEntry.timeEntries.map((entry) => {
return { ...entry, tags };
});
props.updateTimeEntries(updatedTimeEntries);
}
function updateTimeEntryBillable(billable: boolean) {
const updatedTimeEntries = props.timeEntry.timeEntries.map((entry) => {
return { ...entry, billable };
});
props.updateTimeEntries(updatedTimeEntries);
}
function updateProjectAndTask(projectId: string, taskId: string) {
const updatedTimeEntries = props.timeEntry.timeEntries.map((entry) => {
return { ...entry, project_id: projectId, task_id: taskId };
});
props.updateTimeEntries(updatedTimeEntries);
}
const expanded = ref(false);
</script>
<template>
<div
class="border-b border-default-background-separator transition"
data-testid="time_entry_row">
<MainContainer>
<div class="sm:flex py-1.5 items-center justify-between group">
<div class="flex space-x-3 items-center min-w-0">
<input
type="checkbox"
class="h-4 w-4 rounded bg-card-background border-input-border text-accent-500/80 focus:ring-accent-500/80" />
<div class="flex items-center">
<GroupedItemsCountButton
:expanded="expanded"
@click="expanded = !expanded">
{{ timeEntry?.timeEntries?.length }}
</GroupedItemsCountButton>
<TimeEntryDescriptionInput
@changed="updateTimeEntryDescription"
:modelValue="
timeEntry.description
"></TimeEntryDescriptionInput>
</div>
<TimeTrackerProjectTaskDropdown
:clients
:createProject
:createClient
:projects="projects"
:tasks="tasks"
:showBadgeBorder="false"
@changed="updateProjectAndTask"
:project="timeEntry.project_id"
:currency="currency"
:task="
timeEntry.task_id
"></TimeTrackerProjectTaskDropdown>
</div>
<div class="flex items-center font-medium lg:space-x-2">
<TimeEntryRowTagDropdown
:createTag
:tags="tags"
@changed="updateTimeEntryTags"
:modelValue="timeEntry.tags"></TimeEntryRowTagDropdown>
<BillableToggleButton
:modelValue="timeEntry.billable"
size="small"
@changed="
updateTimeEntryBillable
"></BillableToggleButton>
<div class="flex-1">
<button
@click="expanded = !expanded"
class="hidden lg:block 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(timeEntry.start, timeEntry.end) }}
</button>
</div>
<button
@click="expanded = !expanded"
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">
{{
formatHumanReadableDuration(timeEntry.duration ?? 0)
}}
</button>
<TimeTrackerStartStop
@changed="onStartStopClick(timeEntry)"
:active="!!(timeEntry.start && !timeEntry.end)"
class="opacity-20 hidden sm:flex group-hover:opacity-100"></TimeTrackerStartStop>
<TimeEntryMoreOptionsDropdown
@delete="
deleteTimeEntries([timeEntry])
"></TimeEntryMoreOptionsDropdown>
</div>
</div>
</MainContainer>
<div
v-if="expanded"
class="w-full border-t border-default-background-separator bg-black/15">
<TimeEntryRow
:projects="projects"
:tasks="tasks"
:createClient
:clients
:createProject
:tags="tags"
indent
:updateTimeEntry="(arg: TimeEntry) => updateTimeEntries([arg])"
:onStartStopClick="() => onStartStopClick(subEntry)"
:deleteTimeEntry="() => deleteTimeEntries([subEntry])"
:currency="currency"
:createTag
:key="subEntry.id"
v-for="subEntry in timeEntry.timeEntries"
:time-entry="subEntry"></TimeEntryRow>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
const value = defineModel();
const emit = defineEmits(['changed']);
function onChange(event: Event) {
const target = event.target as HTMLInputElement;
if (target.value !== value.value) {
emit('changed', target.value);
value.value = target.value;
}
}
function onInput(event: Event) {
liveDataValue.value = (event.target as HTMLInputElement).value;
}
const liveDataValue = ref(value.value);
const displaysPlaceholder = computed(() => {
return liveDataValue.value === '' || liveDataValue.value === null;
});
</script>
<template>
<div>
<div class="relative text-sm font-medium">
<div
:class="[
'opacity-0 py-2 text-base whitespace-pre pl-3 pr-1',
{ 'min-w-[150px]': displaysPlaceholder },
]">
{{ liveDataValue }}
</div>
<input
data-testid="time_entry_description"
:value="liveDataValue"
@blur="onChange"
@input="onInput"
@keydown.enter="onChange"
placeholder="Add a description"
class="absolute px-0 h-full pl-3 pr-1 left-0 top-0 w-full text-sm lg:text-base text-white font-medium bg-transparent focus-visible:ring-0 rounded-lg border-0" />
</div>
</div>
</template>

View File

@@ -0,0 +1,153 @@
<script setup lang="ts">
import { computed } from 'vue';
import type {
CreateClientBody,
CreateProjectBody,
CreateTimeEntryBody,
Project,
Tag,
Task,
TimeEntry,
Client,
} from '@/packages/api/src';
import {
getDayJsInstance,
getLocalizedDateFromTimestamp,
} from '@/packages/ui/src/utils/time';
import TimeEntryAggregateRow from '@/packages/ui/src/TimeEntry/TimeEntryAggregateRow.vue';
import TimeEntryRowHeading from '@/packages/ui/src/TimeEntry/TimeEntryRowHeading.vue';
import TimeEntryRow from '@/packages/ui/src/TimeEntry/TimeEntryRow.vue';
import dayjs from 'dayjs';
import type { TimeEntriesGroupedByType } from '@/types/time-entries';
const props = defineProps<{
timeEntries: TimeEntry[];
projects: Project[];
tasks: Task[];
tags: Tag[];
clients: Client[];
createTag: (name: string) => Promise<Tag | undefined>;
updateTimeEntry: (entry: TimeEntry) => void;
updateTimeEntries: (entries: TimeEntry[]) => void;
deleteTimeEntries: (entries: TimeEntry[]) => void;
createTimeEntry: (entry: Omit<CreateTimeEntryBody, 'member_id'>) => void;
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
currency: string;
}>();
const groupedTimeEntries = computed(() => {
const groupedEntriesByDay: Record<string, TimeEntry[]> = {};
for (const entry of props.timeEntries) {
// skip current time entry
if (entry.end === null) {
continue;
}
const oldEntries =
groupedEntriesByDay[getLocalizedDateFromTimestamp(entry.start)];
groupedEntriesByDay[getLocalizedDateFromTimestamp(entry.start)] = [
...(oldEntries ?? []),
entry,
];
}
const groupedEntriesByDayAndType: Record<
string,
TimeEntriesGroupedByType[]
> = {};
for (const dailyEntriesKey in groupedEntriesByDay) {
const dailyEntries = groupedEntriesByDay[dailyEntriesKey];
const newDailyEntries: TimeEntriesGroupedByType[] = [];
for (const entry of dailyEntries) {
// check if same entry already exists
const oldEntriesIndex = newDailyEntries.findIndex(
(e) =>
e.project_id === entry.project_id &&
e.task_id === entry.task_id &&
e.billable === entry.billable &&
e.description === entry.description
);
if (oldEntriesIndex !== -1 && newDailyEntries[oldEntriesIndex]) {
newDailyEntries[oldEntriesIndex].timeEntries.push(entry);
// Add up durations for time entries of the same type
newDailyEntries[oldEntriesIndex].duration =
(newDailyEntries[oldEntriesIndex].duration ?? 0) +
(entry?.duration ?? 0);
// adapt start end times so they show the earliest start and latest end time
if (
getDayJsInstance()(entry.start).isBefore(
getDayJsInstance()(
newDailyEntries[oldEntriesIndex].start
)
)
) {
newDailyEntries[oldEntriesIndex].start = entry.start;
}
if (
getDayJsInstance()(entry.end).isAfter(
getDayJsInstance()(newDailyEntries[oldEntriesIndex].end)
)
) {
newDailyEntries[oldEntriesIndex].end = entry.end;
}
} else {
newDailyEntries.push({ ...entry, timeEntries: [entry] });
}
}
groupedEntriesByDayAndType[dailyEntriesKey] = newDailyEntries;
}
return groupedEntriesByDayAndType;
});
function startTimeEntryFromExisting(entry: TimeEntry) {
props.createTimeEntry({
project_id: entry.project_id,
task_id: entry.task_id,
start: dayjs().utc().format(),
end: null,
billable: entry.billable,
description: entry.description,
});
}
</script>
<template>
<div v-for="(value, key) in groupedTimeEntries" :key="key">
<TimeEntryRowHeading :date="key"></TimeEntryRowHeading>
<template v-for="entry in value" :key="entry.id">
<TimeEntryAggregateRow
:createProject
:createClient
:projects="projects"
:tasks="tasks"
:tags="tags"
:clients
:onStartStopClick="startTimeEntryFromExisting"
:updateTimeEntries
:deleteTimeEntries
:createTag
:currency="currency"
v-if="'timeEntries' in entry && entry.timeEntries.length > 1"
:time-entry="entry"></TimeEntryAggregateRow>
<TimeEntryRow
:createClient
:createProject
:projects="projects"
:tasks="tasks"
:tags="tags"
:clients
:createTag
:updateTimeEntry
:onStartStopClick="() => startTimeEntryFromExisting(entry)"
:deleteTimeEntry="() => deleteTimeEntries([entry])"
:currency="currency"
v-else
:time-entry="entry"></TimeEntryRow>
</template>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import { TrashIcon } from '@heroicons/vue/20/solid';
import MoreOptionsDropdown from '@/packages/ui/src/MoreOptionsDropdown.vue';
const emit = defineEmits<{
delete: [];
}>();
</script>
<template>
<MoreOptionsDropdown label="Actions for the time entry">
<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>
</MoreOptionsDropdown>
</template>
<style scoped></style>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { defineProps, ref } from 'vue';
import { formatStartEnd } from '@/packages/ui/src/utils/time';
import TimeRangeSelector from '@/packages/ui/src/Input/TimeRangeSelector.vue';
defineProps<{
start: string;
end: string | null;
}>();
const emit = defineEmits<{
changed: [start: string, end: string | null];
}>();
const open = ref(false);
</script>
<template>
<div class="relative">
<Dropdown
v-model="open"
@submit="open = false"
align="bottom"
:close-on-content-click="false">
<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>
<TimeRangeSelector
@changed="
(newStart: string, newEnd: string) =>
emit('changed', newStart, newEnd)
"
focus
:start="start"
:end="end">
</TimeRangeSelector>
</template>
</Dropdown>
</div>
</template>
<style></style>

View File

@@ -0,0 +1,135 @@
<script setup lang="ts">
import MainContainer from '@/Pages/MainContainer.vue';
import TimeTrackerStartStop from '@/packages/ui/src/TimeTrackerStartStop.vue';
import TimeEntryRangeSelector from '@/packages/ui/src/TimeEntry/TimeEntryRangeSelector.vue';
import type {
Client,
CreateClientBody,
CreateProjectBody,
Project,
Tag,
Task,
TimeEntry,
} from '@/packages/api/src';
import TimeEntryDescriptionInput from '@/packages/ui/src/TimeEntry/TimeEntryDescriptionInput.vue';
import TimeEntryRowTagDropdown from '@/packages/ui/src/TimeEntry/TimeEntryRowTagDropdown.vue';
import TimeEntryRowDurationInput from '@/packages/ui/src/TimeEntry/TimeEntryRowDurationInput.vue';
import TimeEntryMoreOptionsDropdown from '@/packages/ui/src/TimeEntry/TimeEntryMoreOptionsDropdown.vue';
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';
const props = defineProps<{
timeEntry: TimeEntry;
indent?: boolean;
projects: Project[];
tasks: Task[];
tags: Tag[];
clients: Client[];
createTag: (name: string) => Promise<Tag | undefined>;
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
onStartStopClick: () => void;
deleteTimeEntry: () => void;
updateTimeEntry: (timeEntry: TimeEntry) => void;
currency: string;
}>();
function updateTimeEntryDescription(description: string) {
props.updateTimeEntry({ ...props.timeEntry, description });
}
function updateTimeEntryTags(tags: string[]) {
props.updateTimeEntry({ ...props.timeEntry, tags });
}
function updateTimeEntryBillable(billable: boolean) {
props.updateTimeEntry({ ...props.timeEntry, billable });
}
function updateStartEndTime(start: string, end: string | null) {
props.updateTimeEntry({ ...props.timeEntry, start, end });
}
function updateProjectAndTask(projectId: string, taskId: string) {
props.updateTimeEntry({
...props.timeEntry,
project_id: projectId,
task_id: taskId,
});
}
</script>
<template>
<div
class="border-b border-default-background-separator transition"
data-testid="time_entry_row">
<MainContainer>
<div
class="sm:flex py-1 lg:py-1.5 items-center justify-between group">
<div class="flex space-x-1 items-center min-w-0">
<input
type="checkbox"
class="h-4 w-4 rounded bg-card-background border-input-border text-accent-500/80 focus:ring-accent-500/80" />
<div class="w-7 h-7" v-if="indent === true"></div>
<TimeEntryDescriptionInput
class="flex-1 max-w-[220px] md:max-w-[400px] text-ellipsis overflow-ellipsis"
@changed="updateTimeEntryDescription"
:modelValue="
timeEntry.description
"></TimeEntryDescriptionInput>
<TimeTrackerProjectTaskDropdown
:createProject
:createClient
:clients
:projects="projects"
:tasks="tasks"
:showBadgeBorder="false"
@changed="updateProjectAndTask"
:project="timeEntry.project_id"
:currency="currency"
:task="
timeEntry.task_id
"></TimeTrackerProjectTaskDropdown>
</div>
<div class="flex items-center font-medium lg:space-x-2">
<TimeEntryRowTagDropdown
@changed="updateTimeEntryTags"
:createTag
:tags="tags"
:modelValue="timeEntry.tags"></TimeEntryRowTagDropdown>
<BillableToggleButton
:modelValue="timeEntry.billable"
size="small"
@changed="
updateTimeEntryBillable
"></BillableToggleButton>
<div class="flex-1">
<TimeEntryRangeSelector
class="hidden lg:block"
: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 hidden sm:flex group-hover:opacity-100"></TimeTrackerStartStop>
<TimeEntryMoreOptionsDropdown
@delete="
deleteTimeEntry
"></TimeEntryMoreOptionsDropdown>
</div>
</div>
</MainContainer>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import {
calculateDifference,
formatHumanReadableDuration,
} from '@/packages/ui/src/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>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import DaySectionHeader from '@/packages/ui/src/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 lg:py-1.5 text-xs sm:text-sm">
<MainContainer>
<DaySectionHeader :date></DaySectionHeader>
</MainContainer>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import { computed } from 'vue';
import TagBadge from '@/packages/ui/src/Tag/TagBadge.vue';
import type { Tag } from '@/packages/api/src';
const props = defineProps<{
tags: Tag[];
createTag: (name: string) => Promise<Tag | undefined>;
}>();
const emit = defineEmits<{
changed: [model: string[]];
}>();
const model = defineModel<string[]>({
default: [],
});
const timeEntryTags = computed<Tag[]>(() => {
return props.tags.filter((tag) => model.value.includes(tag.id));
});
</script>
<template>
<TagDropdown
:tags="tags"
align="bottom-end"
:createTag
@changed="emit('changed', model)"
v-model="model">
<template #trigger>
<button
data-testid="time_entry_tag_dropdown"
class="opacity-50 group-hover:opacity-100 transition">
<TagBadge
:border="false"
size="large"
class="border-0"
:name="
timeEntryTags.map((tag: Tag) => tag.name).join(', ')
"></TagBadge>
</button>
</template>
</TagDropdown>
</template>
<style scoped></style>

View File

@@ -0,0 +1,161 @@
<script setup lang="ts">
import TimeTrackerTagDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerTagDropdown.vue';
import TimeTrackerStartStop from '@/packages/ui/src/TimeTrackerStartStop.vue';
import TimeTrackerRangeSelector from '@/packages/ui/src/TimeTracker/TimeTrackerRangeSelector.vue';
import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import type {
CreateClientBody,
CreateProjectBody,
Project,
Tag,
Task,
TimeEntry,
Client,
} from '@/packages/api/src';
import { ref, watch } from 'vue';
import type { Dayjs } from 'dayjs';
const currentTimeEntry = defineModel<TimeEntry>('currentTimeEntry', {
required: true,
});
const liveTimer = defineModel<Dayjs | null>('liveTimer', { required: true });
const currentTimeEntryDescriptionInput = ref<HTMLInputElement | null>(null);
const props = defineProps<{
projects: Project[];
tasks: Task[];
tags: Tag[];
clients: Client[];
createTag: (name: string) => Promise<Tag | undefined>;
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
isActive: boolean;
currency: string;
}>();
const emit = defineEmits<{
startTimer: [];
stopTimer: [];
updateTimeEntry: [];
startLiveTimer: [];
stopLiveTimer: [];
}>();
function updateProject() {
setBillableDefaultForProject();
emit('updateTimeEntry');
}
function startTimerIfNotActive() {
if (!props.isActive) {
currentTimeEntry.value.description = tempDescription.value;
emit('startTimer');
}
}
function setBillableDefaultForProject() {
const project = props.projects.find(
(project) => project.id === currentTimeEntry.value.project_id
);
if (project) {
currentTimeEntry.value.billable = project.is_billable;
}
}
function onToggleButtonPress(newState: boolean) {
if (newState) {
emit('startTimer');
currentTimeEntryDescriptionInput.value?.focus();
} else {
emit('stopTimer');
}
}
const tempDescription = ref(currentTimeEntry.value.description);
watch(
() => currentTimeEntry.value.description,
() => {
tempDescription.value = currentTimeEntry.value.description;
}
);
function updateTimeEntryDescription() {
if (currentTimeEntry.value.description !== tempDescription.value) {
currentTimeEntry.value.description = tempDescription.value;
emit('updateTimeEntry');
}
}
</script>
<template>
<div
class="flex items-center relative @container"
data-testid="dashboard_timer">
<div
class="flex flex-col sm:flex-row w-full justify-between rounded-lg bg-card-background border-card-border border transition shadow-card">
<div class="flex flex-1 items-center pr-6">
<input
placeholder="What are you working on?"
data-testid="time_entry_description"
ref="currentTimeEntryDescriptionInput"
v-model="tempDescription"
@keydown.enter="startTimerIfNotActive"
@blur="updateTimeEntryDescription"
class="w-full rounded-l-lg py-4 sm:py-2.5 px-3.5 border-b border-b-card-background-separator lg:px-4 text-base @4xl:text-lg text-white font-medium bg-transparent border-none placeholder-muted focus:ring-0 transition"
type="text" />
</div>
<div class="flex items-center justify-between pl-2 shrink min-w-0">
<div
class="flex items-center w-[130px] sm:w-auto shrink min-w-0">
<TimeTrackerProjectTaskDropdown
:createClient
:clients
:createProject
:currency="currency"
:projects="projects"
:tasks="tasks"
@changed="updateProject"
v-model:project="currentTimeEntry.project_id"
v-model:task="
currentTimeEntry.task_id
"></TimeTrackerProjectTaskDropdown>
</div>
<div class="flex items-center lg:space-x-2 px-2 lg:px-4">
<TimeTrackerTagDropdown
@changed="$emit('updateTimeEntry')"
:createTag
:tags="tags"
v-model="
currentTimeEntry.tags
"></TimeTrackerTagDropdown>
<BillableToggleButton
@changed="$emit('updateTimeEntry')"
v-model="
currentTimeEntry.billable
"></BillableToggleButton>
</div>
<div class="border-l border-card-border">
<TimeTrackerRangeSelector
@startLiveTimer="emit('startLiveTimer')"
@stopLiveTimer="emit('stopLiveTimer')"
@updateTimer="emit('updateTimeEntry')"
@startTimer="emit('startTimer')"
v-model:currentTimeEntry="currentTimeEntry"
v-model:liveTimer="liveTimer"
@keydown.enter="
startTimerIfNotActive
"></TimeTrackerRangeSelector>
</div>
</div>
</div>
<div
class="pl-4 lg:pl-6 pr-3 absolute sm:relative top-[6px] sm:top-0 right-0">
<TimeTrackerStartStop
:active="isActive"
@changed="onToggleButtonPress"
size="large"></TimeTrackerStartStop>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,433 @@
<script setup lang="ts">
import { ChevronRightIcon } from '@heroicons/vue/16/solid';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { type Component, computed, nextTick, ref, watch } from 'vue';
import ProjectDropdownItem from '@/packages/ui/src/Project/ProjectDropdownItem.vue';
import type {
CreateClientBody,
CreateProjectBody,
Project,
Task,
Client,
} from '@/packages/api/src';
import ProjectBadge from '@/packages/ui/src/Project/ProjectBadge.vue';
import Badge from '@/packages/ui/src/Badge.vue';
import { PlusIcon, PlusCircleIcon } from '@heroicons/vue/16/solid';
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
const task = defineModel<string | null>('task', {
default: null,
});
const project = defineModel<string | null>('project', {
default: null,
});
const searchInput = ref<HTMLInputElement | null>(null);
const open = ref(false);
const dropdownViewport = ref<Component | null>(null);
const searchValue = ref('');
watch(open, (isOpen) => {
if (isOpen) {
nextTick(() => {
initializeHighlightedItem();
searchInput.value?.focus();
});
}
});
type ProjectWithTasks = {
project: Project;
tasks: Task[];
};
const props = withDefaults(
defineProps<{
showBadgeBorder: boolean;
size: 'base' | 'large' | 'xlarge';
projects: Project[];
tasks: Task[];
clients: Client[];
createProject: (
project: CreateProjectBody
) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
currency: string;
}>(),
{
showBadgeBorder: true,
size: 'large',
}
);
const filteredProjects = computed(() => {
return props.projects.reduce(
(filtered: ProjectWithTasks[], filterProject) => {
const projectNameIncludesSearchTerm = filterProject.name
.toLowerCase()
.includes(searchValue.value?.toLowerCase()?.trim() || '');
// check if one of the project tasks
const projectTasks = props.tasks.filter((task) => {
return task.project_id === filterProject.id;
});
const filteredTasks = projectTasks.filter((filterTask) => {
return (
filterTask.name
.toLowerCase()
.includes(
searchValue.value?.toLowerCase()?.trim() || ''
) &&
(!filterTask.is_done || filterTask.id === task.value)
);
});
if (
(projectNameIncludesSearchTerm || filteredTasks.length > 0) &&
(!filterProject.is_archived ||
project.value === filterProject.id)
) {
filtered.push({ project: filterProject, tasks: filteredTasks });
}
return filtered;
},
[
{
project: {
id: '',
name: 'No Project',
color: 'var(--theme-color-icon-default)',
value: '',
client_id: null,
billable_rate: null,
is_archived: false,
is_billable: false,
},
tasks: [],
},
]
);
});
async function addClientIfNoneExists() {
setProjectAndClientBasedOnHighlightedItem();
}
function isProjectSelected(project: Project) {
return project.value === project.id;
}
function initializeHighlightedItem() {
if (filteredProjects.value.length > 0) {
highlightedItemId.value = filteredProjects.value[0].project.id;
}
}
watch(filteredProjects, () => {
initializeHighlightedItem();
});
function setProjectAndClientBasedOnHighlightedItem() {
const highlightedProject = filteredProjects.value.find(
(project) => project.project.id === highlightedItemId.value
);
if (highlightedProject) {
selectProject(highlightedProject.project.id);
}
const highlightedTask = filteredProjects.value
.map((project) => project.tasks)
.flat()
.find((task) => task.id === highlightedItemId.value);
if (highlightedTask) {
selectTask(highlightedTask.id);
}
}
function updateSearchValue(event: Event) {
const newInput = (event.target as HTMLInputElement).value;
if (newInput === ' ') {
searchValue.value = '';
setProjectAndClientBasedOnHighlightedItem();
} else {
searchValue.value = newInput;
}
}
const emit = defineEmits(['update:modelValue', 'changed']);
function moveHighlightUp() {
const currentHighlightedIndex = filteredProjects.value.findIndex(
(projectWithTasks) =>
projectWithTasks.project.id === highlightedItemId.value
);
// check if it is a project id
if (currentHighlightedIndex === -1) {
// the ID is a task ID
const currentProjectWithTasks = filteredProjects.value.find(
(projectWithTasks) =>
projectWithTasks.tasks.some(
(task) => task.id === highlightedItemId.value
)
);
if (currentProjectWithTasks) {
const taskIndex = currentProjectWithTasks.tasks.findIndex(
(task) => task.id === highlightedItemId.value
);
if (taskIndex === -1) {
return;
}
if (taskIndex === 0) {
// highlight the project if it was the first task before
highlightedItemId.value = currentProjectWithTasks.project.id;
return;
}
highlightedItemId.value =
currentProjectWithTasks.tasks[taskIndex - 1].id;
}
}
if (currentHighlightedIndex === 0) {
// highlight the last project or the last project of the last project
const lastProject =
filteredProjects.value[filteredProjects.value.length - 1];
if (lastProject.tasks.length > 0) {
// highlight last task of last project
highlightedItemId.value =
lastProject.tasks[lastProject.tasks.length - 1].id;
} else {
highlightedItemId.value =
filteredProjects.value[
filteredProjects.value.length - 1
].project.id;
}
} else {
const previousProject =
filteredProjects.value[currentHighlightedIndex - 1];
if (previousProject.tasks.length > 0) {
// highlight last task of previous project
highlightedItemId.value =
previousProject.tasks[previousProject.tasks.length - 1].id;
} else {
highlightedItemId.value =
filteredProjects.value[currentHighlightedIndex - 1].project.id;
}
}
}
function moveHighlightDown() {
const currentHighlightedIndex = filteredProjects.value.findIndex(
(projectWithTasks) =>
projectWithTasks.project.id === highlightedItemId.value
);
// check if it is a project id
if (currentHighlightedIndex === -1) {
// the ID is a task ID
const currentProjectWithTasks = filteredProjects.value.find(
(projectWithTasks) =>
projectWithTasks.tasks.some(
(task) => task.id === highlightedItemId.value
)
);
if (currentProjectWithTasks) {
const taskIndex = currentProjectWithTasks.tasks.findIndex(
(task) => task.id === highlightedItemId.value
);
if (taskIndex === -1) {
return;
}
if (taskIndex === currentProjectWithTasks.tasks.length - 1) {
// highlight the next project if it was the last task in current project
const projectIndex = filteredProjects.value.indexOf(
currentProjectWithTasks
);
if (projectIndex === filteredProjects.value.length - 1) {
// highlight the first project if it was the last project
highlightedItemId.value =
filteredProjects.value[0].project.id;
} else {
highlightedItemId.value =
filteredProjects.value[projectIndex + 1].project.id;
}
return;
}
highlightedItemId.value =
currentProjectWithTasks.tasks[taskIndex + 1].id;
}
}
if (currentHighlightedIndex === filteredProjects.value.length - 1) {
// highlight the first project or the last project of the last project
const lastProject =
filteredProjects.value[filteredProjects.value.length - 1];
if (lastProject.tasks.length > 0) {
// highlight last task of last project
highlightedItemId.value = lastProject.tasks[0].id;
} else {
highlightedItemId.value = filteredProjects.value[0].project.id;
}
} else {
const currentProjectWithTasks =
filteredProjects.value[currentHighlightedIndex];
if (currentProjectWithTasks.tasks.length > 0) {
// highlight last task of previous project
highlightedItemId.value = currentProjectWithTasks.tasks[0].id;
} else {
highlightedItemId.value =
filteredProjects.value[currentHighlightedIndex + 1].project.id;
}
}
}
const highlightedItemId = ref<string | null>(null);
const currentProject = computed(() => {
return props.projects.find(
(iteratingProject) => iteratingProject.id === project.value
);
});
const currentTask = computed(() => {
return props.tasks.find(
(iteratingTasks) => iteratingTasks.id === task.value
);
});
const selectedProjectName = computed(() => {
return currentProject.value?.name || 'No Project';
});
const selectedProjectColor = computed(() => {
return currentProject.value?.color || 'var(--theme-color-icon-default)';
});
function selectTask(taskId: string) {
task.value = taskId;
project.value =
props.tasks.find((task) => task.id === taskId)?.project_id || null;
open.value = false;
emit('changed', project.value, task.value);
}
function selectProject(projectId: string) {
project.value = projectId;
task.value = null;
open.value = false;
emit('changed', project.value, task.value);
}
const showCreateProject = ref(false);
</script>
<template>
<div v-if="projects.length === 0">
<Badge
@click="showCreateProject = true"
size="large"
class="cursor-pointer hover:bg-tertiary">
<PlusIcon class="-ml-1 w-5"></PlusIcon>
<span>Add new project</span>
</Badge>
</div>
<Dropdown v-else v-model="open" :closeOnContentClick="false" align="bottom">
<template #trigger>
<ProjectBadge
ref="projectDropdownTrigger"
:color="selectedProjectColor"
:size="size"
:border="showBadgeBorder"
tag="button"
:name="selectedProjectName"
class="focus:border-border-tertiary w-full focus:outline-0 focus:bg-card-background-separator min-w-0 hover:bg-card-background-separator">
<div class="flex items-center lg:space-x-1 min-w-0">
<span class="whitespace-nowrap text-xs lg:text-sm">
{{ selectedProjectName }}
</span>
<ChevronRightIcon
v-if="currentTask"
class="w-4 lg:w-5 text-muted shrink-0"></ChevronRightIcon>
<div
class="min-w-0 shrink text-xs lg:text-sm truncate"
v-if="currentTask">
{{ currentTask.name }}
</div>
</div>
</ProjectBadge>
</template>
<template #content>
<input
:value="searchValue"
@input="updateSearchValue"
@keydown.enter="addClientIfNoneExists"
@click.prevent="searchInput?.focus()"
data-testid="client_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-separator focus:border-card-background-separator w-full"
placeholder="Search for a project or task..." />
<div
ref="dropdownViewport"
class="min-w-[300px] max-h-[250px] overflow-y-scroll relative">
<template
v-for="projectWithTasks in filteredProjects"
:key="projectWithTasks.project.id">
<div
role="option"
:value="projectWithTasks.project.id"
@click="selectProject(projectWithTasks.project.id)"
class="border-t border-card-background-separator"
:class="{
'bg-card-background-active':
projectWithTasks.project.id ===
highlightedItemId,
}"
data-testid="client_dropdown_entries"
:data-project-id="projectWithTasks.project.id">
<ProjectDropdownItem
:selected="
isProjectSelected(projectWithTasks.project)
"
:name="projectWithTasks.project.name"
:color="
projectWithTasks.project.color
"></ProjectDropdownItem>
</div>
<div
v-for="task in projectWithTasks.tasks"
:key="task.id"
@click="selectTask(task.id)"
:class="{
'bg-card-background-active':
task.id === highlightedItemId,
}"
class="flex items-center space-x-3 w-full px-3 py-1.5 text-start text-xs font-semibold leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<div class="w-3 h-3 rounded-full"></div>
<span>{{ task.name }}</span>
</div>
</template>
</div>
<div class="hover:bg-card-background-active rounded-b-lg">
<button
@click="
open = false;
showCreateProject = true;
"
class="text-white flex space-x-3 items-center px-4 py-3 text-xs font-semibold border-t border-card-background-separator">
<PlusCircleIcon
class="w-5 flex-shrink-0 text-icon-default"></PlusCircleIcon>
<span>Create new Project</span>
</button>
</div>
</template>
</Dropdown>
<ProjectCreateModal
:createClient
:currency="currency"
:clients="clients"
:createProject
v-model:show="showCreateProject"></ProjectCreateModal>
</template>
<style scoped></style>

View File

@@ -0,0 +1,165 @@
<script setup lang="ts">
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { computed, ref } from 'vue';
import TimeRangeSelector from '@/packages/ui/src/Input/TimeRangeSelector.vue';
import dayjs, { Dayjs } from 'dayjs';
import parse from 'parse-duration';
import { formatDuration, getDayJsInstance } from '@/packages/ui/src/utils/time';
import type { TimeEntry } from '@/packages/api/src';
const currentTimeEntry = defineModel<TimeEntry>('currentTimeEntry', {
required: true,
});
const now = defineModel<null | Dayjs>('liveTimer');
const emit = defineEmits<{
startLiveTimer: [];
stopLiveTimer: [];
updateTimer: [];
startTimer: [];
}>();
const open = ref(false);
function pauseLiveTimerUpdate(event: FocusEvent) {
(event.target as HTMLInputElement).select();
emit('stopLiveTimer');
}
function onTimeEntryEnterPress() {
updateTimerAndStartLiveTimerUpdate();
const activeElement = document.activeElement as HTMLElement;
activeElement?.blur();
}
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, 'seconds');
return formatDuration(diff);
}
return null;
},
// setter
set(newValue) {
if (newValue) {
temporaryCustomTimerEntry.value = newValue;
} else {
temporaryCustomTimerEntry.value = '';
}
},
});
function updateTimerAndStartLiveTimerUpdate() {
const time = parse(temporaryCustomTimerEntry.value, 's');
if (isNumeric(temporaryCustomTimerEntry.value)) {
const newStartDate = dayjs().subtract(
parseInt(temporaryCustomTimerEntry.value),
'm'
);
currentTimeEntry.value.start = newStartDate.utc().format();
if (currentTimeEntry.value.id !== '') {
emit('updateTimer');
} else {
emit('startTimer');
}
} else if (isHHMM(temporaryCustomTimerEntry.value)) {
const results = parseHHMM(temporaryCustomTimerEntry.value);
if (results) {
const newStartDate = dayjs()
.subtract(parseInt(results[1]), 'h')
.subtract(parseInt(results[2]), 'm');
currentTimeEntry.value.start = newStartDate.utc().format();
if (currentTimeEntry.value.id !== '') {
emit('updateTimer');
} else {
emit('startTimer');
}
}
}
// try to parse natural language like "1h 30m"
else if (time && time > 1) {
const newStartDate = dayjs().subtract(time, 's');
currentTimeEntry.value.start = newStartDate.utc().format();
if (currentTimeEntry.value.id !== '') {
emit('updateTimer');
} else {
emit('startTimer');
}
}
// fallback to minutes if just a number is given
now.value = dayjs().utc();
temporaryCustomTimerEntry.value = '';
emit('startLiveTimer');
}
function isNumeric(value: string) {
return /^-?\d+$/.test(value);
}
const HHMMtimeRegex = /^([0-9]{1,2}):([0-5]?[0-9])$/;
function isHHMM(value: string): boolean {
return HHMMtimeRegex.test(value);
}
function parseHHMM(value: string): string[] | null {
return value.match(HHMMtimeRegex);
}
const temporaryCustomTimerEntry = ref<string>('');
async function updateTimeRange(newStart: string) {
// prohibit updates in the future
if (getDayJsInstance()(newStart).isBefore(getDayJsInstance()())) {
currentTimeEntry.value.start = newStart;
if (currentTimeEntry.value.id) {
emit('updateTimer');
} else {
emit('startTimer');
}
}
}
const startTime = computed(() => {
if (currentTimeEntry.value.start && currentTimeEntry.value.start !== '') {
return currentTimeEntry.value.start;
}
return dayjs().utc().format();
});
</script>
<template>
<div class="relative">
<Dropdown
v-model="open"
@submit="open = false"
align="bottom"
:close-on-content-click="false">
<template #trigger>
<input
placeholder="00:00:00"
@focus="pauseLiveTimerUpdate"
data-testid="time_entry_time"
@blur="updateTimerAndStartLiveTimerUpdate"
@keydown.enter="onTimeEntryEnterPress"
v-model="currentTime"
class="w-[110px] lg:w-[130px] h-full text-white py-2.5 rounded-r-lg text-center px-4 text-base lg:text-lg font-bold bg-card-background border-none placeholder-muted focus:ring-0 transition"
type="text" />
</template>
<template #content>
<TimeRangeSelector
@changed="updateTimeRange"
:start="startTime"
:end="null">
</TimeRangeSelector>
</template>
</Dropdown>
</div>
</template>
<style></style>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
defineEmits<{
switchOrganization: [];
}>();
</script>
<template>
<div
class="absolute w-full h-full backdrop-blur-sm z-10 flex items-center justify-center">
<div
class="w-full h-[calc(100%+10px)] absolute bg-default-background opacity-75 backdrop-blur-sm"></div>
<div class="flex space-x-3 items-center w-full z-20 justify-center">
<span class="text-sm text-white">
The Timer is running in a different organization.
</span>
<SecondaryButton @click="$emit('switchOrganization')"
>Switch to organization</SecondaryButton
>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,54 @@
<script setup lang="ts">
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import { twMerge } from 'tailwind-merge';
import { TagIcon } from '@heroicons/vue/20/solid';
import { computed } from 'vue';
import type { Tag } from '@/packages/api/src';
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';
}
});
defineProps<{
tags: Tag[];
createTag: (name: string) => Promise<Tag | undefined>;
}>();
</script>
<template>
<TagDropdown
:createTag
@changed="emit('changed')"
v-model="model"
:tags="tags">
<template #trigger>
<button
data-testid="tag_dropdown"
:class="
twMerge(
iconColorClasses,
'flex-shrink-0 ring-0 focus:outline-none focus:ring-0 transition focus-visible:bg-card-background-separator hover:bg-card-background-separator rounded-full w-11 h-11 flex items-center justify-center'
)
">
<TagIcon class="w-5 h-5 lg:h-6 lg:w-6"></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>

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import { twMerge } from 'tailwind-merge';
import { computed } from 'vue';
const emit = defineEmits(['changed']);
const props = withDefaults(
defineProps<{
size: 'base' | 'large' | 'small';
active: boolean;
}>(),
{
size: 'base',
active: false,
}
);
const buttonSizeClasses = {
small: 'w-6 h-6 bg-accent-200/40 hover:bg-accent-300/70',
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-4 sm:ring-8 hover:scale-110',
};
const iconClass = {
small: 'w-2.5 h-2.5',
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/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>

View File

@@ -0,0 +1,68 @@
declare global {
interface Window {
getWeekStartSetting: () => string;
getTimezoneSetting: () => string;
}
}
export * from './utils/money';
export * from './utils/color';
export * from './utils/random';
export * from './utils/time';
export * from './Badge.vue';
export * from './BillableRateModal.vue';
export * from './CardTitle.vue';
export * from './DaySectionHeader.vue';
export * from './DialogModal.vue';
export * from './GroupedItemsCountButton.vue';
export * from './LoadingSpinner.vue';
export * from './Modal.vue';
export * from './TimeTrackerStartStop.vue';
export * from './TimeTracker/TimeTrackerControls.vue';
export * from './TimeTracker/TimeTrackerProjectTaskDropdown.vue';
export * from './TimeTracker/TimeTrackerRangeSelector.vue';
export * from './TimeTracker/TimeTrackerRunningInDifferentOrganizationOverlay.vue';
export * from './TimeTracker/TimeTrackerTagDropdown.vue';
export * from './TimeEntry/TimeEntryAggregateRow.vue';
export * from './TimeEntry/TimeEntryDescriptionInput.vue';
export * from './TimeEntry/TimeEntryGroupedTable.vue';
export * from './TimeEntry/TimeEntryMoreOptionsDropdown.vue';
export * from './TimeEntry/TimeEntryRangeSelector.vue';
export * from './TimeEntry/TimeEntryRow.vue';
export * from './TimeEntry/TimeEntryRowDurationInput.vue';
export * from './TimeEntry/TimeEntryRowHeading.vue';
export * from './TimeEntry/TimeEntryRowTagDropdown.vue';
export * from './Tag/TagBadge.vue';
export * from './Tag/TagCreateModal.vue';
export * from './Tag/TagDropdown.vue';
export * from './Project/ProjectBadge.vue';
export * from './Project/ProjectBillableRateModal.vue';
export * from './Project/ProjectBillableSelect.vue';
export * from './Project/ProjectColorSelector.vue';
export * from './Project/ProjectCreateModal.vue';
export * from './Input/BillableRateInput.vue';
export * from './Input/BillableToggleButton.vue';
export * from './Input/Checkbox.vue';
export * from './Input/DatePicker.vue';
export * from './Input/DateRangePicker.vue';
export * from './Input/Dropdown.vue';
export * from './Input/InputError.vue';
export * from './Input/InputLabel.vue';
export * from './Input/SelectDropdown.vue';
export * from './Input/SelectDropdownItem.vue';
export * from './Input/TextInput.vue';
export * from './Icons/BillableIcon.vue';
export * from './Client/ClientDropdown.vue';
export * from './Client/ClientDropdownItem.vue';
export * from './Buttons/DangerButton.vue';
export * from './Buttons/PrimaryButton.vue';
export * from './Buttons/SecondaryButton.vue';

View File

@@ -0,0 +1,33 @@
import Prando from '@/packages/ui/src/utils/random';
export const colors = [
'#ef5350',
'#ec407a',
'#ab47bc',
'#7e57c2',
'#5c6bc0',
'#42a5f5',
'#29b6f6',
'#26c6da',
'#26a69a',
'#66bb6a',
'#9ccc65',
'#d4e157',
'#ffee58',
'#ffca28',
'#ffa726',
'#ff7043',
'#8d6e63',
'#bdbdbd',
'#78909c',
];
export function getRandomColor() {
return colors[Math.floor(Math.random() * colors.length)];
}
export function getRandomColorWithSeed(seed: string) {
const pseudoRandom = new Prando(seed);
const index = pseudoRandom.nextInt(0, colors.length - 1);
return colors[index];
}

View File

@@ -0,0 +1,22 @@
function formatMoney(amount: number, currency: string) {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: currency,
}).format(amount);
}
export function formatCents(amount: number, currency: string) {
return formatMoney(amount / 100, currency);
}
export function getOrganizationCurrencySymbol(currency: string) {
return (0)
.toLocaleString('de-DE', {
style: 'currency',
currency: currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
})
.replace(/\d/g, '')
.trim();
}

View File

@@ -0,0 +1,201 @@
/**
* This is a hardfork of Prando, a pseudo-random number generator.
* @source https://github.com/zeh/prando
*/
export default class Prando {
private static readonly MIN: number = -2147483648; // Int32 min
private static readonly MAX: number = 2147483647; // Int32 max
private _seed: number;
private _value = NaN;
// ================================================================================================================
// CONSTRUCTOR ----------------------------------------------------------------------------------------------------
/**
* Generate a new Prando pseudo-random number generator.
*
* @param seed - A number or string seed that determines which pseudo-random number sequence will be created. Defaults to a random seed based on `Math.random()`.
*/
constructor(seed?: number | string) {
if (typeof seed === 'string') {
// String seed
this._seed = this.hashCode(seed);
} else if (typeof seed === 'number') {
// Numeric seed
this._seed = this.getSafeSeed(seed);
} else {
// Pseudo-random seed
this._seed = this.getSafeSeed(
Prando.MIN +
Math.floor((Prando.MAX - Prando.MIN) * Math.random())
);
}
this.reset();
}
// ================================================================================================================
// PUBLIC INTERFACE -----------------------------------------------------------------------------------------------
/**
* Generates a pseudo-random number between a lower (inclusive) and a higher (exclusive) bounds.
*
* @param min - The minimum number that can be randomly generated.
* @param pseudoMax - The maximum number that can be randomly generated (exclusive).
* @return The generated pseudo-random number.
*/
public next(min = 0, pseudoMax = 1): number {
this.recalculate();
return this.map(this._value, Prando.MIN, Prando.MAX, min, pseudoMax);
}
/**
* Generates a pseudo-random integer number in a range (inclusive).
*
* @param min - The minimum number that can be randomly generated.
* @param max - The maximum number that can be randomly generated.
* @return The generated pseudo-random number.
*/
public nextInt(min = 10, max = 100): number {
this.recalculate();
return Math.floor(
this.map(this._value, Prando.MIN, Prando.MAX, min, max + 1)
);
}
/**
* Generates a pseudo-random string sequence of a particular length from a specific character range.
*
* Note: keep in mind that creating a random string sequence does not guarantee uniqueness; there is always a
* 1 in (char_length^string_length) chance of collision. For real unique string ids, always check for
* pre-existing ids, or employ a robust GUID/UUID generator.
*
* @param length - Length of the string to be generated.
* @param chars - Characters that are used when creating the random string. Defaults to all alphanumeric chars (A-Z, a-z, 0-9).
* @return The generated string sequence.
*/
public nextString(
length = 16,
chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
): string {
let str = '';
while (str.length < length) {
str += this.nextChar(chars);
}
return str;
}
/**
* Generates a pseudo-random string of 1 character specific character range.
*
* @param chars - Characters that are used when creating the random string. Defaults to all alphanumeric chars (A-Z, a-z, 0-9).
* @return The generated character.
*/
public nextChar(
chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
): string {
return chars.substr(this.nextInt(0, chars.length - 1), 1);
}
/**
* Picks a pseudo-random item from an array. The array is left unmodified.
*
* Note: keep in mind that while the returned item will be random enough, picking one item from the array at a time
* does not guarantee nor imply that a sequence of random non-repeating items will be picked. If you want to
* *pick items in a random order* from an array, instead of *pick one random item from an array*, it's best to
* apply a *shuffle* transformation to the array instead, then read it linearly.
*
* @param array - Array of any type containing one or more candidates for random picking.
* @return An item from the array.
*/
public nextArrayItem<T>(array: T[]): T {
return array[this.nextInt(0, array.length - 1)];
}
/**
* Generates a pseudo-random boolean.
*
* @return A value of true or false.
*/
public nextBoolean(): boolean {
this.recalculate();
return this._value > 0.5;
}
/**
* Skips ahead in the sequence of numbers that are being generated. This is equivalent to
* calling next() a specified number of times, but faster since it doesn't need to map the
* new random numbers to a range and return it.
*
* @param iterations - The number of items to skip ahead.
*/
public skip(iterations = 1): void {
while (iterations-- > 0) {
this.recalculate();
}
}
/**
* Reset the pseudo-random number sequence back to its starting seed. Further calls to next()
* will then produce the same sequence of numbers it had produced before. This is equivalent to
* creating a new Prando instance with the same seed as another Prando instance.
*
* Example:
* let rng = new Prando(12345678);
* console.log(rng.next()); // 0.6177754114889017
* console.log(rng.next()); // 0.5784605181725837
* rng.reset();
* console.log(rng.next()); // 0.6177754114889017 again
* console.log(rng.next()); // 0.5784605181725837 again
*/
public reset(): void {
this._value = this._seed;
}
// ================================================================================================================
// PRIVATE INTERFACE ----------------------------------------------------------------------------------------------
private recalculate(): void {
this._value = this.xorshift(this._value);
}
private xorshift(value: number): number {
// Xorshift*32
// Based on George Marsaglia's work: http://www.jstatsoft.org/v08/i14/paper
value ^= value << 13;
value ^= value >> 17;
value ^= value << 5;
return value;
}
private map(
val: number,
minFrom: number,
maxFrom: number,
minTo: number,
maxTo: number
): number {
return (
((val - minFrom) / (maxFrom - minFrom)) * (maxTo - minTo) + minTo
);
}
private hashCode(str: string): number {
let hash = 0;
if (str) {
const l = str.length;
for (let i = 0; i < l; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash |= 0;
hash = this.xorshift(hash);
}
}
return this.getSafeSeed(hash);
}
private getSafeSeed(seed: number): number {
if (seed === 0) return 1;
return seed;
}
}

View File

@@ -0,0 +1,111 @@
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
import isToday from 'dayjs/plugin/isToday';
import isYesterday from 'dayjs/plugin/isYesterday';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import weekOfYear from 'dayjs/plugin/weekOfYear';
import { getUserTimezone, getWeekStart } from '@/utils/useUser';
import updateLocale from 'dayjs/plugin/updateLocale';
import { computed } from 'vue';
dayjs.extend(relativeTime);
dayjs.extend(isToday);
dayjs.extend(isYesterday);
dayjs.extend(duration);
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(updateLocale);
dayjs.extend(weekOfYear);
export function getDayJsInstance() {
dayjs.updateLocale('en', {
weekStart: firstDayIndex.value,
});
return dayjs;
}
export const firstDayIndex = computed(() => {
const apiDayOrder = [
'sunday',
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
];
return apiDayOrder.indexOf(getWeekStart());
});
export function formatHumanReadableDuration(duration: number): string {
const dayJsDuration = dayjs.duration(duration, 's');
const hours = Math.floor(dayJsDuration.asHours());
const minutes = dayJsDuration.minutes();
return `${hours}h ${minutes.toString().padStart(2, '0')}min`;
}
export function formatDuration(duration: number): string {
const dayJsDuration = dayjs.duration(duration, 's');
const hours = Math.floor(dayJsDuration.asHours());
const minutes = dayJsDuration.minutes();
const seconds = dayJsDuration.seconds();
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
export function calculateDifference(start: string, end: string | null) {
if (end === null) {
end = dayjs().utc().format();
}
return dayjs(end).diff(dayjs(start), 'second');
}
/**
* Returns a formatted time.
* @param date - A UTC date time string.
*/
export function formatTime(date: string) {
return dayjs.utc(date).tz(getUserTimezone()).format('HH:mm');
}
export function getLocalizedDayJs(timestamp: string | null) {
return dayjs.utc(timestamp).tz(getUserTimezone());
}
export function getLocalizedDateFromTimestamp(timestamp: string) {
return getLocalizedDayJs(timestamp).format('YYYY-MM-DD');
}
/*
* Returns a formatted date.
* @param date - date in the format of 'YYYY-MM-DD'
*/
export function formatDate(date: string): string {
return dayjs(date).format('DD.MM.YYYY');
}
export function formatWeek(date: string | null): string {
return 'Week ' + getDayJsInstance()(date).week();
}
/*
* Returns a human readable date format.
* @param date - date in the format of 'YYYY-MM-DD'
*/
export function formatHumanReadableDate(date: string) {
if (dayjs(date).isToday()) {
return 'Today';
} else if (dayjs(date).isYesterday()) {
return 'Yesterday';
}
return dayjs(date).fromNow();
}
export function formatStartEnd(start: string, end: string | null) {
if (end) {
return `${formatTime(start)} - ${formatTime(end)}`;
} else {
return `${formatTime(start)} - ...`;
}
}