Compare commits

..

16 Commits

Author SHA1 Message Date
Gregor Vostrak
d2f3fe411a add missing query invalidation after report create 2026-02-18 23:58:39 +01:00
Gregor Vostrak
f880f9f730 fix firefox flaky input in e2e test 2026-02-18 23:22:04 +01:00
Gregor Vostrak
556bbedeca add dynamic loading of paginated endpoints above page_limit
add request classes and fix collection typing for clients, tasks and tags
2026-02-18 22:32:56 +01:00
Gregor Vostrak
eed638d0aa add default sorting to task, project, member, invitation, api token endpoints 2026-02-18 19:16:14 +01:00
Gregor Vostrak
864f41bda6 fix project member query invalidations after update, query key change regression 2026-02-18 18:51:21 +01:00
Gregor Vostrak
26524c5f40 fix member edit modal ui regression from field component migration 2026-02-18 17:57:11 +01:00
Gregor Vostrak
cf98fabe0a add table sorting to members, clients and tags table 2026-02-18 17:41:36 +01:00
Gregor Vostrak
88c0c334e9 add project progress sorting and fix direction ui for number based
columns in the project table
2026-02-18 16:45:17 +01:00
Gregor Vostrak
0fc325363d update query keys to include org id, preventing stale data after organization switch 2026-02-18 12:53:22 +01:00
Gregor Vostrak
1afc16573a cleanup postcss config dependency in ui package 2026-02-17 18:06:35 +01:00
Gregor Vostrak
147514a606 convert billable query string to boolean for shared report + e2e tests #876 2026-02-17 17:08:38 +01:00
Gregor Vostrak
435522b502 make OrganizationPolicy use “organizations:update” to remove jetstream inconsistencies
The frontend did not show organization settings for admin users because of the team ownership check
2026-02-17 14:35:52 +01:00
Gregor Vostrak
f1d001e03e add lazy loading to modals and dropdowns to improve time page render performance 2026-02-17 13:54:26 +01:00
Gregor Vostrak
7f145cf1c2 make sure cost column shows in shared report view, #1019 2026-02-17 13:42:22 +01:00
Gregor Vostrak
b579ed1075 bump ui package version to 0.0.16 2026-02-16 18:31:11 +01:00
Gregor Vostrak
ed2b7476ae clear inertia cache on organization change to fix wrongly loaded stale pages 2026-02-16 16:44:20 +01:00
74 changed files with 2346 additions and 419 deletions

View File

@@ -35,6 +35,7 @@ class ApiTokenController extends Controller
/** @var Builder<Client> $query */ /** @var Builder<Client> $query */
$query->whereJsonContains('grant_types', 'personal_access'); $query->whereJsonContains('grant_types', 'personal_access');
}) })
->orderBy('created_at', 'desc')
->get(); ->get();
return new ApiTokenCollection($tokens); return new ApiTokenCollection($tokens);

View File

@@ -41,6 +41,7 @@ class InvitationController extends Controller
$this->checkPermission($organization, 'invitations:view'); $this->checkPermission($organization, 'invitations:view');
$invitations = $organization->teamInvitations() $invitations = $organization->teamInvitations()
->orderBy('created_at', 'desc')
->paginate(config('app.pagination_per_page_default')); ->paginate(config('app.pagination_per_page_default'));
return InvitationCollection::make($invitations); return InvitationCollection::make($invitations);

View File

@@ -60,6 +60,7 @@ class MemberController extends Controller
$members = Member::query() $members = Member::query()
->whereBelongsTo($organization, 'organization') ->whereBelongsTo($organization, 'organization')
->with(['user']) ->with(['user'])
->orderBy('created_at', 'desc')
->paginate(config('app.pagination_per_page_default')); ->paginate(config('app.pagination_per_page_default'));
return MemberCollection::make($members); return MemberCollection::make($members);

View File

@@ -60,7 +60,9 @@ class ProjectController extends Controller
$projectsQuery->whereNull('archived_at'); $projectsQuery->whereNull('archived_at');
} }
$projects = $projectsQuery->paginate(config('app.pagination_per_page_default')); $projects = $projectsQuery
->orderBy('created_at', 'desc')
->paginate(config('app.pagination_per_page_default'));
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;

View File

@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException; use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException; use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
use App\Http\Requests\V1\ProjectMember\ProjectMemberIndexRequest;
use App\Http\Requests\V1\ProjectMember\ProjectMemberStoreRequest; use App\Http\Requests\V1\ProjectMember\ProjectMemberStoreRequest;
use App\Http\Requests\V1\ProjectMember\ProjectMemberUpdateRequest; use App\Http\Requests\V1\ProjectMember\ProjectMemberUpdateRequest;
use App\Http\Resources\V1\ProjectMember\ProjectMemberCollection; use App\Http\Resources\V1\ProjectMember\ProjectMemberCollection;
@@ -41,12 +42,13 @@ class ProjectMemberController extends Controller
* *
* @operationId getProjectMembers * @operationId getProjectMembers
*/ */
public function index(Organization $organization, Project $project): ProjectMemberCollection public function index(Organization $organization, Project $project, ProjectMemberIndexRequest $request): ProjectMemberCollection
{ {
$this->checkPermission($organization, 'project-members:view', $project); $this->checkPermission($organization, 'project-members:view', $project);
$projectMembers = ProjectMember::query() $projectMembers = ProjectMember::query()
->whereBelongsTo($project, 'project') ->whereBelongsTo($project, 'project')
->orderBy('created_at', 'desc')
->paginate(config('app.pagination_per_page_default')); ->paginate(config('app.pagination_per_page_default'));
return new ProjectMemberCollection($projectMembers); return new ProjectMemberCollection($projectMembers);

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Http\Requests\V1\Report\ReportIndexRequest;
use App\Http\Requests\V1\Report\ReportStoreRequest; use App\Http\Requests\V1\Report\ReportStoreRequest;
use App\Http\Requests\V1\Report\ReportUpdateRequest; use App\Http\Requests\V1\Report\ReportUpdateRequest;
use App\Http\Resources\V1\Report\DetailedReportResource; use App\Http\Resources\V1\Report\DetailedReportResource;
@@ -40,7 +41,7 @@ class ReportController extends Controller
* *
* @operationId getReports * @operationId getReports
*/ */
public function index(Organization $organization): ReportCollection public function index(Organization $organization, ReportIndexRequest $request): ReportCollection
{ {
$this->checkPermission($organization, 'reports:view'); $this->checkPermission($organization, 'reports:view');

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\EntityStillInUseApiException; use App\Exceptions\Api\EntityStillInUseApiException;
use App\Http\Requests\V1\Tag\TagIndexRequest;
use App\Http\Requests\V1\Tag\TagStoreRequest; use App\Http\Requests\V1\Tag\TagStoreRequest;
use App\Http\Requests\V1\Tag\TagUpdateRequest; use App\Http\Requests\V1\Tag\TagUpdateRequest;
use App\Http\Resources\V1\Tag\TagCollection; use App\Http\Resources\V1\Tag\TagCollection;
@@ -34,7 +35,7 @@ class TagController extends Controller
* *
* @throws AuthorizationException * @throws AuthorizationException
*/ */
public function index(Organization $organization): TagCollection public function index(Organization $organization, TagIndexRequest $request): TagCollection
{ {
$this->checkPermission($organization, 'tags:view'); $this->checkPermission($organization, 'tags:view');

View File

@@ -82,7 +82,9 @@ class TaskController extends Controller
$query->whereNull('done_at'); $query->whereNull('done_at');
} }
$tasks = $query->paginate(config('app.pagination_per_page_default')); $tasks = $query
->orderBy('created_at', 'desc')
->paginate(config('app.pagination_per_page_default'));
return new TaskCollection($tasks); return new TaskCollection($tasks);
} }

View File

@@ -21,6 +21,11 @@ class InvitationIndexRequest extends BaseFormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'page' => [
'integer',
'min:1',
'max:2147483647',
],
]; ];
} }
} }

View File

@@ -21,6 +21,11 @@ class MemberIndexRequest extends BaseFormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'page' => [
'integer',
'min:1',
'max:2147483647',
],
]; ];
} }
} }

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\ProjectMember;
use App\Http\Requests\V1\BaseFormRequest;
use Illuminate\Contracts\Validation\ValidationRule;
class ProjectMemberIndexRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'page' => [
'integer',
'min:1',
'max:2147483647',
],
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Report;
use App\Http\Requests\V1\BaseFormRequest;
use Illuminate\Contracts\Validation\ValidationRule;
class ReportIndexRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'page' => [
'integer',
'min:1',
'max:2147483647',
],
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Tag;
use App\Http\Requests\V1\BaseFormRequest;
use Illuminate\Contracts\Validation\ValidationRule;
class TagIndexRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'page' => [
'integer',
'min:1',
'max:2147483647',
],
];
}
}

View File

