mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-14 19:22:14 +01:00
move ui and api to seperate packages and add npm actions for them
This commit is contained in:
50
resources/js/packages/ui/src/Badge.vue
Normal file
50
resources/js/packages/ui/src/Badge.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
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>
|
||||
58
resources/js/packages/ui/src/BillableRateModal.vue
Normal file
58
resources/js/packages/ui/src/BillableRateModal.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import PrimaryButton from './Buttons/PrimaryButton.vue';
|
||||
import DialogModal from './DialogModal.vue';
|
||||
import SecondaryButton from './Buttons/SecondaryButton.vue';
|
||||
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid';
|
||||
|
||||
const show = defineModel('show', { default: false });
|
||||
const saving = defineModel('saving', { default: false });
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [];
|
||||
}>();
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal closeable :show="show" @close="show = false">
|
||||
<template #title>
|
||||
<div class="flex justify-center">
|
||||
<span> {{ title }} </span>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="col-span-6 sm:col-span-4 flex-1">
|
||||
<slot></slot>
|
||||
<div class="space-x-3 pt-5 pb-2 flex justify-center">
|
||||
<PrimaryButton
|
||||
:class="{ 'opacity-25': saving }"
|
||||
:disabled="saving"
|
||||
@click="emit('submit')">
|
||||
Yes, update existing time entries
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
<p class="text-center pt-3 pb-1">
|
||||
Learn more about the
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://docs.solidtime.io/user-guide/billable-rates"
|
||||
class="text-blue-400 hover:text-blue-500 transition"
|
||||
>billable rate logic
|
||||
<ArrowTopRightOnSquareIcon
|
||||
class="w-4 -mt-0.5 inline-block"></ArrowTopRightOnSquareIcon
|
||||
></a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<SecondaryButton @click="show = false"> Cancel </SecondaryButton>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
20
resources/js/packages/ui/src/Buttons/DangerButton.vue
Normal file
20
resources/js/packages/ui/src/Buttons/DangerButton.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { HtmlButtonType } from '@/types/dom';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
type?: HtmlButtonType;
|
||||
}>(),
|
||||
{
|
||||
type: 'button',
|
||||
}
|
||||
);
|
||||
</script>
|
||||
Modal.vue
|
||||
<template>
|
||||
<button
|
||||
:type="type"
|
||||
class="inline-flex items-center justify-center px-4 py-2 bg-red-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-red-500 active:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 transition ease-in-out duration-150">
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
25
resources/js/packages/ui/src/Buttons/PrimaryButton.vue
Normal file
25
resources/js/packages/ui/src/Buttons/PrimaryButton.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { HtmlButtonType } from '@/types/dom';
|
||||
import LoadingSpinner from '../LoadingSpinner.vue';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
type: HtmlButtonType;
|
||||
loading: boolean;
|
||||
}>(),
|
||||
{
|
||||
type: 'submit',
|
||||
loading: false,
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:type="type"
|
||||
:disabled="loading"
|
||||
class="inline-flex items-center px-2 sm:px-3 py-1 sm:py-2 bg-accent-300/10 border border-accent-300/20 rounded-md font-medium text-xs sm:text-sm text-white hover:bg-accent-300/20 active:bg-accent-300/20 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition ease-in-out duration-150">
|
||||
<LoadingSpinner v-if="loading"></LoadingSpinner>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
46
resources/js/packages/ui/src/Buttons/SecondaryButton.vue
Normal file
46
resources/js/packages/ui/src/Buttons/SecondaryButton.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import type { HtmlButtonType } from '@/types/dom';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { type Component } from 'vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
type: HtmlButtonType;
|
||||
icon?: Component;
|
||||
size: 'small' | 'base';
|
||||
}>(),
|
||||
{
|
||||
type: 'button',
|
||||
size: 'base',
|
||||
}
|
||||
);
|
||||
|
||||
const sizeClasses = {
|
||||
small: 'text-xs px-2 sm:px-2.5 py-1 sm:py-1.5',
|
||||
base: 'text-xs sm:text-sm px-2 sm:px-3 py-1 sm:py-2',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:type="type"
|
||||
:class="
|
||||
twMerge(
|
||||
'bg-button-secondary-background border border-button-secondary-border hover:bg-button-secondary-background-hover shadow-sm transition text-white rounded-lg font-semibold inline-flex items-center space-x-1.5 focus-visible:border-input-border-active focus:outline-none focus:ring-0 disabled:opacity-25 ease-in-out',
|
||||
sizeClasses[props.size]
|
||||
)
|
||||
">
|
||||
<span
|
||||
:class="
|
||||
twMerge('flex items-center ', props.icon ? 'space-x-1.5' : '')
|
||||
">
|
||||
<component
|
||||
v-if="props.icon"
|
||||
:is="props.icon"
|
||||
class="w-4 sm:w-5 h-4 sm:h-5 -ml-0.5 sm:-ml-1"></component>
|
||||
<span>
|
||||
<slot />
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
28
resources/js/packages/ui/src/CardTitle.vue
Normal file
28
resources/js/packages/ui/src/CardTitle.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue';
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
icon?: Component;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-full items-center justify-between pb-2.5 lg:pb-4">
|
||||
<h3
|
||||
class="text-white font-bold text-sm lg:text-base flex items-center space-x-2 lg:space-x-2.5">
|
||||
<component
|
||||
v-if="icon"
|
||||
:is="icon"
|
||||
class="w-5 lg:w-6 text-icon-default"></component>
|
||||
<span>
|
||||
{{ title }}
|
||||
</span>
|
||||
</h3>
|
||||
<div class="flex-1 flex justify-end items-center">
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
181
resources/js/packages/ui/src/Client/ClientDropdown.vue
Normal file
181
resources/js/packages/ui/src/Client/ClientDropdown.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
import { PlusCircleIcon } from '@heroicons/vue/20/solid';
|
||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||
import { type Component, computed, nextTick, ref, watch } from 'vue';
|
||||
import ClientDropdownItem from '@/packages/ui/src/Client/ClientDropdownItem.vue';
|
||||
import type { CreateClientBody, Client } from '@/packages/api/src';
|
||||
|
||||
const model = defineModel<string | null>({
|
||||
default: null,
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
clients: Client[];
|
||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||
}>();
|
||||
|
||||
const searchInput = ref<HTMLInputElement | null>(null);
|
||||
const open = ref(false);
|
||||
const dropdownViewport = ref<Component | null>(null);
|
||||
|
||||
const searchValue = ref('');
|
||||
|
||||
function isClientSelected(id: string) {
|
||||
return model.value === id;
|
||||
}
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
nextTick(() => {
|
||||
searchInput.value?.focus();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const filteredClients = computed(() => {
|
||||
return props.clients.filter((client) => {
|
||||
return client.name
|
||||
.toLowerCase()
|
||||
.includes(searchValue.value?.toLowerCase()?.trim() || '');
|
||||
});
|
||||
});
|
||||
|
||||
async function addClientIfNoneExists() {
|
||||
if (searchValue.value.length > 0 && filteredClients.value.length === 0) {
|
||||
const newClient = await props.createClient({
|
||||
name: searchValue.value,
|
||||
});
|
||||
if (newClient) {
|
||||
model.value = newClient.id;
|
||||
searchValue.value = '';
|
||||
}
|
||||
} else {
|
||||
if (highlightedItemId.value) {
|
||||
model.value = highlightedItemId.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(filteredClients, () => {
|
||||
if (filteredClients.value.length > 0) {
|
||||
highlightedItemId.value = filteredClients.value[0].id;
|
||||
}
|
||||
});
|
||||
|
||||
function updateSearchValue(event: Event) {
|
||||
const newInput = (event.target as HTMLInputElement).value;
|
||||
if (newInput === ' ') {
|
||||
searchValue.value = '';
|
||||
const highlightedClientId = highlightedItemId.value;
|
||||
if (highlightedClientId) {
|
||||
const highlightedClient = props.clients.find(
|
||||
(client) => client.id === highlightedClientId
|
||||
);
|
||||
if (highlightedClient) {
|
||||
model.value = highlightedClient.id;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
searchValue.value = newInput;
|
||||
}
|
||||
}
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'changed']);
|
||||
|
||||
function updateClient(newValue: string) {
|
||||
model.value = newValue;
|
||||
nextTick(() => {
|
||||
emit('changed');
|
||||
});
|
||||
}
|
||||
|
||||
function moveHighlightUp() {
|
||||
if (highlightedItem.value) {
|
||||
const currentHightlightedIndex = filteredClients.value.indexOf(
|
||||
highlightedItem.value
|
||||
);
|
||||
if (currentHightlightedIndex === 0) {
|
||||
highlightedItemId.value =
|
||||
filteredClients.value[filteredClients.value.length - 1].id;
|
||||
} else {
|
||||
highlightedItemId.value =
|
||||
filteredClients.value[currentHightlightedIndex - 1].id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function moveHighlightDown() {
|
||||
if (highlightedItem.value) {
|
||||
const currentHightlightedIndex = filteredClients.value.indexOf(
|
||||
highlightedItem.value
|
||||
);
|
||||
if (currentHightlightedIndex === filteredClients.value.length - 1) {
|
||||
highlightedItemId.value = filteredClients.value[0].id;
|
||||
} else {
|
||||
highlightedItemId.value =
|
||||
filteredClients.value[currentHightlightedIndex + 1].id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const highlightedItemId = ref<string | null>(null);
|
||||
const highlightedItem = computed(() => {
|
||||
return props.clients.find(
|
||||
(client) => client.id === highlightedItemId.value
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dropdown width="120" v-model="open" :closeOnContentClick="true">
|
||||
<template #trigger>
|
||||
<slot name="trigger"></slot>
|
||||
</template>
|
||||
<template #content>
|
||||
<input
|
||||
:value="searchValue"
|
||||
@input="updateSearchValue"
|
||||
@keydown.enter="addClientIfNoneExists"
|
||||
data-testid="client_dropdown_search"
|
||||
@keydown.up.prevent="moveHighlightUp"
|
||||
@keydown.down.prevent="moveHighlightDown"
|
||||
ref="searchInput"
|
||||
class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
||||
placeholder="Search for a client..." />
|
||||
<div ref="dropdownViewport" class="w-60">
|
||||
<div
|
||||
v-if="
|
||||
searchValue.length > 0 && filteredClients.length === 0
|
||||
"
|
||||
@click="addClientIfNoneExists"
|
||||
class="bg-card-background-active">
|
||||
<div
|
||||
class="flex space-x-3 items-center px-4 py-3 text-xs text-white font-medium border-t rounded-b-lg border-card-background-separator">
|
||||
<PlusCircleIcon
|
||||
class="w-5 flex-shrink-0"></PlusCircleIcon>
|
||||
<span>Add "{{ searchValue }}" as a new Client</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else></div>
|
||||
<div
|
||||
v-for="client in filteredClients"
|
||||
:key="client.id"
|
||||
role="option"
|
||||
:value="client.id"
|
||||
:class="{
|
||||
'bg-card-background-active':
|
||||
client.id === highlightedItemId,
|
||||
}"
|
||||
data-testid="client_dropdown_entries"
|
||||
:data-client-id="client.id">
|
||||
<ClientDropdownItem
|
||||
:selected="isClientSelected(client.id)"
|
||||
@click="updateClient(client.id)"
|
||||
:name="client.name"></ClientDropdownItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
28
resources/js/packages/ui/src/Client/ClientDropdownItem.vue
Normal file
28
resources/js/packages/ui/src/Client/ClientDropdownItem.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
|
||||
import { computed } from 'vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
const props = defineProps<{
|
||||
name: string;
|
||||
selected: boolean;
|
||||
}>();
|
||||
|
||||
const iconClasses = computed(() => {
|
||||
if (props.selected) {
|
||||
return 'text-accent-200';
|
||||
} else {
|
||||
return 'text-card-border';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
|
||||
<CheckCircleIcon :class="twMerge(iconClasses, 'w-5')"></CheckCircleIcon>
|
||||
<span>{{ name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
35
resources/js/packages/ui/src/DaySectionHeader.vue
Normal file
35
resources/js/packages/ui/src/DaySectionHeader.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
formatDate,
|
||||
formatHumanReadableDate,
|
||||
} from '@/packages/ui/src/utils/time';
|
||||
|
||||
defineProps<{
|
||||
date: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center space-x-2">
|
||||
<svg
|
||||
class="w-4 sm:w-5 text-muted"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none">
|
||||
<path
|
||||
d="m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427c-.002-.01-.009-.017-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093c.012.004.023 0 .029-.008l.004-.014l-.034-.614c-.003-.012-.01-.02-.02-.022m-.715.002a.023.023 0 0 0-.027.006l-.006.014l-.034.614c0 .012.007.02.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z" />
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7zm-5-9a1 1 0 0 1 1 1v1h2a2 2 0 0 1 2 2v3H3V7a2 2 0 0 1 2-2h2V4a1 1 0 0 1 2 0v1h6V4a1 1 0 0 1 1-1" />
|
||||
</g>
|
||||
</svg>
|
||||
<span class="font-semibold text-white">
|
||||
{{ formatHumanReadableDate(date) }}
|
||||
</span>
|
||||
<span class="font-semibold text-muted">
|
||||
{{ formatDate(date) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
47
resources/js/packages/ui/src/DialogModal.vue
Normal file
47
resources/js/packages/ui/src/DialogModal.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import Modal from './Modal.vue';
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
maxWidth: {
|
||||
type: String,
|
||||
default: '2xl',
|
||||
},
|
||||
closeable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
emit('close');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:show="show"
|
||||
:max-width="maxWidth"
|
||||
:closeable="closeable"
|
||||
@close="close">
|
||||
<div class="px-6 py-4">
|
||||
<div class="text-lg font-medium text-white" role="heading">
|
||||
<slot name="title" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-sm text-muted">
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-row justify-end px-6 py-4 border-t border-card-background-separator bg-default-background rounded-b-2xl text-end">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
37
resources/js/packages/ui/src/GroupedItemsCountButton.vue
Normal file
37
resources/js/packages/ui/src/GroupedItemsCountButton.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
expanded?: boolean;
|
||||
size: string;
|
||||
}>(),
|
||||
{
|
||||
expanded: false,
|
||||
size: 'w-7 h-7',
|
||||
}
|
||||
);
|
||||
|
||||
const expandedStatusClasses = computed(() => {
|
||||
if (props.expanded) {
|
||||
return 'border-card-border border bg-card-background-active text-white';
|
||||
}
|
||||
return 'border-card-border border bg-card-background text-muted';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:class="
|
||||
twMerge(
|
||||
'font-medium rounded flex items-center transition justify-center',
|
||||
expandedStatusClasses,
|
||||
props.size
|
||||
)
|
||||
">
|
||||
<slot></slot>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
14
resources/js/packages/ui/src/Icons/BillableIcon.vue
Normal file
14
resources/js/packages/ui/src/Icons/BillableIcon.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<svg viewBox="0 0 8 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M4 1V13M1 10.182L1.879 10.841C3.05 11.72 4.949 11.72 6.121 10.841C7.293 9.962 7.293 8.538 6.121 7.659C5.536 7.219 4.768 7 4 7C3.275 7 2.55 6.78 1.997 6.341C0.891 5.462 0.891 4.038 1.997 3.159C3.103 2.28 4.897 2.28 6.003 3.159L6.418 3.489"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
96
resources/js/packages/ui/src/Input/BillableRateInput.vue
Normal file
96
resources/js/packages/ui/src/Input/BillableRateInput.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
|
||||
import {
|
||||
formatCents,
|
||||
getOrganizationCurrencySymbol,
|
||||
} from '@/packages/ui/src/utils/money';
|
||||
import { ref, watch } from 'vue';
|
||||
import { useFocus } from '@vueuse/core';
|
||||
|
||||
const props = defineProps<{
|
||||
name: string;
|
||||
focus?: boolean;
|
||||
currency: string;
|
||||
}>();
|
||||
|
||||
const model = defineModel<number | null>({
|
||||
default: null,
|
||||
});
|
||||
|
||||
const billableRateInput = ref<HTMLInputElement | null>(null);
|
||||
useFocus(billableRateInput, { initialValue: props.focus });
|
||||
|
||||
function cleanUpDecimalValue(value: string) {
|
||||
value = value.replace(/,/g, '');
|
||||
value = value.replace(props.currency, '');
|
||||
return value.replace(/\./g, '');
|
||||
}
|
||||
|
||||
function updateRate(value: string) {
|
||||
value = value.trim();
|
||||
if (value.includes(',')) {
|
||||
const parts = value.split(',');
|
||||
const lastPart = (parts[parts.length - 1] = parts[parts.length - 1]);
|
||||
if (lastPart.length === 2) {
|
||||
// we detected a decimal number with 2 digits after the comma
|
||||
value = cleanUpDecimalValue(value);
|
||||
model.value = parseInt(value);
|
||||
}
|
||||
} else if (value.includes('.')) {
|
||||
const parts = value.split('.');
|
||||
const lastPart = (parts[parts.length - 1] = parts[parts.length - 1]);
|
||||
if (lastPart.length === 2) {
|
||||
value = cleanUpDecimalValue(value);
|
||||
model.value = parseInt(value);
|
||||
}
|
||||
} else if (value === '') {
|
||||
model.value = 0;
|
||||
} else {
|
||||
// if it doesn't contain a comma or a dot, it's probably a whole number so let's convert it to cents
|
||||
const parsedValue = parseInt(cleanUpDecimalValue(value)) * 100;
|
||||
if (parsedValue) {
|
||||
model.value = parsedValue;
|
||||
} else {
|
||||
model.value = 0;
|
||||
}
|
||||
}
|
||||
inputValue.value = formatValue(model.value);
|
||||
}
|
||||
|
||||
function formatValue(modelValue: number | null) {
|
||||
const formattedValue = formatCents(modelValue ?? 0, props.currency);
|
||||
return formattedValue
|
||||
.replace(getOrganizationCurrencySymbol(props.currency), '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
watch(model, (newValue) => {
|
||||
inputValue.value = formatValue(newValue);
|
||||
});
|
||||
|
||||
const inputValue = ref(formatValue(model.value));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<TextInput
|
||||
:id="name"
|
||||
ref="billableRateInput"
|
||||
v-model="inputValue"
|
||||
@blur="updateRate($event.target.value)"
|
||||
@keydown.enter="updateRate($event.target.value)"
|
||||
type="text"
|
||||
:name="name"
|
||||
placeholder="Billable Rate"
|
||||
class="mt-2 block w-full"
|
||||
autocomplete="teamMemberRate" />
|
||||
<div
|
||||
class="absolute top-0 right-0 h-full flex items-center px-4 font-medium pointer-events-none">
|
||||
<span>
|
||||
{{ currency }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
55
resources/js/packages/ui/src/Input/BillableToggleButton.vue
Normal file
55
resources/js/packages/ui/src/Input/BillableToggleButton.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
const active = defineModel({ default: false });
|
||||
const emit = defineEmits(['changed']);
|
||||
function toggleBillable() {
|
||||
active.value = !active.value;
|
||||
emit('changed', active.value);
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
size: 'small' | 'base';
|
||||
}>(),
|
||||
{
|
||||
size: 'base',
|
||||
}
|
||||
);
|
||||
|
||||
const iconColorClasses = computed(() => {
|
||||
if (active.value) {
|
||||
return 'text-accent-300 focus:text-accent-200 hover:text-accent-200';
|
||||
} else {
|
||||
return 'text-icon-default focus:text-icon-active hover:text-icon-active';
|
||||
}
|
||||
});
|
||||
|
||||
const iconSizeClasses = computed(() => {
|
||||
if (props.size === 'small') {
|
||||
return 'w-5 h-5';
|
||||
} else {
|
||||
return 'w-5 lg:w-6 h-5 lg:h-6';
|
||||
}
|
||||
});
|
||||
|
||||
const iconSizeWrapperClasses =
|
||||
props.size === 'small' ? 'w-6 sm:w-8 h-6 sm:h-8' : 'w-11 h-11';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
@click="toggleBillable"
|
||||
:class="
|
||||
twMerge(
|
||||
iconColorClasses,
|
||||
iconSizeWrapperClasses,
|
||||
'flex-shrink-0 ring-0 focus:outline-none focus:ring-0 transition focus:bg-card-background-separator hover:bg-card-background-separator rounded-full flex items-center justify-center'
|
||||
)
|
||||
">
|
||||
<BillableIcon :class="iconSizeClasses"></BillableIcon>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
34
resources/js/packages/ui/src/Input/Checkbox.vue
Normal file
34
resources/js/packages/ui/src/Input/Checkbox.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
const emit = defineEmits(['update:checked']);
|
||||
|
||||
const props = defineProps({
|
||||
checked: {
|
||||
type: [Array, Boolean],
|
||||
default: false,
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const proxyChecked = computed({
|
||||
get() {
|
||||
return props.checked;
|
||||
},
|
||||
|
||||
set(val) {
|
||||
emit('update:checked', val);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
v-model="proxyChecked"
|
||||
type="checkbox"
|
||||
:value="value"
|
||||
class="rounded bg-input-background border-input-border text-indigo-600 shadow-sm focus:ring-indigo-500" />
|
||||
</template>
|
||||
73
resources/js/packages/ui/src/Input/DatePicker.vue
Normal file
73
resources/js/packages/ui/src/Input/DatePicker.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import {
|
||||
getDayJsInstance,
|
||||
getLocalizedDayJs,
|
||||
} from '@/packages/ui/src/utils/time';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
const props = defineProps<{
|
||||
class?: string;
|
||||
}>();
|
||||
|
||||
// This has to be a localized timestamp, not UTC
|
||||
const model = defineModel<string | null>({
|
||||
default: null,
|
||||
});
|
||||
|
||||
const tempDate = ref(getLocalizedDayJs(model.value).format('YYYY-MM-DD'));
|
||||
|
||||
watch(model, (value) => {
|
||||
tempDate.value = getLocalizedDayJs(value).format('YYYY-MM-DD');
|
||||
});
|
||||
|
||||
function updateDate(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const newValue = target.value;
|
||||
const newDate = getDayJsInstance()(newValue);
|
||||
if (newDate.isValid()) {
|
||||
model.value = getLocalizedDayJs(model.value)
|
||||
.set('year', newDate.year())
|
||||
.set('month', newDate.month())
|
||||
.set('date', newDate.date())
|
||||
.format();
|
||||
emit('changed', model.value);
|
||||
}
|
||||
}
|
||||
|
||||
const datePicker = ref<HTMLInputElement | null>(null);
|
||||
|
||||
function updateTempValue(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
tempDate.value = target.value;
|
||||
}
|
||||
|
||||
const emit = defineEmits(['changed']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-center text-muted">
|
||||
<input
|
||||
ref="datePicker"
|
||||
@change="updateTempValue"
|
||||
@blur="updateDate"
|
||||
@keydown.enter="updateDate"
|
||||
:class="
|
||||
twMerge(
|
||||
'bg-input-background border text-white border-input-border rounded-md',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
type="date"
|
||||
id="start"
|
||||
name="trip-start"
|
||||
:value="tempDate" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
input::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1);
|
||||
opacity: 0.2;
|
||||
}
|
||||
</style>
|
||||
39
resources/js/packages/ui/src/Input/DateRangePicker.vue
Normal file
39
resources/js/packages/ui/src/Input/DateRangePicker.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { CalendarIcon } from '@heroicons/vue/20/solid';
|
||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
|
||||
import { formatDate } from '@/packages/ui/src/utils/time';
|
||||
|
||||
const start = defineModel('start', { default: '' });
|
||||
const end = defineModel('end', { default: '' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dropdown :close-on-content-click="false" align="bottom-end">
|
||||
<template #trigger>
|
||||
<button
|
||||
class="px-3 py-1.5 bg-input-background border border-input-border font-medium rounded-lg flex items-center space-x-2">
|
||||
<CalendarIcon class="w-5"></CalendarIcon>
|
||||
<div class="text-white">
|
||||
{{ formatDate(start) }}
|
||||
<span class="px-1.5 text-muted">-</span>
|
||||
{{ formatDate(end) }}
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="overflow-hidden w-[280px] px-3 py-1.5">
|
||||
<div class="flex space-x-3 items-center justify-between">
|
||||
<div class="text-sm font-medium text-muted">Start Date</div>
|
||||
<DatePicker v-model="start"></DatePicker>
|
||||
</div>
|
||||
<div class="mt-2 flex space-x-3 items-center justify-between">
|
||||
<div class="text-sm font-medium text-muted">End Date</div>
|
||||
<DatePicker v-model="end"></DatePicker>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
99
resources/js/packages/ui/src/Input/Dropdown.vue
Normal file
99
resources/js/packages/ui/src/Input/Dropdown.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import {
|
||||
flip,
|
||||
type Placement,
|
||||
type ReferenceElement,
|
||||
useFloating,
|
||||
} from '@floating-ui/vue';
|
||||
import { offset } from '@floating-ui/vue';
|
||||
import { autoUpdate } from '@floating-ui/vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
align: Placement;
|
||||
closeOnContentClick: boolean;
|
||||
}>(),
|
||||
{
|
||||
align: 'bottom-start',
|
||||
closeOnContentClick: true,
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits(['open', 'submit']);
|
||||
const open = defineModel({ default: false });
|
||||
|
||||
const closeOnEscape = (e: KeyboardEvent) => {
|
||||
if (open.value && e.key === 'Escape') {
|
||||
open.value = false;
|
||||
}
|
||||
if (open.value && e.key === 'Enter') {
|
||||
emit('submit');
|
||||
if (props.closeOnContentClick) open.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', closeOnEscape));
|
||||
onUnmounted(() => document.removeEventListener('keydown', closeOnEscape));
|
||||
|
||||
function onContentClick() {
|
||||
if (props.closeOnContentClick === true) {
|
||||
open.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleOpen() {
|
||||
open.value = !open.value;
|
||||
if (open.value === true) {
|
||||
emit('open');
|
||||
}
|
||||
}
|
||||
|
||||
function onBackgroundClick() {
|
||||
emit('submit');
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
const reference = ref<null | ReferenceElement>(null);
|
||||
const floating = ref(null);
|
||||
const { floatingStyles } = useFloating(reference, floating, {
|
||||
placement: props.align,
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [flip(), offset(10)],
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-w-0">
|
||||
<div @click.prevent="toggleOpen" ref="reference" class="min-w-0">
|
||||
<slot name="trigger" />
|
||||
</div>
|
||||
|
||||
<!-- Full Screen Dropdown Overlay -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-show="open"
|
||||
class="fixed inset-0 z-40"
|
||||
@click.prevent="onBackgroundClick" />
|
||||
<transition
|
||||
enter-active-class="transition-opacity ease-out duration-200"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition-opacity ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95">
|
||||
<div
|
||||
v-if="open"
|
||||
class="z-50"
|
||||
ref="floating"
|
||||
:style="floatingStyles"
|
||||
@click="onContentClick">
|
||||
<div
|
||||
class="rounded-lg ring-1 relative ring-black ring-opacity-5 border border-card-border overflow-none shadow-dropdown bg-card-background">
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
13
resources/js/packages/ui/src/Input/InputError.vue
Normal file
13
resources/js/packages/ui/src/Input/InputError.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
message: String,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-show="message">
|
||||
<p class="text-sm text-red-400">
|
||||
{{ message }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
12
resources/js/packages/ui/src/Input/InputLabel.vue
Normal file
12
resources/js/packages/ui/src/Input/InputLabel.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
value: String,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="block font-medium text-sm text-white">
|
||||
<span v-if="value">{{ value }}</span>
|
||||
<span v-else><slot /></span>
|
||||
</label>
|
||||
</template>
|
||||
149
resources/js/packages/ui/src/Input/SelectDropdown.vue
Normal file
149
resources/js/packages/ui/src/Input/SelectDropdown.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||
import { type Component, computed, ref, watch } from 'vue';
|
||||
import SelectDropdownItem from '@/packages/ui/src/Input/SelectDropdownItem.vue';
|
||||
import { onKeyStroke } from '@vueuse/core';
|
||||
import { type Placement } from '@floating-ui/vue';
|
||||
|
||||
const model = defineModel<string | null>({
|
||||
default: null,
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: T[];
|
||||
getKeyFromItem: (item: T) => string | null;
|
||||
getNameForItem: (item: T) => string;
|
||||
align?: Placement;
|
||||
}>(),
|
||||
{
|
||||
align: 'bottom-start',
|
||||
}
|
||||
);
|
||||
|
||||
const open = ref(false);
|
||||
const dropdownViewport = ref<Component | null>(null);
|
||||
|
||||
const searchValue = ref('');
|
||||
|
||||
// DropdownMultiselect
|
||||
const filteredItems = computed<T[]>(() => {
|
||||
return props.items.filter((item: T) => {
|
||||
return props
|
||||
.getNameForItem(item)
|
||||
.toLowerCase()
|
||||
.includes(searchValue.value?.toLowerCase()?.trim() || '');
|
||||
});
|
||||
});
|
||||
|
||||
watch(filteredItems, () => {
|
||||
if (filteredItems.value.length > 0) {
|
||||
highlightedItemId.value = props.getKeyFromItem(filteredItems.value[0]);
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'changed']);
|
||||
|
||||
function setItem(newValue: string | null) {
|
||||
model.value = newValue;
|
||||
emit('changed');
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function moveHighlightUp() {
|
||||
if (highlightedItem.value) {
|
||||
const currentHightlightedIndex = filteredItems.value.indexOf(
|
||||
highlightedItem.value
|
||||
);
|
||||
if (currentHightlightedIndex === 0) {
|
||||
highlightedItemId.value = props.getKeyFromItem(
|
||||
filteredItems.value[filteredItems.value.length - 1]
|
||||
);
|
||||
} else {
|
||||
highlightedItemId.value = props.getKeyFromItem(
|
||||
filteredItems.value[currentHightlightedIndex - 1]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function moveHighlightDown() {
|
||||
if (highlightedItem.value) {
|
||||
const currentHightlightedIndex = filteredItems.value.indexOf(
|
||||
highlightedItem.value
|
||||
);
|
||||
if (currentHightlightedIndex === filteredItems.value.length - 1) {
|
||||
highlightedItemId.value = props.getKeyFromItem(
|
||||
filteredItems.value[0]
|
||||
);
|
||||
} else {
|
||||
highlightedItemId.value = props.getKeyFromItem(
|
||||
filteredItems.value[currentHightlightedIndex + 1]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const highlightedItemId = ref<string | null>(null);
|
||||
const highlightedItem = computed(() => {
|
||||
return props.items.find(
|
||||
(item) => props.getKeyFromItem(item) === highlightedItemId.value
|
||||
);
|
||||
});
|
||||
|
||||
onKeyStroke('ArrowDown', (e) => {
|
||||
if (open.value === true) {
|
||||
moveHighlightDown();
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
onKeyStroke('ArrowUp', (e) => {
|
||||
if (open.value === true) {
|
||||
moveHighlightUp();
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
onKeyStroke('Enter', (e) => {
|
||||
if (open.value === true) {
|
||||
setItem(highlightedItemId.value);
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
watch(open, () => {
|
||||
if (open.value === true) {
|
||||
highlightedItemId.value = model.value;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dropdown v-model="open" :align="align" :closeOnContentClick="false">
|
||||
<template #trigger>
|
||||
<slot name="trigger"> </slot>
|
||||
</template>
|
||||
<template #content>
|
||||
<div ref="dropdownViewport" class="w-60">
|
||||
<div
|
||||
v-for="item in filteredItems"
|
||||
:key="props.getKeyFromItem(item) ?? 'none'"
|
||||
role="option"
|
||||
:value="props.getKeyFromItem(item)"
|
||||
:class="{
|
||||
'bg-card-background-active':
|
||||
props.getKeyFromItem(item) === highlightedItemId,
|
||||
}"
|
||||
:data-item-id="props.getKeyFromItem(item)">
|
||||
<SelectDropdownItem
|
||||
:selected="props.getKeyFromItem(item) === model"
|
||||
@click="setItem(props.getKeyFromItem(item))"
|
||||
:name="props.getNameForItem(item)"></SelectDropdownItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
24
resources/js/packages/ui/src/Input/SelectDropdownItem.vue
Normal file
24
resources/js/packages/ui/src/Input/SelectDropdownItem.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
const props = defineProps<{
|
||||
name: string;
|
||||
selected: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
twMerge(
|
||||
'flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out cursor-pointer ',
|
||||
props.selected
|
||||
? 'bg-accent-300/20'
|
||||
: 'hover:bg-card-background-active'
|
||||
)
|
||||
">
|
||||
<span>{{ name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
26
resources/js/packages/ui/src/Input/TextInput.vue
Normal file
26
resources/js/packages/ui/src/Input/TextInput.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
defineProps<{
|
||||
name?: string;
|
||||
}>();
|
||||
|
||||
const input = ref<HTMLInputElement | null>(null);
|
||||
|
||||
onMounted(() => {
|
||||
if (input.value?.hasAttribute('autofocus')) {
|
||||
input.value?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({ focus: () => input.value?.focus() });
|
||||
const model = defineModel();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
ref="input"
|
||||
class="border-input-border border bg-input-background text-white focus:ring-input-border-active focus:ring-0 focus-visible:border-input-border-active rounded-md shadow-sm"
|
||||
v-model="model"
|
||||
:name="name" />
|
||||
</template>
|
||||
121
resources/js/packages/ui/src/Input/TimePicker.vue
Normal file
121
resources/js/packages/ui/src/Input/TimePicker.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import {
|
||||
getDayJsInstance,
|
||||
getLocalizedDayJs,
|
||||
} from '@/packages/ui/src/utils/time';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { useFocus } from '@vueuse/core';
|
||||
|
||||
// This has to be a localized timestamp, not UTC
|
||||
const model = defineModel<string | null>({
|
||||
default: null,
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
size: 'base' | 'large';
|
||||
focus: boolean;
|
||||
}>(),
|
||||
{
|
||||
size: 'base',
|
||||
focus: false,
|
||||
}
|
||||
);
|
||||
|
||||
const hours = ref(
|
||||
model.value ? getLocalizedDayJs(model.value).format('HH') : null
|
||||
);
|
||||
const minutes = ref(
|
||||
model.value ? getLocalizedDayJs(model.value).format('mm') : null
|
||||
);
|
||||
watch(
|
||||
() => model.value,
|
||||
() => {
|
||||
hours.value = model.value
|
||||
? getLocalizedDayJs(model.value).format('HH')
|
||||
: null;
|
||||
minutes.value = model.value
|
||||
? getLocalizedDayJs(model.value).format('mm')
|
||||
: null;
|
||||
}
|
||||
);
|
||||
|
||||
function updateMinutes(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const newValue = target.value;
|
||||
if (!isNaN(parseInt(newValue))) {
|
||||
model.value = getDayJsInstance()(model.value)
|
||||
.set('minutes', Math.min(parseInt(newValue), 59))
|
||||
.format();
|
||||
}
|
||||
minutes.value = model.value
|
||||
? getLocalizedDayJs(model.value).format('mm')
|
||||
: null;
|
||||
}
|
||||
|
||||
function updateHours(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const newValue = target.value;
|
||||
if (newValue.endsWith(':')) {
|
||||
minutesInput.value?.focus();
|
||||
} else if (!isNaN(parseInt(newValue))) {
|
||||
model.value = getLocalizedDayJs(model.value)
|
||||
.set('hours', Math.min(parseInt(newValue), 23))
|
||||
.format();
|
||||
}
|
||||
hours.value = model.value
|
||||
? getLocalizedDayJs(model.value).format('HH')
|
||||
: null;
|
||||
}
|
||||
|
||||
const hoursInput = ref<HTMLInputElement | null>(null);
|
||||
const minutesInput = ref<HTMLInputElement | null>(null);
|
||||
const emit = defineEmits(['changed']);
|
||||
|
||||
useFocus(hoursInput, { initialValue: props.focus });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-center text-white">
|
||||
<div
|
||||
:class="
|
||||
twMerge(
|
||||
'border bg-input-background rounded-md border-input-border overflow-hidden',
|
||||
props.size === 'large' ? 'py-1.5 px-2' : ''
|
||||
)
|
||||
">
|
||||
<input
|
||||
v-model="hours"
|
||||
ref="hoursInput"
|
||||
@input="updateHours"
|
||||
@keydown.enter="emit('changed')"
|
||||
@focus="($event.target as HTMLInputElement).select()"
|
||||
data-testid="time_picker_hour"
|
||||
type="text"
|
||||
:class="
|
||||
twMerge(
|
||||
'border-none bg-transparent px-1 py-0.5 w-[30px] text-center focus:ring-0 focus:bg-card-background-active',
|
||||
props.size === 'large' ? 'text-base' : 'text-sm'
|
||||
)
|
||||
" />
|
||||
<span>:</span>
|
||||
<input
|
||||
v-model="minutes"
|
||||
ref="minutesInput"
|
||||
@keydown.enter="emit('changed')"
|
||||
@input="updateMinutes"
|
||||
@focus="($event.target as HTMLInputElement).select()"
|
||||
data-testid="time_picker_minute"
|
||||
type="text"
|
||||
:class="
|
||||
twMerge(
|
||||
'border-none bg-transparent px-1 py-1 w-[30px] text-center focus:ring-0 focus:bg-card-background-active',
|
||||
props.size === 'large' ? 'text-base' : 'text-sm'
|
||||
)
|
||||
" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
89
resources/js/packages/ui/src/Input/TimeRangeSelector.vue
Normal file
89
resources/js/packages/ui/src/Input/TimeRangeSelector.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import { defineProps, ref, watch } from 'vue';
|
||||
import TimePicker from '@/packages/ui/src/Input/TimePicker.vue';
|
||||
import { useFocusWithin } from '@vueuse/core';
|
||||
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
|
||||
import {
|
||||
getDayJsInstance,
|
||||
getLocalizedDayJs,
|
||||
} from '@/packages/ui/src/utils/time';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const props = defineProps<{
|
||||
start: string;
|
||||
end: string | null;
|
||||
focus?: boolean;
|
||||
}>();
|
||||
|
||||
// The timestamps for the changed event are UTC
|
||||
const emit = defineEmits(['changed']);
|
||||
|
||||
const tempStart = ref(
|
||||
props.start ? getLocalizedDayJs(props.start).format() : dayjs().format()
|
||||
);
|
||||
const tempEnd = ref(props.end ? getLocalizedDayJs(props.end).format() : null);
|
||||
|
||||
watch(props, () => {
|
||||
tempStart.value = getLocalizedDayJs(props.start).format();
|
||||
tempEnd.value = getLocalizedDayJs(props.end).format();
|
||||
});
|
||||
function updateTimeEntry() {
|
||||
const tempStartUtc = getDayJsInstance()(tempStart.value).utc().format();
|
||||
const tempEndUtc = tempEnd.value
|
||||
? getDayJsInstance()(tempEnd.value).utc().format()
|
||||
: null;
|
||||
if (tempStartUtc !== props.start || tempEndUtc !== props.end) {
|
||||
emit(
|
||||
'changed',
|
||||
getDayJsInstance()(tempStart.value).utc().format(),
|
||||
getDayJsInstance()(tempEnd.value).utc().format()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const dropdownContent = ref();
|
||||
const { focused } = useFocusWithin(dropdownContent);
|
||||
|
||||
watch(focused, (newValue, oldValue) => {
|
||||
if (oldValue === true && newValue === false) {
|
||||
updateTimeEntry();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="dropdownContent"
|
||||
class="grid grid-cols-2 divide-x divide-card-background-separator text-center py-2">
|
||||
<div class="px-2">
|
||||
<div class="font-bold text-white text-sm pb-2">Start</div>
|
||||
<div class="space-y-1">
|
||||
<TimePicker
|
||||
data-testid="time_entry_range_start"
|
||||
:focus
|
||||
@changed="updateTimeEntry"
|
||||
v-model="tempStart"></TimePicker>
|
||||
<DatePicker
|
||||
class="text-sm px-2 py-1"
|
||||
@changed="updateTimeEntry"
|
||||
v-model="tempStart"></DatePicker>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-2">
|
||||
<div class="font-bold text-white text-sm pb-2">End</div>
|
||||
<div v-if="tempEnd !== null" class="space-y-1">
|
||||
<TimePicker
|
||||
data-testid="time_entry_range_end"
|
||||
@changed="updateTimeEntry"
|
||||
v-model="tempEnd"></TimePicker>
|
||||
<DatePicker
|
||||
class="text-sm px-2 py-1"
|
||||
@changed="updateTimeEntry"
|
||||
v-model="tempEnd"></DatePicker>
|
||||
</div>
|
||||
<div class="text-muted" v-else>-- : --</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style></style>
|
||||
31
resources/js/packages/ui/src/LoadingSpinner.vue
Normal file
31
resources/js/packages/ui/src/LoadingSpinner.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
const props = defineProps<{
|
||||
class?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:class="
|
||||
twMerge('animate-spin -ml-1 mr-3 h-5 w-5 text-white', props.class)
|
||||
"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
103
resources/js/packages/ui/src/Modal.vue
Normal file
103
resources/js/packages/ui/src/Modal.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
maxWidth: {
|
||||
type: String,
|
||||
default: '2xl',
|
||||
},
|
||||
closeable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
() => {
|
||||
if (props.show) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = 'visible';
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const close = () => {
|
||||
if (props.closeable) {
|
||||
emit('close');
|
||||
}
|
||||
};
|
||||
|
||||
const closeOnEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && props.show) {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', closeOnEscape));
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', closeOnEscape);
|
||||
document.body.style.overflow = 'visible';
|
||||
});
|
||||
|
||||
const maxWidthClass = computed(() => {
|
||||
return {
|
||||
sm: 'sm:max-w-sm',
|
||||
md: 'sm:max-w-md',
|
||||
lg: 'sm:max-w-lg',
|
||||
xl: 'sm:max-w-xl',
|
||||
'2xl': 'sm:max-w-2xl',
|
||||
}[props.maxWidth];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<transition leave-active-class="duration-200">
|
||||
<div
|
||||
v-show="show"
|
||||
class="fixed inset-0 overflow-y-auto px-4 py-32 sm:px-0 z-50"
|
||||
scroll-region>
|
||||
<transition
|
||||
enter-active-class="ease-out duration-300"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="ease-in duration-200"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0">
|
||||
<div
|
||||
v-show="show"
|
||||
class="fixed inset-0 transform transition-all backdrop-blur-sm"
|
||||
@click="close">
|
||||
<div
|
||||
class="absolute inset-0 bg-default-background opacity-30" />
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<transition
|
||||
enter-active-class="ease-out duration-300"
|
||||
enter-from-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enter-to-class="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave-active-class="ease-in duration-200"
|
||||
leave-from-class="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave-to-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
|
||||
<div
|
||||
v-show="show"
|
||||
role="dialog"
|
||||
class="mb-6 bg-default-background border border-card-border rounded-lg shadow-xl transform transition-all sm:w-full sm:mx-auto"
|
||||
:class="maxWidthClass">
|
||||
<slot v-if="show" />
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</transition>
|
||||
</teleport>
|
||||
</template>
|
||||
35
resources/js/packages/ui/src/MoreOptionsDropdown.vue
Normal file
35
resources/js/packages/ui/src/MoreOptionsDropdown.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||
|
||||
defineProps<{
|
||||
label: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dropdown align="bottom-end">
|
||||
<template #trigger>
|
||||
<button
|
||||
class="focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-1 focus-visible:ring-input-border-active focus-visible:opacity-100 hover:bg-card-background group-hover:opacity-100 opacity-20 transition-opacity text-muted"
|
||||
:aria-label="label">
|
||||
<svg
|
||||
class="h-10 w-10 p-2 rounded-full"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<slot></slot>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
48
resources/js/packages/ui/src/Project/ProjectBadge.vue
Normal file
48
resources/js/packages/ui/src/Project/ProjectBadge.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import Badge from '@/packages/ui/src/Badge.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
name: string;
|
||||
size: 'base' | 'large' | 'xlarge';
|
||||
tag: string;
|
||||
class?: string;
|
||||
color: string;
|
||||
border: boolean;
|
||||
}>(),
|
||||
{
|
||||
name: '',
|
||||
size: 'base',
|
||||
tag: 'div',
|
||||
color: 'var(--theme-color-icon-default)',
|
||||
border: true,
|
||||
}
|
||||
);
|
||||
|
||||
const indicatorClasses = {
|
||||
base: 'w-2.5 h-2.5',
|
||||
large: 'w-2 sm:w-3 h-2 sm:h-3',
|
||||
xlarge: 'w-3 sm:w-4 h-3 sm:h-4',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Badge :name :size :tag :class="props.class" :color :border>
|
||||
<div
|
||||
:style="{ backgroundColor: props.color }"
|
||||
:class="
|
||||
twMerge(
|
||||
indicatorClasses[size],
|
||||
'inline-block rounded-full shrink-0'
|
||||
)
|
||||
"></div>
|
||||
<div class="min-w-0">
|
||||
<slot>
|
||||
{{ name }}
|
||||
</slot>
|
||||
</div>
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { formatCents } from '@/packages/ui/src/utils/money';
|
||||
import BillableRateModal from '@/packages/ui/src/BillableRateModal.vue';
|
||||
|
||||
const show = defineModel('show', { default: false });
|
||||
const saving = defineModel('saving', { default: false });
|
||||
|
||||
defineProps<{
|
||||
newBillableRate?: number | null;
|
||||
projectName: string;
|
||||
currency: string;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
submit: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BillableRateModal
|
||||
@submit="$emit('submit')"
|
||||
v-model:show="show"
|
||||
v-model:saving="saving"
|
||||
title="Update Project Billable Rate">
|
||||
<p class="py-1 text-center">
|
||||
The billable rate of {{ projectName }} will be updated to
|
||||
<strong>{{
|
||||
newBillableRate
|
||||
? formatCents(newBillableRate, currency)
|
||||
: ' the default rate of the organization member'
|
||||
}}</strong
|
||||
>.
|
||||
</p>
|
||||
<p class="py-1 text-center font-semibold max-w-md mx-auto">
|
||||
Do you want to update all existing time entries, where the project
|
||||
billable rate applies as well?
|
||||
</p>
|
||||
</BillableRateModal>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import SelectDropdown from '@/packages/ui/src/Input/SelectDropdown.vue';
|
||||
import Badge from '@/packages/ui/src/Badge.vue';
|
||||
import { ChevronDownIcon } from '@heroicons/vue/20/solid';
|
||||
import type { BillableKey } from '@/types/projects';
|
||||
|
||||
const model = defineModel<BillableKey>({
|
||||
default: 'non-billable',
|
||||
});
|
||||
|
||||
type Option = { key: BillableKey; name: string };
|
||||
|
||||
const options: Option[] = [
|
||||
{
|
||||
key: 'non-billable',
|
||||
name: 'Non-billable',
|
||||
},
|
||||
{
|
||||
key: 'default-rate',
|
||||
name: 'Default Rate',
|
||||
},
|
||||
{
|
||||
key: 'custom-rate',
|
||||
name: 'Custom Rate',
|
||||
},
|
||||
];
|
||||
|
||||
function getKeyFromItem(item: Option) {
|
||||
return item.key;
|
||||
}
|
||||
|
||||
function getNameFromItem(item: Option) {
|
||||
return item.name;
|
||||
}
|
||||
|
||||
function getNameForKey(key: BillableKey | undefined) {
|
||||
const item = options.find((item) => getKeyFromItem(item) === key);
|
||||
if (item) {
|
||||
return getNameFromItem(item);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectDropdown
|
||||
v-model="model"
|
||||
:get-key-from-item="getKeyFromItem"
|
||||
:get-name-for-item="getNameFromItem"
|
||||
:items="options">
|
||||
<template #trigger>
|
||||
<Badge size="xlarge" class="bg-input-background cursor-pointer">
|
||||
<span>
|
||||
{{ getNameForKey(model) }}
|
||||
</span>
|
||||
<ChevronDownIcon class="text-muted w-5"></ChevronDownIcon>
|
||||
</Badge>
|
||||
</template>
|
||||
</SelectDropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||
import { colors } from '@/packages/ui/src/utils/color';
|
||||
|
||||
const model = defineModel<string>({ default: '' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Dropdown align="bottom">
|
||||
<template #trigger>
|
||||
<button
|
||||
class="p-2 bg-input-background hover:bg-tertiary transition rounded-full border border-input-border">
|
||||
<div
|
||||
:style="{
|
||||
backgroundColor: model,
|
||||
}"
|
||||
class="w-6 h-6 rounded-full cursor-pointer"></div>
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="text-white grid grid-cols-6 gap-3 px-3 py-3">
|
||||
<div
|
||||
v-for="color in colors"
|
||||
:key="color"
|
||||
@click="model = color"
|
||||
:style="{
|
||||
backgroundColor: color,
|
||||
boxShadow: `var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) ${color}30`,
|
||||
}"
|
||||
class="w-4 h-4 rounded-full cursor-pointer"></div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
142
resources/js/packages/ui/src/Project/ProjectCreateModal.vue
Normal file
142
resources/js/packages/ui/src/Project/ProjectCreateModal.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import type {
|
||||
CreateClientBody,
|
||||
CreateProjectBody,
|
||||
Project,
|
||||
} from '@/packages/api/src';
|
||||
import { getRandomColor } from '@/packages/ui/src/utils/color';
|
||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import { useFocus } from '@vueuse/core';
|
||||
import ClientDropdown from '@/packages/ui/src/Client/ClientDropdown.vue';
|
||||
import Badge from '@/packages/ui/src/Badge.vue';
|
||||
import ProjectColorSelector from '@/packages/ui/src/Project/ProjectColorSelector.vue';
|
||||
import { UserCircleIcon } from '@heroicons/vue/20/solid';
|
||||
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
|
||||
import ProjectEditBillableSection from '@/packages/ui/src/Project/ProjectEditBillableSection.vue';
|
||||
import type { Client } from '@/packages/api/src';
|
||||
|
||||
const show = defineModel('show', { default: false });
|
||||
const saving = ref(false);
|
||||
|
||||
const props = defineProps<{
|
||||
clients: Client[];
|
||||
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||
currency: string;
|
||||
}>();
|
||||
|
||||
const project = ref<CreateProjectBody>({
|
||||
name: '',
|
||||
color: getRandomColor(),
|
||||
client_id: null,
|
||||
billable_rate: null,
|
||||
is_billable: false,
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
await props.createProject(project.value);
|
||||
show.value = false;
|
||||
project.value = {
|
||||
name: '',
|
||||
color: getRandomColor(),
|
||||
client_id: null,
|
||||
billable_rate: null,
|
||||
is_billable: false,
|
||||
};
|
||||
}
|
||||
|
||||
const projectNameInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
useFocus(projectNameInput, { initialValue: true });
|
||||
|
||||
const currentClientName = computed(() => {
|
||||
if (project.value.client_id) {
|
||||
return props.clients.find(
|
||||
(client) => client.id === project.value.client_id
|
||||
)?.name;
|
||||
}
|
||||
return 'No Client';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal closeable :show="show" @close="show = false">
|
||||
<template #title>
|
||||
<div class="flex space-x-2">
|
||||
<span> Create Project </span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div
|
||||
class="sm:flex items-center space-y-2 sm:space-y-0 sm:space-x-4">
|
||||
<div class="flex-1 flex items-center">
|
||||
<div class="text-center pr-5">
|
||||
<InputLabel for="color" value="Color" />
|
||||
<ProjectColorSelector
|
||||
class="mt-2.5"
|
||||
v-model="project.color"></ProjectColorSelector>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<InputLabel for="projectName" value="Project name" />
|
||||
<TextInput
|
||||
id="projectName"
|
||||
name="projectName"
|
||||
ref="projectNameInput"
|
||||
v-model="project.name"
|
||||
type="text"
|
||||
placeholder="The next big thing"
|
||||
@keydown.enter="submit()"
|
||||
class="mt-2 block w-full"
|
||||
required
|
||||
autocomplete="projectName" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="client" value="Client" />
|
||||
<ClientDropdown
|
||||
:createClient="createClient"
|
||||
:clients="clients"
|
||||
class="mt-2"
|
||||
v-model="project.client_id">
|
||||
<template #trigger>
|
||||
<Badge
|
||||
class="bg-input-background cursor-pointer hover:bg-tertiary"
|
||||
size="xlarge">
|
||||
<div class="flex items-center space-x-2">
|
||||
<UserCircleIcon
|
||||
class="w-5 text-icon-default"></UserCircleIcon>
|
||||
<span>
|
||||
{{ currentClientName }}
|
||||
</span>
|
||||
</div>
|
||||
</Badge>
|
||||
</template>
|
||||
</ClientDropdown>
|
||||
</div>
|
||||
</div>
|
||||
<ProjectEditBillableSection
|
||||
:currency="currency"
|
||||
v-model:isBillable="project.is_billable"
|
||||
v-model:billableRate="
|
||||
project.billable_rate
|
||||
"></ProjectEditBillableSection>
|
||||
</template>
|
||||
<template #footer>
|
||||
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
|
||||
<PrimaryButton
|
||||
class="ms-3"
|
||||
:class="{ 'opacity-25': saving }"
|
||||
:disabled="saving"
|
||||
@click="submit">
|
||||
Create Project
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
19
resources/js/packages/ui/src/Project/ProjectDropdownItem.vue
Normal file
19
resources/js/packages/ui/src/Project/ProjectDropdownItem.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
name: string;
|
||||
selected: boolean;
|
||||
color: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
|
||||
<div
|
||||
:style="{ backgroundColor: color }"
|
||||
class="w-3 h-3 rounded-full"></div>
|
||||
<span>{{ name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
|
||||
import BillableRateInput from '@/packages/ui/src/Input/BillableRateInput.vue';
|
||||
import ProjectBillableSelect from '@/packages/ui/src/Project/ProjectBillableSelect.vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import type { BillableKey } from '@/types/projects';
|
||||
|
||||
defineProps<{
|
||||
currency: string;
|
||||
}>();
|
||||
|
||||
const billableRateSelect = ref<BillableKey>('non-billable');
|
||||
|
||||
const billableRate = defineModel<number | null>('billableRate');
|
||||
const isBillable = defineModel<boolean>('isBillable');
|
||||
|
||||
onMounted(() => {
|
||||
if (isBillable.value === true) {
|
||||
if (billableRate.value) {
|
||||
billableRateSelect.value = 'custom-rate';
|
||||
} else {
|
||||
billableRateSelect.value = 'default-rate';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
watch(billableRateSelect, () => {
|
||||
if (billableRateSelect.value === 'non-billable') {
|
||||
isBillable.value = false;
|
||||
billableRate.value = null;
|
||||
} else if (billableRateSelect.value === 'default-rate') {
|
||||
isBillable.value = true;
|
||||
billableRate.value = null;
|
||||
} else {
|
||||
isBillable.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
billableRateSelect.value = 'non-billable';
|
||||
|
||||
const billableOptionInfoTexts: { [key in BillableKey]: string } = {
|
||||
'non-billable':
|
||||
'New time entries for this project will not be marked billable by default.',
|
||||
'default-rate':
|
||||
'New time entries for this project will be billable at the default rate by default.',
|
||||
'custom-rate':
|
||||
'New time entries for this project will be billable at a custom rate by default.',
|
||||
};
|
||||
|
||||
const billableOptionInfoText = computed(() => {
|
||||
return billableOptionInfoTexts[billableRateSelect.value];
|
||||
});
|
||||
const emit = defineEmits(['submit']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sm:flex items-center space-y-2 sm:space-y-0 sm:space-x-4 pt-6">
|
||||
<div>
|
||||
<InputLabel for="billable" value="Billable Default" />
|
||||
<ProjectBillableSelect
|
||||
v-model="billableRateSelect"
|
||||
class="mt-2"></ProjectBillableSelect>
|
||||
</div>
|
||||
<div
|
||||
class="sm:max-w-[120px]"
|
||||
v-if="billableRateSelect === 'custom-rate'">
|
||||
<InputLabel for="billableRate" value="Billable Rate" />
|
||||
<BillableRateInput
|
||||
@keydown.enter="emit('submit')"
|
||||
:currency="currency"
|
||||
v-model="billableRate"
|
||||
name="billableRate" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center text-muted pt-2 pl-1">
|
||||
<span>
|
||||
<span class="font-semibold"> Info: </span>
|
||||
{{ billableOptionInfoText }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
40
resources/js/packages/ui/src/Tag/TagBadge.vue
Normal file
40
resources/js/packages/ui/src/Tag/TagBadge.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import Badge from '../Badge.vue';
|
||||
import { TagIcon } from '@heroicons/vue/20/solid';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
name: string;
|
||||
size: 'base' | 'large';
|
||||
tag: string;
|
||||
class?: string;
|
||||
color: string;
|
||||
border: boolean;
|
||||
}>(),
|
||||
{
|
||||
size: 'base',
|
||||
tag: 'div',
|
||||
color: 'var(--theme-color-icon-default)',
|
||||
border: true,
|
||||
}
|
||||
);
|
||||
|
||||
const indicatorClasses = {
|
||||
base: 'w-3 h-3',
|
||||
large: 'w-5 h-5',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Badge :name :size :tag :class="props.class" :color :border>
|
||||
<TagIcon
|
||||
:style="{ color: color }"
|
||||
:class="twMerge(indicatorClasses[size])"></TagIcon>
|
||||
<span>
|
||||
{{ name }}
|
||||
</span>
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
68
resources/js/packages/ui/src/Tag/TagCreateModal.vue
Normal file
68
resources/js/packages/ui/src/Tag/TagCreateModal.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
||||
import { ref } from 'vue';
|
||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import { useFocus } from '@vueuse/core';
|
||||
import type { CreateTagBody, Tag } from '@/packages/api/src';
|
||||
const show = defineModel('show', { default: false });
|
||||
const saving = ref(false);
|
||||
|
||||
const tag = ref<CreateTagBody>({
|
||||
name: '',
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
createTag: (name: string) => Promise<Tag | undefined>;
|
||||
}>();
|
||||
|
||||
async function submit() {
|
||||
const newTag = props.createTag(tag.value.name);
|
||||
if (newTag !== undefined) {
|
||||
show.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const tagNameInput = ref<HTMLInputElement | null>(null);
|
||||
useFocus(tagNameInput, { initialValue: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal closeable :show="show" @close="show = false">
|
||||
<template #title>
|
||||
<div class="flex space-x-2">
|
||||
<span> Create Tags </span>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="col-span-6 sm:col-span-4 flex-1">
|
||||
<TextInput
|
||||
id="tagName"
|
||||
ref="tagNameInput"
|
||||
v-model="tag.name"
|
||||
@keydown.enter="submit"
|
||||
type="text"
|
||||
placeholder="Tag Name"
|
||||
class="mt-1 block w-full"
|
||||
required
|
||||
autocomplete="tagName" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<SecondaryButton @click="show = false"> Cancel </SecondaryButton>
|
||||
|
||||
<PrimaryButton
|
||||
class="ms-3"
|
||||
:class="{ 'opacity-25': saving }"
|
||||
:disabled="saving"
|
||||
@click="submit">
|
||||
Create Tag
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
224
resources/js/packages/ui/src/Tag/TagDropdown.vue
Normal file
224
resources/js/packages/ui/src/Tag/TagDropdown.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<script setup lang="ts">
|
||||
import { PlusCircleIcon } from '@heroicons/vue/20/solid';
|
||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||
import { type Component, computed, nextTick, ref, watch } from 'vue';
|
||||
import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
|
||||
import MultiselectDropdownItem from '@/Components/Common/MultiselectDropdownItem.vue';
|
||||
import type { Tag } from '@/packages/api/src';
|
||||
import type { Placement } from '@floating-ui/vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
tags: Tag[];
|
||||
createTag: (name: string) => Promise<Tag | undefined>;
|
||||
align: Placement;
|
||||
}>(),
|
||||
{
|
||||
align: 'bottom-start',
|
||||
}
|
||||
);
|
||||
|
||||
const model = defineModel<string[]>({
|
||||
default: [],
|
||||
});
|
||||
|
||||
const searchInput = ref<HTMLInputElement | null>(null);
|
||||
const open = ref(false);
|
||||
const dropdownViewport = ref<Component | null>(null);
|
||||
|
||||
const searchValue = ref('');
|
||||
|
||||
function isTagSelected(id: string) {
|
||||
return model.value.includes(id);
|
||||
}
|
||||
|
||||
function addOrRemoveTagFromSelection(id: string) {
|
||||
if (model.value.includes(id)) {
|
||||
model.value = model.value.filter((tagId) => tagId !== id);
|
||||
} else {
|
||||
model.value = [...model.value, id];
|
||||
}
|
||||
emit('changed');
|
||||
}
|
||||
|
||||
const sortedTags = ref(props.tags);
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
nextTick(() => {
|
||||
searchInput.value?.focus();
|
||||
});
|
||||
|
||||
// sort tags alphabetically
|
||||
sortedTags.value = [...props.tags].sort((a, b) => {
|
||||
const aIsSelected = model.value.includes(a.id);
|
||||
const bIsSelected = model.value.includes(b.id);
|
||||
if (aIsSelected === bIsSelected) {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
return model.value.includes(a.id) ? -1 : 1;
|
||||
});
|
||||
nextTick(() => {
|
||||
if (filteredTags.value.length > 0) {
|
||||
highlightedItemId.value = filteredTags.value[0].id;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const filteredTags = computed(() => {
|
||||
return sortedTags.value.filter((tag) => {
|
||||
return tag.name
|
||||
.toLowerCase()
|
||||
.includes(searchValue.value?.toLowerCase()?.trim() || '');
|
||||
});
|
||||
});
|
||||
|
||||
async function createAndAddTag(name: string) {
|
||||
const newTag = await props.createTag(name);
|
||||
if (newTag) {
|
||||
addOrRemoveTagFromSelection(newTag.id);
|
||||
}
|
||||
searchValue.value = '';
|
||||
return newTag;
|
||||
}
|
||||
|
||||
async function addTagIfNoneExists() {
|
||||
if (highlightedItemId.value) {
|
||||
addOrRemoveTagFromSelection(highlightedItemId.value);
|
||||
}
|
||||
}
|
||||
|
||||
watch(filteredTags, () => {
|
||||
if (filteredTags.value.length > 0) {
|
||||
highlightedItemId.value = filteredTags.value[0].id;
|
||||
}
|
||||
});
|
||||
|
||||
function updateSearchValue(event: Event) {
|
||||
const newInput = (event.target as HTMLInputElement).value;
|
||||
if (newInput === ' ') {
|
||||
searchValue.value = '';
|
||||
const highlightedTagId = highlightedItemId.value;
|
||||
if (highlightedTagId) {
|
||||
const highlightedTag = props.tags.find(
|
||||
(tag) => tag.id === highlightedTagId
|
||||
);
|
||||
if (highlightedTag) {
|
||||
addOrRemoveTagFromSelection(highlightedTag.id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
searchValue.value = newInput;
|
||||
}
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
changed: [];
|
||||
submit: [];
|
||||
}>();
|
||||
|
||||
function toggleTag(newValue: string) {
|
||||
if (model.value.includes(newValue)) {
|
||||
model.value = [...model.value].filter((id) => id !== newValue);
|
||||
} else {
|
||||
model.value = [...model.value, newValue];
|
||||
}
|
||||
emit('changed');
|
||||
}
|
||||
|
||||
function moveHighlightUp() {
|
||||
if (highlightedItem.value) {
|
||||
const currentHightlightedIndex = filteredTags.value.indexOf(
|
||||
highlightedItem.value
|
||||
);
|
||||
if (currentHightlightedIndex === 0) {
|
||||
highlightedItemId.value =
|
||||
filteredTags.value[filteredTags.value.length - 1].id;
|
||||
} else {
|
||||
highlightedItemId.value =
|
||||
filteredTags.value[currentHightlightedIndex - 1].id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function moveHighlightDown() {
|
||||
if (highlightedItem.value) {
|
||||
const currentHightlightedIndex = filteredTags.value.indexOf(
|
||||
highlightedItem.value
|
||||
);
|
||||
if (currentHightlightedIndex === filteredTags.value.length - 1) {
|
||||
highlightedItemId.value = filteredTags.value[0].id;
|
||||
} else {
|
||||
highlightedItemId.value =
|
||||
filteredTags.value[currentHightlightedIndex + 1].id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const highlightedItemId = ref<string | null>(null);
|
||||
const highlightedItem = computed(() => {
|
||||
return props.tags.find((tag) => tag.id === highlightedItemId.value);
|
||||
});
|
||||
|
||||
const showCreateTagModal = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TagCreateModal
|
||||
:createTag="createAndAddTag"
|
||||
v-model:show="showCreateTagModal"></TagCreateModal>
|
||||
<Dropdown
|
||||
@submit="emit('submit')"
|
||||
v-model="open"
|
||||
:align="align"
|
||||
:closeOnContentClick="false">
|
||||
<template #trigger>
|
||||
<slot name="trigger"></slot>
|
||||
</template>
|
||||
<template #content>
|
||||
<input
|
||||
:value="searchValue"
|
||||
@input="updateSearchValue"
|
||||
@keydown.enter="addTagIfNoneExists"
|
||||
data-testid="tag_dropdown_search"
|
||||
@keydown.up.prevent="moveHighlightUp"
|
||||
@keydown.down.prevent="moveHighlightDown"
|
||||
ref="searchInput"
|
||||
class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
||||
placeholder="Search for a Tag..." />
|
||||
<div ref="dropdownViewport" class="w-60 max-h-48 overflow-y-scroll">
|
||||
<div
|
||||
v-for="tag in filteredTags"
|
||||
:key="tag.id"
|
||||
role="option"
|
||||
:value="tag.id"
|
||||
:class="{
|
||||
'bg-card-background-active':
|
||||
tag.id === highlightedItemId,
|
||||
}"
|
||||
data-testid="tag_dropdown_entries"
|
||||
:data-tag-id="tag.id">
|
||||
<MultiselectDropdownItem
|
||||
:selected="isTagSelected(tag.id)"
|
||||
@click="toggleTag(tag.id)"
|
||||
:name="tag.name"></MultiselectDropdownItem>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hover:bg-card-background-active rounded-b-lg">
|
||||
<button
|
||||
@click="
|
||||
open = false;
|
||||
showCreateTagModal = true;
|
||||
"
|
||||
class="text-white w-full flex space-x-3 items-center px-4 py-3 text-xs font-semibold border-t border-card-background-separator">
|
||||
<PlusCircleIcon
|
||||
class="w-5 flex-shrink-0 text-icon-default"></PlusCircleIcon>
|
||||
<span>Create new Tag</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
170
resources/js/packages/ui/src/TimeEntry/TimeEntryAggregateRow.vue
Normal file
170
resources/js/packages/ui/src/TimeEntry/TimeEntryAggregateRow.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<script setup lang="ts">
|
||||
import MainContainer from '@/Pages/MainContainer.vue';
|
||||
import TimeTrackerStartStop from '../TimeTrackerStartStop.vue';
|
||||
import type {
|
||||
CreateClientBody,
|
||||
CreateProjectBody,
|
||||
Project,
|
||||
Tag,
|
||||
Task,
|
||||
TimeEntry,
|
||||
Client,
|
||||
} from '@/packages/api/src';
|
||||
import TimeEntryDescriptionInput from '@/packages/ui/src/TimeEntry/TimeEntryDescriptionInput.vue';
|
||||
import TimeEntryRowTagDropdown from '@/packages/ui/src/TimeEntry/TimeEntryRowTagDropdown.vue';
|
||||
import TimeEntryMoreOptionsDropdown from '@/packages/ui/src/TimeEntry/TimeEntryMoreOptionsDropdown.vue';
|
||||
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
|
||||
import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
formatHumanReadableDuration,
|
||||
formatStartEnd,
|
||||
} from '@/packages/ui/src/utils/time';
|
||||
import TimeEntryRow from '@/packages/ui/src/TimeEntry/TimeEntryRow.vue';
|
||||
import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';
|
||||
import type { TimeEntriesGroupedByType } from '@/types/time-entries';
|
||||
|
||||
const props = defineProps<{
|
||||
timeEntry: TimeEntriesGroupedByType;
|
||||
projects: Project[];
|
||||
tasks: Task[];
|
||||
tags: Tag[];
|
||||
clients: Client[];
|
||||
createTag: (name: string) => Promise<Tag | undefined>;
|
||||
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||
onStartStopClick: (timeEntry: TimeEntry) => void;
|
||||
updateTimeEntries: (timeEntries: TimeEntry[]) => void;
|
||||
deleteTimeEntries: (timeEntries: TimeEntry[]) => void;
|
||||
currency: string;
|
||||
}>();
|
||||
|
||||
function updateTimeEntryDescription(description: string) {
|
||||
const updatedTimeEntries = props.timeEntry.timeEntries.map((entry) => {
|
||||
return { ...entry, description };
|
||||
});
|
||||
props.updateTimeEntries(updatedTimeEntries);
|
||||
}
|
||||
|
||||
function updateTimeEntryTags(tags: string[]) {
|
||||
const updatedTimeEntries = props.timeEntry.timeEntries.map((entry) => {
|
||||
return { ...entry, tags };
|
||||
});
|
||||
props.updateTimeEntries(updatedTimeEntries);
|
||||
}
|
||||
|
||||
function updateTimeEntryBillable(billable: boolean) {
|
||||
const updatedTimeEntries = props.timeEntry.timeEntries.map((entry) => {
|
||||
return { ...entry, billable };
|
||||
});
|
||||
props.updateTimeEntries(updatedTimeEntries);
|
||||
}
|
||||
|
||||
function updateProjectAndTask(projectId: string, taskId: string) {
|
||||
const updatedTimeEntries = props.timeEntry.timeEntries.map((entry) => {
|
||||
return { ...entry, project_id: projectId, task_id: taskId };
|
||||
});
|
||||
props.updateTimeEntries(updatedTimeEntries);
|
||||
}
|
||||
|
||||
const expanded = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="border-b border-default-background-separator transition"
|
||||
data-testid="time_entry_row">
|
||||
<MainContainer>
|
||||
<div class="sm:flex py-1.5 items-center justify-between group">
|
||||
<div class="flex space-x-3 items-center min-w-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded bg-card-background border-input-border text-accent-500/80 focus:ring-accent-500/80" />
|
||||
<div class="flex items-center">
|
||||
<GroupedItemsCountButton
|
||||
:expanded="expanded"
|
||||
@click="expanded = !expanded">
|
||||
{{ timeEntry?.timeEntries?.length }}
|
||||
</GroupedItemsCountButton>
|
||||
<TimeEntryDescriptionInput
|
||||
@changed="updateTimeEntryDescription"
|
||||
:modelValue="
|
||||
timeEntry.description
|
||||
"></TimeEntryDescriptionInput>
|
||||
</div>
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
:clients
|
||||
:createProject
|
||||
:createClient
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:showBadgeBorder="false"
|
||||
@changed="updateProjectAndTask"
|
||||
:project="timeEntry.project_id"
|
||||
:currency="currency"
|
||||
:task="
|
||||
timeEntry.task_id
|
||||
"></TimeTrackerProjectTaskDropdown>
|
||||
</div>
|
||||
<div class="flex items-center font-medium lg:space-x-2">
|
||||
<TimeEntryRowTagDropdown
|
||||
:createTag
|
||||
:tags="tags"
|
||||
@changed="updateTimeEntryTags"
|
||||
:modelValue="timeEntry.tags"></TimeEntryRowTagDropdown>
|
||||
<BillableToggleButton
|
||||
:modelValue="timeEntry.billable"
|
||||
size="small"
|
||||
@changed="
|
||||
updateTimeEntryBillable
|
||||
"></BillableToggleButton>
|
||||
<div class="flex-1">
|
||||
<button
|
||||
@click="expanded = !expanded"
|
||||
class="hidden lg:block text-muted w-[110px] px-2 py-2 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium">
|
||||
{{ formatStartEnd(timeEntry.start, timeEntry.end) }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@click="expanded = !expanded"
|
||||
class="text-white w-[100px] px-3 py-2 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-semibold">
|
||||
{{
|
||||
formatHumanReadableDuration(timeEntry.duration ?? 0)
|
||||
}}
|
||||
</button>
|
||||
|
||||
<TimeTrackerStartStop
|
||||
@changed="onStartStopClick(timeEntry)"
|
||||
:active="!!(timeEntry.start && !timeEntry.end)"
|
||||
class="opacity-20 hidden sm:flex group-hover:opacity-100"></TimeTrackerStartStop>
|
||||
<TimeEntryMoreOptionsDropdown
|
||||
@delete="
|
||||
deleteTimeEntries([timeEntry])
|
||||
"></TimeEntryMoreOptionsDropdown>
|
||||
</div>
|
||||
</div>
|
||||
</MainContainer>
|
||||
<div
|
||||
v-if="expanded"
|
||||
class="w-full border-t border-default-background-separator bg-black/15">
|
||||
<TimeEntryRow
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:createClient
|
||||
:clients
|
||||
:createProject
|
||||
:tags="tags"
|
||||
indent
|
||||
:updateTimeEntry="(arg: TimeEntry) => updateTimeEntries([arg])"
|
||||
:onStartStopClick="() => onStartStopClick(subEntry)"
|
||||
:deleteTimeEntry="() => deleteTimeEntries([subEntry])"
|
||||
:currency="currency"
|
||||
:createTag
|
||||
:key="subEntry.id"
|
||||
v-for="subEntry in timeEntry.timeEntries"
|
||||
:time-entry="subEntry"></TimeEntryRow>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
const value = defineModel();
|
||||
const emit = defineEmits(['changed']);
|
||||
|
||||
function onChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (target.value !== value.value) {
|
||||
emit('changed', target.value);
|
||||
value.value = target.value;
|
||||
}
|
||||
}
|
||||
|
||||
function onInput(event: Event) {
|
||||
liveDataValue.value = (event.target as HTMLInputElement).value;
|
||||
}
|
||||
|
||||
const liveDataValue = ref(value.value);
|
||||
|
||||
const displaysPlaceholder = computed(() => {
|
||||
return liveDataValue.value === '' || liveDataValue.value === null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="relative text-sm font-medium">
|
||||
<div
|
||||
:class="[
|
||||
'opacity-0 py-2 text-base whitespace-pre pl-3 pr-1',
|
||||
{ 'min-w-[150px]': displaysPlaceholder },
|
||||
]">
|
||||
{{ liveDataValue }}
|
||||
</div>
|
||||
<input
|
||||
data-testid="time_entry_description"
|
||||
:value="liveDataValue"
|
||||
@blur="onChange"
|
||||
@input="onInput"
|
||||
@keydown.enter="onChange"
|
||||
placeholder="Add a description"
|
||||
class="absolute px-0 h-full pl-3 pr-1 left-0 top-0 w-full text-sm lg:text-base text-white font-medium bg-transparent focus-visible:ring-0 rounded-lg border-0" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
153
resources/js/packages/ui/src/TimeEntry/TimeEntryGroupedTable.vue
Normal file
153
resources/js/packages/ui/src/TimeEntry/TimeEntryGroupedTable.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type {
|
||||
CreateClientBody,
|
||||
CreateProjectBody,
|
||||
CreateTimeEntryBody,
|
||||
Project,
|
||||
Tag,
|
||||
Task,
|
||||
TimeEntry,
|
||||
Client,
|
||||
} from '@/packages/api/src';
|
||||
import {
|
||||
getDayJsInstance,
|
||||
getLocalizedDateFromTimestamp,
|
||||
} from '@/packages/ui/src/utils/time';
|
||||
import TimeEntryAggregateRow from '@/packages/ui/src/TimeEntry/TimeEntryAggregateRow.vue';
|
||||
import TimeEntryRowHeading from '@/packages/ui/src/TimeEntry/TimeEntryRowHeading.vue';
|
||||
import TimeEntryRow from '@/packages/ui/src/TimeEntry/TimeEntryRow.vue';
|
||||
import dayjs from 'dayjs';
|
||||
import type { TimeEntriesGroupedByType } from '@/types/time-entries';
|
||||
|
||||
const props = defineProps<{
|
||||
timeEntries: TimeEntry[];
|
||||
projects: Project[];
|
||||
tasks: Task[];
|
||||
tags: Tag[];
|
||||
clients: Client[];
|
||||
createTag: (name: string) => Promise<Tag | undefined>;
|
||||
updateTimeEntry: (entry: TimeEntry) => void;
|
||||
updateTimeEntries: (entries: TimeEntry[]) => void;
|
||||
deleteTimeEntries: (entries: TimeEntry[]) => void;
|
||||
createTimeEntry: (entry: Omit<CreateTimeEntryBody, 'member_id'>) => void;
|
||||
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||
currency: string;
|
||||
}>();
|
||||
|
||||
const groupedTimeEntries = computed(() => {
|
||||
const groupedEntriesByDay: Record<string, TimeEntry[]> = {};
|
||||
for (const entry of props.timeEntries) {
|
||||
// skip current time entry
|
||||
if (entry.end === null) {
|
||||
continue;
|
||||
}
|
||||
const oldEntries =
|
||||
groupedEntriesByDay[getLocalizedDateFromTimestamp(entry.start)];
|
||||
groupedEntriesByDay[getLocalizedDateFromTimestamp(entry.start)] = [
|
||||
...(oldEntries ?? []),
|
||||
entry,
|
||||
];
|
||||
}
|
||||
const groupedEntriesByDayAndType: Record<
|
||||
string,
|
||||
TimeEntriesGroupedByType[]
|
||||
> = {};
|
||||
for (const dailyEntriesKey in groupedEntriesByDay) {
|
||||
const dailyEntries = groupedEntriesByDay[dailyEntriesKey];
|
||||
const newDailyEntries: TimeEntriesGroupedByType[] = [];
|
||||
|
||||
for (const entry of dailyEntries) {
|
||||
// check if same entry already exists
|
||||
const oldEntriesIndex = newDailyEntries.findIndex(
|
||||
(e) =>
|
||||
e.project_id === entry.project_id &&
|
||||
e.task_id === entry.task_id &&
|
||||
e.billable === entry.billable &&
|
||||
e.description === entry.description
|
||||
);
|
||||
if (oldEntriesIndex !== -1 && newDailyEntries[oldEntriesIndex]) {
|
||||
newDailyEntries[oldEntriesIndex].timeEntries.push(entry);
|
||||
|
||||
// Add up durations for time entries of the same type
|
||||
newDailyEntries[oldEntriesIndex].duration =
|
||||
(newDailyEntries[oldEntriesIndex].duration ?? 0) +
|
||||
(entry?.duration ?? 0);
|
||||
|
||||
// adapt start end times so they show the earliest start and latest end time
|
||||
if (
|
||||
getDayJsInstance()(entry.start).isBefore(
|
||||
getDayJsInstance()(
|
||||
newDailyEntries[oldEntriesIndex].start
|
||||
)
|
||||
)
|
||||
) {
|
||||
newDailyEntries[oldEntriesIndex].start = entry.start;
|
||||
}
|
||||
if (
|
||||
getDayJsInstance()(entry.end).isAfter(
|
||||
getDayJsInstance()(newDailyEntries[oldEntriesIndex].end)
|
||||
)
|
||||
) {
|
||||
newDailyEntries[oldEntriesIndex].end = entry.end;
|
||||
}
|
||||
} else {
|
||||
newDailyEntries.push({ ...entry, timeEntries: [entry] });
|
||||
}
|
||||
}
|
||||
|
||||
groupedEntriesByDayAndType[dailyEntriesKey] = newDailyEntries;
|
||||
}
|
||||
return groupedEntriesByDayAndType;
|
||||
});
|
||||
|
||||
function startTimeEntryFromExisting(entry: TimeEntry) {
|
||||
props.createTimeEntry({
|
||||
project_id: entry.project_id,
|
||||
task_id: entry.task_id,
|
||||
start: dayjs().utc().format(),
|
||||
end: null,
|
||||
billable: entry.billable,
|
||||
description: entry.description,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-for="(value, key) in groupedTimeEntries" :key="key">
|
||||
<TimeEntryRowHeading :date="key"></TimeEntryRowHeading>
|
||||
<template v-for="entry in value" :key="entry.id">
|
||||
<TimeEntryAggregateRow
|
||||
:createProject
|
||||
:createClient
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:tags="tags"
|
||||
:clients
|
||||
:onStartStopClick="startTimeEntryFromExisting"
|
||||
:updateTimeEntries
|
||||
:deleteTimeEntries
|
||||
:createTag
|
||||
:currency="currency"
|
||||
v-if="'timeEntries' in entry && entry.timeEntries.length > 1"
|
||||
:time-entry="entry"></TimeEntryAggregateRow>
|
||||
<TimeEntryRow
|
||||
:createClient
|
||||
:createProject
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:tags="tags"
|
||||
:clients
|
||||
:createTag
|
||||
:updateTimeEntry
|
||||
:onStartStopClick="() => startTimeEntryFromExisting(entry)"
|
||||
:deleteTimeEntry="() => deleteTimeEntries([entry])"
|
||||
:currency="currency"
|
||||
v-else
|
||||
:time-entry="entry"></TimeEntryRow>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { TrashIcon } from '@heroicons/vue/20/solid';
|
||||
import MoreOptionsDropdown from '@/packages/ui/src/MoreOptionsDropdown.vue';
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MoreOptionsDropdown label="Actions for the time entry">
|
||||
<button
|
||||
@click="emit('delete')"
|
||||
data-testid="time_entry_delete"
|
||||
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
|
||||
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
</MoreOptionsDropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||
import { defineProps, ref } from 'vue';
|
||||
import { formatStartEnd } from '@/packages/ui/src/utils/time';
|
||||
import TimeRangeSelector from '@/packages/ui/src/Input/TimeRangeSelector.vue';
|
||||
|
||||
defineProps<{
|
||||
start: string;
|
||||
end: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
changed: [start: string, end: string | null];
|
||||
}>();
|
||||
|
||||
const open = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<Dropdown
|
||||
v-model="open"
|
||||
@submit="open = false"
|
||||
align="bottom"
|
||||
:close-on-content-click="false">
|
||||
<template #trigger>
|
||||
<button
|
||||
data-testid="time_entry_range_selector"
|
||||
class="text-muted w-[110px] px-2 py-2 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium">
|
||||
{{ formatStartEnd(start, end) }}
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<TimeRangeSelector
|
||||
@changed="
|
||||
(newStart: string, newEnd: string) =>
|
||||
emit('changed', newStart, newEnd)
|
||||
"
|
||||
focus
|
||||
:start="start"
|
||||
:end="end">
|
||||
</TimeRangeSelector>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style></style>
|
||||
135
resources/js/packages/ui/src/TimeEntry/TimeEntryRow.vue
Normal file
135
resources/js/packages/ui/src/TimeEntry/TimeEntryRow.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
import MainContainer from '@/Pages/MainContainer.vue';
|
||||
import TimeTrackerStartStop from '@/packages/ui/src/TimeTrackerStartStop.vue';
|
||||
import TimeEntryRangeSelector from '@/packages/ui/src/TimeEntry/TimeEntryRangeSelector.vue';
|
||||
import type {
|
||||
Client,
|
||||
CreateClientBody,
|
||||
CreateProjectBody,
|
||||
Project,
|
||||
Tag,
|
||||
Task,
|
||||
TimeEntry,
|
||||
} from '@/packages/api/src';
|
||||
import TimeEntryDescriptionInput from '@/packages/ui/src/TimeEntry/TimeEntryDescriptionInput.vue';
|
||||
import TimeEntryRowTagDropdown from '@/packages/ui/src/TimeEntry/TimeEntryRowTagDropdown.vue';
|
||||
import TimeEntryRowDurationInput from '@/packages/ui/src/TimeEntry/TimeEntryRowDurationInput.vue';
|
||||
import TimeEntryMoreOptionsDropdown from '@/packages/ui/src/TimeEntry/TimeEntryMoreOptionsDropdown.vue';
|
||||
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
|
||||
import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
timeEntry: TimeEntry;
|
||||
indent?: boolean;
|
||||
projects: Project[];
|
||||
tasks: Task[];
|
||||
tags: Tag[];
|
||||
clients: Client[];
|
||||
createTag: (name: string) => Promise<Tag | undefined>;
|
||||
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||
onStartStopClick: () => void;
|
||||
deleteTimeEntry: () => void;
|
||||
updateTimeEntry: (timeEntry: TimeEntry) => void;
|
||||
currency: string;
|
||||
}>();
|
||||
|
||||
function updateTimeEntryDescription(description: string) {
|
||||
props.updateTimeEntry({ ...props.timeEntry, description });
|
||||
}
|
||||
|
||||
function updateTimeEntryTags(tags: string[]) {
|
||||
props.updateTimeEntry({ ...props.timeEntry, tags });
|
||||
}
|
||||
|
||||
function updateTimeEntryBillable(billable: boolean) {
|
||||
props.updateTimeEntry({ ...props.timeEntry, billable });
|
||||
}
|
||||
|
||||
function updateStartEndTime(start: string, end: string | null) {
|
||||
props.updateTimeEntry({ ...props.timeEntry, start, end });
|
||||
}
|
||||
|
||||
function updateProjectAndTask(projectId: string, taskId: string) {
|
||||
props.updateTimeEntry({
|
||||
...props.timeEntry,
|
||||
project_id: projectId,
|
||||
task_id: taskId,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="border-b border-default-background-separator transition"
|
||||
data-testid="time_entry_row">
|
||||
<MainContainer>
|
||||
<div
|
||||
class="sm:flex py-1 lg:py-1.5 items-center justify-between group">
|
||||
<div class="flex space-x-1 items-center min-w-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded bg-card-background border-input-border text-accent-500/80 focus:ring-accent-500/80" />
|
||||
<div class="w-7 h-7" v-if="indent === true"></div>
|
||||
<TimeEntryDescriptionInput
|
||||
class="flex-1 max-w-[220px] md:max-w-[400px] text-ellipsis overflow-ellipsis"
|
||||
@changed="updateTimeEntryDescription"
|
||||
:modelValue="
|
||||
timeEntry.description
|
||||
"></TimeEntryDescriptionInput>
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
:createProject
|
||||
:createClient
|
||||
:clients
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:showBadgeBorder="false"
|
||||
@changed="updateProjectAndTask"
|
||||
:project="timeEntry.project_id"
|
||||
:currency="currency"
|
||||
:task="
|
||||
timeEntry.task_id
|
||||
"></TimeTrackerProjectTaskDropdown>
|
||||
</div>
|
||||
<div class="flex items-center font-medium lg:space-x-2">
|
||||
<TimeEntryRowTagDropdown
|
||||
@changed="updateTimeEntryTags"
|
||||
:createTag
|
||||
:tags="tags"
|
||||
:modelValue="timeEntry.tags"></TimeEntryRowTagDropdown>
|
||||
<BillableToggleButton
|
||||
:modelValue="timeEntry.billable"
|
||||
size="small"
|
||||
@changed="
|
||||
updateTimeEntryBillable
|
||||
"></BillableToggleButton>
|
||||
<div class="flex-1">
|
||||
<TimeEntryRangeSelector
|
||||
class="hidden lg:block"
|
||||
:start="timeEntry.start"
|
||||
:end="timeEntry.end"
|
||||
@changed="
|
||||
updateStartEndTime
|
||||
"></TimeEntryRangeSelector>
|
||||
</div>
|
||||
<TimeEntryRowDurationInput
|
||||
:start="timeEntry.start"
|
||||
:end="timeEntry.end"
|
||||
@changed="
|
||||
updateStartEndTime
|
||||
"></TimeEntryRowDurationInput>
|
||||
<TimeTrackerStartStop
|
||||
@changed="onStartStopClick"
|
||||
:active="!!(timeEntry.start && !timeEntry.end)"
|
||||
class="opacity-20 hidden sm:flex group-hover:opacity-100"></TimeTrackerStartStop>
|
||||
<TimeEntryMoreOptionsDropdown
|
||||
@delete="
|
||||
deleteTimeEntry
|
||||
"></TimeEntryMoreOptionsDropdown>
|
||||
</div>
|
||||
</div>
|
||||
</MainContainer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
calculateDifference,
|
||||
formatHumanReadableDuration,
|
||||
} from '@/packages/ui/src/utils/time';
|
||||
import { computed, defineProps, ref } from 'vue';
|
||||
import parse from 'parse-duration';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const props = defineProps<{
|
||||
start: string;
|
||||
end: string | null;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
changed: [start: string, end: string | null];
|
||||
}>();
|
||||
|
||||
const temporaryCustomTimerEntry = ref<string>('');
|
||||
|
||||
function updateTimerAndStartLiveTimerUpdate() {
|
||||
const time = parse(temporaryCustomTimerEntry.value, 's');
|
||||
if (time && time > 0) {
|
||||
let newEndDate = props.end;
|
||||
let newStartDate = props.start;
|
||||
if (props.end) {
|
||||
// only update end for time entries that are already finished
|
||||
newEndDate = dayjs(props.start).utc().add(time, 's').format();
|
||||
} else {
|
||||
newStartDate = dayjs().utc().subtract(time, 's').format();
|
||||
}
|
||||
emit('changed', newStartDate, newEndDate);
|
||||
}
|
||||
temporaryCustomTimerEntry.value = '';
|
||||
}
|
||||
|
||||
const currentTime = computed({
|
||||
get() {
|
||||
if (temporaryCustomTimerEntry.value !== '') {
|
||||
return temporaryCustomTimerEntry.value;
|
||||
}
|
||||
return formatHumanReadableDuration(
|
||||
calculateDifference(props.start, props.end)
|
||||
);
|
||||
},
|
||||
// setter
|
||||
set(newValue) {
|
||||
if (newValue) {
|
||||
temporaryCustomTimerEntry.value = newValue;
|
||||
} else {
|
||||
temporaryCustomTimerEntry.value = '';
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function selectInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
target.select();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
data-testid="time_entry_duration_input"
|
||||
class="text-white w-[100px] px-3 py-2 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-semibold"
|
||||
@focus="selectInput"
|
||||
@blur="updateTimerAndStartLiveTimerUpdate"
|
||||
@keydown.enter="updateTimerAndStartLiveTimerUpdate"
|
||||
v-model="currentTime" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import DaySectionHeader from '@/packages/ui/src/DaySectionHeader.vue';
|
||||
import MainContainer from '@/Pages/MainContainer.vue';
|
||||
defineProps<{
|
||||
date: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-card-background border-t border-b border-card-border py-1 lg:py-1.5 text-xs sm:text-sm">
|
||||
<MainContainer>
|
||||
<DaySectionHeader :date></DaySectionHeader>
|
||||
</MainContainer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||
import { computed } from 'vue';
|
||||
import TagBadge from '@/packages/ui/src/Tag/TagBadge.vue';
|
||||
import type { Tag } from '@/packages/api/src';
|
||||
|
||||
const props = defineProps<{
|
||||
tags: Tag[];
|
||||
createTag: (name: string) => Promise<Tag | undefined>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
changed: [model: string[]];
|
||||
}>();
|
||||
|
||||
const model = defineModel<string[]>({
|
||||
default: [],
|
||||
});
|
||||
|
||||
const timeEntryTags = computed<Tag[]>(() => {
|
||||
return props.tags.filter((tag) => model.value.includes(tag.id));
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<TagDropdown
|
||||
:tags="tags"
|
||||
align="bottom-end"
|
||||
:createTag
|
||||
@changed="emit('changed', model)"
|
||||
v-model="model">
|
||||
<template #trigger>
|
||||
<button
|
||||
data-testid="time_entry_tag_dropdown"
|
||||
class="opacity-50 group-hover:opacity-100 transition">
|
||||
<TagBadge
|
||||
:border="false"
|
||||
size="large"
|
||||
class="border-0"
|
||||
:name="
|
||||
timeEntryTags.map((tag: Tag) => tag.name).join(', ')
|
||||
"></TagBadge>
|
||||
</button>
|
||||
</template>
|
||||
</TagDropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
161
resources/js/packages/ui/src/TimeTracker/TimeTrackerControls.vue
Normal file
161
resources/js/packages/ui/src/TimeTracker/TimeTrackerControls.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
import TimeTrackerTagDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerTagDropdown.vue';
|
||||
import TimeTrackerStartStop from '@/packages/ui/src/TimeTrackerStartStop.vue';
|
||||
import TimeTrackerRangeSelector from '@/packages/ui/src/TimeTracker/TimeTrackerRangeSelector.vue';
|
||||
import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';
|
||||
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
|
||||
import type {
|
||||
CreateClientBody,
|
||||
CreateProjectBody,
|
||||
Project,
|
||||
Tag,
|
||||
Task,
|
||||
TimeEntry,
|
||||
Client,
|
||||
} from '@/packages/api/src';
|
||||
import { ref, watch } from 'vue';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
const currentTimeEntry = defineModel<TimeEntry>('currentTimeEntry', {
|
||||
required: true,
|
||||
});
|
||||
const liveTimer = defineModel<Dayjs | null>('liveTimer', { required: true });
|
||||
|
||||
const currentTimeEntryDescriptionInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const props = defineProps<{
|
||||
projects: Project[];
|
||||
tasks: Task[];
|
||||
tags: Tag[];
|
||||
clients: Client[];
|
||||
createTag: (name: string) => Promise<Tag | undefined>;
|
||||
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||
isActive: boolean;
|
||||
currency: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
startTimer: [];
|
||||
stopTimer: [];
|
||||
updateTimeEntry: [];
|
||||
startLiveTimer: [];
|
||||
stopLiveTimer: [];
|
||||
}>();
|
||||
|
||||
function updateProject() {
|
||||
setBillableDefaultForProject();
|
||||
emit('updateTimeEntry');
|
||||
}
|
||||
|
||||
function startTimerIfNotActive() {
|
||||
if (!props.isActive) {
|
||||
currentTimeEntry.value.description = tempDescription.value;
|
||||
emit('startTimer');
|
||||
}
|
||||
}
|
||||
|
||||
function setBillableDefaultForProject() {
|
||||
const project = props.projects.find(
|
||||
(project) => project.id === currentTimeEntry.value.project_id
|
||||
);
|
||||
if (project) {
|
||||
currentTimeEntry.value.billable = project.is_billable;
|
||||
}
|
||||
}
|
||||
|
||||
function onToggleButtonPress(newState: boolean) {
|
||||
if (newState) {
|
||||
emit('startTimer');
|
||||
currentTimeEntryDescriptionInput.value?.focus();
|
||||
} else {
|
||||
emit('stopTimer');
|
||||
}
|
||||
}
|
||||
const tempDescription = ref(currentTimeEntry.value.description);
|
||||
watch(
|
||||
() => currentTimeEntry.value.description,
|
||||
() => {
|
||||
tempDescription.value = currentTimeEntry.value.description;
|
||||
}
|
||||
);
|
||||
function updateTimeEntryDescription() {
|
||||
if (currentTimeEntry.value.description !== tempDescription.value) {
|
||||
currentTimeEntry.value.description = tempDescription.value;
|
||||
emit('updateTimeEntry');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center relative @container"
|
||||
data-testid="dashboard_timer">
|
||||
<div
|
||||
class="flex flex-col sm:flex-row w-full justify-between rounded-lg bg-card-background border-card-border border transition shadow-card">
|
||||
<div class="flex flex-1 items-center pr-6">
|
||||
<input
|
||||
placeholder="What are you working on?"
|
||||
data-testid="time_entry_description"
|
||||
ref="currentTimeEntryDescriptionInput"
|
||||
v-model="tempDescription"
|
||||
@keydown.enter="startTimerIfNotActive"
|
||||
@blur="updateTimeEntryDescription"
|
||||
class="w-full rounded-l-lg py-4 sm:py-2.5 px-3.5 border-b border-b-card-background-separator lg:px-4 text-base @4xl:text-lg text-white font-medium bg-transparent border-none placeholder-muted focus:ring-0 transition"
|
||||
type="text" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between pl-2 shrink min-w-0">
|
||||
<div
|
||||
class="flex items-center w-[130px] sm:w-auto shrink min-w-0">
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
:createClient
|
||||
:clients
|
||||
:createProject
|
||||
:currency="currency"
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
@changed="updateProject"
|
||||
v-model:project="currentTimeEntry.project_id"
|
||||
v-model:task="
|
||||
currentTimeEntry.task_id
|
||||
"></TimeTrackerProjectTaskDropdown>
|
||||
</div>
|
||||
<div class="flex items-center lg:space-x-2 px-2 lg:px-4">
|
||||
<TimeTrackerTagDropdown
|
||||
@changed="$emit('updateTimeEntry')"
|
||||
:createTag
|
||||
:tags="tags"
|
||||
v-model="
|
||||
currentTimeEntry.tags
|
||||
"></TimeTrackerTagDropdown>
|
||||
<BillableToggleButton
|
||||
@changed="$emit('updateTimeEntry')"
|
||||
v-model="
|
||||
currentTimeEntry.billable
|
||||
"></BillableToggleButton>
|
||||
</div>
|
||||
<div class="border-l border-card-border">
|
||||
<TimeTrackerRangeSelector
|
||||
@startLiveTimer="emit('startLiveTimer')"
|
||||
@stopLiveTimer="emit('stopLiveTimer')"
|
||||
@updateTimer="emit('updateTimeEntry')"
|
||||
@startTimer="emit('startTimer')"
|
||||
v-model:currentTimeEntry="currentTimeEntry"
|
||||
v-model:liveTimer="liveTimer"
|
||||
@keydown.enter="
|
||||
startTimerIfNotActive
|
||||
"></TimeTrackerRangeSelector>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="pl-4 lg:pl-6 pr-3 absolute sm:relative top-[6px] sm:top-0 right-0">
|
||||
<TimeTrackerStartStop
|
||||
:active="isActive"
|
||||
@changed="onToggleButtonPress"
|
||||
size="large"></TimeTrackerStartStop>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,433 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronRightIcon } from '@heroicons/vue/16/solid';
|
||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||
import { type Component, computed, nextTick, ref, watch } from 'vue';
|
||||
import ProjectDropdownItem from '@/packages/ui/src/Project/ProjectDropdownItem.vue';
|
||||
import type {
|
||||
CreateClientBody,
|
||||
CreateProjectBody,
|
||||
Project,
|
||||
Task,
|
||||
Client,
|
||||
} from '@/packages/api/src';
|
||||
import ProjectBadge from '@/packages/ui/src/Project/ProjectBadge.vue';
|
||||
import Badge from '@/packages/ui/src/Badge.vue';
|
||||
import { PlusIcon, PlusCircleIcon } from '@heroicons/vue/16/solid';
|
||||
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
||||
|
||||
const task = defineModel<string | null>('task', {
|
||||
default: null,
|
||||
});
|
||||
|
||||
const project = defineModel<string | null>('project', {
|
||||
default: null,
|
||||
});
|
||||
|
||||
const searchInput = ref<HTMLInputElement | null>(null);
|
||||
const open = ref(false);
|
||||
const dropdownViewport = ref<Component | null>(null);
|
||||
|
||||
const searchValue = ref('');
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
nextTick(() => {
|
||||
initializeHighlightedItem();
|
||||
searchInput.value?.focus();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type ProjectWithTasks = {
|
||||
project: Project;
|
||||
tasks: Task[];
|
||||
};
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
showBadgeBorder: boolean;
|
||||
size: 'base' | 'large' | 'xlarge';
|
||||
projects: Project[];
|
||||
tasks: Task[];
|
||||
clients: Client[];
|
||||
createProject: (
|
||||
project: CreateProjectBody
|
||||
) => Promise<Project | undefined>;
|
||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||
currency: string;
|
||||
}>(),
|
||||
{
|
||||
showBadgeBorder: true,
|
||||
size: 'large',
|
||||
}
|
||||
);
|
||||
|
||||
const filteredProjects = computed(() => {
|
||||
return props.projects.reduce(
|
||||
(filtered: ProjectWithTasks[], filterProject) => {
|
||||
const projectNameIncludesSearchTerm = filterProject.name
|
||||
.toLowerCase()
|
||||
.includes(searchValue.value?.toLowerCase()?.trim() || '');
|
||||
|
||||
// check if one of the project tasks
|
||||
const projectTasks = props.tasks.filter((task) => {
|
||||
return task.project_id === filterProject.id;
|
||||
});
|
||||
|
||||
const filteredTasks = projectTasks.filter((filterTask) => {
|
||||
return (
|
||||
filterTask.name
|
||||
.toLowerCase()
|
||||
.includes(
|
||||
searchValue.value?.toLowerCase()?.trim() || ''
|
||||
) &&
|
||||
(!filterTask.is_done || filterTask.id === task.value)
|
||||
);
|
||||
});
|
||||
|
||||
if (
|
||||
(projectNameIncludesSearchTerm || filteredTasks.length > 0) &&
|
||||
(!filterProject.is_archived ||
|
||||
project.value === filterProject.id)
|
||||
) {
|
||||
filtered.push({ project: filterProject, tasks: filteredTasks });
|
||||
}
|
||||
|
||||
return filtered;
|
||||
},
|
||||
[
|
||||
{
|
||||
project: {
|
||||
id: '',
|
||||
name: 'No Project',
|
||||
color: 'var(--theme-color-icon-default)',
|
||||
value: '',
|
||||
client_id: null,
|
||||
billable_rate: null,
|
||||
is_archived: false,
|
||||
is_billable: false,
|
||||
},
|
||||
tasks: [],
|
||||
},
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
async function addClientIfNoneExists() {
|
||||
setProjectAndClientBasedOnHighlightedItem();
|
||||
}
|
||||
|
||||
function isProjectSelected(project: Project) {
|
||||
return project.value === project.id;
|
||||
}
|
||||
|
||||
function initializeHighlightedItem() {
|
||||
if (filteredProjects.value.length > 0) {
|
||||
highlightedItemId.value = filteredProjects.value[0].project.id;
|
||||
}
|
||||
}
|
||||
|
||||
watch(filteredProjects, () => {
|
||||
initializeHighlightedItem();
|
||||
});
|
||||
|
||||
function setProjectAndClientBasedOnHighlightedItem() {
|
||||
const highlightedProject = filteredProjects.value.find(
|
||||
(project) => project.project.id === highlightedItemId.value
|
||||
);
|
||||
if (highlightedProject) {
|
||||
selectProject(highlightedProject.project.id);
|
||||
}
|
||||
const highlightedTask = filteredProjects.value
|
||||
.map((project) => project.tasks)
|
||||
.flat()
|
||||
.find((task) => task.id === highlightedItemId.value);
|
||||
if (highlightedTask) {
|
||||
selectTask(highlightedTask.id);
|
||||
}
|
||||
}
|
||||
|
||||
function updateSearchValue(event: Event) {
|
||||
const newInput = (event.target as HTMLInputElement).value;
|
||||
if (newInput === ' ') {
|
||||
searchValue.value = '';
|
||||
setProjectAndClientBasedOnHighlightedItem();
|
||||
} else {
|
||||
searchValue.value = newInput;
|
||||
}
|
||||
}
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'changed']);
|
||||
|
||||
function moveHighlightUp() {
|
||||
const currentHighlightedIndex = filteredProjects.value.findIndex(
|
||||
(projectWithTasks) =>
|
||||
projectWithTasks.project.id === highlightedItemId.value
|
||||
);
|
||||
// check if it is a project id
|
||||
if (currentHighlightedIndex === -1) {
|
||||
// the ID is a task ID
|
||||
const currentProjectWithTasks = filteredProjects.value.find(
|
||||
(projectWithTasks) =>
|
||||
projectWithTasks.tasks.some(
|
||||
(task) => task.id === highlightedItemId.value
|
||||
)
|
||||
);
|
||||
if (currentProjectWithTasks) {
|
||||
const taskIndex = currentProjectWithTasks.tasks.findIndex(
|
||||
(task) => task.id === highlightedItemId.value
|
||||
);
|
||||
if (taskIndex === -1) {
|
||||
return;
|
||||
}
|
||||
if (taskIndex === 0) {
|
||||
// highlight the project if it was the first task before
|
||||
highlightedItemId.value = currentProjectWithTasks.project.id;
|
||||
return;
|
||||
}
|
||||
highlightedItemId.value =
|
||||
currentProjectWithTasks.tasks[taskIndex - 1].id;
|
||||
}
|
||||
}
|
||||
if (currentHighlightedIndex === 0) {
|
||||
// highlight the last project or the last project of the last project
|
||||
const lastProject =
|
||||
filteredProjects.value[filteredProjects.value.length - 1];
|
||||
if (lastProject.tasks.length > 0) {
|
||||
// highlight last task of last project
|
||||
highlightedItemId.value =
|
||||
lastProject.tasks[lastProject.tasks.length - 1].id;
|
||||
} else {
|
||||
highlightedItemId.value =
|
||||
filteredProjects.value[
|
||||
filteredProjects.value.length - 1
|
||||
].project.id;
|
||||
}
|
||||
} else {
|
||||
const previousProject =
|
||||
filteredProjects.value[currentHighlightedIndex - 1];
|
||||
if (previousProject.tasks.length > 0) {
|
||||
// highlight last task of previous project
|
||||
highlightedItemId.value =
|
||||
previousProject.tasks[previousProject.tasks.length - 1].id;
|
||||
} else {
|
||||
highlightedItemId.value =
|
||||
filteredProjects.value[currentHighlightedIndex - 1].project.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function moveHighlightDown() {
|
||||
const currentHighlightedIndex = filteredProjects.value.findIndex(
|
||||
(projectWithTasks) =>
|
||||
projectWithTasks.project.id === highlightedItemId.value
|
||||
);
|
||||
// check if it is a project id
|
||||
if (currentHighlightedIndex === -1) {
|
||||
// the ID is a task ID
|
||||
const currentProjectWithTasks = filteredProjects.value.find(
|
||||
(projectWithTasks) =>
|
||||
projectWithTasks.tasks.some(
|
||||
(task) => task.id === highlightedItemId.value
|
||||
)
|
||||
);
|
||||
if (currentProjectWithTasks) {
|
||||
const taskIndex = currentProjectWithTasks.tasks.findIndex(
|
||||
(task) => task.id === highlightedItemId.value
|
||||
);
|
||||
if (taskIndex === -1) {
|
||||
return;
|
||||
}
|
||||
if (taskIndex === currentProjectWithTasks.tasks.length - 1) {
|
||||
// highlight the next project if it was the last task in current project
|
||||
const projectIndex = filteredProjects.value.indexOf(
|
||||
currentProjectWithTasks
|
||||
);
|
||||
if (projectIndex === filteredProjects.value.length - 1) {
|
||||
// highlight the first project if it was the last project
|
||||
highlightedItemId.value =
|
||||
filteredProjects.value[0].project.id;
|
||||
} else {
|
||||
highlightedItemId.value =
|
||||
filteredProjects.value[projectIndex + 1].project.id;
|
||||
}
|
||||
return;
|
||||
}
|
||||
highlightedItemId.value =
|
||||
currentProjectWithTasks.tasks[taskIndex + 1].id;
|
||||
}
|
||||
}
|
||||
if (currentHighlightedIndex === filteredProjects.value.length - 1) {
|
||||
// highlight the first project or the last project of the last project
|
||||
const lastProject =
|
||||
filteredProjects.value[filteredProjects.value.length - 1];
|
||||
if (lastProject.tasks.length > 0) {
|
||||
// highlight last task of last project
|
||||
highlightedItemId.value = lastProject.tasks[0].id;
|
||||
} else {
|
||||
highlightedItemId.value = filteredProjects.value[0].project.id;
|
||||
}
|
||||
} else {
|
||||
const currentProjectWithTasks =
|
||||
filteredProjects.value[currentHighlightedIndex];
|
||||
if (currentProjectWithTasks.tasks.length > 0) {
|
||||
// highlight last task of previous project
|
||||
highlightedItemId.value = currentProjectWithTasks.tasks[0].id;
|
||||
} else {
|
||||
highlightedItemId.value =
|
||||
filteredProjects.value[currentHighlightedIndex + 1].project.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const highlightedItemId = ref<string | null>(null);
|
||||
|
||||
const currentProject = computed(() => {
|
||||
return props.projects.find(
|
||||
(iteratingProject) => iteratingProject.id === project.value
|
||||
);
|
||||
});
|
||||
|
||||
const currentTask = computed(() => {
|
||||
return props.tasks.find(
|
||||
(iteratingTasks) => iteratingTasks.id === task.value
|
||||
);
|
||||
});
|
||||
|
||||
const selectedProjectName = computed(() => {
|
||||
return currentProject.value?.name || 'No Project';
|
||||
});
|
||||
|
||||
const selectedProjectColor = computed(() => {
|
||||
return currentProject.value?.color || 'var(--theme-color-icon-default)';
|
||||
});
|
||||
|
||||
function selectTask(taskId: string) {
|
||||
task.value = taskId;
|
||||
project.value =
|
||||
props.tasks.find((task) => task.id === taskId)?.project_id || null;
|
||||
open.value = false;
|
||||
emit('changed', project.value, task.value);
|
||||
}
|
||||
|
||||
function selectProject(projectId: string) {
|
||||
project.value = projectId;
|
||||
task.value = null;
|
||||
open.value = false;
|
||||
emit('changed', project.value, task.value);
|
||||
}
|
||||
|
||||
const showCreateProject = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="projects.length === 0">
|
||||
<Badge
|
||||
@click="showCreateProject = true"
|
||||
size="large"
|
||||
class="cursor-pointer hover:bg-tertiary">
|
||||
<PlusIcon class="-ml-1 w-5"></PlusIcon>
|
||||
<span>Add new project</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<Dropdown v-else v-model="open" :closeOnContentClick="false" align="bottom">
|
||||
<template #trigger>
|
||||
<ProjectBadge
|
||||
ref="projectDropdownTrigger"
|
||||
:color="selectedProjectColor"
|
||||
:size="size"
|
||||
:border="showBadgeBorder"
|
||||
tag="button"
|
||||
:name="selectedProjectName"
|
||||
class="focus:border-border-tertiary w-full focus:outline-0 focus:bg-card-background-separator min-w-0 hover:bg-card-background-separator">
|
||||
<div class="flex items-center lg:space-x-1 min-w-0">
|
||||
<span class="whitespace-nowrap text-xs lg:text-sm">
|
||||
{{ selectedProjectName }}
|
||||
</span>
|
||||
<ChevronRightIcon
|
||||
v-if="currentTask"
|
||||
class="w-4 lg:w-5 text-muted shrink-0"></ChevronRightIcon>
|
||||
<div
|
||||
class="min-w-0 shrink text-xs lg:text-sm truncate"
|
||||
v-if="currentTask">
|
||||
{{ currentTask.name }}
|
||||
</div>
|
||||
</div>
|
||||
</ProjectBadge>
|
||||
</template>
|
||||
<template #content>
|
||||
<input
|
||||
:value="searchValue"
|
||||
@input="updateSearchValue"
|
||||
@keydown.enter="addClientIfNoneExists"
|
||||
@click.prevent="searchInput?.focus()"
|
||||
data-testid="client_dropdown_search"
|
||||
@keydown.up.prevent="moveHighlightUp"
|
||||
@keydown.down.prevent="moveHighlightDown"
|
||||
ref="searchInput"
|
||||
class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
||||
placeholder="Search for a project or task..." />
|
||||
<div
|
||||
ref="dropdownViewport"
|
||||
class="min-w-[300px] max-h-[250px] overflow-y-scroll relative">
|
||||
<template
|
||||
v-for="projectWithTasks in filteredProjects"
|
||||
:key="projectWithTasks.project.id">
|
||||
<div
|
||||
role="option"
|
||||
:value="projectWithTasks.project.id"
|
||||
@click="selectProject(projectWithTasks.project.id)"
|
||||
class="border-t border-card-background-separator"
|
||||
:class="{
|
||||
'bg-card-background-active':
|
||||
projectWithTasks.project.id ===
|
||||
highlightedItemId,
|
||||
}"
|
||||
data-testid="client_dropdown_entries"
|
||||
:data-project-id="projectWithTasks.project.id">
|
||||
<ProjectDropdownItem
|
||||
:selected="
|
||||
isProjectSelected(projectWithTasks.project)
|
||||
"
|
||||
:name="projectWithTasks.project.name"
|
||||
:color="
|
||||
projectWithTasks.project.color
|
||||
"></ProjectDropdownItem>
|
||||
</div>
|
||||
<div
|
||||
v-for="task in projectWithTasks.tasks"
|
||||
:key="task.id"
|
||||
@click="selectTask(task.id)"
|
||||
:class="{
|
||||
'bg-card-background-active':
|
||||
task.id === highlightedItemId,
|
||||
}"
|
||||
class="flex items-center space-x-3 w-full px-3 py-1.5 text-start text-xs font-semibold leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
|
||||
<div class="w-3 h-3 rounded-full"></div>
|
||||
<span>{{ task.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="hover:bg-card-background-active rounded-b-lg">
|
||||
<button
|
||||
@click="
|
||||
open = false;
|
||||
showCreateProject = true;
|
||||
"
|
||||
class="text-white flex space-x-3 items-center px-4 py-3 text-xs font-semibold border-t border-card-background-separator">
|
||||
<PlusCircleIcon
|
||||
class="w-5 flex-shrink-0 text-icon-default"></PlusCircleIcon>
|
||||
<span>Create new Project</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
<ProjectCreateModal
|
||||
:createClient
|
||||
:currency="currency"
|
||||
:clients="clients"
|
||||
:createProject
|
||||
v-model:show="showCreateProject"></ProjectCreateModal>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import TimeRangeSelector from '@/packages/ui/src/Input/TimeRangeSelector.vue';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import parse from 'parse-duration';
|
||||
import { formatDuration, getDayJsInstance } from '@/packages/ui/src/utils/time';
|
||||
import type { TimeEntry } from '@/packages/api/src';
|
||||
|
||||
const currentTimeEntry = defineModel<TimeEntry>('currentTimeEntry', {
|
||||
required: true,
|
||||
});
|
||||
const now = defineModel<null | Dayjs>('liveTimer');
|
||||
|
||||
const emit = defineEmits<{
|
||||
startLiveTimer: [];
|
||||
stopLiveTimer: [];
|
||||
updateTimer: [];
|
||||
startTimer: [];
|
||||
}>();
|
||||
|
||||
const open = ref(false);
|
||||
|
||||
function pauseLiveTimerUpdate(event: FocusEvent) {
|
||||
(event.target as HTMLInputElement).select();
|
||||
emit('stopLiveTimer');
|
||||
}
|
||||
|
||||
function onTimeEntryEnterPress() {
|
||||
updateTimerAndStartLiveTimerUpdate();
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
activeElement?.blur();
|
||||
}
|
||||
|
||||
const currentTime = computed({
|
||||
get() {
|
||||
if (temporaryCustomTimerEntry.value !== '') {
|
||||
return temporaryCustomTimerEntry.value;
|
||||
}
|
||||
if (now.value && currentTimeEntry.value.start) {
|
||||
const startTime = dayjs(currentTimeEntry.value.start);
|
||||
const diff = now.value.diff(startTime, 'seconds');
|
||||
return formatDuration(diff);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
// setter
|
||||
set(newValue) {
|
||||
if (newValue) {
|
||||
temporaryCustomTimerEntry.value = newValue;
|
||||
} else {
|
||||
temporaryCustomTimerEntry.value = '';
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function updateTimerAndStartLiveTimerUpdate() {
|
||||
const time = parse(temporaryCustomTimerEntry.value, 's');
|
||||
|
||||
if (isNumeric(temporaryCustomTimerEntry.value)) {
|
||||
const newStartDate = dayjs().subtract(
|
||||
parseInt(temporaryCustomTimerEntry.value),
|
||||
'm'
|
||||
);
|
||||
currentTimeEntry.value.start = newStartDate.utc().format();
|
||||
if (currentTimeEntry.value.id !== '') {
|
||||
emit('updateTimer');
|
||||
} else {
|
||||
emit('startTimer');
|
||||
}
|
||||
} else if (isHHMM(temporaryCustomTimerEntry.value)) {
|
||||
const results = parseHHMM(temporaryCustomTimerEntry.value);
|
||||
if (results) {
|
||||
const newStartDate = dayjs()
|
||||
.subtract(parseInt(results[1]), 'h')
|
||||
.subtract(parseInt(results[2]), 'm');
|
||||
currentTimeEntry.value.start = newStartDate.utc().format();
|
||||
if (currentTimeEntry.value.id !== '') {
|
||||
emit('updateTimer');
|
||||
} else {
|
||||
emit('startTimer');
|
||||
}
|
||||
}
|
||||
}
|
||||
// try to parse natural language like "1h 30m"
|
||||
else if (time && time > 1) {
|
||||
const newStartDate = dayjs().subtract(time, 's');
|
||||
currentTimeEntry.value.start = newStartDate.utc().format();
|
||||
if (currentTimeEntry.value.id !== '') {
|
||||
emit('updateTimer');
|
||||
} else {
|
||||
emit('startTimer');
|
||||
}
|
||||
}
|
||||
// fallback to minutes if just a number is given
|
||||
now.value = dayjs().utc();
|
||||
temporaryCustomTimerEntry.value = '';
|
||||
emit('startLiveTimer');
|
||||
}
|
||||
|
||||
function isNumeric(value: string) {
|
||||
return /^-?\d+$/.test(value);
|
||||
}
|
||||
|
||||
const HHMMtimeRegex = /^([0-9]{1,2}):([0-5]?[0-9])$/;
|
||||
|
||||
function isHHMM(value: string): boolean {
|
||||
return HHMMtimeRegex.test(value);
|
||||
}
|
||||
|
||||
function parseHHMM(value: string): string[] | null {
|
||||
return value.match(HHMMtimeRegex);
|
||||
}
|
||||
const temporaryCustomTimerEntry = ref<string>('');
|
||||
|
||||
async function updateTimeRange(newStart: string) {
|
||||
// prohibit updates in the future
|
||||
if (getDayJsInstance()(newStart).isBefore(getDayJsInstance()())) {
|
||||
currentTimeEntry.value.start = newStart;
|
||||
if (currentTimeEntry.value.id) {
|
||||
emit('updateTimer');
|
||||
} else {
|
||||
emit('startTimer');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const startTime = computed(() => {
|
||||
if (currentTimeEntry.value.start && currentTimeEntry.value.start !== '') {
|
||||
return currentTimeEntry.value.start;
|
||||
}
|
||||
return dayjs().utc().format();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<Dropdown
|
||||
v-model="open"
|
||||
@submit="open = false"
|
||||
align="bottom"
|
||||
:close-on-content-click="false">
|
||||
<template #trigger>
|
||||
<input
|
||||
placeholder="00:00:00"
|
||||
@focus="pauseLiveTimerUpdate"
|
||||
data-testid="time_entry_time"
|
||||
@blur="updateTimerAndStartLiveTimerUpdate"
|
||||
@keydown.enter="onTimeEntryEnterPress"
|
||||
v-model="currentTime"
|
||||
class="w-[110px] lg:w-[130px] h-full text-white py-2.5 rounded-r-lg text-center px-4 text-base lg:text-lg font-bold bg-card-background border-none placeholder-muted focus:ring-0 transition"
|
||||
type="text" />
|
||||
</template>
|
||||
<template #content>
|
||||
<TimeRangeSelector
|
||||
@changed="updateTimeRange"
|
||||
:start="startTime"
|
||||
:end="null">
|
||||
</TimeRangeSelector>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style></style>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
|
||||
defineEmits<{
|
||||
switchOrganization: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute w-full h-full backdrop-blur-sm z-10 flex items-center justify-center">
|
||||
<div
|
||||
class="w-full h-[calc(100%+10px)] absolute bg-default-background opacity-75 backdrop-blur-sm"></div>
|
||||
<div class="flex space-x-3 items-center w-full z-20 justify-center">
|
||||
<span class="text-sm text-white">
|
||||
The Timer is running in a different organization.
|
||||
</span>
|
||||
<SecondaryButton @click="$emit('switchOrganization')"
|
||||
>Switch to organization</SecondaryButton
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { TagIcon } from '@heroicons/vue/20/solid';
|
||||
import { computed } from 'vue';
|
||||
import type { Tag } from '@/packages/api/src';
|
||||
|
||||
const emit = defineEmits<{
|
||||
changed: [];
|
||||
}>();
|
||||
|
||||
const model = defineModel({
|
||||
default: [],
|
||||
});
|
||||
const iconColorClasses = computed(() => {
|
||||
if (model.value.length > 0) {
|
||||
return 'text-accent-200/80 focus:text-accent-200 hover:text-accent-200';
|
||||
} else {
|
||||
return 'text-icon-default hover:text-icon-active focus:text-icon-active';
|
||||
}
|
||||
});
|
||||
defineProps<{
|
||||
tags: Tag[];
|
||||
createTag: (name: string) => Promise<Tag | undefined>;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TagDropdown
|
||||
:createTag
|
||||
@changed="emit('changed')"
|
||||
v-model="model"
|
||||
:tags="tags">
|
||||
<template #trigger>
|
||||
<button
|
||||
data-testid="tag_dropdown"
|
||||
:class="
|
||||
twMerge(
|
||||
iconColorClasses,
|
||||
'flex-shrink-0 ring-0 focus:outline-none focus:ring-0 transition focus-visible:bg-card-background-separator hover:bg-card-background-separator rounded-full w-11 h-11 flex items-center justify-center'
|
||||
)
|
||||
">
|
||||
<TagIcon class="w-5 h-5 lg:h-6 lg:w-6"></TagIcon>
|
||||
<span
|
||||
v-if="model.length > 1"
|
||||
class="font-extrabold absolute rounded-full text-xs w-3 h-3 block top-[15px] rotate-[45deg] right-[14px] text-card-background">
|
||||
{{ model.length }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</TagDropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
91
resources/js/packages/ui/src/TimeTrackerStartStop.vue
Normal file
91
resources/js/packages/ui/src/TimeTrackerStartStop.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const emit = defineEmits(['changed']);
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
size: 'base' | 'large' | 'small';
|
||||
active: boolean;
|
||||
}>(),
|
||||
{
|
||||
size: 'base',
|
||||
active: false,
|
||||
}
|
||||
);
|
||||
const buttonSizeClasses = {
|
||||
small: 'w-6 h-6 bg-accent-200/40 hover:bg-accent-300/70',
|
||||
base: 'w-8 h-8 bg-accent-200/40 hover:scale-110 hover:bg-accent-300/70 ring-accent-200/10 focus:ring-accent-200/10 hover:ring-4',
|
||||
large: 'w-11 h-11 ring-accent-200/10 focus:ring-accent-200/20 ring-4 sm:ring-8 hover:scale-110',
|
||||
};
|
||||
const iconClass = {
|
||||
small: 'w-2.5 h-2.5',
|
||||
base: 'w-3.5 h-3.5',
|
||||
large: 'w-4 h-4',
|
||||
};
|
||||
|
||||
const buttonColorClasses = computed(() => {
|
||||
if (props.active) {
|
||||
return 'bg-red-400/80 hover:bg-red-500/80 focus:bg-red-500/80';
|
||||
} else {
|
||||
return 'bg-accent-300/70 hover:bg-accent-400/70 focus:bg-accent-400/70';
|
||||
}
|
||||
});
|
||||
|
||||
function toggleState() {
|
||||
emit('changed', !props.active);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
@click="toggleState"
|
||||
data-testid="timer_button"
|
||||
:class="
|
||||
twMerge(
|
||||
buttonSizeClasses[size],
|
||||
buttonColorClasses,
|
||||
'flex items-center justify-center py-1 transition focus:outline-0 rounded-full text-white '
|
||||
)
|
||||
">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<svg
|
||||
v-if="props.active"
|
||||
:class="iconClass[size]"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M0.461426 2.74913C0.461426 1.48677 1.48666 0.461538 2.75076 0.461538H11.249C12.5131 0.461538 13.5383 1.48677 13.5383 2.75087V11.2491C13.5383 12.5132 12.5131 13.5385 11.249 13.5385H2.7525C2.4518 13.5387 2.154 13.4796 1.87614 13.3647C1.59828 13.2497 1.34582 13.0811 1.13319 12.8684C0.920559 12.6558 0.751936 12.4033 0.636968 12.1255C0.521999 11.8476 0.462941 11.5498 0.46317 11.2491V2.75262L0.461426 2.74913Z"
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
:class="iconClass[size]"
|
||||
viewBox="0 0 7 8"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M6.56167 3.18089C6.70764 3.26214 6.82926 3.38092 6.91393 3.52494C6.99859 3.66896 7.04324 3.83299 7.04324 4.00005C7.04324 4.16712 6.99859 4.33115 6.91393 4.47517C6.82926 4.61919 6.70764 4.73797 6.56167 4.81922L1.8925 7.41339C1.74982 7.49259 1.58895 7.53317 1.42578 7.53113C1.26261 7.52909 1.1028 7.48449 0.962147 7.40175C0.821497 7.31901 0.704879 7.20099 0.623826 7.05937C0.542772 6.91774 0.50009 6.7574 0.5 6.59422V1.40589C0.5 0.691721 1.2675 0.239221 1.8925 0.586721L6.56167 3.18089Z"
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
</Transition>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
68
resources/js/packages/ui/src/index.ts
Normal file
68
resources/js/packages/ui/src/index.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
getWeekStartSetting: () => string;
|
||||
getTimezoneSetting: () => string;
|
||||
}
|
||||
}
|
||||
|
||||
export * from './utils/money';
|
||||
export * from './utils/color';
|
||||
export * from './utils/random';
|
||||
export * from './utils/time';
|
||||
|
||||
export * from './Badge.vue';
|
||||
export * from './BillableRateModal.vue';
|
||||
export * from './CardTitle.vue';
|
||||
export * from './DaySectionHeader.vue';
|
||||
export * from './DialogModal.vue';
|
||||
export * from './GroupedItemsCountButton.vue';
|
||||
export * from './LoadingSpinner.vue';
|
||||
export * from './Modal.vue';
|
||||
export * from './TimeTrackerStartStop.vue';
|
||||
|
||||
export * from './TimeTracker/TimeTrackerControls.vue';
|
||||
export * from './TimeTracker/TimeTrackerProjectTaskDropdown.vue';
|
||||
export * from './TimeTracker/TimeTrackerRangeSelector.vue';
|
||||
export * from './TimeTracker/TimeTrackerRunningInDifferentOrganizationOverlay.vue';
|
||||
export * from './TimeTracker/TimeTrackerTagDropdown.vue';
|
||||
|
||||
export * from './TimeEntry/TimeEntryAggregateRow.vue';
|
||||
export * from './TimeEntry/TimeEntryDescriptionInput.vue';
|
||||
export * from './TimeEntry/TimeEntryGroupedTable.vue';
|
||||
export * from './TimeEntry/TimeEntryMoreOptionsDropdown.vue';
|
||||
export * from './TimeEntry/TimeEntryRangeSelector.vue';
|
||||
export * from './TimeEntry/TimeEntryRow.vue';
|
||||
export * from './TimeEntry/TimeEntryRowDurationInput.vue';
|
||||
export * from './TimeEntry/TimeEntryRowHeading.vue';
|
||||
export * from './TimeEntry/TimeEntryRowTagDropdown.vue';
|
||||
|
||||
export * from './Tag/TagBadge.vue';
|
||||
export * from './Tag/TagCreateModal.vue';
|
||||
export * from './Tag/TagDropdown.vue';
|
||||
|
||||
export * from './Project/ProjectBadge.vue';
|
||||
export * from './Project/ProjectBillableRateModal.vue';
|
||||
export * from './Project/ProjectBillableSelect.vue';
|
||||
export * from './Project/ProjectColorSelector.vue';
|
||||
export * from './Project/ProjectCreateModal.vue';
|
||||
|
||||
export * from './Input/BillableRateInput.vue';
|
||||
export * from './Input/BillableToggleButton.vue';
|
||||
export * from './Input/Checkbox.vue';
|
||||
export * from './Input/DatePicker.vue';
|
||||
export * from './Input/DateRangePicker.vue';
|
||||
export * from './Input/Dropdown.vue';
|
||||
export * from './Input/InputError.vue';
|
||||
export * from './Input/InputLabel.vue';
|
||||
export * from './Input/SelectDropdown.vue';
|
||||
export * from './Input/SelectDropdownItem.vue';
|
||||
export * from './Input/TextInput.vue';
|
||||
|
||||
export * from './Icons/BillableIcon.vue';
|
||||
|
||||
export * from './Client/ClientDropdown.vue';
|
||||
export * from './Client/ClientDropdownItem.vue';
|
||||
|
||||
export * from './Buttons/DangerButton.vue';
|
||||
export * from './Buttons/PrimaryButton.vue';
|
||||
export * from './Buttons/SecondaryButton.vue';
|
||||
33
resources/js/packages/ui/src/utils/color.ts
Normal file
33
resources/js/packages/ui/src/utils/color.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import Prando from '@/packages/ui/src/utils/random';
|
||||
|
||||
export const colors = [
|
||||
'#ef5350',
|
||||
'#ec407a',
|
||||
'#ab47bc',
|
||||
'#7e57c2',
|
||||
'#5c6bc0',
|
||||
'#42a5f5',
|
||||
'#29b6f6',
|
||||
'#26c6da',
|
||||
'#26a69a',
|
||||
'#66bb6a',
|
||||
'#9ccc65',
|
||||
'#d4e157',
|
||||
'#ffee58',
|
||||
'#ffca28',
|
||||
'#ffa726',
|
||||
'#ff7043',
|
||||
'#8d6e63',
|
||||
'#bdbdbd',
|
||||
'#78909c',
|
||||
];
|
||||
|
||||
export function getRandomColor() {
|
||||
return colors[Math.floor(Math.random() * colors.length)];
|
||||
}
|
||||
|
||||
export function getRandomColorWithSeed(seed: string) {
|
||||
const pseudoRandom = new Prando(seed);
|
||||
const index = pseudoRandom.nextInt(0, colors.length - 1);
|
||||
return colors[index];
|
||||
}
|
||||
22
resources/js/packages/ui/src/utils/money.ts
Normal file
22
resources/js/packages/ui/src/utils/money.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
function formatMoney(amount: number, currency: string) {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
export function formatCents(amount: number, currency: string) {
|
||||
return formatMoney(amount / 100, currency);
|
||||
}
|
||||
|
||||
export function getOrganizationCurrencySymbol(currency: string) {
|
||||
return (0)
|
||||
.toLocaleString('de-DE', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
})
|
||||
.replace(/\d/g, '')
|
||||
.trim();
|
||||
}
|
||||
201
resources/js/packages/ui/src/utils/random.ts
Normal file
201
resources/js/packages/ui/src/utils/random.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* This is a hardfork of Prando, a pseudo-random number generator.
|
||||
* @source https://github.com/zeh/prando
|
||||
*/
|
||||
|
||||
export default class Prando {
|
||||
private static readonly MIN: number = -2147483648; // Int32 min
|
||||
private static readonly MAX: number = 2147483647; // Int32 max
|
||||
|
||||
private _seed: number;
|
||||
private _value = NaN;
|
||||
|
||||
// ================================================================================================================
|
||||
// CONSTRUCTOR ----------------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate a new Prando pseudo-random number generator.
|
||||
*
|
||||
* @param seed - A number or string seed that determines which pseudo-random number sequence will be created. Defaults to a random seed based on `Math.random()`.
|
||||
*/
|
||||
constructor(seed?: number | string) {
|
||||
if (typeof seed === 'string') {
|
||||
// String seed
|
||||
this._seed = this.hashCode(seed);
|
||||
} else if (typeof seed === 'number') {
|
||||
// Numeric seed
|
||||
this._seed = this.getSafeSeed(seed);
|
||||
} else {
|
||||
// Pseudo-random seed
|
||||
this._seed = this.getSafeSeed(
|
||||
Prando.MIN +
|
||||
Math.floor((Prando.MAX - Prando.MIN) * Math.random())
|
||||
);
|
||||
}
|
||||
this.reset();
|
||||
}
|
||||
|
||||
// ================================================================================================================
|
||||
// PUBLIC INTERFACE -----------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generates a pseudo-random number between a lower (inclusive) and a higher (exclusive) bounds.
|
||||
*
|
||||
* @param min - The minimum number that can be randomly generated.
|
||||
* @param pseudoMax - The maximum number that can be randomly generated (exclusive).
|
||||
* @return The generated pseudo-random number.
|
||||
*/
|
||||
public next(min = 0, pseudoMax = 1): number {
|
||||
this.recalculate();
|
||||
return this.map(this._value, Prando.MIN, Prando.MAX, min, pseudoMax);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a pseudo-random integer number in a range (inclusive).
|
||||
*
|
||||
* @param min - The minimum number that can be randomly generated.
|
||||
* @param max - The maximum number that can be randomly generated.
|
||||
* @return The generated pseudo-random number.
|
||||
*/
|
||||
public nextInt(min = 10, max = 100): number {
|
||||
this.recalculate();
|
||||
return Math.floor(
|
||||
this.map(this._value, Prando.MIN, Prando.MAX, min, max + 1)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a pseudo-random string sequence of a particular length from a specific character range.
|
||||
*
|
||||
* Note: keep in mind that creating a random string sequence does not guarantee uniqueness; there is always a
|
||||
* 1 in (char_length^string_length) chance of collision. For real unique string ids, always check for
|
||||
* pre-existing ids, or employ a robust GUID/UUID generator.
|
||||
*
|
||||
* @param length - Length of the string to be generated.
|
||||
* @param chars - Characters that are used when creating the random string. Defaults to all alphanumeric chars (A-Z, a-z, 0-9).
|
||||
* @return The generated string sequence.
|
||||
*/
|
||||
public nextString(
|
||||
length = 16,
|
||||
chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
): string {
|
||||
let str = '';
|
||||
while (str.length < length) {
|
||||
str += this.nextChar(chars);
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a pseudo-random string of 1 character specific character range.
|
||||
*
|
||||
* @param chars - Characters that are used when creating the random string. Defaults to all alphanumeric chars (A-Z, a-z, 0-9).
|
||||
* @return The generated character.
|
||||
*/
|
||||
public nextChar(
|
||||
chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
): string {
|
||||
return chars.substr(this.nextInt(0, chars.length - 1), 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks a pseudo-random item from an array. The array is left unmodified.
|
||||
*
|
||||
* Note: keep in mind that while the returned item will be random enough, picking one item from the array at a time
|
||||
* does not guarantee nor imply that a sequence of random non-repeating items will be picked. If you want to
|
||||
* *pick items in a random order* from an array, instead of *pick one random item from an array*, it's best to
|
||||
* apply a *shuffle* transformation to the array instead, then read it linearly.
|
||||
*
|
||||
* @param array - Array of any type containing one or more candidates for random picking.
|
||||
* @return An item from the array.
|
||||
*/
|
||||
public nextArrayItem<T>(array: T[]): T {
|
||||
return array[this.nextInt(0, array.length - 1)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a pseudo-random boolean.
|
||||
*
|
||||
* @return A value of true or false.
|
||||
*/
|
||||
public nextBoolean(): boolean {
|
||||
this.recalculate();
|
||||
return this._value > 0.5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skips ahead in the sequence of numbers that are being generated. This is equivalent to
|
||||
* calling next() a specified number of times, but faster since it doesn't need to map the
|
||||
* new random numbers to a range and return it.
|
||||
*
|
||||
* @param iterations - The number of items to skip ahead.
|
||||
*/
|
||||
public skip(iterations = 1): void {
|
||||
while (iterations-- > 0) {
|
||||
this.recalculate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the pseudo-random number sequence back to its starting seed. Further calls to next()
|
||||
* will then produce the same sequence of numbers it had produced before. This is equivalent to
|
||||
* creating a new Prando instance with the same seed as another Prando instance.
|
||||
*
|
||||
* Example:
|
||||
* let rng = new Prando(12345678);
|
||||
* console.log(rng.next()); // 0.6177754114889017
|
||||
* console.log(rng.next()); // 0.5784605181725837
|
||||
* rng.reset();
|
||||
* console.log(rng.next()); // 0.6177754114889017 again
|
||||
* console.log(rng.next()); // 0.5784605181725837 again
|
||||
*/
|
||||
public reset(): void {
|
||||
this._value = this._seed;
|
||||
}
|
||||
|
||||
// ================================================================================================================
|
||||
// PRIVATE INTERFACE ----------------------------------------------------------------------------------------------
|
||||
|
||||
private recalculate(): void {
|
||||
this._value = this.xorshift(this._value);
|
||||
}
|
||||
|
||||
private xorshift(value: number): number {
|
||||
// Xorshift*32
|
||||
// Based on George Marsaglia's work: http://www.jstatsoft.org/v08/i14/paper
|
||||
value ^= value << 13;
|
||||
value ^= value >> 17;
|
||||
value ^= value << 5;
|
||||
return value;
|
||||
}
|
||||
|
||||
private map(
|
||||
val: number,
|
||||
minFrom: number,
|
||||
maxFrom: number,
|
||||
minTo: number,
|
||||
maxTo: number
|
||||
): number {
|
||||
return (
|
||||
((val - minFrom) / (maxFrom - minFrom)) * (maxTo - minTo) + minTo
|
||||
);
|
||||
}
|
||||
|
||||
private hashCode(str: string): number {
|
||||
let hash = 0;
|
||||
if (str) {
|
||||
const l = str.length;
|
||||
for (let i = 0; i < l; i++) {
|
||||
hash = (hash << 5) - hash + str.charCodeAt(i);
|
||||
hash |= 0;
|
||||
hash = this.xorshift(hash);
|
||||
}
|
||||
}
|
||||
return this.getSafeSeed(hash);
|
||||
}
|
||||
|
||||
private getSafeSeed(seed: number): number {
|
||||
if (seed === 0) return 1;
|
||||
return seed;
|
||||
}
|
||||
}
|
||||
111
resources/js/packages/ui/src/utils/time.ts
Normal file
111
resources/js/packages/ui/src/utils/time.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import isToday from 'dayjs/plugin/isToday';
|
||||
import isYesterday from 'dayjs/plugin/isYesterday';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import weekOfYear from 'dayjs/plugin/weekOfYear';
|
||||
import { getUserTimezone, getWeekStart } from '@/utils/useUser';
|
||||
import updateLocale from 'dayjs/plugin/updateLocale';
|
||||
import { computed } from 'vue';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.extend(isToday);
|
||||
dayjs.extend(isYesterday);
|
||||
dayjs.extend(duration);
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
dayjs.extend(updateLocale);
|
||||
dayjs.extend(weekOfYear);
|
||||
|
||||
export function getDayJsInstance() {
|
||||
dayjs.updateLocale('en', {
|
||||
weekStart: firstDayIndex.value,
|
||||
});
|
||||
return dayjs;
|
||||
}
|
||||
|
||||
export const firstDayIndex = computed(() => {
|
||||
const apiDayOrder = [
|
||||
'sunday',
|
||||
'monday',
|
||||
'tuesday',
|
||||
'wednesday',
|
||||
'thursday',
|
||||
'friday',
|
||||
'saturday',
|
||||
];
|
||||
return apiDayOrder.indexOf(getWeekStart());
|
||||
});
|
||||
|
||||
export function formatHumanReadableDuration(duration: number): string {
|
||||
const dayJsDuration = dayjs.duration(duration, 's');
|
||||
const hours = Math.floor(dayJsDuration.asHours());
|
||||
const minutes = dayJsDuration.minutes();
|
||||
return `${hours}h ${minutes.toString().padStart(2, '0')}min`;
|
||||
}
|
||||
|
||||
export function formatDuration(duration: number): string {
|
||||
const dayJsDuration = dayjs.duration(duration, 's');
|
||||
const hours = Math.floor(dayJsDuration.asHours());
|
||||
const minutes = dayJsDuration.minutes();
|
||||
const seconds = dayJsDuration.seconds();
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function calculateDifference(start: string, end: string | null) {
|
||||
if (end === null) {
|
||||
end = dayjs().utc().format();
|
||||
}
|
||||
return dayjs(end).diff(dayjs(start), 'second');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a formatted time.
|
||||
* @param date - A UTC date time string.
|
||||
*/
|
||||
export function formatTime(date: string) {
|
||||
return dayjs.utc(date).tz(getUserTimezone()).format('HH:mm');
|
||||
}
|
||||
|
||||
export function getLocalizedDayJs(timestamp: string | null) {
|
||||
return dayjs.utc(timestamp).tz(getUserTimezone());
|
||||
}
|
||||
|
||||
export function getLocalizedDateFromTimestamp(timestamp: string) {
|
||||
return getLocalizedDayJs(timestamp).format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
/*
|
||||
* Returns a formatted date.
|
||||
* @param date - date in the format of 'YYYY-MM-DD'
|
||||
*/
|
||||
export function formatDate(date: string): string {
|
||||
return dayjs(date).format('DD.MM.YYYY');
|
||||
}
|
||||
|
||||
export function formatWeek(date: string | null): string {
|
||||
return 'Week ' + getDayJsInstance()(date).week();
|
||||
}
|
||||
|
||||
/*
|
||||
* Returns a human readable date format.
|
||||
* @param date - date in the format of 'YYYY-MM-DD'
|
||||
*/
|
||||
export function formatHumanReadableDate(date: string) {
|
||||
if (dayjs(date).isToday()) {
|
||||
return 'Today';
|
||||
} else if (dayjs(date).isYesterday()) {
|
||||
return 'Yesterday';
|
||||
}
|
||||
return dayjs(date).fromNow();
|
||||
}
|
||||
|
||||
export function formatStartEnd(start: string, end: string | null) {
|
||||
if (end) {
|
||||
return `${formatTime(start)} - ${formatTime(end)}`;
|
||||
} else {
|
||||
return `${formatTime(start)} - ...`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user