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,7 +30,7 @@ class UserMemberController extends Controller
$members = Member::query() $members = Member::query()
->whereBelongsTo($user, 'user') ->whereBelongsTo($user, 'user')
->with(['organization']) ->with(['organization'])
->get(); ->paginate(config('app.pagination_per_page_default'));
return new PersonalMemberCollection($members); return new PersonalMemberCollection($members);
} }

View File

@@ -24,7 +24,7 @@ class PersonalMemberResource extends BaseResource
/** @var string $id ID of membership */ /** @var string $id ID of membership */
'id' => $this->resource->id, 'id' => $this->resource->id,
'organization' => [ 'organization' => [
/** @var int $id ID of organization */ /** @var string $id ID of organization */
'id' => $this->resource->organization->id, 'id' => $this->resource->organization->id,
/** @var string $name Name of organization */ /** @var string $name Name of organization */
'name' => $this->resource->organization->name, 'name' => $this->resource->organization->name,

View File

@@ -108,7 +108,7 @@ services:
- sail - sail
- reverse-proxy - reverse-proxy
playwright: playwright:
image: mcr.microsoft.com/playwright:v1.44.1-jammy image: mcr.microsoft.com/playwright:v1.45.2-jammy
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0'] command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
working_dir: /src working_dir: /src
extra_hosts: extra_hosts:

File diff suppressed because it is too large Load Diff

1573
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -28,9 +28,9 @@
"tailwindcss": "^3.1.0", "tailwindcss": "^3.1.0",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"vite": "^5.0.0", "vite": "^5.0.0",
"vite-plugin-checker": "^0.6.2", "vite-plugin-checker": "^0.7.2",
"vue": "^3.4.0", "vue": "^3.4.0",
"vue-tsc": "^1.8.27" "vue-tsc": "^2.0.28"
}, },
"dependencies": { "dependencies": {
"@floating-ui/core": "^1.6.0", "@floating-ui/core": "^1.6.0",

View File

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

View File

@@ -8,13 +8,13 @@ defineProps<{
</script> </script>
<template> <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 <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 <component
v-if="icon" v-if="icon"
:is="icon" :is="icon"
class="w-6 text-icon-default"></component> class="w-5 lg:w-6 text-icon-default"></component>
<span> <span>
{{ title }} {{ title }}
</span> </span>

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import TextInput from '@/Components/TextInput.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue'; import SecondaryButton from '@/Components/SecondaryButton.vue';
import DialogModal from '@/Components/DialogModal.vue'; import DialogModal from '@/Components/DialogModal.vue';
import { computed, ref } from '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 { getRandomColor } from '@/utils/color';
import PrimaryButton from '@/Components/PrimaryButton.vue'; import PrimaryButton from '@/Components/PrimaryButton.vue';
import { useFocus } from '@vueuse/core'; import { useFocus } from '@vueuse/core';
@@ -20,10 +20,8 @@ const saving = ref(false);
const props = defineProps<{ const props = defineProps<{
clients: Client[]; clients: Client[];
}>(); createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
const emit = defineEmits<{
submit: [project: CreateProjectBody, callback: () => void];
}>(); }>();
const project = ref<CreateProjectBody>({ const project = ref<CreateProjectBody>({
@@ -35,16 +33,15 @@ const project = ref<CreateProjectBody>({
}); });
async function submit() { async function submit() {
emit('submit', project.value, () => { await props.createProject(project.value);
show.value = false; show.value = false;
project.value = { project.value = {
name: '', name: '',
color: getRandomColor(), color: getRandomColor(),
client_id: null, client_id: null,
billable_rate: null, billable_rate: null,
is_billable: false, is_billable: false,
}; };
});
} }
const projectNameInput = ref<HTMLInputElement | null>(null); const projectNameInput = ref<HTMLInputElement | null>(null);
@@ -96,7 +93,11 @@ const currentClientName = computed(() => {
</div> </div>
<div> <div>
<InputLabel for="client" value="Client" /> <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> <template #trigger>
<Badge <Badge
class="bg-input-background cursor-pointer hover:bg-tertiary" 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 SecondaryButton from '@/Components/SecondaryButton.vue';
import DialogModal from '@/Components/DialogModal.vue'; import DialogModal from '@/Components/DialogModal.vue';
import { computed, ref } from '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 PrimaryButton from '@/Components/PrimaryButton.vue';
import { useProjectsStore } from '@/utils/useProjects'; import { useProjectsStore } from '@/utils/useProjects';
import { useFocus } from '@vueuse/core'; import { useFocus } from '@vueuse/core';
@@ -26,6 +26,10 @@ const props = defineProps<{
originalProject: Project; originalProject: Project;
}>(); }>();
async function createClient(body: CreateClientBody) {
return await useClientsStore().createClient(body);
}
const project = ref<CreateProjectBody>({ const project = ref<CreateProjectBody>({
name: props.originalProject.name, name: props.originalProject.name,
color: props.originalProject.color, color: props.originalProject.color,
@@ -97,7 +101,11 @@ async function submitBillableRate() {
</div> </div>
<div class=""> <div class="">
<InputLabel for="client" value="Client" /> <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> <template #trigger>
<Badge <Badge
class="bg-input-background cursor-pointer hover:bg-tertiary" 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 ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue'; import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
import { canCreateProjects } from '@/utils/permissions'; 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 { useProjectsStore } from '@/utils/useProjects';
import { useClientsStore } from '@/utils/useClients'; import { useClientsStore } from '@/utils/useClients';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
@@ -17,17 +22,25 @@ defineProps<{
}>(); }>();
const showCreateProjectModal = ref(false); const showCreateProjectModal = ref(false);
async function createProject(project: CreateProjectBody, callback: () => void) { async function createProject(
await useProjectsStore().createProject(project); project: CreateProjectBody
callback(); ): 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()); const { clients } = storeToRefs(useClientsStore());
</script> </script>
<template> <template>
<ProjectCreateModal <ProjectCreateModal
:createProject
:createClient
:clients="clients" :clients="clients"
@submit="createProject"
v-model:show="showCreateProjectModal"></ProjectCreateModal> v-model:show="showCreateProjectModal"></ProjectCreateModal>
<div class="flow-root max-w-[100vw] overflow-x-auto"> <div class="flow-root max-w-[100vw] overflow-x-auto">
<div class="inline-block min-w-full align-middle"> <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 { type Component, computed, ref, watch } from 'vue';
import SelectDropdownItem from '@/Components/Common/SelectDropdownItem.vue'; import SelectDropdownItem from '@/Components/Common/SelectDropdownItem.vue';
import { onKeyStroke } from '@vueuse/core'; import { onKeyStroke } from '@vueuse/core';
import { type Placement } from '@floating-ui/vue';
const model = defineModel<string | null>({ const model = defineModel<string | null>({
default: null, default: null,
}); });
const props = defineProps<{ const props = withDefaults(
items: T[]; defineProps<{
getKeyFromItem: (item: T) => string | null; items: T[];
getNameForItem: (item: T) => string; getKeyFromItem: (item: T) => string | null;
}>(); getNameForItem: (item: T) => string;
align?: Placement;
}>(),
{
align: 'bottom-start',
}
);
const open = ref(false); const open = ref(false);
const dropdownViewport = ref<Component | null>(null); const dropdownViewport = ref<Component | null>(null);
@@ -113,7 +120,7 @@ watch(open, () => {
</script> </script>
<template> <template>
<Dropdown v-model="open" align="bottom-start" :closeOnContentClick="false"> <Dropdown v-model="open" :align="align" :closeOnContentClick="false">
<template #trigger> <template #trigger>
<slot name="trigger"> </slot> <slot name="trigger"> </slot>
</template> </template>

View File

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

View File

@@ -1,9 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import MainContainer from '@/Pages/MainContainer.vue'; import MainContainer from '@/Pages/MainContainer.vue';
import TimeTrackerStartStop from '@/Components/Common/TimeTrackerStartStop.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 TimeEntryDescriptionInput from '@/Components/Common/TimeEntry/TimeEntryDescriptionInput.vue';
import { type TimeEntriesGroupedByType } from '@/utils/useTimeEntries';
import TimeEntryRowTagDropdown from '@/Components/Common/TimeEntry/TimeEntryRowTagDropdown.vue'; import TimeEntryRowTagDropdown from '@/Components/Common/TimeEntry/TimeEntryRowTagDropdown.vue';
import TimeEntryMoreOptionsDropdown from '@/Components/Common/TimeEntry/TimeEntryMoreOptionsDropdown.vue'; import TimeEntryMoreOptionsDropdown from '@/Components/Common/TimeEntry/TimeEntryMoreOptionsDropdown.vue';
import TimeTrackerProjectTaskDropdown from '@/Components/Common/TimeTracker/TimeTrackerProjectTaskDropdown.vue'; import TimeTrackerProjectTaskDropdown from '@/Components/Common/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
@@ -15,13 +22,17 @@ import {
} from '../../../utils/time'; } from '../../../utils/time';
import TimeEntryRow from '@/Components/Common/TimeEntry/TimeEntryRow.vue'; import TimeEntryRow from '@/Components/Common/TimeEntry/TimeEntryRow.vue';
import GroupedItemsCountButton from '@/Components/Common/GroupedItemsCountButton.vue'; import GroupedItemsCountButton from '@/Components/Common/GroupedItemsCountButton.vue';
import type { TimeEntriesGroupedByType } from '@/types/time-entries';
const props = defineProps<{ const props = defineProps<{
timeEntry: TimeEntriesGroupedByType; timeEntry: TimeEntriesGroupedByType;
projects: Project[]; projects: Project[];
tasks: Task[]; tasks: Task[];
tags: Tag[]; tags: Tag[];
clients: Client[];
createTag: (name: string) => Promise<Tag | undefined>; createTag: (name: string) => Promise<Tag | undefined>;
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
onStartStopClick: (timeEntry: TimeEntry) => void; onStartStopClick: (timeEntry: TimeEntry) => void;
updateTimeEntries: (timeEntries: TimeEntry[]) => void; updateTimeEntries: (timeEntries: TimeEntry[]) => void;
deleteTimeEntries: (timeEntries: TimeEntry[]) => void; deleteTimeEntries: (timeEntries: TimeEntry[]) => void;
@@ -64,7 +75,7 @@ const expanded = ref(false);
data-testid="time_entry_row"> data-testid="time_entry_row">
<MainContainer> <MainContainer>
<div class="sm:flex py-1.5 items-center justify-between group"> <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 <input
type="checkbox" type="checkbox"
class="h-4 w-4 rounded bg-card-background border-input-border text-accent-500/80 focus:ring-accent-500/80" /> 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> "></TimeEntryDescriptionInput>
</div> </div>
<TimeTrackerProjectTaskDropdown <TimeTrackerProjectTaskDropdown
:clients
:createProject
:createClient
:projects="projects" :projects="projects"
:tasks="tasks" :tasks="tasks"
:showBadgeBorder="false" :showBadgeBorder="false"
@@ -134,6 +148,9 @@ const expanded = ref(false);
<TimeEntryRow <TimeEntryRow
:projects="projects" :projects="projects"
:tasks="tasks" :tasks="tasks"
:createClient
:clients
:createProject
:tags="tags" :tags="tags"
indent indent
:updateTimeEntry="(arg) => updateTimeEntries([arg])" :updateTimeEntry="(arg) => updateTimeEntries([arg])"

View File

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

View File

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

View File

@@ -2,7 +2,15 @@
import MainContainer from '@/Pages/MainContainer.vue'; import MainContainer from '@/Pages/MainContainer.vue';
import TimeTrackerStartStop from '@/Components/Common/TimeTrackerStartStop.vue'; import TimeTrackerStartStop from '@/Components/Common/TimeTrackerStartStop.vue';
import TimeEntryRangeSelector from '@/Components/Common/TimeEntry/TimeEntryRangeSelector.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 TimeEntryDescriptionInput from '@/Components/Common/TimeEntry/TimeEntryDescriptionInput.vue';
import TimeEntryRowTagDropdown from '@/Components/Common/TimeEntry/TimeEntryRowTagDropdown.vue'; import TimeEntryRowTagDropdown from '@/Components/Common/TimeEntry/TimeEntryRowTagDropdown.vue';
import TimeEntryRowDurationInput from '@/Components/Common/TimeEntry/TimeEntryRowDurationInput.vue'; import TimeEntryRowDurationInput from '@/Components/Common/TimeEntry/TimeEntryRowDurationInput.vue';
@@ -16,7 +24,10 @@ const props = defineProps<{
projects: Project[]; projects: Project[];
tasks: Task[]; tasks: Task[];
tags: Tag[]; tags: Tag[];
clients: Client[];
createTag: (name: string) => Promise<Tag | undefined>; createTag: (name: string) => Promise<Tag | undefined>;
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
onStartStopClick: () => void; onStartStopClick: () => void;
deleteTimeEntry: () => void; deleteTimeEntry: () => void;
updateTimeEntry: (timeEntry: TimeEntry) => void; updateTimeEntry: (timeEntry: TimeEntry) => void;
@@ -54,7 +65,7 @@ function updateProjectAndTask(projectId: string, taskId: string) {
<MainContainer> <MainContainer>
<div <div
class="sm:flex py-1 lg:py-1.5 items-center justify-between group"> 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 <input
type="checkbox" type="checkbox"
class="h-4 w-4 rounded bg-card-background border-input-border text-accent-500/80 focus:ring-accent-500/80" /> 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 timeEntry.description
"></TimeEntryDescriptionInput> "></TimeEntryDescriptionInput>
<TimeTrackerProjectTaskDropdown <TimeTrackerProjectTaskDropdown
:createProject
:createClient
:clients
:projects="projects" :projects="projects"
:tasks="tasks" :tasks="tasks"
:showBadgeBorder="false" :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 Dropdown from '@/Components/Dropdown.vue';
import { type Component, computed, nextTick, ref, watch } from 'vue'; import { type Component, computed, nextTick, ref, watch } from 'vue';
import ProjectDropdownItem from '@/Components/Common/Project/ProjectDropdownItem.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 ProjectBadge from '@/Components/Common/Project/ProjectBadge.vue';
import Badge from '@/Components/Common/Badge.vue'; import Badge from '@/Components/Common/Badge.vue';
import { PlusIcon, PlusCircleIcon } from '@heroicons/vue/16/solid'; import { PlusIcon, PlusCircleIcon } from '@heroicons/vue/16/solid';
import ProjectCreateModal from '@/Components/Common/Project/ProjectCreateModal.vue'; 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', { const task = defineModel<string | null>('task', {
default: null, default: null,
@@ -46,6 +49,11 @@ const props = withDefaults(
size: 'base' | 'large' | 'xlarge'; size: 'base' | 'large' | 'xlarge';
projects: Project[]; projects: Project[];
tasks: Task[]; tasks: Task[];
clients: Client[];
createProject: (
project: CreateProjectBody
) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
}>(), }>(),
{ {
showBadgeBorder: true, showBadgeBorder: true,
@@ -309,14 +317,6 @@ function selectProject(projectId: string) {
} }
const showCreateProject = ref(false); const showCreateProject = ref(false);
const clientsStore = useClientsStore();
const { clients } = storeToRefs(clientsStore);
async function createProject(project: CreateProjectBody, callback: () => void) {
await useProjectsStore().createProject(project);
callback();
}
</script> </script>
<template> <template>
@@ -338,15 +338,19 @@ async function createProject(project: CreateProjectBody, callback: () => void) {
:border="showBadgeBorder" :border="showBadgeBorder"
tag="button" tag="button"
:name="selectedProjectName" :name="selectedProjectName"
class="focus:border-border-tertiary focus:outline-0 focus:bg-card-background-separator hover:bg-card-background-separator"> 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 nowrap items-center space-x-1"> <div class="flex items-center lg:space-x-1 min-w-0">
<span class="whitespace-nowrap text-xs lg:text-sm"> <span class="whitespace-nowrap text-xs lg:text-sm">
{{ selectedProjectName }} {{ selectedProjectName }}
</span> </span>
<ChevronRightIcon <ChevronRightIcon
v-if="currentTask" v-if="currentTask"
class="w-5 text-muted"></ChevronRightIcon> class="w-4 lg:w-5 text-muted shrink-0"></ChevronRightIcon>
<span v-if="currentTask">{{ currentTask.name }}</span> <div
class="min-w-0 shrink text-xs lg:text-sm truncate"
v-if="currentTask">
{{ currentTask.name }}
</div>
</div> </div>
</ProjectBadge> </ProjectBadge>
</template> </template>
@@ -418,8 +422,9 @@ async function createProject(project: CreateProjectBody, callback: () => void) {
</template> </template>
</Dropdown> </Dropdown>
<ProjectCreateModal <ProjectCreateModal
:createClient
:clients="clients" :clients="clients"
@submit="createProject" :createProject
v-model:show="showCreateProject"></ProjectCreateModal> v-model:show="showCreateProject"></ProjectCreateModal>
</template> </template>

View File

@@ -2,23 +2,28 @@
import Dropdown from '@/Components/Dropdown.vue'; import Dropdown from '@/Components/Dropdown.vue';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import TimeRangeSelector from '@/Components/Common/TimeRangeSelector.vue'; import TimeRangeSelector from '@/Components/Common/TimeRangeSelector.vue';
import dayjs from 'dayjs'; import dayjs, { Dayjs } from 'dayjs';
import parse from 'parse-duration'; import parse from 'parse-duration';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { storeToRefs } from 'pinia';
import { formatDuration, getDayJsInstance } from '@/utils/time'; import { formatDuration, getDayJsInstance } from '@/utils/time';
const currentTimeEntryStore = useCurrentTimeEntryStore(); import type { TimeEntry } from '@/utils/api';
const { startLiveTimer, stopLiveTimer, updateTimer, startTimer } =
currentTimeEntryStore;
const { currentTimeEntry, now } = storeToRefs(currentTimeEntryStore);
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); const open = ref(false);
function pauseLiveTimerUpdate(event: FocusEvent) { function pauseLiveTimerUpdate(event: FocusEvent) {
(event.target as HTMLInputElement).select(); (event.target as HTMLInputElement).select();
stopLiveTimer(); emit('stopLiveTimer');
} }
function onTimeEntryEnterPress() { function onTimeEntryEnterPress() {
@@ -59,9 +64,9 @@ function updateTimerAndStartLiveTimerUpdate() {
); );
currentTimeEntry.value.start = newStartDate.utc().format(); currentTimeEntry.value.start = newStartDate.utc().format();
if (currentTimeEntry.value.id !== '') { if (currentTimeEntry.value.id !== '') {
currentTimeEntryStore.updateTimer(); emit('updateTimer');
} else { } else {
currentTimeEntryStore.startTimer(); emit('startTimer');
} }
} else if (isHHMM(temporaryCustomTimerEntry.value)) { } else if (isHHMM(temporaryCustomTimerEntry.value)) {
const results = parseHHMM(temporaryCustomTimerEntry.value); const results = parseHHMM(temporaryCustomTimerEntry.value);
@@ -71,9 +76,9 @@ function updateTimerAndStartLiveTimerUpdate() {
.subtract(parseInt(results[2]), 'm'); .subtract(parseInt(results[2]), 'm');
currentTimeEntry.value.start = newStartDate.utc().format(); currentTimeEntry.value.start = newStartDate.utc().format();
if (currentTimeEntry.value.id !== '') { if (currentTimeEntry.value.id !== '') {
currentTimeEntryStore.updateTimer(); emit('updateTimer');
} else { } else {
currentTimeEntryStore.startTimer(); emit('startTimer');
} }
} }
} }
@@ -82,15 +87,15 @@ function updateTimerAndStartLiveTimerUpdate() {
const newStartDate = dayjs().subtract(time, 's'); const newStartDate = dayjs().subtract(time, 's');
currentTimeEntry.value.start = newStartDate.utc().format(); currentTimeEntry.value.start = newStartDate.utc().format();
if (currentTimeEntry.value.id !== '') { if (currentTimeEntry.value.id !== '') {
currentTimeEntryStore.updateTimer(); emit('updateTimer');
} else { } else {
currentTimeEntryStore.startTimer(); emit('startTimer');
} }
} }
// fallback to minutes if just a number is given // fallback to minutes if just a number is given
now.value = dayjs().utc(); now.value = dayjs().utc();
temporaryCustomTimerEntry.value = ''; temporaryCustomTimerEntry.value = '';
startLiveTimer(); emit('startLiveTimer');
} }
function isNumeric(value: string) { function isNumeric(value: string) {
@@ -113,9 +118,9 @@ async function updateTimeRange(newStart: string) {
if (getDayJsInstance()(newStart).isBefore(getDayJsInstance()())) { if (getDayJsInstance()(newStart).isBefore(getDayJsInstance()())) {
currentTimeEntry.value.start = newStart; currentTimeEntry.value.start = newStart;
if (currentTimeEntry.value.id) { if (currentTimeEntry.value.id) {
await updateTimer(); emit('updateTimer');
} else { } else {
await startTimer(); emit('startTimer');
} }
} }
} }
@@ -143,7 +148,7 @@ const startTime = computed(() => {
@blur="updateTimerAndStartLiveTimerUpdate" @blur="updateTimerAndStartLiveTimerUpdate"
@keydown.enter="onTimeEntryEnterPress" @keydown.enter="onTimeEntryEnterPress"
v-model="currentTime" 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" /> type="text" />
</template> </template>
<template #content> <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=" :class="
twMerge( twMerge(
iconColorClasses, 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 <span
v-if="model.length > 1" 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"> 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( const props = withDefaults(
defineProps<{ defineProps<{
size: 'base' | 'large'; size: 'base' | 'large' | 'small';
active: boolean; active: boolean;
}>(), }>(),
{ {
@@ -15,10 +15,12 @@ const props = withDefaults(
} }
); );
const buttonSizeClasses = { 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', 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', large: 'w-11 h-11 ring-accent-200/10 focus:ring-accent-200/20 ring-4 sm:ring-8 hover:scale-110',
}; };
const iconClass = { const iconClass = {
small: 'w-2.5 h-2.5',
base: 'w-3.5 h-3.5', base: 'w-3.5 h-3.5',
large: 'w-4 h-4', large: 'w-4 h-4',
}; };

View File

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

View File

@@ -1,26 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { ClockIcon } from '@heroicons/vue/20/solid'; import { ClockIcon } from '@heroicons/vue/20/solid';
import CardTitle from '@/Components/Common/CardTitle.vue'; 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 { usePage } from '@inertiajs/vue3';
import { type User } from '@/types/models'; import { type User } from '@/types/models';
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, watch } from 'vue';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc'; import utc from 'dayjs/plugin/utc';
import duration from 'dayjs/plugin/duration'; import duration from 'dayjs/plugin/duration';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry'; import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { storeToRefs } from 'pinia'; 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 { getCurrentOrganizationId } from '@/utils/useUser';
import { switchOrganization } from '@/utils/useOrganization'; 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 { useProjectsStore } from '@/utils/useProjects';
import { useTasksStore } from '@/utils/useTasks'; import { useTasksStore } from '@/utils/useTasks';
import { useTagsStore } from '@/utils/useTags'; 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<{ const page = usePage<{
auth: { auth: {
@@ -34,12 +32,13 @@ dayjs.extend(utc);
const currentTimeEntryStore = useCurrentTimeEntryStore(); const currentTimeEntryStore = useCurrentTimeEntryStore();
const { currentTimeEntry, isActive, now } = storeToRefs(currentTimeEntryStore); const { currentTimeEntry, isActive, now } = storeToRefs(currentTimeEntryStore);
const { startLiveTimer, stopLiveTimer, setActiveState } = currentTimeEntryStore; const { startLiveTimer, stopLiveTimer, setActiveState } = currentTimeEntryStore;
const currentTimeEntryDescriptionInput = ref<HTMLInputElement | null>(null);
const projectStore = useProjectsStore(); const projectStore = useProjectsStore();
const { projects } = storeToRefs(projectStore); const { projects } = storeToRefs(projectStore);
const taskStore = useTasksStore(); const taskStore = useTasksStore();
const { tasks } = storeToRefs(taskStore); const { tasks } = storeToRefs(taskStore);
const clientStore = useClientsStore();
const { clients } = storeToRefs(clientStore);
watch(isActive, () => { watch(isActive, () => {
if (isActive.value) { 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() { function updateTimeEntry() {
if (currentTimeEntry.value.id) { if (currentTimeEntry.value.id) {
useCurrentTimeEntryStore().updateTimer(); useCurrentTimeEntryStore().updateTimer();
} }
} }
function onToggleButtonPress(newState: boolean) {
setActiveState(newState);
if (newState) {
currentTimeEntryDescriptionInput.value?.focus();
}
}
function startTimerIfNotActive() {
if (!isActive.value) {
setActiveState(true);
}
}
const isRunningInDifferentOrganization = computed(() => { const isRunningInDifferentOrganization = computed(() => {
return ( return (
currentTimeEntry.value.organization_id && 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() { function switchToTimeEntryOrganization() {
if (currentTimeEntry.value.organization_id) { if (currentTimeEntry.value.organization_id) {
switchOrganization(currentTimeEntry.value.organization_id); switchOrganization(currentTimeEntry.value.organization_id);
@@ -114,74 +93,27 @@ const { tags } = storeToRefs(useTagsStore());
<template> <template>
<CardTitle title="Time Tracker" :icon="ClockIcon"></CardTitle> <CardTitle title="Time Tracker" :icon="ClockIcon"></CardTitle>
<div class="relative"> <div class="relative">
<div <TimeTrackerRunningInDifferentOrganizationOverlay
class="absolute w-full h-full backdrop-blur-sm z-10 flex items-center justify-center" @switchOrganization="switchToTimeEntryOrganization"
v-if="isRunningInDifferentOrganization"> v-if="
<div isRunningInDifferentOrganization
class="w-full h-[calc(100%+10px)] absolute bg-default-background opacity-75 backdrop-blur-sm"></div> "></TimeTrackerRunningInDifferentOrganizationOverlay>
<div class="flex space-x-3 items-center w-full z-20 justify-center">
<span class="text-sm text-white"> <TimeTrackerControls
The Timer is running in a different organization. :createProject
</span> :createClient
<SecondaryButton @click="switchToTimeEntryOrganization" :clients
>Switch to organization</SecondaryButton :tags
> :tasks
</div> :projects
</div> :createTag
<div class="flex items-center relative" data-testid="dashboard_timer"> :isActive
<div v-model:currentTimeEntry="currentTimeEntry"
class="flex flex-col sm:flex-row w-full rounded-lg bg-card-background border-card-border border transition shadow-card"> v-model:liveTimer="now"
<div class="flex-1 flex items-center pr-6"> @startLiveTimer="startLiveTimer"
<input @stopLiveTimer="stopLiveTimer"
placeholder="What are you working on?" @startTimer="setActiveState(true)"
data-testid="time_entry_description" @stopTimer="setActiveState(false)"
ref="currentTimeEntryDescriptionInput" @updateTimeEntry="updateTimeEntry"></TimeTrackerControls>
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>
</div> </div>
</template> </template>

View File

@@ -13,7 +13,12 @@ import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
import TabBar from '@/Components/Common/TabBar/TabBar.vue'; import TabBar from '@/Components/Common/TabBar/TabBar.vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useClientsStore } from '@/utils/useClients'; import { useClientsStore } from '@/utils/useClients';
import type { CreateProjectBody } from '@/utils/api'; import type {
CreateClientBody,
Client,
CreateProjectBody,
Project,
} from '@/utils/api';
onMounted(() => { onMounted(() => {
useProjectsStore().fetchProjects(); useProjectsStore().fetchProjects();
@@ -27,11 +32,6 @@ function isActiveTab(tab: string) {
return activeTab.value === tab; return activeTab.value === tab;
} }
async function createProject(project: CreateProjectBody, callback: () => void) {
await useProjectsStore().createProject(project);
callback();
}
const { projects } = storeToRefs(useProjectsStore()); const { projects } = storeToRefs(useProjectsStore());
const shownProjects = computed(() => { const shownProjects = computed(() => {
@@ -42,6 +42,16 @@ const shownProjects = computed(() => {
return project.is_archived; return project.is_archived;
}); });
}); });
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);
}
</script> </script>
<template> <template>
@@ -70,6 +80,8 @@ const shownProjects = computed(() => {
>Create Project >Create Project
</SecondaryButton> </SecondaryButton>
<ProjectCreateModal <ProjectCreateModal
:createProject
:createClient
:clients="clients" :clients="clients"
@submit="createProject" @submit="createProject"
v-model:show="showCreateProjectModal"></ProjectCreateModal> v-model:show="showCreateProjectModal"></ProjectCreateModal>

View File

@@ -35,10 +35,10 @@ import { getCurrentMembershipId, getCurrentRole } from '@/utils/useUser';
import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue'; import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue';
import { useTagsStore } from '@/utils/useTags'; import { useTagsStore } from '@/utils/useTags';
const startDate = ref<string | null>( const startDate = ref<string>(
getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format() getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format()
); );
const endDate = ref<string | null>( const endDate = ref<string>(
getLocalizedDayJs(getDayJsInstance()().format()).format() getLocalizedDayJs(getDayJsInstance()().format()).format()
); );
const selectedTags = ref<string[]>([]); const selectedTags = ref<string[]>([]);

View File

@@ -5,7 +5,14 @@ import { onMounted, ref, watch } from 'vue';
import MainContainer from '@/Pages/MainContainer.vue'; import MainContainer from '@/Pages/MainContainer.vue';
import { useTimeEntriesStore } from '@/utils/useTimeEntries'; import { useTimeEntriesStore } from '@/utils/useTimeEntries';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import type { CreateTimeEntryBody, TimeEntry } from '@/utils/api'; import type {
CreateClientBody,
CreateProjectBody,
CreateTimeEntryBody,
Project,
TimeEntry,
Client,
} from '@/utils/api';
import { useElementVisibility } from '@vueuse/core'; import { useElementVisibility } from '@vueuse/core';
import { ClockIcon } from '@heroicons/vue/20/solid'; import { ClockIcon } from '@heroicons/vue/20/solid';
import SecondaryButton from '@/Components/SecondaryButton.vue'; import SecondaryButton from '@/Components/SecondaryButton.vue';
@@ -17,6 +24,7 @@ import { useTasksStore } from '@/utils/useTasks';
import { useProjectsStore } from '@/utils/useProjects'; import { useProjectsStore } from '@/utils/useProjects';
import TimeEntryGroupedTable from '@/Components/Common/TimeEntry/TimeEntryGroupedTable.vue'; import TimeEntryGroupedTable from '@/Components/Common/TimeEntry/TimeEntryGroupedTable.vue';
import { useTagsStore } from '@/utils/useTags'; import { useTagsStore } from '@/utils/useTags';
import { useClientsStore } from '@/utils/useClients';
const timeEntriesStore = useTimeEntriesStore(); const timeEntriesStore = useTimeEntriesStore();
const { timeEntries, allTimeEntriesLoaded } = storeToRefs(timeEntriesStore); const { timeEntries, allTimeEntriesLoaded } = storeToRefs(timeEntriesStore);
@@ -76,10 +84,22 @@ const projectStore = useProjectsStore();
const { projects } = storeToRefs(projectStore); const { projects } = storeToRefs(projectStore);
const taskStore = useTasksStore(); const taskStore = useTasksStore();
const { tasks } = storeToRefs(taskStore); const { tasks } = storeToRefs(taskStore);
const clientStore = useClientsStore();
const { clients } = storeToRefs(clientStore);
async function createTag(name: string) { async function createTag(name: string) {
return await useTagsStore().createTag(name); return await useTagsStore().createTag(name);
} }
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);
}
</script> </script>
<template> <template>
@@ -104,6 +124,9 @@ async function createTag(name: string) {
</div> </div>
</MainContainer> </MainContainer>
<TimeEntryGroupedTable <TimeEntryGroupedTable
:createProject
:clients
:createClient
:updateTimeEntry :updateTimeEntry
:updateTimeEntries :updateTimeEntries
:deleteTimeEntries :deleteTimeEntries

3
resources/js/types/time-entries.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
import type { TimeEntry } from '@/utils/api';
export type TimeEntriesGroupedByType = TimeEntry & { timeEntries: TimeEntry[] };

View File

@@ -128,6 +128,13 @@ export type UpdateOrganizationBody = ZodiosBodyByAlias<
'updateOrganization' 'updateOrganization'
>; >;
export type MyMemberships = ZodiosResponseByAlias<
SolidTimeApi,
'getMyMemberships'
>['data'];
export type MyMembership = MyMemberships[0];
export async function fetchToken() { export async function fetchToken() {
return new Promise((resolve) => { return new Promise((resolve) => {
router.reload({ router.reload({

View File

@@ -90,7 +90,7 @@ export const useNotificationsStore = defineStore('notifications', () => {
); );
} }
} }
throw new Error('Failed to handle API request', { cause: error }); throw new Error('Failed to handle API request');
} }
} }

View File

@@ -76,15 +76,12 @@ export const useClientsStore = defineStore('clients', () => {
if (organization) { if (organization) {
await handleApiRequestNotifications( await handleApiRequestNotifications(
() => () =>
api.deleteClient( api.deleteClient(undefined, {
{}, params: {
{ organization: organization,
params: { client: clientId,
organization: organization, },
client: clientId, }),
},
}
),
'Client deleted successfully', 'Client deleted successfully',
'Failed to delete client' 'Failed to delete client'
); );

View File

@@ -36,15 +36,12 @@ export const useMembersStore = defineStore('members', () => {
if (organization) { if (organization) {
await handleApiRequestNotifications( await handleApiRequestNotifications(
() => () =>
api.removeMember( api.removeMember(undefined, {
{}, params: {
{ organization: organization,
params: { member: membershipId,
organization: organization, },
member: membershipId, }),
},
}
),
'Member deleted successfully', 'Member deleted successfully',
'Failed to delete member' 'Failed to delete member'
); );

View File

@@ -81,15 +81,12 @@ export const useProjectMembersStore = defineStore('project-members', () => {
if (organizationId) { if (organizationId) {
await handleApiRequestNotifications( await handleApiRequestNotifications(
() => () =>
api.deleteProjectMember( api.deleteProjectMember(undefined, {
{}, params: {
{ organization: organizationId,
params: { projectMember: projectMemberId,
organization: organizationId, },
projectMember: projectMemberId, }),
},
}
),
'Project member removed successfully', 'Project member removed successfully',
'Failed to remove project member' 'Failed to remove project member'
); );

View File

@@ -37,7 +37,7 @@ export const useProjectsStore = defineStore('projects', () => {
async function createProject(projectBody: CreateProjectBody) { async function createProject(projectBody: CreateProjectBody) {
const organization = getCurrentOrganizationId(); const organization = getCurrentOrganizationId();
if (organization) { if (organization) {
await handleApiRequestNotifications( const response = await handleApiRequestNotifications(
() => () =>
api.createProject(projectBody, { api.createProject(projectBody, {
params: { params: {
@@ -49,6 +49,7 @@ export const useProjectsStore = defineStore('projects', () => {
); );
await fetchProjects(); await fetchProjects();
return response['data'];
} }
} }
@@ -57,15 +58,12 @@ export const useProjectsStore = defineStore('projects', () => {
if (organizationId) { if (organizationId) {
await handleApiRequestNotifications( await handleApiRequestNotifications(
() => () =>
api.deleteProject( api.deleteProject(undefined, {
{}, params: {
{ organization: organizationId,
params: { project: projectId,
organization: organizationId, },
project: projectId, }),
},
}
),
'Project deleted successfully', 'Project deleted successfully',
'Failed to delete project' 'Failed to delete project'
); );

View File

@@ -36,15 +36,12 @@ export const useTagsStore = defineStore('tags', () => {
if (organizationId) { if (organizationId) {
await handleApiRequestNotifications( await handleApiRequestNotifications(
() => () =>
api.deleteTag( api.deleteTag(undefined, {
{}, params: {
{ organization: organizationId,
params: { tag: tagId,
organization: organizationId, },
tag: tagId, }),
},
}
),
'Tag deleted successfully', 'Tag deleted successfully',
'Failed to delete tag' 'Failed to delete tag'
); );

View File

@@ -68,15 +68,12 @@ export const useTasksStore = defineStore('tasks', () => {
if (organizationId) { if (organizationId) {
await handleApiRequestNotifications( await handleApiRequestNotifications(
() => () =>
api.deleteTask( api.deleteTask(undefined, {
{}, params: {
{ organization: organizationId,
params: { task: taskId,
organization: organizationId, },
task: taskId, }),
},
}
),
'Task deleted successfully', 'Task deleted successfully',
'Failed to delete task' 'Failed to delete task'
); );

View File

@@ -9,8 +9,6 @@ import type { CreateTimeEntryBody, TimeEntry } from '@/utils/api';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useNotificationsStore } from '@/utils/notification'; import { useNotificationsStore } from '@/utils/notification';
export type TimeEntriesGroupedByType = TimeEntry & { timeEntries: TimeEntry[] };
export const useTimeEntriesStore = defineStore('timeEntries', () => { export const useTimeEntriesStore = defineStore('timeEntries', () => {
const timeEntries = ref<TimeEntry[]>(reactive([])); const timeEntries = ref<TimeEntry[]>(reactive([]));
@@ -124,15 +122,12 @@ export const useTimeEntriesStore = defineStore('timeEntries', () => {
if (organizationId) { if (organizationId) {
await handleApiRequestNotifications( await handleApiRequestNotifications(
() => () =>
api.deleteTimeEntry( api.deleteTimeEntry(undefined, {
{}, params: {
{ organization: organizationId,
params: { timeEntry: timeEntryId,
organization: organizationId, },
timeEntry: timeEntryId, }),
},
}
),
'Time entry deleted successfully', 'Time entry deleted successfully',
'Failed to delete time entry' 'Failed to delete time entry'
); );