@@ -26,6 +26,11 @@ class TaskIndexRequest extends BaseFormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'page' => [
'integer',
'min:1',
'max:2147483647',
],
'project_id' => [ 'project_id' => [
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */

View File

@@ -4,9 +4,10 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Client; namespace App\Http\Resources\V1\Client;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Http\Resources\Json\ResourceCollection;
class ClientCollection extends ResourceCollection class ClientCollection extends ResourceCollection implements PaginatedResourceCollection
{ {
/** /**
* The resource that this resource collects. * The resource that this resource collects.

View File

@@ -4,9 +4,10 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Tag; namespace App\Http\Resources\V1\Tag;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Http\Resources\Json\ResourceCollection;
class TagCollection extends ResourceCollection class TagCollection extends ResourceCollection implements PaginatedResourceCollection
{ {
/** /**
* The resource that this resource collects. * The resource that this resource collects.

View File

@@ -6,6 +6,7 @@ namespace App\Policies;
use App\Models\Organization; use App\Models\Organization;
use App\Models\User; use App\Models\User;
use App\Service\PermissionStore;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Auth\Access\HandlesAuthorization; use Illuminate\Auth\Access\HandlesAuthorization;
@@ -58,7 +59,7 @@ class OrganizationPolicy
return true; return true;
} }
return $user->ownsTeam($organization); return app(PermissionStore::class)->userHas($organization, $user, 'organizations:update');
} }
/** /**

View File

@@ -8,6 +8,7 @@ import {
createProjectViaApi, createProjectViaApi,
createPublicProjectViaApi, createPublicProjectViaApi,
} from './utils/api'; } from './utils/api';
import { getTableRowNames } from './utils/table';
async function goToClientsOverview(page: Page) { async function goToClientsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/clients'); await page.goto(PLAYWRIGHT_BASE_URL + '/clients');
@@ -131,6 +132,92 @@ test('test that deleting a client via actions menu works', async ({ page, ctx })
await expect(page.getByTestId('client_table')).not.toContainText(clientName); await expect(page.getByTestId('client_table')).not.toContainText(clientName);
}); });
// =============================================
// Sorting Tests
// =============================================
async function clearClientTableState(page: Page) {
await page.evaluate(() => {
localStorage.removeItem('client-table-state');
});
}
test('test that sorting clients by name and status works', async ({ page, ctx }) => {
await createClientViaApi(ctx, { name: 'AAA SortClient' });
await createClientViaApi(ctx, { name: 'ZZZ SortClient' });
await goToClientsOverview(page);
await clearClientTableState(page);
await page.reload();
const table = page.getByTestId('client_table');
await expect(table).toBeVisible();
// -- Name sorting (default is name asc) --
let names = await getTableRowNames(table);
expect(names.indexOf('AAA SortClient')).toBeLessThan(names.indexOf('ZZZ SortClient'));
const nameHeader = table.getByText('Name').first();
await nameHeader.click(); // toggle to desc
names = await getTableRowNames(table);
expect(names.indexOf('ZZZ SortClient')).toBeLessThan(names.indexOf('AAA SortClient'));
// -- Status sorting --
const statusHeader = table.getByText('Status').first();
await statusHeader.click(); // asc
await expect(statusHeader.locator('svg')).toBeVisible();
await statusHeader.click(); // desc
await expect(statusHeader.locator('svg')).toBeVisible();
});
test('test that sorting clients by project count works', async ({ page, ctx }) => {
const clientWithMany = await createClientViaApi(ctx, { name: 'ManyProjects Client' });
const clientWithNone = await createClientViaApi(ctx, { name: 'NoProjects Client' });
// Create projects for the first client
await createProjectViaApi(ctx, { name: 'Proj1', client_id: clientWithMany.id });
await createProjectViaApi(ctx, { name: 'Proj2', client_id: clientWithMany.id });
await goToClientsOverview(page);
await clearClientTableState(page);
await page.reload();
const table = page.getByTestId('client_table');
await expect(table).toBeVisible();
// Click Projects header - first click should sort desc (most projects first)
const projectsHeader = table.getByText('Projects').first();
await projectsHeader.click();
await expect(projectsHeader.locator('svg')).toBeVisible();
let names = await getTableRowNames(table);
expect(names.indexOf('ManyProjects Client')).toBeLessThan(names.indexOf('NoProjects Client'));
// Second click toggles to asc (least projects first)
await projectsHeader.click();
names = await getTableRowNames(table);
expect(names.indexOf('NoProjects Client')).toBeLessThan(names.indexOf('ManyProjects Client'));
});
test('test that client sort state persists after page reload', async ({ page }) => {
await goToClientsOverview(page);
await clearClientTableState(page);
await page.reload();
const table = page.getByTestId('client_table');
await expect(table).toBeVisible();
const nameHeader = table.getByText('Name').first();
await nameHeader.click(); // toggle to desc
await expect(nameHeader.locator('svg')).toBeVisible();
await page.reload();
await expect(page.getByTestId('client_table')).toBeVisible();
await expect(
page.getByTestId('client_table').getByText('Name').first().locator('svg')
).toBeVisible();
});
// ============================================= // =============================================
// Employee Permission Tests // Employee Permission Tests
// ============================================= // =============================================

View File

@@ -5,7 +5,13 @@ import { expect, test } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
import { inviteAndAcceptMember } from './utils/members'; import { inviteAndAcceptMember } from './utils/members';
import { createPlaceholderMemberViaImportApi } from './utils/api'; import {
createPlaceholderMemberViaImportApi,
getMembersViaApi,
updateMemberBillableRateViaApi,
updateOrganizationSettingViaApi,
} from './utils/api';
import { getTableRowNames } from './utils/table';
// Tests that invite + accept members need more time // Tests that invite + accept members need more time
test.describe.configure({ timeout: 45000 }); test.describe.configure({ timeout: 45000 });
@@ -79,8 +85,8 @@ test('test that organization billable rate can be updated with all existing time
const newBillableRate = Math.round(Math.random() * 10000); const newBillableRate = Math.round(Math.random() * 10000);
await page.getByRole('row').first().getByRole('button').click(); await page.getByRole('row').first().getByRole('button').click();
await page.getByRole('menuitem').getByText('Edit').click(); await page.getByRole('menuitem').getByText('Edit').click();
await page.getByText('Organization Default Rate').click(); await page.getByRole('combobox').last().click();
await page.getByText('Custom Rate').click(); await page.getByRole('option', { name: 'Custom Rate' }).click();
await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString()); await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString());
await page.getByRole('button', { name: 'Update Member' }).click(); await page.getByRole('button', { name: 'Update Member' }).click();
@@ -102,6 +108,136 @@ test('test that organization billable rate can be updated with all existing time
]); ]);
}); });
test('test that switching member billable rate from custom back to default rate works', async ({
page,
ctx,
}) => {
// Set a known org billable rate
await updateOrganizationSettingViaApi(ctx, { billable_rate: 12000 });
// Create a placeholder member with a custom billable rate
await createPlaceholderMemberViaImportApi(ctx, 'CustomToDefault Member');
const members = await getMembersViaApi(ctx);
const member = members.find((m) => m.name === 'CustomToDefault Member');
expect(member).toBeDefined();
await updateMemberBillableRateViaApi(ctx, member!.id, 25000);
await goToMembersPage(page);
const memberRow = page.getByRole('row').filter({ hasText: 'CustomToDefault Member' });
await expect(memberRow).toBeVisible();
// Open edit modal
await memberRow.getByRole('button').click();
await page.getByRole('menuitem').getByText('Edit').click();
await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();
// Verify it starts on Custom Rate
const billableCombobox = page.getByRole('dialog').getByRole('combobox').last();
await expect(billableCombobox).toContainText('Custom Rate');
// Switch to Default Rate
await billableCombobox.click();
await page.getByRole('option', { name: 'Default Rate' }).click();
await expect(billableCombobox).toContainText('Default Rate');
// Verify the billable rate input is disabled
await expect(page.getByPlaceholder('Billable Rate')).toBeDisabled();
// Submit — billable_rate changes from 25000 to null, so confirmation dialog appears
await page.getByRole('button', { name: 'Update Member' }).click();
await expect(page.getByRole('heading', { name: 'Update Member Billable Rate' })).toBeVisible();
await expect(page.getByText('the default rate of the organization')).toBeVisible();
// Confirm the update
await Promise.all([
page.getByRole('button', { name: 'Yes, update existing time' }).click(),
page.waitForRequest(
(request) =>
request.url().includes('/members/') &&
request.method() === 'PUT' &&
request.postDataJSON().billable_rate === null
),
]);
// Verify both dialogs are closed
await expect(page.getByRole('dialog')).not.toBeVisible();
});
test('test that default rate shows disabled input with organization billable rate', async ({
page,
ctx,
}) => {
// Set a known org billable rate (150.00)
await updateOrganizationSettingViaApi(ctx, { billable_rate: 15000 });
await goToMembersPage(page);
// Open edit modal for the owner (who uses default rate by default)
await page.getByRole('row').first().getByRole('button').click();
await page.getByRole('menuitem').getByText('Edit').click();
await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();
// Verify it's on Default Rate
const billableCombobox = page.getByRole('dialog').getByRole('combobox').last();
await expect(billableCombobox).toContainText('Default Rate');
// Verify the input is disabled and shows the org rate (formatted with currency)
const billableInput = page.getByPlaceholder('Billable Rate');
await expect(billableInput).toBeDisabled();
await expect(billableInput).toHaveAttribute('aria-valuenow', '150');
// Close the dialog
await page.getByRole('button', { name: 'Cancel' }).click();
await expect(page.getByRole('dialog')).not.toBeVisible();
});
test('test that cancelling the billable rate confirmation dialog does not update the member', async ({
page,
ctx,
}) => {
// Create a placeholder member with a custom billable rate
await createPlaceholderMemberViaImportApi(ctx, 'CancelConfirm Member');
const members = await getMembersViaApi(ctx);
const member = members.find((m) => m.name === 'CancelConfirm Member');
expect(member).toBeDefined();
await updateMemberBillableRateViaApi(ctx, member!.id, 10000);
await goToMembersPage(page);
const memberRow = page.getByRole('row').filter({ hasText: 'CancelConfirm Member' });
await expect(memberRow).toBeVisible();
// Open edit modal
await memberRow.getByRole('button').click();
await page.getByRole('menuitem').getByText('Edit').click();
await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();
// Change the billable rate
await page.getByPlaceholder('Billable Rate').fill('200');
// Click Update Member — confirmation dialog should appear
await page.getByRole('button', { name: 'Update Member' }).click();
await expect(page.getByRole('heading', { name: 'Update Member Billable Rate' })).toBeVisible();
// Set up listener to verify no PUT request is sent after cancel
let putRequestSent = false;
page.on('request', (request) => {
if (request.url().includes('/members/') && request.method() === 'PUT') {
putRequestSent = true;
}
});
// Click Cancel on the confirmation dialog
await page.getByRole('button', { name: 'Cancel' }).click();
// Verify confirmation dialog is closed
await expect(
page.getByRole('heading', { name: 'Update Member Billable Rate' })
).not.toBeVisible();
// Verify no API call was made
expect(putRequestSent).toBe(false);
});
test('test that changing role of placeholder member is rejected', async ({ page, ctx }) => { test('test that changing role of placeholder member is rejected', async ({ page, ctx }) => {
const placeholderName = 'RoleChange ' + Math.floor(Math.random() * 10000); const placeholderName = 'RoleChange ' + Math.floor(Math.random() * 10000);
@@ -487,6 +623,125 @@ test('test that accepted invitation disappears from invitations tab', async ({ p
await expect(page.getByText(memberEmail)).not.toBeVisible(); await expect(page.getByText(memberEmail)).not.toBeVisible();
}); });
// =============================================
// Sorting Tests
// =============================================
// Helper to clear localStorage before tests that check sorting
async function clearMemberTableState(page: Page) {
await page.evaluate(() => {
localStorage.removeItem('member-table-state');
});
}
test('test that sorting members by name, role, and status works', async ({ page, ctx }) => {
// Create two placeholder members with names that sort predictably around "John Doe"
await createPlaceholderMemberViaImportApi(ctx, 'AAA SortFirst');
await createPlaceholderMemberViaImportApi(ctx, 'ZZZ SortLast');
await goToMembersPage(page);
await clearMemberTableState(page);
await page.reload();
const table = page.getByTestId('member_table');
await expect(table).toBeVisible();
// -- Name sorting (default is already name asc after clearing state) --
const nameHeader = table.getByText('Name').first();
let names = await getTableRowNames(table);
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('ZZZ SortLast'));
await nameHeader.click(); // toggle to desc
names = await getTableRowNames(table);
expect(names.indexOf('ZZZ SortLast')).toBeLessThan(names.indexOf('AAA SortFirst'));
// -- Role sorting --
const roleHeader = table.getByText('Role').first();
await roleHeader.click(); // asc: Owner(0) < Placeholder(4)
names = await getTableRowNames(table);
const ownerIdx = names.indexOf('John Doe');
const placeholderIdx = names.indexOf('AAA SortFirst');
expect(ownerIdx).toBeLessThan(placeholderIdx);
await roleHeader.click(); // desc: Placeholder first
names = await getTableRowNames(table);
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe'));
// -- Status sorting --
const statusHeader = table.getByText('Status').first();
await statusHeader.click(); // asc: Active(0) < Inactive(1)
names = await getTableRowNames(table);
expect(names.indexOf('John Doe')).toBeLessThan(names.indexOf('AAA SortFirst'));
await statusHeader.click(); // desc: Inactive first
names = await getTableRowNames(table);
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe'));
// -- Email: just verify sort indicator appears --
const emailHeader = table.getByText('Email').first();
await emailHeader.click();
await expect(emailHeader.locator('svg')).toBeVisible();
});
test('test that member sort state persists after page reload', async ({ page }) => {
await goToMembersPage(page);
await clearMemberTableState(page);
await page.reload();
const table = page.getByTestId('member_table');
await expect(table).toBeVisible();
// Click Role header twice to set descending sort
const roleHeader = table.getByText('Role').first();
await roleHeader.click();
await expect(roleHeader.locator('svg')).toBeVisible();
await roleHeader.click();
await expect(roleHeader.locator('svg')).toBeVisible();
// Reload the page
await page.reload();
// Verify the sort indicator is still visible on Role column
await expect(page.getByTestId('member_table')).toBeVisible();
await expect(
page.getByTestId('member_table').getByText('Role').first().locator('svg')
).toBeVisible();
});
test('test that sorting members by billable rate works', async ({ page, ctx }) => {
// Create two placeholder members and set different billable rates
await createPlaceholderMemberViaImportApi(ctx, 'HighRate Member');
await createPlaceholderMemberViaImportApi(ctx, 'LowRate Member');
const members = await getMembersViaApi(ctx);
const highRateMember = members.find((m) => m.name === 'HighRate Member');
const lowRateMember = members.find((m) => m.name === 'LowRate Member');
expect(highRateMember).toBeDefined();
expect(lowRateMember).toBeDefined();
await updateMemberBillableRateViaApi(ctx, highRateMember!.id, 20000);
await updateMemberBillableRateViaApi(ctx, lowRateMember!.id, 5000);
await goToMembersPage(page);
await clearMemberTableState(page);
await page.reload();
const table = page.getByTestId('member_table');
await expect(table).toBeVisible();
// First click = desc (highest first), null rates last
const billableHeader = table.getByText('Billable Rate').first();
await billableHeader.click();
await expect(billableHeader.locator('svg')).toBeVisible();
let names = await getTableRowNames(table);
expect(names.indexOf('HighRate Member')).toBeLessThan(names.indexOf('LowRate Member'));
// Second click = asc (lowest first), null rates still last
await billableHeader.click();
names = await getTableRowNames(table);
expect(names.indexOf('LowRate Member')).toBeLessThan(names.indexOf('HighRate Member'));
});
// ============================================= // =============================================
// Employee Permission Tests // Employee Permission Tests
// ============================================= // =============================================
@@ -522,7 +777,7 @@ test.describe('Employee Sidebar Navigation', () => {
}); });
// Member table is empty — no rows rendered (only headers) // Member table is empty — no rows rendered (only headers)
await expect(employee.page.getByTestId('client_table').locator('[role="row"]')).toHaveCount( await expect(employee.page.getByTestId('member_table').locator('[role="row"]')).toHaveCount(
0 0
); );

View File

@@ -369,6 +369,40 @@ test('test that format settings persist after page reload', async ({ page }) =>
await expect(page.getByLabel('Date Format')).toContainText('DD/MM/YYYY'); await expect(page.getByLabel('Date Format')).toContainText('DD/MM/YYYY');
}); });
// =============================================
// Admin Permission Tests
// =============================================
test.describe('Admin Organization Settings Access', () => {
test('admin can see and edit organization settings', async ({ ctx, admin }) => {
await admin.page.goto(PLAYWRIGHT_BASE_URL + '/teams/' + ctx.orgId);
// Organization Name section is visible
await expect(
admin.page.getByRole('heading', { name: 'Organization Name', level: 3 })
).toBeVisible({ timeout: 10000 });
// Editable settings sections should be visible
await expect(
admin.page.getByRole('heading', { name: 'Billable Rate', level: 3 })
).toBeVisible();
await expect(
admin.page.getByRole('heading', { name: 'Format Settings', level: 3 })
).toBeVisible();
await expect(
admin.page.getByRole('heading', { name: 'Organization Settings', level: 3 })
).toBeVisible();
// Save buttons should be visible (admin can update)
await expect(admin.page.getByRole('button', { name: 'Save' }).first()).toBeVisible();
// Delete organization should NOT be visible (owner only)
await expect(
admin.page.getByRole('heading', { name: 'Delete Organization' })
).not.toBeVisible();
});
});
// ============================================= // =============================================
// Employee Permission Tests // Employee Permission Tests
// ============================================= // =============================================

View File

@@ -3,11 +3,13 @@ import type { Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures'; import { test } from '../playwright/fixtures';
import { formatCentsWithOrganizationDefaults } from './utils/money'; import { formatCentsWithOrganizationDefaults } from './utils/money';
import type { CurrencyFormat } from '../resources/js/packages/ui/src/utils/money';
import { import {
createProjectViaApi, createProjectViaApi,
createPublicProjectViaApi, createPublicProjectViaApi,
createTaskViaApi, createTaskViaApi,
createClientViaApi,
createTimeEntryViaApi,
archiveProjectViaApi,
updateOrganizationSettingViaApi, updateOrganizationSettingViaApi,
} from './utils/api'; } from './utils/api';
@@ -335,61 +337,179 @@ test('test that editing an existing billable project with default rate loads cor
}); });
// Sorting tests // Sorting tests
test('test that sorting projects by name works', async ({ page }) => { test('test that sorting projects by all columns works', async ({ page, ctx }) => {
// Seed projects with distinct values for each sortable column
const clientAlpha = await createClientViaApi(ctx, { name: 'Alpha Client' });
const clientBeta = await createClientViaApi(ctx, { name: 'Beta Client' });
// Project A: client Alpha, low billable rate, has estimated time, active
const projectA = await createProjectViaApi(ctx, {
name: 'AAA Project',
client_id: clientAlpha.id,
is_billable: true,
billable_rate: 5000,
estimated_time: 36000, // 10h
});
// Add 1h of time entries (10% progress)
await createTimeEntryViaApi(ctx, {
duration: '1h',
projectId: projectA.id,
});
// Project B: client Beta, high billable rate, has estimated time, archived
const projectB = await createProjectViaApi(ctx, {
name: 'BBB Project',
client_id: clientBeta.id,
is_billable: true,
billable_rate: 15000,
estimated_time: 7200, // 2h
});
// Add 1h of time entries (50% progress)
await createTimeEntryViaApi(ctx, {
duration: '1h',
projectId: projectB.id,
});
await archiveProjectViaApi(ctx, {
...projectB,
client_id: clientBeta.id,
billable_rate: 15000,
estimated_time: 7200,
});
// Project C: no client, medium billable rate, no estimated time, active
const projectC = await createProjectViaApi(ctx, {
name: 'CCC Project',
is_billable: true,
billable_rate: 10000,
});
// Add 3h of time entries
await createTimeEntryViaApi(ctx, {
duration: '3h',
projectId: projectC.id,
});
await goToProjectsOverview(page); await goToProjectsOverview(page);
await clearProjectTableState(page); await clearProjectTableState(page);
await page.reload(); await page.reload();
// Wait for the table to load
await expect(page.getByTestId('project_table')).toBeVisible(); await expect(page.getByTestId('project_table')).toBeVisible();
await expect(page.getByText('AAA Project')).toBeVisible();
await expect(page.getByText('BBB Project')).toBeVisible();
await expect(page.getByText('CCC Project')).toBeVisible();
// Get initial project names // Helper to get the visual order of our seeded projects by reading
const getProjectNames = async () => { // all row text in a single evaluate call (avoids locator timing issues)
const rows = page const seededNames = ['AAA Project', 'BBB Project', 'CCC Project'];
.getByTestId('project_table') const getOrder = async (): Promise<string[]> => {
.locator('[data-testid="project_table"] > div') const allRowTexts = await page.evaluate(() => {
.filter({ hasNot: page.locator('.border-t') }); const table = document.querySelector('[data-testid="project_table"]');
const names: string[] = []; if (!table) return [];
const count = await page.getByTestId('project_table').getByRole('row').count(); const rows = table.querySelectorAll('[role="row"]');
for (let i = 0; i < count; i++) { return Array.from(rows).map((row) => row.textContent ?? '');
const row = page.getByTestId('project_table').getByRole('row').nth(i); });
const nameCell = row.locator('div').first(); const order: string[] = [];
const text = await nameCell.textContent(); for (const text of allRowTexts) {
if (text) { const match = seededNames.find((name) => text.includes(name));
names.push(text.trim()); if (match) order.push(match);
} }
} return order;
return names;
}; };
// Click on Name header to sort ascending (default should already be ascending) // Helper: click a column header and wait for sort to apply.
const nameHeader = page.getByText('Name').first(); // expectedFirstAmongSeeded = which of our 3 seeded projects should appear first
await nameHeader.click(); const clickSortHeader = async (headerText: string, expectedFirstAmongSeeded: string) => {
const header = page
.locator('[data-testid="project_table"] .select-none', {
hasText: headerText,
})
.first();
await header.click();
// Wait until the expected project appears before the others among our seeded set
await page.waitForFunction(
({ expected, names }) => {
const table = document.querySelector('[data-testid="project_table"]');
if (!table) return false;
const rows = table.querySelectorAll('[role="row"]');
let firstSeededIdx = -1;
for (let i = 0; i < rows.length; i++) {
const text = rows[i].textContent ?? '';
if (names.some((n: string) => text.includes(n))) {
firstSeededIdx = i;
break;
}
}
if (firstSeededIdx === -1) return false;
return (rows[firstSeededIdx].textContent ?? '').includes(expected);
},
{ expected: expectedFirstAmongSeeded, names: seededNames },
{ timeout: 5000 }
);
};
// Wait for sort indicator to appear // --- Sort by Name ---
await expect(nameHeader.locator('svg')).toBeVisible(); // Default is name asc (A-Z)
let order = await getOrder();
expect(order).toEqual(['AAA Project', 'BBB Project', 'CCC Project']);
// Click again to sort descending // Click to toggle to Z-A
await nameHeader.click(); await clickSortHeader('Name', 'CCC Project');
order = await getOrder();
expect(order).toEqual(['CCC Project', 'BBB Project', 'AAA Project']);
// Verify the sort indicator is still visible (showing descending) // --- Sort by Client (text: first click = A-Z, no-client last) ---
await expect(nameHeader.locator('svg')).toBeVisible(); await clickSortHeader('Client', 'AAA Project');
}); order = await getOrder();
expect(order).toEqual(['AAA Project', 'BBB Project', 'CCC Project']); // Alpha, Beta, No client
test('test that sorting projects by status works', async ({ page }) => { // Reverse: Z-A, no-client still last
await goToProjectsOverview(page); await clickSortHeader('Client', 'BBB Project');
await clearProjectTableState(page); order = await getOrder();
await page.reload(); expect(order).toEqual(['BBB Project', 'AAA Project', 'CCC Project']); // Beta, Alpha, No client
// Default is "all" so no filter needed - Wait for the table to load // --- Sort by Total Time (numeric: first click = highest first) ---
await expect(page.getByTestId('project_table')).toBeVisible(); await clickSortHeader('Total Time', 'CCC Project');
order = await getOrder();
expect(order[0]).toBe('CCC Project'); // C=3h first, A and B tied at 1h
// Click on Status header to sort // Reverse: lowest first
const statusHeader = page.getByText('Status').first(); await clickSortHeader('Total Time', 'AAA Project');
await statusHeader.click(); order = await getOrder();
expect(order[2]).toBe('CCC Project'); // C=3h last
// Sort indicator should be visible // --- Sort by Billable Rate (numeric: first click = highest first) ---
await expect(statusHeader.locator('svg')).toBeVisible(); await clickSortHeader('Billable Rate', 'BBB Project');
order = await getOrder();
expect(order).toEqual(['BBB Project', 'CCC Project', 'AAA Project']); // 15000, 10000, 5000
// Reverse: lowest first
await clickSortHeader('Billable Rate', 'AAA Project');
order = await getOrder();
expect(order).toEqual(['AAA Project', 'CCC Project', 'BBB Project']); // 5000, 10000, 15000
// --- Sort by Progress (numeric: first click = highest first, no-estimate last) ---
await clickSortHeader('Progress', 'BBB Project');
order = await getOrder();
expect(order).toEqual(['BBB Project', 'AAA Project', 'CCC Project']); // 50%, 10%, no estimate
// Reverse: lowest first, no-estimate still last
await clickSortHeader('Progress', 'AAA Project');
order = await getOrder();
expect(order).toEqual(['AAA Project', 'BBB Project', 'CCC Project']); // 10%, 50%, no estimate
// --- Sort by Status (first click = active first, archived last) ---
await expect(async () => {
await clickSortHeader('Status', 'AAA Project');
order = await getOrder();
expect(order.indexOf('BBB Project')).toBeGreaterThan(order.indexOf('AAA Project'));
expect(order.indexOf('BBB Project')).toBeGreaterThan(order.indexOf('CCC Project'));
}).toPass({ timeout: 5000 });
// Reverse: archived first
await expect(async () => {
await clickSortHeader('Status', 'BBB Project');
order = await getOrder();
expect(order.indexOf('BBB Project')).toBeLessThan(order.indexOf('AAA Project'));
expect(order.indexOf('BBB Project')).toBeLessThan(order.indexOf('CCC Project'));
}).toPass({ timeout: 5000 });
}); });
// Filter tests // Filter tests
@@ -642,22 +762,6 @@ test('test that estimated time input displays formatted value after blur', async
await expect(estimatedTimeInput).toHaveValue(/1h.*30/); await expect(estimatedTimeInput).toHaveValue(/1h.*30/);
}); });
// Create new project with new Client
// Create new project with existing Client
// Delete project via More Options
// Test that project task count is displayed correctly
// Edit Project Modal Test
// Add Project with billable rate
// Edit Project with billable rate
// Edit Project Member Billable Rate
test('test that editing a task name on the project detail page works', async ({ page, ctx }) => { test('test that editing a task name on the project detail page works', async ({ page, ctx }) => {
const projectName = 'Task Edit Project ' + Math.floor(1 + Math.random() * 10000); const projectName = 'Task Edit Project ' + Math.floor(1 + Math.random() * 10000);
const originalTaskName = 'Original Task ' + Math.floor(1 + Math.random() * 10000); const originalTaskName = 'Original Task ' + Math.floor(1 + Math.random() * 10000);

View File

@@ -8,6 +8,9 @@ import {
createTimeEntryViaApi, createTimeEntryViaApi,
createTimeEntryWithTagViaApi, createTimeEntryWithTagViaApi,
createBareTimeEntryViaApi, createBareTimeEntryViaApi,
createBillableProjectViaApi,
createTimeEntryWithBillableStatusViaApi,
createTagViaApi,
} from './utils/api'; } from './utils/api';
import { import {
goToReporting, goToReporting,
@@ -246,6 +249,191 @@ test('test that shared report with No Task filter shows entries without a task',
await expect(page.getByText('Total')).toBeVisible(); await expect(page.getByText('Total')).toBeVisible();
}); });
test('test that shared report respects task filter', async ({ page, ctx }) => {
const projectName = 'TaskFilterProj ' + Math.floor(Math.random() * 10000);
const taskA = 'TaskA ' + Math.floor(Math.random() * 10000);
const taskB = 'TaskB ' + Math.floor(Math.random() * 10000);
const reportName = 'TaskFilterReport ' + Math.floor(Math.random() * 10000);
const project = await createProjectViaApi(ctx, { name: projectName });
const task = await createTaskViaApi(ctx, { name: taskA, project_id: project.id });
await createTaskViaApi(ctx, { name: taskB, project_id: project.id });
await createTimeEntryViaApi(ctx, {
description: `Entry for ${taskA}`,
duration: '1h',
projectId: project.id,
taskId: task.id,
});
await createTimeEntryViaApi(ctx, {
description: `Entry for ${projectName} no task`,
duration: '2h',
projectId: project.id,
});
await goToReporting(page);
await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();
// Filter by task A
await page.getByRole('button', { name: 'Tasks' }).first().click();
await Promise.all([
page.getByRole('option').filter({ hasText: taskA }).click(),
waitForReportingUpdate(page),
]);
await page.keyboard.press('Escape');
const { shareableLink } = await saveAsSharedReport(page, reportName);
// View the shared report
await page.goto(shareableLink);
await expect(page.getByText('Reporting')).toBeVisible();
await expect(page.getByText('Total')).toBeVisible();
await expect(page.getByText('1h 00min').first()).toBeVisible();
await expect(page.getByText('3h 00min')).not.toBeVisible();
});
test('test that shared report respects client filter', async ({ page, ctx }) => {
const clientA = 'ClientA ' + Math.floor(Math.random() * 10000);
const clientB = 'ClientB ' + Math.floor(Math.random() * 10000);
const projectA = 'ClientFilterProjA ' + Math.floor(Math.random() * 10000);
const projectB = 'ClientFilterProjB ' + Math.floor(Math.random() * 10000);
const reportName = 'ClientFilterReport ' + Math.floor(Math.random() * 10000);
const cliA = await createClientViaApi(ctx, { name: clientA });
const cliB = await createClientViaApi(ctx, { name: clientB });
const projA = await createProjectViaApi(ctx, { name: projectA, client_id: cliA.id });
const projB = await createProjectViaApi(ctx, { name: projectB, client_id: cliB.id });
await createTimeEntryViaApi(ctx, {
description: `Entry for ${clientA}`,
duration: '1h',
projectId: projA.id,
});
await createTimeEntryViaApi(ctx, {
description: `Entry for ${clientB}`,
duration: '2h',
projectId: projB.id,
});
await goToReporting(page);
await expect(page.getByTestId('reporting_view').getByText(projectA)).toBeVisible();
// Filter by client A
await page.getByRole('button', { name: 'Clients' }).first().click();
await Promise.all([
page.getByRole('option').filter({ hasText: clientA }).click(),
waitForReportingUpdate(page),
]);
await page.keyboard.press('Escape');
const { shareableLink } = await saveAsSharedReport(page, reportName);
// View the shared report
await page.goto(shareableLink);
await expect(page.getByText('Reporting')).toBeVisible();
await expect(page.getByText(projectA)).toBeVisible();
await expect(page.getByText(projectB)).not.toBeVisible();
});
test('test that shared report respects tag filter', async ({ page, ctx }) => {
const tagA = 'TagA ' + Math.floor(Math.random() * 10000);
const tagB = 'TagB ' + Math.floor(Math.random() * 10000);
const reportName = 'TagFilterReport ' + Math.floor(Math.random() * 10000);
const tagObjA = await createTagViaApi(ctx, { name: tagA });
await createTagViaApi(ctx, { name: tagB });
await createTimeEntryViaApi(ctx, {
description: `Entry with ${tagA}`,
duration: '1h',
tags: [tagObjA.id],
});
await createBareTimeEntryViaApi(ctx, 'Entry no tags', '2h');
await goToReporting(page);
await expect(page.getByTestId('reporting_view').getByText('Total')).toBeVisible();
// Filter by tag A
await page.getByRole('button', { name: 'Tags' }).first().click();
await Promise.all([
page.getByRole('option').filter({ hasText: tagA }).click(),
waitForReportingUpdate(page),
]);
await page.keyboard.press('Escape');
const { shareableLink } = await saveAsSharedReport(page, reportName);
// View the shared report
await page.goto(shareableLink);
await expect(page.getByText('Reporting')).toBeVisible();
await expect(page.getByText('Total')).toBeVisible();
await expect(page.getByText('1h 00min').first()).toBeVisible();
await expect(page.getByText('3h 00min')).not.toBeVisible();
});
test('test that shared report respects member filter', async ({ page, ctx }) => {
const projectName = 'MemberFilterProj ' + Math.floor(Math.random() * 10000);
const reportName = 'MemberFilterReport ' + Math.floor(Math.random() * 10000);
const project = await createProjectViaApi(ctx, { name: projectName });
await createTimeEntryViaApi(ctx, {
description: `Entry for ${projectName}`,
duration: '1h',
projectId: project.id,
});
await goToReporting(page);
await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();
// Filter by current member (John Doe)
await page.getByRole('button', { name: 'Members' }).first().click();
await Promise.all([
page.getByRole('option').filter({ hasText: 'John Doe' }).click(),
waitForReportingUpdate(page),
]);
await page.keyboard.press('Escape');
const { shareableLink } = await saveAsSharedReport(page, reportName);
// View the shared report — should still show data since all entries belong to this member
await page.goto(shareableLink);
await expect(page.getByText('Reporting')).toBeVisible();
await expect(page.getByText(projectName)).toBeVisible();
await expect(page.getByText('Total')).toBeVisible();
});
test('test that shared report with billable filter only shows billable entries', async ({
page,
ctx,
}) => {
const reportName = 'BillableFilterReport ' + Math.floor(Math.random() * 10000);
// Create one billable (1h) and one non-billable (2h) entry
await createTimeEntryWithBillableStatusViaApi(ctx, true, '1h');
await createTimeEntryWithBillableStatusViaApi(ctx, false, '2h');
await goToReporting(page);
await expect(page.getByTestId('reporting_view').getByText('Total')).toBeVisible();
// Filter by billable only
await page.getByRole('combobox').filter({ hasText: 'Billable' }).click();
await Promise.all([
page.getByRole('option', { name: 'Billable', exact: true }).click(),
waitForReportingUpdate(page),
]);
// Verify only 1h shows before saving
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
const { shareableLink } = await saveAsSharedReport(page, reportName);
// Navigate to the shared report
await page.goto(shareableLink);
await expect(page.getByText('Reporting')).toBeVisible();
await expect(page.getByText('Total')).toBeVisible();
// Shared report should only show the 1h billable entry, not the 2h non-billable
await expect(page.getByText('1h 00min').first()).toBeVisible();
await expect(page.getByText('3h 00min')).not.toBeVisible();
});
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────
// Report Date Picker Tests // Report Date Picker Tests
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────
@@ -577,3 +765,58 @@ test('test that updating expiration date on already-public report works', async
const now = new Date(); const now = new Date();
expect(returnedDate.getTime()).toBeGreaterThan(now.getTime()); expect(returnedDate.getTime()).toBeGreaterThan(now.getTime());
}); });
// ──────────────────────────────────────────────────
// Shared Report Cost Column Tests
// ──────────────────────────────────────────────────
test('test that shared report displays cost column correctly aligned with data rows', async ({
page,
ctx,
}) => {
const projectName = 'BillableProj ' + Math.floor(Math.random() * 10000);
const reportName = 'BillableReport ' + Math.floor(Math.random() * 10000);
const project = await createBillableProjectViaApi(ctx, {
name: projectName,
billable_rate: 10000, // 100.00 per hour
});
await createTimeEntryViaApi(ctx, {
description: `Entry for ${projectName}`,
duration: '1h',
projectId: project.id,
billable: true,
});
await goToReporting(page);
await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();
const { shareableLink } = await saveAsSharedReport(page, reportName);
// Navigate to the shared report
await page.goto(shareableLink);
await expect(page.getByText('Reporting')).toBeVisible();
await expect(page.getByText(projectName)).toBeVisible();
// Verify the table header has all three columns
await expect(page.getByText('Name', { exact: true })).toBeVisible();
await expect(page.getByText('Duration', { exact: true })).toBeVisible();
await expect(page.getByText('Cost', { exact: true })).toBeVisible();
// Verify the Total row displays both duration and cost
await expect(page.getByText('Total')).toBeVisible();
// The data rows should render cost values (not just header + duration)
// With 1h at 100/h the cost should be displayed somewhere in the table
// If showCost is not passed to ReportingRow, only the header "Cost" and
// the Total row cost will render, but individual row costs will be missing
const table = page.locator('[style*="grid-template-columns"]');
// Count elements containing the cost value - header "Cost" + project row cost + total row cost = 3
// If broken (showCost not passed), the project row won't render its cost cell
await expect(table.getByText(/100/).first()).toBeVisible();
// Verify the cost value appears at least twice in the table
// (once for the data row, once for the total) beyond just the header
const costValues = table.getByText(/100/);
await expect(costValues).toHaveCount(2);
});

View File

@@ -3,6 +3,7 @@ import type { Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures'; import { test } from '../playwright/fixtures';
import { createTagViaApi } from './utils/api'; import { createTagViaApi } from './utils/api';
import { getTableRowNames } from './utils/table';
async function goToTagsOverview(page: Page) { async function goToTagsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/tags'); await page.goto(PLAYWRIGHT_BASE_URL + '/tags');
@@ -89,6 +90,57 @@ test('test that multiple tags can be created via API and displayed in the table'
await expect(page.getByTestId('tag_table')).toContainText(tagName2); await expect(page.getByTestId('tag_table')).toContainText(tagName2);
}); });
// =============================================
// Sorting Tests
// =============================================
async function clearTagTableState(page: Page) {
await page.evaluate(() => {
localStorage.removeItem('tag-table-state');
});
}
test('test that sorting tags by name works', async ({ page, ctx }) => {
await createTagViaApi(ctx, { name: 'AAA SortTag' });
await createTagViaApi(ctx, { name: 'ZZZ SortTag' });
await goToTagsOverview(page);
await clearTagTableState(page);
await page.reload();
const table = page.getByTestId('tag_table');
await expect(table).toBeVisible();
// Default is name asc
let names = await getTableRowNames(table);
expect(names.indexOf('AAA SortTag')).toBeLessThan(names.indexOf('ZZZ SortTag'));
const nameHeader = table.getByText('Name').first();
await nameHeader.click(); // toggle to desc
names = await getTableRowNames(table);
expect(names.indexOf('ZZZ SortTag')).toBeLessThan(names.indexOf('AAA SortTag'));
});
test('test that tag sort state persists after page reload', async ({ page }) => {
await goToTagsOverview(page);
await clearTagTableState(page);
await page.reload();
const table = page.getByTestId('tag_table');
await expect(table).toBeVisible();
const nameHeader = table.getByText('Name').first();
await nameHeader.click(); // toggle to desc
await expect(nameHeader.locator('svg')).toBeVisible();
await page.reload();
await expect(page.getByTestId('tag_table')).toBeVisible();
await expect(
page.getByTestId('tag_table').getByText('Name').first().locator('svg')
).toBeVisible();
});
// ============================================= // =============================================
// Employee Permission Tests // Employee Permission Tests
// ============================================= // =============================================

View File

@@ -1159,6 +1159,8 @@ test('test that end time picker works in create modal', async ({ page }) => {
await endTimeInput.press('Tab'); await endTimeInput.press('Tab');
// Set duration (this will adjust based on the times) // Set duration (this will adjust based on the times)
// clear() before fill() needed because fill() appends on Firefox instead of replacing
await page.locator('[role="dialog"] input[name="Duration"]').clear();
await page.locator('[role="dialog"] input[name="Duration"]').fill('1h'); await page.locator('[role="dialog"] input[name="Duration"]').fill('1h');
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab'); await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');

View File

@@ -201,6 +201,37 @@ export async function createProjectViaApi(
return body.data as { id: string; name: string; color: string; is_billable: boolean }; return body.data as { id: string; name: string; color: string; is_billable: boolean };
} }
export async function archiveProjectViaApi(
ctx: TestContext,
project: {
id: string;
name: string;
color: string;
is_billable: boolean;
client_id?: string | null;
billable_rate?: number | null;
estimated_time?: number | null;
}
) {
const response = await ctx.request.put(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/projects/${project.id}`,
{
data: {
name: project.name,
color: project.color,
is_billable: project.is_billable,
is_archived: true,
client_id: project.client_id ?? null,
billable_rate: project.billable_rate ?? null,
estimated_time: project.estimated_time ?? null,
},
}
);
expect(response.status()).toBe(200);
const body = await response.json();
return body.data;
}
export async function createBillableProjectViaApi( export async function createBillableProjectViaApi(
ctx: TestContext, ctx: TestContext,
data: { name: string; billable_rate?: number | null } data: { name: string; billable_rate?: number | null }
@@ -314,6 +345,36 @@ export async function createProjectMemberViaApi(
return body.data as { id: string; billable_rate: number | null }; return body.data as { id: string; billable_rate: number | null };
} }
export async function getMembersViaApi(ctx: TestContext) {
const response = await ctx.request.get(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/members`
);
expect(response.status()).toBe(200);
const body = await response.json();
return body.data as Array<{
id: string;
name: string;
email: string;
role: string;
billable_rate: number | null;
is_placeholder: boolean;
}>;
}
export async function updateMemberBillableRateViaApi(
ctx: TestContext,
memberId: string,
billableRate: number | null
) {
const response = await ctx.request.put(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/members/${memberId}`,
{ data: { billable_rate: billableRate } }
);
expect(response.status()).toBe(200);
const body = await response.json();
return body.data;
}
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────
// Composite helpers (matching existing UI helper signatures) // Composite helpers (matching existing UI helper signatures)
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────

View File

@@ -68,6 +68,85 @@ export async function inviteAndAcceptMember(
await secondUser.close(); await secondUser.close();
} }
/**
* Set up an admin member in the owner's organization.
* Returns the admin's page, their member ID, and a cleanup function.
*/
export async function setupAdminUser(
ownerPage: Page,
ownerCtx: TestContext,
browser: Browser
): Promise<{
adminPage: Page;
adminMemberId: string;
closeAdmin: () => Promise<void>;
}> {
const memberId = Math.floor(Math.random() * 100000);
const memberEmail = `admin+${memberId}@admin-perms.test`;
const memberName = 'Admin ' + memberId;
const admin = await registerUser(browser, memberName, memberEmail);
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: 'Administrator' }).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(),
]);
const acceptUrl = await getInvitationAcceptUrl(admin.page.request, memberEmail);
await admin.page.goto(acceptUrl);
await admin.page.waitForURL(/dashboard/);
await admin.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await expect(admin.page.getByTestId('dashboard_view')).toBeVisible({ timeout: 15000 });
const orgSwitcherText = await admin.page
.getByTestId('organization_switcher')
.first()
.textContent();
if (!orgSwitcherText?.includes("John's Organization")) {
const cookies = await admin.page.context().cookies();
const xsrfCookie = cookies.find((c) => c.name === 'XSRF-TOKEN');
const xsrfToken = xsrfCookie ? decodeURIComponent(xsrfCookie.value) : '';
await admin.page.request.put(`${PLAYWRIGHT_BASE_URL}/current-team`, {
headers: {
'X-XSRF-TOKEN': xsrfToken,
Accept: 'text/html',
},
data: { team_id: ownerCtx.orgId },
});
await admin.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await expect(admin.page.getByTestId('dashboard_view')).toBeVisible({ timeout: 15000 });
}
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 adminMember = membersBody.data.find(
(m: { role: string; name: string }) => m.role === 'admin' && m.name === memberName
);
expect(adminMember).toBeTruthy();
return {
adminPage: admin.page,
adminMemberId: adminMember.id,
closeAdmin: admin.close,
};
}
/** /**
* Set up an employee member in the owner's organization. * Set up an employee member in the owner's organization.
* Returns the employee's page, their member ID, and a cleanup function. * Returns the employee's page, their member ID, and a cleanup function.

16
e2e/utils/table.ts Normal file
View File

@@ -0,0 +1,16 @@
import type { Locator } from '@playwright/test';
/**
* Extract the first cell's text content from each row in a table.
* Useful for reading the ordered names/labels from a sorted table.
*/
export async function getTableRowNames(table: Locator): Promise<string[]> {
const rows = table.getByRole('row');
const count = await rows.count();
const names: string[] = [];
for (let i = 0; i < count; i++) {
const text = await rows.nth(i).locator('div').first().textContent();
if (text) names.push(text.trim());
}
return names;
}

