mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-11 17:52:15 +01:00
828 lines
32 KiB
Vue
828 lines
32 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
ref,
|
|
watch,
|
|
inject,
|
|
type ComputedRef,
|
|
nextTick,
|
|
onMounted,
|
|
onActivated,
|
|
onDeactivated,
|
|
onUnmounted,
|
|
} from 'vue';
|
|
import { useLocalStorage } from '@vueuse/core';
|
|
import { useCssVariable } from '../utils/useCssVariable';
|
|
import { useBreaksEnabled } from '../utils/useBreaksEnabled';
|
|
import { getLocalizedDayJs, getLocalizedDayJsFromMinutes } from '../utils/time';
|
|
import { LoadingSpinner, TimeEntryCreateModal, TimeEntryEditModal } from '..';
|
|
import BreakCreateModal from '../TimeEntry/BreakCreateModal.vue';
|
|
import FullCalendarDayHeader from './FullCalendarDayHeader.vue';
|
|
import CalendarToolbar from './CalendarToolbar.vue';
|
|
import CalendarDayColumn from './CalendarDayColumn.vue';
|
|
import type { CalendarSettings } from './calendarSettings';
|
|
import {
|
|
ContextMenu,
|
|
ContextMenuContent,
|
|
ContextMenuItem,
|
|
ContextMenuSeparator,
|
|
ContextMenuTrigger,
|
|
} from '..';
|
|
import {
|
|
PencilIcon,
|
|
DocumentDuplicateIcon,
|
|
TrashIcon,
|
|
ScissorsIcon,
|
|
PlusIcon,
|
|
StopIcon,
|
|
XMarkIcon,
|
|
} from '@heroicons/vue/20/solid';
|
|
import { Coffee } from '@lucide/vue';
|
|
import type { ActivityPeriod } from './activityTypes';
|
|
import { SLOT_HEIGHT, TIME_AXIS_WIDTH, type DayEvent } from './calendarTypes';
|
|
import { useCalendarGrid } from './useCalendarGrid';
|
|
import { useCalendarNavigation } from './useCalendarNavigation';
|
|
import { useCalendarEvents } from './useCalendarEvents';
|
|
import { useActivityBoxes } from './useActivityBoxes';
|
|
import { useEventDrag } from './useEventDrag';
|
|
import { useEventResize } from './useEventResize';
|
|
import { useSlotSelection } from './useSlotSelection';
|
|
import { useContextMenu } from './useContextMenu';
|
|
import type {
|
|
TimeEntry,
|
|
Project,
|
|
Client,
|
|
Task,
|
|
CreateProjectBody,
|
|
CreateClientBody,
|
|
Tag,
|
|
Organization,
|
|
} from '@/packages/api/src';
|
|
import type { Dayjs } from 'dayjs';
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'dates-change', payload: { start: Dayjs; end: Dayjs }): void;
|
|
(e: 'refresh'): void;
|
|
}>();
|
|
|
|
const props = defineProps<{
|
|
timeEntries: TimeEntry[];
|
|
projects: Project[];
|
|
tasks: Task[];
|
|
clients: Client[];
|
|
tags: Tag[];
|
|
activityPeriods?: ActivityPeriod[];
|
|
loading?: boolean;
|
|
|
|
enableEstimatedTime: boolean;
|
|
currency: string;
|
|
canCreateProject: boolean;
|
|
organizationBillableRate: number | null;
|
|
// Local date (YYYY-MM-DD) to open the calendar on, e.g. from a "Fix in calendar" deep link
|
|
initialDate?: string | null;
|
|
|
|
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>;
|
|
}>();
|
|
|
|
const newEventStart = ref<Dayjs | null>(null);
|
|
const newEventEnd = ref<Dayjs | null>(null);
|
|
const showCreateBreakModal = ref(false);
|
|
const newBreakStart = ref<Dayjs | null>(null);
|
|
const newBreakEnd = ref<Dayjs | null>(null);
|
|
const showCreateTimeEntryModal = ref<boolean>(false);
|
|
const showEditTimeEntryModal = ref<boolean>(false);
|
|
const selectedTimeEntry = ref<TimeEntry | null>(null);
|
|
const contextMenuOpen = ref(false);
|
|
|
|
const rootRef = ref<HTMLElement | null>(null);
|
|
const scrollerRef = ref<HTMLElement | null>(null);
|
|
|
|
const calendarSettings = useLocalStorage<CalendarSettings>(
|
|
'solidtime:calendar-settings',
|
|
{
|
|
snapMinutes: 15,
|
|
startHour: 0,
|
|
endHour: 24,
|
|
slotMinutes: 15,
|
|
},
|
|
{ mergeDefaults: true }
|
|
);
|
|
|
|
function onSettingsUpdate(newSettings: CalendarSettings) {
|
|
calendarSettings.value = newSettings;
|
|
}
|
|
|
|
const currentTime = ref(getLocalizedDayJs());
|
|
let currentTimeInterval: ReturnType<typeof setInterval> | null = null;
|
|
|
|
const organization = inject<ComputedRef<Organization>>('organization');
|
|
const breaksEnabled = useBreaksEnabled();
|
|
|
|
const {
|
|
slots,
|
|
totalGridHeight,
|
|
formatSlotLabel,
|
|
minutesToPixels,
|
|
pixelsToMinutesFromMidnight,
|
|
timeToMinutesFromMidnight,
|
|
getDayFromClientX,
|
|
clientYToGridPixels,
|
|
} = useCalendarGrid(calendarSettings, organization, scrollerRef, rootRef);
|
|
|
|
const {
|
|
activeView,
|
|
viewDays,
|
|
viewTitle,
|
|
emitDatesChange,
|
|
handlePrev,
|
|
handleNext,
|
|
handleToday,
|
|
handleChangeView,
|
|
} = useCalendarNavigation({
|
|
onDatesChange: (payload) => emit('dates-change', payload),
|
|
scrollToCurrentTime: () => scrollToCurrentTime(),
|
|
// Parse as local midnight in the user's timezone — getLocalizedDayJs would
|
|
// treat the bare date as UTC midnight, landing on the previous local day
|
|
// for negative UTC offsets
|
|
initialDate: props.initialDate ? getLocalizedDayJsFromMinutes(props.initialDate, 0) : null,
|
|
});
|
|
|
|
const cssBackground = useCssVariable('--color-bg-background');
|
|
|
|
const {
|
|
optimisticOverrides,
|
|
calendarEvents,
|
|
eventsByDay,
|
|
dailyTotals,
|
|
dailyBreakTotals,
|
|
isToday,
|
|
nowIndicatorTop,
|
|
} = useCalendarEvents({
|
|
timeEntries: () => props.timeEntries,
|
|
projects: () => props.projects,
|
|
clients: () => props.clients,
|
|
tasks: () => props.tasks,
|
|
calendarSettings,
|
|
viewDays,
|
|
currentTime,
|
|
cssBackground,
|
|
minutesToPixels,
|
|
timeToMinutesFromMidnight,
|
|
});
|
|
|
|
const {
|
|
activityBoxesForDay,
|
|
dayHasActivityStatus,
|
|
getActivityBoxLabel,
|
|
getActivityBoxActivities,
|
|
getActivityPercentage,
|
|
getActivityText,
|
|
getTopActivity,
|
|
} = useActivityBoxes({
|
|
activityPeriods: () => props.activityPeriods,
|
|
viewDays,
|
|
calendarSettings,
|
|
minutesToPixels,
|
|
});
|
|
|
|
const { isDragging, dragEventId, dragPreviewsByDay, onEventPointerDown } = useEventDrag({
|
|
calendarSettings,
|
|
viewDays,
|
|
optimisticOverrides,
|
|
updateTimeEntry: (entry) => props.updateTimeEntry(entry),
|
|
emitRefresh: () => emit('refresh'),
|
|
minutesToPixels,
|
|
pixelsToMinutesFromMidnight,
|
|
getDayFromClientX,
|
|
clientYToGridPixels,
|
|
onClickEvent: (ev) => {
|
|
selectedTimeEntry.value = ev.timeEntry;
|
|
showEditTimeEntryModal.value = true;
|
|
},
|
|
});
|
|
|
|
const {
|
|
isResizing,
|
|
resizeEventId,
|
|
resizeCurrentTop,
|
|
resizeCurrentHeight,
|
|
resizeCrossDayPreviewsByDay,
|
|
resizeLiveDurationSeconds,
|
|
getResizeOriginalDayStr,
|
|
onResizerPointerDown,
|
|
} = useEventResize({
|
|
calendarSettings,
|
|
viewDays,
|
|
eventsByDay,
|
|
optimisticOverrides,
|
|
updateTimeEntry: (entry) => props.updateTimeEntry(entry),
|
|
emitRefresh: () => emit('refresh'),
|
|
minutesToPixels,
|
|
pixelsToMinutesFromMidnight,
|
|
getDayFromClientX,
|
|
clientYToGridPixels,
|
|
});
|
|
|
|
const {
|
|
isSelecting,
|
|
selectionDay,
|
|
selectionTop,
|
|
selectionHeight,
|
|
selectionEndDay,
|
|
selectionEndTop,
|
|
selectionEndHeight,
|
|
selectionIntermediateDays,
|
|
onSlotPointerDown,
|
|
clearSelection,
|
|
} = useSlotSelection({
|
|
calendarSettings,
|
|
viewDays,
|
|
totalGridHeight,
|
|
pixelsToMinutesFromMidnight,
|
|
getDayFromClientX,
|
|
clientYToGridPixels,
|
|
onSelectionComplete: (start, end) => {
|
|
newEventStart.value = start;
|
|
newEventEnd.value = end;
|
|
showCreateTimeEntryModal.value = true;
|
|
},
|
|
});
|
|
|
|
const {
|
|
contextMenuTimeEntry,
|
|
handleCalendarContextMenu,
|
|
handleContextEdit,
|
|
handleContextDuplicate,
|
|
handleContextDelete,
|
|
handleContextSplit,
|
|
handleContextStop,
|
|
handleContextDiscard,
|
|
handleContextCreate,
|
|
handleContextCreateBreak,
|
|
} = useContextMenu({
|
|
calendarSettings,
|
|
calendarEvents,
|
|
pixelsToMinutesFromMidnight,
|
|
getDayFromClientX,
|
|
clientYToGridPixels,
|
|
createTimeEntry: (entry) => props.createTimeEntry(entry),
|
|
updateTimeEntry: (entry) => props.updateTimeEntry(entry),
|
|
deleteTimeEntry: (id) => props.deleteTimeEntry(id),
|
|
onEditEvent: (entry) => {
|
|
selectedTimeEntry.value = entry;
|
|
showEditTimeEntryModal.value = true;
|
|
},
|
|
onCreateEvent: (start, end) => {
|
|
newEventStart.value = start;
|
|
newEventEnd.value = end;
|
|
showCreateTimeEntryModal.value = true;
|
|
},
|
|
onCreateBreak: (start, end) => {
|
|
newBreakStart.value = start;
|
|
newBreakEnd.value = end;
|
|
showCreateBreakModal.value = true;
|
|
},
|
|
emitRefresh: () => emit('refresh'),
|
|
});
|
|
|
|
watch(showCreateTimeEntryModal, (value) => {
|
|
if (!value) {
|
|
newEventStart.value = null;
|
|
newEventEnd.value = null;
|
|
clearSelection();
|
|
emit('refresh');
|
|
}
|
|
});
|
|
|
|
watch(showCreateBreakModal, (value) => {
|
|
if (!value) {
|
|
newBreakStart.value = null;
|
|
newBreakEnd.value = null;
|
|
emit('refresh');
|
|
}
|
|
});
|
|
|
|
watch(showEditTimeEntryModal, (value) => {
|
|
if (!value) {
|
|
selectedTimeEntry.value = null;
|
|
emit('refresh');
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Guards slot pointer-down so that clicks which dismiss an open Reka UI
|
|
* layer (context menu, popover, dialog) don't simultaneously start a
|
|
* new time-entry selection on the calendar grid.
|
|
*
|
|
* Because Reka's DismissableLayer registers its document-level
|
|
* `pointerdown` listener *without* capture, it fires AFTER the
|
|
* calendar grid's own handler. That means when this guard runs,
|
|
* `contextMenuOpen` (and modal refs) still reflect the *open* state.
|
|
*/
|
|
function guardedSlotPointerDown(e: PointerEvent) {
|
|
if (contextMenuOpen.value) return;
|
|
if (showCreateTimeEntryModal.value || showEditTimeEntryModal.value) return;
|
|
onSlotPointerDown(e);
|
|
}
|
|
|
|
const scrollToCurrentTime = () => {
|
|
nextTick(() => {
|
|
if (!scrollerRef.value) return;
|
|
const now = getLocalizedDayJs();
|
|
const oneHourBefore = now.subtract(1, 'hour');
|
|
const s = calendarSettings.value;
|
|
const startMin = s.startHour * 60;
|
|
|
|
const targetMinutes = now.isSame(oneHourBefore, 'day')
|
|
? oneHourBefore.hour() * 60 + oneHourBefore.minute()
|
|
: now.hour() * 60 + now.minute();
|
|
|
|
const scrollTop = minutesToPixels(Math.max(0, targetMinutes - startMin));
|
|
scrollerRef.value.scrollTop = scrollTop;
|
|
});
|
|
};
|
|
|
|
watch(
|
|
() => props.timeEntries,
|
|
() => {
|
|
if (optimisticOverrides.value.size > 0) {
|
|
optimisticOverrides.value = new Map();
|
|
}
|
|
}
|
|
);
|
|
|
|
watch(
|
|
calendarSettings,
|
|
() => {
|
|
emitDatesChange();
|
|
},
|
|
{ deep: true }
|
|
);
|
|
|
|
let hasScrolledOnLoad = false;
|
|
|
|
watch(
|
|
() => props.loading,
|
|
(loading) => {
|
|
if (!loading && !hasScrolledOnLoad) {
|
|
hasScrolledOnLoad = true;
|
|
scrollToCurrentTime();
|
|
}
|
|
}
|
|
);
|
|
|
|
onMounted(() => {
|
|
scrollToCurrentTime();
|
|
emitDatesChange();
|
|
currentTimeInterval = setInterval(() => {
|
|
currentTime.value = getLocalizedDayJs();
|
|
}, 60000);
|
|
});
|
|
|
|
onActivated(() => {
|
|
scrollToCurrentTime();
|
|
});
|
|
|
|
onDeactivated(() => {
|
|
contextMenuOpen.value = false;
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
if (currentTimeInterval) {
|
|
clearInterval(currentTimeInterval);
|
|
currentTimeInterval = null;
|
|
}
|
|
});
|
|
|
|
function getEventStyle(dayEvent: DayEvent, dayStr: string): Record<string, string> {
|
|
const ev = dayEvent.event;
|
|
const isResizeTarget = resizeEventId.value === ev.id;
|
|
|
|
let top = dayEvent.top;
|
|
let height = dayEvent.height;
|
|
const left = dayEvent.left;
|
|
const width = dayEvent.width;
|
|
let zIndex = '1';
|
|
|
|
if (isResizeTarget) {
|
|
const isOnResizeOriginDay = dayStr === getResizeOriginalDayStr();
|
|
if (isOnResizeOriginDay) {
|
|
top = resizeCurrentTop.value;
|
|
height = resizeCurrentHeight.value;
|
|
zIndex = '100';
|
|
}
|
|
}
|
|
|
|
return {
|
|
position: 'absolute',
|
|
top: `${top}px`,
|
|
height: `${height}px`,
|
|
left,
|
|
width,
|
|
backgroundColor: ev.backgroundColor,
|
|
borderColor: ev.borderColor,
|
|
zIndex,
|
|
};
|
|
}
|
|
|
|
function getEventOpacityClass(dayEvent: DayEvent, dayStr: string): string {
|
|
const ev = dayEvent.event;
|
|
const isDragTarget = isDragging.value && dragEventId.value === ev.id;
|
|
const isResizeTarget = resizeEventId.value === ev.id;
|
|
|
|
if (isDragTarget) return 'opacity-30';
|
|
|
|
if (isResizeTarget) {
|
|
const isOnResizeOriginDay = dayStr === getResizeOriginalDayStr();
|
|
if (!isOnResizeOriginDay) return 'opacity-50';
|
|
return 'opacity-100';
|
|
}
|
|
|
|
return 'opacity-90 hover:opacity-100';
|
|
}
|
|
|
|
function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
|
|
const ev = dayEvent.event;
|
|
const isResizeTarget = resizeEventId.value === ev.id;
|
|
|
|
if (
|
|
isResizeTarget &&
|
|
dayStr === getResizeOriginalDayStr() &&
|
|
resizeLiveDurationSeconds.value !== null
|
|
) {
|
|
return resizeLiveDurationSeconds.value;
|
|
}
|
|
|
|
return ev.durationMinutes * 60;
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="w-full relative h-full flex-1 flex flex-col overflow-hidden min-h-0">
|
|
<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"
|
|
:currency="currency"
|
|
:can-create-project="canCreateProject"
|
|
:organization-billable-rate="organizationBillableRate"
|
|
:tags="tags as any"
|
|
:projects="projects"
|
|
:tasks="tasks"
|
|
:clients="clients"
|
|
:start="newEventStart ? newEventStart.toISOString() : undefined"
|
|
:end="newEventEnd ? newEventEnd.toISOString() : undefined" />
|
|
|
|
<BreakCreateModal
|
|
v-model:show="showCreateBreakModal"
|
|
:create-time-entry="createTimeEntry"
|
|
:start="newBreakStart ? newBreakStart.toISOString() : undefined"
|
|
:end="newBreakEnd ? newBreakEnd.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"
|
|
:currency="currency"
|
|
:can-create-project="canCreateProject"
|
|
:organization-billable-rate="organizationBillableRate" />
|
|
|
|
<template v-if="!loading">
|
|
<CalendarToolbar
|
|
:view-title="viewTitle"
|
|
:active-view="activeView"
|
|
:settings="calendarSettings"
|
|
@prev="handlePrev"
|
|
@next="handleNext"
|
|
@today="handleToday"
|
|
@change-view="handleChangeView"
|
|
@update:settings="onSettingsUpdate" />
|
|
|
|
<ContextMenu v-model:open="contextMenuOpen">
|
|
<ContextMenuTrigger
|
|
as="div"
|
|
class="flex-1 min-h-0"
|
|
@contextmenu="handleCalendarContextMenu">
|
|
<div
|
|
ref="rootRef"
|
|
class="fc h-full flex flex-col bg-default-background text-foreground font-inherit border border-border border-l-transparent overflow-hidden">
|
|
<div
|
|
class="fc-header-scroll flex border-b border-border shrink-0 sticky top-0 z-10 bg-default-background">
|
|
<div
|
|
class="shrink-0 bg-default-background border-r border-border"
|
|
:style="{
|
|
width: TIME_AXIS_WIDTH + 'px',
|
|
minWidth: TIME_AXIS_WIDTH + 'px',
|
|
}"></div>
|
|
<div
|
|
class="grid flex-1 min-w-0"
|
|
:style="{
|
|
gridTemplateColumns: 'repeat(' + viewDays.length + ', 1fr)',
|
|
}">
|
|
<div
|
|
v-for="day in viewDays"
|
|
:key="day.format('YYYY-MM-DD')"
|
|
class="fc-col-header-cell border-r border-border px-2 py-3 bg-default-background text-center"
|
|
:class="{
|
|
'bg-secondary': isToday(day),
|
|
'fc-day-today': isToday(day),
|
|
}"
|
|
:data-date="day.format('YYYY-MM-DD')">
|
|
<FullCalendarDayHeader
|
|
:date="day"
|
|
:is-today="isToday(day)"
|
|
:total-seconds="dailyTotals[day.format('YYYY-MM-DD')] || 0"
|
|
:break-seconds="
|
|
dailyBreakTotals[day.format('YYYY-MM-DD')] || 0
|
|
" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div ref="scrollerRef" class="fc-scroller">
|
|
<div class="flex min-w-0">
|
|
<div
|
|
class="shrink-0 bg-default-background border-r border-border"
|
|
:style="{
|
|
width: TIME_AXIS_WIDTH + 'px',
|
|
minWidth: TIME_AXIS_WIDTH + 'px',
|
|
}">
|
|
<div
|
|
v-for="slot in slots"
|
|
:key="slot.time"
|
|
class="fc-timegrid-slot fc-timegrid-slot-label relative text-right border-t border-border pr-1.5 pt-2 box-border"
|
|
:class="{
|
|
'fc-timegrid-slot-minor border-t-transparent':
|
|
!slot.isHour,
|
|
}"
|
|
:data-time="slot.time"
|
|
:style="{ height: SLOT_HEIGHT + 'px' }">
|
|
<span
|
|
v-if="slot.isHour"
|
|
class="fc-timegrid-slot-label-cushion text-[0.8125rem] text-muted-foreground leading-none block font-light">
|
|
{{ formatSlotLabel(slot.minutes / 60) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class="flex-1 min-w-0 relative"
|
|
@pointerdown="guardedSlotPointerDown($event)">
|
|
<div
|
|
class="bg-default-background relative"
|
|
:style="{ height: totalGridHeight + 'px' }">
|
|
<div
|
|
class="absolute inset-0 grid"
|
|
:style="{
|
|
gridTemplateColumns:
|
|
'repeat(' + viewDays.length + ', 1fr)',
|
|
}">
|
|
<div
|
|
v-for="day in viewDays"
|
|
:key="'bg-' + day.format('YYYY-MM-DD')"
|
|
:style="
|
|
isToday(day)
|
|
? {
|
|
backgroundColor:
|
|
'var(--theme-color-default-background)',
|
|
}
|
|
: undefined
|
|
" />
|
|
</div>
|
|
<div
|
|
v-for="slot in slots"
|
|
:key="'lane-' + slot.time"
|
|
class="fc-timegrid-slot fc-timegrid-slot-lane border-t border-border box-border relative"
|
|
:class="{
|
|
'fc-timegrid-slot-minor border-dotted':
|
|
!slot.isHour,
|
|
}"
|
|
:data-time="slot.time"
|
|
:style="{ height: SLOT_HEIGHT + 'px' }"></div>
|
|
</div>
|
|
<div
|
|
class="grid absolute inset-0 pointer-events-none min-w-0"
|
|
:style="{
|
|
gridTemplateColumns:
|
|
'repeat(' + viewDays.length + ', 1fr)',
|
|
}">
|
|
<CalendarDayColumn
|
|
v-for="day in viewDays"
|
|
:key="day.format('YYYY-MM-DD')"
|
|
:day-str="day.format('YYYY-MM-DD')"
|
|
:total-grid-height="totalGridHeight"
|
|
:has-activity-status="
|
|
dayHasActivityStatus(day.format('YYYY-MM-DD'))
|
|
"
|
|
:day-events="
|
|
eventsByDay[day.format('YYYY-MM-DD')] || []
|
|
"
|
|
:get-event-style="getEventStyle"
|
|
:get-event-opacity-class="getEventOpacityClass"
|
|
:get-event-duration-seconds="getEventDurationSeconds"
|
|
:is-dragging="isDragging"
|
|
:drag-event-id="dragEventId"
|
|
:drag-preview="
|
|
dragPreviewsByDay[day.format('YYYY-MM-DD')]
|
|
"
|
|
:resize-event-id="resizeEventId"
|
|
:resize-cross-day-preview="
|
|
isResizing
|
|
? resizeCrossDayPreviewsByDay[
|
|
day.format('YYYY-MM-DD')
|
|
]
|
|
: undefined
|
|
"
|
|
:show-now-indicator="
|
|
isToday(day) && nowIndicatorTop >= 0
|
|
"
|
|
:now-indicator-top="nowIndicatorTop"
|
|
:activity-boxes="
|
|
activityBoxesForDay(day.format('YYYY-MM-DD'))
|
|
"
|
|
:get-activity-box-label="getActivityBoxLabel"
|
|
:get-activity-box-activities="getActivityBoxActivities"
|
|
:get-activity-percentage="getActivityPercentage"
|
|
:get-activity-text="getActivityText"
|
|
:get-top-activity="getTopActivity"
|
|
:is-day-view="activeView === 'timeGridDay'"
|
|
:show-selection="
|
|
isSelecting || showCreateTimeEntryModal
|
|
"
|
|
:is-selection-start="
|
|
selectionDay === day.format('YYYY-MM-DD')
|
|
"
|
|
:is-selection-intermediate="
|
|
selectionIntermediateDays.has(
|
|
day.format('YYYY-MM-DD')
|
|
)
|
|
"
|
|
:is-selection-end="
|
|
selectionEndDay === day.format('YYYY-MM-DD')
|
|
"
|
|
:selection-top="selectionTop"
|
|
:selection-height="selectionHeight"
|
|
:selection-end-top="selectionEndTop"
|
|
:selection-end-height="selectionEndHeight"
|
|
@activity-pointerdown="guardedSlotPointerDown"
|
|
@event-pointerdown="
|
|
(e, dayEvent) =>
|
|
onEventPointerDown(e, dayEvent.event, dayEvent)
|
|
"
|
|
@event-keydown-enter="
|
|
(dayEvent) => {
|
|
selectedTimeEntry = dayEvent.event.timeEntry;
|
|
showEditTimeEntryModal = true;
|
|
}
|
|
"
|
|
@resizer-pointerdown="
|
|
(e, dayEvent, edge) =>
|
|
onResizerPointerDown(
|
|
e,
|
|
dayEvent.event,
|
|
dayEvent,
|
|
edge,
|
|
day.format('YYYY-MM-DD')
|
|
)
|
|
" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</ContextMenuTrigger>
|
|
|
|
<ContextMenuContent class="min-w-[160px]">
|
|
<template v-if="contextMenuTimeEntry && contextMenuTimeEntry.end !== null">
|
|
<ContextMenuItem class="space-x-3" @select="handleContextEdit()">
|
|
<PencilIcon class="w-4 h-4 text-icon-default" />
|
|
<span>Edit</span>
|
|
</ContextMenuItem>
|
|
<!-- Duplicate/Split create a new entry of the same type, which the
|
|
server rejects for breaks when breaks are disabled -->
|
|
<ContextMenuItem
|
|
v-if="contextMenuTimeEntry.type !== 'break' || breaksEnabled"
|
|
class="space-x-3"
|
|
@select="handleContextDuplicate()">
|
|
<DocumentDuplicateIcon class="w-4 h-4 text-icon-default" />
|
|
<span>Duplicate</span>
|
|
</ContextMenuItem>
|
|
<ContextMenuItem
|
|
v-if="contextMenuTimeEntry.type !== 'break' || breaksEnabled"
|
|
class="space-x-3"
|
|
@select="handleContextSplit()">
|
|
<ScissorsIcon class="w-4 h-4 text-icon-default" />
|
|
<span>Split</span>
|
|
</ContextMenuItem>
|
|
<ContextMenuSeparator />
|
|
<ContextMenuItem
|
|
class="space-x-3 text-destructive"
|
|
@select="handleContextDelete()">
|
|
<TrashIcon class="w-4 h-4 text-icon-default" />
|
|
<span>Delete</span>
|
|
</ContextMenuItem>
|
|
</template>
|
|
<template v-else-if="contextMenuTimeEntry && contextMenuTimeEntry.end === null">
|
|
<ContextMenuItem class="space-x-3" @select="handleContextStop()">
|
|
<StopIcon class="w-4 h-4 text-icon-default" />
|
|
<span>Stop</span>
|
|
</ContextMenuItem>
|
|
<ContextMenuSeparator />
|
|
<ContextMenuItem
|
|
class="space-x-3 text-destructive"
|
|
@select="handleContextDiscard()">
|
|
<XMarkIcon class="w-4 h-4 text-icon-default" />
|
|
<span>Discard</span>
|
|
</ContextMenuItem>
|
|
</template>
|
|
<template v-else>
|
|
<ContextMenuItem class="space-x-3" @select="handleContextCreate()">
|
|
<PlusIcon class="w-4 h-4 text-icon-default" />
|
|
<span>Create Time Entry</span>
|
|
</ContextMenuItem>
|
|
<ContextMenuItem
|
|
v-if="breaksEnabled"
|
|
class="space-x-3"
|
|
@select="handleContextCreateBreak()">
|
|
<Coffee class="w-4 h-4 text-icon-default" />
|
|
<span>Add Break</span>
|
|
</ContextMenuItem>
|
|
</template>
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.fc-header-scroll {
|
|
overflow-y: auto;
|
|
scrollbar-width: thin;
|
|
scrollbar-gutter: stable;
|
|
scrollbar-color: transparent transparent;
|
|
}
|
|
.fc-header-scroll::-webkit-scrollbar {
|
|
width: 8px;
|
|
}
|
|
.fc-header-scroll::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
.fc-header-scroll::-webkit-scrollbar-thumb {
|
|
background-color: transparent;
|
|
}
|
|
|
|
.fc-scroller {
|
|
overflow-y: auto;
|
|
flex: 1;
|
|
min-height: 0;
|
|
scrollbar-width: thin;
|
|
scrollbar-color: var(--muted-foreground) transparent;
|
|
scrollbar-gutter: stable;
|
|
}
|
|
.fc-scroller::-webkit-scrollbar {
|
|
width: 8px;
|
|
}
|
|
.fc-scroller::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
.fc-scroller::-webkit-scrollbar-thumb {
|
|
background-color: var(--muted-foreground);
|
|
border-radius: 4px;
|
|
}
|
|
.fc-scroller::-webkit-scrollbar-thumb:hover {
|
|
background-color: var(--foreground);
|
|
}
|
|
</style>
|
|
|
|
<style>
|
|
body.fc-resizing-active,
|
|
body.fc-resizing-active * {
|
|
cursor: row-resize !important;
|
|
}
|
|
</style>
|