add e2e tests for employee restrictions

This commit is contained in:
Gregor Vostrak
2026-02-10 14:41:04 +01:00
parent e45662c715
commit 8be55359ce
21 changed files with 1211 additions and 37 deletions
+36
View File
@@ -6,6 +6,7 @@ import {
createBillableProjectViaApi,
createProjectViaApi,
createBareTimeEntryViaApi,
createTimeEntryViaApi,
} from './utils/api';
async function goToCalendar(page: Page) {
@@ -288,3 +289,38 @@ test('test that deleting time entry from calendar modal works', async ({ page, c
// Verify the event is removed from the calendar
await expect(page.locator('.fc-event').filter({ hasText: description })).not.toBeVisible();
});
// =============================================
// Employee Permission Tests
// =============================================
test.describe('Employee Calendar Isolation', () => {
test('employee can only see their own time entries on the calendar', async ({
ctx,
employee,
}) => {
// Owner creates a time entry for today
const ownerDescription = 'OwnerCalEntry ' + Math.floor(Math.random() * 10000);
await createBareTimeEntryViaApi(ctx, ownerDescription, '1h');
// Create a time entry for the employee for today
const employeeDescription = 'EmpCalEntry ' + Math.floor(Math.random() * 10000);
await createTimeEntryViaApi(
{ ...ctx, memberId: employee.memberId },
{ description: employeeDescription, duration: '30min' }
);
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/calendar');
await expect(employee.page.locator('.fc')).toBeVisible({ timeout: 10000 });
// Employee's event IS visible
await expect(
employee.page.locator('.fc-event').filter({ hasText: employeeDescription }).first()
).toBeVisible({ timeout: 10000 });
// Owner's event is NOT visible
await expect(
employee.page.locator('.fc-event').filter({ hasText: ownerDescription })
).not.toBeVisible();
});
});
+89 -1
View File
@@ -2,7 +2,12 @@ import { expect } from '@playwright/test';
import type { Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
import { createClientViaApi } from './utils/api';
import {
createClientViaApi,
createProjectMemberViaApi,
createProjectViaApi,
createPublicProjectViaApi,
} from './utils/api';
async function goToClientsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/clients');
@@ -125,3 +130,86 @@ test('test that deleting a client via actions menu works', async ({ page, ctx })
await expect(page.getByTestId('client_table')).not.toContainText(clientName);
});
// =============================================
// Employee Permission Tests
// =============================================
test.describe('Employee Clients Restrictions', () => {
test('employee can view clients but cannot create', async ({ ctx, employee }) => {
// Create a client with a public project so the employee can see the client
const clientName = 'EmpViewClient ' + Math.floor(Math.random() * 10000);
const client = await createClientViaApi(ctx, { name: clientName });
await createPublicProjectViaApi(ctx, { name: 'EmpClientProj', client_id: client.id });
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/clients');
await expect(employee.page.getByTestId('clients_view')).toBeVisible({
timeout: 10000,
});
// Employee can see the client
await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 });
// Employee cannot see Create Client button
await expect(
employee.page.getByRole('button', { name: 'Create Client' })
).not.toBeVisible();
});
test('employee cannot see edit/delete/archive actions on clients', async ({
ctx,
employee,
}) => {
const clientName = 'EmpActionsClient ' + Math.floor(Math.random() * 10000);
const client = await createClientViaApi(ctx, { name: clientName });
await createPublicProjectViaApi(ctx, { name: 'EmpClientActProj', client_id: client.id });
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/clients');
await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 });
// Click the actions dropdown trigger to open the menu
const actionsButton = employee.page.locator(
`[aria-label='Actions for Client ${clientName}']`
);
await actionsButton.click();
// The dropdown menu items (Edit, Archive, Delete) should NOT be visible
await expect(
employee.page.locator(`[aria-label='Edit Client ${clientName}']`)
).not.toBeVisible();
await expect(
employee.page.locator(`[aria-label='Archive Client ${clientName}']`)
).not.toBeVisible();
await expect(
employee.page.locator(`[aria-label='Delete Client ${clientName}']`)
).not.toBeVisible();
});
test('employee can see client when they are a member of its private project', async ({
ctx,
employee,
}) => {
const clientName = 'EmpPrivateClient ' + Math.floor(Math.random() * 10000);
const client = await createClientViaApi(ctx, { name: clientName });
// Create a private project under this client
const project = await createProjectViaApi(ctx, {
name: 'PrivateProj',
client_id: client.id,
is_public: false,
});
// Add the employee as a project member
await createProjectMemberViaApi(ctx, project.id, {
member_id: employee.memberId,
});
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/clients');
await expect(employee.page.getByTestId('clients_view')).toBeVisible({
timeout: 10000,
});
// Employee can see the client because they are a member of its private project
await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 });
});
});
+66
View File
@@ -400,3 +400,69 @@ test.describe('Command Palette', () => {
});
});
});
// =============================================
// Employee Permission Tests
// =============================================
test.describe('Employee Command Palette Restrictions', () => {
test('employee command palette does not show restricted navigation commands', async ({
employee,
}) => {
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({
timeout: 10000,
});
// Open command palette
await employee.page.getByTestId('command_palette_button').click();
await expect(employee.page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 });
// Available navigation commands
await expect(employee.page.getByRole('option', { name: 'Go to Dashboard' })).toBeVisible();
await expect(employee.page.getByRole('option', { name: 'Go to Time' })).toBeVisible();
await expect(employee.page.getByRole('option', { name: 'Go to Calendar' })).toBeVisible();
// Restricted commands should NOT be visible
await expect(
employee.page.getByRole('option', { name: 'Go to Members' })
).not.toBeVisible();
await expect(
employee.page.getByRole('option', { name: 'Go to Settings' })
).not.toBeVisible();
});
test('employee command palette does not show create commands for restricted entities', async ({
employee,
}) => {
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({
timeout: 10000,
});
// Open command palette
await employee.page.getByTestId('command_palette_button').click();
await expect(employee.page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 });
// Search for "Create" to filter
await employee.page.locator('[role="dialog"] input').fill('Create');
await employee.page.waitForTimeout(300);
// Should NOT see create commands for restricted entities
await expect(
employee.page.getByRole('option', { name: 'Create Project' })
).not.toBeVisible();
await expect(
employee.page.getByRole('option', { name: 'Create Client' })
).not.toBeVisible();
await expect(employee.page.getByRole('option', { name: 'Create Tag' })).not.toBeVisible();
await expect(
employee.page.getByRole('option', { name: 'Invite Member' })
).not.toBeVisible();
// Should still see Create Time Entry (employees can create time entries)
await expect(
employee.page.getByRole('option', { name: 'Create Time Entry' })
).toBeVisible();
});
});
+119
View File
@@ -0,0 +1,119 @@
import { expect, test } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import type { Page } from '@playwright/test';
import {
assertThatTimerHasStarted,
assertThatTimerIsStopped,
newTimeEntryResponse,
startOrStopTimerWithButton,
stoppedTimeEntryResponse,
} from './utils/currentTimeEntry';
import { createBareTimeEntryViaApi } from './utils/api';
async function goToDashboard(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
}
test('test that dashboard loads with all expected sections', async ({ page }) => {
await goToDashboard(page);
await expect(page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000 });
// Timer section (scoped to dashboard_timer to avoid matching sidebar timer)
await expect(page.getByTestId('time_entry_description')).toBeVisible();
await expect(page.getByTestId('dashboard_timer').getByTestId('timer_button')).toBeVisible();
// Dashboard cards
await expect(page.getByText('Recent Time Entries', { exact: true })).toBeVisible();
await expect(page.getByText('Last 7 Days', { exact: true })).toBeVisible();
await expect(page.getByText('Activity Graph', { exact: true })).toBeVisible();
await expect(page.getByText('Team Activity', { exact: true })).toBeVisible();
// Weekly overview section
await expect(page.getByText('This Week', { exact: true })).toBeVisible();
});
test('test that dashboard shows time entry data after creating entries', async ({ page, ctx }) => {
await createBareTimeEntryViaApi(ctx, 'Dashboard test entry', '1h');
await goToDashboard(page);
await expect(page.getByTestId('dashboard_view')).toBeVisible();
// The "Last 7 Days" or "This Week" section should reflect tracked time
await expect(page.getByText('This Week', { exact: true })).toBeVisible();
});
test('test that timer on dashboard can start and stop', async ({ page }) => {
await goToDashboard(page);
await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
await assertThatTimerHasStarted(page);
await page.waitForTimeout(1500);
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
await assertThatTimerIsStopped(page);
});
test('test that weekly overview section displays stat cards', async ({ page, ctx }) => {
await createBareTimeEntryViaApi(ctx, 'Stats test entry', '2h');
await goToDashboard(page);
// Verify stat card labels are visible
await expect(page.getByText('Spent Time')).toBeVisible();
await expect(page.getByText('Billable Time')).toBeVisible();
await expect(page.getByText('Billable Amount')).toBeVisible();
});
test('test that stopping timer refreshes dashboard data', async ({ page }) => {
await goToDashboard(page);
// Start timer
await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
await assertThatTimerHasStarted(page);
await page.waitForTimeout(1500);
// Stop timer and verify dashboard queries are refetched
await Promise.all([
stoppedTimeEntryResponse(page),
page.waitForResponse(
(response) =>
response.url().includes('/charts/') &&
response.request().method() === 'GET' &&
response.status() === 200
),
startOrStopTimerWithButton(page),
]);
await assertThatTimerIsStopped(page);
});
// =============================================
// Employee Permission Tests
// =============================================
test.describe('Employee Dashboard Restrictions', () => {
test('employee dashboard loads and timer is functional', async ({ employee }) => {
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({
timeout: 10000,
});
// Timer should be available
await expect(
employee.page.getByTestId('dashboard_timer').getByTestId('timer_button')
).toBeVisible();
await expect(employee.page.getByTestId('time_entry_description')).toBeEditable();
});
test('employee cannot see Team Activity card', async ({ employee }) => {
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({
timeout: 10000,
});
// Other dashboard cards should be visible
await expect(employee.page.getByText('Recent Time Entries', { exact: true })).toBeVisible();
// Team Activity should NOT be visible for employees
await expect(employee.page.getByText('Team Activity', { exact: true })).not.toBeVisible();
});
});
+154
View File
@@ -0,0 +1,154 @@
import { expect, test } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import type { Page } from '@playwright/test';
import path from 'path';
async function goToImportExport(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/import');
}
test('test that import page loads with type dropdown and file upload', async ({ page }) => {
await goToImportExport(page);
await expect(page.getByTestId('import_view')).toBeVisible({ timeout: 10000 });
// Import section
await expect(page.getByRole('heading', { name: 'Import Data' })).toBeVisible();
await expect(page.locator('#importType')).toBeVisible();
// Export section
await expect(page.getByRole('heading', { name: 'Export Data' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Export Organization Data' })).toBeVisible();
});
test('test that selecting an import type shows instructions', async ({ page }) => {
await goToImportExport(page);
// Select a Toggl import type
await page.getByLabel('Import Type').selectOption({ index: 1 });
// Instructions should appear
await expect(page.getByText('Instructions:')).toBeVisible();
});
test('test that importing without selecting type shows error', async ({ page }) => {
await goToImportExport(page);
// Click Import Data without selecting a type
await page.getByRole('button', { name: 'Import Data' }).click();
// Should show an error notification
await expect(page.getByText('Please select the import type')).toBeVisible();
});
test('test that importing without selecting file shows error', async ({ page }) => {
await goToImportExport(page);
// Select an import type first
await page.getByLabel('Import Type').selectOption({ index: 1 });
// Click Import Data without selecting a file
await page.getByRole('button', { name: 'Import Data' }).click();
// Should show an error notification
await expect(
page.getByText('Please select the CSV or ZIP file that you want to import')
).toBeVisible();
});
test('test that export button triggers export and shows success modal', async ({ page }) => {
await goToImportExport(page);
await expect(page.getByRole('button', { name: 'Export Organization Data' })).toBeVisible();
// Override window.open to prevent the page from navigating away to the
// download URL (the app uses window.open(url, '_self') which would navigate
// away before we can verify the success modal)
await page.evaluate(() => {
window.open = () => null;
});
// Click Export Organization Data and wait for the API response
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/export') &&
response.request().method() === 'POST' &&
response.status() === 200,
{ timeout: 60000 }
),
page.getByRole('button', { name: 'Export Organization Data' }).click(),
]);
// Success modal should appear after export completes
await expect(page.getByText('The export was successful!')).toBeVisible();
});
test('test that import type dropdown has multiple options', async ({ page }) => {
await goToImportExport(page);
// The dropdown should load with options from the API
await page.waitForResponse(
(response) =>
response.url().includes('/importers') &&
response.request().method() === 'GET' &&
response.status() === 200
);
// Verify the select has options besides the default placeholder
const options = page.getByLabel('Import Type').locator('option');
const count = await options.count();
// Should have at least the placeholder + some import types
expect(count).toBeGreaterThan(1);
});
test('test that importing a generic time entries CSV works', async ({ page }) => {
await goToImportExport(page);
await expect(page.getByTestId('import_view')).toBeVisible({ timeout: 10000 });
// Select "Generic Time Entries" import type
await page.getByLabel('Import Type').selectOption({ label: 'Generic Time Entries' });
await expect(page.getByText('Instructions:')).toBeVisible();
// Upload the test CSV file
const csvPath = path.resolve('resources/testfiles/generic_time_entries_import_test_1.csv');
await page.locator('#file-upload').setInputFiles(csvPath);
// Click Import and wait for the API response
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/import') &&
response.request().method() === 'POST' &&
response.status() === 200,
{ timeout: 30000 }
),
page.getByRole('button', { name: 'Import Data' }).click(),
]);
// Verify success modal with import results
await expect(page.getByRole('heading', { name: 'Import Result' })).toBeVisible();
await expect(page.getByText('The import was successful!')).toBeVisible();
// The CSV has 2 time entries, 1 client, 2 projects, 1 task
await expect(page.getByText('Time entries created:').locator('..')).toContainText('2');
await expect(page.getByText('Projects created:').locator('..')).toContainText('2');
await expect(page.getByText('Clients created:').locator('..')).toContainText('1');
await expect(page.getByText('Tasks created:').locator('..')).toContainText('1');
});
// =============================================
// Employee Permission Tests
// =============================================
test.describe('Employee Import Restrictions', () => {
test('employee does not see Import / Export link in the sidebar', async ({ employee }) => {
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({
timeout: 10000,
});
// The Import / Export link should NOT be visible in the sidebar for employees
await expect(
employee.page.getByRole('link', { name: 'Import / Export' })
).not.toBeVisible();
});
});
+73
View File
@@ -445,6 +445,33 @@ test('test that invitation can be resent', async ({ page }) => {
]);
});
test('test that admin user cannot transfer ownership', async ({ page, browser }) => {
const memberId = Math.floor(Math.random() * 100000);
const memberEmail = `admin+${memberId}@perms.test`;
// Invite and accept an admin member
await inviteAndAcceptMember(
page,
browser,
'Admin User ' + memberId,
memberEmail,
'Administrator'
);
// Go to members page and verify the admin exists
await goToMembersPage(page);
const adminRow = page.getByRole('row').filter({ hasText: 'Admin User' });
await expect(adminRow).toBeVisible();
// The owner should still be the owner
const ownerRow = page.getByRole('row').filter({ hasText: 'Owner' });
await expect(ownerRow).toBeVisible();
// Open actions menu for the admin - should NOT have "Transfer Ownership" option
await adminRow.getByRole('button').click();
await expect(page.getByRole('menuitem').getByText('Edit')).toBeVisible();
});
test('test that accepted invitation disappears from invitations tab', async ({ page, browser }) => {
const memberId = Math.round(Math.random() * 100000);
const memberEmail = `accepted+${memberId}@invite.test`;
@@ -459,3 +486,49 @@ test('test that accepted invitation disappears from invitations tab', async ({ p
// The accepted invitation should not be visible
await expect(page.getByText(memberEmail)).not.toBeVisible();
});
// =============================================
// Employee Permission Tests
// =============================================
test.describe('Employee Sidebar Navigation', () => {
test('employee sidebar shows correct navigation links', async ({ employee }) => {
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({
timeout: 10000,
});
// Visible links
await expect(employee.page.getByRole('link', { name: 'Dashboard' })).toBeVisible();
await expect(employee.page.getByRole('link', { name: 'Time' })).toBeVisible();
await expect(employee.page.getByRole('link', { name: 'Calendar' })).toBeVisible();
await expect(employee.page.getByRole('link', { name: 'Projects' })).toBeVisible();
await expect(employee.page.getByRole('link', { name: 'Clients' })).toBeVisible();
await expect(employee.page.getByRole('link', { name: 'Tags' })).toBeVisible();
// Hidden links
await expect(employee.page.getByRole('link', { name: 'Members' })).not.toBeVisible();
await expect(
employee.page.getByRole('link', { name: 'Settings', exact: true })
).not.toBeVisible();
});
test('employee cannot see members list or invite members', async ({ employee }) => {
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/members');
// Page loads but the members API returns 403 (no members:view permission)
await expect(employee.page.getByRole('heading', { name: 'Members' })).toBeVisible({
timeout: 10000,
});
// Member table is empty — no rows rendered (only headers)
await expect(employee.page.getByTestId('client_table').locator('[role="row"]')).toHaveCount(
0
);
// Employee should NOT see the Invite Member button
await expect(
employee.page.getByRole('button', { name: 'Invite member' })
).not.toBeVisible();
});
});
+29
View File
@@ -364,3 +364,32 @@ test('test that format settings persist after page reload', async ({ page }) =>
await page.reload();
await expect(page.getByLabel('Date Format')).toContainText('DD/MM/YYYY');
});
// =============================================
// Employee Permission Tests
// =============================================
test.describe('Employee Organization Settings Restrictions', () => {
test('employee can see org name but not editable settings', async ({ ctx, employee }) => {
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/teams/' + ctx.orgId);
// Organization Name section is visible (but inputs are disabled)
await expect(
employee.page.getByRole('heading', { name: 'Organization Name', level: 3 })
).toBeVisible({ timeout: 10000 });
// Editable settings sections should NOT be visible
await expect(
employee.page.getByRole('heading', { name: 'Billable Rate', level: 3 })
).not.toBeVisible();
await expect(
employee.page.getByRole('heading', { name: 'Format Settings', level: 3 })
).not.toBeVisible();
await expect(
employee.page.getByRole('heading', { name: 'Organization Settings', level: 3 })
).not.toBeVisible();
// Save button should not be visible (employee cannot update)
await expect(employee.page.getByRole('button', { name: 'Save' })).not.toBeVisible();
});
});
+99 -1
View File
@@ -4,7 +4,12 @@ import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
import { formatCentsWithOrganizationDefaults } from './utils/money';
import type { CurrencyFormat } from '../resources/js/packages/ui/src/utils/money';
import { createProjectViaApi, createTaskViaApi } from './utils/api';
import {
createProjectViaApi,
createPublicProjectViaApi,
createTaskViaApi,
updateOrganizationSettingViaApi,
} from './utils/api';
async function goToProjectsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
@@ -502,3 +507,96 @@ test('test that editing a task name on the project detail page works', async ({
await expect(page.getByTestId('task_table')).toContainText(updatedTaskName);
await expect(page.getByTestId('task_table')).not.toContainText(originalTaskName);
});
// =============================================
// Employee Permission Tests
// =============================================
test.describe('Employee Projects Restrictions', () => {
test('employee can view public projects but cannot create', async ({ ctx, employee }) => {
const projectName = 'EmpViewProj ' + Math.floor(Math.random() * 10000);
await createPublicProjectViaApi(ctx, { name: projectName });
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await expect(employee.page.getByTestId('projects_view')).toBeVisible({
timeout: 10000,
});
// Employee can see the public project
await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 });
// Employee cannot see Create Project button
await expect(
employee.page.getByRole('button', { name: 'Create Project' })
).not.toBeVisible();
});
test('employee cannot see edit/delete/archive actions on projects', async ({
ctx,
employee,
}) => {
const projectName = 'EmpActionsProj ' + Math.floor(Math.random() * 10000);
await createPublicProjectViaApi(ctx, { name: projectName });
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 });
// Click the actions dropdown trigger to open the menu
const actionsButton = employee.page.locator(
`[aria-label='Actions for Project ${projectName}']`
);
await actionsButton.click();
// The dropdown menu items (Edit, Archive, Delete) should NOT be visible
await expect(
employee.page.locator(`[aria-label='Edit Project ${projectName}']`)
).not.toBeVisible();
await expect(
employee.page.locator(`[aria-label='Archive Project ${projectName}']`)
).not.toBeVisible();
await expect(
employee.page.locator(`[aria-label='Delete Project ${projectName}']`)
).not.toBeVisible();
});
});
test.describe('Employee Billable Rate Visibility', () => {
test('employee cannot see billable rate column by default', async ({ ctx, employee }) => {
const projectName = 'EmpBillableProj ' + Math.floor(Math.random() * 10000);
await createPublicProjectViaApi(ctx, {
name: projectName,
is_billable: true,
billable_rate: 15000,
});
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 });
// Billable Rate column should not be visible to employee by default
await expect(employee.page.getByText('Billable Rate')).not.toBeVisible();
});
test('employee can see billable rate column when employees_can_see_billable_rates is enabled', async ({
ctx,
employee,
}) => {
await updateOrganizationSettingViaApi(ctx, { employees_can_see_billable_rates: true });
const projectName = 'EmpBillableVisProj ' + Math.floor(Math.random() * 10000);
await createPublicProjectViaApi(ctx, {
name: projectName,
is_billable: true,
billable_rate: 20000,
});
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 });
// Billable Rate column header should be visible
await expect(employee.page.getByText('Billable Rate')).toBeVisible();
// The project row should show the formatted billable rate
const projectRow = employee.page.getByRole('row').filter({ hasText: projectName });
await expect(projectRow).toContainText('200');
});
});
+129
View File
@@ -9,6 +9,9 @@ import {
createTimeEntryViaApi,
createTimeEntryWithTagViaApi,
createTimeEntryWithBillableStatusViaApi,
createBareTimeEntryViaApi,
createPublicProjectViaApi,
updateOrganizationSettingViaApi,
} from './utils/api';
// Each test registers a new user and creates test data via API
@@ -859,3 +862,129 @@ test('test that export dropdown shows all export options', async ({ page, ctx })
await expect(page.getByRole('menuitem', { name: 'Export as CSV' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Export as ODS' })).toBeVisible();
});
// =============================================
// Employee Permission Tests
// =============================================
test.describe('Employee Reporting Restrictions', () => {
test('employee can access overview reporting and sees own data', async ({ ctx, employee }) => {
// Owner creates a time entry
await createBareTimeEntryViaApi(ctx, 'Owner report entry', '2h');
// Create employee time entry
await createTimeEntryViaApi(
{ ...ctx, memberId: employee.memberId },
{ description: 'Emp report entry', duration: '1h' }
);
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/reporting');
await expect(employee.page.getByTestId('reporting_view')).toBeVisible({
timeout: 10000,
});
// Employee's data should be visible (1h)
await expect(
employee.page.getByTestId('reporting_view').getByText('1h 00min').first()
).toBeVisible();
});
test('employee can access detailed reporting and sees only own entries', async ({
ctx,
employee,
}) => {
// Owner creates time entries
const ownerDescription = 'OwnerDetailEntry ' + Math.floor(Math.random() * 10000);
await createBareTimeEntryViaApi(ctx, ownerDescription, '2h');
// Create employee time entry
const empDescription = 'EmpDetailEntry ' + Math.floor(Math.random() * 10000);
await createTimeEntryViaApi(
{ ...ctx, memberId: employee.memberId },
{ description: empDescription, duration: '1h' }
);
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/reporting/detailed');
await expect(employee.page.getByTestId('reporting_view')).toBeVisible({
timeout: 10000,
});
// Employee's entry IS visible
await expect(
employee.page.getByTestId('reporting_view').locator(`text=${empDescription}`).first()
).toBeAttached({ timeout: 10000 });
// Owner's entry is NOT visible
await expect(
employee.page.getByTestId('reporting_view').locator(`text=${ownerDescription}`)
).not.toBeAttached();
});
test('employee cannot see shared reports tab in reporting', async ({ employee }) => {
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/reporting');
await expect(employee.page.getByTestId('reporting_view')).toBeVisible({
timeout: 10000,
});
// Overview and Detailed tabs should be visible (scope to main to avoid sidebar matches)
const mainContent = employee.page.getByRole('main');
await expect(mainContent.getByRole('link', { name: 'Overview' })).toBeVisible();
await expect(mainContent.getByRole('link', { name: 'Detailed' })).toBeVisible();
// Shared tab should NOT be visible for employees
await expect(mainContent.getByRole('link', { name: 'Shared' })).not.toBeVisible();
});
test('employee cannot see Cost column in reporting by default', async ({ ctx, employee }) => {
const project = await createPublicProjectViaApi(ctx, {
name: 'EmpBillProj',
is_billable: true,
billable_rate: 10000,
});
await createTimeEntryViaApi(
{ ...ctx, memberId: employee.memberId },
{ description: 'Emp cost entry', duration: '1h', projectId: project.id }
);
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/reporting');
await expect(employee.page.getByTestId('reporting_view')).toBeVisible({
timeout: 10000,
});
// Cost column header should NOT be visible
await expect(employee.page.getByText('Cost', { exact: true })).not.toBeVisible();
});
test('employee can see Cost column when employees_can_see_billable_rates is enabled', async ({
ctx,
employee,
}) => {
await updateOrganizationSettingViaApi(ctx, { employees_can_see_billable_rates: true });
const project = await createPublicProjectViaApi(ctx, {
name: 'EmpBillVisProj',
is_billable: true,
billable_rate: 10000,
});
await createTimeEntryViaApi(
{ ...ctx, memberId: employee.memberId },
{
description: 'Emp cost visible entry',
duration: '1h',
projectId: project.id,
billable: true,
}
);
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/reporting');
await expect(employee.page.getByTestId('reporting_view')).toBeVisible({
timeout: 10000,
});
// Cost column header should be visible
await expect(employee.page.getByText('Cost', { exact: true })).toBeVisible();
// 1h at 100.00/h billable rate = 100.00 cost (shown in row and total)
await expect(employee.page.getByText('100,00 EUR').first()).toBeVisible();
});
});
+60 -1
View File
@@ -2,7 +2,13 @@ import { expect } from '@playwright/test';
import type { Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
import { createProjectViaApi, createTaskViaApi, createClientViaApi } from './utils/api';
import {
createProjectViaApi,
createPublicProjectViaApi,
createTaskViaApi,
createClientViaApi,
updateOrganizationSettingViaApi,
} from './utils/api';
async function goToProjectsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
@@ -188,3 +194,56 @@ test('test that multiple tasks are displayed on project detail page', async ({ p
await expect(page.getByText(taskName1)).toBeVisible();
await expect(page.getByText(taskName2)).toBeVisible();
});
// =============================================
// Employee Permission Tests
// =============================================
test.describe('Employee Tasks Restrictions', () => {
test('employee cannot see task management actions when employees_can_manage_tasks is disabled', async ({
ctx,
employee,
}) => {
// Create a public project with a task
const projectName = 'EmpTaskProj ' + Math.floor(Math.random() * 10000);
const taskName = 'EmpTask ' + Math.floor(Math.random() * 10000);
const project = await createPublicProjectViaApi(ctx, { name: projectName });
await createTaskViaApi(ctx, { name: taskName, project_id: project.id });
// Navigate to the project detail page
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 });
await employee.page.getByText(projectName).first().click();
await employee.page.waitForURL(/\/projects\/[a-f0-9-]+/);
// Task should be visible
await expect(employee.page.getByText(taskName)).toBeVisible({ timeout: 10000 });
// Create Task button should not be visible
await expect(employee.page.getByRole('button', { name: 'Create Task' })).not.toBeVisible();
// Task actions button should not be visible
const actionsButton = employee.page.locator(`[aria-label='Actions for Task ${taskName}']`);
await expect(actionsButton).not.toBeVisible();
});
test('employee can manage tasks when employees_can_manage_tasks is enabled', async ({
ctx,
employee,
}) => {
// Enable the setting
await updateOrganizationSettingViaApi(ctx, { employees_can_manage_tasks: true });
const projectName = 'EmpTaskMgmtProj ' + Math.floor(Math.random() * 10000);
await createPublicProjectViaApi(ctx, { name: projectName });
// Navigate to the project detail page
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 });
await employee.page.getByText(projectName).first().click();
await employee.page.waitForURL(/\/projects\/[a-f0-9-]+/);
// Create Task button SHOULD be visible
await expect(employee.page.getByRole('button', { name: 'Create Task' })).toBeVisible();
});
});
+131
View File
@@ -13,6 +13,8 @@ import {
createProjectViaApi,
createBillableProjectViaApi,
createBareTimeEntryViaApi,
createTimeEntryViaApi,
updateOrganizationCurrencyViaWeb,
} from './utils/api';
// Date picker button name patterns for different date formats
@@ -602,6 +604,32 @@ test('test that date picker displays date in organization date format', async ({
// TODO: Test that project can be created in the time entry row
test('test that billable icon shows dollar sign for USD currency on time entry row', async ({
page,
ctx,
}) => {
await updateOrganizationCurrencyViaWeb(ctx, 'USD');
await goToTimeOverview(page);
await createEmptyTimeEntry(page);
const timeEntryRow = page.locator('[data-testid="time_entry_row"]').first();
const billableButton = timeEntryRow.getByRole('button', { name: 'Non Billable' }).first();
await expect(billableButton).toBeVisible();
await expect(billableButton.locator('svg')).toHaveAttribute('viewBox', '0 0 8 14');
});
test('test that billable icon shows euro sign for EUR currency on time entry row', async ({
page,
ctx,
}) => {
await updateOrganizationCurrencyViaWeb(ctx, 'EUR');
await goToTimeOverview(page);
await createEmptyTimeEntry(page);
const timeEntryRow = page.locator('[data-testid="time_entry_row"]').first();
const billableButton = timeEntryRow.getByRole('button', { name: 'Non Billable' }).first();
await expect(billableButton).toBeVisible();
await expect(billableButton.locator('svg')).toHaveAttribute('viewBox', '0 0 12 12');
});
test('test that editing billable status via the edit modal works', async ({ page }) => {
await goToTimeOverview(page);
await createEmptyTimeEntry(page);
@@ -1399,3 +1427,106 @@ test('test that time entries page loads multiple entries created via API', async
const count = await timeEntryRows.count();
expect(count).toBeGreaterThanOrEqual(5);
});
// =============================================
// Employee Permission Tests
// =============================================
test.describe('Employee Time Entry Isolation', () => {
test('employee can only see their own time entries on the time page', async ({
ctx,
employee,
}) => {
// Owner creates a time entry
const ownerDescription = 'OwnerWork ' + Math.floor(Math.random() * 10000);
await createBareTimeEntryViaApi(ctx, ownerDescription, '1h');
// Create a time entry for the employee using the owner's context
const employeeDescription = 'EmpWork ' + Math.floor(Math.random() * 10000);
await createTimeEntryViaApi(
{ ...ctx, memberId: employee.memberId },
{ description: employeeDescription, duration: '30min' }
);
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/time');
await expect(
employee.page.getByTestId('dashboard_timer').getByTestId('timer_button')
).toBeVisible({ timeout: 10000 });
// Employee's time entry IS visible
const employeeRow = employee.page
.locator('[data-testid="time_entry_row"]')
.filter({ hasText: employeeDescription });
await expect(employeeRow).toBeVisible({ timeout: 10000 });
// Owner's time entry is NOT visible
const ownerRow = employee.page
.locator('[data-testid="time_entry_row"]')
.filter({ hasText: ownerDescription });
await expect(ownerRow).not.toBeVisible();
});
test('employee can edit their own time entry', async ({ ctx, employee }) => {
const description = 'EmpEditEntry ' + Math.floor(Math.random() * 10000);
await createTimeEntryViaApi(
{ ...ctx, memberId: employee.memberId },
{ description, duration: '1h' }
);
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/time');
const timeEntryRow = employee.page
.locator('[data-testid="time_entry_row"]')
.filter({ hasText: description });
await expect(timeEntryRow).toBeVisible({ timeout: 10000 });
// Update description
const updatedDescription = 'Updated ' + description;
const descriptionInput = timeEntryRow.getByTestId('time_entry_description').first();
await descriptionInput.fill(updatedDescription);
await Promise.all([
employee.page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
descriptionInput.press('Tab'),
]);
// Verify updated description
await expect(timeEntryRow.getByTestId('time_entry_description').first()).toHaveValue(
updatedDescription
);
});
test('employee can delete their own time entry', async ({ ctx, employee }) => {
const description = 'EmpDeleteEntry ' + Math.floor(Math.random() * 10000);
await createTimeEntryViaApi(
{ ...ctx, memberId: employee.memberId },
{ description, duration: '1h' }
);
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/time');
const timeEntryRow = employee.page
.locator('[data-testid="time_entry_row"]')
.filter({ hasText: description });
await expect(timeEntryRow).toBeVisible({ timeout: 10000 });
// Delete via actions menu
await timeEntryRow
.getByRole('button', { name: 'Actions for the time entry' })
.first()
.click();
await Promise.all([
employee.page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.request().method() === 'DELETE'
),
employee.page.getByTestId('time_entry_delete').click(),
]);
// Verify entry is gone
await expect(timeEntryRow).not.toBeVisible();
});
});
+19
View File
@@ -9,6 +9,7 @@ import {
} from './utils/currentTimeEntry';
import type { Page } from '@playwright/test';
import { newTagResponse } from './utils/tags';
import { updateOrganizationCurrencyViaWeb } from './utils/api';
// Date picker button name patterns for different date formats
const DATE_DISPLAY_PATTERN = /^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$|^\d{2}\.\d{2}\.\d{4}$/;
@@ -28,6 +29,24 @@ test('test that starting and stopping a timer without description and project wo
await assertThatTimerIsStopped(page);
});
test('test that billable icon shows dollar sign for USD currency', async ({ page, ctx }) => {
await updateOrganizationCurrencyViaWeb(ctx, 'USD');
await goToDashboard(page);
await page.waitForLoadState('networkidle');
const billableButton = page.getByRole('button', { name: 'Non Billable' }).first();
await expect(billableButton).toBeVisible();
await expect(billableButton.locator('svg')).toHaveAttribute('viewBox', '0 0 8 14');
});
test('test that billable icon shows euro sign for EUR currency', async ({ page, ctx }) => {
await updateOrganizationCurrencyViaWeb(ctx, 'EUR');
await goToDashboard(page);
await page.waitForLoadState('networkidle');
const billableButton = page.getByRole('button', { name: 'Non Billable' }).first();
await expect(billableButton).toBeVisible();
await expect(billableButton.locator('svg')).toHaveAttribute('viewBox', '0 0 12 12');
});
test('test that starting and stopping a timer with a description works', async ({ page }) => {
await goToDashboard(page);
// Wait for the description input to be editable before filling
+17
View File
@@ -155,6 +155,21 @@ function randomColor(): string {
// Entity creation
// ──────────────────────────────────────────────────
export async function createPublicProjectViaApi(
ctx: TestContext,
data: {
name: string;
is_billable?: boolean;
billable_rate?: number | null;
client_id?: string | null;
}
) {
return createProjectViaApi(ctx, {
...data,
is_public: true,
});
}
export async function createProjectViaApi(
ctx: TestContext,
data: {
@@ -164,6 +179,7 @@ export async function createProjectViaApi(
billable_rate?: number | null;
client_id?: string | null;
estimated_time?: number | null;
is_public?: boolean;
}
) {
const response = await ctx.request.post(
@@ -176,6 +192,7 @@ export async function createProjectViaApi(
billable_rate: data.billable_rate ?? null,
client_id: data.client_id ?? null,
estimated_time: data.estimated_time ?? null,
is_public: data.is_public ?? false,
},
}
);
+96
View File
@@ -2,6 +2,7 @@ import { expect } from '@playwright/test';
import type { Browser, Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../../playwright/config';
import { getInvitationAcceptUrl } from './mailpit';
import type { TestContext } from './api';
/**
* Register a new user in a fresh browser context and return the page + context.
@@ -66,3 +67,98 @@ export async function inviteAndAcceptMember(
// 4. Clean up
await secondUser.close();
}
/**
* Set up an employee member in the owner's organization.
* Returns the employee's page, their member ID, and a cleanup function.
*
* The owner page (from the fixture) is used to invite the employee.
* Test data should be created via the owner's ctx.
*
* IMPORTANT: Projects must be created with is_public: true for the employee to see them,
* or the employee must be added as a project member via createProjectMemberViaApi.
* Clients are only visible to employees if they have at least one visible project.
* Tags are visible to all org members with tags:view permission.
*/
export async function setupEmployeeUser(
ownerPage: Page,
ownerCtx: TestContext,
browser: Browser
): Promise<{
employeePage: Page;
employeeMemberId: string;
closeEmployee: () => Promise<void>;
}> {
const memberId = Math.floor(Math.random() * 100000);
const memberEmail = `employee+${memberId}@emp-perms.test`;
const memberName = 'Emp ' + memberId;
// Register the employee user first
const employee = await registerUser(browser, memberName, memberEmail);
// Send invitation from the owner
await ownerPage.goto(PLAYWRIGHT_BASE_URL + '/members');
await ownerPage.getByRole('button', { name: 'Invite Member' }).click();
await expect(ownerPage.getByPlaceholder('Member Email')).toBeVisible();
await ownerPage.getByPlaceholder('Member Email').fill(memberEmail);
await ownerPage.getByRole('button', { name: 'Employee' }).click();
await Promise.all([
ownerPage.waitForResponse(
(response) =>
response.url().includes('/invitations') &&
response.request().method() === 'POST' &&
response.status() === 204
),
ownerPage.getByRole('button', { name: 'Invite Member', exact: true }).click(),
]);
// Accept the invitation
const acceptUrl = await getInvitationAcceptUrl(employee.page.request, memberEmail);
await employee.page.goto(acceptUrl);
await employee.page.waitForURL(/dashboard/);
// Navigate to dashboard explicitly and wait for it to load to ensure the correct org context.
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({ timeout: 15000 });
// Verify we're on the correct organization (John's Organization).
const orgSwitcherText = await employee.page
.getByTestId('organization_switcher')
.first()
.textContent();
if (!orgSwitcherText?.includes("John's Organization")) {
// Switch to the owner's org using the PUT /current-team endpoint
const cookies = await employee.page.context().cookies();
const xsrfCookie = cookies.find((c) => c.name === 'XSRF-TOKEN');
const xsrfToken = xsrfCookie ? decodeURIComponent(xsrfCookie.value) : '';
await employee.page.request.put(`${PLAYWRIGHT_BASE_URL}/current-team`, {
headers: {
'X-XSRF-TOKEN': xsrfToken,
Accept: 'text/html',
},
data: { team_id: ownerCtx.orgId },
});
// Reload to pick up the new org
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({ timeout: 15000 });
}
// Find the employee's member ID in the owner's organization
const membersResponse = await ownerCtx.request.get(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ownerCtx.orgId}/members`
);
expect(membersResponse.status()).toBe(200);
const membersBody = await membersResponse.json();
const employeeMember = membersBody.data.find(
(m: { role: string; name: string }) => m.role === 'employee' && m.name === memberName
);
expect(employeeMember).toBeTruthy();
return {
employeePage: employee.page,
employeeMemberId: employeeMember.id,
closeEmployee: employee.close,
};
}
+21 -1
View File
@@ -1,17 +1,27 @@
import { test as baseTest } from '@playwright/test';
import type { Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from './config';
import { type TestContext, setupTestContext } from '../e2e/utils/api';
import { setupEmployeeUser } from '../e2e/utils/members';
export * from '@playwright/test';
export type { TestContext };
export interface EmployeeFixture {
page: Page;
memberId: string;
}
/**
* API-based authentication fixture - creates a new user via HTTP requests instead of UI interactions.
* This is ~10-25x faster than UI-based authentication (~100-200ms vs ~3-5s).
*
* Uses page.context().request() to ensure cookies are shared between the API request and page.
*/
export const test = baseTest.extend<{ ctx: TestContext }, { workerStorageState: string }>({
export const test = baseTest.extend<
{ ctx: TestContext; employee: EmployeeFixture },
{ workerStorageState: string }
>({
page: async ({ page }, use) => {
// Generate unique email for this test
const email = `john+${Date.now()}_${Math.floor(Math.random() * 10000)}@doe.com`;
@@ -80,4 +90,14 @@ export const test = baseTest.extend<{ ctx: TestContext }, { workerStorageState:
const ctx = await setupTestContext(page);
await use(ctx);
},
employee: async ({ page, ctx, browser }, use) => {
const { employeePage, employeeMemberId, closeEmployee } = await setupEmployeeUser(
page,
ctx,
browser
);
await use({ page: employeePage, memberId: employeeMemberId });
await closeEmployee();
},
});
@@ -67,6 +67,12 @@ const { groupByOptions, getNameForReportingRowEntry, emptyPlaceholder } = report
const organization = inject<ComputedRef<Organization>>('organization');
const showBillableRate = computed(() => {
return !!(
getCurrentRole() !== 'employee' || organization?.value?.employees_can_see_billable_rates
);
});
// Ensure sub-group falls back when it collides with group
watch(
group,
@@ -280,12 +286,14 @@ const tableData = computed(() => {
groupByOptions.filter((el) => el.value !== group)
"></ReportingGroupBySelect>
</div>
<div class="grid items-center" style="grid-template-columns: 1fr 100px 150px">
<div
class="grid items-center"
:style="`grid-template-columns: 1fr 100px ${showBillableRate ? '150px' : ''}`">
<div
class="contents [&>*]:border-card-background-separator [&>*]:border-b [&>*]:bg-secondary [&>*]:pb-1.5 [&>*]:pt-1 text-text-tertiary text-sm">
<div class="pl-6">Name</div>
<div class="text-right">Duration</div>
<div class="text-right pr-6">Cost</div>
<div v-if="showBillableRate" class="text-right pr-6">Cost</div>
</div>
<template
v-if="
@@ -297,6 +305,7 @@ const tableData = computed(() => {
:key="entry.description ?? 'none'"
:currency="getOrganizationCurrencyString()"
:type="aggregatedTableTimeEntries.grouped_type"
:show-cost="showBillableRate"
:entry="entry"></ReportingRow>
<div class="contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]">
<div class="flex items-center pl-6 font-medium">
@@ -311,7 +320,9 @@ const tableData = computed(() => {
)
}}
</div>
<div class="justify-end pr-6 flex items-center font-medium">
<div
v-if="showBillableRate"
class="justify-end pr-6 flex items-center font-medium">
{{
aggregatedTableTimeEntries.cost
? formatCents(
@@ -328,7 +339,8 @@ const tableData = computed(() => {
</template>
<div
v-else
class="chart flex flex-col items-center justify-center py-12 col-span-3">
class="chart flex flex-col items-center justify-center py-12"
:class="showBillableRate ? 'col-span-3' : 'col-span-2'">
<p class="text-lg text-text-primary font-medium">No time entries found</p>
<p>Try to change the filters and time range</p>
</div>
@@ -20,6 +20,7 @@ const props = defineProps<{
entry: AggregatedGroupedData;
indent?: boolean;
currency: string;
showCost?: boolean;
}>();
const expanded = ref(false);
@@ -50,7 +51,7 @@ const organization = inject<ComputedRef<Organization>>('organization');
)
}}
</div>
<div class="justify-end pr-6 flex items-center">
<div v-if="showCost" class="justify-end pr-6 flex items-center">
{{
entry.cost
? formatCents(
@@ -66,12 +67,14 @@ const organization = inject<ComputedRef<Organization>>('organization');
</div>
<div
v-if="expanded && entry.grouped_data"
class="col-span-3 grid bg-tertiary"
style="grid-template-columns: 1fr 150px 150px">
:class="showCost ? 'col-span-3' : 'col-span-2'"
class="grid bg-tertiary"
:style="`grid-template-columns: 1fr 150px ${showCost ? '150px' : ''}`">
<ReportingRow
v-for="subEntry in entry.grouped_data"
:key="subEntry.description ?? 'none'"
:currency="props.currency"
:show-cost="showCost"
indent
:entry="subEntry"></ReportingRow>
</div>
@@ -1,31 +1,58 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
import { canViewReport } from '@/utils/permissions';
import { computed } from 'vue';
defineProps<{
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
const props = defineProps<{
active: 'reporting' | 'detailed' | 'shared';
}>();
const showSharedReports = computed(() => canViewReport());
const tabs = computed(() => {
const items = [
{ value: 'reporting', label: 'Overview', href: route('reporting') },
{ value: 'detailed', label: 'Detailed', href: route('reporting.detailed') },
];
if (showSharedReports.value) {
items.push({
value: 'shared',
label: 'Shared',
href: route('reporting.shared'),
});
}
return items;
});
function hrefForTab(value: string) {
return tabs.value.find((tab) => tab.value === value)?.href;
}
function onTabChange(value: string | number) {
const href = hrefForTab(String(value));
if (href) {
router.visit(href);
}
}
function onTabHover(value: string) {
const href = hrefForTab(value);
if (href) {
router.prefetch(href, {}, { cacheFor: '1m' });
}
}
</script>
<template>
<TabBar :model-value="active">
<TabBarItem value="reporting" @click="router.visit(route('reporting'))"
>Overview</TabBarItem
>
<TabBarItem value="detailed" @click="router.visit(route('reporting.detailed'))"
>Detailed</TabBarItem
>
<TabBar :default-value="props.active" @update:model-value="onTabChange">
<TabBarItem
v-if="showSharedReports"
value="shared"
@click="router.visit(route('reporting.shared'))"
>Shared</TabBarItem
>
v-for="tab in tabs"
:key="tab.value"
:value="tab.value"
@mouseenter="onTabHover(tab.value)">
{{ tab.label }}
</TabBarItem>
</TabBar>
</template>
<style scoped></style>
+3 -4
View File
@@ -14,13 +14,12 @@ import { useProjectsStore } from '@/utils/useProjects';
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
import PageTitle from '@/Components/Common/PageTitle.vue';
import { canCreateProjects } from '@/utils/permissions';
import { storeToRefs } from 'pinia';
import { useClientsQuery } from '@/utils/useClientsQuery';
import { useClientsStore } from '@/utils/useClients';
import type { CreateClientBody, Client, CreateProjectBody, Project } from '@/packages/api/src';
import { getOrganizationCurrencyString } from '@/utils/money';
import { getCurrentRole } from '@/utils/useUser';
import { useOrganizationStore } from '@/utils/useOrganization';
import { getCurrentOrganizationId, getCurrentRole } from '@/utils/useUser';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { useStorage } from '@vueuse/core';
import ProjectsFilterDropdown from '@/Components/Common/Project/ProjectsFilterDropdown.vue';
@@ -31,7 +30,7 @@ import { NO_CLIENT_ID } from '@/Components/Common/Project/constants';
// Fetch data using TanStack Query
const { projects } = useProjectsQuery();
const { clients } = useClientsQuery();
const { organization } = storeToRefs(useOrganizationStore());
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
// Table state persisted in localStorage
interface ProjectTableState {
+3 -4
View File
@@ -5,7 +5,6 @@ import SectionBorder from '@/Components/SectionBorder.vue';
import UpdateTeamNameForm from '@/Pages/Teams/Partials/UpdateTeamNameForm.vue';
import type { Organization } from '@/types/models';
import type { Permissions, Role } from '@/types/jetstream';
import { canUpdateOrganization } from '@/utils/permissions';
import OrganizationBillableRate from '@/Pages/Teams/Partials/OrganizationBillableRate.vue';
import OrganizationFormatSettings from '@/Pages/Teams/Partials/OrganizationFormatSettings.vue';
import OrganizationTimeEntrySettings from '@/Pages/Teams/Partials/OrganizationTimeEntrySettings.vue';
@@ -46,13 +45,13 @@ onMounted(async () => {
<UpdateTeamNameForm :team="team" :permissions="permissions" />
<SectionBorder />
<OrganizationBillableRate v-if="canUpdateOrganization()" :team="team" />
<OrganizationBillableRate v-if="permissions.canUpdateTeam" :team="team" />
<SectionBorder />
<OrganizationFormatSettings v-if="canUpdateOrganization()" :team="team" />
<OrganizationFormatSettings v-if="permissions.canUpdateTeam" :team="team" />
<SectionBorder />
<OrganizationTimeEntrySettings v-if="canUpdateOrganization()" />
<OrganizationTimeEntrySettings v-if="permissions.canUpdateTeam" />
<SectionBorder />
<template v-if="permissions.canDeleteTeam && !team.personal_team">
+1 -1
View File
@@ -39,7 +39,7 @@ const maxWidthClass = computed(() => {
<template>
<Dialog :open="show" @update:open="close">
<DialogContent :class="maxWidthClass">
<div>
<div class="min-w-0">
<slot />
</div>