mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 08:12:17 +01:00
Compare commits
1 Commits
feature/ca
...
feature/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68e369811c |
508
e2e/shared-reports-public.spec.ts
Normal file
508
e2e/shared-reports-public.spec.ts
Normal file
@@ -0,0 +1,508 @@
|
||||
import { expect, Page, Browser } from '@playwright/test';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
import { test } from '../playwright/fixtures';
|
||||
|
||||
async function goToSharedReports(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/reporting/shared');
|
||||
}
|
||||
|
||||
async function goToReporting(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/reporting');
|
||||
}
|
||||
|
||||
async function createTimeEntryWithProject(page: Page, projectName: string, duration: string) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||
await page.getByLabel('Project Name').fill(projectName);
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Create Project' }).click();
|
||||
await page.getByText(projectName).waitFor({ state: 'visible' });
|
||||
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||
await page.getByTestId('time_entry_description').fill(`Time entry for ${projectName}`);
|
||||
await page.getByRole('button', { name: 'No Project' }).click();
|
||||
await page.getByText(projectName).click();
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Create Time Entry' }).click(),
|
||||
page.waitForResponse(
|
||||
(response) => response.url().includes('/time-entries') && response.status() === 201
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
async function createTimeEntryWithTag(page: Page, tagName: string, duration: string) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||
await page.getByTestId('time_entry_description').fill(`Time entry with tag ${tagName}`);
|
||||
await page.getByRole('button', { name: 'Tags' }).click();
|
||||
await page.getByText('Create new tag').click();
|
||||
await page.getByPlaceholder('Tag Name').fill(tagName);
|
||||
await page.getByRole('button', { name: 'Create Tag' }).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
|
||||
await page.getByRole('button', { name: 'Create Time Entry' }).click();
|
||||
}
|
||||
|
||||
async function createTimeEntryWithBillableStatus(
|
||||
page: Page,
|
||||
isBillable: boolean,
|
||||
duration: string
|
||||
) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||
await page
|
||||
.getByTestId('time_entry_description')
|
||||
.fill(`Time entry ${isBillable ? 'billable' : 'non-billable'}`);
|
||||
await page.getByRole('button', { name: 'Non-Billable' }).click();
|
||||
if (!isBillable) {
|
||||
await page.getByRole('option', { name: 'Non Billable', exact: true }).click();
|
||||
} else {
|
||||
await page.getByRole('option', { name: 'Billable', exact: true }).click();
|
||||
}
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
|
||||
await page.getByRole('button', { name: 'Create Time Entry' }).click();
|
||||
}
|
||||
|
||||
async function createReport(
|
||||
page: Page,
|
||||
reportName: string,
|
||||
options: {
|
||||
projectFilter?: string;
|
||||
tagFilter?: string;
|
||||
billableFilter?: 'billable' | 'non-billable' | 'all';
|
||||
timeRange?: { start: string; end: string };
|
||||
} = {}
|
||||
) {
|
||||
await goToReporting(page);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Apply filters if specified
|
||||
if (options.projectFilter) {
|
||||
await page.getByRole('button', { name: 'Project' }).nth(0).click();
|
||||
await page.getByText(options.projectFilter).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/aggregate') && response.status() === 200
|
||||
);
|
||||
}
|
||||
|
||||
if (options.tagFilter) {
|
||||
await page.getByRole('button', { name: 'Tags' }).click();
|
||||
await page.getByText(options.tagFilter).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/aggregate') && response.status() === 200
|
||||
);
|
||||
}
|
||||
|
||||
if (options.billableFilter && options.billableFilter !== 'all') {
|
||||
await page.getByRole('button', { name: 'Billable' }).click();
|
||||
if (options.billableFilter === 'billable') {
|
||||
await page.getByRole('option', { name: 'Billable', exact: true }).click();
|
||||
} else {
|
||||
await page.getByRole('option', { name: 'Non Billable', exact: true }).click();
|
||||
}
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/aggregate') && response.status() === 200
|
||||
);
|
||||
}
|
||||
|
||||
// Set custom time range if specified
|
||||
if (options.timeRange) {
|
||||
await page.getByRole('button', { name: 'This Week' }).click();
|
||||
await page.getByRole('option', { name: 'Custom Range' }).click();
|
||||
await page.locator('input[name="startDate"]').fill(options.timeRange.start);
|
||||
await page.locator('input[name="endDate"]').fill(options.timeRange.end);
|
||||
await page.getByRole('button', { name: 'Apply' }).click();
|
||||
await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/aggregate') && response.status() === 200
|
||||
);
|
||||
}
|
||||
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Save the report
|
||||
await page.getByRole('button', { name: 'Save Report' }).click();
|
||||
await page.getByLabel('Report Name').fill(reportName);
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Create Report' }).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
async function makeReportPublic(page: Page, reportName: string): Promise<string> {
|
||||
await goToSharedReports(page);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Find the report row and click the edit button
|
||||
const reportRow = page.locator('tr').filter({ hasText: reportName });
|
||||
await reportRow.getByRole('button', { name: 'Edit' }).click();
|
||||
|
||||
// Make the report public
|
||||
await page.getByRole('switch', { name: 'Make report public' }).click();
|
||||
|
||||
// Wait for the API response
|
||||
await page.waitForResponse(
|
||||
(response) => response.url().includes('/reports/') && response.status() === 200
|
||||
);
|
||||
|
||||
// Save the changes
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Get the public URL
|
||||
const copyButton = reportRow.getByRole('button', { name: 'Copy URL' });
|
||||
await copyButton.click();
|
||||
|
||||
// Extract the URL from clipboard or from the button's data attribute
|
||||
const publicUrl = await page.evaluate(() => navigator.clipboard.readText());
|
||||
|
||||
return publicUrl;
|
||||
}
|
||||
|
||||
async function createUnauthenticatedPage(browser: Browser): Promise<Page> {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
return page;
|
||||
}
|
||||
|
||||
test('access public shared report without authentication', async ({ page, browser }) => {
|
||||
const projectName = 'Public Access Project ' + Math.floor(Math.random() * 10000);
|
||||
const reportName = 'Public Access Report ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
// Create test data with authenticated user
|
||||
await createTimeEntryWithProject(page, projectName, '2h 30min');
|
||||
|
||||
// Create and make report public
|
||||
await createReport(page, reportName, { projectFilter: projectName });
|
||||
const publicUrl = await makeReportPublic(page, reportName);
|
||||
|
||||
// Create unauthenticated page
|
||||
const unauthenticatedPage = await createUnauthenticatedPage(browser);
|
||||
|
||||
// Access the public report URL
|
||||
await unauthenticatedPage.goto(publicUrl);
|
||||
await unauthenticatedPage.waitForLoadState('networkidle');
|
||||
|
||||
// Verify the report is accessible and displays data
|
||||
await expect(unauthenticatedPage.getByText(reportName)).toBeVisible();
|
||||
await expect(unauthenticatedPage.getByText(projectName)).toBeVisible();
|
||||
await expect(unauthenticatedPage.getByText('2h 30min')).toBeVisible();
|
||||
|
||||
// Verify no authentication elements are present
|
||||
await expect(unauthenticatedPage.getByRole('button', { name: 'Login' })).not.toBeVisible();
|
||||
await expect(unauthenticatedPage.getByRole('button', { name: 'Register' })).not.toBeVisible();
|
||||
|
||||
await unauthenticatedPage.close();
|
||||
});
|
||||
|
||||
test('access public shared report with project filter shows filtered data', async ({
|
||||
page,
|
||||
browser,
|
||||
}) => {
|
||||
const projectName = 'Filtered Project ' + Math.floor(Math.random() * 10000);
|
||||
const otherProjectName = 'Other Project ' + Math.floor(Math.random() * 10000);
|
||||
const reportName = 'Filtered Report ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
// Create test data for two projects
|
||||
await createTimeEntryWithProject(page, projectName, '1h 30min');
|
||||
await createTimeEntryWithProject(page, otherProjectName, '45min');
|
||||
|
||||
// Create and make report public with project filter
|
||||
await createReport(page, reportName, { projectFilter: projectName });
|
||||
const publicUrl = await makeReportPublic(page, reportName);
|
||||
|
||||
// Create unauthenticated page
|
||||
const unauthenticatedPage = await createUnauthenticatedPage(browser);
|
||||
|
||||
// Access the public report URL
|
||||
await unauthenticatedPage.goto(publicUrl);
|
||||
await unauthenticatedPage.waitForLoadState('networkidle');
|
||||
|
||||
// Verify only filtered project data is shown
|
||||
await expect(unauthenticatedPage.getByText(projectName)).toBeVisible();
|
||||
await expect(unauthenticatedPage.getByText(otherProjectName)).not.toBeVisible();
|
||||
await expect(unauthenticatedPage.getByText('1h 30min')).toBeVisible();
|
||||
await expect(unauthenticatedPage.getByText('45min')).not.toBeVisible();
|
||||
|
||||
await unauthenticatedPage.close();
|
||||
});
|
||||
|
||||
test('access public shared report with tag filter shows filtered data', async ({
|
||||
page,
|
||||
browser,
|
||||
}) => {
|
||||
const tagName = 'PublicTag' + Math.floor(Math.random() * 10000);
|
||||
const otherTagName = 'PrivateTag' + Math.floor(Math.random() * 10000);
|
||||
const reportName = 'Tag Filtered Report ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
// Create test data for two tags
|
||||
await createTimeEntryWithTag(page, tagName, '2h');
|
||||
await createTimeEntryWithTag(page, otherTagName, '1h');
|
||||
|
||||
// Create and make report public with tag filter
|
||||
await createReport(page, reportName, { tagFilter: tagName });
|
||||
const publicUrl = await makeReportPublic(page, reportName);
|
||||
|
||||
// Create unauthenticated page
|
||||
const unauthenticatedPage = await createUnauthenticatedPage(browser);
|
||||
|
||||
// Access the public report URL
|
||||
await unauthenticatedPage.goto(publicUrl);
|
||||
await unauthenticatedPage.waitForLoadState('networkidle');
|
||||
|
||||
// Verify only filtered tag data is shown
|
||||
await expect(unauthenticatedPage.getByText(tagName)).toBeVisible();
|
||||
await expect(unauthenticatedPage.getByText(otherTagName)).not.toBeVisible();
|
||||
await expect(unauthenticatedPage.getByText('2h 00min')).toBeVisible();
|
||||
await expect(unauthenticatedPage.getByText('1h 00min')).not.toBeVisible();
|
||||
|
||||
await unauthenticatedPage.close();
|
||||
});
|
||||
|
||||
test('access public shared report with billable filter shows filtered data', async ({
|
||||
page,
|
||||
browser,
|
||||
}) => {
|
||||
const reportName = 'Billable Filtered Report ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
// Create test data for billable and non-billable entries
|
||||
await createTimeEntryWithBillableStatus(page, true, '3h');
|
||||
await createTimeEntryWithBillableStatus(page, false, '1h 30min');
|
||||
|
||||
// Create and make report public with billable filter
|
||||
await createReport(page, reportName, { billableFilter: 'billable' });
|
||||
const publicUrl = await makeReportPublic(page, reportName);
|
||||
|
||||
// Create unauthenticated page
|
||||
const unauthenticatedPage = await createUnauthenticatedPage(browser);
|
||||
|
||||
// Access the public report URL
|
||||
await unauthenticatedPage.goto(publicUrl);
|
||||
await unauthenticatedPage.waitForLoadState('networkidle');
|
||||
|
||||
// Verify only billable data is shown
|
||||
await expect(unauthenticatedPage.getByText('3h 00min')).toBeVisible();
|
||||
await expect(unauthenticatedPage.getByText('1h 30min')).not.toBeVisible();
|
||||
|
||||
await unauthenticatedPage.close();
|
||||
});
|
||||
|
||||
test('access public shared report with custom time range shows filtered data', async ({
|
||||
page,
|
||||
browser,
|
||||
}) => {
|
||||
const projectName = 'TimeRange Project ' + Math.floor(Math.random() * 10000);
|
||||
const reportName = 'TimeRange Report ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
// Create test data
|
||||
await createTimeEntryWithProject(page, projectName, '2h 15min');
|
||||
|
||||
// Create and make report public with custom time range
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - 7);
|
||||
const endDate = new Date();
|
||||
|
||||
await createReport(page, reportName, {
|
||||
projectFilter: projectName,
|
||||
timeRange: {
|
||||
start: startDate.toISOString().split('T')[0],
|
||||
end: endDate.toISOString().split('T')[0],
|
||||
},
|
||||
});
|
||||
const publicUrl = await makeReportPublic(page, reportName);
|
||||
|
||||
// Create unauthenticated page
|
||||
const unauthenticatedPage = await createUnauthenticatedPage(browser);
|
||||
|
||||
// Access the public report URL
|
||||
await unauthenticatedPage.goto(publicUrl);
|
||||
await unauthenticatedPage.waitForLoadState('networkidle');
|
||||
|
||||
// Verify the data is shown within the time range
|
||||
await expect(unauthenticatedPage.getByText(projectName)).toBeVisible();
|
||||
await expect(unauthenticatedPage.getByText('2h 15min')).toBeVisible();
|
||||
|
||||
await unauthenticatedPage.close();
|
||||
});
|
||||
|
||||
test('access public shared report with multiple filters shows correctly filtered data', async ({
|
||||
page,
|
||||
browser,
|
||||
}) => {
|
||||
const projectName = 'MultiFilter Project ' + Math.floor(Math.random() * 10000);
|
||||
const tagName = 'MultiTag' + Math.floor(Math.random() * 10000);
|
||||
const reportName = 'MultiFilter Report ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
// Create test data
|
||||
await createTimeEntryWithProject(page, projectName, '1h');
|
||||
|
||||
// Create a time entry with project, tag, and billable status
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||
await page.getByTestId('time_entry_description').fill('Multi-filter entry');
|
||||
|
||||
// Set project
|
||||
await page.getByRole('button', { name: 'No Project' }).click();
|
||||
await page.getByText(projectName).click();
|
||||
|
||||
// Set tag
|
||||
await page.getByRole('button', { name: 'Tags' }).click();
|
||||
await page.getByText('Create new tag').click();
|
||||
await page.getByPlaceholder('Tag Name').fill(tagName);
|
||||
await page.getByRole('button', { name: 'Create Tag' }).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Set as billable
|
||||
await page.getByRole('button', { name: 'Non-Billable' }).click();
|
||||
await page.getByRole('option', { name: 'Billable', exact: true }).click();
|
||||
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').fill('2h 30min');
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
|
||||
await page.getByRole('button', { name: 'Create Time Entry' }).click();
|
||||
|
||||
// Create and make report public with multiple filters
|
||||
await createReport(page, reportName, {
|
||||
projectFilter: projectName,
|
||||
tagFilter: tagName,
|
||||
billableFilter: 'billable',
|
||||
});
|
||||
const publicUrl = await makeReportPublic(page, reportName);
|
||||
|
||||
// Create unauthenticated page
|
||||
const unauthenticatedPage = await createUnauthenticatedPage(browser);
|
||||
|
||||
// Access the public report URL
|
||||
await unauthenticatedPage.goto(publicUrl);
|
||||
await unauthenticatedPage.waitForLoadState('networkidle');
|
||||
|
||||
// Verify the filtered data is shown
|
||||
await expect(unauthenticatedPage.getByText(projectName)).toBeVisible();
|
||||
await expect(unauthenticatedPage.getByText(tagName)).toBeVisible();
|
||||
await expect(unauthenticatedPage.getByText('2h 30min')).toBeVisible();
|
||||
|
||||
await unauthenticatedPage.close();
|
||||
});
|
||||
|
||||
test('cannot access private shared report without authentication', async ({ page, browser }) => {
|
||||
const projectName = 'Private Project ' + Math.floor(Math.random() * 10000);
|
||||
const reportName = 'Private Report ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
// Create test data
|
||||
await createTimeEntryWithProject(page, projectName, '1h');
|
||||
|
||||
// Create report but don't make it public
|
||||
await createReport(page, reportName, { projectFilter: projectName });
|
||||
|
||||
// Try to access the shared reports page without authentication
|
||||
const unauthenticatedPage = await createUnauthenticatedPage(browser);
|
||||
await unauthenticatedPage.goto(PLAYWRIGHT_BASE_URL + '/reporting/shared');
|
||||
|
||||
// Should redirect to login or show unauthorized
|
||||
await expect(unauthenticatedPage.getByRole('button', { name: 'Login' })).toBeVisible();
|
||||
|
||||
await unauthenticatedPage.close();
|
||||
});
|
||||
|
||||
test('cannot access public shared report with invalid share secret', async ({ page, browser }) => {
|
||||
const projectName = 'Invalid Secret Project ' + Math.floor(Math.random() * 10000);
|
||||
const reportName = 'Invalid Secret Report ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
// Create test data
|
||||
await createTimeEntryWithProject(page, projectName, '1h');
|
||||
|
||||
// Create and make report public
|
||||
await createReport(page, reportName, { projectFilter: projectName });
|
||||
await makeReportPublic(page, reportName);
|
||||
|
||||
// Create unauthenticated page
|
||||
const unauthenticatedPage = await createUnauthenticatedPage(browser);
|
||||
|
||||
// Try to access with invalid share secret
|
||||
const invalidUrl = PLAYWRIGHT_BASE_URL + '/shared-report#invalid-secret-123';
|
||||
await unauthenticatedPage.goto(invalidUrl);
|
||||
|
||||
// Should show error or not found
|
||||
await expect(unauthenticatedPage.getByText('Report not found')).toBeVisible();
|
||||
|
||||
await unauthenticatedPage.close();
|
||||
});
|
||||
|
||||
test('public shared report displays charts and visualizations', async ({ page, browser }) => {
|
||||
const projectName = 'Chart Project ' + Math.floor(Math.random() * 10000);
|
||||
const reportName = 'Chart Report ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
// Create test data
|
||||
await createTimeEntryWithProject(page, projectName, '4h');
|
||||
|
||||
// Create and make report public
|
||||
await createReport(page, reportName, { projectFilter: projectName });
|
||||
const publicUrl = await makeReportPublic(page, reportName);
|
||||
|
||||
// Create unauthenticated page
|
||||
const unauthenticatedPage = await createUnauthenticatedPage(browser);
|
||||
|
||||
// Access the public report URL
|
||||
await unauthenticatedPage.goto(publicUrl);
|
||||
await unauthenticatedPage.waitForLoadState('networkidle');
|
||||
|
||||
// Verify charts are displayed
|
||||
await expect(unauthenticatedPage.locator('canvas')).toBeVisible();
|
||||
|
||||
// Verify summary statistics
|
||||
await expect(unauthenticatedPage.getByText('Total Time')).toBeVisible();
|
||||
await expect(unauthenticatedPage.getByText('4h 00min')).toBeVisible();
|
||||
|
||||
await unauthenticatedPage.close();
|
||||
});
|
||||
|
||||
test('public shared report shows correct report metadata', async ({ page, browser }) => {
|
||||
const projectName = 'Metadata Project ' + Math.floor(Math.random() * 10000);
|
||||
const reportName = 'Metadata Report ' + Math.floor(Math.random() * 10000);
|
||||
const description = 'This is a public report showing project data';
|
||||
|
||||
// Create test data
|
||||
await createTimeEntryWithProject(page, projectName, '1h 45min');
|
||||
|
||||
// Create report
|
||||
await createReport(page, reportName, { projectFilter: projectName });
|
||||
|
||||
// Add description and make public
|
||||
await goToSharedReports(page);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const reportRow = page.locator('tr').filter({ hasText: reportName });
|
||||
await reportRow.getByRole('button', { name: 'Edit' }).click();
|
||||
await page.getByLabel('Description').fill(description);
|
||||
await page.getByRole('switch', { name: 'Make report public' }).click();
|
||||
await page.waitForResponse(
|
||||
(response) => response.url().includes('/reports/') && response.status() === 200
|
||||
);
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Get public URL
|
||||
const copyButton = reportRow.getByRole('button', { name: 'Copy URL' });
|
||||
await copyButton.click();
|
||||
const publicUrl = await page.evaluate(() => navigator.clipboard.readText());
|
||||
|
||||
// Create unauthenticated page
|
||||
const unauthenticatedPage = await createUnauthenticatedPage(browser);
|
||||
|
||||
// Access the public report URL
|
||||
await unauthenticatedPage.goto(publicUrl);
|
||||
await unauthenticatedPage.waitForLoadState('networkidle');
|
||||
|
||||
// Verify report metadata
|
||||
await expect(unauthenticatedPage.getByText(reportName)).toBeVisible();
|
||||
await expect(unauthenticatedPage.getByText(description)).toBeVisible();
|
||||
|
||||
await unauthenticatedPage.close();
|
||||
});
|
||||
542
e2e/shared-reports-validation.spec.ts
Normal file
542
e2e/shared-reports-validation.spec.ts
Normal file
@@ -0,0 +1,542 @@
|
||||
import { expect, Page, Browser } from '@playwright/test';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
import { test } from '../playwright/fixtures';
|
||||
|
||||
async function goToSharedReports(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/reporting/shared');
|
||||
}
|
||||
|
||||
async function goToReporting(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/reporting');
|
||||
}
|
||||
|
||||
async function createTimeEntryWithProject(
|
||||
page: Page,
|
||||
projectName: string,
|
||||
duration: string,
|
||||
description: string = ''
|
||||
) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||
await page.getByLabel('Project Name').fill(projectName);
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Create Project' }).click();
|
||||
await page.getByText(projectName).waitFor({ state: 'visible' });
|
||||
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||
await page
|
||||
.getByTestId('time_entry_description')
|
||||
.fill(description || `Time entry for ${projectName}`);
|
||||
await page.getByRole('button', { name: 'No Project' }).click();
|
||||
await page.getByText(projectName).click();
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Create Time Entry' }).click(),
|
||||
page.waitForResponse(
|
||||
(response) => response.url().includes('/time-entries') && response.status() === 201
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
async function createTimeEntryWithTag(
|
||||
page: Page,
|
||||
tagName: string,
|
||||
duration: string,
|
||||
description: string = ''
|
||||
) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||
await page
|
||||
.getByTestId('time_entry_description')
|
||||
.fill(description || `Time entry with tag ${tagName}`);
|
||||
await page.getByRole('button', { name: 'Tags' }).click();
|
||||
await page.getByText('Create new tag').click();
|
||||
await page.getByPlaceholder('Tag Name').fill(tagName);
|
||||
await page.getByRole('button', { name: 'Create Tag' }).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
|
||||
await page.getByRole('button', { name: 'Create Time Entry' }).click();
|
||||
}
|
||||
|
||||
async function createTimeEntryWithBillableStatus(
|
||||
page: Page,
|
||||
isBillable: boolean,
|
||||
duration: string,
|
||||
description: string = ''
|
||||
) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||
await page
|
||||
.getByTestId('time_entry_description')
|
||||
.fill(description || `Time entry ${isBillable ? 'billable' : 'non-billable'}`);
|
||||
await page.getByRole('button', { name: 'Non-Billable' }).click();
|
||||
if (!isBillable) {
|
||||
await page.getByRole('option', { name: 'Non Billable', exact: true }).click();
|
||||
} else {
|
||||
await page.getByRole('option', { name: 'Billable', exact: true }).click();
|
||||
}
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
|
||||
await page.getByRole('button', { name: 'Create Time Entry' }).click();
|
||||
}
|
||||
|
||||
async function createReport(
|
||||
page: Page,
|
||||
reportName: string,
|
||||
options: {
|
||||
projectFilter?: string;
|
||||
tagFilter?: string;
|
||||
billableFilter?: 'billable' | 'non-billable' | 'all';
|
||||
timeRange?: { start: string; end: string };
|
||||
} = {}
|
||||
) {
|
||||
await goToReporting(page);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Apply filters if specified
|
||||
if (options.projectFilter) {
|
||||
await page.getByRole('button', { name: 'Project' }).nth(0).click();
|
||||
await page.getByText(options.projectFilter).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/aggregate') && response.status() === 200
|
||||
);
|
||||
}
|
||||
|
||||
if (options.tagFilter) {
|
||||
await page.getByRole('button', { name: 'Tags' }).click();
|
||||
await page.getByText(options.tagFilter).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/aggregate') && response.status() === 200
|
||||
);
|
||||
}
|
||||
|
||||
if (options.billableFilter && options.billableFilter !== 'all') {
|
||||
await page.getByRole('button', { name: 'Billable' }).click();
|
||||
if (options.billableFilter === 'billable') {
|
||||
await page.getByRole('option', { name: 'Billable', exact: true }).click();
|
||||
} else {
|
||||
await page.getByRole('option', { name: 'Non Billable', exact: true }).click();
|
||||
}
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/aggregate') && response.status() === 200
|
||||
);
|
||||
}
|
||||
|
||||
// Set custom time range if specified
|
||||
if (options.timeRange) {
|
||||
await page.getByRole('button', { name: 'This Week' }).click();
|
||||
await page.getByRole('option', { name: 'Custom Range' }).click();
|
||||
await page.locator('input[name="startDate"]').fill(options.timeRange.start);
|
||||
await page.locator('input[name="endDate"]').fill(options.timeRange.end);
|
||||
await page.getByRole('button', { name: 'Apply' }).click();
|
||||
await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/aggregate') && response.status() === 200
|
||||
);
|
||||
}
|
||||
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Save the report
|
||||
await page.getByRole('button', { name: 'Save Report' }).click();
|
||||
await page.getByLabel('Report Name').fill(reportName);
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Create Report' }).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
async function makeReportPublic(page: Page, reportName: string): Promise<string> {
|
||||
await goToSharedReports(page);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Find the report row and click the edit button
|
||||
const reportRow = page.locator('tr').filter({ hasText: reportName });
|
||||
await reportRow.getByRole('button', { name: 'Edit' }).click();
|
||||
|
||||
// Make the report public
|
||||
await page.getByRole('switch', { name: 'Make report public' }).click();
|
||||
|
||||
// Wait for the API response
|
||||
await page.waitForResponse(
|
||||
(response) => response.url().includes('/reports/') && response.status() === 200
|
||||
);
|
||||
|
||||
// Save the changes
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Get the public URL
|
||||
const copyButton = reportRow.getByRole('button', { name: 'Copy URL' });
|
||||
await copyButton.click();
|
||||
|
||||
// Extract the URL from clipboard or from the button's data attribute
|
||||
const publicUrl = await page.evaluate(() => navigator.clipboard.readText());
|
||||
|
||||
return publicUrl;
|
||||
}
|
||||
|
||||
async function createUnauthenticatedPage(browser: Browser): Promise<Page> {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
return page;
|
||||
}
|
||||
|
||||
test('verify shared report data accuracy with project filter', async ({ page, browser }) => {
|
||||
const projectName = 'Accuracy Project ' + Math.floor(Math.random() * 10000);
|
||||
const otherProjectName = 'Other Accuracy Project ' + Math.floor(Math.random() * 10000);
|
||||
const reportName = 'Accuracy Report ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
// Create test data with specific durations
|
||||
await createTimeEntryWithProject(page, projectName, '2h 30min', 'Task 1');
|
||||
await createTimeEntryWithProject(page, projectName, '1h 15min', 'Task 2');
|
||||
await createTimeEntryWithProject(page, otherProjectName, '3h', 'Other task');
|
||||
|
||||
// Create and make report public with project filter
|
||||
await createReport(page, reportName, { projectFilter: projectName });
|
||||
const publicUrl = await makeReportPublic(page, reportName);
|
||||
|
||||
// Verify data in authenticated reporting view
|
||||
await goToReporting(page);
|
||||
await page.getByRole('button', { name: 'Project' }).nth(0).click();
|
||||
await page.getByText(projectName).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/aggregate') && response.status() === 200
|
||||
);
|
||||
|
||||
// Note expected total: 2h 30min + 1h 15min = 3h 45min
|
||||
await expect(page.getByText('3h 45min')).toBeVisible();
|
||||
|
||||
// Verify same data in public view
|
||||
const unauthenticatedPage = await createUnauthenticatedPage(browser);
|
||||
await unauthenticatedPage.goto(publicUrl);
|
||||
await unauthenticatedPage.waitForLoadState('networkidle');
|
||||
|
||||
// Verify total time matches
|
||||
await expect(unauthenticatedPage.getByText('3h 45min')).toBeVisible();
|
||||
await expect(unauthenticatedPage.getByText(projectName)).toBeVisible();
|
||||
await expect(unauthenticatedPage.getByText('Task 1')).toBeVisible();
|
||||
await expect(unauthenticatedPage.getByText('Task 2')).toBeVisible();
|
||||
await expect(unauthenticatedPage.getByText(otherProjectName)).not.toBeVisible();
|
||||
|
||||
await unauthenticatedPage.close();
|
||||
});
|
||||
|
||||
test('verify shared report data accuracy with tag filter', async ({ page, browser }) => {
|
||||
const tagName = 'AccuracyTag' + Math.floor(Math.random() * 10000);
|
||||
const otherTagName = 'OtherTag' + Math.floor(Math.random() * 10000);
|
||||
const reportName = 'Tag Accuracy Report ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
// Create test data with specific durations
|
||||
await createTimeEntryWithTag(page, tagName, '1h 30min', 'Tagged task 1');
|
||||
await createTimeEntryWithTag(page, tagName, '2h 15min', 'Tagged task 2');
|
||||
await createTimeEntryWithTag(page, otherTagName, '45min', 'Other tagged task');
|
||||
|
||||
// Create and make report public with tag filter
|
||||
await createReport(page, reportName, { tagFilter: tagName });
|
||||
const publicUrl = await makeReportPublic(page, reportName);
|
||||
|
||||
// Verify data in authenticated reporting view
|
||||
await goToReporting(page);
|
||||
await page.getByRole('button', { name: 'Tags' }).click();
|
||||
await page.getByText(tagName).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/aggregate') && response.status() === 200
|
||||
);
|
||||
|
||||
// Note expected total: 1h 30min + 2h 15min = 3h 45min
|
||||
await expect(page.getByText('3h 45min')).toBeVisible();
|
||||
|
||||
// Verify same data in public view
|
||||
const unauthenticatedPage = await createUnauthenticatedPage(browser);
|
||||
await unauthenticatedPage.goto(publicUrl);
|
||||
await unauthenticatedPage.waitForLoadState('networkidle');
|
||||
|
||||
// Verify total time matches
|
||||
await expect(unauthenticatedPage.getByText('3h 45min')).toBeVisible();
|
||||
await expect(unauthenticatedPage.getByText(tagName)).toBeVisible();
|
||||
await expect(unauthenticatedPage.getByText('Tagged task 1')).toBeVisible();
|
||||
await expect(unauthenticatedPage.getByText('Tagged task 2')).toBeVisible();
|
||||
await expect(unauthenticatedPage.getByText(otherTagName)).not.toBeVisible();
|
||||
|
||||
await unauthenticatedPage.close();
|
||||
});
|
||||
|
||||
test('verify shared report data accuracy with billable filter', async ({ page, browser }) => {
|
||||
const reportName = 'Billable Accuracy Report ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
// Create test data with specific durations
|
||||
await createTimeEntryWithBillableStatus(page, true, '2h', 'Billable task 1');
|
||||
await createTimeEntryWithBillableStatus(page, true, '1h 30min', 'Billable task 2');
|
||||
await createTimeEntryWithBillableStatus(page, false, '45min', 'Non-billable task');
|
||||
|
||||
// Create and make report public with billable filter
|
||||
await createReport(page, reportName, { billableFilter: 'billable' });
|
||||
const publicUrl = await makeReportPublic(page, reportName);
|
||||
|
||||
// Verify data in authenticated reporting view
|
||||
await goToReporting(page);
|
||||
await page.getByRole('button', { name: 'Billable' }).click();
|
||||
await page.getByRole('option', { name: 'Billable', exact: true }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/aggregate') && response.status() === 200
|
||||
);
|
||||
|
||||
// Note expected total: 2h + 1h 30min = 3h 30min
|
||||
await expect(page.getByText('3h 30min')).toBeVisible();
|
||||
|
||||
// Verify same data in public view
|
||||
const unauthenticatedPage = await createUnauthenticatedPage(browser);
|
||||
await unauthenticatedPage.goto(publicUrl);
|
||||
await unauthenticatedPage.waitForLoadState('networkidle');
|
||||
|
||||
// Verify total time matches
|
||||
await expect(unauthenticatedPage.getByText('3h 30min')).toBeVisible();
|
||||
await expect(unauthenticatedPage.getByText('Billable task 1')).toBeVisible();
|
||||
await expect(unauthenticatedPage.getByText('Billable task 2')).toBeVisible();
|
||||
await expect(unauthenticatedPage.getByText('Non-billable task')).not.toBeVisible();
|
||||
|
||||
await unauthenticatedPage.close();
|
||||
});
|
||||
|
||||
test('verify shared report data accuracy with non-billable filter', async ({ page, browser }) => {
|
||||
const reportName = 'Non-Billable Accuracy Report ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
// Create test data with specific durations
|
||||
await createTimeEntryWithBillableStatus(page, false, '1h 45min', 'Non-billable task 1');
|
||||
await createTimeEntryWithBillableStatus(page, false, '2h 30min', 'Non-billable task 2');
|
||||
await createTimeEntryWithBillableStatus(page, true, '1h', 'Billable task');
|
||||
|
||||
// Create and make report public with non-billable filter
|
||||
await createReport(page, reportName, { billableFilter: 'non-billable' });
|
||||
const publicUrl = await makeReportPublic(page, reportName);
|
||||
|
||||
// Verify data in authenticated reporting view
|
||||
await goToReporting(page);
|
||||
await page.getByRole('button', { name: 'Billable' }).click();
|
||||
await page.getByRole('option', { name: 'Non Billable', exact: true }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/aggregate') && response.status() === 200
|
||||
);
|
||||
|
||||
// Note expected total: 1h 45min + 2h 30min = 4h 15min
|
||||
await expect(page.getByText('4h 15min')).toBeVisible();
|
||||
|
||||
// Verify same data in public view
|
||||
const unauthenticatedPage = await createUnauthenticatedPage(browser);
|
||||
await unauthenticatedPage.goto(publicUrl);
|
||||
await unauthenticatedPage.waitForLoadState('networkidle');
|
||||
|
||||
// Verify total time matches
|
||||
await expect(unauthenticatedPage.getByText('4h 15min')).toBeVisible();
|
||||
await expect(unauthenticatedPage.getByText('Non-billable task 1')).toBeVisible();
|
||||
await expect(unauthenticatedPage.getByText('Non-billable task 2')).toBeVisible();
|
||||
await expect(unauthenticatedPage.getByText('Billable task')).not.toBeVisible();
|
||||
|
||||
await unauthenticatedPage.close();
|
||||
});
|
||||
|
||||
test('verify shared report data accuracy with multiple filters', async ({ page, browser }) => {
|
||||
const projectName = 'MultiAccuracy Project ' + Math.floor(Math.random() * 10000);
|
||||
const tagName = 'MultiAccuracyTag' + Math.floor(Math.random() * 10000);
|
||||
const reportName = 'MultiAccuracy Report ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
// Create test data
|
||||
await createTimeEntryWithProject(page, projectName, '1h', 'Project only');
|
||||
|
||||
// Create a time entry with project, tag, and billable status
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||
await page.getByTestId('time_entry_description').fill('Multi-filter matched entry');
|
||||
|
||||
// Set project
|
||||
await page.getByRole('button', { name: 'No Project' }).click();
|
||||
await page.getByText(projectName).click();
|
||||
|
||||
// Set tag
|
||||
await page.getByRole('button', { name: 'Tags' }).click();
|
||||
await page.getByText('Create new tag').click();
|
||||
await page.getByPlaceholder('Tag Name').fill(tagName);
|
||||
await page.getByRole('button', { name: 'Create Tag' }).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Set as billable
|
||||
await page.getByRole('button', { name: 'Non-Billable' }).click();
|
||||
await page.getByRole('option', { name: 'Billable', exact: true }).click();
|
||||
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').fill('2h 30min');
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
|
||||
await page.getByRole('button', { name: 'Create Time Entry' }).click();
|
||||
|
||||
// Create another entry that won't match all filters
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||
await page.getByTestId('time_entry_description').fill('Partial match entry');
|
||||
|
||||
// Set same project but different tag and non-billable
|
||||
await page.getByRole('button', { name: 'No Project' }).click();
|
||||
await page.getByText(projectName).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Tags' }).click();
|
||||
await page.getByText('Create new tag').click();
|
||||
await page.getByPlaceholder('Tag Name').fill('DifferentTag');
|
||||
await page.getByRole('button', { name: 'Create Tag' }).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').fill('1h 15min');
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
|
||||
await page.getByRole('button', { name: 'Create Time Entry' }).click();
|
||||
|
||||
// Create and make report public with multiple filters
|
||||
await createReport(page, reportName, {
|
||||
projectFilter: projectName,
|
||||
tagFilter: tagName,
|
||||
billableFilter: 'billable',
|
||||
});
|
||||
const publicUrl = await makeReportPublic(page, reportName);
|
||||
|
||||
// Verify data in authenticated reporting view
|
||||
await goToReporting(page);
|
||||
await page.getByRole('button', { name: 'Project' }).nth(0).click();
|
||||
await page.getByText(projectName).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/aggregate') && response.status() === 200
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Tags' }).click();
|
||||
await page.getByText(tagName).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/aggregate') && response.status() === 200
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Billable' }).click();
|
||||
await page.getByRole('option', { name: 'Billable', exact: true }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/aggregate') && response.status() === 200
|
||||
);
|
||||
|
||||
// Should only show the entry that matches all filters (2h 30min)
|
||||
await expect(page.getByText('2h 30min')).toBeVisible();
|
||||
|
||||
// Verify same data in public view
|
||||
const unauthenticatedPage = await createUnauthenticatedPage(browser);
|
||||
await unauthenticatedPage.goto(publicUrl);
|
||||
await unauthenticatedPage.waitForLoadState('networkidle');
|
||||
|
||||
// Verify only the matching entry is shown
|
||||
await expect(unauthenticatedPage.getByText('2h 30min')).toBeVisible();
|
||||
await expect(unauthenticatedPage.getByText('Multi-filter matched entry')).toBeVisible();
|
||||
await expect(unauthenticatedPage.getByText('Project only')).not.toBeVisible();
|
||||
await expect(unauthenticatedPage.getByText('Partial match entry')).not.toBeVisible();
|
||||
|
||||
await unauthenticatedPage.close();
|
||||
});
|
||||
|
||||
test('verify shared report data accuracy with time range filter', async ({ page, browser }) => {
|
||||
const projectName = 'TimeRange Accuracy Project ' + Math.floor(Math.random() * 10000);
|
||||
const reportName = 'TimeRange Accuracy Report ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
// Create test data within date range
|
||||
await createTimeEntryWithProject(page, projectName, '1h 30min', 'Within range 1');
|
||||
await createTimeEntryWithProject(page, projectName, '2h 15min', 'Within range 2');
|
||||
|
||||
// Create and make report public with time range
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - 1);
|
||||
const endDate = new Date();
|
||||
endDate.setDate(endDate.getDate() + 1);
|
||||
|
||||
await createReport(page, reportName, {
|
||||
projectFilter: projectName,
|
||||
timeRange: {
|
||||
start: startDate.toISOString().split('T')[0],
|
||||
end: endDate.toISOString().split('T')[0],
|
||||
},
|
||||
});
|
||||
const publicUrl = await makeReportPublic(page, reportName);
|
||||
|
||||
// Verify data in authenticated reporting view
|
||||
await goToReporting(page);
|
||||
await page.getByRole('button', { name: 'Project' }).nth(0).click();
|
||||
await page.getByText(projectName).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/aggregate') && response.status() === 200
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'This Week' }).click();
|
||||
await page.getByRole('option', { name: 'Custom Range' }).click();
|
||||
await page.locator('input[name="startDate"]').fill(startDate.toISOString().split('T')[0]);
|
||||
await page.locator('input[name="endDate"]').fill(endDate.toISOString().split('T')[0]);
|
||||
await page.getByRole('button', { name: 'Apply' }).click();
|
||||
await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/aggregate') && response.status() === 200
|
||||
);
|
||||
|
||||
// Note expected total: 1h 30min + 2h 15min = 3h 45min
|
||||
await expect(page.getByText('3h 45min')).toBeVisible();
|
||||
|
||||
// Verify same data in public view
|
||||
const unauthenticatedPage = await createUnauthenticatedPage(browser);
|
||||
await unauthenticatedPage.goto(publicUrl);
|
||||
await unauthenticatedPage.waitForLoadState('networkidle');
|
||||
|
||||
// Verify total time matches
|
||||
await expect(unauthenticatedPage.getByText('3h 45min')).toBeVisible();
|
||||
await expect(unauthenticatedPage.getByText('Within range 1')).toBeVisible();
|
||||
await expect(unauthenticatedPage.getByText('Within range 2')).toBeVisible();
|
||||
|
||||
await unauthenticatedPage.close();
|
||||
});
|
||||
|
||||
test('verify shared report shows zero data when no entries match filters', async ({
|
||||
page,
|
||||
browser,
|
||||
}) => {
|
||||
const projectName = 'NoMatch Project ' + Math.floor(Math.random() * 10000);
|
||||
const tagName = 'NoMatchTag' + Math.floor(Math.random() * 10000);
|
||||
const reportName = 'NoMatch Report ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
// Create test data that won't match our filters
|
||||
await createTimeEntryWithProject(page, 'Other Project', '1h', 'Other entry');
|
||||
|
||||
// Create and make report public with filters that won't match
|
||||
await createReport(page, reportName, {
|
||||
projectFilter: projectName, // This project doesn't exist
|
||||
tagFilter: tagName, // This tag doesn't exist
|
||||
});
|
||||
const publicUrl = await makeReportPublic(page, reportName);
|
||||
|
||||
// Verify data in public view shows zero/empty results
|
||||
const unauthenticatedPage = await createUnauthenticatedPage(browser);
|
||||
await unauthenticatedPage.goto(publicUrl);
|
||||
await unauthenticatedPage.waitForLoadState('networkidle');
|
||||
|
||||
// Verify no data is shown
|
||||
await expect(unauthenticatedPage.getByText('0h 00min')).toBeVisible();
|
||||
await expect(unauthenticatedPage.getByText('No data available')).toBeVisible();
|
||||
|
||||
await unauthenticatedPage.close();
|
||||
});
|
||||
392
e2e/shared-reports.spec.ts
Normal file
392
e2e/shared-reports.spec.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
import { expect, Page } from '@playwright/test';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
import { test } from '../playwright/fixtures';
|
||||
|
||||
async function goToSharedReports(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/reporting/shared');
|
||||
}
|
||||
|
||||
async function goToReporting(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/reporting');
|
||||
}
|
||||
|
||||
async function createTimeEntryWithProject(page: Page, projectName: string, duration: string) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||
await page.getByLabel('Project Name').fill(projectName);
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Create Project' }).click();
|
||||
await page.getByText(projectName).waitFor({ state: 'visible' });
|
||||
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||
await page.getByTestId('time_entry_description').fill(`Time entry for ${projectName}`);
|
||||
await page.getByRole('button', { name: 'No Project' }).click();
|
||||
await page.getByText(projectName).click();
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Create Time Entry' }).click(),
|
||||
page.waitForResponse(
|
||||
(response) => response.url().includes('/time-entries') && response.status() === 201
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
async function createTimeEntryWithTag(page: Page, tagName: string, duration: string) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||
await page.getByTestId('time_entry_description').fill(`Time entry with tag ${tagName}`);
|
||||
await page.getByRole('button', { name: 'Tags' }).click();
|
||||
await page.getByText('Create new tag').click();
|
||||
await page.getByPlaceholder('Tag Name').fill(tagName);
|
||||
await page.getByRole('button', { name: 'Create Tag' }).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
|
||||
await page.getByRole('button', { name: 'Create Time Entry' }).click();
|
||||
}
|
||||
|
||||
async function createTimeEntryWithBillableStatus(
|
||||
page: Page,
|
||||
isBillable: boolean,
|
||||
duration: string
|
||||
) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||
await page
|
||||
.getByTestId('time_entry_description')
|
||||
.fill(`Time entry ${isBillable ? 'billable' : 'non-billable'}`);
|
||||
await page.getByRole('button', { name: 'Non-Billable' }).click();
|
||||
if (!isBillable) {
|
||||
await page.getByRole('option', { name: 'Non Billable', exact: true }).click();
|
||||
} else {
|
||||
await page.getByRole('option', { name: 'Billable', exact: true }).click();
|
||||
}
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
|
||||
await page.getByRole('button', { name: 'Create Time Entry' }).click();
|
||||
}
|
||||
|
||||
async function createReport(
|
||||
page: Page,
|
||||
reportName: string,
|
||||
options: {
|
||||
projectFilter?: string;
|
||||
tagFilter?: string;
|
||||
billableFilter?: 'billable' | 'non-billable' | 'all';
|
||||
timeRange?: { start: string; end: string };
|
||||
} = {}
|
||||
) {
|
||||
await goToReporting(page);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Apply filters if specified
|
||||
if (options.projectFilter) {
|
||||
await page.getByRole('button', { name: 'Project' }).nth(0).click();
|
||||
await page.getByText(options.projectFilter).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/aggregate') && response.status() === 200
|
||||
);
|
||||
}
|
||||
|
||||
if (options.tagFilter) {
|
||||
await page.getByRole('button', { name: 'Tags' }).click();
|
||||
await page.getByText(options.tagFilter).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/aggregate') && response.status() === 200
|
||||
);
|
||||
}
|
||||
|
||||
if (options.billableFilter && options.billableFilter !== 'all') {
|
||||
await page.getByRole('button', { name: 'Billable' }).click();
|
||||
if (options.billableFilter === 'billable') {
|
||||
await page.getByRole('option', { name: 'Billable', exact: true }).click();
|
||||
} else {
|
||||
await page.getByRole('option', { name: 'Non Billable', exact: true }).click();
|
||||
}
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/aggregate') && response.status() === 200
|
||||
);
|
||||
}
|
||||
|
||||
// Set custom time range if specified
|
||||
if (options.timeRange) {
|
||||
await page.getByRole('button', { name: 'This Week' }).click();
|
||||
await page.getByRole('option', { name: 'Custom Range' }).click();
|
||||
await page.locator('input[name="startDate"]').fill(options.timeRange.start);
|
||||
await page.locator('input[name="endDate"]').fill(options.timeRange.end);
|
||||
await page.getByRole('button', { name: 'Apply' }).click();
|
||||
await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/aggregate') && response.status() === 200
|
||||
);
|
||||
}
|
||||
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Save the report
|
||||
await page.getByRole('button', { name: 'Save Report' }).click();
|
||||
await page.getByLabel('Report Name').fill(reportName);
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Create Report' }).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
async function makeReportPublic(page: Page, reportName: string): Promise<string> {
|
||||
await goToSharedReports(page);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Find the report row and click the edit button
|
||||
const reportRow = page.locator('tr').filter({ hasText: reportName });
|
||||
await reportRow.getByRole('button', { name: 'Edit' }).click();
|
||||
|
||||
// Make the report public
|
||||
await page.getByRole('switch', { name: 'Make report public' }).click();
|
||||
|
||||
// Wait for the API response
|
||||
await page.waitForResponse(
|
||||
(response) => response.url().includes('/reports/') && response.status() === 200
|
||||
);
|
||||
|
||||
// Save the changes
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Get the public URL
|
||||
const copyButton = reportRow.getByRole('button', { name: 'Copy URL' });
|
||||
await copyButton.click();
|
||||
|
||||
// Extract the URL from clipboard or from the button's data attribute
|
||||
const publicUrl = await page.evaluate(() => navigator.clipboard.readText());
|
||||
|
||||
return publicUrl;
|
||||
}
|
||||
|
||||
test('create shared report with project filter', async ({ page }) => {
|
||||
const projectName = 'Shared Report Project ' + Math.floor(Math.random() * 10000);
|
||||
const reportName = 'Project Report ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
// Create test data
|
||||
await createTimeEntryWithProject(page, projectName, '2h');
|
||||
await createTimeEntryWithProject(page, 'Other Project', '1h');
|
||||
|
||||
// Create a report with project filter
|
||||
await createReport(page, reportName, { projectFilter: projectName });
|
||||
|
||||
// Make the report public
|
||||
const publicUrl = await makeReportPublic(page, reportName);
|
||||
|
||||
// Verify the report appears in shared reports list
|
||||
await expect(page.getByText(reportName)).toBeVisible();
|
||||
await expect(page.getByText('Public')).toBeVisible();
|
||||
|
||||
expect(publicUrl).toContain('/shared-report#');
|
||||
});
|
||||
|
||||
test('create shared report with tag filter', async ({ page }) => {
|
||||
const tagName = 'SharedTag' + Math.floor(Math.random() * 10000);
|
||||
const reportName = 'Tag Report ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
// Create test data
|
||||
await createTimeEntryWithTag(page, tagName, '1h 30min');
|
||||
await createTimeEntryWithTag(page, 'OtherTag', '45min');
|
||||
|
||||
// Create a report with tag filter
|
||||
await createReport(page, reportName, { tagFilter: tagName });
|
||||
|
||||
// Make the report public
|
||||
const publicUrl = await makeReportPublic(page, reportName);
|
||||
|
||||
// Verify the report appears in shared reports list
|
||||
await expect(page.getByText(reportName)).toBeVisible();
|
||||
await expect(page.getByText('Public')).toBeVisible();
|
||||
|
||||
expect(publicUrl).toContain('/shared-report#');
|
||||
});
|
||||
|
||||
test('create shared report with billable filter', async ({ page }) => {
|
||||
const reportName = 'Billable Report ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
// Create test data
|
||||
await createTimeEntryWithBillableStatus(page, true, '2h');
|
||||
await createTimeEntryWithBillableStatus(page, false, '1h');
|
||||
|
||||
// Create a report with billable filter
|
||||
await createReport(page, reportName, { billableFilter: 'billable' });
|
||||
|
||||
// Make the report public
|
||||
const publicUrl = await makeReportPublic(page, reportName);
|
||||
|
||||
// Verify the report appears in shared reports list
|
||||
await expect(page.getByText(reportName)).toBeVisible();
|
||||
await expect(page.getByText('Public')).toBeVisible();
|
||||
|
||||
expect(publicUrl).toContain('/shared-report#');
|
||||
});
|
||||
|
||||
test('create shared report with custom time range', async ({ page }) => {
|
||||
const projectName = 'TimeRange Project ' + Math.floor(Math.random() * 10000);
|
||||
const reportName = 'TimeRange Report ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
// Create test data
|
||||
await createTimeEntryWithProject(page, projectName, '3h');
|
||||
|
||||
// Create a report with custom time range (last 30 days)
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - 30);
|
||||
const endDate = new Date();
|
||||
|
||||
await createReport(page, reportName, {
|
||||
projectFilter: projectName,
|
||||
timeRange: {
|
||||
start: startDate.toISOString().split('T')[0],
|
||||
end: endDate.toISOString().split('T')[0],
|
||||
},
|
||||
});
|
||||
|
||||
// Make the report public
|
||||
const publicUrl = await makeReportPublic(page, reportName);
|
||||
|
||||
// Verify the report appears in shared reports list
|
||||
await expect(page.getByText(reportName)).toBeVisible();
|
||||
await expect(page.getByText('Public')).toBeVisible();
|
||||
|
||||
expect(publicUrl).toContain('/shared-report#');
|
||||
});
|
||||
|
||||
test('create shared report with multiple filters', async ({ page }) => {
|
||||
const projectName = 'MultiFilter Project ' + Math.floor(Math.random() * 10000);
|
||||
const tagName = 'MultiTag' + Math.floor(Math.random() * 10000);
|
||||
const reportName = 'MultiFilter Report ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
// Create test data
|
||||
await createTimeEntryWithProject(page, projectName, '2h');
|
||||
|
||||
// Create a time entry with both project and tag
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||
await page.getByRole('button', { name: 'Manual time entry' }).click();
|
||||
await page.getByTestId('time_entry_description').fill('Multi-filter entry');
|
||||
|
||||
// Set project
|
||||
await page.getByRole('button', { name: 'No Project' }).click();
|
||||
await page.getByText(projectName).click();
|
||||
|
||||
// Set tag
|
||||
await page.getByRole('button', { name: 'Tags' }).click();
|
||||
await page.getByText('Create new tag').click();
|
||||
await page.getByPlaceholder('Tag Name').fill(tagName);
|
||||
await page.getByRole('button', { name: 'Create Tag' }).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Set as billable
|
||||
await page.getByRole('button', { name: 'Non-Billable' }).click();
|
||||
await page.getByRole('option', { name: 'Billable', exact: true }).click();
|
||||
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').fill('1h 30min');
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
|
||||
await page.getByRole('button', { name: 'Create Time Entry' }).click();
|
||||
|
||||
// Create a report with multiple filters
|
||||
await createReport(page, reportName, {
|
||||
projectFilter: projectName,
|
||||
tagFilter: tagName,
|
||||
billableFilter: 'billable',
|
||||
});
|
||||
|
||||
// Make the report public
|
||||
const publicUrl = await makeReportPublic(page, reportName);
|
||||
|
||||
// Verify the report appears in shared reports list
|
||||
await expect(page.getByText(reportName)).toBeVisible();
|
||||
await expect(page.getByText('Public')).toBeVisible();
|
||||
|
||||
expect(publicUrl).toContain('/shared-report#');
|
||||
});
|
||||
|
||||
test('toggle report visibility from public to private', async ({ page }) => {
|
||||
const projectName = 'Toggle Project ' + Math.floor(Math.random() * 10000);
|
||||
const reportName = 'Toggle Report ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
// Create test data
|
||||
await createTimeEntryWithProject(page, projectName, '1h');
|
||||
|
||||
// Create a report
|
||||
await createReport(page, reportName, { projectFilter: projectName });
|
||||
|
||||
// Make the report public
|
||||
await makeReportPublic(page, reportName);
|
||||
|
||||
// Verify it's public
|
||||
await expect(page.getByText('Public')).toBeVisible();
|
||||
|
||||
// Make it private again
|
||||
const reportRow = page.locator('tr').filter({ hasText: reportName });
|
||||
await reportRow.getByRole('button', { name: 'Edit' }).click();
|
||||
await page.getByRole('switch', { name: 'Make report public' }).click();
|
||||
await page.waitForResponse(
|
||||
(response) => response.url().includes('/reports/') && response.status() === 200
|
||||
);
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Verify it's now private
|
||||
await expect(page.getByText('Private')).toBeVisible();
|
||||
await expect(page.getByText('Public')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('edit shared report name and description', async ({ page }) => {
|
||||
const projectName = 'Edit Project ' + Math.floor(Math.random() * 10000);
|
||||
const reportName = 'Original Report ' + Math.floor(Math.random() * 10000);
|
||||
const updatedName = 'Updated Report ' + Math.floor(Math.random() * 10000);
|
||||
const description = 'This is an updated description';
|
||||
|
||||
// Create test data
|
||||
await createTimeEntryWithProject(page, projectName, '1h');
|
||||
|
||||
// Create a report
|
||||
await createReport(page, reportName, { projectFilter: projectName });
|
||||
|
||||
// Make the report public
|
||||
await makeReportPublic(page, reportName);
|
||||
|
||||
// Edit the report
|
||||
const reportRow = page.locator('tr').filter({ hasText: reportName });
|
||||
await reportRow.getByRole('button', { name: 'Edit' }).click();
|
||||
|
||||
await page.getByLabel('Report Name').fill(updatedName);
|
||||
await page.getByLabel('Description').fill(description);
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Verify the changes
|
||||
await expect(page.getByText(updatedName)).toBeVisible();
|
||||
await expect(page.getByText(reportName)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('delete shared report', async ({ page }) => {
|
||||
const projectName = 'Delete Project ' + Math.floor(Math.random() * 10000);
|
||||
const reportName = 'Delete Report ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
// Create test data
|
||||
await createTimeEntryWithProject(page, projectName, '1h');
|
||||
|
||||
// Create a report
|
||||
await createReport(page, reportName, { projectFilter: projectName });
|
||||
|
||||
// Make the report public
|
||||
await makeReportPublic(page, reportName);
|
||||
|
||||
// Delete the report
|
||||
const reportRow = page.locator('tr').filter({ hasText: reportName });
|
||||
await reportRow.getByRole('button', { name: 'Delete' }).click();
|
||||
await page.getByRole('button', { name: 'Delete Report' }).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Verify the report is deleted
|
||||
await expect(page.getByText(reportName)).not.toBeVisible();
|
||||
});
|
||||
79
package-lock.json
generated
79
package-lock.json
generated
@@ -7,11 +7,6 @@
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.6.0",
|
||||
"@floating-ui/vue": "^1.0.6",
|
||||
"@fullcalendar/core": "^6.1.18",
|
||||
"@fullcalendar/daygrid": "^6.1.18",
|
||||
"@fullcalendar/interaction": "^6.1.18",
|
||||
"@fullcalendar/timegrid": "^6.1.18",
|
||||
"@fullcalendar/vue3": "^6.1.18",
|
||||
"@heroicons/vue": "^2.1.1",
|
||||
"@rushstack/eslint-patch": "^1.10.5",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
@@ -23,7 +18,6 @@
|
||||
"@vue/eslint-config-typescript": "^14.3.0",
|
||||
"@vueuse/core": "^12.8.2",
|
||||
"@vueuse/integrations": "^12.5.0",
|
||||
"chroma-js": "3.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.11",
|
||||
@@ -45,7 +39,6 @@
|
||||
"@playwright/test": "^1.41.1",
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@types/chroma-js": "2.4.5",
|
||||
"@types/node": "^22.10.10",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"@vue/tsconfig": "^0.5.1",
|
||||
@@ -1034,55 +1027,6 @@
|
||||
"vue-demi": ">=0.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/core": {
|
||||
"version": "6.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.18.tgz",
|
||||
"integrity": "sha512-cD7XtZIZZ87Cg2+itnpsONCsZ89VIfLLDZ22pQX4IQVWlpYUB3bcCf878DhWkqyEen6dhi5ePtBoqYgm5K+0fQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"preact": "~10.12.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/daygrid": {
|
||||
"version": "6.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.18.tgz",
|
||||
"integrity": "sha512-s452Zle1SdMEzZDw+pDczm8m3JLIZzS9ANMThXTnqeqJewW1gqNFYas18aHypJSgF9Fh9rDJjTSUw04BpXB/Mg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@fullcalendar/core": "~6.1.18"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/interaction": {
|
||||
"version": "6.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.18.tgz",
|
||||
"integrity": "sha512-f/mD5RTjzw+Q6MGTMZrLCgIrQLIUUO9NV/58aM2J6ZBQZeRlNizDqmqldqyG+j49zj2vFhUfZibPrVKWm5yA4Q==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@fullcalendar/core": "~6.1.18"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/timegrid": {
|
||||
"version": "6.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.18.tgz",
|
||||
"integrity": "sha512-T/ouhs+T1tM8JcW7Cjx+KiohL/qQWKqvRITwjol8ktJ1e1N/6noC40/obR1tyolqOxMRWHjJkYoj9fUqfoez9A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fullcalendar/daygrid": "~6.1.18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@fullcalendar/core": "~6.1.18"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/vue3": {
|
||||
"version": "6.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/vue3/-/vue3-6.1.18.tgz",
|
||||
"integrity": "sha512-YMagwTumxsIx3GFYWLa9Yr73EMA+JuH6S3EeZGS+rEjvG5fDGdf+33rxGMzmw+LdO7SWi3ctbzRnJlv3fnm3RQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@fullcalendar/core": "~6.1.18",
|
||||
"vue": "^3.0.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroicons/vue": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz",
|
||||
@@ -1898,13 +1842,6 @@
|
||||
"vue": "^2.7.0 || ^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chroma-js": {
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/chroma-js/-/chroma-js-2.4.5.tgz",
|
||||
"integrity": "sha512-6ISjhzJViaPCy2q2e6PgK+8HcHQDQ0V2LDiKmYAh+jJlLqDa6HbwDh0wOevHY0kHHUx0iZwjSRbVD47WOUx5EQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
|
||||
@@ -2940,12 +2877,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/chroma-js": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-3.1.2.tgz",
|
||||
"integrity": "sha512-IJnETTalXbsLx1eKEgx19d5L6SRM7cH4vINw/99p/M11HCuXGRWL+6YmCm7FWFGIo6dtWuQoQi1dc5yQ7ESIHg==",
|
||||
"license": "(BSD-3-Clause AND Apache-2.0)"
|
||||
},
|
||||
"node_modules/class-variance-authority": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
||||
@@ -5209,16 +5140,6 @@
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.12.1",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
|
||||
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
"@playwright/test": "^1.41.1",
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@types/chroma-js": "2.4.5",
|
||||
"@types/node": "^22.10.10",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"@vue/tsconfig": "^0.5.1",
|
||||
@@ -40,11 +39,6 @@
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.6.0",
|
||||
"@floating-ui/vue": "^1.0.6",
|
||||
"@fullcalendar/core": "^6.1.18",
|
||||
"@fullcalendar/daygrid": "^6.1.18",
|
||||
"@fullcalendar/interaction": "^6.1.18",
|
||||
"@fullcalendar/timegrid": "^6.1.18",
|
||||
"@fullcalendar/vue3": "^6.1.18",
|
||||
"@heroicons/vue": "^2.1.1",
|
||||
"@rushstack/eslint-patch": "^1.10.5",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
@@ -56,7 +50,6 @@
|
||||
"@vue/eslint-config-typescript": "^14.3.0",
|
||||
"@vueuse/core": "^12.8.2",
|
||||
"@vueuse/integrations": "^12.5.0",
|
||||
"chroma-js": "3.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.11",
|
||||
|
||||
@@ -46,16 +46,14 @@
|
||||
|
||||
--color-accent-default: rgba(var(--color-accent-300), 0.2);
|
||||
--color-accent-foreground: rgb(var(--color-accent-100));
|
||||
--theme-color-default-background: var(--color-bg-primary);
|
||||
|
||||
}
|
||||
|
||||
:root.light {
|
||||
--color-bg-primary: #FFFFFF;
|
||||
--color-bg-primary: #F5F5F5;
|
||||
--color-bg-secondary: #f7f7f8;
|
||||
--color-bg-tertiary: #eeeeef;
|
||||
--color-bg-quaternary: #e1e1e3;
|
||||
--color-bg-background: #F5F5F5;
|
||||
--color-bg-tertiary: #e1e1e3;
|
||||
--color-bg-quaternary: #ffffff;
|
||||
--color-bg-background: #ffffff;
|
||||
--color-text-primary: #18181b;
|
||||
--color-text-secondary: #3f3f46;
|
||||
--color-text-tertiary: #57575C;
|
||||
@@ -65,14 +63,14 @@
|
||||
--color-border-tertiary: #dfdfdf;
|
||||
--color-border-quaternary: #d1d1d1;
|
||||
--color-input-border-active: rgba(0,0,0,0.3);
|
||||
--theme-color-menu-active: var(--color-bg-quaternary);
|
||||
--theme-color-menu-active: var(--color-bg-tertiary);
|
||||
|
||||
--theme-color-card-background: var(--color-bg-primary);
|
||||
--theme-color-card-background-active: var(--color-bg-tertiary);
|
||||
--theme-color-card-background: var(--color-bg-quaternary);
|
||||
--theme-color-card-background-active: var(--color-bg-primary);
|
||||
|
||||
--theme-color-chart: var(--color-accent-400);
|
||||
|
||||
--theme-shadow-card: lch(0 0 0 / 0.022) 0px 3px 6px -2px, lch(0 0 0 / 0.044) 0px 1px 1px;
|
||||
--theme-shadow-card: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
--theme-shadow-dropdown: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
|
||||
--theme-color-row-background: var(--theme-color-card-background);
|
||||
@@ -87,18 +85,17 @@
|
||||
--theme-color-button-primary-border: rgba(var(--color-accent-600), 1);
|
||||
--theme-color-button-primary-text: #FFFFFF;
|
||||
|
||||
--theme-color-input-background: var(--color-bg-primary);
|
||||
--theme-color-input-background: var(--color-bg-quaternary);
|
||||
|
||||
--theme-color-input-select-active: rgb(var(--color-accent-400));
|
||||
--theme-color-input-select-active-hover: rgb(var(--color-accent-500));
|
||||
|
||||
--color-accent-default: rgb(var(--color-accent-100));
|
||||
--color-accent-foreground: rgb(var(--color-accent-800));
|
||||
--theme-color-default-background: #FCFCFC;
|
||||
|
||||
}
|
||||
|
||||
:root {
|
||||
--theme-color-default-background: var(--color-bg-primary);
|
||||
--theme-color-icon-active: rgb(var(--color-text-tertiary));
|
||||
--theme-color-card-background-separator: var(--color-border-tertiary);
|
||||
--theme-color-card-border: var(--color-border-secondary);
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
|
||||
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
|
||||
import { useForm, usePage } from '@inertiajs/vue3';
|
||||
import type { User } from '@/types/models';
|
||||
import { useSessionStorage } from '@vueuse/core';
|
||||
|
||||
const show = defineModel('show', { default: false });
|
||||
const saving = defineModel('saving', { default: false });
|
||||
|
||||
const timezone = ref('');
|
||||
const userTimezone = ref('');
|
||||
|
||||
const page = usePage<{
|
||||
auth: {
|
||||
user: User;
|
||||
};
|
||||
}>();
|
||||
|
||||
const hideTimezoneMismatchModal = useSessionStorage<boolean>('hide-timezone-mismatch-modal', false);
|
||||
|
||||
onMounted(() => {
|
||||
timezone.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
userTimezone.value = getUserTimezone();
|
||||
|
||||
if (
|
||||
getDayJsInstance()().tz(timezone.value).format() !==
|
||||
getDayJsInstance()().tz(userTimezone.value).format() &&
|
||||
!hideTimezoneMismatchModal.value
|
||||
) {
|
||||
show.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
function submit() {
|
||||
saving.value = true;
|
||||
const form = useForm({
|
||||
_method: 'PUT',
|
||||
timezone: timezone.value,
|
||||
name: page.props.auth.user.name,
|
||||
email: page.props.auth.user.email,
|
||||
week_start: page.props.auth.user.week_start,
|
||||
});
|
||||
|
||||
form.post(route('user-profile-information.update'), {
|
||||
errorBag: 'updateProfileInformation',
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
saving.value = false;
|
||||
show.value = false;
|
||||
location.reload();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
show.value = false;
|
||||
hideTimezoneMismatchModal.value = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal closeable :show="show" @close="show = false">
|
||||
<template #title>
|
||||
<div class="flex justify-center">
|
||||
<span> Timezone mismatch detected </span>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="col-span-6 sm:col-span-4 flex-1 space-y-2">
|
||||
<p>
|
||||
The timezone of your device does not match the timezone in your user
|
||||
settings. <br />
|
||||
<strong
|
||||
>We highly recommend that you update your timezone settings to your
|
||||
current timezone.</strong
|
||||
>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Want to change your timezone setting from
|
||||
<strong>{{ userTimezone }}</strong> to <strong>{{ timezone }}</strong
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<SecondaryButton @click="cancel"> Cancel</SecondaryButton>
|
||||
<PrimaryButton
|
||||
class="ms-3"
|
||||
:class="{ 'opacity-25': saving }"
|
||||
:disabled="saving"
|
||||
@click="submit()">
|
||||
Update timezone
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -4,7 +4,9 @@ import { computed } from 'vue';
|
||||
import RecentlyTrackedTasksCardEntry from '@/Components/Dashboard/RecentlyTrackedTasksCardEntry.vue';
|
||||
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
|
||||
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import { PlusCircleIcon } from '@heroicons/vue/24/solid';
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import { api } from '@/packages/api/src';
|
||||
import { LoadingSpinner } from '@/packages/ui/src';
|
||||
@@ -88,8 +90,23 @@ window.addEventListener('dashboard:refresh', () => {
|
||||
<div v-else class="text-center flex flex-1 justify-center items-center">
|
||||
<div>
|
||||
<PlusCircleIcon class="w-8 text-icon-default inline pb-2"></PlusCircleIcon>
|
||||
<h3 class="text-text-primary font-semibold text-sm">No recent time entries</h3>
|
||||
<p class="pb-5 text-sm">Start tracking your time!</p>
|
||||
<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>
|
||||
<SecondaryButton @click="router.visit(route('projects'))"
|
||||
>Go to Projects
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="latestTasks && latestTasks.length === 1"
|
||||
class="text-center flex flex-1 justify-center items-center text-sm">
|
||||
<div>
|
||||
<PlusCircleIcon class="w-8 text-icon-default inline pb-2"></PlusCircleIcon>
|
||||
<h3 class="text-text-primary font-semibold">Add more tasks</h3>
|
||||
<p class="pb-5">Create tasks inside of a project!</p>
|
||||
<SecondaryButton @click="router.visit(route('projects'))"
|
||||
>Go to Projects
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardCard>
|
||||
|
||||
@@ -5,7 +5,6 @@ import OrganizationSwitcher from '@/Components/OrganizationSwitcher.vue';
|
||||
import CurrentSidebarTimer from '@/Components/CurrentSidebarTimer.vue';
|
||||
import {
|
||||
Bars3Icon,
|
||||
CalendarIcon,
|
||||
ChartBarIcon,
|
||||
ClockIcon,
|
||||
Cog6ToothIcon,
|
||||
@@ -40,17 +39,14 @@ import { ArrowsRightLeftIcon } from '@heroicons/vue/16/solid';
|
||||
import { fetchToken, isTokenValid } from '@/utils/session';
|
||||
import UpdateSidebarNotification from '@/Components/UpdateSidebarNotification.vue';
|
||||
import BillingBanner from '@/Components/Billing/BillingBanner.vue';
|
||||
import UserTimezoneMismatchModal from '@/Components/Common/User/UserTimezoneMismatchModal.vue';
|
||||
import { useTheme } from '@/utils/theme';
|
||||
import { useQuery } from '@tanstack/vue-query';
|
||||
import { api } from '@/packages/api/src';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
defineProps({
|
||||
title: String,
|
||||
mainClass: String,
|
||||
});
|
||||
|
||||
const showSidebarMenu = ref(false);
|
||||
@@ -135,11 +131,6 @@ const page = usePage<{
|
||||
:icon="ClockIcon"
|
||||
:current="route().current('time')"
|
||||
:href="route('time')"></NavigationSidebarItem>
|
||||
<NavigationSidebarItem
|
||||
title="Calendar"
|
||||
:icon="CalendarIcon"
|
||||
:current="route().current('calendar')"
|
||||
:href="route('calendar')"></NavigationSidebarItem>
|
||||
<NavigationSidebarItem
|
||||
title="Reporting"
|
||||
:icon="ChartBarIcon"
|
||||
@@ -256,21 +247,21 @@ const page = usePage<{
|
||||
</div>
|
||||
<div class="flex-1 lg:ml-[230px] 2xl:ml-[250px] min-w-0">
|
||||
<div
|
||||
class="h-screen overflow-y-auto flex flex-col bg-default-background border-l border-default-background-separator">
|
||||
<div
|
||||
class="lg:hidden w-full px-3 py-1 border-b border-b-default-background-separator text-text-secondary flex justify-between items-center">
|
||||
<Bars3Icon
|
||||
class="w-7 text-text-secondary"
|
||||
@click="showSidebarMenu = !showSidebarMenu"></Bars3Icon>
|
||||
<OrganizationSwitcher></OrganizationSwitcher>
|
||||
</div>
|
||||
class="lg:hidden w-full px-3 py-1 border-b border-b-default-background-separator text-text-secondary flex justify-between items-center">
|
||||
<Bars3Icon
|
||||
class="w-7 text-text-secondary"
|
||||
@click="showSidebarMenu = !showSidebarMenu"></Bars3Icon>
|
||||
<OrganizationSwitcher></OrganizationSwitcher>
|
||||
</div>
|
||||
|
||||
<Head :title="title" />
|
||||
<Head :title="title" />
|
||||
|
||||
<Banner />
|
||||
<BillingBanner v-if="isBillingActivated()" />
|
||||
|
||||
<div
|
||||
class="min-h-screen flex flex-col bg-default-background border-l border-default-background-separator">
|
||||
<!-- Page Heading -->
|
||||
<Banner />
|
||||
<BillingBanner v-if="isBillingActivated()" />
|
||||
|
||||
<header
|
||||
v-if="$slots.header"
|
||||
class="bg-default-background border-b border-default-background-separator shadow">
|
||||
@@ -282,7 +273,7 @@ const page = usePage<{
|
||||
</header>
|
||||
|
||||
<!-- Page Content -->
|
||||
<main :class="twMerge('pb-28 relative flex-1', mainClass)">
|
||||
<main class="pb-28 flex-1">
|
||||
<div
|
||||
v-if="isOrganizationLoading"
|
||||
class="flex items-center justify-center h-screen">
|
||||
@@ -294,5 +285,4 @@ const page = usePage<{
|
||||
</div>
|
||||
</div>
|
||||
<NotificationContainer></NotificationContainer>
|
||||
<UserTimezoneMismatchModal></UserTimezoneMismatchModal>
|
||||
</template>
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query';
|
||||
import {
|
||||
api,
|
||||
type Client,
|
||||
type CreateClientBody,
|
||||
type CreateProjectBody,
|
||||
type Project,
|
||||
type TimeEntryResponse,
|
||||
} from '@/packages/api/src';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import { computed, ref } from 'vue';
|
||||
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
|
||||
import { TimeEntryCalendar } from '@/packages/ui/src';
|
||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
|
||||
import { useTagsStore } from '@/utils/useTags';
|
||||
import { useProjectsStore } from '@/utils/useProjects';
|
||||
import { useClientsStore } from '@/utils/useClients';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useTasksStore } from '@/utils/useTasks';
|
||||
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
|
||||
|
||||
const calendarStart = ref<Date | undefined>(undefined);
|
||||
const calendarEnd = ref<Date | undefined>(undefined);
|
||||
|
||||
const enableCalendarQuery = computed(() => {
|
||||
return !!getCurrentOrganizationId() && !!calendarStart.value && !!calendarEnd.value;
|
||||
});
|
||||
|
||||
// Calculate expanded date range to include previous and next periods with timezone transformations
|
||||
const expandedDateRange = computed(() => {
|
||||
if (!calendarStart.value || !calendarEnd.value) {
|
||||
return { start: null, end: null };
|
||||
}
|
||||
|
||||
const dayjs = getDayJsInstance();
|
||||
const duration = dayjs(calendarEnd.value).diff(dayjs(calendarStart.value), 'milliseconds');
|
||||
|
||||
// Calculate previous period
|
||||
const previousStart = dayjs(calendarStart.value).subtract(duration, 'milliseconds');
|
||||
// Calculate next period
|
||||
const nextEnd = dayjs(calendarEnd.value).add(duration, 'milliseconds');
|
||||
|
||||
// Apply timezone transformations
|
||||
const formattedStart = previousStart.utc().tz(getUserTimezone(), true).utc().format();
|
||||
const formattedEnd = nextEnd.utc().tz(getUserTimezone(), true).utc().format();
|
||||
|
||||
return {
|
||||
start: formattedStart,
|
||||
end: formattedEnd,
|
||||
};
|
||||
});
|
||||
|
||||
const { data: timeEntryResponse, isLoading: timeEntriesLoading } = useQuery<TimeEntryResponse>({
|
||||
queryKey: computed(() => [
|
||||
'timeEntry',
|
||||
'calendar',
|
||||
{
|
||||
start: expandedDateRange.value.start,
|
||||
end: expandedDateRange.value.end,
|
||||
organization: getCurrentOrganizationId(),
|
||||
},
|
||||
]),
|
||||
enabled: enableCalendarQuery,
|
||||
placeholderData: (previousData) => previousData,
|
||||
queryFn: () =>
|
||||
api.getTimeEntries({
|
||||
params: {
|
||||
organization: getCurrentOrganizationId() || '',
|
||||
},
|
||||
queries: {
|
||||
start: expandedDateRange.value.start!,
|
||||
end: expandedDateRange.value.end!,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const currentTimeEntries = computed(() => {
|
||||
return timeEntryResponse?.value?.data || [];
|
||||
});
|
||||
|
||||
const { createTimeEntry, updateTimeEntry, deleteTimeEntry } = useTimeEntriesStore();
|
||||
|
||||
async function createTag(name: string) {
|
||||
return await useTagsStore().createTag(name);
|
||||
}
|
||||
|
||||
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
|
||||
return await useProjectsStore().createProject(project);
|
||||
}
|
||||
|
||||
async function createClient(body: CreateClientBody): Promise<Client | undefined> {
|
||||
return await useClientsStore().createClient(body);
|
||||
}
|
||||
|
||||
const projectStore = useProjectsStore();
|
||||
const { projects } = storeToRefs(projectStore);
|
||||
const taskStore = useTasksStore();
|
||||
const { tasks } = storeToRefs(taskStore);
|
||||
const clientStore = useClientsStore();
|
||||
const { clients } = storeToRefs(clientStore);
|
||||
const tagsStore = useTagsStore();
|
||||
const { tags } = storeToRefs(tagsStore);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
function onDatesChange({ start, end }: { start: Date; end: Date }) {
|
||||
calendarStart.value = start;
|
||||
calendarEnd.value = end;
|
||||
}
|
||||
|
||||
function onRefresh() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['timeEntry', 'calendar'],
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout title="Calendar" data-testid="calendar_view" main-class="p-0">
|
||||
<TimeEntryCalendar
|
||||
:time-entries="currentTimeEntries"
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:clients="clients"
|
||||
:tags="tags"
|
||||
:loading="timeEntriesLoading"
|
||||
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
||||
:create-time-entry="createTimeEntry"
|
||||
:update-time-entry="updateTimeEntry"
|
||||
:delete-time-entry="deleteTimeEntry"
|
||||
:create-client="createClient"
|
||||
:create-project="createProject"
|
||||
:create-tag="createTag"
|
||||
@dates-change="onDatesChange"
|
||||
@refresh="onRefresh" />
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -369,7 +369,6 @@ async function downloadExport(format: ExportFormat) {
|
||||
:tags="tags"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
:clients="clients"
|
||||
class="border-b border-default-background-separator"
|
||||
:update-time-entries="
|
||||
(args) =>
|
||||
updateTimeEntries(
|
||||
|
||||
@@ -144,7 +144,6 @@ function deleteSelected() {
|
||||
:tags="tags"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
:clients="clients"
|
||||
class="border-t border-default-background-separator"
|
||||
:update-time-entries="
|
||||
(args) =>
|
||||
updateTimeEntries(
|
||||
|
||||
@@ -10,8 +10,7 @@ const props = withDefaults(
|
||||
icon?: Component;
|
||||
size?: 'small' | 'base';
|
||||
loading?: boolean;
|
||||
// Accept any valid Vue class binding shape (string | object | array)
|
||||
class?: Parameters<typeof twMerge>[0];
|
||||
class?: string;
|
||||
}>(),
|
||||
{
|
||||
type: 'button',
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, type ComputedRef } from 'vue';
|
||||
import { formatDate, formatHumanReadableDuration } from '../utils/time';
|
||||
import type { Organization } from '@/packages/api/src';
|
||||
|
||||
const props = defineProps<{
|
||||
date: Date;
|
||||
totalMinutes?: number;
|
||||
}>();
|
||||
|
||||
const totalSeconds = computed(() => (props.totalMinutes ?? 0) * 60);
|
||||
|
||||
// Injected organization for formatting settings
|
||||
const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined;
|
||||
const intervalFormat = computed(() => organization?.value?.interval_format);
|
||||
const numberFormat = computed(() => organization?.value?.number_format);
|
||||
const dateFormat = computed(() => organization?.value?.date_format);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fc-day-header-custom">
|
||||
<div class="text-xs text-muted-foreground font-medium">
|
||||
{{ date.toLocaleDateString('en-US', { weekday: 'short' }) }}
|
||||
</div>
|
||||
<span>{{ formatDate(date.toISOString(), dateFormat) }}</span>
|
||||
<span class="block text-xs text-muted-foreground font-medium mt-1">
|
||||
{{ formatHumanReadableDuration(totalSeconds, intervalFormat, numberFormat) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,58 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, type ComputedRef } from 'vue';
|
||||
import { formatHumanReadableDuration, getDayJsInstance } from '../utils/time';
|
||||
import type { Organization } from '@/packages/api/src';
|
||||
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
projectName?: string | null;
|
||||
taskName?: string | null;
|
||||
clientName?: string | null;
|
||||
durationSeconds?: number;
|
||||
start?: string | Date | null;
|
||||
end?: string | Date | null;
|
||||
}>();
|
||||
|
||||
const effectiveDurationSeconds = computed(() => {
|
||||
if (typeof props.durationSeconds === 'number') {
|
||||
return props.durationSeconds;
|
||||
}
|
||||
if (props.start && props.end) {
|
||||
const end = getDayJsInstance()(props.end as unknown as string | Date);
|
||||
const start = getDayJsInstance()(props.start as unknown as string | Date);
|
||||
const minutes = end.diff(start, 'minutes');
|
||||
return minutes * 60;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined;
|
||||
const intervalFormat = computed(() => organization?.value?.interval_format);
|
||||
const numberFormat = computed(() => organization?.value?.number_format);
|
||||
|
||||
const formattedDuration = computed(() =>
|
||||
formatHumanReadableDuration(
|
||||
effectiveDurationSeconds.value,
|
||||
intervalFormat.value,
|
||||
numberFormat.value
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-xs leading-tight">
|
||||
<div class="font-semibold mb-0.5">{{ title }}</div>
|
||||
<div v-if="projectName" class="font-medium text-[0.6875rem] opacity-90">
|
||||
{{ projectName }}
|
||||
</div>
|
||||
<div v-if="taskName" class="font-medium text-[0.6875rem] opacity-90">
|
||||
{{ taskName }}
|
||||
</div>
|
||||
<div v-if="clientName" class="text-[0.625rem] italic opacity-85">
|
||||
{{ clientName }}
|
||||
</div>
|
||||
<div class="text-[0.625rem] font-semibold opacity-90 mt-0.5">
|
||||
{{ formattedDuration }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,595 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import FullCalendar from '@fullcalendar/vue3';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import timeGridPlugin from '@fullcalendar/timegrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import type { DatesSetArg, EventClickArg, EventDropArg, EventChangeArg } from '@fullcalendar/core';
|
||||
import { computed, ref, watch, inject, type ComputedRef } from 'vue';
|
||||
import chroma from 'chroma-js';
|
||||
import { useCssVariable } from '@/utils/useCssVariable';
|
||||
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
|
||||
import { getUserTimezone, getWeekStart } from '../utils/settings';
|
||||
import { LoadingSpinner, TimeEntryCreateModal, TimeEntryEditModal } from '..';
|
||||
import FullCalendarEventContent from './FullCalendarEventContent.vue';
|
||||
import FullCalendarDayHeader from './FullCalendarDayHeader.vue';
|
||||
import type {
|
||||
TimeEntry,
|
||||
Project,
|
||||
Client,
|
||||
Task,
|
||||
CreateProjectBody,
|
||||
CreateClientBody,
|
||||
Tag,
|
||||
Organization,
|
||||
} from '@/packages/api/src';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
type CalendarExtendedProps = { timeEntry: TimeEntry } & Record<string, unknown>;
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'dates-change', payload: { start: Date; end: Date }): void;
|
||||
(e: 'refresh'): void;
|
||||
}>();
|
||||
|
||||
const props = defineProps<{
|
||||
timeEntries: TimeEntry[];
|
||||
projects: Project[];
|
||||
tasks: Task[];
|
||||
clients: Client[];
|
||||
tags: Tag[];
|
||||
loading?: boolean;
|
||||
|
||||
// Permissions / feature flags
|
||||
enableEstimatedTime: boolean;
|
||||
|
||||
createTimeEntry: (
|
||||
entry: Omit<TimeEntry, 'id' | 'organization_id' | 'user_id'>
|
||||
) => Promise<void>;
|
||||
updateTimeEntry: (entry: TimeEntry) => Promise<void>;
|
||||
deleteTimeEntry: (timeEntryId: string) => Promise<void>;
|
||||
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||
createTag: (name: string) => Promise<Tag | undefined>;
|
||||
}>();
|
||||
|
||||
// Local component state
|
||||
const newEventStart = ref<Dayjs | null>(null);
|
||||
const newEventEnd = ref<Dayjs | null>(null);
|
||||
const showCreateTimeEntryModal = ref<boolean>(false);
|
||||
const showEditTimeEntryModal = ref<boolean>(false);
|
||||
const selectedTimeEntry = ref<TimeEntry | null>(null);
|
||||
|
||||
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);
|
||||
|
||||
// Inject organization data for settings
|
||||
const organization = inject<ComputedRef<Organization>>('organization');
|
||||
|
||||
// Helper function to convert week start to FullCalendar firstDay value
|
||||
const getFirstDay = () => {
|
||||
const weekStart = getWeekStart();
|
||||
const weekStartMap: Record<string, number> = {
|
||||
'sunday': 0,
|
||||
'monday': 1,
|
||||
'tuesday': 2,
|
||||
'wednesday': 3,
|
||||
'thursday': 4,
|
||||
'friday': 5,
|
||||
'saturday': 6,
|
||||
};
|
||||
return weekStartMap[weekStart] ?? 1; // Default to Monday if not found
|
||||
};
|
||||
|
||||
// Helper function to get time format for slot labels
|
||||
const getSlotLabelFormat = () => {
|
||||
const timeFormat = organization?.value?.time_format || '24-hours';
|
||||
if (timeFormat === '12-hours') {
|
||||
return {
|
||||
hour: 'numeric' as const,
|
||||
hour12: true,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
hour: '2-digit' as const,
|
||||
minute: '2-digit' as const,
|
||||
hour12: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const cssBackground = useCssVariable('--color-bg-background');
|
||||
|
||||
const events = computed(() => {
|
||||
const themeBackground = (() => {
|
||||
return cssBackground.value?.trim();
|
||||
})();
|
||||
return props.timeEntries
|
||||
?.filter((timeEntry) => timeEntry.end !== null)
|
||||
?.map((timeEntry) => {
|
||||
const project = props.projects.find((p) => p.id === timeEntry.project_id);
|
||||
const client = props.clients.find((c) => c.id === project?.client_id);
|
||||
const task = props.tasks.find((t) => t.id === timeEntry.task_id);
|
||||
const duration = getDayJsInstance()(timeEntry.end!).diff(
|
||||
getDayJsInstance()(timeEntry.start),
|
||||
'minutes'
|
||||
);
|
||||
|
||||
const title = timeEntry.description || 'No description';
|
||||
|
||||
const baseColor = project?.color || '#6B7280';
|
||||
const backgroundColor = chroma.mix(baseColor, themeBackground, 0.65, 'lab').hex();
|
||||
const borderColor = chroma.mix(baseColor, themeBackground, 0.5, 'lab').hex();
|
||||
|
||||
// For 0-duration events, display them with minimum visual duration but preserve actual duration
|
||||
const startTime = getLocalizedDayJs(timeEntry.start);
|
||||
const endTime =
|
||||
duration === 0
|
||||
? startTime.add(1, 'second') // Show as 1 second for minimal visibility
|
||||
: getLocalizedDayJs(timeEntry.end!);
|
||||
|
||||
return {
|
||||
id: timeEntry.id,
|
||||
start: startTime.format(),
|
||||
end: endTime.format(),
|
||||
title,
|
||||
backgroundColor,
|
||||
borderColor,
|
||||
textColor: 'var(--foreground)',
|
||||
extendedProps: {
|
||||
timeEntry,
|
||||
project,
|
||||
client,
|
||||
task,
|
||||
duration,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// Daily totals used in day header
|
||||
const dailyTotals = computed(() => {
|
||||
const totals: Record<string, number> = {};
|
||||
props.timeEntries
|
||||
.filter((entry) => entry.end !== null)
|
||||
.forEach((entry) => {
|
||||
const date = getDayJsInstance()(entry.start).format('YYYY-MM-DD');
|
||||
const duration = getDayJsInstance()(entry.end!).diff(
|
||||
getDayJsInstance()(entry.start),
|
||||
'minutes'
|
||||
);
|
||||
totals[date] = (totals[date] || 0) + duration;
|
||||
});
|
||||
return totals;
|
||||
});
|
||||
|
||||
function emitDatesChange(arg: DatesSetArg) {
|
||||
emit('dates-change', { start: arg.start, end: arg.end });
|
||||
}
|
||||
|
||||
function handleDateSelect(arg: { start: Date; end: Date }) {
|
||||
const startTime = getDayJsInstance()(arg.start.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.utc();
|
||||
const endTime = getDayJsInstance()(arg.end.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.utc();
|
||||
newEventStart.value = startTime;
|
||||
newEventEnd.value = endTime;
|
||||
showCreateTimeEntryModal.value = true;
|
||||
}
|
||||
|
||||
function handleEventClick(arg: EventClickArg) {
|
||||
const ext = arg.event.extendedProps as CalendarExtendedProps;
|
||||
selectedTimeEntry.value = ext.timeEntry;
|
||||
showEditTimeEntryModal.value = true;
|
||||
}
|
||||
|
||||
async function handleEventDrop(arg: EventDropArg) {
|
||||
const ext = arg.event.extendedProps as CalendarExtendedProps;
|
||||
const timeEntry = ext.timeEntry;
|
||||
if (!arg.event.start || !arg.event.end) return;
|
||||
const updatedTimeEntry = {
|
||||
...timeEntry,
|
||||
start: getDayJsInstance()(arg.event.start.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.utc()
|
||||
.format(),
|
||||
end: getDayJsInstance()(arg.event.end.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.utc()
|
||||
.format(),
|
||||
} as TimeEntry;
|
||||
await props.updateTimeEntry(updatedTimeEntry);
|
||||
emit('refresh');
|
||||
}
|
||||
|
||||
async function handleEventResize(arg: EventChangeArg) {
|
||||
const ext = arg.event.extendedProps as CalendarExtendedProps;
|
||||
const timeEntry = ext.timeEntry;
|
||||
if (!arg.event.start || !arg.event.end) return;
|
||||
const updatedTimeEntry = {
|
||||
...timeEntry,
|
||||
start: getDayJsInstance()(arg.event.start.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.utc()
|
||||
.format(),
|
||||
end: getDayJsInstance()(arg.event.end.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.utc()
|
||||
.format(),
|
||||
} as TimeEntry;
|
||||
await props.updateTimeEntry(updatedTimeEntry);
|
||||
emit('refresh');
|
||||
}
|
||||
|
||||
const calendarOptions = computed(() => ({
|
||||
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
|
||||
initialView: 'timeGridWeek',
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'timeGridWeek,timeGridDay',
|
||||
},
|
||||
height: 'parent',
|
||||
slotMinTime: '00:00:00',
|
||||
slotMaxTime: '24:00:00',
|
||||
slotDuration: '00:15:00',
|
||||
slotLabelInterval: '01:00:00',
|
||||
slotLabelFormat: getSlotLabelFormat(),
|
||||
snapDuration: '00:15:00',
|
||||
firstDay: getFirstDay(),
|
||||
allDaySlot: false,
|
||||
nowIndicator: true,
|
||||
selectable: true,
|
||||
selectMirror: true,
|
||||
editable: true,
|
||||
eventResizableFromStart: true,
|
||||
eventDurationEditable: true,
|
||||
timeZone: 'America/Adak',
|
||||
eventStartEditable: true,
|
||||
select: handleDateSelect,
|
||||
eventClick: handleEventClick,
|
||||
eventDrop: handleEventDrop,
|
||||
eventResize: handleEventResize,
|
||||
datesSet: emitDatesChange,
|
||||
|
||||
events: events.value,
|
||||
}));
|
||||
|
||||
watch(showCreateTimeEntryModal, (value) => {
|
||||
if (!value) {
|
||||
newEventStart.value = null;
|
||||
newEventEnd.value = null;
|
||||
// Ensure FullCalendar clears the selection mirror when modal closes
|
||||
calendarRef.value?.getApi().unselect();
|
||||
emit('refresh');
|
||||
}
|
||||
});
|
||||
|
||||
watch(showEditTimeEntryModal, (value) => {
|
||||
if (!value) {
|
||||
selectedTimeEntry.value = null;
|
||||
emit('refresh');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full relative h-full flex-1">
|
||||
<div v-if="loading" class="flex items-center justify-center h-full">
|
||||
<div class="flex flex-col items-center space-y-4">
|
||||
<LoadingSpinner class="h-8 w-8" />
|
||||
<p class="text-muted-foreground">Loading calendar data...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TimeEntryCreateModal
|
||||
v-model:show="showCreateTimeEntryModal"
|
||||
:enable-estimated-time="enableEstimatedTime"
|
||||
:create-time-entry="createTimeEntry"
|
||||
:create-client="createClient"
|
||||
:create-project="createProject"
|
||||
:create-tag="createTag"
|
||||
:tags="tags as any"
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:clients="clients"
|
||||
:start="newEventStart ? newEventStart.toISOString() : undefined"
|
||||
:end="newEventEnd ? newEventEnd.toISOString() : undefined" />
|
||||
|
||||
<TimeEntryEditModal
|
||||
v-model:show="showEditTimeEntryModal"
|
||||
:time-entry="selectedTimeEntry as any"
|
||||
:enable-estimated-time="enableEstimatedTime"
|
||||
:update-time-entry="updateTimeEntry"
|
||||
:delete-time-entry="deleteTimeEntry"
|
||||
:create-client="createClient"
|
||||
:create-project="createProject"
|
||||
:create-tag="createTag"
|
||||
:tags="tags as any"
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:clients="clients" />
|
||||
<FullCalendar ref="calendarRef" class="fullcalendar" :options="calendarOptions">
|
||||
<template #eventContent="arg">
|
||||
<FullCalendarEventContent
|
||||
:title="arg.event.title"
|
||||
:project-name="(arg.event.extendedProps as any).project?.name"
|
||||
:task-name="(arg.event.extendedProps as any).task?.name"
|
||||
:client-name="(arg.event.extendedProps as any).client?.name"
|
||||
:duration-seconds="
|
||||
((arg.event.extendedProps as any).duration ?? undefined)
|
||||
? (arg.event.extendedProps as any).duration * 60
|
||||
: undefined
|
||||
"
|
||||
:start="arg.event.start as any"
|
||||
:end="arg.event.end as any" />
|
||||
</template>
|
||||
<template #dayHeaderContent="arg">
|
||||
<FullCalendarDayHeader
|
||||
:date="arg.date"
|
||||
:total-minutes="
|
||||
dailyTotals[getDayJsInstance()(arg.date).format('YYYY-MM-DD')] || 0
|
||||
" />
|
||||
</template>
|
||||
</FullCalendar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fullcalendar {
|
||||
height: 100%;
|
||||
--fc-border-color: var(--border);
|
||||
}
|
||||
|
||||
/* FullCalendar theme customization */
|
||||
.fullcalendar :deep(.fc) {
|
||||
background-color: var(--theme-color-default-background);
|
||||
color: var(--foreground);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-timegrid-slot) {
|
||||
height: 25px;
|
||||
transition: height 0.2s ease;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-timegrid-slot-label) {
|
||||
background-color: var(--theme-color-default-background);
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-toolbar) {
|
||||
background-color: var(--theme-color-default-background);
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-toolbar-title) {
|
||||
color: var(--foreground);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-button) {
|
||||
background-color: var(--secondary);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--foreground);
|
||||
font-weight: 500;
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-button:hover) {
|
||||
background-color: var(--muted);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-button:focus) {
|
||||
box-shadow: 0 0 0 2px var(--ring);
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-button-active) {
|
||||
background-color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-col-header) {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-col-header-cell) {
|
||||
border-right: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0.75rem 0.5rem;
|
||||
background-color: var(--theme-color-default-background);
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-timegrid-axis) {
|
||||
background-color: var(--theme-color-default-background) !important;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-col-header-cell .fc-col-header-cell-cushion) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-timegrid-axis) {
|
||||
background-color: var(--theme-color-default-background);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Quarter-hour slots - transparent borders */
|
||||
.fullcalendar :deep(.fc-timegrid-slot-minor.fc-timegrid-slot-label) {
|
||||
border-top: 1px solid transparent;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-timegrid-slot-minor.fc-timegrid-slot-lane) {
|
||||
--tw-border-opacity: 0;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-day-today.fc-col-header-cell) {
|
||||
background-color: var(--color-accent-default);
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-day-today) {
|
||||
background-color: var(--theme-color-default-background);
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-now-indicator) {
|
||||
border-color: var(--primary);
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-event) {
|
||||
border-radius: var(--radius);
|
||||
padding: 0.45rem 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--theme-shadow-card);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-v-event) {
|
||||
background-color: var(--muted);
|
||||
border-color: var(--muted);
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-event-title) {
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Enhanced FullCalendar resize handles */
|
||||
.fullcalendar :deep(.fc-event-resizer) {
|
||||
position: absolute;
|
||||
z-index: 99;
|
||||
background: '#FFF';
|
||||
border-radius: 2px;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
left: 0;
|
||||
transition: all 0.2s ease;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-event-resizer-start) {
|
||||
top: -2px;
|
||||
cursor: n-resize;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-event-resizer-end) {
|
||||
bottom: -2px;
|
||||
cursor: s-resize;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-event:hover .fc-event-resizer) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-event-resizer:hover) {
|
||||
background: '#FFF';
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
/* Update the earlier hover rule to include the shadow */
|
||||
.fullcalendar :deep(.fc-event:hover) {
|
||||
opacity: 1;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: var(--theme-shadow-dropdown);
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-timegrid-event-harness) {
|
||||
margin: 0 1px;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-highlight) {
|
||||
background-color: var(--theme-color-default-background);
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-select-mirror) {
|
||||
background-color: var(--accent);
|
||||
border: 1px solid var(--primary);
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-scrollgrid) {
|
||||
border: 1px solid var(--border);
|
||||
border-left: 1px solid transparent;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-scrollgrid-section > td) {
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-timegrid-body) {
|
||||
background-color: var(--theme-color-default-background);
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-timegrid-col) {
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-timegrid-axis-cushion) {
|
||||
color: var(--theme-text-secondary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-timegrid-slot-label-cushion) {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-col-header-cell-cushion) {
|
||||
color: var(--foreground);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Daily totals styling */
|
||||
.fullcalendar :deep(.fc-col-header-cell .text-muted-foreground) {
|
||||
color: var(--muted-foreground);
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
/* Reduce visibility of time slot dividers */
|
||||
.fullcalendar :deep(.fc-timegrid-divider) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Make scrollbars gray */
|
||||
.fullcalendar :deep(.fc-scroller) {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--muted-foreground) transparent;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-scroller::-webkit-scrollbar) {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-scroller::-webkit-scrollbar-track) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-scroller::-webkit-scrollbar-thumb) {
|
||||
background-color: var(--muted-foreground);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-scroller::-webkit-scrollbar-thumb:hover) {
|
||||
background-color: var(--foreground);
|
||||
}
|
||||
|
||||
/* Improve time axis styling */
|
||||
.fullcalendar :deep(.fc-timegrid-axis-chunk) {
|
||||
background-color: var(--theme-color-default-background);
|
||||
}
|
||||
|
||||
/* Simple event main styling */
|
||||
.fullcalendar :deep(.fc-event-main) {
|
||||
padding: 0.125rem 0.25rem;
|
||||
}
|
||||
</style>
|
||||
@@ -92,7 +92,7 @@ function onSelectChange(checked: boolean) {
|
||||
class="border-b border-default-background-separator bg-row-background min-w-0 transition"
|
||||
data-testid="time_entry_row">
|
||||
<MainContainer class="min-w-0">
|
||||
<div class="sm:flex py-2 items-center min-w-0 justify-between group">
|
||||
<div class="sm:flex py-1.5 items-center min-w-0 justify-between group">
|
||||
<div class="flex space-x-3 items-center min-w-0">
|
||||
<Checkbox
|
||||
:checked="
|
||||
@@ -172,7 +172,6 @@ function onSelectChange(checked: boolean) {
|
||||
class="opacity-20 flex group-hover:opacity-100 focus-visible:opacity-100"
|
||||
@changed="onStartStopClick(timeEntry)"></TimeTrackerStartStop>
|
||||
<TimeEntryMoreOptionsDropdown
|
||||
:show-edit="false"
|
||||
@delete="
|
||||
deleteTimeEntries(timeEntry?.timeEntries ?? [])
|
||||
"></TimeEntryMoreOptionsDropdown>
|
||||
|
||||
@@ -41,8 +41,6 @@ const props = defineProps<{
|
||||
projects: Project[];
|
||||
tasks: Task[];
|
||||
clients: Client[];
|
||||
start?: string;
|
||||
end?: string;
|
||||
}>();
|
||||
|
||||
const description = ref<HTMLInputElement | null>(null);
|
||||
@@ -65,27 +63,7 @@ const timeEntryDefaultValues = {
|
||||
end: getDayJsInstance().utc().format(),
|
||||
};
|
||||
|
||||
const timeEntry = ref({
|
||||
...timeEntryDefaultValues,
|
||||
});
|
||||
|
||||
// update the localStart and localEnd when props.start or props.end get updates
|
||||
watch(
|
||||
() => props.start,
|
||||
(value) => {
|
||||
if (value) {
|
||||
localStart.value = getLocalizedDayJs(value).format();
|
||||
}
|
||||
}
|
||||
);
|
||||
watch(
|
||||
() => props.end,
|
||||
(value) => {
|
||||
if (value) {
|
||||
localEnd.value = getLocalizedDayJs(value).format();
|
||||
}
|
||||
}
|
||||
);
|
||||
const timeEntry = ref({ ...timeEntryDefaultValues });
|
||||
|
||||
watch(
|
||||
() => timeEntry.value.project_id,
|
||||
|
||||
@@ -1,311 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
|
||||
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
|
||||
import { TagIcon } from '@heroicons/vue/20/solid';
|
||||
import { getLocalizedDayJs } from '@/packages/ui/src/utils/time';
|
||||
import type {
|
||||
CreateClientBody,
|
||||
CreateProjectBody,
|
||||
Project,
|
||||
Client,
|
||||
TimeEntry,
|
||||
} from '@/packages/api/src';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import { canCreateProjects } from '@/utils/permissions';
|
||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||
import { Badge } from '@/packages/ui/src';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
import SelectDropdown from '@/packages/ui/src/Input/SelectDropdown.vue';
|
||||
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
|
||||
import DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';
|
||||
|
||||
import { InformationCircleIcon } from '@heroicons/vue/20/solid';
|
||||
import type { Tag, Task } from '@/packages/api/src';
|
||||
import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';
|
||||
|
||||
const show = defineModel('show', { default: false });
|
||||
const saving = ref(false);
|
||||
const deleting = ref(false);
|
||||
|
||||
const props = defineProps<{
|
||||
timeEntry: TimeEntry | null;
|
||||
enableEstimatedTime: boolean;
|
||||
updateTimeEntry: (entry: TimeEntry) => Promise<void>;
|
||||
deleteTimeEntry: (timeEntryId: string) => Promise<void>;
|
||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
||||
createTag: (name: string) => Promise<Tag | undefined>;
|
||||
tags: Tag[];
|
||||
projects: Project[];
|
||||
tasks: Task[];
|
||||
clients: Client[];
|
||||
}>();
|
||||
|
||||
const description = ref<HTMLInputElement | null>(null);
|
||||
|
||||
watch(show, (value) => {
|
||||
if (value) {
|
||||
nextTick(() => {
|
||||
description.value?.focus();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const editableTimeEntry = ref<TimeEntry | null>(null);
|
||||
|
||||
watch(
|
||||
() => props.timeEntry,
|
||||
(newTimeEntry) => {
|
||||
if (newTimeEntry) {
|
||||
editableTimeEntry.value = { ...newTimeEntry };
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => editableTimeEntry.value?.project_id,
|
||||
(value) => {
|
||||
if (value && editableTimeEntry.value) {
|
||||
// check if project is billable by default and set billable accordingly
|
||||
const project = props.projects.find((p) => p.id === value);
|
||||
if (project) {
|
||||
editableTimeEntry.value.billable = project.is_billable;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const localStart = computed({
|
||||
get: () =>
|
||||
editableTimeEntry.value ? getLocalizedDayJs(editableTimeEntry.value.start).format() : '',
|
||||
set: (value: string) => {
|
||||
if (editableTimeEntry.value) {
|
||||
editableTimeEntry.value.start = getLocalizedDayJs(value).utc().format();
|
||||
if (getLocalizedDayJs(localEnd.value).isBefore(getLocalizedDayJs(value))) {
|
||||
localEnd.value = value;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const localEnd = computed({
|
||||
get: () =>
|
||||
editableTimeEntry.value ? getLocalizedDayJs(editableTimeEntry.value.end).format() : '',
|
||||
set: (value: string) => {
|
||||
if (editableTimeEntry.value) {
|
||||
editableTimeEntry.value.end = getLocalizedDayJs(value).utc().format();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
if (editableTimeEntry.value) {
|
||||
saving.value = true;
|
||||
try {
|
||||
await props.updateTimeEntry(editableTimeEntry.value);
|
||||
show.value = false;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteEntry() {
|
||||
if (editableTimeEntry.value) {
|
||||
deleting.value = true;
|
||||
try {
|
||||
await props.deleteTimeEntry(editableTimeEntry.value.id);
|
||||
show.value = false;
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const billableProxy = computed({
|
||||
get: () =>
|
||||
editableTimeEntry.value ? (editableTimeEntry.value.billable ? 'true' : 'false') : 'false',
|
||||
set: (value: string) => {
|
||||
if (editableTimeEntry.value) {
|
||||
editableTimeEntry.value.billable = value === 'true';
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
type BillableOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal closeable :show="show" @close="show = false">
|
||||
<template #title>
|
||||
<div class="flex space-x-2">
|
||||
<span> Edit time entry </span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div v-if="editableTimeEntry" class="space-y-4">
|
||||
<div class="sm:flex items-end space-y-2 sm:space-y-0 sm:space-x-4">
|
||||
<div class="flex-1">
|
||||
<TextInput
|
||||
id="description"
|
||||
ref="description"
|
||||
v-model="editableTimeEntry.description"
|
||||
placeholder="What did you work on?"
|
||||
type="text"
|
||||
class="mt-1 block w-full"
|
||||
@keydown.enter="submit" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="sm:flex justify-between items-end space-y-2 sm:space-y-0 pt-4 sm:space-x-4">
|
||||
<div class="flex w-full items-center space-x-2 justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
v-model:project="editableTimeEntry.project_id"
|
||||
v-model:task="editableTimeEntry.task_id"
|
||||
:clients
|
||||
:create-project
|
||||
:create-client
|
||||
:can-create-project="canCreateProjects()"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
size="xlarge"
|
||||
class="bg-input-background"
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:enable-estimated-time="
|
||||
enableEstimatedTime
|
||||
"></TimeTrackerProjectTaskDropdown>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex-col">
|
||||
<TagDropdown
|
||||
v-model="editableTimeEntry.tags"
|
||||
:create-tag
|
||||
:tags="tags">
|
||||
<template #trigger>
|
||||
<Badge
|
||||
class="bg-input-background"
|
||||
tag="button"
|
||||
size="xlarge">
|
||||
<TagIcon
|
||||
v-if="editableTimeEntry.tags.length === 0"
|
||||
class="w-4"></TagIcon>
|
||||
<div
|
||||
v-else
|
||||
class="bg-accent-300/20 w-5 h-5 font-medium rounded flex items-center transition justify-center">
|
||||
{{ editableTimeEntry.tags.length }}
|
||||
</div>
|
||||
<span>Tags</span>
|
||||
</Badge>
|
||||
</template>
|
||||
</TagDropdown>
|
||||
</div>
|
||||
<div class="flex-col">
|
||||
<SelectDropdown
|
||||
v-model="billableProxy"
|
||||
:get-key-from-item="(item: BillableOption) => item.value"
|
||||
:get-name-for-item="(item: BillableOption) => item.label"
|
||||
:items="[
|
||||
{
|
||||
label: 'Billable',
|
||||
value: 'true',
|
||||
},
|
||||
{
|
||||
label: 'Non Billable',
|
||||
value: 'false',
|
||||
},
|
||||
]">
|
||||
<template #trigger>
|
||||
<Badge
|
||||
class="bg-input-background"
|
||||
tag="button"
|
||||
size="xlarge">
|
||||
<BillableIcon class="h-4"></BillableIcon>
|
||||
<span>{{
|
||||
editableTimeEntry.billable
|
||||
? 'Billable'
|
||||
: 'Non-Billable'
|
||||
}}</span>
|
||||
</Badge>
|
||||
</template>
|
||||
</SelectDropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex pt-4 space-x-4">
|
||||
<div class="flex-1">
|
||||
<InputLabel>Duration</InputLabel>
|
||||
<div class="space-y-2 mt-1 flex flex-col">
|
||||
<DurationHumanInput
|
||||
v-model:start="localStart"
|
||||
v-model:end="localEnd"
|
||||
name="Duration"></DurationHumanInput>
|
||||
<div class="text-sm flex space-x-1">
|
||||
<InformationCircleIcon
|
||||
class="w-4 text-text-quaternary"></InformationCircleIcon>
|
||||
<span class="text-text-secondary text-xs">
|
||||
You can type natural language here f.e.
|
||||
<span class="font-semibold"> 2h 30m</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="">
|
||||
<InputLabel>Start</InputLabel>
|
||||
<div class="flex flex-col items-center space-y-2 mt-1">
|
||||
<TimePickerSimple v-model="localStart" size="large"></TimePickerSimple>
|
||||
<DatePicker
|
||||
v-model="localStart"
|
||||
tabindex="1"
|
||||
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
|
||||
</div>
|
||||
</div>
|
||||
<div class="">
|
||||
<InputLabel>End</InputLabel>
|
||||
<div class="flex flex-col items-center space-y-2 mt-1">
|
||||
<TimePickerSimple v-model="localEnd" size="large"></TimePickerSimple>
|
||||
<DatePicker
|
||||
v-model="localEnd"
|
||||
tabindex="1"
|
||||
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex justify-between w-full">
|
||||
<SecondaryButton
|
||||
tabindex="2"
|
||||
class="bg-red-600 hover:bg-red-700 text-white border-red-600 hover:border-red-700"
|
||||
:disabled="deleting || saving"
|
||||
@click="deleteEntry">
|
||||
{{ deleting ? 'Deleting...' : 'Delete' }}
|
||||
</SecondaryButton>
|
||||
<div class="flex space-x-3">
|
||||
<SecondaryButton tabindex="2" @click="show = false"> Cancel</SecondaryButton>
|
||||
<PrimaryButton
|
||||
tabindex="2"
|
||||
:class="{ 'opacity-25': saving }"
|
||||
:disabled="saving || deleting"
|
||||
@click="submit">
|
||||
{{ saving ? 'Updating...' : 'Update Time Entry' }}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -63,7 +63,7 @@ const showMassUpdateModal = ref(false);
|
||||
:class="
|
||||
twMerge(
|
||||
props.class,
|
||||
'text-sm py-1.5 font-medium bg-secondary flex items-center space-x-3'
|
||||
'text-sm py-1.5 font-medium border-t border-b bg-secondary border-border-secondary flex items-center space-x-3'
|
||||
)
|
||||
">
|
||||
<Checkbox
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { TrashIcon, PencilIcon } from '@heroicons/vue/20/solid';
|
||||
import { TrashIcon } from '@heroicons/vue/20/solid';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -7,17 +7,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/Components/ui/dropdown-menu';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
showEdit?: boolean;
|
||||
}>(),
|
||||
{
|
||||
showEdit: true,
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [];
|
||||
delete: [];
|
||||
}>();
|
||||
</script>
|
||||
@@ -43,14 +33,6 @@ const emit = defineEmits<{
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="min-w-[150px]" align="end">
|
||||
<DropdownMenuItem
|
||||
v-if="props.showEdit"
|
||||
data-testid="time_entry_edit"
|
||||
class="flex items-center space-x-3 cursor-pointer"
|
||||
@click="emit('edit')">
|
||||
<PencilIcon class="w-5" />
|
||||
<span>Edit</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-testid="time_entry_delete"
|
||||
class="flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive"
|
||||
|
||||
@@ -16,9 +16,8 @@ import TimeEntryDescriptionInput from '@/packages/ui/src/TimeEntry/TimeEntryDesc
|
||||
import TimeEntryRowTagDropdown from '@/packages/ui/src/TimeEntry/TimeEntryRowTagDropdown.vue';
|
||||
import TimeEntryRowDurationInput from '@/packages/ui/src/TimeEntry/TimeEntryRowDurationInput.vue';
|
||||
import TimeEntryMoreOptionsDropdown from '@/packages/ui/src/TimeEntry/TimeEntryMoreOptionsDropdown.vue';
|
||||
import { TimeEntryEditModal } from '@/packages/ui/src';
|
||||
import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
|
||||
import { Checkbox } from '@/packages/ui/src';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
@@ -47,8 +46,6 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{ selected: []; unselected: [] }>();
|
||||
|
||||
const showEditModal = ref(false);
|
||||
|
||||
function updateTimeEntryDescription(description: string) {
|
||||
props.updateTimeEntry({ ...props.timeEntry, description });
|
||||
}
|
||||
@@ -90,20 +87,6 @@ function onSelectChange(checked: boolean) {
|
||||
emit('unselected');
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
showEditModal.value = true;
|
||||
}
|
||||
|
||||
async function handleUpdateTimeEntry(updatedEntry: TimeEntry) {
|
||||
props.updateTimeEntry(updatedEntry);
|
||||
showEditModal.value = false;
|
||||
}
|
||||
|
||||
async function handleDeleteTimeEntry() {
|
||||
props.deleteTimeEntry();
|
||||
showEditModal.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -165,26 +148,11 @@ async function handleDeleteTimeEntry() {
|
||||
class="opacity-20 flex focus-visible:opacity-100 group-hover:opacity-100"
|
||||
@changed="onStartStopClick"></TimeTrackerStartStop>
|
||||
<TimeEntryMoreOptionsDropdown
|
||||
@edit="handleEdit"
|
||||
@delete="deleteTimeEntry"></TimeEntryMoreOptionsDropdown>
|
||||
</div>
|
||||
</div>
|
||||
</MainContainer>
|
||||
</div>
|
||||
|
||||
<TimeEntryEditModal
|
||||
v-model:show="showEditModal"
|
||||
:time-entry="timeEntry"
|
||||
:enable-estimated-time="enableEstimatedTime"
|
||||
:update-time-entry="handleUpdateTimeEntry"
|
||||
:delete-time-entry="handleDeleteTimeEntry"
|
||||
:create-client="createClient"
|
||||
:create-project="createProject"
|
||||
:create-tag="createTag"
|
||||
:tags="tags"
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:clients="clients" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -27,11 +27,7 @@ import Checkbox from './Input/Checkbox.vue';
|
||||
import TimeEntryGroupedTable from './TimeEntry/TimeEntryGroupedTable.vue';
|
||||
import TimeEntryMassActionRow from './TimeEntry/TimeEntryMassActionRow.vue';
|
||||
import TimeEntryCreateModal from './TimeEntry/TimeEntryCreateModal.vue';
|
||||
import TimeEntryEditModal from './TimeEntry/TimeEntryEditModal.vue';
|
||||
import MoreOptionsDropdown from './MoreOptionsDropdown.vue';
|
||||
import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue';
|
||||
import FullCalendarDayHeader from './FullCalendar/FullCalendarDayHeader.vue';
|
||||
import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';
|
||||
|
||||
export {
|
||||
money,
|
||||
@@ -56,8 +52,4 @@ export {
|
||||
TimeEntryMassActionRow,
|
||||
MoreOptionsDropdown,
|
||||
TimeEntryCreateModal,
|
||||
TimeEntryEditModal,
|
||||
FullCalendarEventContent,
|
||||
FullCalendarDayHeader,
|
||||
TimeEntryCalendar,
|
||||
};
|
||||
|
||||
@@ -36,10 +36,6 @@ Route::middleware([
|
||||
return Inertia::render('Time');
|
||||
})->name('time');
|
||||
|
||||
Route::get('/calendar', function () {
|
||||
return Inertia::render('Calendar');
|
||||
})->name('calendar');
|
||||
|
||||
Route::get('/reporting', function () {
|
||||
return Inertia::render('Reporting');
|
||||
})->name('reporting');
|
||||
|
||||
Reference in New Issue
Block a user