From 22f3af2b79b3646af57677d390e81204bb85006d Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Wed, 4 Feb 2026 17:07:46 +0100 Subject: [PATCH] Make sure that time entry billable status updates when project changes, fixes #981 --- e2e/calendar.spec.ts | 188 +++++++++++++ e2e/time.spec.ts | 252 ++++++++++++++++++ e2e/utils/reporting.ts | 19 ++ .../ui/src/TimeEntry/TimeEntryEditModal.vue | 12 + .../ui/src/TimeEntry/TimeEntryRow.vue | 2 + 5 files changed, 473 insertions(+) create mode 100644 e2e/calendar.spec.ts diff --git a/e2e/calendar.spec.ts b/e2e/calendar.spec.ts new file mode 100644 index 00000000..71971169 --- /dev/null +++ b/e2e/calendar.spec.ts @@ -0,0 +1,188 @@ +import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; +import { test } from '../playwright/fixtures'; +import { expect } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { createProject, createBillableProject, createBareTimeEntry } from './utils/reporting'; + +async function goToCalendar(page: Page) { + await page.goto(PLAYWRIGHT_BASE_URL + '/calendar'); +} + +/** + * These tests verify that changing the project on a time entry via the calendar + * updates the billable status to match the new project's is_billable setting. + * + * Issue: https://github.com/solidtime-io/solidtime/issues/981 + */ + +test('test that changing project in calendar edit modal from non-billable to billable updates billable status', async ({ + page, +}) => { + const billableProjectName = 'Billable Cal Project ' + Math.floor(1 + Math.random() * 10000); + + await createBillableProject(page, billableProjectName); + await createBareTimeEntry(page, 'Test billable calendar', '1h'); + + await goToCalendar(page); + + // Click on the time entry event in the calendar + await page.locator('.fc-event').filter({ hasText: 'Test billable calendar' }).first().click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // Verify initially non-billable + await expect( + page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' }) + ).toBeVisible(); + + // Select the billable project + await page.getByRole('dialog').getByRole('button', { name: 'No Project' }).click(); + await page.getByRole('option', { name: billableProjectName }).click(); + + // Verify the billable dropdown updated to Billable + await expect( + page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' }) + ).toBeVisible(); + + // Save and verify + const [updateResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries/') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + page.getByRole('button', { name: 'Update Time Entry' }).click(), + ]); + const responseBody = await updateResponse.json(); + expect(responseBody.data.billable).toBe(true); +}); + +test('test that changing project in calendar edit modal from billable to non-billable updates billable status', async ({ + page, +}) => { + const billableProjectName = 'Billable Cal Rev Project ' + Math.floor(1 + Math.random() * 10000); + const nonBillableProjectName = + 'NonBillable Cal Rev Project ' + Math.floor(1 + Math.random() * 10000); + + await createBillableProject(page, billableProjectName); + await createProject(page, nonBillableProjectName); + await createBareTimeEntry(page, 'Test billable cal reverse', '1h'); + + await goToCalendar(page); + + // Click on the time entry event in the calendar + await page + .locator('.fc-event') + .filter({ hasText: 'Test billable cal reverse' }) + .first() + .click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // First assign the billable project + await page.getByRole('dialog').getByRole('button', { name: 'No Project' }).click(); + await page.getByRole('option', { name: billableProjectName }).click(); + + // Verify billable status flipped to Billable + await expect( + page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' }) + ).toBeVisible(); + + // Now switch to the non-billable project + await page.getByRole('dialog').getByRole('button', { name: billableProjectName }).click(); + await page.getByRole('option', { name: nonBillableProjectName }).click(); + + // Verify billable status reverted to Non-Billable + await expect( + page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' }) + ).toBeVisible(); + + // Save and verify + const [updateResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries/') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + page.getByRole('button', { name: 'Update Time Entry' }).click(), + ]); + const responseBody = await updateResponse.json(); + expect(responseBody.data.billable).toBe(false); +}); + +test('test that opening calendar edit modal for a time entry with manually overridden billable status preserves that status', async ({ + page, +}) => { + const billableProjectName = + 'Billable Cal Persist Project ' + Math.floor(1 + Math.random() * 10000); + + await createBillableProject(page, billableProjectName); + await createBareTimeEntry(page, 'Test cal persist override', '1h'); + + await goToCalendar(page); + + // Click on the time entry event in the calendar + await page + .locator('.fc-event') + .filter({ hasText: 'Test cal persist override' }) + .first() + .click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // Assign the billable project + await page.getByRole('dialog').getByRole('button', { name: 'No Project' }).click(); + await page.getByRole('option', { name: billableProjectName }).click(); + + // Verify it auto-set to Billable + await expect( + page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' }) + ).toBeVisible(); + + // Now manually override billable to Non-Billable via the dropdown + await page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' }).click(); + await page.getByRole('option', { name: 'Non Billable' }).click(); + + // Verify it shows Non-Billable now + await expect( + page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' }) + ).toBeVisible(); + + // Save + const [firstSaveResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries/') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + page.getByRole('button', { name: 'Update Time Entry' }).click(), + ]); + const firstBody = await firstSaveResponse.json(); + expect(firstBody.data.billable).toBe(false); + + // Re-open the edit modal from the calendar — the project_id watcher should NOT override billable + await page + .locator('.fc-event') + .filter({ hasText: 'Test cal persist override' }) + .first() + .click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // The billable dropdown should still show Non-Billable + await expect( + page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' }) + ).toBeVisible(); + + // Save without changes and verify the response still has billable=false + const [updateResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries/') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + page.getByRole('button', { name: 'Update Time Entry' }).click(), + ]); + const responseBody = await updateResponse.json(); + expect(responseBody.data.billable).toBe(false); +}); diff --git a/e2e/time.spec.ts b/e2e/time.spec.ts index 8d2cb027..0776790c 100644 --- a/e2e/time.spec.ts +++ b/e2e/time.spec.ts @@ -9,6 +9,7 @@ import { startOrStopTimerWithButton, stoppedTimeEntryResponse, } from './utils/currentTimeEntry'; +import { createProject, createBillableProject, createBareTimeEntry } from './utils/reporting'; async function goToTimeOverview(page: Page) { await page.goto(PLAYWRIGHT_BASE_URL + '/time'); @@ -448,3 +449,254 @@ test('test that setting billable status via the create modal works', async ({ pa ), ]); }); + +/** + * The following tests verify that changing the project on a time entry + * updates the billable status to match the new project's is_billable setting. + * + * Issue: https://github.com/solidtime-io/solidtime/issues/981 + */ + +test('test that changing project on a time entry row from non-billable to billable updates billable status', async ({ + page, +}) => { + const billableProjectName = 'Billable Row Project ' + Math.floor(1 + Math.random() * 10000); + const nonBillableProjectName = + 'NonBillable Row Project ' + Math.floor(1 + Math.random() * 10000); + + await createProject(page, nonBillableProjectName); + await createBillableProject(page, billableProjectName); + await createBareTimeEntry(page, 'Test billable row', '1h'); + + await goToTimeOverview(page); + const timeEntryRow = page.locator('[data-testid="time_entry_row"]').first(); + + // Assign the non-billable project first + await timeEntryRow.getByRole('button', { name: 'No Project' }).click(); + await page.getByRole('option', { name: nonBillableProjectName }).click(); + await page.waitForResponse( + (response) => + response.url().includes('/time-entries/') && + response.request().method() === 'PUT' && + response.status() === 200 + ); + + // Now switch to the billable project + await timeEntryRow.getByRole('button', { name: nonBillableProjectName }).click(); + await page.getByRole('option', { name: billableProjectName }).click(); + + const updateResponse = await page.waitForResponse( + (response) => + response.url().includes('/time-entries/') && + response.request().method() === 'PUT' && + response.status() === 200 + ); + const responseBody = await updateResponse.json(); + expect(responseBody.data.billable).toBe(true); +}); + +test('test that changing project on a time entry row from billable to non-billable updates billable status', async ({ + page, +}) => { + const billableProjectName = 'Billable Row Rev Project ' + Math.floor(1 + Math.random() * 10000); + const nonBillableProjectName = + 'NonBillable Row Rev Project ' + Math.floor(1 + Math.random() * 10000); + + await createBillableProject(page, billableProjectName); + await createProject(page, nonBillableProjectName); + await createBareTimeEntry(page, 'Test billable row reverse', '1h'); + + await goToTimeOverview(page); + const timeEntryRow = page.locator('[data-testid="time_entry_row"]').first(); + + // Assign the billable project first + await timeEntryRow.getByRole('button', { name: 'No Project' }).click(); + await page.getByRole('option', { name: billableProjectName }).click(); + const firstResponse = await page.waitForResponse( + (response) => + response.url().includes('/time-entries/') && + response.request().method() === 'PUT' && + response.status() === 200 + ); + const firstBody = await firstResponse.json(); + expect(firstBody.data.billable).toBe(true); + + // Now switch to the non-billable project + await timeEntryRow.getByRole('button', { name: billableProjectName }).click(); + await page.getByRole('option', { name: nonBillableProjectName }).click(); + + const updateResponse = await page.waitForResponse( + (response) => + response.url().includes('/time-entries/') && + response.request().method() === 'PUT' && + response.status() === 200 + ); + const responseBody = await updateResponse.json(); + expect(responseBody.data.billable).toBe(false); +}); + +test('test that changing project in edit modal from non-billable to billable updates billable status', async ({ + page, +}) => { + const billableProjectName = 'Billable Modal Project ' + Math.floor(1 + Math.random() * 10000); + + await createBillableProject(page, billableProjectName); + await createBareTimeEntry(page, 'Test billable modal', '1h'); + + await goToTimeOverview(page); + const timeEntryRow = page.locator('[data-testid="time_entry_row"]').first(); + + // Open edit modal + await timeEntryRow.getByRole('button', { name: 'Actions for the time entry' }).first().click(); + await page.getByRole('menuitem', { name: 'Edit' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // Verify initially non-billable + await expect( + page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' }) + ).toBeVisible(); + + // Select the billable project + await page.getByRole('dialog').getByRole('button', { name: 'No Project' }).click(); + await page.getByRole('option', { name: billableProjectName }).click(); + + // Verify the billable dropdown updated to Billable + await expect( + page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' }) + ).toBeVisible(); + + // Save and verify + const [updateResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries/') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + page.getByRole('button', { name: 'Update Time Entry' }).click(), + ]); + const responseBody = await updateResponse.json(); + expect(responseBody.data.billable).toBe(true); +}); + +test('test that opening edit modal for a time entry with manually overridden billable status preserves that status', async ({ + page, +}) => { + const billableProjectName = 'Billable Persist Project ' + Math.floor(1 + Math.random() * 10000); + + await createBillableProject(page, billableProjectName); + await createBareTimeEntry(page, 'Test persist billable override', '1h'); + + await goToTimeOverview(page); + const timeEntryRow = page.locator('[data-testid="time_entry_row"]').first(); + + // Open edit modal and assign the billable project + await timeEntryRow.getByRole('button', { name: 'Actions for the time entry' }).first().click(); + await page.getByRole('menuitem', { name: 'Edit' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + await page.getByRole('dialog').getByRole('button', { name: 'No Project' }).click(); + await page.getByRole('option', { name: billableProjectName }).click(); + + // Verify it auto-set to Billable + await expect( + page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' }) + ).toBeVisible(); + + // Now manually override billable to Non-Billable via the dropdown + await page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' }).click(); + await page.getByRole('option', { name: 'Non Billable' }).click(); + + // Verify it shows Non-Billable now + await expect( + page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' }) + ).toBeVisible(); + + // Save + const [firstSaveResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries/') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + page.getByRole('button', { name: 'Update Time Entry' }).click(), + ]); + const firstBody = await firstSaveResponse.json(); + expect(firstBody.data.billable).toBe(false); + + // Re-open the edit modal — the project_id watcher should NOT override billable back to true + await timeEntryRow.getByRole('button', { name: 'Actions for the time entry' }).first().click(); + await page.getByRole('menuitem', { name: 'Edit' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // The billable dropdown should still show Non-Billable + await expect( + page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' }) + ).toBeVisible(); + + // Save without changes and verify the response still has billable=false + const [updateResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries/') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + page.getByRole('button', { name: 'Update Time Entry' }).click(), + ]); + const responseBody = await updateResponse.json(); + expect(responseBody.data.billable).toBe(false); +}); + +test('test that changing project in edit modal from billable to non-billable updates billable status', async ({ + page, +}) => { + const billableProjectName = + 'Billable Modal Rev Project ' + Math.floor(1 + Math.random() * 10000); + const nonBillableProjectName = + 'NonBillable Modal Rev Project ' + Math.floor(1 + Math.random() * 10000); + + await createBillableProject(page, billableProjectName); + await createProject(page, nonBillableProjectName); + await createBareTimeEntry(page, 'Test billable modal reverse', '1h'); + + await goToTimeOverview(page); + const timeEntryRow = page.locator('[data-testid="time_entry_row"]').first(); + + // Open edit modal + await timeEntryRow.getByRole('button', { name: 'Actions for the time entry' }).first().click(); + await page.getByRole('menuitem', { name: 'Edit' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // First assign the billable project + await page.getByRole('dialog').getByRole('button', { name: 'No Project' }).click(); + await page.getByRole('option', { name: billableProjectName }).click(); + + // Verify billable status flipped to Billable + await expect( + page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' }) + ).toBeVisible(); + + // Now switch to the non-billable project + await page.getByRole('dialog').getByRole('button', { name: billableProjectName }).click(); + await page.getByRole('option', { name: nonBillableProjectName }).click(); + + // Verify billable status reverted to Non-Billable + await expect( + page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' }) + ).toBeVisible(); + + // Save and verify + const [updateResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries/') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + page.getByRole('button', { name: 'Update Time Entry' }).click(), + ]); + const responseBody = await updateResponse.json(); + expect(responseBody.data.billable).toBe(false); +}); diff --git a/e2e/utils/reporting.ts b/e2e/utils/reporting.ts index 25bf4cc9..acf80ae1 100644 --- a/e2e/utils/reporting.ts +++ b/e2e/utils/reporting.ts @@ -35,6 +35,25 @@ export async function createProject(page: Page, projectName: string) { await expect(page.getByText(projectName)).toBeVisible(); } +export async function createBillableProject(page: Page, projectName: string) { + await page.goto(PLAYWRIGHT_BASE_URL + '/projects'); + await expect(page.getByRole('button', { name: 'Create Project' })).toBeVisible(); + await page.getByRole('button', { name: 'Create Project' }).click(); + await page.getByLabel('Project name').fill(projectName); + await page.getByText('Non-Billable').click(); + await page.getByText('Default Rate').click(); + await Promise.all([ + page.getByRole('dialog').getByRole('button', { name: 'Create Project' }).click(), + page.waitForResponse( + (response) => + response.url().includes('/projects') && + response.request().method() === 'POST' && + response.status() === 201 + ), + ]); + await expect(page.getByText(projectName)).toBeVisible(); +} + export async function createClient(page: Page, clientName: string) { await page.goto(PLAYWRIGHT_BASE_URL + '/clients'); await expect(page.getByRole('button', { name: 'Create Client' })).toBeVisible(); diff --git a/resources/js/packages/ui/src/TimeEntry/TimeEntryEditModal.vue b/resources/js/packages/ui/src/TimeEntry/TimeEntryEditModal.vue index 91e9dd18..b148e25b 100644 --- a/resources/js/packages/ui/src/TimeEntry/TimeEntryEditModal.vue +++ b/resources/js/packages/ui/src/TimeEntry/TimeEntryEditModal.vue @@ -74,6 +74,18 @@ watch( { immediate: true } ); +watch( + () => editableTimeEntry.value?.project_id, + (value, oldValue) => { + if (oldValue !== undefined && value !== oldValue && editableTimeEntry.value) { + 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() : '', diff --git a/resources/js/packages/ui/src/TimeEntry/TimeEntryRow.vue b/resources/js/packages/ui/src/TimeEntry/TimeEntryRow.vue index d5c77784..17a32d99 100644 --- a/resources/js/packages/ui/src/TimeEntry/TimeEntryRow.vue +++ b/resources/js/packages/ui/src/TimeEntry/TimeEntryRow.vue @@ -67,10 +67,12 @@ function updateStartEndTime(start: string, end: string | null) { } function updateProjectAndTask(projectId: string, taskId: string) { + const project = props.projects.find((p) => p.id === projectId); props.updateTimeEntry({ ...props.timeEntry, project_id: projectId, task_id: taskId, + billable: project ? project.is_billable : props.timeEntry.billable, }); }