refactor timetracker to seperate data and ui logic

This commit is contained in:
Gregor Vostrak
2024-07-22 17:56:51 +02:00
parent a9c874e540
commit 06fef6e40f
41 changed files with 1984 additions and 961 deletions

View File

@@ -30,14 +30,12 @@ const iconSizeClasses = computed(() => {
if (props.size === 'small') {
return 'w-5 h-5';
} else {
return 'w-5 sm:w-6 h-5 sm:h-6';
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-7 sm:w-10 h-7 sm:h-10';
props.size === 'small' ? 'w-6 sm:w-8 h-6 sm:h-8' : 'w-11 h-11';
</script>
<template>

View File

@@ -8,13 +8,13 @@ defineProps<{
</script>
<template>
<div class="flex w-full items-center justify-between pb-2.5 sm:pb-4">
<div class="flex w-full items-center justify-between pb-2.5 lg:pb-4">
<h3
class="text-white font-bold text-base flex items-center space-x-2.5">
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-6 text-icon-default"></component>
class="w-5 lg:w-6 text-icon-default"></component>
<span>
{{ title }}
</span>

View File

@@ -2,17 +2,18 @@
import { PlusCircleIcon } from '@heroicons/vue/20/solid';
import Dropdown from '@/Components/Dropdown.vue';
import { type Component, computed, nextTick, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useClientsStore } from '@/utils/useClients';
import ClientDropdownItem from '@/Components/Common/Client/ClientDropdownItem.vue';
const clientsStore = useClientsStore();
const { clients } = storeToRefs(clientsStore);
import type { CreateClientBody, Client } from '@/utils/api';
const model = defineModel<string | null>({
default: null,
});
const props = defineProps<{
clients: Client[];
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
}>();
const searchInput = ref<HTMLInputElement | null>(null);
const open = ref(false);
const dropdownViewport = ref<Component | null>(null);
@@ -32,7 +33,7 @@ watch(open, (isOpen) => {
});
const filteredClients = computed(() => {
return clients.value.filter((client) => {
return props.clients.filter((client) => {
return client.name
.toLowerCase()
.includes(searchValue.value?.toLowerCase()?.trim() || '');
@@ -41,7 +42,7 @@ const filteredClients = computed(() => {
async function addClientIfNoneExists() {
if (searchValue.value.length > 0 && filteredClients.value.length === 0) {
const newClient = await clientsStore.createClient({
const newClient = await props.createClient({
name: searchValue.value,
});
if (newClient) {
@@ -67,7 +68,7 @@ function updateSearchValue(event: Event) {
searchValue.value = '';
const highlightedClientId = highlightedItemId.value;
if (highlightedClientId) {
const highlightedClient = clients.value.find(
const highlightedClient = props.clients.find(
(client) => client.id === highlightedClientId
);
if (highlightedClient) {
@@ -119,7 +120,7 @@ function moveHighlightDown() {
const highlightedItemId = ref<string | null>(null);
const highlightedItem = computed(() => {
return clients.value.find(
return props.clients.find(
(client) => client.id === highlightedItemId.value
);
});

View File

@@ -18,15 +18,12 @@ async function deleteInvitation() {
if (organizationId) {
await handleApiRequestNotifications(
() =>
api.removeInvitation(
{},
{
params: {
invitation: props.invitation.id,
organization: organizationId,
},
}
),
api.removeInvitation(undefined, {
params: {
invitation: props.invitation.id,
organization: organizationId,
},
}),
'Invitation removed successfully',
'Error removing invitation',
() => {

View File

@@ -32,9 +32,12 @@ const indicatorClasses = {
<div
:style="{ backgroundColor: props.color }"
:class="
twMerge(indicatorClasses[size], 'inline-block rounded-full')
twMerge(
indicatorClasses[size],
'inline-block rounded-full shrink-0'
)
"></div>
<div>
<div class="min-w-0">
<slot>
{{ name }}
</slot>

View File

@@ -3,7 +3,7 @@ import TextInput from '@/Components/TextInput.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import DialogModal from '@/Components/DialogModal.vue';
import { computed, ref } from 'vue';
import type { CreateProjectBody } from '@/utils/api';
import type { CreateClientBody, CreateProjectBody, Project } from '@/utils/api';
import { getRandomColor } from '@/utils/color';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import { useFocus } from '@vueuse/core';
@@ -20,10 +20,8 @@ const saving = ref(false);
const props = defineProps<{
clients: Client[];
}>();
const emit = defineEmits<{
submit: [project: CreateProjectBody, callback: () => void];
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
}>();
const project = ref<CreateProjectBody>({
@@ -35,16 +33,15 @@ const project = ref<CreateProjectBody>({
});
async function submit() {
emit('submit', project.value, () => {
show.value = false;
project.value = {
name: '',
color: getRandomColor(),
client_id: null,
billable_rate: null,
is_billable: false,
};
});
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);
@@ -96,7 +93,11 @@ const currentClientName = computed(() => {
</div>
<div>
<InputLabel for="client" value="Client" />
<ClientDropdown class="mt-2" v-model="project.client_id">
<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"

View File

@@ -3,7 +3,7 @@ import TextInput from '@/Components/TextInput.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import DialogModal from '@/Components/DialogModal.vue';
import { computed, ref } from 'vue';
import type { CreateProjectBody, Project } from '@/utils/api';
import type { CreateClientBody, CreateProjectBody, Project } from '@/utils/api';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import { useProjectsStore } from '@/utils/useProjects';
import { useFocus } from '@vueuse/core';
@@ -26,6 +26,10 @@ const props = defineProps<{
originalProject: Project;
}>();
async function createClient(body: CreateClientBody) {
return await useClientsStore().createClient(body);
}
const project = ref<CreateProjectBody>({
name: props.originalProject.name,
color: props.originalProject.color,
@@ -97,7 +101,11 @@ async function submitBillableRate() {
</div>
<div class="">
<InputLabel for="client" value="Client" />
<ClientDropdown class="mt-1" v-model="project.client_id">
<ClientDropdown
:createClient
:clients="clients"
class="mt-1"
v-model="project.client_id">
<template #trigger>
<Badge
class="bg-input-background cursor-pointer hover:bg-tertiary"

View File

@@ -7,7 +7,12 @@ import ProjectCreateModal from '@/Components/Common/Project/ProjectCreateModal.v
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
import { canCreateProjects } from '@/utils/permissions';
import type { CreateProjectBody, Project } from '@/utils/api';
import type {
CreateProjectBody,
Project,
Client,
CreateClientBody,
} from '@/utils/api';
import { useProjectsStore } from '@/utils/useProjects';
import { useClientsStore } from '@/utils/useClients';
import { storeToRefs } from 'pinia';
@@ -17,17 +22,25 @@ defineProps<{
}>();
const showCreateProjectModal = ref(false);
async function createProject(project: CreateProjectBody, callback: () => void) {
await useProjectsStore().createProject(project);
callback();
async function createProject(
project: CreateProjectBody
): Promise<Project | undefined> {
return await useProjectsStore().createProject(project);
}
async function createClient(
client: CreateClientBody
): Promise<Client | undefined> {
return await useClientsStore().createClient(client);
}
const { clients } = storeToRefs(useClientsStore());
</script>
<template>
<ProjectCreateModal
:createProject
:createClient
:clients="clients"
@submit="createProject"
v-model:show="showCreateProjectModal"></ProjectCreateModal>
<div class="flow-root max-w-[100vw] overflow-x-auto">
<div class="inline-block min-w-full align-middle">

View File

@@ -3,16 +3,23 @@ import Dropdown from '@/Components/Dropdown.vue';
import { type Component, computed, ref, watch } from 'vue';
import SelectDropdownItem from '@/Components/Common/SelectDropdownItem.vue';
import { onKeyStroke } from '@vueuse/core';
import { type Placement } from '@floating-ui/vue';
const model = defineModel<string | null>({
default: null,
});
const props = defineProps<{
items: T[];
getKeyFromItem: (item: T) => string | null;
getNameForItem: (item: T) => string;
}>();
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);
@@ -113,7 +120,7 @@ watch(open, () => {
</script>
<template>
<Dropdown v-model="open" align="bottom-start" :closeOnContentClick="false">
<Dropdown v-model="open" :align="align" :closeOnContentClick="false">
<template #trigger>
<slot name="trigger"> </slot>
</template>

View File

@@ -29,7 +29,7 @@ function addOrRemoveTagFromSelection(id: string) {
if (model.value.includes(id)) {
model.value = model.value.filter((tagId) => tagId !== id);
} else {
model.value.push(id);
model.value = [...model.value, id];
}
emit('changed');
}

View File

@@ -1,9 +1,16 @@
<script setup lang="ts">
import MainContainer from '@/Pages/MainContainer.vue';
import TimeTrackerStartStop from '@/Components/Common/TimeTrackerStartStop.vue';
import type { Project, Tag, Task, TimeEntry } from '@/utils/api';
import type {
CreateClientBody,
CreateProjectBody,
Project,
Tag,
Task,
TimeEntry,
Client,
} from '@/utils/api';
import TimeEntryDescriptionInput from '@/Components/Common/TimeEntry/TimeEntryDescriptionInput.vue';
import { type TimeEntriesGroupedByType } from '@/utils/useTimeEntries';
import TimeEntryRowTagDropdown from '@/Components/Common/TimeEntry/TimeEntryRowTagDropdown.vue';
import TimeEntryMoreOptionsDropdown from '@/Components/Common/TimeEntry/TimeEntryMoreOptionsDropdown.vue';
import TimeTrackerProjectTaskDropdown from '@/Components/Common/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
@@ -15,13 +22,17 @@ import {
} from '../../../utils/time';
import TimeEntryRow from '@/Components/Common/TimeEntry/TimeEntryRow.vue';
import GroupedItemsCountButton from '@/Components/Common/GroupedItemsCountButton.vue';
import type { TimeEntriesGroupedByType } from '@/types/time-entries';
const props = defineProps<{
timeEntry: TimeEntriesGroupedByType;
projects: Project[];
tasks: Task[];
tags: Tag[];
clients: Client[];
createTag: (name: string) => Promise<Tag | undefined>;
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
onStartStopClick: (timeEntry: TimeEntry) => void;
updateTimeEntries: (timeEntries: TimeEntry[]) => void;
deleteTimeEntries: (timeEntries: TimeEntry[]) => void;
@@ -64,7 +75,7 @@ const expanded = ref(false);
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">
<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" />
@@ -81,6 +92,9 @@ const expanded = ref(false);
"></TimeEntryDescriptionInput>
</div>
<TimeTrackerProjectTaskDropdown
:clients
:createProject
:createClient
:projects="projects"
:tasks="tasks"
:showBadgeBorder="false"
@@ -134,6 +148,9 @@ const expanded = ref(false);
<TimeEntryRow
:projects="projects"
:tasks="tasks"
:createClient
:clients
:createProject
:tags="tags"
indent
:updateTimeEntry="(arg) => updateTimeEntries([arg])"

View File

@@ -17,15 +17,36 @@ import { storeToRefs } from 'pinia';
import { useTasksStore } from '@/utils/useTasks';
import { useProjectsStore } from '@/utils/useProjects';
import { useTagsStore } from '@/utils/useTags';
import type {
CreateClientBody,
CreateProjectBody,
Project,
Client,
} from '@/utils/api';
import { useClientsStore } from '@/utils/useClients';
const projectStore = useProjectsStore();
const { projects } = storeToRefs(projectStore);
const taskStore = useTasksStore();
const { tasks } = storeToRefs(taskStore);
const clientStore = useClientsStore();
const { clients } = storeToRefs(clientStore);
const { createTimeEntry } = useTimeEntriesStore();
const show = defineModel('show', { default: false });
const saving = ref(false);
async function createProject(
project: CreateProjectBody
): Promise<Project | undefined> {
return await useProjectsStore().createProject(project);
}
async function createClient(
body: CreateClientBody
): Promise<Client | undefined> {
return await useClientsStore().createClient(body);
}
const description = ref<HTMLInputElement | null>(null);
watch(show, (value) => {
@@ -102,6 +123,9 @@ async function createTag(tag: string) {
<div class="flex items-center justify-between">
<div>
<TimeTrackerProjectTaskDropdown
:clients
:createProject
:createClient
class="mt-1"
size="xlarge"
:projects="projects"

View File

@@ -1,29 +1,35 @@
<script setup lang="ts">
import { computed } from 'vue';
import type {
CreateClientBody,
CreateProjectBody,
CreateTimeEntryBody,
Project,
Tag,
Task,
TimeEntry,
Client,
} from '@/utils/api';
import { getDayJsInstance, getLocalizedDateFromTimestamp } from '@/utils/time';
import type { TimeEntriesGroupedByType } from '@/utils/useTimeEntries';
import TimeEntryAggregateRow from '@/Components/Common/TimeEntry/TimeEntryAggregateRow.vue';
import TimeEntryRowHeading from '@/Components/Common/TimeEntry/TimeEntryRowHeading.vue';
import TimeEntryRow from '@/Components/Common/TimeEntry/TimeEntryRow.vue';
import dayjs from 'dayjs';
import type { TimeEntriesGroupedByType } from '@/types/time-entries';
const props = defineProps<{
timeEntries: TimeEntry[];
projects: Project[];
tasks: Task[];
tags: Tag[];
clients: Client[];
createTag: (name: string) => Promise<Tag | undefined>;
updateTimeEntry: (entry: TimeEntry) => void;
updateTimeEntries: (entries: TimeEntry[]) => void;
deleteTimeEntries: (entries: TimeEntry[]) => void;
createTimeEntry: (entry: Omit<CreateTimeEntryBody, 'member_id'>) => void;
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
}>();
const groupedTimeEntries = computed(() => {
@@ -109,9 +115,12 @@ function startTimeEntryFromExisting(entry: TimeEntry) {
<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
@@ -119,9 +128,12 @@ function startTimeEntryFromExisting(entry: TimeEntry) {
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)"

View File

@@ -2,7 +2,15 @@
import MainContainer from '@/Pages/MainContainer.vue';
import TimeTrackerStartStop from '@/Components/Common/TimeTrackerStartStop.vue';
import TimeEntryRangeSelector from '@/Components/Common/TimeEntry/TimeEntryRangeSelector.vue';
import type { Project, Tag, Task, TimeEntry } from '@/utils/api';
import type {
Client,
CreateClientBody,
CreateProjectBody,
Project,
Tag,
Task,
TimeEntry,
} from '@/utils/api';
import TimeEntryDescriptionInput from '@/Components/Common/TimeEntry/TimeEntryDescriptionInput.vue';
import TimeEntryRowTagDropdown from '@/Components/Common/TimeEntry/TimeEntryRowTagDropdown.vue';
import TimeEntryRowDurationInput from '@/Components/Common/TimeEntry/TimeEntryRowDurationInput.vue';
@@ -16,7 +24,10 @@ 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>;
onStartStopClick: () => void;
deleteTimeEntry: () => void;
updateTimeEntry: (timeEntry: TimeEntry) => void;
@@ -54,7 +65,7 @@ function updateProjectAndTask(projectId: string, taskId: string) {
<MainContainer>
<div
class="sm:flex py-1 lg:py-1.5 items-center justify-between group">
<div class="flex space-x-1 items-center">
<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" />
@@ -66,6 +77,9 @@ function updateProjectAndTask(projectId: string, taskId: string) {
timeEntry.description
"></TimeEntryDescriptionInput>
<TimeTrackerProjectTaskDropdown
:createProject
:createClient
:clients
:projects="projects"
:tasks="tasks"
:showBadgeBorder="false"

View File

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

View File

@@ -3,14 +3,17 @@ import { ChevronRightIcon } from '@heroicons/vue/16/solid';
import Dropdown from '@/Components/Dropdown.vue';
import { type Component, computed, nextTick, ref, watch } from 'vue';
import ProjectDropdownItem from '@/Components/Common/Project/ProjectDropdownItem.vue';
import type { CreateProjectBody, Project, Task } from '@/utils/api';
import type {
CreateClientBody,
CreateProjectBody,
Project,
Task,
Client,
} from '@/utils/api';
import ProjectBadge from '@/Components/Common/Project/ProjectBadge.vue';
import Badge from '@/Components/Common/Badge.vue';
import { PlusIcon, PlusCircleIcon } from '@heroicons/vue/16/solid';
import ProjectCreateModal from '@/Components/Common/Project/ProjectCreateModal.vue';
import { useClientsStore } from '@/utils/useClients';
import { storeToRefs } from 'pinia';
import { useProjectsStore } from '@/utils/useProjects';
const task = defineModel<string | null>('task', {
default: null,
@@ -46,6 +49,11 @@ const props = withDefaults(
size: 'base' | 'large' | 'xlarge';
projects: Project[];
tasks: Task[];
clients: Client[];
createProject: (
project: CreateProjectBody
) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
}>(),
{
showBadgeBorder: true,
@@ -309,14 +317,6 @@ function selectProject(projectId: string) {
}
const showCreateProject = ref(false);
const clientsStore = useClientsStore();
const { clients } = storeToRefs(clientsStore);
async function createProject(project: CreateProjectBody, callback: () => void) {
await useProjectsStore().createProject(project);
callback();
}
</script>
<template>
@@ -338,15 +338,19 @@ async function createProject(project: CreateProjectBody, callback: () => void) {
:border="showBadgeBorder"
tag="button"
:name="selectedProjectName"
class="focus:border-border-tertiary focus:outline-0 focus:bg-card-background-separator hover:bg-card-background-separator">
<div class="flex nowrap items-center space-x-1">
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-5 text-muted"></ChevronRightIcon>
<span v-if="currentTask">{{ currentTask.name }}</span>
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>
@@ -418,8 +422,9 @@ async function createProject(project: CreateProjectBody, callback: () => void) {
</template>
</Dropdown>
<ProjectCreateModal
:createClient
:clients="clients"
@submit="createProject"
:createProject
v-model:show="showCreateProject"></ProjectCreateModal>
</template>

View File

@@ -2,23 +2,28 @@
import Dropdown from '@/Components/Dropdown.vue';
import { computed, ref } from 'vue';
import TimeRangeSelector from '@/Components/Common/TimeRangeSelector.vue';
import dayjs from 'dayjs';
import dayjs, { Dayjs } from 'dayjs';
import parse from 'parse-duration';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { storeToRefs } from 'pinia';
import { formatDuration, getDayJsInstance } from '@/utils/time';
const currentTimeEntryStore = useCurrentTimeEntryStore();
const { startLiveTimer, stopLiveTimer, updateTimer, startTimer } =
currentTimeEntryStore;
const { currentTimeEntry, now } = storeToRefs(currentTimeEntryStore);
import type { TimeEntry } from '@/utils/api';
defineEmits(['changed']);
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();
stopLiveTimer();
emit('stopLiveTimer');
}
function onTimeEntryEnterPress() {
@@ -59,9 +64,9 @@ function updateTimerAndStartLiveTimerUpdate() {
);
currentTimeEntry.value.start = newStartDate.utc().format();
if (currentTimeEntry.value.id !== '') {
currentTimeEntryStore.updateTimer();
emit('updateTimer');
} else {
currentTimeEntryStore.startTimer();
emit('startTimer');
}
} else if (isHHMM(temporaryCustomTimerEntry.value)) {
const results = parseHHMM(temporaryCustomTimerEntry.value);
@@ -71,9 +76,9 @@ function updateTimerAndStartLiveTimerUpdate() {
.subtract(parseInt(results[2]), 'm');
currentTimeEntry.value.start = newStartDate.utc().format();
if (currentTimeEntry.value.id !== '') {
currentTimeEntryStore.updateTimer();
emit('updateTimer');
} else {
currentTimeEntryStore.startTimer();
emit('startTimer');
}
}
}
@@ -82,15 +87,15 @@ function updateTimerAndStartLiveTimerUpdate() {
const newStartDate = dayjs().subtract(time, 's');
currentTimeEntry.value.start = newStartDate.utc().format();
if (currentTimeEntry.value.id !== '') {
currentTimeEntryStore.updateTimer();
emit('updateTimer');
} else {
currentTimeEntryStore.startTimer();
emit('startTimer');
}
}
// fallback to minutes if just a number is given
now.value = dayjs().utc();
temporaryCustomTimerEntry.value = '';
startLiveTimer();
emit('startLiveTimer');
}
function isNumeric(value: string) {
@@ -113,9 +118,9 @@ async function updateTimeRange(newStart: string) {
if (getDayJsInstance()(newStart).isBefore(getDayJsInstance()())) {
currentTimeEntry.value.start = newStart;
if (currentTimeEntry.value.id) {
await updateTimer();
emit('updateTimer');
} else {
await startTimer();
emit('startTimer');
}
}
}
@@ -143,7 +148,7 @@ const startTime = computed(() => {
@blur="updateTimerAndStartLiveTimerUpdate"
@keydown.enter="onTimeEntryEnterPress"
v-model="currentTime"
class="w-[110px] sm:w-[130px] h-full text-white py-2.5 rounded-r-lg text-center px-4 text-sm sm:text-lg font-bold bg-card-background border-none placeholder-muted focus:ring-0 transition"
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>

View File

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

View File

@@ -37,10 +37,10 @@ defineProps<{
: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-7 sm:w-10 h-7 sm:h-10 flex items-center justify-center'
'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 sm:w-6 h-5 sm:h-6"></TagIcon>
<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">

View File

@@ -6,7 +6,7 @@ const emit = defineEmits(['changed']);
const props = withDefaults(
defineProps<{
size: 'base' | 'large';
size: 'base' | 'large' | 'small';
active: boolean;
}>(),
{
@@ -15,10 +15,12 @@ const props = withDefaults(
}
);
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',
};

View File

@@ -1,6 +1,11 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { flip, type Placement, useFloating } from '@floating-ui/vue';
import {
flip,
type Placement,
type ReferenceElement,
useFloating,
} from '@floating-ui/vue';
import { offset } from '@floating-ui/vue';
import { autoUpdate } from '@floating-ui/vue';
@@ -49,7 +54,7 @@ function onBackgroundClick() {
open.value = false;
}
const reference = ref(null);
const reference = ref<null | ReferenceElement>(null);
const floating = ref(null);
const { floatingStyles } = useFloating(reference, floating, {
placement: props.align,
@@ -59,37 +64,36 @@ const { floatingStyles } = useFloating(reference, floating, {
</script>
<template>
<div>
<div @click.prevent="toggleOpen" ref="reference">
<div class="min-w-0">
<div @click.prevent="toggleOpen" ref="reference" class="min-w-0">
<slot name="trigger" />
</div>
<!-- Full Screen Dropdown Overlay -->
<div
v-show="open"
class="fixed inset-0 z-40"
@click.prevent="onBackgroundClick" />
<Teleport to="body">
<div
v-show="open"
ref="floating"
class="z-50"
:style="floatingStyles"
@click="onContentClick">
<transition
enter-active-class="transition ease-out duration-200"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95">
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
v-if="open"
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>
</transition>
</div>
</div>
</transition>
</Teleport>
</div>
</template>

View File

@@ -1,26 +1,24 @@
<script setup lang="ts">
import { ClockIcon } from '@heroicons/vue/20/solid';
import CardTitle from '@/Components/Common/CardTitle.vue';
import BillableToggleButton from '@/Components/Common/BillableToggleButton.vue';
import TimeTrackerStartStop from '@/Components/Common/TimeTrackerStartStop.vue';
import { usePage } from '@inertiajs/vue3';
import { type User } from '@/types/models';
import { computed, onMounted, ref, watch } from 'vue';
import { computed, onMounted, watch } from 'vue';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import duration from 'dayjs/plugin/duration';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { storeToRefs } from 'pinia';
import TimeTrackerTagDropdown from '@/Components/Common/TimeTracker/TimeTrackerTagDropdown.vue';
import TimeTrackerProjectTaskDropdown from '@/Components/Common/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { switchOrganization } from '@/utils/useOrganization';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import TimeTrackerRangeSelector from '@/Components/Common/TimeTracker/TimeTrackerRangeSelector.vue';
import { useProjectsStore } from '@/utils/useProjects';
import { useTasksStore } from '@/utils/useTasks';
import { useTagsStore } from '@/utils/useTags';
import TimeTrackerControls from '@/Components/Common/TimeTracker/TimeTrackerControls.vue';
import type { CreateClientBody, CreateProjectBody, Project } from '@/utils/api';
import TimeTrackerRunningInDifferentOrganizationOverlay from '@/Components/Common/TimeTracker/TimeTrackerRunningInDifferentOrganizationOverlay.vue';
import { useClientsStore } from '@/utils/useClients';
const page = usePage<{
auth: {
@@ -34,12 +32,13 @@ dayjs.extend(utc);
const currentTimeEntryStore = useCurrentTimeEntryStore();
const { currentTimeEntry, isActive, now } = storeToRefs(currentTimeEntryStore);
const { startLiveTimer, stopLiveTimer, setActiveState } = currentTimeEntryStore;
const currentTimeEntryDescriptionInput = ref<HTMLInputElement | null>(null);
const projectStore = useProjectsStore();
const { projects } = storeToRefs(projectStore);
const taskStore = useTasksStore();
const { tasks } = storeToRefs(taskStore);
const clientStore = useClientsStore();
const { clients } = storeToRefs(clientStore);
watch(isActive, () => {
if (isActive.value) {
@@ -56,41 +55,12 @@ onMounted(async () => {
}
});
function setBillableDefaultForProject() {
const projectssStore = useProjectsStore();
const { projects } = storeToRefs(projectssStore);
const project = projects.value.find(
(project) => project.id === currentTimeEntry.value.project_id
);
if (project) {
currentTimeEntry.value.billable = project.is_billable;
}
}
function updateProject() {
setBillableDefaultForProject();
updateTimeEntry();
}
function updateTimeEntry() {
if (currentTimeEntry.value.id) {
useCurrentTimeEntryStore().updateTimer();
}
}
function onToggleButtonPress(newState: boolean) {
setActiveState(newState);
if (newState) {
currentTimeEntryDescriptionInput.value?.focus();
}
}
function startTimerIfNotActive() {
if (!isActive.value) {
setActiveState(true);
}
}
const isRunningInDifferentOrganization = computed(() => {
return (
currentTimeEntry.value.organization_id &&
@@ -99,6 +69,15 @@ const isRunningInDifferentOrganization = computed(() => {
);
});
async function createProject(
project: CreateProjectBody
): Promise<Project | undefined> {
return await useProjectsStore().createProject(project);
}
async function createClient(client: CreateClientBody) {
return await useClientsStore().createClient(client);
}
function switchToTimeEntryOrganization() {
if (currentTimeEntry.value.organization_id) {
switchOrganization(currentTimeEntry.value.organization_id);
@@ -114,74 +93,27 @@ const { tags } = storeToRefs(useTagsStore());
<template>
<CardTitle title="Time Tracker" :icon="ClockIcon"></CardTitle>
<div class="relative">
<div
class="absolute w-full h-full backdrop-blur-sm z-10 flex items-center justify-center"
v-if="isRunningInDifferentOrganization">
<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="switchToTimeEntryOrganization"
>Switch to organization</SecondaryButton
>
</div>
</div>
<div class="flex items-center relative" data-testid="dashboard_timer">
<div
class="flex flex-col sm:flex-row w-full rounded-lg bg-card-background border-card-border border transition shadow-card">
<div class="flex-1 flex items-center pr-6">
<input
placeholder="What are you working on?"
data-testid="time_entry_description"
ref="currentTimeEntryDescriptionInput"
v-model="currentTimeEntry.description"
@keydown.enter="startTimerIfNotActive"
@blur="updateTimeEntry"
class="w-full rounded-l-lg py-4 sm:py-2.5 px-3 border-b border-b-card-background-separator sm:px-4 text-base sm: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">
<div class="flex items-center w-[130px] sm:w-auto">
<TimeTrackerProjectTaskDropdown
: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 space-x-2 px-4">
<TimeTrackerTagDropdown
@changed="updateTimeEntry"
:createTag
:tags="tags"
v-model="
currentTimeEntry.tags
"></TimeTrackerTagDropdown>
<BillableToggleButton
@changed="updateTimeEntry"
v-model="
currentTimeEntry.billable
"></BillableToggleButton>
</div>
<div class="border-l border-card-border">
<TimeTrackerRangeSelector
@keydown.enter="
startTimerIfNotActive
"></TimeTrackerRangeSelector>
</div>
</div>
</div>
<div
class="pl-6 pr-3 absolute sm:relative top-[6px] sm:top-0 right-0">
<TimeTrackerStartStop
:active="isActive"
@changed="onToggleButtonPress"
size="large"></TimeTrackerStartStop>
</div>
</div>
<TimeTrackerRunningInDifferentOrganizationOverlay
@switchOrganization="switchToTimeEntryOrganization"
v-if="
isRunningInDifferentOrganization
"></TimeTrackerRunningInDifferentOrganizationOverlay>
<TimeTrackerControls
:createProject
:createClient
:clients
:tags
:tasks
:projects
:createTag
:isActive
v-model:currentTimeEntry="currentTimeEntry"
v-model:liveTimer="now"
@startLiveTimer="startLiveTimer"
@stopLiveTimer="stopLiveTimer"
@startTimer="setActiveState(true)"
@stopTimer="setActiveState(false)"
@updateTimeEntry="updateTimeEntry"></TimeTrackerControls>
</div>
</template>