mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-17 12:42:15 +01:00
Conditionally show cost column in report tables; Task/Project Modal
Field cleanup; improve estimated time UX
This commit is contained in:
@@ -8,7 +8,12 @@ import {
|
|||||||
startOrStopTimerWithButton,
|
startOrStopTimerWithButton,
|
||||||
stoppedTimeEntryResponse,
|
stoppedTimeEntryResponse,
|
||||||
} from './utils/currentTimeEntry';
|
} from './utils/currentTimeEntry';
|
||||||
import { createBareTimeEntryViaApi } from './utils/api';
|
import {
|
||||||
|
createBareTimeEntryViaApi,
|
||||||
|
createPublicProjectViaApi,
|
||||||
|
createTimeEntryViaApi,
|
||||||
|
updateOrganizationSettingViaApi,
|
||||||
|
} from './utils/api';
|
||||||
|
|
||||||
async function goToDashboard(page: Page) {
|
async function goToDashboard(page: Page) {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||||
@@ -116,4 +121,70 @@ test.describe('Employee Dashboard Restrictions', () => {
|
|||||||
// Team Activity should NOT be visible for employees
|
// Team Activity should NOT be visible for employees
|
||||||
await expect(employee.page.getByText('Team Activity', { exact: true })).not.toBeVisible();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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('row').first().getByRole('button').click();
|
||||||
await page.getByRole('menuitem').getByText('Edit').first().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.getByPlaceholder('Billable Rate').fill(newBillableRate.toString());
|
||||||
await page.getByRole('button', { name: 'Update Project' }).click();
|
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();
|
).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
|
// Sorting tests
|
||||||
test('test that sorting projects by name works', async ({ page }) => {
|
test('test that sorting projects by name works', async ({ page }) => {
|
||||||
await goToProjectsOverview(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
|
// Edit the project to set a custom billable rate
|
||||||
await page.getByRole('row').first().getByRole('button').click();
|
await page.getByRole('row').first().getByRole('button').click();
|
||||||
await page.getByRole('menuitem').getByText('Edit').first().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.getByPlaceholder('Billable Rate').fill(newBillableRate.toString());
|
||||||
await page.getByRole('button', { name: 'Update Project' }).click();
|
await page.getByRole('button', { name: 'Update Project' }).click();
|
||||||
|
|
||||||
|
|||||||
@@ -195,6 +195,69 @@ test('test that multiple tasks are displayed on project detail page', async ({ p
|
|||||||
await expect(page.getByText(taskName2)).toBeVisible();
|
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
|
// Employee Permission Tests
|
||||||
// =============================================
|
// =============================================
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import ProjectBadge from '@/packages/ui/src/Project/ProjectBadge.vue';
|
|
||||||
import { computed, nextTick, ref, watch } from 'vue';
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
||||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||||
@@ -11,13 +10,16 @@ import {
|
|||||||
ComboboxRoot,
|
ComboboxRoot,
|
||||||
ComboboxViewport,
|
ComboboxViewport,
|
||||||
} from 'radix-vue';
|
} from 'radix-vue';
|
||||||
import { PlusCircleIcon } from '@heroicons/vue/20/solid';
|
import { Check, Plus } from 'lucide-vue-next';
|
||||||
import { api } from '@/packages/api/src';
|
import type { CreateClientBody, CreateProjectBody, Project } from '@/packages/api/src';
|
||||||
import { usePage } from '@inertiajs/vue3';
|
|
||||||
import { getRandomColor } from '@/packages/ui/src/utils/color';
|
|
||||||
import type { Project } from '@/packages/api/src';
|
|
||||||
import ProjectDropdownItem from '@/packages/ui/src/Project/ProjectDropdownItem.vue';
|
|
||||||
import { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component';
|
import { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component';
|
||||||
|
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
||||||
|
import { useProjectsStore } from '@/utils/useProjects';
|
||||||
|
import { useClientsStore } from '@/utils/useClients';
|
||||||
|
import { useClientsQuery } from '@/utils/useClientsQuery';
|
||||||
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||||
|
import { canCreateProjects } from '@/utils/permissions';
|
||||||
|
|
||||||
const searchValue = ref('');
|
const searchValue = ref('');
|
||||||
const searchInput = ref<HTMLElement | null>(null);
|
const searchInput = ref<HTMLElement | null>(null);
|
||||||
@@ -25,10 +27,13 @@ const model = defineModel<string | null>({
|
|||||||
default: null,
|
default: null,
|
||||||
});
|
});
|
||||||
const open = ref(false);
|
const open = ref(false);
|
||||||
const { projects, invalidateProjects } = useProjectsQuery();
|
const showCreateProject = ref(false);
|
||||||
|
const { projects } = useProjectsQuery();
|
||||||
|
const { clients } = useClientsQuery();
|
||||||
const emit = defineEmits(['update:modelValue', 'changed']);
|
const emit = defineEmits(['update:modelValue', 'changed']);
|
||||||
|
|
||||||
const projectDropdownTrigger = ref<HTMLElement | null>(null);
|
const activeClients = computed(() => clients.value.filter((c) => !c.is_archived));
|
||||||
|
|
||||||
const sortedProjects = ref<Project[]>([]);
|
const sortedProjects = ref<Project[]>([]);
|
||||||
|
|
||||||
const shownProjects = computed(() => {
|
const shownProjects = computed(() => {
|
||||||
@@ -37,38 +42,17 @@ const shownProjects = computed(() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
withDefaults(
|
async function handleCreateProject(projectBody: CreateProjectBody) {
|
||||||
defineProps<{
|
const newProject = await useProjectsStore().createProject(projectBody);
|
||||||
border?: boolean;
|
if (newProject) {
|
||||||
}>(),
|
model.value = newProject.id;
|
||||||
{
|
emit('changed');
|
||||||
border: true,
|
|
||||||
}
|
}
|
||||||
);
|
return newProject;
|
||||||
|
}
|
||||||
|
|
||||||
const page = usePage<{
|
async function handleCreateClient(clientBody: CreateClientBody) {
|
||||||
auth: {
|
return await useClientsStore().createClient(clientBody);
|
||||||
user: {
|
|
||||||
current_team_id: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}>();
|
|
||||||
|
|
||||||
async function addProjectIfNoneExists() {
|
|
||||||
if (searchValue.value.length > 0 && shownProjects.value.length === 0) {
|
|
||||||
const response = await api.createProject(
|
|
||||||
{
|
|
||||||
name: searchValue.value,
|
|
||||||
color: getRandomColor(),
|
|
||||||
is_billable: false,
|
|
||||||
},
|
|
||||||
{ params: { organization: page.props.auth.user.current_team_id } }
|
|
||||||
);
|
|
||||||
invalidateProjects();
|
|
||||||
model.value = response.data.id;
|
|
||||||
searchValue.value = '';
|
|
||||||
open.value = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(open, (isOpen) => {
|
watch(open, (isOpen) => {
|
||||||
@@ -107,16 +91,12 @@ function updateValue(project: Project) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dropdown v-model="open" align="start" width="60">
|
<Dropdown v-model="open" align="start">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<ProjectBadge
|
<slot
|
||||||
ref="projectDropdownTrigger"
|
name="trigger"
|
||||||
:color="selectedProjectColor"
|
:selected-project-name="selectedProjectName"
|
||||||
size="xlarge"
|
:selected-project-color="selectedProjectColor"></slot>
|
||||||
:border
|
|
||||||
tag="button"
|
|
||||||
:name="selectedProjectName"
|
|
||||||
class="focus:border-input-border-active bg-input-background focus:outline-0 focus:bg-card-background-separator hover:bg-card-background-separator"></ProjectBadge>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #content>
|
<template #content>
|
||||||
@@ -130,40 +110,53 @@ function updateValue(project: Project) {
|
|||||||
<ComboboxAnchor>
|
<ComboboxAnchor>
|
||||||
<ComboboxInput
|
<ComboboxInput
|
||||||
ref="searchInput"
|
ref="searchInput"
|
||||||
class="bg-card-background border-0 placeholder-text-tertiary text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
class="bg-transparent border-0 placeholder-muted-foreground text-sm text-popover-foreground py-2 px-3 focus:ring-0 border-b border-popover-border focus:border-popover-border w-full"
|
||||||
placeholder="Search for a project..."
|
placeholder="Search for a project..." />
|
||||||
@keydown.enter="addProjectIfNoneExists" />
|
|
||||||
</ComboboxAnchor>
|
</ComboboxAnchor>
|
||||||
<ComboboxContent>
|
<ComboboxContent>
|
||||||
<ComboboxViewport
|
<ComboboxViewport
|
||||||
ref="dropdownViewport"
|
class="w-[--reka-popper-anchor-width] max-h-60 overflow-y-scroll p-1">
|
||||||
class="w-60 max-h-60 overflow-y-scroll">
|
|
||||||
<ComboboxItem
|
<ComboboxItem
|
||||||
v-for="project in shownProjects"
|
v-for="project in shownProjects"
|
||||||
:key="project.id"
|
:key="project.id"
|
||||||
:value="project"
|
:value="project"
|
||||||
class="data-[highlighted]:bg-card-background-active"
|
class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
|
||||||
:data-project-id="project.id">
|
:data-project-id="project.id">
|
||||||
<ProjectDropdownItem
|
<span class="flex items-center gap-2">
|
||||||
:selected="isProjectSelected(project)"
|
<span
|
||||||
:color="project.color"
|
:style="{ backgroundColor: project.color }"
|
||||||
:name="project.name"></ProjectDropdownItem>
|
class="w-3 h-3 rounded-full shrink-0"></span>
|
||||||
|
<span>{{ project.name }}</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="isProjectSelected(project)"
|
||||||
|
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<Check class="h-4 w-4" />
|
||||||
|
</span>
|
||||||
</ComboboxItem>
|
</ComboboxItem>
|
||||||
<div
|
|
||||||
v-if="searchValue.length > 0 && shownProjects.length === 0"
|
|
||||||
class="bg-card-background-active">
|
|
||||||
<div
|
|
||||||
class="flex space-x-3 items-center px-4 py-3 text-xs font-medium border-t rounded-b-lg border-card-background-separator">
|
|
||||||
<PlusCircleIcon class="w-5 flex-shrink-0"></PlusCircleIcon>
|
|
||||||
<span>Add "{{ searchValue }}" as a new Project</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ComboboxViewport>
|
</ComboboxViewport>
|
||||||
|
<div
|
||||||
|
v-if="canCreateProjects()"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground border-t border-popover-border"
|
||||||
|
@click="
|
||||||
|
open = false;
|
||||||
|
showCreateProject = true;
|
||||||
|
">
|
||||||
|
<Plus class="h-4 w-4 shrink-0" />
|
||||||
|
<span>Create new Project</span>
|
||||||
|
</div>
|
||||||
</ComboboxContent>
|
</ComboboxContent>
|
||||||
</ComboboxRoot>
|
</ComboboxRoot>
|
||||||
</UseFocusTrap>
|
</UseFocusTrap>
|
||||||
</template>
|
</template>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
<ProjectCreateModal
|
||||||
|
v-model:show="showCreateProject"
|
||||||
|
:create-project="handleCreateProject"
|
||||||
|
:create-client="handleCreateClient"
|
||||||
|
:clients="activeClients"
|
||||||
|
:currency="getOrganizationCurrencyString()"
|
||||||
|
:enable-estimated-time="isAllowedToPerformPremiumAction()" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ import { useProjectsStore } from '@/utils/useProjects';
|
|||||||
import { useClientsStore } from '@/utils/useClients';
|
import { useClientsStore } from '@/utils/useClients';
|
||||||
import { useFocus } from '@vueuse/core';
|
import { useFocus } from '@vueuse/core';
|
||||||
import ClientDropdown from '@/packages/ui/src/Client/ClientDropdown.vue';
|
import ClientDropdown from '@/packages/ui/src/Client/ClientDropdown.vue';
|
||||||
import Badge from '@/packages/ui/src/Badge.vue';
|
|
||||||
import { useClientsQuery } from '@/utils/useClientsQuery';
|
import { useClientsQuery } from '@/utils/useClientsQuery';
|
||||||
import ProjectColorSelector from '@/packages/ui/src/Project/ProjectColorSelector.vue';
|
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 { UserCircleIcon } from '@heroicons/vue/20/solid';
|
||||||
import EstimatedTimeSection from '@/packages/ui/src/EstimatedTimeSection.vue';
|
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 ProjectBillableRateModal from '@/packages/ui/src/Project/ProjectBillableRateModal.vue';
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
import ProjectEditBillableSection from '@/packages/ui/src/Project/ProjectEditBillableSection.vue';
|
import ProjectEditBillableSection from '@/packages/ui/src/Project/ProjectEditBillableSection.vue';
|
||||||
@@ -81,59 +82,47 @@ async function submitBillableRate() {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #content>
|
<template #content>
|
||||||
<div class="sm:flex items-center space-y-2 sm:space-y-0 sm:space-x-5">
|
<FieldGroup>
|
||||||
<Field class="flex-1 flex items-center">
|
<FieldGroup class="flex-row items-end">
|
||||||
<div class="text-center">
|
<Field class="w-auto text-center">
|
||||||
<FieldLabel for="color">Color</FieldLabel>
|
<FieldLabel for="color">Color</FieldLabel>
|
||||||
<ProjectColorSelector v-model="project.color"></ProjectColorSelector>
|
<ProjectColorSelector v-model="project.color"></ProjectColorSelector>
|
||||||
</div>
|
</Field>
|
||||||
</Field>
|
<Field class="w-full">
|
||||||
<Field class="w-full">
|
<FieldLabel for="projectName">Project name</FieldLabel>
|
||||||
<FieldLabel for="projectName">Project name</FieldLabel>
|
<TextInput
|
||||||
<TextInput
|
id="projectName"
|
||||||
id="projectName"
|
ref="projectNameInput"
|
||||||
ref="projectNameInput"
|
v-model="project.name"
|
||||||
v-model="project.name"
|
type="text"
|
||||||
type="text"
|
placeholder="Project Name"
|
||||||
placeholder="Project Name"
|
class="block w-full"
|
||||||
class="block w-full"
|
required
|
||||||
required
|
autocomplete="projectName"
|
||||||
autocomplete="projectName"
|
@keydown.enter="submit()" />
|
||||||
@keydown.enter="submit()" />
|
</Field>
|
||||||
</Field>
|
</FieldGroup>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel for="client">Client</FieldLabel>
|
<FieldLabel for="client" :icon="UserCircleIcon">Client</FieldLabel>
|
||||||
<ClientDropdown v-model="project.client_id" :create-client :clients="clients">
|
<ClientDropdown v-model="project.client_id" :create-client :clients="clients">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<Badge
|
<Button variant="input" class="w-full justify-between">
|
||||||
class="bg-input-background cursor-pointer hover:bg-tertiary"
|
<span class="truncate">{{ currentClientName }}</span>
|
||||||
size="xlarge">
|
<ChevronDown class="w-4 h-4 text-icon-default" />
|
||||||
<div class="flex items-center space-x-2">
|
</Button>
|
||||||
<UserCircleIcon class="w-5 text-icon-default"></UserCircleIcon>
|
|
||||||
<span class="whitespace-nowrap">
|
|
||||||
{{ currentClientName }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Badge>
|
|
||||||
</template>
|
</template>
|
||||||
</ClientDropdown>
|
</ClientDropdown>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
<ProjectEditBillableSection
|
||||||
<div>
|
v-model:is-billable="project.is_billable"
|
||||||
<div>
|
v-model:billable-rate="project.billable_rate"
|
||||||
<ProjectEditBillableSection
|
:currency="getOrganizationCurrencyString()"
|
||||||
v-model:is-billable="project.is_billable"
|
@submit="submit"></ProjectEditBillableSection>
|
||||||
v-model:billable-rate="project.billable_rate"
|
<EstimatedTimeSection
|
||||||
:currency="getOrganizationCurrencyString()"
|
v-if="isAllowedToPerformPremiumAction()"
|
||||||
@submit="submit"></ProjectEditBillableSection>
|
v-model="project.estimated_time"
|
||||||
</div>
|
@submit="submit()"></EstimatedTimeSection>
|
||||||
<div>
|
</FieldGroup>
|
||||||
<EstimatedTimeSection
|
|
||||||
v-if="isAllowedToPerformPremiumAction()"
|
|
||||||
v-model="project.estimated_time"
|
|
||||||
@submit="submit()"></EstimatedTimeSection>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
|
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
|
||||||
|
|||||||
@@ -292,7 +292,9 @@ const tableData = computed(() => {
|
|||||||
<div
|
<div
|
||||||
class="contents [&>*]:border-card-background-separator [&>*]:border-b [&>*]:bg-secondary [&>*]:pb-1.5 [&>*]:pt-1 text-text-tertiary text-sm">
|
class="contents [&>*]:border-card-background-separator [&>*]:border-b [&>*]:bg-secondary [&>*]:pb-1.5 [&>*]:pt-1 text-text-tertiary text-sm">
|
||||||
<div class="pl-6">Name</div>
|
<div class="pl-6">Name</div>
|
||||||
<div class="text-right">Duration</div>
|
<div class="text-right" :class="!showBillableRate ? 'pr-6' : ''">
|
||||||
|
Duration
|
||||||
|
</div>
|
||||||
<div v-if="showBillableRate" class="text-right pr-6">Cost</div>
|
<div v-if="showBillableRate" class="text-right pr-6">Cost</div>
|
||||||
</div>
|
</div>
|
||||||
<template
|
<template
|
||||||
@@ -311,7 +313,9 @@ const tableData = computed(() => {
|
|||||||
<div class="flex items-center pl-6 font-medium">
|
<div class="flex items-center pl-6 font-medium">
|
||||||
<span>Total</span>
|
<span>Total</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="justify-end flex items-center font-medium">
|
<div
|
||||||
|
class="justify-end flex items-center font-medium"
|
||||||
|
:class="!showBillableRate ? 'pr-6' : ''">
|
||||||
{{
|
{{
|
||||||
formatHumanReadableDuration(
|
formatHumanReadableDuration(
|
||||||
aggregatedTableTimeEntries.seconds,
|
aggregatedTableTimeEntries.seconds,
|
||||||
@@ -324,7 +328,8 @@ const tableData = computed(() => {
|
|||||||
v-if="showBillableRate"
|
v-if="showBillableRate"
|
||||||
class="justify-end pr-6 flex items-center font-medium">
|
class="justify-end pr-6 flex items-center font-medium">
|
||||||
{{
|
{{
|
||||||
aggregatedTableTimeEntries.cost
|
aggregatedTableTimeEntries.cost !== null &&
|
||||||
|
aggregatedTableTimeEntries.cost !== undefined
|
||||||
? formatCents(
|
? formatCents(
|
||||||
aggregatedTableTimeEntries.cost,
|
aggregatedTableTimeEntries.cost,
|
||||||
getOrganizationCurrencyString(),
|
getOrganizationCurrencyString(),
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const organization = inject<ComputedRef<Organization>>('organization');
|
|||||||
{{ entry.description }}
|
{{ entry.description }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="justify-end flex items-center">
|
<div class="justify-end flex items-center" :class="!showCost ? 'pr-6' : ''">
|
||||||
{{
|
{{
|
||||||
formatHumanReadableDuration(
|
formatHumanReadableDuration(
|
||||||
entry.seconds,
|
entry.seconds,
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import { useTasksStore } from '@/utils/useTasks';
|
|||||||
import ProjectDropdown from '@/Components/Common/Project/ProjectDropdown.vue';
|
import ProjectDropdown from '@/Components/Common/Project/ProjectDropdown.vue';
|
||||||
import EstimatedTimeSection from '@/packages/ui/src/EstimatedTimeSection.vue';
|
import EstimatedTimeSection from '@/packages/ui/src/EstimatedTimeSection.vue';
|
||||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||||
|
import { Field, FieldGroup, FieldLabel } from '@/packages/ui/src/field';
|
||||||
|
import { Button } from '@/packages/ui/src/Buttons';
|
||||||
|
import { ChevronDown } from 'lucide-vue-next';
|
||||||
|
import { FolderIcon } from '@heroicons/vue/20/solid';
|
||||||
|
|
||||||
const { createTask } = useTasksStore();
|
const { createTask } = useTasksStore();
|
||||||
const show = defineModel('show', { default: false });
|
const show = defineModel('show', { default: false });
|
||||||
@@ -54,8 +58,9 @@ useFocus(taskNameInput, { initialValue: true });
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #content>
|
<template #content>
|
||||||
<div class="flex items-center space-x-4">
|
<FieldGroup>
|
||||||
<div class="col-span-6 sm:col-span-4 flex-1">
|
<Field class="w-full">
|
||||||
|
<FieldLabel for="taskName">Task name</FieldLabel>
|
||||||
<TextInput
|
<TextInput
|
||||||
id="taskName"
|
id="taskName"
|
||||||
ref="taskNameInput"
|
ref="taskNameInput"
|
||||||
@@ -66,15 +71,28 @@ useFocus(taskNameInput, { initialValue: true });
|
|||||||
required
|
required
|
||||||
autocomplete="taskName"
|
autocomplete="taskName"
|
||||||
@keydown.enter="submit()" />
|
@keydown.enter="submit()" />
|
||||||
</div>
|
</Field>
|
||||||
<div class="col-span-6 sm:col-span-4">
|
<Field class="w-auto">
|
||||||
<ProjectDropdown v-model="taskProjectId"></ProjectDropdown>
|
<FieldLabel :icon="FolderIcon" for="project">Project</FieldLabel>
|
||||||
</div>
|
<ProjectDropdown v-model="taskProjectId">
|
||||||
</div>
|
<template #trigger="{ selectedProjectName, selectedProjectColor }">
|
||||||
<EstimatedTimeSection
|
<Button variant="input" class="w-full justify-between">
|
||||||
v-if="isAllowedToPerformPremiumAction()"
|
<span class="flex items-center gap-2 truncate">
|
||||||
v-model="estimatedTime"
|
<span
|
||||||
@submit="submit()"></EstimatedTimeSection>
|
:style="{ backgroundColor: selectedProjectColor }"
|
||||||
|
class="w-3 h-3 rounded-full shrink-0"></span>
|
||||||
|
<span class="truncate">{{ selectedProjectName }}</span>
|
||||||
|
</span>
|
||||||
|
<ChevronDown class="w-4 h-4 text-icon-default" />
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</ProjectDropdown>
|
||||||
|
</Field>
|
||||||
|
<EstimatedTimeSection
|
||||||
|
v-if="isAllowedToPerformPremiumAction()"
|
||||||
|
v-model="estimatedTime"
|
||||||
|
@submit="submit()"></EstimatedTimeSection>
|
||||||
|
</FieldGroup>
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<SecondaryButton @click="show = false"> Cancel </SecondaryButton>
|
<SecondaryButton @click="show = false"> Cancel </SecondaryButton>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useTasksStore } from '@/utils/useTasks';
|
|||||||
import type { Task, UpdateTaskBody } from '@/packages/api/src';
|
import type { Task, UpdateTaskBody } from '@/packages/api/src';
|
||||||
import EstimatedTimeSection from '@/packages/ui/src/EstimatedTimeSection.vue';
|
import EstimatedTimeSection from '@/packages/ui/src/EstimatedTimeSection.vue';
|
||||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||||
|
import { Field, FieldGroup, FieldLabel } from '@/packages/ui/src/field';
|
||||||
|
|
||||||
const { updateTask } = useTasksStore();
|
const { updateTask } = useTasksStore();
|
||||||
const show = defineModel('show', { default: false });
|
const show = defineModel('show', { default: false });
|
||||||
@@ -42,24 +43,25 @@ useFocus(taskNameInput, { initialValue: true });
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #content>
|
<template #content>
|
||||||
<div class="flex items-center space-x-4">
|
<FieldGroup>
|
||||||
<div class="col-span-6 sm:col-span-4 flex-1">
|
<Field>
|
||||||
|
<FieldLabel for="taskName">Task name</FieldLabel>
|
||||||
<TextInput
|
<TextInput
|
||||||
id="taskName"
|
id="taskName"
|
||||||
ref="taskNameInput"
|
ref="taskNameInput"
|
||||||
v-model="taskBody.name"
|
v-model="taskBody.name"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Task Name"
|
placeholder="Task Name"
|
||||||
class="mt-1 block w-full"
|
class="block w-full"
|
||||||
required
|
required
|
||||||
autocomplete="taskName"
|
autocomplete="taskName"
|
||||||
@keydown.enter="submit()" />
|
@keydown.enter="submit()" />
|
||||||
</div>
|
</Field>
|
||||||
</div>
|
<EstimatedTimeSection
|
||||||
<EstimatedTimeSection
|
v-if="isAllowedToPerformPremiumAction()"
|
||||||
v-if="isAllowedToPerformPremiumAction()"
|
v-model="taskBody.estimated_time"
|
||||||
v-model="taskBody.estimated_time"
|
@submit="submit()"></EstimatedTimeSection>
|
||||||
@submit="submit()"></EstimatedTimeSection>
|
</FieldGroup>
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<SecondaryButton @click="show = false"> Cancel </SecondaryButton>
|
<SecondaryButton @click="show = false"> Cancel </SecondaryButton>
|
||||||
|
|||||||
@@ -114,6 +114,12 @@ const tableData = computed(() => {
|
|||||||
}) ?? []
|
}) ?? []
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const showBillableRate = computed(() => {
|
||||||
|
return !!(
|
||||||
|
getCurrentRole() !== 'employee' || organization?.value?.employees_can_see_billable_rates
|
||||||
|
);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -132,15 +138,20 @@ const tableData = computed(() => {
|
|||||||
"></ReportingGroupBySelect>
|
"></ReportingGroupBySelect>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid items-center" style="grid-template-columns: 1fr 100px 150px">
|
<div
|
||||||
|
class="grid items-center"
|
||||||
|
:style="`grid-template-columns: 1fr 100px ${showBillableRate ? '150px' : ''}`">
|
||||||
<div
|
<div
|
||||||
class="contents [&>*]:border-card-background-separator [&>*]:border-b [&>*]:pb-1.5 [&>*]:pt-1 text-text-tertiary text-sm">
|
class="contents [&>*]:border-card-background-separator [&>*]:border-b [&>*]:pb-1.5 [&>*]:pt-1 text-text-tertiary text-sm">
|
||||||
<div class="pl-6">Name</div>
|
<div class="pl-6">Name</div>
|
||||||
<div class="text-right">Duration</div>
|
<div class="text-right" :class="!showBillableRate ? 'pr-6' : ''">Duration</div>
|
||||||
<div class="text-right pr-6">Cost</div>
|
<div v-if="showBillableRate" class="text-right pr-6">Cost</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isLoading" class="flex justify-center py-10 col-span-3 text-text-tertiary">
|
<div
|
||||||
|
v-if="isLoading"
|
||||||
|
class="flex justify-center py-10 text-text-tertiary"
|
||||||
|
:class="showBillableRate ? 'col-span-3' : 'col-span-2'">
|
||||||
Loading reporting data…
|
Loading reporting data…
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -153,12 +164,15 @@ const tableData = computed(() => {
|
|||||||
v-for="entry in tableData"
|
v-for="entry in tableData"
|
||||||
:key="entry.description ?? 'none'"
|
:key="entry.description ?? 'none'"
|
||||||
:currency="getOrganizationCurrencyString()"
|
:currency="getOrganizationCurrencyString()"
|
||||||
|
:show-cost="showBillableRate"
|
||||||
:entry="entry"></ReportingRow>
|
:entry="entry"></ReportingRow>
|
||||||
<div class="contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]">
|
<div class="contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]">
|
||||||
<div class="flex items-center pl-6 font-medium">
|
<div class="flex items-center pl-6 font-medium">
|
||||||
<span>Total</span>
|
<span>Total</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="justify-end flex items-center font-medium">
|
<div
|
||||||
|
class="justify-end flex items-center font-medium"
|
||||||
|
:class="!showBillableRate ? 'pr-6' : ''">
|
||||||
{{
|
{{
|
||||||
formatHumanReadableDuration(
|
formatHumanReadableDuration(
|
||||||
aggregatedTableTimeEntries.seconds,
|
aggregatedTableTimeEntries.seconds,
|
||||||
@@ -167,7 +181,9 @@ const tableData = computed(() => {
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
<div class="justify-end pr-6 flex items-center font-medium">
|
<div
|
||||||
|
v-if="showBillableRate"
|
||||||
|
class="justify-end pr-6 flex items-center font-medium">
|
||||||
{{
|
{{
|
||||||
aggregatedTableTimeEntries.cost
|
aggregatedTableTimeEntries.cost
|
||||||
? formatCents(
|
? formatCents(
|
||||||
@@ -183,7 +199,10 @@ const tableData = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-else class="chart flex flex-col items-center justify-center py-12 col-span-3">
|
<div
|
||||||
|
v-else
|
||||||
|
class="chart flex flex-col items-center justify-center py-12"
|
||||||
|
:class="showBillableRate ? 'col-span-3' : 'col-span-2'">
|
||||||
<p class="text-lg text-text-primary font-medium">No time entries found</p>
|
<p class="text-lg text-text-primary font-medium">No time entries found</p>
|
||||||
<p>Try to track some time entries this week</p>
|
<p>Try to track some time entries this week</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { PlusCircleIcon } from '@heroicons/vue/20/solid';
|
|
||||||
import { computed, nextTick, ref, watch } from 'vue';
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
import ClientDropdownItem from '@/packages/ui/src/Client/ClientDropdownItem.vue';
|
|
||||||
import type { CreateClientBody, Client } from '@/packages/api/src';
|
import type { CreateClientBody, Client } from '@/packages/api/src';
|
||||||
import {
|
import {
|
||||||
ComboboxAnchor,
|
ComboboxAnchor,
|
||||||
@@ -13,6 +11,7 @@ import {
|
|||||||
} from 'radix-vue';
|
} from 'radix-vue';
|
||||||
import { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component';
|
import { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component';
|
||||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||||
|
import { Check, Plus } from 'lucide-vue-next';
|
||||||
|
|
||||||
const model = defineModel<string | null>({
|
const model = defineModel<string | null>({
|
||||||
default: null,
|
default: null,
|
||||||
@@ -77,7 +76,7 @@ function updateValue(client: { id: string | null; name: string }) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dropdown v-model="open" align="start" width="60">
|
<Dropdown v-model="open" align="start">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<slot name="trigger"></slot>
|
<slot name="trigger"></slot>
|
||||||
</template>
|
</template>
|
||||||
@@ -92,35 +91,41 @@ function updateValue(client: { id: string | null; name: string }) {
|
|||||||
<ComboboxAnchor>
|
<ComboboxAnchor>
|
||||||
<ComboboxInput
|
<ComboboxInput
|
||||||
ref="searchInput"
|
ref="searchInput"
|
||||||
class="bg-card-background border-0 placeholder-text-tertiary text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
class="bg-transparent border-0 placeholder-muted-foreground text-sm text-popover-foreground py-2 px-3 focus:ring-0 border-b border-popover-border focus:border-popover-border w-full"
|
||||||
placeholder="Search for a client..." />
|
placeholder="Search for a client..." />
|
||||||
</ComboboxAnchor>
|
</ComboboxAnchor>
|
||||||
<ComboboxContent>
|
<ComboboxContent>
|
||||||
<ComboboxViewport class="w-60 max-h-60 overflow-y-scroll">
|
<ComboboxViewport
|
||||||
|
class="w-[--reka-popper-anchor-width] max-h-60 overflow-y-scroll p-1">
|
||||||
<ComboboxItem
|
<ComboboxItem
|
||||||
:value="{ id: null, name: 'No Client' }"
|
:value="{ id: null, name: 'No Client' }"
|
||||||
class="data-[highlighted]:bg-card-background-active">
|
class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground">
|
||||||
<ClientDropdownItem :selected="model === null" name="No Client" />
|
<span>No Client</span>
|
||||||
|
<span
|
||||||
|
v-if="model === null"
|
||||||
|
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<Check class="h-4 w-4" />
|
||||||
|
</span>
|
||||||
</ComboboxItem>
|
</ComboboxItem>
|
||||||
<ComboboxItem
|
<ComboboxItem
|
||||||
v-for="client in filteredClients"
|
v-for="client in filteredClients"
|
||||||
:key="client.id"
|
:key="client.id"
|
||||||
:value="client"
|
:value="client"
|
||||||
class="data-[highlighted]:bg-card-background-active"
|
class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
|
||||||
:data-client-id="client.id">
|
:data-client-id="client.id">
|
||||||
<ClientDropdownItem
|
<span>{{ client.name }}</span>
|
||||||
:selected="isClientSelected(client.id)"
|
<span
|
||||||
:name="client.name" />
|
v-if="isClientSelected(client.id)"
|
||||||
|
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<Check class="h-4 w-4" />
|
||||||
|
</span>
|
||||||
</ComboboxItem>
|
</ComboboxItem>
|
||||||
<div
|
<div
|
||||||
v-if="searchValue.length > 0 && filteredClients.length === 0"
|
v-if="searchValue.length > 0 && filteredClients.length === 0"
|
||||||
class="bg-card-background-active">
|
class="flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground"
|
||||||
<div
|
@click="addClientIfNoneExists">
|
||||||
class="flex space-x-3 items-center px-4 py-3 text-xs text-text-primary font-medium border-t rounded-b-lg border-card-background-separator"
|
<Plus class="h-4 w-4 shrink-0" />
|
||||||
@click="addClientIfNoneExists">
|
<span>Add "{{ searchValue }}" as a new Client</span>
|
||||||
<PlusCircleIcon class="w-5 flex-shrink-0" />
|
|
||||||
<span>Add "{{ searchValue }}" as a new Client</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ComboboxViewport>
|
</ComboboxViewport>
|
||||||
</ComboboxContent>
|
</ComboboxContent>
|
||||||
|
|||||||
@@ -1,26 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import EstimatedTimeInput from '@/packages/ui/src/Input/EstimatedTimeInput.vue';
|
import EstimatedTimeInput from '@/packages/ui/src/Input/EstimatedTimeInput.vue';
|
||||||
import { ClockIcon } from '@heroicons/vue/20/solid';
|
import { ClockIcon } from '@heroicons/vue/20/solid';
|
||||||
import { Field, FieldLabel } from './field';
|
import { Field, FieldDescription, FieldLabel } from './field';
|
||||||
|
|
||||||
const model = defineModel<number | null>();
|
const model = defineModel<number | null>();
|
||||||
const emit = defineEmits(['submit']);
|
const emit = defineEmits(['submit']);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Field class="pt-6">
|
<Field>
|
||||||
<div class="flex items-center space-x-1">
|
<FieldLabel for="time_estimated" :icon="ClockIcon">Time Estimated</FieldLabel>
|
||||||
<ClockIcon class="text-text-quaternary w-4"></ClockIcon>
|
<EstimatedTimeInput
|
||||||
<FieldLabel for="billable">Time Estimated</FieldLabel>
|
id="time_estimated"
|
||||||
</div>
|
v-model="model"
|
||||||
<EstimatedTimeInput v-model="model" @submit="emit('submit')"></EstimatedTimeInput>
|
@submit="emit('submit')"></EstimatedTimeInput>
|
||||||
<div class="flex items-center text-text-secondary text-xs pt-2 pl-1">
|
<FieldDescription>
|
||||||
<span>
|
You can type natural language like
|
||||||
<span class="font-semibold">Info:</span>
|
<span class="font-semibold">2h 30m</span>
|
||||||
You can type natural language like
|
</FieldDescription>
|
||||||
<span class="font-semibold">2h 30m</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Field>
|
</Field>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const props = defineProps<{
|
|||||||
name: string;
|
name: string;
|
||||||
focus?: boolean;
|
focus?: boolean;
|
||||||
currency: string;
|
currency: string;
|
||||||
|
disabled?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const model = defineModel<number | null>({
|
const model = defineModel<number | null>({
|
||||||
@@ -33,6 +34,7 @@ function formatValue(modelValue: number | null) {
|
|||||||
:id="name"
|
:id="name"
|
||||||
ref="billableRateInput"
|
ref="billableRateInput"
|
||||||
:model-value="formatValue(model)"
|
:model-value="formatValue(model)"
|
||||||
|
:disabled="disabled"
|
||||||
:step-snapping="false"
|
:step-snapping="false"
|
||||||
class="block w-full"
|
class="block w-full"
|
||||||
:format-options="{
|
:format-options="{
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ const model = defineModel<string>({ default: '' });
|
|||||||
<Dropdown align="center">
|
<Dropdown align="center">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<button
|
<button
|
||||||
class="p-2 bg-input-background hover:bg-tertiary transition rounded-full border border-input-border">
|
class="h-9 w-9 flex items-center justify-center bg-input-background hover:bg-tertiary transition rounded-full border border-input-border">
|
||||||
<div
|
<div
|
||||||
:style="{
|
:style="{
|
||||||
backgroundColor: model,
|
backgroundColor: model,
|
||||||
}"
|
}"
|
||||||
class="w-6 h-6 rounded-full cursor-pointer"></div>
|
class="w-5 h-5 rounded-full cursor-pointer"></div>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ import { getRandomColor } from '@/packages/ui/src/utils/color';
|
|||||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||||
import { useFocus } from '@vueuse/core';
|
import { useFocus } from '@vueuse/core';
|
||||||
import ClientDropdown from '@/packages/ui/src/Client/ClientDropdown.vue';
|
import ClientDropdown from '@/packages/ui/src/Client/ClientDropdown.vue';
|
||||||
import Badge from '@/packages/ui/src/Badge.vue';
|
|
||||||
import ProjectColorSelector from '@/packages/ui/src/Project/ProjectColorSelector.vue';
|
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 { UserCircleIcon } from '@heroicons/vue/20/solid';
|
||||||
import EstimatedTimeSection from '@/packages/ui/src/EstimatedTimeSection.vue';
|
import EstimatedTimeSection from '@/packages/ui/src/EstimatedTimeSection.vue';
|
||||||
import { Field, FieldLabel } from '../field';
|
import { Field, FieldGroup, FieldLabel } from '../field';
|
||||||
import ProjectEditBillableSection from '@/packages/ui/src/Project/ProjectEditBillableSection.vue';
|
import ProjectEditBillableSection from '@/packages/ui/src/Project/ProjectEditBillableSection.vue';
|
||||||
import type { Client } from '@/packages/api/src';
|
import type { Client } from '@/packages/api/src';
|
||||||
|
|
||||||
@@ -74,9 +75,9 @@ const currentClientName = computed(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #content>
|
<template #content>
|
||||||
<div class="sm:flex items-center space-y-2 sm:space-y-0 sm:space-x-4">
|
<FieldGroup>
|
||||||
<div class="flex-1 flex items-center">
|
<FieldGroup class="flex-row items-end">
|
||||||
<Field class="text-center pr-5">
|
<Field class="w-auto text-center">
|
||||||
<FieldLabel for="color">Color</FieldLabel>
|
<FieldLabel for="color">Color</FieldLabel>
|
||||||
<ProjectColorSelector v-model="project.color"></ProjectColorSelector>
|
<ProjectColorSelector v-model="project.color"></ProjectColorSelector>
|
||||||
</Field>
|
</Field>
|
||||||
@@ -94,43 +95,30 @@ const currentClientName = computed(() => {
|
|||||||
autocomplete="projectName"
|
autocomplete="projectName"
|
||||||
@keydown.enter="submit()" />
|
@keydown.enter="submit()" />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</FieldGroup>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel for="client">Client</FieldLabel>
|
<FieldLabel for="client" :icon="UserCircleIcon">Client</FieldLabel>
|
||||||
<ClientDropdown
|
<ClientDropdown
|
||||||
v-model="project.client_id"
|
v-model="project.client_id"
|
||||||
:create-client="createClient"
|
:create-client="createClient"
|
||||||
:clients="activeClients">
|
:clients="activeClients">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<Badge
|
<Button variant="input" class="w-full justify-between">
|
||||||
tag="button"
|
<span class="truncate">{{ currentClientName }}</span>
|
||||||
class="bg-input-background cursor-pointer hover:bg-tertiary"
|
<ChevronDown class="w-4 h-4 text-icon-default" />
|
||||||
size="xlarge">
|
</Button>
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<UserCircleIcon class="w-5 text-icon-default"></UserCircleIcon>
|
|
||||||
<span>
|
|
||||||
{{ currentClientName }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Badge>
|
|
||||||
</template>
|
</template>
|
||||||
</ClientDropdown>
|
</ClientDropdown>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
<ProjectEditBillableSection
|
||||||
<div>
|
v-model:is-billable="project.is_billable"
|
||||||
<div>
|
v-model:billable-rate="project.billable_rate"
|
||||||
<ProjectEditBillableSection
|
:currency="currency"></ProjectEditBillableSection>
|
||||||
v-model:is-billable="project.is_billable"
|
<EstimatedTimeSection
|
||||||
v-model:billable-rate="project.billable_rate"
|
v-if="enableEstimatedTime"
|
||||||
:currency="currency"></ProjectEditBillableSection>
|
v-model="project.estimated_time"
|
||||||
</div>
|
@submit="submit()"></EstimatedTimeSection>
|
||||||
<div>
|
</FieldGroup>
|
||||||
<EstimatedTimeSection
|
|
||||||
v-if="enableEstimatedTime"
|
|
||||||
v-model="project.estimated_time"
|
|
||||||
@submit="submit()"></EstimatedTimeSection>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
|
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
|
||||||
|
|||||||
@@ -1,82 +1,137 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Field, FieldLabel } from '../field';
|
import { Field, FieldDescription, FieldLabel } from '../field';
|
||||||
import BillableRateInput from '@/packages/ui/src/Input/BillableRateInput.vue';
|
import BillableRateInput from '@/packages/ui/src/Input/BillableRateInput.vue';
|
||||||
import ProjectBillableSelect from '@/packages/ui/src/Project/ProjectBillableSelect.vue';
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/Components/ui/select';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/packages/ui/src/tooltip';
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import type { BillableKey } from '@/types/projects';
|
|
||||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||||
|
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
|
||||||
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
currency: string;
|
currency: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const billableRateSelect = ref<BillableKey>('non-billable');
|
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
|
||||||
|
|
||||||
|
type RateType = 'default-rate' | 'custom-rate';
|
||||||
|
|
||||||
|
const billableDefault = ref<'billable' | 'non-billable'>('non-billable');
|
||||||
|
const rateType = ref<RateType>('default-rate');
|
||||||
|
|
||||||
const billableRate = defineModel<number | null>('billableRate');
|
const billableRate = defineModel<number | null>('billableRate');
|
||||||
const isBillable = defineModel<boolean>('isBillable');
|
const isBillable = defineModel<boolean>('isBillable');
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (isBillable.value === true) {
|
if (isBillable.value === true) {
|
||||||
if (billableRate.value) {
|
billableDefault.value = 'billable';
|
||||||
billableRateSelect.value = 'custom-rate';
|
rateType.value = billableRate.value ? 'custom-rate' : 'default-rate';
|
||||||
} else {
|
|
||||||
billableRateSelect.value = 'default-rate';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(billableRateSelect, () => {
|
watch(billableDefault, () => {
|
||||||
if (billableRateSelect.value === 'non-billable') {
|
if (billableDefault.value === 'non-billable') {
|
||||||
isBillable.value = false;
|
isBillable.value = false;
|
||||||
billableRate.value = null;
|
|
||||||
} else if (billableRateSelect.value === 'default-rate') {
|
|
||||||
isBillable.value = true;
|
|
||||||
billableRate.value = null;
|
|
||||||
} else {
|
} else {
|
||||||
isBillable.value = true;
|
isBillable.value = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
billableRateSelect.value = 'non-billable';
|
watch(rateType, () => {
|
||||||
|
if (rateType.value === 'default-rate') {
|
||||||
const billableOptionInfoTexts: { [key in BillableKey]: string } = {
|
billableRate.value = null;
|
||||||
'non-billable': 'New time entries for this project will not be marked billable by default.',
|
} else if (rateType.value === 'custom-rate') {
|
||||||
'default-rate':
|
billableDefault.value = 'billable';
|
||||||
'New time entries for this project will be billable at the default rate by default.',
|
isBillable.value = true;
|
||||||
'custom-rate':
|
if (!billableRate.value) {
|
||||||
'New time entries for this project will be billable at a custom rate by default.',
|
billableRate.value = organization.value?.billable_rate ?? null;
|
||||||
};
|
}
|
||||||
|
}
|
||||||
const billableOptionInfoText = computed(() => {
|
|
||||||
return billableOptionInfoTexts[billableRateSelect.value];
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const displayedRate = computed({
|
||||||
|
get() {
|
||||||
|
if (rateType.value === 'default-rate') {
|
||||||
|
return organization.value?.billable_rate ?? null;
|
||||||
|
}
|
||||||
|
return billableRate.value;
|
||||||
|
},
|
||||||
|
set(value: number | null) {
|
||||||
|
if (rateType.value === 'custom-rate') {
|
||||||
|
billableRate.value = value;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const billableDescription = computed(() => {
|
||||||
|
if (billableDefault.value === 'non-billable') {
|
||||||
|
return 'New time entries for this project will not be marked billable by default.';
|
||||||
|
}
|
||||||
|
return 'New time entries for this project will be marked billable by default.';
|
||||||
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['submit']);
|
const emit = defineEmits(['submit']);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="sm:flex items-center space-y-2 sm:space-y-0 sm:space-x-4 pt-6">
|
<Field>
|
||||||
<Field>
|
<FieldLabel for="billable" :icon="BillableIcon">Billable Default</FieldLabel>
|
||||||
<div class="flex items-center space-x-1">
|
<Select v-model="billableDefault">
|
||||||
<BillableIcon class="text-text-quaternary h-4 ml-1 mr-0.5"></BillableIcon>
|
<SelectTrigger id="billable">
|
||||||
<FieldLabel for="billable">Billable Default</FieldLabel>
|
<SelectValue />
|
||||||
</div>
|
</SelectTrigger>
|
||||||
<ProjectBillableSelect v-model="billableRateSelect"></ProjectBillableSelect>
|
<SelectContent>
|
||||||
</Field>
|
<SelectItem value="non-billable">Non-billable</SelectItem>
|
||||||
<Field v-if="billableRateSelect === 'custom-rate'">
|
<SelectItem value="billable">Billable</SelectItem>
|
||||||
<FieldLabel for="billableRate">Billable Rate</FieldLabel>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FieldDescription>{{ billableDescription }}</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel :icon="BillableIcon" for="billableRateType">Billable Rate</FieldLabel>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
<Select v-model="rateType">
|
||||||
|
<SelectTrigger id="billableRateType">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="default-rate">Default Rate</SelectItem>
|
||||||
|
<SelectItem value="custom-rate">Custom Rate</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<TooltipProvider v-if="rateType === 'default-rate'">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<div>
|
||||||
|
<BillableRateInput
|
||||||
|
v-model="displayedRate"
|
||||||
|
:currency="currency"
|
||||||
|
disabled
|
||||||
|
name="billableRate" />
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent> Uses the default rate of the organization </TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
<BillableRateInput
|
<BillableRateInput
|
||||||
v-model="billableRate"
|
v-else
|
||||||
|
v-model="displayedRate"
|
||||||
:currency="currency"
|
:currency="currency"
|
||||||
name="billableRate"
|
name="billableRate"
|
||||||
@keydown.enter="emit('submit')" />
|
@keydown.enter="emit('submit')" />
|
||||||
</Field>
|
</div>
|
||||||
</div>
|
</Field>
|
||||||
<div class="flex items-center text-text-secondary text-xs pt-2 pl-1">
|
|
||||||
<span>
|
|
||||||
<span class="font-semibold"> Info: </span>
|
|
||||||
{{ billableOptionInfoText }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ const billableProxy = computed({
|
|||||||
@keydown.enter="submit" />
|
@keydown.enter="submit" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col sm:flex-row sm:items-end gap-2 sm:gap-4 pt-4">
|
<div class="flex flex-col sm:flex-row sm:items-end gap-2 sm:gap-4">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<TimeTrackerProjectTaskDropdown
|
<TimeTrackerProjectTaskDropdown
|
||||||
v-model:project="editableTimeEntry.project_id"
|
v-model:project="editableTimeEntry.project_id"
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { HTMLAttributes } from 'vue';
|
import type { Component, HTMLAttributes } from 'vue';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Label } from '@/Components/ui/label';
|
import { Label } from '@/Components/ui/label';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
class?: HTMLAttributes['class'];
|
class?: HTMLAttributes['class'];
|
||||||
|
icon?: Component;
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -13,12 +14,14 @@ const props = defineProps<{
|
|||||||
data-slot="field-label"
|
data-slot="field-label"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
|
'group/field-label peer/field-label flex w-fit gap-1.5 leading-snug group-data-[disabled=true]/field:opacity-50',
|
||||||
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&_>[data-slot=field]]:p-3',
|
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&_>[data-slot=field]]:p-3',
|
||||||
'has-[[data-state=checked]]:bg-primary/5 has-[[data-state=checked]]:border-primary dark:has-[[data-state=checked]]:bg-primary/10',
|
'has-[[data-state=checked]]:bg-primary/5 has-[[data-state=checked]]:border-primary dark:has-[[data-state=checked]]:bg-primary/10',
|
||||||
|
icon ? 'items-center' : '',
|
||||||
props.class
|
props.class
|
||||||
)
|
)
|
||||||
">
|
">
|
||||||
|
<component :is="icon" v-if="icon" class="h-4 w-4 text-text-quaternary shrink-0" />
|
||||||
<slot />
|
<slot />
|
||||||
</Label>
|
</Label>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user