add format check, update prettier rules, apply rules consistently

This commit is contained in:
Gregor Vostrak
2025-07-29 18:58:31 +02:00
parent b11672732b
commit cb30487a21
323 changed files with 3728 additions and 5579 deletions

23
.github/workflows/npm-format-check.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: NPM Format Check
on: [push]
jobs:
format-check:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: "Checkout code"
uses: actions/checkout@v4
- name: "Use Node.js"
uses: actions/setup-node@v4
with:
node-version: '20.x'
- name: "Install npm dependencies"
run: npm ci
- name: "Check code formatting"
run: npm run format:check

27
.prettierignore Normal file
View File

@@ -0,0 +1,27 @@
# Ignore build outputs
node_modules/
vendor/
storage/
bootstrap/cache/
public/build/
public/hot/
# Ignore lock files
package-lock.json
composer.lock
# Ignore generated files
*.min.js
*.min.css
# Ignore test results
test-results/
playwright-report/
# Ignore IDE files
.idea/
.vscode/
# Ignore OS files
.DS_Store
Thumbs.db

View File

@@ -3,5 +3,6 @@
"tabWidth": 4, "tabWidth": 4,
"singleQuote": true, "singleQuote": true,
"bracketSameLine": true, "bracketSameLine": true,
"quoteProps": "preserve" "quoteProps": "preserve",
"printWidth": 100
} }

View File

@@ -7,11 +7,8 @@ async function goToProjectsOverview(page: Page) {
} }
// Create new project via modal // Create new project via modal
test('test that creating and deleting a new client via the modal works', async ({ test('test that creating and deleting a new client via the modal works', async ({ page }) => {
page, const newClientName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
}) => {
const newClientName =
'New Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page); await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Client' }).click(); await page.getByRole('button', { name: 'Create Client' }).click();
await page.getByPlaceholder('Client Name').fill(newClientName); await page.getByPlaceholder('Client Name').fill(newClientName);
@@ -28,13 +25,9 @@ test('test that creating and deleting a new client via the modal works', async (
]); ]);
await expect(page.getByTestId('client_table')).toContainText(newClientName); await expect(page.getByTestId('client_table')).toContainText(newClientName);
const moreButton = page.locator( const moreButton = page.locator("[aria-label='Actions for Client " + newClientName + "']");
"[aria-label='Actions for Client " + newClientName + "']"
);
moreButton.click(); moreButton.click();
const deleteButton = page.locator( const deleteButton = page.locator("[aria-label='Delete Client " + newClientName + "']");
"[aria-label='Delete Client " + newClientName + "']"
);
await Promise.all([ await Promise.all([
deleteButton.click(), deleteButton.click(),
@@ -45,9 +38,7 @@ test('test that creating and deleting a new client via the modal works', async (
response.status() === 204 response.status() === 204
), ),
]); ]);
await expect(page.getByTestId('client_table')).not.toContainText( await expect(page.getByTestId('client_table')).not.toContainText(newClientName);
newClientName
);
}); });
test('test that archiving and unarchiving clients works', async ({ page }) => { test('test that archiving and unarchiving clients works', async ({ page }) => {

View File

@@ -22,12 +22,8 @@ test('test that new manager can be invited', async ({ page }) => {
await page.getByLabel('Email').fill(`new+${editorId}@editor.test`); await page.getByLabel('Email').fill(`new+${editorId}@editor.test`);
await page.getByRole('button', { name: 'Manager' }).click(); await page.getByRole('button', { name: 'Manager' }).click();
await Promise.all([ await Promise.all([
page page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
.getByRole('button', { name: 'Invite Member', exact: true }) expect(page.getByRole('main')).toContainText(`new+${editorId}@editor.test`),
.click(),
expect(page.getByRole('main')).toContainText(
`new+${editorId}@editor.test`
),
]); ]);
}); });
@@ -38,12 +34,8 @@ test('test that new employee can be invited', async ({ page }) => {
await page.getByLabel('Email').fill(`new+${editorId}@editor.test`); await page.getByLabel('Email').fill(`new+${editorId}@editor.test`);
await page.getByRole('button', { name: 'Employee' }).click(); await page.getByRole('button', { name: 'Employee' }).click();
await Promise.all([ await Promise.all([
page page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
.getByRole('button', { name: 'Invite Member', exact: true }) await expect(page.getByRole('main')).toContainText(`new+${editorId}@editor.test`),
.click(),
await expect(page.getByRole('main')).toContainText(
`new+${editorId}@editor.test`
),
]); ]);
}); });
@@ -54,12 +46,8 @@ test('test that new admin can be invited', async ({ page }) => {
await page.getByLabel('Email').fill(`new+${adminId}@admin.test`); await page.getByLabel('Email').fill(`new+${adminId}@admin.test`);
await page.getByRole('button', { name: 'Administrator' }).click(); await page.getByRole('button', { name: 'Administrator' }).click();
await Promise.all([ await Promise.all([
page page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
.getByRole('button', { name: 'Invite Member', exact: true }) expect(page.getByRole('main')).toContainText(`new+${adminId}@admin.test`),
.click(),
expect(page.getByRole('main')).toContainText(
`new+${adminId}@admin.test`
),
]); ]);
}); });
test('test that error shows if no role is selected', async ({ page }) => { test('test that error shows if no role is selected', async ({ page }) => {
@@ -69,9 +57,7 @@ test('test that error shows if no role is selected', async ({ page }) => {
await page.getByLabel('Email').fill(`new+${noRoleId}@norole.test`); await page.getByLabel('Email').fill(`new+${noRoleId}@norole.test`);
await Promise.all([ await Promise.all([
page page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
.getByRole('button', { name: 'Invite Member', exact: true })
.click(),
expect(page.getByText('Please select a role')).toBeVisible(), expect(page.getByText('Please select a role')).toBeVisible(),
]); ]);
}); });
@@ -85,9 +71,7 @@ test('test that organization billable rate can be updated with all existing time
await page.getByRole('menuitem').getByText('Edit').click(); await page.getByRole('menuitem').getByText('Edit').click();
await page.getByText('Organization Default Rate').click(); await page.getByText('Organization Default Rate').click();
await page.getByText('Custom Rate').click(); await page.getByText('Custom Rate').click();
await page await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString());
.getByPlaceholder('Billable Rate')
.fill(newBillableRate.toString());
await page.getByRole('button', { name: 'Update Member' }).click(); await page.getByRole('button', { name: 'Update Member' }).click();
await Promise.all([ await Promise.all([
@@ -103,8 +87,7 @@ test('test that organization billable rate can be updated with all existing time
response.url().includes('/organizations/') && response.url().includes('/organizations/') &&
response.request().method() === 'PUT' && response.request().method() === 'PUT' &&
response.status() === 200 && response.status() === 200 &&
(await response.json()).data.billable_rate === (await response.json()).data.billable_rate === newBillableRate * 100
newBillableRate * 100
), ),
]); ]);
}); });

View File

@@ -35,9 +35,9 @@ test('test that organization name can be updated', async ({ page }) => {
await page.getByLabel('Organization Name').fill('NEW ORG NAME'); await page.getByLabel('Organization Name').fill('NEW ORG NAME');
await page.getByLabel('Organization Name').press('Enter'); await page.getByLabel('Organization Name').press('Enter');
await page.getByLabel('Organization Name').press('Meta+r'); await page.getByLabel('Organization Name').press('Meta+r');
await expect( await expect(page.locator('[data-testid="organization_switcher"]:visible')).toContainText(
page.locator('[data-testid="organization_switcher"]:visible') 'NEW ORG NAME'
).toContainText('NEW ORG NAME'); );
}); });
test('test that organization billable rate can be updated with all existing time entries', async ({ test('test that organization billable rate can be updated with all existing time entries', async ({
@@ -46,9 +46,7 @@ test('test that organization billable rate can be updated with all existing time
await goToOrganizationSettings(page); await goToOrganizationSettings(page);
const newBillableRate = Math.round(Math.random() * 10000); const newBillableRate = Math.round(Math.random() * 10000);
await page.getByLabel('Organization Billable Rate').click(); await page.getByLabel('Organization Billable Rate').click();
await page await page.getByLabel('Organization Billable Rate').fill(newBillableRate.toString());
.getByLabel('Organization Billable Rate')
.fill(newBillableRate.toString());
await page await page
.locator('form') .locator('form')
.filter({ hasText: 'Organization Billable' }) .filter({ hasText: 'Organization Billable' })
@@ -56,9 +54,7 @@ test('test that organization billable rate can be updated with all existing time
.click(); .click();
await Promise.all([ await Promise.all([
page page.getByRole('button', { name: 'Yes, update existing time entries' }).click(),
.getByRole('button', { name: 'Yes, update existing time entries' })
.click(),
page.waitForRequest( page.waitForRequest(
async (request) => async (request) =>
request.url().includes('/organizations/') && request.url().includes('/organizations/') &&
@@ -70,15 +66,12 @@ test('test that organization billable rate can be updated with all existing time
response.url().includes('/organizations/') && response.url().includes('/organizations/') &&
response.request().method() === 'PUT' && response.request().method() === 'PUT' &&
response.status() === 200 && response.status() === 200 &&
(await response.json()).data.billable_rate === (await response.json()).data.billable_rate === newBillableRate * 100
newBillableRate * 100
), ),
]); ]);
}); });
test('test that organization format settings can be updated', async ({ test('test that organization format settings can be updated', async ({ page }) => {
page,
}) => {
await goToOrganizationSettings(page); await goToOrganizationSettings(page);
// Test number format // Test number format
@@ -113,8 +106,7 @@ test('test that organization format settings can be updated', async ({
response.url().includes('/organizations/') && response.url().includes('/organizations/') &&
response.request().method() === 'PUT' && response.request().method() === 'PUT' &&
response.status() === 200 && response.status() === 200 &&
(await response.json()).data.currency_format === (await response.json()).data.currency_format === 'iso-code-after-with-space'
'iso-code-after-with-space'
), ),
]); ]);
@@ -132,8 +124,7 @@ test('test that organization format settings can be updated', async ({
response.url().includes('/organizations/') && response.url().includes('/organizations/') &&
response.request().method() === 'PUT' && response.request().method() === 'PUT' &&
response.status() === 200 && response.status() === 200 &&
(await response.json()).data.date_format === (await response.json()).data.date_format === 'slash-separated-dd-mm-yyyy'
'slash-separated-dd-mm-yyyy'
), ),
]); ]);
@@ -169,19 +160,14 @@ test('test that organization format settings can be updated', async ({
response.url().includes('/organizations/') && response.url().includes('/organizations/') &&
response.request().method() === 'PUT' && response.request().method() === 'PUT' &&
response.status() === 200 && response.status() === 200 &&
(await response.json()).data.interval_format === (await response.json()).data.interval_format === 'hours-minutes-colon-separated'
'hours-minutes-colon-separated'
), ),
]); ]);
}); });
test('test that format settings are reflected in the dashboard', async ({ test('test that format settings are reflected in the dashboard', async ({ page }) => {
page,
}) => {
// check that 0h 00min is displayed // check that 0h 00min is displayed
await expect( await expect(page.getByText('0h 00min', { exact: true }).nth(0)).toBeVisible();
page.getByText('0h 00min', { exact: true }).nth(0)
).toBeVisible();
// First set the format settings // First set the format settings
await goToOrganizationSettings(page); await goToOrganizationSettings(page);
@@ -213,10 +199,8 @@ test('test that format settings are reflected in the dashboard', async ({
response.url().includes('/organizations/') && response.url().includes('/organizations/') &&
response.request().method() === 'PUT' && response.request().method() === 'PUT' &&
response.status() === 200 && response.status() === 200 &&
(await response.json()).data.interval_format === (await response.json()).data.interval_format === 'hours-minutes-colon-separated' &&
'hours-minutes-colon-separated' && (await response.json()).data.currency_format === 'symbol-after' &&
(await response.json()).data.currency_format ===
'symbol-after' &&
(await response.json()).data.number_format === 'comma-point' (await response.json()).data.number_format === 'comma-point'
), ),
]); ]);
@@ -232,16 +216,12 @@ test('test that format settings are reflected in the dashboard', async ({
// check that 00:00 is displayed // check that 00:00 is displayed
await expect(page.getByText('0:00', { exact: true }).nth(0)).toBeVisible(); await expect(page.getByText('0:00', { exact: true }).nth(0)).toBeVisible();
// check that 0h 00min is not displayed // check that 0h 00min is not displayed
await expect( await expect(page.getByText('0h 00min', { exact: true }).nth(0)).not.toBeVisible();
page.getByText('0h 00min', { exact: true }).nth(0)
).not.toBeVisible();
// check that the current date is displayed in the dd/mm/yyyy format on the time page // check that the current date is displayed in the dd/mm/yyyy format on the time page
await page.goto(PLAYWRIGHT_BASE_URL + '/time'); await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await expect( await expect(
page page.getByText(new Date().toLocaleDateString('en-GB'), { exact: true }).nth(0)
.getByText(new Date().toLocaleDateString('en-GB'), { exact: true })
.nth(0)
).toBeVisible(); ).toBeVisible();
}); });

View File

@@ -1,34 +1,32 @@
import {test, expect} from '../playwright/fixtures'; import { test, expect } from '../playwright/fixtures';
import {PLAYWRIGHT_BASE_URL} from '../playwright/config'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
test('test that user name can be updated', async ({page}) => { test('test that user name can be updated', async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile'); await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
await page.getByLabel('Name', {exact: true} ).fill('NEW NAME'); await page.getByLabel('Name', { exact: true }).fill('NEW NAME');
await Promise.all([ await Promise.all([
page.getByRole('button', {name: 'Save'}).first().click(), page.getByRole('button', { name: 'Save' }).first().click(),
page.waitForResponse('**/user/profile-information'), page.waitForResponse('**/user/profile-information'),
]); ]);
await page.reload(); await page.reload();
await expect(page.getByLabel('Name', {exact: true})).toHaveValue('NEW NAME'); await expect(page.getByLabel('Name', { exact: true })).toHaveValue('NEW NAME');
}); });
test.skip('test that user email can be updated', async ({page}) => { test.skip('test that user email can be updated', async ({ page }) => {
// this does not work because of email verification currently // this does not work because of email verification currently
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile'); await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
const emailId = Math.round(Math.random() * 10000); const emailId = Math.round(Math.random() * 10000);
await page.getByLabel('Email').fill(`newemail+${emailId}@test.com`); await page.getByLabel('Email').fill(`newemail+${emailId}@test.com`);
await page.getByRole('button', {name: 'Save'}).first().click(); await page.getByRole('button', { name: 'Save' }).first().click();
await page.reload(); await page.reload();
await expect(page.getByLabel('Email')).toHaveValue( await expect(page.getByLabel('Email')).toHaveValue(`newemail+${emailId}@test.com`);
`newemail+${emailId}@test.com`
);
}); });
async function createNewApiToken(page) { async function createNewApiToken(page) {
await page.getByLabel('API Key Name').fill('NEW API KEY'); await page.getByLabel('API Key Name').fill('NEW API KEY');
await Promise.all([ await Promise.all([
page.getByRole('button', {name: 'Create API Key'}).click(), page.getByRole('button', { name: 'Create API Key' }).click(),
page.waitForResponse('**/users/me/api-tokens') page.waitForResponse('**/users/me/api-tokens'),
]); ]);
await expect(page.locator('body')).toContainText('API Token created successfully'); await expect(page.locator('body')).toContainText('API Token created successfully');
@@ -36,34 +34,37 @@ async function createNewApiToken(page) {
await expect(page.locator('body')).toContainText('NEW API KEY'); await expect(page.locator('body')).toContainText('NEW API KEY');
} }
test('test that user can create an API key', async ({page}) => { test('test that user can create an API key', async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile'); await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
await createNewApiToken(page); await createNewApiToken(page);
}); });
test('test that user can delete an API key', async ({page}) => { test('test that user can delete an API key', async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile'); await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
await createNewApiToken(page); await createNewApiToken(page);
page.getByLabel('Delete API Token NEW API KEY').click(); page.getByLabel('Delete API Token NEW API KEY').click();
await expect(page.getByRole('dialog')).toContainText('Are you sure you would like to delete this API token?'); await expect(page.getByRole('dialog')).toContainText(
'Are you sure you would like to delete this API token?'
);
await Promise.all([ await Promise.all([
page.getByRole('dialog').getByRole('button', {name: 'Delete'}).click(), page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click(),
page.waitForResponse('**/users/me/api-tokens') page.waitForResponse('**/users/me/api-tokens'),
]); ]);
await expect(page.locator('body')).not.toContainText('NEW API KEY'); await expect(page.locator('body')).not.toContainText('NEW API KEY');
}); });
test('test that user can revoke an API key', async ({ page }) => {
test('test that user can revoke an API key', async ({page}) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile'); await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
await createNewApiToken(page); await createNewApiToken(page);
page.getByLabel('Revoke API Token NEW API KEY').click(); page.getByLabel('Revoke API Token NEW API KEY').click();
await expect(page.getByRole('dialog')).toContainText('Are you sure you would like to revoke this API token?'); await expect(page.getByRole('dialog')).toContainText(
'Are you sure you would like to revoke this API token?'
);
await Promise.all([ await Promise.all([
page.getByRole('dialog').getByRole('button', {name: 'Revoke'}).click(), page.getByRole('dialog').getByRole('button', { name: 'Revoke' }).click(),
page.waitForResponse('**/users/me/api-tokens') page.waitForResponse('**/users/me/api-tokens'),
]); ]);
await expect(page.getByRole('button', {name: 'Revoke'})).toBeHidden(); await expect(page.getByRole('button', { name: 'Revoke' })).toBeHidden();
await expect(page.locator('body')).toContainText('NEW API KEY'); await expect(page.locator('body')).toContainText('NEW API KEY');
await expect(page.locator('body')).toContainText('Revoked'); await expect(page.locator('body')).toContainText('Revoked');
}); });

View File

