Compare commits

..

41 Commits

Author SHA1 Message Date
Gregor Vostrak
3c9159f2d4 Conditionally show cost column in report tables; Task/Project Modal
Field cleanup; improve estimated time UX
2026-02-11 17:11:05 +01:00
Gregor Vostrak
abfa7cea0d improve format settings e2e test consistency; improve euro icon sizing
consistency
2026-02-10 17:51:37 +01:00
Gregor Vostrak
bfc33b48c1 make sure that 404 current time entry requests do not override local
state while preparing new time entry
2026-02-10 17:21:18 +01:00
Gregor Vostrak
dc98151d42 responsive time entry modal fixes 2026-02-10 17:18:49 +01:00
Gregor Vostrak
e038870bc4 fix reporting tab selectors in e2e test 2026-02-10 15:20:59 +01:00
Gregor Vostrak
6f8f46f375 use frankenphp in the playwright tests CI to handle parallel requests
better
2026-02-10 14:57:31 +01:00
Gregor Vostrak
6c319fafbc add e2e tests for employee restrictions 2026-02-10 14:41:04 +01:00
Gregor Vostrak
d06b0633d3 add sharding for e2e tests in CI 2026-02-10 13:21:05 +01:00
Gregor Vostrak
2c4af95ee3 Add Tag Edit Modal and UI 2026-02-10 13:19:30 +01:00
Gregor Vostrak
215957104f Add Euro Symbol as Billable Icon when EUR is the organization currency.
fixes #423
2026-02-10 12:47:27 +01:00
Gregor Vostrak
fd012e7c69 Add Field component system and migrate UI 2026-02-10 12:22:53 +01:00
Gregor Vostrak
1ecb332458 Expand e2e test coverage migrate to API-based data setup 2026-02-10 11:45:08 +01:00
Gregor Vostrak
bbe05ca0d8 improve time estimate input, responsive time entry create modal fixes,
fixes #460, #800
2026-02-06 14:35:52 +01:00
Gregor Vostrak
d2644112c5 Allow updating public_until on already-public reports 2026-02-05 15:48:06 +01:00
Gregor Vostrak
66681066bc migrate datepickers to shadcn, Fixes #877, #807 2026-02-05 15:02:01 +01:00
Gregor Vostrak
f82f5e780c fix desync of checkboxes on the reporting detailed page, fixes #892 2026-02-04 17:22:08 +01:00
Gregor Vostrak
22f3af2b79 Make sure that time entry billable status updates when project changes,
fixes #981
2026-02-04 17:07:46 +01:00
Gregor Vostrak
7d068fecae fix admin panel time entry save and update, fixes #997 2026-02-04 14:32:56 +01:00
Gregor Vostrak
9be97a8f84 Improve Time page responsiveness and compact tags, fixes #896 2026-02-03 19:21:57 +01:00
Gregor Vostrak
03e0377101 fix responsive issues in timetracker recently tracked entries dropdown 2026-02-03 14:30:56 +01:00
Gregor Vostrak
a58becc268 Add calendar query prefetch 2026-02-03 14:12:36 +01:00
Gregor Vostrak
09c3205680 Allow NONE filter value to shared reports and add shared-report tests 2026-02-02 20:42:07 +01:00
Gregor Vostrak
18989a9a8e Add Mailpit SMTP and refine Playwright tests 2026-02-02 16:06:56 +01:00
Gregor Vostrak
98634f4a19 fix Y-Label ui regression from echarts update 2026-02-02 14:55:09 +01:00
Gregor Vostrak
1597b5490a Enable npm workspaces and update dependencies 2026-02-02 03:20:45 +01:00
Gregor Vostrak
72662727c5 Add client_ids filter to time entry export 2026-02-02 01:57:40 +01:00
Gregor Vostrak
0d3978a55d Add reporting e2e helpers and detailed tests 2026-02-02 01:31:35 +01:00
Gregor Vostrak
fe8c7e9a7d Update openapi api client spec 2026-02-02 01:28:42 +01:00
Gregor Vostrak
66dfc511a9 add no project, no task, no client, no task, no tag support to the API 2026-02-02 01:16:28 +01:00
Gregor Vostrak
8524e01033 refactor: extract ReportingFilterBar and migrate reporting to TanStack Query 2026-02-02 01:03:00 +01:00
Gregor Vostrak
bca1e8b3b5 migrate select/multiselect components to Radix Vue primitives 2026-02-02 00:56:06 +01:00
Gregor Vostrak
44bcce97cf fix styling inconsistencies 2026-01-27 20:49:30 +01:00
Gregor Vostrak
99400ca655 fix: display custom billable rate correctly on project detail page 2026-01-27 20:11:35 +01:00
Gregor Vostrak
672c243c91 add command palette 2026-01-27 18:29:40 +01:00
Gregor Vostrak
3fb75ec3d5 add outline and secondary variants to TimeTrackerStartStop button to reduce visual complexity 2026-01-15 19:17:55 +01:00
Gregor Vostrak
79999fde28 remove redundant projects pinia store after tanstack query migration 2026-01-14 19:41:07 +01:00
Gregor Vostrak
b2a04c8de5 load time entries above pagination limit for calendar, fixes #995 2026-01-14 19:22:17 +01:00
Gregor Vostrak
900ee29a6f fix e2e project filtering in reporting e2e test 2026-01-14 18:58:28 +01:00
Gregor Vostrak
ebbc4e6837 use tanstack query in ProjectMultiselectDropdown, ClientTableRow and ProjectDropdown; fix e2e 2026-01-14 18:21:29 +01:00
Gregor Vostrak
6f68bbbd48 refactor timeentries queries and mutations, improve activitygraph, add dashboard reporting table 2026-01-14 17:01:45 +01:00
Gregor Vostrak
47c2d8e6de upgrade inertia v2; add prefetching; migrate queries to tanstack query
vue
2026-01-09 03:32:42 +01:00
110 changed files with 598 additions and 3694 deletions

View File

@@ -9,8 +9,8 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8] shardIndex: [1, 2, 3, 4]
shardTotal: [8] shardTotal: [4]
services: services:
mailpit: mailpit:

View File

