diff --git a/e2e/calendar.spec.ts b/e2e/calendar.spec.ts index f50ba685..49cfaa55 100644 --- a/e2e/calendar.spec.ts +++ b/e2e/calendar.spec.ts @@ -7,6 +7,7 @@ import { createProjectViaApi, createBareTimeEntryViaApi, createTimeEntryViaApi, + createRunningTimeEntryViaApi, } from './utils/api'; async function goToCalendar(page: Page) { @@ -415,6 +416,66 @@ test('test that context menu create time entry opens the create modal', async ({ await expect(page.getByRole('dialog')).toBeVisible(); }); +test('test that context menu for running entry shows stop and discard options', async ({ + page, + ctx, +}) => { + const description = 'Running ctx menu test ' + Math.floor(1 + Math.random() * 10000); + await createRunningTimeEntryViaApi(ctx, description); + + await goToCalendar(page); + await openContextMenu(page, description); + + // Running entry should show Stop and Discard, not Edit/Duplicate/Split/Delete + await expect(page.getByRole('menuitem', { name: 'Stop' })).toBeVisible(); + await expect(page.getByRole('menuitem', { name: 'Discard' })).toBeVisible(); + await expect(page.getByRole('menuitem', { name: 'Edit' })).not.toBeVisible(); + await expect(page.getByRole('menuitem', { name: 'Duplicate' })).not.toBeVisible(); + await expect(page.getByRole('menuitem', { name: 'Split' })).not.toBeVisible(); +}); + +test('test that context menu stop on running entry sets end time', async ({ page, ctx }) => { + const description = 'Running stop test ' + Math.floor(1 + Math.random() * 10000); + await createRunningTimeEntryViaApi(ctx, description); + + await goToCalendar(page); + await openContextMenu(page, description); + + const [updateResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries/') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + page.getByRole('menuitem', { name: 'Stop' }).click(), + ]); + + const body = await updateResponse.json(); + expect(body.data.end).not.toBeNull(); + expect(body.data.description).toBe(description); +}); + +test('test that context menu discard on running entry deletes it', async ({ page, ctx }) => { + const description = 'Running discard test ' + Math.floor(1 + Math.random() * 10000); + await createRunningTimeEntryViaApi(ctx, description); + + await goToCalendar(page); + await openContextMenu(page, description); + + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries/') && + response.request().method() === 'DELETE' && + response.status() === 204 + ), + page.getByRole('menuitem', { name: 'Discard' }).click(), + ]); + + await expect(page.locator('.fc-event').filter({ hasText: description })).not.toBeVisible(); +}); + // ============================================= // Employee Permission Tests // ============================================= diff --git a/e2e/utils/api.ts b/e2e/utils/api.ts index 35998229..8566e4b2 100644 --- a/e2e/utils/api.ts +++ b/e2e/utils/api.ts @@ -473,6 +473,25 @@ export async function createTimeEntryWithTagViaApi( return { tag, entry }; } +export async function createRunningTimeEntryViaApi(ctx: TestContext, description: string) { + const start = new Date(); + start.setMinutes(start.getMinutes() - 10); + const response = await ctx.request.post( + `${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/time-entries`, + { + data: { + member_id: ctx.memberId, + start: formatTimestamp(start), + description, + billable: false, + }, + } + ); + expect(response.status()).toBe(201); + const body = await response.json(); + return body.data as { id: string; start: string; end: null; description: string }; +} + export async function createBareTimeEntryViaApi( ctx: TestContext, description: string, diff --git a/resources/js/Pages/Calendar.vue b/resources/js/Pages/Calendar.vue index e752fe0c..afe39f76 100644 --- a/resources/js/Pages/Calendar.vue +++ b/resources/js/Pages/Calendar.vue @@ -9,9 +9,7 @@ import { type CreateClientBody, type CreateProjectBody, type Project, - type TimeEntry, } from '@/packages/api/src'; -import { getDayJsInstance } from '@/packages/ui/src/utils/time'; import { TimeEntryCalendar } from '@/packages/ui/src'; import { isAllowedToPerformPremiumAction } from '@/utils/billing'; import { useTagsStore } from '@/utils/useTags'; @@ -23,6 +21,7 @@ import { useProjectsStore } from '@/utils/useProjects'; import { useClientsStore } from '@/utils/useClients'; import { getOrganizationCurrencyString } from '@/utils/money'; import { canCreateProjects } from '@/utils/permissions'; +import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry'; const calendarStart = ref(undefined); const calendarEnd = ref(undefined); @@ -57,39 +56,6 @@ async function deleteTimeEntry(timeEntryId: string): Promise { await deleteTimeEntryMutation(timeEntryId); } -async function duplicateTimeEntry(entry: TimeEntry): Promise { - await createTimeEntryMutation({ - start: entry.start, - end: entry.end, - billable: entry.billable, - description: entry.description, - project_id: entry.project_id, - task_id: entry.task_id, - tags: entry.tags, - }); -} - -async function splitTimeEntry(entry: TimeEntry): Promise { - 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'); - - // Update the original entry to end at the midpoint - await updateTimeEntryMutation({ ...entry, end: midpoint.utc().format() }); - - // Create a new entry from midpoint to original end - await createTimeEntryMutation({ - start: midpoint.utc().format(), - end: entry.end, - billable: entry.billable, - description: entry.description, - project_id: entry.project_id, - task_id: entry.task_id, - tags: entry.tags, - }); -} - async function createTag(name: string) { return await useTagsStore().createTag(name); } @@ -116,8 +82,9 @@ function onDatesChange({ start, end }: { start: Date; end: Date }) { function onRefresh() { queryClient.invalidateQueries({ - queryKey: ['timeEntries', 'calendar'], + queryKey: ['timeEntries'], }); + useCurrentTimeEntryStore().fetchCurrentTimeEntry(); } @@ -136,8 +103,6 @@ function onRefresh() { :create-time-entry="createTimeEntry" :update-time-entry="updateTimeEntry" :delete-time-entry="deleteTimeEntry" - :duplicate-time-entry="duplicateTimeEntry" - :split-time-entry="splitTimeEntry" :create-client="createClient" :create-project="createProject" :create-tag="createTag" diff --git a/resources/js/packages/ui/package.json b/resources/js/packages/ui/package.json index a547e4a8..b1d0aa1d 100644 --- a/resources/js/packages/ui/package.json +++ b/resources/js/packages/ui/package.json @@ -21,7 +21,7 @@ "default": "./dist/solidtime-ui-lib.umd.cjs" } }, - "./style.css": "./dist/style.css", + "./style.css": "./dist/solidtime-ui-lib.css", "./styles.css": "./styles.css", "./tailwind.theme.js": "./tailwind.theme.js" }, diff --git a/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue b/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue index b3086277..63dc57cb 100644 --- a/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue +++ b/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue @@ -45,6 +45,8 @@ import { TrashIcon, ScissorsIcon, PlusIcon, + StopIcon, + XMarkIcon, } from '@heroicons/vue/20/solid'; import activityStatusPlugin, { type ActivityPeriod, @@ -94,8 +96,6 @@ const props = defineProps<{ createProject: (project: CreateProjectBody) => Promise; createClient: (client: CreateClientBody) => Promise; createTag: (name: string) => Promise; - duplicateTimeEntry: (entry: TimeEntry) => Promise; - splitTimeEntry: (entry: TimeEntry) => Promise; }>(); // Local component state @@ -328,8 +328,6 @@ function handleCalendarContextMenu(event: MouseEvent) { if (!fcEvent) return; const ext = fcEvent.extendedProps as CalendarExtendedProps; - if (ext.isRunning) return; - contextMenuTimeEntry.value = ext.timeEntry; contextMenuCreateTime.value = null; } @@ -342,7 +340,16 @@ function handleContextEdit() { async function handleContextDuplicate() { if (!contextMenuTimeEntry.value || contextMenuTimeEntry.value.end === null) return; - await props.duplicateTimeEntry(contextMenuTimeEntry.value); + const entry = contextMenuTimeEntry.value; + await props.createTimeEntry({ + start: entry.start, + end: entry.end, + billable: entry.billable, + description: entry.description, + project_id: entry.project_id, + task_id: entry.task_id, + tags: entry.tags, + }); emit('refresh'); } @@ -354,7 +361,38 @@ async function handleContextDelete() { async function handleContextSplit() { if (!contextMenuTimeEntry.value || contextMenuTimeEntry.value.end === null) return; - await props.splitTimeEntry(contextMenuTimeEntry.value); + 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'); + + await props.updateTimeEntry({ ...entry, end: midpoint.utc().format() }); + await props.createTimeEntry({ + start: midpoint.utc().format(), + end: entry.end, + billable: entry.billable, + description: entry.description, + project_id: entry.project_id, + task_id: entry.task_id, + tags: entry.tags, + }); + emit('refresh'); +} + +async function handleContextStop() { + if (!contextMenuTimeEntry.value || contextMenuTimeEntry.value.end !== null) return; + const entry = contextMenuTimeEntry.value; + await props.updateTimeEntry({ + ...entry, + end: getDayJsInstance()().utc().format(), + }); + emit('refresh'); +} + +async function handleContextDiscard() { + if (!contextMenuTimeEntry.value || contextMenuTimeEntry.value.end !== null) return; + await props.deleteTimeEntry(contextMenuTimeEntry.value.id); emit('refresh'); } @@ -659,7 +697,7 @@ onUnmounted(() => { -