mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02:15 +01:00
refactor timetracker to seperate data and ui logic
This commit is contained in:
@@ -30,7 +30,7 @@ class UserMemberController extends Controller
|
||||
$members = Member::query()
|
||||
->whereBelongsTo($user, 'user')
|
||||
->with(['organization'])
|
||||
->get();
|
||||
->paginate(config('app.pagination_per_page_default'));
|
||||
|
||||
return new PersonalMemberCollection($members);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ class PersonalMemberResource extends BaseResource
|
||||
/** @var string $id ID of membership */
|
||||
'id' => $this->resource->id,
|
||||
'organization' => [
|
||||
/** @var int $id ID of organization */
|
||||
/** @var string $id ID of organization */
|
||||
'id' => $this->resource->organization->id,
|
||||
/** @var string $name Name of organization */
|
||||
'name' => $this->resource->organization->name,
|
||||
|
||||
@@ -108,7 +108,7 @@ services:
|
||||
- sail
|
||||
- reverse-proxy
|
||||
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']
|
||||
working_dir: /src
|
||||
extra_hosts:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1573
package-lock.json
generated
1573
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -28,9 +28,9 @@
|
||||
"tailwindcss": "^3.1.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-checker": "^0.6.2",
|
||||
"vite-plugin-checker": "^0.7.2",
|
||||
"vue": "^3.4.0",
|
||||
"vue-tsc": "^1.8.27"
|
||||
"vue-tsc": "^2.0.28"
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.6.0",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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])"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -13,7 +13,12 @@ import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
|
||||
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useClientsStore } from '@/utils/useClients';
|
||||
import type { CreateProjectBody } from '@/utils/api';
|
||||
import type {
|
||||
CreateClientBody,
|
||||
Client,
|
||||
CreateProjectBody,
|
||||
Project,
|
||||
} from '@/utils/api';
|
||||
|
||||
onMounted(() => {
|
||||
useProjectsStore().fetchProjects();
|
||||
@@ -27,11 +32,6 @@ function isActiveTab(tab: string) {
|
||||
return activeTab.value === tab;
|
||||
}
|
||||
|
||||
async function createProject(project: CreateProjectBody, callback: () => void) {
|
||||
await useProjectsStore().createProject(project);
|
||||
callback();
|
||||
}
|
||||
|
||||
const { projects } = storeToRefs(useProjectsStore());
|
||||
|
||||
const shownProjects = computed(() => {
|
||||
@@ -42,6 +42,16 @@ const shownProjects = computed(() => {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -70,6 +80,8 @@ const shownProjects = computed(() => {
|
||||
>Create Project
|
||||
</SecondaryButton>
|
||||
<ProjectCreateModal
|
||||
:createProject
|
||||
:createClient
|
||||
:clients="clients"
|
||||
@submit="createProject"
|
||||
v-model:show="showCreateProjectModal"></ProjectCreateModal>
|
||||
|
||||
@@ -35,10 +35,10 @@ import { getCurrentMembershipId, getCurrentRole } from '@/utils/useUser';
|
||||
import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue';
|
||||
import { useTagsStore } from '@/utils/useTags';
|
||||
|
||||
const startDate = ref<string | null>(
|
||||
const startDate = ref<string>(
|
||||
getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format()
|
||||
);
|
||||
const endDate = ref<string | null>(
|
||||
const endDate = ref<string>(
|
||||
getLocalizedDayJs(getDayJsInstance()().format()).format()
|
||||
);
|
||||
const selectedTags = ref<string[]>([]);
|
||||
|
||||
@@ -5,7 +5,14 @@ import { onMounted, ref, watch } from 'vue';
|
||||
import MainContainer from '@/Pages/MainContainer.vue';
|
||||
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
|
||||
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 { ClockIcon } from '@heroicons/vue/20/solid';
|
||||
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
||||
@@ -17,6 +24,7 @@ import { useTasksStore } from '@/utils/useTasks';
|
||||
import { useProjectsStore } from '@/utils/useProjects';
|
||||
import TimeEntryGroupedTable from '@/Components/Common/TimeEntry/TimeEntryGroupedTable.vue';
|
||||
import { useTagsStore } from '@/utils/useTags';
|
||||
import { useClientsStore } from '@/utils/useClients';
|
||||
|
||||
const timeEntriesStore = useTimeEntriesStore();
|
||||
const { timeEntries, allTimeEntriesLoaded } = storeToRefs(timeEntriesStore);
|
||||
@@ -76,10 +84,22 @@ const projectStore = useProjectsStore();
|
||||
const { projects } = storeToRefs(projectStore);
|
||||
const taskStore = useTasksStore();
|
||||
const { tasks } = storeToRefs(taskStore);
|
||||
const clientStore = useClientsStore();
|
||||
const { clients } = storeToRefs(clientStore);
|
||||
|
||||
async function createTag(name: string) {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -104,6 +124,9 @@ async function createTag(name: string) {
|
||||
</div>
|
||||
</MainContainer>
|
||||
<TimeEntryGroupedTable
|
||||
:createProject
|
||||
:clients
|
||||
:createClient
|
||||
:updateTimeEntry
|
||||
:updateTimeEntries
|
||||
:deleteTimeEntries
|
||||
|
||||
3
resources/js/types/time-entries.d.ts
vendored
Normal file
3
resources/js/types/time-entries.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { TimeEntry } from '@/utils/api';
|
||||
|
||||
export type TimeEntriesGroupedByType = TimeEntry & { timeEntries: TimeEntry[] };
|
||||
@@ -128,6 +128,13 @@ export type UpdateOrganizationBody = ZodiosBodyByAlias<
|
||||
'updateOrganization'
|
||||
>;
|
||||
|
||||
export type MyMemberships = ZodiosResponseByAlias<
|
||||
SolidTimeApi,
|
||||
'getMyMemberships'
|
||||
>['data'];
|
||||
|
||||
export type MyMembership = MyMemberships[0];
|
||||
|
||||
export async function fetchToken() {
|
||||
return new Promise((resolve) => {
|
||||
router.reload({
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,15 +76,12 @@ export const useClientsStore = defineStore('clients', () => {
|
||||
if (organization) {
|
||||
await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.deleteClient(
|
||||
{},
|
||||
{
|
||||
params: {
|
||||
organization: organization,
|
||||
client: clientId,
|
||||
},
|
||||
}
|
||||
),
|
||||
api.deleteClient(undefined, {
|
||||
params: {
|
||||
organization: organization,
|
||||
client: clientId,
|
||||
},
|
||||
}),
|
||||
'Client deleted successfully',
|
||||
'Failed to delete client'
|
||||
);
|
||||
|
||||
@@ -36,15 +36,12 @@ export const useMembersStore = defineStore('members', () => {
|
||||
if (organization) {
|
||||
await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.removeMember(
|
||||
{},
|
||||
{
|
||||
params: {
|
||||
organization: organization,
|
||||
member: membershipId,
|
||||
},
|
||||
}
|
||||
),
|
||||
api.removeMember(undefined, {
|
||||
params: {
|
||||
organization: organization,
|
||||
member: membershipId,
|
||||
},
|
||||
}),
|
||||
'Member deleted successfully',
|
||||
'Failed to delete member'
|
||||
);
|
||||
|
||||
@@ -81,15 +81,12 @@ export const useProjectMembersStore = defineStore('project-members', () => {
|
||||
if (organizationId) {
|
||||
await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.deleteProjectMember(
|
||||
{},
|
||||
{
|
||||
params: {
|
||||
organization: organizationId,
|
||||
projectMember: projectMemberId,
|
||||
},
|
||||
}
|
||||
),
|
||||
api.deleteProjectMember(undefined, {
|
||||
params: {
|
||||
organization: organizationId,
|
||||
projectMember: projectMemberId,
|
||||
},
|
||||
}),
|
||||
'Project member removed successfully',
|
||||
'Failed to remove project member'
|
||||
);
|
||||
|
||||
@@ -37,7 +37,7 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
async function createProject(projectBody: CreateProjectBody) {
|
||||
const organization = getCurrentOrganizationId();
|
||||
if (organization) {
|
||||
await handleApiRequestNotifications(
|
||||
const response = await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.createProject(projectBody, {
|
||||
params: {
|
||||
@@ -49,6 +49,7 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
);
|
||||
|
||||
await fetchProjects();
|
||||
return response['data'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,15 +58,12 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
if (organizationId) {
|
||||
await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.deleteProject(
|
||||
{},
|
||||
{
|
||||
params: {
|
||||
organization: organizationId,
|
||||
project: projectId,
|
||||
},
|
||||
}
|
||||
),
|
||||
api.deleteProject(undefined, {
|
||||
params: {
|
||||
organization: organizationId,
|
||||
project: projectId,
|
||||
},
|
||||
}),
|
||||
'Project deleted successfully',
|
||||
'Failed to delete project'
|
||||
);
|
||||
|
||||
@@ -36,15 +36,12 @@ export const useTagsStore = defineStore('tags', () => {
|
||||
if (organizationId) {
|
||||
await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.deleteTag(
|
||||
{},
|
||||
{
|
||||
params: {
|
||||
organization: organizationId,
|
||||
tag: tagId,
|
||||
},
|
||||
}
|
||||
),
|
||||
api.deleteTag(undefined, {
|
||||
params: {
|
||||
organization: organizationId,
|
||||
tag: tagId,
|
||||
},
|
||||
}),
|
||||
'Tag deleted successfully',
|
||||
'Failed to delete tag'
|
||||
);
|
||||
|
||||
@@ -68,15 +68,12 @@ export const useTasksStore = defineStore('tasks', () => {
|
||||
if (organizationId) {
|
||||
await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.deleteTask(
|
||||
{},
|
||||
{
|
||||
params: {
|
||||
organization: organizationId,
|
||||
task: taskId,
|
||||
},
|
||||
}
|
||||
),
|
||||
api.deleteTask(undefined, {
|
||||
params: {
|
||||
organization: organizationId,
|
||||
task: taskId,
|
||||
},
|
||||
}),
|
||||
'Task deleted successfully',
|
||||
'Failed to delete task'
|
||||
);
|
||||
|
||||
@@ -9,8 +9,6 @@ import type { CreateTimeEntryBody, TimeEntry } from '@/utils/api';
|
||||
import dayjs from 'dayjs';
|
||||
import { useNotificationsStore } from '@/utils/notification';
|
||||
|
||||
export type TimeEntriesGroupedByType = TimeEntry & { timeEntries: TimeEntry[] };
|
||||
|
||||
export const useTimeEntriesStore = defineStore('timeEntries', () => {
|
||||
const timeEntries = ref<TimeEntry[]>(reactive([]));
|
||||
|
||||
@@ -124,15 +122,12 @@ export const useTimeEntriesStore = defineStore('timeEntries', () => {
|
||||
if (organizationId) {
|
||||
await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.deleteTimeEntry(
|
||||
{},
|
||||
{
|
||||
params: {
|
||||
organization: organizationId,
|
||||
timeEntry: timeEntryId,
|
||||
},
|
||||
}
|
||||
),
|
||||
api.deleteTimeEntry(undefined, {
|
||||
params: {
|
||||
organization: organizationId,
|
||||
timeEntry: timeEntryId,
|
||||
},
|
||||
}),
|
||||
'Time entry deleted successfully',
|
||||
'Failed to delete time entry'
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user