@@ -35,7 +35,6 @@ 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,7 +41,6 @@ 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,7 +60,6 @@ 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,9 +60,7 @@ class ProjectController extends Controller
$projectsQuery->whereNull('archived_at'); $projectsQuery->whereNull('archived_at');
} }
$projects = $projectsQuery $projects = $projectsQuery->paginate(config('app.pagination_per_page_default'));
->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,7 +6,6 @@ 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;
@@ -42,13 +41,12 @@ class ProjectMemberController extends Controller
* *
* @operationId getProjectMembers * @operationId getProjectMembers
*/ */
public function index(Organization $organization, Project $project, ProjectMemberIndexRequest $request): ProjectMemberCollection public function index(Organization $organization, Project $project): 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,7 +5,6 @@ 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;
@@ -41,7 +40,7 @@ class ReportController extends Controller
* *
* @operationId getReports * @operationId getReports
*/ */
public function index(Organization $organization, ReportIndexRequest $request): ReportCollection public function index(Organization $organization): ReportCollection
{ {
$this->checkPermission($organization, 'reports:view'); $this->checkPermission($organization, 'reports:view');

View File

@@ -5,7 +5,6 @@ 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;
@@ -35,7 +34,7 @@ class TagController extends Controller
* *
* @throws AuthorizationException * @throws AuthorizationException
*/ */
public function index(Organization $organization, TagIndexRequest $request): TagCollection public function index(Organization $organization): TagCollection
{ {
$this->checkPermission($organization, 'tags:view'); $this->checkPermission($organization, 'tags:view');

View File

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

View File

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

View File

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

View File

@@ -1,27 +0,0 @@
<?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

@@ -1,27 +0,0 @@
<?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

@@ -1,27 +0,0 @@
<?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,11 +26,6 @@ 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,10 +4,9 @@ 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 implements PaginatedResourceCollection class ClientCollection extends ResourceCollection
{ {
/** /**
* The resource that this resource collects. * The resource that this resource collects.

View File

@@ -4,10 +4,9 @@ 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 implements PaginatedResourceCollection class TagCollection extends ResourceCollection
{ {
/** /**
* The resource that this resource collects. * The resource that this resource collects.

View File

@@ -6,7 +6,6 @@ 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;
@@ -59,7 +58,7 @@ class OrganizationPolicy
return true; return true;
} }
return app(PermissionStore::class)->userHas($organization, $user, 'organizations:update'); return $user->ownsTeam($organization);
} }
/** /**

View File

@@ -196,7 +196,6 @@ class MemberService
$placeholderUser = $user->replicate(); $placeholderUser = $user->replicate();
$placeholderUser->is_placeholder = true; $placeholderUser->is_placeholder = true;
$placeholderUser->current_team_id = $member->organization_id;
$placeholderUser->save(); $placeholderUser->save();
$member->user()->associate($placeholderUser); $member->user()->associate($placeholderUser);

View File

@@ -31,17 +31,12 @@ class TimeEntryService
throw new LogicException('Rounding minutes must be greater than 0'); throw new LogicException('Rounding minutes must be greater than 0');
} }
$end = 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')'; $end = 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
$start = $this->getStartSelectRawForRounding($roundingType, $roundingMinutes);
if ($roundingType === TimeEntryRoundingType::Down) { if ($roundingType === TimeEntryRoundingType::Down) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$start.')'; return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
} elseif ($roundingType === TimeEntryRoundingType::Up) { } elseif ($roundingType === TimeEntryRoundingType::Up) {
// If end is already on a boundary, keep it; otherwise round up to next boundary return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
return 'CASE WHEN '.$end.' = date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$start.') '.
'THEN '.$end.' '.
'ELSE date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$start.') '.
'END';
} elseif ($roundingType === TimeEntryRoundingType::Nearest) { } elseif ($roundingType === TimeEntryRoundingType::Nearest) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$start.')'; return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
} }
} }
} }

View File

@@ -1,172 +0,0 @@
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
import { expect } from '@playwright/test';
import type { Page } from '@playwright/test';
async function goToCalendar(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/calendar');
await expect(page.locator('.fc')).toBeVisible();
}
async function openSettingsPopover(page: Page) {
await page.getByRole('button', { name: 'Calendar settings' }).click();
await expect(page.getByText('Calendar Settings')).toBeVisible();
}
async function clearCalendarSettings(page: Page) {
await page.evaluate(() => localStorage.removeItem('solidtime:calendar-settings'));
}
test.describe('Calendar Settings', () => {
test.beforeEach(async ({ page }) => {
await clearCalendarSettings(page);
});
test('settings popover shows all fields with correct defaults', async ({ page }) => {
await goToCalendar(page);
await openSettingsPopover(page);
await expect(page.getByLabel('Snap Interval')).toContainText('15 min');
await expect(page.getByLabel('Start Time')).toContainText('12:00 AM');
await expect(page.getByLabel('End Time')).toContainText('12:00 AM (next)');
await expect(page.getByLabel('Grid Scale')).toContainText('15 min');
});
test('snap interval can be changed and persists across reload', async ({ page }) => {
await goToCalendar(page);
await openSettingsPopover(page);
// Change snap interval to 30 min
await page.getByLabel('Snap Interval').click();
await page.getByRole('option', { name: '30 min' }).click();
await page.locator('.fc-toolbar-title').click();
// Verify localStorage was updated
const stored = await page.evaluate(() =>
JSON.parse(localStorage.getItem('solidtime:calendar-settings') || '{}')
);
expect(stored.snapMinutes).toBe(30);
// Reload and verify persistence
await page.reload();
await expect(page.locator('.fc')).toBeVisible();
await openSettingsPopover(page);
await expect(page.getByLabel('Snap Interval')).toContainText('30 min');
});
test('start time change is applied to calendar and rejects values >= end time', async ({
page,
}) => {
await goToCalendar(page);
// Verify 7 AM slot exists with default start (00:00)
await expect(page.locator('.fc-timegrid-slot[data-time="07:00:00"]')).not.toHaveCount(0);
await openSettingsPopover(page);
// Set end time to 6 PM first
await page.getByLabel('End Time').click();
await page.getByRole('option', { name: '6:00 PM' }).click();
// Change start time to 8 AM (valid)
await page.getByLabel('Start Time').click();
await page.getByRole('option', { name: '8:00 AM' }).click();
await page.locator('.fc-toolbar-title').click();
// Calendar should no longer show hours before 8 AM
await expect(page.locator('.fc-timegrid-slot[data-time="07:00:00"]')).toHaveCount(0);
await expect(page.locator('.fc-timegrid-slot[data-time="08:00:00"]')).not.toHaveCount(0);
// Try to set start time to 6 PM (invalid: equals end time)
await openSettingsPopover(page);
await page.getByLabel('Start Time').click();
await page.getByRole('option', { name: '6:00 PM' }).click();
// Should be rejected — start time stays at 8 AM
await expect(page.getByLabel('Start Time')).toContainText('8:00 AM');
});
test('end time change is applied to calendar and rejects values <= start time', async ({
page,
}) => {
await goToCalendar(page);
// Verify 19:00 slot exists with default end (24:00)
await expect(page.locator('.fc-timegrid-slot[data-time="19:00:00"]')).not.toHaveCount(0);
await openSettingsPopover(page);
// Set start time to 8 AM first
await page.getByLabel('Start Time').click();
await page.getByRole('option', { name: '8:00 AM' }).click();
// Change end time to 6 PM (valid)
await page.getByLabel('End Time').click();
await page.getByRole('option', { name: '6:00 PM' }).click();
await page.locator('.fc-toolbar-title').click();
// Calendar should no longer show hours at or after 6 PM
await expect(page.locator('.fc-timegrid-slot[data-time="18:00:00"]')).toHaveCount(0);
await expect(page.locator('.fc-timegrid-slot[data-time="17:00:00"]')).not.toHaveCount(0);
// Try to set end time to 8 AM (invalid: equals start time)
await openSettingsPopover(page);
await page.getByLabel('End Time').click();
await page.getByRole('option', { name: '8:00 AM' }).click();
// Should be rejected — end time stays at 6 PM
await expect(page.getByLabel('End Time')).toContainText('6:00 PM');
});
test('grid scale affects number of calendar slots', async ({ page }) => {
await goToCalendar(page);
// Count slots with default 15-min scale
const defaultSlotCount = await page.locator('.fc-timegrid-slot').count();
// Change to 30 min scale (should halve the slots)
await openSettingsPopover(page);
await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '30 min' }).click();
await page.locator('.fc-toolbar-title').click();
const largerSlotCount = await page.locator('.fc-timegrid-slot').count();
expect(largerSlotCount).toBeLessThan(defaultSlotCount);
// Change to 5 min scale (should have many more slots)
await openSettingsPopover(page);
await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '5 min', exact: true }).click();
await page.locator('.fc-toolbar-title').click();
const smallerSlotCount = await page.locator('.fc-timegrid-slot').count();
expect(smallerSlotCount).toBeGreaterThan(defaultSlotCount);
});
test('all settings persist across navigation', async ({ page }) => {
await goToCalendar(page);
await openSettingsPopover(page);
// Change every setting
await page.getByLabel('Snap Interval').click();
await page.getByRole('option', { name: '5 min', exact: true }).click();
await page.getByLabel('Start Time').click();
await page.getByRole('option', { name: '6:00 AM' }).click();
await page.getByLabel('End Time').click();
await page.getByRole('option', { name: '10:00 PM' }).click();
await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '30 min' }).click();
await page.locator('.fc-toolbar-title').click();
// Navigate away and back
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await goToCalendar(page);
// Verify all settings persisted
await openSettingsPopover(page);
await expect(page.getByLabel('Snap Interval')).toContainText('5 min');
await expect(page.getByLabel('Start Time')).toContainText('6:00 AM');
await expect(page.getByLabel('End Time')).toContainText('10:00 PM');
await expect(page.getByLabel('Grid Scale')).toContainText('30 min');
});
});

View File

@@ -8,7 +8,6 @@ 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');
@@ -132,92 +131,6 @@ 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

@@ -31,21 +31,15 @@ async function selectCommand(page: Page, name: string) {
} }
async function assertTimerIsRunning(page: Page) { async function assertTimerIsRunning(page: Page) {
await expect(page.locator(TIMER_BUTTON_SELECTOR).and(page.locator(':visible'))).toHaveClass( await expect(page.locator(TIMER_BUTTON_SELECTOR)).toHaveClass(/bg-red-400\/80/, {
/bg-red-400\/80/, timeout: 10000,
{ });
timeout: 10000,
}
);
} }
async function assertTimerIsStopped(page: Page) { async function assertTimerIsStopped(page: Page) {
await expect(page.locator(TIMER_BUTTON_SELECTOR).and(page.locator(':visible'))).toHaveClass( await expect(page.locator(TIMER_BUTTON_SELECTOR)).toHaveClass(/bg-accent-300\/70/, {
/bg-accent-300\/70/, timeout: 10000,
{ });
timeout: 10000,
}
);
} }
test.describe('Command Palette', () => { test.describe('Command Palette', () => {

View File

@@ -25,12 +25,7 @@ test('test that dashboard loads with all expected sections', async ({ page }) =>
// Timer section (scoped to dashboard_timer to avoid matching sidebar timer) // Timer section (scoped to dashboard_timer to avoid matching sidebar timer)
await expect(page.getByTestId('time_entry_description')).toBeVisible(); await expect(page.getByTestId('time_entry_description')).toBeVisible();
await expect( await expect(page.getByTestId('dashboard_timer').getByTestId('timer_button')).toBeVisible();
page
.getByTestId('dashboard_timer')
.getByTestId('timer_button')
.and(page.locator(':visible'))
).toBeVisible();
// Dashboard cards // Dashboard cards
await expect(page.getByText('Recent Time Entries', { exact: true })).toBeVisible(); await expect(page.getByText('Recent Time Entries', { exact: true })).toBeVisible();
@@ -109,10 +104,7 @@ test.describe('Employee Dashboard Restrictions', () => {
// Timer should be available // Timer should be available
await expect( await expect(
employee.page employee.page.getByTestId('dashboard_timer').getByTestId('timer_button')
.getByTestId('dashboard_timer')
.getByTestId('timer_button')
.and(employee.page.locator(':visible'))
).toBeVisible(); ).toBeVisible();
await expect(employee.page.getByTestId('time_entry_description')).toBeEditable(); await expect(employee.page.getByTestId('time_entry_description')).toBeEditable();
}); });

View File

@@ -5,13 +5,7 @@ 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 { import { createPlaceholderMemberViaImportApi } from './utils/api';
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 });
@@ -85,8 +79,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.getByRole('combobox').last().click(); await page.getByText('Organization Default Rate').click();
await page.getByRole('option', { name: 'Custom Rate' }).click(); await page.getByText('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();
@@ -108,136 +102,6 @@ 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);
@@ -623,125 +487,6 @@ 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
// ============================================= // =============================================
@@ -777,7 +522,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('member_table').locator('[role="row"]')).toHaveCount( await expect(employee.page.getByTestId('client_table').locator('[role="row"]')).toHaveCount(
0 0
); );

View File

@@ -369,40 +369,6 @@ 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,13 +3,11 @@ 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';
@@ -337,179 +335,61 @@ test('test that editing an existing billable project with default rate loads cor
}); });
// Sorting tests // Sorting tests
test('test that sorting projects by all columns works', async ({ page, ctx }) => { test('test that sorting projects by name works', async ({ page }) => {
// 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();
// Helper to get the visual order of our seeded projects by reading // Get initial project names
// all row text in a single evaluate call (avoids locator timing issues) const getProjectNames = async () => {
const seededNames = ['AAA Project', 'BBB Project', 'CCC Project']; const rows = page
const getOrder = async (): Promise<string[]> => { .getByTestId('project_table')
const allRowTexts = await page.evaluate(() => { .locator('[data-testid="project_table"] > div')
const table = document.querySelector('[data-testid="project_table"]'); .filter({ hasNot: page.locator('.border-t') });
if (!table) return []; const names: string[] = [];
const rows = table.querySelectorAll('[role="row"]'); const count = await page.getByTestId('project_table').getByRole('row').count();
return Array.from(rows).map((row) => row.textContent ?? ''); for (let i = 0; i < count; i++) {
}); const row = page.getByTestId('project_table').getByRole('row').nth(i);
const order: string[] = []; const nameCell = row.locator('div').first();
for (const text of allRowTexts) { const text = await nameCell.textContent();
const match = seededNames.find((name) => text.includes(name)); if (text) {
if (match) order.push(match); names.push(text.trim());
}
} }
return order; return names;
}; };
// Helper: click a column header and wait for sort to apply. // Click on Name header to sort ascending (default should already be ascending)
// expectedFirstAmongSeeded = which of our 3 seeded projects should appear first const nameHeader = page.getByText('Name').first();
const clickSortHeader = async (headerText: string, expectedFirstAmongSeeded: string) => { await nameHeader.click();
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 }
);
};
// --- Sort by Name --- // Wait for sort indicator to appear
// Default is name asc (A-Z) await expect(nameHeader.locator('svg')).toBeVisible();
let order = await getOrder();
expect(order).toEqual(['AAA Project', 'BBB Project', 'CCC Project']);
// Click to toggle to Z-A // Click again to sort descending
await clickSortHeader('Name', 'CCC Project'); await nameHeader.click();
order = await getOrder();
expect(order).toEqual(['CCC Project', 'BBB Project', 'AAA Project']);
// --- Sort by Client (text: first click = A-Z, no-client last) --- // Verify the sort indicator is still visible (showing descending)
await clickSortHeader('Client', 'AAA Project'); await expect(nameHeader.locator('svg')).toBeVisible();
order = await getOrder(); });
expect(order).toEqual(['AAA Project', 'BBB Project', 'CCC Project']); // Alpha, Beta, No client
// Reverse: Z-A, no-client still last test('test that sorting projects by status works', async ({ page }) => {
await clickSortHeader('Client', 'BBB Project'); await goToProjectsOverview(page);
order = await getOrder(); await clearProjectTableState(page);
expect(order).toEqual(['BBB Project', 'AAA Project', 'CCC Project']); // Beta, Alpha, No client await page.reload();
// --- Sort by Total Time (numeric: first click = highest first) --- // Default is "all" so no filter needed - Wait for the table to load
await clickSortHeader('Total Time', 'CCC Project'); await expect(page.getByTestId('project_table')).toBeVisible();
order = await getOrder();
expect(order[0]).toBe('CCC Project'); // C=3h first, A and B tied at 1h
// Reverse: lowest first // Click on Status header to sort
await clickSortHeader('Total Time', 'AAA Project'); const statusHeader = page.getByText('Status').first();
order = await getOrder(); await statusHeader.click();
expect(order[2]).toBe('CCC Project'); // C=3h last
// --- Sort by Billable Rate (numeric: first click = highest first) --- // Sort indicator should be visible
await clickSortHeader('Billable Rate', 'BBB Project'); await expect(statusHeader.locator('svg')).toBeVisible();
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
@@ -762,6 +642,22 @@ 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,9 +8,6 @@ import {
createTimeEntryViaApi, createTimeEntryViaApi,
createTimeEntryWithTagViaApi, createTimeEntryWithTagViaApi,
createBareTimeEntryViaApi, createBareTimeEntryViaApi,
createBillableProjectViaApi,
createTimeEntryWithBillableStatusViaApi,
createTagViaApi,
} from './utils/api'; } from './utils/api';
import { import {
goToReporting, goToReporting,
@@ -249,191 +246,6 @@ 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
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────
@@ -765,58 +577,3 @@ 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,7 +3,6 @@ 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');
@@ -90,57 +89,6 @@ 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,8 +1159,6 @@ 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');
@@ -1452,10 +1450,7 @@ test.describe('Employee Time Entry Isolation', () => {
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/time'); await employee.page.goto(PLAYWRIGHT_BASE_URL + '/time');
await expect( await expect(
employee.page employee.page.getByTestId('dashboard_timer').getByTestId('timer_button')
.getByTestId('dashboard_timer')
.getByTestId('timer_button')
.and(employee.page.locator(':visible'))
).toBeVisible({ timeout: 10000 }); ).toBeVisible({ timeout: 10000 });
// Employee's time entry IS visible // Employee's time entry IS visible

View File

@@ -354,10 +354,7 @@ test('test that timer started on dashboard is visible on time page', async ({ pa
// Timer should still be running (the timer button should be red/active) // Timer should still be running (the timer button should be red/active)
await expect( await expect(
page page.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]')
.getByTestId('dashboard_timer')
.getByTestId('timer_button')
.and(page.locator(':visible'))
).toHaveClass(/bg-red-400\/80/); ).toHaveClass(/bg-red-400\/80/);
// Stop the timer // Stop the timer

View File

@@ -201,37 +201,6 @@ 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 }
@@ -345,36 +314,6 @@ 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

@@ -2,19 +2,12 @@ import { expect } from '@playwright/test';
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
export async function startOrStopTimerWithButton(page: Page) { export async function startOrStopTimerWithButton(page: Page) {
await page await page.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]').click();
.getByTestId('dashboard_timer')
.getByTestId('timer_button')
.and(page.locator(':visible'))
.click();
} }
export async function assertThatTimerHasStarted(page: Page) { export async function assertThatTimerHasStarted(page: Page) {
await expect( await expect(
page page.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]')
.getByTestId('dashboard_timer')
.getByTestId('timer_button')
.and(page.locator(':visible'))
).toHaveClass(/bg-red-400\/80/); ).toHaveClass(/bg-red-400\/80/);
} }
@@ -41,10 +34,7 @@ export function newTimeEntryResponse(
export async function assertThatTimerIsStopped(page: Page) { export async function assertThatTimerIsStopped(page: Page) {
await expect( await expect(
page page.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]')
.getByTestId('dashboard_timer')
.getByTestId('timer_button')
.and(page.locator(':visible'))
).toHaveClass(/bg-accent-300\/70/); ).toHaveClass(/bg-accent-300\/70/);
} }

View File

@@ -68,85 +68,6 @@ 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.

View File

@@ -1,16 +0,0 @@
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

@@ -18,7 +18,7 @@ export default defineConfig({
/* Retry on CI only */ /* Retry on CI only */
retries: process.env.CI ? 1 : 0, retries: process.env.CI ? 1 : 0,
/* Run tests in parallel */ /* Run tests in parallel */
workers: process.env.CI ? 2 : 4, workers: 4,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: process.env.CI ? 'blob' : 'html', reporter: process.env.CI ? 'blob' : 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */

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 { setupAdminUser, setupEmployeeUser } from '../e2e/utils/members'; import { setupEmployeeUser } from '../e2e/utils/members';
export * from '@playwright/test'; export * from '@playwright/test';
export type { TestContext }; export type { TestContext };
@@ -12,11 +12,6 @@ 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).
@@ -24,7 +19,7 @@ export interface AdminFixture {
* 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; admin: AdminFixture }, { ctx: TestContext; employee: EmployeeFixture },
{ workerStorageState: string } { workerStorageState: string }
>({ >({
page: async ({ page }, use) => { page: async ({ page }, use) => {
@@ -105,10 +100,4 @@ 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,104 +2,17 @@
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, computed, ref } from 'vue'; import { type Component, 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';
export type SortColumn = 'name' | 'projects_count' | 'status'; defineProps<{
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>
@@ -110,12 +23,8 @@ const sortedClients = computed(() => {
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>
:sort-column="props.sortColumn" <div v-if="clients.length === 0" class="col-span-3 py-24 text-center">
: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>
@@ -126,7 +35,7 @@ const sortedClients = computed(() => {
>Create your First Client >Create your First Client
</SecondaryButton> </SecondaryButton>
</div> </div>
<template v-for="client in sortedClients" :key="client.id"> <template v-for="client in clients" :key="client.id">
<ClientTableRow :client="client"></ClientTableRow> <ClientTableRow :client="client"></ClientTableRow>
</template> </template>
</div> </div>

View File

@@ -1,67 +1,18 @@
<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 <div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
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

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Client } from '@/packages/api/src'; import type { Client } from '@/packages/api/src';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { CheckCircleIcon, ArchiveBoxIcon } from '@heroicons/vue/24/outline'; import { CheckCircleIcon } from '@heroicons/vue/20/solid';
import { useClientsStore } from '@/utils/useClients'; import { useClientsStore } from '@/utils/useClients';
import ClientMoreOptionsDropdown from '@/Components/Common/Client/ClientMoreOptionsDropdown.vue'; import ClientMoreOptionsDropdown from '@/Components/Common/Client/ClientMoreOptionsDropdown.vue';
import { useProjectsQuery } from '@/utils/useProjectsQuery'; import { useProjectsQuery } from '@/utils/useProjectsQuery';
@@ -36,25 +36,19 @@ 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 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12"> class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<span> <span>
{{ client.name }} {{ client.name }}
</span> </span>
</div> </div>
<div <div
class="whitespace-nowrap flex items-center px-3 py-4 text-sm font-medium text-text-primary"> class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<span class="text-text-secondary"> {{ projectCount }} Projects </span> <span class="text-text-secondary"> {{ projectCount }} Projects </span>
</div> </div>
<div <div
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium"> class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1 items-center font-medium">
<template v-if="client.is_archived"> <CheckCircleIcon class="w-5"></CheckCircleIcon>
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon> <span>Active</span>
<span>Archived</span>
</template>
<template v-else>
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
<span>Active</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

@@ -1,35 +1,20 @@
<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, onMounted, ref, watch } from 'vue'; import { computed, ref } 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, FieldDescription } from '@/packages/ui/src/field'; import { Field, FieldLabel } 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);
@@ -90,26 +75,10 @@ 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') {
if (!memberBody.value.billable_rate) { memberBody.value.billable_rate = props.member.billable_rate ?? 0;
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 = {
'owner': 'owner':
'The owner has full access of the organization. The owner is the only role that can: delete the organization, transfer the ownership to another user and access to the billing settings', 'The owner has full access of the organization. The owner is the only role that can: delete the organization, transfer the ownership to another user and access to the billing settings',
@@ -151,55 +120,34 @@ 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"> <div class="pb-5 flex space-x-6">
<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">
<Field> <div class="col-span-6 sm:col-span-4 flex-1 flex space-x-5">
<FieldLabel :icon="BillableIcon" for="billableRateType" <Field>
>Billable Rate</FieldLabel <FieldLabel for="billableType">Billable</FieldLabel>
> <MemberBillableSelect
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2"> v-model="billableRateSelect"
<Select v-model="billableRateSelect"> name="billableType"></MemberBillableSelect>
<SelectTrigger id="billableRateType"> </Field>
<SelectValue /> <Field v-if="billableRateSelect === 'custom-rate'" class="flex-1">
</SelectTrigger> <FieldLabel for="memberBillableRate">Billable Rate</FieldLabel>
<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
v-model="displayedRate"
:currency="getOrganizationCurrencyString()"
disabled
name="memberBillableRate" />
</div>
</TooltipTrigger>
<TooltipContent
>Uses the default rate of the organization</TooltipContent
>
</Tooltip>
</TooltipProvider>
<BillableRateInput <BillableRateInput
v-else v-model="memberBody.billable_rate"
v-model="displayedRate"
focus focus
class="w-full"
:currency="getOrganizationCurrencyString()" :currency="getOrganizationCurrencyString()"
name="memberBillableRate" name="memberBillableRate"
@keydown.enter="saveWithChecks()" /> @keydown.enter="saveWithChecks()"></BillableRateInput>
</div> </Field>
</Field> </div>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -2,117 +2,19 @@
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="member_table" data-testid="client_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>
:sort-column="props.sortColumn" <template v-for="member in members" :key="member.id">
: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,83 +1,20 @@
<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 <div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
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/24/outline'; import { CheckCircleIcon, UserCircleIcon } from '@heroicons/vue/20/solid';
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,15 +82,11 @@ const userHasValidMailAddress = computed(() => {
}} }}
</div> </div>
<div <div
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium"> class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1 items-center font-medium">
<template v-if="member.is_placeholder === false"> <CheckCircleIcon v-if="member.is_placeholder === false" class="w-5"></CheckCircleIcon>
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon> <span v-if="member.is_placeholder === false">Active</span>
<span>Active</span> <UserCircleIcon v-if="member.is_placeholder === true" class="w-5"></UserCircleIcon>
</template> <span v-if="member.is_placeholder === true">Inactive</span>
<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,17 +4,11 @@ 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 from '@/Components/Common/Project/ProjectTableHeading.vue'; import ProjectTableHeading, {
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';
@@ -37,7 +31,7 @@ const props = defineProps<{
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
sort: [column: SortColumn, direction: SortDirection]; sort: [column: SortColumn];
}>(); }>();
const { clients } = useClientsQuery(); const { clients } = useClientsQuery();
@@ -51,7 +45,7 @@ const clientNameMap = computed(() => {
return map; return map;
}); });
// Convert sort props to TanStack Table format // Convert our sort state to TanStack Table format
const sorting = computed<SortingState>(() => [ const sorting = computed<SortingState>(() => [
{ {
id: props.sortColumn, id: props.sortColumn,
@@ -59,67 +53,38 @@ const sorting = computed<SortingState>(() => [
}, },
]); ]);
// Define column accessors for sorting. // Define column accessors for sorting
// Numeric columns use sortDescFirst so that the first click (chevron down) sorts highest-first, const columns = [
// 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 undefined; if (!row.client_id) return '';
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;
}, },
get columns() { columns,
return columns.value;
},
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
state: { state: {
@@ -134,6 +99,10 @@ 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> {
@@ -164,7 +133,6 @@ 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,13 +1,14 @@
<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<{
@@ -21,18 +22,6 @@ 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>
@@ -41,49 +30,58 @@ function isChevronUp(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="isChevronDown('name')" class="w-4 h-4" /> <ChevronDownIcon v-if="isSorted('name') && sortDirection === 'asc'" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('name')" class="w-4 h-4" /> <ChevronUpIcon
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 v-if="isChevronDown('client_name')" class="w-4 h-4" /> <ChevronDownIcon
<ChevronUpIcon v-else-if="isChevronUp('client_name')" class="w-4 h-4" /> v-if="isSorted('client_name') && sortDirection === 'asc'"
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 v-if="isChevronDown('spent_time')" class="w-4 h-4" /> <ChevronDownIcon
<ChevronUpIcon v-else-if="isChevronUp('spent_time')" class="w-4 h-4" /> v-if="isSorted('spent_time') && sortDirection === 'asc'"
<span v-else class="w-4 h-4"></span> class="w-4 h-4" />
</div> <ChevronUpIcon
<div v-else-if="isSorted('spent_time') && sortDirection === 'desc'"
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="w-4 h-4" />
@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 v-if="isChevronDown('billable_rate')" class="w-4 h-4" /> <ChevronDownIcon
<ChevronUpIcon v-else-if="isChevronUp('billable_rate')" class="w-4 h-4" /> v-if="isSorted('billable_rate') && sortDirection === 'asc'"
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="isChevronDown('status')" class="w-4 h-4" /> <ChevronDownIcon v-if="isSorted('status') && sortDirection === 'asc'" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('status')" class="w-4 h-4" /> <ChevronUpIcon
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, useQueryClient } from '@tanstack/vue-query'; import { useMutation } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
import { api } from '@/packages/api/src'; import { api } from '@/packages/api/src';
import { Checkbox } from '@/packages/ui/src'; import { Checkbox } from '@/packages/ui/src';
@@ -17,7 +17,6 @@ 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) => {
@@ -31,11 +30,6 @@ const createReportMutation = useMutation({
}, },
}); });
}, },
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['reports'],
});
},
}); });
const props = defineProps<{ const props = defineProps<{

View File

@@ -1,11 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { import { ChartBarIcon } from '@heroicons/vue/20/solid';
ChartBarIcon,
ArrowDownTrayIcon,
EllipsisVerticalIcon,
LockClosedIcon,
} from '@heroicons/vue/20/solid';
import { SaveIcon } from 'lucide-vue-next';
import { getOrganizationCurrencyString } from '@/utils/money'; import { getOrganizationCurrencyString } from '@/utils/money';
import { import {
formatHumanReadableDuration, formatHumanReadableDuration,
@@ -14,25 +8,17 @@ import {
} from '@/packages/ui/src/utils/time'; } from '@/packages/ui/src/utils/time';
import { formatCents } from '@/packages/ui/src/utils/money'; import { formatCents } from '@/packages/ui/src/utils/money';
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue'; import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
import ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.vue';
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue'; import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
import PageTitle from '@/Components/Common/PageTitle.vue'; import PageTitle from '@/Components/Common/PageTitle.vue';
import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue'; import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue';
import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue'; import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';
import MainContainer from '@/packages/ui/src/MainContainer.vue'; import MainContainer from '@/packages/ui/src/MainContainer.vue';
import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue'; import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue';
import ReportSaveButton from '@/Components/Common/Report/ReportSaveButton.vue';
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue'; import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
import ReportingFilterBar from '@/Components/Common/Reporting/ReportingFilterBar.vue'; import ReportingFilterBar from '@/Components/Common/Reporting/ReportingFilterBar.vue';
import { SecondaryButton } from '@/packages/ui/src';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
import ReportCreateModal from '@/Components/Common/Report/ReportCreateModal.vue';
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
import { canCreateReports } from '@/utils/permissions';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { computed, type ComputedRef, inject, ref, watch } from 'vue'; import { computed, type ComputedRef, inject, ref, watch } from 'vue';
import { type GroupingOption, useReportingStore } from '@/utils/useReporting'; import { type GroupingOption, useReportingStore } from '@/utils/useReporting';
import { import {
@@ -157,18 +143,8 @@ 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 {
...rest, ...filterParams.value,
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),
@@ -206,28 +182,6 @@ async function downloadExport(format: ExportFormat) {
const { projects } = useProjectsQuery(); const { projects } = useProjectsQuery();
const showExportModal = ref(false); const showExportModal = ref(false);
const exportUrl = ref<string | null>(null); const exportUrl = ref<string | null>(null);
const showCreateReportModal = ref(false);
const showPremiumModal = ref(false);
const exportLoading = ref(false);
function triggerExport(format: ExportFormat) {
if (format === 'pdf' && !isAllowedToPerformPremiumAction()) {
showPremiumModal.value = true;
return;
}
exportLoading.value = true;
downloadExport(format).finally(() => {
exportLoading.value = false;
});
}
function onSaveReportClick() {
if (isAllowedToPerformPremiumAction()) {
showCreateReportModal.value = true;
} else {
showPremiumModal.value = true;
}
}
const groupedPieChartData = computed(() => { const groupedPieChartData = computed(() => {
return ( return (
@@ -286,79 +240,16 @@ const tableData = computed(() => {
<ReportingExportModal <ReportingExportModal
v-model:show="showExportModal" v-model:show="showExportModal"
:export-url="exportUrl"></ReportingExportModal> :export-url="exportUrl"></ReportingExportModal>
<ReportCreateModal
v-model:show="showCreateReportModal"
:properties="reportProperties"></ReportCreateModal>
<UpgradeModal v-model:show="showPremiumModal">
This feature is only available in solidtime Professional.
</UpgradeModal>
<MainContainer <MainContainer
class="h-14 sm:h-16 border-b border-default-background-separator flex flex-wrap gap-y-3 justify-between items-center"> class="py-3 sm:py-5 border-b border-default-background-separator flex justify-between items-center">
<div class="flex items-center space-x-3 sm:space-x-6"> <div class="flex items-center space-x-3 sm:space-x-6">
<PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle> <PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle>
<ReportingTabNavbar active="reporting" class="hidden sm:flex"></ReportingTabNavbar> <ReportingTabNavbar active="reporting"></ReportingTabNavbar>
</div> </div>
<div class="hidden sm:flex space-x-2"> <div class="flex space-x-2">
<DropdownMenu> <ReportingExportButton :download="downloadExport"></ReportingExportButton>
<DropdownMenuTrigger as-child> <ReportSaveButton :report-properties="reportProperties"></ReportSaveButton>
<SecondaryButton :icon="ArrowDownTrayIcon" :loading="exportLoading">
Export
</SecondaryButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="triggerExport('pdf')">
<div class="flex items-center space-x-2">
<span>Export as PDF</span>
<LockClosedIcon
v-if="!isAllowedToPerformPremiumAction()"
class="w-3.5 text-text-tertiary" />
</div>
</DropdownMenuItem>
<DropdownMenuItem @click="triggerExport('xlsx')">
Export as Excel
</DropdownMenuItem>
<DropdownMenuItem @click="triggerExport('csv')">
Export as CSV
</DropdownMenuItem>
<DropdownMenuItem @click="triggerExport('ods')">
Export as ODS
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<SecondaryButton v-if="canCreateReports()" :icon="SaveIcon" @click="onSaveReportClick">
Save Report
</SecondaryButton>
</div> </div>
<DropdownMenu>
<DropdownMenuTrigger as-child class="sm:hidden">
<button
class="p-1.5 rounded-lg border border-border-tertiary text-text-secondary hover:text-text-primary hover:bg-secondary transition"
aria-label="More options">
<EllipsisVerticalIcon class="w-5 h-5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="triggerExport('pdf')">
<div class="flex items-center space-x-2">
<span>Export as PDF</span>
<LockClosedIcon
v-if="!isAllowedToPerformPremiumAction()"
class="w-3.5 text-text-tertiary" />
</div>
</DropdownMenuItem>
<DropdownMenuItem @click="triggerExport('xlsx')">
Export as Excel
</DropdownMenuItem>
<DropdownMenuItem @click="triggerExport('csv')"> Export as CSV </DropdownMenuItem>
<DropdownMenuItem @click="triggerExport('ods')"> Export as ODS </DropdownMenuItem>
<DropdownMenuItem v-if="canCreateReports()" @click="onSaveReportClick">
Save Report
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</MainContainer>
<MainContainer class="sm:hidden py-2 border-b border-default-background-separator">
<ReportingTabNavbar active="reporting"></ReportingTabNavbar>
</MainContainer> </MainContainer>
<ReportingFilterBar <ReportingFilterBar
v-model:selected-members="selectedMembers" v-model:selected-members="selectedMembers"
@@ -437,7 +328,8 @@ 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 aggregatedTableTimeEntries.cost !== null &&
aggregatedTableTimeEntries.cost !== undefined
? formatCents( ? formatCents(
aggregatedTableTimeEntries.cost, aggregatedTableTimeEntries.cost,
getOrganizationCurrencyString(), getOrganizationCurrencyString(),

View File

@@ -16,7 +16,7 @@ const props = defineProps<{
:icon="icon" :icon="icon"
:class=" :class="
twMerge( twMerge(
'rounded-md px-2 sm:px-3 border py-1.5 text-xs sm:text-sm font-medium text-text-tertiary hover:text-text-primary focus-visible:outline-none data-[state=active]:bg-tab-background data-[state=active]:border-input-border data-[state=active]:text-text-primary border-tab-border', 'rounded-md px-2 sm:px-3 py-1 border sm:py-1.5 text-xs sm:text-sm font-medium text-text-tertiary hover:text-text-primary focus-visible:outline-none data-[state=active]:bg-tab-background data-[state=active]:border-input-border data-[state=active]:text-text-primary border-tab-border',
props.class props.class
) )
"> ">

View File

@@ -2,80 +2,18 @@
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 { computed, ref } from 'vue'; import { 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';
import { defineProps<{
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>
@@ -86,12 +24,8 @@ const sortedTags = computed(() => {
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>
:sort-column="props.sortColumn" <div v-if="tags.length === 0" class="col-span-5 py-24 text-center">
: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>
@@ -102,7 +36,7 @@ const sortedTags = computed(() => {
>Create your First Tag</SecondaryButton >Create your First Tag</SecondaryButton
> >
</div> </div>
<template v-for="tag in sortedTags" :key="tag.id"> <template v-for="tag in tags" :key="tag.id">
<TagTableRow :tag="tag"></TagTableRow> <TagTableRow :tag="tag"></TagTableRow>
</template> </template>
</div> </div>

View File

@@ -1,51 +1,16 @@
<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 <div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
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

@@ -106,29 +106,15 @@ const option = computed(() => {
return { return {
tooltip: {}, tooltip: {},
visualMap: { visualMap: {
min: 0,
max: max.value,
type: 'piecewise', type: 'piecewise',
orient: 'horizontal', orient: 'horizontal',
left: 'center', left: 'center',
top: 'center', top: 'center',
pieces: [ inRange: {
{ value: 0, color: chartEmptyColor.value }, color: [chartEmptyColor.value, chartColor.value],
{ },
gt: 0,
lte: max.value * 0.25,
color: chroma.mix(chartEmptyColor.value, chartColor.value, 0.3).hex(),
},
{
gt: max.value * 0.25,
lte: max.value * 0.5,
color: chroma.mix(chartEmptyColor.value, chartColor.value, 0.6).hex(),
},
{
gt: max.value * 0.5,
lte: max.value * 0.75,
color: chroma.mix(chartEmptyColor.value, chartColor.value, 0.8).hex(),
},
{ gt: max.value * 0.75, lte: max.value, color: chartColor.value },
],
show: false, show: false,
}, },
calendar: { calendar: {

View File

@@ -85,11 +85,11 @@ window.addEventListener('dashboard:refresh', () => {
filteredLatestTasks.length === 4 ? 'last:border-0' : '' filteredLatestTasks.length === 4 ? 'last:border-0' : ''
"></RecentlyTrackedTasksCardEntry> "></RecentlyTrackedTasksCardEntry>
</div> </div>
<div v-else class="text-center flex flex-1 justify-center items-center py-5"> <div v-else class="text-center flex flex-1 justify-center items-center">
<div> <div>
<PlusCircleIcon class="w-8 text-icon-default inline pb-2"></PlusCircleIcon> <PlusCircleIcon class="w-8 text-icon-default inline pb-2"></PlusCircleIcon>
<h3 class="text-text-primary font-semibold text-sm">No recent time entries</h3> <h3 class="text-text-primary font-semibold text-sm">No recent time entries</h3>
<p class="text-sm">Start tracking your time!</p> <p class="pb-5 text-sm">Start tracking your time!</p>
</div> </div>
</div> </div>
</DashboardCard> </DashboardCard>

View File

@@ -44,7 +44,6 @@ const { data: latestTeamActivity, isLoading } = useQuery({
<div v-else class="text-center text-gray-500 py-8">No team activity found</div> <div v-else class="text-center text-gray-500 py-8">No team activity found</div>
<div <div
v-if="latestTeamActivity && latestTeamActivity.length <= 1" v-if="latestTeamActivity && latestTeamActivity.length <= 1"
:class="latestTeamActivity?.length === 1 ? 'pb-5' : 'py-5'"
class="text-center flex flex-1 justify-center items-center"> class="text-center flex flex-1 justify-center items-center">
<div> <div>
<UserGroupIcon class="w-8 text-icon-default inline pb-2"></UserGroupIcon> <UserGroupIcon class="w-8 text-icon-default inline pb-2"></UserGroupIcon>

View File

@@ -31,14 +31,14 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
<div <div
:class=" :class="
cn( cn(
'fixed top-0 left-0 z-50 pointer-events-none w-screen h-screen flex items-start px-2 pt-3 md:pt-20 xl:pt-32 justify-center overflow-auto' 'fixed top-0 left-0 z-50 pointer-events-none w-screen h-screen flex items-start pt-6 md:pt-20 xl:pt-32 justify-center overflow-auto'
) )
"> ">
<DialogContent <DialogContent
v-bind="forwarded" v-bind="forwarded"
:class=" :class="
cn( cn(
'pointer-events-auto bg-default-background grid w-full max-w-lg border border-border-tertiary shadow-lg duration-200 rounded-lg outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95', 'pointer-events-auto bg-default-background grid w-full max-w-lg border border-border-tertiary shadow-lg duration-200 sm:rounded-lg outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
props.class props.class
) )
"> ">

View File

@@ -13,7 +13,7 @@ const props = defineProps<{
data-slot="input" data-slot="input"
:class=" :class="
cn( cn(
'flex h-9 w-full rounded-md border border-input-border bg-input-background px-3 py-1 text-base sm:text-sm text-center shadow-sm transition-colors placeholder:text-muted-foreground focus:border-input-border focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50', 'flex h-9 w-full rounded-md border border-input-border bg-input-background px-3 py-1 text-sm text-center shadow-sm transition-colors placeholder:text-muted-foreground focus:border-input-border focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
props.class props.class
) )
" /> " />

View File

@@ -4,6 +4,7 @@ import Banner from '@/Components/Banner.vue';
import OrganizationSwitcher from '@/Components/OrganizationSwitcher.vue'; import OrganizationSwitcher from '@/Components/OrganizationSwitcher.vue';
import CurrentSidebarTimer from '@/Components/CurrentSidebarTimer.vue'; import CurrentSidebarTimer from '@/Components/CurrentSidebarTimer.vue';
import { import {
Bars3Icon,
CalendarIcon, CalendarIcon,
ChartBarIcon, ChartBarIcon,
ClockIcon, ClockIcon,
@@ -18,11 +19,10 @@ import {
XMarkIcon, XMarkIcon,
DocumentTextIcon, DocumentTextIcon,
} from '@heroicons/vue/20/solid'; } from '@heroicons/vue/20/solid';
import { PanelLeft } from 'lucide-vue-next';
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue'; import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
import UserSettingsIcon from '@/Components/UserSettingsIcon.vue'; import UserSettingsIcon from '@/Components/UserSettingsIcon.vue';
import MainContainer from '@/packages/ui/src/MainContainer.vue'; import MainContainer from '@/packages/ui/src/MainContainer.vue';
import { nextTick, onMounted, provide, ref } from 'vue'; import { onMounted, provide, ref } from 'vue';
import NotificationContainer from '@/Components/NotificationContainer.vue'; import NotificationContainer from '@/Components/NotificationContainer.vue';
import { initializeStores } from '@/utils/init'; import { initializeStores } from '@/utils/init';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry'; import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
@@ -61,24 +61,6 @@ defineProps({
}); });
const showSidebarMenu = ref(false); const showSidebarMenu = ref(false);
const sidebarVisible = ref(false);
function openSidebar() {
showSidebarMenu.value = true;
nextTick(() => {
requestAnimationFrame(() => {
sidebarVisible.value = true;
});
});
}
function closeSidebar() {
sidebarVisible.value = false;
setTimeout(() => {
showSidebarMenu.value = false;
}, 200);
}
const isUnloading = ref(false); const isUnloading = ref(false);
const { organization, isLoading: isOrganizationLoading } = useOrganizationQuery( const { organization, isLoading: isOrganizationLoading } = useOrganizationQuery(
@@ -120,23 +102,11 @@ const page = usePage<{
<template> <template>
<div v-bind="$attrs" class="flex flex-wrap bg-background text-text-secondary"> <div v-bind="$attrs" class="flex flex-wrap bg-background text-text-secondary">
<!-- Mobile sidebar overlay -->
<Teleport to="body">
<div v-if="showSidebarMenu" class="fixed inset-0 z-40 lg:hidden" @click="closeSidebar">
<div
class="absolute inset-0 bg-default-background transition-opacity duration-200"
:class="sidebarVisible ? 'opacity-50' : 'opacity-0'" />
</div>
</Teleport>
<div <div
:class="[ :class="{
sidebarVisible '!flex bg-default-background w-full z-30': showSidebarMenu,
? 'max-lg:translate-x-0 max-lg:shadow-xl' }"
: 'max-lg:-translate-x-full', class="flex-shrink-0 h-screen hidden fixed w-[230px] 2xl:w-[250px] px-2.5 2xl:px-3 py-4 lg:flex flex-col justify-between">
]"
class="flex-shrink-0 h-screen fixed w-[280px] px-2.5 py-4 hidden lg:flex flex-col justify-between bg-background border-r border-default-background-separator max-lg:z-50 max-lg:transition-transform max-lg:duration-200 max-lg:ease-in-out lg:w-[230px] 2xl:w-[250px] 2xl:px-3 lg:border-r-0"
:style="showSidebarMenu ? { display: 'flex' } : undefined">
<div class="flex flex-col h-full"> <div class="flex flex-col h-full">
<div <div
class="border-b border-default-background-separator pb-2 flex items-center gap-1"> class="border-b border-default-background-separator pb-2 flex items-center gap-1">
@@ -151,13 +121,9 @@ const page = usePage<{
@click="openPalette"> @click="openPalette">
<MagnifyingGlassIcon class="h-4 w-4 text-icon-default" /> <MagnifyingGlassIcon class="h-4 w-4 text-icon-default" />
</Button> </Button>
<Button <XMarkIcon
variant="ghost" class="w-8 lg:hidden flex-shrink-0"
size="icon" @click="showSidebarMenu = false"></XMarkIcon>
class="h-7 w-7 flex-shrink-0 lg:hidden"
@click="closeSidebar">
<XMarkIcon class="h-4 w-4 text-icon-default" />
</Button>
</div> </div>
<div class="border-b border-default-background-separator"> <div class="border-b border-default-background-separator">
<CurrentSidebarTimer></CurrentSidebarTimer> <CurrentSidebarTimer></CurrentSidebarTimer>
@@ -313,13 +279,9 @@ const page = usePage<{
class="h-screen overflow-y-auto flex flex-col bg-default-background border-l border-default-background-separator"> class="h-screen overflow-y-auto flex flex-col bg-default-background border-l border-default-background-separator">
<div <div
class="lg:hidden w-full px-3 py-1 border-b border-b-default-background-separator text-text-secondary flex justify-between items-center"> class="lg:hidden w-full px-3 py-1 border-b border-b-default-background-separator text-text-secondary flex justify-between items-center">
<Button <Bars3Icon
variant="ghost" class="w-7 text-text-secondary"
size="icon" @click="showSidebarMenu = !showSidebarMenu"></Bars3Icon>
class="h-7 w-7 shrink-0"
@click="openSidebar">
<PanelLeft class="h-4 w-4 text-icon-default" />
</Button>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<OrganizationSwitcher></OrganizationSwitcher> <OrganizationSwitcher></OrganizationSwitcher>
<Button <Button

View File

@@ -12,8 +12,6 @@ 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();
@@ -21,26 +19,6 @@ 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') {
@@ -67,10 +45,6 @@ const shownClients = computed(() => {
> >
<ClientCreateModal v-model:show="createClient"></ClientCreateModal> <ClientCreateModal v-model:show="createClient"></ClientCreateModal>
</MainContainer> </MainContainer>
<ClientTable <ClientTable :clients="shownClients"></ClientTable>
:clients="shownClients"
:sort-column="tableState.sortColumn"
:sort-direction="tableState.sortDirection"
@sort="handleSort"></ClientTable>
</AppLayout> </AppLayout>
</template> </template>

View File

@@ -13,8 +13,6 @@ 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);
@@ -23,26 +21,6 @@ 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>
@@ -67,11 +45,7 @@ function handleSort(column: SortColumn, direction: SortDirection) {
:available-roles="availableRoles" :available-roles="availableRoles"
@close="activeTab = 'invitations'"></MemberInviteModal> @close="activeTab = 'invitations'"></MemberInviteModal>
</MainContainer> </MainContainer>
<MemberTable <MemberTable v-if="activeTab === 'all'"></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

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import MainContainer from '@/packages/ui/src/MainContainer.vue'; import MainContainer from '@/packages/ui/src/MainContainer.vue';
import AppLayout from '@/Layouts/AppLayout.vue'; import AppLayout from '@/Layouts/AppLayout.vue';
import { FolderIcon, PlusIcon } from '@heroicons/vue/20/solid'; import { FolderIcon, PlusIcon } from '@heroicons/vue/16/solid';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue'; import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useProjectsQuery } from '@/utils/useProjectsQuery'; import { useProjectsQuery } from '@/utils/useProjectsQuery';
@@ -84,11 +84,9 @@ const shownTasks = computed(() => {
<ol role="list" class="flex items-center space-x-2"> <ol role="list" class="flex items-center space-x-2">
<li> <li>
<div class="flex items-center space-x-6"> <div class="flex items-center space-x-6">
<Link <Link :href="route('projects')" class="flex items-center space-x-2.5">
:href="route('projects')" <FolderIcon class="w-6 text-icon-default"></FolderIcon>
class="flex items-center space-x-2 sm:space-x-2.5"> <span class="font-medium">Projects</span>
<FolderIcon class="w-5 text-icon-default"></FolderIcon>
<span class="text-sm sm:text-base font-medium">Projects</span>
</Link> </Link>
</div> </div>
</li> </li>

View File

@@ -1,9 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import MainContainer from '@/packages/ui/src/MainContainer.vue'; import MainContainer from '@/packages/ui/src/MainContainer.vue';
import AppLayout from '@/Layouts/AppLayout.vue'; import AppLayout from '@/Layouts/AppLayout.vue';
import { FolderIcon, PlusIcon } from '@heroicons/vue/20/solid'; import { FolderIcon, PlusIcon } from '@heroicons/vue/24/outline';
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';
@@ -22,7 +26,6 @@ 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();
@@ -53,9 +56,14 @@ const tableState = useStorage<ProjectTableState>(
{ mergeDefaults: true } { mergeDefaults: true }
); );
function handleSort(column: SortColumn, direction: SortDirection) { // Handle sorting - toggle direction if same column, otherwise set new column with asc
tableState.value.sortColumn = column; function handleSort(column: SortColumn) {
tableState.value.sortDirection = direction; if (tableState.value.sortColumn === column) {
tableState.value.sortDirection = tableState.value.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
tableState.value.sortColumn = column;
tableState.value.sortDirection = 'asc';
}
} }
// Filter projects based on current filters // Filter projects based on current filters

View File

@@ -9,17 +9,7 @@ import {
ChevronRightIcon, ChevronRightIcon,
ChevronDoubleRightIcon, ChevronDoubleRightIcon,
ClockIcon, ClockIcon,
EllipsisVerticalIcon,
ArrowDownTrayIcon,
LockClosedIcon,
} from '@heroicons/vue/20/solid'; } from '@heroicons/vue/20/solid';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
import { SecondaryButton } from '@/packages/ui/src';
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time'; import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
@@ -56,7 +46,7 @@ import {
import { useQueryClient } from '@tanstack/vue-query'; import { useQueryClient } from '@tanstack/vue-query';
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser'; import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue'; import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
import UpgradeModal from '@/Components/Common/UpgradeModal.vue'; import ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.vue';
import type { ExportFormat } from '@/types/reporting'; import type { ExportFormat } from '@/types/reporting';
import { useNotificationsStore } from '@/utils/notification'; import { useNotificationsStore } from '@/utils/notification';
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue'; import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
@@ -170,19 +160,6 @@ const selectedTimeEntries = ref<TimeEntry[]>([]);
const showExportModal = ref(false); const showExportModal = ref(false);
const exportUrl = ref<string | null>(null); const exportUrl = ref<string | null>(null);
const showPremiumModal = ref(false);
const exportLoading = ref(false);
function triggerExport(format: ExportFormat) {
if (format === 'pdf' && !isAllowedToPerformPremiumAction()) {
showPremiumModal.value = true;
return;
}
exportLoading.value = true;
downloadExport(format).finally(() => {
exportLoading.value = false;
});
}
async function createTag(name: string) { async function createTag(name: string) {
return await useTagsStore().createTag(name); return await useTagsStore().createTag(name);
@@ -259,74 +236,13 @@ async function downloadExport(format: ExportFormat) {
<ReportingExportModal <ReportingExportModal
v-model:show="showExportModal" v-model:show="showExportModal"
:export-url="exportUrl"></ReportingExportModal> :export-url="exportUrl"></ReportingExportModal>
<UpgradeModal v-model:show="showPremiumModal">
<strong>PDF Reports</strong> are only available in solidtime Professional.
</UpgradeModal>
<MainContainer <MainContainer
class="h-14 sm:h-16 border-b border-default-background-separator flex flex-wrap gap-y-3 justify-between items-center"> class="py-3 sm:py-5 border-b border-default-background-separator flex justify-between items-center">
<div class="flex items-center space-x-3 sm:space-x-6"> <div class="flex items-center space-x-3 sm:space-x-6">
<PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle> <PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle>
<ReportingTabNavbar active="detailed" class="hidden sm:flex"></ReportingTabNavbar> <ReportingTabNavbar active="detailed"></ReportingTabNavbar>
</div> </div>
<div class="hidden sm:block"> <ReportingExportButton :download="downloadExport"></ReportingExportButton>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SecondaryButton :icon="ArrowDownTrayIcon" :loading="exportLoading">
Export
</SecondaryButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="triggerExport('pdf')">
<div class="flex items-center space-x-2">
<span>Export as PDF</span>
<LockClosedIcon
v-if="!isAllowedToPerformPremiumAction()"
class="w-3.5 text-text-tertiary" />
</div>
</DropdownMenuItem>
<DropdownMenuItem @click="triggerExport('xlsx')">
Export as Excel
</DropdownMenuItem>
<DropdownMenuItem @click="triggerExport('csv')">
Export as CSV
</DropdownMenuItem>
<DropdownMenuItem @click="triggerExport('ods')">
Export as ODS
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<DropdownMenu>
<DropdownMenuTrigger as-child class="sm:hidden">
<button
class="p-1.5 rounded-lg border border-border-tertiary text-text-secondary hover:text-text-primary hover:bg-secondary transition"
aria-label="More options">
<EllipsisVerticalIcon class="w-5 h-5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="triggerExport('pdf')">
<div class="flex items-center space-x-2">
<span>Export as PDF</span>
<LockClosedIcon
v-if="!isAllowedToPerformPremiumAction()"
class="w-3.5 text-text-tertiary" />
</div>
</DropdownMenuItem>
<DropdownMenuItem @click="triggerExport('xlsx')">
Export as Excel
</DropdownMenuItem>
<DropdownMenuItem @click="triggerExport('csv')">
Export as CSV
</DropdownMenuItem>
<DropdownMenuItem @click="triggerExport('ods')">
Export as ODS
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</MainContainer>
<MainContainer class="sm:hidden py-2 border-b border-default-background-separator">
<ReportingTabNavbar active="detailed"></ReportingTabNavbar>
</MainContainer> </MainContainer>
<ReportingFilterBar <ReportingFilterBar

View File

@@ -2,10 +2,29 @@
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 { ChartBarIcon, CreditCardIcon, UserGroupIcon } from '@heroicons/vue/20/solid'; import {
import { computed } from 'vue'; ChartBarIcon,
ChevronLeftIcon,
ChevronDoubleLeftIcon,
ChevronRightIcon,
ChevronDoubleRightIcon,
CreditCardIcon,
UserGroupIcon,
} from '@heroicons/vue/20/solid';
import { computed, ref, watch } from 'vue';
import { useQuery } from '@tanstack/vue-query'; import { api, type ReportIndexResponse } from '@/packages/api/src';
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';
@@ -13,37 +32,49 @@ 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 { data: reportsData } = useQuery({ const pageLimit = 15;
queryKey: computed(() => ['reports', getCurrentOrganizationId()]), const currentPage = ref(1);
const { data: reportsResponse } = useQuery<ReportIndexResponse>({
queryKey: ['reports', currentPage],
enabled: !!getCurrentOrganizationId(), enabled: !!getCurrentOrganizationId(),
queryFn: async () => { queryFn: () =>
const organizationId = getCurrentOrganizationId(); api.getReports({
if (!organizationId) throw new Error('No organization'); params: {
const data = await fetchAllReports(organizationId); organization: getCurrentOrganizationId() || '',
return { data }; },
}, }),
staleTime: 1000 * 30,
}); });
const reports = computed(() => { const reports = computed(() => {
return reportsData.value?.data ?? []; return reportsResponse.value?.data ?? [];
});
const totalPages = computed(() => {
return 1;
});
const queryClient = useQueryClient();
async function updateFilteredTimeEntries() {
await queryClient.invalidateQueries({
queryKey: ['reports'],
});
}
watch(currentPage, () => {
updateFilteredTimeEntries();
}); });
</script> </script>
<template> <template>
<AppLayout title="Reporting" data-testid="reporting_view" class="overflow-hidden"> <AppLayout title="Reporting" data-testid="reporting_view" class="overflow-hidden">
<MainContainer <MainContainer
class="h-14 sm:h-16 border-b border-default-background-separator flex flex-wrap gap-y-3 justify-between items-center"> class="py-3 sm:py-5 min-h-[79px] border-b border-default-background-separator flex justify-between items-center">
<div class="flex items-center space-x-3 sm:space-x-6"> <div class="flex items-center space-x-3 sm:space-x-6">
<PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle> <PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle>
<ReportingTabNavbar active="shared" class="hidden sm:flex"></ReportingTabNavbar> <ReportingTabNavbar active="shared"></ReportingTabNavbar>
</div> </div>
</MainContainer> </MainContainer>
<MainContainer class="sm:hidden py-2 border-b border-default-background-separator">
<ReportingTabNavbar active="shared"></ReportingTabNavbar>
</MainContainer>
<div v-if="!isAllowedToPerformPremiumAction()"> <div v-if="!isAllowedToPerformPremiumAction()">
<div class="py-12"> <div class="py-12">
@@ -76,5 +107,64 @@ const reports = computed(() => {
<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,7 +222,6 @@ 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]">
@@ -240,15 +239,12 @@ 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">
{{ {{
aggregatedTableTimeEntries.cost formatCents(
? formatCents( aggregatedTableTimeEntries.cost,
aggregatedTableTimeEntries.cost, reportCurrency,
reportCurrency, reportCurrencyFormat,
reportCurrencyFormat, reportCurrencySymbol
reportCurrencySymbol, )
reportNumberFormat
)
: '--'
}} }}
</div> </div>
</div> </div>

View File

@@ -9,31 +9,9 @@ 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);
} }
@@ -56,10 +34,6 @@ async function createTag(tag: string) {
v-model:show="showCreateTagModal" v-model:show="showCreateTagModal"
:create-tag="createTag"></TagCreateModal> :create-tag="createTag"></TagCreateModal>
</MainContainer> </MainContainer>
<TagTable <TagTable :create-tag="createTag"></TagTable>
:create-tag="createTag"
:sort-column="tableState.sortColumn"
:sort-direction="tableState.sortDirection"
@sort="handleSort"></TagTable>
</AppLayout> </AppLayout>
</template> </template>

View File

@@ -32,8 +32,7 @@ import { useClientsStore } from '@/utils/useClients';
import { useTimeEntriesInfiniteQuery } from '@/utils/useTimeEntriesInfiniteQuery'; import { useTimeEntriesInfiniteQuery } from '@/utils/useTimeEntriesInfiniteQuery';
import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations'; import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending } = const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useTimeEntriesInfiniteQuery();
useTimeEntriesInfiniteQuery();
const { const {
createTimeEntry: createTimeEntryMutation, createTimeEntry: createTimeEntryMutation,
updateTimeEntry, updateTimeEntry,
@@ -146,10 +145,7 @@ function deleteSelected() {
:currency="getOrganizationCurrencyString()" :currency="getOrganizationCurrencyString()"
:time-entries="timeEntries" :time-entries="timeEntries"
:tags="tags"></TimeEntryGroupedTable> :tags="tags"></TimeEntryGroupedTable>
<div v-if="isPending" class="flex justify-center items-center py-12"> <div v-if="timeEntries.length === 0" class="text-center pt-12">
<LoadingSpinner></LoadingSpinner>
</div>
<div v-else-if="timeEntries.length === 0" class="text-center pt-12">
<ClockIcon class="w-8 text-icon-default inline pb-2"></ClockIcon> <ClockIcon class="w-8 text-icon-default inline pb-2"></ClockIcon>
<h3 class="text-text-primary font-semibold">No time entries found</h3> <h3 class="text-text-primary font-semibold">No time entries found</h3>
<p class="pb-5">Create your first time entry now!</p> <p class="pb-5">Create your first time entry now!</p>
@@ -162,7 +158,7 @@ function deleteSelected() {
<span> Loading more time entries... </span> <span> Loading more time entries... </span>
</div> </div>
<div <div
v-else-if="!hasNextPage && timeEntries.length > 0" v-else-if="!hasNextPage"
class="flex justify-center items-center py-5 text-sm text-text-tertiary"> class="flex justify-center items-center py-5 text-sm text-text-tertiary">
All time entries are loaded! All time entries are loaded!
</div> </div>

View File

@@ -33,6 +33,7 @@ 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() })
@@ -597,6 +598,7 @@ 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
@@ -709,6 +711,7 @@ export const schemas = {
ApiTokenStoreRequest, ApiTokenStoreRequest,
ApiTokenWithAccessTokenResource, ApiTokenWithAccessTokenResource,
ClientResource, ClientResource,
ClientCollection,
ClientStoreRequest, ClientStoreRequest,
ClientUpdateRequest, ClientUpdateRequest,
ImportRequest, ImportRequest,
@@ -752,6 +755,7 @@ export const schemas = {
ReportUpdateRequest, ReportUpdateRequest,
DetailedWithDataReportResource, DetailedWithDataReportResource,
TagResource, TagResource,
TagCollection,
TagStoreRequest, TagStoreRequest,
TagUpdateRequest, TagUpdateRequest,
TaskResource, TaskResource,
@@ -1197,39 +1201,7 @@ const endpoints = makeApi([
schema: z.enum(['true', 'false', 'all']).optional(), schema: z.enum(['true', 'false', 'all']).optional(),
}, },
], ],
response: z response: z.object({ data: ClientCollection }).passthrough(),
.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,
@@ -1540,11 +1512,6 @@ 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({
@@ -2170,11 +2137,6 @@ 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({
@@ -2780,11 +2742,6 @@ 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({
@@ -2835,13 +2792,6 @@ 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(),
},
], ],
}, },
{ {
@@ -2910,11 +2860,6 @@ 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({
@@ -2965,13 +2910,6 @@ 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(),
},
], ],
}, },
{ {
@@ -3148,45 +3086,8 @@ 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({ data: TagCollection }).passthrough(),
.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,
@@ -3203,13 +3104,6 @@ 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(),
},
], ],
}, },
{ {
@@ -3357,11 +3251,6 @@ 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',
@@ -4341,11 +4230,6 @@ 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.16", "version": "0.0.15",
"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

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

View File

@@ -21,7 +21,7 @@ const props = withDefaults(
<button <button
:type="type" :type="type"
:disabled="loading" :disabled="loading"
class="inline-flex items-center h-9 px-3 text-sm bg-button-primary-background border border-button-primary-border rounded-md font-medium text-button-primary-text hover:bg-button-primary-background-hover active:bg-button-primary-background-hover focus:outline-none focus-visible:ring-2 focus-visible:border-transparent focus-visible:ring-ring transition ease-in-out duration-150"> class="inline-flex items-center px-3 py-2 bg-button-primary-background border border-button-primary-border rounded-md font-medium text-xs sm:text-sm text-button-primary-text hover:bg-button-primary-background-hover active:bg-button-primary-background-hover focus:outline-none focus-visible:ring-2 focus-visible:border-transparent focus-visible:ring-ring transition ease-in-out duration-150">
<span :class="twMerge('flex items-center ', props.icon ? 'space-x-1.5' : '')"> <span :class="twMerge('flex items-center ', props.icon ? 'space-x-1.5' : '')">
<LoadingSpinner v-if="loading"></LoadingSpinner> <LoadingSpinner v-if="loading"></LoadingSpinner>
<component <component

View File

@@ -22,7 +22,7 @@ const props = withDefaults(
const sizeClasses = { const sizeClasses = {
small: 'text-xs px-2.5 py-1.5', small: 'text-xs px-2.5 py-1.5',
base: 'h-9 px-3 text-sm', base: 'text-xs sm:text-sm px-3 py-2',
}; };
</script> </script>

View File

@@ -1,198 +0,0 @@
<script setup lang="ts">
import { Popover, PopoverContent, PopoverTrigger, Button } from '..';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import { Field, FieldLabel } from '../field';
import { Settings } from 'lucide-vue-next';
import { ref, watch } from 'vue';
import type { CalendarSettings } from './calendarSettings';
export type { CalendarSettings };
const props = defineProps<{
settings: CalendarSettings;
}>();
const emit = defineEmits<{
'update:settings': [value: CalendarSettings];
}>();
const snapMinutes = ref(String(props.settings.snapMinutes));
const startHour = ref(String(props.settings.startHour));
const endHour = ref(String(props.settings.endHour));
const slotMinutes = ref(String(props.settings.slotMinutes));
watch(
() => props.settings,
(s) => {
snapMinutes.value = String(s.snapMinutes);
startHour.value = String(s.startHour);
endHour.value = String(s.endHour);
slotMinutes.value = String(s.slotMinutes);
}
);
function emitUpdate(partial: Partial<CalendarSettings>) {
emit('update:settings', { ...props.settings, ...partial });
}
function onSnapChange(value: string) {
snapMinutes.value = value;
emitUpdate({ snapMinutes: parseInt(value) });
}
function onStartHourChange(value: string) {
const newStart = parseInt(value);
// Ensure start < end
if (newStart >= parseInt(endHour.value)) {
startHour.value = String(props.settings.startHour);
return;
}
startHour.value = value;
emitUpdate({ startHour: newStart });
}
function onEndHourChange(value: string) {
const newEnd = parseInt(value);
// Ensure end > start
if (newEnd <= parseInt(startHour.value)) {
endHour.value = String(props.settings.endHour);
return;
}
endHour.value = value;
emitUpdate({ endHour: newEnd });
}
function onSlotChange(value: string) {
slotMinutes.value = value;
emitUpdate({ slotMinutes: parseInt(value) });
}
const snapOptions = [
{ value: '1', label: '1 min' },
{ value: '5', label: '5 min' },
{ value: '10', label: '10 min' },
{ value: '15', label: '15 min' },
{ value: '30', label: '30 min' },
{ value: '60', label: '1 hour' },
];
const slotOptions = [
{ value: '5', label: '5 min' },
{ value: '10', label: '10 min' },
{ value: '15', label: '15 min' },
{ value: '30', label: '30 min' },
{ value: '60', label: '1 hour' },
];
// Generate hour options 0-24
const hourOptions = Array.from({ length: 25 }, (_, i) => ({
value: String(i),
label:
i === 0
? '12:00 AM'
: i === 12
? '12:00 PM'
: i === 24
? '12:00 AM (next)'
: i < 12
? `${i}:00 AM`
: `${i - 12}:00 PM`,
}));
</script>
<template>
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" size="sm" aria-label="Calendar settings" class="h-8 w-8 p-0">
<Settings class="h-4 w-4 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" class="w-72 p-4">
<div class="space-y-4">
<div class="text-sm font-semibold">Calendar Settings</div>
<Field>
<FieldLabel for="calendar-snap">Snap Interval</FieldLabel>
<Select
:model-value="snapMinutes"
@update:model-value="(v) => onSnapChange(v as string)">
<SelectTrigger id="calendar-snap" size="sm" class="w-full">
<SelectValue placeholder="Snap interval" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="opt in snapOptions"
:key="opt.value"
:value="opt.value">
{{ opt.label }}
</SelectItem>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel for="calendar-start-hour">Start Time</FieldLabel>
<Select
:model-value="startHour"
@update:model-value="(v) => onStartHourChange(v as string)">
<SelectTrigger id="calendar-start-hour" size="sm" class="w-full">
<SelectValue placeholder="Start time" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="opt in hourOptions.slice(0, -1)"
:key="opt.value"
:value="opt.value">
{{ opt.label }}
</SelectItem>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel for="calendar-end-hour">End Time</FieldLabel>
<Select
:model-value="endHour"
@update:model-value="(v) => onEndHourChange(v as string)">
<SelectTrigger id="calendar-end-hour" size="sm" class="w-full">
<SelectValue placeholder="End time" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="opt in hourOptions.slice(1)"
:key="opt.value"
:value="opt.value">
{{ opt.label }}
</SelectItem>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel for="calendar-scale">Grid Scale</FieldLabel>
<Select
:model-value="slotMinutes"
@update:model-value="(v) => onSlotChange(v as string)">
<SelectTrigger id="calendar-scale" size="sm" class="w-full">
<SelectValue placeholder="Grid scale" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="opt in slotOptions"
:key="opt.value"
:value="opt.value">
{{ opt.label }}
</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
</PopoverContent>
</Popover>
</template>

View File

@@ -6,10 +6,10 @@ import type { Dayjs } from 'dayjs';
const props = defineProps<{ const props = defineProps<{
date: Dayjs; date: Dayjs;
totalSeconds?: number; totalMinutes?: number;
}>(); }>();
const totalSecondsValue = computed(() => props.totalSeconds ?? 0); const totalSeconds = computed(() => (props.totalMinutes ?? 0) * 60);
// Injected organization for formatting settings // Injected organization for formatting settings
const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined; const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined;
@@ -25,7 +25,7 @@ const dateFormat = computed(() => organization?.value?.date_format);
</div> </div>
<span class="text-xs">{{ formatDate(date.toISOString(), dateFormat) }}</span> <span class="text-xs">{{ formatDate(date.toISOString(), dateFormat) }}</span>
<span class="block text-xs text-muted-foreground font-medium mt-1"> <span class="block text-xs text-muted-foreground font-medium mt-1">
{{ formatHumanReadableDuration(totalSecondsValue, intervalFormat, numberFormat) }} {{ formatHumanReadableDuration(totalSeconds, intervalFormat, numberFormat) }}
</span> </span>
</div> </div>
</template> </template>

View File

@@ -15,22 +15,13 @@ import {
onActivated, onActivated,
onUnmounted, onUnmounted,
} from 'vue'; } from 'vue';
import { useLocalStorage } from '@vueuse/core';
import chroma from 'chroma-js'; import chroma from 'chroma-js';
import { useCssVariable } from '@/utils/useCssVariable'; import { useCssVariable } from '@/utils/useCssVariable';
import { import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
getDayJsInstance,
getLocalizedDayJs,
formatHumanReadableDuration,
formatDuration,
} from '../utils/time';
import { getUserTimezone, getWeekStart } from '../utils/settings'; import { getUserTimezone, getWeekStart } from '../utils/settings';
import { LoadingSpinner, TimeEntryCreateModal, TimeEntryEditModal } from '..'; import { LoadingSpinner, TimeEntryCreateModal, TimeEntryEditModal } from '..';
import FullCalendarEventContent from './FullCalendarEventContent.vue'; import FullCalendarEventContent from './FullCalendarEventContent.vue';
import FullCalendarDayHeader from './FullCalendarDayHeader.vue'; import FullCalendarDayHeader from './FullCalendarDayHeader.vue';
import CalendarSettingsPopover from './CalendarSettingsPopover.vue';
import type { CalendarSettings } from './calendarSettings';
import { useVisualSnap } from './useVisualSnap';
import activityStatusPlugin, { import activityStatusPlugin, {
type ActivityPeriod, type ActivityPeriod,
renderActivityStatusBoxes, renderActivityStatusBoxes,
@@ -90,22 +81,6 @@ const selectedTimeEntry = ref<TimeEntry | null>(null);
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null); const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);
// Calendar settings with localStorage persistence via VueUse
const calendarSettings = useLocalStorage<CalendarSettings>(
'solidtime:calendar-settings',
{
snapMinutes: 15,
startHour: 0,
endHour: 24,
slotMinutes: 15,
},
{ mergeDefaults: true }
);
function onSettingsUpdate(newSettings: CalendarSettings) {
calendarSettings.value = newSettings;
}
// Reactive "now" for running time entry - updates every minute // Reactive "now" for running time entry - updates every minute
const currentTime = ref(getDayJsInstance()()); const currentTime = ref(getDayJsInstance()());
let currentTimeInterval: ReturnType<typeof setInterval> | null = null; let currentTimeInterval: ReturnType<typeof setInterval> | null = null;
@@ -204,20 +179,20 @@ const dailyTotals = computed(() => {
const totals: Record<string, number> = {}; const totals: Record<string, number> = {};
props.timeEntries.forEach((entry) => { props.timeEntries.forEach((entry) => {
const date = getDayJsInstance()(entry.start).format('YYYY-MM-DD'); const date = getDayJsInstance()(entry.start).format('YYYY-MM-DD');
let durationSeconds: number; let duration: number;
if (entry.end !== null) { if (entry.end !== null) {
// Completed entry // Completed entry
durationSeconds = getDayJsInstance()(entry.end).diff( duration = getDayJsInstance()(entry.end).diff(
getDayJsInstance()(entry.start), getDayJsInstance()(entry.start),
'seconds' 'minutes'
); );
} else { } else {
// Running entry - use current time // Running entry - use current time
durationSeconds = currentTime.value.diff(getDayJsInstance()(entry.start), 'seconds'); duration = currentTime.value.diff(getDayJsInstance()(entry.start), 'minutes');
} }
totals[date] = (totals[date] || 0) + durationSeconds; totals[date] = (totals[date] || 0) + duration;
}); });
return totals; return totals;
}); });
@@ -229,19 +204,16 @@ function emitDatesChange(arg: DatesSetArg) {
} }
function handleDateSelect(arg: { start: Date; end: Date }) { function handleDateSelect(arg: { start: Date; end: Date }) {
stopVisualSnap(); const startTime = getDayJsInstance()(arg.start.toISOString())
const snap = calendarSettings.value.snapMinutes;
const startLocal = getDayJsInstance()(arg.start.toISOString())
.utc() .utc()
.tz(getUserTimezone(), true); .tz(getUserTimezone(), true)
const endLocal = getDayJsInstance()(arg.end.toISOString()).utc().tz(getUserTimezone(), true); .utc();
const snappedStart = snapToGrid(startLocal, snap); const endTime = getDayJsInstance()(arg.end.toISOString())
let snappedEnd = snapToGrid(endLocal, snap); .utc()
if (!snappedEnd.isAfter(snappedStart)) { .tz(getUserTimezone(), true)
snappedEnd = snappedStart.add(snap, 'minute'); .utc();
} newEventStart.value = startTime;
newEventStart.value = snappedStart.utc(); newEventEnd.value = endTime;
newEventEnd.value = snappedEnd.utc();
showCreateTimeEntryModal.value = true; showCreateTimeEntryModal.value = true;
} }
@@ -255,136 +227,90 @@ function handleEventClick(arg: EventClickArg) {
showEditTimeEntryModal.value = true; showEditTimeEntryModal.value = true;
} }
// Snap a dayjs time to the nearest snap interval boundary
function snapToGrid(time: Dayjs, snapMinutes: number): Dayjs {
const minutes = time.hour() * 60 + time.minute();
const snapped = Math.round(minutes / snapMinutes) * snapMinutes;
return time.startOf('day').add(snapped, 'minute');
}
// --- Visual snap (composable) ---
const {
startDragSnap: startVisualDragSnap,
startResizeSnap: startVisualResizeSnap,
stop: stopVisualSnap,
} = useVisualSnap({
calendarRef,
snapMinutes: () => calendarSettings.value.snapMinutes,
slotMinutes: () => calendarSettings.value.slotMinutes,
formatDuration: (seconds) =>
formatHumanReadableDuration(
seconds,
organization?.value?.interval_format,
organization?.value?.number_format
),
});
async function handleEventDrop(arg: EventDropArg) { async function handleEventDrop(arg: EventDropArg) {
stopVisualSnap();
const ext = arg.event.extendedProps as CalendarExtendedProps; const ext = arg.event.extendedProps as CalendarExtendedProps;
const timeEntry = ext.timeEntry; const timeEntry = ext.timeEntry;
if (!arg.event.start || !arg.event.end) return; if (!arg.event.start || !arg.event.end) return;
// Running entries have no end time — can't compute duration for drop
if (!timeEntry.end) return;
const snap = calendarSettings.value.snapMinutes;
const startLocal = getDayJsInstance()(arg.event.start.toISOString())
.utc()
.tz(getUserTimezone(), true)
.second(0);
const snappedStart = snapToGrid(startLocal, snap);
const durationMs = getLocalizedDayJs(timeEntry.end).diff(getLocalizedDayJs(timeEntry.start));
const snappedEnd = snappedStart.add(durationMs, 'millisecond');
// Set FC event to snapped position immediately to avoid flash
arg.event.setDates(snappedStart.utc(true).toDate(), snappedEnd.utc(true).toDate());
const updatedTimeEntry = { const updatedTimeEntry = {
...timeEntry, ...timeEntry,
start: snappedStart.utc().format(), start: getDayJsInstance()(arg.event.start.toISOString())
end: snappedEnd.utc().format(), .utc()
.tz(getUserTimezone(), true)
.second(0)
.utc()
.format(),
end: getDayJsInstance()(arg.event.end.toISOString())
.utc()
.tz(getUserTimezone(), true)
.second(0)
.utc()
.format(),
} as TimeEntry; } as TimeEntry;
await props.updateTimeEntry(updatedTimeEntry); await props.updateTimeEntry(updatedTimeEntry);
emit('refresh'); emit('refresh');
} }
async function handleEventResize(arg: EventChangeArg) { async function handleEventResize(arg: EventChangeArg) {
stopVisualSnap();
const ext = arg.event.extendedProps as CalendarExtendedProps; const ext = arg.event.extendedProps as CalendarExtendedProps;
const timeEntry = ext.timeEntry; const timeEntry = ext.timeEntry;
if (!arg.event.start || !arg.event.end) return; if (!arg.event.start || !arg.event.end) return;
const snap = calendarSettings.value.snapMinutes;
const newStartLocal = getDayJsInstance()(arg.event.start.toISOString())
.utc()
.tz(getUserTimezone(), true)
.second(0);
const newEndLocal = getDayJsInstance()(arg.event.end.toISOString())
.utc()
.tz(getUserTimezone(), true)
.second(0);
const origStartLocal = getLocalizedDayJs(timeEntry.start).second(0);
const startChanged = !newStartLocal.isSame(origStartLocal, 'minute');
// Snap only the changed edge once, reuse for both setDates and API update
const snappedStart = startChanged ? snapToGrid(newStartLocal, snap) : null;
const snappedEnd = !startChanged && !ext.isRunning ? snapToGrid(newEndLocal, snap) : null;
// Set FC event to snapped position immediately to avoid flash.
// Use the original event date for the edge that wasn't resized.
if (snappedStart) {
arg.event.setDates(snappedStart.utc(true).toDate(), arg.oldEvent.end!);
} else if (snappedEnd) {
arg.event.setDates(arg.oldEvent.start!, snappedEnd.utc(true).toDate());
}
const updatedTimeEntry = { const updatedTimeEntry = {
...timeEntry, ...timeEntry,
start: snappedStart ? snappedStart.utc().format() : timeEntry.start, start: getDayJsInstance()(arg.event.start.toISOString())
end: ext.isRunning ? null : snappedEnd ? snappedEnd.utc().format() : timeEntry.end, .utc()
.tz(getUserTimezone(), true)
.second(0)
.utc()
.format(),
// Preserve null end for running entries
end: ext.isRunning
? null
: getDayJsInstance()(arg.event.end.toISOString())
.utc()
.tz(getUserTimezone(), true)
.second(0)
.utc()
.format(),
} as TimeEntry; } as TimeEntry;
await props.updateTimeEntry(updatedTimeEntry); await props.updateTimeEntry(updatedTimeEntry);
emit('refresh'); emit('refresh');
} }
const calendarOptions = computed(() => { const calendarOptions = computed(() => ({
const s = calendarSettings.value; plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, activityStatusPlugin],
initialView: 'timeGridWeek',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'timeGridWeek,timeGridDay',
},
height: 'parent',
slotMinTime: '00:00:00',
slotMaxTime: '24:00:00',
slotDuration: '00:15:00',
slotLabelInterval: '01:00:00',
slotLabelFormat: getSlotLabelFormat(),
snapDuration: '00:01:00',
firstDay: getFirstDay(),
allDaySlot: false,
nowIndicator: true,
eventMinHeight: 1,
selectable: true,
selectMirror: true,
editable: true,
eventResizableFromStart: true,
eventDurationEditable: true,
timeZone: getUserTimezone(),
eventStartEditable: true,
select: handleDateSelect,
eventClick: handleEventClick,
eventDrop: handleEventDrop,
eventResize: handleEventResize,
datesSet: emitDatesChange,
return { events: events.value,
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, activityStatusPlugin], activityPeriods: props.activityPeriods || [],
initialView: 'timeGridWeek', }));
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'timeGridWeek,timeGridDay',
},
height: 'parent',
slotMinTime: formatDuration(s.startHour * 3600),
slotMaxTime: formatDuration(s.endHour * 3600),
slotDuration: formatDuration(s.slotMinutes * 60),
slotLabelInterval: '01:00:00',
slotLabelFormat: getSlotLabelFormat(),
snapDuration: '00:01:00',
firstDay: getFirstDay(),
allDaySlot: false,
nowIndicator: true,
eventMinHeight: 1,
selectable: true,
selectMirror: true,
editable: true,
eventResizableFromStart: true,
eventDurationEditable: true,
timeZone: getUserTimezone(),
eventStartEditable: true,
select: handleDateSelect,
eventClick: handleEventClick,
eventDragStart: startVisualDragSnap,
eventDrop: handleEventDrop,
eventResizeStart: startVisualResizeSnap,
eventResize: handleEventResize,
datesSet: emitDatesChange,
events: events.value,
activityPeriods: props.activityPeriods || [],
};
});
watch(showCreateTimeEntryModal, (value) => { watch(showCreateTimeEntryModal, (value) => {
if (!value) { if (!value) {
@@ -450,6 +376,7 @@ onActivated(() => {
}); });
onUnmounted(() => { onUnmounted(() => {
// Clean up interval
if (currentTimeInterval) { if (currentTimeInterval) {
clearInterval(currentTimeInterval); clearInterval(currentTimeInterval);
currentTimeInterval = null; currentTimeInterval = null;
@@ -497,11 +424,6 @@ onUnmounted(() => {
:clients="clients" :clients="clients"
:currency="currency" :currency="currency"
:can-create-project="canCreateProject" /> :can-create-project="canCreateProject" />
<div class="calendar-settings-trigger">
<CalendarSettingsPopover
:settings="calendarSettings"
@update:settings="onSettingsUpdate" />
</div>
<FullCalendar ref="calendarRef" class="fullcalendar" :options="calendarOptions"> <FullCalendar ref="calendarRef" class="fullcalendar" :options="calendarOptions">
<template #eventContent="arg"> <template #eventContent="arg">
<FullCalendarEventContent <FullCalendarEventContent
@@ -522,7 +444,7 @@ onUnmounted(() => {
:date=" :date="
getDayJsInstance()(arg.date.toISOString()).utc().tz(getUserTimezone(), true) getDayJsInstance()(arg.date.toISOString()).utc().tz(getUserTimezone(), true)
" "
:total-seconds=" :total-minutes="
dailyTotals[ dailyTotals[
getDayJsInstance()(arg.date) getDayJsInstance()(arg.date)
.utc() .utc()
@@ -536,13 +458,6 @@ onUnmounted(() => {
</template> </template>
<style scoped> <style scoped>
.calendar-settings-trigger {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 20;
}
.fullcalendar { .fullcalendar {
height: 100%; height: 100%;
--fc-border-color: var(--border); --fc-border-color: var(--border);
@@ -567,7 +482,6 @@ onUnmounted(() => {
.fullcalendar :deep(.fc-toolbar) { .fullcalendar :deep(.fc-toolbar) {
background-color: var(--background); background-color: var(--background);
padding: 0.5rem; padding: 0.5rem;
padding-right: 2.75rem;
margin-bottom: 0; margin-bottom: 0;
} }
@@ -666,65 +580,36 @@ onUnmounted(() => {
line-height: 1.2; line-height: 1.2;
} }
/* Resize handle hit areas */ /* Enhanced FullCalendar resize handles */
.fullcalendar :deep(.fc-event-resizer) { .fullcalendar :deep(.fc-event-resizer) {
position: absolute; position: absolute;
z-index: 99; z-index: 99;
background: '#FFF';
border-radius: 2px;
width: 100%; width: 100%;
height: 12px; height: 4px;
left: 0; left: 0;
cursor: row-resize; transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
opacity: 0; opacity: 0;
transition: opacity 0.15s ease;
} }
.fullcalendar :deep(.fc-event-resizer-start) { .fullcalendar :deep(.fc-event-resizer-start) {
top: -2px; top: -2px;
cursor: n-resize;
} }
.fullcalendar :deep(.fc-event-resizer-end) { .fullcalendar :deep(.fc-event-resizer-end) {
bottom: -2px; bottom: -2px;
} cursor: s-resize;
/* Visual grip indicator */
.fullcalendar :deep(.fc-event-resizer::after) {
content: '';
width: 24px;
height: 3px;
border-radius: 1.5px;
background: rgba(255, 255, 255, 0.6);
transition: background 0.15s ease;
} }
.fullcalendar :deep(.fc-event:hover .fc-event-resizer) { .fullcalendar :deep(.fc-event:hover .fc-event-resizer) {
opacity: 1; opacity: 1;
} }
.fullcalendar :deep(.fc-event-resizer:hover::after) { .fullcalendar :deep(.fc-event-resizer:hover) {
background: rgba(255, 255, 255, 0.9); background: '#FFF';
} height: 6px;
/* Keep resize cursor during active resize */
.fullcalendar :deep(.fc-event-resizing),
.fullcalendar :deep(.fc-event-resizing .fc-event-resizer) {
cursor: row-resize !important;
}
/* Keep event in hover state while resizing */
.fullcalendar :deep(.fc-event-resizing) {
opacity: 1;
box-shadow: var(--theme-shadow-dropdown);
}
.fullcalendar :deep(.fc-event-resizing .fc-event-resizer) {
opacity: 1;
}
.fullcalendar :deep(.fc-event-resizing .fc-event-resizer::after) {
background: rgba(255, 255, 255, 0.9);
} }
/* Update the earlier hover rule to include the shadow */ /* Update the earlier hover rule to include the shadow */
@@ -878,11 +763,3 @@ onUnmounted(() => {
border-bottom-right-radius: 0px; border-bottom-right-radius: 0px;
} }
</style> </style>
<style>
/* Global cursor override during resize — must be unscoped to affect body */
body.fc-resizing-active,
body.fc-resizing-active * {
cursor: row-resize !important;
}
</style>

View File

@@ -1,6 +0,0 @@
export interface CalendarSettings {
snapMinutes: number;
startHour: number;
endHour: number;
slotMinutes: number;
}

View File

@@ -1,186 +0,0 @@
import { onActivated, onDeactivated, onMounted, onUnmounted, type Ref } from 'vue';
import type FullCalendar from '@fullcalendar/vue3';
interface VisualSnapOptions {
calendarRef: Ref<InstanceType<typeof FullCalendar> | null>;
snapMinutes: () => number;
slotMinutes: () => number;
formatDuration: (durationSeconds: number) => string;
}
export function useVisualSnap({
calendarRef,
snapMinutes,
slotMinutes,
formatDuration,
}: VisualSnapOptions) {
let rafId: number | null = null;
function getCalendarEl(): HTMLElement | null {
return (calendarRef.value?.$el as HTMLElement) ?? null;
}
function getSnapPixels(): number {
const calendarEl = getCalendarEl();
if (!calendarEl) return 25;
const slot = calendarEl.querySelector('.fc-timegrid-slot-lane') as HTMLElement;
if (!slot) return 25;
const slotHeightPx = slot.getBoundingClientRect().height;
return (snapMinutes() / slotMinutes()) * slotHeightPx;
}
function findMirrorHarness(calendarEl: HTMLElement) {
const mirror = calendarEl.querySelector('.fc-event-mirror') as HTMLElement | null;
const harness = mirror?.closest('.fc-timegrid-event-harness') as HTMLElement | null;
return { mirror, harness };
}
function updateMirrorDurationLabel(
mirror: HTMLElement,
snappedTop: number,
snappedEnd: number,
snapPx: number
) {
const snappedDurationMin = Math.round((snappedEnd - snappedTop) / snapPx) * snapMinutes();
const durationText = formatDuration(snappedDurationMin * 60);
const durationEl = mirror.querySelector('.fc-event-main')?.querySelector('div:last-child');
if (durationEl) {
durationEl.textContent = durationText;
}
}
function startLoop(onFrame: (calendarEl: HTMLElement, snapPx: number) => void) {
const calendarEl = getCalendarEl();
if (!calendarEl) return;
const snapPx = getSnapPixels();
if (snapPx <= 0) return;
const loop = () => {
onFrame(calendarEl, snapPx);
rafId = requestAnimationFrame(loop);
};
rafId = requestAnimationFrame(loop);
}
function stop() {
document.body.classList.remove('fc-resizing-active');
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
}
// --- Public snap starters ---
function startSelectSnap() {
// Don't start if another snap loop is already running
if (rafId !== null) return;
startLoop((calendarEl, snapPx) => {
const { mirror, harness } = findMirrorHarness(calendarEl);
if (!harness || !mirror) return;
const top = parseFloat(harness.style.top) || 0;
const endPos = -(parseFloat(harness.style.bottom) || 0);
const snappedTop = Math.round(top / snapPx) * snapPx;
const snappedEnd = Math.round(endPos / snapPx) * snapPx;
const clampedEnd = Math.max(snappedTop + snapPx, snappedEnd);
harness.style.top = snappedTop + 'px';
harness.style.bottom = -clampedEnd + 'px';
updateMirrorDurationLabel(mirror, snappedTop, clampedEnd, snapPx);
});
}
function startDragSnap() {
stop();
startLoop((calendarEl, snapPx) => {
const { harness } = findMirrorHarness(calendarEl);
if (!harness) return;
const top = parseFloat(harness.style.top) || 0;
const endPos = -(parseFloat(harness.style.bottom) || 0);
const height = endPos - top;
const snappedTop = Math.round(top / snapPx) * snapPx;
harness.style.top = snappedTop + 'px';
harness.style.bottom = -(snappedTop + height) + 'px';
});
}
function startResizeSnap() {
stop();
document.body.classList.add('fc-resizing-active');
let initialTop: number | null = null;
let initialEnd: number | null = null;
let resizeEdge: 'top' | 'bottom' | null = null;
startLoop((calendarEl, snapPx) => {
const { mirror, harness } = findMirrorHarness(calendarEl);
if (!harness) return;
const top = parseFloat(harness.style.top) || 0;
const endPos = -(parseFloat(harness.style.bottom) || 0);
// Detect which edge is being resized
if (initialTop === null) {
initialTop = top;
initialEnd = endPos;
} else if (resizeEdge === null) {
const topDelta = Math.abs(top - initialTop);
const endDelta = Math.abs(endPos - initialEnd!);
if (topDelta > 0.5) {
resizeEdge = 'top';
} else if (endDelta > 0.5) {
resizeEdge = 'bottom';
}
}
if (resizeEdge === 'bottom') {
const snappedEnd = Math.round(endPos / snapPx) * snapPx;
const clampedEnd = Math.max(top + snapPx, snappedEnd);
harness.style.bottom = -clampedEnd + 'px';
if (mirror) updateMirrorDurationLabel(mirror, top, clampedEnd, snapPx);
} else if (resizeEdge === 'top') {
const snappedTop = Math.round(top / snapPx) * snapPx;
const clampedTop = Math.min(endPos - snapPx, snappedTop);
harness.style.top = clampedTop + 'px';
if (mirror) updateMirrorDurationLabel(mirror, clampedTop, endPos, snapPx);
}
});
}
// Pointerdown handler for starting select snap on timegrid background
function handleTimegridPointerDown(e: PointerEvent) {
const target = e.target as HTMLElement;
if (target.closest('.fc-event')) return;
startSelectSnap();
}
// Lifecycle: attach/detach pointerdown listener
function attachListener() {
const calendarEl = getCalendarEl();
calendarEl?.addEventListener('pointerdown', handleTimegridPointerDown);
}
function detachListener() {
const calendarEl = getCalendarEl();
calendarEl?.removeEventListener('pointerdown', handleTimegridPointerDown);
}
onMounted(attachListener);
onActivated(attachListener);
onDeactivated(() => {
stop();
detachListener();
});
onUnmounted(() => {
stop();
detachListener();
});
return {
startSelectSnap,
startDragSnap,
startResizeSnap,
stop,
};
}

View File

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

View File

@@ -25,7 +25,7 @@ const model = defineModel();
v-model="model" v-model="model"
:class=" :class="
twMerge( twMerge(
'h-9 px-3 py-1 text-base sm:text-sm border-input-border border bg-input-background text-text-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-transparent rounded-md shadow-sm', 'h-9 px-3 py-1 text-sm border-input-border border bg-input-background text-text-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-transparent rounded-md shadow-sm',
props.class props.class
) )
" "

View File

@@ -91,7 +91,6 @@ 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

@@ -69,7 +69,7 @@ const showMassUpdateModal = ref(false);
"> ">
<Checkbox <Checkbox
id="selectAll" id="selectAll"
:checked="allSelected && selectedTimeEntries.length > 0" :checked="allSelected"
@update:checked="allSelected ? emit('unselectAll') : emit('selectAll')"> @update:checked="allSelected ? emit('unselectAll') : emit('selectAll')">
</Checkbox> </Checkbox>
<FieldLabel <FieldLabel

View File

@@ -228,7 +228,6 @@ 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

@@ -208,22 +208,17 @@ useSelectEvents(
<div class="flex items-center relative @container" data-testid="dashboard_timer"> <div class="flex items-center relative @container" data-testid="dashboard_timer">
<div <div
class="flex flex-col @2xl:flex-row w-full justify-between rounded-lg bg-card-background border-card-border border transition shadow-card"> class="flex flex-col @2xl:flex-row w-full justify-between rounded-lg bg-card-background border-card-border border transition shadow-card">
<div class="flex flex-1 items-center relative"> <div class="flex flex-1 items-center pr-6 relative">
<input <input
ref="currentTimeEntryDescriptionInput" ref="currentTimeEntryDescriptionInput"
v-model="tempDescription" v-model="tempDescription"
placeholder="What are you working on?" placeholder="What are you working on?"
data-testid="time_entry_description" data-testid="time_entry_description"
class="w-full rounded-l-lg py-4 sm:py-2.5 px-3.5 border-b border-b-card-background-separator @2xl:px-4 text-lg text-text-primary bg-transparent border-none placeholder-text-secondary font-medium focus:ring-0 transition" class="w-full rounded-l-lg py-4 sm:py-2.5 px-3.5 border-b border-b-card-background-separator @2xl:px-4 text-base @4xl:text-lg text-text-primary bg-transparent border-none placeholder-text-secondary font-medium focus:ring-0 transition"
type="text" type="text"
@keydown.enter="startTimerIfNotActive" @keydown.enter="startTimerIfNotActive"
@keydown.esc="showDropdown = false" @keydown.esc="showDropdown = false"
@blur="updateTimeEntryDescription" /> @blur="updateTimeEntryDescription" />
<div class="@2xl:hidden pr-3 shrink-0">
<TimeTrackerStartStop
:active="isActive"
@changed="onToggleButtonPress"></TimeTrackerStartStop>
</div>
<div <div
v-if="showDropdown && filteredRecentlyTrackedTimeEntries.length > 0" v-if="showDropdown && filteredRecentlyTrackedTimeEntries.length > 0"
ref="floating" ref="floating"
@@ -267,7 +262,7 @@ useSelectEvents(
:enable-estimated-time="enableEstimatedTime" :enable-estimated-time="enableEstimatedTime"
@changed="updateProject"></TimeTrackerProjectTaskDropdown> @changed="updateProject"></TimeTrackerProjectTaskDropdown>
</div> </div>
<div class="flex items-center space-x-1 @2xl:space-x-2 px-2 @2xl:px-4 shrink-0"> <div class="flex items-center @2xl:space-x-2 px-2 @2xl:px-4">
<TimeTrackerTagDropdown <TimeTrackerTagDropdown
v-model="currentTimeEntry.tags" v-model="currentTimeEntry.tags"
:create-tag :create-tag
@@ -290,7 +285,7 @@ useSelectEvents(
</div> </div>
</div> </div>
</div> </div>
<div class="pl-4 @2xl:pl-6 pr-3 hidden @2xl:block"> <div class="pl-4 @2xl:pl-6 pr-3 absolute sm:relative top-[6px] sm:top-0 right-0">
<TimeTrackerStartStop <TimeTrackerStartStop
:active="isActive" :active="isActive"
size="large" size="large"

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 } from 'vue'; import { computed, nextTick, ref, watch, watchEffect } 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,7 +33,6 @@ 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 });
@@ -149,7 +148,7 @@ function addProjectToFilterObject(
} }
} }
function updateFilteredResults() { watchEffect(() => {
const tempFilteredClients: ClientsWithProjectsWithTasks = []; const tempFilteredClients: ClientsWithProjectsWithTasks = [];
if (searchValue.value.length === 0) { if (searchValue.value.length === 0) {
@@ -244,13 +243,6 @@ function updateFilteredResults() {
}); });
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() {
@@ -506,16 +498,16 @@ const showCreateProject = ref(false);
</script> </script>
<template> <template>
<template v-if="projects.length === 0 && canCreateProject"> <div v-if="projects.length === 0 && canCreateProject">
<Button <Button
:variant="props.variant" :variant="props.variant"
:size="props.size" :size="props.size"
:class="twMerge('w-full justify-start', props.class)" :class="twMerge('w-full justify-start', props.class)"
@click="showCreateProject = true"> @click="showCreateProject = true">
<PlusIcon class="w-4" /> <PlusIcon class="w-4" />
<span class="truncate">Add new project</span> <span>Add new project</span>
</Button> </Button>
</template> </div>
<Dropdown v-else v-model="open" :close-on-content-click="false" :align="props.align"> <Dropdown v-else v-model="open" :close-on-content-click="false" :align="props.align">
<template #trigger> <template #trigger>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
@@ -656,7 +648,6 @@ 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

@@ -26,22 +26,22 @@ defineProps<{
</script> </script>
<template> <template>
<TagDropdown <TagDropdown v-model="model" :create-tag :tags="tags" @changed="emit('changed')">
v-model="model"
:create-tag
:tags="tags"
:show-no-tag-option="false"
@changed="emit('changed')">
<template #trigger> <template #trigger>
<button <button
data-testid="tag_dropdown" data-testid="tag_dropdown"
:class=" :class="
twMerge( twMerge(
iconColorClasses, iconColorClasses,
'relative flex-shrink-0 ring-0 focus:outline-none focus:ring-2 focus:ring-ring transition focus-visible:bg-card-background-separator hover:bg-card-background-separator rounded-full w-10 h-10 flex items-center justify-center' 'flex-shrink-0 ring-0 focus:outline-none focus:ring-2 focus:ring-ring transition focus-visible:bg-card-background-separator hover:bg-card-background-separator rounded-full w-10 h-10 flex items-center justify-center'
) )
"> ">
<TagIcon class="w-5 h-5 lg:h-6 lg:w-6"></TagIcon> <TagIcon class="w-5 h-5 lg:h-6 lg:w-6"></TagIcon>
<span
v-if="model.length > 1"
class="font-extrabold absolute rounded-full text-xs w-3 h-3 block top-[15px] rotate-[45deg] right-[14px] text-card-background">
{{ model.length }}
</span>
</button> </button>
</template> </template>
</TagDropdown> </TagDropdown>

View File

@@ -36,7 +36,6 @@ import TimeEntryEditModal from './TimeEntry/TimeEntryEditModal.vue';
import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue'; import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue';
import FullCalendarDayHeader from './FullCalendar/FullCalendarDayHeader.vue'; import FullCalendarDayHeader from './FullCalendar/FullCalendarDayHeader.vue';
import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue'; import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';
import CalendarSettingsPopover from './FullCalendar/CalendarSettingsPopover.vue';
import DateRangePicker from './Input/DateRangePicker.vue'; import DateRangePicker from './Input/DateRangePicker.vue';
import TimezoneMismatchModal from './TimezoneMismatchModal.vue'; import TimezoneMismatchModal from './TimezoneMismatchModal.vue';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip/index'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip/index';
@@ -60,7 +59,6 @@ import {
} from './field/index'; } from './field/index';
export type { FieldVariants } from './field/index'; export type { FieldVariants } from './field/index';
export type { ActivityPeriod } from './FullCalendar/idleStatusPlugin'; export type { ActivityPeriod } from './FullCalendar/idleStatusPlugin';
export type { CalendarSettings } from './FullCalendar/calendarSettings';
export type { export type {
CommandPaletteCommand, CommandPaletteCommand,
CommandPaletteGroup, CommandPaletteGroup,
@@ -94,7 +92,6 @@ export {
FullCalendarEventContent, FullCalendarEventContent,
FullCalendarDayHeader, FullCalendarDayHeader,
TimeEntryCalendar, TimeEntryCalendar,
CalendarSettingsPopover,
DateRangePicker, DateRangePicker,
TimezoneMismatchModal, TimezoneMismatchModal,
Tooltip, Tooltip,

View File

@@ -1,22 +0,0 @@
/**
* 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,13 +8,6 @@ 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.
@@ -86,10 +79,6 @@ function prefetchDashboard(queryClient: QueryClient) {
const organizationId = getCurrentOrganizationId(); const organizationId = getCurrentOrganizationId();
if (!organizationId) return; if (!organizationId) return;
// Prefetch projects and tasks for RecentlyTrackedTasksCard
prefetchProjects(queryClient);
prefetchTasks(queryClient);
// Prefetch all dashboard card data // Prefetch all dashboard card data
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ['timeEntries', organizationId], queryKey: ['timeEntries', organizationId],
@@ -158,9 +147,13 @@ function prefetchProjects(queryClient: QueryClient) {
if (!organizationId) return; if (!organizationId) return;
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ['projects', organizationId], queryKey: ['projects'],
queryFn: async () => ({ data: await fetchAllProjects(organizationId) }), queryFn: () =>
staleTime: 30000, api.getProjects({
params: { organization: organizationId },
queries: { archived: 'all' },
}),
staleTime: 30000, // Consider fresh for 30 seconds
}); });
} }
@@ -169,8 +162,12 @@ function prefetchTasks(queryClient: QueryClient) {
if (!organizationId) return; if (!organizationId) return;
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ['tasks', organizationId], queryKey: ['tasks'],
queryFn: async () => ({ data: await fetchAllTasks(organizationId) }), queryFn: () =>
api.getTasks({
params: { organization: organizationId },
queries: { done: 'all' },
}),
staleTime: 30000, staleTime: 30000,
}); });
} }
@@ -180,8 +177,11 @@ function prefetchTags(queryClient: QueryClient) {
if (!organizationId) return; if (!organizationId) return;
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ['tags', organizationId], queryKey: ['tags'],
queryFn: async () => ({ data: await fetchAllTags(organizationId) }), queryFn: () =>
api.getTags({
params: { organization: organizationId },
}),
staleTime: 30000, staleTime: 30000,
}); });
} }
@@ -191,8 +191,12 @@ function prefetchClients(queryClient: QueryClient) {
if (!organizationId || !canViewClients()) return; if (!organizationId || !canViewClients()) return;
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ['clients', organizationId], queryKey: ['clients'],
queryFn: async () => ({ data: await fetchAllClients(organizationId) }), queryFn: () =>
api.getClients({
params: { organization: organizationId },
queries: { archived: 'all' },
}),
staleTime: 30000, staleTime: 30000,
}); });
} }
@@ -202,8 +206,11 @@ function prefetchMembers(queryClient: QueryClient) {
if (!organizationId || !canViewMembers()) return; if (!organizationId || !canViewMembers()) return;
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ['members', organizationId], queryKey: ['members'],
queryFn: async () => ({ data: await fetchAllMembers(organizationId) }), queryFn: () =>
api.getMembers({
params: { organization: organizationId },
}),
staleTime: 30000, staleTime: 30000,
}); });
} }
@@ -213,8 +220,11 @@ function prefetchReports(queryClient: QueryClient) {
if (!organizationId) return; if (!organizationId) return;
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ['reports', organizationId], queryKey: ['reports', 1],
queryFn: async () => ({ data: await fetchAllReports(organizationId) }), queryFn: () =>
api.getReports({
params: { organization: organizationId },
}),
staleTime: 30000, staleTime: 30000,
}); });
} }
@@ -262,10 +272,11 @@ function prefetchProjectMembers(queryClient: QueryClient, projectId: string) {
if (!organizationId || !canViewMembers()) return; if (!organizationId || !canViewMembers()) return;
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ['projectMembers', organizationId, projectId], queryKey: ['projectMembers', projectId],
queryFn: async () => ({ queryFn: () =>
data: await fetchAllProjectMembers(organizationId, projectId), api.getProjectMembers({
}), params: { organization: organizationId, project: projectId },
}),
staleTime: 30000, staleTime: 30000,
}); });
} }

View File

@@ -3,27 +3,19 @@ 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: computed(() => ['clients', getCurrentOrganizationId()]), queryKey: ['clients'],
queryFn: async () => { queryFn: async () => {
const organizationId = getCurrentOrganizationId(); const organizationId = getCurrentOrganizationId();
if (!organizationId) throw new Error('No organization'); if (!organizationId) throw new Error('No organization');
const data = await fetchAllClients(organizationId); return api.getClients({
return { data }; params: { organization: organizationId },
queries: { archived: 'all' },
});
}, },
enabled: () => !!getCurrentOrganizationId(), enabled: () => !!getCurrentOrganizationId(),
staleTime: 1000 * 30, // 30 seconds staleTime: 1000 * 30, // 30 seconds

View File

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

View File

@@ -3,27 +3,18 @@ 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: computed(() => ['members', getCurrentOrganizationId()]), queryKey: ['members'],
queryFn: async () => { queryFn: async () => {
const organizationId = getCurrentOrganizationId(); const organizationId = getCurrentOrganizationId();
if (!organizationId) throw new Error('No organization'); if (!organizationId) throw new Error('No organization');
const data = await fetchAllMembers(organizationId); return api.getMembers({
return { data }; params: { organization: organizationId },
});
}, },
enabled: () => !!getCurrentOrganizationId(), enabled: () => !!getCurrentOrganizationId(),
staleTime: 1000 * 30, // 30 seconds staleTime: 1000 * 30, // 30 seconds

View File

@@ -12,10 +12,6 @@ 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,9 +26,7 @@ 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({ queryClient.invalidateQueries({ queryKey: ['projectMembers', projectId] });
queryKey: ['projectMembers', organization, projectId],
});
} }
} }
@@ -51,7 +49,7 @@ export const useProjectMembersStore = defineStore('project-members', () => {
); );
if (response?.data?.project_id) { if (response?.data?.project_id) {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ['projectMembers', organization, response.data.project_id], queryKey: ['projectMembers', response.data.project_id],
}); });
} }
} }
@@ -71,9 +69,7 @@ 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({ queryClient.invalidateQueries({ queryKey: ['projectMembers', projectId] });
queryKey: ['projectMembers', organizationId, projectId],
});
} }
} }

View File

@@ -3,19 +3,6 @@ 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();
@@ -25,17 +12,14 @@ export function useProjectMembersQuery(projectId: Ref<string | null> | string) {
}); });
const query = useQuery({ const query = useQuery({
queryKey: computed(() => [ queryKey: ['projectMembers', projectIdValue],
'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');
const data = await fetchAllProjectMembers(organizationId, pid); return api.getProjectMembers({
return { data }; params: { organization: organizationId, project: pid },
});
}, },
enabled: () => !!getCurrentOrganizationId() && !!projectIdValue.value, enabled: () => !!getCurrentOrganizationId() && !!projectIdValue.value,
staleTime: 1000 * 30, // 30 seconds staleTime: 1000 * 30, // 30 seconds
@@ -44,9 +28,7 @@ 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({ queryClient.invalidateQueries({ queryKey: ['projectMembers', projectIdValue.value] });
queryKey: ['projectMembers', getCurrentOrganizationId(), projectIdValue.value],
});
}; };
return { return {

View File

@@ -3,27 +3,19 @@ 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: computed(() => ['projects', getCurrentOrganizationId()]), queryKey: ['projects'],
queryFn: async () => { queryFn: async () => {
const organizationId = getCurrentOrganizationId(); const organizationId = getCurrentOrganizationId();
if (!organizationId) throw new Error('No organization'); if (!organizationId) throw new Error('No organization');
const data = await fetchAllProjects(organizationId); return api.getProjects({
return { data }; params: { organization: organizationId },
queries: { archived: 'all' },
});
}, },
enabled: () => !!getCurrentOrganizationId(), enabled: () => !!getCurrentOrganizationId(),
staleTime: 1000 * 30, // 30 seconds staleTime: 1000 * 30, // 30 seconds

View File

@@ -1,12 +0,0 @@
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,27 +3,18 @@ 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: computed(() => ['tags', getCurrentOrganizationId()]), queryKey: ['tags'],
queryFn: async () => { queryFn: async () => {
const organizationId = getCurrentOrganizationId(); const organizationId = getCurrentOrganizationId();
if (!organizationId) throw new Error('No organization'); if (!organizationId) throw new Error('No organization');
const data = await fetchAllTags(organizationId); return api.getTags({
return { data }; params: { organization: organizationId },
});
}, },
enabled: () => !!getCurrentOrganizationId(), enabled: () => !!getCurrentOrganizationId(),
staleTime: 1000 * 30, // 30 seconds staleTime: 1000 * 30, // 30 seconds

View File

@@ -3,27 +3,19 @@ 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: computed(() => ['tasks', getCurrentOrganizationId()]), queryKey: ['tasks'],
queryFn: async () => { queryFn: async () => {
const organizationId = getCurrentOrganizationId(); const organizationId = getCurrentOrganizationId();
if (!organizationId) throw new Error('No organization'); if (!organizationId) throw new Error('No organization');
const data = await fetchAllTasks(organizationId); return api.getTasks({
return { data }; params: { organization: organizationId },
queries: { done: 'all' },
});
}, },
enabled: () => !!getCurrentOrganizationId(), enabled: () => !!getCurrentOrganizationId(),
staleTime: 1000 * 30, // 30 seconds staleTime: 1000 * 30, // 30 seconds

View File

@@ -21,7 +21,6 @@ export function useTimeEntriesInfiniteQuery() {
const queries: Record<string, string | undefined> = { const queries: Record<string, string | undefined> = {
only_full_dates: 'true', only_full_dates: 'true',
member_id: memberId.value, member_id: memberId.value,
limit: '50',
}; };
if (pageParam) { if (pageParam) {

View File

@@ -7,12 +7,7 @@ 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(() => [ queryKey: computed(() => ['timeEntries', 'detailed-report', unref(filterParams)]),
'timeEntries',
'detailed-report',
getCurrentOrganizationId(),
unref(filterParams),
]),
enabled: computed(() => !!getCurrentOrganizationId()), enabled: computed(() => !!getCurrentOrganizationId()),
queryFn: () => queryFn: () =>
api.getTimeEntries({ api.getTimeEntries({

View File

@@ -2,7 +2,7 @@
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}"> <html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title inertia>{{ config('app.name', 'Laravel') }}</title> <title inertia>{{ config('app.name', 'Laravel') }}</title>

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