mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-05-07 20:32:26 +00:00
improve time estimate input, responsive time entry create modal fixes,
fixes #460, #800
This commit is contained in:
@@ -366,6 +366,149 @@ test('test that custom billable rate is displayed correctly on project detail pa
|
||||
);
|
||||
});
|
||||
|
||||
// Tests for estimated time input (Issue #460)
|
||||
test('test that creating a project with estimated time in human-readable format works', async ({
|
||||
page,
|
||||
}) => {
|
||||
const newProjectName = 'Estimated Time 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);
|
||||
|
||||
// Fill in estimated time using human-readable format
|
||||
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
|
||||
await estimatedTimeInput.fill('2h 30m');
|
||||
await estimatedTimeInput.press('Tab');
|
||||
|
||||
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 &&
|
||||
// 2h 30m = 9000 seconds
|
||||
(await response.json()).data.estimated_time === 9000
|
||||
),
|
||||
]);
|
||||
|
||||
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
|
||||
});
|
||||
|
||||
test('test that creating a project with estimated time using decimal notation works', async ({
|
||||
page,
|
||||
}) => {
|
||||
const newProjectName = 'Decimal Estimated 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);
|
||||
|
||||
// Fill in estimated time using decimal notation (1.5 hours = 1h 30m)
|
||||
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
|
||||
await estimatedTimeInput.fill('1.5');
|
||||
await estimatedTimeInput.press('Tab');
|
||||
|
||||
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 &&
|
||||
// 1.5 hours = 5400 seconds
|
||||
(await response.json()).data.estimated_time === 5400
|
||||
),
|
||||
]);
|
||||
|
||||
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
|
||||
});
|
||||
|
||||
test('test that creating a project with estimated time using comma decimal notation works', async ({
|
||||
page,
|
||||
}) => {
|
||||
const newProjectName = 'Comma Decimal 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);
|
||||
|
||||
// Fill in estimated time using comma decimal notation (2,5 hours = 2h 30m)
|
||||
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
|
||||
await estimatedTimeInput.fill('2,5');
|
||||
await estimatedTimeInput.press('Tab');
|
||||
|
||||
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 &&
|
||||
// 2.5 hours = 9000 seconds
|
||||
(await response.json()).data.estimated_time === 9000
|
||||
),
|
||||
]);
|
||||
|
||||
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
|
||||
});
|
||||
|
||||
test('test that updating estimated time on existing project works', async ({ page }) => {
|
||||
const newProjectName = 'Update Estimated Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
await goToProjectsOverview(page);
|
||||
|
||||
// Create a project first
|
||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||
await page.getByLabel('Project Name').fill(newProjectName);
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Create Project' }).click(),
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/projects') &&
|
||||
response.request().method() === 'POST' &&
|
||||
response.status() === 201
|
||||
),
|
||||
]);
|
||||
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Edit the project to add estimated time
|
||||
await page.getByRole('row').first().getByRole('button').click();
|
||||
await page.getByRole('menuitem').getByText('Edit').first().click();
|
||||
|
||||
// Fill in estimated time
|
||||
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
|
||||
await estimatedTimeInput.fill('4h 15m');
|
||||
await estimatedTimeInput.press('Tab');
|
||||
|
||||
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 &&
|
||||
// 4h 15m = 15300 seconds
|
||||
(await response.json()).data.estimated_time === 15300
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
test('test that estimated time input displays formatted value after blur', async ({ page }) => {
|
||||
await goToProjectsOverview(page);
|
||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||
|
||||
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
|
||||
|
||||
// Enter time in various formats and check the displayed value
|
||||
await estimatedTimeInput.fill('90');
|
||||
await estimatedTimeInput.press('Tab');
|
||||
// 90 hours should be displayed as "90h 00min" (default format)
|
||||
await expect(estimatedTimeInput).toHaveValue(/90h/);
|
||||
|
||||
await estimatedTimeInput.fill('1:30');
|
||||
await estimatedTimeInput.press('Tab');
|
||||
// 1:30 should be displayed as "1h 30min"
|
||||
await expect(estimatedTimeInput).toHaveValue(/1h.*30/);
|
||||
});
|
||||
|
||||
// Create new project with new Client
|
||||
|
||||
// Create new project with existing Client
|
||||
|
||||
@@ -694,6 +694,74 @@ test('test that mass update billable status works', async ({ page }) => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that resetting project selection in mass update modal does not update project', async ({
|
||||
page,
|
||||
}) => {
|
||||
const projectName = 'Mass Update Reset Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
await createProject(page, projectName);
|
||||
|
||||
// Create a time entry with the project assigned
|
||||
await createBareTimeEntry(page, 'Mass update reset test', '1h');
|
||||
await goToTimeOverview(page);
|
||||
|
||||
// Assign project to the time entry
|
||||
const timeEntryRow = page.locator('[data-testid="time_entry_row"]').first();
|
||||
await expect(timeEntryRow).toBeVisible();
|
||||
await timeEntryRow.getByRole('button', { name: 'No Project' }).click();
|
||||
await page.getByRole('option', { name: projectName }).click();
|
||||
await expect(timeEntryRow.getByRole('button', { name: projectName })).toBeVisible();
|
||||
|
||||
// Now open mass update modal
|
||||
await page.getByLabel('Select All').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Edit' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// The project dropdown should show "Select project..." (initial unset state)
|
||||
const projectDropdown = page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: 'Select project...' });
|
||||
await expect(projectDropdown).toBeVisible();
|
||||
|
||||
// Select the project, then click the reset (X) button
|
||||
await projectDropdown.click();
|
||||
await page.getByRole('option', { name: projectName }).click();
|
||||
|
||||
// Now the dropdown shows the project name, click the X to reset
|
||||
await expect(page.getByRole('dialog').getByRole('button', { name: projectName })).toBeVisible();
|
||||
|
||||
// Find and click the reset button (the X icon next to the dropdown)
|
||||
await page.getByRole('dialog').getByTestId('project_reset_button').click();
|
||||
|
||||
// After reset, it should show "Select project..." again (not "No Project")
|
||||
await expect(
|
||||
page.getByRole('dialog').getByRole('button', { name: 'Select project...' })
|
||||
).toBeVisible();
|
||||
|
||||
// Submit the mass update - need to make at least one change for the API to accept it
|
||||
// Change billable status to keep it unchanged by selecting the "Keep current" option
|
||||
// Actually, we need to verify the reset behavior, so let's just change billable to trigger the request
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('combobox')
|
||||
.filter({ hasText: 'Set billable status' })
|
||||
.click();
|
||||
await page.getByRole('option', { name: 'Billable', exact: true }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Update Time Entries' }).click();
|
||||
|
||||
// Wait for dialog to close
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||
|
||||
// Verify the time entry still has the original project (was not changed to "No Project")
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid="time_entry_row"]')
|
||||
.first()
|
||||
.getByRole('button', { name: projectName })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that setting billable status via the create modal works', async ({ page }) => {
|
||||
await goToTimeOverview(page);
|
||||
|
||||
@@ -817,6 +885,254 @@ test('test that changing project on a time entry row from billable to non-billab
|
||||
expect(responseBody.data.billable).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* Tests for TimeEntryCreateModal functionality
|
||||
*/
|
||||
|
||||
test('test that natural language duration input works in create modal', async ({ page }) => {
|
||||
await goToTimeOverview(page);
|
||||
|
||||
// Open the create modal
|
||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Set description
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('textbox', { name: 'Description' })
|
||||
.fill('Duration test entry');
|
||||
|
||||
// Test natural language duration input "2h 30m"
|
||||
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
|
||||
await durationInput.fill('2h 30m');
|
||||
await durationInput.press('Tab');
|
||||
|
||||
// Verify the duration was parsed correctly (should show "2h 30min")
|
||||
await expect(durationInput).toHaveValue('2h 30min');
|
||||
|
||||
// Submit and verify the duration in the response (2h 30m = 9000 seconds)
|
||||
const [createResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) => response.url().includes('/time-entries') && response.status() === 201
|
||||
),
|
||||
page.getByRole('button', { name: 'Create Time Entry' }).click(),
|
||||
]);
|
||||
const createBody = await createResponse.json();
|
||||
expect(createBody.data.duration).toBe(9000);
|
||||
});
|
||||
|
||||
test('test that decimal duration input works in create modal', async ({ page }) => {
|
||||
await goToTimeOverview(page);
|
||||
|
||||
// Open the create modal
|
||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Set description
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('textbox', { name: 'Description' })
|
||||
.fill('Decimal duration test');
|
||||
|
||||
// Test decimal duration input "1.5h" (should be interpreted as 1.5 hours = 90 minutes)
|
||||
// Note: parse-duration library requires a unit suffix for decimal values
|
||||
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
|
||||
await durationInput.fill('1.5h');
|
||||
await durationInput.press('Tab');
|
||||
|
||||
// Verify the duration was parsed correctly (should show "1h 30min")
|
||||
await expect(durationInput).toHaveValue('1h 30min');
|
||||
|
||||
// Submit and verify the duration in the response (1.5h = 5400 seconds)
|
||||
const [createResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) => response.url().includes('/time-entries') && response.status() === 201
|
||||
),
|
||||
page.getByRole('button', { name: 'Create Time Entry' }).click(),
|
||||
]);
|
||||
const createBody = await createResponse.json();
|
||||
expect(createBody.data.duration).toBe(5400);
|
||||
});
|
||||
|
||||
test('test that project selection works in create modal', async ({ page }) => {
|
||||
const projectName = 'Create Modal Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
await createProject(page, projectName);
|
||||
|
||||
await goToTimeOverview(page);
|
||||
|
||||
// Open the create modal
|
||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Set description
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('textbox', { name: 'Description' })
|
||||
.fill('Project selection test');
|
||||
|
||||
// Select project
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'No Project' }).click();
|
||||
await page.getByRole('option', { name: projectName }).click();
|
||||
|
||||
// Verify project is selected
|
||||
await expect(page.getByRole('dialog').getByRole('button', { name: projectName })).toBeVisible();
|
||||
|
||||
// Set duration
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').fill('1h');
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
|
||||
|
||||
// Submit and verify project_id is set in response
|
||||
const [createResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) => response.url().includes('/time-entries') && response.status() === 201
|
||||
),
|
||||
page.getByRole('button', { name: 'Create Time Entry' }).click(),
|
||||
]);
|
||||
const createBody = await createResponse.json();
|
||||
expect(createBody.data.project_id).not.toBeNull();
|
||||
});
|
||||
|
||||
test('test that tag selection works in create modal', async ({ page }) => {
|
||||
await goToTimeOverview(page);
|
||||
|
||||
// Open the create modal
|
||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Set description
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('textbox', { name: 'Description' })
|
||||
.fill('Tag selection test');
|
||||
|
||||
// Open tags dropdown
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Tags' }).click();
|
||||
|
||||
// Create a new tag
|
||||
const tagName = 'TestTag' + Math.floor(1 + Math.random() * 10000);
|
||||
await page.getByText('Create new tag').click();
|
||||
await page.getByPlaceholder('Tag Name').fill(tagName);
|
||||
const [tagResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) => response.url().includes('/tags') && response.status() === 201
|
||||
),
|
||||
page.getByRole('button', { name: 'Create Tag' }).click(),
|
||||
]);
|
||||
const tagBody = await tagResponse.json();
|
||||
const tagId = tagBody.data.id;
|
||||
|
||||
// Verify tag button now shows "1 Tag"
|
||||
await expect(page.getByRole('dialog').getByRole('button', { name: '1 Tag' })).toBeVisible();
|
||||
|
||||
// Set duration
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').fill('1h');
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
|
||||
|
||||
// Submit and verify tags array contains the created tag
|
||||
const [createResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) => response.url().includes('/time-entries') && response.status() === 201
|
||||
),
|
||||
page.getByRole('button', { name: 'Create Time Entry' }).click(),
|
||||
]);
|
||||
const createBody = await createResponse.json();
|
||||
expect(createBody.data.tags).toContain(tagId);
|
||||
});
|
||||
|
||||
test('test that tags dropdown does not show No Tag option in create modal', async ({ page }) => {
|
||||
await goToTimeOverview(page);
|
||||
|
||||
// Open the create modal
|
||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Open tags dropdown
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Tags' }).click();
|
||||
|
||||
// Verify "No Tag" option is not visible
|
||||
await expect(page.getByText('No Tag')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('test that start time picker works in create modal', async ({ page }) => {
|
||||
await goToTimeOverview(page);
|
||||
|
||||
// Open the create modal
|
||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Set description
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('textbox', { name: 'Description' })
|
||||
.fill('Time picker test');
|
||||
|
||||
// Set duration first (so it doesn't recalculate start time when we set it)
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').fill('1h');
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
|
||||
|
||||
// Find the start time input (first time_picker_input in the modal)
|
||||
const modal = page.getByRole('dialog');
|
||||
const startTimeInput = modal.getByTestId('time_picker_input').first();
|
||||
await startTimeInput.fill('09:30');
|
||||
await startTimeInput.press('Tab');
|
||||
|
||||
// Verify the time picker input shows the correct value
|
||||
await expect(startTimeInput).toHaveValue('09:30');
|
||||
|
||||
// Submit and verify the time entry was created
|
||||
const [createResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) => response.url().includes('/time-entries') && response.status() === 201
|
||||
),
|
||||
page.getByRole('button', { name: 'Create Time Entry' }).click(),
|
||||
]);
|
||||
const createBody = await createResponse.json();
|
||||
// The start time should contain 09:30 in the timestamp
|
||||
expect(createBody.data.start).toMatch(/09:30/);
|
||||
});
|
||||
|
||||
test('test that end time picker works in create modal', async ({ page }) => {
|
||||
await goToTimeOverview(page);
|
||||
|
||||
// Open the create modal
|
||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Set description
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('textbox', { name: 'Description' })
|
||||
.fill('End time picker test');
|
||||
|
||||
// Find the end time input (second time_picker_input in the modal)
|
||||
const modal = page.getByRole('dialog');
|
||||
const endTimeInput = modal.getByTestId('time_picker_input').nth(1);
|
||||
await endTimeInput.fill('17:45');
|
||||
await endTimeInput.press('Tab');
|
||||
|
||||
// Set duration (this will adjust based on the times)
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').fill('1h');
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
|
||||
|
||||
// Submit and verify end time contains 17:45
|
||||
const [createResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) => response.url().includes('/time-entries') && response.status() === 201
|
||||
),
|
||||
page.getByRole('button', { name: 'Create Time Entry' }).click(),
|
||||
]);
|
||||
const createBody = await createResponse.json();
|
||||
// The end time should be set (we filled duration after, so it recalculates)
|
||||
expect(createBody.data.end).toBeTruthy();
|
||||
});
|
||||
|
||||
test('test that changing project in edit modal from non-billable to billable updates billable status', async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -201,6 +201,7 @@ const firstProjectId = computed(() => projects.value[0]?.id ?? '');
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
v-model:project="currentTimeEntry.project_id"
|
||||
v-model:task="currentTimeEntry.task_id"
|
||||
variant="input"
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:clients="activeClients"
|
||||
@@ -209,7 +210,6 @@ const firstProjectId = computed(() => projects.value[0]?.id ?? '');
|
||||
:can-create-project="canCreateProjects()"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
||||
size="xlarge"
|
||||
class="w-full" />
|
||||
</template>
|
||||
<template #footer>
|
||||
@@ -225,6 +225,7 @@ const firstProjectId = computed(() => projects.value[0]?.id ?? '');
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
v-model:project="currentTimeEntry.project_id"
|
||||
v-model:task="currentTimeEntry.task_id"
|
||||
variant="input"
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:clients="activeClients"
|
||||
@@ -233,7 +234,6 @@ const firstProjectId = computed(() => projects.value[0]?.id ?? '');
|
||||
:can-create-project="canCreateProjects()"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
||||
size="xlarge"
|
||||
class="w-full" />
|
||||
</template>
|
||||
<template #footer>
|
||||
|
||||
@@ -21,7 +21,7 @@ const props = withDefaults(
|
||||
<button
|
||||
:type="type"
|
||||
:disabled="loading"
|
||||
class="inline-flex items-center px-2 sm:px-3 py-1 sm:py-2 bg-button-primary-background border border-button-primary-border rounded-md font-medium text-xs sm:text-sm text-button-primary-text hover:bg-button-primary-background-hover active:bg-button-primary-background-hover focus:outline-none focus-visible:ring-2 focus-visible:border-transparent focus-visible:ring-ring transition ease-in-out duration-150">
|
||||
class="inline-flex items-center px-3 py-2 bg-button-primary-background border border-button-primary-border rounded-md font-medium text-xs sm:text-sm text-button-primary-text hover:bg-button-primary-background-hover active:bg-button-primary-background-hover focus:outline-none focus-visible:ring-2 focus-visible:border-transparent focus-visible:ring-ring transition ease-in-out duration-150">
|
||||
<span :class="twMerge('flex items-center ', props.icon ? 'space-x-1.5' : '')">
|
||||
<LoadingSpinner v-if="loading"></LoadingSpinner>
|
||||
<component
|
||||
|
||||
@@ -21,8 +21,8 @@ const props = withDefaults(
|
||||
);
|
||||
|
||||
const sizeClasses = {
|
||||
small: 'text-xs px-2 sm:px-2.5 py-1 sm:py-1.5',
|
||||
base: 'text-xs sm:text-sm px-2 sm:px-3 py-1 sm:py-2',
|
||||
small: 'text-xs px-2.5 py-1.5',
|
||||
base: 'text-xs sm:text-sm px-3 py-2',
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ const close = () => {
|
||||
|
||||
<template>
|
||||
<Modal :show="show" :max-width="maxWidth" :closeable="closeable" @close="close">
|
||||
<div class="px-6 py-4">
|
||||
<div class="px-4 lg:px-6 py-4">
|
||||
<div class="text-lg font-medium text-text-primary" role="heading">
|
||||
<slot name="title" />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import DurationInput from '@/packages/ui/src/Input/DurationInput.vue';
|
||||
import EstimatedTimeInput from '@/packages/ui/src/Input/EstimatedTimeInput.vue';
|
||||
import { ClockIcon } from '@heroicons/vue/20/solid';
|
||||
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
|
||||
|
||||
@@ -13,10 +13,14 @@ const emit = defineEmits(['submit']);
|
||||
<ClockIcon class="text-text-quaternary w-4"></ClockIcon>
|
||||
<InputLabel for="billable" value="Time Estimated" />
|
||||
</div>
|
||||
<DurationInput
|
||||
v-model="model"
|
||||
class="max-w-[150px]"
|
||||
@submit="emit('submit')"></DurationInput>
|
||||
<EstimatedTimeInput v-model="model" @submit="emit('submit')"></EstimatedTimeInput>
|
||||
<div class="flex items-center text-text-secondary text-xs pt-2 pl-1">
|
||||
<span>
|
||||
<span class="font-semibold">Info:</span>
|
||||
You can type natural language like
|
||||
<span class="font-semibold">2h 30m</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { TextInput } from '@/packages/ui/src';
|
||||
|
||||
const model = defineModel<number | null>({
|
||||
default: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [];
|
||||
}>();
|
||||
|
||||
const temporaryCustomTimerEntry = ref<string>('');
|
||||
|
||||
function updateDuration() {
|
||||
const hours = parseInt(temporaryCustomTimerEntry.value);
|
||||
if (!isNaN(hours)) {
|
||||
model.value = hours * 60 * 60;
|
||||
}
|
||||
temporaryCustomTimerEntry.value = '';
|
||||
}
|
||||
|
||||
const currentTime = computed({
|
||||
get() {
|
||||
if (temporaryCustomTimerEntry.value !== '') {
|
||||
return temporaryCustomTimerEntry.value;
|
||||
}
|
||||
if (model.value === null) {
|
||||
return '';
|
||||
}
|
||||
return Math.round(model.value / 60 / 60).toString();
|
||||
},
|
||||
// setter
|
||||
set(newValue) {
|
||||
if (newValue) {
|
||||
temporaryCustomTimerEntry.value = newValue;
|
||||
} else {
|
||||
temporaryCustomTimerEntry.value = '';
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function selectInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
target.select();
|
||||
}
|
||||
|
||||
function updateAndSubmit() {
|
||||
updateDuration();
|
||||
emit('submit');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<TextInput
|
||||
v-model="currentTime"
|
||||
class="w-full overflow-hidden pr-14"
|
||||
placeholder="0"
|
||||
@focus="selectInput"
|
||||
@blur="updateDuration"
|
||||
@keydown.enter="updateAndSubmit">
|
||||
</TextInput>
|
||||
<div class="absolute top-0 right-0 h-full flex items-center px-4 font-medium">
|
||||
<span> hours </span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch, inject } from 'vue';
|
||||
import { formatHumanReadableDuration, parseTimeInput } from '@/packages/ui/src/utils/time';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { TextInput } from '@/packages/ui/src';
|
||||
import type { Organization } from '@/packages/api/src';
|
||||
import { type ComputedRef } from 'vue';
|
||||
|
||||
const temporaryInput = ref<string>('');
|
||||
|
||||
const model = defineModel<number | null>({
|
||||
default: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [];
|
||||
}>();
|
||||
|
||||
const organization = inject<ComputedRef<Organization>>('organization');
|
||||
|
||||
function updateDuration() {
|
||||
const input = temporaryInput.value.trim();
|
||||
|
||||
if (input === '') {
|
||||
model.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Use parseTimeInput with 'hours' as default unit for estimated time
|
||||
const seconds = parseTimeInput(input, 'hours');
|
||||
if (seconds !== null && seconds > 0) {
|
||||
model.value = seconds;
|
||||
}
|
||||
|
||||
updateInputDisplay();
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
class?: string;
|
||||
}>();
|
||||
|
||||
watch(model, updateInputDisplay);
|
||||
onMounted(() => updateInputDisplay());
|
||||
|
||||
function updateInputDisplay() {
|
||||
if (model.value !== null && model.value > 0) {
|
||||
temporaryInput.value = formatHumanReadableDuration(
|
||||
model.value,
|
||||
organization?.value?.interval_format,
|
||||
organization?.value?.number_format
|
||||
);
|
||||
} else {
|
||||
temporaryInput.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function selectInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
target.select();
|
||||
}
|
||||
|
||||
function updateAndSubmit() {
|
||||
updateDuration();
|
||||
emit('submit');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TextInput
|
||||
ref="inputField"
|
||||
v-model="temporaryInput"
|
||||
:class="twMerge('text-text-secondary', props.class)"
|
||||
type="text"
|
||||
placeholder="e.g. 2h 30m or 1.5"
|
||||
@focus="selectInput"
|
||||
@blur="updateDuration"
|
||||
@keydown.enter="updateAndSubmit" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -119,7 +119,6 @@ function onSelectChange(checked: boolean) {
|
||||
:can-create-project
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:show-badge-border="false"
|
||||
:project="timeEntry.project_id"
|
||||
:enable-estimated-time
|
||||
:currency="currency"
|
||||
@@ -136,8 +135,8 @@ function onSelectChange(checked: boolean) {
|
||||
@changed="updateTimeEntryTags"></TimeEntryRowTagDropdown>
|
||||
<BillableToggleButton
|
||||
:model-value="timeEntry.billable"
|
||||
class="opacity-50 focus-visible:opacity-100 group-hover:opacity-100"
|
||||
size="small"
|
||||
class="opacity-50 focus-visible:opacity-100 group-hover:opacity-100"
|
||||
@changed="updateTimeEntryBillable"></BillableToggleButton>
|
||||
<div class="flex-1">
|
||||
<button
|
||||
@@ -220,7 +219,6 @@ function onSelectChange(checked: boolean) {
|
||||
:can-create-project
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:show-badge-border="false"
|
||||
:project="timeEntry.project_id"
|
||||
:enable-estimated-time
|
||||
:currency="currency"
|
||||
|
||||
@@ -16,7 +16,6 @@ import type {
|
||||
CreateTimeEntryBody,
|
||||
} from '@/packages/api/src';
|
||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||
import { Badge } from '@/packages/ui/src';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
import {
|
||||
Select,
|
||||
@@ -25,6 +24,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/Components/ui/select';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
|
||||
import DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';
|
||||
|
||||
@@ -159,63 +159,53 @@ const billableProxy = computed({
|
||||
@keydown.enter="submit" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="sm:flex justify-between items-end space-y-2 sm:space-y-0 pt-4 sm:space-x-4">
|
||||
<div class="flex w-full items-center space-x-2 justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
v-model:project="timeEntry.project_id"
|
||||
v-model:task="timeEntry.task_id"
|
||||
:clients
|
||||
:create-project
|
||||
:create-client
|
||||
:can-create-project
|
||||
:currency
|
||||
size="xlarge"
|
||||
class="bg-input-background"
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:enable-estimated-time="
|
||||
enableEstimatedTime
|
||||
"></TimeTrackerProjectTaskDropdown>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex-col">
|
||||
<TagDropdown v-model="timeEntry.tags" :create-tag :tags="tags">
|
||||
<template #trigger>
|
||||
<Badge class="bg-input-background" tag="button" size="xlarge">
|
||||
<TagIcon
|
||||
v-if="timeEntry.tags.length === 0"
|
||||
class="w-4"></TagIcon>
|
||||
<div
|
||||
v-else
|
||||
class="bg-accent-300/20 w-5 h-5 font-medium rounded flex items-center transition justify-center">
|
||||
{{ timeEntry.tags.length }}
|
||||
</div>
|
||||
<span>Tags</span>
|
||||
</Badge>
|
||||
</template>
|
||||
</TagDropdown>
|
||||
</div>
|
||||
<div class="flex-col">
|
||||
<Select v-model="billableProxy">
|
||||
<SelectTrigger size="small" :show-chevron="false">
|
||||
<SelectValue class="flex items-center gap-2">
|
||||
<BillableIcon class="h-4 text-icon-default" />
|
||||
<span>{{
|
||||
timeEntry.billable ? 'Billable' : 'Non-Billable'
|
||||
}}</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">Billable</SelectItem>
|
||||
<SelectItem value="false">Non Billable</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row sm:items-end gap-2 sm:gap-4 pt-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
v-model:project="timeEntry.project_id"
|
||||
v-model:task="timeEntry.task_id"
|
||||
variant="input"
|
||||
:clients
|
||||
:create-project
|
||||
:create-client
|
||||
:can-create-project
|
||||
:currency
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:enable-estimated-time="enableEstimatedTime" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<TagDropdown
|
||||
v-model="timeEntry.tags"
|
||||
:create-tag
|
||||
:tags="tags"
|
||||
:show-no-tag-option="false">
|
||||
<template #trigger>
|
||||
<Button variant="input" size="sm">
|
||||
<TagIcon class="h-4 text-icon-default" />
|
||||
<span>{{
|
||||
timeEntry.tags.length === 0
|
||||
? 'Tags'
|
||||
: `${timeEntry.tags.length} Tag${timeEntry.tags.length > 1 ? 's' : ''}`
|
||||
}}</span>
|
||||
</Button>
|
||||
</template>
|
||||
</TagDropdown>
|
||||
<Select v-model="billableProxy">
|
||||
<SelectTrigger size="small" :show-chevron="false">
|
||||
<SelectValue class="flex items-center gap-2">
|
||||
<BillableIcon class="h-4 text-icon-default" />
|
||||
<span>{{ timeEntry.billable ? 'Billable' : 'Non-Billable' }}</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">Billable</SelectItem>
|
||||
<SelectItem value="false">Non Billable</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex pt-4 space-x-4">
|
||||
<div class="flex flex-col sm:flex-row gap-4 pt-4">
|
||||
<div class="flex-1">
|
||||
<InputLabel>Duration</InputLabel>
|
||||
<div class="space-y-2 mt-1 flex flex-col">
|
||||
@@ -225,32 +215,37 @@ const billableProxy = computed({
|
||||
name="Duration"></DurationHumanInput>
|
||||
<div class="text-sm flex space-x-1">
|
||||
<InformationCircleIcon
|
||||
class="w-4 text-text-quaternary"></InformationCircleIcon>
|
||||
class="w-4 shrink-0 text-text-quaternary"></InformationCircleIcon>
|
||||
<span class="text-text-secondary text-xs">
|
||||
You can type natural language here f.e.
|
||||
You can type natural language like
|
||||
<span class="font-semibold"> 2h 30m</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="">
|
||||
<InputLabel>Start</InputLabel>
|
||||
<div class="flex flex-col items-center space-y-2 mt-1 w-28 mx-auto">
|
||||
<TimePickerSimple
|
||||
v-model="localStart"
|
||||
class="w-full"
|
||||
size="large"></TimePickerSimple>
|
||||
<DatePicker v-model="localStart" class="w-full" tabindex="1"></DatePicker>
|
||||
<div class="grid grid-cols-2 sm:flex gap-4">
|
||||
<div>
|
||||
<InputLabel>Start</InputLabel>
|
||||
<div class="flex flex-col gap-2 mt-1 sm:w-28">
|
||||
<TimePickerSimple
|
||||
v-model="localStart"
|
||||
class="w-full"
|
||||
size="large"></TimePickerSimple>
|
||||
<DatePicker
|
||||
v-model="localStart"
|
||||
class="w-full"
|
||||
tabindex="1"></DatePicker>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="">
|
||||
<InputLabel>End</InputLabel>
|
||||
<div class="flex flex-col items-center space-y-2 mt-1 w-28 mx-auto">
|
||||
<TimePickerSimple
|
||||
v-model="localEnd"
|
||||
class="w-full"
|
||||
size="large"></TimePickerSimple>
|
||||
<DatePicker v-model="localEnd" class="w-full" tabindex="1"></DatePicker>
|
||||
<div>
|
||||
<InputLabel>End</InputLabel>
|
||||
<div class="flex flex-col gap-2 mt-1 sm:w-28">
|
||||
<TimePickerSimple
|
||||
v-model="localEnd"
|
||||
class="w-full"
|
||||
size="large"></TimePickerSimple>
|
||||
<DatePicker v-model="localEnd" class="w-full" tabindex="1"></DatePicker>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,6 @@ import type {
|
||||
TimeEntry,
|
||||
} from '@/packages/api/src';
|
||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||
import { Badge } from '@/packages/ui/src';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
import {
|
||||
Select,
|
||||
@@ -25,6 +24,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/Components/ui/select';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
|
||||
import DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';
|
||||
|
||||
@@ -166,72 +166,55 @@ const billableProxy = computed({
|
||||
@keydown.enter="submit" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="sm:flex justify-between items-end space-y-2 sm:space-y-0 pt-4 sm:space-x-4">
|
||||
<div class="flex w-full items-center space-x-2 justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
v-model:project="editableTimeEntry.project_id"
|
||||
v-model:task="editableTimeEntry.task_id"
|
||||
:clients
|
||||
:create-project
|
||||
:create-client
|
||||
:can-create-project="canCreateProject"
|
||||
:currency="currency"
|
||||
size="xlarge"
|
||||
class="bg-input-background"
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:enable-estimated-time="
|
||||
enableEstimatedTime
|
||||
"></TimeTrackerProjectTaskDropdown>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex-col">
|
||||
<TagDropdown
|
||||
v-model="editableTimeEntry.tags"
|
||||
:create-tag
|
||||
:tags="tags">
|
||||
<template #trigger>
|
||||
<Badge
|
||||
class="bg-input-background"
|
||||
tag="button"
|
||||
size="xlarge">
|
||||
<TagIcon
|
||||
v-if="editableTimeEntry.tags.length === 0"
|
||||
class="w-4"></TagIcon>
|
||||
<div
|
||||
v-else
|
||||
class="bg-accent-300/20 w-5 h-5 font-medium rounded flex items-center transition justify-center">
|
||||
{{ editableTimeEntry.tags.length }}
|
||||
</div>
|
||||
<span>Tags</span>
|
||||
</Badge>
|
||||
</template>
|
||||
</TagDropdown>
|
||||
</div>
|
||||
<div class="flex-col">
|
||||
<Select v-model="billableProxy">
|
||||
<SelectTrigger size="small" :show-chevron="false">
|
||||
<SelectValue class="flex items-center gap-2">
|
||||
<BillableIcon class="h-4 text-icon-default" />
|
||||
<span>{{
|
||||
editableTimeEntry.billable
|
||||
? 'Billable'
|
||||
: 'Non-Billable'
|
||||
}}</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">Billable</SelectItem>
|
||||
<SelectItem value="false">Non Billable</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row sm:items-end gap-2 sm:gap-4 pt-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
v-model:project="editableTimeEntry.project_id"
|
||||
v-model:task="editableTimeEntry.task_id"
|
||||
variant="input"
|
||||
:clients
|
||||
:create-project
|
||||
:create-client
|
||||
:can-create-project="canCreateProject"
|
||||
:currency="currency"
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:enable-estimated-time="enableEstimatedTime" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<TagDropdown
|
||||
v-model="editableTimeEntry.tags"
|
||||
:create-tag
|
||||
:tags="tags"
|
||||
:show-no-tag-option="false">
|
||||
<template #trigger>
|
||||
<Button variant="input" size="sm">
|
||||
<TagIcon class="h-4 text-icon-default" />
|
||||
<span>{{
|
||||
editableTimeEntry.tags.length === 0
|
||||
? 'Tags'
|
||||
: `${editableTimeEntry.tags.length} Tag${editableTimeEntry.tags.length > 1 ? 's' : ''}`
|
||||
}}</span>
|
||||
</Button>
|
||||
</template>
|
||||
</TagDropdown>
|
||||
<Select v-model="billableProxy">
|
||||
<SelectTrigger size="small" :show-chevron="false">
|
||||
<SelectValue class="flex items-center gap-2">
|
||||
<BillableIcon class="h-4 text-icon-default" />
|
||||
<span>{{
|
||||
editableTimeEntry.billable ? 'Billable' : 'Non-Billable'
|
||||
}}</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">Billable</SelectItem>
|
||||
<SelectItem value="false">Non Billable</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex pt-4 space-x-4">
|
||||
<div class="flex flex-col sm:flex-row gap-4 pt-4">
|
||||
<div class="flex-1">
|
||||
<InputLabel>Duration</InputLabel>
|
||||
<div class="space-y-2 mt-1 flex flex-col">
|
||||
@@ -241,35 +224,40 @@ const billableProxy = computed({
|
||||
name="Duration"></DurationHumanInput>
|
||||
<div class="text-sm flex space-x-1">
|
||||
<InformationCircleIcon
|
||||
class="w-4 text-text-quaternary"></InformationCircleIcon>
|
||||
class="w-4 shrink-0 text-text-quaternary"></InformationCircleIcon>
|
||||
<span class="text-text-secondary text-xs">
|
||||
You can type natural language here f.e.
|
||||
You can type natural language like
|
||||
<span class="font-semibold"> 2h 30m</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="">
|
||||
<InputLabel>Start</InputLabel>
|
||||
<div class="flex flex-col items-center space-y-2 mt-1 w-28 mx-auto">
|
||||
<TimePickerSimple
|
||||
v-model="localStart"
|
||||
class="w-full"
|
||||
size="large"></TimePickerSimple>
|
||||
<DatePicker
|
||||
v-model="localStart"
|
||||
class="w-full"
|
||||
tabindex="1"></DatePicker>
|
||||
<div class="grid grid-cols-2 sm:flex gap-4">
|
||||
<div>
|
||||
<InputLabel>Start</InputLabel>
|
||||
<div class="flex flex-col gap-2 mt-1 sm:w-28">
|
||||
<TimePickerSimple
|
||||
v-model="localStart"
|
||||
class="w-full"
|
||||
size="large"></TimePickerSimple>
|
||||
<DatePicker
|
||||
v-model="localStart"
|
||||
class="w-full"
|
||||
tabindex="1"></DatePicker>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="">
|
||||
<InputLabel>End</InputLabel>
|
||||
<div class="flex flex-col items-center space-y-2 mt-1 w-28 mx-auto">
|
||||
<TimePickerSimple
|
||||
v-model="localEnd"
|
||||
class="w-full"
|
||||
size="large"></TimePickerSimple>
|
||||
<DatePicker v-model="localEnd" class="w-full" tabindex="1"></DatePicker>
|
||||
<div>
|
||||
<InputLabel>End</InputLabel>
|
||||
<div class="flex flex-col gap-2 mt-1 sm:w-28">
|
||||
<TimePickerSimple
|
||||
v-model="localEnd"
|
||||
class="w-full"
|
||||
size="large"></TimePickerSimple>
|
||||
<DatePicker
|
||||
v-model="localEnd"
|
||||
class="w-full"
|
||||
tabindex="1"></DatePicker>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
type TimeEntry,
|
||||
type UpdateMultipleTimeEntriesChangeset,
|
||||
} from '@/packages/api/src';
|
||||
import { Badge, Checkbox } from '@/packages/ui/src';
|
||||
import { Checkbox } from '@/packages/ui/src';
|
||||
import { TagIcon } from '@heroicons/vue/20/solid';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/Components/ui/select';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||
import type { Tag, Task } from '@/packages/api/src';
|
||||
|
||||
@@ -159,6 +161,9 @@ watch(removeAllTags, () => {
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
v-model:project="projectId"
|
||||
v-model:task="taskId"
|
||||
variant="input"
|
||||
align="start"
|
||||
size="default"
|
||||
:clients
|
||||
:create-project
|
||||
:create-client
|
||||
@@ -167,7 +172,6 @@ watch(removeAllTags, () => {
|
||||
class="mt-1"
|
||||
empty-placeholder="Select project..."
|
||||
allow-reset
|
||||
size="xlarge"
|
||||
:enable-estimated-time
|
||||
:projects="projects"
|
||||
:tasks="tasks"></TimeTrackerProjectTaskDropdown>
|
||||
@@ -175,14 +179,19 @@ watch(removeAllTags, () => {
|
||||
<div class="space-y-2">
|
||||
<InputLabel for="project" value="Tag" />
|
||||
<div class="flex space-x-5">
|
||||
<TagDropdown v-model="selectedTags" :create-tag :tags="tags">
|
||||
<TagDropdown
|
||||
v-model="selectedTags"
|
||||
:create-tag
|
||||
:tags="tags"
|
||||
:show-no-tag-option="false">
|
||||
<template #trigger>
|
||||
<Badge :disabled="removeAllTags" tag="button" size="xlarge">
|
||||
<Button variant="input" :disabled="removeAllTags">
|
||||
<TagIcon class="h-4 text-icon-default" />
|
||||
<span v-if="selectedTags.length > 0">
|
||||
Set {{ selectedTags.length }} tags
|
||||
</span>
|
||||
<span v-else> Select Tags... </span>
|
||||
</Badge>
|
||||
<span v-else>Select Tags...</span>
|
||||
</Button>
|
||||
</template>
|
||||
</TagDropdown>
|
||||
<div class="flex items-center space-x-2">
|
||||
@@ -195,7 +204,7 @@ watch(removeAllTags, () => {
|
||||
<InputLabel for="project" value="Billable" />
|
||||
<div class="flex">
|
||||
<Select v-model="timeEntryBillable">
|
||||
<SelectTrigger size="small" :show-chevron="false">
|
||||
<SelectTrigger>
|
||||
<SelectValue>
|
||||
<span v-if="billable === undefined">Set billable status</span>
|
||||
<span v-else-if="billable === true">Billable</span>
|
||||
|
||||
@@ -131,7 +131,6 @@ async function handleDeleteTimeEntry() {
|
||||
:clients
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:show-badge-border="false"
|
||||
:project="timeEntry.project_id"
|
||||
:currency="currency"
|
||||
:enable-estimated-time
|
||||
@@ -150,10 +149,10 @@ async function handleDeleteTimeEntry() {
|
||||
@changed="updateTimeEntryTags"></TimeEntryRowTagDropdown>
|
||||
<BillableToggleButton
|
||||
:model-value="timeEntry.billable"
|
||||
size="small"
|
||||
:class="
|
||||
twMerge('opacity-50 group-hover:opacity-100 focus-visible:opacity-100')
|
||||
"
|
||||
size="small"
|
||||
@changed="updateTimeEntryBillable"></BillableToggleButton>
|
||||
<div class="flex-1">
|
||||
<TimeEntryRangeSelector
|
||||
@@ -199,7 +198,6 @@ async function handleDeleteTimeEntry() {
|
||||
:clients
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:show-badge-border="false"
|
||||
:project="timeEntry.project_id"
|
||||
:currency="currency"
|
||||
:enable-estimated-time
|
||||
|
||||
@@ -251,6 +251,7 @@ useSelectEvents(
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
v-model:project="currentTimeEntry.project_id"
|
||||
v-model:task="currentTimeEntry.task_id"
|
||||
variant="outline"
|
||||
:create-client
|
||||
:can-create-project
|
||||
:clients
|
||||
|
||||
@@ -10,11 +10,11 @@ import type {
|
||||
Task,
|
||||
Client,
|
||||
} from '@/packages/api/src';
|
||||
import ProjectBadge from '@/packages/ui/src/Project/ProjectBadge.vue';
|
||||
import Badge from '@/packages/ui/src/Badge.vue';
|
||||
|
||||
import { PlusIcon, PlusCircleIcon, MinusIcon, XMarkIcon } from '@heroicons/vue/16/solid';
|
||||
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
|
||||
const task = defineModel<string | null>('task', {
|
||||
default: null,
|
||||
@@ -48,8 +48,6 @@ type ClientsWithProjectsWithTasks = ClientWithProjectsWithTasks[];
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
showBadgeBorder?: boolean;
|
||||
size?: 'base' | 'large' | 'xlarge';
|
||||
projects: Project[];
|
||||
tasks: Task[];
|
||||
clients: Client[];
|
||||
@@ -61,12 +59,16 @@ const props = withDefaults(
|
||||
enableEstimatedTime: boolean;
|
||||
canCreateProject: boolean;
|
||||
class?: string;
|
||||
variant?: 'input' | 'ghost' | 'outline';
|
||||
align?: 'center' | 'end' | 'start';
|
||||
size?: 'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'input';
|
||||
}>(),
|
||||
{
|
||||
showBadgeBorder: true,
|
||||
size: 'large',
|
||||
emptyPlaceholder: 'No Project',
|
||||
allowReset: false,
|
||||
variant: 'ghost',
|
||||
align: 'center',
|
||||
size: 'sm',
|
||||
}
|
||||
);
|
||||
|
||||
@@ -486,56 +488,51 @@ function selectProject(projectId: string) {
|
||||
emit('changed', project.value, task.value);
|
||||
}
|
||||
|
||||
function resetProject() {
|
||||
project.value = null;
|
||||
task.value = null;
|
||||
emit('changed', project.value, task.value);
|
||||
}
|
||||
|
||||
const showCreateProject = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="projects.length === 0 && canCreateProject">
|
||||
<Badge
|
||||
size="large"
|
||||
tag="button"
|
||||
class="cursor-pointer hover:bg-tertiary"
|
||||
<Button
|
||||
:variant="props.variant"
|
||||
:size="props.size"
|
||||
:class="twMerge('w-full justify-start', props.class)"
|
||||
@click="showCreateProject = true">
|
||||
<PlusIcon class="-ml-1 w-5"></PlusIcon>
|
||||
<PlusIcon class="w-4" />
|
||||
<span>Add new project</span>
|
||||
</Badge>
|
||||
</Button>
|
||||
</div>
|
||||
<Dropdown v-else v-model="open" :close-on-content-click="false" align="center">
|
||||
<Dropdown v-else v-model="open" :close-on-content-click="false" :align="props.align">
|
||||
<template #trigger>
|
||||
<ProjectBadge
|
||||
ref="projectDropdownTrigger"
|
||||
:color="selectedProjectColor"
|
||||
:size="size"
|
||||
:border="showBadgeBorder"
|
||||
tag="button"
|
||||
:name="selectedProjectName"
|
||||
:class="
|
||||
twMerge(
|
||||
'focus:border-border-tertiary w-full focus:outline-0 focus:bg-card-background-separator min-w-0 relative',
|
||||
props.class
|
||||
)
|
||||
">
|
||||
<div class="flex items-center lg:space-x-1 min-w-0 overflow-hidden">
|
||||
<span class="text-xs lg:text-sm shrink-0">
|
||||
{{ selectedProjectName }}
|
||||
</span>
|
||||
<ChevronRightIcon
|
||||
v-if="currentTask"
|
||||
class="w-4 lg:w-5 text-text-secondary shrink-0"></ChevronRightIcon>
|
||||
<div v-if="currentTask" class="min-w-0 text-xs lg:text-sm truncate shrink">
|
||||
{{ currentTask.name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
:variant="props.variant"
|
||||
:size="props.size"
|
||||
:class="twMerge('w-full justify-start', props.class)">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full shrink-0"
|
||||
:style="{ backgroundColor: selectedProjectColor }"></div>
|
||||
<span class="truncate">{{ selectedProjectName }}</span>
|
||||
<template v-if="currentTask">
|
||||
<ChevronRightIcon class="w-4 h-4 text-text-tertiary shrink-0" />
|
||||
<span class="truncate">{{ currentTask.name }}</span>
|
||||
</template>
|
||||
</Button>
|
||||
<button
|
||||
v-if="project !== null && allowReset"
|
||||
class="absolute right-0 top-0 h-full flex items-center pr-3 text-text-quaternary hover:text-text-secondary"
|
||||
@click.stop="
|
||||
project = null;
|
||||
task = null;
|
||||
">
|
||||
<XMarkIcon class="w-5"></XMarkIcon>
|
||||
v-if="allowReset && project !== null"
|
||||
type="button"
|
||||
data-testid="project_reset_button"
|
||||
class="p-1 rounded hover:bg-quaternary text-text-tertiary hover:text-text-primary"
|
||||
@click.stop="resetProject">
|
||||
<XMarkIcon class="w-4 h-4" />
|
||||
</button>
|
||||
</ProjectBadge>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<UseFocusTrap v-if="open" :options="{ immediate: true, allowOutsideClick: true }">
|
||||
|
||||
Reference in New Issue
Block a user