@@ -12,8 +12,7 @@ async function goToProjectsOverview(page: Page) {
test('test that updating project member billable rate works for existing time entries', async ({ test('test that updating project member billable rate works for existing time entries', async ({
page, page,
}) => { }) => {
const newProjectName = const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
'New Project ' + Math.floor(1 + Math.random() * 10000);
const newBillableRate = Math.round(Math.random() * 10000); const newBillableRate = Math.round(Math.random() * 10000);
await goToProjectsOverview(page); await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click(); await page.getByRole('button', { name: 'Create Project' }).click();
@@ -36,9 +35,7 @@ test('test that updating project member billable rate works for existing time en
.first() .first()
.getByRole('button') .getByRole('button')
.click(); .click();
await page await page.getByRole('menuitem', { name: 'Edit Project Member' }).click();
.getByRole('menuitem', { name: 'Edit Project Member' })
.click();
await page.getByLabel('Billable Rate').fill(newBillableRate.toString()); await page.getByLabel('Billable Rate').fill(newBillableRate.toString());
await page.getByRole('button', { name: 'Update Project Member' }).click(); await page.getByRole('button', { name: 'Update Project Member' }).click();
@@ -55,8 +52,7 @@ test('test that updating project member billable rate works for existing time en
response.url().includes('/project-members/') && response.url().includes('/project-members/') &&
response.request().method() === 'PUT' && response.request().method() === 'PUT' &&
response.status() === 200 && response.status() === 200 &&
(await response.json()).data.billable_rate === (await response.json()).data.billable_rate === newBillableRate * 100
newBillableRate * 100
), ),
]); ]);
await expect( await expect(

View File

@@ -9,11 +9,8 @@ async function goToProjectsOverview(page: Page) {
} }
// Create new project via modal // Create new project via modal
test('test that creating and deleting a new project via the modal works', async ({ test('test that creating and deleting a new project via the modal works', async ({ page }) => {
page, const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
}) => {
const newProjectName =
'New Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page); await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click(); await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(newProjectName); await page.getByLabel('Project Name').fill(newProjectName);
@@ -31,16 +28,10 @@ test('test that creating and deleting a new project via the modal works', async
), ),
]); ]);
await expect(page.getByTestId('project_table')).toContainText( await expect(page.getByTestId('project_table')).toContainText(newProjectName);
newProjectName const moreButton = page.locator("[aria-label='Actions for Project " + newProjectName + "']");
);
const moreButton = page.locator(
"[aria-label='Actions for Project " + newProjectName + "']"
);
moreButton.click(); moreButton.click();
const deleteButton = page.locator( const deleteButton = page.locator("[aria-label='Delete Project " + newProjectName + "']");
"[aria-label='Delete Project " + newProjectName + "']"
);
await Promise.all([ await Promise.all([
deleteButton.click(), deleteButton.click(),
@@ -51,14 +42,11 @@ test('test that creating and deleting a new project via the modal works', async
response.status() === 204 response.status() === 204
), ),
]); ]);
await expect(page.getByTestId('project_table')).not.toContainText( await expect(page.getByTestId('project_table')).not.toContainText(newProjectName);
newProjectName
);
}); });
test('test that archiving and unarchiving projects works', async ({ page }) => { test('test that archiving and unarchiving projects works', async ({ page }) => {
const newProjectName = const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
'New Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page); await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click(); await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(newProjectName); await page.getByLabel('Project Name').fill(newProjectName);
@@ -87,11 +75,8 @@ test('test that archiving and unarchiving projects works', async ({ page }) => {
]); ]);
}); });
test('test that updating billable rate works with existing time entries', async ({ test('test that updating billable rate works with existing time entries', async ({ page }) => {
page, const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
}) => {
const newProjectName =
'New Project ' + Math.floor(1 + Math.random() * 10000);
const newBillableRate = Math.round(Math.random() * 10000); const newBillableRate = Math.round(Math.random() * 10000);
await goToProjectsOverview(page); await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click(); await page.getByRole('button', { name: 'Create Project' }).click();
@@ -104,15 +89,11 @@ test('test that updating billable rate works with existing time entries', async
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('Non-Billable').click();
await page.getByText('Custom Rate').click(); await page.getByText('Custom Rate').click();
await page await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString());
.getByPlaceholder('Billable Rate')
.fill(newBillableRate.toString());
await page.getByRole('button', { name: 'Update Project' }).click(); await page.getByRole('button', { name: 'Update Project' }).click();
await Promise.all([ await Promise.all([
page page.locator('button').filter({ hasText: 'Yes, update existing time' }).click(),
.locator('button').filter({ hasText: 'Yes, update existing time' })
.click(),
page.waitForRequest( page.waitForRequest(
async (request) => async (request) =>
request.url().includes('/projects/') && request.url().includes('/projects/') &&
@@ -124,8 +105,7 @@ test('test that updating billable rate works with existing time entries', async
response.url().includes('/projects/') && response.url().includes('/projects/') &&
response.request().method() === 'PUT' && response.request().method() === 'PUT' &&
response.status() === 200 && response.status() === 200 &&
(await response.json()).data.billable_rate === (await response.json()).data.billable_rate === newBillableRate * 100
newBillableRate * 100
), ),
]); ]);
await expect( await expect(

View File

@@ -2,8 +2,6 @@ import { expect, Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures'; import { test } from '../playwright/fixtures';
async function goToTimeOverview(page: Page) { async function goToTimeOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/time'); await page.goto(PLAYWRIGHT_BASE_URL + '/time');
} }
@@ -31,7 +29,10 @@ async function createTimeEntryWithProject(page: Page, projectName: string, durat
await page.getByRole('button', { name: 'Manual time entry' }).click(); await page.getByRole('button', { name: 'Manual time entry' }).click();
// Fill in the time entry details // Fill in the time entry details
await page.getByRole('dialog').getByRole('textbox', { name: 'Description' }).fill(`Time entry for ${projectName}`); await page
.getByRole('dialog')
.getByRole('textbox', { name: 'Description' })
.fill(`Time entry for ${projectName}`);
await page.getByRole('button', { name: 'No Project' }).click(); await page.getByRole('button', { name: 'No Project' }).click();
await page.getByText(projectName).click(); await page.getByText(projectName).click();
@@ -43,7 +44,9 @@ async function createTimeEntryWithProject(page: Page, projectName: string, durat
// Submit the time entry // Submit the time entry
await Promise.all([ await Promise.all([
page.getByRole('button', { name: 'Create Time Entry' }).click(), page.getByRole('button', { name: 'Create Time Entry' }).click(),
page.waitForResponse(response => response.url().includes('/time-entries') && response.status() === 201) page.waitForResponse(
(response) => response.url().includes('/time-entries') && response.status() === 201
),
]); ]);
} }
@@ -52,7 +55,10 @@ async function createTimeEntryWithTag(page: Page, tagName: string, duration: str
await page.getByRole('button', { name: 'Manual time entry' }).click(); await page.getByRole('button', { name: 'Manual time entry' }).click();
// Fill in the time entry details // Fill in the time entry details
await page.getByRole('dialog').getByRole('textbox', { name: 'Description' }).fill(`Time entry with tag ${tagName}`); await page
.getByRole('dialog')
.getByRole('textbox', { name: 'Description' })
.fill(`Time entry with tag ${tagName}`);
// Add tag // Add tag
await page.getByRole('button', { name: 'Tags' }).click(); await page.getByRole('button', { name: 'Tags' }).click();
@@ -69,12 +75,19 @@ async function createTimeEntryWithTag(page: Page, tagName: string, duration: str
await page.getByRole('button', { name: 'Create Time Entry' }).click(); await page.getByRole('button', { name: 'Create Time Entry' }).click();
} }
async function createTimeEntryWithBillableStatus(page: Page, isBillable: boolean, duration: string) { async function createTimeEntryWithBillableStatus(
page: Page,
isBillable: boolean,
duration: string
) {
await goToTimeOverview(page); await goToTimeOverview(page);
await page.getByRole('button', { name: 'Manual time entry' }).click(); await page.getByRole('button', { name: 'Manual time entry' }).click();
// Fill in the time entry details // Fill in the time entry details
await page.getByRole('dialog').getByRole('textbox', { name: 'Description' }).fill(`Time entry ${isBillable ? 'billable' : 'non-billable'}`); await page
.getByRole('dialog')
.getByRole('textbox', { name: 'Description' })
.fill(`Time entry ${isBillable ? 'billable' : 'non-billable'}`);
// Set billable status // Set billable status
await page.getByRole('button', { name: 'Non-Billable' }).click(); await page.getByRole('button', { name: 'Non-Billable' }).click();
@@ -109,7 +122,10 @@ test('test that project filtering works in reporting', async ({ page }) => {
// escape // escape
page.keyboard.press('Escape'), page.keyboard.press('Escape'),
// wait for API request to finish // wait for API request to finish
page.waitForResponse(response => response.url().includes('/time-entries/aggregate') && response.status() === 200) page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
),
]); ]);
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
@@ -138,7 +154,10 @@ test('test that tag filtering works in reporting', async ({ page }) => {
// escape // escape
page.keyboard.press('Escape'), page.keyboard.press('Escape'),
// wait for API request to finish // wait for API request to finish
page.waitForResponse(response => response.url().includes('/time-entries/aggregate') && response.status() === 200) page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
),
]); ]);
// Verify only time entries with tag1 are shown // Verify only time entries with tag1 are shown
@@ -160,14 +179,16 @@ test('test that billable status filtering works in reporting', async ({ page })
// escape // escape
page.keyboard.press('Escape'), page.keyboard.press('Escape'),
// wait for API request to finish // wait for API request to finish
page.waitForResponse(response => response.url().includes('/time-entries/aggregate') && response.status() === 200) page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
),
]); ]);
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible(); await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
}); });
test('test that detailed view shows time entries correctly', async ({ page }) => { test('test that detailed view shows time entries correctly', async ({ page }) => {
const projectName = 'Detailed View Project ' + Math.floor(Math.random() * 10000); const projectName = 'Detailed View Project ' + Math.floor(Math.random() * 10000);

View File

@@ -7,9 +7,7 @@ async function goToTagsOverview(page: Page) {
} }
// Create new project via modal // Create new project via modal
test('test that creating and deleting a new client via the modal works', async ({ test('test that creating and deleting a new client via the modal works', async ({ page }) => {
page,
}) => {
const newTagName = 'New Tag ' + Math.floor(1 + Math.random() * 10000); const newTagName = 'New Tag ' + Math.floor(1 + Math.random() * 10000);
await goToTagsOverview(page); await goToTagsOverview(page);
await page.getByRole('button', { name: 'Create Tag' }).click(); await page.getByRole('button', { name: 'Create Tag' }).click();
@@ -27,13 +25,9 @@ test('test that creating and deleting a new client via the modal works', async (
]); ]);
await expect(page.getByTestId('tag_table')).toContainText(newTagName); await expect(page.getByTestId('tag_table')).toContainText(newTagName);
const moreButton = page.locator( const moreButton = page.locator("[aria-label='Actions for Tag " + newTagName + "']");
"[aria-label='Actions for Tag " + newTagName + "']"
);
moreButton.click(); moreButton.click();
const deleteButton = page.locator( const deleteButton = page.locator("[aria-label='Delete Tag " + newTagName + "']");
"[aria-label='Delete Tag " + newTagName + "']"
);
await Promise.all([ await Promise.all([
deleteButton.click(), deleteButton.click(),

View File

@@ -7,11 +7,8 @@ async function goToProjectsOverview(page: Page) {
} }
// Create new project via modal // Create new project via modal
test('test that creating and deleting a new tag in a new project works', async ({ test('test that creating and deleting a new tag in a new project works', async ({ page }) => {
page, const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
}) => {
const newProjectName =
'New Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page); await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click(); await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(newProjectName); await page.getByLabel('Project Name').fill(newProjectName);
@@ -29,9 +26,7 @@ test('test that creating and deleting a new tag in a new project works', async (
), ),
]); ]);
await expect(page.getByTestId('project_table')).toContainText( await expect(page.getByTestId('project_table')).toContainText(newProjectName);
newProjectName
);
await page.getByText(newProjectName).click(); await page.getByText(newProjectName).click();
@@ -55,13 +50,9 @@ test('test that creating and deleting a new tag in a new project works', async (
await expect(page.getByTestId('task_table')).toContainText(newTaskName); await expect(page.getByTestId('task_table')).toContainText(newTaskName);
const taskMoreButton = page.locator( const taskMoreButton = page.locator("[aria-label='Actions for Task " + newTaskName + "']");
"[aria-label='Actions for Task " + newTaskName + "']"
);
taskMoreButton.click(); taskMoreButton.click();
const taskDeleteButton = page.locator( const taskDeleteButton = page.locator("[aria-label='Delete Task " + newTaskName + "']");
"[aria-label='Delete Task " + newTaskName + "']"
);
await Promise.all([ await Promise.all([
taskDeleteButton.click(), taskDeleteButton.click(),
@@ -76,13 +67,9 @@ test('test that creating and deleting a new tag in a new project works', async (
await goToProjectsOverview(page); await goToProjectsOverview(page);
const moreButton = page.locator( const moreButton = page.locator("[aria-label='Actions for Project " + newProjectName + "']");
"[aria-label='Actions for Project " + newProjectName + "']"
);
moreButton.click(); moreButton.click();
const deleteButton = page.locator( const deleteButton = page.locator("[aria-label='Delete Project " + newProjectName + "']");
"[aria-label='Delete Project " + newProjectName + "']"
);
await Promise.all([ await Promise.all([
deleteButton.click(), deleteButton.click(),
@@ -93,14 +80,11 @@ test('test that creating and deleting a new tag in a new project works', async (
response.status() === 204 response.status() === 204
), ),
]); ]);
await expect(page.getByTestId('project_table')).not.toContainText( await expect(page.getByTestId('project_table')).not.toContainText(newProjectName);
newProjectName
);
}); });
test('test that archiving and unarchiving tasks works', async ({ page }) => { test('test that archiving and unarchiving tasks works', async ({ page }) => {
const newProjectName = const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
'New Project ' + Math.floor(1 + Math.random() * 10000);
const newTaskName = 'New Project ' + Math.floor(1 + Math.random() * 10000); const newTaskName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page); await goToProjectsOverview(page);

View File

@@ -25,9 +25,7 @@ async function createEmptyTimeEntry(page: Page) {
startOrStopTimerWithButton(page), startOrStopTimerWithButton(page),
assertThatTimerIsStopped(page), assertThatTimerIsStopped(page),
page.waitForResponse( page.waitForResponse(
(response) => (response) => response.url().includes('/time-entries') && response.status() === 200
response.url().includes('/time-entries') &&
response.status() === 200
), ),
]); ]);
} }
@@ -38,9 +36,7 @@ test('test that starting and stopping an empty time entry shows a new time entry
await Promise.all([ await Promise.all([
goToTimeOverview(page), goToTimeOverview(page),
page.waitForResponse( page.waitForResponse(
(response) => (response) => response.url().includes('/time-entries') && response.status() === 200
response.url().includes('/time-entries') &&
response.status() === 200
), ),
]); ]);
await page.waitForTimeout(100); await page.waitForTimeout(100);
@@ -56,9 +52,7 @@ test('test that starting and stopping an empty time entry shows a new time entry
// Test that description update works // Test that description update works
async function assertThatTimeEntryRowIsStopped(newTimeEntry: Locator) { async function assertThatTimeEntryRowIsStopped(newTimeEntry: Locator) {
await expect(newTimeEntry.getByTestId('timer_button')).toHaveClass( await expect(newTimeEntry.getByTestId('timer_button')).toHaveClass(/bg-accent-300\/70/);
/bg-accent-300\/70/
);
} }
test('test that updating a description of a time entry in the overview works on blur', async ({ test('test that updating a description of a time entry in the overview works on blur', async ({
@@ -71,17 +65,14 @@ test('test that updating a description of a time entry in the overview works on
await assertThatTimeEntryRowIsStopped(newTimeEntry); await assertThatTimeEntryRowIsStopped(newTimeEntry);
const newDescription = Math.floor(Math.random() * 1000000).toString(); const newDescription = Math.floor(Math.random() * 1000000).toString();
const descriptionElement = newTimeEntry.getByTestId( const descriptionElement = newTimeEntry.getByTestId('time_entry_description');
'time_entry_description'
);
await descriptionElement.fill(newDescription); await descriptionElement.fill(newDescription);
await Promise.all([ await Promise.all([
descriptionElement.press('Tab'), descriptionElement.press('Tab'),
page.waitForResponse(async (response) => { page.waitForResponse(async (response) => {
return ( return (
response.status() === 200 && response.status() === 200 &&
(await response.headerValue('Content-Type')) === (await response.headerValue('Content-Type')) === 'application/json' &&
'application/json' &&
(await response.json()).data.id !== null && (await response.json()).data.id !== null &&
(await response.json()).data.start !== null && (await response.json()).data.start !== null &&
(await response.json()).data.end !== null && (await response.json()).data.end !== null &&
@@ -90,8 +81,7 @@ test('test that updating a description of a time entry in the overview works on
(await response.json()).data.task_id === null && (await response.json()).data.task_id === null &&
(await response.json()).data.duration !== null && (await response.json()).data.duration !== null &&
(await response.json()).data.user_id !== null && (await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) === JSON.stringify((await response.json()).data.tags) === JSON.stringify([])
JSON.stringify([])
); );
}), }),
]); ]);
@@ -107,17 +97,14 @@ test('test that updating a description of a time entry in the overview works on
const newTimeEntry = timeEntryRows.first(); const newTimeEntry = timeEntryRows.first();
await assertThatTimeEntryRowIsStopped(newTimeEntry); await assertThatTimeEntryRowIsStopped(newTimeEntry);
const newDescription = Math.floor(Math.random() * 1000000).toString(); const newDescription = Math.floor(Math.random() * 1000000).toString();
const descriptionElement = newTimeEntry.getByTestId( const descriptionElement = newTimeEntry.getByTestId('time_entry_description');
'time_entry_description'
);
await descriptionElement.fill(newDescription); await descriptionElement.fill(newDescription);
await Promise.all([ await Promise.all([
descriptionElement.press('Enter'), descriptionElement.press('Enter'),
page.waitForResponse(async (response) => { page.waitForResponse(async (response) => {
return ( return (
response.status() === 200 && response.status() === 200 &&
(await response.headerValue('Content-Type')) === (await response.headerValue('Content-Type')) === 'application/json' &&
'application/json' &&
(await response.json()).data.id !== null && (await response.json()).data.id !== null &&
(await response.json()).data.start !== null && (await response.json()).data.start !== null &&
(await response.json()).data.end !== null && (await response.json()).data.end !== null &&
@@ -126,16 +113,13 @@ test('test that updating a description of a time entry in the overview works on
(await response.json()).data.task_id === null && (await response.json()).data.task_id === null &&
(await response.json()).data.duration !== null && (await response.json()).data.duration !== null &&
(await response.json()).data.user_id !== null && (await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) === JSON.stringify((await response.json()).data.tags) === JSON.stringify([])
JSON.stringify([])
); );
}), }),
]); ]);
}); });
test('test that adding a new tag to an existing time entry works', async ({ test('test that adding a new tag to an existing time entry works', async ({ page }) => {
page,
}) => {
await goToTimeOverview(page); await goToTimeOverview(page);
const timeEntryRows = page.locator('[data-testid="time_entry_row"]'); const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
await createEmptyTimeEntry(page); await createEmptyTimeEntry(page);
@@ -152,8 +136,7 @@ test('test that adding a new tag to an existing time entry works', async ({
page.waitForResponse(async (response) => { page.waitForResponse(async (response) => {
return ( return (
response.status() === 201 && response.status() === 201 &&
(await response.headerValue('Content-Type')) === (await response.headerValue('Content-Type')) === 'application/json' &&
'application/json' &&
(await response.json()).data.name === newTagName (await response.json()).data.name === newTagName
); );
}), }),
@@ -163,8 +146,7 @@ test('test that adding a new tag to an existing time entry works', async ({
await page.waitForResponse(async (response) => { await page.waitForResponse(async (response) => {
return ( return (
response.status() === 200 && response.status() === 200 &&
(await response.headerValue('Content-Type')) === (await response.headerValue('Content-Type')) === 'application/json' &&
'application/json' &&
(await response.json()).data.id !== null && (await response.json()).data.id !== null &&
(await response.json()).data.start !== null && (await response.json()).data.start !== null &&
(await response.json()).data.end !== null && (await response.json()).data.end !== null &&
@@ -187,17 +169,14 @@ test('test that updating a the start of an existing time entry in the overview w
const newTimeEntry = timeEntryRows.first(); const newTimeEntry = timeEntryRows.first();
await assertThatTimeEntryRowIsStopped(newTimeEntry); await assertThatTimeEntryRowIsStopped(newTimeEntry);
await page.waitForTimeout(1500); await page.waitForTimeout(1500);
const timeEntryRangeElement = newTimeEntry.getByTestId( const timeEntryRangeElement = newTimeEntry.getByTestId('time_entry_range_selector');
'time_entry_range_selector'
);
await timeEntryRangeElement.click(); await timeEntryRangeElement.click();
await page.getByTestId('time_entry_range_start').first().fill('1'); await page.getByTestId('time_entry_range_start').first().fill('1');
await Promise.all([ await Promise.all([
page.waitForResponse(async (response) => { page.waitForResponse(async (response) => {
return ( return (
response.status() === 200 && response.status() === 200 &&
(await response.headerValue('Content-Type')) === (await response.headerValue('Content-Type')) === 'application/json' &&
'application/json' &&
(await response.json()).data.id !== null && (await response.json()).data.id !== null &&
// TODO! Actually check the value // TODO! Actually check the value
(await response.json()).data.start !== null && (await response.json()).data.start !== null &&
@@ -208,9 +187,7 @@ test('test that updating a the start of an existing time entry in the overview w
]); ]);
}); });
test('test that updating a the duration in the overview works on blur', async ({ test('test that updating a the duration in the overview works on blur', async ({ page }) => {
page,
}) => {
await goToTimeOverview(page); await goToTimeOverview(page);
const timeEntryRows = page.locator('[data-testid="time_entry_row"]'); const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
await createEmptyTimeEntry(page); await createEmptyTimeEntry(page);
@@ -225,8 +202,7 @@ test('test that updating a the duration in the overview works on blur', async ({
page.waitForResponse(async (response) => { page.waitForResponse(async (response) => {
return ( return (
response.status() === 200 && response.status() === 200 &&
(await response.headerValue('Content-Type')) === (await response.headerValue('Content-Type')) === 'application/json' &&
'application/json' &&
(await response.json()).data.id !== null && (await response.json()).data.id !== null &&
// TODO! Actually check the value // TODO! Actually check the value
(await response.json()).data.start !== null && (await response.json()).data.start !== null &&
@@ -240,9 +216,7 @@ test('test that updating a the duration in the overview works on blur', async ({
}); });
// Test that start stop button stops running timer // Test that start stop button stops running timer
test('test that starting a time entry from the overview works', async ({ test('test that starting a time entry from the overview works', async ({ page }) => {
page,
}) => {
await goToTimeOverview(page); await goToTimeOverview(page);
const timeEntryRows = page.locator('[data-testid="time_entry_row"]'); const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
await createEmptyTimeEntry(page); await createEmptyTimeEntry(page);
@@ -255,8 +229,7 @@ test('test that starting a time entry from the overview works', async ({
page.waitForResponse(async (response) => { page.waitForResponse(async (response) => {
return ( return (
response.status() === 200 && response.status() === 200 &&
(await response.headerValue('Content-Type')) === (await response.headerValue('Content-Type')) === 'application/json' &&
'application/json' &&
(await response.json()).data.id !== null && (await response.json()).data.id !== null &&
(await response.json()).data.start !== null && (await response.json()).data.start !== null &&
(await response.json()).data.end !== null (await response.json()).data.end !== null
@@ -272,8 +245,7 @@ test('test that starting a time entry from the overview works', async ({
page.waitForResponse(async (response) => { page.waitForResponse(async (response) => {
return ( return (
response.status() === 200 && response.status() === 200 &&
(await response.headerValue('Content-Type')) === (await response.headerValue('Content-Type')) === 'application/json' &&
'application/json' &&
(await response.json()).data.id !== null && (await response.json()).data.id !== null &&
(await response.json()).data.start !== null && (await response.json()).data.start !== null &&
(await response.json()).data.end !== null (await response.json()).data.end !== null
@@ -284,9 +256,7 @@ test('test that starting a time entry from the overview works', async ({
]); ]);
}); });
test('test that deleting a time entry from the overview works', async ({ test('test that deleting a time entry from the overview works', async ({ page }) => {
page,
}) => {
await goToTimeOverview(page); await goToTimeOverview(page);
const timeEntryRows = page.locator('[data-testid="time_entry_row"]'); const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
await createEmptyTimeEntry(page); await createEmptyTimeEntry(page);
@@ -302,16 +272,12 @@ test('test that deleting a time entry from the overview works', async ({
await expect(timeEntryRows).toHaveCount(0); await expect(timeEntryRows).toHaveCount(0);
}); });
test.skip('test that load more works when the end of page is reached', async ({ test.skip('test that load more works when the end of page is reached', async ({ page }) => {
page,
}) => {
// this test is flaky when you do not need to scroll // this test is flaky when you do not need to scroll
await Promise.all([ await Promise.all([
goToTimeOverview(page), goToTimeOverview(page),
page.waitForResponse( page.waitForResponse(
(response) => (response) => response.url().includes('/time-entries') && response.status() === 200
response.url().includes('/time-entries') &&
response.status() === 200
), ),
]); ]);
@@ -322,18 +288,14 @@ test.skip('test that load more works when the end of page is reached', async ({
return ( return (
response.status() === 200 && response.status() === 200 &&
response.url().includes('before') && response.url().includes('before') &&
(await response.headerValue('Content-Type')) === (await response.headerValue('Content-Type')) === 'application/json' &&
'application/json' && JSON.stringify((await response.json()).data) === JSON.stringify([])
JSON.stringify((await response.json()).data) ===
JSON.stringify([])
); );
}), }),
]); ]);
// assert that "All time entries are loaded!" is visible on page // assert that "All time entries are loaded!" is visible on page
await expect(page.locator('body')).toHaveText( await expect(page.locator('body')).toHaveText(/All time entries are loaded!/);
/All time entries are loaded!/
);
}); });
// TODO: Test that updating the time entry start / end times works while it is running // TODO: Test that updating the time entry start / end times works while it is running

View File

@@ -24,22 +24,15 @@ test('test that starting and stopping a timer without description and project wo
assertThatTimerHasStarted(page), assertThatTimerHasStarted(page),
]); ]);
await page.waitForTimeout(1500); await page.waitForTimeout(1500);
await Promise.all([ await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
stoppedTimeEntryResponse(page),
startOrStopTimerWithButton(page),
]);
await assertThatTimerIsStopped(page); await assertThatTimerIsStopped(page);
}); });
test('test that starting and stopping a timer with a description works', async ({ test('test that starting and stopping a timer with a description works', async ({ page }) => {
page,
}) => {
await goToDashboard(page); await goToDashboard(page);
// TODO: Fix flakyness by disabling description input field until timer is loaded // TODO: Fix flakyness by disabling description input field until timer is loaded
await page.waitForTimeout(500); await page.waitForTimeout(500);
await page await page.getByTestId('time_entry_description').fill('New Time Entry Description');
.getByTestId('time_entry_description')
.fill('New Time Entry Description');
await Promise.all([ await Promise.all([
newTimeEntryResponse(page, { newTimeEntryResponse(page, {
description: 'New Time Entry Description', description: 'New Time Entry Description',
@@ -62,47 +55,29 @@ test('test that starting the time entry starts the live timer and that it keeps
}) => { }) => {
await goToDashboard(page); await goToDashboard(page);
await Promise.all([ await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
newTimeEntryResponse(page),
startOrStopTimerWithButton(page),
]);
await assertThatTimerHasStarted(page); await assertThatTimerHasStarted(page);
await page.waitForTimeout(500); await page.waitForTimeout(500);
const beforeTimerValue = await page const beforeTimerValue = await page.getByTestId('time_entry_time').inputValue();
.getByTestId('time_entry_time')
.inputValue();
await page.waitForTimeout(2000); await page.waitForTimeout(2000);
const afterWaitTimeValue = await page const afterWaitTimeValue = await page.getByTestId('time_entry_time').inputValue();
.getByTestId('time_entry_time')
.inputValue();
expect(afterWaitTimeValue).not.toEqual(beforeTimerValue); expect(afterWaitTimeValue).not.toEqual(beforeTimerValue);
await page.reload(); await page.reload();
await page.waitForTimeout(500); await page.waitForTimeout(500);
const afterReloadTimerValue = await page const afterReloadTimerValue = await page.getByTestId('time_entry_time').inputValue();
.getByTestId('time_entry_time')
.inputValue();
await page.waitForTimeout(2000); await page.waitForTimeout(2000);
const afterReloadAfterWaitTimerValue = await page const afterReloadAfterWaitTimerValue = await page.getByTestId('time_entry_time').inputValue();
.getByTestId('time_entry_time')
.inputValue();
expect(afterReloadTimerValue).not.toEqual(afterReloadAfterWaitTimerValue); expect(afterReloadTimerValue).not.toEqual(afterReloadAfterWaitTimerValue);
}); });
test('test that starting and updating the description while running works', async ({ test('test that starting and updating the description while running works', async ({ page }) => {
page,
}) => {
await goToDashboard(page); await goToDashboard(page);
await Promise.all([ await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
newTimeEntryResponse(page),
startOrStopTimerWithButton(page),
]);
await assertThatTimerHasStarted(page); await assertThatTimerHasStarted(page);
await page.waitForTimeout(500); await page.waitForTimeout(500);
await page await page.getByTestId('time_entry_description').fill('New Time Entry Description');
.getByTestId('time_entry_description')
.fill('New Time Entry Description');
await Promise.all([ await Promise.all([
newTimeEntryResponse(page, { newTimeEntryResponse(page, {
@@ -121,9 +96,7 @@ test('test that starting and updating the description while running works', asyn
await assertThatTimerIsStopped(page); await assertThatTimerIsStopped(page);
}); });
test('test that starting and updating the time while running works', async ({ test('test that starting and updating the time while running works', async ({ page }) => {
page,
}) => {
await goToDashboard(page); await goToDashboard(page);
const [createResponse] = await Promise.all([ const [createResponse] = await Promise.all([
newTimeEntryResponse(page), newTimeEntryResponse(page),
@@ -138,19 +111,16 @@ test('test that starting and updating the time while running works', async ({
return ( return (
response.url().includes('/time-entries') && response.url().includes('/time-entries') &&
response.status() === 200 && response.status() === 200 &&
(await response.headerValue('Content-Type')) === (await response.headerValue('Content-Type')) === 'application/json' &&
'application/json' &&
(await response.json()).data.id !== null && (await response.json()).data.id !== null &&
(await response.json()).data.start !== null && (await response.json()).data.start !== null &&
(await response.json()).data.start !== (await response.json()).data.start !== (await createResponse.json()).data.start &&
(await createResponse.json()).data.start &&
(await response.json()).data.end === null && (await response.json()).data.end === null &&
(await response.json()).data.project_id === null && (await response.json()).data.project_id === null &&
(await response.json()).data.description === '' && (await response.json()).data.description === '' &&
(await response.json()).data.task_id === null && (await response.json()).data.task_id === null &&
(await response.json()).data.user_id !== null && (await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) === JSON.stringify((await response.json()).data.tags) === JSON.stringify([])
JSON.stringify([])
); );
}), }),
page.getByTestId('time_entry_time').press('Enter'), page.getByTestId('time_entry_time').press('Enter'),
@@ -158,16 +128,11 @@ test('test that starting and updating the time while running works', async ({
await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20/); await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20/);
await page.waitForTimeout(500); await page.waitForTimeout(500);
await Promise.all([ await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
stoppedTimeEntryResponse(page),
startOrStopTimerWithButton(page),
]);
await assertThatTimerIsStopped(page); await assertThatTimerIsStopped(page);
}); });
test('test that entering a human readable time starts the timer on blur', async ({ test('test that entering a human readable time starts the timer on blur', async ({ page }) => {
page,
}) => {
await goToDashboard(page); await goToDashboard(page);
await page.getByTestId('time_entry_time').fill('20min'); await page.getByTestId('time_entry_time').fill('20min');
await Promise.all([ await Promise.all([
@@ -177,18 +142,13 @@ test('test that entering a human readable time starts the timer on blur', async
await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20:/); await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20:/);
await assertThatTimerHasStarted(page); await assertThatTimerHasStarted(page);
await Promise.all([ await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
stoppedTimeEntryResponse(page),
startOrStopTimerWithButton(page),
]);
await page.locator( await page.locator(
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70' '[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70'
); );
}); });
test('test that entering a number in the time range starts the timer on blur', async ({ test('test that entering a number in the time range starts the timer on blur', async ({ page }) => {
page,
}) => {
await goToDashboard(page); await goToDashboard(page);
await page.getByTestId('time_entry_time').fill('5'); await page.getByTestId('time_entry_time').fill('5');
await Promise.all([ await Promise.all([
@@ -198,10 +158,7 @@ test('test that entering a number in the time range starts the timer on blur', a
await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:05:/); await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:05:/);
await assertThatTimerHasStarted(page); await assertThatTimerHasStarted(page);
await Promise.all([ await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
stoppedTimeEntryResponse(page),
startOrStopTimerWithButton(page),
]);
await page.locator( await page.locator(
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70' '[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70'
); );
@@ -219,10 +176,7 @@ test('test that entering a value with the format hh:mm in the time range starts
await expect(page.getByTestId('time_entry_time')).toHaveValue(/12:30:/); await expect(page.getByTestId('time_entry_time')).toHaveValue(/12:30:/);
await assertThatTimerHasStarted(page); await assertThatTimerHasStarted(page);
await Promise.all([ await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
stoppedTimeEntryResponse(page),
startOrStopTimerWithButton(page),
]);
await page.locator( await page.locator(
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70' '[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70'
); );
@@ -239,9 +193,7 @@ test('test that entering a random value in the time range does not start the tim
); );
}); });
test('test that entering a time starts the timer on enter', async ({ test('test that entering a time starts the timer on enter', async ({ page }) => {
page,
}) => {
await goToDashboard(page); await goToDashboard(page);
await page.getByTestId('time_entry_time').fill('20min'); await page.getByTestId('time_entry_time').fill('20min');
await Promise.all([ await Promise.all([
@@ -249,10 +201,7 @@ test('test that entering a time starts the timer on enter', async ({
page.getByTestId('time_entry_time').press('Enter'), page.getByTestId('time_entry_time').press('Enter'),
]); ]);
await assertThatTimerHasStarted(page); await assertThatTimerHasStarted(page);
await Promise.all([ await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
stoppedTimeEntryResponse(page),
startOrStopTimerWithButton(page),
]);
await assertThatTimerIsStopped(page); await assertThatTimerIsStopped(page);
}); });
@@ -273,15 +222,10 @@ test('test that adding a new tag works', async ({ page }) => {
await expect(page.getByRole('option', { name: newTagName })).toBeVisible(); await expect(page.getByRole('option', { name: newTagName })).toBeVisible();
}); });
test('test that adding a new tag when the timer is running', async ({ test('test that adding a new tag when the timer is running', async ({ page }) => {
page,
}) => {
const newTagName = 'New Tag' + Math.floor(Math.random() * 10000); const newTagName = 'New Tag' + Math.floor(Math.random() * 10000);
await goToDashboard(page); await goToDashboard(page);
await Promise.all([ await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
newTimeEntryResponse(page),
startOrStopTimerWithButton(page),
]);
await assertThatTimerHasStarted(page); await assertThatTimerHasStarted(page);
await page.getByTestId('tag_dropdown').click(); await page.getByTestId('tag_dropdown').click();
await page.getByText('Create new tag').click(); await page.getByText('Create new tag').click();

View File

@@ -1,9 +1,7 @@
import { expect, Page } from '@playwright/test'; import { expect, Page } from '@playwright/test';
export async function startOrStopTimerWithButton(page: Page) { export async function startOrStopTimerWithButton(page: Page) {
await page await page.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]').click();
.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]')
.click();
} }
export async function assertThatTimerHasStarted(page: Page) { export async function assertThatTimerHasStarted(page: Page) {
@@ -20,8 +18,7 @@ export function newTimeEntryResponse(
return ( return (
response.url().includes('/time-entries') && response.url().includes('/time-entries') &&
response.status() === status && response.status() === status &&
(await response.headerValue('Content-Type')) === (await response.headerValue('Content-Type')) === 'application/json' &&
'application/json' &&
(await response.json()).data.id !== null && (await response.json()).data.id !== null &&
(await response.json()).data.start !== null && (await response.json()).data.start !== null &&
(await response.json()).data.end === null && (await response.json()).data.end === null &&
@@ -29,30 +26,23 @@ export function newTimeEntryResponse(
(await response.json()).data.description === description && (await response.json()).data.description === description &&
(await response.json()).data.task_id === null && (await response.json()).data.task_id === null &&
(await response.json()).data.user_id !== null && (await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) === JSON.stringify((await response.json()).data.tags) === JSON.stringify(tags)
JSON.stringify(tags)
); );
}); });
} }
export async function assertThatTimerIsStopped(page: Page) { export async function assertThatTimerIsStopped(page: Page) {
await expect( await expect(
page.locator( page.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]')
'[data-testid="dashboard_timer"] [data-testid="timer_button"]'
)
).toHaveClass(/bg-accent-300\/70/); ).toHaveClass(/bg-accent-300\/70/);
} }
export async function stoppedTimeEntryResponse( export async function stoppedTimeEntryResponse(page: Page, { description = '', tags = [] } = {}) {
page: Page,
{ description = '', tags = [] } = {}
) {
return page.waitForResponse(async (response) => { return page.waitForResponse(async (response) => {
return ( return (
response.status() === 200 && response.status() === 200 &&
response.url().includes('/time-entries/') && response.url().includes('/time-entries/') &&
(await response.headerValue('Content-Type')) === (await response.headerValue('Content-Type')) === 'application/json' &&
'application/json' &&
(await response.json()).data.id !== null && (await response.json()).data.id !== null &&
(await response.json()).data.start !== null && (await response.json()).data.start !== null &&
(await response.json()).data.end !== null && (await response.json()).data.end !== null &&
@@ -61,8 +51,7 @@ export async function stoppedTimeEntryResponse(
(await response.json()).data.task_id === null && (await response.json()).data.task_id === null &&
(await response.json()).data.duration !== null && (await response.json()).data.duration !== null &&
(await response.json()).data.user_id !== null && (await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) === JSON.stringify((await response.json()).data.tags) === JSON.stringify(tags)
JSON.stringify(tags)
); );
}); });
} }

View File

@@ -4,8 +4,7 @@ export function newTagResponse(page: Page, { name = '' } = {}) {
return page.waitForResponse(async (response) => { return page.waitForResponse(async (response) => {
return ( return (
response.status() === 201 && response.status() === 201 &&
(await response.headerValue('Content-Type')) === (await response.headerValue('Content-Type')) === 'application/json' &&
'application/json' &&
(await response.json()).data.name === name (await response.json()).data.name === name
); );
}); });

View File

@@ -3,7 +3,7 @@ import eslintConfigPrettier from 'eslint-config-prettier';
import eslintPluginVue from 'eslint-plugin-vue'; import eslintPluginVue from 'eslint-plugin-vue';
import globals from 'globals'; import globals from 'globals';
import typescriptEslint from 'typescript-eslint'; import typescriptEslint from 'typescript-eslint';
import unusedImports from "eslint-plugin-unused-imports"; import unusedImports from 'eslint-plugin-unused-imports';
export default typescriptEslint.config( export default typescriptEslint.config(
{ ignores: ['*.d.ts', '**/coverage', '**/dist'] }, { ignores: ['*.d.ts', '**/coverage', '**/dist'] },
@@ -23,18 +23,21 @@ export default typescriptEslint.config(
}, },
}, },
plugins: { plugins: {
"unused-imports": unusedImports, 'unused-imports': unusedImports,
}, },
rules: { rules: {
"vue/multi-word-component-names": "off", 'vue/multi-word-component-names': 'off',
"@typescript-eslint/no-unused-vars": "off", '@typescript-eslint/no-unused-vars': 'off',
"unused-imports/no-unused-imports": "error", 'unused-imports/no-unused-imports': 'error',
"unused-imports/no-unused-vars": ["error", { 'unused-imports/no-unused-vars': [
"vars": "all", 'error',
"varsIgnorePattern": "^_", {
"args": "after-used", 'vars': 'all',
"argsIgnorePattern": "^_", 'varsIgnorePattern': '^_',
}], 'args': 'after-used',
'argsIgnorePattern': '^_',
},
],
}, },
}, },
eslintConfigPrettier eslintConfigPrettier

View File

@@ -8,7 +8,9 @@
"lint:fix": "eslint --fix resources/js", "lint:fix": "eslint --fix resources/js",
"type-check": "vue-tsc --noEmit", "type-check": "vue-tsc --noEmit",
"test:e2e": "rm -rf test-results/.auth && npx playwright test", "test:e2e": "rm -rf test-results/.auth && npx playwright test",
"zod:generate": "npx openapi-zod-client http://localhost:80/docs/api.json --output resources/js/packages/api/src/openapi.json.client.ts --base-url /api" "zod:generate": "npx openapi-zod-client http://localhost:80/docs/api.json --output resources/js/packages/api/src/openapi.json.client.ts --base-url /api",
"format": "prettier --write './**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,vue}'",
"format:check": "prettier --check './**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,vue}'"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",

View File

@@ -1,2 +1 @@
export const PLAYWRIGHT_BASE_URL = export const PLAYWRIGHT_BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://solidtime.test';
process.env.PLAYWRIGHT_BASE_URL ?? 'http://solidtime.test';

View File

@@ -8,12 +8,8 @@ export const test = baseTest.extend<object, { workerStorageState: string }>({
// Perform authentication steps. Replace these actions with your own. // Perform authentication steps. Replace these actions with your own.
await page.goto(PLAYWRIGHT_BASE_URL + '/register'); await page.goto(PLAYWRIGHT_BASE_URL + '/register');
await page.getByLabel('Name').fill('John Doe'); await page.getByLabel('Name').fill('John Doe');
await page await page.getByLabel('Email').fill(`john+${Math.round(Math.random() * 1000000)}@doe.com`);
.getByLabel('Email') await page.getByLabel('Password', { exact: true }).fill('amazingpassword123');
.fill(`john+${Math.round(Math.random() * 1000000)}@doe.com`);
await page
.getByLabel('Password', { exact: true })
.fill('amazingpassword123');
await page.getByLabel('Confirm Password').fill('amazingpassword123'); await page.getByLabel('Confirm Password').fill('amazingpassword123');
await page.getByLabel('I agree to the Terms of').click(); await page.getByLabel('I agree to the Terms of').click();
await page.getByRole('button', { name: 'Register' }).click(); await page.getByRole('button', { name: 'Register' }).click();

View File

@@ -14,8 +14,7 @@ import SectionTitle from './SectionTitle.vue';
</SectionTitle> </SectionTitle>
<div class="mt-5 md:mt-0 md:col-span-2"> <div class="mt-5 md:mt-0 md:col-span-2">
<div <div class="px-4 py-5 sm:p-6 bg-card-background shadow sm:rounded-lg">
class="px-4 py-5 sm:p-6 bg-card-background shadow sm:rounded-lg">
<slot name="content" /> <slot name="content" />
</div> </div>
</div> </div>

View File

@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from "vue"; import { onMounted } from 'vue';
import { useTheme } from "@/utils/theme.js"; import { useTheme } from '@/utils/theme.js';
onMounted(async () => { onMounted(async () => {
useTheme() useTheme();
}); });
</script> </script>

View File

@@ -24,9 +24,7 @@ watchEffect(async () => {
<template> <template>
<div> <div>
<div <div v-if="show && message" class="bg-secondary border-b border-border-secondary">
v-if="show && message"
class="bg-secondary border-b border-border-secondary">
<div class="mx-auto py-1 px-3 sm:px-6 lg:px-8"> <div class="mx-auto py-1 px-3 sm:px-6 lg:px-8">
<div class="flex items-center justify-between flex-wrap"> <div class="flex items-center justify-between flex-wrap">
<div class="w-0 flex-1 flex items-center min-w-0"> <div class="w-0 flex-1 flex items-center min-w-0">

View File

@@ -1,10 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import MainContainer from '@/packages/ui/src/MainContainer.vue'; import MainContainer from '@/packages/ui/src/MainContainer.vue';
import { import { CheckBadgeIcon, XMarkIcon, XCircleIcon } from '@heroicons/vue/16/solid';
CheckBadgeIcon,
XMarkIcon,
XCircleIcon,
} from '@heroicons/vue/16/solid';
import { Link } from '@inertiajs/vue3'; import { Link } from '@inertiajs/vue3';
import { computed } from 'vue'; import { computed } from 'vue';
import { import {
@@ -18,28 +14,20 @@ import { useSessionStorage } from '@vueuse/core';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
import { canManageBilling } from '@/utils/permissions'; import { canManageBilling } from '@/utils/permissions';
const hideTrialBanner = useSessionStorage( const hideTrialBanner = useSessionStorage('showTrialBanner-' + getCurrentOrganizationId(), false);
'showTrialBanner-' + getCurrentOrganizationId(),
false
);
const showTrialBanner = computed(() => isInTrial() && !hideTrialBanner.value); const showTrialBanner = computed(() => isInTrial() && !hideTrialBanner.value);
const hideBlockedBanner = useSessionStorage( const hideBlockedBanner = useSessionStorage(
'showBlockedBanner-' + getCurrentOrganizationId(), 'showBlockedBanner-' + getCurrentOrganizationId(),
false false
); );
const showBlockedBanner = computed( const showBlockedBanner = computed(() => isBlocked() && !hideBlockedBanner.value);
() => isBlocked() && !hideBlockedBanner.value
);
const hideFreeUpgradeBanner = useSessionStorage( const hideFreeUpgradeBanner = useSessionStorage(
'showFreeUpgradeBanner-' + getCurrentOrganizationId(), 'showFreeUpgradeBanner-' + getCurrentOrganizationId(),
false false
); );
const showFreeUpgradeBanner = computed( const showFreeUpgradeBanner = computed(
() => () =>
isFreePlan() && isFreePlan() && !isBlocked() && !hideFreeUpgradeBanner.value && !showBlackFridayBanner.value
!isBlocked() &&
!hideFreeUpgradeBanner.value &&
!showBlackFridayBanner.value
); );
const hideBlackFridayBanner = useSessionStorage( const hideBlackFridayBanner = useSessionStorage(
'hideBlackFridayBanner-' + getCurrentOrganizationId(), 'hideBlackFridayBanner-' + getCurrentOrganizationId(),
@@ -62,10 +50,7 @@ const showBlackFridayBanner = computed(() => {
class="bg-tertiary text-xs lg:text-sm pb-1 pt-2 border-b border-border-secondary"> class="bg-tertiary text-xs lg:text-sm pb-1 pt-2 border-b border-border-secondary">
<MainContainer class="flex items-center justify-between"> <MainContainer class="flex items-center justify-between">
<div class="flex items-center space-x-1.5"> <div class="flex items-center space-x-1.5">
<svg <svg class="w-4 mr-1" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
class="w-4 mr-1"
viewBox="0 0 256 256"
xmlns="http://www.w3.org/2000/svg">
<path <path
fill="#FF37AD" fill="#FF37AD"
d="M22.498 68.97a11.845 11.845 0 1 0 0-23.687c-6.471.098-11.666 5.372-11.666 11.844s5.195 11.746 11.666 11.844m181.393-10.04a11.845 11.845 0 1 0-.003-23.688c-6.471.098-11.665 5.373-11.665 11.845c.001 6.472 5.197 11.745 11.668 11.842" /> d="M22.498 68.97a11.845 11.845 0 1 0 0-23.687c-6.471.098-11.666 5.372-11.666 11.844s5.195 11.746 11.666 11.844m181.393-10.04a11.845 11.845 0 1 0-.003-23.688c-6.471.098-11.665 5.373-11.665 11.845c.001 6.472 5.197 11.745 11.668 11.842" />
@@ -113,8 +98,7 @@ const showBlackFridayBanner = computed(() => {
</div> </div>
</Link> </Link>
<button class="p-1" @click="hideBlackFridayBanner = true"> <button class="p-1" @click="hideBlackFridayBanner = true">
<XMarkIcon <XMarkIcon class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
</button> </button>
</div> </div>
</MainContainer> </MainContainer>
@@ -130,8 +114,8 @@ const showBlackFridayBanner = computed(() => {
Your trial expires in {{ daysLeftInTrial() }} days. Your trial expires in {{ daysLeftInTrial() }} days.
</span> </span>
<span class="hidden md:inline"> <span class="hidden md:inline">
To continue using all features & support the development To continue using all features & support the development of solidtime,
of solidtime, please upgrade your plan. please upgrade your plan.
</span> </span>
</div> </div>
</div> </div>
@@ -143,8 +127,7 @@ const showBlackFridayBanner = computed(() => {
</div> </div>
</Link> </Link>
<button class="p-1" @click="hideTrialBanner = true"> <button class="p-1" @click="hideTrialBanner = true">
<XMarkIcon <XMarkIcon class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
</button> </button>
</div> </div>
</MainContainer> </MainContainer>
@@ -156,27 +139,22 @@ const showBlackFridayBanner = computed(() => {
<div class="flex items-center space-x-1.5"> <div class="flex items-center space-x-1.5">
<XCircleIcon class="w-4 text-text-primary/50"></XCircleIcon> <XCircleIcon class="w-4 text-text-primary/50"></XCircleIcon>
<div class="flex-1 space-x-1"> <div class="flex-1 space-x-1">
<span class="font-medium"> <span class="font-medium"> Your organization is currently blocked. </span>
Your organization is currently blocked.
</span>
<span class="hidden md:inline"> <span class="hidden md:inline">
Please upgrade to a premium plan or remove all users Please upgrade to a premium plan or remove all users except the owner to
except the owner to unblock your organization. unblock your organization.
</span> </span>
</div> </div>
</div> </div>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<Link <Link v-if="isBillingActivated() && canManageBilling()" href="/billing">
v-if="isBillingActivated() && canManageBilling()"
href="/billing">
<div <div
class="text-text-primary font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5"> class="text-text-primary font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
<span>Upgrade now</span> <span>Upgrade now</span>
</div> </div>
</Link> </Link>
<button class="p-1" @click="hideBlockedBanner = true"> <button class="p-1" @click="hideBlockedBanner = true">
<XMarkIcon <XMarkIcon class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
</button> </button>
</div> </div>
</MainContainer> </MainContainer>
@@ -188,27 +166,22 @@ const showBlackFridayBanner = computed(() => {
<div class="flex items-center space-x-1.5"> <div class="flex items-center space-x-1.5">
<XCircleIcon class="w-4 text-text-primary/50"></XCircleIcon> <XCircleIcon class="w-4 text-text-primary/50"></XCircleIcon>
<div class="flex-1 space-x-1"> <div class="flex-1 space-x-1">
<span class="font-medium"> <span class="font-medium"> You are currently using the Free Plan. </span>
You are currently using the Free Plan.
</span>
<span class="hidden md:inline"> <span class="hidden md:inline">
To unlock all premium features & support the development To unlock all premium features & support the development of solidtime,
of solidtime, please upgrade your plan.</span please upgrade your plan.</span
> >
</div> </div>
</div> </div>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<Link <Link v-if="isBillingActivated() && canManageBilling()" href="/billing">
v-if="isBillingActivated() && canManageBilling()"
href="/billing">
<div <div
class="text-text-primary font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5"> class="text-text-primary font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
<span>Upgrade now</span> <span>Upgrade now</span>
</div> </div>
</Link> </Link>
<button class="p-1" @click="hideFreeUpgradeBanner = true"> <button class="p-1" @click="hideFreeUpgradeBanner = true">
<XMarkIcon <XMarkIcon class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
</button> </button>
</div> </div>
</MainContainer> </MainContainer>

View File

@@ -1,7 +1,8 @@
<script setup lang="ts"></script> <script setup lang="ts"></script>
<template> <template>
<div class="rounded-lg border overflow-hidden border-card-border bg-card-background shadow-card"> <div
class="rounded-lg border overflow-hidden border-card-border bg-card-background shadow-card">
<slot></slot> <slot></slot>
</div> </div>
</template> </template>

View File

@@ -1,9 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { import { ArchiveBoxIcon, PencilSquareIcon, TrashIcon } from '@heroicons/vue/20/solid';
ArchiveBoxIcon,
PencilSquareIcon,
TrashIcon,
} from '@heroicons/vue/20/solid';
import type { Client } from '@/packages/api/src'; import type { Client } from '@/packages/api/src';
import { canDeleteClients, canUpdateClients } from '@/utils/permissions'; import { canDeleteClients, canUpdateClients } from '@/utils/permissions';
import { import {

View File

@@ -24,17 +24,10 @@ const createClient = ref(false);
class="grid min-w-full" class="grid min-w-full"
style="grid-template-columns: 1fr 150px 200px 80px"> style="grid-template-columns: 1fr 150px 200px 80px">
<ClientTableHeading></ClientTableHeading> <ClientTableHeading></ClientTableHeading>
<div <div v-if="clients.length === 0" class="col-span-3 py-24 text-center">
v-if="clients.length === 0" <UserCircleIcon class="w-8 text-icon-default inline pb-2"></UserCircleIcon>
class="col-span-3 py-24 text-center"> <h3 class="text-text-primary font-semibold">No clients found</h3>
<UserCircleIcon <p v-if="canCreateClients()" class="pb-5">Create your first client now!</p>
class="w-8 text-icon-default inline pb-2"></UserCircleIcon>
<h3 class="text-text-primary font-semibold">
No clients found
</h3>
<p v-if="canCreateClients()" class="pb-5">
Create your first client now!
</p>
<SecondaryButton <SecondaryButton
v-if="canCreateClients()" v-if="canCreateClients()"
:icon="PlusIcon as Component" :icon="PlusIcon as Component"

View File

@@ -20,9 +20,7 @@ function deleteClient() {
} }
const projectCount = computed(() => { const projectCount = computed(() => {
return projects.value.filter( return projects.value.filter((projects) => projects.client_id === props.client.id).length;
(projects) => projects.client_id === props.client.id
).length;
}); });
function archiveClient() { function archiveClient() {
@@ -37,9 +35,7 @@ const showEditModal = ref(false);
<template> <template>
<TableRow> <TableRow>
<ClientEditModal <ClientEditModal v-model:show="showEditModal" :client="client"></ClientEditModal>
v-model:show="showEditModal"
:client="client"></ClientEditModal>
<div <div
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12"> class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<span> <span>

View File

@@ -20,11 +20,8 @@ onMounted(async () => {
class="grid min-w-full" class="grid min-w-full"
style="grid-template-columns: 1fr 1fr 80px"> style="grid-template-columns: 1fr 1fr 80px">
<InvitationTableHeading></InvitationTableHeading> <InvitationTableHeading></InvitationTableHeading>
<template <template v-for="invitation in invitations" :key="invitation.id">
v-for="invitation in invitations" <InvitationTableRow :invitation="invitation"></InvitationTableRow>
:key="invitation.id">
<InvitationTableRow
:invitation="invitation"></InvitationTableRow>
</template> </template>
</div> </div>
</div> </div>

View File

@@ -9,8 +9,7 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
Email Email
</div> </div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Role</div> <div class="px-3 py-1.5 text-left font-semibold text-text-primary">Role</div>
<div <div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
<span class="sr-only">Edit</span> <span class="sr-only">Edit</span>
</div> </div>
</TableHeading> </TableHeading>

View File

@@ -38,15 +38,12 @@ async function resendInvitation() {
if (organizationId) { if (organizationId) {
await handleApiRequestNotifications( await handleApiRequestNotifications(
() => () =>
api.resendInvitationEmail( api.resendInvitationEmail(undefined, {
undefined, params: {
{ invitation: props.invitation.id,
params: { organization: organizationId,
invitation: props.invitation.id, },
organization: organizationId, }),
},
}
),
'Invitation mail sent successfully', 'Invitation mail sent successfully',
'Error sending invitation mail' 'Error sending invitation mail'
); );
@@ -65,9 +62,7 @@ async function resendInvitation() {
</div> </div>
<div <div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-4 sm:pr-6 lg:pr-8 3xl:pr-12"> class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<InvitationMoreOptionsDropdown <InvitationMoreOptionsDropdown @delete="deleteInvitation" @resend="resendInvitation" />
@delete="deleteInvitation"
@resend="resendInvitation" />
</div> </div>
</TableRow> </TableRow>
</template> </template>

View File

@@ -42,8 +42,8 @@ defineEmits<{
>. >.
</p> </p>
<p class="py-1 text-center font-semibold max-w-md mx-auto"> <p class="py-1 text-center font-semibold max-w-md mx-auto">
Do you want to update all existing time entries, where the member Do you want to update all existing time entries, where the member billable rate applies
billable rate applies as well? as well?
</p> </p>
</BillableRateModal> </BillableRateModal>
</template> </template>

View File

@@ -35,12 +35,8 @@ useFocus(searchInput, { initialValue: true });
const filteredMembers = computed<Member[]>(() => { const filteredMembers = computed<Member[]>(() => {
return members.value.filter((member) => { return members.value.filter((member) => {
return ( return (
member.name member.name.toLowerCase().includes(searchValue.value?.toLowerCase()?.trim() || '') &&
.toLowerCase() !props.hiddenMembers.some((hiddenMember) => hiddenMember.member_id === member.id) &&
.includes(searchValue.value?.toLowerCase()?.trim() || '') &&
!props.hiddenMembers.some(
(hiddenMember) => hiddenMember.member_id === member.id
) &&
member.is_placeholder === false member.is_placeholder === false
); );
}); });

View File

@@ -44,7 +44,7 @@ const deleteMutation = useMutation({
onSuccess: () => { onSuccess: () => {
close(); close();
useMembersStore().fetchMembers(); useMembersStore().fetchMembers();
} },
}); });
const form = useForm({ const form = useForm({
@@ -70,77 +70,77 @@ const close = () => {
<template> <template>
<Modal :show="show" max-width="md" @close="close"> <Modal :show="show" max-width="md" @close="close">
<div class="p-6"> <div class="p-6">
<h2 class="text-lg font-medium text-text-primary"> <h2 class="text-lg font-medium text-text-primary">Delete Member</h2>
Delete Member
</h2>
<div class="mt-4 text-sm text-text-secondary"> <div class="mt-4 text-sm text-text-secondary">
<p class="mb-4"> <p class="mb-4">
Are you sure you want to delete {{ member.name }}? This action cannot be undone. Are you sure you want to delete {{ member.name }}? This action cannot be undone.
</p> </p>
<p class="mb-4"> <p class="mb-4">This will permanently delete:</p>
This will permanently delete:
</p>
<ul class="list-disc ml-6 mt-2"> <ul class="list-disc ml-6 mt-2">
<li>All time entries created by this member</li> <li>All time entries created by this member</li>
<li>Their project assignments</li> <li>Their project assignments</li>
<li>Their organization membership</li> <li>Their organization membership</li>
</ul> </ul>
<p class="pt-4"> <p class="pt-4">
<strong>Note:</strong> Deleting time entries will affect all reports and statistics. <strong>Note:</strong> Deleting time entries will affect all reports and
If you want to keep the time entries but remove the member from your organization, you can convert them to a placeholder user instead. Placeholder users are not charged and their time entries remain intact for reporting purposes. statistics. If you want to keep the time entries but remove the member from your
organization, you can convert them to a placeholder user instead. Placeholder
users are not charged and their time entries remain intact for reporting
purposes.
</p> </p>
</div> </div>
<form <form
class="mt-6" @submit=" class="mt-6"
(e) => { @submit="
e.preventDefault(); (e) => {
e.stopPropagation(); e.preventDefault();
form.handleSubmit(); e.stopPropagation();
} form.handleSubmit();
"> }
">
<div class="flex items-start"> <div class="flex items-start">
<form.Field <form.Field
name="confirmDelete" name="confirmDelete"
:validators="{ :validators="{
onSubmit: ({value}) => { onSubmit: ({ value }) => {
if (!value) { if (!value) {
return 'You must confirm that you understand the consequences of this action'; return 'You must confirm that you understand the consequences of this action';
}
return '';
} }
}" return '';
> },
}">
<template #default="{ field }"> <template #default="{ field }">
<div class="flex flex-col"> <div class="flex flex-col">
<div class="flex items-center space-x-3 text-sm"> <div class="flex items-center space-x-3 text-sm">
<Checkbox <Checkbox
:id="field.name" :id="field.name"
:name="field.name" :name="field.name"
:checked="field.state.value" :checked="field.state.value"
@update:checked="field.handleChange" @update:checked="field.handleChange"
@blur="field.handleBlur" @blur="field.handleBlur" />
/> <InputLabel
<InputLabel :for="field.name" class="font-medium text-text-primary"> :for="field.name"
I understand that this will permanently delete all data related to this member class="font-medium text-text-primary">
</InputLabel> I understand that this will permanently delete all data
</div> related to this member
<InputError class="pl-7 pt-2" :message="field.state.meta.errors[0]" /> </InputLabel>
</div>
<InputError
class="pl-7 pt-2"
:message="field.state.meta.errors[0]" />
</div> </div>
</template> </template>
</form.Field> </form.Field>
</div> </div>
<div class="mt-6 flex justify-end space-x-3"> <div class="mt-6 flex justify-end space-x-3">
<SecondaryButton @click="close">Cancel</SecondaryButton> <SecondaryButton @click="close">Cancel</SecondaryButton>
<form.Subscribe> <form.Subscribe>
<template #default="{ canSubmit, isSubmitting }"> <template #default="{ canSubmit, isSubmitting }">
<DangerButton <DangerButton type="submit" :disabled="!canSubmit">
type="submit" {{ isSubmitting ? 'Deleting...' : 'Delete Member' }}
:disabled="!canSubmit"
>
{{ isSubmitting ? 'Deleting...' : 'Delete Member' }}
</DangerButton> </DangerButton>
</template> </template>
</form.Subscribe> </form.Subscribe>

View File

@@ -54,10 +54,7 @@ function saveWithChecks() {
showBillableRateModal.value = true; showBillableRateModal.value = true;
}, 0); }, 0);
show.value = false; show.value = false;
} else if ( } else if (memberBody.value.role === 'owner' && props.member.role !== 'owner') {
memberBody.value.role === 'owner' &&
props.member.role !== 'owner'
) {
show.value = false; show.value = false;
showOwnershipTransferConfirmModal.value = true; showOwnershipTransferConfirmModal.value = true;
} else { } else {
@@ -96,10 +93,7 @@ const roleDescriptionTexts = {
}; };
const roleDescription = computed(() => { const roleDescription = computed(() => {
if ( if (memberBody.value.role && memberBody.value.role in roleDescriptionTexts) {
memberBody.value.role &&
memberBody.value.role in roleDescriptionTexts
) {
return roleDescriptionTexts[memberBody.value.role]; return roleDescriptionTexts[memberBody.value.role];
} }
return ''; return '';
@@ -143,22 +137,14 @@ const roleDescription = computed(() => {
<div> <div>
<InputLabel for="billableType" value="Billable" /> <InputLabel for="billableType" value="Billable" />
<MemberBillableSelect <MemberBillableSelect
v-model=" v-model="billableRateSelect"
billableRateSelect
"
class="mt-2" class="mt-2"
name="billableType"></MemberBillableSelect> name="billableType"></MemberBillableSelect>
</div> </div>
<div <div v-if="billableRateSelect === 'custom-rate'" class="flex-1">
v-if="billableRateSelect === 'custom-rate'" <InputLabel for="memberBillableRate" value="Billable Rate" />
class="flex-1">
<InputLabel
for="memberBillableRate"
value="Billable Rate" />
<BillableRateInput <BillableRateInput
v-model=" v-model="memberBody.billable_rate"
memberBody.billable_rate
"
focus focus
class="w-full" class="w-full"
:currency="getOrganizationCurrencyString()" :currency="getOrganizationCurrencyString()"

View File

@@ -11,10 +11,7 @@ import type { Role } from '@/types/jetstream';
import { Link, useForm } from '@inertiajs/vue3'; import { Link, useForm } from '@inertiajs/vue3';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
import { filterRoles } from '@/utils/roles'; import { filterRoles } from '@/utils/roles';
import { import { isAllowedToPerformPremiumAction, isBillingActivated } from '@/utils/billing';
isAllowedToPerformPremiumAction,
isBillingActivated,
} from '@/utils/billing';
import { CreditCardIcon, UserGroupIcon } from '@heroicons/vue/20/solid'; import { CreditCardIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
import { canManageBilling, canUpdateOrganization } from '@/utils/permissions'; import { canManageBilling, canUpdateOrganization } from '@/utils/permissions';
import { api } from '@/packages/api/src'; import { api } from '@/packages/api/src';
@@ -44,14 +41,10 @@ const { handleApiRequestNotifications } = useNotificationsStore();
async function submit() { async function submit() {
if (addTeamMemberForm.role === null || addTeamMemberForm.email === '') { if (addTeamMemberForm.role === null || addTeamMemberForm.email === '') {
errors.value.email = z errors.value.email = z.string().email().safeParse(addTeamMemberForm.email).success
.string()
.email()
.safeParse(addTeamMemberForm.email).success
? '' ? ''
: 'Please enter a valid email address'; : 'Please enter a valid email address';
errors.value.role = errors.value.role = addTeamMemberForm.role === null ? 'Please select a role' : '';
addTeamMemberForm.role === null ? 'Please select a role' : '';
return; return;
} }
@@ -100,21 +93,15 @@ useFocus(clientNameInput, { initialValue: true });
<UserGroupIcon class="w-12"></UserGroupIcon> <UserGroupIcon class="w-12"></UserGroupIcon>
</div> </div>
<div class="max-w-sm text-center mx-auto py-4 text-base"> <div class="max-w-sm text-center mx-auto py-4 text-base">
<p class="py-1"> <p class="py-1">The Free plan is <strong>limited to one member</strong></p>
The Free plan is <strong>limited to one member</strong>
</p>
<p class="py-1"> <p class="py-1">
To add new team members to your organization you, To add new team members to your organization you,
<strong>please upgrade to a paid plan</strong>. <strong>please upgrade to a paid plan</strong>.
</p> </p>
<Link <Link v-if="isBillingActivated() && canManageBilling()" href="/billing">
v-if="isBillingActivated() && canManageBilling()"
href="/billing">
<PrimaryButton <PrimaryButton
v-if=" v-if="isBillingActivated() && canUpdateOrganization()"
isBillingActivated() && canUpdateOrganization()
"
type="button" type="button"
class="mt-6"> class="mt-6">
<CreditCardIcon class="w-5 h-5 me-2" /> <CreditCardIcon class="w-5 h-5 me-2" />
@@ -154,8 +141,7 @@ useFocus(clientNameInput, { initialValue: true });
:class="{ :class="{
'border-t border-card-border focus:border-none rounded-t-none': 'border-t border-card-border focus:border-none rounded-t-none':
i > 0, i > 0,
'rounded-b-none': 'rounded-b-none': i != Object.keys(availableRoles).length - 1,
i != Object.keys(availableRoles).length - 1,
}" }"
@click="addTeamMemberForm.role = role.key"> @click="addTeamMemberForm.role = role.key">
<div <div
@@ -169,17 +155,13 @@ useFocus(clientNameInput, { initialValue: true });
<div <div
class="text-sm text-text-primary" class="text-sm text-text-primary"
:class="{ :class="{
'font-semibold': 'font-semibold': addTeamMemberForm.role == role.key,
addTeamMemberForm.role ==
role.key,
}"> }">
{{ role.name }} {{ role.name }}
</div> </div>
<svg <svg
v-if=" v-if="addTeamMemberForm.role == role.key"
addTeamMemberForm.role == role.key
"
class="ms-2 h-5 w-5 text-green-400" class="ms-2 h-5 w-5 text-green-400"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"

View File

@@ -1,17 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue'; import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue'; import DialogModal from '@/packages/ui/src/DialogModal.vue';
import {ref} from 'vue'; import { ref } from 'vue';
import {api, type Member} from '@/packages/api/src'; import { api, type Member } from '@/packages/api/src';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue'; import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import {useMutation} from '@tanstack/vue-query'; import { useMutation } from '@tanstack/vue-query';
import {getCurrentOrganizationId} from "@/utils/useUser"; import { getCurrentOrganizationId } from '@/utils/useUser';
import {useNotificationsStore} from "@/utils/notification"; import { useNotificationsStore } from '@/utils/notification';
import {useMembersStore} from "@/utils/useMembers"; import { useMembersStore } from '@/utils/useMembers';
const {handleApiRequestNotifications} = useNotificationsStore(); const { handleApiRequestNotifications } = useNotificationsStore();
const show = defineModel('show', {default: false}); const show = defineModel('show', { default: false });
const saving = ref(false); const saving = ref(false);
const props = defineProps<{ const props = defineProps<{
@@ -27,7 +27,7 @@ const turnToPlaceholderMutation = useMutation({
return await api.makePlaceholder(undefined, { return await api.makePlaceholder(undefined, {
params: { params: {
organization: organizationId, organization: organizationId,
member: props.member.id member: props.member.id,
}, },
}); });
}, },
@@ -36,17 +36,15 @@ const turnToPlaceholderMutation = useMutation({
async function submit() { async function submit() {
saving.value = true; saving.value = true;
await handleApiRequestNotifications( await handleApiRequestNotifications(
() => () => turnToPlaceholderMutation.mutateAsync(),
turnToPlaceholderMutation.mutateAsync(),
'Deactivating the member was successful!', 'Deactivating the member was successful!',
'There was an error deactivating the user.', 'There was an error deactivating the user.',
() => { () => {
show.value = false; show.value = false;
useMembersStore().fetchMembers() useMembersStore().fetchMembers();
} }
); );
} }
</script> </script>
<template> <template>
@@ -59,8 +57,9 @@ async function submit() {
<template #content> <template #content>
<p> <p>
Deactivating the user <strong>{{ member.name }} </strong> will remove the user's access to Deactivating the user <strong>{{ member.name }} </strong> will remove the user's
the organization. You will not be billed for inactive users and all time entries will be preserved. access to the organization. You will not be billed for inactive users and all time
entries will be preserved.
</p> </p>
</template> </template>
<template #footer> <template #footer>

View File

@@ -2,14 +2,14 @@
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue'; import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue'; import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { ref } from 'vue'; import { ref } from 'vue';
import {api, type Member} from '@/packages/api/src'; import { api, type Member } from '@/packages/api/src';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue'; import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import MemberCombobox from "@/Components/Common/Member/MemberCombobox.vue"; import MemberCombobox from '@/Components/Common/Member/MemberCombobox.vue';
import {UserIcon, ArrowRightIcon} from "@heroicons/vue/24/solid"; import { UserIcon, ArrowRightIcon } from '@heroicons/vue/24/solid';
import {Badge} from "@/packages/ui/src"; import { Badge } from '@/packages/ui/src';
import { useMutation } from '@tanstack/vue-query'; import { useMutation } from '@tanstack/vue-query';
import {getCurrentOrganizationId} from "@/utils/useUser"; import { getCurrentOrganizationId } from '@/utils/useUser';
import {useNotificationsStore} from "@/utils/notification"; import { useNotificationsStore } from '@/utils/notification';
const { handleApiRequestNotifications, addNotification } = useNotificationsStore(); const { handleApiRequestNotifications, addNotification } = useNotificationsStore();
const show = defineModel('show', { default: false }); const show = defineModel('show', { default: false });
@@ -27,40 +27,36 @@ const mergeMember = useMutation({
if (organizationId === null) { if (organizationId === null) {
throw new Error('No current organization id - create report'); throw new Error('No current organization id - create report');
} }
return await api.mergeMember({ return await api.mergeMember(
member_id: newMemberId, {
}, { member_id: newMemberId,
params: {
organization: organizationId,
member: props.member.id
}, },
}); {
params: {
organization: organizationId,
member: props.member.id,
},
}
);
}, },
}); });
async function submit() { async function submit() {
const newMemberId = newMember.value; const newMemberId = newMember.value;
if(newMemberId !== ''){ if (newMemberId !== '') {
saving.value = true; saving.value = true;
await handleApiRequestNotifications( await handleApiRequestNotifications(
() => () => mergeMember.mutateAsync(newMemberId),
mergeMember.mutateAsync(newMemberId),
'Members successfully merged!', 'Members successfully merged!',
'There was an error merging the members.', 'There was an error merging the members.',
() => { () => {
show.value = false; show.value = false;
} }
); );
} else {
addNotification('error', 'Please select a member to merge into.');
} }
else{
addNotification(
'error',
'Please select a member to merge into.',
);
}
} }
</script> </script>
<template> <template>
@@ -72,10 +68,14 @@ async function submit() {
</template> </template>
<template #content> <template #content>
<p>Merging the user <strong>{{ member.name }} </strong> into another one will transfer all time entries to the new user. <strong>This cannot be reverted!</strong></p> <p>
Merging the user <strong>{{ member.name }} </strong> into another one will transfer
all time entries to the new user. <strong>This cannot be reverted!</strong>
</p>
<div class="py-5 flex flex-col md:flex-row gap-6 items-center"> <div class="py-5 flex flex-col md:flex-row gap-6 items-center">
<div class="flex-1"> <div class="flex-1">
<Badge class="flex w-full text-base text-left space-x-3 px-3 text-text-secondary font-normal cursor py-1.5"> <Badge
class="flex w-full text-base text-left space-x-3 px-3 text-text-secondary font-normal cursor py-1.5">
<UserIcon class="relative z-10 w-4 text-text-secondary"></UserIcon> <UserIcon class="relative z-10 w-4 text-text-secondary"></UserIcon>
<div class="flex-1 font-medium truncate"> <div class="flex-1 font-medium truncate">
{{ member.name }} {{ member.name }}
@@ -86,9 +86,7 @@ async function submit() {
<ArrowRightIcon class="relative z-10 w-4 text-muted"></ArrowRightIcon> <ArrowRightIcon class="relative z-10 w-4 text-muted"></ArrowRightIcon>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<MemberCombobox <MemberCombobox v-model="newMember"></MemberCombobox>
v-model="newMember"
></MemberCombobox>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -1,7 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { TrashIcon, UserCircleIcon, PencilSquareIcon, ArrowDownOnSquareStackIcon } from '@heroicons/vue/20/solid'; import {
TrashIcon,
UserCircleIcon,
PencilSquareIcon,
ArrowDownOnSquareStackIcon,
} from '@heroicons/vue/20/solid';
import type { Member } from '@/packages/api/src'; import type { Member } from '@/packages/api/src';
import {canDeleteMembers, canMakeMembersPlaceholders, canMergeMembers, canUpdateMembers} from '@/utils/permissions'; import {
canDeleteMembers,
canMakeMembersPlaceholders,
canMergeMembers,
canUpdateMembers,
} from '@/utils/permissions';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,

View File

@@ -26,8 +26,8 @@ const emit = defineEmits<{
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<div class="col-span-6 sm:col-span-4 flex-1"> <div class="col-span-6 sm:col-span-4 flex-1">
<p class="py-1 text-center"> <p class="py-1 text-center">
You are about to transfer the ownership of this You are about to transfer the ownership of this organization to
organization to {{ memberName }}. {{ memberName }}.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -22,9 +22,7 @@ function getNameFromItem(item: Role) {
} }
function getNameForKey(key: string | undefined) { function getNameForKey(key: string | undefined) {
const item = page.props.availableRoles.find( const item = page.props.availableRoles.find((item) => getKeyFromItem(item) === key);
(item) => getKeyFromItem(item) === key
);
if (item) { if (item) {
return getNameFromItem(item); return getNameFromItem(item);
} }

View File

@@ -10,12 +10,9 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
</div> </div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Email</div> <div class="px-3 py-1.5 text-left font-semibold text-text-primary">Email</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Role</div> <div class="px-3 py-1.5 text-left font-semibold text-text-primary">Role</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary"> <div class="px-3 py-1.5 text-left font-semibold text-text-primary">Billable Rate</div>
Billable Rate
</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div> <div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div>
<div <div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
<span class="sr-only">Edit</span> <span class="sr-only">Edit</span>
</div> </div>
</TableHeading> </TableHeading>

View File

@@ -86,13 +86,9 @@ const userHasValidMailAddress = computed(() => {
</div> </div>
<div <div
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1 items-center font-medium"> class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1 items-center font-medium">
<CheckCircleIcon <CheckCircleIcon v-if="member.is_placeholder === false" class="w-5"></CheckCircleIcon>
v-if="member.is_placeholder === false"
class="w-5"></CheckCircleIcon>
<span v-if="member.is_placeholder === false">Active</span> <span v-if="member.is_placeholder === false">Active</span>
<UserCircleIcon <UserCircleIcon v-if="member.is_placeholder === true" class="w-5"></UserCircleIcon>
v-if="member.is_placeholder === true"
class="w-5"></UserCircleIcon>
<span v-if="member.is_placeholder === true">Inactive</span> <span v-if="member.is_placeholder === true">Inactive</span>
</div> </div>
<div <div
@@ -116,12 +112,8 @@ const userHasValidMailAddress = computed(() => {
showMakeMemberPlaceholderModal = true showMakeMemberPlaceholderModal = true
"></MemberMoreOptionsDropdown> "></MemberMoreOptionsDropdown>
</div> </div>
<MemberEditModal <MemberEditModal v-model:show="showEditMemberModal" :member="member"></MemberEditModal>
v-model:show="showEditMemberModal" <MemberMergeModal v-model:show="showMergeMemberModal" :member="member"></MemberMergeModal>
:member="member"></MemberEditModal>
<MemberMergeModal
v-model:show="showMergeMemberModal"
:member="member"></MemberMergeModal>
<MemberMakePlaceholderModal <MemberMakePlaceholderModal
v-model:show="showMakeMemberPlaceholderModal" v-model:show="showMakeMemberPlaceholderModal"
:member="member"></MemberMakePlaceholderModal> :member="member"></MemberMakePlaceholderModal>

View File

@@ -41,8 +41,8 @@ defineEmits<{
>. >.
</p> </p>
<p class="py-0.5 text-center font-semibold"> <p class="py-0.5 text-center font-semibold">
Do you want to update all existing time entries, where the Do you want to update all existing time entries, where the organization billable rate
organization billable rate applies as well? applies as well?
</p> </p>
</BillableRateModal> </BillableRateModal>
</template> </template>

View File

@@ -1,41 +1,39 @@
<script setup lang="ts"> <script setup lang="ts">
import ProjectBadge from "@/packages/ui/src/Project/ProjectBadge.vue"; import ProjectBadge from '@/packages/ui/src/Project/ProjectBadge.vue';
import { computed, nextTick, ref, watch } from "vue"; import { computed, nextTick, ref, watch } from 'vue';
import { useProjectsStore } from "@/utils/useProjects"; import { useProjectsStore } from '@/utils/useProjects';
import Dropdown from "@/packages/ui/src/Input/Dropdown.vue"; import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { import {
ComboboxAnchor, ComboboxAnchor,
ComboboxContent, ComboboxContent,
ComboboxInput, ComboboxInput,
ComboboxItem, ComboboxItem,
ComboboxRoot, ComboboxRoot,
ComboboxViewport ComboboxViewport,
} from "radix-vue"; } from 'radix-vue';
import { PlusCircleIcon } from "@heroicons/vue/20/solid"; import { PlusCircleIcon } from '@heroicons/vue/20/solid';
import { storeToRefs } from "pinia"; import { storeToRefs } from 'pinia';
import { api } from "@/packages/api/src"; import { api } from '@/packages/api/src';
import { usePage } from "@inertiajs/vue3"; import { usePage } from '@inertiajs/vue3';
import { getRandomColor } from "@/packages/ui/src/utils/color"; import { getRandomColor } from '@/packages/ui/src/utils/color';
import type { Project } from "@/packages/api/src"; import type { Project } from '@/packages/api/src';
import ProjectDropdownItem from "@/packages/ui/src/Project/ProjectDropdownItem.vue"; import ProjectDropdownItem from '@/packages/ui/src/Project/ProjectDropdownItem.vue';
import { UseFocusTrap } from "@vueuse/integrations/useFocusTrap/component"; import { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component';
const searchValue = ref(""); const searchValue = ref('');
const searchInput = ref<HTMLElement | null>(null); const searchInput = ref<HTMLElement | null>(null);
const model = defineModel<string | null>({ const model = defineModel<string | null>({
default: null default: null,
}); });
const open = ref(false); const open = ref(false);
const projectsStore = useProjectsStore(); const projectsStore = useProjectsStore();
const emit = defineEmits(["update:modelValue", "changed"]); const emit = defineEmits(['update:modelValue', 'changed']);
const { projects } = storeToRefs(projectsStore); const { projects } = storeToRefs(projectsStore);
const projectDropdownTrigger = ref<HTMLElement | null>(null); const projectDropdownTrigger = ref<HTMLElement | null>(null);
const shownProjects = computed(() => { const shownProjects = computed(() => {
return projects.value.filter((project) => { return projects.value.filter((project) => {
return project.name return project.name.toLowerCase().includes(searchValue.value?.toLowerCase()?.trim() || '');
.toLowerCase()
.includes(searchValue.value?.toLowerCase()?.trim() || "");
}); });
}); });
@@ -44,7 +42,7 @@ withDefaults(
border?: boolean; border?: boolean;
}>(), }>(),
{ {
border: true border: true,
} }
); );
@@ -62,13 +60,13 @@ async function addProjectIfNoneExists() {
{ {
name: searchValue.value, name: searchValue.value,
color: getRandomColor(), color: getRandomColor(),
is_billable: false is_billable: false,
}, },
{ params: { organization: page.props.auth.user.current_team_id } } { params: { organization: page.props.auth.user.current_team_id } }
); );
projects.value.unshift(response.data); projects.value.unshift(response.data);
model.value = response.data.id; model.value = response.data.id;
searchValue.value = ""; searchValue.value = '';
open.value = false; open.value = false;
} }
} }
@@ -95,16 +93,16 @@ function isProjectSelected(project: Project) {
} }
const selectedProjectName = computed(() => { const selectedProjectName = computed(() => {
return currentProject.value?.name || "No Project"; return currentProject.value?.name || 'No Project';
}); });
const selectedProjectColor = computed(() => { const selectedProjectColor = computed(() => {
return currentProject.value?.color || "var(--theme-color-icon-default)"; return currentProject.value?.color || 'var(--theme-color-icon-default)';
}); });
function updateValue(project: Project) { function updateValue(project: Project) {
model.value = project.id; model.value = project.id;
emit("changed"); emit('changed');
} }
</script> </script>
@@ -122,16 +120,13 @@ function updateValue(project: Project) {
</template> </template>
<template #content> <template #content>
<UseFocusTrap <UseFocusTrap v-if="open" :options="{ immediate: true, allowOutsideClick: true }">
v-if="open"
:options="{ immediate: true, allowOutsideClick: true }">
<ComboboxRoot <ComboboxRoot
v-model:search-term="searchValue" v-model:search-term="searchValue"
:open="open" :open="open"
:model-value="currentProject" :model-value="currentProject"
class="relative" class="relative"
@update:model-value="updateValue" @update:model-value="updateValue">
>
<ComboboxAnchor> <ComboboxAnchor>
<ComboboxInput <ComboboxInput
ref="searchInput" ref="searchInput"
@@ -155,19 +150,12 @@ function updateValue(project: Project) {
:name="project.name"></ProjectDropdownItem> :name="project.name"></ProjectDropdownItem>
</ComboboxItem> </ComboboxItem>
<div <div
v-if=" v-if="searchValue.length > 0 && shownProjects.length === 0"
searchValue.length > 0 &&
shownProjects.length === 0
"
class="bg-card-background-active"> class="bg-card-background-active">
<div <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"> 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 <PlusCircleIcon class="w-5 flex-shrink-0"></PlusCircleIcon>
class="w-5 flex-shrink-0"></PlusCircleIcon> <span>Add "{{ searchValue }}" as a new Project</span>
<span
>Add "{{ searchValue }}" as a new
Project</span
>
</div> </div>
</div> </div>
</ComboboxViewport> </ComboboxViewport>

View File

@@ -3,11 +3,7 @@ import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue'; import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue'; import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import type { import type { CreateClientBody, CreateProjectBody, Project } from '@/packages/api/src';
CreateClientBody,
CreateProjectBody,
Project,
} from '@/packages/api/src';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue'; import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { useProjectsStore } from '@/utils/useProjects'; import { useProjectsStore } from '@/utils/useProjects';
import { useFocus } from '@vueuse/core'; import { useFocus } from '@vueuse/core';
@@ -64,9 +60,7 @@ useFocus(projectNameInput, { initialValue: true });
const currentClientName = computed(() => { const currentClientName = computed(() => {
if (project.value.client_id) { if (project.value.client_id) {
return clients.value.find( return clients.value.find((client) => client.id === project.value.client_id)?.name;
(client) => client.id === project.value.client_id
)?.name;
} }
return 'No Client'; return 'No Client';
}); });
@@ -87,8 +81,7 @@ async function submitBillableRate() {
</template> </template>
<template #content> <template #content>
<div <div class="sm:flex items-center space-y-2 sm:space-y-0 sm:space-x-5">
class="sm:flex items-center space-y-2 sm:space-y-0 sm:space-x-5">
<div class="flex-1 flex items-center"> <div class="flex-1 flex items-center">
<div class="text-center"> <div class="text-center">
<InputLabel for="color" value="Color" /> <InputLabel for="color" value="Color" />
@@ -122,8 +115,7 @@ async function submitBillableRate() {
class="bg-input-background cursor-pointer hover:bg-tertiary" class="bg-input-background cursor-pointer hover:bg-tertiary"
size="xlarge"> size="xlarge">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<UserCircleIcon <UserCircleIcon class="w-5 text-icon-default"></UserCircleIcon>
class="w-5 text-icon-default"></UserCircleIcon>
<span class="whitespace-nowrap"> <span class="whitespace-nowrap">
{{ currentClientName }} {{ currentClientName }}
</span> </span>
@@ -137,9 +129,7 @@ async function submitBillableRate() {
<div> <div>
<ProjectEditBillableSection <ProjectEditBillableSection
v-model:is-billable="project.is_billable" v-model:is-billable="project.is_billable"
v-model:billable-rate=" v-model:billable-rate="project.billable_rate"
project.billable_rate
"
:currency="getOrganizationCurrencyString()" :currency="getOrganizationCurrencyString()"
@submit="submit"></ProjectEditBillableSection> @submit="submit"></ProjectEditBillableSection>
</div> </div>

View File

@@ -1,9 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { import { TrashIcon, PencilSquareIcon, ArchiveBoxIcon } from '@heroicons/vue/20/solid';
TrashIcon,
PencilSquareIcon,
ArchiveBoxIcon,
} from '@heroicons/vue/20/solid';
import type { Project } from '@/packages/api/src'; import type { Project } from '@/packages/api/src';
import { canDeleteProjects, canUpdateProjects } from '@/utils/permissions'; import { canDeleteProjects, canUpdateProjects } from '@/utils/permissions';
import { import {

View File

@@ -7,12 +7,7 @@ import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue'; import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue'; import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
import { canCreateProjects } from '@/utils/permissions'; import { canCreateProjects } from '@/utils/permissions';
import type { import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
CreateProjectBody,
Project,
Client,
CreateClientBody,
} from '@/packages/api/src';
import { useProjectsStore } from '@/utils/useProjects'; import { useProjectsStore } from '@/utils/useProjects';
import { useClientsStore } from '@/utils/useClients'; import { useClientsStore } from '@/utils/useClients';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
@@ -24,15 +19,11 @@ const props = defineProps<{
}>(); }>();
const showCreateProjectModal = ref(false); const showCreateProjectModal = ref(false);
async function createProject( async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
project: CreateProjectBody
): Promise<Project | undefined> {
return await useProjectsStore().createProject(project); return await useProjectsStore().createProject(project);
} }
async function createClient( async function createClient(client: CreateClientBody): Promise<Client | undefined> {
client: CreateClientBody
): Promise<Client | undefined> {
return await useClientsStore().createClient(client); return await useClientsStore().createClient(client);
} }
const { clients } = storeToRefs(useClientsStore()); const { clients } = storeToRefs(useClientsStore());
@@ -52,19 +43,11 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
:enable-estimated-time="isAllowedToPerformPremiumAction"></ProjectCreateModal> :enable-estimated-time="isAllowedToPerformPremiumAction"></ProjectCreateModal>
<div class="flow-root max-w-[100vw] overflow-x-auto"> <div class="flow-root max-w-[100vw] overflow-x-auto">
<div class="inline-block min-w-full align-middle"> <div class="inline-block min-w-full align-middle">
<div <div data-testid="project_table" class="grid min-w-full" :style="gridTemplate">
data-testid="project_table"
class="grid min-w-full"
:style="gridTemplate">
<ProjectTableHeading <ProjectTableHeading
:show-billable-rate=" :show-billable-rate="props.showBillableRate"></ProjectTableHeading>
props.showBillableRate <div v-if="projects.length === 0" class="col-span-5 py-24 text-center">
"></ProjectTableHeading> <FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
<div
v-if="projects.length === 0"
class="col-span-5 py-24 text-center">
<FolderPlusIcon
class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
<h3 class="text-text-primary font-semibold"> <h3 class="text-text-primary font-semibold">
{{ {{
canCreateProjects() canCreateProjects()

View File

@@ -12,15 +12,9 @@ defineProps<{
Name Name
</div> </div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Client</div> <div class="px-3 py-1.5 text-left font-semibold text-text-primary">Client</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary"> <div class="px-3 py-1.5 text-left font-semibold text-text-primary">Total Time</div>
Total Time <div class="px-3 py-1.5 text-left font-semibold text-text-primary">Progress</div>
</div> <div v-if="showBillableRate" class="px-3 py-1.5 text-left font-semibold text-text-primary">
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">
Progress
</div>
<div
v-if="showBillableRate"
class="px-3 py-1.5 text-left font-semibold text-text-primary">
Billable Rate Billable Rate
</div> </div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div> <div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div>

View File

@@ -26,14 +26,11 @@ const props = defineProps<{
}>(); }>();
const client = computed(() => { const client = computed(() => {
return clients.value.find( return clients.value.find((client) => client.id === props.project.client_id);
(client) => client.id === props.project.client_id
);
}); });
const projectTasksCount = computed(() => { const projectTasksCount = computed(() => {
return tasks.value.filter((task) => task.project_id === props.project.id) return tasks.value.filter((task) => task.project_id === props.project.id).length;
.length;
}); });
function deleteProject() { function deleteProject() {
@@ -67,7 +64,6 @@ const billableRateInfo = computed(() => {
}); });
const showEditProjectModal = ref(false); const showEditProjectModal = ref(false);
</script> </script>
<template> <template>
@@ -86,15 +82,10 @@ const showEditProjectModal = ref(false);
<span class="overflow-ellipsis overflow-hidden"> <span class="overflow-ellipsis overflow-hidden">
{{ project.name }} {{ project.name }}
</span> </span>
<span class="text-text-secondary"> <span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span>
{{ projectTasksCount }} Tasks
</span>
</div> </div>
<div <div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-secondary">
class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-secondary"> <div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">
<div
v-if="project.client_id"
class="overflow-ellipsis overflow-hidden">
{{ client?.name }} {{ client?.name }}
</div> </div>
<div v-else>No client</div> <div v-else>No client</div>
@@ -111,10 +102,8 @@ const showEditProjectModal = ref(false);
</div> </div>
<div v-else>--</div> <div v-else>--</div>
</div> </div>
<div <div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary"> <UpgradeBadge v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
<UpgradeBadge
v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
<EstimatedTimeProgress <EstimatedTimeProgress
v-else-if="project.estimated_time" v-else-if="project.estimated_time"
:estimated="project.estimated_time" :estimated="project.estimated_time"

View File

@@ -42,8 +42,8 @@ defineEmits<{
>. >.
</p> </p>
<p class="py-1 text-center font-semibold max-w-md mx-auto"> <p class="py-1 text-center font-semibold max-w-md mx-auto">
Do you want to update all existing time entries, where the project Do you want to update all existing time entries, where the project member billable rate
member billable rate applies as well? applies as well?
</p> </p>
</BillableRateModal> </BillableRateModal>
</template> </template>

View File

@@ -2,10 +2,7 @@
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue'; import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue'; import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { ref } from 'vue'; import { ref } from 'vue';
import type { import type { CreateProjectMemberBody, ProjectMember } from '@/packages/api/src';
CreateProjectMemberBody,
ProjectMember,
} from '@/packages/api/src';
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 { useProjectMembersStore } from '@/utils/useProjectMembers'; import { useProjectMembersStore } from '@/utils/useProjectMembers';
@@ -57,9 +54,7 @@ useFocus(projectNameInput, { initialValue: true });
</div> </div>
<div class="col-span-3 sm:col-span-1 flex-1"> <div class="col-span-3 sm:col-span-1 flex-1">
<BillableRateInput <BillableRateInput
v-model=" v-model="projectMember.billable_rate"
projectMember.billable_rate
"
name="billable_rate" name="billable_rate"
:currency="getOrganizationCurrencyString()"></BillableRateInput> :currency="getOrganizationCurrencyString()"></BillableRateInput>
</div> </div>

View File

@@ -2,10 +2,7 @@
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue'; import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue'; import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import type { import type { ProjectMember, UpdateProjectMemberBody } from '@/packages/api/src';
ProjectMember,
UpdateProjectMemberBody,
} from '@/packages/api/src';
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 { useProjectMembersStore } from '@/utils/useProjectMembers'; import { useProjectMembersStore } from '@/utils/useProjectMembers';
@@ -29,10 +26,7 @@ const projectMemberBody = ref<UpdateProjectMemberBody>({
}); });
const showBillableRateModal = ref(false); const showBillableRateModal = ref(false);
async function submit() { async function submit() {
if ( if (props.projectMember.billable_rate !== projectMemberBody.value.billable_rate) {
props.projectMember.billable_rate !==
projectMemberBody.value.billable_rate
) {
// make sure that the alert modal is not immediately submitted when user presses enter // make sure that the alert modal is not immediately submitted when user presses enter
setTimeout(() => { setTimeout(() => {
showBillableRateModal.value = true; showBillableRateModal.value = true;
@@ -84,20 +78,14 @@ useFocus(projectNameInput, { initialValue: true });
@close="showBillableRateModal = false" @close="showBillableRateModal = false"
@submit="submitBillableRate"></ProjectMemberBillableRateModal> @submit="submitBillableRate"></ProjectMemberBillableRateModal>
<div class="grid grid-cols-3 items-center space-x-4"> <div class="grid grid-cols-3 items-center space-x-4">
<div <div class="col-span-3 sm:col-span-2 space-x-2 flex items-center">
class="col-span-3 sm:col-span-2 space-x-2 flex items-center">
<UserIcon class="w-4 text-text-secondary"></UserIcon> <UserIcon class="w-4 text-text-secondary"></UserIcon>
<span>{{ props.name }}</span> <span>{{ props.name }}</span>
</div> </div>
<div class="col-span-3 sm:col-span-1 flex-1"> <div class="col-span-3 sm:col-span-1 flex-1">
<InputLabel <InputLabel for="billable_rate" class="mb-2" value="Billable Rate"></InputLabel>
for="billable_rate"
class="mb-2"
value="Billable Rate"></InputLabel>
<BillableRateInput <BillableRateInput
v-model=" v-model="projectMemberBody.billable_rate"
projectMemberBody.billable_rate
"
:currency="getOrganizationCurrencyString()" :currency="getOrganizationCurrencyString()"
name="billable_rate" name="billable_rate"
@keydown.enter="submit"></BillableRateInput> @keydown.enter="submit"></BillableRateInput>

View File

@@ -22,9 +22,7 @@ const props = defineProps<{
const { members } = storeToRefs(useMembersStore()); const { members } = storeToRefs(useMembersStore());
const currentMember = computed(() => { const currentMember = computed(() => {
return members.value.find( return members.value.find((member) => member.id === props.projectMember.user_id);
(member) => member.id === props.projectMember.user_id
);
}); });
</script> </script>

View File

@@ -28,24 +28,16 @@ const createProjectMember = ref(false);
class="grid min-w-full" class="grid min-w-full"
style="grid-template-columns: 1fr 150px 150px 80px"> style="grid-template-columns: 1fr 150px 150px 80px">
<ProjectMemberTableHeading></ProjectMemberTableHeading> <ProjectMemberTableHeading></ProjectMemberTableHeading>
<div <div v-if="projectMembers.length === 0" class="col-span-5 py-24 text-center">
v-if="projectMembers.length === 0" <UserGroupIcon class="w-8 text-icon-default inline pb-2"></UserGroupIcon>
class="col-span-5 py-24 text-center">
<UserGroupIcon
class="w-8 text-icon-default inline pb-2"></UserGroupIcon>
<h3 class="text-text-primary font-semibold">No project members</h3> <h3 class="text-text-primary font-semibold">No project members</h3>
<p class="pb-5">Add the first project member!</p> <p class="pb-5">Add the first project member!</p>
<SecondaryButton <SecondaryButton :icon="PlusIcon" @click="createProjectMember = true"
:icon="PlusIcon"
@click="createProjectMember = true"
>Add a new Project Member >Add a new Project Member
</SecondaryButton> </SecondaryButton>
</div> </div>
<template <template v-for="projectMember in projectMembers" :key="projectMember.id">
v-for="projectMember in projectMembers" <ProjectMemberTableRow :project-member="projectMember"></ProjectMemberTableRow>
:key="projectMember.id">
<ProjectMemberTableRow
:project-member="projectMember"></ProjectMemberTableRow>
</template> </template>
</div> </div>
</div> </div>

View File

@@ -8,9 +8,7 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12"> class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Name Name
</div> </div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary"> <div class="px-3 py-1.5 text-left font-semibold text-text-primary">Billable Rate</div>
Billable Rate
</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Role</div> <div class="px-3 py-1.5 text-left font-semibold text-text-primary">Role</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12"> <div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span> <span class="sr-only">Edit</span>

View File

@@ -31,9 +31,7 @@ function editProjectMember() {
const { members } = storeToRefs(useMembersStore()); const { members } = storeToRefs(useMembersStore());
const member = computed(() => { const member = computed(() => {
return members.value.find( return members.value.find((member) => member.id === props.projectMember.member_id);
(member) => member.id === props.projectMember.member_id
);
}); });
const showEditModal = ref(false); const showEditModal = ref(false);
</script> </script>

View File

@@ -5,10 +5,7 @@ import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { ref } from 'vue'; import { ref } from 'vue';
import PrimaryButton from '../../../packages/ui/src/Buttons/PrimaryButton.vue'; import PrimaryButton from '../../../packages/ui/src/Buttons/PrimaryButton.vue';
import InputLabel from '../../../packages/ui/src/Input/InputLabel.vue'; import InputLabel from '../../../packages/ui/src/Input/InputLabel.vue';
import type { import type { CreateReportBody, CreateReportBodyProperties } from '@/packages/api/src';
CreateReportBody,
CreateReportBodyProperties,
} from '@/packages/api/src';
import { useMutation } from '@tanstack/vue-query'; import { useMutation } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
import { api } from '@/packages/api/src'; import { api } from '@/packages/api/src';
@@ -80,10 +77,7 @@ async function submit() {
<div class="items-center space-y-4 w-full"> <div class="items-center space-y-4 w-full">
<div class="w-full"> <div class="w-full">
<InputLabel for="name" value="Name" /> <InputLabel for="name" value="Name" />
<TextInput <TextInput id="name" v-model="report.name" class="mt-1.5 w-full"></TextInput>
id="name"
v-model="report.name"
class="mt-1.5 w-full"></TextInput>
</div> </div>
<div> <div>
<InputLabel for="description" value="Description" /> <InputLabel for="description" value="Description" />
@@ -95,19 +89,13 @@ async function submit() {
<InputLabel value="Visibility" /> <InputLabel value="Visibility" />
<div class="flex items-center space-x-12"> <div class="flex items-center space-x-12">
<div class="flex items-center space-x-3 px-2 py-3"> <div class="flex items-center space-x-3 px-2 py-3">
<Checkbox <Checkbox id="is_public" v-model:checked="report.is_public"></Checkbox>
id="is_public"
v-model:checked="report.is_public"></Checkbox>
<InputLabel for="is_public" value="Public" /> <InputLabel for="is_public" value="Public" />
</div> </div>
<div <div v-if="report.is_public" class="flex items-center space-x-4">
v-if="report.is_public"
class="flex items-center space-x-4">
<div> <div>
<InputLabel for="public_until" value="Expires at" /> <InputLabel for="public_until" value="Expires at" />
<div class="text-text-tertiary font-medium"> <div class="text-text-tertiary font-medium">(optional)</div>
(optional)
</div>
</div> </div>
<DatePicker id="public_until"></DatePicker> <DatePicker id="public_until"></DatePicker>
</div> </div>

View File

@@ -94,10 +94,7 @@ async function submit() {
<div class="items-center space-y-4 w-full"> <div class="items-center space-y-4 w-full">
<div class="w-full"> <div class="w-full">
<InputLabel for="name" value="Name" /> <InputLabel for="name" value="Name" />
<TextInput <TextInput id="name" v-model="report.name" class="mt-1.5 w-full"></TextInput>
id="name"
v-model="report.name"
class="mt-1.5 w-full"></TextInput>
</div> </div>
<div> <div>
<InputLabel for="description" value="Description" /> <InputLabel for="description" value="Description" />
@@ -109,14 +106,10 @@ async function submit() {
<InputLabel value="Visibility" /> <InputLabel value="Visibility" />
<div class="flex items-center space-x-12"> <div class="flex items-center space-x-12">
<div class="flex items-center space-x-2 px-2 py-3"> <div class="flex items-center space-x-2 px-2 py-3">
<Checkbox <Checkbox id="is_public" v-model:checked="report.is_public"></Checkbox>
id="is_public"
v-model:checked="report.is_public"></Checkbox>
<InputLabel for="is_public" value="Public" /> <InputLabel for="is_public" value="Public" />
</div> </div>
<div <div v-if="report.is_public" class="flex items-center space-x-4">
v-if="report.is_public"
class="flex items-center space-x-4">
<InputLabel for="public_until" value="Expires at" /> <InputLabel for="public_until" value="Expires at" />
<DatePicker id="public_until"></DatePicker> <DatePicker id="public_until"></DatePicker>
</div> </div>

View File

@@ -31,13 +31,9 @@ function onSaveReportClick() {
v-model:show="showCreateReportModal" v-model:show="showCreateReportModal"
:properties="reportProperties"></ReportCreateModal> :properties="reportProperties"></ReportCreateModal>
<UpgradeModal v-model:show="showPremiumModal"> <UpgradeModal v-model:show="showPremiumModal">
<strong>Sharable Reports</strong> is only available in solidtime <strong>Sharable Reports</strong> is only available in solidtime Professional.
Professional.
</UpgradeModal> </UpgradeModal>
<SecondaryButton <SecondaryButton v-if="canCreateReports()" :icon="SaveIcon" @click="onSaveReportClick"
v-if="canCreateReports()"
:icon="SaveIcon"
@click="onSaveReportClick"
>Save Report</SecondaryButton >Save Report</SecondaryButton
> >
</template> </template>

View File

@@ -21,25 +21,15 @@ const gridTemplate = computed(() => {
<template> <template>
<div class="flow-root max-w-[100vw] overflow-x-auto"> <div class="flow-root max-w-[100vw] overflow-x-auto">
<div class="inline-block min-w-full align-middle"> <div class="inline-block min-w-full align-middle">
<div <div data-testid="report_table" class="grid min-w-full" :style="gridTemplate">
data-testid="report_table"
class="grid min-w-full"
:style="gridTemplate">
<ReportTableHeading></ReportTableHeading> <ReportTableHeading></ReportTableHeading>
<div <div v-if="reports.length === 0" class="col-span-5 py-24 text-center">
v-if="reports.length === 0" <FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
class="col-span-5 py-24 text-center"> <h3 class="text-text-primary font-semibold">No shared reports found</h3>
<FolderPlusIcon
class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
<h3 class="text-text-primary font-semibold">
No shared reports found
</h3>
<p v-if="canCreateProjects()" class="pb-5"> <p v-if="canCreateProjects()" class="pb-5">
Go to the overview to create a report Go to the overview to create a report
</p> </p>
<SecondaryButton <SecondaryButton :icon="PlusIcon" @click="router.visit(route('reporting'))"
:icon="PlusIcon"
@click="router.visit(route('reporting'))"
>Go to overview >Go to overview
</SecondaryButton> </SecondaryButton>
</div> </div>

View File

@@ -8,15 +8,9 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12"> class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Name Name
</div> </div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary"> <div class="px-3 py-1.5 text-left font-semibold text-text-primary">Description</div>
Description <div class="px-3 py-1.5 text-left font-semibold text-text-primary">Visibility</div>
</div> <div class="px-3 py-1.5 text-left font-semibold text-text-primary">Public URL</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">
Visibility
</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">
Public URL
</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12"> <div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span> <span class="sr-only">Edit</span>
</div> </div>

View File

@@ -57,9 +57,7 @@ async function deleteReport() {
</script> </script>
<template> <template>
<ReportEditModal <ReportEditModal v-model:show="showEditReportModal" :original-report="report"></ReportEditModal>
v-model:show="showEditReportModal"
:original-report="report"></ReportEditModal>
<TableRow> <TableRow>
<div <div
class="whitespace-nowrap min-w-0 flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12"> class="whitespace-nowrap min-w-0 flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
@@ -75,14 +73,9 @@ async function deleteReport() {
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary"> <div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{ report.is_public ? 'Public' : 'Private' }} {{ report.is_public ? 'Public' : 'Private' }}
</div> </div>
<div <div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary"> <div v-if="report.shareable_link" class="space-x-2 flex items-center">
<div <SecondaryButton v-if="isSupported" @click="copy(report.shareable_link)">
v-if="report.shareable_link"
class="space-x-2 flex items-center">
<SecondaryButton
v-if="isSupported"
@click="copy(report.shareable_link)">
<span v-if="!copied">Copy URL</span> <span v-if="!copied">Copy URL</span>
<span v-else>Copied!</span> <span v-else>Copied!</span>
</SecondaryButton> </SecondaryButton>

View File

@@ -2,11 +2,7 @@
import VChart, { THEME_KEY } from 'vue-echarts'; import VChart, { THEME_KEY } from 'vue-echarts';
import { computed, provide, inject, shallowRef, type ComputedRef } from 'vue'; import { computed, provide, inject, shallowRef, type ComputedRef } from 'vue';
import LinearGradient from 'zrender/lib/graphic/LinearGradient'; import LinearGradient from 'zrender/lib/graphic/LinearGradient';
import { import { formatDate, formatHumanReadableDuration, formatWeek } from '@/packages/ui/src/utils/time';
formatDate,
formatHumanReadableDuration,
formatWeek,
} from '@/packages/ui/src/utils/time';
import { use } from 'echarts/core'; import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers'; import { CanvasRenderer } from 'echarts/renderers';
import { BarChart } from 'echarts/charts'; import { BarChart } from 'echarts/charts';
@@ -19,14 +15,7 @@ import {
import type { AggregatedTimeEntries, Organization } from '@/packages/api/src'; import type { AggregatedTimeEntries, Organization } from '@/packages/api/src';
import { useCssVariable } from '@/utils/useCssVariable'; import { useCssVariable } from '@/utils/useCssVariable';
use([ use([CanvasRenderer, BarChart, TitleComponent, GridComponent, TooltipComponent, LegendComponent]);
CanvasRenderer,
BarChart,
TitleComponent,
GridComponent,
TooltipComponent,
LegendComponent,
]);
provide(THEME_KEY, 'dark'); provide(THEME_KEY, 'dark');
@@ -174,9 +163,7 @@ const option = computed(() => ({
class="chart" class="chart"
:option="option" /> :option="option" />
<div v-else class="chart flex flex-col items-center justify-center"> <div v-else class="chart flex flex-col items-center justify-center">
<p class="text-lg text-text-primary font-semibold"> <p class="text-lg text-text-primary font-semibold">No time entries found</p>
No time entries found
</p>
<p>Try to change the filters and time range</p> <p>Try to change the filters and time range</p>
</div> </div>
</div> </div>

View File

@@ -27,15 +27,11 @@ function triggerDownload(format: ExportFormat) {
<template> <template>
<Dropdown align="end"> <Dropdown align="end">
<template #trigger> <template #trigger>
<SecondaryButton :icon="ArrowDownTrayIcon" :loading> <SecondaryButton :icon="ArrowDownTrayIcon" :loading> Export </SecondaryButton>
Export
</SecondaryButton>
</template> </template>
<template #content> <template #content>
<div class="flex flex-col space-y-1 p-1.5"> <div class="flex flex-col space-y-1 p-1.5">
<SecondaryButton <SecondaryButton class="border-0 px-2" @click="triggerDownload('pdf')">
class="border-0 px-2"
@click="triggerDownload('pdf')">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<span> Export as PDF </span> <span> Export as PDF </span>
<LockClosedIcon <LockClosedIcon
@@ -43,27 +39,20 @@ function triggerDownload(format: ExportFormat) {
class="w-3.5 text-text-tertiary"></LockClosedIcon> class="w-3.5 text-text-tertiary"></LockClosedIcon>
</div> </div>
</SecondaryButton> </SecondaryButton>
<SecondaryButton <SecondaryButton class="border-0 px-2" @click="triggerDownload('xlsx')"
class="border-0 px-2"
@click="triggerDownload('xlsx')"
>Export as Excel</SecondaryButton >Export as Excel</SecondaryButton
> >
<SecondaryButton <SecondaryButton class="border-0 px-2" @click="triggerDownload('csv')"
class="border-0 px-2"
@click="triggerDownload('csv')"
>Export as CSV</SecondaryButton >Export as CSV</SecondaryButton
> >
<SecondaryButton <SecondaryButton class="border-0 px-2" @click="triggerDownload('ods')"
class="border-0 px-2"
@click="triggerDownload('ods')"
>Export as ODS >Export as ODS
</SecondaryButton> </SecondaryButton>
</div> </div>
</template> </template>
</Dropdown> </Dropdown>
<UpgradeModal v-model:show="showPremiumModal"> <UpgradeModal v-model:show="showPremiumModal">
<strong>PDF Reports</strong> are only available in solidtime <strong>PDF Reports</strong> are only available in solidtime Professional.
Professional.
</UpgradeModal> </UpgradeModal>
</template> </template>

View File

@@ -1,9 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { import { ArrowDownTrayIcon, CheckCircleIcon, XMarkIcon } from '@heroicons/vue/20/solid';
ArrowDownTrayIcon,
CheckCircleIcon,
XMarkIcon,
} from '@heroicons/vue/20/solid';
import { Modal, PrimaryButton } from '@/packages/ui/src'; import { Modal, PrimaryButton } from '@/packages/ui/src';
const props = defineProps<{ const props = defineProps<{
exportUrl: string | null; exportUrl: string | null;
@@ -19,30 +15,19 @@ function downloadCurrentExport() {
</script> </script>
<template> <template>
<Modal <Modal closeable max-width="lg" :show="showExportModal" @close="showExportModal = false">
closeable
max-width="lg"
:show="showExportModal"
@close="showExportModal = false">
<button <button
class="text-text-tertiary w-6 mx-auto absolute focus-visible:outline-none focus-visible:ring-2 rounded-full focus-visible:ring-ring transition focus-visible:text-text-primary hover:text-text-primary top-2 right-2"> class="text-text-tertiary w-6 mx-auto absolute focus-visible:outline-none focus-visible:ring-2 rounded-full focus-visible:ring-ring transition focus-visible:text-text-primary hover:text-text-primary top-2 right-2">
<XMarkIcon @click="showExportModal = false"></XMarkIcon> <XMarkIcon @click="showExportModal = false"></XMarkIcon>
</button> </button>
<div class="text-center text-text-primary py-6"> <div class="text-center text-text-primary py-6">
<div <div class="flex items-center font-semibold text-lg justify-center space-x-2 pb-2">
class="flex items-center font-semibold text-lg justify-center space-x-2 pb-2"> <CheckCircleIcon class="text-text-tertiary w-6"></CheckCircleIcon>
<CheckCircleIcon
class="text-text-tertiary w-6"></CheckCircleIcon>
<span> Export Successful! </span> <span> Export Successful! </span>
</div> </div>
<div class="text-center text-sm max-w-64 mx-auto"> <div class="text-center text-sm max-w-64 mx-auto">
<p class="pb-5"> <p class="pb-5">Your export is ready, you can download it with the button below.</p>
Your export is ready, you can download it with the button <PrimaryButton :icon="ArrowDownTrayIcon" @click="downloadCurrentExport"
below.
</p>
<PrimaryButton
:icon="ArrowDownTrayIcon"
@click="downloadCurrentExport"
>Download</PrimaryButton >Download</PrimaryButton
> >
</div> </div>

View File

@@ -26,18 +26,8 @@ const iconClass = computed(() => {
</script> </script>
<template> <template>
<Button <Button variant="outline" size="sm" :class="twMerge(activeClass)">
variant="outline" <component :is="icon" :class="iconClass"></component>
size="sm"
:class="
twMerge(
activeClass
)
">
<component
:is="icon"
:class="iconClass"
></component>
<span class="text-nowrap"> {{ title }} </span> <span class="text-nowrap"> {{ title }} </span>
<div <div
v-if="count" v-if="count"

View File

@@ -8,12 +8,10 @@ const props = defineProps<{
groupByOptions: { value: string; label: string; icon: Component }[]; groupByOptions: { value: string; label: string; icon: Component }[];
}>(); }>();
const icon = computed(() => { const icon = computed(() => {
return props.groupByOptions.find((option) => option.value === model.value) return props.groupByOptions.find((option) => option.value === model.value)?.icon;
?.icon;
}); });
const title = computed(() => { const title = computed(() => {
return props.groupByOptions.find((option) => option.value === model.value) return props.groupByOptions.find((option) => option.value === model.value)?.label;
?.label;
}); });
</script> </script>

View File

@@ -1,10 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { import { ChartBarIcon, CheckCircleIcon, TagIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
ChartBarIcon,
CheckCircleIcon,
TagIcon,
UserGroupIcon,
} from '@heroicons/vue/20/solid';
import { FolderIcon } from '@heroicons/vue/16/solid'; import { FolderIcon } from '@heroicons/vue/16/solid';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue'; import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { getOrganizationCurrencyString } from '@/utils/money'; import { getOrganizationCurrencyString } from '@/utils/money';
@@ -43,11 +38,7 @@ import {
type CreateReportBodyProperties, type CreateReportBodyProperties,
type Organization, type Organization,
} from '@/packages/api/src'; } from '@/packages/api/src';
import { import { getCurrentMembershipId, getCurrentOrganizationId, getCurrentRole } from '@/utils/useUser';
getCurrentMembershipId,
getCurrentOrganizationId,
getCurrentRole,
} from '@/utils/useUser';
import { useTagsStore } from '@/utils/useTags'; import { useTagsStore } from '@/utils/useTags';
import { useSessionStorage, useStorage } from '@vueuse/core'; import { useSessionStorage, useStorage } from '@vueuse/core';
import { useNotificationsStore } from '@/utils/notification'; import { useNotificationsStore } from '@/utils/notification';
@@ -84,8 +75,7 @@ const subGroup = useStorage<GroupingOption>('reporting-sub-group', 'task');
const reportingStore = useReportingStore(); const reportingStore = useReportingStore();
const { aggregatedGraphTimeEntries, aggregatedTableTimeEntries } = const { aggregatedGraphTimeEntries, aggregatedTableTimeEntries } = storeToRefs(reportingStore);
storeToRefs(reportingStore);
const { groupByOptions } = reportingStore; const { groupByOptions } = reportingStore;
@@ -103,26 +93,13 @@ function getFilterAttributes(): AggregatedTimeEntriesQueryParams {
}; };
params = { params = {
...params, ...params,
member_ids: member_ids: selectedMembers.value.length > 0 ? selectedMembers.value : undefined,
selectedMembers.value.length > 0 project_ids: selectedProjects.value.length > 0 ? selectedProjects.value : undefined,
? selectedMembers.value task_ids: selectedTasks.value.length > 0 ? selectedTasks.value : undefined,
: undefined, client_ids: selectedClients.value.length > 0 ? selectedClients.value : undefined,
project_ids:
selectedProjects.value.length > 0
? selectedProjects.value
: undefined,
task_ids:
selectedTasks.value.length > 0 ? selectedTasks.value : undefined,
client_ids:
selectedClients.value.length > 0
? selectedClients.value
: undefined,
tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined, tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,
billable: billable.value !== null ? billable.value : undefined, billable: billable.value !== null ? billable.value : undefined,
member_id: member_id: getCurrentRole() === 'employee' ? getCurrentMembershipId() : undefined,
getCurrentRole() === 'employee'
? getCurrentMembershipId()
: undefined,
rounding_type: roundingEnabled.value ? roundingType.value : undefined, rounding_type: roundingEnabled.value ? roundingType.value : undefined,
rounding_minutes: roundingEnabled.value ? roundingMinutes.value : undefined, rounding_minutes: roundingEnabled.value ? roundingMinutes.value : undefined,
}; };
@@ -142,9 +119,7 @@ function updateGraphReporting() {
function updateTableReporting() { function updateTableReporting() {
const params = getFilterAttributes(); const params = getFilterAttributes();
if (group.value === subGroup.value) { if (group.value === subGroup.value) {
const fallbackOption = groupByOptions.find( const fallbackOption = groupByOptions.find((el) => el.value !== group.value);
(el) => el.value !== group.value
);
if (fallbackOption?.value) { if (fallbackOption?.value) {
subGroup.value = fallbackOption.value; subGroup.value = fallbackOption.value;
} }
@@ -162,14 +137,8 @@ function updateReporting() {
updateTableReporting(); updateTableReporting();
} }
function getOptimalGroupingOption( function getOptimalGroupingOption(startDate: string, endDate: string): 'day' | 'week' | 'month' {
startDate: string, const diffInDays = getDayJsInstance()(endDate).diff(getDayJsInstance()(startDate), 'd');
endDate: string
): 'day' | 'week' | 'month' {
const diffInDays = getDayJsInstance()(endDate).diff(
getDayJsInstance()(startDate),
'd'
);
if (diffInDays <= 31) { if (diffInDays <= 31) {
return 'day'; return 'day';
@@ -213,10 +182,7 @@ async function downloadExport(format: ExportFormat) {
...getFilterAttributes(), ...getFilterAttributes(),
group: group.value, group: group.value,
sub_group: subGroup.value, sub_group: subGroup.value,
history_group: getOptimalGroupingOption( history_group: getOptimalGroupingOption(startDate.value, endDate.value),
startDate.value,
endDate.value
),
format: format, format: format,
}, },
}), }),
@@ -249,17 +215,12 @@ const groupedPieChartData = computed(() => {
if ( if (
name && name &&
aggregatedTableTimeEntries.value?.grouped_type && aggregatedTableTimeEntries.value?.grouped_type &&
emptyPlaceholder[ emptyPlaceholder[aggregatedTableTimeEntries.value?.grouped_type] === name
aggregatedTableTimeEntries.value?.grouped_type
] === name
) { ) {
color = '#CCCCCC'; color = '#CCCCCC';
} else if ( } else if (aggregatedTableTimeEntries.value?.grouped_type === 'project') {
aggregatedTableTimeEntries.value?.grouped_type === 'project'
) {
color = color =
projects.value?.find((project) => project.id === entry.key) projects.value?.find((project) => project.id === entry.key)?.color ?? '#CCCCCC';
?.color ?? '#CCCCCC';
} }
return { return {
value: entry.seconds, value: entry.seconds,
@@ -288,10 +249,7 @@ const tableData = computed(() => {
return { return {
seconds: el.seconds, seconds: el.seconds,
cost: el.cost, cost: el.cost,
description: getNameForReportingRowEntry( description: getNameForReportingRowEntry(el.key, entry.grouped_type),
el.key,
entry.grouped_type
),
}; };
}) ?? [], }) ?? [],
}; };
@@ -310,20 +268,15 @@ const tableData = computed(() => {
<ReportingTabNavbar active="reporting"></ReportingTabNavbar> <ReportingTabNavbar active="reporting"></ReportingTabNavbar>
</div> </div>
<div class="flex space-x-2"> <div class="flex space-x-2">
<ReportingExportButton <ReportingExportButton :download="downloadExport"></ReportingExportButton>
:download="downloadExport"></ReportingExportButton> <ReportSaveButton :report-properties="reportProperties"></ReportSaveButton>
<ReportSaveButton
:report-properties="reportProperties"></ReportSaveButton>
</div> </div>
</MainContainer> </MainContainer>
<div class="py-2.5 w-full border-b border-default-background-separator"> <div class="py-2.5 w-full border-b border-default-background-separator">
<MainContainer class="sm:flex space-y-4 sm:space-y-0 justify-between"> <MainContainer class="sm:flex space-y-4 sm:space-y-0 justify-between">
<div <div class="flex flex-wrap items-center space-y-2 sm:space-y-0 space-x-3">
class="flex flex-wrap items-center space-y-2 sm:space-y-0 space-x-3">
<div class="text-sm font-medium">Filters</div> <div class="text-sm font-medium">Filters</div>
<MemberMultiselectDropdown <MemberMultiselectDropdown v-model="selectedMembers" @submit="updateReporting">
v-model="selectedMembers"
@submit="updateReporting">
<template #trigger> <template #trigger>
<ReportingFilterBadge <ReportingFilterBadge
:count="selectedMembers.length" :count="selectedMembers.length"
@@ -332,9 +285,7 @@ const tableData = computed(() => {
:icon="UserGroupIcon"></ReportingFilterBadge> :icon="UserGroupIcon"></ReportingFilterBadge>
</template> </template>
</MemberMultiselectDropdown> </MemberMultiselectDropdown>
<ProjectMultiselectDropdown <ProjectMultiselectDropdown v-model="selectedProjects" @submit="updateReporting">
v-model="selectedProjects"
@submit="updateReporting">
<template #trigger> <template #trigger>
<ReportingFilterBadge <ReportingFilterBadge
:count="selectedProjects.length" :count="selectedProjects.length"
@@ -343,9 +294,7 @@ const tableData = computed(() => {
:icon="FolderIcon"></ReportingFilterBadge> :icon="FolderIcon"></ReportingFilterBadge>
</template> </template>
</ProjectMultiselectDropdown> </ProjectMultiselectDropdown>
<TaskMultiselectDropdown <TaskMultiselectDropdown v-model="selectedTasks" @submit="updateReporting">
v-model="selectedTasks"
@submit="updateReporting">
<template #trigger> <template #trigger>
<ReportingFilterBadge <ReportingFilterBadge
:count="selectedTasks.length" :count="selectedTasks.length"
@@ -354,9 +303,7 @@ const tableData = computed(() => {
:icon="CheckCircleIcon"></ReportingFilterBadge> :icon="CheckCircleIcon"></ReportingFilterBadge>
</template> </template>
</TaskMultiselectDropdown> </TaskMultiselectDropdown>
<ClientMultiselectDropdown <ClientMultiselectDropdown v-model="selectedClients" @submit="updateReporting">
v-model="selectedClients"
@submit="updateReporting">
<template #trigger> <template #trigger>
<ReportingFilterBadge <ReportingFilterBadge
:count="selectedClients.length" :count="selectedClients.length"
@@ -401,11 +348,7 @@ const tableData = computed(() => {
<template #trigger> <template #trigger>
<ReportingFilterBadge <ReportingFilterBadge
:active="billable !== null" :active="billable !== null"
:title=" :title="billable === 'false' ? 'Non Billable' : 'Billable'"
billable === 'false'
? 'Non Billable'
: 'Billable'
"
:icon="BillableIcon"></ReportingFilterBadge> :icon="BillableIcon"></ReportingFilterBadge>
</template> </template>
</SelectDropdown> </SelectDropdown>
@@ -427,37 +370,26 @@ const tableData = computed(() => {
<div class="pt-10 w-full px-3 relative"> <div class="pt-10 w-full px-3 relative">
<ReportingChart <ReportingChart
:grouped-type="aggregatedGraphTimeEntries?.grouped_type" :grouped-type="aggregatedGraphTimeEntries?.grouped_type"
:grouped-data=" :grouped-data="aggregatedGraphTimeEntries?.grouped_data"></ReportingChart>
aggregatedGraphTimeEntries?.grouped_data
"></ReportingChart>
</div> </div>
</MainContainer> </MainContainer>
<MainContainer> <MainContainer>
<div class="sm:grid grid-cols-4 pt-6 items-start"> <div class="sm:grid grid-cols-4 pt-6 items-start">
<div <div class="col-span-3 bg-card-background rounded-lg border border-card-border pt-3">
class="col-span-3 bg-card-background rounded-lg border border-card-border pt-3">
<div <div
class="text-sm flex text-text-primary items-center space-x-3 font-medium px-6 border-b border-card-background-separator pb-3"> class="text-sm flex text-text-primary items-center space-x-3 font-medium px-6 border-b border-card-background-separator pb-3">
<span>Group by</span> <span>Group by</span>
<ReportingGroupBySelect <ReportingGroupBySelect
v-model="group" v-model="group"
:group-by-options="groupByOptions" :group-by-options="groupByOptions"
@changed=" @changed="updateTableReporting"></ReportingGroupBySelect>
updateTableReporting
"></ReportingGroupBySelect>
<span>and</span> <span>and</span>
<ReportingGroupBySelect <ReportingGroupBySelect
v-model="subGroup" v-model="subGroup"
:group-by-options=" :group-by-options="groupByOptions.filter((el) => el.value !== group)"
groupByOptions.filter((el) => el.value !== group) @changed="updateTableReporting"></ReportingGroupBySelect>
"
@changed="
updateTableReporting
"></ReportingGroupBySelect>
</div> </div>
<div <div class="grid items-center" style="grid-template-columns: 1fr 100px 150px">
class="grid items-center"
style="grid-template-columns: 1fr 100px 150px">
<div <div
class="contents [&>*]:border-card-background-separator [&>*]:border-b [&>*]:bg-tertiary [&>*]:pb-1.5 [&>*]:pt-1 text-text-secondary text-sm"> class="contents [&>*]:border-card-background-separator [&>*]:border-b [&>*]:bg-tertiary [&>*]:pb-1.5 [&>*]:pt-1 text-text-secondary text-sm">
<div class="pl-6">Name</div> <div class="pl-6">Name</div>
@@ -475,13 +407,11 @@ const tableData = computed(() => {
:currency="getOrganizationCurrencyString()" :currency="getOrganizationCurrencyString()"
:type="aggregatedTableTimeEntries.grouped_type" :type="aggregatedTableTimeEntries.grouped_type"
:entry="entry"></ReportingRow> :entry="entry"></ReportingRow>
<div <div class="contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]">
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 <div class="justify-end flex items-center font-medium">
class="justify-end flex items-center font-medium">
{{ {{
formatHumanReadableDuration( formatHumanReadableDuration(
aggregatedTableTimeEntries.seconds, aggregatedTableTimeEntries.seconds,
@@ -490,8 +420,7 @@ const tableData = computed(() => {
) )
}} }}
</div> </div>
<div <div class="justify-end pr-6 flex items-center font-medium">
class="justify-end pr-6 flex items-center font-medium">
{{ {{
aggregatedTableTimeEntries.cost aggregatedTableTimeEntries.cost
? formatCents( ? formatCents(
@@ -509,16 +438,13 @@ const tableData = computed(() => {
<div <div
v-else v-else
class="chart flex flex-col items-center justify-center py-12 col-span-3"> class="chart flex flex-col items-center justify-center py-12 col-span-3">
<p class="text-lg text-text-primary font-medium"> <p class="text-lg text-text-primary font-medium">No time entries found</p>
No time entries found
</p>
<p>Try to change the filters and time range</p> <p>Try to change the filters and time range</p>
</div> </div>
</div> </div>
</div> </div>
<div class="px-2 lg:px-4"> <div class="px-2 lg:px-4">
<ReportingPieChart <ReportingPieChart :data="groupedPieChartData"></ReportingPieChart>
:data="groupedPieChartData"></ReportingPieChart>
</div> </div>
</div> </div>
</MainContainer> </MainContainer>

View File

@@ -14,14 +14,7 @@ import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { useCssVariable } from '@/utils/useCssVariable'; import { useCssVariable } from '@/utils/useCssVariable';
import type { Organization } from '@/packages/api/src'; import type { Organization } from '@/packages/api/src';
use([ use([CanvasRenderer, PieChart, TitleComponent, GridComponent, TooltipComponent, LegendComponent]);
CanvasRenderer,
PieChart,
TitleComponent,
GridComponent,
TooltipComponent,
LegendComponent,
]);
provide(THEME_KEY, 'dark'); provide(THEME_KEY, 'dark');

View File

@@ -7,7 +7,7 @@ import {
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue SelectValue,
} from '@/Components/ui/select'; } from '@/Components/ui/select';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue'; import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import { import {
@@ -15,7 +15,7 @@ import {
NumberFieldInput, NumberFieldInput,
NumberFieldContent, NumberFieldContent,
NumberFieldIncrement, NumberFieldIncrement,
NumberFieldDecrement NumberFieldDecrement,
} from '@/Components/ui/number-field'; } from '@/Components/ui/number-field';
import { ArrowsUpDownIcon } from '@heroicons/vue/20/solid'; import { ArrowsUpDownIcon } from '@heroicons/vue/20/solid';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
@@ -30,7 +30,7 @@ const TimeEntryRoundingType = {
Nearest: 'nearest' as const, Nearest: 'nearest' as const,
} as const; } as const;
type TimeEntryRoundingType = typeof TimeEntryRoundingType[keyof typeof TimeEntryRoundingType]; type TimeEntryRoundingType = (typeof TimeEntryRoundingType)[keyof typeof TimeEntryRoundingType];
interface Props { interface Props {
enabled: boolean; enabled: boolean;
@@ -79,8 +79,8 @@ const selectedInterval = ref('');
// Compute the current interval value based on props // Compute the current interval value based on props
const currentInterval = computed(() => { const currentInterval = computed(() => {
const predefined = predefinedIntervals.find(interval => const predefined = predefinedIntervals.find(
interval.value !== 'custom' && parseInt(interval.value) === props.minutes (interval) => interval.value !== 'custom' && parseInt(interval.value) === props.minutes
); );
return predefined ? predefined.value : 'custom'; return predefined ? predefined.value : 'custom';
}); });
@@ -116,10 +116,14 @@ function handleCustomMinutesChange(value: string | number) {
} }
// Watch for changes in props.minutes // Watch for changes in props.minutes
watch(() => props.minutes, (newMinutes) => { watch(
customMinutes.value = newMinutes; () => props.minutes,
initializeSelectedInterval(); (newMinutes) => {
}, { immediate: true }); customMinutes.value = newMinutes;
initializeSelectedInterval();
},
{ immediate: true }
);
watch(currentInterval, () => { watch(currentInterval, () => {
initializeSelectedInterval(); initializeSelectedInterval();
@@ -136,7 +140,9 @@ const activeClass = computed(() => {
const iconClass = computed(() => { const iconClass = computed(() => {
return twMerge( return twMerge(
'w-4 h-4', 'w-4 h-4',
props.enabled ? 'dark:text-accent-300/80 text-accent-400/80' : 'text-muted-foreground opacity-50' props.enabled
? 'dark:text-accent-300/80 text-accent-400/80'
: 'text-muted-foreground opacity-50'
); );
}); });
</script> </script>
@@ -144,10 +150,7 @@ const iconClass = computed(() => {
<template> <template>
<Popover> <Popover>
<PopoverTrigger as-child> <PopoverTrigger as-child>
<Button <Button variant="outline" size="sm" :class="twMerge(activeClass)">
variant="outline"
size="sm"
:class="twMerge(activeClass)">
<ArrowsUpDownIcon :class="iconClass" /> <ArrowsUpDownIcon :class="iconClass" />
Rounding {{ enabled ? 'on' : 'off' }} Rounding {{ enabled ? 'on' : 'off' }}
</Button> </Button>
@@ -155,7 +158,9 @@ const iconClass = computed(() => {
<PopoverContent class="w-72 p-4"> <PopoverContent class="w-72 p-4">
<div v-if="!isAllowedToPerformPremiumAction()" class="flex flex-col space-y-2"> <div v-if="!isAllowedToPerformPremiumAction()" class="flex flex-col space-y-2">
<span class="font-semibold text-xs">Premium</span> <span class="font-semibold text-xs">Premium</span>
<span class="text-xs text-text-secondary flex-1">Rounding is a premium feature. Upgrade to unlock this feature.</span> <span class="text-xs text-text-secondary flex-1"
>Rounding is a premium feature. Upgrade to unlock this feature.</span
>
<Link href="/billing"> <Link href="/billing">
<Button size="sm" variant="input" class="items-center space-x-1"> <Button size="sm" variant="input" class="items-center space-x-1">
<CreditCardIcon class="w-3.5 h-3.5 text-text-tertiary mr-1" /> <CreditCardIcon class="w-3.5 h-3.5 text-text-tertiary mr-1" />
@@ -166,17 +171,18 @@ const iconClass = computed(() => {
<div v-else class="space-y-4"> <div v-else class="space-y-4">
<div> <div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<InputLabel for="enable-rounding" value="Enable Rounding" /> <InputLabel for="enable-rounding" value="Enable Rounding" />
<Switch <Switch
id="enable-rounding" id="enable-rounding"
:model-value="enabled" :model-value="enabled"
class="data-[state=checked]:bg-accent-500" class="data-[state=checked]:bg-accent-500"
@update:model-value="updateEnabled" /> @update:model-value="updateEnabled" />
</div> </div>
<div class="mb-3 pb-2 pt-1 text-xs text-muted-foreground border-b border-border-secondary text-text-tertiary"> <div
Rounding is applied to each individual time entry, not to the accumulated total. class="mb-3 pb-2 pt-1 text-xs text-muted-foreground border-b border-border-secondary text-text-tertiary">
</div> Rounding is applied to each individual time entry, not to the accumulated
total.
</div>
</div> </div>
<div> <div>
@@ -185,7 +191,11 @@ const iconClass = computed(() => {
:model-value="type" :model-value="type"
:disabled="!enabled" :disabled="!enabled"
@update:model-value="(value) => updateType(value as TimeEntryRoundingType)"> @update:model-value="(value) => updateType(value as TimeEntryRoundingType)">
<SelectTrigger id="rounding-type" size="small" class="w-full" :disabled="!enabled"> <SelectTrigger
id="rounding-type"
size="small"
class="w-full"
:disabled="!enabled">
<SelectValue placeholder="Select rounding type" /> <SelectValue placeholder="Select rounding type" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -201,8 +211,12 @@ const iconClass = computed(() => {
:model-value="selectedInterval" :model-value="selectedInterval"
:disabled="!enabled" :disabled="!enabled"
@update:model-value="(value) => handleIntervalChange(value as string)"> @update:model-value="(value) => handleIntervalChange(value as string)">
<SelectTrigger id="minutes-interval" size="small" class="w-full" :disabled="!enabled"> <SelectTrigger
<SelectValue placeholder="Select interval" /> id="minutes-interval"
size="small"
class="w-full"
:disabled="!enabled">
<SelectValue placeholder="Select interval" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem <SelectItem
@@ -226,7 +240,9 @@ const iconClass = computed(() => {
@update:model-value="handleCustomMinutesChange"> @update:model-value="handleCustomMinutesChange">
<NumberFieldContent> <NumberFieldContent>
<NumberFieldDecrement :disabled="!enabled" /> <NumberFieldDecrement :disabled="!enabled" />
<NumberFieldInput placeholder="Enter custom minutes" :disabled="!enabled" /> <NumberFieldInput
placeholder="Enter custom minutes"
:disabled="!enabled" />
<NumberFieldIncrement :disabled="!enabled" /> <NumberFieldIncrement :disabled="!enabled" />
</NumberFieldContent> </NumberFieldContent>
</NumberField> </NumberField>

View File

@@ -32,10 +32,7 @@ const organization = inject<ComputedRef<Organization>>('organization');
class="contents text-text-primary [&>*]:transition [&>*]:border-card-background-separator [&>*]:border-b [&>*]:h-[50px]"> class="contents text-text-primary [&>*]:transition [&>*]:border-card-background-separator [&>*]:border-b [&>*]:h-[50px]">
<div <div
:class=" :class="
twMerge( twMerge('pl-6 font-medium flex items-center space-x-3', props.indent ? 'pl-16' : '')
'pl-6 font-medium flex items-center space-x-3',
props.indent ? 'pl-16' : ''
)
"> ">
<GroupedItemsCountButton <GroupedItemsCountButton
v-if="entry.grouped_data && entry.grouped_data?.length > 0" v-if="entry.grouped_data && entry.grouped_data?.length > 0"
@@ -57,13 +54,17 @@ const organization = inject<ComputedRef<Organization>>('organization');
}} }}
</div> </div>
<div class="justify-end pr-6 flex items-center"> <div class="justify-end pr-6 flex items-center">
{{ entry.cost ? formatCents( {{
entry.cost, entry.cost
props.currency, ? formatCents(
organization?.currency_format, entry.cost,
organization?.currency_symbol, props.currency,
organization?.number_format organization?.currency_format,
) : '--' }} organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
</div> </div>
</div> </div>
<div <div

View File

@@ -2,8 +2,8 @@
import { router } from '@inertiajs/vue3'; import { router } from '@inertiajs/vue3';
import TabBar from '@/Components/Common/TabBar/TabBar.vue'; import TabBar from '@/Components/Common/TabBar/TabBar.vue';
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue'; import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
import {canViewReport} from "@/utils/permissions"; import { canViewReport } from '@/utils/permissions';
import {computed} from "vue"; import { computed } from 'vue';
defineProps<{ defineProps<{
active: 'reporting' | 'detailed' | 'shared'; active: 'reporting' | 'detailed' | 'shared';
}>(); }>();
@@ -12,17 +12,11 @@ const showSharedReports = computed(() => canViewReport());
</script> </script>
<template> <template>
<TabBar <TabBar :model-value="active">
:model-value="active" <TabBarItem value="reporting" @click="router.visit(route('reporting'))"
>
<TabBarItem
value="reporting"
@click="router.visit(route('reporting'))"
>Overview</TabBarItem >Overview</TabBarItem
> >
<TabBarItem <TabBarItem value="detailed" @click="router.visit(route('reporting.detailed'))"
value="detailed"
@click="router.visit(route('reporting.detailed'))"
>Detailed</TabBarItem >Detailed</TabBarItem
> >
<TabBarItem <TabBarItem

View File

@@ -6,8 +6,7 @@ defineProps<{
</script> </script>
<template> <template>
<div <div class="rounded-lg bg-card-background border-card-border shadow-card border px-3.5 py-2.5">
class="rounded-lg bg-card-background border-card-border shadow-card border px-3.5 py-2.5">
<dt class="font-semibold text-sm text-text-secondary">{{ title }}</dt> <dt class="font-semibold text-sm text-text-secondary">{{ title }}</dt>
<dd class="text-2xl text-text-primary pt-1 font-semibold"> <dd class="text-2xl text-text-primary pt-1 font-semibold">
{{ value ?? '--' }} {{ value ?? '--' }}

View File

@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { Tabs, TabsList } from '@/Components/ui/tabs' import { Tabs, TabsList } from '@/Components/ui/tabs';
defineProps<{ defineProps<{
defaultValue?: string defaultValue?: string;
}>() }>();
</script> </script>
<template> <template>

View File

@@ -1,20 +1,25 @@
<script setup lang="ts"> <script setup lang="ts">
import { TabsTrigger } from '@/Components/ui/tabs' import { TabsTrigger } from '@/Components/ui/tabs';
import { twMerge } from 'tailwind-merge' import { twMerge } from 'tailwind-merge';
import type { Component } from 'vue' import type { Component } from 'vue';
const props = defineProps<{ const props = defineProps<{
value: string value: string;
class?: string class?: string;
icon?: Component icon?: Component;
}>() }>();
</script> </script>
<template> <template>
<TabsTrigger <TabsTrigger
:value="value" :value="value"
:icon="icon" :icon="icon"
:class="twMerge('rounded-md px-2 sm:px-3 py-1 border sm:py-1.5 text-xs sm:text-sm font-medium text-text-tertiary hover:text-text-primary focus-visible:outline-none data-[state=active]:bg-tab-background data-[state=active]:border-input-border data-[state=active]:text-text-primary border-tab-border', props.class)"> :class="
twMerge(
'rounded-md px-2 sm:px-3 py-1 border sm:py-1.5 text-xs sm:text-sm font-medium text-text-tertiary hover:text-text-primary focus-visible:outline-none data-[state=active]:bg-tab-background data-[state=active]:border-input-border data-[state=active]:text-text-primary border-tab-border',
props.class
)
">
<slot></slot> <slot></slot>
</TabsTrigger> </TabsTrigger>
</template> </template>

View File

@@ -18,9 +18,7 @@ const showCreateTagModal = ref(false);
</script> </script>
<template> <template>
<TagCreateModal <TagCreateModal v-model:show="showCreateTagModal" :create-tag></TagCreateModal>
v-model:show="showCreateTagModal"
:create-tag></TagCreateModal>
<div class="flow-root"> <div class="flow-root">
<div class="inline-block min-w-full align-middle"> <div class="inline-block min-w-full align-middle">
<div <div
@@ -28,15 +26,10 @@ const showCreateTagModal = ref(false);
class="grid min-w-full" class="grid min-w-full"
style="grid-template-columns: 1fr 80px"> style="grid-template-columns: 1fr 80px">
<TagTableHeading></TagTableHeading> <TagTableHeading></TagTableHeading>
<div <div v-if="tags.length === 0" class="col-span-5 py-24 text-center">
v-if="tags.length === 0" <FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
class="col-span-5 py-24 text-center">
<FolderPlusIcon
class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
<h3 class="text-text-primary font-semibold">No tags found</h3> <h3 class="text-text-primary font-semibold">No tags found</h3>
<p v-if="canCreateTags()" class="pb-5"> <p v-if="canCreateTags()" class="pb-5">Create your first tag now!</p>
Create your first tag now!
</p>
<SecondaryButton <SecondaryButton
v-if="canCreateTags()" v-if="canCreateTags()"
:icon="PlusIcon" :icon="PlusIcon"

View File

@@ -2,7 +2,7 @@
import TextInput from '@/packages/ui/src/Input/TextInput.vue'; import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue'; import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue'; import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { ref, watch } from "vue"; import { ref, watch } from 'vue';
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 { useTasksStore } from '@/utils/useTasks'; import { useTasksStore } from '@/utils/useTasks';
@@ -23,9 +23,12 @@ const props = defineProps<{
const taskProjectId = ref<string>(props.projectId); const taskProjectId = ref<string>(props.projectId);
watch(() => props.projectId, (value) => { watch(
taskProjectId.value = value; () => props.projectId,
}); (value) => {
taskProjectId.value = value;
}
);
async function submit() { async function submit() {
await createTask({ await createTask({

View File

@@ -1,9 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { import { TrashIcon, PencilSquareIcon, CheckCircleIcon } from '@heroicons/vue/20/solid';
TrashIcon,
PencilSquareIcon,
CheckCircleIcon,
} from '@heroicons/vue/20/solid';
import type { Task } from '@/packages/api/src'; import type { Task } from '@/packages/api/src';
import { canDeleteTasks, canUpdateTasks } from '@/utils/permissions'; import { canDeleteTasks, canUpdateTasks } from '@/utils/permissions';
import { import {

View File

@@ -18,9 +18,7 @@ const createTask = ref(false);
</script> </script>
<template> <template>
<TaskCreateModal <TaskCreateModal v-model:show="createTask" :project-id="props.projectId"></TaskCreateModal>
v-model:show="createTask"
:project-id="props.projectId"></TaskCreateModal>
<div class="flow-root"> <div class="flow-root">
<div class="inline-block min-w-full align-middle"> <div class="inline-block min-w-full align-middle">
<div <div
@@ -29,22 +27,14 @@ const createTask = ref(false);
class="grid min-w-full" class="grid min-w-full"
style=" style="
grid-template-columns: grid-template-columns:
1fr minmax(80px, auto) minmax(120px, auto) minmax( 1fr minmax(80px, auto) minmax(120px, auto) minmax(50px, auto)
50px,
auto
)
80px; 80px;
"> ">
<TaskTableHeading></TaskTableHeading> <TaskTableHeading></TaskTableHeading>
<div <div v-if="tasks.length === 0" class="col-span-5 py-24 text-center">
v-if="tasks.length === 0" <PlusCircleIcon class="w-8 text-icon-default inline pb-2"></PlusCircleIcon>
class="col-span-5 py-24 text-center">
<PlusCircleIcon
class="w-8 text-icon-default inline pb-2"></PlusCircleIcon>
<h3 class="text-text-primary font-semibold">No tasks found</h3> <h3 class="text-text-primary font-semibold">No tasks found</h3>
<p v-if="canCreateTasks()" class="pb-5"> <p v-if="canCreateTasks()" class="pb-5">Create your first task now!</p>
Create your first task now!
</p>
<SecondaryButton <SecondaryButton
v-if="canCreateTasks()" v-if="canCreateTasks()"
:icon="PlusIcon" :icon="PlusIcon"

View File

@@ -8,12 +8,8 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12"> class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Task Name Task Name
</div> </div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary"> <div class="px-3 py-1.5 text-left font-semibold text-text-primary">Total Time</div>
Total Time <div class="px-3 py-1.5 text-left font-semibold text-text-primary">Progress</div>
</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">
Progress
</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div> <div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12"> <div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span> <span class="sr-only">Edit</span>

View File

@@ -54,10 +54,8 @@ const showTaskEditModal = ref(false);
</span> </span>
<span v-else> -- </span> <span v-else> -- </span>
</div> </div>
<div <div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary"> <UpgradeBadge v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
<UpgradeBadge
v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
<EstimatedTimeProgress <EstimatedTimeProgress
v-else-if="task.estimated_time" v-else-if="task.estimated_time"
:estimated="task.estimated_time" :estimated="task.estimated_time"
@@ -83,9 +81,7 @@ const showTaskEditModal = ref(false);
@edit="showTaskEditModal = true" @edit="showTaskEditModal = true"
@delete="deleteTask"></TaskMoreOptionsDropdown> @delete="deleteTask"></TaskMoreOptionsDropdown>
</div> </div>
<TaskEditModal <TaskEditModal v-model:show="showTaskEditModal" :task="task"></TaskEditModal>
v-model:show="showTaskEditModal"
:task="task"></TaskEditModal>
</TableRow> </TableRow>
</template> </template>

View File

@@ -7,8 +7,7 @@ const showUpgradeModal = ref(false);
<template> <template>
<UpgradeModal v-model:show="showUpgradeModal"> <UpgradeModal v-model:show="showUpgradeModal">
<strong>Project and Task Estimates</strong> is only available in <strong>Project and Task Estimates</strong> is only available in solidtime Professional.
solidtime Professional.
</UpgradeModal> </UpgradeModal>
<button <button
class="inline-flex bg-secondary hover:bg-tertiary px-2 py-1 rounded border border-border-secondary hover:border-border-tertiary items-center space-x-1" class="inline-flex bg-secondary hover:bg-tertiary px-2 py-1 rounded border border-border-secondary hover:border-border-tertiary items-center space-x-1"

View File

@@ -46,13 +46,9 @@ const show = defineModel('show', { default: false });
to try out this feature. to try out this feature.
</p> </p>
<Link <Link v-if="isBillingActivated() && canManageBilling()" href="/billing">
v-if="isBillingActivated() && canManageBilling()"
href="/billing">
<PrimaryButton <PrimaryButton
v-if=" v-if="isBillingActivated() && canUpdateOrganization()"
isBillingActivated() && canUpdateOrganization()
"
type="button" type="button"
class="mt-6" class="mt-6"
:icon="CreditCardIcon"> :icon="CreditCardIcon">

View File

@@ -24,11 +24,7 @@ const close = () => {
</script> </script>
<template> <template>
<Modal <Modal :show="show" :max-width="maxWidth" :closeable="closeable" @close="close">
:show="show"
:max-width="maxWidth"
:closeable="closeable"
@close="close">
<div class="bg-card-background px-4 pt-5 pb-4 sm:p-6 sm:pb-4"> <div class="bg-card-background px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start"> <div class="sm:flex sm:items-start">
<div <div
@@ -59,8 +55,7 @@ const close = () => {
</div> </div>
</div> </div>
<div <div class="flex flex-row justify-end px-6 py-4 bg-card-background text-end">
class="flex flex-row justify-end px-6 py-4 bg-card-background text-end">
<slot name="footer" /> <slot name="footer" />
</div> </div>
</Modal> </Modal>

View File

@@ -43,9 +43,7 @@ const isRunningInDifferentOrganization = computed(() => {
</div> </div>
</div> </div>
<div> <div>
<div class="text-text-secondary font-medium text-xs"> <div class="text-text-secondary font-medium text-xs">Current Timer</div>
Current Timer
</div>
<div class="text-text-primary font-medium text-lg"> <div class="text-text-primary font-medium text-lg">
{{ currentTime }} {{ currentTime }}
</div> </div>

View File

@@ -55,10 +55,7 @@ provide(THEME_KEY, 'dark');
const max = computed(() => { const max = computed(() => {
if (!isLoading.value && dailyHoursTracked.value) { if (!isLoading.value && dailyHoursTracked.value) {
return Math.max( return Math.max(Math.max(...dailyHoursTracked.value.map((el) => el.duration)), 1);
Math.max(...dailyHoursTracked.value.map((el) => el.duration)),
1
);
} else { } else {
return 1; return 1;
} }
@@ -97,10 +94,7 @@ const option = computed(() => {
}, },
range: [ range: [
dayjs().format('YYYY-MM-DD'), dayjs().format('YYYY-MM-DD'),
getDayJsInstance()() getDayJsInstance()().subtract(50, 'day').startOf('week').format('YYYY-MM-DD'),
.subtract(50, 'day')
.startOf('week')
.format('YYYY-MM-DD'),
], ],
itemStyle: { itemStyle: {
color: 'transparent', color: 'transparent',
@@ -112,9 +106,7 @@ const option = computed(() => {
series: { series: {
type: 'heatmap', type: 'heatmap',
coordinateSystem: 'calendar', coordinateSystem: 'calendar',
data: data: dailyHoursTracked?.value?.map((el) => [el.date, el.duration]) ?? [],
dailyHoursTracked?.value?.map((el) => [el.date, el.duration]) ??
[],
itemStyle: { itemStyle: {
borderRadius: 5, borderRadius: 5,
borderColor: borderColor.value, borderColor: borderColor.value,
@@ -159,9 +151,7 @@ const option = computed(() => {
:option="option" :option="option"
style="height: 260px; background-color: transparent" /> style="height: 260px; background-color: transparent" />
</div> </div>
<div v-else class="text-center text-gray-500 py-8"> <div v-else class="text-center text-gray-500 py-8">No activity data available</div>
No activity data available
</div>
</div> </div>
</DashboardCard> </DashboardCard>
</template> </template>

View File

@@ -10,19 +10,21 @@ const props = defineProps<{
const accentColor = useCssVariable('--theme-color-chart'); const accentColor = useCssVariable('--theme-color-chart');
const markLineColor = useCssVariable('--color-border-secondary'); const markLineColor = useCssVariable('--color-border-secondary');
const seriesData = computed(() => props.history.map((el) => { const seriesData = computed(() =>
return { props.history.map((el) => {
value: el, return {
...{ value: el,
itemStyle: { ...{
borderWidth: 1, itemStyle: {
borderColor: 'rgba(' + accentColor.value + ',0.8)', borderWidth: 1,
borderRadius: [2, 2, 0, 0], borderColor: 'rgba(' + accentColor.value + ',0.8)',
color: 'rgba(' + accentColor.value + ',0.8)', borderRadius: [2, 2, 0, 0],
color: 'rgba(' + accentColor.value + ',0.8)',
},
}, },
}, };
}; })
})); );
const option = computed(() => ({ const option = computed(() => ({
grid: { grid: {
top: 0, top: 0,

View File

@@ -1,9 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import DayOverviewCardChart from '@/Components/Dashboard/DayOverviewCardChart.vue'; import DayOverviewCardChart from '@/Components/Dashboard/DayOverviewCardChart.vue';
import { import { formatHumanReadableDate, formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
formatHumanReadableDate,
formatHumanReadableDuration,
} from '@/packages/ui/src/utils/time';
import { inject, type ComputedRef } from 'vue'; import { inject, type ComputedRef } from 'vue';
import type { Organization } from '@/packages/api/src'; import type { Organization } from '@/packages/api/src';
@@ -17,8 +14,7 @@ defineProps<{
</script> </script>
<template> <template>
<div <div class="px-3.5 py-2 flex justify-between @container border-b border-b-background-separator">
class="px-3.5 py-2 flex justify-between @container border-b border-b-background-separator">
<div class="flex items-center min-w-[70px]"> <div class="flex items-center min-w-[70px]">
<p class="font-medium text-sm text-text-primary"> <p class="font-medium text-sm text-text-primary">
{{ formatHumanReadableDate(date) }} {{ formatHumanReadableDate(date) }}

View File

@@ -1,35 +1,33 @@
<script setup lang="ts"> <script setup lang="ts">
import { useQuery } from "@tanstack/vue-query"; import { useQuery } from '@tanstack/vue-query';
import { computed } from "vue"; import { computed } from 'vue';
import DashboardCard from "@/Components/Dashboard/DashboardCard.vue"; import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
import DayOverviewCardEntry from "@/Components/Dashboard/DayOverviewCardEntry.vue"; import DayOverviewCardEntry from '@/Components/Dashboard/DayOverviewCardEntry.vue';
import { CalendarIcon } from "@heroicons/vue/20/solid"; import { CalendarIcon } from '@heroicons/vue/20/solid';
import { getCurrentOrganizationId } from "@/utils/useUser"; import { getCurrentOrganizationId } from '@/utils/useUser';
import { api } from "@/packages/api/src"; import { api } from '@/packages/api/src';
import { LoadingSpinner } from "@/packages/ui/src"; import { LoadingSpinner } from '@/packages/ui/src';
// Get the organization ID using the utility function // Get the organization ID using the utility function
const organizationId = computed(() => getCurrentOrganizationId()); const organizationId = computed(() => getCurrentOrganizationId());
// Set up the query // Set up the query
const { data: last7Days, isLoading } = useQuery({ const { data: last7Days, isLoading } = useQuery({
queryKey: ["lastSevenDays", organizationId], queryKey: ['lastSevenDays', organizationId],
queryFn: () => { queryFn: () => {
return api.lastSevenDays({ return api.lastSevenDays({
params: { params: {
organization: organizationId.value! organization: organizationId.value!,
} },
}); });
}, },
enabled: computed(() => !!organizationId.value), enabled: computed(() => !!organizationId.value),
placeholderData: Array.from({ length: 7 }, (_, i) => ({ placeholderData: Array.from({ length: 7 }, (_, i) => ({
date: new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString().split("T")[0], date: new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
duration: 0, duration: 0,
history: Array(8).fill(0) history: Array(8).fill(0),
})) })),
}); });
</script> </script>
<template> <template>
@@ -46,8 +44,6 @@ const { data: last7Days, isLoading } = useQuery({
:history="day.history" :history="day.history"
:duration="day.duration"></DayOverviewCardEntry> :duration="day.duration"></DayOverviewCardEntry>
</div> </div>
<div v-else class="text-center text-gray-500 py-8"> <div v-else class="text-center text-gray-500 py-8">No data available</div>
No data available
</div>
</DashboardCard> </DashboardCard>
</template> </template>

View File

@@ -12,16 +12,9 @@ import {
} from 'echarts/components'; } from 'echarts/components';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time'; import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { useCssVariable } from '@/utils/useCssVariable'; import { useCssVariable } from '@/utils/useCssVariable';
import type { Organization } from "@/packages/api/src"; import type { Organization } from '@/packages/api/src';
use([ use([CanvasRenderer, PieChart, TitleComponent, GridComponent, TooltipComponent, LegendComponent]);
CanvasRenderer,
PieChart,
TitleComponent,
GridComponent,
TooltipComponent,
LegendComponent,
]);
provide(THEME_KEY, 'dark'); provide(THEME_KEY, 'dark');
const labelColor = useCssVariable('--color-text-secondary'); const labelColor = useCssVariable('--color-text-secondary');
@@ -72,7 +65,11 @@ const option = computed(() => ({
}, },
tooltip: { tooltip: {
valueFormatter: (value: number) => { valueFormatter: (value: number) => {
return formatHumanReadableDuration(value, organization?.value?.interval_format, organization?.value?.number_format); return formatHumanReadableDuration(
value,
organization?.value?.interval_format,
organization?.value?.number_format
);
}, },
}, },
data: seriesData, data: seriesData,

View File

@@ -1,15 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { useQuery } from "@tanstack/vue-query"; import { useQuery } from '@tanstack/vue-query';
import { computed } from "vue"; import { computed } from 'vue';
import RecentlyTrackedTasksCardEntry from "@/Components/Dashboard/RecentlyTrackedTasksCardEntry.vue"; import RecentlyTrackedTasksCardEntry from '@/Components/Dashboard/RecentlyTrackedTasksCardEntry.vue';
import DashboardCard from "@/Components/Dashboard/DashboardCard.vue"; import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
import { CheckCircleIcon } from "@heroicons/vue/20/solid"; import { CheckCircleIcon } from '@heroicons/vue/20/solid';
import SecondaryButton from "@/packages/ui/src/Buttons/SecondaryButton.vue"; import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { PlusCircleIcon } from "@heroicons/vue/24/solid"; import { PlusCircleIcon } from '@heroicons/vue/24/solid';
import { router } from "@inertiajs/vue3"; import { router } from '@inertiajs/vue3';
import { getCurrentMembershipId, getCurrentOrganizationId } from "@/utils/useUser"; import { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser';
import { api } from "@/packages/api/src"; import { api } from '@/packages/api/src';
import { LoadingSpinner } from "@/packages/ui/src"; import { LoadingSpinner } from '@/packages/ui/src';
// Get the organization ID using the utility function // Get the organization ID using the utility function
const organizationId = computed(() => getCurrentOrganizationId()); const organizationId = computed(() => getCurrentOrganizationId());
@@ -17,19 +17,23 @@ const organizationId = computed(() => getCurrentOrganizationId());
// Function to fetch latest tasks using the API client // Function to fetch latest tasks using the API client
// Set up the query // Set up the query
const { data: timeEntriesResponse, isLoading, refetch } = useQuery({ const {
queryKey: ["timeEntries", organizationId], data: timeEntriesResponse,
isLoading,
refetch,
} = useQuery({
queryKey: ['timeEntries', organizationId],
queryFn: () => { queryFn: () => {
return api.getTimeEntries({ return api.getTimeEntries({
params: { params: {
organization: organizationId.value! organization: organizationId.value!,
}, },
queries: { queries: {
member_id: getCurrentMembershipId() member_id: getCurrentMembershipId(),
} },
}); });
}, },
enabled: computed(() => !!organizationId.value) enabled: computed(() => !!organizationId.value),
}); });
const latestTasks = computed(() => { const latestTasks = computed(() => {
@@ -45,21 +49,26 @@ const filteredLatestTasks = computed(() => {
const finishedTimeEntries = latestTasks.value.filter((item) => item.end !== null); const finishedTimeEntries = latestTasks.value.filter((item) => item.end !== null);
// filter out duplicates based on description, task, project, tags and billable // filter out duplicates based on description, task, project, tags and billable
return finishedTimeEntries.filter((item, index, self) => { return finishedTimeEntries
return index === self.findIndex((t) => ( .filter((item, index, self) => {
t.description === item.description && return (
t.task_id === item.task_id && index ===
t.project_id === item.project_id && self.findIndex(
t.tags.length === item.tags.length && (t) =>
t.tags.every((tag) => item.tags.includes(tag)) && t.description === item.description &&
t.billable === item.billable t.task_id === item.task_id &&
)); t.project_id === item.project_id &&
}).slice(0, 4); t.tags.length === item.tags.length &&
t.tags.every((tag) => item.tags.includes(tag)) &&
t.billable === item.billable
)
);
})
.slice(0, 4);
}); });
// Listen for dashboard refresh events // Listen for dashboard refresh events
window.addEventListener("dashboard:refresh", () => { window.addEventListener('dashboard:refresh', () => {
refetch(); refetch();
}); });
</script> </script>
@@ -74,20 +83,17 @@ window.addEventListener("dashboard:refresh", () => {
v-for="lastTask in filteredLatestTasks" v-for="lastTask in filteredLatestTasks"
:key="lastTask.id" :key="lastTask.id"
:time-entry="lastTask" :time-entry="lastTask"
:class="filteredLatestTasks.length === 4 ? 'last:border-0' : ''"></RecentlyTrackedTasksCardEntry> :class="
filteredLatestTasks.length === 4 ? 'last:border-0' : ''
"></RecentlyTrackedTasksCardEntry>
</div> </div>
<div <div v-else class="text-center flex flex-1 justify-center items-center">
v-else
class="text-center flex flex-1 justify-center items-center">
<div> <div>
<PlusCircleIcon <PlusCircleIcon class="w-8 text-icon-default inline pb-2"></PlusCircleIcon>
class="w-8 text-icon-default inline pb-2"></PlusCircleIcon> <h3 class="text-text-primary font-semibold text-sm">No recent tasks found</h3>
<h3 class="text-text-primary font-semibold text-sm">
No recent tasks found
</h3>
<p class="pb-5 text-sm">Create tasks inside of a project!</p> <p class="pb-5 text-sm">Create tasks inside of a project!</p>
<SecondaryButton @click="router.visit(route('projects'))" <SecondaryButton @click="router.visit(route('projects'))"
>Go to Projects >Go to Projects
</SecondaryButton> </SecondaryButton>
</div> </div>
</div> </div>
@@ -95,12 +101,11 @@ window.addEventListener("dashboard:refresh", () => {
v-if="latestTasks && latestTasks.length === 1" v-if="latestTasks && latestTasks.length === 1"
class="text-center flex flex-1 justify-center items-center text-sm"> class="text-center flex flex-1 justify-center items-center text-sm">
<div> <div>
<PlusCircleIcon <PlusCircleIcon class="w-8 text-icon-default inline pb-2"></PlusCircleIcon>
class="w-8 text-icon-default inline pb-2"></PlusCircleIcon>
<h3 class="text-text-primary font-semibold">Add more tasks</h3> <h3 class="text-text-primary font-semibold">Add more tasks</h3>
<p class="pb-5">Create tasks inside of a project!</p> <p class="pb-5">Create tasks inside of a project!</p>
<SecondaryButton @click="router.visit(route('projects'))" <SecondaryButton @click="router.visit(route('projects'))"
>Go to Projects >Go to Projects
</SecondaryButton> </SecondaryButton>
</div> </div>
</div> </div>

View File

@@ -6,12 +6,12 @@ import { storeToRefs } from 'pinia';
import { computed } from 'vue'; import { computed } from 'vue';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry'; import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { getDayJsInstance } from '@/packages/ui/src/utils/time'; import { getDayJsInstance } from '@/packages/ui/src/utils/time';
import type { TimeEntry } from "@/packages/api/src"; import type { TimeEntry } from '@/packages/api/src';
import { useTasksStore } from "@/utils/useTasks"; import { useTasksStore } from '@/utils/useTasks';
import { ChevronRightIcon } from "@heroicons/vue/16/solid"; import { ChevronRightIcon } from '@heroicons/vue/16/solid';
const props = defineProps<{ const props = defineProps<{
timeEntry: TimeEntry timeEntry: TimeEntry;
}>(); }>();
const { projects } = storeToRefs(useProjectsStore()); const { projects } = storeToRefs(useProjectsStore());
@@ -20,7 +20,7 @@ const project = computed(() => {
return projects.value.find((project) => project.id === props.timeEntry.project_id); return projects.value.find((project) => project.id === props.timeEntry.project_id);
}); });
const {tasks} = storeToRefs(useTasksStore()); const { tasks } = storeToRefs(useTasksStore());
const task = computed(() => { const task = computed(() => {
return tasks.value.find((task) => task.id === props.timeEntry.task_id); return tasks.value.find((task) => task.id === props.timeEntry.task_id);
@@ -42,39 +42,31 @@ async function startTaskTimer() {
await setActiveState(true); await setActiveState(true);
useCurrentTimeEntryStore().fetchCurrentTimeEntry(); useCurrentTimeEntryStore().fetchCurrentTimeEntry();
} }
</script> </script>
<template> <template>
<div <div class="px-3.5 py-2 grid grid-cols-5 border-b border-b-background-separator">
class="px-3.5 py-2 grid grid-cols-5 border-b border-b-background-separator">
<div class="col-span-4"> <div class="col-span-4">
<p class="font-medium text-text-primary text-sm pb-1 truncate"> <p class="font-medium text-text-primary text-sm pb-1 truncate">
<span v-if="timeEntry.description"> {{ timeEntry.description }}</span> <span v-if="timeEntry.description"> {{ timeEntry.description }}</span>
<span v-else class="text-text-tertiary">No description</span> <span v-else class="text-text-tertiary">No description</span>
</p> </p>
<ProjectBadge <ProjectBadge size="base" class="min-w-0 max-w-full" :color="project?.color">
size="base"
class="min-w-0 max-w-full"
:color="project?.color">
<div class="flex items-center lg:space-x-0.5 min-w-0"> <div class="flex items-center lg:space-x-0.5 min-w-0">
<span class="whitespace-nowrap "> <span class="whitespace-nowrap">
{{ project?.name ?? 'No Project' }} {{ project?.name ?? 'No Project' }}
</span> </span>
<ChevronRightIcon <ChevronRightIcon
v-if="task" v-if="task"
class="w-4 text-text-secondary shrink-0"></ChevronRightIcon> class="w-4 text-text-secondary shrink-0"></ChevronRightIcon>
<div <div v-if="task" class="min-w-0 shrink truncate">
v-if="task"
class="min-w-0 shrink truncate">
{{ task.name }} {{ task.name }}
</div> </div>
</div> </div>
</ProjectBadge> </ProjectBadge>
</div> </div>
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<TimeTrackerStartStop <TimeTrackerStartStop @changed="startTaskTimer"></TimeTrackerStartStop>
@changed="startTaskTimer"></TimeTrackerStartStop>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -7,7 +7,7 @@ import { UserGroupIcon } from '@heroicons/vue/20/solid';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue'; import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
import { api } from '@/packages/api/src'; import { api } from '@/packages/api/src';
import { LoadingSpinner } from "@/packages/ui/src"; import { LoadingSpinner } from '@/packages/ui/src';
import { router } from '@inertiajs/vue3'; import { router } from '@inertiajs/vue3';
// Get the organization ID using the utility function // Get the organization ID using the utility function
@@ -15,17 +15,16 @@ const organizationId = computed(() => getCurrentOrganizationId());
// Set up the query // Set up the query
const { data: latestTeamActivity, isLoading } = useQuery({ const { data: latestTeamActivity, isLoading } = useQuery({
queryKey: ['latestTeamActivity', organizationId], queryKey: ['latestTeamActivity', organizationId],
queryFn: () => { queryFn: () => {
return api.latestTeamActivity({ return api.latestTeamActivity({
params: { params: {
organization: organizationId.value! organization: organizationId.value!,
} },
}) });
}, },
enabled: computed(() => !!organizationId.value), enabled: computed(() => !!organizationId.value),
}); });
</script> </script>
<template> <template>
@@ -42,18 +41,13 @@ const { data: latestTeamActivity, isLoading } = useQuery({
:description="activity.description" :description="activity.description"
:working="activity.status"></TeamActivityCardEntry> :working="activity.status"></TeamActivityCardEntry>
</div> </div>
<div v-else class="text-center text-gray-500 py-8"> <div v-else class="text-center text-gray-500 py-8">No team activity found</div>
No team activity found
</div>
<div <div
v-if="latestTeamActivity && latestTeamActivity.length <= 1" v-if="latestTeamActivity && latestTeamActivity.length <= 1"
class="text-center flex flex-1 justify-center items-center"> class="text-center flex flex-1 justify-center items-center">
<div> <div>
<UserGroupIcon <UserGroupIcon class="w-8 text-icon-default inline pb-2"></UserGroupIcon>
class="w-8 text-icon-default inline pb-2"></UserGroupIcon> <h3 class="text-text-primary font-semibold text-sm">Invite your co-workers</h3>
<h3 class="text-text-primary font-semibold text-sm">
Invite your co-workers
</h3>
<p class="pb-5 text-sm">You can invite your entire team.</p> <p class="pb-5 text-sm">You can invite your entire team.</p>
<SecondaryButton @click="router.visit(route('members'))" <SecondaryButton @click="router.visit(route('members'))"
>Go to Members >Go to Members

View File

@@ -13,20 +13,13 @@ defineProps<{
<p class="font-semibold text-sm text-text-primary"> <p class="font-semibold text-sm text-text-primary">
{{ name }} {{ name }}
</p> </p>
<div <div v-if="working" class="flex space-x-1.5 items-center justify-end">
v-if="working" <span class="relative flex h-3 w-3 justify-center items-center">
class="flex space-x-1.5 items-center justify-end">
<span
class="relative flex h-3 w-3 justify-center items-center">
<span <span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span> class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
<span <span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
<span
class="text-green-500 font-medium text-sm block pb-0.5">
working
</span> </span>
<span class="text-green-500 font-medium text-sm block pb-0.5"> working </span>
</div> </div>
</div> </div>
<div <div

View File

@@ -24,14 +24,7 @@ import { useQuery } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
import { api, type Organization } from '@/packages/api/src'; import { api, type Organization } from '@/packages/api/src';
use([ use([CanvasRenderer, BarChart, TitleComponent, GridComponent, TooltipComponent, LegendComponent]);
CanvasRenderer,
BarChart,
TitleComponent,
GridComponent,
TooltipComponent,
LegendComponent,
]);
provide(THEME_KEY, 'dark'); provide(THEME_KEY, 'dark');
@@ -246,15 +239,8 @@ const option = computed(() => {
<div <div
class="grid space-y-5 sm:space-y-0 sm:gap-x-6 xl:gap-x-6 grid-cols-1 lg:grid-cols-3 xl:grid-cols-4"> class="grid space-y-5 sm:space-y-0 sm:gap-x-6 xl:gap-x-6 grid-cols-1 lg:grid-cols-3 xl:grid-cols-4">
<div class="col-span-2 xl:col-span-3"> <div class="col-span-2 xl:col-span-3">
<CardTitle <CardTitle title="This Week" class="pb-8" :icon="ClockIcon"></CardTitle>
title="This Week" <v-chart v-if="weeklyHistory" :autoresize="true" class="chart" :option="option" />
class="pb-8"
:icon="ClockIcon"></CardTitle>
<v-chart
v-if="weeklyHistory"
:autoresize="true"
class="chart"
:option="option" />
</div> </div>
<div class="space-y-6"> <div class="space-y-6">
<StatCard <StatCard
@@ -294,9 +280,7 @@ const option = computed(() => {
" /> " />
<ProjectsChartCard <ProjectsChartCard
v-if="weeklyProjectOverview" v-if="weeklyProjectOverview"
:weekly-project-overview=" :weekly-project-overview="weeklyProjectOverview"></ProjectsChartCard>
weeklyProjectOverview
"></ProjectsChartCard>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -22,11 +22,7 @@ const hasActions = computed(() => !!useSlots().actions);
<form @submit.prevent="$emit('submitted')"> <form @submit.prevent="$emit('submitted')">
<div <div
class="px-4 py-5 bg-card-background sm:p-6 shadow" class="px-4 py-5 bg-card-background sm:p-6 shadow"
:class=" :class="hasActions ? 'sm:rounded-tl-md sm:rounded-tr-md' : 'sm:rounded-md'">
hasActions
? 'sm:rounded-tl-md sm:rounded-tr-md'
: 'sm:rounded-md'
">
<div class="grid grid-cols-6 gap-6"> <div class="grid grid-cols-6 gap-6">
<slot name="form" /> <slot name="form" />
</div> </div>

View File

@@ -1,11 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { type Component } from 'vue'; import { type Component } from 'vue';
import NavigationSidebarLink from '@/Components/NavigationSidebarLink.vue'; import NavigationSidebarLink from '@/Components/NavigationSidebarLink.vue';
import { import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from 'radix-vue';
CollapsibleContent,
CollapsibleRoot,
CollapsibleTrigger,
} from 'radix-vue';
import { useSessionStorage } from '@vueuse/core'; import { useSessionStorage } from '@vueuse/core';
import { ChevronRightIcon } from '@heroicons/vue/20/solid'; import { ChevronRightIcon } from '@heroicons/vue/20/solid';
@@ -14,7 +10,7 @@ const props = defineProps<{
icon?: Component; icon?: Component;
current?: boolean; current?: boolean;
href: string; href: string;
subItems?: { title: string; route: string, show: boolean }[]; subItems?: { title: string; route: string; show: boolean }[];
}>(); }>();
const open = useSessionStorage('nav-collapse-state-' + props.title, true); const open = useSessionStorage('nav-collapse-state-' + props.title, true);
@@ -69,9 +65,7 @@ const open = useSessionStorage('nav-collapse-state-' + props.title, true);
v-if="subItem.show" v-if="subItem.show"
:title="subItem.title" :title="subItem.title"
:current="route().current(subItem.route)" :current="route().current(subItem.route)"
:href=" :href="route(subItem.route)"></NavigationSidebarLink>
route(subItem.route)
"></NavigationSidebarLink>
</li> </li>
</ul> </ul>
</div> </div>

View File

@@ -22,9 +22,7 @@ defineProps<{
:is="icon" :is="icon"
v-if="icon" v-if="icon"
:class="[ :class="[
current current ? 'text-icon-active' : 'text-icon-default group-hover:text-icon-active',
? 'text-icon-active'
: 'text-icon-default group-hover:text-icon-active',
'transition h-4 w-4 shrink-0', 'transition h-4 w-4 shrink-0',
]" ]"
aria-hidden="true" /> aria-hidden="true" />

View File

@@ -26,34 +26,23 @@
<div class="max-w-sm text-center mx-auto py-4 text-base"> <div class="max-w-sm text-center mx-auto py-4 text-base">
<p class="py-1"> <p class="py-1">
Your organization is currently Your organization is currently
<strong class="font-semibold" <strong class="font-semibold">blocked from performing this action</strong>
>blocked from performing this action</strong
>
</p> </p>
<p class="py-1"> <p class="py-1">
To unblock your organization, please To unblock your organization, please
<strong class="font-semibold"> <strong class="font-semibold"> upgrade to a premium plan</strong>
upgrade to a premium plan</strong
>
or remove all users except the owner. or remove all users except the owner.
</p> </p>
<Link <Link v-if="isBillingActivated() && canManageBilling()" href="/billing">
v-if="isBillingActivated() && canManageBilling()" <PrimaryButton :icon="CreditCardIcon" type="button" class="mt-6">
href="/billing">
<PrimaryButton
:icon="CreditCardIcon"
type="button"
class="mt-6">
Go to Billing Go to Billing
</PrimaryButton> </PrimaryButton>
</Link> </Link>
</div> </div>
</template> </template>
<template #footer> <template #footer>
<SecondaryButton @click="showActionBlockedModal = false"> <SecondaryButton @click="showActionBlockedModal = false"> Cancel</SecondaryButton>
Cancel</SecondaryButton
>
</template> </template>
</DialogModal> </DialogModal>
</template> </template>
@@ -70,7 +59,5 @@ import { Link } from '@inertiajs/vue3';
import PrimaryButton from '../packages/ui/src/Buttons/PrimaryButton.vue'; import PrimaryButton from '../packages/ui/src/Buttons/PrimaryButton.vue';
import SecondaryButton from '../packages/ui/src/Buttons/SecondaryButton.vue'; import SecondaryButton from '../packages/ui/src/Buttons/SecondaryButton.vue';
const { notifications, showActionBlockedModal } = storeToRefs( const { notifications, showActionBlockedModal } = storeToRefs(useNotificationsStore());
useNotificationsStore()
);
</script> </script>

Some files were not shown because too many files have changed in this diff Show More