add calendar view

This commit is contained in:
Gregor Vostrak
2025-08-14 16:14:21 +02:00
parent 04c44097d0
commit 9fa9522237
14 changed files with 1266 additions and 35 deletions
+64
View File
@@ -7,6 +7,11 @@
"dependencies": {
"@floating-ui/core": "^1.6.0",
"@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",
"@rushstack/eslint-patch": "^1.10.5",
"@tailwindcss/container-queries": "^0.1.1",
@@ -1027,6 +1032,55 @@
"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": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz",
@@ -5140,6 +5194,16 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"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": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+5
View File
@@ -39,6 +39,11 @@
"dependencies": {
"@floating-ui/core": "^1.6.0",
"@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",
"@rushstack/eslint-patch": "^1.10.5",
"@tailwindcss/container-queries": "^0.1.1",
+7 -4
View File
@@ -46,14 +46,16 @@
--color-accent-default: rgba(var(--color-accent-300), 0.2);
--color-accent-foreground: rgb(var(--color-accent-100));
--theme-color-default-background: var(--color-bg-primary);
}
:root.light {
--color-bg-primary: #F5F5F5;
--color-bg-primary: #FFFFFF;
--color-bg-secondary: #f7f7f8;
--color-bg-tertiary: #e1e1e3;
--color-bg-quaternary: #ffffff;
--color-bg-background: #ffffff;
--color-bg-background: #F5F5F5;
--color-text-primary: #18181b;
--color-text-secondary: #3f3f46;
--color-text-tertiary: #57575C;
@@ -70,7 +72,7 @@
--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-color-row-background: var(--theme-color-card-background);
@@ -92,10 +94,11 @@
--color-accent-default: rgb(var(--color-accent-100));
--color-accent-foreground: rgb(var(--color-accent-800));
--theme-color-default-background: #FCFCFC;
}
:root {
--theme-color-default-background: var(--color-bg-primary);
--theme-color-icon-active: rgb(var(--color-text-tertiary));
--theme-color-card-background-separator: var(--color-border-tertiary);
--theme-color-card-border: var(--color-border-secondary);
@@ -1,19 +1,19 @@
<script setup lang="ts">
import SecondaryButton from "@/packages/ui/src/Buttons/SecondaryButton.vue";
import DialogModal from "@/packages/ui/src/DialogModal.vue";
import PrimaryButton from "@/packages/ui/src/Buttons/PrimaryButton.vue";
import { onMounted, ref } from "vue";
import { getUserTimezone } from "@/packages/ui/src/utils/settings";
import { getDayJsInstance } from "@/packages/ui/src/utils/time";
import { useForm, usePage } from "@inertiajs/vue3";
import type { User } from "@/types/models";
import { useSessionStorage } from "@vueuse/core";
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { onMounted, ref } from 'vue';
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
import { useForm, usePage } from '@inertiajs/vue3';
import type { User } from '@/types/models';
import { useSessionStorage } from '@vueuse/core';
const show = defineModel("show", { default: false });
const saving = defineModel("saving", { default: false });
const show = defineModel('show', { default: false });
const saving = defineModel('saving', { default: false });
const timezone = ref("");
const userTimezone = ref("");
const timezone = ref('');
const userTimezone = ref('');
const page = usePage<{
auth: {
@@ -21,23 +21,22 @@ const page = usePage<{
};
}>();
const hideTimezoneMismatchModal = useSessionStorage<boolean>(
'hide-timezone-mismatch-modal',
false
);
const hideTimezoneMismatchModal = useSessionStorage<boolean>('hide-timezone-mismatch-modal', false);
onMounted(() => {
timezone.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
userTimezone.value = getUserTimezone();
if(getDayJsInstance()().tz(timezone.value).format() !== getDayJsInstance()().tz(userTimezone.value).format()
&& !hideTimezoneMismatchModal.value
if (
getDayJsInstance()().tz(timezone.value).format() !==
getDayJsInstance()().tz(userTimezone.value).format() &&
!hideTimezoneMismatchModal.value
) {
show.value = true;
}
});
function submit(){
function submit() {
saving.value = true;
const form = useForm({
_method: 'PUT',
@@ -54,15 +53,14 @@ function submit(){
saving.value = false;
show.value = false;
location.reload();
}
},
});
}
function cancel(){
function cancel() {
show.value = false;
hideTimezoneMismatchModal.value = true;
}
</script>
<template>
@@ -76,13 +74,18 @@ function cancel(){
<div class="flex items-center space-x-4">
<div class="col-span-6 sm:col-span-4 flex-1 space-y-2">
<p>
The timezone of your device does not match the timezone in your user settings. <br>
<strong>We highly recommend that you update your timezone settings to your current
timezone.</strong>
The timezone of your device does not match the timezone in your user
settings. <br />
<strong
>We highly recommend that you update your timezone settings to your
current timezone.</strong
>
</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>
</div>
</div>
+10 -2
View File
@@ -5,6 +5,7 @@ import OrganizationSwitcher from '@/Components/OrganizationSwitcher.vue';
import CurrentSidebarTimer from '@/Components/CurrentSidebarTimer.vue';
import {
Bars3Icon,
CalendarIcon,
ChartBarIcon,
ClockIcon,
Cog6ToothIcon,
@@ -39,15 +40,17 @@ import { ArrowsRightLeftIcon } from '@heroicons/vue/16/solid';
import { fetchToken, isTokenValid } from '@/utils/session';
import UpdateSidebarNotification from '@/Components/UpdateSidebarNotification.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 { useQuery } from '@tanstack/vue-query';
import { api } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
import { twMerge } from 'tailwind-merge';
defineProps({
title: String,
mainClass: String,
});
const showSidebarMenu = ref(false);
@@ -132,6 +135,11 @@ const page = usePage<{
:icon="ClockIcon"
:current="route().current('time')"
:href="route('time')"></NavigationSidebarItem>
<NavigationSidebarItem
title="Calendar"
:icon="CalendarIcon"
:current="route().current('calendar')"
:href="route('calendar')"></NavigationSidebarItem>
<NavigationSidebarItem
title="Reporting"
:icon="ChartBarIcon"
@@ -274,7 +282,7 @@ const page = usePage<{
</header>
<!-- Page Content -->
<main class="pb-28 flex-1">
<main :class="twMerge('pb-28 flex-1', mainClass)">
<div
v-if="isOrganizationLoading"
class="flex items-center justify-center h-screen">
+177
View 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;
size?: 'small' | 'base';
loading?: boolean;
class?: string;
// Accept any valid Vue class binding shape (string | object | array)
class?: Parameters<typeof twMerge>[0];
}>(),
{
type: 'button',
@@ -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>
@@ -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[];
tasks: Task[];
clients: Client[];
start?: string;
end?: string;
}>();
const description = ref<HTMLInputElement | null>(null);
@@ -63,7 +65,27 @@ const timeEntryDefaultValues = {
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(
() => timeEntry.value.project_id,
@@ -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>
+8
View File
@@ -27,7 +27,11 @@ import Checkbox from './Input/Checkbox.vue';
import TimeEntryGroupedTable from './TimeEntry/TimeEntryGroupedTable.vue';
import TimeEntryMassActionRow from './TimeEntry/TimeEntryMassActionRow.vue';
import TimeEntryCreateModal from './TimeEntry/TimeEntryCreateModal.vue';
import TimeEntryEditModal from './TimeEntry/TimeEntryEditModal.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 {
money,
@@ -52,4 +56,8 @@ export {
TimeEntryMassActionRow,
MoreOptionsDropdown,
TimeEntryCreateModal,
TimeEntryEditModal,
CalendarEventContent,
CalendarDayHeader,
TimeEntryCalendar,
};
+4
View File
@@ -36,6 +36,10 @@ Route::middleware([
return Inertia::render('Time');
})->name('time');
Route::get('/calendar', function () {
return Inertia::render('Calendar');
})->name('calendar');
Route::get('/reporting', function () {
return Inertia::render('Reporting');
})->name('reporting');