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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
<script setup lang="ts">
import TextInput from '@/Components/TextInput.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import DialogModal from '@/Components/DialogModal.vue';
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { ref } from 'vue';
import type { CreateClientBody } from '@/utils/api';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import type { CreateClientBody } from '@/packages/api/src';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { useFocus } from '@vueuse/core';
import { useClientsStore } from '@/utils/useClients';

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
<script setup lang="ts">
import TextInput from '@/Components/TextInput.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import DialogModal from '@/Components/DialogModal.vue';
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { ref } from 'vue';
import type { Client, UpdateClientBody } from '@/utils/api';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import type { Client, UpdateClientBody } from '@/packages/api/src';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { useFocus } from '@vueuse/core';
import { useClientsStore } from '@/utils/useClients';

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import { PencilSquareIcon, TrashIcon } from '@heroicons/vue/20/solid';
import type { Client } from '@/utils/api';
import type { Client } from '@/packages/api/src';
import { canDeleteClients, canUpdateClients } from '@/utils/permissions';
import MoreOptionsDropdown from '@/Components/MoreOptionsDropdown.vue';
import MoreOptionsDropdown from '@/packages/ui/src/MoreOptionsDropdown.vue';
const emit = defineEmits<{
delete: [];

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import MultiselectDropdown from '@/Components/Common/MultiselectDropdown.vue';
import { storeToRefs } from 'pinia';
import type { Client } from '@/utils/api';
import type { Client } from '@/packages/api/src';
import { useClientsStore } from '@/utils/useClients';
const clientsStore = useClientsStore();

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import SecondaryButton from '@/Components/SecondaryButton.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { UserCircleIcon } from '@heroicons/vue/24/solid';
import { PlusIcon } from '@heroicons/vue/16/solid';
import { ref } from 'vue';
import { type Component, ref } from 'vue';
import { storeToRefs } from 'pinia';
import { useClientsStore } from '@/utils/useClients';
import ClientTableRow from '@/Components/Common/Client/ClientTableRow.vue';
@@ -36,7 +36,7 @@ const createClient = ref(false);
<SecondaryButton
v-if="canCreateClients()"
@click="createClient = true"
:icon="PlusIcon"
:icon="PlusIcon as Component"
>Create your First Client
</SecondaryButton>
</div>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { Client } from '@/utils/api';
import type { Client } from '@/packages/api/src';
import { computed, ref } from 'vue';
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
import { useClientsStore } from '@/utils/useClients';

View File

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

View File

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

View File

@@ -1,32 +0,0 @@
<script setup lang="ts">
import { formatDate, formatHumanReadableDate } from '@/utils/time';
defineProps<{
date: string;
}>();
</script>
<template>
<div class="flex items-center space-x-2">
<svg
class="w-4 sm:w-5 text-muted"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<g fill="none">
<path
d="m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427c-.002-.01-.009-.017-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093c.012.004.023 0 .029-.008l.004-.014l-.034-.614c-.003-.012-.01-.02-.02-.022m-.715.002a.023.023 0 0 0-.027.006l-.006.014l-.034.614c0 .012.007.02.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z" />
<path
fill="currentColor"
d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7zm-5-9a1 1 0 0 1 1 1v1h2a2 2 0 0 1 2 2v3H3V7a2 2 0 0 1 2-2h2V4a1 1 0 0 1 2 0v1h6V4a1 1 0 0 1 1-1" />
</g>
</svg>
<span class="font-semibold text-white">
{{ formatHumanReadableDate(date) }}
</span>
<span class="font-semibold text-muted">
{{ formatDate(date) }}
</span>
</div>
</template>
<style scoped></style>

View File

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

View File

@@ -1,14 +0,0 @@
<script setup lang="ts"></script>
<template>
<svg viewBox="0 0 8 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M4 1V13M1 10.182L1.879 10.841C3.05 11.72 4.949 11.72 6.121 10.841C7.293 9.962 7.293 8.538 6.121 7.659C5.536 7.219 4.768 7 4 7C3.275 7 2.55 6.78 1.997 6.341C0.891 5.462 0.891 4.038 1.997 3.159C3.103 2.28 4.897 2.28 6.003 3.159L6.418 3.489"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</template>
<style scoped></style>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { TrashIcon, ArrowPathIcon } from '@heroicons/vue/20/solid';
import MoreOptionsDropdown from '@/Components/MoreOptionsDropdown.vue';
import MoreOptionsDropdown from '@/packages/ui/src/MoreOptionsDropdown.vue';
const emit = defineEmits<{
delete: [];
resend: [];

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import type { Invitation } from '@/utils/api';
import type { Invitation } from '@/packages/api/src';
import TableRow from '@/Components/TableRow.vue';
import { capitalizeFirstLetter } from '../../../utils/format';
import InvitationMoreOptionsDropdown from '@/Components/Common/Invitation/InvitationMoreOptionsDropdown.vue';
import { api } from '@/utils/api';
import { api } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
import { useInvitationsStore } from '@/utils/useInvitations';

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { formatCents } from '../../../utils/money';
import BillableRateModal from '@/Components/Common/BillableRateModal.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
import BillableRateModal from '@/packages/ui/src/BillableRateModal.vue';
import { formatCents } from '@/packages/ui/src/utils/money';
const show = defineModel('show', { default: false });
const saving = defineModel('saving', { default: false });
@@ -25,7 +26,10 @@ defineEmits<{
The billable rate of {{ memberName }} will be updated to
<strong>{{
newBillableRate
? formatCents(newBillableRate)
? formatCents(
newBillableRate,
getOrganizationCurrencyString()
)
: ' the default rate of the organization'
}}</strong
>.

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import SelectDropdown from '@/Components/Common/SelectDropdown.vue';
import Badge from '@/Components/Common/Badge.vue';
import SelectDropdown from '@/packages/ui/src/Input/SelectDropdown.vue';
import Badge from '@/packages/ui/src/Badge.vue';
import { ChevronDownIcon } from '@heroicons/vue/20/solid';
import type { BillableKey } from '@/types/projects';

View File

@@ -1,13 +1,13 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import ClientDropdownItem from '@/Components/Common/Client/ClientDropdownItem.vue';
import ClientDropdownItem from '@/packages/ui/src/Client/ClientDropdownItem.vue';
import { useMembersStore } from '@/utils/useMembers';
import { UserIcon, XMarkIcon } from '@heroicons/vue/24/solid';
import TextInput from '@/Components/TextInput.vue';
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import { useFocus } from '@vueuse/core';
import type { ProjectMember } from '@/utils/api';
import Dropdown from '@/Components/Dropdown.vue';
import type { ProjectMember } from '@/packages/api/src';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
const membersStore = useMembersStore();
const { members } = storeToRefs(membersStore);

View File

@@ -1,17 +1,18 @@
<script setup lang="ts">
import SecondaryButton from '@/Components/SecondaryButton.vue';
import DialogModal from '@/Components/DialogModal.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 { Member, UpdateMemberBody } from '@/utils/api';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import type { Member, UpdateMemberBody } from '@/packages/api/src';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { type MemberBillableKey, useMembersStore } from '@/utils/useMembers';
import BillableRateInput from '@/Components/Common/BillableRateInput.vue';
import InputLabel from '@/Components/InputLabel.vue';
import BillableRateInput from '@/packages/ui/src/Input/BillableRateInput.vue';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import MemberBillableRateModal from '@/Components/Common/Member/MemberBillableRateModal.vue';
import MemberBillableSelect from '@/Components/Common/Member/MemberBillableSelect.vue';
import { onMounted, watch } from 'vue';
import MemberRoleSelect from '@/Components/Common/Member/MemberRoleSelect.vue';
import MemberOwnershipTransferConfirmModal from '@/Components/Common/Member/MemberOwnershipTransferConfirmModal.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
const { updateMember } = useMembersStore();
const show = defineModel('show', { default: false });
@@ -154,6 +155,7 @@ const roleDescription = computed(() => {
<BillableRateInput
focus
class="w-full"
:currency="getOrganizationCurrencyString()"
@keydown.enter="saveWithChecks()"
name="memberBillableRate"
v-model="

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
import TextInput from '@/Components/TextInput.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import DialogModal from '@/Components/DialogModal.vue';
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { ref } from 'vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { useFocus } from '@vueuse/core';
import InputLabel from '@/Components/InputLabel.vue';
import InputError from '@/Components/InputError.vue';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import InputError from '@/packages/ui/src/Input/InputError.vue';
import type { Role } from '@/types/jetstream';
import { Link, useForm } from '@inertiajs/vue3';
import { getCurrentOrganizationId } from '@/utils/useUser';
@@ -14,8 +14,8 @@ import { filterRoles } from '@/utils/roles';
import { hasActiveSubscription, isBillingActivated } from '@/utils/billing';
import { CreditCardIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
import { canUpdateOrganization } from '@/utils/permissions';
import { api } from '@/utils/api';
import type { MemberRole } from '@/utils/api';
import { api } from '@/packages/api/src';
import type { MemberRole } from '@/packages/api/src';
import { z } from 'zod';
import { useNotificationsStore } from '@/utils/notification';

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import { TrashIcon, PencilSquareIcon } from '@heroicons/vue/20/solid';
import type { Member } from '@/utils/api';
import type { Member } from '@/packages/api/src';
import { canDeleteMembers, canUpdateMembers } from '@/utils/permissions';
import MoreOptionsDropdown from '@/Components/MoreOptionsDropdown.vue';
import MoreOptionsDropdown from '@/packages/ui/src/MoreOptionsDropdown.vue';
const emit = defineEmits<{
delete: [];

View File

@@ -2,7 +2,7 @@
import MultiselectDropdown from '@/Components/Common/MultiselectDropdown.vue';
import { useMembersStore } from '@/utils/useMembers';
import { storeToRefs } from 'pinia';
import type { Member } from '@/utils/api';
import type { Member } from '@/packages/api/src';
const membersStore = useMembersStore();
const { members } = storeToRefs(membersStore);

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import SecondaryButton from '@/Components/SecondaryButton.vue';
import DialogModal from '@/Components/DialogModal.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
const show = defineModel('show', { default: false });
const saving = defineModel('saving', { default: false });

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import SelectDropdown from '@/Components/Common/SelectDropdown.vue';
import Badge from '@/Components/Common/Badge.vue';
import SelectDropdown from '@/packages/ui/src/Input/SelectDropdown.vue';
import Badge from '@/packages/ui/src/Badge.vue';
import { ChevronDownIcon } from '@heroicons/vue/20/solid';
import type { Role } from '@/types/jetstream';
import { usePage } from '@inertiajs/vue3';

View File

@@ -1,18 +1,19 @@
<script setup lang="ts">
import type { Member } from '@/utils/api';
import type { Member } from '@/packages/api/src';
import { CheckCircleIcon, UserCircleIcon } from '@heroicons/vue/20/solid';
import MemberMoreOptionsDropdown from '@/Components/Common/Member/MemberMoreOptionsDropdown.vue';
import TableRow from '@/Components/TableRow.vue';
import { capitalizeFirstLetter } from '../../../utils/format';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import { api } from '@/utils/api';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { api } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
import { canInvitePlaceholderMembers } from '@/utils/permissions';
import { useMembersStore } from '@/utils/useMembers';
import { ref } from 'vue';
import MemberEditModal from '@/Components/Common/Member/MemberEditModal.vue';
import { formatCents } from '../../../utils/money';
import { getOrganizationCurrencyString } from '@/utils/money';
import { formatCents } from '@/packages/ui/src/utils/money';
const props = defineProps<{
member: Member;
@@ -62,7 +63,12 @@ async function invitePlaceholder(id: string) {
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted">
{{
member.billable_rate ? formatCents(member.billable_rate) : '--'
member.billable_rate
? formatCents(
member.billable_rate,
getOrganizationCurrencyString()
)
: '--'
}}
</div>
<div

View File

@@ -1,5 +1,5 @@
<script setup lang="ts" generic="T">
import Dropdown from '@/Components/Dropdown.vue';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { type Component, computed, nextTick, ref, watch } from 'vue';
import MultiselectDropdownItem from '@/Components/Common/MultiselectDropdownItem.vue';

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { formatCents } from '../../../utils/money';
import BillableRateModal from '@/Components/Common/BillableRateModal.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
import BillableRateModal from '@/packages/ui/src/BillableRateModal.vue';
import { formatCents } from '@/packages/ui/src/utils/money';
const show = defineModel('show', { default: false });
const saving = defineModel('saving', { default: false });
@@ -23,7 +24,12 @@ defineEmits<{
<p class="py-0.5 text-center">
The organization billable rate will be updated to
<strong>{{
newBillableRate ? formatCents(newBillableRate) : ' none.'
newBillableRate
? formatCents(
newBillableRate,
getOrganizationCurrencyString()
)
: ' none.'
}}</strong
>.
</p>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import ProjectBadge from '@/Components/Common/Project/ProjectBadge.vue';
import ProjectBadge from '@/packages/ui/src/Project/ProjectBadge.vue';
import { computed, nextTick, ref, watch } from 'vue';
import { useProjectsStore } from '@/utils/useProjects';
import Dropdown from '@/Components/Dropdown.vue';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import {
ComboboxAnchor,
ComboboxContent,
@@ -12,12 +12,12 @@ import {
ComboboxViewport,
} from 'radix-vue';
import { PlusCircleIcon } from '@heroicons/vue/20/solid';
import ProjectDropdownItem from '@/Components/Common/Project/ProjectDropdownItem.vue';
import { storeToRefs } from 'pinia';
import { api } from '@/utils/api';
import { api } from '@/packages/api/src';
import { usePage } from '@inertiajs/vue3';
import { getRandomColor } from '@/utils/color';
import type { Project } from '@/utils/api';
import { getRandomColor } from '@/packages/ui/src/utils/color';
import type { Project } from '@/packages/api/src';
import ProjectDropdownItem from '@/packages/ui/src/Project/ProjectDropdownItem.vue';
const searchValue = ref('');
const searchInput = ref<HTMLElement | null>(null);

View File

@@ -1,19 +0,0 @@
<script setup lang="ts">
defineProps<{
name: string;
selected: boolean;
color: string;
}>();
</script>
<template>
<div
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<div
:style="{ backgroundColor: color }"
class="w-3 h-3 rounded-full"></div>
<span>{{ name }}</span>
</div>
</template>
<style scoped></style>

View File

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

View File

@@ -1,21 +1,26 @@
<script setup lang="ts">
import TextInput from '@/Components/TextInput.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import DialogModal from '@/Components/DialogModal.vue';
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 '@/utils/api';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import type {
CreateClientBody,
CreateProjectBody,
Project,
} from '@/packages/api/src';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { useProjectsStore } from '@/utils/useProjects';
import { useFocus } from '@vueuse/core';
import ClientDropdown from '@/Components/Common/Client/ClientDropdown.vue';
import Badge from '@/Components/Common/Badge.vue';
import ClientDropdown from '@/packages/ui/src/Client/ClientDropdown.vue';
import Badge from '@/packages/ui/src/Badge.vue';
import { useClientsStore } from '@/utils/useClients';
import { storeToRefs } from 'pinia';
import ProjectColorSelector from '@/Components/Common/Project/ProjectColorSelector.vue';
import ProjectEditBillableSection from '@/Components/Common/Project/ProjectEditBillableSection.vue';
import ProjectColorSelector from '@/packages/ui/src/Project/ProjectColorSelector.vue';
import { UserCircleIcon } from '@heroicons/vue/20/solid';
import InputLabel from '@/Components/InputLabel.vue';
import ProjectBillableRateModal from '@/Components/Common/Project/ProjectBillableRateModal.vue';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import ProjectBillableRateModal from '@/packages/ui/src/Project/ProjectBillableRateModal.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
import ProjectEditBillableSection from '@/packages/ui/src/Project/ProjectEditBillableSection.vue';
const { updateProject } = useProjectsStore();
const { clients } = storeToRefs(useClientsStore());
@@ -124,6 +129,7 @@ async function submitBillableRate() {
</div>
<ProjectEditBillableSection
@submit="submit"
:currency="getOrganizationCurrencyString()"
v-model:isBillable="project.is_billable"
v-model:billableRate="
project.billable_rate
@@ -143,6 +149,7 @@ async function submitBillableRate() {
</DialogModal>
<ProjectBillableRateModal
v-model:show="showBillableRateModal"
:currency="getOrganizationCurrencyString()"
@submit="submitBillableRate"
:new-billable-rate="project.billable_rate"
:project-name="project.name"></ProjectBillableRateModal>

View File

@@ -4,9 +4,9 @@ import {
PencilSquareIcon,
ArchiveBoxIcon,
} from '@heroicons/vue/20/solid';
import type { Project } from '@/utils/api';
import type { Project } from '@/packages/api/src';
import { canDeleteProjects, canUpdateProjects } from '@/utils/permissions';
import MoreOptionsDropdown from '@/Components/MoreOptionsDropdown.vue';
import MoreOptionsDropdown from '@/packages/ui/src/MoreOptionsDropdown.vue';
const emit = defineEmits<{
delete: [];
edit: [];

View File

@@ -2,7 +2,7 @@
import MultiselectDropdown from '@/Components/Common/MultiselectDropdown.vue';
import { storeToRefs } from 'pinia';
import { useProjectsStore } from '@/utils/useProjects';
import type { Project } from '@/utils/api';
import type { Project } from '@/packages/api/src';
const projectsStore = useProjectsStore();
const { projects } = storeToRefs(projectsStore);

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import SecondaryButton from '@/Components/SecondaryButton.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { FolderPlusIcon } from '@heroicons/vue/24/solid';
import { PlusIcon } from '@heroicons/vue/16/solid';
import { ref } from 'vue';
import ProjectCreateModal from '@/Components/Common/Project/ProjectCreateModal.vue';
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
import { canCreateProjects } from '@/utils/permissions';
@@ -12,10 +12,11 @@ import type {
Project,
Client,
CreateClientBody,
} from '@/utils/api';
} from '@/packages/api/src';
import { useProjectsStore } from '@/utils/useProjects';
import { useClientsStore } from '@/utils/useClients';
import { storeToRefs } from 'pinia';
import { getOrganizationCurrencyString } from '@/utils/money';
defineProps<{
projects: Project[];
@@ -40,6 +41,7 @@ const { clients } = storeToRefs(useClientsStore());
<ProjectCreateModal
:createProject
:createClient
:currency="getOrganizationCurrencyString()"
:clients="clients"
v-model:show="showCreateProjectModal"></ProjectCreateModal>
<div class="flow-root max-w-[100vw] overflow-x-auto">

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreOptionsDropdown.vue';
import type { Project } from '@/utils/api';
import type { Project } from '@/packages/api/src';
import { computed, ref } from 'vue';
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
import { useClientsStore } from '@/utils/useClients';
@@ -9,7 +9,8 @@ import { useTasksStore } from '@/utils/useTasks';
import { useProjectsStore } from '@/utils/useProjects';
import TableRow from '@/Components/TableRow.vue';
import ProjectEditModal from '@/Components/Common/Project/ProjectEditModal.vue';
import { formatCents } from '@/utils/money';
import { formatCents } from '@/packages/ui/src/utils/money';
import { getOrganizationCurrencyString } from '@/utils/money';
const { clients } = storeToRefs(useClientsStore());
const { tasks } = storeToRefs(useTasksStore());
@@ -43,7 +44,10 @@ function archiveProject() {
const billableRateInfo = computed(() => {
if (props.project.is_billable) {
if (props.project.billable_rate) {
return formatCents(props.project.billable_rate);
return formatCents(
props.project.billable_rate,
getOrganizationCurrencyString()
);
} else {
return 'Default Rate';
}

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { formatCents } from '../../../utils/money';
import BillableRateModal from '@/Components/Common/BillableRateModal.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
import BillableRateModal from '@/packages/ui/src/BillableRateModal.vue';
import { formatCents } from '@/packages/ui/src/utils/money';
const show = defineModel('show', { default: false });
const saving = defineModel('saving', { default: false });
@@ -25,7 +26,10 @@ defineEmits<{
The billable rate of {{ memberName }} will be updated to
<strong>{{
newBillableRate
? formatCents(newBillableRate)
? formatCents(
newBillableRate,
getOrganizationCurrencyString()
)
: ' the default rate of the project'
}}</strong
>.

View File

@@ -1,13 +1,17 @@
<script setup lang="ts">
import SecondaryButton from '@/Components/SecondaryButton.vue';
import DialogModal from '@/Components/DialogModal.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { ref } from 'vue';
import type { CreateProjectMemberBody, ProjectMember } from '@/utils/api';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import type {
CreateProjectMemberBody,
ProjectMember,
} from '@/packages/api/src';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { useFocus } from '@vueuse/core';
import { useProjectMembersStore } from '@/utils/useProjectMembers';
import MemberCombobox from '@/Components/Common/Member/MemberCombobox.vue';
import BillableRateInput from '@/Components/Common/BillableRateInput.vue';
import BillableRateInput from '@/packages/ui/src/Input/BillableRateInput.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
const { createProjectMember } = useProjectMembersStore();
const show = defineModel('show', { default: false });
const saving = ref(false);
@@ -54,6 +58,7 @@ useFocus(projectNameInput, { initialValue: true });
<div class="col-span-3 sm:col-span-1 flex-1">
<BillableRateInput
name="billable_rate"
:currency="getOrganizationCurrencyString()"
v-model="
projectMember.billable_rate
"></BillableRateInput>

View File

@@ -1,15 +1,19 @@
<script setup lang="ts">
import SecondaryButton from '@/Components/SecondaryButton.vue';
import DialogModal from '@/Components/DialogModal.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { ref, watch } from 'vue';
import type { ProjectMember, UpdateProjectMemberBody } from '@/utils/api';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import type {
ProjectMember,
UpdateProjectMemberBody,
} from '@/packages/api/src';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { useFocus } from '@vueuse/core';
import { useProjectMembersStore } from '@/utils/useProjectMembers';
import BillableRateInput from '@/Components/Common/BillableRateInput.vue';
import BillableRateInput from '@/packages/ui/src/Input/BillableRateInput.vue';
import { UserIcon } from '@heroicons/vue/24/solid';
import ProjectMemberBillableRateModal from '@/Components/Common/ProjectMember/ProjectMemberBillableRateModal.vue';
import InputLabel from '@/Components/InputLabel.vue';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
const { updateProjectMember } = useProjectMembersStore();
const show = defineModel('show', { default: false });
@@ -87,6 +91,7 @@ useFocus(projectNameInput, { initialValue: true });
value="Billable Rate"></InputLabel>
<BillableRateInput
@keydown.enter="submit"
:currency="getOrganizationCurrencyString()"
name="billable_rate"
v-model="
projectMemberBody.billable_rate

View File

@@ -1,10 +1,10 @@
<script setup lang="ts">
import { TrashIcon, PencilSquareIcon } from '@heroicons/vue/20/solid';
import type { ProjectMember } from '@/utils/api';
import type { ProjectMember } from '@/packages/api/src';
import { useMembersStore } from '@/utils/useMembers';
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import MoreOptionsDropdown from '@/Components/MoreOptionsDropdown.vue';
import MoreOptionsDropdown from '@/packages/ui/src/MoreOptionsDropdown.vue';
const emit = defineEmits<{
delete: [];

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
import SecondaryButton from '@/Components/SecondaryButton.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { PlusIcon } from '@heroicons/vue/16/solid';
import { ref } from 'vue';
import ProjectMemberTableRow from '@/Components/Common/ProjectMember/ProjectMemberTableRow.vue';
import { UserGroupIcon } from '@heroicons/vue/24/solid';
import ProjectMemberTableHeading from '@/Components/Common/ProjectMember/ProjectMemberTableHeading.vue';
import ProjectMemberCreateModal from '@/Components/Common/ProjectMember/ProjectMemberCreateModal.vue';
import type { ProjectMember } from '@/utils/api';
import type { ProjectMember } from '@/packages/api/src';
defineProps<{
projectId: string;

View File

@@ -1,14 +1,15 @@
<script setup lang="ts">
import type { ProjectMember } from '@/utils/api';
import type { ProjectMember } from '@/packages/api/src';
import { computed, ref } from 'vue';
import { storeToRefs } from 'pinia';
import TableRow from '@/Components/TableRow.vue';
import { useMembersStore } from '@/utils/useMembers';
import { useProjectMembersStore } from '@/utils/useProjectMembers';
import ProjectMemberMoreOptionsDropdown from '@/Components/Common/ProjectMember/ProjectMemberMoreOptionsDropdown.vue';
import { formatCents } from '@/utils/money';
import { formatCents } from '@/packages/ui/src/utils/money';
import { capitalizeFirstLetter } from '@/utils/format';
import ProjectMemberEditModal from '@/Components/Common/ProjectMember/ProjectMemberEditModal.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
const props = defineProps<{
projectMember: ProjectMember;
@@ -48,7 +49,10 @@ const showEditModal = ref(false);
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted">
{{
projectMember.billable_rate
? formatCents(projectMember.billable_rate)
? formatCents(
projectMember.billable_rate,
getOrganizationCurrencyString()
)
: '--'
}}
</div>

View File

@@ -6,7 +6,7 @@ import {
formatDate,
formatHumanReadableDuration,
formatWeek,
} from '@/utils/time';
} from '@/packages/ui/src/utils/time';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { BarChart } from 'echarts/charts';
@@ -16,7 +16,7 @@ import {
TitleComponent,
TooltipComponent,
} from 'echarts/components';
import type { AggregatedTimeEntries } from '@/utils/api';
import type { AggregatedTimeEntries } from '@/packages/api/src';
import { useCssVar } from '@vueuse/core';
use([

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import Badge from '@/Components/Common/Badge.vue';
import Badge from '@/packages/ui/src/Badge.vue';
const props = defineProps<{
icon: Component;

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import SelectDropdown from '@/Components/Common/SelectDropdown.vue';
import Badge from '@/Components/Common/Badge.vue';
import SelectDropdown from '@/packages/ui/src/Input/SelectDropdown.vue';
import Badge from '@/packages/ui/src/Badge.vue';
import { type Component, computed } from 'vue';
const model = defineModel<string | null>({ default: null });

View File

@@ -11,9 +11,9 @@ import {
TitleComponent,
TooltipComponent,
} from 'echarts/components';
import { formatHumanReadableDuration } from '@/utils/time';
import { getRandomColorWithSeed } from '@/utils/color';
import type { GroupedDataEntries } from '@/utils/api';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
import type { GroupedDataEntries } from '@/packages/api/src';
import { useReportingStore } from '@/utils/useReporting';
use([

View File

@@ -1,10 +1,11 @@
<script setup lang="ts">
import { formatHumanReadableDuration } from '@/utils/time';
import { formatCents } from '@/utils/money';
import GroupedItemsCountButton from '@/Components/Common/GroupedItemsCountButton.vue';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { formatCents } from '@/packages/ui/src/utils/money';
import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';
import { ref } from 'vue';
import { twMerge } from 'tailwind-merge';
import { useReportingStore } from '@/utils/useReporting';
import { getOrganizationCurrencyString } from '@/utils/money';
const { getNameForReportingRowEntry } = useReportingStore();
type AggregatedGroupedData = GroupedData & {
@@ -54,7 +55,7 @@ const expanded = ref(false);
{{ formatHumanReadableDuration(entry.seconds) }}
</div>
<div class="justify-end pr-6 flex items-center">
{{ formatCents(entry.cost) }}
{{ formatCents(entry.cost, getOrganizationCurrencyString()) }}
</div>
</div>
<div

View File

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

View File

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

View File

@@ -1,40 +0,0 @@
<script setup lang="ts">
import { twMerge } from 'tailwind-merge';
import Badge from '@/Components/Common/Badge.vue';
import { TagIcon } from '@heroicons/vue/20/solid';
const props = withDefaults(
defineProps<{
name: string;
size: 'base' | 'large';
tag: string;
class?: string;
color: string;
border: boolean;
}>(),
{
size: 'base',
tag: 'div',
color: 'var(--theme-color-icon-default)',
border: true,
}
);
const indicatorClasses = {
base: 'w-3 h-3',
large: 'w-5 h-5',
};
</script>
<template>
<Badge :name :size :tag :class="props.class" :color :border>
<TagIcon
:style="{ color: color }"
:class="twMerge(indicatorClasses[size])"></TagIcon>
<span>
{{ name }}
</span>
</Badge>
</template>
<style scoped></style>

View File

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

View File

@@ -1,224 +0,0 @@
<script setup lang="ts">
import { PlusCircleIcon } from '@heroicons/vue/20/solid';
import Dropdown from '@/Components/Dropdown.vue';
import { type Component, computed, nextTick, ref, watch } from 'vue';
import TagCreateModal from '@/Components/Common/Tag/TagCreateModal.vue';
import MultiselectDropdownItem from '@/Components/Common/MultiselectDropdownItem.vue';
import type { Tag } from '@/utils/api';
import type { Placement } from '@floating-ui/vue';
const props = withDefaults(
defineProps<{
tags: Tag[];
createTag: (name: string) => Promise<Tag | undefined>;
align: Placement;
}>(),
{
align: 'bottom-start',
}
);
const model = defineModel<string[]>({
default: [],
});
const searchInput = ref<HTMLInputElement | null>(null);
const open = ref(false);
const dropdownViewport = ref<Component | null>(null);
const searchValue = ref('');
function isTagSelected(id: string) {
return model.value.includes(id);
}
function addOrRemoveTagFromSelection(id: string) {
if (model.value.includes(id)) {
model.value = model.value.filter((tagId) => tagId !== id);
} else {
model.value = [...model.value, id];
}
emit('changed');
}
const sortedTags = ref(props.tags);
watch(open, (isOpen) => {
if (isOpen) {
nextTick(() => {
searchInput.value?.focus();
});
// sort tags alphabetically
sortedTags.value = [...props.tags].sort((a, b) => {
const aIsSelected = model.value.includes(a.id);
const bIsSelected = model.value.includes(b.id);
if (aIsSelected === bIsSelected) {
return a.name.localeCompare(b.name);
}
return model.value.includes(a.id) ? -1 : 1;
});
nextTick(() => {
if (filteredTags.value.length > 0) {
highlightedItemId.value = filteredTags.value[0].id;
}
});
}
});
const filteredTags = computed(() => {
return sortedTags.value.filter((tag) => {
return tag.name
.toLowerCase()
.includes(searchValue.value?.toLowerCase()?.trim() || '');
});
});
async function createAndAddTag(name: string) {
const newTag = await props.createTag(name);
if (newTag) {
addOrRemoveTagFromSelection(newTag.id);
}
searchValue.value = '';
return newTag;
}
async function addTagIfNoneExists() {
if (highlightedItemId.value) {
addOrRemoveTagFromSelection(highlightedItemId.value);
}
}
watch(filteredTags, () => {
if (filteredTags.value.length > 0) {
highlightedItemId.value = filteredTags.value[0].id;
}
});
function updateSearchValue(event: Event) {
const newInput = (event.target as HTMLInputElement).value;
if (newInput === ' ') {
searchValue.value = '';
const highlightedTagId = highlightedItemId.value;
if (highlightedTagId) {
const highlightedTag = props.tags.find(
(tag) => tag.id === highlightedTagId
);
if (highlightedTag) {
addOrRemoveTagFromSelection(highlightedTag.id);
}
}
} else {
searchValue.value = newInput;
}
}
const emit = defineEmits<{
changed: [];
submit: [];
}>();
function toggleTag(newValue: string) {
if (model.value.includes(newValue)) {
model.value = [...model.value].filter((id) => id !== newValue);
} else {
model.value = [...model.value, newValue];
}
emit('changed');
}
function moveHighlightUp() {
if (highlightedItem.value) {
const currentHightlightedIndex = filteredTags.value.indexOf(
highlightedItem.value
);
if (currentHightlightedIndex === 0) {
highlightedItemId.value =
filteredTags.value[filteredTags.value.length - 1].id;
} else {
highlightedItemId.value =
filteredTags.value[currentHightlightedIndex - 1].id;
}
}
}
function moveHighlightDown() {
if (highlightedItem.value) {
const currentHightlightedIndex = filteredTags.value.indexOf(
highlightedItem.value
);
if (currentHightlightedIndex === filteredTags.value.length - 1) {
highlightedItemId.value = filteredTags.value[0].id;
} else {
highlightedItemId.value =
filteredTags.value[currentHightlightedIndex + 1].id;
}
}
}
const highlightedItemId = ref<string | null>(null);
const highlightedItem = computed(() => {
return props.tags.find((tag) => tag.id === highlightedItemId.value);
});
const showCreateTagModal = ref(false);
</script>
<template>
<TagCreateModal
:createTag="createAndAddTag"
v-model:show="showCreateTagModal"></TagCreateModal>
<Dropdown
@submit="emit('submit')"
v-model="open"
:align="align"
:closeOnContentClick="false">
<template #trigger>
<slot name="trigger"></slot>
</template>
<template #content>
<input
:value="searchValue"
@input="updateSearchValue"
@keydown.enter="addTagIfNoneExists"
data-testid="tag_dropdown_search"
@keydown.up.prevent="moveHighlightUp"
@keydown.down.prevent="moveHighlightDown"
ref="searchInput"
class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
placeholder="Search for a Tag..." />
<div ref="dropdownViewport" class="w-60 max-h-48 overflow-y-scroll">
<div
v-for="tag in filteredTags"
:key="tag.id"
role="option"
:value="tag.id"
:class="{
'bg-card-background-active':
tag.id === highlightedItemId,
}"
data-testid="tag_dropdown_entries"
:data-tag-id="tag.id">
<MultiselectDropdownItem
:selected="isTagSelected(tag.id)"
@click="toggleTag(tag.id)"
:name="tag.name"></MultiselectDropdownItem>
</div>
</div>
<div class="hover:bg-card-background-active rounded-b-lg">
<button
@click="
open = false;
showCreateTagModal = true;
"
class="text-white w-full flex space-x-3 items-center px-4 py-3 text-xs font-semibold border-t border-card-background-separator">
<PlusCircleIcon
class="w-5 flex-shrink-0 text-icon-default"></PlusCircleIcon>
<span>Create new Tag</span>
</button>
</div>
</template>
</Dropdown>
</template>
<style scoped></style>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { TrashIcon } from '@heroicons/vue/20/solid';
import type { Tag } from '@/utils/api';
import MoreOptionsDropdown from '@/Components/MoreOptionsDropdown.vue';
import type { Tag } from '@/packages/api/src';
import MoreOptionsDropdown from '@/packages/ui/src/MoreOptionsDropdown.vue';
const emit = defineEmits<{
delete: [];

View File

@@ -1,15 +1,15 @@
<script setup lang="ts">
import SecondaryButton from '@/Components/SecondaryButton.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { FolderPlusIcon } from '@heroicons/vue/24/solid';
import { PlusIcon } from '@heroicons/vue/16/solid';
import { ref } from 'vue';
import { storeToRefs } from 'pinia';
import { useTagsStore } from '@/utils/useTags';
import TagTableRow from '@/Components/Common/Tag/TagTableRow.vue';
import TagCreateModal from '@/Components/Common/Tag/TagCreateModal.vue';
import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
import TagTableHeading from '@/Components/Common/Tag/TagTableHeading.vue';
import { canCreateTags } from '@/utils/permissions';
import type { Tag } from '@/utils/api';
import type { Tag } from '@/packages/api/src';
defineProps<{
createTag: (name: string) => Promise<Tag | undefined>;
}>();

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { Tag } from '@/utils/api';
import type { Tag } from '@/packages/api/src';
import { useTagsStore } from '@/utils/useTags';
import TagMoreOptionsDropdown from '@/Components/Common/Tag/TagMoreOptionsDropdown.vue';
import TableRow from '@/Components/TableRow.vue';

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import TextInput from '@/Components/TextInput.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import DialogModal from '@/Components/DialogModal.vue';
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { ref } from 'vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { useFocus } from '@vueuse/core';
import { useTasksStore } from '@/utils/useTasks';
import ProjectDropdown from '@/Components/Common/Project/ProjectDropdown.vue';

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
import TextInput from '@/Components/TextInput.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import DialogModal from '@/Components/DialogModal.vue';
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { ref } from 'vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { useFocus } from '@vueuse/core';
import { useTasksStore } from '@/utils/useTasks';
import type { Task, UpdateTaskBody } from '@/utils/api';
import type { Task, UpdateTaskBody } from '@/packages/api/src';
const { updateTask } = useTasksStore();
const show = defineModel('show', { default: false });

View File

@@ -4,9 +4,9 @@ import {
PencilSquareIcon,
CheckCircleIcon,
} from '@heroicons/vue/20/solid';
import type { Task } from '@/utils/api';
import type { Task } from '@/packages/api/src';
import { canDeleteTasks, canUpdateTasks } from '@/utils/permissions';
import MoreOptionsDropdown from '@/Components/MoreOptionsDropdown.vue';
import MoreOptionsDropdown from '@/packages/ui/src/MoreOptionsDropdown.vue';
const emit = defineEmits<{
delete: [];
edit: [];

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import MultiselectDropdown from '@/Components/Common/MultiselectDropdown.vue';
import { storeToRefs } from 'pinia';
import type { Task } from '@/utils/api';
import type { Task } from '@/packages/api/src';
import { useTasksStore } from '@/utils/useTasks';
const tasksStore = useTasksStore();

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import SecondaryButton from '@/Components/SecondaryButton.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { PlusCircleIcon } from '@heroicons/vue/24/solid';
import { PlusIcon } from '@heroicons/vue/16/solid';
import { ref } from 'vue';
@@ -7,7 +7,7 @@ import TaskTableRow from '@/Components/Common/Task/TaskTableRow.vue';
import TaskTableHeading from '@/Components/Common/Task/TaskTableHeading.vue';
import TaskCreateModal from '@/Components/Common/Task/TaskCreateModal.vue';
import { canCreateTasks } from '@/utils/permissions';
import type { Task } from '@/utils/api';
import type { Task } from '@/packages/api/src';
const props = defineProps<{
projectId: string;

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { Task } from '@/utils/api';
import type { Task } from '@/packages/api/src';
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
import { useTasksStore } from '@/utils/useTasks';
import TaskMoreOptionsDropdown from '@/Components/Common/Task/TaskMoreOptionsDropdown.vue';

View File

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

View File

@@ -1,18 +1,20 @@
<script setup lang="ts">
import TextInput from '@/Components/TextInput.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import DialogModal from '@/Components/DialogModal.vue';
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 { nextTick, ref, watch } from 'vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import TimeTrackerTagDropdown from '@/Components/Common/TimeTracker/TimeTrackerTagDropdown.vue';
import TimeTrackerProjectTaskDropdown from '@/Components/Common/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import BillableToggleButton from '@/Components/Common/BillableToggleButton.vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import TimeTrackerTagDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerTagDropdown.vue';
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';
import { getCurrentUserId } from '@/utils/useUser';
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
import InputLabel from '@/Components/InputLabel.vue';
import TimePicker from '@/Components/Common/TimePicker.vue';
import DatePicker from '@/Components/Common/DatePicker.vue';
import { getDayJsInstance, getLocalizedDayJs } from '@/utils/time';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
import {
getDayJsInstance,
getLocalizedDayJs,
} from '@/packages/ui/src/utils/time';
import { storeToRefs } from 'pinia';
import { useTasksStore } from '@/utils/useTasks';
import { useProjectsStore } from '@/utils/useProjects';
@@ -22,8 +24,10 @@ import type {
CreateProjectBody,
Project,
Client,
} from '@/utils/api';
} from '@/packages/api/src';
import { useClientsStore } from '@/utils/useClients';
import TimePicker from '@/packages/ui/src/Input/TimePicker.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
const projectStore = useProjectsStore();
const { projects } = storeToRefs(projectStore);
const taskStore = useTasksStore();
@@ -126,6 +130,7 @@ async function createTag(tag: string) {
:clients
:createProject
:createClient
:currency="getOrganizationCurrencyString()"
class="mt-1"
size="xlarge"
:projects="projects"

View File

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

View File

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

View File

@@ -1,22 +0,0 @@
<script setup lang="ts">
import { TrashIcon } from '@heroicons/vue/20/solid';
import MoreOptionsDropdown from '@/Components/MoreOptionsDropdown.vue';
const emit = defineEmits<{
delete: [];
}>();
</script>
<template>
<MoreOptionsDropdown label="Actions for the time entry">
<button
@click="emit('delete')"
data-testid="time_entry_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span>
</button>
</MoreOptionsDropdown>
</template>
<style scoped></style>

View File

@@ -1,47 +0,0 @@
<script setup lang="ts">
import Dropdown from '@/Components/Dropdown.vue';
import { defineProps, ref } from 'vue';
import { formatStartEnd } from '@/utils/time';
import TimeRangeSelector from '@/Components/Common/TimeRangeSelector.vue';
defineProps<{
start: string;
end: string | null;
}>();
const emit = defineEmits<{
changed: [start: string, end: string | null];
}>();
const open = ref(false);
</script>
<template>
<div class="relative">
<Dropdown
v-model="open"
@submit="open = false"
align="bottom"
:close-on-content-click="false">
<template #trigger>
<button
data-testid="time_entry_range_selector"
class="text-muted w-[110px] px-2 py-2 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium">
{{ formatStartEnd(start, end) }}
</button>
</template>
<template #content>
<TimeRangeSelector
@changed="
(newStart, newEnd) => emit('changed', newStart, newEnd)
"
focus
:start="start"
:end="end">
</TimeRangeSelector>
</template>
</Dropdown>
</div>
</template>
<style></style>

View File

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

View File

@@ -1,68 +0,0 @@
<script setup lang="ts">
import { calculateDifference, formatHumanReadableDuration } from '@/utils/time';
import { computed, defineProps, ref } from 'vue';
import parse from 'parse-duration';
import dayjs from 'dayjs';
const props = defineProps<{
start: string;
end: string | null;
}>();
const emit = defineEmits<{
changed: [start: string, end: string | null];
}>();
const temporaryCustomTimerEntry = ref<string>('');
function updateTimerAndStartLiveTimerUpdate() {
const time = parse(temporaryCustomTimerEntry.value, 's');
if (time && time > 0) {
let newEndDate = props.end;
let newStartDate = props.start;
if (props.end) {
// only update end for time entries that are already finished
newEndDate = dayjs(props.start).utc().add(time, 's').format();
} else {
newStartDate = dayjs().utc().subtract(time, 's').format();
}
emit('changed', newStartDate, newEndDate);
}
temporaryCustomTimerEntry.value = '';
}
const currentTime = computed({
get() {
if (temporaryCustomTimerEntry.value !== '') {
return temporaryCustomTimerEntry.value;
}
return formatHumanReadableDuration(
calculateDifference(props.start, props.end)
);
},
// setter
set(newValue) {
if (newValue) {
temporaryCustomTimerEntry.value = newValue;
} else {
temporaryCustomTimerEntry.value = '';
}
},
});
function selectInput(event: Event) {
const target = event.target as HTMLInputElement;
target.select();
}
</script>
<template>
<input
data-testid="time_entry_duration_input"
class="text-white w-[100px] px-3 py-2 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-semibold"
@focus="selectInput"
@blur="updateTimerAndStartLiveTimerUpdate"
@keydown.enter="updateTimerAndStartLiveTimerUpdate"
v-model="currentTime" />
</template>
<style scoped></style>

View File

@@ -1,18 +0,0 @@
<script setup lang="ts">
import DaySectionHeader from '@/Components/Common/DaySectionHeader.vue';
import MainContainer from '@/Pages/MainContainer.vue';
defineProps<{
date: string;
}>();
</script>
<template>
<div
class="bg-card-background border-t border-b border-card-border py-1 lg:py-1.5 text-xs sm:text-sm">
<MainContainer>
<DaySectionHeader :date></DaySectionHeader>
</MainContainer>
</div>
</template>
<style scoped></style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,54 +0,0 @@
<script setup lang="ts">
import TagDropdown from '@/Components/Common/Tag/TagDropdown.vue';
import { twMerge } from 'tailwind-merge';
import { TagIcon } from '@heroicons/vue/20/solid';
import { computed } from 'vue';
import type { Tag } from '@/utils/api';
const emit = defineEmits<{
changed: [];
}>();
const model = defineModel({
default: [],
});
const iconColorClasses = computed(() => {
if (model.value.length > 0) {
return 'text-accent-200/80 focus:text-accent-200 hover:text-accent-200';
} else {
return 'text-icon-default hover:text-icon-active focus:text-icon-active';
}
});
defineProps<{
tags: Tag[];
createTag: (name: string) => Promise<Tag | undefined>;
}>();
</script>
<template>
<TagDropdown
:createTag
@changed="emit('changed')"
v-model="model"
:tags="tags">
<template #trigger>
<button
data-testid="tag_dropdown"
:class="
twMerge(
iconColorClasses,
'flex-shrink-0 ring-0 focus:outline-none focus:ring-0 transition focus-visible:bg-card-background-separator hover:bg-card-background-separator rounded-full w-11 h-11 flex items-center justify-center'
)
">
<TagIcon class="w-5 h-5 lg:h-6 lg:w-6"></TagIcon>
<span
v-if="model.length > 1"
class="font-extrabold absolute rounded-full text-xs w-3 h-3 block top-[15px] rotate-[45deg] right-[14px] text-card-background">
{{ model.length }}
</span>
</button>
</template>
</TagDropdown>
</template>
<style scoped></style>

View File

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

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import Modal from './Modal.vue';
import Modal from '@/packages/ui/src/Modal.vue';
const emit = defineEmits(['close']);

View File

@@ -1,10 +1,10 @@
<script setup lang="ts">
import { ref, reactive, nextTick } from 'vue';
import DialogModal from './DialogModal.vue';
import InputError from './InputError.vue';
import PrimaryButton from './PrimaryButton.vue';
import SecondaryButton from './SecondaryButton.vue';
import TextInput from './TextInput.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import InputError from '@/packages/ui/src/Input/InputError.vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import axios from 'axios';
const emit = defineEmits(['confirmed']);

View File

@@ -3,8 +3,8 @@ import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import dayjs from 'dayjs';
import { formatHumanReadableDuration } from '@/utils/time';
import TimeTrackerStartStop from '@/Components/Common/TimeTrackerStartStop.vue';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import TimeTrackerStartStop from '@/packages/ui/src/TimeTrackerStartStop.vue';
import { getCurrentOrganizationId } from '@/utils/useUser';
const store = useCurrentTimeEntryStore();
const { currentTimeEntry, now, isActive } = storeToRefs(store);

View File

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

View File

@@ -18,7 +18,7 @@ import {
formatDate,
formatHumanReadableDuration,
getDayJsInstance,
} from '@/utils/time';
} from '@/packages/ui/src/utils/time';
import { useCssVar } from '@vueuse/core';
const props = defineProps<{

View File

@@ -12,7 +12,7 @@
<script setup lang="ts">
import type { Component } from 'vue';
import CardTitle from '@/Components/Common/CardTitle.vue';
import CardTitle from '@/packages/ui/src/CardTitle.vue';
defineProps<{
title: string;

View File

@@ -9,7 +9,7 @@ defineProps<{
import {
formatHumanReadableDate,
formatHumanReadableDuration,
} from '@/utils/time';
} from '@/packages/ui/src/utils/time';
</script>
<template>

View File

@@ -11,7 +11,7 @@ import {
TitleComponent,
TooltipComponent,
} from 'echarts/components';
import { formatHumanReadableDuration } from '@/utils/time';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
use([
CanvasRenderer,

View File

@@ -2,7 +2,7 @@
import RecentlyTrackedTasksCardEntry from '@/Components/Dashboard/RecentlyTrackedTasksCardEntry.vue';
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { PlusCircleIcon } from '@heroicons/vue/24/solid';
import { router } from '@inertiajs/vue3';

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import ProjectBadge from '@/Components/Common/Project/ProjectBadge.vue';
import TimeTrackerStartStop from '@/Components/Common/TimeTrackerStartStop.vue';
import ProjectBadge from '@/packages/ui/src/Project/ProjectBadge.vue';
import TimeTrackerStartStop from '@/packages/ui/src/TimeTrackerStartStop.vue';
import { useProjectsStore } from '@/utils/useProjects';
import { storeToRefs } from 'pinia';
import { computed } from 'vue';

View File

@@ -3,7 +3,7 @@ import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
import TeamActivityCardEntry from '@/Components/Dashboard/TeamActivityCardEntry.vue';
import { UserGroupIcon } from '@heroicons/vue/20/solid';
import { router } from '@inertiajs/vue3';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
defineProps<{
latestTeamActivity: {

Some files were not shown because too many files have changed in this diff Show More