From 3c9159f2d4ac91549b2c95ebab02c8b019719604 Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Wed, 11 Feb 2026 17:09:32 +0100 Subject: [PATCH] Conditionally show cost column in report tables; Task/Project Modal Field cleanup; improve estimated time UX --- e2e/dashboard.spec.ts | 73 ++++++- e2e/projects.spec.ts | 196 +++++++++++++++++- e2e/tasks.spec.ts | 63 ++++++ .../Common/Project/ProjectDropdown.vue | 127 ++++++------ .../Common/Project/ProjectEditModal.vue | 83 ++++---- .../Common/Reporting/ReportingOverview.vue | 11 +- .../Common/Reporting/ReportingRow.vue | 2 +- .../Common/Task/TaskCreateModal.vue | 40 +++- .../Components/Common/Task/TaskEditModal.vue | 20 +- .../Dashboard/ThisWeekReportingTable.vue | 33 ++- .../packages/ui/src/Client/ClientDropdown.vue | 41 ++-- .../packages/ui/src/EstimatedTimeSection.vue | 25 +-- .../ui/src/Input/BillableRateInput.vue | 2 + .../ui/src/Project/ProjectColorSelector.vue | 4 +- .../ui/src/Project/ProjectCreateModal.vue | 54 ++--- .../Project/ProjectEditBillableSection.vue | 147 +++++++++---- .../ui/src/TimeEntry/TimeEntryEditModal.vue | 2 +- .../js/packages/ui/src/field/FieldLabel.vue | 7 +- 18 files changed, 664 insertions(+), 266 deletions(-) diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts index 669306c7..79dba6b7 100644 --- a/e2e/dashboard.spec.ts +++ b/e2e/dashboard.spec.ts @@ -8,7 +8,12 @@ import { startOrStopTimerWithButton, stoppedTimeEntryResponse, } from './utils/currentTimeEntry'; -import { createBareTimeEntryViaApi } from './utils/api'; +import { + createBareTimeEntryViaApi, + createPublicProjectViaApi, + createTimeEntryViaApi, + updateOrganizationSettingViaApi, +} from './utils/api'; async function goToDashboard(page: Page) { await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); @@ -116,4 +121,70 @@ test.describe('Employee Dashboard Restrictions', () => { // Team Activity should NOT be visible for employees await expect(employee.page.getByText('Team Activity', { exact: true })).not.toBeVisible(); }); + + test('employee cannot see Cost column in This Week table by default', async ({ + ctx, + employee, + }) => { + const project = await createPublicProjectViaApi(ctx, { + name: 'EmpDashBillProj', + is_billable: true, + billable_rate: 10000, + }); + await createTimeEntryViaApi( + { ...ctx, memberId: employee.memberId }, + { + description: 'Emp dashboard cost entry', + duration: '1h', + projectId: project.id, + billable: true, + } + ); + + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); + await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({ + timeout: 10000, + }); + + // This Week table should be visible + await expect(employee.page.getByText('This Week', { exact: true })).toBeVisible(); + + // Duration column should be visible, but Cost column should NOT + await expect(employee.page.getByText('Duration', { exact: true })).toBeVisible(); + await expect(employee.page.getByText('Cost', { exact: true })).not.toBeVisible(); + }); + + test('employee can see Cost column in This Week table when employees_can_see_billable_rates is enabled', async ({ + ctx, + employee, + }) => { + await updateOrganizationSettingViaApi(ctx, { employees_can_see_billable_rates: true }); + + const project = await createPublicProjectViaApi(ctx, { + name: 'EmpDashBillVisProj', + is_billable: true, + billable_rate: 10000, + }); + await createTimeEntryViaApi( + { ...ctx, memberId: employee.memberId }, + { + description: 'Emp dashboard cost visible entry', + duration: '1h', + projectId: project.id, + billable: true, + } + ); + + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); + await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({ + timeout: 10000, + }); + + // Both Duration and Cost columns should be visible + await expect(employee.page.getByText('Duration', { exact: true })).toBeVisible(); + await expect(employee.page.getByText('Cost', { exact: true })).toBeVisible(); + + // 1h at 100.00/h = 100.00 EUR cost should be visible + await expect(employee.page.getByText('100,00 EUR').first()).toBeVisible(); + }); }); diff --git a/e2e/projects.spec.ts b/e2e/projects.spec.ts index 92e35fa4..bc0f97b2 100644 --- a/e2e/projects.spec.ts +++ b/e2e/projects.spec.ts @@ -124,8 +124,15 @@ test('test that updating billable rate works with existing time entries', async await page.getByRole('row').first().getByRole('button').click(); await page.getByRole('menuitem').getByText('Edit').first().click(); - await page.getByText('Non-Billable').click(); - await page.getByText('Custom Rate').click(); + + // Set billable default to Billable + await page.getByRole('dialog').locator('#billable').click(); + await page.getByRole('option', { name: 'Billable', exact: true }).click(); + + // Set billable rate to Custom Rate + await page.getByRole('dialog').locator('#billableRateType').click(); + await page.getByRole('option', { name: 'Custom Rate' }).click(); + await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString()); await page.getByRole('button', { name: 'Update Project' }).click(); @@ -153,6 +160,180 @@ test('test that updating billable rate works with existing time entries', async ).toBeVisible(); }); +test('test that creating a project with default billable rate works', async ({ page }) => { + const newProjectName = 'Default Rate Project ' + Math.floor(1 + Math.random() * 10000); + await goToProjectsOverview(page); + await page.getByRole('button', { name: 'Create Project' }).click(); + await page.getByLabel('Project Name').fill(newProjectName); + + // Set billable default to Billable (leaves rate type as Default Rate) + await page.getByRole('dialog').locator('#billable').click(); + await page.getByRole('option', { name: 'Billable', exact: true }).click(); + + // Verify rate type is "Default Rate" and the rate input is disabled + await expect(page.getByRole('dialog').locator('#billableRateType')).toContainText( + 'Default Rate' + ); + await expect(page.getByPlaceholder('Billable Rate')).toBeDisabled(); + + await Promise.all([ + page.getByRole('button', { name: 'Create Project' }).click(), + page.waitForResponse( + async (response) => + response.url().includes('/projects') && + response.request().method() === 'POST' && + response.status() === 201 && + (await response.json()).data.is_billable === true && + (await response.json()).data.billable_rate === null + ), + ]); + + await expect(page.getByTestId('project_table')).toContainText(newProjectName); +}); + +test('test that creating a non-billable project works', async ({ page }) => { + const newProjectName = 'Non-Billable Project ' + Math.floor(1 + Math.random() * 10000); + await goToProjectsOverview(page); + await page.getByRole('button', { name: 'Create Project' }).click(); + await page.getByLabel('Project Name').fill(newProjectName); + + // Billable default should already be "Non-billable" by default + await expect(page.getByRole('dialog').locator('#billable')).toContainText('Non-billable'); + + await Promise.all([ + page.getByRole('button', { name: 'Create Project' }).click(), + page.waitForResponse( + async (response) => + response.url().includes('/projects') && + response.request().method() === 'POST' && + response.status() === 201 && + (await response.json()).data.is_billable === false && + (await response.json()).data.billable_rate === null + ), + ]); + + await expect(page.getByTestId('project_table')).toContainText(newProjectName); +}); + +test('test that switching from custom rate to default rate clears billable rate', async ({ + page, + ctx, +}) => { + const newProjectName = 'Rate Switch Project ' + Math.floor(1 + Math.random() * 10000); + // Create a project with an existing custom billable rate + await createProjectViaApi(ctx, { + name: newProjectName, + is_billable: true, + billable_rate: 15000, + }); + + await goToProjectsOverview(page); + await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 }); + + await page.getByRole('row').first().getByRole('button').click(); + await page.getByRole('menuitem').getByText('Edit').first().click(); + + // Verify it loaded as Billable with Custom Rate + await expect(page.getByRole('dialog').locator('#billable')).toContainText('Billable'); + await expect(page.getByRole('dialog').locator('#billableRateType')).toContainText( + 'Custom Rate' + ); + + // Switch to Default Rate + await page.getByRole('dialog').locator('#billableRateType').click(); + await page.getByRole('option', { name: 'Default Rate' }).click(); + + // Rate input should now be disabled + await expect(page.getByPlaceholder('Billable Rate')).toBeDisabled(); + + // Submit — billable_rate changes from 15000 to null, so confirmation dialog appears + await page.getByRole('button', { name: 'Update Project' }).click(); + await Promise.all([ + page.locator('button').filter({ hasText: 'Yes, update existing time' }).click(), + page.waitForResponse( + async (response) => + response.url().includes('/projects/') && + response.request().method() === 'PUT' && + response.status() === 200 && + (await response.json()).data.is_billable === true && + (await response.json()).data.billable_rate === null + ), + ]); +}); + +test('test that switching from billable to non-billable preserves rate settings', async ({ + page, + ctx, +}) => { + const newProjectName = 'Billable Reset Project ' + Math.floor(1 + Math.random() * 10000); + // Create a project with a custom billable rate + await createProjectViaApi(ctx, { + name: newProjectName, + is_billable: true, + billable_rate: 20000, + }); + + await goToProjectsOverview(page); + await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 }); + + await page.getByRole('row').first().getByRole('button').click(); + await page.getByRole('menuitem').getByText('Edit').first().click(); + + // Verify it loaded correctly as Billable with Custom Rate + await expect(page.getByRole('dialog').locator('#billable')).toContainText('Billable'); + await expect(page.getByRole('dialog').locator('#billableRateType')).toContainText( + 'Custom Rate' + ); + + // Switch to Non-billable + await page.getByRole('dialog').locator('#billable').click(); + await page.getByRole('option', { name: 'Non-billable' }).click(); + + // Rate type should still be Custom Rate (not reset) + await expect(page.getByRole('dialog').locator('#billableRateType')).toContainText( + 'Custom Rate' + ); + + // Submit and verify project is non-billable but keeps its custom rate + await Promise.all([ + page.getByRole('button', { name: 'Update Project' }).click(), + page.waitForResponse( + async (response) => + response.url().includes('/projects/') && + response.request().method() === 'PUT' && + response.status() === 200 && + (await response.json()).data.is_billable === false && + (await response.json()).data.billable_rate === 20000 + ), + ]); +}); + +test('test that editing an existing billable project with default rate loads correctly', async ({ + page, + ctx, +}) => { + const newProjectName = 'Default Rate Edit Project ' + Math.floor(1 + Math.random() * 10000); + // Create a project that is billable but has no custom rate (= default rate) + await createProjectViaApi(ctx, { + name: newProjectName, + is_billable: true, + billable_rate: null, + }); + + await goToProjectsOverview(page); + await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 }); + + await page.getByRole('row').first().getByRole('button').click(); + await page.getByRole('menuitem').getByText('Edit').first().click(); + + // Verify it loaded as Billable with Default Rate + await expect(page.getByRole('dialog').locator('#billable')).toContainText('Billable'); + await expect(page.getByRole('dialog').locator('#billableRateType')).toContainText( + 'Default Rate' + ); + await expect(page.getByPlaceholder('Billable Rate')).toBeDisabled(); +}); + // Sorting tests test('test that sorting projects by name works', async ({ page }) => { await goToProjectsOverview(page); @@ -296,8 +477,15 @@ test('test that custom billable rate is displayed correctly on project detail pa // Edit the project to set a custom billable rate await page.getByRole('row').first().getByRole('button').click(); await page.getByRole('menuitem').getByText('Edit').first().click(); - await page.getByText('Non-Billable').click(); - await page.getByText('Custom Rate').click(); + + // Set billable default to Billable + await page.getByRole('dialog').locator('#billable').click(); + await page.getByRole('option', { name: 'Billable', exact: true }).click(); + + // Set billable rate to Custom Rate + await page.getByRole('dialog').locator('#billableRateType').click(); + await page.getByRole('option', { name: 'Custom Rate' }).click(); + await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString()); await page.getByRole('button', { name: 'Update Project' }).click(); diff --git a/e2e/tasks.spec.ts b/e2e/tasks.spec.ts index 69ef358b..d8abdf53 100644 --- a/e2e/tasks.spec.ts +++ b/e2e/tasks.spec.ts @@ -195,6 +195,69 @@ test('test that multiple tasks are displayed on project detail page', async ({ p await expect(page.getByText(taskName2)).toBeVisible(); }); +test('test that creating a new project from the task create modal project dropdown works', async ({ + page, + ctx, +}) => { + const existingProjectName = 'Existing Project ' + Math.floor(1 + Math.random() * 10000); + const newProjectName = 'Dropdown Created Project ' + Math.floor(1 + Math.random() * 10000); + const newTaskName = 'Task With New Project ' + Math.floor(1 + Math.random() * 10000); + + const project = await createProjectViaApi(ctx, { name: existingProjectName }); + await page.goto(PLAYWRIGHT_BASE_URL + '/projects/' + project.id); + + // Open the Create Task modal + await page.getByRole('button', { name: 'Create Task' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await page.getByPlaceholder('Task Name').fill(newTaskName); + + // Open the project dropdown (it should show the current project) + await page.getByRole('dialog').getByRole('button', { name: existingProjectName }).click(); + + // Click "Create new Project" at the bottom of the dropdown + await page.getByText('Create new Project').click(); + + // The ProjectCreateModal should appear + await expect(page.getByLabel('Project name')).toBeVisible(); + await page.getByLabel('Project name').fill(newProjectName); + + // Submit the project creation + await Promise.all([ + page.getByRole('button', { name: 'Create Project' }).click(), + page.waitForResponse( + async (response) => + response.url().includes('/projects') && + response.request().method() === 'POST' && + response.status() === 201 && + (await response.json()).data.name === newProjectName + ), + ]); + + // The project dropdown trigger should now show the new project name + await expect( + page.getByRole('dialog').getByRole('button', { name: newProjectName }) + ).toBeVisible(); + + // Submit the task and capture the response to get the new project ID + const [taskResponse] = await Promise.all([ + page.waitForResponse( + async (response) => + response.url().includes('/tasks') && + response.request().method() === 'POST' && + response.status() === 201 && + (await response.json()).data.name === newTaskName + ), + page.getByRole('button', { name: 'Create Task' }).click(), + ]); + + const taskData = await taskResponse.json(); + const newProjectId = taskData.data.project_id; + + // Navigate to the new project's page and verify the task is there + await page.goto(PLAYWRIGHT_BASE_URL + '/projects/' + newProjectId); + await expect(page.getByTestId('task_table')).toContainText(newTaskName); +}); + // ============================================= // Employee Permission Tests // ============================================= diff --git a/resources/js/Components/Common/Project/ProjectDropdown.vue b/resources/js/Components/Common/Project/ProjectDropdown.vue index 1a43d475..28c78853 100644 --- a/resources/js/Components/Common/Project/ProjectDropdown.vue +++ b/resources/js/Components/Common/Project/ProjectDropdown.vue @@ -1,5 +1,4 @@ diff --git a/resources/js/Components/Common/Project/ProjectEditModal.vue b/resources/js/Components/Common/Project/ProjectEditModal.vue index 2be30f91..725bc2e4 100644 --- a/resources/js/Components/Common/Project/ProjectEditModal.vue +++ b/resources/js/Components/Common/Project/ProjectEditModal.vue @@ -9,12 +9,13 @@ import { useProjectsStore } from '@/utils/useProjects'; import { useClientsStore } from '@/utils/useClients'; import { useFocus } from '@vueuse/core'; import ClientDropdown from '@/packages/ui/src/Client/ClientDropdown.vue'; -import Badge from '@/packages/ui/src/Badge.vue'; import { useClientsQuery } from '@/utils/useClientsQuery'; import ProjectColorSelector from '@/packages/ui/src/Project/ProjectColorSelector.vue'; +import { Button } from '@/packages/ui/src/Buttons'; +import { ChevronDown } from 'lucide-vue-next'; import { UserCircleIcon } from '@heroicons/vue/20/solid'; import EstimatedTimeSection from '@/packages/ui/src/EstimatedTimeSection.vue'; -import { Field, FieldLabel } from '@/packages/ui/src/field'; +import { Field, FieldGroup, FieldLabel } from '@/packages/ui/src/field'; import ProjectBillableRateModal from '@/packages/ui/src/Project/ProjectBillableRateModal.vue'; import { getOrganizationCurrencyString } from '@/utils/money'; import ProjectEditBillableSection from '@/packages/ui/src/Project/ProjectEditBillableSection.vue'; @@ -81,59 +82,47 @@ async function submitBillableRate() {