View File

@@ -2,7 +2,7 @@ import { test as baseTest } from '@playwright/test';
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from './config'; import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from './config';
import { type TestContext, setupTestContext } from '../e2e/utils/api'; import { type TestContext, setupTestContext } from '../e2e/utils/api';
import { setupEmployeeUser } from '../e2e/utils/members'; import { setupAdminUser, setupEmployeeUser } from '../e2e/utils/members';
export * from '@playwright/test'; export * from '@playwright/test';
export type { TestContext }; export type { TestContext };
@@ -12,6 +12,11 @@ export interface EmployeeFixture {
memberId: string; memberId: string;
} }
export interface AdminFixture {
page: Page;
memberId: string;
}
/** /**
* API-based authentication fixture - creates a new user via HTTP requests instead of UI interactions. * 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). * This is ~10-25x faster than UI-based authentication (~100-200ms vs ~3-5s).
@@ -19,7 +24,7 @@ export interface EmployeeFixture {
* Uses page.context().request() to ensure cookies are shared between the API request and page. * Uses page.context().request() to ensure cookies are shared between the API request and page.
*/ */
export const test = baseTest.extend< export const test = baseTest.extend<
{ ctx: TestContext; employee: EmployeeFixture }, { ctx: TestContext; employee: EmployeeFixture; admin: AdminFixture },
{ workerStorageState: string } { workerStorageState: string }
>({ >({
page: async ({ page }, use) => { page: async ({ page }, use) => {
@@ -100,4 +105,10 @@ export const test = baseTest.extend<
await use({ page: employeePage, memberId: employeeMemberId }); await use({ page: employeePage, memberId: employeeMemberId });
await closeEmployee(); await closeEmployee();
}, },
admin: async ({ page, ctx, browser }, use) => {
const { adminPage, adminMemberId, closeAdmin } = await setupAdminUser(page, ctx, browser);
await use({ page: adminPage, memberId: adminMemberId });
await closeAdmin();
},
}); });

