mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-15 03:32:15 +01:00
refactor timetracker to seperate data and ui logic
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user