mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02:15 +01:00
add format check, update prettier rules, apply rules consistently
This commit is contained in:
23
.github/workflows/npm-format-check.yml
vendored
Normal file
23
.github/workflows/npm-format-check.yml
vendored
Normal 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
27
.prettierignore
Normal 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
|
||||||
@@ -3,5 +3,6 @@
|
|||||||
"tabWidth": 4,
|
"tabWidth": 4,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"bracketSameLine": true,
|
"bracketSameLine": true,
|
||||||
"quoteProps": "preserve"
|
"quoteProps": "preserve",
|
||||||
|
"printWidth": 100
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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
|
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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)
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,4 +14,4 @@ export function formatCentsWithOrganizationDefaults(
|
|||||||
currencySymbol,
|
currencySymbol,
|
||||||
'point-comma' as NumberFormat
|
'point-comma' as NumberFormat
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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';
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
|||||||
import Checkbox from '@/packages/ui/src/Input/Checkbox.vue';
|
import Checkbox from '@/packages/ui/src/Input/Checkbox.vue';
|
||||||
import { useNotificationsStore } from '@/utils/notification';
|
import { useNotificationsStore } from '@/utils/notification';
|
||||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
|
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
|
||||||
import InputError from '@/packages/ui/src/Input/InputError.vue';
|
import InputError from '@/packages/ui/src/Input/InputError.vue';
|
||||||
import { useMembersStore } from '@/utils/useMembers';
|
import { useMembersStore } from '@/utils/useMembers';
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ const deleteMutation = useMutation({
|
|||||||
if (!organizationId) {
|
if (!organizationId) {
|
||||||
throw new Error('No organization ID found');
|
throw new Error('No organization ID found');
|
||||||
}
|
}
|
||||||
|
|
||||||
return api.removeMember(undefined, {
|
return api.removeMember(undefined, {
|
||||||
params: {
|
params: {
|
||||||
member: props.member.id,
|
member: props.member.id,
|
||||||
@@ -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>
|
||||||
@@ -148,4 +148,4 @@ class="mt-6" @submit="
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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()"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -2,20 +2,20 @@
|
|||||||
import { Switch } from '@/Components/ui/switch';
|
import { Switch } from '@/Components/ui/switch';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
|
||||||
import { Button } from '@/Components/ui/button';
|
import { Button } from '@/Components/ui/button';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
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 {
|
||||||
NumberField,
|
NumberField,
|
||||||
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,26 +171,31 @@ 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>
|
||||||
<InputLabel for="rounding-type" value="Rounding Type" class="mb-2" />
|
<InputLabel for="rounding-type" value="Rounding Type" class="mb-2" />
|
||||||
<Select
|
<Select
|
||||||
: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>
|
||||||
@@ -197,23 +207,27 @@ const iconClass = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<InputLabel for="minutes-interval" value="Minutes Interval" class="mb-2" />
|
<InputLabel for="minutes-interval" value="Minutes Interval" class="mb-2" />
|
||||||
<Select
|
<Select
|
||||||
: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
|
||||||
v-for="interval in predefinedIntervals"
|
v-for="interval in predefinedIntervals"
|
||||||
:key="interval.value"
|
:key="interval.value"
|
||||||
:value="interval.value">
|
:value="interval.value">
|
||||||
{{ interval.label }}
|
{{ interval.label }}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<div v-if="showCustomInput" class="mt-2">
|
<div v-if="showCustomInput" class="mt-2">
|
||||||
<NumberField
|
<NumberField
|
||||||
id="custom-minutes"
|
id="custom-minutes"
|
||||||
@@ -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>
|
||||||
@@ -235,4 +251,4 @@ const iconClass = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ?? '--' }}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) }}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user