View File

@@ -2,17 +2,104 @@
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue'; import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { UserCircleIcon } from '@heroicons/vue/24/solid'; import { UserCircleIcon } from '@heroicons/vue/24/solid';
import { PlusIcon } from '@heroicons/vue/16/solid'; import { PlusIcon } from '@heroicons/vue/16/solid';
import { type Component, ref } from 'vue'; import { type Component, computed, ref } from 'vue';
import { type Client } from '@/packages/api/src'; import { type Client } from '@/packages/api/src';
import ClientTableRow from '@/Components/Common/Client/ClientTableRow.vue'; import ClientTableRow from '@/Components/Common/Client/ClientTableRow.vue';
import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue'; import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';
import ClientTableHeading from '@/Components/Common/Client/ClientTableHeading.vue'; import ClientTableHeading from '@/Components/Common/Client/ClientTableHeading.vue';
import { canCreateClients } from '@/utils/permissions'; import { canCreateClients } from '@/utils/permissions';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
import {
useVueTable,
getCoreRowModel,
getSortedRowModel,
type SortingState,
} from '@tanstack/vue-table';
defineProps<{ export type SortColumn = 'name' | 'projects_count' | 'status';
export type SortDirection = 'asc' | 'desc';
const props = defineProps<{
clients: Client[]; clients: Client[];
sortColumn: SortColumn;
sortDirection: SortDirection;
}>(); }>();
const emit = defineEmits<{
sort: [column: SortColumn, direction: SortDirection];
}>();
const createClient = ref(false); const createClient = ref(false);
const { projects } = useProjectsQuery();
const projectCountMap = computed(() => {
const map = new Map<string, number>();
projects.value.forEach((project) => {
if (project.client_id) {
map.set(project.client_id, (map.get(project.client_id) ?? 0) + 1);
}
});
return map;
});
const sorting = computed<SortingState>(() => [
{
id: props.sortColumn,
desc: props.sortDirection === 'desc',
},
]);
const columns = computed(() => [
{
id: 'name',
accessorFn: (row: Client) => row.name.toLowerCase(),
},
{
id: 'projects_count',
sortDescFirst: true,
accessorFn: (row: Client) => projectCountMap.value.get(row.id) ?? 0,
},
{
id: 'status',
accessorFn: (row: Client) => (row.is_archived ? 1 : 0),
},
]);
const descFirstColumns = new Set<SortColumn>(
columns.value
.filter((c) => 'sortDescFirst' in c && c.sortDescFirst)
.map((c) => c.id as SortColumn)
);
function handleSort(column: SortColumn) {
if (props.sortColumn === column) {
emit('sort', column, props.sortDirection === 'asc' ? 'desc' : 'asc');
} else {
emit('sort', column, descFirstColumns.has(column) ? 'desc' : 'asc');
}
}
const table = useVueTable({
get data() {
return props.clients;
},
get columns() {
return columns.value;
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
state: {
get sorting() {
return sorting.value;
},
},
manualSorting: false,
});
const sortedClients = computed(() => {
return table.getRowModel().rows.map((row) => row.original);
});
</script> </script>
<template> <template>
@@ -23,8 +110,12 @@ const createClient = ref(false);
data-testid="client_table" data-testid="client_table"
class="grid min-w-full" class="grid min-w-full"
style="grid-template-columns: 1fr 150px 200px 80px"> style="grid-template-columns: 1fr 150px 200px 80px">
<ClientTableHeading></ClientTableHeading> <ClientTableHeading
<div v-if="clients.length === 0" class="col-span-3 py-24 text-center"> :sort-column="props.sortColumn"
:sort-direction="props.sortDirection"
:desc-first-columns="descFirstColumns"
@sort="handleSort"></ClientTableHeading>
<div v-if="sortedClients.length === 0" class="col-span-3 py-24 text-center">
<UserCircleIcon class="w-8 text-icon-default inline pb-2"></UserCircleIcon> <UserCircleIcon class="w-8 text-icon-default inline pb-2"></UserCircleIcon>
<h3 class="text-text-primary font-semibold">No clients found</h3> <h3 class="text-text-primary font-semibold">No clients found</h3>
<p v-if="canCreateClients()" class="pb-5">Create your first client now!</p> <p v-if="canCreateClients()" class="pb-5">Create your first client now!</p>
@@ -35,7 +126,7 @@ const createClient = ref(false);
>Create your First Client >Create your First Client
</SecondaryButton> </SecondaryButton>
</div> </div>
<template v-for="client in clients" :key="client.id"> <template v-for="client in sortedClients" :key="client.id">
<ClientTableRow :client="client"></ClientTableRow> <ClientTableRow :client="client"></ClientTableRow>
</template> </template>
</div> </div>

View File

@@ -1,18 +1,67 @@
<script setup lang="ts"> <script setup lang="ts">
import TableHeading from '@/Components/Common/TableHeading.vue'; import TableHeading from '@/Components/Common/TableHeading.vue';
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
import type { SortColumn, SortDirection } from '@/Components/Common/Client/ClientTable.vue';
const props = defineProps<{
sortColumn: SortColumn;
sortDirection: SortDirection;
descFirstColumns: ReadonlySet<SortColumn>;
}>();
const emit = defineEmits<{
sort: [column: SortColumn];
}>();
function handleSort(column: SortColumn) {
emit('sort', column);
}
function isSorted(column: SortColumn): boolean {
return props.sortColumn === column;
}
function isChevronDown(column: SortColumn): boolean {
if (!isSorted(column)) return false;
return props.descFirstColumns.has(column)
? props.sortDirection === 'desc'
: props.sortDirection === 'asc';
}
function isChevronUp(column: SortColumn): boolean {
if (!isSorted(column)) return false;
return !isChevronDown(column);
}
</script> </script>
<template> <template>
<TableHeading> <TableHeading>
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12"> <div
class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('name')">
Name Name
<ChevronDownIcon v-if="isChevronDown('name')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('name')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('projects_count')">
Projects
<ChevronDownIcon v-if="isChevronDown('projects_count')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('projects_count')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('status')">
Status
<ChevronDownIcon v-if="isChevronDown('status')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('status')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div> </div>
<div class="px-3 py-1.5 text-left text-text-tertiary"></div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Status</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12"> <div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span> <span class="sr-only">Edit</span>
</div> </div>
</TableHeading> </TableHeading>
</template> </template>
<style scoped></style>

View File

@@ -36,13 +36,13 @@ const showEditModal = ref(false);
<TableRow> <TableRow>
<ClientEditModal v-model:show="showEditModal" :client="client"></ClientEditModal> <ClientEditModal v-model:show="showEditModal" :client="client"></ClientEditModal>
<div <div
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12"> class="whitespace-nowrap flex items-center space-x-5 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<span> <span>
{{ client.name }} {{ client.name }}
</span> </span>
</div> </div>
<div <div
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12"> class="whitespace-nowrap flex items-center px-3 py-4 text-sm font-medium text-text-primary">
<span class="text-text-secondary"> {{ projectCount }} Projects </span> <span class="text-text-secondary"> {{ projectCount }} Projects </span>
</div> </div>
<div <div

View File

@@ -1,20 +1,35 @@
<script setup lang="ts"> <script setup lang="ts">
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue'; import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue'; import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { computed, ref } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import type { Member, UpdateMemberBody } from '@/packages/api/src'; import type { Member, UpdateMemberBody } from '@/packages/api/src';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue'; import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { type MemberBillableKey, useMembersStore } from '@/utils/useMembers'; import { type MemberBillableKey, useMembersStore } from '@/utils/useMembers';
import BillableRateInput from '@/packages/ui/src/Input/BillableRateInput.vue'; import BillableRateInput from '@/packages/ui/src/Input/BillableRateInput.vue';
import { Field, FieldLabel } from '@/packages/ui/src/field'; import { Field, FieldLabel, FieldDescription } from '@/packages/ui/src/field';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/packages/ui/src/tooltip';
import MemberBillableRateModal from '@/Components/Common/Member/MemberBillableRateModal.vue'; import MemberBillableRateModal from '@/Components/Common/Member/MemberBillableRateModal.vue';
import MemberBillableSelect from '@/Components/Common/Member/MemberBillableSelect.vue';
import { onMounted, watch } from 'vue';
import MemberRoleSelect from '@/Components/Common/Member/MemberRoleSelect.vue'; import MemberRoleSelect from '@/Components/Common/Member/MemberRoleSelect.vue';
import MemberOwnershipTransferConfirmModal from '@/Components/Common/Member/MemberOwnershipTransferConfirmModal.vue'; import MemberOwnershipTransferConfirmModal from '@/Components/Common/Member/MemberOwnershipTransferConfirmModal.vue';
import { getOrganizationCurrencyString } from '@/utils/money'; import { getOrganizationCurrencyString } from '@/utils/money';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { getCurrentOrganizationId } from '@/utils/useUser';
const { updateMember } = useMembersStore(); const { updateMember } = useMembersStore();
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
const show = defineModel('show', { default: false }); const show = defineModel('show', { default: false });
const saving = ref(false); const saving = ref(false);
@@ -75,8 +90,24 @@ watch(billableRateSelect, () => {
if (billableRateSelect.value === 'default-rate') { if (billableRateSelect.value === 'default-rate') {
memberBody.value.billable_rate = null; memberBody.value.billable_rate = null;
} else if (billableRateSelect.value === 'custom-rate') { } else if (billableRateSelect.value === 'custom-rate') {
memberBody.value.billable_rate = props.member.billable_rate ?? 0; if (!memberBody.value.billable_rate) {
memberBody.value.billable_rate = organization.value?.billable_rate ?? 0;
} }
}
});
const displayedRate = computed({
get() {
if (billableRateSelect.value === 'default-rate') {
return organization.value?.billable_rate ?? null;
}
return memberBody.value.billable_rate;
},
set(value: number | null) {
if (billableRateSelect.value === 'custom-rate') {
memberBody.value.billable_rate = value;
}
},
}); });
const roleDescriptionTexts = { const roleDescriptionTexts = {
@@ -120,34 +151,55 @@ const roleDescription = computed(() => {
<template #content> <template #content>
<div class="pb-5 pt-2 divide-y divide-border-secondary"> <div class="pb-5 pt-2 divide-y divide-border-secondary">
<div class="pb-5 flex space-x-6"> <div class="pb-5">
<Field> <Field>
<FieldLabel for="role">Role</FieldLabel> <FieldLabel for="role">Role</FieldLabel>
<MemberRoleSelect v-model="memberBody.role" name="role"></MemberRoleSelect> <MemberRoleSelect v-model="memberBody.role" name="role"></MemberRoleSelect>
<FieldDescription v-if="roleDescription">{{
roleDescription
}}</FieldDescription>
</Field> </Field>
<div class="flex-1 text-xs flex items-center pt-6">
<p>{{ roleDescription }}</p>
</div> </div>
</div> <div class="pt-5">
<div class="flex items-center space-x-4 pt-5">
<div class="col-span-6 sm:col-span-4 flex-1 flex space-x-5">
<Field> <Field>
<FieldLabel for="billableType">Billable</FieldLabel> <FieldLabel :icon="BillableIcon" for="billableRateType"
<MemberBillableSelect >Billable Rate</FieldLabel
v-model="billableRateSelect" >
name="billableType"></MemberBillableSelect> <div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
</Field> <Select v-model="billableRateSelect">
<Field v-if="billableRateSelect === 'custom-rate'" class="flex-1"> <SelectTrigger id="billableRateType">
<FieldLabel for="memberBillableRate">Billable Rate</FieldLabel> <SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default-rate">Default Rate</SelectItem>
<SelectItem value="custom-rate">Custom Rate</SelectItem>
</SelectContent>
</Select>
<TooltipProvider v-if="billableRateSelect === 'default-rate'">
<Tooltip>
<TooltipTrigger as-child>
<div>
<BillableRateInput <BillableRateInput
v-model="memberBody.billable_rate" v-model="displayedRate"
:currency="getOrganizationCurrencyString()"
disabled
name="memberBillableRate" />
</div>
</TooltipTrigger>
<TooltipContent
>Uses the default rate of the organization</TooltipContent
>
</Tooltip>
</TooltipProvider>
<BillableRateInput
v-else
v-model="displayedRate"
focus focus
class="w-full"
:currency="getOrganizationCurrencyString()" :currency="getOrganizationCurrencyString()"
name="memberBillableRate" name="memberBillableRate"
@keydown.enter="saveWithChecks()"></BillableRateInput> @keydown.enter="saveWithChecks()" />
</Field>
</div> </div>
</Field>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -2,19 +2,117 @@
import MemberTableHeading from '@/Components/Common/Member/MemberTableHeading.vue'; import MemberTableHeading from '@/Components/Common/Member/MemberTableHeading.vue';
import MemberTableRow from '@/Components/Common/Member/MemberTableRow.vue'; import MemberTableRow from '@/Components/Common/Member/MemberTableRow.vue';
import { useMembersQuery } from '@/utils/useMembersQuery'; import { useMembersQuery } from '@/utils/useMembersQuery';
import type { Member } from '@/packages/api/src';
import { computed } from 'vue';
import {
useVueTable,
getCoreRowModel,
getSortedRowModel,
type SortingState,
} from '@tanstack/vue-table';
export type SortColumn = 'name' | 'email' | 'role' | 'billable_rate' | 'status';
export type SortDirection = 'asc' | 'desc';
const props = defineProps<{
sortColumn: SortColumn;
sortDirection: SortDirection;
}>();
const emit = defineEmits<{
sort: [column: SortColumn, direction: SortDirection];
}>();
const { members } = useMembersQuery(); const { members } = useMembersQuery();
const roleOrder: Record<string, number> = {
owner: 0,
admin: 1,
manager: 2,
employee: 3,
placeholder: 4,
};
const sorting = computed<SortingState>(() => [
{
id: props.sortColumn,
desc: props.sortDirection === 'desc',
},
]);
const columns = [
{
id: 'name',
accessorFn: (row: Member) => row.name.toLowerCase(),
},
{
id: 'email',
accessorFn: (row: Member) => row.email.toLowerCase(),
},
{
id: 'role',
accessorFn: (row: Member) => roleOrder[row.role] ?? 99,
},
{
id: 'billable_rate',
sortDescFirst: true,
sortUndefined: 'last' as const,
accessorFn: (row: Member) => {
if (row.billable_rate === null) return undefined;
return row.billable_rate;
},
},
{
id: 'status',
accessorFn: (row: Member) => (row.is_placeholder ? 1 : 0),
},
];
const descFirstColumns = new Set<SortColumn>(
columns.filter((c) => c.sortDescFirst).map((c) => c.id as SortColumn)
);
function handleSort(column: SortColumn) {
if (props.sortColumn === column) {
emit('sort', column, props.sortDirection === 'asc' ? 'desc' : 'asc');
} else {
emit('sort', column, descFirstColumns.has(column) ? 'desc' : 'asc');
}
}
const table = useVueTable({
get data() {
return members.value;
},
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
state: {
get sorting() {
return sorting.value;
},
},
manualSorting: false,
});
const sortedMembers = computed(() => {
return table.getRowModel().rows.map((row) => row.original);
});
</script> </script>
<template> <template>
<div class="flow-root max-w-[100vw] overflow-x-auto"> <div class="flow-root max-w-[100vw] overflow-x-auto">
<div class="inline-block min-w-full align-middle"> <div class="inline-block min-w-full align-middle">
<div <div
data-testid="client_table" data-testid="member_table"
class="grid min-w-full" class="grid min-w-full"
style="grid-template-columns: 1fr 1fr 180px 180px 150px 130px"> style="grid-template-columns: 1fr 1fr 180px 180px 150px 130px">
<MemberTableHeading></MemberTableHeading> <MemberTableHeading
<template v-for="member in members" :key="member.id"> :sort-column="props.sortColumn"
:sort-direction="props.sortDirection"
:desc-first-columns="descFirstColumns"
@sort="handleSort"></MemberTableHeading>
<template v-for="member in sortedMembers" :key="member.id">
<MemberTableRow :member="member"></MemberTableRow> <MemberTableRow :member="member"></MemberTableRow>
</template> </template>
</div> </div>

View File

@@ -1,20 +1,83 @@
<script setup lang="ts"> <script setup lang="ts">
import TableHeading from '@/Components/Common/TableHeading.vue'; import TableHeading from '@/Components/Common/TableHeading.vue';
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
import type { SortColumn, SortDirection } from '@/Components/Common/Member/MemberTable.vue';
const props = defineProps<{
sortColumn: SortColumn;
sortDirection: SortDirection;
descFirstColumns: ReadonlySet<SortColumn>;
}>();
const emit = defineEmits<{
sort: [column: SortColumn];
}>();
function handleSort(column: SortColumn) {
emit('sort', column);
}
function isSorted(column: SortColumn): boolean {
return props.sortColumn === column;
}
function isChevronDown(column: SortColumn): boolean {
if (!isSorted(column)) return false;
return props.descFirstColumns.has(column)
? props.sortDirection === 'desc'
: props.sortDirection === 'asc';
}
function isChevronUp(column: SortColumn): boolean {
if (!isSorted(column)) return false;
return !isChevronDown(column);
}
</script> </script>
<template> <template>
<TableHeading> <TableHeading>
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12"> <div
class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('name')">
Name Name
<ChevronDownIcon v-if="isChevronDown('name')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('name')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('email')">
Email
<ChevronDownIcon v-if="isChevronDown('email')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('email')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('role')">
Role
<ChevronDownIcon v-if="isChevronDown('role')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('role')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('billable_rate')">
Billable Rate
<ChevronDownIcon v-if="isChevronDown('billable_rate')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('billable_rate')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('status')">
Status
<ChevronDownIcon v-if="isChevronDown('status')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('status')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div> </div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Email</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Role</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Billable Rate</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Status</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background"> <div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
<span class="sr-only">Edit</span> <span class="sr-only">Edit</span>
</div> </div>
</TableHeading> </TableHeading>
</template> </template>
<style scoped></style>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Member, Organization } from '@/packages/api/src'; import type { Member, Organization } from '@/packages/api/src';
import { api } from '@/packages/api/src'; import { api } from '@/packages/api/src';
import { CheckCircleIcon, UserCircleIcon } from '@heroicons/vue/20/solid'; import { CheckCircleIcon, UserCircleIcon } from '@heroicons/vue/24/outline';
import MemberMoreOptionsDropdown from '@/Components/Common/Member/MemberMoreOptionsDropdown.vue'; import MemberMoreOptionsDropdown from '@/Components/Common/Member/MemberMoreOptionsDropdown.vue';
import TableRow from '@/Components/TableRow.vue'; import TableRow from '@/Components/TableRow.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue'; import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
@@ -82,11 +82,15 @@ const userHasValidMailAddress = computed(() => {
}} }}
</div> </div>
<div <div
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1 items-center font-medium"> class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
<CheckCircleIcon v-if="member.is_placeholder === false" class="w-5"></CheckCircleIcon> <template v-if="member.is_placeholder === false">
<span v-if="member.is_placeholder === false">Active</span> <CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
<UserCircleIcon v-if="member.is_placeholder === true" class="w-5"></UserCircleIcon> <span>Active</span>
<span v-if="member.is_placeholder === true">Inactive</span> </template>
<template v-else>
<UserCircleIcon class="w-4 text-icon-default"></UserCircleIcon>
<span>Inactive</span>
</template>
</div> </div>
<div <div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12"> class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">

View File

@@ -4,11 +4,17 @@ import { FolderPlusIcon } from '@heroicons/vue/24/solid';
import { PlusIcon } from '@heroicons/vue/16/solid'; import { PlusIcon } from '@heroicons/vue/16/solid';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue'; import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
import ProjectTableHeading, { import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
type SortColumn,
type SortDirection,
} from '@/Components/Common/Project/ProjectTableHeading.vue';
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue'; import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
export type SortColumn =
| 'name'
| 'client_name'
| 'spent_time'
| 'progress'
| 'billable_rate'
| 'status';
export type SortDirection = 'asc' | 'desc';
import { canCreateProjects } from '@/utils/permissions'; import { canCreateProjects } from '@/utils/permissions';
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src'; import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
import { useProjectsStore } from '@/utils/useProjects'; import { useProjectsStore } from '@/utils/useProjects';
@@ -31,7 +37,7 @@ const props = defineProps<{
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
sort: [column: SortColumn]; sort: [column: SortColumn, direction: SortDirection];
}>(); }>();
const { clients } = useClientsQuery(); const { clients } = useClientsQuery();
@@ -45,7 +51,7 @@ const clientNameMap = computed(() => {
return map; return map;
}); });
// Convert our sort state to TanStack Table format // Convert sort props to TanStack Table format
const sorting = computed<SortingState>(() => [ const sorting = computed<SortingState>(() => [
{ {
id: props.sortColumn, id: props.sortColumn,
@@ -53,38 +59,67 @@ const sorting = computed<SortingState>(() => [
}, },
]); ]);
// Define column accessors for sorting // Define column accessors for sorting.
const columns = [ // Numeric columns use sortDescFirst so that the first click (chevron down) sorts highest-first,
// while text columns default to ascending (A-Z) on first click (chevron down).
const columns = computed(() => [
{ {
id: 'name', id: 'name',
accessorFn: (row: Project) => row.name.toLowerCase(), accessorFn: (row: Project) => row.name.toLowerCase(),
}, },
{ {
id: 'client_name', id: 'client_name',
sortUndefined: 'last' as const,
accessorFn: (row: Project) => { accessorFn: (row: Project) => {
if (!row.client_id) return ''; if (!row.client_id) return undefined;
return (clientNameMap.value.get(row.client_id) ?? '').toLowerCase(); return (clientNameMap.value.get(row.client_id) ?? '').toLowerCase();
}, },
}, },
{ {
id: 'spent_time', id: 'spent_time',
sortDescFirst: true,
accessorFn: (row: Project) => row.spent_time ?? 0, accessorFn: (row: Project) => row.spent_time ?? 0,
}, },
{
id: 'progress',
sortDescFirst: true,
sortUndefined: 'last' as const,
accessorFn: (row: Project) => {
if (!row.estimated_time) return undefined;
return (row.spent_time / row.estimated_time) * 100;
},
},
{ {
id: 'billable_rate', id: 'billable_rate',
sortDescFirst: true,
accessorFn: (row: Project) => row.billable_rate ?? 0, accessorFn: (row: Project) => row.billable_rate ?? 0,
}, },
{ {
id: 'status', id: 'status',
accessorFn: (row: Project) => (row.is_archived ? 1 : 0), accessorFn: (row: Project) => (row.is_archived ? 1 : 0),
}, },
]; ]);
// Columns with sortDescFirst get desc as default direction on first click.
const descFirstColumns = new Set<SortColumn>(
columns.value.filter((c) => c.sortDescFirst).map((c) => c.id as SortColumn)
);
function handleSort(column: SortColumn) {
if (props.sortColumn === column) {
emit('sort', column, props.sortDirection === 'asc' ? 'desc' : 'asc');
} else {
emit('sort', column, descFirstColumns.has(column) ? 'desc' : 'asc');
}
}
const table = useVueTable({ const table = useVueTable({
get data() { get data() {
return props.projects; return props.projects;
}, },
columns, get columns() {
return columns.value;
},
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
state: { state: {
@@ -99,10 +134,6 @@ const sortedProjects = computed(() => {
return table.getRowModel().rows.map((row) => row.original); return table.getRowModel().rows.map((row) => row.original);
}); });
function handleSort(column: SortColumn) {
emit('sort', column);
}
const showCreateProjectModal = ref(false); const showCreateProjectModal = ref(false);
async function createProject(project: CreateProjectBody): Promise<Project | undefined> { async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
@@ -133,6 +164,7 @@ const gridTemplate = computed(() => {
:show-billable-rate="props.showBillableRate" :show-billable-rate="props.showBillableRate"
:sort-column="props.sortColumn" :sort-column="props.sortColumn"
:sort-direction="props.sortDirection" :sort-direction="props.sortDirection"
:desc-first-columns="descFirstColumns"
@sort="handleSort"></ProjectTableHeading> @sort="handleSort"></ProjectTableHeading>
<div v-if="sortedProjects.length === 0" class="col-span-5 py-24 text-center"> <div v-if="sortedProjects.length === 0" class="col-span-5 py-24 text-center">
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon> <FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>

View File

@@ -1,14 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import TableHeading from '@/Components/Common/TableHeading.vue'; import TableHeading from '@/Components/Common/TableHeading.vue';
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid'; import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
import type { SortColumn, SortDirection } from '@/Components/Common/Project/ProjectTable.vue';
export type SortColumn = 'name' | 'client_name' | 'spent_time' | 'billable_rate' | 'status';
export type SortDirection = 'asc' | 'desc';
const props = defineProps<{ const props = defineProps<{
showBillableRate: boolean; showBillableRate: boolean;
sortColumn: SortColumn; sortColumn: SortColumn;
sortDirection: SortDirection; sortDirection: SortDirection;
descFirstColumns: ReadonlySet<SortColumn>;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -22,6 +21,18 @@ function handleSort(column: SortColumn) {
function isSorted(column: SortColumn): boolean { function isSorted(column: SortColumn): boolean {
return props.sortColumn === column; return props.sortColumn === column;
} }
function isChevronDown(column: SortColumn): boolean {
if (!isSorted(column)) return false;
return props.descFirstColumns.has(column)
? props.sortDirection === 'desc'
: props.sortDirection === 'asc';
}
function isChevronUp(column: SortColumn): boolean {
if (!isSorted(column)) return false;
return !isChevronDown(column);
}
</script> </script>
<template> <template>
@@ -30,58 +41,49 @@ function isSorted(column: SortColumn): boolean {
class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1" class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('name')"> @click="handleSort('name')">
Name Name
<ChevronDownIcon v-if="isSorted('name') && sortDirection === 'asc'" class="w-4 h-4" /> <ChevronDownIcon v-if="isChevronDown('name')" class="w-4 h-4" />
<ChevronUpIcon <ChevronUpIcon v-else-if="isChevronUp('name')" class="w-4 h-4" />
v-else-if="isSorted('name') && sortDirection === 'desc'"
class="w-4 h-4" />
<span v-else class="w-4 h-4"></span> <span v-else class="w-4 h-4"></span>
</div> </div>
<div <div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1" class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('client_name')"> @click="handleSort('client_name')">
Client Client
<ChevronDownIcon <ChevronDownIcon v-if="isChevronDown('client_name')" class="w-4 h-4" />
v-if="isSorted('client_name') && sortDirection === 'asc'" <ChevronUpIcon v-else-if="isChevronUp('client_name')" class="w-4 h-4" />
class="w-4 h-4" />
<ChevronUpIcon
v-else-if="isSorted('client_name') && sortDirection === 'desc'"
class="w-4 h-4" />
<span v-else class="w-4 h-4"></span> <span v-else class="w-4 h-4"></span>
</div> </div>
<div <div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1" class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('spent_time')"> @click="handleSort('spent_time')">
Total Time Total Time
<ChevronDownIcon <ChevronDownIcon v-if="isChevronDown('spent_time')" class="w-4 h-4" />
v-if="isSorted('spent_time') && sortDirection === 'asc'" <ChevronUpIcon v-else-if="isChevronUp('spent_time')" class="w-4 h-4" />
class="w-4 h-4" /> <span v-else class="w-4 h-4"></span>
<ChevronUpIcon </div>
v-else-if="isSorted('spent_time') && sortDirection === 'desc'" <div
class="w-4 h-4" /> class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('progress')">
Progress
<ChevronDownIcon v-if="isChevronDown('progress')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('progress')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span> <span v-else class="w-4 h-4"></span>
</div> </div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Progress</div>
<div <div
v-if="showBillableRate" v-if="showBillableRate"
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1" class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('billable_rate')"> @click="handleSort('billable_rate')">
Billable Rate Billable Rate
<ChevronDownIcon <ChevronDownIcon v-if="isChevronDown('billable_rate')" class="w-4 h-4" />
v-if="isSorted('billable_rate') && sortDirection === 'asc'" <ChevronUpIcon v-else-if="isChevronUp('billable_rate')" class="w-4 h-4" />
class="w-4 h-4" />
<ChevronUpIcon
v-else-if="isSorted('billable_rate') && sortDirection === 'desc'"
class="w-4 h-4" />
<span v-else class="w-4 h-4"></span> <span v-else class="w-4 h-4"></span>
</div> </div>
<div <div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1" class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('status')"> @click="handleSort('status')">
Status Status
<ChevronDownIcon v-if="isSorted('status') && sortDirection === 'asc'" class="w-4 h-4" /> <ChevronDownIcon v-if="isChevronDown('status')" class="w-4 h-4" />
<ChevronUpIcon <ChevronUpIcon v-else-if="isChevronUp('status')" class="w-4 h-4" />
v-else-if="isSorted('status') && sortDirection === 'desc'"
class="w-4 h-4" />
<span v-else class="w-4 h-4"></span> <span v-else class="w-4 h-4"></span>
</div> </div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12"> <div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">

View File

@@ -6,7 +6,7 @@ import { ref } from 'vue';
import PrimaryButton from '../../../packages/ui/src/Buttons/PrimaryButton.vue'; import PrimaryButton from '../../../packages/ui/src/Buttons/PrimaryButton.vue';
import { Field, FieldLabel } from '@/packages/ui/src/field'; import { Field, FieldLabel } from '@/packages/ui/src/field';
import type { CreateReportBody, CreateReportBodyProperties } from '@/packages/api/src'; import type { CreateReportBody, CreateReportBodyProperties } from '@/packages/api/src';
import { useMutation } from '@tanstack/vue-query'; import { useMutation, useQueryClient } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
import { api } from '@/packages/api/src'; import { api } from '@/packages/api/src';
import { Checkbox } from '@/packages/ui/src'; import { Checkbox } from '@/packages/ui/src';
@@ -17,6 +17,7 @@ import { router } from '@inertiajs/vue3';
const show = defineModel('show', { default: false }); const show = defineModel('show', { default: false });
const saving = ref(false); const saving = ref(false);
const queryClient = useQueryClient();
const createReportMutation = useMutation({ const createReportMutation = useMutation({
mutationFn: async (report: CreateReportBody) => { mutationFn: async (report: CreateReportBody) => {
@@ -30,6 +31,11 @@ const createReportMutation = useMutation({
}, },
}); });
}, },
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['reports'],
});
},
}); });
const props = defineProps<{ const props = defineProps<{

View File

@@ -157,8 +157,18 @@ const aggregatedTableTimeEntries = computed<AggregatedTimeEntries | undefined>((
}); });
const reportProperties = computed(() => { const reportProperties = computed(() => {
const { billable: billableFilter, ...rest } = filterParams.value;
let billableValue: boolean | null = null;
if (billableFilter === 'true') {
billableValue = true;
} else if (billableFilter === 'false') {
billableValue = false;
}
return { return {
...filterParams.value, ...rest,
billable: billableValue,
group: group.value, group: group.value,
sub_group: subGroup.value, sub_group: subGroup.value,
history_group: getOptimalGroupingOption(startDate.value, endDate.value), history_group: getOptimalGroupingOption(startDate.value, endDate.value),
@@ -427,8 +437,7 @@ const tableData = computed(() => {
v-if="showBillableRate" v-if="showBillableRate"
class="justify-end pr-6 flex items-center font-medium"> class="justify-end pr-6 flex items-center font-medium">
{{ {{
aggregatedTableTimeEntries.cost !== null && aggregatedTableTimeEntries.cost
aggregatedTableTimeEntries.cost !== undefined
? formatCents( ? formatCents(
aggregatedTableTimeEntries.cost, aggregatedTableTimeEntries.cost,
getOrganizationCurrencyString(), getOrganizationCurrencyString(),

View File

@@ -2,18 +2,80 @@
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue'; import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { FolderPlusIcon } from '@heroicons/vue/24/solid'; import { FolderPlusIcon } from '@heroicons/vue/24/solid';
import { PlusIcon } from '@heroicons/vue/16/solid'; import { PlusIcon } from '@heroicons/vue/16/solid';
import { ref } from 'vue'; import { computed, ref } from 'vue';
import { useTagsQuery } from '@/utils/useTagsQuery'; import { useTagsQuery } from '@/utils/useTagsQuery';
import TagTableRow from '@/Components/Common/Tag/TagTableRow.vue'; import TagTableRow from '@/Components/Common/Tag/TagTableRow.vue';
import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue'; import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
import TagTableHeading from '@/Components/Common/Tag/TagTableHeading.vue'; import TagTableHeading from '@/Components/Common/Tag/TagTableHeading.vue';
import { canCreateTags } from '@/utils/permissions'; import { canCreateTags } from '@/utils/permissions';
import type { Tag } from '@/packages/api/src'; import type { Tag } from '@/packages/api/src';
defineProps<{ import {
useVueTable,
getCoreRowModel,
getSortedRowModel,
type SortingState,
} from '@tanstack/vue-table';
export type SortColumn = 'name';
export type SortDirection = 'asc' | 'desc';
const props = defineProps<{
createTag: (name: string) => Promise<Tag | undefined>; createTag: (name: string) => Promise<Tag | undefined>;
sortColumn: SortColumn;
sortDirection: SortDirection;
}>(); }>();
const emit = defineEmits<{
sort: [column: SortColumn, direction: SortDirection];
}>();
const { tags } = useTagsQuery(); const { tags } = useTagsQuery();
const showCreateTagModal = ref(false); const showCreateTagModal = ref(false);
const sorting = computed<SortingState>(() => [
{
id: props.sortColumn,
desc: props.sortDirection === 'desc',
},
]);
const columns = [
{
id: 'name',
accessorFn: (row: Tag) => row.name.toLowerCase(),
},
];
const descFirstColumns = new Set<SortColumn>(
columns.filter((c) => 'sortDescFirst' in c && c.sortDescFirst).map((c) => c.id as SortColumn)
);
function handleSort(column: SortColumn) {
if (props.sortColumn === column) {
emit('sort', column, props.sortDirection === 'asc' ? 'desc' : 'asc');
} else {
emit('sort', column, descFirstColumns.has(column) ? 'desc' : 'asc');
}
}
const table = useVueTable({
get data() {
return tags.value;
},
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
state: {
get sorting() {
return sorting.value;
},
},
manualSorting: false,
});
const sortedTags = computed(() => {
return table.getRowModel().rows.map((row) => row.original);
});
</script> </script>
<template> <template>
@@ -24,8 +86,12 @@ const showCreateTagModal = ref(false);
data-testid="tag_table" data-testid="tag_table"
class="grid min-w-full" class="grid min-w-full"
style="grid-template-columns: 1fr 80px"> style="grid-template-columns: 1fr 80px">
<TagTableHeading></TagTableHeading> <TagTableHeading
<div v-if="tags.length === 0" class="col-span-5 py-24 text-center"> :sort-column="props.sortColumn"
:sort-direction="props.sortDirection"
:desc-first-columns="descFirstColumns"
@sort="handleSort"></TagTableHeading>
<div v-if="sortedTags.length === 0" class="col-span-5 py-24 text-center">
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon> <FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
<h3 class="text-text-primary font-semibold">No tags found</h3> <h3 class="text-text-primary font-semibold">No tags found</h3>
<p v-if="canCreateTags()" class="pb-5">Create your first tag now!</p> <p v-if="canCreateTags()" class="pb-5">Create your first tag now!</p>
@@ -36,7 +102,7 @@ const showCreateTagModal = ref(false);
>Create your First Tag</SecondaryButton >Create your First Tag</SecondaryButton
> >
</div> </div>
<template v-for="tag in tags" :key="tag.id"> <template v-for="tag in sortedTags" :key="tag.id">
<TagTableRow :tag="tag"></TagTableRow> <TagTableRow :tag="tag"></TagTableRow>
</template> </template>
</div> </div>

View File

@@ -1,16 +1,51 @@
<script setup lang="ts"> <script setup lang="ts">
import TableHeading from '@/Components/Common/TableHeading.vue'; import TableHeading from '@/Components/Common/TableHeading.vue';
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
import type { SortColumn, SortDirection } from '@/Components/Common/Tag/TagTable.vue';
const props = defineProps<{
sortColumn: SortColumn;
sortDirection: SortDirection;
descFirstColumns: ReadonlySet<SortColumn>;
}>();
const emit = defineEmits<{
sort: [column: SortColumn];
}>();
function handleSort(column: SortColumn) {
emit('sort', column);
}
function isSorted(column: SortColumn): boolean {
return props.sortColumn === column;
}
function isChevronDown(column: SortColumn): boolean {
if (!isSorted(column)) return false;
return props.descFirstColumns.has(column)
? props.sortDirection === 'desc'
: props.sortDirection === 'asc';
}
function isChevronUp(column: SortColumn): boolean {
if (!isSorted(column)) return false;
return !isChevronDown(column);
}
</script> </script>
<template> <template>
<TableHeading> <TableHeading>
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12"> <div
class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('name')">
Name Name
<ChevronDownIcon v-if="isChevronDown('name')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('name')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div> </div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12"> <div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span> <span class="sr-only">Edit</span>
</div> </div>
</TableHeading> </TableHeading>
</template> </template>
<style scoped></style>

View File

@@ -12,6 +12,8 @@ import PageTitle from '@/Components/Common/PageTitle.vue';
import { canCreateClients } from '@/utils/permissions'; import { canCreateClients } from '@/utils/permissions';
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue'; import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
import TabBar from '@/Components/Common/TabBar/TabBar.vue'; import TabBar from '@/Components/Common/TabBar/TabBar.vue';
import { useStorage } from '@vueuse/core';
import type { SortColumn, SortDirection } from '@/Components/Common/Client/ClientTable.vue';
const { clients } = useClientsQuery(); const { clients } = useClientsQuery();
@@ -19,6 +21,26 @@ const activeTab = ref<'active' | 'archived'>('active');
const createClient = ref(false); const createClient = ref(false);
interface ClientTableState {
sortColumn: SortColumn;
sortDirection: SortDirection;
}
const tableState = useStorage<ClientTableState>(
'client-table-state',
{
sortColumn: 'name',
sortDirection: 'asc',
},
undefined,
{ mergeDefaults: true }
);
function handleSort(column: SortColumn, direction: SortDirection) {
tableState.value.sortColumn = column;
tableState.value.sortDirection = direction;
}
const shownClients = computed(() => { const shownClients = computed(() => {
return clients.value.filter((client) => { return clients.value.filter((client) => {
if (activeTab.value === 'active') { if (activeTab.value === 'active') {
@@ -45,6 +67,10 @@ const shownClients = computed(() => {
> >
<ClientCreateModal v-model:show="createClient"></ClientCreateModal> <ClientCreateModal v-model:show="createClient"></ClientCreateModal>
</MainContainer> </MainContainer>
<ClientTable :clients="shownClients"></ClientTable> <ClientTable
:clients="shownClients"
:sort-column="tableState.sortColumn"
:sort-direction="tableState.sortDirection"
@sort="handleSort"></ClientTable>
</AppLayout> </AppLayout>
</template> </template>

View File

@@ -13,6 +13,8 @@ import type { Role } from '@/types/jetstream';
import PageTitle from '@/Components/Common/PageTitle.vue'; import PageTitle from '@/Components/Common/PageTitle.vue';
import InvitationTable from '@/Components/Common/Invitation/InvitationTable.vue'; import InvitationTable from '@/Components/Common/Invitation/InvitationTable.vue';
import { canCreateInvitations } from '@/utils/permissions'; import { canCreateInvitations } from '@/utils/permissions';
import { useStorage } from '@vueuse/core';
import type { SortColumn, SortDirection } from '@/Components/Common/Member/MemberTable.vue';
const inviteMember = ref(false); const inviteMember = ref(false);
@@ -21,6 +23,26 @@ defineProps<{
}>(); }>();
const activeTab = ref<'all' | 'invitations'>('all'); const activeTab = ref<'all' | 'invitations'>('all');
interface MemberTableState {
sortColumn: SortColumn;
sortDirection: SortDirection;
}
const tableState = useStorage<MemberTableState>(
'member-table-state',
{
sortColumn: 'name',
sortDirection: 'asc',
},
undefined,
{ mergeDefaults: true }
);
function handleSort(column: SortColumn, direction: SortDirection) {
tableState.value.sortColumn = column;
tableState.value.sortDirection = direction;
}
</script> </script>
<template> <template>
@@ -45,7 +67,11 @@ const activeTab = ref<'all' | 'invitations'>('all');
:available-roles="availableRoles" :available-roles="availableRoles"
@close="activeTab = 'invitations'"></MemberInviteModal> @close="activeTab = 'invitations'"></MemberInviteModal>
</MainContainer> </MainContainer>
<MemberTable v-if="activeTab === 'all'"></MemberTable> <MemberTable
v-if="activeTab === 'all'"
:sort-column="tableState.sortColumn"
:sort-direction="tableState.sortDirection"
@sort="handleSort"></MemberTable>
<InvitationTable v-if="activeTab === 'invitations'"></InvitationTable> <InvitationTable v-if="activeTab === 'invitations'"></InvitationTable>
</AppLayout> </AppLayout>
</template> </template>

View File

@@ -4,10 +4,6 @@ import AppLayout from '@/Layouts/AppLayout.vue';
import { FolderIcon, PlusIcon } from '@heroicons/vue/20/solid'; import { FolderIcon, PlusIcon } from '@heroicons/vue/20/solid';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue'; import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import ProjectTable from '@/Components/Common/Project/ProjectTable.vue'; import ProjectTable from '@/Components/Common/Project/ProjectTable.vue';
import type {
SortColumn,
SortDirection,
} from '@/Components/Common/Project/ProjectTableHeading.vue';
import { computed } from 'vue'; import { computed } from 'vue';
import { useProjectsQuery } from '@/utils/useProjectsQuery'; import { useProjectsQuery } from '@/utils/useProjectsQuery';
import { useProjectsStore } from '@/utils/useProjects'; import { useProjectsStore } from '@/utils/useProjects';
@@ -26,6 +22,7 @@ import ProjectsFilterDropdown from '@/Components/Common/Project/ProjectsFilterDr
import ProjectStatusFilterBadge from '@/Components/Common/Project/ProjectStatusFilterBadge.vue'; import ProjectStatusFilterBadge from '@/Components/Common/Project/ProjectStatusFilterBadge.vue';
import ProjectClientFilterBadge from '@/Components/Common/Project/ProjectClientFilterBadge.vue'; import ProjectClientFilterBadge from '@/Components/Common/Project/ProjectClientFilterBadge.vue';
import { NO_CLIENT_ID } from '@/Components/Common/Project/constants'; import { NO_CLIENT_ID } from '@/Components/Common/Project/constants';
import type { SortColumn, SortDirection } from '@/Components/Common/Project/ProjectTable.vue';
// Fetch data using TanStack Query // Fetch data using TanStack Query
const { projects } = useProjectsQuery(); const { projects } = useProjectsQuery();
@@ -56,14 +53,9 @@ const tableState = useStorage<ProjectTableState>(
{ mergeDefaults: true } { mergeDefaults: true }
); );
// Handle sorting - toggle direction if same column, otherwise set new column with asc function handleSort(column: SortColumn, direction: SortDirection) {
function handleSort(column: SortColumn) {
if (tableState.value.sortColumn === column) {
tableState.value.sortDirection = tableState.value.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
tableState.value.sortColumn = column; tableState.value.sortColumn = column;
tableState.value.sortDirection = 'asc'; tableState.value.sortDirection = direction;
}
} }
// Filter projects based on current filters // Filter projects based on current filters

View File

@@ -2,29 +2,10 @@
import MainContainer from '@/packages/ui/src/MainContainer.vue'; import MainContainer from '@/packages/ui/src/MainContainer.vue';
import AppLayout from '@/Layouts/AppLayout.vue'; import AppLayout from '@/Layouts/AppLayout.vue';
import PageTitle from '@/Components/Common/PageTitle.vue'; import PageTitle from '@/Components/Common/PageTitle.vue';
import { import { ChartBarIcon, CreditCardIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
ChartBarIcon, import { computed } from 'vue';
ChevronLeftIcon,
ChevronDoubleLeftIcon,
ChevronRightIcon,
ChevronDoubleRightIcon,
CreditCardIcon,
UserGroupIcon,
} from '@heroicons/vue/20/solid';
import { computed, ref, watch } from 'vue';
import { api, type ReportIndexResponse } from '@/packages/api/src'; import { useQuery } from '@tanstack/vue-query';
import {
PaginationEllipsis,
PaginationFirst,
PaginationLast,
PaginationList,
PaginationListItem,
PaginationNext,
PaginationPrev,
PaginationRoot,
} from 'radix-vue';
import { useQuery, useQueryClient } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue'; import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
import ReportTable from '@/Components/Common/Report/ReportTable.vue'; import ReportTable from '@/Components/Common/Report/ReportTable.vue';
@@ -32,37 +13,22 @@ import { isAllowedToPerformPremiumAction, isBillingActivated } from '@/utils/bil
import { canManageBilling, canUpdateOrganization } from '@/utils/permissions'; import { canManageBilling, canUpdateOrganization } from '@/utils/permissions';
import PrimaryButton from '../packages/ui/src/Buttons/PrimaryButton.vue'; import PrimaryButton from '../packages/ui/src/Buttons/PrimaryButton.vue';
import { Link } from '@inertiajs/vue3'; import { Link } from '@inertiajs/vue3';
import { fetchAllReports } from '@/utils/useReportsQuery';
const pageLimit = 15; const { data: reportsData } = useQuery({
const currentPage = ref(1); queryKey: computed(() => ['reports', getCurrentOrganizationId()]),
const { data: reportsResponse } = useQuery<ReportIndexResponse>({
queryKey: ['reports', currentPage],
enabled: !!getCurrentOrganizationId(), enabled: !!getCurrentOrganizationId(),
queryFn: () => queryFn: async () => {
api.getReports({ const organizationId = getCurrentOrganizationId();
params: { if (!organizationId) throw new Error('No organization');
organization: getCurrentOrganizationId() || '', const data = await fetchAllReports(organizationId);
return { data };
}, },
}), staleTime: 1000 * 30,
}); });
const reports = computed(() => { const reports = computed(() => {
return reportsResponse.value?.data ?? []; return reportsData.value?.data ?? [];
});
const totalPages = computed(() => {
return 1;
});
const queryClient = useQueryClient();
async function updateFilteredTimeEntries() {
await queryClient.invalidateQueries({
queryKey: ['reports'],
});
}
watch(currentPage, () => {
updateFilteredTimeEntries();
}); });
</script> </script>
@@ -110,64 +76,5 @@ watch(currentPage, () => {
<ReportTable <ReportTable
v-if="reports.length > 0 || isAllowedToPerformPremiumAction()" v-if="reports.length > 0 || isAllowedToPerformPremiumAction()"
:reports="reports"></ReportTable> :reports="reports"></ReportTable>
<PaginationRoot
v-if="reports.length > 0 || isAllowedToPerformPremiumAction()"
v-model:page="currentPage"
:total="totalPages"
:items-per-page="pageLimit"
class="flex justify-center items-center py-8"
:sibling-count="1"
show-edges>
<PaginationList v-slot="{ items }" class="flex items-center space-x-1 relative">
<div class="pr-2 flex items-center space-x-1 border-r border-border-primary mr-1">
<PaginationFirst class="navigation-item">
<ChevronDoubleLeftIcon class="w-4"> </ChevronDoubleLeftIcon>
</PaginationFirst>
<PaginationPrev class="mr-4 navigation-item">
<ChevronLeftIcon class="w-4 text-text-tertiary hover:text-text-primary">
</ChevronLeftIcon>
</PaginationPrev>
</div>
<template v-for="(page, index) in items">
<PaginationListItem
v-if="page.type === 'page'"
:key="index"
class="pagination-item"
:value="page.value">
{{ page.value }}
</PaginationListItem>
<PaginationEllipsis
v-else
:key="page.type"
:index="index"
class="PaginationEllipsis">
<div class="px-2">&#8230;</div>
</PaginationEllipsis>
</template>
<div class="!ml-2 pl-2 flex items-center space-x-1 border-l border-border-primary">
<PaginationNext class="navigation-item">
<ChevronRightIcon
class="w-4 text-text-tertiary hover:text-text-primary"></ChevronRightIcon>
</PaginationNext>
<PaginationLast class="navigation-item">
<ChevronDoubleRightIcon
class="w-4 text-text-tertiary hover:text-text-primary"></ChevronDoubleRightIcon>
</PaginationLast>
</div>
</PaginationList>
</PaginationRoot>
</AppLayout> </AppLayout>
</template> </template>
<style lang="postcss">
.navigation-item {
@apply bg-quaternary h-8 w-8 flex items-center justify-center rounded border border-border-primary text-text-tertiary hover:text-text-primary transition cursor-pointer hover:border-border-secondary hover:bg-secondary focus-visible:text-text-primary focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-ring;
}
.pagination-item {
@apply bg-secondary h-8 w-8 flex items-center justify-center rounded border border-border-tertiary text-text-secondary hover:text-text-primary transition cursor-pointer hover:border-border-secondary hover:bg-secondary focus-visible:text-text-primary focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-ring;
}
.pagination-item[data-selected] {
@apply text-text-primary bg-accent-300/10 border border-accent-300/20 rounded-md font-medium hover:bg-accent-300/20 active:bg-accent-300/20 outline-0 focus-visible:ring-2 focus:ring-ring transition ease-in-out duration-150;
}
</style>

View File

@@ -222,6 +222,7 @@ onMounted(async () => {
:key="entry.description ?? 'none'" :key="entry.description ?? 'none'"
:currency="reportCurrency" :currency="reportCurrency"
:currency-format="reportCurrencyFormat" :currency-format="reportCurrencyFormat"
:show-cost="true"
:entry="entry"></ReportingRow> :entry="entry"></ReportingRow>
<div <div
class="contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]"> class="contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]">
@@ -239,12 +240,15 @@ onMounted(async () => {
</div> </div>
<div class="justify-end pr-6 flex items-center font-medium"> <div class="justify-end pr-6 flex items-center font-medium">
{{ {{
formatCents( aggregatedTableTimeEntries.cost
? formatCents(
aggregatedTableTimeEntries.cost, aggregatedTableTimeEntries.cost,
reportCurrency, reportCurrency,
reportCurrencyFormat, reportCurrencyFormat,
reportCurrencySymbol reportCurrencySymbol,
reportNumberFormat
) )
: '--'
}} }}
</div> </div>
</div> </div>

View File

@@ -9,9 +9,31 @@ import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
import PageTitle from '@/Components/Common/PageTitle.vue'; import PageTitle from '@/Components/Common/PageTitle.vue';
import { canCreateTags } from '@/utils/permissions'; import { canCreateTags } from '@/utils/permissions';
import { useTagsStore } from '@/utils/useTags'; import { useTagsStore } from '@/utils/useTags';
import { useStorage } from '@vueuse/core';
import type { SortColumn, SortDirection } from '@/Components/Common/Tag/TagTable.vue';
const showCreateTagModal = ref(false); const showCreateTagModal = ref(false);
interface TagTableState {
sortColumn: SortColumn;
sortDirection: SortDirection;
}
const tableState = useStorage<TagTableState>(
'tag-table-state',
{
sortColumn: 'name',
sortDirection: 'asc',
},
undefined,
{ mergeDefaults: true }
);
function handleSort(column: SortColumn, direction: SortDirection) {
tableState.value.sortColumn = column;
tableState.value.sortDirection = direction;
}
async function createTag(tag: string) { async function createTag(tag: string) {
return await useTagsStore().createTag(tag); return await useTagsStore().createTag(tag);
} }
@@ -34,6 +56,10 @@ async function createTag(tag: string) {
v-model:show="showCreateTagModal" v-model:show="showCreateTagModal"
:create-tag="createTag"></TagCreateModal> :create-tag="createTag"></TagCreateModal>
</MainContainer> </MainContainer>
<TagTable :create-tag="createTag"></TagTable> <TagTable
:create-tag="createTag"
:sort-column="tableState.sortColumn"
:sort-direction="tableState.sortDirection"
@sort="handleSort"></TagTable>
</AppLayout> </AppLayout>
</template> </template>

View File

@@ -33,7 +33,6 @@ const ClientResource = z
updated_at: z.string(), updated_at: z.string(),
}) })
.passthrough(); .passthrough();
const ClientCollection = z.array(ClientResource);
const ClientStoreRequest = z.object({ name: z.string().min(1).max(255) }).passthrough(); const ClientStoreRequest = z.object({ name: z.string().min(1).max(255) }).passthrough();
const ClientUpdateRequest = z const ClientUpdateRequest = z
.object({ name: z.string().min(1).max(255), is_archived: z.boolean().optional() }) .object({ name: z.string().min(1).max(255), is_archived: z.boolean().optional() })
@@ -598,7 +597,6 @@ const DetailedWithDataReportResource = z
const TagResource = z const TagResource = z
.object({ id: z.string(), name: z.string(), created_at: z.string(), updated_at: z.string() }) .object({ id: z.string(), name: z.string(), created_at: z.string(), updated_at: z.string() })
.passthrough(); .passthrough();
const TagCollection = z.array(TagResource);
const TagStoreRequest = z.object({ name: z.string().min(1).max(255) }).passthrough(); const TagStoreRequest = z.object({ name: z.string().min(1).max(255) }).passthrough();
const TagUpdateRequest = z.object({ name: z.string().min(1).max(255) }).passthrough(); const TagUpdateRequest = z.object({ name: z.string().min(1).max(255) }).passthrough();
const TaskResource = z const TaskResource = z
@@ -711,7 +709,6 @@ export const schemas = {
ApiTokenStoreRequest, ApiTokenStoreRequest,
ApiTokenWithAccessTokenResource, ApiTokenWithAccessTokenResource,
ClientResource, ClientResource,
ClientCollection,
ClientStoreRequest, ClientStoreRequest,
ClientUpdateRequest, ClientUpdateRequest,
ImportRequest, ImportRequest,
@@ -755,7 +752,6 @@ export const schemas = {
ReportUpdateRequest, ReportUpdateRequest,
DetailedWithDataReportResource, DetailedWithDataReportResource,
TagResource, TagResource,
TagCollection,
TagStoreRequest, TagStoreRequest,
TagUpdateRequest, TagUpdateRequest,
TaskResource, TaskResource,
@@ -1201,7 +1197,39 @@ const endpoints = makeApi([
schema: z.enum(['true', 'false', 'all']).optional(), schema: z.enum(['true', 'false', 'all']).optional(),
}, },
], ],
response: z.object({ data: ClientCollection }).passthrough(), response: z
.object({
data: z.array(ClientResource),
links: z
.object({
first: z.union([z.string(), z.null()]),
last: z.union([z.string(), z.null()]),
prev: z.union([z.string(), z.null()]),
next: z.union([z.string(), z.null()]),
})
.passthrough(),
meta: z
.object({
current_page: z.number().int(),
from: z.union([z.number(), z.null()]),
last_page: z.number().int(),
links: z.array(
z
.object({
url: z.union([z.string(), z.null()]),
label: z.string(),
active: z.boolean(),
})
.passthrough()
),
path: z.union([z.string(), z.null()]),
per_page: z.number().int(),
to: z.union([z.number(), z.null()]),
total: z.number().int(),
})
.passthrough(),
})
.passthrough(),
errors: [ errors: [
{ {
status: 401, status: 401,
@@ -1512,6 +1540,11 @@ const endpoints = makeApi([
type: 'Path', type: 'Path',
schema: z.string(), schema: z.string(),
}, },
{
name: 'page',
type: 'Query',
schema: z.number().int().gte(1).lte(2147483647).optional(),
},
], ],
response: z response: z
.object({ .object({
@@ -2137,6 +2170,11 @@ const endpoints = makeApi([
type: 'Path', type: 'Path',
schema: z.string(), schema: z.string(),
}, },
{
name: 'page',
type: 'Query',
schema: z.number().int().gte(1).lte(2147483647).optional(),
},
], ],
response: z response: z
.object({ .object({
@@ -2742,6 +2780,11 @@ const endpoints = makeApi([
type: 'Path', type: 'Path',
schema: z.string(), schema: z.string(),
}, },
{
name: 'page',
type: 'Query',
schema: z.number().int().gte(1).lte(2147483647).optional(),
},
], ],
response: z response: z
.object({ .object({
@@ -2792,6 +2835,13 @@ const endpoints = makeApi([
description: `Not found`, description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(), schema: z.object({ message: z.string() }).passthrough(),
}, },
{
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
], ],
}, },
{ {
@@ -2860,6 +2910,11 @@ const endpoints = makeApi([
type: 'Path', type: 'Path',
schema: z.string(), schema: z.string(),
}, },
{
name: 'page',
type: 'Query',
schema: z.number().int().gte(1).lte(2147483647).optional(),
},
], ],
response: z response: z
.object({ .object({
@@ -2910,6 +2965,13 @@ const endpoints = makeApi([
description: `Not found`, description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(), schema: z.object({ message: z.string() }).passthrough(),
}, },
{
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
], ],
}, },
{ {
@@ -3086,8 +3148,45 @@ const endpoints = makeApi([
type: 'Path', type: 'Path',
schema: z.string(), schema: z.string(),
}, },
{
name: 'page',
type: 'Query',
schema: z.number().int().gte(1).lte(2147483647).optional(),
},
], ],
response: z.object({ data: TagCollection }).passthrough(), response: z
.object({
data: z.array(TagResource),
links: z
.object({
first: z.union([z.string(), z.null()]),
last: z.union([z.string(), z.null()]),
prev: z.union([z.string(), z.null()]),
next: z.union([z.string(), z.null()]),
})
.passthrough(),
meta: z
.object({
current_page: z.number().int(),
from: z.union([z.number(), z.null()]),
last_page: z.number().int(),
links: z.array(
z
.object({
url: z.union([z.string(), z.null()]),
label: z.string(),
active: z.boolean(),
})
.passthrough()
),
path: z.union([z.string(), z.null()]),
per_page: z.number().int(),
to: z.union([z.number(), z.null()]),
total: z.number().int(),
})
.passthrough(),
})
.passthrough(),
errors: [ errors: [
{ {
status: 401, status: 401,
@@ -3104,6 +3203,13 @@ const endpoints = makeApi([
description: `Not found`, description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(), schema: z.object({ message: z.string() }).passthrough(),
}, },
{
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
], ],
}, },
{ {
@@ -3251,6 +3357,11 @@ const endpoints = makeApi([
type: 'Path', type: 'Path',
schema: z.string(), schema: z.string(),
}, },
{
name: 'page',
type: 'Query',
schema: z.number().int().gte(1).lte(2147483647).optional(),
},
{ {
name: 'project_id', name: 'project_id',
type: 'Query', type: 'Query',
@@ -4230,6 +4341,11 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
type: 'Query', type: 'Query',
schema: z.array(z.string().uuid()).min(1).optional(), schema: z.array(z.string().uuid()).min(1).optional(),
}, },
{
name: 'client_ids',
type: 'Query',
schema: z.array(z.string()).min(1).optional(),
},
{ {
name: 'project_ids', name: 'project_ids',
type: 'Query', type: 'Query',

View File

@@ -1,6 +1,6 @@
{ {
"name": "@solidtime/ui", "name": "@solidtime/ui",
"version": "0.0.15", "version": "0.0.16",
"description": "Package containing the solidtime ui components", "description": "Package containing the solidtime ui components",
"main": "./dist/solidtime-ui-lib.umd.cjs", "main": "./dist/solidtime-ui-lib.umd.cjs",
"module": "./dist/solidtime-ui-lib.js", "module": "./dist/solidtime-ui-lib.js",

View File

@@ -1,8 +0,0 @@
export default {
plugins: {
'postcss-import': {},
'tailwindcss/nesting': {},
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -1,12 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, inject, type ComputedRef } from 'vue';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery'; import type { Organization } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import DollarIcon from './DollarIcon.vue'; import DollarIcon from './DollarIcon.vue';
import EuroIcon from './EuroIcon.vue'; import EuroIcon from './EuroIcon.vue';
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!); const organization = inject<ComputedRef<Organization>>('organization');
const icon = computed(() => (organization.value?.currency === 'EUR' ? EuroIcon : DollarIcon)); const icon = computed(() => (organization?.value?.currency === 'EUR' ? EuroIcon : DollarIcon));
</script> </script>
<template> <template>

View File

@@ -91,6 +91,7 @@ const showCreateTagModal = ref(false);
<template> <template>
<TagCreateModal <TagCreateModal
v-if="showCreateTagModal"
v-model:show="showCreateTagModal" v-model:show="showCreateTagModal"
:create-tag="createAndAddTag"></TagCreateModal> :create-tag="createAndAddTag"></TagCreateModal>
<Dropdown <Dropdown

View File

@@ -228,6 +228,7 @@ async function handleDeleteTimeEntry() {
</div> </div>
<TimeEntryEditModal <TimeEntryEditModal
v-if="showEditModal"
v-model:show="showEditModal" v-model:show="showEditModal"
:time-entry="timeEntry" :time-entry="timeEntry"
:enable-estimated-time="enableEstimatedTime" :enable-estimated-time="enableEstimatedTime"

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ChevronRightIcon, ChevronDownIcon } from '@heroicons/vue/16/solid'; import { ChevronRightIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue'; import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { computed, nextTick, ref, watch, watchEffect } from 'vue'; import { computed, nextTick, ref, watch } from 'vue';
import ProjectDropdownItem from '@/packages/ui/src/Project/ProjectDropdownItem.vue'; import ProjectDropdownItem from '@/packages/ui/src/Project/ProjectDropdownItem.vue';
import type { import type {
CreateClientBody, CreateClientBody,
@@ -33,6 +33,7 @@ const searchValue = ref('');
watch(open, (isOpen) => { watch(open, (isOpen) => {
if (isOpen) { if (isOpen) {
updateFilteredResults();
nextTick(() => { nextTick(() => {
initializeHighlightedItem(); initializeHighlightedItem();
searchInput.value?.focus({ preventScroll: true }); searchInput.value?.focus({ preventScroll: true });
@@ -148,7 +149,7 @@ function addProjectToFilterObject(
} }
} }
watchEffect(() => { function updateFilteredResults() {
const tempFilteredClients: ClientsWithProjectsWithTasks = []; const tempFilteredClients: ClientsWithProjectsWithTasks = [];
if (searchValue.value.length === 0) { if (searchValue.value.length === 0) {
@@ -243,6 +244,13 @@ watchEffect(() => {
}); });
filteredResults.value = tempFilteredClients; filteredResults.value = tempFilteredClients;
}
// Recompute filtered results when search value changes while open
watch(searchValue, () => {
if (open.value) {
updateFilteredResults();
}
}); });
async function addClientIfNoneExists() { async function addClientIfNoneExists() {
@@ -648,6 +656,7 @@ const showCreateProject = ref(false);
</template> </template>
</Dropdown> </Dropdown>
<ProjectCreateModal <ProjectCreateModal
v-if="showCreateProject"
v-model:show="showCreateProject" v-model:show="showCreateProject"
:create-client :create-client
:enable-estimated-time="enableEstimatedTime" :enable-estimated-time="enableEstimatedTime"

View File

@@ -0,0 +1,22 @@
/**
* Fetches all pages from a paginated Laravel API endpoint.
* Uses `meta.last_page` to determine the total number of pages,
* so only a single request is made when all data fits on one page.
*/
export async function fetchAllPages<T>(
fetchPage: (page: number) => Promise<{
data: T[];
meta: { per_page: number; last_page: number };
}>
): Promise<T[]> {
const firstResponse = await fetchPage(1);
const allItems: T[] = [...firstResponse.data];
const { last_page } = firstResponse.meta;
for (let page = 2; page <= last_page; page++) {
const response = await fetchPage(page);
allItems.push(...response.data);
}
return allItems;
}

View File

@@ -8,6 +8,13 @@ import {
createCalendarQueryKey, createCalendarQueryKey,
fetchAllCalendarEntries, fetchAllCalendarEntries,
} from '@/utils/useTimeEntriesCalendarQuery'; } from '@/utils/useTimeEntriesCalendarQuery';
import { fetchAllProjects } from '@/utils/useProjectsQuery';
import { fetchAllTasks } from '@/utils/useTasksQuery';
import { fetchAllTags } from '@/utils/useTagsQuery';
import { fetchAllClients } from '@/utils/useClientsQuery';
import { fetchAllMembers } from '@/utils/useMembersQuery';
import { fetchAllReports } from '@/utils/useReportsQuery';
import { fetchAllProjectMembers } from '@/utils/useProjectMembersQuery';
/** /**
* Route patterns mapped to their prefetch functions. * Route patterns mapped to their prefetch functions.
@@ -151,13 +158,9 @@ function prefetchProjects(queryClient: QueryClient) {
if (!organizationId) return; if (!organizationId) return;
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ['projects'], queryKey: ['projects', organizationId],
queryFn: () => queryFn: async () => ({ data: await fetchAllProjects(organizationId) }),
api.getProjects({ staleTime: 30000,
params: { organization: organizationId },
queries: { archived: 'all' },
}),
staleTime: 30000, // Consider fresh for 30 seconds
}); });
} }
@@ -166,12 +169,8 @@ function prefetchTasks(queryClient: QueryClient) {
if (!organizationId) return; if (!organizationId) return;
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ['tasks'], queryKey: ['tasks', organizationId],
queryFn: () => queryFn: async () => ({ data: await fetchAllTasks(organizationId) }),
api.getTasks({
params: { organization: organizationId },
queries: { done: 'all' },
}),
staleTime: 30000, staleTime: 30000,
}); });
} }
@@ -181,11 +180,8 @@ function prefetchTags(queryClient: QueryClient) {
if (!organizationId) return; if (!organizationId) return;
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ['tags'], queryKey: ['tags', organizationId],
queryFn: () => queryFn: async () => ({ data: await fetchAllTags(organizationId) }),
api.getTags({
params: { organization: organizationId },
}),
staleTime: 30000, staleTime: 30000,
}); });
} }
@@ -195,12 +191,8 @@ function prefetchClients(queryClient: QueryClient) {
if (!organizationId || !canViewClients()) return; if (!organizationId || !canViewClients()) return;
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ['clients'], queryKey: ['clients', organizationId],
queryFn: () => queryFn: async () => ({ data: await fetchAllClients(organizationId) }),
api.getClients({
params: { organization: organizationId },
queries: { archived: 'all' },
}),
staleTime: 30000, staleTime: 30000,
}); });
} }
@@ -210,11 +202,8 @@ function prefetchMembers(queryClient: QueryClient) {
if (!organizationId || !canViewMembers()) return; if (!organizationId || !canViewMembers()) return;
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ['members'], queryKey: ['members', organizationId],
queryFn: () => queryFn: async () => ({ data: await fetchAllMembers(organizationId) }),
api.getMembers({
params: { organization: organizationId },
}),
staleTime: 30000, staleTime: 30000,
}); });
} }
@@ -224,11 +213,8 @@ function prefetchReports(queryClient: QueryClient) {
if (!organizationId) return; if (!organizationId) return;
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ['reports', 1], queryKey: ['reports', organizationId],
queryFn: () => queryFn: async () => ({ data: await fetchAllReports(organizationId) }),
api.getReports({
params: { organization: organizationId },
}),
staleTime: 30000, staleTime: 30000,
}); });
} }
@@ -276,10 +262,9 @@ function prefetchProjectMembers(queryClient: QueryClient, projectId: string) {
if (!organizationId || !canViewMembers()) return; if (!organizationId || !canViewMembers()) return;
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ['projectMembers', projectId], queryKey: ['projectMembers', organizationId, projectId],
queryFn: () => queryFn: async () => ({
api.getProjectMembers({ data: await fetchAllProjectMembers(organizationId, projectId),
params: { organization: organizationId, project: projectId },
}), }),
staleTime: 30000, staleTime: 30000,
}); });

View File

@@ -3,19 +3,27 @@ import { api } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
import type { Client } from '@/packages/api/src'; import type { Client } from '@/packages/api/src';
import { computed } from 'vue'; import { computed } from 'vue';
import { fetchAllPages } from '@/utils/fetchAllPages';
export async function fetchAllClients(organizationId: string): Promise<Client[]> {
return fetchAllPages((page) =>
api.getClients({
params: { organization: organizationId },
queries: { archived: 'all', page },
})
);
}
export function useClientsQuery() { export function useClientsQuery() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const query = useQuery({ const query = useQuery({
queryKey: ['clients'], queryKey: computed(() => ['clients', getCurrentOrganizationId()]),
queryFn: async () => { queryFn: async () => {
const organizationId = getCurrentOrganizationId(); const organizationId = getCurrentOrganizationId();
if (!organizationId) throw new Error('No organization'); if (!organizationId) throw new Error('No organization');
return api.getClients({ const data = await fetchAllClients(organizationId);
params: { organization: organizationId }, return { data };
queries: { archived: 'all' },
});
}, },
enabled: () => !!getCurrentOrganizationId(), enabled: () => !!getCurrentOrganizationId(),
staleTime: 1000 * 30, // 30 seconds staleTime: 1000 * 30, // 30 seconds

View File

@@ -1,31 +1,35 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { api } from '@/packages/api/src'; import { api } from '@/packages/api/src';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import type { import type { CreateInvitationBody, Invitation } from '@/packages/api/src';
InvitationsIndexResponse,
CreateInvitationBody,
Invitation,
} from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification'; import { useNotificationsStore } from '@/utils/notification';
import { fetchAllPages } from '@/utils/fetchAllPages';
export async function fetchAllInvitations(organizationId: string): Promise<Invitation[]> {
return fetchAllPages((page) =>
api.getInvitations({
params: { organization: organizationId },
queries: { page },
})
);
}
export const useInvitationsStore = defineStore('invitations', () => { export const useInvitationsStore = defineStore('invitations', () => {
const invitationsResponse = ref<InvitationsIndexResponse | null>(null); const invitationsData = ref<Invitation[]>([]);
const { handleApiRequestNotifications } = useNotificationsStore(); const { handleApiRequestNotifications } = useNotificationsStore();
async function fetchInvitations() { async function fetchInvitations() {
const organization = getCurrentOrganizationId(); const organization = getCurrentOrganizationId();
if (organization) { if (organization) {
invitationsResponse.value = await handleApiRequestNotifications( const data = await handleApiRequestNotifications(
() => () => fetchAllInvitations(organization),
api.getInvitations({
params: {
organization: organization,
},
}),
undefined, undefined,
'Failed to fetch invitations' 'Failed to fetch invitations'
); );
if (data) {
invitationsData.value = data;
}
} }
} }
@@ -47,7 +51,7 @@ export const useInvitationsStore = defineStore('invitations', () => {
} }
const invitations = computed<Invitation[]>(() => { const invitations = computed<Invitation[]>(() => {
return invitationsResponse.value?.data || []; return invitationsData.value;
}); });
return { invitations, fetchInvitations, createInvitation }; return { invitations, fetchInvitations, createInvitation };

View File

@@ -3,18 +3,27 @@ import { api } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
import type { Member } from '@/packages/api/src'; import type { Member } from '@/packages/api/src';
import { computed } from 'vue'; import { computed } from 'vue';
import { fetchAllPages } from '@/utils/fetchAllPages';
export async function fetchAllMembers(organizationId: string): Promise<Member[]> {
return fetchAllPages((page) =>
api.getMembers({
params: { organization: organizationId },
queries: { page },
})
);
}
export function useMembersQuery() { export function useMembersQuery() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const query = useQuery({ const query = useQuery({
queryKey: ['members'], queryKey: computed(() => ['members', getCurrentOrganizationId()]),
queryFn: async () => { queryFn: async () => {
const organizationId = getCurrentOrganizationId(); const organizationId = getCurrentOrganizationId();
if (!organizationId) throw new Error('No organization'); if (!organizationId) throw new Error('No organization');
return api.getMembers({ const data = await fetchAllMembers(organizationId);
params: { organization: organizationId }, return { data };
});
}, },
enabled: () => !!getCurrentOrganizationId(), enabled: () => !!getCurrentOrganizationId(),
staleTime: 1000 * 30, // 30 seconds staleTime: 1000 * 30, // 30 seconds

View File

@@ -12,6 +12,10 @@ import { getCurrentOrganizationId } from '@/utils/useUser';
import { api } from '@/packages/api/src'; import { api } from '@/packages/api/src';
export function switchOrganization(organizationId: string) { export function switchOrganization(organizationId: string) {
// Clear Inertia's prefetch cache to prevent stale pages from the old
// organization being served when navigating after the switch.
router.flushAll();
router.put( router.put(
route('current-team.update'), route('current-team.update'),
{ {

View File

@@ -26,7 +26,9 @@ export const useProjectMembersStore = defineStore('project-members', () => {
'Project member added successfully', 'Project member added successfully',
'Failed to add project member' 'Failed to add project member'
); );
queryClient.invalidateQueries({ queryKey: ['projectMembers', projectId] }); queryClient.invalidateQueries({
queryKey: ['projectMembers', organization, projectId],
});
} }
} }
@@ -49,7 +51,7 @@ export const useProjectMembersStore = defineStore('project-members', () => {
); );
if (response?.data?.project_id) { if (response?.data?.project_id) {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ['projectMembers', response.data.project_id], queryKey: ['projectMembers', organization, response.data.project_id],
}); });
} }
} }
@@ -69,7 +71,9 @@ export const useProjectMembersStore = defineStore('project-members', () => {
'Project member removed successfully', 'Project member removed successfully',
'Failed to remove project member' 'Failed to remove project member'
); );
queryClient.invalidateQueries({ queryKey: ['projectMembers', projectId] }); queryClient.invalidateQueries({
queryKey: ['projectMembers', organizationId, projectId],
});
} }
} }

View File

@@ -3,6 +3,19 @@ import { api } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
import type { ProjectMember } from '@/packages/api/src'; import type { ProjectMember } from '@/packages/api/src';
import { computed, type Ref } from 'vue'; import { computed, type Ref } from 'vue';
import { fetchAllPages } from '@/utils/fetchAllPages';
export async function fetchAllProjectMembers(
organizationId: string,
projectId: string
): Promise<ProjectMember[]> {
return fetchAllPages((page) =>
api.getProjectMembers({
params: { organization: organizationId, project: projectId },
queries: { page },
})
);
}
export function useProjectMembersQuery(projectId: Ref<string | null> | string) { export function useProjectMembersQuery(projectId: Ref<string | null> | string) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -12,14 +25,17 @@ export function useProjectMembersQuery(projectId: Ref<string | null> | string) {
}); });
const query = useQuery({ const query = useQuery({
queryKey: ['projectMembers', projectIdValue], queryKey: computed(() => [
'projectMembers',
getCurrentOrganizationId(),
projectIdValue.value,
]),
queryFn: async () => { queryFn: async () => {
const organizationId = getCurrentOrganizationId(); const organizationId = getCurrentOrganizationId();
const pid = projectIdValue.value; const pid = projectIdValue.value;
if (!organizationId || !pid) throw new Error('No organization or project'); if (!organizationId || !pid) throw new Error('No organization or project');
return api.getProjectMembers({ const data = await fetchAllProjectMembers(organizationId, pid);
params: { organization: organizationId, project: pid }, return { data };
});
}, },
enabled: () => !!getCurrentOrganizationId() && !!projectIdValue.value, enabled: () => !!getCurrentOrganizationId() && !!projectIdValue.value,
staleTime: 1000 * 30, // 30 seconds staleTime: 1000 * 30, // 30 seconds
@@ -28,7 +44,9 @@ export function useProjectMembersQuery(projectId: Ref<string | null> | string) {
const projectMembers = computed<ProjectMember[]>(() => query.data.value?.data ?? []); const projectMembers = computed<ProjectMember[]>(() => query.data.value?.data ?? []);
const invalidateProjectMembers = () => { const invalidateProjectMembers = () => {
queryClient.invalidateQueries({ queryKey: ['projectMembers', projectIdValue.value] }); queryClient.invalidateQueries({
queryKey: ['projectMembers', getCurrentOrganizationId(), projectIdValue.value],
});
}; };
return { return {

View File

@@ -3,19 +3,27 @@ import { api } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
import type { Project } from '@/packages/api/src'; import type { Project } from '@/packages/api/src';
import { computed } from 'vue'; import { computed } from 'vue';
import { fetchAllPages } from '@/utils/fetchAllPages';
export async function fetchAllProjects(organizationId: string): Promise<Project[]> {
return fetchAllPages((page) =>
api.getProjects({
params: { organization: organizationId },
queries: { archived: 'all', page },
})
);
}
export function useProjectsQuery() { export function useProjectsQuery() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const query = useQuery({ const query = useQuery({
queryKey: ['projects'], queryKey: computed(() => ['projects', getCurrentOrganizationId()]),
queryFn: async () => { queryFn: async () => {
const organizationId = getCurrentOrganizationId(); const organizationId = getCurrentOrganizationId();
if (!organizationId) throw new Error('No organization'); if (!organizationId) throw new Error('No organization');
return api.getProjects({ const data = await fetchAllProjects(organizationId);
params: { organization: organizationId }, return { data };
queries: { archived: 'all' },
});
}, },
enabled: () => !!getCurrentOrganizationId(), enabled: () => !!getCurrentOrganizationId(),
staleTime: 1000 * 30, // 30 seconds staleTime: 1000 * 30, // 30 seconds

View File

@@ -0,0 +1,12 @@
import { api } from '@/packages/api/src';
import type { Report } from '@/packages/api/src';
import { fetchAllPages } from '@/utils/fetchAllPages';
export async function fetchAllReports(organizationId: string): Promise<Report[]> {
return fetchAllPages((page) =>
api.getReports({
params: { organization: organizationId },
queries: { page },
})
);
}

View File

@@ -3,18 +3,27 @@ import { api } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
import type { Tag } from '@/packages/api/src'; import type { Tag } from '@/packages/api/src';
import { computed } from 'vue'; import { computed } from 'vue';
import { fetchAllPages } from '@/utils/fetchAllPages';
export async function fetchAllTags(organizationId: string): Promise<Tag[]> {
return fetchAllPages((page) =>
api.getTags({
params: { organization: organizationId },
queries: { page },
})
);
}
export function useTagsQuery() { export function useTagsQuery() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const query = useQuery({ const query = useQuery({
queryKey: ['tags'], queryKey: computed(() => ['tags', getCurrentOrganizationId()]),
queryFn: async () => { queryFn: async () => {
const organizationId = getCurrentOrganizationId(); const organizationId = getCurrentOrganizationId();
if (!organizationId) throw new Error('No organization'); if (!organizationId) throw new Error('No organization');
return api.getTags({ const data = await fetchAllTags(organizationId);
params: { organization: organizationId }, return { data };
});
}, },
enabled: () => !!getCurrentOrganizationId(), enabled: () => !!getCurrentOrganizationId(),
staleTime: 1000 * 30, // 30 seconds staleTime: 1000 * 30, // 30 seconds

View File

@@ -3,19 +3,27 @@ import { api } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
import type { Task } from '@/packages/api/src'; import type { Task } from '@/packages/api/src';
import { computed } from 'vue'; import { computed } from 'vue';
import { fetchAllPages } from '@/utils/fetchAllPages';
export async function fetchAllTasks(organizationId: string): Promise<Task[]> {
return fetchAllPages((page) =>
api.getTasks({
params: { organization: organizationId },
queries: { done: 'all', page },
})
);
}
export function useTasksQuery() { export function useTasksQuery() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const query = useQuery({ const query = useQuery({
queryKey: ['tasks'], queryKey: computed(() => ['tasks', getCurrentOrganizationId()]),
queryFn: async () => { queryFn: async () => {
const organizationId = getCurrentOrganizationId(); const organizationId = getCurrentOrganizationId();
if (!organizationId) throw new Error('No organization'); if (!organizationId) throw new Error('No organization');
return api.getTasks({ const data = await fetchAllTasks(organizationId);
params: { organization: organizationId }, return { data };
queries: { done: 'all' },
});
}, },
enabled: () => !!getCurrentOrganizationId(), enabled: () => !!getCurrentOrganizationId(),
staleTime: 1000 * 30, // 30 seconds staleTime: 1000 * 30, // 30 seconds

View File

@@ -7,7 +7,12 @@ export function useTimeEntriesReportQuery(
filterParams: Ref<Record<string, unknown>> | ComputedRef<Record<string, unknown>> filterParams: Ref<Record<string, unknown>> | ComputedRef<Record<string, unknown>>
) { ) {
return useQuery<TimeEntryResponse>({ return useQuery<TimeEntryResponse>({
queryKey: computed(() => ['timeEntries', 'detailed-report', unref(filterParams)]), queryKey: computed(() => [
'timeEntries',
'detailed-report',
getCurrentOrganizationId(),
unref(filterParams),
]),
enabled: computed(() => !!getCurrentOrganizationId()), enabled: computed(() => !!getCurrentOrganizationId()),
queryFn: () => queryFn: () =>
api.getTimeEntries({ api.getTimeEntries({

View File

@@ -40,6 +40,10 @@ Route::middleware([
return Inertia::render('Calendar'); return Inertia::render('Calendar');
})->name('calendar'); })->name('calendar');
Route::get('/timesheet', function () {
return Inertia::render('Timesheet');
})->name('timesheet');
Route::get('/reporting', function () { Route::get('/reporting', function () {
return Inertia::render('Reporting'); return Inertia::render('Reporting');
})->name('reporting'); })->name('reporting');

View File

@@ -46,6 +46,31 @@ class ApiTokenEndpointTest extends ApiEndpointTestAbstract
]); ]);
} }
public function test_index_endpoint_returns_api_tokens_ordered_by_created_at_descending(): void
{
// Arrange
$data = $this->createUserWithPermission([]);
$personalAccessClient = $this->createPersonalAccessClient();
$tokenOldest = Token::factory()->forUser($data->user)->forClient($personalAccessClient)->create([
'created_at' => now()->subDays(3),
]);
$tokenNewest = Token::factory()->forUser($data->user)->forClient($personalAccessClient)->create([
'created_at' => now()->subDay(),
]);
$tokenMiddle = Token::factory()->forUser($data->user)->forClient($personalAccessClient)->create([
'created_at' => now()->subDays(2),
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.api-tokens.index'));
// Assert
$this->assertResponseCode($response, 200);
$ids = collect($response->json('data'))->pluck('id')->values()->toArray();
$this->assertSame([$tokenNewest->id, $tokenMiddle->id, $tokenOldest->id], $ids);
}
public function test_store_endpoint_creates_new_api_token(): void public function test_store_endpoint_creates_new_api_token(): void
{ {
// Arrange // Arrange

View File

@@ -55,6 +55,32 @@ class InvitationEndpointTest extends ApiEndpointTestAbstract
]); ]);
} }
public function test_index_returns_invitations_ordered_by_created_at_descending(): void
{
// Arrange
$data = $this->createUserWithPermission([
'invitations:view',
]);
$invitationOldest = OrganizationInvitation::factory()->forOrganization($data->organization)->create([
'created_at' => now()->subDays(3),
]);
$invitationNewest = OrganizationInvitation::factory()->forOrganization($data->organization)->create([
'created_at' => now()->subDay(),
]);
$invitationMiddle = OrganizationInvitation::factory()->forOrganization($data->organization)->create([
'created_at' => now()->subDays(2),
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.invitations.index', $data->organization->getKey()));
// Assert
$response->assertStatus(200);
$ids = collect($response->json('data'))->pluck('id')->values()->toArray();
$this->assertSame([$invitationNewest->getKey(), $invitationMiddle->getKey(), $invitationOldest->getKey()], $ids);
}
public function test_store_fails_if_user_has_no_permission_to_create_invitations(): void public function test_store_fails_if_user_has_no_permission_to_create_invitations(): void
{ {
// Arrange // Arrange

View File

@@ -52,6 +52,38 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
$response->assertStatus(200); $response->assertStatus(200);
} }
public function test_index_returns_members_ordered_by_created_at_descending(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:view',
]);
$memberOldest = Member::factory()->forOrganization($data->organization)->create([
'created_at' => now()->subDays(3),
]);
$memberNewest = Member::factory()->forOrganization($data->organization)->create([
'created_at' => now()->subDay(),
]);
$memberMiddle = Member::factory()->forOrganization($data->organization)->create([
'created_at' => now()->subDays(2),
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.members.index', $data->organization->getKey()));
// Assert
$response->assertStatus(200);
$ids = collect($response->json('data'))->pluck('id')->values()->toArray();
// Verify that the three explicitly created members appear in newest-first order
$createdMemberIds = array_values(array_filter($ids, fn ($id) => in_array($id, [
$memberOldest->getKey(),
$memberNewest->getKey(),
$memberMiddle->getKey(),
], true)));
$this->assertSame([$memberNewest->getKey(), $memberMiddle->getKey(), $memberOldest->getKey()], $createdMemberIds);
}
public function test_update_member_fails_if_user_has_no_permission_to_update_members(): void public function test_update_member_fails_if_user_has_no_permission_to_update_members(): void
{ {
// Arrange // Arrange

View File

@@ -54,6 +54,33 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonCount(4, 'data'); $response->assertJsonCount(4, 'data');
} }
public function test_index_endpoint_returns_projects_ordered_by_created_at_descending(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:view',
'projects:view:all',
]);
$projectOldest = Project::factory()->forOrganization($data->organization)->create([
'created_at' => now()->subDays(3),
]);
$projectNewest = Project::factory()->forOrganization($data->organization)->create([
'created_at' => now()->subDay(),
]);
$projectMiddle = Project::factory()->forOrganization($data->organization)->create([
'created_at' => now()->subDays(2),
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.projects.index', [$data->organization->getKey()]));
// Assert
$response->assertStatus(200);
$ids = collect($response->json('data'))->pluck('id')->values()->toArray();
$this->assertSame([$projectNewest->getKey(), $projectMiddle->getKey(), $projectOldest->getKey()], $ids);
}
public function test_index_endpoint_without_filter_archived_returns_only_non_archived_projects(): void public function test_index_endpoint_without_filter_archived_returns_only_non_archived_projects(): void
{ {
// Arrange // Arrange
@@ -211,10 +238,10 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
->has('data') ->has('data')
->has('links') ->has('links')
->has('meta') ->has('meta')
->where('data.0.billable_rate', 112) ->where('data.0.billable_rate', 113)
->where('data.1.billable_rate', 112) ->where('data.1.billable_rate', 113)
->where('data.2.billable_rate', 113) ->where('data.2.billable_rate', 112)
->where('data.3.billable_rate', 113) ->where('data.3.billable_rate', 112)
); );
} }

View File

@@ -80,6 +80,36 @@ class ProjectMemberEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonCount(4, 'data'); $response->assertJsonCount(4, 'data');
} }
public function test_index_endpoint_returns_project_members_ordered_by_created_at_descending(): void
{
// Arrange
$data = $this->createUserWithPermission([
'project-members:view',
]);
$project = Project::factory()->forOrganization($data->organization)->create();
$pmOldest = ProjectMember::factory()->forProject($project)->create([
'created_at' => now()->subDays(3),
]);
$pmNewest = ProjectMember::factory()->forProject($project)->create([
'created_at' => now()->subDay(),
]);
$pmMiddle = ProjectMember::factory()->forProject($project)->create([
'created_at' => now()->subDays(2),
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.project-members.index', [
$data->organization->getKey(),
$project->getKey(),
]));
// Assert
$response->assertStatus(200);
$ids = collect($response->json('data'))->pluck('id')->values()->toArray();
$this->assertSame([$pmNewest->getKey(), $pmMiddle->getKey(), $pmOldest->getKey()], $ids);
}
public function test_store_endpoint_fails_if_user_has_no_permission_to_add_members_to_project(): void public function test_store_endpoint_fails_if_user_has_no_permission_to_add_members_to_project(): void
{ {
// Arrange // Arrange

View File

@@ -78,6 +78,33 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonCount(4, 'data'); $response->assertJsonCount(4, 'data');
} }
public function test_index_endpoint_returns_tasks_ordered_by_created_at_descending(): void
{
// Arrange
$data = $this->createUserWithPermission([
'tasks:view',
'tasks:view:all',
]);
$taskOldest = Task::factory()->forOrganization($data->organization)->create([
'created_at' => now()->subDays(3),
]);
$taskNewest = Task::factory()->forOrganization($data->organization)->create([
'created_at' => now()->subDay(),
]);
$taskMiddle = Task::factory()->forOrganization($data->organization)->create([
'created_at' => now()->subDays(2),
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.tasks.index', [$data->organization->getKey(), 'done' => 'all']));
// Assert
$response->assertStatus(200);
$ids = collect($response->json('data'))->pluck('id')->values()->toArray();
$this->assertSame([$taskNewest->getKey(), $taskMiddle->getKey(), $taskOldest->getKey()], $ids);
}
public function test_index_endpoint_without_filter_done_returns_list_of_all_tasks_of_organization(): void public function test_index_endpoint_without_filter_done_returns_list_of_all_tasks_of_organization(): void
{ {
// Arrange // Arrange