mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-14 11:12:16 +01:00
add format check, update prettier rules, apply rules consistently
This commit is contained in:
@@ -22,10 +22,7 @@ const props = withDefaults(
|
||||
:type="type"
|
||||
:disabled="loading"
|
||||
class="inline-flex items-center px-2 sm:px-3 py-1 sm:py-2 bg-button-primary-background border border-button-primary-border rounded-md font-medium text-xs sm:text-sm text-button-primary-text hover:bg-button-primary-background-hover active:bg-button-primary-background-hover focus:outline-none focus-visible:ring-2 focus-visible:border-transparent focus-visible:ring-ring transition ease-in-out duration-150">
|
||||
<span
|
||||
:class="
|
||||
twMerge('flex items-center ', props.icon ? 'space-x-1.5' : '')
|
||||
">
|
||||
<span :class="twMerge('flex items-center ', props.icon ? 'space-x-1.5' : '')">
|
||||
<LoadingSpinner v-if="loading"></LoadingSpinner>
|
||||
<component
|
||||
:is="props.icon"
|
||||
|
||||
@@ -36,10 +36,7 @@ const sizeClasses = {
|
||||
props.class
|
||||
)
|
||||
">
|
||||
<span
|
||||
:class="
|
||||
twMerge('flex items-center ', props.icon ? 'space-x-1.5' : '')
|
||||
">
|
||||
<span :class="twMerge('flex items-center ', props.icon ? 'space-x-1.5' : '')">
|
||||
<LoadingSpinner v-if="loading"></LoadingSpinner>
|
||||
<component
|
||||
:is="props.icon"
|
||||
|
||||
@@ -11,10 +11,7 @@ defineProps<{
|
||||
<div class="flex w-full items-center justify-between pb-2.5 lg:pb-3">
|
||||
<h3
|
||||
class="text-text-primary font-medium text-sm lg:text-base flex items-center space-x-1.5 lg:space-x-2">
|
||||
<component
|
||||
:is="icon"
|
||||
v-if="icon"
|
||||
class="w-4 lg:w-4 text-icon-default"></component>
|
||||
<component :is="icon" v-if="icon" class="w-4 lg:w-4 text-icon-default"></component>
|
||||
<span>
|
||||
{{ title }}
|
||||
</span>
|
||||
|
||||
@@ -42,9 +42,7 @@ watch(open, (isOpen) => {
|
||||
|
||||
const filteredClients = computed(() => {
|
||||
return props.clients.filter((client) => {
|
||||
return client.name
|
||||
.toLowerCase()
|
||||
.includes(searchValue.value?.toLowerCase()?.trim() || '');
|
||||
return client.name.toLowerCase().includes(searchValue.value?.toLowerCase()?.trim() || '');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,9 +82,7 @@ function updateValue(client: { id: string | null; name: string }) {
|
||||
<slot name="trigger"></slot>
|
||||
</template>
|
||||
<template #content>
|
||||
<UseFocusTrap
|
||||
v-if="open"
|
||||
:options="{ immediate: true, allowOutsideClick: true }">
|
||||
<UseFocusTrap v-if="open" :options="{ immediate: true, allowOutsideClick: true }">
|
||||
<ComboboxRoot
|
||||
v-model:search-term="searchValue"
|
||||
:open="open"
|
||||
@@ -100,14 +96,11 @@ function updateValue(client: { id: string | null; name: string }) {
|
||||
placeholder="Search for a client..." />
|
||||
</ComboboxAnchor>
|
||||
<ComboboxContent>
|
||||
<ComboboxViewport
|
||||
class="w-60 max-h-60 overflow-y-scroll">
|
||||
<ComboboxViewport class="w-60 max-h-60 overflow-y-scroll">
|
||||
<ComboboxItem
|
||||
:value="{ id: null, name: 'No Client' }"
|
||||
class="data-[highlighted]:bg-card-background-active">
|
||||
<ClientDropdownItem
|
||||
:selected="model === null"
|
||||
name="No Client" />
|
||||
<ClientDropdownItem :selected="model === null" name="No Client" />
|
||||
</ComboboxItem>
|
||||
<ComboboxItem
|
||||
v-for="client in filteredClients"
|
||||
@@ -120,19 +113,13 @@ function updateValue(client: { id: string | null; name: string }) {
|
||||
:name="client.name" />
|
||||
</ComboboxItem>
|
||||
<div
|
||||
v-if="
|
||||
searchValue.length > 0 &&
|
||||
filteredClients.length === 0
|
||||
"
|
||||
v-if="searchValue.length > 0 && filteredClients.length === 0"
|
||||
class="bg-card-background-active">
|
||||
<div
|
||||
class="flex space-x-3 items-center px-4 py-3 text-xs text-text-primary font-medium border-t rounded-b-lg border-card-background-separator"
|
||||
@click="addClientIfNoneExists">
|
||||
<PlusCircleIcon class="w-5 flex-shrink-0" />
|
||||
<span
|
||||
>Add "{{ searchValue }}" as a new
|
||||
Client</span
|
||||
>
|
||||
<span>Add "{{ searchValue }}" as a new Client</span>
|
||||
</div>
|
||||
</div>
|
||||
</ComboboxViewport>
|
||||
|
||||
@@ -24,11 +24,7 @@ const close = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:show="show"
|
||||
:max-width="maxWidth"
|
||||
:closeable="closeable"
|
||||
@close="close">
|
||||
<Modal :show="show" :max-width="maxWidth" :closeable="closeable" @close="close">
|
||||
<div class="px-6 py-4">
|
||||
<div class="text-lg font-medium text-text-primary" role="heading">
|
||||
<slot name="title" />
|
||||
|
||||
@@ -34,8 +34,7 @@ const iconSizeClasses = computed(() => {
|
||||
}
|
||||
});
|
||||
|
||||
const iconSizeWrapperClasses =
|
||||
props.size === 'small' ? 'w-6 sm:w-8 h-6 sm:h-8' : 'w-10 h-10';
|
||||
const iconSizeWrapperClasses = props.size === 'small' ? 'w-6 sm:w-8 h-6 sm:h-8' : 'w-10 h-10';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import {
|
||||
getDayJsInstance,
|
||||
getLocalizedDayJs,
|
||||
} from '@/packages/ui/src/utils/time';
|
||||
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/Components/ui/popover';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { RangeCalendar } from '@/Components/ui/range-calendar';
|
||||
import { CalendarDate } from '@internationalized/date';
|
||||
import { CalendarIcon } from 'lucide-vue-next';
|
||||
import { computed, ref, inject, type ComputedRef, watch } from 'vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import {
|
||||
getDayJsInstance,
|
||||
getLocalizedDayJs,
|
||||
} from '@/packages/ui/src/utils/time';
|
||||
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
|
||||
import { type Organization } from '@/packages/api/src';
|
||||
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
|
||||
import { formatDate } from '@/packages/ui/src/utils/time';
|
||||
@@ -72,128 +65,68 @@ const modelValue = computed<CalendarDateRange>({
|
||||
const open = ref(false);
|
||||
|
||||
function setToday() {
|
||||
emit(
|
||||
'update:start',
|
||||
getLocalizedDayJs().startOf('day').format()
|
||||
);
|
||||
emit('update:start', getLocalizedDayJs().startOf('day').format());
|
||||
emit('update:end', getLocalizedDayJs().endOf('day').format());
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function setThisWeek() {
|
||||
emit(
|
||||
'update:start',
|
||||
getLocalizedDayJs().startOf('week').format()
|
||||
);
|
||||
emit('update:start', getLocalizedDayJs().startOf('week').format());
|
||||
emit('update:end', getLocalizedDayJs().endOf('week').format());
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function setLastWeek() {
|
||||
emit(
|
||||
'update:start',
|
||||
getLocalizedDayJs()
|
||||
.subtract(1, 'week')
|
||||
.startOf('week')
|
||||
.format()
|
||||
);
|
||||
emit(
|
||||
'update:end',
|
||||
getLocalizedDayJs()
|
||||
.subtract(1, 'week')
|
||||
.endOf('week')
|
||||
.format()
|
||||
);
|
||||
emit('update:start', getLocalizedDayJs().subtract(1, 'week').startOf('week').format());
|
||||
emit('update:end', getLocalizedDayJs().subtract(1, 'week').endOf('week').format());
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function setLast14Days() {
|
||||
emit(
|
||||
'update:start',
|
||||
getLocalizedDayJs().subtract(14, 'days').format()
|
||||
);
|
||||
emit('update:start', getLocalizedDayJs().subtract(14, 'days').format());
|
||||
emit('update:end', getLocalizedDayJs().format());
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function setThisMonth() {
|
||||
emit(
|
||||
'update:start',
|
||||
getLocalizedDayJs().startOf('month').format()
|
||||
);
|
||||
emit('update:start', getLocalizedDayJs().startOf('month').format());
|
||||
emit('update:end', getLocalizedDayJs().endOf('month').format());
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function setLastMonth() {
|
||||
emit(
|
||||
'update:start',
|
||||
getLocalizedDayJs()
|
||||
.subtract(1, 'month')
|
||||
.startOf('month')
|
||||
.format()
|
||||
);
|
||||
emit(
|
||||
'update:end',
|
||||
getLocalizedDayJs()
|
||||
.subtract(1, 'month')
|
||||
.endOf('month')
|
||||
.format()
|
||||
);
|
||||
emit('update:start', getLocalizedDayJs().subtract(1, 'month').startOf('month').format());
|
||||
emit('update:end', getLocalizedDayJs().subtract(1, 'month').endOf('month').format());
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function setLast30Days() {
|
||||
emit(
|
||||
'update:start',
|
||||
getLocalizedDayJs().subtract(30, 'days').format()
|
||||
);
|
||||
emit('update:start', getLocalizedDayJs().subtract(30, 'days').format());
|
||||
emit('update:end', getLocalizedDayJs().format());
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function setLast90Days() {
|
||||
emit(
|
||||
'update:start',
|
||||
getDayJsInstance()().subtract(90, 'days').format()
|
||||
);
|
||||
emit('update:start', getDayJsInstance()().subtract(90, 'days').format());
|
||||
emit('update:end', getDayJsInstance()().format());
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function setLast12Months() {
|
||||
emit(
|
||||
'update:start',
|
||||
getLocalizedDayJs().subtract(12, 'months').format()
|
||||
);
|
||||
emit('update:start', getLocalizedDayJs().subtract(12, 'months').format());
|
||||
emit('update:end', getLocalizedDayJs().format());
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function setThisYear() {
|
||||
emit(
|
||||
'update:start',
|
||||
getLocalizedDayJs().startOf('year').format()
|
||||
);
|
||||
emit('update:start', getLocalizedDayJs().startOf('year').format());
|
||||
emit('update:end', getLocalizedDayJs().endOf('year').format());
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function setLastYear() {
|
||||
emit(
|
||||
'update:start',
|
||||
getLocalizedDayJs()
|
||||
.subtract(1, 'year')
|
||||
.startOf('year')
|
||||
.format()
|
||||
);
|
||||
emit(
|
||||
'update:end',
|
||||
getLocalizedDayJs()
|
||||
.subtract(1, 'year')
|
||||
.endOf('year')
|
||||
.format()
|
||||
);
|
||||
emit('update:start', getLocalizedDayJs().subtract(1, 'year').startOf('year').format());
|
||||
emit('update:end', getLocalizedDayJs().subtract(1, 'year').endOf('year').format());
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
@@ -220,27 +153,12 @@ watch(open, (value) => {
|
||||
<CalendarIcon class="-ml-0.5 text-text-quaternary h-4 w-4" />
|
||||
<template v-if="modelValue.start">
|
||||
<template v-if="modelValue.end">
|
||||
{{
|
||||
formatDate(
|
||||
modelValue.start.toString(),
|
||||
organization?.date_format
|
||||
)
|
||||
}}
|
||||
{{ formatDate(modelValue.start.toString(), organization?.date_format) }}
|
||||
-
|
||||
{{
|
||||
formatDate(
|
||||
modelValue.end.toString(),
|
||||
organization?.date_format
|
||||
)
|
||||
}}
|
||||
{{ formatDate(modelValue.end.toString(), organization?.date_format) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{
|
||||
formatDate(
|
||||
modelValue.start.toString(),
|
||||
organization?.date_format
|
||||
)
|
||||
}}
|
||||
{{ formatDate(modelValue.start.toString(), organization?.date_format) }}
|
||||
</template>
|
||||
</template>
|
||||
<template v-else> Pick a date </template>
|
||||
@@ -250,17 +168,39 @@ watch(open, (value) => {
|
||||
<div class="flex divide-x divide-border-secondary">
|
||||
<div
|
||||
class="text-text-primary text-sm flex flex-col space-y-0.5 items-start py-2 px-2">
|
||||
<Button variant="ghost" size="sm" class="justify-start" @click="setToday">Today</Button>
|
||||
<Button variant="ghost" size="sm" class="justify-start" @click="setThisWeek">This Week</Button>
|
||||
<Button variant="ghost" size="sm" class="justify-start" @click="setLastWeek">Last Week</Button>
|
||||
<Button variant="ghost" size="sm" class="justify-start" @click="setLast14Days">Last 14 days</Button>
|
||||
<Button variant="ghost" size="sm" class="justify-start" @click="setThisMonth">This Month</Button>
|
||||
<Button variant="ghost" size="sm" class="justify-start" @click="setLastMonth">Last Month</Button>
|
||||
<Button variant="ghost" size="sm" class="justify-start" @click="setLast30Days">Last 30 days</Button>
|
||||
<Button variant="ghost" size="sm" class="justify-start" @click="setLast90Days">Last 90 days</Button>
|
||||
<Button variant="ghost" size="sm" class="justify-start" @click="setLast12Months">Last 12 months</Button>
|
||||
<Button variant="ghost" size="sm" class="justify-start" @click="setThisYear">This year</Button>
|
||||
<Button variant="ghost" size="sm" class="justify-start" @click="setLastYear">Last year</Button>
|
||||
<Button variant="ghost" size="sm" class="justify-start" @click="setToday"
|
||||
>Today</Button
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="justify-start" @click="setThisWeek"
|
||||
>This Week</Button
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="justify-start" @click="setLastWeek"
|
||||
>Last Week</Button
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="justify-start" @click="setLast14Days"
|
||||
>Last 14 days</Button
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="justify-start" @click="setThisMonth"
|
||||
>This Month</Button
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="justify-start" @click="setLastMonth"
|
||||
>Last Month</Button
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="justify-start" @click="setLast30Days"
|
||||
>Last 30 days</Button
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="justify-start" @click="setLast90Days"
|
||||
>Last 90 days</Button
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="justify-start" @click="setLast12Months"
|
||||
>Last 12 months</Button
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="justify-start" @click="setThisYear"
|
||||
>This year</Button
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="justify-start" @click="setLastYear"
|
||||
>Last year</Button
|
||||
>
|
||||
</div>
|
||||
<div class="pl-2">
|
||||
<RangeCalendar
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
|
||||
import { watch } from "vue";
|
||||
import { watch } from 'vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -42,7 +42,6 @@ watch(open, (value) => {
|
||||
emit('submit');
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import parse from 'parse-duration';
|
||||
import { onMounted, ref, watch, inject } from 'vue';
|
||||
import {
|
||||
formatHumanReadableDuration,
|
||||
getDayJsInstance,
|
||||
} from '@/packages/ui/src/utils/time';
|
||||
import { formatHumanReadableDuration, getDayJsInstance } from '@/packages/ui/src/utils/time';
|
||||
import dayjs from 'dayjs';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { TextInput } from '@/packages/ui/src';
|
||||
|
||||
@@ -61,8 +61,7 @@ function updateAndSubmit() {
|
||||
@blur="updateDuration"
|
||||
@keydown.enter="updateAndSubmit">
|
||||
</TextInput>
|
||||
<div
|
||||
class="absolute top-0 right-0 h-full flex items-center px-4 font-medium">
|
||||
<div class="absolute top-0 right-0 h-full flex items-center px-4 font-medium">
|
||||
<span> hours </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,8 +8,7 @@ const props = defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label
|
||||
:class="twMerge('block font-medium text-sm text-text-primary', props.class)">
|
||||
<label :class="twMerge('block font-medium text-sm text-text-primary', props.class)">
|
||||
<span v-if="value">{{ value }}</span>
|
||||
<span v-else><slot /></span>
|
||||
</label>
|
||||
|
||||
@@ -44,17 +44,13 @@ watch(open, (isOpen) => {
|
||||
const aIsSelected = model.value.includes(props.getKeyFromItem(a));
|
||||
const bIsSelected = model.value.includes(props.getKeyFromItem(b));
|
||||
if (aIsSelected === bIsSelected) {
|
||||
return props
|
||||
.getNameForItem(a)
|
||||
.localeCompare(props.getNameForItem(b));
|
||||
return props.getNameForItem(a).localeCompare(props.getNameForItem(b));
|
||||
}
|
||||
return model.value.includes(props.getKeyFromItem(a)) ? -1 : 1;
|
||||
});
|
||||
nextTick(() => {
|
||||
if (filteredItems.value.length > 0) {
|
||||
highlightedItemId.value = props.getKeyFromItem(
|
||||
filteredItems.value[0]
|
||||
);
|
||||
highlightedItemId.value = props.getKeyFromItem(filteredItems.value[0]);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -85,9 +81,7 @@ function updateSearchValue(event: Event) {
|
||||
(item) => props.getKeyFromItem(item) === highlightedTagId
|
||||
);
|
||||
if (highlightedItem) {
|
||||
addOrRemoveItemFromSelection(
|
||||
props.getKeyFromItem(highlightedItem)
|
||||
);
|
||||
addOrRemoveItemFromSelection(props.getKeyFromItem(highlightedItem));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -110,9 +104,7 @@ function toggleItem(newValue: string | null) {
|
||||
|
||||
function moveHighlightUp() {
|
||||
if (highlightedItem.value) {
|
||||
const currentHightlightedIndex = filteredItems.value.indexOf(
|
||||
highlightedItem.value
|
||||
);
|
||||
const currentHightlightedIndex = filteredItems.value.indexOf(highlightedItem.value);
|
||||
if (currentHightlightedIndex === 0) {
|
||||
highlightedItemId.value = props.getKeyFromItem(
|
||||
filteredItems.value[filteredItems.value.length - 1]
|
||||
@@ -127,13 +119,9 @@ function moveHighlightUp() {
|
||||
|
||||
function moveHighlightDown() {
|
||||
if (highlightedItem.value) {
|
||||
const currentHightlightedIndex = filteredItems.value.indexOf(
|
||||
highlightedItem.value
|
||||
);
|
||||
const currentHightlightedIndex = filteredItems.value.indexOf(highlightedItem.value);
|
||||
if (currentHightlightedIndex === filteredItems.value.length - 1) {
|
||||
highlightedItemId.value = props.getKeyFromItem(
|
||||
filteredItems.value[0]
|
||||
);
|
||||
highlightedItemId.value = props.getKeyFromItem(filteredItems.value[0]);
|
||||
} else {
|
||||
highlightedItemId.value = props.getKeyFromItem(
|
||||
filteredItems.value[currentHightlightedIndex + 1]
|
||||
@@ -144,9 +132,7 @@ function moveHighlightDown() {
|
||||
|
||||
const highlightedItemId = ref<string | null>(null);
|
||||
const highlightedItem = computed(() => {
|
||||
return props.items.find(
|
||||
(item) => props.getKeyFromItem(item) === highlightedItemId.value
|
||||
);
|
||||
return props.items.find((item) => props.getKeyFromItem(item) === highlightedItemId.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -165,9 +151,7 @@ const highlightedItem = computed(() => {
|
||||
@keydown.up.prevent="moveHighlightUp"
|
||||
@keydown.down.prevent="moveHighlightDown"
|
||||
@keydown.enter="toggleItem(highlightedItemId)" />
|
||||
<div
|
||||
ref="dropdownViewport"
|
||||
class="min-w-60 max-w-80 max-h-60 overflow-y-scroll">
|
||||
<div ref="dropdownViewport" class="min-w-60 max-w-80 max-h-60 overflow-y-scroll">
|
||||
<div
|
||||
v-for="item in filteredItems"
|
||||
:key="props.getKeyFromItem(item)"
|
||||
@@ -180,9 +164,7 @@ const highlightedItem = computed(() => {
|
||||
:data-item-id="props.getKeyFromItem(item)">
|
||||
<MultiselectDropdownItem
|
||||
:selected="isItemSelected(props.getKeyFromItem(item))"
|
||||
:name="
|
||||
props.getNameForItem(item)
|
||||
"
|
||||
:name="props.getNameForItem(item)"
|
||||
@click="toggleItem(props.getKeyFromItem(item))"></MultiselectDropdownItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,9 +21,7 @@ const iconClasses = computed(() => {
|
||||
<div
|
||||
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary 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 class="flex-1 min-w-0 overflow-ellipsis overflow-hidden">{{
|
||||
name
|
||||
}}</span>
|
||||
<span class="flex-1 min-w-0 overflow-ellipsis overflow-hidden">{{ name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||
import {computed, nextTick, onMounted, ref, watch} from 'vue';
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import SelectDropdownItem from '@/packages/ui/src/Input/SelectDropdownItem.vue';
|
||||
import { onKeyStroke } from '@vueuse/core';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
@@ -42,7 +42,7 @@ const filteredItems = computed<T[]>(() => {
|
||||
const highlightedItemId = ref<string | null>(model.value);
|
||||
|
||||
watch(model, () => {
|
||||
if(model.value){
|
||||
if (model.value) {
|
||||
highlightedItemId.value = model.value;
|
||||
}
|
||||
});
|
||||
@@ -57,7 +57,7 @@ watch(filteredItems, () => {
|
||||
resetHightlightedItem();
|
||||
});
|
||||
|
||||
function resetHightlightedItem(){
|
||||
function resetHightlightedItem() {
|
||||
if (
|
||||
filteredItems.value.length > 0 &&
|
||||
filteredItems.value.find(
|
||||
@@ -91,9 +91,7 @@ function setItem(newValue: string | null) {
|
||||
|
||||
function moveHighlightUp() {
|
||||
if (highlightedItem.value) {
|
||||
const currentHightlightedIndex = filteredItems.value.indexOf(
|
||||
highlightedItem.value
|
||||
);
|
||||
const currentHightlightedIndex = filteredItems.value.indexOf(highlightedItem.value);
|
||||
if (currentHightlightedIndex === 0) {
|
||||
highlightedItemId.value = props.getKeyFromItem(
|
||||
filteredItems.value[filteredItems.value.length - 1]
|
||||
@@ -108,13 +106,9 @@ function moveHighlightUp() {
|
||||
|
||||
function moveHighlightDown() {
|
||||
if (highlightedItem.value) {
|
||||
const currentHightlightedIndex = filteredItems.value.indexOf(
|
||||
highlightedItem.value
|
||||
);
|
||||
const currentHightlightedIndex = filteredItems.value.indexOf(highlightedItem.value);
|
||||
if (currentHightlightedIndex === filteredItems.value.length - 1) {
|
||||
highlightedItemId.value = props.getKeyFromItem(
|
||||
filteredItems.value[0]
|
||||
);
|
||||
highlightedItemId.value = props.getKeyFromItem(filteredItems.value[0]);
|
||||
} else {
|
||||
highlightedItemId.value = props.getKeyFromItem(
|
||||
filteredItems.value[currentHightlightedIndex + 1]
|
||||
@@ -124,9 +118,7 @@ function moveHighlightDown() {
|
||||
}
|
||||
|
||||
const highlightedItem = computed(() => {
|
||||
return props.items.find(
|
||||
(item) => props.getKeyFromItem(item) === highlightedItemId.value
|
||||
);
|
||||
return props.items.find((item) => props.getKeyFromItem(item) === highlightedItemId.value);
|
||||
});
|
||||
|
||||
onKeyStroke('ArrowDown', (e) => {
|
||||
@@ -173,12 +165,7 @@ watch(open, () => {
|
||||
<template #content>
|
||||
<div
|
||||
ref="dropdownViewport"
|
||||
:class="
|
||||
twMerge(
|
||||
'w-60 py-1.5 max-h-60 overflow-y-scroll',
|
||||
props.class
|
||||
)
|
||||
">
|
||||
:class="twMerge('w-60 py-1.5 max-h-60 overflow-y-scroll', props.class)">
|
||||
<div
|
||||
v-for="item in filteredItems"
|
||||
:key="props.getKeyFromItem(item) ?? 'none'"
|
||||
@@ -187,14 +174,10 @@ watch(open, () => {
|
||||
:value="props.getKeyFromItem(item)"
|
||||
:data-item-id="props.getKeyFromItem(item)">
|
||||
<SelectDropdownItem
|
||||
:highlighted="
|
||||
props.getKeyFromItem(item) === highlightedItemId
|
||||
"
|
||||
:highlighted="props.getKeyFromItem(item) === highlightedItemId"
|
||||
:selected="props.getKeyFromItem(item) === model"
|
||||
:name="props.getNameForItem(item)"
|
||||
@mouseenter="
|
||||
highlightedItemId = props.getKeyFromItem(item)
|
||||
"
|
||||
@mouseenter="highlightedItemId = props.getKeyFromItem(item)"
|
||||
@click="setItem(props.getKeyFromItem(item))"></SelectDropdownItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,9 +15,7 @@ const props = defineProps<{
|
||||
twMerge(
|
||||
'flex items-center space-x-3 w-full px-1.5 py-1.5 rounded text-start text-sm font-medium leading-5 text-text-primary focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out cursor-pointer ',
|
||||
props.highlighted && 'bg-card-background-active',
|
||||
props.selected
|
||||
? 'bg-accent-300/20'
|
||||
: 'hover:bg-card-background-active'
|
||||
props.selected ? 'bg-accent-300/20' : 'hover:bg-card-background-active'
|
||||
)
|
||||
">
|
||||
<span>{{ name }}</span>
|
||||
|
||||
@@ -6,21 +6,24 @@ const model = defineModel<string | number>({
|
||||
default: '',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
id?: string;
|
||||
type?: string;
|
||||
class?: string;
|
||||
rows?: number;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
}>(), {
|
||||
type: 'text',
|
||||
class: '',
|
||||
rows: 3,
|
||||
disabled: false,
|
||||
required: false,
|
||||
});
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string;
|
||||
type?: string;
|
||||
class?: string;
|
||||
rows?: number;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
}>(),
|
||||
{
|
||||
type: 'text',
|
||||
class: '',
|
||||
rows: 3,
|
||||
disabled: false,
|
||||
required: false,
|
||||
}
|
||||
);
|
||||
|
||||
const classes = computed(() => {
|
||||
return twMerge(
|
||||
@@ -39,6 +42,5 @@ const classes = computed(() => {
|
||||
:rows="rows"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:required="required"
|
||||
/>
|
||||
:required="required" />
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import {
|
||||
getDayJsInstance,
|
||||
getLocalizedDayJs,
|
||||
} from '@/packages/ui/src/utils/time';
|
||||
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
|
||||
import { useFocus } from '@vueuse/core';
|
||||
import { SelectDropdown, TextInput } from '@/packages/ui/src';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
@@ -41,10 +38,7 @@ function updateTime(event: Event) {
|
||||
else if (/^\d+$/.test(newValue)) {
|
||||
if (newValue.length === 4) {
|
||||
// parse 1300 to 13:00
|
||||
const [hours, minutes] = [
|
||||
newValue.slice(0, 2),
|
||||
newValue.slice(2, 4),
|
||||
];
|
||||
const [hours, minutes] = [newValue.slice(0, 2), newValue.slice(2, 4)];
|
||||
model.value = getLocalizedDayJs(model.value)
|
||||
.set('hours', Math.min(parseInt(hours), 23))
|
||||
.set('minutes', Math.min(parseInt(minutes), 59))
|
||||
@@ -52,10 +46,7 @@ function updateTime(event: Event) {
|
||||
emit('changed', model.value);
|
||||
} else if (newValue.length === 3) {
|
||||
// parse 130 to 01:30
|
||||
const [hours, minutes] = [
|
||||
newValue.slice(0, 1),
|
||||
newValue.slice(1, 3),
|
||||
];
|
||||
const [hours, minutes] = [newValue.slice(0, 1), newValue.slice(1, 3)];
|
||||
model.value = getLocalizedDayJs(model.value)
|
||||
.set('hours', Math.min(parseInt(hours), 23))
|
||||
.set('minutes', Math.min(parseInt(minutes), 59))
|
||||
@@ -113,9 +104,7 @@ const getStartOptions = computed<TimeOption[]>(() => {
|
||||
}
|
||||
return options;
|
||||
});
|
||||
const inputValue = ref(
|
||||
model.value ? getLocalizedDayJs(model.value).format('HH:mm') : null
|
||||
);
|
||||
const inputValue = ref(model.value ? getLocalizedDayJs(model.value).format('HH:mm') : null);
|
||||
const open = ref(false);
|
||||
const closestValue = computed({
|
||||
get() {
|
||||
@@ -123,9 +112,7 @@ const closestValue = computed({
|
||||
let closestDiff: number | null = null;
|
||||
let closest = target;
|
||||
for (const option of getStartOptions.value) {
|
||||
const diff = Math.abs(
|
||||
getDayJsInstance()(option.timestamp).diff(target)
|
||||
);
|
||||
const diff = Math.abs(getDayJsInstance()(option.timestamp).diff(target));
|
||||
if (closestDiff === null || diff < closestDiff) {
|
||||
closestDiff = diff;
|
||||
closest = getDayJsInstance()(option.timestamp);
|
||||
@@ -153,12 +140,7 @@ const closestValue = computed({
|
||||
<TextInput
|
||||
ref="timeInput"
|
||||
v-model="inputValue"
|
||||
:class="
|
||||
twMerge(
|
||||
'text-center w-24 px-3 py-2',
|
||||
size === 'large' && 'w-28'
|
||||
)
|
||||
"
|
||||
:class="twMerge('text-center w-24 px-3 py-2', size === 'large' && 'w-28')"
|
||||
data-testid="time_picker_input"
|
||||
type="text"
|
||||
@blur="updateTime"
|
||||
|
||||
@@ -38,10 +38,7 @@ function updateTime(event: Event) {
|
||||
else if (/^\d+$/.test(newValue)) {
|
||||
if (newValue.length === 4) {
|
||||
// parse 1300 to 13:00
|
||||
const [hours, minutes] = [
|
||||
newValue.slice(0, 2),
|
||||
newValue.slice(2, 4),
|
||||
];
|
||||
const [hours, minutes] = [newValue.slice(0, 2), newValue.slice(2, 4)];
|
||||
model.value = getLocalizedDayJs(model.value)
|
||||
.set('hours', Math.min(parseInt(hours), 23))
|
||||
.set('minutes', Math.min(parseInt(minutes), 59))
|
||||
@@ -49,10 +46,7 @@ function updateTime(event: Event) {
|
||||
emit('changed', model.value);
|
||||
} else if (newValue.length === 3) {
|
||||
// parse 130 to 01:30
|
||||
const [hours, minutes] = [
|
||||
newValue.slice(0, 1),
|
||||
newValue.slice(1, 3),
|
||||
];
|
||||
const [hours, minutes] = [newValue.slice(0, 1), newValue.slice(1, 3)];
|
||||
model.value = getLocalizedDayJs(model.value)
|
||||
.set('hours', Math.min(parseInt(hours), 23))
|
||||
.set('minutes', Math.min(parseInt(minutes), 59))
|
||||
@@ -87,9 +81,7 @@ const emit = defineEmits(['changed']);
|
||||
|
||||
useFocus(timeInput, { initialValue: props.focus });
|
||||
|
||||
const inputValue = ref(
|
||||
model.value ? getLocalizedDayJs(model.value).format('HH:mm') : null
|
||||
);
|
||||
const inputValue = ref(model.value ? getLocalizedDayJs(model.value).format('HH:mm') : null);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -97,9 +89,7 @@ const inputValue = ref(
|
||||
v-bind="$attrs"
|
||||
ref="timeInput"
|
||||
v-model="inputValue"
|
||||
:class="
|
||||
twMerge('text-center w-24 px-3 py-2', size === 'large' && 'w-28')
|
||||
"
|
||||
:class="twMerge('text-center w-24 px-3 py-2', size === 'large' && 'w-28')"
|
||||
data-testid="time_picker_input"
|
||||
type="text"
|
||||
@blur="updateTime"
|
||||
|
||||
@@ -2,10 +2,7 @@
|
||||
import { defineProps, nextTick, ref, watch } from '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 { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
|
||||
import dayjs from 'dayjs';
|
||||
import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';
|
||||
|
||||
@@ -18,9 +15,7 @@ const props = defineProps<{
|
||||
// The timestamps for the changed event are UTC
|
||||
const emit = defineEmits(['changed', 'close']);
|
||||
|
||||
const tempStart = ref(
|
||||
props.start ? getLocalizedDayJs(props.start).format() : dayjs().format()
|
||||
);
|
||||
const tempStart = ref(props.start ? getLocalizedDayJs(props.start).format() : dayjs().format());
|
||||
const tempEnd = ref(props.end ? getLocalizedDayJs(props.end).format() : null);
|
||||
|
||||
watch(props, () => {
|
||||
@@ -29,9 +24,7 @@ watch(props, () => {
|
||||
});
|
||||
function updateTimeEntry() {
|
||||
const tempStartUtc = getDayJsInstance()(tempStart.value).utc().format();
|
||||
const tempEndUtc = tempEnd.value
|
||||
? getDayJsInstance()(tempEnd.value).utc().format()
|
||||
: null;
|
||||
const tempEndUtc = tempEnd.value ? getDayJsInstance()(tempEnd.value).utc().format() : null;
|
||||
if (tempStartUtc !== props.start || tempEndUtc !== props.end) {
|
||||
emit(
|
||||
'changed',
|
||||
@@ -55,9 +48,7 @@ watch(focused, (newValue, oldValue) => {
|
||||
<div
|
||||
ref="dropdownContent"
|
||||
class="grid grid-cols-2 divide-x divide-card-background-separator text-center py-2">
|
||||
<div
|
||||
class="px-2"
|
||||
@keydown.enter.prevent="nextTick(() => emit('close'))">
|
||||
<div class="px-2" @keydown.enter.prevent="nextTick(() => emit('close'))">
|
||||
<div class="font-semibold text-text-primary text-sm pb-2">Start</div>
|
||||
<div class="space-y-2">
|
||||
<TimePickerSimple
|
||||
|
||||
@@ -8,9 +8,7 @@ const props = defineProps<{
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:class="
|
||||
twMerge('animate-spin -ml-1 mr-3 h-5 w-5 text-text-primary', props.class)
|
||||
"
|
||||
:class="twMerge('animate-spin -ml-1 mr-3 h-5 w-5 text-text-primary', props.class)"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
} from '@/Components/ui/dialog'
|
||||
import { computed } from 'vue'
|
||||
import { Dialog, DialogContent, DialogFooter } from '@/Components/ui/dialog';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
@@ -43,7 +39,6 @@ const maxWidthClass = computed(() => {
|
||||
<template>
|
||||
<Dialog :open="show" @update:open="close">
|
||||
<DialogContent :class="maxWidthClass">
|
||||
|
||||
<div>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -31,12 +31,7 @@ const indicatorClasses = {
|
||||
<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>
|
||||
:class="twMerge(indicatorClasses[size], 'inline-block rounded-full shrink-0')"></div>
|
||||
<div class="min-w-0">
|
||||
<slot>
|
||||
{{ name }}
|
||||
|
||||
@@ -42,8 +42,8 @@ defineEmits<{
|
||||
>.
|
||||
</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?
|
||||
Do you want to update all existing time entries, where the project billable rate applies
|
||||
as well?
|
||||
</p>
|
||||
</BillableRateModal>
|
||||
</template>
|
||||
|
||||
@@ -49,10 +49,7 @@ function getNameForKey(key: BillableKey | undefined) {
|
||||
:get-name-for-item="getNameFromItem"
|
||||
:items="options">
|
||||
<template #trigger>
|
||||
<Badge
|
||||
tag="button"
|
||||
size="xlarge"
|
||||
class="bg-input-background cursor-pointer">
|
||||
<Badge tag="button" size="xlarge" class="bg-input-background cursor-pointer">
|
||||
<span>
|
||||
{{ getNameForKey(model) }}
|
||||
</span>
|
||||
|
||||
@@ -3,11 +3,7 @@ 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 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';
|
||||
@@ -63,9 +59,7 @@ 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 props.clients.find((client) => client.id === project.value.client_id)?.name;
|
||||
}
|
||||
return 'No Client';
|
||||
});
|
||||
@@ -80,8 +74,7 @@ const currentClientName = computed(() => {
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div
|
||||
class="sm:flex items-center space-y-2 sm:space-y-0 sm:space-x-4">
|
||||
<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" />
|
||||
@@ -117,8 +110,7 @@ const currentClientName = computed(() => {
|
||||
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>
|
||||
<UserCircleIcon class="w-5 text-icon-default"></UserCircleIcon>
|
||||
<span>
|
||||
{{ currentClientName }}
|
||||
</span>
|
||||
@@ -132,9 +124,7 @@ const currentClientName = computed(() => {
|
||||
<div>
|
||||
<ProjectEditBillableSection
|
||||
v-model:is-billable="project.is_billable"
|
||||
v-model:billable-rate="
|
||||
project.billable_rate
|
||||
"
|
||||
v-model:billable-rate="project.billable_rate"
|
||||
:currency="currency"></ProjectEditBillableSection>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -10,9 +10,7 @@ defineProps<{
|
||||
<div
|
||||
class="flex justify-between items-center w-full text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
|
||||
<div class="flex space-x-3 items-center px-3 py-1.5">
|
||||
<div
|
||||
:style="{ backgroundColor: color }"
|
||||
class="w-3 h-3 rounded-full"></div>
|
||||
<div :style="{ backgroundColor: color }" class="w-3 h-3 rounded-full"></div>
|
||||
<span>{{ name }}</span>
|
||||
</div>
|
||||
<slot name="actions"></slot>
|
||||
|
||||
@@ -40,8 +40,7 @@ watch(billableRateSelect, () => {
|
||||
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.',
|
||||
'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':
|
||||
@@ -58,8 +57,7 @@ const emit = defineEmits(['submit']);
|
||||
<div class="sm:flex items-center space-y-2 sm:space-y-0 sm:space-x-4 pt-6">
|
||||
<div>
|
||||
<div class="flex items-center space-x-1 mb-2">
|
||||
<BillableIcon
|
||||
class="text-text-quaternary h-4 ml-1 mr-0.5"></BillableIcon>
|
||||
<BillableIcon class="text-text-quaternary h-4 ml-1 mr-0.5"></BillableIcon>
|
||||
<InputLabel for="billable" value="Billable Default" />
|
||||
</div>
|
||||
<ProjectBillableSelect
|
||||
|
||||
@@ -5,7 +5,7 @@ import { type Component, computed, nextTick, ref, watch } from 'vue';
|
||||
import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
|
||||
import MultiselectDropdownItem from '@/packages/ui/src/Input/MultiselectDropdownItem.vue';
|
||||
import type { Tag } from '@/packages/api/src';
|
||||
import {UseFocusTrap} from "@vueuse/integrations/useFocusTrap/component";
|
||||
import { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -68,9 +68,7 @@ watch(open, (isOpen) => {
|
||||
|
||||
const filteredTags = computed(() => {
|
||||
return sortedTags.value.filter((tag: Tag) => {
|
||||
return tag.name
|
||||
.toLowerCase()
|
||||
.includes(searchValue.value?.toLowerCase()?.trim() || '');
|
||||
return tag.name.toLowerCase().includes(searchValue.value?.toLowerCase()?.trim() || '');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,9 +99,7 @@ function updateSearchValue(event: Event) {
|
||||
searchValue.value = '';
|
||||
const highlightedTagId = highlightedItemId.value;
|
||||
if (highlightedTagId) {
|
||||
const highlightedTag = props.tags.find(
|
||||
(tag) => tag.id === highlightedTagId
|
||||
);
|
||||
const highlightedTag = props.tags.find((tag) => tag.id === highlightedTagId);
|
||||
if (highlightedTag) {
|
||||
addOrRemoveTagFromSelection(highlightedTag.id);
|
||||
}
|
||||
@@ -129,29 +125,22 @@ function toggleTag(newValue: string) {
|
||||
|
||||
function moveHighlightUp() {
|
||||
if (highlightedItem.value) {
|
||||
const currentHightlightedIndex = filteredTags.value.indexOf(
|
||||
highlightedItem.value
|
||||
);
|
||||
const currentHightlightedIndex = filteredTags.value.indexOf(highlightedItem.value);
|
||||
if (currentHightlightedIndex === 0) {
|
||||
highlightedItemId.value =
|
||||
filteredTags.value[filteredTags.value.length - 1].id;
|
||||
highlightedItemId.value = filteredTags.value[filteredTags.value.length - 1].id;
|
||||
} else {
|
||||
highlightedItemId.value =
|
||||
filteredTags.value[currentHightlightedIndex - 1].id;
|
||||
highlightedItemId.value = filteredTags.value[currentHightlightedIndex - 1].id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function moveHighlightDown() {
|
||||
if (highlightedItem.value) {
|
||||
const currentHightlightedIndex = filteredTags.value.indexOf(
|
||||
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;
|
||||
highlightedItemId.value = filteredTags.value[currentHightlightedIndex + 1].id;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,9 +166,7 @@ const showCreateTagModal = ref(false);
|
||||
<slot name="trigger"></slot>
|
||||
</template>
|
||||
<template #content>
|
||||
<UseFocusTrap
|
||||
v-if="open"
|
||||
:options="{ immediate: true, allowOutsideClick: true }">
|
||||
<UseFocusTrap v-if="open" :options="{ immediate: true, allowOutsideClick: true }">
|
||||
<input
|
||||
ref="searchInput"
|
||||
:value="searchValue"
|
||||
@@ -198,9 +185,8 @@ const showCreateTagModal = ref(false);
|
||||
role="option"
|
||||
:value="tag.id"
|
||||
:class="{
|
||||
'bg-card-background-active':
|
||||
tag.id === highlightedItemId,
|
||||
}"
|
||||
'bg-card-background-active': tag.id === highlightedItemId,
|
||||
}"
|
||||
data-testid="tag_dropdown_entries"
|
||||
:data-tag-id="tag.id">
|
||||
<MultiselectDropdownItem
|
||||
@@ -213,9 +199,9 @@ const showCreateTagModal = ref(false);
|
||||
<button
|
||||
class="text-text-primary w-full flex space-x-3 items-center px-4 py-3 text-xs font-semibold border-t border-card-background-separator"
|
||||
@click="
|
||||
open = false;
|
||||
showCreateTagModal = true;
|
||||
">
|
||||
open = false;
|
||||
showCreateTagModal = true;
|
||||
">
|
||||
<PlusCircleIcon
|
||||
class="w-5 flex-shrink-0 text-icon-default"></PlusCircleIcon>
|
||||
<span>Create new Tag</span>
|
||||
|
||||
@@ -17,10 +17,7 @@ import TimeEntryMoreOptionsDropdown from '@/packages/ui/src/TimeEntry/TimeEntryM
|
||||
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
|
||||
import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';
|
||||
import { ref, inject, type ComputedRef } from 'vue';
|
||||
import {
|
||||
formatHumanReadableDuration,
|
||||
formatStartEnd,
|
||||
} from '@/packages/ui/src/utils/time';
|
||||
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';
|
||||
@@ -95,31 +92,23 @@ function onSelectChange(checked: boolean) {
|
||||
class="border-b border-default-background-separator bg-row-background min-w-0 transition"
|
||||
data-testid="time_entry_row">
|
||||
<MainContainer class="min-w-0">
|
||||
<div
|
||||
class="sm:flex py-1.5 items-center min-w-0 justify-between group">
|
||||
<div class="sm:flex py-1.5 items-center min-w-0 justify-between group">
|
||||
<div class="flex space-x-3 items-center min-w-0">
|
||||
<Checkbox
|
||||
:checked="
|
||||
timeEntry.timeEntries.every(
|
||||
(aggregateTimeEntry: TimeEntry) =>
|
||||
selectedTimeEntries.includes(
|
||||
aggregateTimeEntry
|
||||
)
|
||||
timeEntry.timeEntries.every((aggregateTimeEntry: TimeEntry) =>
|
||||
selectedTimeEntries.includes(aggregateTimeEntry)
|
||||
)
|
||||
"
|
||||
@update:checked="onSelectChange" />
|
||||
<div class="flex items-center min-w-0">
|
||||
<GroupedItemsCountButton
|
||||
:expanded="expanded"
|
||||
@click="expanded = !expanded">
|
||||
<GroupedItemsCountButton :expanded="expanded" @click="expanded = !expanded">
|
||||
{{ timeEntry?.timeEntries?.length }}
|
||||
</GroupedItemsCountButton>
|
||||
<TimeEntryDescriptionInput
|
||||
class="min-w-0 mr-4"
|
||||
:model-value="timeEntry.description"
|
||||
@changed="
|
||||
updateTimeEntryDescription
|
||||
"></TimeEntryDescriptionInput>
|
||||
@changed="updateTimeEntryDescription"></TimeEntryDescriptionInput>
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
:clients
|
||||
:create-project
|
||||
@@ -132,9 +121,7 @@ function onSelectChange(checked: boolean) {
|
||||
:enable-estimated-time
|
||||
:currency="currency"
|
||||
:task="timeEntry.task_id"
|
||||
@changed="
|
||||
updateProjectAndTask
|
||||
"></TimeTrackerProjectTaskDropdown>
|
||||
@changed="updateProjectAndTask"></TimeTrackerProjectTaskDropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center font-medium lg:space-x-2">
|
||||
@@ -142,21 +129,30 @@ function onSelectChange(checked: boolean) {
|
||||
:create-tag
|
||||
:tags="tags"
|
||||
:model-value="timeEntry.tags"
|
||||
@changed="
|
||||
updateTimeEntryTags
|
||||
"></TimeEntryRowTagDropdown>
|
||||
@changed="updateTimeEntryTags"></TimeEntryRowTagDropdown>
|
||||
<BillableToggleButton
|
||||
:model-value="timeEntry.billable"
|
||||
class="opacity-50 focus-visible:opacity-100 group-hover:opacity-100"
|
||||
size="small"
|
||||
@changed="
|
||||
updateTimeEntryBillable
|
||||
"></BillableToggleButton>
|
||||
@changed="updateTimeEntryBillable"></BillableToggleButton>
|
||||
<div class="flex-1">
|
||||
<button
|
||||
:class="twMerge('text-text-secondary px-1 py-1.5 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary', organization?.time_format === '12-hours' ? 'w-[170px]' : 'w-[120px]')"
|
||||
:class="
|
||||
twMerge(
|
||||
'text-text-secondary px-1 py-1.5 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary',
|
||||
organization?.time_format === '12-hours'
|
||||
? 'w-[170px]'
|
||||
: 'w-[120px]'
|
||||
)
|
||||
"
|
||||
@click="expanded = !expanded">
|
||||
{{ formatStartEnd(timeEntry.start, timeEntry.end, organization?.time_format) }}
|
||||
{{
|
||||
formatStartEnd(
|
||||
timeEntry.start,
|
||||
timeEntry.end,
|
||||
organization?.time_format
|
||||
)
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@@ -174,9 +170,7 @@ function onSelectChange(checked: boolean) {
|
||||
<TimeTrackerStartStop
|
||||
:active="!!(timeEntry.start && !timeEntry.end)"
|
||||
class="opacity-20 flex group-hover:opacity-100 focus-visible:opacity-100"
|
||||
@changed="
|
||||
onStartStopClick(timeEntry)
|
||||
"></TimeTrackerStartStop>
|
||||
@changed="onStartStopClick(timeEntry)"></TimeTrackerStartStop>
|
||||
<TimeEntryMoreOptionsDropdown
|
||||
@delete="
|
||||
deleteTimeEntries(timeEntry?.timeEntries ?? [])
|
||||
@@ -196,8 +190,7 @@ function onSelectChange(checked: boolean) {
|
||||
:tasks="tasks"
|
||||
:selected="
|
||||
!!selectedTimeEntries.find(
|
||||
(filterEntry: TimeEntry) =>
|
||||
filterEntry.id === subEntry.id
|
||||
(filterEntry: TimeEntry) => filterEntry.id === subEntry.id
|
||||
)
|
||||
"
|
||||
:create-client
|
||||
@@ -205,9 +198,7 @@ function onSelectChange(checked: boolean) {
|
||||
:create-project
|
||||
:tags="tags"
|
||||
indent
|
||||
:update-time-entry="
|
||||
(timeEntry: TimeEntry) => updateTimeEntry(timeEntry)
|
||||
"
|
||||
:update-time-entry="(timeEntry: TimeEntry) => updateTimeEntry(timeEntry)"
|
||||
:on-start-stop-click="() => onStartStopClick(subEntry)"
|
||||
:delete-time-entry="() => deleteTimeEntries([subEntry])"
|
||||
:currency="currency"
|
||||
|
||||
@@ -7,10 +7,7 @@ import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
|
||||
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
|
||||
import { TagIcon } from '@heroicons/vue/20/solid';
|
||||
import {
|
||||
getDayJsInstance,
|
||||
getLocalizedDayJs,
|
||||
} from '@/packages/ui/src/utils/time';
|
||||
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
|
||||
import type {
|
||||
CreateClientBody,
|
||||
CreateProjectBody,
|
||||
@@ -29,16 +26,14 @@ import DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';
|
||||
|
||||
import { InformationCircleIcon } from '@heroicons/vue/20/solid';
|
||||
import type { Tag, Task } from '@/packages/api/src';
|
||||
import TimePickerSimple from "@/packages/ui/src/Input/TimePickerSimple.vue";
|
||||
import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';
|
||||
|
||||
const show = defineModel('show', { default: false });
|
||||
const saving = ref(false);
|
||||
|
||||
const props = defineProps<{
|
||||
enableEstimatedTime: boolean;
|
||||
createTimeEntry: (
|
||||
entry: Omit<CreateTimeEntryBody, 'member_id'>
|
||||
) => Promise<void>;
|
||||
createTimeEntry: (entry: Omit<CreateTimeEntryBody, 'member_id'>) => Promise<void>;
|
||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
||||
createTag: (name: string) => Promise<Tag | undefined>;
|
||||
@@ -83,9 +78,7 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
const localStart = ref(
|
||||
getLocalizedDayJs(timeEntryDefaultValues.start).format()
|
||||
);
|
||||
const localStart = ref(getLocalizedDayJs(timeEntryDefaultValues.start).format());
|
||||
|
||||
const localEnd = ref(getLocalizedDayJs(timeEntryDefaultValues.end).format());
|
||||
|
||||
@@ -143,15 +136,12 @@ type BillableOption = {
|
||||
@keydown.enter="submit" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="sm:flex justify-between items-end space-y-2 sm:space-y-0 pt-4 sm:space-x-4">
|
||||
<div class="sm:flex justify-between items-end space-y-2 sm:space-y-0 pt-4 sm:space-x-4">
|
||||
<div class="flex w-full items-center space-x-2 justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
v-model:project="timeEntry.project_id"
|
||||
v-model:task="
|
||||
timeEntry.task_id
|
||||
"
|
||||
v-model:task="timeEntry.task_id"
|
||||
:clients
|
||||
:create-project
|
||||
:create-client
|
||||
@@ -161,19 +151,15 @@ type BillableOption = {
|
||||
class="bg-input-background"
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:enable-estimated-time="enableEstimatedTime"></TimeTrackerProjectTaskDropdown>
|
||||
:enable-estimated-time="
|
||||
enableEstimatedTime
|
||||
"></TimeTrackerProjectTaskDropdown>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex-col">
|
||||
<TagDropdown
|
||||
v-model="timeEntry.tags"
|
||||
:create-tag
|
||||
:tags="tags">
|
||||
<TagDropdown v-model="timeEntry.tags" :create-tag :tags="tags">
|
||||
<template #trigger>
|
||||
<Badge
|
||||
class="bg-input-background"
|
||||
tag="button"
|
||||
size="xlarge">
|
||||
<Badge class="bg-input-background" tag="button" size="xlarge">
|
||||
<TagIcon
|
||||
v-if="timeEntry.tags.length === 0"
|
||||
class="w-4"></TagIcon>
|
||||
@@ -190,12 +176,8 @@ type BillableOption = {
|
||||
<div class="flex-col">
|
||||
<SelectDropdown
|
||||
v-model="billableProxy"
|
||||
:get-key-from-item="
|
||||
(item: BillableOption) => item.value
|
||||
"
|
||||
:get-name-for-item="
|
||||
(item: BillableOption) => item.label
|
||||
"
|
||||
:get-key-from-item="(item: BillableOption) => item.value"
|
||||
:get-name-for-item="(item: BillableOption) => item.label"
|
||||
:items="[
|
||||
{
|
||||
label: 'Billable',
|
||||
@@ -207,16 +189,10 @@ type BillableOption = {
|
||||
},
|
||||
]">
|
||||
<template #trigger>
|
||||
<Badge
|
||||
class="bg-input-background"
|
||||
tag="button"
|
||||
size="xlarge">
|
||||
<BillableIcon
|
||||
class="h-4"></BillableIcon>
|
||||
<Badge class="bg-input-background" tag="button" size="xlarge">
|
||||
<BillableIcon class="h-4"></BillableIcon>
|
||||
<span>{{
|
||||
timeEntry.billable
|
||||
? 'Billable'
|
||||
: 'Non-Billable'
|
||||
timeEntry.billable ? 'Billable' : 'Non-Billable'
|
||||
}}</span>
|
||||
</Badge>
|
||||
</template>
|
||||
@@ -246,10 +222,7 @@ type BillableOption = {
|
||||
<div class="">
|
||||
<InputLabel>Start</InputLabel>
|
||||
<div class="flex flex-col items-center space-y-2 mt-1">
|
||||
<TimePickerSimple
|
||||
|
||||
v-model="localStart"
|
||||
size="large"></TimePickerSimple>
|
||||
<TimePickerSimple v-model="localStart" size="large"></TimePickerSimple>
|
||||
<DatePicker
|
||||
v-model="localStart"
|
||||
tabindex="1"
|
||||
@@ -259,9 +232,7 @@ type BillableOption = {
|
||||
<div class="">
|
||||
<InputLabel>End</InputLabel>
|
||||
<div class="flex flex-col items-center space-y-2 mt-1">
|
||||
<TimePickerSimple
|
||||
v-model="localEnd"
|
||||
size="large"></TimePickerSimple>
|
||||
<TimePickerSimple v-model="localEnd" size="large"></TimePickerSimple>
|
||||
<DatePicker
|
||||
v-model="localEnd"
|
||||
tabindex="1"
|
||||
|
||||
@@ -31,8 +31,7 @@ const displaysPlaceholder = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative min-w-0 flex-1 text-ellipsis whitespace-nowrap overflow-hidden">
|
||||
<div class="relative min-w-0 flex-1 text-ellipsis whitespace-nowrap overflow-hidden">
|
||||
<div class="relative text-sm font-medium min-w-0">
|
||||
<div
|
||||
:class="[
|
||||
|
||||
@@ -10,10 +10,7 @@ import type {
|
||||
TimeEntry,
|
||||
Client,
|
||||
} from '@/packages/api/src';
|
||||
import {
|
||||
getDayJsInstance,
|
||||
getLocalizedDateFromTimestamp,
|
||||
} from '@/packages/ui/src/utils/time';
|
||||
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';
|
||||
@@ -48,17 +45,13 @@ const groupedTimeEntries = computed(() => {
|
||||
if (entry.end === null) {
|
||||
continue;
|
||||
}
|
||||
const oldEntries =
|
||||
groupedEntriesByDay[getLocalizedDateFromTimestamp(entry.start)];
|
||||
const oldEntries = groupedEntriesByDay[getLocalizedDateFromTimestamp(entry.start)];
|
||||
groupedEntriesByDay[getLocalizedDateFromTimestamp(entry.start)] = [
|
||||
...(oldEntries ?? []),
|
||||
entry,
|
||||
];
|
||||
}
|
||||
const groupedEntriesByDayAndType: Record<
|
||||
string,
|
||||
TimeEntriesGroupedByType[]
|
||||
> = {};
|
||||
const groupedEntriesByDayAndType: Record<string, TimeEntriesGroupedByType[]> = {};
|
||||
for (const dailyEntriesKey in groupedEntriesByDay) {
|
||||
const dailyEntries = groupedEntriesByDay[dailyEntriesKey];
|
||||
const newDailyEntries: TimeEntriesGroupedByType[] = [];
|
||||
@@ -77,15 +70,12 @@ const groupedTimeEntries = computed(() => {
|
||||
|
||||
// Add up durations for time entries of the same type
|
||||
newDailyEntries[oldEntriesIndex].duration =
|
||||
(newDailyEntries[oldEntriesIndex].duration ?? 0) +
|
||||
(entry?.duration ?? 0);
|
||||
(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
|
||||
)
|
||||
getDayJsInstance()(newDailyEntries[oldEntriesIndex].start)
|
||||
)
|
||||
) {
|
||||
newDailyEntries[oldEntriesIndex].start = entry.start;
|
||||
@@ -133,17 +123,15 @@ function selectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
|
||||
}
|
||||
}
|
||||
function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
|
||||
selectedTimeEntries.value = selectedTimeEntries.value.filter(
|
||||
(timeEntry) => {
|
||||
return !value.find(
|
||||
(filterTimeEntry) =>
|
||||
filterTimeEntry.id === timeEntry.id ||
|
||||
filterTimeEntry.timeEntries?.find(
|
||||
(subTimeEntry) => subTimeEntry.id === timeEntry.id
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
selectedTimeEntries.value = selectedTimeEntries.value.filter((timeEntry) => {
|
||||
return !value.find(
|
||||
(filterTimeEntry) =>
|
||||
filterTimeEntry.id === timeEntry.id ||
|
||||
filterTimeEntry.timeEntries?.find(
|
||||
(subTimeEntry) => subTimeEntry.id === timeEntry.id
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -153,9 +141,7 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
|
||||
:date="key"
|
||||
:duration="sumDuration(value)"
|
||||
:checked="
|
||||
value.every((timeEntry: TimeEntry) =>
|
||||
selectedTimeEntries.includes(timeEntry)
|
||||
)
|
||||
value.every((timeEntry: TimeEntry) => selectedTimeEntries.includes(timeEntry))
|
||||
"
|
||||
@select-all="selectAllTimeEntries(value)"
|
||||
@unselect-all="unselectAllTimeEntries(value)"></TimeEntryRowHeading>
|
||||
@@ -180,10 +166,7 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
|
||||
:time-entry="entry"
|
||||
@selected="
|
||||
(timeEntries: TimeEntry[]) => {
|
||||
selectedTimeEntries = [
|
||||
...selectedTimeEntries,
|
||||
...timeEntries,
|
||||
];
|
||||
selectedTimeEntries = [...selectedTimeEntries, ...timeEntries];
|
||||
}
|
||||
"
|
||||
@unselected="
|
||||
@@ -191,8 +174,7 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
|
||||
selectedTimeEntries = selectedTimeEntries.filter(
|
||||
(item: TimeEntry) =>
|
||||
!timeEntriesToUnselect.find(
|
||||
(filterEntry: TimeEntry) =>
|
||||
filterEntry.id === item.id
|
||||
(filterEntry: TimeEntry) => filterEntry.id === item.id
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,9 +28,7 @@ const props = defineProps<{
|
||||
createTag: (name: string) => Promise<Tag | undefined>;
|
||||
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||
updateTimeEntries: (
|
||||
changeset: UpdateMultipleTimeEntriesChangeset
|
||||
) => Promise<void>;
|
||||
updateTimeEntries: (changeset: UpdateMultipleTimeEntriesChangeset) => Promise<void>;
|
||||
currency: string;
|
||||
enableEstimatedTime: boolean;
|
||||
canCreateProject: boolean;
|
||||
@@ -71,9 +69,7 @@ const showMassUpdateModal = ref(false);
|
||||
<Checkbox
|
||||
id="selectAll"
|
||||
:checked="allSelected"
|
||||
@update:checked="
|
||||
allSelected ? emit('unselectAll') : emit('selectAll')
|
||||
">
|
||||
@update:checked="allSelected ? emit('unselectAll') : emit('selectAll')">
|
||||
</Checkbox>
|
||||
<InputLabel
|
||||
v-if="selectedTimeEntries.length > 0"
|
||||
@@ -81,10 +77,7 @@ const showMassUpdateModal = ref(false);
|
||||
class="select-none text-text-secondary">
|
||||
{{ selectedTimeEntries.length }} selected
|
||||
</InputLabel>
|
||||
<InputLabel
|
||||
v-else
|
||||
for="selectAll"
|
||||
class="text-text-secondary select-none"
|
||||
<InputLabel v-else for="selectAll" class="text-text-secondary select-none"
|
||||
>Select All</InputLabel
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -31,9 +31,7 @@ const props = defineProps<{
|
||||
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||
createTag: (name: string) => Promise<Tag | undefined>;
|
||||
updateTimeEntries: (
|
||||
changeset: UpdateMultipleTimeEntriesChangeset
|
||||
) => Promise<void>;
|
||||
updateTimeEntries: (changeset: UpdateMultipleTimeEntriesChangeset) => Promise<void>;
|
||||
currency: string;
|
||||
enableEstimatedTime: boolean;
|
||||
canCreateProject: boolean;
|
||||
@@ -172,15 +170,9 @@ type SelectOption = { label: string; value: string };
|
||||
<div class="space-y-2">
|
||||
<InputLabel for="project" value="Tag" />
|
||||
<div class="flex space-x-5">
|
||||
<TagDropdown
|
||||
v-model="selectedTags"
|
||||
:create-tag
|
||||
:tags="tags">
|
||||
<TagDropdown v-model="selectedTags" :create-tag :tags="tags">
|
||||
<template #trigger>
|
||||
<Badge
|
||||
:disabled="removeAllTags"
|
||||
tag="button"
|
||||
size="xlarge">
|
||||
<Badge :disabled="removeAllTags" tag="button" size="xlarge">
|
||||
<span v-if="selectedTags.length > 0">
|
||||
Set {{ selectedTags.length }} tags
|
||||
</span>
|
||||
@@ -189,9 +181,7 @@ type SelectOption = { label: string; value: string };
|
||||
</template>
|
||||
</TagDropdown>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="no_tags"
|
||||
v-model:checked="removeAllTags"></Checkbox>
|
||||
<Checkbox id="no_tags" v-model:checked="removeAllTags"></Checkbox>
|
||||
<InputLabel for="no_tags" value="Remove all tags" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -201,12 +191,8 @@ type SelectOption = { label: string; value: string };
|
||||
<div class="flex">
|
||||
<SelectDropdown
|
||||
v-model="timeEntryBillable"
|
||||
:get-key-from-item="
|
||||
(item: SelectOption) => item.value
|
||||
"
|
||||
:get-name-for-item="
|
||||
(item: SelectOption) => item.label
|
||||
"
|
||||
:get-key-from-item="(item: SelectOption) => item.value"
|
||||
:get-name-for-item="(item: SelectOption) => item.label"
|
||||
:items="[
|
||||
{
|
||||
label: 'Keep current billable status',
|
||||
@@ -223,12 +209,8 @@ type SelectOption = { label: string; value: string };
|
||||
]">
|
||||
<template #trigger>
|
||||
<Badge tag="button" size="xlarge">
|
||||
<span v-if="billable === undefined">
|
||||
Set billable status
|
||||
</span>
|
||||
<span v-else-if="billable === true">
|
||||
Billable
|
||||
</span>
|
||||
<span v-if="billable === undefined"> Set billable status </span>
|
||||
<span v-else-if="billable === true"> Billable </span>
|
||||
<span v-else> Non Billable </span></Badge
|
||||
>
|
||||
</template>
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||
import { defineProps, ref, inject, type ComputedRef } from 'vue';
|
||||
import {
|
||||
formatDateLocalized,
|
||||
formatStartEnd,
|
||||
} from '@/packages/ui/src/utils/time';
|
||||
import { formatDateLocalized, formatStartEnd } from '@/packages/ui/src/utils/time';
|
||||
import TimeRangeSelector from '@/packages/ui/src/Input/TimeRangeSelector.vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import type { Organization } from '@/packages/api/src';
|
||||
@@ -23,7 +20,6 @@ const open = ref(false);
|
||||
const triggerElement = ref<HTMLButtonElement | null>(null);
|
||||
|
||||
const organization = inject<ComputedRef<Organization>>('organization');
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -59,8 +55,7 @@ const organization = inject<ComputedRef<Organization>>('organization');
|
||||
:start="start"
|
||||
:end="end"
|
||||
@changed="
|
||||
(newStart: string, newEnd: string) =>
|
||||
emit('changed', newStart, newEnd)
|
||||
(newStart: string, newEnd: string) => emit('changed', newStart, newEnd)
|
||||
"
|
||||
@close="open = false">
|
||||
</TimeRangeSelector>
|
||||
|
||||
@@ -72,9 +72,7 @@ function updateProjectAndTask(projectId: string, taskId: string) {
|
||||
|
||||
const memberName = computed(() => {
|
||||
if (props.members) {
|
||||
const member = props.members.find(
|
||||
(member) => member.user_id === props.timeEntry.user_id
|
||||
);
|
||||
const member = props.members.find((member) => member.user_id === props.timeEntry.user_id);
|
||||
if (member) {
|
||||
return member.name;
|
||||
}
|
||||
@@ -82,7 +80,7 @@ const memberName = computed(() => {
|
||||
return '';
|
||||
});
|
||||
|
||||
function onSelectChange(checked : boolean) {
|
||||
function onSelectChange(checked: boolean) {
|
||||
if (checked) {
|
||||
emit('selected');
|
||||
} else {
|
||||
@@ -96,18 +94,13 @@ function onSelectChange(checked : boolean) {
|
||||
class="border-b border-default-background-separator transition min-w-0 bg-row-background"
|
||||
data-testid="time_entry_row">
|
||||
<MainContainer class="min-w-0">
|
||||
<div
|
||||
class="sm:flex py-2 min-w-0 items-center justify-between group">
|
||||
<div class="sm:flex py-2 min-w-0 items-center justify-between group">
|
||||
<div class="flex items-center min-w-0">
|
||||
<Checkbox
|
||||
:checked="selected"
|
||||
@update:checked="onSelectChange" />
|
||||
<Checkbox :checked="selected" @update:checked="onSelectChange" />
|
||||
<div v-if="indent === true" class="w-10 h-7"></div>
|
||||
<TimeEntryDescriptionInput
|
||||
class="min-w-0 mr-4"
|
||||
:model-value="
|
||||
timeEntry.description
|
||||
"
|
||||
:model-value="timeEntry.description"
|
||||
@changed="updateTimeEntryDescription"></TimeEntryDescriptionInput>
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
:create-project
|
||||
@@ -120,13 +113,10 @@ function onSelectChange(checked : boolean) {
|
||||
:project="timeEntry.project_id"
|
||||
:currency="currency"
|
||||
:enable-estimated-time
|
||||
:task="
|
||||
timeEntry.task_id
|
||||
"
|
||||
:task="timeEntry.task_id"
|
||||
@changed="updateProjectAndTask"></TimeTrackerProjectTaskDropdown>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center font-medium space-x-1 lg:space-x-2">
|
||||
<div class="flex items-center font-medium space-x-1 lg:space-x-2">
|
||||
<div v-if="showMember && members" class="text-sm px-2">
|
||||
{{ memberName }}
|
||||
</div>
|
||||
@@ -137,34 +127,28 @@ function onSelectChange(checked : boolean) {
|
||||
@changed="updateTimeEntryTags"></TimeEntryRowTagDropdown>
|
||||
<BillableToggleButton
|
||||
:model-value="timeEntry.billable"
|
||||
:class="twMerge('opacity-50 group-hover:opacity-100 focus-visible:opacity-100')"
|
||||
:class="
|
||||
twMerge('opacity-50 group-hover:opacity-100 focus-visible:opacity-100')
|
||||
"
|
||||
size="small"
|
||||
@changed="
|
||||
updateTimeEntryBillable
|
||||
"></BillableToggleButton>
|
||||
@changed="updateTimeEntryBillable"></BillableToggleButton>
|
||||
<div class="flex-1">
|
||||
<TimeEntryRangeSelector
|
||||
:start="timeEntry.start"
|
||||
:end="timeEntry.end"
|
||||
:show-date
|
||||
@changed="
|
||||
updateStartEndTime
|
||||
"></TimeEntryRangeSelector>
|
||||
@changed="updateStartEndTime"></TimeEntryRangeSelector>
|
||||
</div>
|
||||
<TimeEntryRowDurationInput
|
||||
:start="timeEntry.start"
|
||||
:end="timeEntry.end"
|
||||
@changed="
|
||||
updateStartEndTime
|
||||
"></TimeEntryRowDurationInput>
|
||||
@changed="updateStartEndTime"></TimeEntryRowDurationInput>
|
||||
<TimeTrackerStartStop
|
||||
:active="!!(timeEntry.start && !timeEntry.end)"
|
||||
class="opacity-20 flex focus-visible:opacity-100 group-hover:opacity-100"
|
||||
@changed="onStartStopClick"></TimeTrackerStartStop>
|
||||
<TimeEntryMoreOptionsDropdown
|
||||
@delete="
|
||||
deleteTimeEntry
|
||||
"></TimeEntryMoreOptionsDropdown>
|
||||
@delete="deleteTimeEntry"></TimeEntryMoreOptionsDropdown>
|
||||
</div>
|
||||
</div>
|
||||
</MainContainer>
|
||||
|
||||
@@ -28,13 +28,8 @@ const open = ref(false);
|
||||
|
||||
function updateTimerAndStartLiveTimerUpdate() {
|
||||
const defaultUnit =
|
||||
organizationSettings?.value?.intervalFormat === 'decimal'
|
||||
? 'hours'
|
||||
: 'minutes';
|
||||
const { seconds } = parseTimeInput(
|
||||
temporaryCustomTimerEntry.value,
|
||||
defaultUnit
|
||||
);
|
||||
organizationSettings?.value?.intervalFormat === 'decimal' ? 'hours' : 'minutes';
|
||||
const { seconds } = parseTimeInput(temporaryCustomTimerEntry.value, defaultUnit);
|
||||
if (seconds && seconds > 0) {
|
||||
let newEndDate = props.end;
|
||||
let newStartDate = props.start;
|
||||
|
||||
@@ -36,9 +36,7 @@ const timeEntryTags = computed<Tag[]>(() => {
|
||||
:border="false"
|
||||
size="large"
|
||||
class="border-0 sm:px-1.5 text-icon-default group-focus-within/dropdown:text-text-primary"
|
||||
:name="
|
||||
timeEntryTags.map((tag: Tag) => tag.name).join(', ')
|
||||
"></TagBadge>
|
||||
:name="timeEntryTags.map((tag: Tag) => tag.name).join(', ')"></TagBadge>
|
||||
</button>
|
||||
</template>
|
||||
</TagDropdown>
|
||||
|
||||
@@ -13,19 +13,19 @@ import type {
|
||||
TimeEntry,
|
||||
Client,
|
||||
} from '@/packages/api/src';
|
||||
import {computed, nextTick, ref, watch} from 'vue';
|
||||
import type {Dayjs} from 'dayjs';
|
||||
import {useTimeEntriesStore} from '@/utils/useTimeEntries';
|
||||
import {storeToRefs} from 'pinia';
|
||||
import {useFocus} from "@vueuse/core";
|
||||
import {autoUpdate, flip, limitShift, offset, shift, useFloating} from "@floating-ui/vue";
|
||||
import TimeTrackerRecentlyTrackedEntry from "@/packages/ui/src/TimeTracker/TimeTrackerRecentlyTrackedEntry.vue";
|
||||
import {useSelectEvents} from "@/packages/ui/src/utils/select";
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useFocus } from '@vueuse/core';
|
||||
import { autoUpdate, flip, limitShift, offset, shift, useFloating } from '@floating-ui/vue';
|
||||
import TimeTrackerRecentlyTrackedEntry from '@/packages/ui/src/TimeTracker/TimeTrackerRecentlyTrackedEntry.vue';
|
||||
import { useSelectEvents } from '@/packages/ui/src/utils/select';
|
||||
|
||||
const currentTimeEntry = defineModel<TimeEntry>('currentTimeEntry', {
|
||||
required: true,
|
||||
});
|
||||
const liveTimer = defineModel<Dayjs | null>('liveTimer', {required: true});
|
||||
const liveTimer = defineModel<Dayjs | null>('liveTimer', { required: true });
|
||||
|
||||
const currentTimeEntryDescriptionInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
@@ -60,13 +60,12 @@ function setAndStartTimer(timeEntry: TimeEntry) {
|
||||
setCurrentTimeEntry(timeEntry);
|
||||
if (!props.isActive) {
|
||||
emit('startTimer');
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
emit('updateTimeEntry');
|
||||
}
|
||||
}
|
||||
|
||||
function setCurrentTimeEntry(timeEntry: TimeEntry){
|
||||
function setCurrentTimeEntry(timeEntry: TimeEntry) {
|
||||
currentTimeEntry.value.description = timeEntry.description;
|
||||
currentTimeEntry.value.project_id = timeEntry.project_id;
|
||||
currentTimeEntry.value.task_id = timeEntry.task_id;
|
||||
@@ -75,21 +74,21 @@ function setCurrentTimeEntry(timeEntry: TimeEntry){
|
||||
}
|
||||
|
||||
function startTimerIfNotActive() {
|
||||
if(highlightedDropdownEntryId.value){
|
||||
const timeEntry = filteredRecentlyTrackedTimeEntries.value.find((item) => item.id === highlightedDropdownEntryId.value);
|
||||
if(timeEntry){
|
||||
if (highlightedDropdownEntryId.value) {
|
||||
const timeEntry = filteredRecentlyTrackedTimeEntries.value.find(
|
||||
(item) => item.id === highlightedDropdownEntryId.value
|
||||
);
|
||||
if (timeEntry) {
|
||||
setCurrentTimeEntry(timeEntry);
|
||||
showDropdown.value = false;
|
||||
}
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
currentTimeEntry.value.description = tempDescription.value;
|
||||
}
|
||||
|
||||
if (!props.isActive) {
|
||||
emit('startTimer');
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
emit('updateTimeEntry');
|
||||
}
|
||||
}
|
||||
@@ -108,7 +107,7 @@ const blockRefocus = ref(false);
|
||||
function onToggleButtonPress(newState: boolean) {
|
||||
if (newState) {
|
||||
emit('startTimer');
|
||||
if (!blockRefocus.value){
|
||||
if (!blockRefocus.value) {
|
||||
currentTimeEntryDescriptionInput.value?.focus();
|
||||
}
|
||||
} else {
|
||||
@@ -131,41 +130,47 @@ function updateTimeEntryDescription() {
|
||||
}
|
||||
}
|
||||
|
||||
const {timeEntries} = storeToRefs(useTimeEntriesStore());
|
||||
const { timeEntries } = storeToRefs(useTimeEntriesStore());
|
||||
const filteredRecentlyTrackedTimeEntries = computed(() => {
|
||||
// do not include running time entries
|
||||
const finishedTimeEntries = timeEntries.value.filter((item) => item.end !== null);
|
||||
|
||||
// filter out duplicates based on description, task, project, tags and billable
|
||||
const nonDuplicateTimeEntries = finishedTimeEntries.filter((item, index, self) => {
|
||||
return index === self.findIndex((t) => (
|
||||
t.description === item.description &&
|
||||
t.task_id === item.task_id &&
|
||||
t.project_id === item.project_id &&
|
||||
t.tags.length === item.tags.length &&
|
||||
t.tags.every((tag) => item.tags.includes(tag)) &&
|
||||
t.billable === item.billable
|
||||
));
|
||||
return (
|
||||
index ===
|
||||
self.findIndex(
|
||||
(t) =>
|
||||
t.description === item.description &&
|
||||
t.task_id === item.task_id &&
|
||||
t.project_id === item.project_id &&
|
||||
t.tags.length === item.tags.length &&
|
||||
t.tags.every((tag) => item.tags.includes(tag)) &&
|
||||
t.billable === item.billable
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
// filter time entries based on current description
|
||||
return nonDuplicateTimeEntries.filter((item) => {
|
||||
return item.description
|
||||
?.toLowerCase()
|
||||
?.includes(tempDescription.value?.toLowerCase()?.trim() || '');
|
||||
}).slice(0, 5);
|
||||
return nonDuplicateTimeEntries
|
||||
.filter((item) => {
|
||||
return item.description
|
||||
?.toLowerCase()
|
||||
?.includes(tempDescription.value?.toLowerCase()?.trim() || '');
|
||||
})
|
||||
.slice(0, 5);
|
||||
});
|
||||
|
||||
const showDropdown = ref(false);
|
||||
const {focused} = useFocus(currentTimeEntryDescriptionInput)
|
||||
const { focused } = useFocus(currentTimeEntryDescriptionInput);
|
||||
|
||||
watch(focused, (focused) => {
|
||||
nextTick(() => {
|
||||
// make sure the click event on the dropdown does not get interrupted
|
||||
showDropdown.value = focused
|
||||
showDropdown.value = focused;
|
||||
|
||||
// make sure that the input does not get refocused after the dropdown is closed
|
||||
if(!focused){
|
||||
if (!focused) {
|
||||
blockRefocus.value = true;
|
||||
setTimeout(() => {
|
||||
blockRefocus.value = false;
|
||||
@@ -174,9 +179,8 @@ watch(focused, (focused) => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
const floating = ref(null);
|
||||
const {floatingStyles} = useFloating(currentTimeEntryDescriptionInput, floating, {
|
||||
const { floatingStyles } = useFloating(currentTimeEntryDescriptionInput, floating, {
|
||||
placement: 'bottom-start',
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [
|
||||
@@ -193,16 +197,16 @@ const {floatingStyles} = useFloating(currentTimeEntryDescriptionInput, floating,
|
||||
});
|
||||
const highlightedDropdownEntryId = ref<string | null>(null);
|
||||
|
||||
useSelectEvents(filteredRecentlyTrackedTimeEntries,
|
||||
useSelectEvents(
|
||||
filteredRecentlyTrackedTimeEntries,
|
||||
highlightedDropdownEntryId,
|
||||
(item) => item.id,
|
||||
showDropdown)
|
||||
showDropdown
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center relative @container"
|
||||
data-testid="dashboard_timer">
|
||||
<div class="flex items-center relative @container" data-testid="dashboard_timer">
|
||||
<div
|
||||
class="flex flex-col @2xl: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 relative">
|
||||
@@ -211,18 +215,18 @@ useSelectEvents(filteredRecentlyTrackedTimeEntries,
|
||||
v-model="tempDescription"
|
||||
placeholder="What are you working on?"
|
||||
data-testid="time_entry_description"
|
||||
class="w-full rounded-l-lg py-4 sm:py-2.5 px-3.5 border-b border-b-card-background-separator @2xl:px-4 text-base @4xl:text-lg text-text-primary bg-transparent border-none placeholder-text-secondary font-medium focus:ring-0 transition"
|
||||
class="w-full rounded-l-lg py-4 sm:py-2.5 px-3.5 border-b border-b-card-background-separator @2xl:px-4 text-base @4xl:text-lg text-text-primary bg-transparent border-none placeholder-text-secondary font-medium focus:ring-0 transition"
|
||||
type="text"
|
||||
@keydown.enter="startTimerIfNotActive"
|
||||
@keydown.esc="showDropdown = false"
|
||||
@blur="updateTimeEntryDescription"/>
|
||||
@blur="updateTimeEntryDescription" />
|
||||
<div
|
||||
v-if="showDropdown && filteredRecentlyTrackedTimeEntries.length > 0"
|
||||
ref="floating"
|
||||
class="z-50 w-full max-w-2xl"
|
||||
:style="floatingStyles">
|
||||
<div
|
||||
class="rounded-lg w-full fixed min-w-xl top-0 left-0 border border-card-border overflow-none shadow-dropdown bg-card-background">
|
||||
class="rounded-lg w-full fixed min-w-xl top-0 left-0 border border-card-border overflow-none shadow-dropdown bg-card-background">
|
||||
<div
|
||||
class="text-text-tertiary text-xs font-semibold border-b border-border-tertiary px-2 py-1.5">
|
||||
Recently Tracked Time Entries
|
||||
@@ -236,20 +240,18 @@ useSelectEvents(filteredRecentlyTrackedTimeEntries,
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
@mousedown="setAndStartTimer(timeEntry)"
|
||||
@mouseenter="highlightedDropdownEntryId = timeEntry.id"
|
||||
></TimeTrackerRecentlyTrackedEntry>
|
||||
@mouseenter="
|
||||
highlightedDropdownEntryId = timeEntry.id
|
||||
"></TimeTrackerRecentlyTrackedEntry>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between pl-2 shrink min-w-0">
|
||||
<div
|
||||
class="flex items-center w-[130px] @2xl:w-auto shrink min-w-0">
|
||||
<div class="flex items-center w-[130px] @2xl:w-auto shrink min-w-0">
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
v-model:project="currentTimeEntry.project_id"
|
||||
v-model:task="
|
||||
currentTimeEntry.task_id
|
||||
"
|
||||
v-model:task="currentTimeEntry.task_id"
|
||||
:create-client
|
||||
:can-create-project
|
||||
:clients
|
||||
@@ -262,16 +264,12 @@ useSelectEvents(filteredRecentlyTrackedTimeEntries,
|
||||
</div>
|
||||
<div class="flex items-center @2xl:space-x-2 px-2 @2xl:px-4">
|
||||
<TimeTrackerTagDropdown
|
||||
v-model="
|
||||
currentTimeEntry.tags
|
||||
"
|
||||
v-model="currentTimeEntry.tags"
|
||||
:create-tag
|
||||
:tags="tags"
|
||||
@changed="$emit('updateTimeEntry')"></TimeTrackerTagDropdown>
|
||||
<BillableToggleButton
|
||||
v-model="
|
||||
currentTimeEntry.billable
|
||||
"
|
||||
v-model="currentTimeEntry.billable"
|
||||
@changed="$emit('updateTimeEntry')"></BillableToggleButton>
|
||||
</div>
|
||||
<div class="border-l border-card-border">
|
||||
@@ -282,14 +280,11 @@ useSelectEvents(filteredRecentlyTrackedTimeEntries,
|
||||
@stop-live-timer="emit('stopLiveTimer')"
|
||||
@update-timer="emit('updateTimeEntry')"
|
||||
@start-timer="emit('startTimer')"
|
||||
@keydown.enter="
|
||||
startTimerIfNotActive
|
||||
"></TimeTrackerRangeSelector>
|
||||
@keydown.enter="startTimerIfNotActive"></TimeTrackerRangeSelector>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="pl-4 @2xl:pl-6 pr-3 absolute sm:relative top-[6px] sm:top-0 right-0">
|
||||
<div class="pl-4 @2xl:pl-6 pr-3 absolute sm:relative top-[6px] sm:top-0 right-0">
|
||||
<TimeTrackerStartStop
|
||||
:active="isActive"
|
||||
size="large"
|
||||
|
||||
@@ -12,12 +12,7 @@ import type {
|
||||
} from '@/packages/api/src';
|
||||
import ProjectBadge from '@/packages/ui/src/Project/ProjectBadge.vue';
|
||||
import Badge from '@/packages/ui/src/Badge.vue';
|
||||
import {
|
||||
PlusIcon,
|
||||
PlusCircleIcon,
|
||||
MinusIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/vue/16/solid';
|
||||
import { PlusIcon, PlusCircleIcon, MinusIcon, XMarkIcon } from '@heroicons/vue/16/solid';
|
||||
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
@@ -58,9 +53,7 @@ const props = withDefaults(
|
||||
projects: Project[];
|
||||
tasks: Task[];
|
||||
clients: Client[];
|
||||
createProject: (
|
||||
project: CreateProjectBody
|
||||
) => Promise<Project | undefined>;
|
||||
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||
currency: string;
|
||||
emptyPlaceholder?: string;
|
||||
@@ -95,9 +88,7 @@ function addProjectToFilterObject(
|
||||
(client) => client.id === project.client_id
|
||||
);
|
||||
|
||||
const client = props.clients.find(
|
||||
(client) => client.id === project.client_id
|
||||
);
|
||||
const client = props.clients.find((client) => client.id === project.client_id);
|
||||
|
||||
if (projectClientIndex !== -1) {
|
||||
// client already exists in filter array
|
||||
@@ -216,20 +207,10 @@ watchEffect(() => {
|
||||
(!filterProject.is_archived || project.value === filterProject.id)
|
||||
) {
|
||||
// search term matches project name
|
||||
addProjectToFilterObject(
|
||||
tempFilteredClients,
|
||||
filterProject,
|
||||
filteredTasks,
|
||||
false
|
||||
);
|
||||
addProjectToFilterObject(tempFilteredClients, filterProject, filteredTasks, false);
|
||||
} else if (filteredTasks.length > 0 && !filterProject.is_archived) {
|
||||
// search term matches task name
|
||||
addProjectToFilterObject(
|
||||
tempFilteredClients,
|
||||
filterProject,
|
||||
filteredTasks,
|
||||
true
|
||||
);
|
||||
addProjectToFilterObject(tempFilteredClients, filterProject, filteredTasks, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,11 +297,8 @@ function moveHighlightUp() {
|
||||
// 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
|
||||
)
|
||||
const currentProjectWithTasks = filteredProjects.value.find((projectWithTasks) =>
|
||||
projectWithTasks.tasks.some((task) => task.id === highlightedItemId.value)
|
||||
);
|
||||
if (currentProjectWithTasks) {
|
||||
const taskIndex = currentProjectWithTasks.tasks.findIndex(
|
||||
@@ -334,34 +312,27 @@ function moveHighlightUp() {
|
||||
highlightedItemId.value = currentProjectWithTasks.id;
|
||||
return;
|
||||
}
|
||||
highlightedItemId.value =
|
||||
currentProjectWithTasks.tasks[taskIndex - 1].id;
|
||||
highlightedItemId.value = currentProjectWithTasks.tasks[taskIndex - 1].id;
|
||||
}
|
||||
}
|
||||
if (currentHighlightedIndex === 0) {
|
||||
// selected project is the first project in the list
|
||||
// highlight the last project or the last task of the last project
|
||||
const lastProject =
|
||||
filteredProjects.value[filteredProjects.value.length - 1];
|
||||
const lastProject = filteredProjects.value[filteredProjects.value.length - 1];
|
||||
if (lastProject.tasks.length > 0 && lastProject.expanded) {
|
||||
// highlight last task of last project
|
||||
highlightedItemId.value =
|
||||
lastProject.tasks[lastProject.tasks.length - 1].id;
|
||||
highlightedItemId.value = lastProject.tasks[lastProject.tasks.length - 1].id;
|
||||
} else {
|
||||
highlightedItemId.value =
|
||||
filteredProjects.value[filteredProjects.value.length - 1].id;
|
||||
highlightedItemId.value = filteredProjects.value[filteredProjects.value.length - 1].id;
|
||||
}
|
||||
} else {
|
||||
// selected item is a project that is not the first project in the list
|
||||
const previousProject =
|
||||
filteredProjects.value[currentHighlightedIndex - 1];
|
||||
const previousProject = filteredProjects.value[currentHighlightedIndex - 1];
|
||||
if (previousProject.tasks.length > 0 && previousProject.expanded) {
|
||||
// highlight last task of previous project
|
||||
highlightedItemId.value =
|
||||
previousProject.tasks[previousProject.tasks.length - 1].id;
|
||||
highlightedItemId.value = previousProject.tasks[previousProject.tasks.length - 1].id;
|
||||
} else {
|
||||
highlightedItemId.value =
|
||||
filteredProjects.value[currentHighlightedIndex - 1].id;
|
||||
highlightedItemId.value = filteredProjects.value[currentHighlightedIndex - 1].id;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -375,11 +346,8 @@ function moveHighlightDown() {
|
||||
// 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
|
||||
)
|
||||
const currentProjectWithTasks = filteredProjects.value.find((projectWithTasks) =>
|
||||
projectWithTasks.tasks.some((task) => task.id === highlightedItemId.value)
|
||||
);
|
||||
if (currentProjectWithTasks) {
|
||||
const taskIndex = currentProjectWithTasks.tasks.findIndex(
|
||||
@@ -390,27 +358,22 @@ function moveHighlightDown() {
|
||||
}
|
||||
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
|
||||
);
|
||||
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].id;
|
||||
} else {
|
||||
highlightedItemId.value =
|
||||
filteredProjects.value[projectIndex + 1].id;
|
||||
highlightedItemId.value = filteredProjects.value[projectIndex + 1].id;
|
||||
}
|
||||
return;
|
||||
}
|
||||
highlightedItemId.value =
|
||||
currentProjectWithTasks.tasks[taskIndex + 1].id;
|
||||
highlightedItemId.value = currentProjectWithTasks.tasks[taskIndex + 1].id;
|
||||
}
|
||||
}
|
||||
if (currentHighlightedIndex === filteredProjects.value.length - 1) {
|
||||
// selected project is the last project in the list
|
||||
// highlight the first project or the last project of the last project
|
||||
const lastProject =
|
||||
filteredProjects.value[filteredProjects.value.length - 1];
|
||||
const lastProject = filteredProjects.value[filteredProjects.value.length - 1];
|
||||
if (lastProject.tasks.length > 0 && lastProject.expanded) {
|
||||
// highlight last task of last project
|
||||
highlightedItemId.value = lastProject.tasks[0].id;
|
||||
@@ -419,17 +382,12 @@ function moveHighlightDown() {
|
||||
}
|
||||
} else {
|
||||
// selected item is a project that is not the last project in the list
|
||||
const currentProjectWithTasks =
|
||||
filteredProjects.value[currentHighlightedIndex];
|
||||
if (
|
||||
currentProjectWithTasks.tasks.length > 0 &&
|
||||
currentProjectWithTasks.expanded
|
||||
) {
|
||||
const currentProjectWithTasks = filteredProjects.value[currentHighlightedIndex];
|
||||
if (currentProjectWithTasks.tasks.length > 0 && currentProjectWithTasks.expanded) {
|
||||
// highlight last task of previous project
|
||||
highlightedItemId.value = currentProjectWithTasks.tasks[0].id;
|
||||
} else {
|
||||
highlightedItemId.value =
|
||||
filteredProjects.value[currentHighlightedIndex + 1].id;
|
||||
highlightedItemId.value = filteredProjects.value[currentHighlightedIndex + 1].id;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -481,15 +439,11 @@ function collapseProject() {
|
||||
}
|
||||
|
||||
const currentProject = computed(() => {
|
||||
return props.projects.find(
|
||||
(iteratingProject) => iteratingProject.id === project.value
|
||||
);
|
||||
return props.projects.find((iteratingProject) => iteratingProject.id === project.value);
|
||||
});
|
||||
|
||||
const currentTask = computed(() => {
|
||||
return props.tasks.find(
|
||||
(iteratingTasks) => iteratingTasks.id === task.value
|
||||
);
|
||||
return props.tasks.find((iteratingTasks) => iteratingTasks.id === task.value);
|
||||
});
|
||||
|
||||
const selectedProjectName = computed(() => {
|
||||
@@ -518,8 +472,7 @@ function setHighlightItemId(newId: string) {
|
||||
|
||||
function selectTask(taskId: string) {
|
||||
task.value = taskId;
|
||||
project.value =
|
||||
props.tasks.find((task) => task.id === taskId)?.project_id || null;
|
||||
project.value = props.tasks.find((task) => task.id === taskId)?.project_id || null;
|
||||
open.value = false;
|
||||
searchValue.value = '';
|
||||
emit('changed', project.value, task.value);
|
||||
@@ -569,9 +522,7 @@ const showCreateProject = ref(false);
|
||||
<ChevronRightIcon
|
||||
v-if="currentTask"
|
||||
class="w-4 lg:w-5 text-text-secondary shrink-0"></ChevronRightIcon>
|
||||
<div
|
||||
v-if="currentTask"
|
||||
class="min-w-0 shrink text-xs lg:text-sm truncate">
|
||||
<div v-if="currentTask" class="min-w-0 shrink text-xs lg:text-sm truncate">
|
||||
{{ currentTask.name }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -587,9 +538,7 @@ const showCreateProject = ref(false);
|
||||
</ProjectBadge>
|
||||
</template>
|
||||
<template #content>
|
||||
<UseFocusTrap
|
||||
v-if="open"
|
||||
:options="{ immediate: true, allowOutsideClick: true }">
|
||||
<UseFocusTrap v-if="open" :options="{ immediate: true, allowOutsideClick: true }">
|
||||
<input
|
||||
ref="searchInput"
|
||||
:value="searchValue"
|
||||
@@ -607,9 +556,7 @@ const showCreateProject = ref(false);
|
||||
ref="dropdownViewport"
|
||||
class="min-w-[350px] max-h-[350px] overflow-y-scroll relative"
|
||||
@mousemove="mouseEnterHighlightActivated = true">
|
||||
<template
|
||||
v-for="client in filteredResults"
|
||||
:key="client.id">
|
||||
<template v-for="client in filteredResults" :key="client.id">
|
||||
<div
|
||||
v-if="client.id !== 'no_project_no_client'"
|
||||
class="w-full pb-1 pt-2 px-2 text-text-tertiary text-xs font-semibold flex space-x-1 items-center">
|
||||
@@ -630,27 +577,17 @@ const showCreateProject = ref(false);
|
||||
class="rounded-lg"
|
||||
:class="{
|
||||
'bg-card-background-active':
|
||||
projectWithTasks.id ===
|
||||
highlightedItemId,
|
||||
projectWithTasks.id === highlightedItemId,
|
||||
}">
|
||||
<ProjectDropdownItem
|
||||
class="hover:!bg-transparent"
|
||||
:selected="
|
||||
isProjectSelected(projectWithTasks)
|
||||
"
|
||||
:selected="isProjectSelected(projectWithTasks)"
|
||||
:name="projectWithTasks.name"
|
||||
:color="projectWithTasks.color"
|
||||
@mouseenter="
|
||||
setHighlightItemId(
|
||||
projectWithTasks.id
|
||||
)
|
||||
">
|
||||
@mouseenter="setHighlightItemId(projectWithTasks.id)">
|
||||
<template #actions>
|
||||
<button
|
||||
v-if="
|
||||
projectWithTasks.tasks
|
||||
.length > 0
|
||||
"
|
||||
v-if="projectWithTasks.tasks.length > 0"
|
||||
tabindex="-1"
|
||||
class="px-2 py-0.5 mr-2 relative transition items-center rounded flex space-x-0.5 text-xs"
|
||||
:class="{
|
||||
@@ -667,11 +604,7 @@ const showCreateProject = ref(false);
|
||||
}
|
||||
">
|
||||
<span
|
||||
>{{
|
||||
projectWithTasks.tasks
|
||||
.length
|
||||
}}
|
||||
Tasks</span
|
||||
>{{ projectWithTasks.tasks.length }} Tasks</span
|
||||
>
|
||||
<ChevronDownIcon
|
||||
:class="{
|
||||
@@ -684,31 +617,25 @@ const showCreateProject = ref(false);
|
||||
</ProjectDropdownItem>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="projectWithTasks.expanded"
|
||||
class="bg-quaternary">
|
||||
<div v-if="projectWithTasks.expanded" class="bg-quaternary">
|
||||
<div
|
||||
v-for="task in projectWithTasks.tasks"
|
||||
:key="task.id"
|
||||
:data-task-id="task.id"
|
||||
:class="{
|
||||
'bg-card-background-active':
|
||||
task.id === highlightedItemId,
|
||||
'bg-card-background-active': task.id === highlightedItemId,
|
||||
}"
|
||||
class="flex items-center space-x-2 w-full px-5 py-1.5 text-start text-xs font-semibold leading-5 text-text-primary focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
|
||||
@click="selectTask(task.id)"
|
||||
@mouseenter="setHighlightItemId(task.id)">
|
||||
<MinusIcon
|
||||
class="w-3 h-3 text-text-quaternary"></MinusIcon>
|
||||
<MinusIcon class="w-3 h-3 text-text-quaternary"></MinusIcon>
|
||||
<span>{{ task.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-if="canCreateProject"
|
||||
class="hover:bg-card-background-active rounded-b-lg">
|
||||
<div v-if="canCreateProject" class="hover:bg-card-background-active rounded-b-lg">
|
||||
<button
|
||||
class="text-text-primary flex space-x-3 items-center px-4 py-3 text-xs font-semibold border-t border-card-background-separator"
|
||||
@click="
|
||||
|
||||
@@ -3,11 +3,7 @@ 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 {
|
||||
formatDuration,
|
||||
getDayJsInstance,
|
||||
parseTimeInput,
|
||||
} from '@/packages/ui/src/utils/time';
|
||||
import { formatDuration, getDayJsInstance, parseTimeInput } from '@/packages/ui/src/utils/time';
|
||||
import type { TimeEntry } from '@/packages/api/src';
|
||||
|
||||
const currentTimeEntry = defineModel<TimeEntry>('currentTimeEntry', {
|
||||
@@ -59,10 +55,7 @@ const currentTime = computed({
|
||||
});
|
||||
|
||||
function updateTimerAndStartLiveTimerUpdate() {
|
||||
const { seconds } = parseTimeInput(
|
||||
temporaryCustomTimerEntry.value,
|
||||
'minutes'
|
||||
);
|
||||
const { seconds } = parseTimeInput(temporaryCustomTimerEntry.value, 'minutes');
|
||||
|
||||
if (seconds && seconds > 0) {
|
||||
const newStartDate = dayjs().subtract(seconds, 's');
|
||||
@@ -105,12 +98,7 @@ const timeRangeSelector = ref<HTMLElement | null>(null);
|
||||
function openModalOnTab(e: FocusEvent) {
|
||||
// check if the source is inside the dropdown
|
||||
const source = e.relatedTarget as HTMLElement;
|
||||
if (
|
||||
source &&
|
||||
window.document.body
|
||||
.querySelector<HTMLElement>('#app')
|
||||
?.contains(source)
|
||||
) {
|
||||
if (source && window.document.body.querySelector<HTMLElement>('#app')?.contains(source)) {
|
||||
open.value = true;
|
||||
}
|
||||
}
|
||||
@@ -118,10 +106,9 @@ function openModalOnTab(e: FocusEvent) {
|
||||
function focusNextElement(e: KeyboardEvent) {
|
||||
if (open.value) {
|
||||
e.preventDefault();
|
||||
const focusableElement =
|
||||
timeRangeSelector.value?.querySelector<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
const focusableElement = timeRangeSelector.value?.querySelector<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
focusableElement?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {ProjectBadge} from "@/packages/ui/src";
|
||||
import type {TimeEntry} from "@/packages/api/src";
|
||||
import {twMerge} from "tailwind-merge";
|
||||
import {ChevronRightIcon} from "@heroicons/vue/16/solid";
|
||||
import {computed} from "vue";
|
||||
import type {Project, Task} from "@/packages/api/src";
|
||||
import { ProjectBadge } from '@/packages/ui/src';
|
||||
import type { TimeEntry } from '@/packages/api/src';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { ChevronRightIcon } from '@heroicons/vue/16/solid';
|
||||
import { computed } from 'vue';
|
||||
import type { Project, Task } from '@/packages/api/src';
|
||||
|
||||
const props = defineProps<{
|
||||
timeEntry: TimeEntry,
|
||||
highlighted: boolean,
|
||||
projects?: Project[]
|
||||
tasks?: Task[]
|
||||
}>()
|
||||
timeEntry: TimeEntry;
|
||||
highlighted: boolean;
|
||||
projects?: Project[];
|
||||
tasks?: Task[];
|
||||
}>();
|
||||
const project = computed(() => {
|
||||
return props.projects?.find(
|
||||
(iteratingProject) => iteratingProject.id === props.timeEntry.project_id
|
||||
);
|
||||
});
|
||||
const task = computed(() => {
|
||||
return props.tasks?.find(
|
||||
(iteratingTask) => iteratingTask.id === props.timeEntry.task_id
|
||||
);
|
||||
return props.tasks?.find((iteratingTask) => iteratingTask.id === props.timeEntry.task_id);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -29,40 +26,35 @@ const task = computed(() => {
|
||||
<button
|
||||
tabindex="-1"
|
||||
:data-select-id="timeEntry.id"
|
||||
:class="twMerge('px-2 py-1.5 flex justify-between items-center space-x-2 w-full rounded', props.highlighted && 'bg-card-background-active')">
|
||||
:class="
|
||||
twMerge(
|
||||
'px-2 py-1.5 flex justify-between items-center space-x-2 w-full rounded',
|
||||
props.highlighted && 'bg-card-background-active'
|
||||
)
|
||||
">
|
||||
<span v-if="timeEntry.description !== ''" class="text-sm font-medium">
|
||||
{{
|
||||
timeEntry.description
|
||||
}}
|
||||
</span>
|
||||
<span v-else class="text-sm text-text-tertiary font-medium">
|
||||
No Description
|
||||
{{ timeEntry.description }}
|
||||
</span>
|
||||
<span v-else class="text-sm text-text-tertiary font-medium"> No Description </span>
|
||||
<ProjectBadge
|
||||
ref="projectDropdownTrigger"
|
||||
:color="project?.color"
|
||||
:name="project?.name"
|
||||
class="">
|
||||
<div v-if="project" class="flex items-center lg:space-x-1 min-w-0">
|
||||
<span class="whitespace-nowrap text-xs ">
|
||||
{{ project?.name }}
|
||||
</span>
|
||||
<span class="whitespace-nowrap text-xs">
|
||||
{{ project?.name }}
|
||||
</span>
|
||||
<ChevronRightIcon
|
||||
v-if="task"
|
||||
class="w-4 lg:w-5 text-text-secondary shrink-0"></ChevronRightIcon>
|
||||
<div
|
||||
v-if="task"
|
||||
class="min-w-0 shrink text-xs truncate">
|
||||
<div v-if="task" class="min-w-0 shrink text-xs truncate">
|
||||
{{ task.name }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
No Project
|
||||
</div>
|
||||
<div v-else>No Project</div>
|
||||
</ProjectBadge>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
<style scoped></style>
|
||||
|
||||
@@ -7,8 +7,7 @@ defineEmits<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute w-full h-full backdrop-blur-sm z-10 flex items-center justify-center">
|
||||
<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">
|
||||
|
||||
@@ -26,11 +26,7 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TagDropdown
|
||||
v-model="model"
|
||||
:create-tag
|
||||
:tags="tags"
|
||||
@changed="emit('changed')">
|
||||
<TagDropdown v-model="model" :create-tag :tags="tags" @changed="emit('changed')">
|
||||
<template #trigger>
|
||||
<button
|
||||
data-testid="tag_dropdown"
|
||||
|
||||
@@ -40,13 +40,7 @@ export function formatCents(
|
||||
currencySymbol?: string,
|
||||
numberFormat?: NumberFormat
|
||||
) {
|
||||
return formatMoney(
|
||||
amount / 100,
|
||||
currency,
|
||||
format,
|
||||
currencySymbol,
|
||||
numberFormat
|
||||
);
|
||||
return formatMoney(amount / 100, currency, format, currencySymbol, numberFormat);
|
||||
}
|
||||
|
||||
export function getOrganizationCurrencySymbol(currency: string) {
|
||||
|
||||
@@ -28,8 +28,7 @@ export default class Prando {
|
||||
} else {
|
||||
// Pseudo-random seed
|
||||
this._seed = this.getSafeSeed(
|
||||
Prando.MIN +
|
||||
Math.floor((Prando.MAX - Prando.MIN) * Math.random())
|
||||
Prando.MIN + Math.floor((Prando.MAX - Prando.MIN) * Math.random())
|
||||
);
|
||||
}
|
||||
this.reset();
|
||||
@@ -59,9 +58,7 @@ export default class Prando {
|
||||
*/
|
||||
public nextInt(min = 10, max = 100): number {
|
||||
this.recalculate();
|
||||
return Math.floor(
|
||||
this.map(this._value, Prando.MIN, Prando.MAX, min, max + 1)
|
||||
);
|
||||
return Math.floor(this.map(this._value, Prando.MIN, Prando.MAX, min, max + 1));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,9 +173,7 @@ export default class Prando {
|
||||
minTo: number,
|
||||
maxTo: number
|
||||
): number {
|
||||
return (
|
||||
((val - minFrom) / (maxFrom - minFrom)) * (maxTo - minTo) + minTo
|
||||
);
|
||||
return ((val - minFrom) / (maxFrom - minFrom)) * (maxTo - minTo) + minTo;
|
||||
}
|
||||
|
||||
private hashCode(str: string): number {
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import {computed, type Ref, watch} from "vue";
|
||||
import {onKeyStroke} from "@vueuse/core";
|
||||
|
||||
export function useSelectEvents<Type>(filteredItems: Ref<Array<Type>>,
|
||||
highlightedItemId: Ref<string | null>,
|
||||
getKeyFromItem: (item: Type) => string,
|
||||
open: Ref<boolean>) {
|
||||
import { computed, type Ref, watch } from 'vue';
|
||||
import { onKeyStroke } from '@vueuse/core';
|
||||
|
||||
export function useSelectEvents<Type>(
|
||||
filteredItems: Ref<Array<Type>>,
|
||||
highlightedItemId: Ref<string | null>,
|
||||
getKeyFromItem: (item: Type) => string,
|
||||
open: Ref<boolean>
|
||||
) {
|
||||
function moveHighlightUp() {
|
||||
if (highlightedItem.value) {
|
||||
const currentHightlightedIndex = filteredItems.value.indexOf(
|
||||
highlightedItem.value
|
||||
);
|
||||
const currentHightlightedIndex = filteredItems.value.indexOf(highlightedItem.value);
|
||||
if (currentHightlightedIndex === 0) {
|
||||
highlightedItemId.value = getKeyFromItem(
|
||||
filteredItems.value[filteredItems.value.length - 1]
|
||||
@@ -20,36 +19,30 @@ export function useSelectEvents<Type>(filteredItems: Ref<Array<Type>>,
|
||||
filteredItems.value[currentHightlightedIndex - 1]
|
||||
);
|
||||
}
|
||||
}
|
||||
else {
|
||||
highlightedItemId.value = getKeyFromItem(filteredItems.value[filteredItems.value.length - 1]);
|
||||
} else {
|
||||
highlightedItemId.value = getKeyFromItem(
|
||||
filteredItems.value[filteredItems.value.length - 1]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function moveHighlightDown() {
|
||||
if (highlightedItem.value) {
|
||||
const currentHightlightedIndex = filteredItems.value.indexOf(
|
||||
highlightedItem.value
|
||||
);
|
||||
const currentHightlightedIndex = filteredItems.value.indexOf(highlightedItem.value);
|
||||
if (currentHightlightedIndex === filteredItems.value.length - 1) {
|
||||
highlightedItemId.value = getKeyFromItem(
|
||||
filteredItems.value[0]
|
||||
);
|
||||
highlightedItemId.value = getKeyFromItem(filteredItems.value[0]);
|
||||
} else {
|
||||
highlightedItemId.value = getKeyFromItem(
|
||||
filteredItems.value[currentHightlightedIndex + 1]
|
||||
);
|
||||
}
|
||||
}
|
||||
else{
|
||||
} else {
|
||||
highlightedItemId.value = getKeyFromItem(filteredItems.value[0]);
|
||||
}
|
||||
}
|
||||
|
||||
const highlightedItem = computed(() => {
|
||||
return filteredItems.value.find(
|
||||
(item) => getKeyFromItem(item) === highlightedItemId.value
|
||||
);
|
||||
return filteredItems.value.find((item) => getKeyFromItem(item) === highlightedItemId.value);
|
||||
});
|
||||
|
||||
onKeyStroke('ArrowDown', (e) => {
|
||||
@@ -72,4 +65,3 @@ export function useSelectEvents<Type>(filteredItems: Ref<Array<Type>>,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import updateLocale from 'dayjs/plugin/updateLocale';
|
||||
import { computed } from 'vue';
|
||||
import { formatNumber } from './number';
|
||||
|
||||
|
||||
export type DateFormat =
|
||||
| 'point-separated-d-m-yyyy'
|
||||
| 'slash-separated-mm-dd-yyyy'
|
||||
@@ -28,7 +27,7 @@ const dateFormatMap: Record<DateFormat, string> = {
|
||||
'slash-separated-dd-mm-yyyy': 'DD/MM/YYYY',
|
||||
'hyphen-separated-dd-mm-yyyy': 'DD-MM-YYYY',
|
||||
'hyphen-separated-mm-dd-yyyy': 'MM-DD-YYYY',
|
||||
'hyphen-separated-yyyy-mm-dd': 'YYYY-MM-DD'
|
||||
'hyphen-separated-yyyy-mm-dd': 'YYYY-MM-DD',
|
||||
};
|
||||
|
||||
export type TimeFormat = '12-hours' | '24-hours';
|
||||
@@ -131,9 +130,7 @@ export function getLocalizedDateFromTimestamp(timestamp: string) {
|
||||
*/
|
||||
export function formatDate(date: string, format: DateFormat = 'point-separated-d-m-yyyy'): string {
|
||||
if (date?.includes('+')) {
|
||||
console.warn(
|
||||
'Date contains timezone information, use formatDateLocalized instead'
|
||||
);
|
||||
console.warn('Date contains timezone information, use formatDateLocalized instead');
|
||||
}
|
||||
return getDayJsInstance()(date).format(dateFormatMap[format]);
|
||||
}
|
||||
@@ -142,11 +139,18 @@ export function formatDate(date: string, format: DateFormat = 'point-separated-d
|
||||
* Returns a formatted date.
|
||||
* @param date - date in the format of 'YYYY-MM-DD'
|
||||
*/
|
||||
export function formatDateLocalized(date: string, format: DateFormat = 'point-separated-d-m-yyyy'): string {
|
||||
export function formatDateLocalized(
|
||||
date: string,
|
||||
format: DateFormat = 'point-separated-d-m-yyyy'
|
||||
): string {
|
||||
return getLocalizedDayJs(date).format(dateFormatMap[format]);
|
||||
}
|
||||
|
||||
export function formatDateTimeLocalized(date: string, dateFormat?: DateFormat, timeFormat?: TimeFormat): string {
|
||||
export function formatDateTimeLocalized(
|
||||
date: string,
|
||||
dateFormat?: DateFormat,
|
||||
timeFormat?: TimeFormat
|
||||
): string {
|
||||
const format = `${dateFormatMap[dateFormat ?? 'point-separated-d-m-yyyy']} ${timeFormat === '12-hours' ? 'hh:mm A' : 'HH:mm'}`;
|
||||
return getLocalizedDayJs(date).format(format);
|
||||
}
|
||||
@@ -162,16 +166,16 @@ export function formatWeek(date: string | null): string {
|
||||
export function formatHumanReadableDate(date: string) {
|
||||
const dateObj = dayjs(date);
|
||||
const today = dayjs();
|
||||
|
||||
|
||||
if (dateObj.isToday()) {
|
||||
return 'Today';
|
||||
} else if (dateObj.isYesterday()) {
|
||||
return 'Yesterday';
|
||||
}
|
||||
|
||||
|
||||
// Calculate difference in days
|
||||
const diffInDays = today.diff(dateObj, 'day');
|
||||
|
||||
|
||||
if (diffInDays > 0 && diffInDays <= 30) {
|
||||
// For dates in the past (2-30 days ago)
|
||||
return `${diffInDays} ${diffInDays === 1 ? 'day' : 'days'} ago`;
|
||||
@@ -180,7 +184,7 @@ export function formatHumanReadableDate(date: string) {
|
||||
const futureDays = Math.abs(diffInDays);
|
||||
return `In ${futureDays} ${futureDays === 1 ? 'day' : 'days'}`;
|
||||
}
|
||||
|
||||
|
||||
// For dates older than 30 days, show the actual date
|
||||
return dateObj.format('MMM D, YYYY');
|
||||
}
|
||||
@@ -189,7 +193,11 @@ export function formatWeekday(date: string) {
|
||||
return dayjs(date).format('dddd');
|
||||
}
|
||||
|
||||
export function formatStartEnd(start: string, end: string | null, timeFormat: TimeFormat = '24-hours') {
|
||||
export function formatStartEnd(
|
||||
start: string,
|
||||
end: string | null,
|
||||
timeFormat: TimeFormat = '24-hours'
|
||||
) {
|
||||
if (end) {
|
||||
return `${formatTime(start, timeFormat)} - ${formatTime(end, timeFormat)}`;
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user