Files
solidtime/resources/js/packages/ui/src/FullCalendar/useContextMenu.ts
2026-07-28 16:52:36 +02:00

215 lines
8.1 KiB
TypeScript

import { ref, type Ref, type ComputedRef } from 'vue';
import type { Dayjs } from 'dayjs';
import type { TimeEntry } from '@/packages/api/src';
import { getDayJsInstance, getLocalizedDayJs, getLocalizedDayJsFromMinutes } from '../utils/time';
import type { CalendarSettings } from './calendarSettings';
import type { CalendarEvent } from './calendarTypes';
export function useContextMenu(params: {
calendarSettings: Ref<CalendarSettings>;
calendarEvents: ComputedRef<CalendarEvent[]>;
pixelsToMinutesFromMidnight: (px: number) => number;
getDayFromClientX: (clientX: number) => string | null;
clientYToGridPixels: (clientY: number) => number;
createTimeEntry: (
entry: Omit<TimeEntry, 'id' | 'organization_id' | 'user_id'>
) => Promise<void>;
updateTimeEntry: (entry: TimeEntry) => Promise<void>;
deleteTimeEntry: (id: string) => Promise<void>;
onEditEvent: (entry: TimeEntry) => void;
onCreateEvent: (start: Dayjs, end: Dayjs) => void;
onCreateBreak: (start: Dayjs, end: Dayjs) => void;
emitRefresh: () => void;
}) {
const contextMenuTimeEntry = ref<TimeEntry | null>(null);
const contextMenuCreateTime = ref<{ start: Dayjs; end: Dayjs } | null>(null);
function getTimeAtClickPosition(event: MouseEvent): { start: Dayjs; end: Dayjs } | null {
const date = params.getDayFromClientX(event.clientX);
if (!date) return null;
const gridY = params.clientYToGridPixels(event.clientY);
const minutesFromGridStart = params.pixelsToMinutesFromMidnight(gridY);
const snap = params.calendarSettings.value.snapMinutes;
const snappedMinutes = Math.floor(minutesFromGridStart / snap) * snap;
const startLocal = getLocalizedDayJsFromMinutes(date, snappedMinutes);
const snappedEnd = getLocalizedDayJsFromMinutes(date, snappedMinutes + snap);
return { start: startLocal.utc(), end: snappedEnd.utc() };
}
function handleCalendarContextMenu(event: MouseEvent) {
const target = event.target as HTMLElement;
const eventEl = target.closest<HTMLElement>('[data-event-id]');
if (!eventEl) {
contextMenuTimeEntry.value = null;
const timeInfo = getTimeAtClickPosition(event);
contextMenuCreateTime.value = timeInfo;
return;
}
const eventId = eventEl.getAttribute('data-event-id');
if (!eventId) return;
const ev = params.calendarEvents.value.find((e) => e.id === eventId);
if (!ev) return;
contextMenuTimeEntry.value = ev.timeEntry;
contextMenuCreateTime.value = null;
}
function handleContextEdit() {
if (!contextMenuTimeEntry.value || contextMenuTimeEntry.value.end === null) return;
params.onEditEvent(contextMenuTimeEntry.value);
}
async function handleContextDuplicate() {
if (!contextMenuTimeEntry.value || contextMenuTimeEntry.value.end === null) return;
const entry = contextMenuTimeEntry.value;
await params.createTimeEntry({
start: entry.start,
end: entry.end,
billable: entry.billable,
type: entry.type,
description: entry.description,
project_id: entry.project_id,
task_id: entry.task_id,
tags: entry.tags,
});
params.emitRefresh();
}
async function handleContextDelete() {
if (!contextMenuTimeEntry.value || contextMenuTimeEntry.value.end === null) return;
await params.deleteTimeEntry(contextMenuTimeEntry.value.id);
params.emitRefresh();
}
async function handleContextSplit() {
if (!contextMenuTimeEntry.value || contextMenuTimeEntry.value.end === null) return;
const entry = contextMenuTimeEntry.value;
if (!entry.end) return;
const start = getDayJsInstance()(entry.start);
const end = getDayJsInstance()(entry.end);
const midpoint = start.add(end.diff(start) / 2, 'millisecond').startOf('minute');
try {
await params.updateTimeEntry({ ...entry, end: midpoint.utc().format() });
} catch {
// Update failed, don't proceed with create
params.emitRefresh();
return;
}
try {
await params.createTimeEntry({
start: midpoint.utc().format(),
end: entry.end,
billable: entry.billable,
type: entry.type,
description: entry.description,
project_id: entry.project_id,
task_id: entry.task_id,
tags: entry.tags,
});
} catch {
// Create failed after update succeeded — restore original entry
try {
await params.updateTimeEntry({ ...entry });
} catch {
// Restoration also failed; refresh will show server state
}
}
params.emitRefresh();
}
async function handleContextStop() {
if (!contextMenuTimeEntry.value || contextMenuTimeEntry.value.end !== null) return;
const entry = contextMenuTimeEntry.value;
await params.updateTimeEntry({
...entry,
end: getDayJsInstance()().utc().format(),
});
params.emitRefresh();
}
async function handleContextDiscard() {
if (!contextMenuTimeEntry.value || contextMenuTimeEntry.value.end !== null) return;
await params.deleteTimeEntry(contextMenuTimeEntry.value.id);
params.emitRefresh();
}
function handleContextCreate() {
if (contextMenuCreateTime.value) {
params.onCreateEvent(
contextMenuCreateTime.value.start,
contextMenuCreateTime.value.end
);
} else {
params.onCreateEvent(
getDayJsInstance()().utc(),
getDayJsInstance()().utc().add(1, 'hour')
);
}
}
function handleContextCreateBreak() {
const dayjs = getDayJsInstance();
if (!contextMenuCreateTime.value) {
params.onCreateBreak(dayjs().utc().subtract(30, 'minute'), dayjs().utc());
return;
}
const clickTime = contextMenuCreateTime.value.start;
// Day matching must use the user's configured timezone (the calendar renders
// its day columns in that timezone), not the browser's local timezone
const clickDate = getLocalizedDayJs(clickTime.format()).format('YYYY-MM-DD');
// When the click lands in a gap between two entries of the same day,
// the break is prefilled to exactly fill that gap
let previousEnd: Dayjs | null = null;
let nextStart: Dayjs | null = null;
for (const calendarEvent of params.calendarEvents.value) {
const entry = calendarEvent.timeEntry;
const entryStart = dayjs.utc(entry.start);
if (getLocalizedDayJs(entry.start).format('YYYY-MM-DD') !== clickDate) {
continue;
}
const entryEnd = entry.end === null ? null : dayjs.utc(entry.end);
if (entryEnd !== null && !entryEnd.isAfter(clickTime)) {
if (previousEnd === null || entryEnd.isAfter(previousEnd)) {
previousEnd = entryEnd;
}
}
if (!entryStart.isBefore(clickTime)) {
if (nextStart === null || entryStart.isBefore(nextStart)) {
nextStart = entryStart;
}
}
}
if (previousEnd !== null && nextStart !== null && previousEnd.isBefore(nextStart)) {
params.onCreateBreak(previousEnd, nextStart);
return;
}
params.onCreateBreak(contextMenuCreateTime.value.start, contextMenuCreateTime.value.end);
}
return {
contextMenuTimeEntry,
contextMenuCreateTime,
handleCalendarContextMenu,
handleContextEdit,
handleContextDuplicate,
handleContextDelete,
handleContextSplit,
handleContextStop,
handleContextDiscard,
handleContextCreate,
handleContextCreateBreak,
};
}