mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02:15 +01:00
add calendar view
This commit is contained in:
64
package-lock.json
generated
64
package-lock.json
generated
@@ -7,6 +7,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/core": "^1.6.0",
|
"@floating-ui/core": "^1.6.0",
|
||||||
"@floating-ui/vue": "^1.0.6",
|
"@floating-ui/vue": "^1.0.6",
|
||||||
|
"@fullcalendar/core": "^6.1.18",
|
||||||
|
"@fullcalendar/daygrid": "^6.1.18",
|
||||||
|
"@fullcalendar/interaction": "^6.1.18",
|
||||||
|
"@fullcalendar/timegrid": "^6.1.18",
|
||||||
|
"@fullcalendar/vue3": "^6.1.18",
|
||||||
"@heroicons/vue": "^2.1.1",
|
"@heroicons/vue": "^2.1.1",
|
||||||
"@rushstack/eslint-patch": "^1.10.5",
|
"@rushstack/eslint-patch": "^1.10.5",
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
@@ -1027,6 +1032,55 @@
|
|||||||
"vue-demi": ">=0.13.0"
|
"vue-demi": ">=0.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fullcalendar/core": {
|
||||||
|
"version": "6.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.18.tgz",
|
||||||
|
"integrity": "sha512-cD7XtZIZZ87Cg2+itnpsONCsZ89VIfLLDZ22pQX4IQVWlpYUB3bcCf878DhWkqyEen6dhi5ePtBoqYgm5K+0fQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"preact": "~10.12.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fullcalendar/daygrid": {
|
||||||
|
"version": "6.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.18.tgz",
|
||||||
|
"integrity": "sha512-s452Zle1SdMEzZDw+pDczm8m3JLIZzS9ANMThXTnqeqJewW1gqNFYas18aHypJSgF9Fh9rDJjTSUw04BpXB/Mg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@fullcalendar/core": "~6.1.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fullcalendar/interaction": {
|
||||||
|
"version": "6.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.18.tgz",
|
||||||
|
"integrity": "sha512-f/mD5RTjzw+Q6MGTMZrLCgIrQLIUUO9NV/58aM2J6ZBQZeRlNizDqmqldqyG+j49zj2vFhUfZibPrVKWm5yA4Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@fullcalendar/core": "~6.1.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fullcalendar/timegrid": {
|
||||||
|
"version": "6.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.18.tgz",
|
||||||
|
"integrity": "sha512-T/ouhs+T1tM8JcW7Cjx+KiohL/qQWKqvRITwjol8ktJ1e1N/6noC40/obR1tyolqOxMRWHjJkYoj9fUqfoez9A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@fullcalendar/daygrid": "~6.1.18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@fullcalendar/core": "~6.1.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fullcalendar/vue3": {
|
||||||
|
"version": "6.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fullcalendar/vue3/-/vue3-6.1.18.tgz",
|
||||||
|
"integrity": "sha512-YMagwTumxsIx3GFYWLa9Yr73EMA+JuH6S3EeZGS+rEjvG5fDGdf+33rxGMzmw+LdO7SWi3ctbzRnJlv3fnm3RQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@fullcalendar/core": "~6.1.18",
|
||||||
|
"vue": "^3.0.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@heroicons/vue": {
|
"node_modules/@heroicons/vue": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz",
|
||||||
@@ -5140,6 +5194,16 @@
|
|||||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/preact": {
|
||||||
|
"version": "10.12.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
|
||||||
|
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/preact"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
|
|||||||
@@ -39,6 +39,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/core": "^1.6.0",
|
"@floating-ui/core": "^1.6.0",
|
||||||
"@floating-ui/vue": "^1.0.6",
|
"@floating-ui/vue": "^1.0.6",
|
||||||
|
"@fullcalendar/core": "^6.1.18",
|
||||||
|
"@fullcalendar/daygrid": "^6.1.18",
|
||||||
|
"@fullcalendar/interaction": "^6.1.18",
|
||||||
|
"@fullcalendar/timegrid": "^6.1.18",
|
||||||
|
"@fullcalendar/vue3": "^6.1.18",
|
||||||
"@heroicons/vue": "^2.1.1",
|
"@heroicons/vue": "^2.1.1",
|
||||||
"@rushstack/eslint-patch": "^1.10.5",
|
"@rushstack/eslint-patch": "^1.10.5",
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
|
|||||||
@@ -46,14 +46,16 @@
|
|||||||
|
|
||||||
--color-accent-default: rgba(var(--color-accent-300), 0.2);
|
--color-accent-default: rgba(var(--color-accent-300), 0.2);
|
||||||
--color-accent-foreground: rgb(var(--color-accent-100));
|
--color-accent-foreground: rgb(var(--color-accent-100));
|
||||||
|
--theme-color-default-background: var(--color-bg-primary);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:root.light {
|
:root.light {
|
||||||
--color-bg-primary: #F5F5F5;
|
--color-bg-primary: #FFFFFF;
|
||||||
--color-bg-secondary: #f7f7f8;
|
--color-bg-secondary: #f7f7f8;
|
||||||
--color-bg-tertiary: #e1e1e3;
|
--color-bg-tertiary: #e1e1e3;
|
||||||
--color-bg-quaternary: #ffffff;
|
--color-bg-quaternary: #ffffff;
|
||||||
--color-bg-background: #ffffff;
|
--color-bg-background: #F5F5F5;
|
||||||
--color-text-primary: #18181b;
|
--color-text-primary: #18181b;
|
||||||
--color-text-secondary: #3f3f46;
|
--color-text-secondary: #3f3f46;
|
||||||
--color-text-tertiary: #57575C;
|
--color-text-tertiary: #57575C;
|
||||||
@@ -70,7 +72,7 @@
|
|||||||
|
|
||||||
--theme-color-chart: var(--color-accent-400);
|
--theme-color-chart: var(--color-accent-400);
|
||||||
|
|
||||||
--theme-shadow-card: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
--theme-shadow-card: lch(0 0 0 / 0.022) 0px 3px 6px -2px, lch(0 0 0 / 0.044) 0px 1px 1px;
|
||||||
--theme-shadow-dropdown: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
--theme-shadow-dropdown: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||||
|
|
||||||
--theme-color-row-background: var(--theme-color-card-background);
|
--theme-color-row-background: var(--theme-color-card-background);
|
||||||
@@ -92,10 +94,11 @@
|
|||||||
|
|
||||||
--color-accent-default: rgb(var(--color-accent-100));
|
--color-accent-default: rgb(var(--color-accent-100));
|
||||||
--color-accent-foreground: rgb(var(--color-accent-800));
|
--color-accent-foreground: rgb(var(--color-accent-800));
|
||||||
|
--theme-color-default-background: #FCFCFC;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--theme-color-default-background: var(--color-bg-primary);
|
|
||||||
--theme-color-icon-active: rgb(var(--color-text-tertiary));
|
--theme-color-icon-active: rgb(var(--color-text-tertiary));
|
||||||
--theme-color-card-background-separator: var(--color-border-tertiary);
|
--theme-color-card-background-separator: var(--color-border-tertiary);
|
||||||
--theme-color-card-border: var(--color-border-secondary);
|
--theme-color-card-border: var(--color-border-secondary);
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import SecondaryButton from "@/packages/ui/src/Buttons/SecondaryButton.vue";
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
import DialogModal from "@/packages/ui/src/DialogModal.vue";
|
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
||||||
import PrimaryButton from "@/packages/ui/src/Buttons/PrimaryButton.vue";
|
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||||
import { onMounted, ref } from "vue";
|
import { onMounted, ref } from 'vue';
|
||||||
import { getUserTimezone } from "@/packages/ui/src/utils/settings";
|
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
|
||||||
import { getDayJsInstance } from "@/packages/ui/src/utils/time";
|
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
|
||||||
import { useForm, usePage } from "@inertiajs/vue3";
|
import { useForm, usePage } from '@inertiajs/vue3';
|
||||||
import type { User } from "@/types/models";
|
import type { User } from '@/types/models';
|
||||||
import { useSessionStorage } from "@vueuse/core";
|
import { useSessionStorage } from '@vueuse/core';
|
||||||
|
|
||||||
const show = defineModel("show", { default: false });
|
const show = defineModel('show', { default: false });
|
||||||
const saving = defineModel("saving", { default: false });
|
const saving = defineModel('saving', { default: false });
|
||||||
|
|
||||||
const timezone = ref("");
|
const timezone = ref('');
|
||||||
const userTimezone = ref("");
|
const userTimezone = ref('');
|
||||||
|
|
||||||
const page = usePage<{
|
const page = usePage<{
|
||||||
auth: {
|
auth: {
|
||||||
@@ -21,23 +21,22 @@ const page = usePage<{
|
|||||||
};
|
};
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const hideTimezoneMismatchModal = useSessionStorage<boolean>(
|
const hideTimezoneMismatchModal = useSessionStorage<boolean>('hide-timezone-mismatch-modal', false);
|
||||||
'hide-timezone-mismatch-modal',
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
timezone.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
timezone.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
userTimezone.value = getUserTimezone();
|
userTimezone.value = getUserTimezone();
|
||||||
|
|
||||||
if(getDayJsInstance()().tz(timezone.value).format() !== getDayJsInstance()().tz(userTimezone.value).format()
|
if (
|
||||||
&& !hideTimezoneMismatchModal.value
|
getDayJsInstance()().tz(timezone.value).format() !==
|
||||||
|
getDayJsInstance()().tz(userTimezone.value).format() &&
|
||||||
|
!hideTimezoneMismatchModal.value
|
||||||
) {
|
) {
|
||||||
show.value = true;
|
show.value = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function submit(){
|
function submit() {
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
_method: 'PUT',
|
_method: 'PUT',
|
||||||
@@ -54,15 +53,14 @@ function submit(){
|
|||||||
saving.value = false;
|
saving.value = false;
|
||||||
show.value = false;
|
show.value = false;
|
||||||
location.reload();
|
location.reload();
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancel(){
|
function cancel() {
|
||||||
show.value = false;
|
show.value = false;
|
||||||
hideTimezoneMismatchModal.value = true;
|
hideTimezoneMismatchModal.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -76,13 +74,18 @@ function cancel(){
|
|||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<div class="col-span-6 sm:col-span-4 flex-1 space-y-2">
|
<div class="col-span-6 sm:col-span-4 flex-1 space-y-2">
|
||||||
<p>
|
<p>
|
||||||
The timezone of your device does not match the timezone in your user settings. <br>
|
The timezone of your device does not match the timezone in your user
|
||||||
<strong>We highly recommend that you update your timezone settings to your current
|
settings. <br />
|
||||||
timezone.</strong>
|
<strong
|
||||||
|
>We highly recommend that you update your timezone settings to your
|
||||||
|
current timezone.</strong
|
||||||
|
>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Want to change your timezone setting from <strong>{{ userTimezone }}</strong> to <strong>{{ timezone }}</strong>.
|
Want to change your timezone setting from
|
||||||
|
<strong>{{ userTimezone }}</strong> to <strong>{{ timezone }}</strong
|
||||||
|
>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import OrganizationSwitcher from '@/Components/OrganizationSwitcher.vue';
|
|||||||
import CurrentSidebarTimer from '@/Components/CurrentSidebarTimer.vue';
|
import CurrentSidebarTimer from '@/Components/CurrentSidebarTimer.vue';
|
||||||
import {
|
import {
|
||||||
Bars3Icon,
|
Bars3Icon,
|
||||||
|
CalendarIcon,
|
||||||
ChartBarIcon,
|
ChartBarIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
Cog6ToothIcon,
|
Cog6ToothIcon,
|
||||||
@@ -39,15 +40,17 @@ import { ArrowsRightLeftIcon } from '@heroicons/vue/16/solid';
|
|||||||
import { fetchToken, isTokenValid } from '@/utils/session';
|
import { fetchToken, isTokenValid } from '@/utils/session';
|
||||||
import UpdateSidebarNotification from '@/Components/UpdateSidebarNotification.vue';
|
import UpdateSidebarNotification from '@/Components/UpdateSidebarNotification.vue';
|
||||||
import BillingBanner from '@/Components/Billing/BillingBanner.vue';
|
import BillingBanner from '@/Components/Billing/BillingBanner.vue';
|
||||||
import UserTimezoneMismatchModal from "@/Components/Common/User/UserTimezoneMismatchModal.vue";
|
import UserTimezoneMismatchModal from '@/Components/Common/User/UserTimezoneMismatchModal.vue';
|
||||||
import { useTheme } from '@/utils/theme';
|
import { useTheme } from '@/utils/theme';
|
||||||
import { useQuery } from '@tanstack/vue-query';
|
import { useQuery } from '@tanstack/vue-query';
|
||||||
import { api } from '@/packages/api/src';
|
import { api } from '@/packages/api/src';
|
||||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
title: String,
|
title: String,
|
||||||
|
mainClass: String,
|
||||||
});
|
});
|
||||||
|
|
||||||
const showSidebarMenu = ref(false);
|
const showSidebarMenu = ref(false);
|
||||||
@@ -132,6 +135,11 @@ const page = usePage<{
|
|||||||
:icon="ClockIcon"
|
:icon="ClockIcon"
|
||||||
:current="route().current('time')"
|
:current="route().current('time')"
|
||||||
:href="route('time')"></NavigationSidebarItem>
|
:href="route('time')"></NavigationSidebarItem>
|
||||||
|
<NavigationSidebarItem
|
||||||
|
title="Calendar"
|
||||||
|
:icon="CalendarIcon"
|
||||||
|
:current="route().current('calendar')"
|
||||||
|
:href="route('calendar')"></NavigationSidebarItem>
|
||||||
<NavigationSidebarItem
|
<NavigationSidebarItem
|
||||||
title="Reporting"
|
title="Reporting"
|
||||||
:icon="ChartBarIcon"
|
:icon="ChartBarIcon"
|
||||||
@@ -274,7 +282,7 @@ const page = usePage<{
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Page Content -->
|
<!-- Page Content -->
|
||||||
<main class="pb-28 flex-1">
|
<main :class="twMerge('pb-28 flex-1', mainClass)">
|
||||||
<div
|
<div
|
||||||
v-if="isOrganizationLoading"
|
v-if="isOrganizationLoading"
|
||||||
class="flex items-center justify-center h-screen">
|
class="flex items-center justify-center h-screen">
|
||||||
|
|||||||
177
resources/js/Pages/Calendar.vue
Normal file
177
resources/js/Pages/Calendar.vue
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/vue-query';
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
type Client,
|
||||||
|
type CreateClientBody,
|
||||||
|
type CreateProjectBody,
|
||||||
|
type Project,
|
||||||
|
type TimeEntryResponse,
|
||||||
|
} from '@/packages/api/src';
|
||||||
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
|
||||||
|
import { TimeEntryCalendar } from '@/packages/ui/src';
|
||||||
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||||
|
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
|
||||||
|
import { useTagsStore } from '@/utils/useTags';
|
||||||
|
import { useProjectsStore } from '@/utils/useProjects';
|
||||||
|
import { useClientsStore } from '@/utils/useClients';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { useTasksStore } from '@/utils/useTasks';
|
||||||
|
|
||||||
|
const calendarStart = ref<Date | undefined>(undefined);
|
||||||
|
const calendarEnd = ref<Date | undefined>(undefined);
|
||||||
|
|
||||||
|
const enableCalendarQuery = computed(() => {
|
||||||
|
return !!getCurrentOrganizationId() && !!calendarStart.value && !!calendarEnd.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: timeEntryResponse, isLoading: timeEntriesLoading } = useQuery<TimeEntryResponse>({
|
||||||
|
queryKey: computed(() => [
|
||||||
|
'timeEntry',
|
||||||
|
'calendar',
|
||||||
|
{
|
||||||
|
start: calendarStart.value
|
||||||
|
? getDayJsInstance()(calendarStart.value).utc().format()
|
||||||
|
: null,
|
||||||
|
end: calendarEnd.value ? getDayJsInstance()(calendarEnd.value).utc().format() : null,
|
||||||
|
organization: getCurrentOrganizationId(),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
enabled: enableCalendarQuery,
|
||||||
|
queryFn: () =>
|
||||||
|
api.getTimeEntries({
|
||||||
|
params: {
|
||||||
|
organization: getCurrentOrganizationId() || '',
|
||||||
|
},
|
||||||
|
queries: {
|
||||||
|
start: getDayJsInstance()(calendarStart.value).utc().format(),
|
||||||
|
end: getDayJsInstance()(calendarEnd.value).utc().format(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentTimeEntries = computed(() => {
|
||||||
|
return timeEntryResponse?.value?.data || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const { createTimeEntry, updateTimeEntry, deleteTimeEntry } = useTimeEntriesStore();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectStore = useProjectsStore();
|
||||||
|
const { projects } = storeToRefs(projectStore);
|
||||||
|
const taskStore = useTasksStore();
|
||||||
|
const { tasks } = storeToRefs(taskStore);
|
||||||
|
const clientStore = useClientsStore();
|
||||||
|
const { clients } = storeToRefs(clientStore);
|
||||||
|
const tagsStore = useTagsStore();
|
||||||
|
const { tags } = storeToRefs(tagsStore);
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Helper functions to calculate adjacent date ranges for prefetching
|
||||||
|
function calculatePreviousRange(start: Date, end: Date): { start: Date; end: Date } {
|
||||||
|
const dayjs = getDayJsInstance();
|
||||||
|
const duration = dayjs(end).diff(dayjs(start), 'milliseconds');
|
||||||
|
const previousEnd = dayjs(start);
|
||||||
|
const previousStart = previousEnd.subtract(duration, 'milliseconds');
|
||||||
|
return {
|
||||||
|
start: previousStart.toDate(),
|
||||||
|
end: previousEnd.toDate(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateNextRange(start: Date, end: Date): { start: Date; end: Date } {
|
||||||
|
const dayjs = getDayJsInstance();
|
||||||
|
const duration = dayjs(end).diff(dayjs(start), 'milliseconds');
|
||||||
|
const nextStart = dayjs(end);
|
||||||
|
const nextEnd = nextStart.add(duration, 'milliseconds');
|
||||||
|
return {
|
||||||
|
start: nextStart.toDate(),
|
||||||
|
end: nextEnd.toDate(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefetch function for time entries
|
||||||
|
async function prefetchTimeEntries(start: Date, end: Date) {
|
||||||
|
if (!getCurrentOrganizationId()) return;
|
||||||
|
|
||||||
|
const startFormatted = getDayJsInstance()(start).utc().format();
|
||||||
|
const endFormatted = getDayJsInstance()(end).utc().format();
|
||||||
|
|
||||||
|
await queryClient.prefetchQuery({
|
||||||
|
queryKey: [
|
||||||
|
'timeEntry',
|
||||||
|
'calendar',
|
||||||
|
{
|
||||||
|
start: startFormatted,
|
||||||
|
end: endFormatted,
|
||||||
|
organization: getCurrentOrganizationId(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
queryFn: () =>
|
||||||
|
api.getTimeEntries({
|
||||||
|
params: {
|
||||||
|
organization: getCurrentOrganizationId() || '',
|
||||||
|
},
|
||||||
|
queries: {
|
||||||
|
start: startFormatted,
|
||||||
|
end: endFormatted,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDatesChange({ start, end }: { start: Date; end: Date }) {
|
||||||
|
calendarStart.value = start;
|
||||||
|
calendarEnd.value = end;
|
||||||
|
|
||||||
|
// Prefetch adjacent time ranges for better UX
|
||||||
|
const previousRange = calculatePreviousRange(start, end);
|
||||||
|
const nextRange = calculateNextRange(start, end);
|
||||||
|
|
||||||
|
// Prefetch previous and next ranges
|
||||||
|
prefetchTimeEntries(previousRange.start, previousRange.end);
|
||||||
|
prefetchTimeEntries(nextRange.start, nextRange.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRefresh() {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['timeEntry', 'calendar'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppLayout title="Calendar" data-testid="calendar_view" main-class="p-0">
|
||||||
|
<TimeEntryCalendar
|
||||||
|
:time-entries="currentTimeEntries"
|
||||||
|
:projects="projects"
|
||||||
|
:tasks="tasks"
|
||||||
|
:clients="clients"
|
||||||
|
:tags="tags"
|
||||||
|
:loading="timeEntriesLoading"
|
||||||
|
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
||||||
|
:create-time-entry="createTimeEntry"
|
||||||
|
:update-time-entry="updateTimeEntry"
|
||||||
|
:delete-time-entry="deleteTimeEntry"
|
||||||
|
:create-client="createClient"
|
||||||
|
:create-project="createProject"
|
||||||
|
:create-tag="createTag"
|
||||||
|
@dates-change="onDatesChange"
|
||||||
|
@refresh="onRefresh" />
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
@@ -10,7 +10,8 @@ const props = withDefaults(
|
|||||||
icon?: Component;
|
icon?: Component;
|
||||||
size?: 'small' | 'base';
|
size?: 'small' | 'base';
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
class?: string;
|
// Accept any valid Vue class binding shape (string | object | array)
|
||||||
|
class?: Parameters<typeof twMerge>[0];
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
type: 'button',
|
type: 'button',
|
||||||
|
|||||||
27
resources/js/packages/ui/src/Calendar/CalendarDayHeader.vue
Normal file
27
resources/js/packages/ui/src/Calendar/CalendarDayHeader.vue
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, inject, type ComputedRef } from 'vue';
|
||||||
|
import { formatDate, formatHumanReadableDuration } from '../utils/time';
|
||||||
|
import type { Organization } from '@/packages/api/src';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
date: Date;
|
||||||
|
totalMinutes?: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const totalSeconds = computed(() => (props.totalMinutes ?? 0) * 60);
|
||||||
|
|
||||||
|
// Injected organization for formatting settings
|
||||||
|
const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined;
|
||||||
|
const intervalFormat = computed(() => organization?.value?.interval_format);
|
||||||
|
const numberFormat = computed(() => organization?.value?.number_format);
|
||||||
|
const dateFormat = computed(() => organization?.value?.date_format);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="fc-day-header-custom">
|
||||||
|
<span>{{ formatDate(date.toISOString(), dateFormat) }}</span>
|
||||||
|
<span class="block text-xs text-muted-foreground font-medium mt-1">
|
||||||
|
{{ formatHumanReadableDuration(totalSeconds, intervalFormat, numberFormat) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, inject, type ComputedRef } from 'vue';
|
||||||
|
import { formatHumanReadableDuration, getDayJsInstance } from '../utils/time';
|
||||||
|
import type { Organization } from '@/packages/api/src';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
title: string;
|
||||||
|
projectName?: string | null;
|
||||||
|
taskName?: string | null;
|
||||||
|
clientName?: string | null;
|
||||||
|
durationSeconds?: number;
|
||||||
|
start?: string | Date | null;
|
||||||
|
end?: string | Date | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const effectiveDurationSeconds = computed(() => {
|
||||||
|
if (typeof props.durationSeconds === 'number') {
|
||||||
|
return props.durationSeconds;
|
||||||
|
}
|
||||||
|
if (props.start && props.end) {
|
||||||
|
const end = getDayJsInstance()(props.end as unknown as string | Date);
|
||||||
|
const start = getDayJsInstance()(props.start as unknown as string | Date);
|
||||||
|
const minutes = end.diff(start, 'minutes');
|
||||||
|
return minutes * 60;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined;
|
||||||
|
const intervalFormat = computed(() => organization?.value?.interval_format);
|
||||||
|
const numberFormat = computed(() => organization?.value?.number_format);
|
||||||
|
|
||||||
|
const formattedDuration = computed(() =>
|
||||||
|
formatHumanReadableDuration(
|
||||||
|
effectiveDurationSeconds.value,
|
||||||
|
intervalFormat.value,
|
||||||
|
numberFormat.value
|
||||||
|
)
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="text-xs leading-tight">
|
||||||
|
<div class="font-semibold mb-0.5">{{ title }}</div>
|
||||||
|
<div v-if="projectName" class="font-medium text-[0.6875rem] opacity-90">
|
||||||
|
{{ projectName }}
|
||||||
|
</div>
|
||||||
|
<div v-if="taskName" class="font-medium text-[0.6875rem] opacity-90">
|
||||||
|
{{ taskName }}
|
||||||
|
</div>
|
||||||
|
<div v-if="clientName" class="text-[0.625rem] italic opacity-85">
|
||||||
|
{{ clientName }}
|
||||||
|
</div>
|
||||||
|
<div class="text-[0.625rem] font-semibold opacity-90 mt-0.5">
|
||||||
|
{{ formattedDuration }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
540
resources/js/packages/ui/src/Calendar/TimeEntryCalendar.vue
Normal file
540
resources/js/packages/ui/src/Calendar/TimeEntryCalendar.vue
Normal file
@@ -0,0 +1,540 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import FullCalendar from '@fullcalendar/vue3';
|
||||||
|
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||||
|
import timeGridPlugin from '@fullcalendar/timegrid';
|
||||||
|
import interactionPlugin from '@fullcalendar/interaction';
|
||||||
|
import type { DatesSetArg, EventClickArg, EventDropArg, EventChangeArg } from '@fullcalendar/core';
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
|
||||||
|
import { getUserTimezone } from '../utils/settings';
|
||||||
|
import { LoadingSpinner, TimeEntryCreateModal, TimeEntryEditModal } from '..';
|
||||||
|
import CalendarEventContent from './CalendarEventContent.vue';
|
||||||
|
import CalendarDayHeader from './CalendarDayHeader.vue';
|
||||||
|
import type {
|
||||||
|
TimeEntry,
|
||||||
|
Project,
|
||||||
|
Client,
|
||||||
|
Task,
|
||||||
|
CreateProjectBody,
|
||||||
|
CreateClientBody,
|
||||||
|
Tag,
|
||||||
|
} from '@/packages/api/src';
|
||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
|
||||||
|
type CalendarExtendedProps = { timeEntry: TimeEntry } & Record<string, unknown>;
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'dates-change', payload: { start: Date; end: Date }): void;
|
||||||
|
(e: 'refresh'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
timeEntries: TimeEntry[];
|
||||||
|
projects: Project[];
|
||||||
|
tasks: Task[];
|
||||||
|
clients: Client[];
|
||||||
|
tags: Tag[];
|
||||||
|
loading?: boolean;
|
||||||
|
|
||||||
|
// Permissions / feature flags
|
||||||
|
enableEstimatedTime: boolean;
|
||||||
|
|
||||||
|
createTimeEntry: (
|
||||||
|
entry: Omit<TimeEntry, 'id' | 'organization_id' | 'user_id'>
|
||||||
|
) => Promise<void>;
|
||||||
|
updateTimeEntry: (entry: TimeEntry) => Promise<void>;
|
||||||
|
deleteTimeEntry: (timeEntryId: string) => Promise<void>;
|
||||||
|
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
||||||
|
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||||
|
createTag: (name: string) => Promise<Tag | undefined>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Local component state
|
||||||
|
const newEventStart = ref<Dayjs | null>(null);
|
||||||
|
const newEventEnd = ref<Dayjs | null>(null);
|
||||||
|
const showCreateTimeEntryModal = ref<boolean>(false);
|
||||||
|
const showEditTimeEntryModal = ref<boolean>(false);
|
||||||
|
const selectedTimeEntry = ref<TimeEntry | null>(null);
|
||||||
|
|
||||||
|
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);
|
||||||
|
|
||||||
|
// Map API entries to FullCalendar events
|
||||||
|
const events = computed(() => {
|
||||||
|
return props.timeEntries
|
||||||
|
?.filter((timeEntry) => timeEntry.end !== null)
|
||||||
|
?.map((timeEntry) => {
|
||||||
|
const project = props.projects.find((p) => p.id === timeEntry.project_id);
|
||||||
|
const client = props.clients.find((c) => c.id === project?.client_id);
|
||||||
|
const task = props.tasks.find((t) => t.id === timeEntry.task_id);
|
||||||
|
const duration = getDayJsInstance()(timeEntry.end!).diff(
|
||||||
|
getDayJsInstance()(timeEntry.start),
|
||||||
|
'minutes'
|
||||||
|
);
|
||||||
|
|
||||||
|
const title = timeEntry.description || 'No description';
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: timeEntry.id,
|
||||||
|
start: getLocalizedDayJs(timeEntry.start).format(),
|
||||||
|
end: getLocalizedDayJs(timeEntry.end!).format(),
|
||||||
|
title,
|
||||||
|
backgroundColor: project?.color || '#6B7280',
|
||||||
|
borderColor: project?.color || '#6B7280',
|
||||||
|
textColor: '#FFFFFF',
|
||||||
|
extendedProps: {
|
||||||
|
timeEntry,
|
||||||
|
project,
|
||||||
|
client,
|
||||||
|
task,
|
||||||
|
duration,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Daily totals used in day header
|
||||||
|
const dailyTotals = computed(() => {
|
||||||
|
const totals: Record<string, number> = {};
|
||||||
|
props.timeEntries
|
||||||
|
.filter((entry) => entry.end !== null)
|
||||||
|
.forEach((entry) => {
|
||||||
|
const date = getDayJsInstance()(entry.start).format('YYYY-MM-DD');
|
||||||
|
const duration = getDayJsInstance()(entry.end!).diff(
|
||||||
|
getDayJsInstance()(entry.start),
|
||||||
|
'minutes'
|
||||||
|
);
|
||||||
|
totals[date] = (totals[date] || 0) + duration;
|
||||||
|
});
|
||||||
|
return totals;
|
||||||
|
});
|
||||||
|
|
||||||
|
function emitDatesChange(arg: DatesSetArg) {
|
||||||
|
emit('dates-change', { start: arg.start, end: arg.end });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDateSelect(arg: { start: Date; end: Date }) {
|
||||||
|
const startTime = getDayJsInstance()(arg.start.toISOString())
|
||||||
|
.utc()
|
||||||
|
.tz(getUserTimezone(), true)
|
||||||
|
.utc();
|
||||||
|
let endTime = getDayJsInstance()(arg.end.toISOString()).utc().tz(getUserTimezone(), true).utc();
|
||||||
|
const timeDiff = endTime.diff(startTime, 'minutes');
|
||||||
|
if (timeDiff <= 15) {
|
||||||
|
endTime = startTime.clone().add(1, 'hour');
|
||||||
|
}
|
||||||
|
newEventStart.value = startTime;
|
||||||
|
newEventEnd.value = endTime;
|
||||||
|
showCreateTimeEntryModal.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEventClick(arg: EventClickArg) {
|
||||||
|
const ext = arg.event.extendedProps as CalendarExtendedProps;
|
||||||
|
selectedTimeEntry.value = ext.timeEntry;
|
||||||
|
showEditTimeEntryModal.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEventDrop(arg: EventDropArg) {
|
||||||
|
const ext = arg.event.extendedProps as CalendarExtendedProps;
|
||||||
|
const timeEntry = ext.timeEntry;
|
||||||
|
if (!arg.event.start || !arg.event.end) return;
|
||||||
|
const updatedTimeEntry = {
|
||||||
|
...timeEntry,
|
||||||
|
start: getDayJsInstance()(arg.event.start.toISOString())
|
||||||
|
.utc()
|
||||||
|
.tz(getUserTimezone(), true)
|
||||||
|
.utc()
|
||||||
|
.format(),
|
||||||
|
end: getDayJsInstance()(arg.event.end.toISOString())
|
||||||
|
.utc()
|
||||||
|
.tz(getUserTimezone(), true)
|
||||||
|
.utc()
|
||||||
|
.format(),
|
||||||
|
} as TimeEntry;
|
||||||
|
await props.updateTimeEntry(updatedTimeEntry);
|
||||||
|
emit('refresh');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEventResize(arg: EventChangeArg) {
|
||||||
|
const ext = arg.event.extendedProps as CalendarExtendedProps;
|
||||||
|
const timeEntry = ext.timeEntry;
|
||||||
|
if (!arg.event.start || !arg.event.end) return;
|
||||||
|
const updatedTimeEntry = {
|
||||||
|
...timeEntry,
|
||||||
|
start: getDayJsInstance()(arg.event.start.toISOString())
|
||||||
|
.utc()
|
||||||
|
.tz(getUserTimezone(), true)
|
||||||
|
.utc()
|
||||||
|
.format(),
|
||||||
|
end: getDayJsInstance()(arg.event.end.toISOString())
|
||||||
|
.utc()
|
||||||
|
.tz(getUserTimezone(), true)
|
||||||
|
.utc()
|
||||||
|
.format(),
|
||||||
|
} as TimeEntry;
|
||||||
|
await props.updateTimeEntry(updatedTimeEntry);
|
||||||
|
emit('refresh');
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendarOptions = computed(() => ({
|
||||||
|
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
|
||||||
|
initialView: 'timeGridWeek',
|
||||||
|
headerToolbar: {
|
||||||
|
left: 'prev,next today',
|
||||||
|
center: 'title',
|
||||||
|
right: 'timeGridWeek,timeGridDay',
|
||||||
|
},
|
||||||
|
height: '100vh',
|
||||||
|
slotMinTime: '00:00:00',
|
||||||
|
slotMaxTime: '24:00:00',
|
||||||
|
slotDuration: '00:15:00',
|
||||||
|
slotLabelInterval: '01:00:00',
|
||||||
|
snapDuration: '00:15:00',
|
||||||
|
allDaySlot: false,
|
||||||
|
nowIndicator: true,
|
||||||
|
selectable: true,
|
||||||
|
selectMirror: true,
|
||||||
|
editable: true,
|
||||||
|
eventResizableFromStart: true,
|
||||||
|
eventDurationEditable: true,
|
||||||
|
timeZone: 'America/Adak',
|
||||||
|
eventStartEditable: true,
|
||||||
|
select: handleDateSelect,
|
||||||
|
eventClick: handleEventClick,
|
||||||
|
eventDrop: handleEventDrop,
|
||||||
|
eventResize: handleEventResize,
|
||||||
|
datesSet: emitDatesChange,
|
||||||
|
|
||||||
|
events: events.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
watch(showCreateTimeEntryModal, (value) => {
|
||||||
|
if (!value) {
|
||||||
|
newEventStart.value = null;
|
||||||
|
newEventEnd.value = null;
|
||||||
|
emit('refresh');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(showEditTimeEntryModal, (value) => {
|
||||||
|
if (!value) {
|
||||||
|
selectedTimeEntry.value = null;
|
||||||
|
emit('refresh');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-screen w-full relative">
|
||||||
|
<div v-if="loading" class="flex items-center justify-center h-full">
|
||||||
|
<div class="flex flex-col items-center space-y-4">
|
||||||
|
<LoadingSpinner class="h-8 w-8" />
|
||||||
|
<p class="text-muted-foreground">Loading calendar data...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TimeEntryCreateModal
|
||||||
|
v-model:show="showCreateTimeEntryModal"
|
||||||
|
:enable-estimated-time="enableEstimatedTime"
|
||||||
|
:create-time-entry="createTimeEntry"
|
||||||
|
:create-client="createClient"
|
||||||
|
:create-project="createProject"
|
||||||
|
:create-tag="createTag"
|
||||||
|
:tags="tags as any"
|
||||||
|
:projects="projects"
|
||||||
|
:tasks="tasks"
|
||||||
|
:clients="clients"
|
||||||
|
:start="newEventStart ? newEventStart.toISOString() : undefined"
|
||||||
|
:end="newEventEnd ? newEventEnd.toISOString() : undefined" />
|
||||||
|
|
||||||
|
<TimeEntryEditModal
|
||||||
|
v-model:show="showEditTimeEntryModal"
|
||||||
|
:time-entry="selectedTimeEntry as any"
|
||||||
|
:enable-estimated-time="enableEstimatedTime"
|
||||||
|
:update-time-entry="updateTimeEntry"
|
||||||
|
:delete-time-entry="deleteTimeEntry"
|
||||||
|
:create-client="createClient"
|
||||||
|
:create-project="createProject"
|
||||||
|
:create-tag="createTag"
|
||||||
|
:tags="tags as any"
|
||||||
|
:projects="projects"
|
||||||
|
:tasks="tasks"
|
||||||
|
:clients="clients" />
|
||||||
|
<FullCalendar ref="calendarRef" class="fullcalendar" :options="calendarOptions">
|
||||||
|
<template #eventContent="arg">
|
||||||
|
<CalendarEventContent
|
||||||
|
:title="arg.event.title"
|
||||||
|
:project-name="(arg.event.extendedProps as any).project?.name"
|
||||||
|
:task-name="(arg.event.extendedProps as any).task?.name"
|
||||||
|
:client-name="(arg.event.extendedProps as any).client?.name"
|
||||||
|
:duration-seconds="
|
||||||
|
((arg.event.extendedProps as any).duration ?? undefined)
|
||||||
|
? (arg.event.extendedProps as any).duration * 60
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
:start="arg.event.start as any"
|
||||||
|
:end="arg.event.end as any" />
|
||||||
|
</template>
|
||||||
|
<template #dayHeaderContent="arg">
|
||||||
|
<CalendarDayHeader
|
||||||
|
:date="arg.date"
|
||||||
|
:total-minutes="
|
||||||
|
dailyTotals[getDayJsInstance()(arg.date).format('YYYY-MM-DD')] || 0
|
||||||
|
" />
|
||||||
|
</template>
|
||||||
|
</FullCalendar>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fullcalendar {
|
||||||
|
height: 100%;
|
||||||
|
--fc-border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FullCalendar theme customization */
|
||||||
|
.fullcalendar :deep(.fc) {
|
||||||
|
background-color: var(--theme-color-default-background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-timegrid-slot) {
|
||||||
|
height: 25px;
|
||||||
|
transition: height 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-timegrid-slot-label) {
|
||||||
|
background-color: var(--theme-color-default-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-toolbar) {
|
||||||
|
background-color: var(--theme-color-default-background);
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-toolbar-title) {
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-button) {
|
||||||
|
background-color: var(--secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-button:hover) {
|
||||||
|
background-color: var(--muted);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-button:focus) {
|
||||||
|
box-shadow: 0 0 0 2px var(--ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-button-active) {
|
||||||
|
background-color: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-col-header) {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-col-header-cell) {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 0.75rem 0.5rem;
|
||||||
|
background-color: var(--theme-color-default-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-timegrid-axis) {
|
||||||
|
background-color: var(--theme-color-default-background) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-col-header-cell .fc-col-header-cell-cushion) {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-timegrid-axis) {
|
||||||
|
background-color: var(--theme-color-default-background);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quarter-hour slots - transparent borders */
|
||||||
|
.fullcalendar :deep(.fc-timegrid-slot-minor.fc-timegrid-slot-label) {
|
||||||
|
border-top: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-timegrid-slot-minor.fc-timegrid-slot-lane) {
|
||||||
|
--tw-border-opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-day-today.fc-col-header-cell) {
|
||||||
|
background-color: var(--color-accent-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-day-today) {
|
||||||
|
background-color: var(--theme-color-default-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-now-indicator) {
|
||||||
|
border-color: var(--primary);
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-event) {
|
||||||
|
border: 1px solid !important;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.45rem 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--theme-shadow-card);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-v-event) {
|
||||||
|
background-color: var(--muted);
|
||||||
|
border-color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-event-title) {
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced FullCalendar resize handles */
|
||||||
|
.fullcalendar :deep(.fc-event-resizer) {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 99;
|
||||||
|
background: '#FFF';
|
||||||
|
border-radius: 2px;
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
left: 0;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-event-resizer-start) {
|
||||||
|
top: -2px;
|
||||||
|
cursor: n-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-event-resizer-end) {
|
||||||
|
bottom: -2px;
|
||||||
|
cursor: s-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-event:hover .fc-event-resizer) {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-event-resizer:hover) {
|
||||||
|
background: '#FFF';
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Update the earlier hover rule to include the shadow */
|
||||||
|
.fullcalendar :deep(.fc-event:hover) {
|
||||||
|
opacity: 1;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: var(--theme-shadow-dropdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-timegrid-event-harness) {
|
||||||
|
margin: 0 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-highlight) {
|
||||||
|
background-color: var(--theme-color-default-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-select-mirror) {
|
||||||
|
background-color: var(--accent);
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-scrollgrid) {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-scrollgrid-section > td) {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-timegrid-body) {
|
||||||
|
background-color: var(--theme-color-default-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-timegrid-col) {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-timegrid-axis-cushion) {
|
||||||
|
color: var(--theme-text-secondary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-timegrid-slot-label-cushion) {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-col-header-cell-cushion) {
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Daily totals styling */
|
||||||
|
.fullcalendar :deep(.fc-col-header-cell .text-muted-foreground) {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduce visibility of time slot dividers */
|
||||||
|
.fullcalendar :deep(.fc-timegrid-divider) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make scrollbars gray */
|
||||||
|
.fullcalendar :deep(.fc-scroller) {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--muted-foreground) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-scroller::-webkit-scrollbar) {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-scroller::-webkit-scrollbar-track) {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-scroller::-webkit-scrollbar-thumb) {
|
||||||
|
background-color: var(--muted-foreground);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-scroller::-webkit-scrollbar-thumb:hover) {
|
||||||
|
background-color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Improve time axis styling */
|
||||||
|
.fullcalendar :deep(.fc-timegrid-axis-chunk) {
|
||||||
|
background-color: var(--theme-color-default-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Simple event main styling */
|
||||||
|
.fullcalendar :deep(.fc-event-main) {
|
||||||
|
padding: 0.125rem 0.25rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -41,6 +41,8 @@ const props = defineProps<{
|
|||||||
projects: Project[];
|
projects: Project[];
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
clients: Client[];
|
clients: Client[];
|
||||||
|
start?: string;
|
||||||
|
end?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const description = ref<HTMLInputElement | null>(null);
|
const description = ref<HTMLInputElement | null>(null);
|
||||||
@@ -63,7 +65,27 @@ const timeEntryDefaultValues = {
|
|||||||
end: getDayJsInstance().utc().format(),
|
end: getDayJsInstance().utc().format(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const timeEntry = ref({ ...timeEntryDefaultValues });
|
const timeEntry = ref({
|
||||||
|
...timeEntryDefaultValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
// update the localStart and localEnd when props.start or props.end get updates
|
||||||
|
watch(
|
||||||
|
() => props.start,
|
||||||
|
(value) => {
|
||||||
|
if (value) {
|
||||||
|
localStart.value = getLocalizedDayJs(value).format();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
watch(
|
||||||
|
() => props.end,
|
||||||
|
(value) => {
|
||||||
|
if (value) {
|
||||||
|
localEnd.value = getLocalizedDayJs(value).format();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => timeEntry.value.project_id,
|
() => timeEntry.value.project_id,
|
||||||
|
|||||||
311
resources/js/packages/ui/src/TimeEntry/TimeEntryEditModal.vue
Normal file
311
resources/js/packages/ui/src/TimeEntry/TimeEntryEditModal.vue
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
|
||||||
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
|
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
|
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||||
|
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
|
||||||
|
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
|
||||||
|
import { TagIcon } from '@heroicons/vue/20/solid';
|
||||||
|
import { getLocalizedDayJs } from '@/packages/ui/src/utils/time';
|
||||||
|
import type {
|
||||||
|
CreateClientBody,
|
||||||
|
CreateProjectBody,
|
||||||
|
Project,
|
||||||
|
Client,
|
||||||
|
TimeEntry,
|
||||||
|
} from '@/packages/api/src';
|
||||||
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
|
import { canCreateProjects } from '@/utils/permissions';
|
||||||
|
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||||
|
import { Badge } from '@/packages/ui/src';
|
||||||
|
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||||
|
import SelectDropdown from '@/packages/ui/src/Input/SelectDropdown.vue';
|
||||||
|
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
|
||||||
|
import DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';
|
||||||
|
|
||||||
|
import { InformationCircleIcon } from '@heroicons/vue/20/solid';
|
||||||
|
import type { Tag, Task } from '@/packages/api/src';
|
||||||
|
import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';
|
||||||
|
|
||||||
|
const show = defineModel('show', { default: false });
|
||||||
|
const saving = ref(false);
|
||||||
|
const deleting = ref(false);
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
timeEntry: TimeEntry | null;
|
||||||
|
enableEstimatedTime: boolean;
|
||||||
|
updateTimeEntry: (entry: TimeEntry) => Promise<void>;
|
||||||
|
deleteTimeEntry: (timeEntryId: string) => Promise<void>;
|
||||||
|
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||||
|
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
||||||
|
createTag: (name: string) => Promise<Tag | undefined>;
|
||||||
|
tags: Tag[];
|
||||||
|
projects: Project[];
|
||||||
|
tasks: Task[];
|
||||||
|
clients: Client[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const description = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
watch(show, (value) => {
|
||||||
|
if (value) {
|
||||||
|
nextTick(() => {
|
||||||
|
description.value?.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const editableTimeEntry = ref<TimeEntry | null>(null);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.timeEntry,
|
||||||
|
(newTimeEntry) => {
|
||||||
|
if (newTimeEntry) {
|
||||||
|
editableTimeEntry.value = { ...newTimeEntry };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => editableTimeEntry.value?.project_id,
|
||||||
|
(value) => {
|
||||||
|
if (value && editableTimeEntry.value) {
|
||||||
|
// check if project is billable by default and set billable accordingly
|
||||||
|
const project = props.projects.find((p) => p.id === value);
|
||||||
|
if (project) {
|
||||||
|
editableTimeEntry.value.billable = project.is_billable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const localStart = computed({
|
||||||
|
get: () =>
|
||||||
|
editableTimeEntry.value ? getLocalizedDayJs(editableTimeEntry.value.start).format() : '',
|
||||||
|
set: (value: string) => {
|
||||||
|
if (editableTimeEntry.value) {
|
||||||
|
editableTimeEntry.value.start = getLocalizedDayJs(value).utc().format();
|
||||||
|
if (getLocalizedDayJs(localEnd.value).isBefore(getLocalizedDayJs(value))) {
|
||||||
|
localEnd.value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const localEnd = computed({
|
||||||
|
get: () =>
|
||||||
|
editableTimeEntry.value ? getLocalizedDayJs(editableTimeEntry.value.end).format() : '',
|
||||||
|
set: (value: string) => {
|
||||||
|
if (editableTimeEntry.value) {
|
||||||
|
editableTimeEntry.value.end = getLocalizedDayJs(value).utc().format();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (editableTimeEntry.value) {
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
await props.updateTimeEntry(editableTimeEntry.value);
|
||||||
|
show.value = false;
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteEntry() {
|
||||||
|
if (editableTimeEntry.value) {
|
||||||
|
deleting.value = true;
|
||||||
|
try {
|
||||||
|
await props.deleteTimeEntry(editableTimeEntry.value.id);
|
||||||
|
show.value = false;
|
||||||
|
} finally {
|
||||||
|
deleting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const billableProxy = computed({
|
||||||
|
get: () =>
|
||||||
|
editableTimeEntry.value ? (editableTimeEntry.value.billable ? 'true' : 'false') : 'false',
|
||||||
|
set: (value: string) => {
|
||||||
|
if (editableTimeEntry.value) {
|
||||||
|
editableTimeEntry.value.billable = value === 'true';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type BillableOption = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogModal closeable :show="show" @close="show = false">
|
||||||
|
<template #title>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<span> Edit time entry </span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #content>
|
||||||
|
<div v-if="editableTimeEntry" class="space-y-4">
|
||||||
|
<div class="sm:flex items-end space-y-2 sm:space-y-0 sm:space-x-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<TextInput
|
||||||
|
id="description"
|
||||||
|
ref="description"
|
||||||
|
v-model="editableTimeEntry.description"
|
||||||
|
placeholder="What did you work on?"
|
||||||
|
type="text"
|
||||||
|
class="mt-1 block w-full"
|
||||||
|
@keydown.enter="submit" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="sm:flex justify-between items-end space-y-2 sm:space-y-0 pt-4 sm:space-x-4">
|
||||||
|
<div class="flex w-full items-center space-x-2 justify-between">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<TimeTrackerProjectTaskDropdown
|
||||||
|
v-model:project="editableTimeEntry.project_id"
|
||||||
|
v-model:task="editableTimeEntry.task_id"
|
||||||
|
:clients
|
||||||
|
:create-project
|
||||||
|
:create-client
|
||||||
|
:can-create-project="canCreateProjects()"
|
||||||
|
:currency="getOrganizationCurrencyString()"
|
||||||
|
size="xlarge"
|
||||||
|
class="bg-input-background"
|
||||||
|
:projects="projects"
|
||||||
|
:tasks="tasks"
|
||||||
|
:enable-estimated-time="
|
||||||
|
enableEstimatedTime
|
||||||
|
"></TimeTrackerProjectTaskDropdown>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="flex-col">
|
||||||
|
<TagDropdown
|
||||||
|
v-model="editableTimeEntry.tags"
|
||||||
|
:create-tag
|
||||||
|
:tags="tags">
|
||||||
|
<template #trigger>
|
||||||
|
<Badge
|
||||||
|
class="bg-input-background"
|
||||||
|
tag="button"
|
||||||
|
size="xlarge">
|
||||||
|
<TagIcon
|
||||||
|
v-if="editableTimeEntry.tags.length === 0"
|
||||||
|
class="w-4"></TagIcon>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="bg-accent-300/20 w-5 h-5 font-medium rounded flex items-center transition justify-center">
|
||||||
|
{{ editableTimeEntry.tags.length }}
|
||||||
|
</div>
|
||||||
|
<span>Tags</span>
|
||||||
|
</Badge>
|
||||||
|
</template>
|
||||||
|
</TagDropdown>
|
||||||
|
</div>
|
||||||
|
<div class="flex-col">
|
||||||
|
<SelectDropdown
|
||||||
|
v-model="billableProxy"
|
||||||
|
:get-key-from-item="(item: BillableOption) => item.value"
|
||||||
|
:get-name-for-item="(item: BillableOption) => item.label"
|
||||||
|
:items="[
|
||||||
|
{
|
||||||
|
label: 'Billable',
|
||||||
|
value: 'true',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Non Billable',
|
||||||
|
value: 'false',
|
||||||
|
},
|
||||||
|
]">
|
||||||
|
<template #trigger>
|
||||||
|
<Badge
|
||||||
|
class="bg-input-background"
|
||||||
|
tag="button"
|
||||||
|
size="xlarge">
|
||||||
|
<BillableIcon class="h-4"></BillableIcon>
|
||||||
|
<span>{{
|
||||||
|
editableTimeEntry.billable
|
||||||
|
? 'Billable'
|
||||||
|
: 'Non-Billable'
|
||||||
|
}}</span>
|
||||||
|
</Badge>
|
||||||
|
</template>
|
||||||
|
</SelectDropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex pt-4 space-x-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<InputLabel>Duration</InputLabel>
|
||||||
|
<div class="space-y-2 mt-1 flex flex-col">
|
||||||
|
<DurationHumanInput
|
||||||
|
v-model:start="localStart"
|
||||||
|
v-model:end="localEnd"
|
||||||
|
name="Duration"></DurationHumanInput>
|
||||||
|
<div class="text-sm flex space-x-1">
|
||||||
|
<InformationCircleIcon
|
||||||
|
class="w-4 text-text-quaternary"></InformationCircleIcon>
|
||||||
|
<span class="text-text-secondary text-xs">
|
||||||
|
You can type natural language here f.e.
|
||||||
|
<span class="font-semibold"> 2h 30m</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="">
|
||||||
|
<InputLabel>Start</InputLabel>
|
||||||
|
<div class="flex flex-col items-center space-y-2 mt-1">
|
||||||
|
<TimePickerSimple v-model="localStart" size="large"></TimePickerSimple>
|
||||||
|
<DatePicker
|
||||||
|
v-model="localStart"
|
||||||
|
tabindex="1"
|
||||||
|
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="">
|
||||||
|
<InputLabel>End</InputLabel>
|
||||||
|
<div class="flex flex-col items-center space-y-2 mt-1">
|
||||||
|
<TimePickerSimple v-model="localEnd" size="large"></TimePickerSimple>
|
||||||
|
<DatePicker
|
||||||
|
v-model="localEnd"
|
||||||
|
tabindex="1"
|
||||||
|
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-between w-full">
|
||||||
|
<SecondaryButton
|
||||||
|
tabindex="2"
|
||||||
|
class="bg-red-600 hover:bg-red-700 text-white border-red-600 hover:border-red-700"
|
||||||
|
:disabled="deleting || saving"
|
||||||
|
@click="deleteEntry">
|
||||||
|
{{ deleting ? 'Deleting...' : 'Delete' }}
|
||||||
|
</SecondaryButton>
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
<SecondaryButton tabindex="2" @click="show = false"> Cancel</SecondaryButton>
|
||||||
|
<PrimaryButton
|
||||||
|
tabindex="2"
|
||||||
|
:class="{ 'opacity-25': saving }"
|
||||||
|
:disabled="saving || deleting"
|
||||||
|
@click="submit">
|
||||||
|
{{ saving ? 'Updating...' : 'Update Time Entry' }}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</DialogModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -27,7 +27,11 @@ import Checkbox from './Input/Checkbox.vue';
|
|||||||
import TimeEntryGroupedTable from './TimeEntry/TimeEntryGroupedTable.vue';
|
import TimeEntryGroupedTable from './TimeEntry/TimeEntryGroupedTable.vue';
|
||||||
import TimeEntryMassActionRow from './TimeEntry/TimeEntryMassActionRow.vue';
|
import TimeEntryMassActionRow from './TimeEntry/TimeEntryMassActionRow.vue';
|
||||||
import TimeEntryCreateModal from './TimeEntry/TimeEntryCreateModal.vue';
|
import TimeEntryCreateModal from './TimeEntry/TimeEntryCreateModal.vue';
|
||||||
|
import TimeEntryEditModal from './TimeEntry/TimeEntryEditModal.vue';
|
||||||
import MoreOptionsDropdown from './MoreOptionsDropdown.vue';
|
import MoreOptionsDropdown from './MoreOptionsDropdown.vue';
|
||||||
|
import CalendarEventContent from './Calendar/CalendarEventContent.vue';
|
||||||
|
import CalendarDayHeader from './Calendar/CalendarDayHeader.vue';
|
||||||
|
import TimeEntryCalendar from './Calendar/TimeEntryCalendar.vue';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
money,
|
money,
|
||||||
@@ -52,4 +56,8 @@ export {
|
|||||||
TimeEntryMassActionRow,
|
TimeEntryMassActionRow,
|
||||||
MoreOptionsDropdown,
|
MoreOptionsDropdown,
|
||||||
TimeEntryCreateModal,
|
TimeEntryCreateModal,
|
||||||
|
TimeEntryEditModal,
|
||||||
|
CalendarEventContent,
|
||||||
|
CalendarDayHeader,
|
||||||
|
TimeEntryCalendar,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ Route::middleware([
|
|||||||
return Inertia::render('Time');
|
return Inertia::render('Time');
|
||||||
})->name('time');
|
})->name('time');
|
||||||
|
|
||||||
|
Route::get('/calendar', function () {
|
||||||
|
return Inertia::render('Calendar');
|
||||||
|
})->name('calendar');
|
||||||
|
|
||||||
Route::get('/reporting', function () {
|
Route::get('/reporting', function () {
|
||||||
return Inertia::render('Reporting');
|
return Inertia::render('Reporting');
|
||||||
})->name('reporting');
|
})->name('reporting');
|
||||||
|
|||||||
Reference in New Issue
Block a user