mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-19 13:42:14 +01:00
Compare commits
80 Commits
feature/up
...
99aa7ed450
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99aa7ed450 | ||
|
|
f582adab0d | ||
|
|
c60cff04ce | ||
|
|
cae41e4b4f | ||
|
|
8973be9dab | ||
|
|
2a0b8d31e6 | ||
|
|
d2f3fe411a | ||
|
|
f880f9f730 | ||
|
|
556bbedeca | ||
|
|
eed638d0aa | ||
|
|
864f41bda6 | ||
|
|
26524c5f40 | ||
|
|
cf98fabe0a | ||
|
|
88c0c334e9 | ||
|
|
0fc325363d | ||
|
|
1afc16573a | ||
|
|
147514a606 | ||
|
|
435522b502 | ||
|
|
f1d001e03e | ||
|
|
7f145cf1c2 | ||
|
|
b579ed1075 | ||
|
|
ed2b7476ae | ||
|
|
8107c6a208 | ||
|
|
6dc517e07d | ||
|
|
2c60d04ba4 | ||
|
|
2c222f3f67 | ||
|
|
c5c1a7af13 | ||
|
|
22cf7cf74d | ||
|
|
cfbfbd4b6a | ||
|
|
6629482a0e | ||
|
|
38457cae4d | ||
|
|
0e63ecb520 | ||
|
|
6f207a4926 | ||
|
|
052424a581 | ||
|
|
b258717211 | ||
|
|
685cc29282 | ||
|
|
c78c681ec4 | ||
|
|
2d9f33387e | ||
|
|
b68d68a2a2 | ||
|
|
a9e03f3b29 | ||
|
|
474b294a18 | ||
|
|
334a98016f | ||
|
|
8be55359ce | ||
|
|
e45662c715 | ||
|
|
f3217baed1 | ||
|
|
562ee234a8 | ||
|
|
15e61e9789 | ||
|
|
125f6f062f | ||
|
|
f75a19bccd | ||
|
|
c17d87b710 | ||
|
|
a154293348 | ||
|
|
9832c688fe | ||
|
|
6804eb098d | ||
|
|
531443f0df | ||
|
|
bd2d57dfd1 | ||
|
|
73c92fad47 | ||
|
|
537a023ab9 | ||
|
|
28fc324c6a | ||
|
|
9379c191be | ||
|
|
ff06d4d2f3 | ||
|
|
7efb7e6071 | ||
|
|
b2af9c6bf1 | ||
|
|
73b4d66386 | ||
|
|
cb7baef0ba | ||
|
|
dd75a80df7 | ||
|
|
bc562bf76f | ||
|
|
756b423295 | ||
|
|
3707f2469c | ||
|
|
c6c1434430 | ||
|
|
70b78e41c3 | ||
|
|
8c16302f17 | ||
|
|
bfc369794e | ||
|
|
3c2ea0e645 | ||
|
|
b0d28f2f6d | ||
|
|
6555bca5f1 | ||
|
|
81d9561656 | ||
|
|
0a6bde8bc6 | ||
|
|
51af3db305 | ||
|
|
f242ce48b5 | ||
|
|
19064cdc3d |
4
.github/workflows/playwright.yml
vendored
4
.github/workflows/playwright.yml
vendored
@@ -9,8 +9,8 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
shardIndex: [1, 2, 3, 4]
|
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||||
shardTotal: [4]
|
shardTotal: [8]
|
||||||
|
|
||||||
services:
|
services:
|
||||||
mailpit:
|
mailpit:
|
||||||
|
|||||||
@@ -4,18 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Actions\Jetstream;
|
namespace App\Actions\Jetstream;
|
||||||
|
|
||||||
use App\Enums\Role;
|
use App\Exceptions\MovedToApiException;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Service\MemberService;
|
|
||||||
use Closure;
|
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
use Illuminate\Support\Facades\Validator;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
use Illuminate\Validation\Rules\In;
|
|
||||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
|
||||||
use Laravel\Jetstream\Contracts\AddsTeamMembers;
|
use Laravel\Jetstream\Contracts\AddsTeamMembers;
|
||||||
|
|
||||||
class AddOrganizationMember implements AddsTeamMembers
|
class AddOrganizationMember implements AddsTeamMembers
|
||||||
@@ -25,70 +16,6 @@ class AddOrganizationMember implements AddsTeamMembers
|
|||||||
*/
|
*/
|
||||||
public function add(User $owner, Organization $organization, string $email, ?string $role = null): void
|
public function add(User $owner, Organization $organization, string $email, ?string $role = null): void
|
||||||
{
|
{
|
||||||
Gate::forUser($owner)->authorize('addTeamMember', $organization); // TODO: refactor after owner refactoring
|
throw new MovedToApiException;
|
||||||
|
|
||||||
$this->validate($organization, $email, $role);
|
|
||||||
|
|
||||||
$newOrganizationMember = User::query()
|
|
||||||
->where('email', $email)
|
|
||||||
->where('is_placeholder', '=', false)
|
|
||||||
->firstOrFail();
|
|
||||||
|
|
||||||
app(MemberService::class)->addMember($newOrganizationMember, $organization, Role::from($role));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate the add member operation.
|
|
||||||
*/
|
|
||||||
protected function validate(Organization $organization, string $email, ?string $role): void
|
|
||||||
{
|
|
||||||
Validator::make([
|
|
||||||
'email' => $email,
|
|
||||||
'role' => $role,
|
|
||||||
], $this->rules())->after(
|
|
||||||
$this->ensureUserIsNotAlreadyOnTeam($organization, $email)
|
|
||||||
)->validateWithBag('addTeamMember');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the validation rules for adding a team member.
|
|
||||||
*
|
|
||||||
* @return array<string, array<ValidationRule|Rule|string|In>>
|
|
||||||
*/
|
|
||||||
protected function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'email' => [
|
|
||||||
'required',
|
|
||||||
'email',
|
|
||||||
ExistsEloquent::make(User::class, 'email', function (Builder $builder) {
|
|
||||||
/** @var Builder<User> $builder */
|
|
||||||
return $builder->where('is_placeholder', '=', false);
|
|
||||||
})->withMessage(__('We were unable to find a registered user with this email address.')),
|
|
||||||
],
|
|
||||||
'role' => [
|
|
||||||
'required',
|
|
||||||
'string',
|
|
||||||
Rule::in([
|
|
||||||
Role::Admin->value,
|
|
||||||
Role::Manager->value,
|
|
||||||
Role::Employee->value,
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure that the user is not already on the team.
|
|
||||||
*/
|
|
||||||
protected function ensureUserIsNotAlreadyOnTeam(Organization $team, string $email): Closure
|
|
||||||
{
|
|
||||||
return function ($validator) use ($team, $email): void {
|
|
||||||
$validator->errors()->addIf(
|
|
||||||
$team->hasRealUserWithEmail($email),
|
|
||||||
'email',
|
|
||||||
__('This user already belongs to the team.')
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ class CreateOrganization implements CreatesTeams
|
|||||||
*
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
* @throws ValidationException
|
* @throws ValidationException
|
||||||
|
*
|
||||||
|
* @deprecated Use REST endpoint instead
|
||||||
*/
|
*/
|
||||||
public function create(User $user, array $input): Organization
|
public function create(User $user, array $input): Organization
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ class DeleteOrganization implements DeletesTeams
|
|||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Delete the given team.
|
* Delete the given team.
|
||||||
|
*
|
||||||
|
* @deprecated Use REST endpoint instead
|
||||||
*/
|
*/
|
||||||
public function delete(Organization $organization): void
|
public function delete(Organization $organization): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ class DeleteUser implements DeletesUsers
|
|||||||
* Delete the given user.
|
* Delete the given user.
|
||||||
*
|
*
|
||||||
* @throws ValidationException
|
* @throws ValidationException
|
||||||
|
*
|
||||||
|
* @deprecated Use REST endpoint instead
|
||||||
*/
|
*/
|
||||||
public function delete(User $user): void
|
public function delete(User $user): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ class ValidateOrganizationDeletion
|
|||||||
* @param Organization $organization Organization to be deleted
|
* @param Organization $organization Organization to be deleted
|
||||||
*
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
|
*
|
||||||
|
* @deprecated Use REST endpoint instead
|
||||||
*/
|
*/
|
||||||
public function validate(User $user, Organization $organization): void
|
public function validate(User $user, Organization $organization): void
|
||||||
{
|
{
|
||||||
|
|||||||
28
app/Events/MemberAdded.php
Normal file
28
app/Events/MemberAdded.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
|
||||||
|
class MemberAdded
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
|
||||||
|
public Member $member;
|
||||||
|
|
||||||
|
public Organization $organization;
|
||||||
|
|
||||||
|
public User $user;
|
||||||
|
|
||||||
|
public function __construct(Member $member, Organization $organization, User $user)
|
||||||
|
{
|
||||||
|
$this->member = $member;
|
||||||
|
$this->organization = $organization;
|
||||||
|
$this->user = $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Events/MemberAdding.php
Normal file
28
app/Events/MemberAdding.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use App\Enums\Role;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
|
||||||
|
class MemberAdding
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
|
||||||
|
public User $user;
|
||||||
|
|
||||||
|
public Organization $organization;
|
||||||
|
|
||||||
|
public Role $role;
|
||||||
|
|
||||||
|
public function __construct(User $user, Organization $organization, Role $role)
|
||||||
|
{
|
||||||
|
$this->user = $user;
|
||||||
|
$this->organization = $organization;
|
||||||
|
$this->role = $role;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ class ApiTokenController extends Controller
|
|||||||
/** @var Builder<Client> $query */
|
/** @var Builder<Client> $query */
|
||||||
$query->whereJsonContains('grant_types', 'personal_access');
|
$query->whereJsonContains('grant_types', 'personal_access');
|
||||||
})
|
})
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
return new ApiTokenCollection($tokens);
|
return new ApiTokenCollection($tokens);
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class InvitationController extends Controller
|
|||||||
$this->checkPermission($organization, 'invitations:view');
|
$this->checkPermission($organization, 'invitations:view');
|
||||||
|
|
||||||
$invitations = $organization->teamInvitations()
|
$invitations = $organization->teamInvitations()
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
return InvitationCollection::make($invitations);
|
return InvitationCollection::make($invitations);
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ class MemberController extends Controller
|
|||||||
$members = Member::query()
|
$members = Member::query()
|
||||||
->whereBelongsTo($organization, 'organization')
|
->whereBelongsTo($organization, 'organization')
|
||||||
->with(['user'])
|
->with(['user'])
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
return MemberCollection::make($members);
|
return MemberCollection::make($members);
|
||||||
|
|||||||
@@ -5,11 +5,17 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
|
use App\Events\AfterCreateOrganization;
|
||||||
|
use App\Http\Requests\V1\Organization\OrganizationStoreRequest;
|
||||||
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
|
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
|
||||||
use App\Http\Resources\V1\Organization\OrganizationResource;
|
use App\Http\Resources\V1\Organization\OrganizationResource;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Service\BillableRateService;
|
use App\Service\BillableRateService;
|
||||||
|
use App\Service\DeletionService;
|
||||||
|
use App\Service\IpLookup\IpLookupServiceContract;
|
||||||
|
use App\Service\OrganizationService;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
class OrganizationController extends Controller
|
class OrganizationController extends Controller
|
||||||
{
|
{
|
||||||
@@ -80,4 +86,48 @@ class OrganizationController extends Controller
|
|||||||
|
|
||||||
return new OrganizationResource($organization, true);
|
return new OrganizationResource($organization, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create organization
|
||||||
|
*
|
||||||
|
* @operationId createOrganization
|
||||||
|
*/
|
||||||
|
public function store(OrganizationStoreRequest $request, OrganizationService $organizationService): OrganizationResource
|
||||||
|
{
|
||||||
|
$user = $this->user();
|
||||||
|
$ipLookupResponse = app(IpLookupServiceContract::class)->lookup($request->ip());
|
||||||
|
|
||||||
|
$currency = $ipLookupResponse?->currency;
|
||||||
|
|
||||||
|
$organization = $organizationService->createOrganization(
|
||||||
|
$request->getName(),
|
||||||
|
$user,
|
||||||
|
false,
|
||||||
|
$currency
|
||||||
|
);
|
||||||
|
|
||||||
|
$user->switchTeam($organization);
|
||||||
|
|
||||||
|
// Note: The refresh is necessary for currently unknown reasons. Do not remove it.
|
||||||
|
$organization = $organization->refresh();
|
||||||
|
AfterCreateOrganization::dispatch($organization);
|
||||||
|
|
||||||
|
return new OrganizationResource($organization, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete organization
|
||||||
|
*
|
||||||
|
* @operationId deleteOrganization
|
||||||
|
*
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*/
|
||||||
|
public function destroy(Organization $organization, DeletionService $deletionService): JsonResponse
|
||||||
|
{
|
||||||
|
$this->checkPermission($organization, 'organizations:delete');
|
||||||
|
|
||||||
|
$deletionService->deleteOrganization($organization);
|
||||||
|
|
||||||
|
return response()->json(null, 204);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,9 @@ class ProjectController extends Controller
|
|||||||
$projectsQuery->whereNull('archived_at');
|
$projectsQuery->whereNull('archived_at');
|
||||||
}
|
}
|
||||||
|
|
||||||
$projects = $projectsQuery->paginate(config('app.pagination_per_page_default'));
|
$projects = $projectsQuery
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api\V1;
|
|||||||
|
|
||||||
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
|
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
|
||||||
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
|
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
|
||||||
|
use App\Http\Requests\V1\ProjectMember\ProjectMemberIndexRequest;
|
||||||
use App\Http\Requests\V1\ProjectMember\ProjectMemberStoreRequest;
|
use App\Http\Requests\V1\ProjectMember\ProjectMemberStoreRequest;
|
||||||
use App\Http\Requests\V1\ProjectMember\ProjectMemberUpdateRequest;
|
use App\Http\Requests\V1\ProjectMember\ProjectMemberUpdateRequest;
|
||||||
use App\Http\Resources\V1\ProjectMember\ProjectMemberCollection;
|
use App\Http\Resources\V1\ProjectMember\ProjectMemberCollection;
|
||||||
@@ -41,12 +42,13 @@ class ProjectMemberController extends Controller
|
|||||||
*
|
*
|
||||||
* @operationId getProjectMembers
|
* @operationId getProjectMembers
|
||||||
*/
|
*/
|
||||||
public function index(Organization $organization, Project $project): ProjectMemberCollection
|
public function index(Organization $organization, Project $project, ProjectMemberIndexRequest $request): ProjectMemberCollection
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'project-members:view', $project);
|
$this->checkPermission($organization, 'project-members:view', $project);
|
||||||
|
|
||||||
$projectMembers = ProjectMember::query()
|
$projectMembers = ProjectMember::query()
|
||||||
->whereBelongsTo($project, 'project')
|
->whereBelongsTo($project, 'project')
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
return new ProjectMemberCollection($projectMembers);
|
return new ProjectMemberCollection($projectMembers);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
|
use App\Http\Requests\V1\Report\ReportIndexRequest;
|
||||||
use App\Http\Requests\V1\Report\ReportStoreRequest;
|
use App\Http\Requests\V1\Report\ReportStoreRequest;
|
||||||
use App\Http\Requests\V1\Report\ReportUpdateRequest;
|
use App\Http\Requests\V1\Report\ReportUpdateRequest;
|
||||||
use App\Http\Resources\V1\Report\DetailedReportResource;
|
use App\Http\Resources\V1\Report\DetailedReportResource;
|
||||||
@@ -40,7 +41,7 @@ class ReportController extends Controller
|
|||||||
*
|
*
|
||||||
* @operationId getReports
|
* @operationId getReports
|
||||||
*/
|
*/
|
||||||
public function index(Organization $organization): ReportCollection
|
public function index(Organization $organization, ReportIndexRequest $request): ReportCollection
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'reports:view');
|
$this->checkPermission($organization, 'reports:view');
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||||
|
use App\Http\Requests\V1\Tag\TagIndexRequest;
|
||||||
use App\Http\Requests\V1\Tag\TagStoreRequest;
|
use App\Http\Requests\V1\Tag\TagStoreRequest;
|
||||||
use App\Http\Requests\V1\Tag\TagUpdateRequest;
|
use App\Http\Requests\V1\Tag\TagUpdateRequest;
|
||||||
use App\Http\Resources\V1\Tag\TagCollection;
|
use App\Http\Resources\V1\Tag\TagCollection;
|
||||||
@@ -34,7 +35,7 @@ class TagController extends Controller
|
|||||||
*
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*/
|
*/
|
||||||
public function index(Organization $organization): TagCollection
|
public function index(Organization $organization, TagIndexRequest $request): TagCollection
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'tags:view');
|
$this->checkPermission($organization, 'tags:view');
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,9 @@ class TaskController extends Controller
|
|||||||
$query->whereNull('done_at');
|
$query->whereNull('done_at');
|
||||||
}
|
}
|
||||||
|
|
||||||
$tasks = $query->paginate(config('app.pagination_per_page_default'));
|
$tasks = $query
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
return new TaskCollection($tasks);
|
return new TaskCollection($tasks);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
||||||
use App\Http\Resources\V1\User\UserResource;
|
use App\Http\Resources\V1\User\UserResource;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Service\DeletionService;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
class UserController extends Controller
|
class UserController extends Controller
|
||||||
{
|
{
|
||||||
@@ -24,4 +28,29 @@ class UserController extends Controller
|
|||||||
|
|
||||||
return new UserResource($user);
|
return new UserResource($user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the deletion of a user.
|
||||||
|
*
|
||||||
|
* This endpoint is independent of organization.
|
||||||
|
*
|
||||||
|
* @operationId deleteUser
|
||||||
|
*
|
||||||
|
* @param User $user The user instance to be deleted.
|
||||||
|
* @param DeletionService $deletionService The service responsible for performing the user deletion.
|
||||||
|
* @return JsonResponse A JSON response with a 204 No Content status upon successful deletion.
|
||||||
|
*
|
||||||
|
* @throws AuthorizationException Thrown when the authenticated user does not match the user to be deleted.
|
||||||
|
* @throws CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers Thrown when the user to be deleted is the owner of an organization with multiple members.
|
||||||
|
*/
|
||||||
|
public function destroy(User $user, DeletionService $deletionService): JsonResponse
|
||||||
|
{
|
||||||
|
if ($user->getKey() !== $this->user()->getKey()) {
|
||||||
|
throw new AuthorizationException;
|
||||||
|
}
|
||||||
|
|
||||||
|
$deletionService->deleteUser($user);
|
||||||
|
|
||||||
|
return response()->json(null, 204);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,30 +4,13 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Web;
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
use App\Enums\Role;
|
|
||||||
use App\Service\DashboardService;
|
|
||||||
use App\Service\PermissionStore;
|
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
class DashboardController extends Controller
|
class DashboardController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
public function dashboard(): Response
|
||||||
* @throws AuthorizationException
|
|
||||||
*/
|
|
||||||
public function dashboard(DashboardService $dashboardService, PermissionStore $permissionStore): Response
|
|
||||||
{
|
{
|
||||||
$user = $this->user();
|
|
||||||
$organization = $this->currentOrganization();
|
|
||||||
|
|
||||||
$latestTeamActivity = null;
|
|
||||||
if ($permissionStore->has($organization, 'time-entries:view:all')) {
|
|
||||||
$latestTeamActivity = $dashboardService->latestTeamActivity($organization);
|
|
||||||
}
|
|
||||||
|
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
|
||||||
|
|
||||||
return Inertia::render('Dashboard');
|
return Inertia::render('Dashboard');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Enums\Role;
|
||||||
|
use App\Models\OrganizationInvitation;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Service\MemberService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class OrganizationInvitationController extends Controller
|
||||||
|
{
|
||||||
|
public function accept(OrganizationInvitation $invitation, MemberService $memberService): RedirectResponse
|
||||||
|
{
|
||||||
|
$email = strtolower($invitation->email);
|
||||||
|
$role = Role::tryFrom($invitation->role);
|
||||||
|
if ($role === null || $role === Role::Owner || $role === Role::Placeholder) {
|
||||||
|
throw new RuntimeException('Invalid role');
|
||||||
|
}
|
||||||
|
|
||||||
|
$newOrganizationMember = User::query()
|
||||||
|
->where('email', $email)
|
||||||
|
->where('is_placeholder', '=', false)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($newOrganizationMember === null) {
|
||||||
|
if ($invitation->accepted_at === null) {
|
||||||
|
$invitation->accepted_at = now();
|
||||||
|
$invitation->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect(route('register', [
|
||||||
|
'bannerStyle' => 'info',
|
||||||
|
'bannerText' => __('Please create an account to finish joining the :organization organization.', [
|
||||||
|
'organization' => $invitation->organization->name,
|
||||||
|
]),
|
||||||
|
]));
|
||||||
|
} else {
|
||||||
|
$organization = $invitation->organization;
|
||||||
|
if ($memberService->isEmailAlreadyMember($organization, $email)) {
|
||||||
|
return redirect(route('dashboard', [
|
||||||
|
'bannerStyle' => 'danger',
|
||||||
|
'bannerText' => __('You are already a member of the :organization organization.', [
|
||||||
|
'organization' => $organization->name,
|
||||||
|
]),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
$memberService->addMember($newOrganizationMember, $organization, $role);
|
||||||
|
|
||||||
|
$invitation->delete();
|
||||||
|
|
||||||
|
return redirect(route('dashboard', [
|
||||||
|
'bannerStyle' => 'success',
|
||||||
|
'bannerText' => __('Great! You have accepted the invitation to join the :organization organization.', [
|
||||||
|
'organization' => $invitation->organization->name,
|
||||||
|
]),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,11 @@ class InvitationIndexRequest extends BaseFormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'page' => [
|
||||||
|
'integer',
|
||||||
|
'min:1',
|
||||||
|
'max:2147483647',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ class MemberIndexRequest extends BaseFormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'page' => [
|
||||||
|
'integer',
|
||||||
|
'min:1',
|
||||||
|
'max:2147483647',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\V1\Organization;
|
||||||
|
|
||||||
|
use App\Http\Requests\V1\BaseFormRequest;
|
||||||
|
use App\Models\Organization;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property Organization $organization Organization from model binding
|
||||||
|
*/
|
||||||
|
class OrganizationStoreRequest extends BaseFormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, array<string|\Illuminate\Contracts\Validation\Rule>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return (string) $this->input('name');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\V1\ProjectMember;
|
||||||
|
|
||||||
|
use App\Http\Requests\V1\BaseFormRequest;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
|
||||||
|
class ProjectMemberIndexRequest extends BaseFormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, array<string|ValidationRule>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'page' => [
|
||||||
|
'integer',
|
||||||
|
'min:1',
|
||||||
|
'max:2147483647',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Http/Requests/V1/Report/ReportIndexRequest.php
Normal file
27
app/Http/Requests/V1/Report/ReportIndexRequest.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\V1\Report;
|
||||||
|
|
||||||
|
use App\Http\Requests\V1\BaseFormRequest;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
|
||||||
|
class ReportIndexRequest extends BaseFormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, array<string|ValidationRule>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'page' => [
|
||||||
|
'integer',
|
||||||
|
'min:1',
|
||||||
|
'max:2147483647',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Http/Requests/V1/Tag/TagIndexRequest.php
Normal file
27
app/Http/Requests/V1/Tag/TagIndexRequest.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\V1\Tag;
|
||||||
|
|
||||||
|
use App\Http\Requests\V1\BaseFormRequest;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
|
||||||
|
class TagIndexRequest extends BaseFormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, array<string|ValidationRule>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'page' => [
|
||||||
|
'integer',
|
||||||
|
'min:1',
|
||||||
|
'max:2147483647',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,11 @@ class TaskIndexRequest extends BaseFormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'page' => [
|
||||||
|
'integer',
|
||||||
|
'min:1',
|
||||||
|
'max:2147483647',
|
||||||
|
],
|
||||||
'project_id' => [
|
'project_id' => [
|
||||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Project> $builder */
|
/** @var Builder<Project> $builder */
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Resources\V1\Client;
|
namespace App\Http\Resources\V1\Client;
|
||||||
|
|
||||||
|
use App\Http\Resources\PaginatedResourceCollection;
|
||||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||||
|
|
||||||
class ClientCollection extends ResourceCollection
|
class ClientCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The resource that this resource collects.
|
* The resource that this resource collects.
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Resources\V1\Tag;
|
namespace App\Http\Resources\V1\Tag;
|
||||||
|
|
||||||
|
use App\Http\Resources\PaginatedResourceCollection;
|
||||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||||
|
|
||||||
class TagCollection extends ResourceCollection
|
class TagCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The resource that this resource collects.
|
* The resource that this resource collects.
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Listeners;
|
|
||||||
|
|
||||||
use App\Models\Member;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Service\MemberService;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Laravel\Jetstream\Events\TeamMemberAdded;
|
|
||||||
|
|
||||||
class RemovePlaceholder
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Handle the event.
|
|
||||||
*/
|
|
||||||
public function handle(TeamMemberAdded $event): void
|
|
||||||
{
|
|
||||||
$memberService = app(MemberService::class);
|
|
||||||
$member = Member::query()
|
|
||||||
->whereBelongsTo($event->team, 'organization')
|
|
||||||
->whereBelongsTo($event->user, 'user')
|
|
||||||
->firstOrFail();
|
|
||||||
$placeholders = Member::query()
|
|
||||||
->whereHas('user', function (Builder $query) use ($event): void {
|
|
||||||
/** @var Builder<User> $query */
|
|
||||||
$query->where('is_placeholder', '=', true)
|
|
||||||
->where('email', '=', $event->user->email);
|
|
||||||
})
|
|
||||||
->whereBelongsTo($event->team, 'organization')
|
|
||||||
->with(['user'])
|
|
||||||
->get();
|
|
||||||
|
|
||||||
foreach ($placeholders as $placeholder) {
|
|
||||||
/** @var Member $placeholder */
|
|
||||||
$placeholderUser = $placeholder->user;
|
|
||||||
$memberService->assignOrganizationEntitiesToDifferentMember($event->team, $placeholder, $member);
|
|
||||||
$placeholder->delete();
|
|
||||||
$placeholderUser->delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,6 +8,7 @@ use App\Models\OrganizationInvitation;
|
|||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Mail\Mailable;
|
use Illuminate\Mail\Mailable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
|
|
||||||
class OrganizationInvitationMail extends Mailable
|
class OrganizationInvitationMail extends Mailable
|
||||||
@@ -32,9 +33,12 @@ class OrganizationInvitationMail extends Mailable
|
|||||||
public function build(): self
|
public function build(): self
|
||||||
{
|
{
|
||||||
return $this->markdown('emails.organization-invitation', [
|
return $this->markdown('emails.organization-invitation', [
|
||||||
'acceptUrl' => URL::signedRoute('team-invitations.accept', [
|
'acceptUrl' => URL::to(URL::signedRoute(
|
||||||
'invitation' => $this->invitation,
|
'organization-invitations.accept',
|
||||||
]),
|
['invitation' => $this->invitation->getKey()],
|
||||||
|
Carbon::now()->addDays(90),
|
||||||
|
false
|
||||||
|
)),
|
||||||
])->subject(__('Organization Invitation'));
|
])->subject(__('Organization Invitation'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
* @property string $user_id
|
* @property string $user_id
|
||||||
* @property bool $employees_can_see_billable_rates
|
* @property bool $employees_can_see_billable_rates
|
||||||
* @property bool $employees_can_manage_tasks
|
* @property bool $employees_can_manage_tasks
|
||||||
|
* @property bool $prevent_overlapping_time_entries
|
||||||
* @property User $owner
|
* @property User $owner
|
||||||
* @property Carbon|null $created_at
|
* @property Carbon|null $created_at
|
||||||
* @property Carbon|null $updated_at
|
* @property Carbon|null $updated_at
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
* @property string $email
|
* @property string $email
|
||||||
* @property string $role
|
* @property string $role
|
||||||
* @property string $organization_id
|
* @property string $organization_id
|
||||||
|
* @property Carbon|null $accepted_at
|
||||||
* @property Carbon|null $updated_at
|
* @property Carbon|null $updated_at
|
||||||
* @property Carbon|null $created_at
|
* @property Carbon|null $created_at
|
||||||
* @property-read Organization $organization
|
* @property-read Organization $organization
|
||||||
@@ -41,14 +42,16 @@ class OrganizationInvitation extends JetstreamTeamInvitation implements Auditabl
|
|||||||
protected $table = 'organization_invitations';
|
protected $table = 'organization_invitations';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* Get the attributes that should be cast.
|
||||||
*
|
*
|
||||||
* @var array<int, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
protected $fillable = [
|
public function casts(): array
|
||||||
'email',
|
{
|
||||||
'role',
|
return [
|
||||||
|
'accepted_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the organization that the invitation belongs to.
|
* Get the organization that the invitation belongs to.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\Policies;
|
|||||||
|
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Service\PermissionStore;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||||
|
|
||||||
@@ -58,19 +59,7 @@ class OrganizationPolicy
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $user->ownsTeam($organization);
|
return app(PermissionStore::class)->userHas($organization, $user, 'organizations:update');
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine whether the user can add team members.
|
|
||||||
*/
|
|
||||||
public function addTeamMember(User $user, Organization $organization): bool
|
|
||||||
{
|
|
||||||
if (Filament::isServing()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,11 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Listeners\RemovePlaceholder;
|
|
||||||
use Illuminate\Auth\Events\Registered;
|
use Illuminate\Auth\Events\Registered;
|
||||||
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
||||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
||||||
use Laravel\Jetstream\Events\TeamMemberAdded;
|
|
||||||
|
|
||||||
class EventServiceProvider extends ServiceProvider
|
class EventServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -21,9 +19,6 @@ class EventServiceProvider extends ServiceProvider
|
|||||||
Registered::class => [
|
Registered::class => [
|
||||||
SendEmailVerificationNotification::class,
|
SendEmailVerificationNotification::class,
|
||||||
],
|
],
|
||||||
TeamMemberAdded::class => [
|
|
||||||
RemovePlaceholder::class,
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ use App\Enums\Role;
|
|||||||
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
|
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
|
||||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||||
use App\Mail\OrganizationInvitationMail;
|
use App\Mail\OrganizationInvitationMail;
|
||||||
use App\Models\Member;
|
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\OrganizationInvitation;
|
use App\Models\OrganizationInvitation;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
use Laravel\Jetstream\Events\InvitingTeamMember;
|
use Laravel\Jetstream\Events\InvitingTeamMember;
|
||||||
|
|
||||||
@@ -21,11 +23,7 @@ class InvitationService
|
|||||||
*/
|
*/
|
||||||
public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation
|
public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation
|
||||||
{
|
{
|
||||||
if (Member::query()
|
if (app(MemberService::class)->isEmailAlreadyMember($organization, $email)) {
|
||||||
->whereBelongsTo($organization, 'organization')
|
|
||||||
->whereRelation('user', 'email', '=', $email)
|
|
||||||
->where('role', '!=', Role::Placeholder->value)
|
|
||||||
->exists()) {
|
|
||||||
throw new UserIsAlreadyMemberOfOrganizationApiException;
|
throw new UserIsAlreadyMemberOfOrganizationApiException;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,4 +46,37 @@ class InvitationService
|
|||||||
|
|
||||||
return $invitation;
|
return $invitation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, Organization>
|
||||||
|
*/
|
||||||
|
public function processAcceptedInvitations(User $user): Collection
|
||||||
|
{
|
||||||
|
$organizations = new Collection;
|
||||||
|
|
||||||
|
$invitations = OrganizationInvitation::query()
|
||||||
|
->where('email', $user->email)
|
||||||
|
->whereNotNull('accepted_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($invitations as $invitation) {
|
||||||
|
$organization = $invitation->organization;
|
||||||
|
$role = Role::tryFrom($invitation->role);
|
||||||
|
if ($role === null) {
|
||||||
|
Log::error('Invalid role in invitation', [
|
||||||
|
'invitation' => $invitation->getKey(),
|
||||||
|
'role' => $invitation->role,
|
||||||
|
]);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
app(MemberService::class)->addMember($user, $organization, $role);
|
||||||
|
|
||||||
|
$invitation->delete();
|
||||||
|
|
||||||
|
$organizations->push($organization);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $organizations;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
|||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
|
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
|
use App\Events\MemberAdded;
|
||||||
|
use App\Events\MemberAdding;
|
||||||
use App\Events\MemberRemoved;
|
use App\Events\MemberRemoved;
|
||||||
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
||||||
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
|
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
|
||||||
@@ -36,7 +38,8 @@ class MemberService
|
|||||||
public function addMember(User $user, Organization $organization, Role $role, bool $asSuperAdmin = false): Member
|
public function addMember(User $user, Organization $organization, Role $role, bool $asSuperAdmin = false): Member
|
||||||
{
|
{
|
||||||
if (! $asSuperAdmin) {
|
if (! $asSuperAdmin) {
|
||||||
AddingTeamMember::dispatch($organization, $user);
|
MemberAdding::dispatch($user, $organization, $role);
|
||||||
|
AddingTeamMember::dispatch($organization, $user); // Legacy event
|
||||||
}
|
}
|
||||||
|
|
||||||
$member = new Member;
|
$member = new Member;
|
||||||
@@ -49,14 +52,37 @@ class MemberService
|
|||||||
$user->currentOrganization()->associate($organization);
|
$user->currentOrganization()->associate($organization);
|
||||||
$user->save();
|
$user->save();
|
||||||
});
|
});
|
||||||
|
$this->mergePlaceholderMembersIntoExistingMember($member, $organization, $user);
|
||||||
|
|
||||||
if (! $asSuperAdmin) {
|
if (! $asSuperAdmin) {
|
||||||
TeamMemberAdded::dispatch($organization, $user);
|
MemberAdded::dispatch($member, $organization, $user);
|
||||||
|
TeamMemberAdded::dispatch($organization, $user); // Legacy event
|
||||||
}
|
}
|
||||||
|
|
||||||
return $member;
|
return $member;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function mergePlaceholderMembersIntoExistingMember(Member $member, Organization $organization, User $user): void
|
||||||
|
{
|
||||||
|
$placeholders = Member::query()
|
||||||
|
->whereHas('user', function (Builder $query) use ($user): void {
|
||||||
|
/** @var Builder<User> $query */
|
||||||
|
$query->where('is_placeholder', '=', true)
|
||||||
|
->where('email', '=', $user->email);
|
||||||
|
})
|
||||||
|
->whereBelongsTo($organization, 'organization')
|
||||||
|
->with(['user'])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($placeholders as $placeholder) {
|
||||||
|
/** @var Member $placeholder */
|
||||||
|
$placeholderUser = $placeholder->user;
|
||||||
|
$this->assignOrganizationEntitiesToDifferentMember($organization, $placeholder, $member);
|
||||||
|
$placeholder->delete();
|
||||||
|
$placeholderUser->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws CanNotRemoveOwnerFromOrganization
|
* @throws CanNotRemoveOwnerFromOrganization
|
||||||
* @throws EntityStillInUseApiException
|
* @throws EntityStillInUseApiException
|
||||||
@@ -196,6 +222,7 @@ 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);
|
||||||
@@ -208,4 +235,13 @@ class MemberService
|
|||||||
$this->userService->makeSureUserHasCurrentOrganization($user);
|
$this->userService->makeSureUserHasCurrentOrganization($user);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isEmailAlreadyMember(Organization $organization, string $email): bool
|
||||||
|
{
|
||||||
|
return Member::query()
|
||||||
|
->whereBelongsTo($organization, 'organization')
|
||||||
|
->whereRelation('user', 'email', '=', $email)
|
||||||
|
->where('role', '!=', Role::Placeholder->value)
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,12 +31,17 @@ 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.', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
|
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$start.')';
|
||||||
} elseif ($roundingType === TimeEntryRoundingType::Up) {
|
} elseif ($roundingType === TimeEntryRoundingType::Up) {
|
||||||
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
|
// If end is already on a boundary, keep it; otherwise round up to next boundary
|
||||||
|
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\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
|
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$start.')';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class UserService
|
|||||||
): User {
|
): User {
|
||||||
$user = new User;
|
$user = new User;
|
||||||
$user->name = $name;
|
$user->name = $name;
|
||||||
$user->email = $email;
|
$user->email = strtolower($email);
|
||||||
$user->password = Hash::make($password);
|
$user->password = Hash::make($password);
|
||||||
$user->timezone = $timezone;
|
$user->timezone = $timezone;
|
||||||
$user->week_start = $weekStart;
|
$user->week_start = $weekStart;
|
||||||
@@ -47,6 +47,9 @@ class UserService
|
|||||||
}
|
}
|
||||||
$user->save();
|
$user->save();
|
||||||
|
|
||||||
|
$organizations = app(InvitationService::class)->processAcceptedInvitations($user);
|
||||||
|
|
||||||
|
if ($organizations->isEmpty()) {
|
||||||
$organization = app(OrganizationService::class)->createOrganization(
|
$organization = app(OrganizationService::class)->createOrganization(
|
||||||
$this->getOrganizationNameForUserName($user->name),
|
$this->getOrganizationNameForUserName($user->name),
|
||||||
$user,
|
$user,
|
||||||
@@ -58,8 +61,8 @@ class UserService
|
|||||||
$intervalFormat,
|
$intervalFormat,
|
||||||
$timeFormat,
|
$timeFormat,
|
||||||
);
|
);
|
||||||
|
|
||||||
$user->ownedTeams()->save($organization);
|
$user->ownedTeams()->save($organization);
|
||||||
|
}
|
||||||
|
|
||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|||||||
1562
composer.lock
generated
1562
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -25,9 +25,24 @@ class OrganizationInvitationFactory extends Factory
|
|||||||
'email' => $this->faker->unique()->safeEmail(),
|
'email' => $this->faker->unique()->safeEmail(),
|
||||||
'role' => Role::Employee->value,
|
'role' => Role::Employee->value,
|
||||||
'organization_id' => Organization::factory(),
|
'organization_id' => Organization::factory(),
|
||||||
|
'accepted_at' => null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function role(Role $role): self
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes) => [
|
||||||
|
'role' => $role->value,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function accepted(): self
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes): array => [
|
||||||
|
'accepted_at' => $this->faker->dateTime(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function forOrganization(Organization $organization): self
|
public function forOrganization(Organization $organization): self
|
||||||
{
|
{
|
||||||
return $this->state(fn (array $attributes) => [
|
return $this->state(fn (array $attributes) => [
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('organization_invitations', function (Blueprint $table): void {
|
||||||
|
$table->timestamp('accepted_at')->nullable()->after('email');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('organization_invitations', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('accepted_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
172
e2e/calendar-settings.spec.ts
Normal file
172
e2e/calendar-settings.spec.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
createProjectViaApi,
|
createProjectViaApi,
|
||||||
createPublicProjectViaApi,
|
createPublicProjectViaApi,
|
||||||
} from './utils/api';
|
} from './utils/api';
|
||||||
|
import { getTableRowNames } from './utils/table';
|
||||||
|
|
||||||
async function goToClientsOverview(page: Page) {
|
async function goToClientsOverview(page: Page) {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/clients');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/clients');
|
||||||
@@ -131,6 +132,92 @@ test('test that deleting a client via actions menu works', async ({ page, ctx })
|
|||||||
await expect(page.getByTestId('client_table')).not.toContainText(clientName);
|
await expect(page.getByTestId('client_table')).not.toContainText(clientName);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Sorting Tests
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
async function clearClientTableState(page: Page) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
localStorage.removeItem('client-table-state');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('test that sorting clients by name and status works', async ({ page, ctx }) => {
|
||||||
|
await createClientViaApi(ctx, { name: 'AAA SortClient' });
|
||||||
|
await createClientViaApi(ctx, { name: 'ZZZ SortClient' });
|
||||||
|
|
||||||
|
await goToClientsOverview(page);
|
||||||
|
await clearClientTableState(page);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const table = page.getByTestId('client_table');
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
|
||||||
|
// -- Name sorting (default is name asc) --
|
||||||
|
let names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('AAA SortClient')).toBeLessThan(names.indexOf('ZZZ SortClient'));
|
||||||
|
|
||||||
|
const nameHeader = table.getByText('Name').first();
|
||||||
|
await nameHeader.click(); // toggle to desc
|
||||||
|
names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('ZZZ SortClient')).toBeLessThan(names.indexOf('AAA SortClient'));
|
||||||
|
|
||||||
|
// -- Status sorting --
|
||||||
|
const statusHeader = table.getByText('Status').first();
|
||||||
|
await statusHeader.click(); // asc
|
||||||
|
await expect(statusHeader.locator('svg')).toBeVisible();
|
||||||
|
await statusHeader.click(); // desc
|
||||||
|
await expect(statusHeader.locator('svg')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that sorting clients by project count works', async ({ page, ctx }) => {
|
||||||
|
const clientWithMany = await createClientViaApi(ctx, { name: 'ManyProjects Client' });
|
||||||
|
const clientWithNone = await createClientViaApi(ctx, { name: 'NoProjects Client' });
|
||||||
|
|
||||||
|
// Create projects for the first client
|
||||||
|
await createProjectViaApi(ctx, { name: 'Proj1', client_id: clientWithMany.id });
|
||||||
|
await createProjectViaApi(ctx, { name: 'Proj2', client_id: clientWithMany.id });
|
||||||
|
|
||||||
|
await goToClientsOverview(page);
|
||||||
|
await clearClientTableState(page);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const table = page.getByTestId('client_table');
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
|
||||||
|
// Click Projects header - first click should sort desc (most projects first)
|
||||||
|
const projectsHeader = table.getByText('Projects').first();
|
||||||
|
await projectsHeader.click();
|
||||||
|
await expect(projectsHeader.locator('svg')).toBeVisible();
|
||||||
|
let names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('ManyProjects Client')).toBeLessThan(names.indexOf('NoProjects Client'));
|
||||||
|
|
||||||
|
// Second click toggles to asc (least projects first)
|
||||||
|
await projectsHeader.click();
|
||||||
|
names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('NoProjects Client')).toBeLessThan(names.indexOf('ManyProjects Client'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that client sort state persists after page reload', async ({ page }) => {
|
||||||
|
await goToClientsOverview(page);
|
||||||
|
await clearClientTableState(page);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const table = page.getByTestId('client_table');
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
|
||||||
|
const nameHeader = table.getByText('Name').first();
|
||||||
|
await nameHeader.click(); // toggle to desc
|
||||||
|
await expect(nameHeader.locator('svg')).toBeVisible();
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
await expect(page.getByTestId('client_table')).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId('client_table').getByText('Name').first().locator('svg')
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
// =============================================
|
// =============================================
|
||||||
// Employee Permission Tests
|
// Employee Permission Tests
|
||||||
// =============================================
|
// =============================================
|
||||||
|
|||||||
@@ -31,15 +31,21 @@ async function selectCommand(page: Page, name: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function assertTimerIsRunning(page: Page) {
|
async function assertTimerIsRunning(page: Page) {
|
||||||
await expect(page.locator(TIMER_BUTTON_SELECTOR)).toHaveClass(/bg-red-400\/80/, {
|
await expect(page.locator(TIMER_BUTTON_SELECTOR).and(page.locator(':visible'))).toHaveClass(
|
||||||
|
/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)).toHaveClass(/bg-accent-300\/70/, {
|
await expect(page.locator(TIMER_BUTTON_SELECTOR).and(page.locator(':visible'))).toHaveClass(
|
||||||
|
/bg-accent-300\/70/,
|
||||||
|
{
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe('Command Palette', () => {
|
test.describe('Command Palette', () => {
|
||||||
|
|||||||
@@ -25,7 +25,12 @@ 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(page.getByTestId('dashboard_timer').getByTestId('timer_button')).toBeVisible();
|
await expect(
|
||||||
|
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();
|
||||||
@@ -104,7 +109,10 @@ test.describe('Employee Dashboard Restrictions', () => {
|
|||||||
|
|
||||||
// Timer should be available
|
// Timer should be available
|
||||||
await expect(
|
await expect(
|
||||||
employee.page.getByTestId('dashboard_timer').getByTestId('timer_button')
|
employee.page
|
||||||
|
.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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ import { expect, test } from '../playwright/fixtures';
|
|||||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
import { inviteAndAcceptMember } from './utils/members';
|
import { inviteAndAcceptMember } from './utils/members';
|
||||||
import { createPlaceholderMemberViaImportApi } from './utils/api';
|
import {
|
||||||
|
createPlaceholderMemberViaImportApi,
|
||||||
|
getMembersViaApi,
|
||||||
|
updateMemberBillableRateViaApi,
|
||||||
|
updateOrganizationSettingViaApi,
|
||||||
|
} from './utils/api';
|
||||||
|
import { getTableRowNames } from './utils/table';
|
||||||
|
|
||||||
// Tests that invite + accept members need more time
|
// Tests that invite + accept members need more time
|
||||||
test.describe.configure({ timeout: 45000 });
|
test.describe.configure({ timeout: 45000 });
|
||||||
@@ -79,8 +85,8 @@ test('test that organization billable rate can be updated with all existing time
|
|||||||
const newBillableRate = Math.round(Math.random() * 10000);
|
const newBillableRate = Math.round(Math.random() * 10000);
|
||||||
await page.getByRole('row').first().getByRole('button').click();
|
await page.getByRole('row').first().getByRole('button').click();
|
||||||
await page.getByRole('menuitem').getByText('Edit').click();
|
await page.getByRole('menuitem').getByText('Edit').click();
|
||||||
await page.getByText('Organization Default Rate').click();
|
await page.getByRole('combobox').last().click();
|
||||||
await page.getByText('Custom Rate').click();
|
await page.getByRole('option', { name: 'Custom Rate' }).click();
|
||||||
await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString());
|
await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString());
|
||||||
await page.getByRole('button', { name: 'Update Member' }).click();
|
await page.getByRole('button', { name: 'Update Member' }).click();
|
||||||
|
|
||||||
@@ -102,6 +108,136 @@ test('test that organization billable rate can be updated with all existing time
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('test that switching member billable rate from custom back to default rate works', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
// Set a known org billable rate
|
||||||
|
await updateOrganizationSettingViaApi(ctx, { billable_rate: 12000 });
|
||||||
|
|
||||||
|
// Create a placeholder member with a custom billable rate
|
||||||
|
await createPlaceholderMemberViaImportApi(ctx, 'CustomToDefault Member');
|
||||||
|
const members = await getMembersViaApi(ctx);
|
||||||
|
const member = members.find((m) => m.name === 'CustomToDefault Member');
|
||||||
|
expect(member).toBeDefined();
|
||||||
|
await updateMemberBillableRateViaApi(ctx, member!.id, 25000);
|
||||||
|
|
||||||
|
await goToMembersPage(page);
|
||||||
|
const memberRow = page.getByRole('row').filter({ hasText: 'CustomToDefault Member' });
|
||||||
|
await expect(memberRow).toBeVisible();
|
||||||
|
|
||||||
|
// Open edit modal
|
||||||
|
await memberRow.getByRole('button').click();
|
||||||
|
await page.getByRole('menuitem').getByText('Edit').click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();
|
||||||
|
|
||||||
|
// Verify it starts on Custom Rate
|
||||||
|
const billableCombobox = page.getByRole('dialog').getByRole('combobox').last();
|
||||||
|
await expect(billableCombobox).toContainText('Custom Rate');
|
||||||
|
|
||||||
|
// Switch to Default Rate
|
||||||
|
await billableCombobox.click();
|
||||||
|
await page.getByRole('option', { name: 'Default Rate' }).click();
|
||||||
|
await expect(billableCombobox).toContainText('Default Rate');
|
||||||
|
|
||||||
|
// Verify the billable rate input is disabled
|
||||||
|
await expect(page.getByPlaceholder('Billable Rate')).toBeDisabled();
|
||||||
|
|
||||||
|
// Submit — billable_rate changes from 25000 to null, so confirmation dialog appears
|
||||||
|
await page.getByRole('button', { name: 'Update Member' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Update Member Billable Rate' })).toBeVisible();
|
||||||
|
await expect(page.getByText('the default rate of the organization')).toBeVisible();
|
||||||
|
|
||||||
|
// Confirm the update
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('button', { name: 'Yes, update existing time' }).click(),
|
||||||
|
page.waitForRequest(
|
||||||
|
(request) =>
|
||||||
|
request.url().includes('/members/') &&
|
||||||
|
request.method() === 'PUT' &&
|
||||||
|
request.postDataJSON().billable_rate === null
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify both dialogs are closed
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that default rate shows disabled input with organization billable rate', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
// Set a known org billable rate (150.00)
|
||||||
|
await updateOrganizationSettingViaApi(ctx, { billable_rate: 15000 });
|
||||||
|
|
||||||
|
await goToMembersPage(page);
|
||||||
|
|
||||||
|
// Open edit modal for the owner (who uses default rate by default)
|
||||||
|
await page.getByRole('row').first().getByRole('button').click();
|
||||||
|
await page.getByRole('menuitem').getByText('Edit').click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();
|
||||||
|
|
||||||
|
// Verify it's on Default Rate
|
||||||
|
const billableCombobox = page.getByRole('dialog').getByRole('combobox').last();
|
||||||
|
await expect(billableCombobox).toContainText('Default Rate');
|
||||||
|
|
||||||
|
// Verify the input is disabled and shows the org rate (formatted with currency)
|
||||||
|
const billableInput = page.getByPlaceholder('Billable Rate');
|
||||||
|
await expect(billableInput).toBeDisabled();
|
||||||
|
await expect(billableInput).toHaveAttribute('aria-valuenow', '150');
|
||||||
|
|
||||||
|
// Close the dialog
|
||||||
|
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that cancelling the billable rate confirmation dialog does not update the member', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
// Create a placeholder member with a custom billable rate
|
||||||
|
await createPlaceholderMemberViaImportApi(ctx, 'CancelConfirm Member');
|
||||||
|
const members = await getMembersViaApi(ctx);
|
||||||
|
const member = members.find((m) => m.name === 'CancelConfirm Member');
|
||||||
|
expect(member).toBeDefined();
|
||||||
|
await updateMemberBillableRateViaApi(ctx, member!.id, 10000);
|
||||||
|
|
||||||
|
await goToMembersPage(page);
|
||||||
|
const memberRow = page.getByRole('row').filter({ hasText: 'CancelConfirm Member' });
|
||||||
|
await expect(memberRow).toBeVisible();
|
||||||
|
|
||||||
|
// Open edit modal
|
||||||
|
await memberRow.getByRole('button').click();
|
||||||
|
await page.getByRole('menuitem').getByText('Edit').click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();
|
||||||
|
|
||||||
|
// Change the billable rate
|
||||||
|
await page.getByPlaceholder('Billable Rate').fill('200');
|
||||||
|
|
||||||
|
// Click Update Member — confirmation dialog should appear
|
||||||
|
await page.getByRole('button', { name: 'Update Member' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Update Member Billable Rate' })).toBeVisible();
|
||||||
|
|
||||||
|
// Set up listener to verify no PUT request is sent after cancel
|
||||||
|
let putRequestSent = false;
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (request.url().includes('/members/') && request.method() === 'PUT') {
|
||||||
|
putRequestSent = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click Cancel on the confirmation dialog
|
||||||
|
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||||
|
|
||||||
|
// Verify confirmation dialog is closed
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: 'Update Member Billable Rate' })
|
||||||
|
).not.toBeVisible();
|
||||||
|
|
||||||
|
// Verify no API call was made
|
||||||
|
expect(putRequestSent).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test('test that changing role of placeholder member is rejected', async ({ page, ctx }) => {
|
test('test that changing role of placeholder member is rejected', async ({ page, ctx }) => {
|
||||||
const placeholderName = 'RoleChange ' + Math.floor(Math.random() * 10000);
|
const placeholderName = 'RoleChange ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
@@ -487,6 +623,125 @@ test('test that accepted invitation disappears from invitations tab', async ({ p
|
|||||||
await expect(page.getByText(memberEmail)).not.toBeVisible();
|
await expect(page.getByText(memberEmail)).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Sorting Tests
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// Helper to clear localStorage before tests that check sorting
|
||||||
|
async function clearMemberTableState(page: Page) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
localStorage.removeItem('member-table-state');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('test that sorting members by name, role, and status works', async ({ page, ctx }) => {
|
||||||
|
// Create two placeholder members with names that sort predictably around "John Doe"
|
||||||
|
await createPlaceholderMemberViaImportApi(ctx, 'AAA SortFirst');
|
||||||
|
await createPlaceholderMemberViaImportApi(ctx, 'ZZZ SortLast');
|
||||||
|
|
||||||
|
await goToMembersPage(page);
|
||||||
|
await clearMemberTableState(page);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const table = page.getByTestId('member_table');
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
|
||||||
|
// -- Name sorting (default is already name asc after clearing state) --
|
||||||
|
const nameHeader = table.getByText('Name').first();
|
||||||
|
let names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('ZZZ SortLast'));
|
||||||
|
|
||||||
|
await nameHeader.click(); // toggle to desc
|
||||||
|
names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('ZZZ SortLast')).toBeLessThan(names.indexOf('AAA SortFirst'));
|
||||||
|
|
||||||
|
// -- Role sorting --
|
||||||
|
const roleHeader = table.getByText('Role').first();
|
||||||
|
await roleHeader.click(); // asc: Owner(0) < Placeholder(4)
|
||||||
|
names = await getTableRowNames(table);
|
||||||
|
const ownerIdx = names.indexOf('John Doe');
|
||||||
|
const placeholderIdx = names.indexOf('AAA SortFirst');
|
||||||
|
expect(ownerIdx).toBeLessThan(placeholderIdx);
|
||||||
|
|
||||||
|
await roleHeader.click(); // desc: Placeholder first
|
||||||
|
names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe'));
|
||||||
|
|
||||||
|
// -- Status sorting --
|
||||||
|
const statusHeader = table.getByText('Status').first();
|
||||||
|
await statusHeader.click(); // asc: Active(0) < Inactive(1)
|
||||||
|
names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('John Doe')).toBeLessThan(names.indexOf('AAA SortFirst'));
|
||||||
|
|
||||||
|
await statusHeader.click(); // desc: Inactive first
|
||||||
|
names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe'));
|
||||||
|
|
||||||
|
// -- Email: just verify sort indicator appears --
|
||||||
|
const emailHeader = table.getByText('Email').first();
|
||||||
|
await emailHeader.click();
|
||||||
|
await expect(emailHeader.locator('svg')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that member sort state persists after page reload', async ({ page }) => {
|
||||||
|
await goToMembersPage(page);
|
||||||
|
await clearMemberTableState(page);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const table = page.getByTestId('member_table');
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
|
||||||
|
// Click Role header twice to set descending sort
|
||||||
|
const roleHeader = table.getByText('Role').first();
|
||||||
|
await roleHeader.click();
|
||||||
|
await expect(roleHeader.locator('svg')).toBeVisible();
|
||||||
|
await roleHeader.click();
|
||||||
|
await expect(roleHeader.locator('svg')).toBeVisible();
|
||||||
|
|
||||||
|
// Reload the page
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Verify the sort indicator is still visible on Role column
|
||||||
|
await expect(page.getByTestId('member_table')).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId('member_table').getByText('Role').first().locator('svg')
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that sorting members by billable rate works', async ({ page, ctx }) => {
|
||||||
|
// Create two placeholder members and set different billable rates
|
||||||
|
await createPlaceholderMemberViaImportApi(ctx, 'HighRate Member');
|
||||||
|
await createPlaceholderMemberViaImportApi(ctx, 'LowRate Member');
|
||||||
|
|
||||||
|
const members = await getMembersViaApi(ctx);
|
||||||
|
const highRateMember = members.find((m) => m.name === 'HighRate Member');
|
||||||
|
const lowRateMember = members.find((m) => m.name === 'LowRate Member');
|
||||||
|
expect(highRateMember).toBeDefined();
|
||||||
|
expect(lowRateMember).toBeDefined();
|
||||||
|
|
||||||
|
await updateMemberBillableRateViaApi(ctx, highRateMember!.id, 20000);
|
||||||
|
await updateMemberBillableRateViaApi(ctx, lowRateMember!.id, 5000);
|
||||||
|
|
||||||
|
await goToMembersPage(page);
|
||||||
|
await clearMemberTableState(page);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const table = page.getByTestId('member_table');
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
|
||||||
|
// First click = desc (highest first), null rates last
|
||||||
|
const billableHeader = table.getByText('Billable Rate').first();
|
||||||
|
await billableHeader.click();
|
||||||
|
await expect(billableHeader.locator('svg')).toBeVisible();
|
||||||
|
let names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('HighRate Member')).toBeLessThan(names.indexOf('LowRate Member'));
|
||||||
|
|
||||||
|
// Second click = asc (lowest first), null rates still last
|
||||||
|
await billableHeader.click();
|
||||||
|
names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('LowRate Member')).toBeLessThan(names.indexOf('HighRate Member'));
|
||||||
|
});
|
||||||
|
|
||||||
// =============================================
|
// =============================================
|
||||||
// Employee Permission Tests
|
// Employee Permission Tests
|
||||||
// =============================================
|
// =============================================
|
||||||
@@ -522,7 +777,7 @@ test.describe('Employee Sidebar Navigation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Member table is empty — no rows rendered (only headers)
|
// Member table is empty — no rows rendered (only headers)
|
||||||
await expect(employee.page.getByTestId('client_table').locator('[role="row"]')).toHaveCount(
|
await expect(employee.page.getByTestId('member_table').locator('[role="row"]')).toHaveCount(
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -369,6 +369,40 @@ test('test that format settings persist after page reload', async ({ page }) =>
|
|||||||
await expect(page.getByLabel('Date Format')).toContainText('DD/MM/YYYY');
|
await expect(page.getByLabel('Date Format')).toContainText('DD/MM/YYYY');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Admin Permission Tests
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
test.describe('Admin Organization Settings Access', () => {
|
||||||
|
test('admin can see and edit organization settings', async ({ ctx, admin }) => {
|
||||||
|
await admin.page.goto(PLAYWRIGHT_BASE_URL + '/teams/' + ctx.orgId);
|
||||||
|
|
||||||
|
// Organization Name section is visible
|
||||||
|
await expect(
|
||||||
|
admin.page.getByRole('heading', { name: 'Organization Name', level: 3 })
|
||||||
|
).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Editable settings sections should be visible
|
||||||
|
await expect(
|
||||||
|
admin.page.getByRole('heading', { name: 'Billable Rate', level: 3 })
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
admin.page.getByRole('heading', { name: 'Format Settings', level: 3 })
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
admin.page.getByRole('heading', { name: 'Organization Settings', level: 3 })
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Save buttons should be visible (admin can update)
|
||||||
|
await expect(admin.page.getByRole('button', { name: 'Save' }).first()).toBeVisible();
|
||||||
|
|
||||||
|
// Delete organization should NOT be visible (owner only)
|
||||||
|
await expect(
|
||||||
|
admin.page.getByRole('heading', { name: 'Delete Organization' })
|
||||||
|
).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// =============================================
|
// =============================================
|
||||||
// Employee Permission Tests
|
// Employee Permission Tests
|
||||||
// =============================================
|
// =============================================
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import type { Page } from '@playwright/test';
|
|||||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||||
import { test } from '../playwright/fixtures';
|
import { test } from '../playwright/fixtures';
|
||||||
import { formatCentsWithOrganizationDefaults } from './utils/money';
|
import { formatCentsWithOrganizationDefaults } from './utils/money';
|
||||||
import type { CurrencyFormat } from '../resources/js/packages/ui/src/utils/money';
|
|
||||||
import {
|
import {
|
||||||
createProjectViaApi,
|
createProjectViaApi,
|
||||||
createPublicProjectViaApi,
|
createPublicProjectViaApi,
|
||||||
createTaskViaApi,
|
createTaskViaApi,
|
||||||
|
createClientViaApi,
|
||||||
|
createTimeEntryViaApi,
|
||||||
|
archiveProjectViaApi,
|
||||||
updateOrganizationSettingViaApi,
|
updateOrganizationSettingViaApi,
|
||||||
} from './utils/api';
|
} from './utils/api';
|
||||||
|
|
||||||
@@ -335,61 +337,179 @@ test('test that editing an existing billable project with default rate loads cor
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Sorting tests
|
// Sorting tests
|
||||||
test('test that sorting projects by name works', async ({ page }) => {
|
test('test that sorting projects by all columns works', async ({ page, ctx }) => {
|
||||||
await goToProjectsOverview(page);
|
// Seed projects with distinct values for each sortable column
|
||||||
await clearProjectTableState(page);
|
const clientAlpha = await createClientViaApi(ctx, { name: 'Alpha Client' });
|
||||||
await page.reload();
|
const clientBeta = await createClientViaApi(ctx, { name: 'Beta Client' });
|
||||||
|
|
||||||
// Wait for the table to load
|
// Project A: client Alpha, low billable rate, has estimated time, active
|
||||||
await expect(page.getByTestId('project_table')).toBeVisible();
|
const projectA = await createProjectViaApi(ctx, {
|
||||||
|
name: 'AAA Project',
|
||||||
// Get initial project names
|
client_id: clientAlpha.id,
|
||||||
const getProjectNames = async () => {
|
is_billable: true,
|
||||||
const rows = page
|
billable_rate: 5000,
|
||||||
.getByTestId('project_table')
|
estimated_time: 36000, // 10h
|
||||||
.locator('[data-testid="project_table"] > div')
|
});
|
||||||
.filter({ hasNot: page.locator('.border-t') });
|
// Add 1h of time entries (10% progress)
|
||||||
const names: string[] = [];
|
await createTimeEntryViaApi(ctx, {
|
||||||
const count = await page.getByTestId('project_table').getByRole('row').count();
|
duration: '1h',
|
||||||
for (let i = 0; i < count; i++) {
|
projectId: projectA.id,
|
||||||
const row = page.getByTestId('project_table').getByRole('row').nth(i);
|
});
|
||||||
const nameCell = row.locator('div').first();
|
|
||||||
const text = await nameCell.textContent();
|
// Project B: client Beta, high billable rate, has estimated time, archived
|
||||||
if (text) {
|
const projectB = await createProjectViaApi(ctx, {
|
||||||
names.push(text.trim());
|
name: 'BBB Project',
|
||||||
}
|
client_id: clientBeta.id,
|
||||||
}
|
is_billable: true,
|
||||||
return names;
|
billable_rate: 15000,
|
||||||
};
|
estimated_time: 7200, // 2h
|
||||||
|
});
|
||||||
// Click on Name header to sort ascending (default should already be ascending)
|
// Add 1h of time entries (50% progress)
|
||||||
const nameHeader = page.getByText('Name').first();
|
await createTimeEntryViaApi(ctx, {
|
||||||
await nameHeader.click();
|
duration: '1h',
|
||||||
|
projectId: projectB.id,
|
||||||
// Wait for sort indicator to appear
|
});
|
||||||
await expect(nameHeader.locator('svg')).toBeVisible();
|
await archiveProjectViaApi(ctx, {
|
||||||
|
...projectB,
|
||||||
// Click again to sort descending
|
client_id: clientBeta.id,
|
||||||
await nameHeader.click();
|
billable_rate: 15000,
|
||||||
|
estimated_time: 7200,
|
||||||
// Verify the sort indicator is still visible (showing descending)
|
});
|
||||||
await expect(nameHeader.locator('svg')).toBeVisible();
|
|
||||||
|
// 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,
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that sorting projects by status works', async ({ page }) => {
|
|
||||||
await goToProjectsOverview(page);
|
await goToProjectsOverview(page);
|
||||||
await clearProjectTableState(page);
|
await clearProjectTableState(page);
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
// Default is "all" so no filter needed - 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();
|
||||||
|
|
||||||
// Click on Status header to sort
|
// Helper to get the visual order of our seeded projects by reading
|
||||||
const statusHeader = page.getByText('Status').first();
|
// all row text in a single evaluate call (avoids locator timing issues)
|
||||||
await statusHeader.click();
|
const seededNames = ['AAA Project', 'BBB Project', 'CCC Project'];
|
||||||
|
const getOrder = async (): Promise<string[]> => {
|
||||||
|
const allRowTexts = await page.evaluate(() => {
|
||||||
|
const table = document.querySelector('[data-testid="project_table"]');
|
||||||
|
if (!table) return [];
|
||||||
|
const rows = table.querySelectorAll('[role="row"]');
|
||||||
|
return Array.from(rows).map((row) => row.textContent ?? '');
|
||||||
|
});
|
||||||
|
const order: string[] = [];
|
||||||
|
for (const text of allRowTexts) {
|
||||||
|
const match = seededNames.find((name) => text.includes(name));
|
||||||
|
if (match) order.push(match);
|
||||||
|
}
|
||||||
|
return order;
|
||||||
|
};
|
||||||
|
|
||||||
// Sort indicator should be visible
|
// Helper: click a column header and wait for sort to apply.
|
||||||
await expect(statusHeader.locator('svg')).toBeVisible();
|
// expectedFirstAmongSeeded = which of our 3 seeded projects should appear first
|
||||||
|
const clickSortHeader = async (headerText: string, expectedFirstAmongSeeded: string) => {
|
||||||
|
const header = page
|
||||||
|
.locator('[data-testid="project_table"] .select-none', {
|
||||||
|
hasText: headerText,
|
||||||
|
})
|
||||||
|
.first();
|
||||||
|
await header.click();
|
||||||
|
// Wait until the expected project appears before the others among our seeded set
|
||||||
|
await page.waitForFunction(
|
||||||
|
({ expected, names }) => {
|
||||||
|
const table = document.querySelector('[data-testid="project_table"]');
|
||||||
|
if (!table) return false;
|
||||||
|
const rows = table.querySelectorAll('[role="row"]');
|
||||||
|
let firstSeededIdx = -1;
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const text = rows[i].textContent ?? '';
|
||||||
|
if (names.some((n: string) => text.includes(n))) {
|
||||||
|
firstSeededIdx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (firstSeededIdx === -1) return false;
|
||||||
|
return (rows[firstSeededIdx].textContent ?? '').includes(expected);
|
||||||
|
},
|
||||||
|
{ expected: expectedFirstAmongSeeded, names: seededNames },
|
||||||
|
{ timeout: 5000 }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Sort by Name ---
|
||||||
|
// Default is name asc (A-Z)
|
||||||
|
let order = await getOrder();
|
||||||
|
expect(order).toEqual(['AAA Project', 'BBB Project', 'CCC Project']);
|
||||||
|
|
||||||
|
// Click to toggle to Z-A
|
||||||
|
await clickSortHeader('Name', 'CCC Project');
|
||||||
|
order = await getOrder();
|
||||||
|
expect(order).toEqual(['CCC Project', 'BBB Project', 'AAA Project']);
|
||||||
|
|
||||||
|
// --- Sort by Client (text: first click = A-Z, no-client last) ---
|
||||||
|
await clickSortHeader('Client', 'AAA Project');
|
||||||
|
order = await getOrder();
|
||||||
|
expect(order).toEqual(['AAA Project', 'BBB Project', 'CCC Project']); // Alpha, Beta, No client
|
||||||
|
|
||||||
|
// Reverse: Z-A, no-client still last
|
||||||
|
await clickSortHeader('Client', 'BBB Project');
|
||||||
|
order = await getOrder();
|
||||||
|
expect(order).toEqual(['BBB Project', 'AAA Project', 'CCC Project']); // Beta, Alpha, No client
|
||||||
|
|
||||||
|
// --- Sort by Total Time (numeric: first click = highest first) ---
|
||||||
|
await clickSortHeader('Total Time', 'CCC Project');
|
||||||
|
order = await getOrder();
|
||||||
|
expect(order[0]).toBe('CCC Project'); // C=3h first, A and B tied at 1h
|
||||||
|
|
||||||
|
// Reverse: lowest first
|
||||||
|
await clickSortHeader('Total Time', 'AAA Project');
|
||||||
|
order = await getOrder();
|
||||||
|
expect(order[2]).toBe('CCC Project'); // C=3h last
|
||||||
|
|
||||||
|
// --- Sort by Billable Rate (numeric: first click = highest first) ---
|
||||||
|
await clickSortHeader('Billable Rate', 'BBB Project');
|
||||||
|
order = await getOrder();
|
||||||
|
expect(order).toEqual(['BBB Project', 'CCC Project', 'AAA Project']); // 15000, 10000, 5000
|
||||||
|
|
||||||
|
// Reverse: lowest first
|
||||||
|
await clickSortHeader('Billable Rate', 'AAA Project');
|
||||||
|
order = await getOrder();
|
||||||
|
expect(order).toEqual(['AAA Project', 'CCC Project', 'BBB Project']); // 5000, 10000, 15000
|
||||||
|
|
||||||
|
// --- Sort by Progress (numeric: first click = highest first, no-estimate last) ---
|
||||||
|
await clickSortHeader('Progress', 'BBB Project');
|
||||||
|
order = await getOrder();
|
||||||
|
expect(order).toEqual(['BBB Project', 'AAA Project', 'CCC Project']); // 50%, 10%, no estimate
|
||||||
|
|
||||||
|
// Reverse: lowest first, no-estimate still last
|
||||||
|
await clickSortHeader('Progress', 'AAA Project');
|
||||||
|
order = await getOrder();
|
||||||
|
expect(order).toEqual(['AAA Project', 'BBB Project', 'CCC Project']); // 10%, 50%, no estimate
|
||||||
|
|
||||||
|
// --- Sort by Status (first click = active first, archived last) ---
|
||||||
|
await expect(async () => {
|
||||||
|
await clickSortHeader('Status', 'AAA Project');
|
||||||
|
order = await getOrder();
|
||||||
|
expect(order.indexOf('BBB Project')).toBeGreaterThan(order.indexOf('AAA Project'));
|
||||||
|
expect(order.indexOf('BBB Project')).toBeGreaterThan(order.indexOf('CCC Project'));
|
||||||
|
}).toPass({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Reverse: archived first
|
||||||
|
await expect(async () => {
|
||||||
|
await clickSortHeader('Status', 'BBB Project');
|
||||||
|
order = await getOrder();
|
||||||
|
expect(order.indexOf('BBB Project')).toBeLessThan(order.indexOf('AAA Project'));
|
||||||
|
expect(order.indexOf('BBB Project')).toBeLessThan(order.indexOf('CCC Project'));
|
||||||
|
}).toPass({ timeout: 5000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter tests
|
// Filter tests
|
||||||
@@ -642,22 +762,6 @@ test('test that estimated time input displays formatted value after blur', async
|
|||||||
await expect(estimatedTimeInput).toHaveValue(/1h.*30/);
|
await expect(estimatedTimeInput).toHaveValue(/1h.*30/);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create new project with new Client
|
|
||||||
|
|
||||||
// Create new project with existing Client
|
|
||||||
|
|
||||||
// Delete project via More Options
|
|
||||||
|
|
||||||
// Test that project task count is displayed correctly
|
|
||||||
|
|
||||||
// Edit Project Modal Test
|
|
||||||
|
|
||||||
// Add Project with billable rate
|
|
||||||
|
|
||||||
// Edit Project with billable rate
|
|
||||||
|
|
||||||
// Edit Project Member Billable Rate
|
|
||||||
|
|
||||||
test('test that editing a task name on the project detail page works', async ({ page, ctx }) => {
|
test('test that editing a task name on the project detail page works', async ({ page, ctx }) => {
|
||||||
const projectName = 'Task Edit Project ' + Math.floor(1 + Math.random() * 10000);
|
const projectName = 'Task Edit Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
const originalTaskName = 'Original Task ' + Math.floor(1 + Math.random() * 10000);
|
const originalTaskName = 'Original Task ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import {
|
|||||||
createTimeEntryViaApi,
|
createTimeEntryViaApi,
|
||||||
createTimeEntryWithTagViaApi,
|
createTimeEntryWithTagViaApi,
|
||||||
createBareTimeEntryViaApi,
|
createBareTimeEntryViaApi,
|
||||||
|
createBillableProjectViaApi,
|
||||||
|
createTimeEntryWithBillableStatusViaApi,
|
||||||
|
createTagViaApi,
|
||||||
} from './utils/api';
|
} from './utils/api';
|
||||||
import {
|
import {
|
||||||
goToReporting,
|
goToReporting,
|
||||||
@@ -246,6 +249,191 @@ test('test that shared report with No Task filter shows entries without a task',
|
|||||||
await expect(page.getByText('Total')).toBeVisible();
|
await expect(page.getByText('Total')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('test that shared report respects task filter', async ({ page, ctx }) => {
|
||||||
|
const projectName = 'TaskFilterProj ' + Math.floor(Math.random() * 10000);
|
||||||
|
const taskA = 'TaskA ' + Math.floor(Math.random() * 10000);
|
||||||
|
const taskB = 'TaskB ' + Math.floor(Math.random() * 10000);
|
||||||
|
const reportName = 'TaskFilterReport ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
const project = await createProjectViaApi(ctx, { name: projectName });
|
||||||
|
const task = await createTaskViaApi(ctx, { name: taskA, project_id: project.id });
|
||||||
|
await createTaskViaApi(ctx, { name: taskB, project_id: project.id });
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: `Entry for ${taskA}`,
|
||||||
|
duration: '1h',
|
||||||
|
projectId: project.id,
|
||||||
|
taskId: task.id,
|
||||||
|
});
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: `Entry for ${projectName} no task`,
|
||||||
|
duration: '2h',
|
||||||
|
projectId: project.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToReporting(page);
|
||||||
|
await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();
|
||||||
|
|
||||||
|
// Filter by task A
|
||||||
|
await page.getByRole('button', { name: 'Tasks' }).first().click();
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('option').filter({ hasText: taskA }).click(),
|
||||||
|
waitForReportingUpdate(page),
|
||||||
|
]);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
const { shareableLink } = await saveAsSharedReport(page, reportName);
|
||||||
|
|
||||||
|
// View the shared report
|
||||||
|
await page.goto(shareableLink);
|
||||||
|
await expect(page.getByText('Reporting')).toBeVisible();
|
||||||
|
await expect(page.getByText('Total')).toBeVisible();
|
||||||
|
await expect(page.getByText('1h 00min').first()).toBeVisible();
|
||||||
|
await expect(page.getByText('3h 00min')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that shared report respects client filter', async ({ page, ctx }) => {
|
||||||
|
const clientA = 'ClientA ' + Math.floor(Math.random() * 10000);
|
||||||
|
const clientB = 'ClientB ' + Math.floor(Math.random() * 10000);
|
||||||
|
const projectA = 'ClientFilterProjA ' + Math.floor(Math.random() * 10000);
|
||||||
|
const projectB = 'ClientFilterProjB ' + Math.floor(Math.random() * 10000);
|
||||||
|
const reportName = 'ClientFilterReport ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
const cliA = await createClientViaApi(ctx, { name: clientA });
|
||||||
|
const cliB = await createClientViaApi(ctx, { name: clientB });
|
||||||
|
const projA = await createProjectViaApi(ctx, { name: projectA, client_id: cliA.id });
|
||||||
|
const projB = await createProjectViaApi(ctx, { name: projectB, client_id: cliB.id });
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: `Entry for ${clientA}`,
|
||||||
|
duration: '1h',
|
||||||
|
projectId: projA.id,
|
||||||
|
});
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: `Entry for ${clientB}`,
|
||||||
|
duration: '2h',
|
||||||
|
projectId: projB.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToReporting(page);
|
||||||
|
await expect(page.getByTestId('reporting_view').getByText(projectA)).toBeVisible();
|
||||||
|
|
||||||
|
// Filter by client A
|
||||||
|
await page.getByRole('button', { name: 'Clients' }).first().click();
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('option').filter({ hasText: clientA }).click(),
|
||||||
|
waitForReportingUpdate(page),
|
||||||
|
]);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
const { shareableLink } = await saveAsSharedReport(page, reportName);
|
||||||
|
|
||||||
|
// View the shared report
|
||||||
|
await page.goto(shareableLink);
|
||||||
|
await expect(page.getByText('Reporting')).toBeVisible();
|
||||||
|
await expect(page.getByText(projectA)).toBeVisible();
|
||||||
|
await expect(page.getByText(projectB)).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that shared report respects tag filter', async ({ page, ctx }) => {
|
||||||
|
const tagA = 'TagA ' + Math.floor(Math.random() * 10000);
|
||||||
|
const tagB = 'TagB ' + Math.floor(Math.random() * 10000);
|
||||||
|
const reportName = 'TagFilterReport ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
const tagObjA = await createTagViaApi(ctx, { name: tagA });
|
||||||
|
await createTagViaApi(ctx, { name: tagB });
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: `Entry with ${tagA}`,
|
||||||
|
duration: '1h',
|
||||||
|
tags: [tagObjA.id],
|
||||||
|
});
|
||||||
|
await createBareTimeEntryViaApi(ctx, 'Entry no tags', '2h');
|
||||||
|
|
||||||
|
await goToReporting(page);
|
||||||
|
await expect(page.getByTestId('reporting_view').getByText('Total')).toBeVisible();
|
||||||
|
|
||||||
|
// Filter by tag A
|
||||||
|
await page.getByRole('button', { name: 'Tags' }).first().click();
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('option').filter({ hasText: tagA }).click(),
|
||||||
|
waitForReportingUpdate(page),
|
||||||
|
]);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
const { shareableLink } = await saveAsSharedReport(page, reportName);
|
||||||
|
|
||||||
|
// View the shared report
|
||||||
|
await page.goto(shareableLink);
|
||||||
|
await expect(page.getByText('Reporting')).toBeVisible();
|
||||||
|
await expect(page.getByText('Total')).toBeVisible();
|
||||||
|
await expect(page.getByText('1h 00min').first()).toBeVisible();
|
||||||
|
await expect(page.getByText('3h 00min')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that shared report respects member filter', async ({ page, ctx }) => {
|
||||||
|
const projectName = 'MemberFilterProj ' + Math.floor(Math.random() * 10000);
|
||||||
|
const reportName = 'MemberFilterReport ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
const project = await createProjectViaApi(ctx, { name: projectName });
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: `Entry for ${projectName}`,
|
||||||
|
duration: '1h',
|
||||||
|
projectId: project.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToReporting(page);
|
||||||
|
await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();
|
||||||
|
|
||||||
|
// Filter by current member (John Doe)
|
||||||
|
await page.getByRole('button', { name: 'Members' }).first().click();
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('option').filter({ hasText: 'John Doe' }).click(),
|
||||||
|
waitForReportingUpdate(page),
|
||||||
|
]);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
const { shareableLink } = await saveAsSharedReport(page, reportName);
|
||||||
|
|
||||||
|
// View the shared report — should still show data since all entries belong to this member
|
||||||
|
await page.goto(shareableLink);
|
||||||
|
await expect(page.getByText('Reporting')).toBeVisible();
|
||||||
|
await expect(page.getByText(projectName)).toBeVisible();
|
||||||
|
await expect(page.getByText('Total')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that shared report with billable filter only shows billable entries', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
const reportName = 'BillableFilterReport ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
// Create one billable (1h) and one non-billable (2h) entry
|
||||||
|
await createTimeEntryWithBillableStatusViaApi(ctx, true, '1h');
|
||||||
|
await createTimeEntryWithBillableStatusViaApi(ctx, false, '2h');
|
||||||
|
|
||||||
|
await goToReporting(page);
|
||||||
|
await expect(page.getByTestId('reporting_view').getByText('Total')).toBeVisible();
|
||||||
|
|
||||||
|
// Filter by billable only
|
||||||
|
await page.getByRole('combobox').filter({ hasText: 'Billable' }).click();
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('option', { name: 'Billable', exact: true }).click(),
|
||||||
|
waitForReportingUpdate(page),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify only 1h shows before saving
|
||||||
|
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
|
||||||
|
|
||||||
|
const { shareableLink } = await saveAsSharedReport(page, reportName);
|
||||||
|
|
||||||
|
// Navigate to the shared report
|
||||||
|
await page.goto(shareableLink);
|
||||||
|
await expect(page.getByText('Reporting')).toBeVisible();
|
||||||
|
await expect(page.getByText('Total')).toBeVisible();
|
||||||
|
|
||||||
|
// Shared report should only show the 1h billable entry, not the 2h non-billable
|
||||||
|
await expect(page.getByText('1h 00min').first()).toBeVisible();
|
||||||
|
await expect(page.getByText('3h 00min')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
// Report Date Picker Tests
|
// Report Date Picker Tests
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
@@ -577,3 +765,58 @@ test('test that updating expiration date on already-public report works', async
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
expect(returnedDate.getTime()).toBeGreaterThan(now.getTime());
|
expect(returnedDate.getTime()).toBeGreaterThan(now.getTime());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
// Shared Report Cost Column Tests
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('test that shared report displays cost column correctly aligned with data rows', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
const projectName = 'BillableProj ' + Math.floor(Math.random() * 10000);
|
||||||
|
const reportName = 'BillableReport ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
const project = await createBillableProjectViaApi(ctx, {
|
||||||
|
name: projectName,
|
||||||
|
billable_rate: 10000, // 100.00 per hour
|
||||||
|
});
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: `Entry for ${projectName}`,
|
||||||
|
duration: '1h',
|
||||||
|
projectId: project.id,
|
||||||
|
billable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToReporting(page);
|
||||||
|
await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();
|
||||||
|
|
||||||
|
const { shareableLink } = await saveAsSharedReport(page, reportName);
|
||||||
|
|
||||||
|
// Navigate to the shared report
|
||||||
|
await page.goto(shareableLink);
|
||||||
|
await expect(page.getByText('Reporting')).toBeVisible();
|
||||||
|
await expect(page.getByText(projectName)).toBeVisible();
|
||||||
|
|
||||||
|
// Verify the table header has all three columns
|
||||||
|
await expect(page.getByText('Name', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText('Duration', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText('Cost', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
// Verify the Total row displays both duration and cost
|
||||||
|
await expect(page.getByText('Total')).toBeVisible();
|
||||||
|
|
||||||
|
// The data rows should render cost values (not just header + duration)
|
||||||
|
// With 1h at 100/h the cost should be displayed somewhere in the table
|
||||||
|
// If showCost is not passed to ReportingRow, only the header "Cost" and
|
||||||
|
// the Total row cost will render, but individual row costs will be missing
|
||||||
|
const table = page.locator('[style*="grid-template-columns"]');
|
||||||
|
// Count elements containing the cost value - header "Cost" + project row cost + total row cost = 3
|
||||||
|
// If broken (showCost not passed), the project row won't render its cost cell
|
||||||
|
await expect(table.getByText(/100/).first()).toBeVisible();
|
||||||
|
|
||||||
|
// Verify the cost value appears at least twice in the table
|
||||||
|
// (once for the data row, once for the total) beyond just the header
|
||||||
|
const costValues = table.getByText(/100/);
|
||||||
|
await expect(costValues).toHaveCount(2);
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Page } from '@playwright/test';
|
|||||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||||
import { test } from '../playwright/fixtures';
|
import { test } from '../playwright/fixtures';
|
||||||
import { createTagViaApi } from './utils/api';
|
import { createTagViaApi } from './utils/api';
|
||||||
|
import { getTableRowNames } from './utils/table';
|
||||||
|
|
||||||
async function goToTagsOverview(page: Page) {
|
async function goToTagsOverview(page: Page) {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/tags');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/tags');
|
||||||
@@ -89,6 +90,57 @@ test('test that multiple tags can be created via API and displayed in the table'
|
|||||||
await expect(page.getByTestId('tag_table')).toContainText(tagName2);
|
await expect(page.getByTestId('tag_table')).toContainText(tagName2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Sorting Tests
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
async function clearTagTableState(page: Page) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
localStorage.removeItem('tag-table-state');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('test that sorting tags by name works', async ({ page, ctx }) => {
|
||||||
|
await createTagViaApi(ctx, { name: 'AAA SortTag' });
|
||||||
|
await createTagViaApi(ctx, { name: 'ZZZ SortTag' });
|
||||||
|
|
||||||
|
await goToTagsOverview(page);
|
||||||
|
await clearTagTableState(page);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const table = page.getByTestId('tag_table');
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
|
||||||
|
// Default is name asc
|
||||||
|
let names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('AAA SortTag')).toBeLessThan(names.indexOf('ZZZ SortTag'));
|
||||||
|
|
||||||
|
const nameHeader = table.getByText('Name').first();
|
||||||
|
await nameHeader.click(); // toggle to desc
|
||||||
|
names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('ZZZ SortTag')).toBeLessThan(names.indexOf('AAA SortTag'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that tag sort state persists after page reload', async ({ page }) => {
|
||||||
|
await goToTagsOverview(page);
|
||||||
|
await clearTagTableState(page);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const table = page.getByTestId('tag_table');
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
|
||||||
|
const nameHeader = table.getByText('Name').first();
|
||||||
|
await nameHeader.click(); // toggle to desc
|
||||||
|
await expect(nameHeader.locator('svg')).toBeVisible();
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
await expect(page.getByTestId('tag_table')).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId('tag_table').getByText('Name').first().locator('svg')
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
// =============================================
|
// =============================================
|
||||||
// Employee Permission Tests
|
// Employee Permission Tests
|
||||||
// =============================================
|
// =============================================
|
||||||
|
|||||||
@@ -1159,6 +1159,8 @@ test('test that end time picker works in create modal', async ({ page }) => {
|
|||||||
await endTimeInput.press('Tab');
|
await endTimeInput.press('Tab');
|
||||||
|
|
||||||
// Set duration (this will adjust based on the times)
|
// Set duration (this will adjust based on the times)
|
||||||
|
// clear() before fill() needed because fill() appends on Firefox instead of replacing
|
||||||
|
await page.locator('[role="dialog"] input[name="Duration"]').clear();
|
||||||
await page.locator('[role="dialog"] input[name="Duration"]').fill('1h');
|
await page.locator('[role="dialog"] input[name="Duration"]').fill('1h');
|
||||||
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
|
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
|
||||||
|
|
||||||
@@ -1450,7 +1452,10 @@ 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.getByTestId('dashboard_timer').getByTestId('timer_button')
|
employee.page
|
||||||
|
.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
|
||||||
|
|||||||
@@ -354,7 +354,10 @@ 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.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]')
|
page
|
||||||
|
.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
|
||||||
|
|||||||
@@ -201,6 +201,37 @@ export async function createProjectViaApi(
|
|||||||
return body.data as { id: string; name: string; color: string; is_billable: boolean };
|
return body.data as { id: string; name: string; color: string; is_billable: boolean };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function archiveProjectViaApi(
|
||||||
|
ctx: TestContext,
|
||||||
|
project: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
is_billable: boolean;
|
||||||
|
client_id?: string | null;
|
||||||
|
billable_rate?: number | null;
|
||||||
|
estimated_time?: number | null;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const response = await ctx.request.put(
|
||||||
|
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/projects/${project.id}`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
name: project.name,
|
||||||
|
color: project.color,
|
||||||
|
is_billable: project.is_billable,
|
||||||
|
is_archived: true,
|
||||||
|
client_id: project.client_id ?? null,
|
||||||
|
billable_rate: project.billable_rate ?? null,
|
||||||
|
estimated_time: project.estimated_time ?? null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createBillableProjectViaApi(
|
export async function createBillableProjectViaApi(
|
||||||
ctx: TestContext,
|
ctx: TestContext,
|
||||||
data: { name: string; billable_rate?: number | null }
|
data: { name: string; billable_rate?: number | null }
|
||||||
@@ -314,6 +345,36 @@ export async function createProjectMemberViaApi(
|
|||||||
return body.data as { id: string; billable_rate: number | null };
|
return body.data as { id: string; billable_rate: number | null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getMembersViaApi(ctx: TestContext) {
|
||||||
|
const response = await ctx.request.get(
|
||||||
|
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/members`
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
return body.data as Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
billable_rate: number | null;
|
||||||
|
is_placeholder: boolean;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMemberBillableRateViaApi(
|
||||||
|
ctx: TestContext,
|
||||||
|
memberId: string,
|
||||||
|
billableRate: number | null
|
||||||
|
) {
|
||||||
|
const response = await ctx.request.put(
|
||||||
|
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/members/${memberId}`,
|
||||||
|
{ data: { billable_rate: billableRate } }
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
// Composite helpers (matching existing UI helper signatures)
|
// Composite helpers (matching existing UI helper signatures)
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -2,12 +2,19 @@ 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.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]').click();
|
await page
|
||||||
|
.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.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]')
|
page
|
||||||
|
.getByTestId('dashboard_timer')
|
||||||
|
.getByTestId('timer_button')
|
||||||
|
.and(page.locator(':visible'))
|
||||||
).toHaveClass(/bg-red-400\/80/);
|
).toHaveClass(/bg-red-400\/80/);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +41,10 @@ export function newTimeEntryResponse(
|
|||||||
|
|
||||||
export async function assertThatTimerIsStopped(page: Page) {
|
export async function assertThatTimerIsStopped(page: Page) {
|
||||||
await expect(
|
await expect(
|
||||||
page.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]')
|
page
|
||||||
|
.getByTestId('dashboard_timer')
|
||||||
|
.getByTestId('timer_button')
|
||||||
|
.and(page.locator(':visible'))
|
||||||
).toHaveClass(/bg-accent-300\/70/);
|
).toHaveClass(/bg-accent-300\/70/);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,85 @@ export async function inviteAndAcceptMember(
|
|||||||
await secondUser.close();
|
await secondUser.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up an admin member in the owner's organization.
|
||||||
|
* Returns the admin's page, their member ID, and a cleanup function.
|
||||||
|
*/
|
||||||
|
export async function setupAdminUser(
|
||||||
|
ownerPage: Page,
|
||||||
|
ownerCtx: TestContext,
|
||||||
|
browser: Browser
|
||||||
|
): Promise<{
|
||||||
|
adminPage: Page;
|
||||||
|
adminMemberId: string;
|
||||||
|
closeAdmin: () => Promise<void>;
|
||||||
|
}> {
|
||||||
|
const memberId = Math.floor(Math.random() * 100000);
|
||||||
|
const memberEmail = `admin+${memberId}@admin-perms.test`;
|
||||||
|
const memberName = 'Admin ' + memberId;
|
||||||
|
|
||||||
|
const admin = await registerUser(browser, memberName, memberEmail);
|
||||||
|
|
||||||
|
await ownerPage.goto(PLAYWRIGHT_BASE_URL + '/members');
|
||||||
|
await ownerPage.getByRole('button', { name: 'Invite Member' }).click();
|
||||||
|
await expect(ownerPage.getByPlaceholder('Member Email')).toBeVisible();
|
||||||
|
await ownerPage.getByPlaceholder('Member Email').fill(memberEmail);
|
||||||
|
await ownerPage.getByRole('button', { name: 'Administrator' }).click();
|
||||||
|
await Promise.all([
|
||||||
|
ownerPage.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/invitations') &&
|
||||||
|
response.request().method() === 'POST' &&
|
||||||
|
response.status() === 204
|
||||||
|
),
|
||||||
|
ownerPage.getByRole('button', { name: 'Invite Member', exact: true }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const acceptUrl = await getInvitationAcceptUrl(admin.page.request, memberEmail);
|
||||||
|
await admin.page.goto(acceptUrl);
|
||||||
|
await admin.page.waitForURL(/dashboard/);
|
||||||
|
|
||||||
|
await admin.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||||
|
await expect(admin.page.getByTestId('dashboard_view')).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
const orgSwitcherText = await admin.page
|
||||||
|
.getByTestId('organization_switcher')
|
||||||
|
.first()
|
||||||
|
.textContent();
|
||||||
|
if (!orgSwitcherText?.includes("John's Organization")) {
|
||||||
|
const cookies = await admin.page.context().cookies();
|
||||||
|
const xsrfCookie = cookies.find((c) => c.name === 'XSRF-TOKEN');
|
||||||
|
const xsrfToken = xsrfCookie ? decodeURIComponent(xsrfCookie.value) : '';
|
||||||
|
|
||||||
|
await admin.page.request.put(`${PLAYWRIGHT_BASE_URL}/current-team`, {
|
||||||
|
headers: {
|
||||||
|
'X-XSRF-TOKEN': xsrfToken,
|
||||||
|
Accept: 'text/html',
|
||||||
|
},
|
||||||
|
data: { team_id: ownerCtx.orgId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await admin.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||||
|
await expect(admin.page.getByTestId('dashboard_view')).toBeVisible({ timeout: 15000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const membersResponse = await ownerCtx.request.get(
|
||||||
|
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ownerCtx.orgId}/members`
|
||||||
|
);
|
||||||
|
expect(membersResponse.status()).toBe(200);
|
||||||
|
const membersBody = await membersResponse.json();
|
||||||
|
const adminMember = membersBody.data.find(
|
||||||
|
(m: { role: string; name: string }) => m.role === 'admin' && m.name === memberName
|
||||||
|
);
|
||||||
|
expect(adminMember).toBeTruthy();
|
||||||
|
|
||||||
|
return {
|
||||||
|
adminPage: admin.page,
|
||||||
|
adminMemberId: adminMember.id,
|
||||||
|
closeAdmin: admin.close,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up an employee member in the owner's organization.
|
* Set up an employee member in the owner's organization.
|
||||||
* Returns the employee's page, their member ID, and a cleanup function.
|
* Returns the employee's page, their member ID, and a cleanup function.
|
||||||
|
|||||||
16
e2e/utils/table.ts
Normal file
16
e2e/utils/table.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Locator } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the first cell's text content from each row in a table.
|
||||||
|
* Useful for reading the ordered names/labels from a sorted table.
|
||||||
|
*/
|
||||||
|
export async function getTableRowNames(table: Locator): Promise<string[]> {
|
||||||
|
const rows = table.getByRole('row');
|
||||||
|
const count = await rows.count();
|
||||||
|
const names: string[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const text = await rows.nth(i).locator('div').first().textContent();
|
||||||
|
if (text) names.push(text.trim());
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
@@ -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: 4,
|
workers: process.env.CI ? 2 : 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. */
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { test as baseTest } from '@playwright/test';
|
|||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from './config';
|
import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from './config';
|
||||||
import { type TestContext, setupTestContext } from '../e2e/utils/api';
|
import { type TestContext, setupTestContext } from '../e2e/utils/api';
|
||||||
import { setupEmployeeUser } from '../e2e/utils/members';
|
import { setupAdminUser, setupEmployeeUser } from '../e2e/utils/members';
|
||||||
|
|
||||||
export * from '@playwright/test';
|
export * from '@playwright/test';
|
||||||
export type { TestContext };
|
export type { TestContext };
|
||||||
@@ -12,6 +12,11 @@ export interface EmployeeFixture {
|
|||||||
memberId: string;
|
memberId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminFixture {
|
||||||
|
page: Page;
|
||||||
|
memberId: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API-based authentication fixture - creates a new user via HTTP requests instead of UI interactions.
|
* API-based authentication fixture - creates a new user via HTTP requests instead of UI interactions.
|
||||||
* This is ~10-25x faster than UI-based authentication (~100-200ms vs ~3-5s).
|
* This is ~10-25x faster than UI-based authentication (~100-200ms vs ~3-5s).
|
||||||
@@ -19,7 +24,7 @@ export interface EmployeeFixture {
|
|||||||
* Uses page.context().request() to ensure cookies are shared between the API request and page.
|
* Uses page.context().request() to ensure cookies are shared between the API request and page.
|
||||||
*/
|
*/
|
||||||
export const test = baseTest.extend<
|
export const test = baseTest.extend<
|
||||||
{ ctx: TestContext; employee: EmployeeFixture },
|
{ ctx: TestContext; employee: EmployeeFixture; admin: AdminFixture },
|
||||||
{ workerStorageState: string }
|
{ workerStorageState: string }
|
||||||
>({
|
>({
|
||||||
page: async ({ page }, use) => {
|
page: async ({ page }, use) => {
|
||||||
@@ -100,4 +105,10 @@ export const test = baseTest.extend<
|
|||||||
await use({ page: employeePage, memberId: employeeMemberId });
|
await use({ page: employeePage, memberId: employeeMemberId });
|
||||||
await closeEmployee();
|
await closeEmployee();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
admin: async ({ page, ctx, browser }, use) => {
|
||||||
|
const { adminPage, adminMemberId, closeAdmin } = await setupAdminUser(page, ctx, browser);
|
||||||
|
await use({ page: adminPage, memberId: adminMemberId });
|
||||||
|
await closeAdmin();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,17 +2,104 @@
|
|||||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
import { UserCircleIcon } from '@heroicons/vue/24/solid';
|
import { UserCircleIcon } from '@heroicons/vue/24/solid';
|
||||||
import { PlusIcon } from '@heroicons/vue/16/solid';
|
import { PlusIcon } from '@heroicons/vue/16/solid';
|
||||||
import { type Component, ref } from 'vue';
|
import { type Component, computed, ref } from 'vue';
|
||||||
import { type Client } from '@/packages/api/src';
|
import { type Client } from '@/packages/api/src';
|
||||||
import ClientTableRow from '@/Components/Common/Client/ClientTableRow.vue';
|
import ClientTableRow from '@/Components/Common/Client/ClientTableRow.vue';
|
||||||
import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';
|
import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';
|
||||||
import ClientTableHeading from '@/Components/Common/Client/ClientTableHeading.vue';
|
import ClientTableHeading from '@/Components/Common/Client/ClientTableHeading.vue';
|
||||||
import { canCreateClients } from '@/utils/permissions';
|
import { canCreateClients } from '@/utils/permissions';
|
||||||
|
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
||||||
|
import {
|
||||||
|
useVueTable,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
type SortingState,
|
||||||
|
} from '@tanstack/vue-table';
|
||||||
|
|
||||||
defineProps<{
|
export type SortColumn = 'name' | 'projects_count' | 'status';
|
||||||
|
export type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
clients: Client[];
|
clients: Client[];
|
||||||
|
sortColumn: SortColumn;
|
||||||
|
sortDirection: SortDirection;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
sort: [column: SortColumn, direction: SortDirection];
|
||||||
|
}>();
|
||||||
|
|
||||||
const createClient = ref(false);
|
const createClient = ref(false);
|
||||||
|
|
||||||
|
const { projects } = useProjectsQuery();
|
||||||
|
|
||||||
|
const projectCountMap = computed(() => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
projects.value.forEach((project) => {
|
||||||
|
if (project.client_id) {
|
||||||
|
map.set(project.client_id, (map.get(project.client_id) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sorting = computed<SortingState>(() => [
|
||||||
|
{
|
||||||
|
id: props.sortColumn,
|
||||||
|
desc: props.sortDirection === 'desc',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const columns = computed(() => [
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
accessorFn: (row: Client) => row.name.toLowerCase(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'projects_count',
|
||||||
|
sortDescFirst: true,
|
||||||
|
accessorFn: (row: Client) => projectCountMap.value.get(row.id) ?? 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
accessorFn: (row: Client) => (row.is_archived ? 1 : 0),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const descFirstColumns = new Set<SortColumn>(
|
||||||
|
columns.value
|
||||||
|
.filter((c) => 'sortDescFirst' in c && c.sortDescFirst)
|
||||||
|
.map((c) => c.id as SortColumn)
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleSort(column: SortColumn) {
|
||||||
|
if (props.sortColumn === column) {
|
||||||
|
emit('sort', column, props.sortDirection === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
emit('sort', column, descFirstColumns.has(column) ? 'desc' : 'asc');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = useVueTable({
|
||||||
|
get data() {
|
||||||
|
return props.clients;
|
||||||
|
},
|
||||||
|
get columns() {
|
||||||
|
return columns.value;
|
||||||
|
},
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
state: {
|
||||||
|
get sorting() {
|
||||||
|
return sorting.value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
manualSorting: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedClients = computed(() => {
|
||||||
|
return table.getRowModel().rows.map((row) => row.original);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -23,8 +110,12 @@ const createClient = ref(false);
|
|||||||
data-testid="client_table"
|
data-testid="client_table"
|
||||||
class="grid min-w-full"
|
class="grid min-w-full"
|
||||||
style="grid-template-columns: 1fr 150px 200px 80px">
|
style="grid-template-columns: 1fr 150px 200px 80px">
|
||||||
<ClientTableHeading></ClientTableHeading>
|
<ClientTableHeading
|
||||||
<div v-if="clients.length === 0" class="col-span-3 py-24 text-center">
|
:sort-column="props.sortColumn"
|
||||||
|
:sort-direction="props.sortDirection"
|
||||||
|
:desc-first-columns="descFirstColumns"
|
||||||
|
@sort="handleSort"></ClientTableHeading>
|
||||||
|
<div v-if="sortedClients.length === 0" class="col-span-3 py-24 text-center">
|
||||||
<UserCircleIcon class="w-8 text-icon-default inline pb-2"></UserCircleIcon>
|
<UserCircleIcon class="w-8 text-icon-default inline pb-2"></UserCircleIcon>
|
||||||
<h3 class="text-text-primary font-semibold">No clients found</h3>
|
<h3 class="text-text-primary font-semibold">No clients found</h3>
|
||||||
<p v-if="canCreateClients()" class="pb-5">Create your first client now!</p>
|
<p v-if="canCreateClients()" class="pb-5">Create your first client now!</p>
|
||||||
@@ -35,7 +126,7 @@ const createClient = ref(false);
|
|||||||
>Create your First Client
|
>Create your First Client
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
</div>
|
</div>
|
||||||
<template v-for="client in clients" :key="client.id">
|
<template v-for="client in sortedClients" :key="client.id">
|
||||||
<ClientTableRow :client="client"></ClientTableRow>
|
<ClientTableRow :client="client"></ClientTableRow>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,67 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import TableHeading from '@/Components/Common/TableHeading.vue';
|
import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||||
|
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
|
||||||
|
import type { SortColumn, SortDirection } from '@/Components/Common/Client/ClientTable.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
sortColumn: SortColumn;
|
||||||
|
sortDirection: SortDirection;
|
||||||
|
descFirstColumns: ReadonlySet<SortColumn>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
sort: [column: SortColumn];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function handleSort(column: SortColumn) {
|
||||||
|
emit('sort', column);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSorted(column: SortColumn): boolean {
|
||||||
|
return props.sortColumn === column;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isChevronDown(column: SortColumn): boolean {
|
||||||
|
if (!isSorted(column)) return false;
|
||||||
|
return props.descFirstColumns.has(column)
|
||||||
|
? props.sortDirection === 'desc'
|
||||||
|
: props.sortDirection === 'asc';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isChevronUp(column: SortColumn): boolean {
|
||||||
|
if (!isSorted(column)) return false;
|
||||||
|
return !isChevronDown(column);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TableHeading>
|
<TableHeading>
|
||||||
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
<div
|
||||||
|
class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
||||||
|
@click="handleSort('name')">
|
||||||
Name
|
Name
|
||||||
|
<ChevronDownIcon v-if="isChevronDown('name')" class="w-4 h-4" />
|
||||||
|
<ChevronUpIcon v-else-if="isChevronUp('name')" class="w-4 h-4" />
|
||||||
|
<span v-else class="w-4 h-4"></span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
||||||
|
@click="handleSort('projects_count')">
|
||||||
|
Projects
|
||||||
|
<ChevronDownIcon v-if="isChevronDown('projects_count')" class="w-4 h-4" />
|
||||||
|
<ChevronUpIcon v-else-if="isChevronUp('projects_count')" class="w-4 h-4" />
|
||||||
|
<span v-else class="w-4 h-4"></span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
||||||
|
@click="handleSort('status')">
|
||||||
|
Status
|
||||||
|
<ChevronDownIcon v-if="isChevronDown('status')" class="w-4 h-4" />
|
||||||
|
<ChevronUpIcon v-else-if="isChevronUp('status')" class="w-4 h-4" />
|
||||||
|
<span v-else class="w-4 h-4"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-3 py-1.5 text-left text-text-tertiary"></div>
|
|
||||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Status</div>
|
|
||||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||||
<span class="sr-only">Edit</span>
|
<span class="sr-only">Edit</span>
|
||||||
</div>
|
</div>
|
||||||
</TableHeading>
|
</TableHeading>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
|
|||||||
@@ -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 } from '@heroicons/vue/20/solid';
|
import { CheckCircleIcon, ArchiveBoxIcon } from '@heroicons/vue/24/outline';
|
||||||
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,19 +36,25 @@ const showEditModal = ref(false);
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<ClientEditModal v-model:show="showEditModal" :client="client"></ClientEditModal>
|
<ClientEditModal v-model:show="showEditModal" :client="client"></ClientEditModal>
|
||||||
<div
|
<div
|
||||||
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
class="whitespace-nowrap flex items-center space-x-5 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||||
<span>
|
<span>
|
||||||
{{ client.name }}
|
{{ client.name }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
class="whitespace-nowrap flex items-center px-3 py-4 text-sm font-medium text-text-primary">
|
||||||
<span class="text-text-secondary"> {{ projectCount }} Projects </span>
|
<span class="text-text-secondary"> {{ projectCount }} Projects </span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1 items-center font-medium">
|
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
|
||||||
<CheckCircleIcon class="w-5"></CheckCircleIcon>
|
<template v-if="client.is_archived">
|
||||||
|
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
|
||||||
|
<span>Archived</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
|
||||||
<span>Active</span>
|
<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">
|
||||||
|
|||||||
@@ -1,20 +1,35 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import type { Member, UpdateMemberBody } from '@/packages/api/src';
|
import type { Member, UpdateMemberBody } from '@/packages/api/src';
|
||||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||||
import { type MemberBillableKey, useMembersStore } from '@/utils/useMembers';
|
import { type MemberBillableKey, useMembersStore } from '@/utils/useMembers';
|
||||||
import BillableRateInput from '@/packages/ui/src/Input/BillableRateInput.vue';
|
import BillableRateInput from '@/packages/ui/src/Input/BillableRateInput.vue';
|
||||||
import { Field, FieldLabel } from '@/packages/ui/src/field';
|
import { Field, FieldLabel, FieldDescription } from '@/packages/ui/src/field';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/Components/ui/select';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/packages/ui/src/tooltip';
|
||||||
import MemberBillableRateModal from '@/Components/Common/Member/MemberBillableRateModal.vue';
|
import MemberBillableRateModal from '@/Components/Common/Member/MemberBillableRateModal.vue';
|
||||||
import MemberBillableSelect from '@/Components/Common/Member/MemberBillableSelect.vue';
|
|
||||||
import { onMounted, watch } from 'vue';
|
|
||||||
import MemberRoleSelect from '@/Components/Common/Member/MemberRoleSelect.vue';
|
import MemberRoleSelect from '@/Components/Common/Member/MemberRoleSelect.vue';
|
||||||
import MemberOwnershipTransferConfirmModal from '@/Components/Common/Member/MemberOwnershipTransferConfirmModal.vue';
|
import MemberOwnershipTransferConfirmModal from '@/Components/Common/Member/MemberOwnershipTransferConfirmModal.vue';
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
|
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||||
|
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
|
||||||
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
|
|
||||||
const { updateMember } = useMembersStore();
|
const { updateMember } = useMembersStore();
|
||||||
|
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
|
||||||
const show = defineModel('show', { default: false });
|
const show = defineModel('show', { default: false });
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
|
|
||||||
@@ -75,8 +90,24 @@ watch(billableRateSelect, () => {
|
|||||||
if (billableRateSelect.value === 'default-rate') {
|
if (billableRateSelect.value === 'default-rate') {
|
||||||
memberBody.value.billable_rate = null;
|
memberBody.value.billable_rate = null;
|
||||||
} else if (billableRateSelect.value === 'custom-rate') {
|
} else if (billableRateSelect.value === 'custom-rate') {
|
||||||
memberBody.value.billable_rate = props.member.billable_rate ?? 0;
|
if (!memberBody.value.billable_rate) {
|
||||||
|
memberBody.value.billable_rate = organization.value?.billable_rate ?? 0;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayedRate = computed({
|
||||||
|
get() {
|
||||||
|
if (billableRateSelect.value === 'default-rate') {
|
||||||
|
return organization.value?.billable_rate ?? null;
|
||||||
|
}
|
||||||
|
return memberBody.value.billable_rate;
|
||||||
|
},
|
||||||
|
set(value: number | null) {
|
||||||
|
if (billableRateSelect.value === 'custom-rate') {
|
||||||
|
memberBody.value.billable_rate = value;
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const roleDescriptionTexts = {
|
const roleDescriptionTexts = {
|
||||||
@@ -120,34 +151,55 @@ const roleDescription = computed(() => {
|
|||||||
|
|
||||||
<template #content>
|
<template #content>
|
||||||
<div class="pb-5 pt-2 divide-y divide-border-secondary">
|
<div class="pb-5 pt-2 divide-y divide-border-secondary">
|
||||||
<div class="pb-5 flex space-x-6">
|
<div class="pb-5">
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel for="role">Role</FieldLabel>
|
<FieldLabel for="role">Role</FieldLabel>
|
||||||
<MemberRoleSelect v-model="memberBody.role" name="role"></MemberRoleSelect>
|
<MemberRoleSelect v-model="memberBody.role" name="role"></MemberRoleSelect>
|
||||||
|
<FieldDescription v-if="roleDescription">{{
|
||||||
|
roleDescription
|
||||||
|
}}</FieldDescription>
|
||||||
</Field>
|
</Field>
|
||||||
<div class="flex-1 text-xs flex items-center pt-6">
|
|
||||||
<p>{{ roleDescription }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="pt-5">
|
||||||
<div class="flex items-center space-x-4 pt-5">
|
|
||||||
<div class="col-span-6 sm:col-span-4 flex-1 flex space-x-5">
|
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel for="billableType">Billable</FieldLabel>
|
<FieldLabel :icon="BillableIcon" for="billableRateType"
|
||||||
<MemberBillableSelect
|
>Billable Rate</FieldLabel
|
||||||
v-model="billableRateSelect"
|
>
|
||||||
name="billableType"></MemberBillableSelect>
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
</Field>
|
<Select v-model="billableRateSelect">
|
||||||
<Field v-if="billableRateSelect === 'custom-rate'" class="flex-1">
|
<SelectTrigger id="billableRateType">
|
||||||
<FieldLabel for="memberBillableRate">Billable Rate</FieldLabel>
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="default-rate">Default Rate</SelectItem>
|
||||||
|
<SelectItem value="custom-rate">Custom Rate</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<TooltipProvider v-if="billableRateSelect === 'default-rate'">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<div>
|
||||||
<BillableRateInput
|
<BillableRateInput
|
||||||
v-model="memberBody.billable_rate"
|
v-model="displayedRate"
|
||||||
|
:currency="getOrganizationCurrencyString()"
|
||||||
|
disabled
|
||||||
|
name="memberBillableRate" />
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
>Uses the default rate of the organization</TooltipContent
|
||||||
|
>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<BillableRateInput
|
||||||
|
v-else
|
||||||
|
v-model="displayedRate"
|
||||||
focus
|
focus
|
||||||
class="w-full"
|
|
||||||
:currency="getOrganizationCurrencyString()"
|
:currency="getOrganizationCurrencyString()"
|
||||||
name="memberBillableRate"
|
name="memberBillableRate"
|
||||||
@keydown.enter="saveWithChecks()"></BillableRateInput>
|
@keydown.enter="saveWithChecks()" />
|
||||||
</Field>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,19 +2,117 @@
|
|||||||
import MemberTableHeading from '@/Components/Common/Member/MemberTableHeading.vue';
|
import MemberTableHeading from '@/Components/Common/Member/MemberTableHeading.vue';
|
||||||
import MemberTableRow from '@/Components/Common/Member/MemberTableRow.vue';
|
import MemberTableRow from '@/Components/Common/Member/MemberTableRow.vue';
|
||||||
import { useMembersQuery } from '@/utils/useMembersQuery';
|
import { useMembersQuery } from '@/utils/useMembersQuery';
|
||||||
|
import type { Member } from '@/packages/api/src';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import {
|
||||||
|
useVueTable,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
type SortingState,
|
||||||
|
} from '@tanstack/vue-table';
|
||||||
|
|
||||||
|
export type SortColumn = 'name' | 'email' | 'role' | 'billable_rate' | 'status';
|
||||||
|
export type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
sortColumn: SortColumn;
|
||||||
|
sortDirection: SortDirection;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
sort: [column: SortColumn, direction: SortDirection];
|
||||||
|
}>();
|
||||||
|
|
||||||
const { members } = useMembersQuery();
|
const { members } = useMembersQuery();
|
||||||
|
|
||||||
|
const roleOrder: Record<string, number> = {
|
||||||
|
owner: 0,
|
||||||
|
admin: 1,
|
||||||
|
manager: 2,
|
||||||
|
employee: 3,
|
||||||
|
placeholder: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sorting = computed<SortingState>(() => [
|
||||||
|
{
|
||||||
|
id: props.sortColumn,
|
||||||
|
desc: props.sortDirection === 'desc',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
accessorFn: (row: Member) => row.name.toLowerCase(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'email',
|
||||||
|
accessorFn: (row: Member) => row.email.toLowerCase(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'role',
|
||||||
|
accessorFn: (row: Member) => roleOrder[row.role] ?? 99,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'billable_rate',
|
||||||
|
sortDescFirst: true,
|
||||||
|
sortUndefined: 'last' as const,
|
||||||
|
accessorFn: (row: Member) => {
|
||||||
|
if (row.billable_rate === null) return undefined;
|
||||||
|
return row.billable_rate;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
accessorFn: (row: Member) => (row.is_placeholder ? 1 : 0),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const descFirstColumns = new Set<SortColumn>(
|
||||||
|
columns.filter((c) => c.sortDescFirst).map((c) => c.id as SortColumn)
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleSort(column: SortColumn) {
|
||||||
|
if (props.sortColumn === column) {
|
||||||
|
emit('sort', column, props.sortDirection === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
emit('sort', column, descFirstColumns.has(column) ? 'desc' : 'asc');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = useVueTable({
|
||||||
|
get data() {
|
||||||
|
return members.value;
|
||||||
|
},
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
state: {
|
||||||
|
get sorting() {
|
||||||
|
return sorting.value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
manualSorting: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedMembers = computed(() => {
|
||||||
|
return table.getRowModel().rows.map((row) => row.original);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flow-root max-w-[100vw] overflow-x-auto">
|
<div class="flow-root max-w-[100vw] overflow-x-auto">
|
||||||
<div class="inline-block min-w-full align-middle">
|
<div class="inline-block min-w-full align-middle">
|
||||||
<div
|
<div
|
||||||
data-testid="client_table"
|
data-testid="member_table"
|
||||||
class="grid min-w-full"
|
class="grid min-w-full"
|
||||||
style="grid-template-columns: 1fr 1fr 180px 180px 150px 130px">
|
style="grid-template-columns: 1fr 1fr 180px 180px 150px 130px">
|
||||||
<MemberTableHeading></MemberTableHeading>
|
<MemberTableHeading
|
||||||
<template v-for="member in members" :key="member.id">
|
:sort-column="props.sortColumn"
|
||||||
|
:sort-direction="props.sortDirection"
|
||||||
|
:desc-first-columns="descFirstColumns"
|
||||||
|
@sort="handleSort"></MemberTableHeading>
|
||||||
|
<template v-for="member in sortedMembers" :key="member.id">
|
||||||
<MemberTableRow :member="member"></MemberTableRow>
|
<MemberTableRow :member="member"></MemberTableRow>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,83 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import TableHeading from '@/Components/Common/TableHeading.vue';
|
import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||||
|
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
|
||||||
|
import type { SortColumn, SortDirection } from '@/Components/Common/Member/MemberTable.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
sortColumn: SortColumn;
|
||||||
|
sortDirection: SortDirection;
|
||||||
|
descFirstColumns: ReadonlySet<SortColumn>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
sort: [column: SortColumn];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function handleSort(column: SortColumn) {
|
||||||
|
emit('sort', column);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSorted(column: SortColumn): boolean {
|
||||||
|
return props.sortColumn === column;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isChevronDown(column: SortColumn): boolean {
|
||||||
|
if (!isSorted(column)) return false;
|
||||||
|
return props.descFirstColumns.has(column)
|
||||||
|
? props.sortDirection === 'desc'
|
||||||
|
: props.sortDirection === 'asc';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isChevronUp(column: SortColumn): boolean {
|
||||||
|
if (!isSorted(column)) return false;
|
||||||
|
return !isChevronDown(column);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TableHeading>
|
<TableHeading>
|
||||||
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
<div
|
||||||
|
class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
||||||
|
@click="handleSort('name')">
|
||||||
Name
|
Name
|
||||||
|
<ChevronDownIcon v-if="isChevronDown('name')" class="w-4 h-4" />
|
||||||
|
<ChevronUpIcon v-else-if="isChevronUp('name')" class="w-4 h-4" />
|
||||||
|
<span v-else class="w-4 h-4"></span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
||||||
|
@click="handleSort('email')">
|
||||||
|
Email
|
||||||
|
<ChevronDownIcon v-if="isChevronDown('email')" class="w-4 h-4" />
|
||||||
|
<ChevronUpIcon v-else-if="isChevronUp('email')" class="w-4 h-4" />
|
||||||
|
<span v-else class="w-4 h-4"></span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
||||||
|
@click="handleSort('role')">
|
||||||
|
Role
|
||||||
|
<ChevronDownIcon v-if="isChevronDown('role')" class="w-4 h-4" />
|
||||||
|
<ChevronUpIcon v-else-if="isChevronUp('role')" class="w-4 h-4" />
|
||||||
|
<span v-else class="w-4 h-4"></span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
||||||
|
@click="handleSort('billable_rate')">
|
||||||
|
Billable Rate
|
||||||
|
<ChevronDownIcon v-if="isChevronDown('billable_rate')" class="w-4 h-4" />
|
||||||
|
<ChevronUpIcon v-else-if="isChevronUp('billable_rate')" class="w-4 h-4" />
|
||||||
|
<span v-else class="w-4 h-4"></span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
||||||
|
@click="handleSort('status')">
|
||||||
|
Status
|
||||||
|
<ChevronDownIcon v-if="isChevronDown('status')" class="w-4 h-4" />
|
||||||
|
<ChevronUpIcon v-else-if="isChevronUp('status')" class="w-4 h-4" />
|
||||||
|
<span v-else class="w-4 h-4"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Email</div>
|
|
||||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Role</div>
|
|
||||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Billable Rate</div>
|
|
||||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Status</div>
|
|
||||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
|
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
|
||||||
<span class="sr-only">Edit</span>
|
<span class="sr-only">Edit</span>
|
||||||
</div>
|
</div>
|
||||||
</TableHeading>
|
</TableHeading>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Member, Organization } from '@/packages/api/src';
|
import type { Member, Organization } from '@/packages/api/src';
|
||||||
import { api } from '@/packages/api/src';
|
import { api } from '@/packages/api/src';
|
||||||
import { CheckCircleIcon, UserCircleIcon } from '@heroicons/vue/20/solid';
|
import { CheckCircleIcon, UserCircleIcon } from '@heroicons/vue/24/outline';
|
||||||
import MemberMoreOptionsDropdown from '@/Components/Common/Member/MemberMoreOptionsDropdown.vue';
|
import MemberMoreOptionsDropdown from '@/Components/Common/Member/MemberMoreOptionsDropdown.vue';
|
||||||
import TableRow from '@/Components/TableRow.vue';
|
import TableRow from '@/Components/TableRow.vue';
|
||||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
@@ -82,11 +82,15 @@ const userHasValidMailAddress = computed(() => {
|
|||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1 items-center font-medium">
|
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
|
||||||
<CheckCircleIcon v-if="member.is_placeholder === false" class="w-5"></CheckCircleIcon>
|
<template v-if="member.is_placeholder === false">
|
||||||
<span v-if="member.is_placeholder === false">Active</span>
|
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
|
||||||
<UserCircleIcon v-if="member.is_placeholder === true" class="w-5"></UserCircleIcon>
|
<span>Active</span>
|
||||||
<span v-if="member.is_placeholder === true">Inactive</span>
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<UserCircleIcon class="w-4 text-icon-default"></UserCircleIcon>
|
||||||
|
<span>Inactive</span>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||||
|
|||||||
@@ -4,11 +4,17 @@ import { FolderPlusIcon } from '@heroicons/vue/24/solid';
|
|||||||
import { PlusIcon } from '@heroicons/vue/16/solid';
|
import { PlusIcon } from '@heroicons/vue/16/solid';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
||||||
import ProjectTableHeading, {
|
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
|
||||||
type SortColumn,
|
|
||||||
type SortDirection,
|
|
||||||
} from '@/Components/Common/Project/ProjectTableHeading.vue';
|
|
||||||
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
|
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
|
||||||
|
|
||||||
|
export type SortColumn =
|
||||||
|
| 'name'
|
||||||
|
| 'client_name'
|
||||||
|
| 'spent_time'
|
||||||
|
| 'progress'
|
||||||
|
| 'billable_rate'
|
||||||
|
| 'status';
|
||||||
|
export type SortDirection = 'asc' | 'desc';
|
||||||
import { canCreateProjects } from '@/utils/permissions';
|
import { canCreateProjects } from '@/utils/permissions';
|
||||||
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
|
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
|
||||||
import { useProjectsStore } from '@/utils/useProjects';
|
import { useProjectsStore } from '@/utils/useProjects';
|
||||||
@@ -31,7 +37,7 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
sort: [column: SortColumn];
|
sort: [column: SortColumn, direction: SortDirection];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { clients } = useClientsQuery();
|
const { clients } = useClientsQuery();
|
||||||
@@ -45,7 +51,7 @@ const clientNameMap = computed(() => {
|
|||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convert our sort state to TanStack Table format
|
// Convert sort props to TanStack Table format
|
||||||
const sorting = computed<SortingState>(() => [
|
const sorting = computed<SortingState>(() => [
|
||||||
{
|
{
|
||||||
id: props.sortColumn,
|
id: props.sortColumn,
|
||||||
@@ -53,38 +59,67 @@ const sorting = computed<SortingState>(() => [
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Define column accessors for sorting
|
// Define column accessors for sorting.
|
||||||
const columns = [
|
// Numeric columns use sortDescFirst so that the first click (chevron down) sorts highest-first,
|
||||||
|
// while text columns default to ascending (A-Z) on first click (chevron down).
|
||||||
|
const columns = computed(() => [
|
||||||
{
|
{
|
||||||
id: 'name',
|
id: 'name',
|
||||||
accessorFn: (row: Project) => row.name.toLowerCase(),
|
accessorFn: (row: Project) => row.name.toLowerCase(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'client_name',
|
id: 'client_name',
|
||||||
|
sortUndefined: 'last' as const,
|
||||||
accessorFn: (row: Project) => {
|
accessorFn: (row: Project) => {
|
||||||
if (!row.client_id) return '';
|
if (!row.client_id) return undefined;
|
||||||
return (clientNameMap.value.get(row.client_id) ?? '').toLowerCase();
|
return (clientNameMap.value.get(row.client_id) ?? '').toLowerCase();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'spent_time',
|
id: 'spent_time',
|
||||||
|
sortDescFirst: true,
|
||||||
accessorFn: (row: Project) => row.spent_time ?? 0,
|
accessorFn: (row: Project) => row.spent_time ?? 0,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'progress',
|
||||||
|
sortDescFirst: true,
|
||||||
|
sortUndefined: 'last' as const,
|
||||||
|
accessorFn: (row: Project) => {
|
||||||
|
if (!row.estimated_time) return undefined;
|
||||||
|
return (row.spent_time / row.estimated_time) * 100;
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'billable_rate',
|
id: 'billable_rate',
|
||||||
|
sortDescFirst: true,
|
||||||
accessorFn: (row: Project) => row.billable_rate ?? 0,
|
accessorFn: (row: Project) => row.billable_rate ?? 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'status',
|
id: 'status',
|
||||||
accessorFn: (row: Project) => (row.is_archived ? 1 : 0),
|
accessorFn: (row: Project) => (row.is_archived ? 1 : 0),
|
||||||
},
|
},
|
||||||
];
|
]);
|
||||||
|
|
||||||
|
// Columns with sortDescFirst get desc as default direction on first click.
|
||||||
|
const descFirstColumns = new Set<SortColumn>(
|
||||||
|
columns.value.filter((c) => c.sortDescFirst).map((c) => c.id as SortColumn)
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleSort(column: SortColumn) {
|
||||||
|
if (props.sortColumn === column) {
|
||||||
|
emit('sort', column, props.sortDirection === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
emit('sort', column, descFirstColumns.has(column) ? 'desc' : 'asc');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const table = useVueTable({
|
const table = useVueTable({
|
||||||
get data() {
|
get data() {
|
||||||
return props.projects;
|
return props.projects;
|
||||||
},
|
},
|
||||||
columns,
|
get columns() {
|
||||||
|
return columns.value;
|
||||||
|
},
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
state: {
|
state: {
|
||||||
@@ -99,10 +134,6 @@ const sortedProjects = computed(() => {
|
|||||||
return table.getRowModel().rows.map((row) => row.original);
|
return table.getRowModel().rows.map((row) => row.original);
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleSort(column: SortColumn) {
|
|
||||||
emit('sort', column);
|
|
||||||
}
|
|
||||||
|
|
||||||
const showCreateProjectModal = ref(false);
|
const showCreateProjectModal = ref(false);
|
||||||
|
|
||||||
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
|
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
|
||||||
@@ -133,6 +164,7 @@ const gridTemplate = computed(() => {
|
|||||||
:show-billable-rate="props.showBillableRate"
|
:show-billable-rate="props.showBillableRate"
|
||||||
:sort-column="props.sortColumn"
|
:sort-column="props.sortColumn"
|
||||||
:sort-direction="props.sortDirection"
|
:sort-direction="props.sortDirection"
|
||||||
|
:desc-first-columns="descFirstColumns"
|
||||||
@sort="handleSort"></ProjectTableHeading>
|
@sort="handleSort"></ProjectTableHeading>
|
||||||
<div v-if="sortedProjects.length === 0" class="col-span-5 py-24 text-center">
|
<div v-if="sortedProjects.length === 0" class="col-span-5 py-24 text-center">
|
||||||
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
|
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import TableHeading from '@/Components/Common/TableHeading.vue';
|
import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||||
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
|
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
|
||||||
|
import type { SortColumn, SortDirection } from '@/Components/Common/Project/ProjectTable.vue';
|
||||||
export type SortColumn = 'name' | 'client_name' | 'spent_time' | 'billable_rate' | 'status';
|
|
||||||
export type SortDirection = 'asc' | 'desc';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
showBillableRate: boolean;
|
showBillableRate: boolean;
|
||||||
sortColumn: SortColumn;
|
sortColumn: SortColumn;
|
||||||
sortDirection: SortDirection;
|
sortDirection: SortDirection;
|
||||||
|
descFirstColumns: ReadonlySet<SortColumn>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -22,6 +21,18 @@ function handleSort(column: SortColumn) {
|
|||||||
function isSorted(column: SortColumn): boolean {
|
function isSorted(column: SortColumn): boolean {
|
||||||
return props.sortColumn === column;
|
return props.sortColumn === column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isChevronDown(column: SortColumn): boolean {
|
||||||
|
if (!isSorted(column)) return false;
|
||||||
|
return props.descFirstColumns.has(column)
|
||||||
|
? props.sortDirection === 'desc'
|
||||||
|
: props.sortDirection === 'asc';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isChevronUp(column: SortColumn): boolean {
|
||||||
|
if (!isSorted(column)) return false;
|
||||||
|
return !isChevronDown(column);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -30,58 +41,49 @@ function isSorted(column: SortColumn): boolean {
|
|||||||
class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
||||||
@click="handleSort('name')">
|
@click="handleSort('name')">
|
||||||
Name
|
Name
|
||||||
<ChevronDownIcon v-if="isSorted('name') && sortDirection === 'asc'" class="w-4 h-4" />
|
<ChevronDownIcon v-if="isChevronDown('name')" class="w-4 h-4" />
|
||||||
<ChevronUpIcon
|
<ChevronUpIcon v-else-if="isChevronUp('name')" class="w-4 h-4" />
|
||||||
v-else-if="isSorted('name') && sortDirection === 'desc'"
|
|
||||||
class="w-4 h-4" />
|
|
||||||
<span v-else class="w-4 h-4"></span>
|
<span v-else class="w-4 h-4"></span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
||||||
@click="handleSort('client_name')">
|
@click="handleSort('client_name')">
|
||||||
Client
|
Client
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon v-if="isChevronDown('client_name')" class="w-4 h-4" />
|
||||||
v-if="isSorted('client_name') && sortDirection === 'asc'"
|
<ChevronUpIcon v-else-if="isChevronUp('client_name')" class="w-4 h-4" />
|
||||||
class="w-4 h-4" />
|
|
||||||
<ChevronUpIcon
|
|
||||||
v-else-if="isSorted('client_name') && sortDirection === 'desc'"
|
|
||||||
class="w-4 h-4" />
|
|
||||||
<span v-else class="w-4 h-4"></span>
|
<span v-else class="w-4 h-4"></span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
||||||
@click="handleSort('spent_time')">
|
@click="handleSort('spent_time')">
|
||||||
Total Time
|
Total Time
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon v-if="isChevronDown('spent_time')" class="w-4 h-4" />
|
||||||
v-if="isSorted('spent_time') && sortDirection === 'asc'"
|
<ChevronUpIcon v-else-if="isChevronUp('spent_time')" class="w-4 h-4" />
|
||||||
class="w-4 h-4" />
|
<span v-else class="w-4 h-4"></span>
|
||||||
<ChevronUpIcon
|
</div>
|
||||||
v-else-if="isSorted('spent_time') && sortDirection === 'desc'"
|
<div
|
||||||
class="w-4 h-4" />
|
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
||||||
|
@click="handleSort('progress')">
|
||||||
|
Progress
|
||||||
|
<ChevronDownIcon v-if="isChevronDown('progress')" class="w-4 h-4" />
|
||||||
|
<ChevronUpIcon v-else-if="isChevronUp('progress')" class="w-4 h-4" />
|
||||||
<span v-else class="w-4 h-4"></span>
|
<span v-else class="w-4 h-4"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Progress</div>
|
|
||||||
<div
|
<div
|
||||||
v-if="showBillableRate"
|
v-if="showBillableRate"
|
||||||
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
||||||
@click="handleSort('billable_rate')">
|
@click="handleSort('billable_rate')">
|
||||||
Billable Rate
|
Billable Rate
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon v-if="isChevronDown('billable_rate')" class="w-4 h-4" />
|
||||||
v-if="isSorted('billable_rate') && sortDirection === 'asc'"
|
<ChevronUpIcon v-else-if="isChevronUp('billable_rate')" class="w-4 h-4" />
|
||||||
class="w-4 h-4" />
|
|
||||||
<ChevronUpIcon
|
|
||||||
v-else-if="isSorted('billable_rate') && sortDirection === 'desc'"
|
|
||||||
class="w-4 h-4" />
|
|
||||||
<span v-else class="w-4 h-4"></span>
|
<span v-else class="w-4 h-4"></span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
||||||
@click="handleSort('status')">
|
@click="handleSort('status')">
|
||||||
Status
|
Status
|
||||||
<ChevronDownIcon v-if="isSorted('status') && sortDirection === 'asc'" class="w-4 h-4" />
|
<ChevronDownIcon v-if="isChevronDown('status')" class="w-4 h-4" />
|
||||||
<ChevronUpIcon
|
<ChevronUpIcon v-else-if="isChevronUp('status')" class="w-4 h-4" />
|
||||||
v-else-if="isSorted('status') && sortDirection === 'desc'"
|
|
||||||
class="w-4 h-4" />
|
|
||||||
<span v-else class="w-4 h-4"></span>
|
<span v-else class="w-4 h-4"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ref } from 'vue';
|
|||||||
import PrimaryButton from '../../../packages/ui/src/Buttons/PrimaryButton.vue';
|
import PrimaryButton from '../../../packages/ui/src/Buttons/PrimaryButton.vue';
|
||||||
import { Field, FieldLabel } from '@/packages/ui/src/field';
|
import { Field, FieldLabel } from '@/packages/ui/src/field';
|
||||||
import type { CreateReportBody, CreateReportBodyProperties } from '@/packages/api/src';
|
import type { CreateReportBody, CreateReportBodyProperties } from '@/packages/api/src';
|
||||||
import { useMutation } from '@tanstack/vue-query';
|
import { useMutation, useQueryClient } from '@tanstack/vue-query';
|
||||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
import { api } from '@/packages/api/src';
|
import { api } from '@/packages/api/src';
|
||||||
import { Checkbox } from '@/packages/ui/src';
|
import { Checkbox } from '@/packages/ui/src';
|
||||||
@@ -17,6 +17,7 @@ import { router } from '@inertiajs/vue3';
|
|||||||
|
|
||||||
const show = defineModel('show', { default: false });
|
const show = defineModel('show', { default: false });
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const createReportMutation = useMutation({
|
const createReportMutation = useMutation({
|
||||||
mutationFn: async (report: CreateReportBody) => {
|
mutationFn: async (report: CreateReportBody) => {
|
||||||
@@ -30,6 +31,11 @@ const createReportMutation = useMutation({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['reports'],
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ChartBarIcon } from '@heroicons/vue/20/solid';
|
import {
|
||||||
|
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,
|
||||||
@@ -8,17 +14,25 @@ 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 {
|
||||||
@@ -143,8 +157,18 @@ const aggregatedTableTimeEntries = computed<AggregatedTimeEntries | undefined>((
|
|||||||
});
|
});
|
||||||
|
|
||||||
const reportProperties = computed(() => {
|
const reportProperties = computed(() => {
|
||||||
|
const { billable: billableFilter, ...rest } = filterParams.value;
|
||||||
|
|
||||||
|
let billableValue: boolean | null = null;
|
||||||
|
if (billableFilter === 'true') {
|
||||||
|
billableValue = true;
|
||||||
|
} else if (billableFilter === 'false') {
|
||||||
|
billableValue = false;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...filterParams.value,
|
...rest,
|
||||||
|
billable: billableValue,
|
||||||
group: group.value,
|
group: group.value,
|
||||||
sub_group: subGroup.value,
|
sub_group: subGroup.value,
|
||||||
history_group: getOptimalGroupingOption(startDate.value, endDate.value),
|
history_group: getOptimalGroupingOption(startDate.value, endDate.value),
|
||||||
@@ -182,6 +206,28 @@ 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 (
|
||||||
@@ -240,16 +286,79 @@ 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="py-3 sm:py-5 border-b border-default-background-separator flex justify-between items-center">
|
class="h-14 sm:h-16 border-b border-default-background-separator flex flex-wrap gap-y-3 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>
|
||||||
|
</div>
|
||||||
|
<div class="hidden sm:flex space-x-2">
|
||||||
|
<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>
|
||||||
|
<SecondaryButton v-if="canCreateReports()" :icon="SaveIcon" @click="onSaveReportClick">
|
||||||
|
Save Report
|
||||||
|
</SecondaryButton>
|
||||||
|
</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>
|
<ReportingTabNavbar active="reporting"></ReportingTabNavbar>
|
||||||
</div>
|
|
||||||
<div class="flex space-x-2">
|
|
||||||
<ReportingExportButton :download="downloadExport"></ReportingExportButton>
|
|
||||||
<ReportSaveButton :report-properties="reportProperties"></ReportSaveButton>
|
|
||||||
</div>
|
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
<ReportingFilterBar
|
<ReportingFilterBar
|
||||||
v-model:selected-members="selectedMembers"
|
v-model:selected-members="selectedMembers"
|
||||||
@@ -328,8 +437,7 @@ const tableData = computed(() => {
|
|||||||
v-if="showBillableRate"
|
v-if="showBillableRate"
|
||||||
class="justify-end pr-6 flex items-center font-medium">
|
class="justify-end pr-6 flex items-center font-medium">
|
||||||
{{
|
{{
|
||||||
aggregatedTableTimeEntries.cost !== null &&
|
aggregatedTableTimeEntries.cost
|
||||||
aggregatedTableTimeEntries.cost !== undefined
|
|
||||||
? formatCents(
|
? formatCents(
|
||||||
aggregatedTableTimeEntries.cost,
|
aggregatedTableTimeEntries.cost,
|
||||||
getOrganizationCurrencyString(),
|
getOrganizationCurrencyString(),
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const props = defineProps<{
|
|||||||
:icon="icon"
|
:icon="icon"
|
||||||
:class="
|
:class="
|
||||||
twMerge(
|
twMerge(
|
||||||
'rounded-md px-2 sm:px-3 py-1 border sm:py-1.5 text-xs sm:text-sm font-medium text-text-tertiary hover:text-text-primary focus-visible:outline-none data-[state=active]:bg-tab-background data-[state=active]:border-input-border data-[state=active]:text-text-primary border-tab-border',
|
'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',
|
||||||
props.class
|
props.class
|
||||||
)
|
)
|
||||||
">
|
">
|
||||||
|
|||||||
@@ -2,18 +2,80 @@
|
|||||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
import { FolderPlusIcon } from '@heroicons/vue/24/solid';
|
import { FolderPlusIcon } from '@heroicons/vue/24/solid';
|
||||||
import { PlusIcon } from '@heroicons/vue/16/solid';
|
import { PlusIcon } from '@heroicons/vue/16/solid';
|
||||||
import { ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useTagsQuery } from '@/utils/useTagsQuery';
|
import { useTagsQuery } from '@/utils/useTagsQuery';
|
||||||
import TagTableRow from '@/Components/Common/Tag/TagTableRow.vue';
|
import TagTableRow from '@/Components/Common/Tag/TagTableRow.vue';
|
||||||
import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
|
import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
|
||||||
import TagTableHeading from '@/Components/Common/Tag/TagTableHeading.vue';
|
import TagTableHeading from '@/Components/Common/Tag/TagTableHeading.vue';
|
||||||
import { canCreateTags } from '@/utils/permissions';
|
import { canCreateTags } from '@/utils/permissions';
|
||||||
import type { Tag } from '@/packages/api/src';
|
import type { Tag } from '@/packages/api/src';
|
||||||
defineProps<{
|
import {
|
||||||
|
useVueTable,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
type SortingState,
|
||||||
|
} from '@tanstack/vue-table';
|
||||||
|
|
||||||
|
export type SortColumn = 'name';
|
||||||
|
export type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
createTag: (name: string) => Promise<Tag | undefined>;
|
createTag: (name: string) => Promise<Tag | undefined>;
|
||||||
|
sortColumn: SortColumn;
|
||||||
|
sortDirection: SortDirection;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
sort: [column: SortColumn, direction: SortDirection];
|
||||||
|
}>();
|
||||||
|
|
||||||
const { tags } = useTagsQuery();
|
const { tags } = useTagsQuery();
|
||||||
const showCreateTagModal = ref(false);
|
const showCreateTagModal = ref(false);
|
||||||
|
|
||||||
|
const sorting = computed<SortingState>(() => [
|
||||||
|
{
|
||||||
|
id: props.sortColumn,
|
||||||
|
desc: props.sortDirection === 'desc',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
accessorFn: (row: Tag) => row.name.toLowerCase(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const descFirstColumns = new Set<SortColumn>(
|
||||||
|
columns.filter((c) => 'sortDescFirst' in c && c.sortDescFirst).map((c) => c.id as SortColumn)
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleSort(column: SortColumn) {
|
||||||
|
if (props.sortColumn === column) {
|
||||||
|
emit('sort', column, props.sortDirection === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
emit('sort', column, descFirstColumns.has(column) ? 'desc' : 'asc');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = useVueTable({
|
||||||
|
get data() {
|
||||||
|
return tags.value;
|
||||||
|
},
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
state: {
|
||||||
|
get sorting() {
|
||||||
|
return sorting.value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
manualSorting: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedTags = computed(() => {
|
||||||
|
return table.getRowModel().rows.map((row) => row.original);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -24,8 +86,12 @@ const showCreateTagModal = ref(false);
|
|||||||
data-testid="tag_table"
|
data-testid="tag_table"
|
||||||
class="grid min-w-full"
|
class="grid min-w-full"
|
||||||
style="grid-template-columns: 1fr 80px">
|
style="grid-template-columns: 1fr 80px">
|
||||||
<TagTableHeading></TagTableHeading>
|
<TagTableHeading
|
||||||
<div v-if="tags.length === 0" class="col-span-5 py-24 text-center">
|
:sort-column="props.sortColumn"
|
||||||
|
:sort-direction="props.sortDirection"
|
||||||
|
:desc-first-columns="descFirstColumns"
|
||||||
|
@sort="handleSort"></TagTableHeading>
|
||||||
|
<div v-if="sortedTags.length === 0" class="col-span-5 py-24 text-center">
|
||||||
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
|
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
|
||||||
<h3 class="text-text-primary font-semibold">No tags found</h3>
|
<h3 class="text-text-primary font-semibold">No tags found</h3>
|
||||||
<p v-if="canCreateTags()" class="pb-5">Create your first tag now!</p>
|
<p v-if="canCreateTags()" class="pb-5">Create your first tag now!</p>
|
||||||
@@ -36,7 +102,7 @@ const showCreateTagModal = ref(false);
|
|||||||
>Create your First Tag</SecondaryButton
|
>Create your First Tag</SecondaryButton
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<template v-for="tag in tags" :key="tag.id">
|
<template v-for="tag in sortedTags" :key="tag.id">
|
||||||
<TagTableRow :tag="tag"></TagTableRow>
|
<TagTableRow :tag="tag"></TagTableRow>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,51 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import TableHeading from '@/Components/Common/TableHeading.vue';
|
import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||||
|
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
|
||||||
|
import type { SortColumn, SortDirection } from '@/Components/Common/Tag/TagTable.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
sortColumn: SortColumn;
|
||||||
|
sortDirection: SortDirection;
|
||||||
|
descFirstColumns: ReadonlySet<SortColumn>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
sort: [column: SortColumn];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function handleSort(column: SortColumn) {
|
||||||
|
emit('sort', column);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSorted(column: SortColumn): boolean {
|
||||||
|
return props.sortColumn === column;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isChevronDown(column: SortColumn): boolean {
|
||||||
|
if (!isSorted(column)) return false;
|
||||||
|
return props.descFirstColumns.has(column)
|
||||||
|
? props.sortDirection === 'desc'
|
||||||
|
: props.sortDirection === 'asc';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isChevronUp(column: SortColumn): boolean {
|
||||||
|
if (!isSorted(column)) return false;
|
||||||
|
return !isChevronDown(column);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TableHeading>
|
<TableHeading>
|
||||||
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
<div
|
||||||
|
class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
||||||
|
@click="handleSort('name')">
|
||||||
Name
|
Name
|
||||||
|
<ChevronDownIcon v-if="isChevronDown('name')" class="w-4 h-4" />
|
||||||
|
<ChevronUpIcon v-else-if="isChevronUp('name')" class="w-4 h-4" />
|
||||||
|
<span v-else class="w-4 h-4"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||||
<span class="sr-only">Edit</span>
|
<span class="sr-only">Edit</span>
|
||||||
</div>
|
</div>
|
||||||
</TableHeading>
|
</TableHeading>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
|
|||||||
@@ -106,15 +106,29 @@ 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',
|
||||||
inRange: {
|
pieces: [
|
||||||
color: [chartEmptyColor.value, chartColor.value],
|
{ value: 0, color: chartEmptyColor.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: {
|
||||||
|
|||||||
@@ -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">
|
<div v-else class="text-center flex flex-1 justify-center items-center py-5">
|
||||||
<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="pb-5 text-sm">Start tracking your time!</p>
|
<p class="text-sm">Start tracking your time!</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DashboardCard>
|
</DashboardCard>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ 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>
|
||||||
|
|||||||
@@ -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 pt-6 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 px-2 pt-3 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 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',
|
'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',
|
||||||
props.class
|
props.class
|
||||||
)
|
)
|
||||||
">
|
">
|
||||||
|
|||||||
@@ -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-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-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',
|
||||||
props.class
|
props.class
|
||||||
)
|
)
|
||||||
" />
|
" />
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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,
|
||||||
@@ -19,10 +18,11 @@ 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 { onMounted, provide, ref } from 'vue';
|
import { nextTick, 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,6 +61,24 @@ 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(
|
||||||
@@ -102,11 +120,23 @@ 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
|
<div
|
||||||
:class="{
|
class="absolute inset-0 bg-default-background transition-opacity duration-200"
|
||||||
'!flex bg-default-background w-full z-30': showSidebarMenu,
|
:class="sidebarVisible ? 'opacity-50' : 'opacity-0'" />
|
||||||
}"
|
</div>
|
||||||
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">
|
</Teleport>
|
||||||
|
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
sidebarVisible
|
||||||
|
? 'max-lg:translate-x-0 max-lg:shadow-xl'
|
||||||
|
: 'max-lg:-translate-x-full',
|
||||||
|
]"
|
||||||
|
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">
|
||||||
@@ -121,9 +151,13 @@ 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>
|
||||||
<XMarkIcon
|
<Button
|
||||||
class="w-8 lg:hidden flex-shrink-0"
|
variant="ghost"
|
||||||
@click="showSidebarMenu = false"></XMarkIcon>
|
size="icon"
|
||||||
|
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>
|
||||||
@@ -279,9 +313,13 @@ 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">
|
||||||
<Bars3Icon
|
<Button
|
||||||
class="w-7 text-text-secondary"
|
variant="ghost"
|
||||||
@click="showSidebarMenu = !showSidebarMenu"></Bars3Icon>
|
size="icon"
|
||||||
|
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
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import PageTitle from '@/Components/Common/PageTitle.vue';
|
|||||||
import { canCreateClients } from '@/utils/permissions';
|
import { canCreateClients } from '@/utils/permissions';
|
||||||
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
|
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
|
||||||
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
|
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
|
||||||
|
import { useStorage } from '@vueuse/core';
|
||||||
|
import type { SortColumn, SortDirection } from '@/Components/Common/Client/ClientTable.vue';
|
||||||
|
|
||||||
const { clients } = useClientsQuery();
|
const { clients } = useClientsQuery();
|
||||||
|
|
||||||
@@ -19,6 +21,26 @@ const activeTab = ref<'active' | 'archived'>('active');
|
|||||||
|
|
||||||
const createClient = ref(false);
|
const createClient = ref(false);
|
||||||
|
|
||||||
|
interface ClientTableState {
|
||||||
|
sortColumn: SortColumn;
|
||||||
|
sortDirection: SortDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableState = useStorage<ClientTableState>(
|
||||||
|
'client-table-state',
|
||||||
|
{
|
||||||
|
sortColumn: 'name',
|
||||||
|
sortDirection: 'asc',
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ mergeDefaults: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleSort(column: SortColumn, direction: SortDirection) {
|
||||||
|
tableState.value.sortColumn = column;
|
||||||
|
tableState.value.sortDirection = direction;
|
||||||
|
}
|
||||||
|
|
||||||
const shownClients = computed(() => {
|
const shownClients = computed(() => {
|
||||||
return clients.value.filter((client) => {
|
return clients.value.filter((client) => {
|
||||||
if (activeTab.value === 'active') {
|
if (activeTab.value === 'active') {
|
||||||
@@ -45,6 +67,10 @@ const shownClients = computed(() => {
|
|||||||
>
|
>
|
||||||
<ClientCreateModal v-model:show="createClient"></ClientCreateModal>
|
<ClientCreateModal v-model:show="createClient"></ClientCreateModal>
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
<ClientTable :clients="shownClients"></ClientTable>
|
<ClientTable
|
||||||
|
:clients="shownClients"
|
||||||
|
:sort-column="tableState.sortColumn"
|
||||||
|
:sort-direction="tableState.sortDirection"
|
||||||
|
@sort="handleSort"></ClientTable>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import type { Role } from '@/types/jetstream';
|
|||||||
import PageTitle from '@/Components/Common/PageTitle.vue';
|
import PageTitle from '@/Components/Common/PageTitle.vue';
|
||||||
import InvitationTable from '@/Components/Common/Invitation/InvitationTable.vue';
|
import InvitationTable from '@/Components/Common/Invitation/InvitationTable.vue';
|
||||||
import { canCreateInvitations } from '@/utils/permissions';
|
import { canCreateInvitations } from '@/utils/permissions';
|
||||||
|
import { useStorage } from '@vueuse/core';
|
||||||
|
import type { SortColumn, SortDirection } from '@/Components/Common/Member/MemberTable.vue';
|
||||||
|
|
||||||
const inviteMember = ref(false);
|
const inviteMember = ref(false);
|
||||||
|
|
||||||
@@ -21,6 +23,26 @@ defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const activeTab = ref<'all' | 'invitations'>('all');
|
const activeTab = ref<'all' | 'invitations'>('all');
|
||||||
|
|
||||||
|
interface MemberTableState {
|
||||||
|
sortColumn: SortColumn;
|
||||||
|
sortDirection: SortDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableState = useStorage<MemberTableState>(
|
||||||
|
'member-table-state',
|
||||||
|
{
|
||||||
|
sortColumn: 'name',
|
||||||
|
sortDirection: 'asc',
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ mergeDefaults: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleSort(column: SortColumn, direction: SortDirection) {
|
||||||
|
tableState.value.sortColumn = column;
|
||||||
|
tableState.value.sortDirection = direction;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -45,7 +67,11 @@ const activeTab = ref<'all' | 'invitations'>('all');
|
|||||||
:available-roles="availableRoles"
|
:available-roles="availableRoles"
|
||||||
@close="activeTab = 'invitations'"></MemberInviteModal>
|
@close="activeTab = 'invitations'"></MemberInviteModal>
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
<MemberTable v-if="activeTab === 'all'"></MemberTable>
|
<MemberTable
|
||||||
|
v-if="activeTab === 'all'"
|
||||||
|
:sort-column="tableState.sortColumn"
|
||||||
|
:sort-direction="tableState.sortDirection"
|
||||||
|
@sort="handleSort"></MemberTable>
|
||||||
<InvitationTable v-if="activeTab === 'invitations'"></InvitationTable>
|
<InvitationTable v-if="activeTab === 'invitations'"></InvitationTable>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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/16/solid';
|
import { FolderIcon, PlusIcon } from '@heroicons/vue/20/solid';
|
||||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
||||||
@@ -84,9 +84,11 @@ 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 :href="route('projects')" class="flex items-center space-x-2.5">
|
<Link
|
||||||
<FolderIcon class="w-6 text-icon-default"></FolderIcon>
|
:href="route('projects')"
|
||||||
<span class="font-medium">Projects</span>
|
class="flex items-center space-x-2 sm:space-x-2.5">
|
||||||
|
<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>
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
<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/24/outline';
|
import { FolderIcon, PlusIcon } from '@heroicons/vue/20/solid';
|
||||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
import ProjectTable from '@/Components/Common/Project/ProjectTable.vue';
|
import ProjectTable from '@/Components/Common/Project/ProjectTable.vue';
|
||||||
import type {
|
|
||||||
SortColumn,
|
|
||||||
SortDirection,
|
|
||||||
} from '@/Components/Common/Project/ProjectTableHeading.vue';
|
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
||||||
import { useProjectsStore } from '@/utils/useProjects';
|
import { useProjectsStore } from '@/utils/useProjects';
|
||||||
@@ -26,6 +22,7 @@ import ProjectsFilterDropdown from '@/Components/Common/Project/ProjectsFilterDr
|
|||||||
import ProjectStatusFilterBadge from '@/Components/Common/Project/ProjectStatusFilterBadge.vue';
|
import ProjectStatusFilterBadge from '@/Components/Common/Project/ProjectStatusFilterBadge.vue';
|
||||||
import ProjectClientFilterBadge from '@/Components/Common/Project/ProjectClientFilterBadge.vue';
|
import ProjectClientFilterBadge from '@/Components/Common/Project/ProjectClientFilterBadge.vue';
|
||||||
import { NO_CLIENT_ID } from '@/Components/Common/Project/constants';
|
import { NO_CLIENT_ID } from '@/Components/Common/Project/constants';
|
||||||
|
import type { SortColumn, SortDirection } from '@/Components/Common/Project/ProjectTable.vue';
|
||||||
|
|
||||||
// Fetch data using TanStack Query
|
// Fetch data using TanStack Query
|
||||||
const { projects } = useProjectsQuery();
|
const { projects } = useProjectsQuery();
|
||||||
@@ -56,14 +53,9 @@ const tableState = useStorage<ProjectTableState>(
|
|||||||
{ mergeDefaults: true }
|
{ mergeDefaults: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle sorting - toggle direction if same column, otherwise set new column with asc
|
function handleSort(column: SortColumn, direction: SortDirection) {
|
||||||
function handleSort(column: SortColumn) {
|
|
||||||
if (tableState.value.sortColumn === column) {
|
|
||||||
tableState.value.sortDirection = tableState.value.sortDirection === 'asc' ? 'desc' : 'asc';
|
|
||||||
} else {
|
|
||||||
tableState.value.sortColumn = column;
|
tableState.value.sortColumn = column;
|
||||||
tableState.value.sortDirection = 'asc';
|
tableState.value.sortDirection = direction;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter projects based on current filters
|
// Filter projects based on current filters
|
||||||
|
|||||||
@@ -9,7 +9,17 @@ 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';
|
||||||
@@ -46,7 +56,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 ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.vue';
|
import UpgradeModal from '@/Components/Common/UpgradeModal.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';
|
||||||
@@ -160,6 +170,19 @@ 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);
|
||||||
@@ -236,13 +259,74 @@ 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="py-3 sm:py-5 border-b border-default-background-separator flex justify-between items-center">
|
class="h-14 sm:h-16 border-b border-default-background-separator flex flex-wrap gap-y-3 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"></ReportingTabNavbar>
|
<ReportingTabNavbar active="detailed" class="hidden sm:flex"></ReportingTabNavbar>
|
||||||
</div>
|
</div>
|
||||||
<ReportingExportButton :download="downloadExport"></ReportingExportButton>
|
<div class="hidden sm:block">
|
||||||
|
<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
|
||||||
|
|||||||
@@ -2,29 +2,10 @@
|
|||||||
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||||
import PageTitle from '@/Components/Common/PageTitle.vue';
|
import PageTitle from '@/Components/Common/PageTitle.vue';
|
||||||
import {
|
import { ChartBarIcon, CreditCardIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
|
||||||
ChartBarIcon,
|
import { computed } from 'vue';
|
||||||
ChevronLeftIcon,
|
|
||||||
ChevronDoubleLeftIcon,
|
|
||||||
ChevronRightIcon,
|
|
||||||
ChevronDoubleRightIcon,
|
|
||||||
CreditCardIcon,
|
|
||||||
UserGroupIcon,
|
|
||||||
} from '@heroicons/vue/20/solid';
|
|
||||||
import { computed, ref, watch } from 'vue';
|
|
||||||
|
|
||||||
import { api, type ReportIndexResponse } from '@/packages/api/src';
|
import { useQuery } from '@tanstack/vue-query';
|
||||||
import {
|
|
||||||
PaginationEllipsis,
|
|
||||||
PaginationFirst,
|
|
||||||
PaginationLast,
|
|
||||||
PaginationList,
|
|
||||||
PaginationListItem,
|
|
||||||
PaginationNext,
|
|
||||||
PaginationPrev,
|
|
||||||
PaginationRoot,
|
|
||||||
} from 'radix-vue';
|
|
||||||
import { useQuery, useQueryClient } from '@tanstack/vue-query';
|
|
||||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
|
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
|
||||||
import ReportTable from '@/Components/Common/Report/ReportTable.vue';
|
import ReportTable from '@/Components/Common/Report/ReportTable.vue';
|
||||||
@@ -32,49 +13,37 @@ import { isAllowedToPerformPremiumAction, isBillingActivated } from '@/utils/bil
|
|||||||
import { canManageBilling, canUpdateOrganization } from '@/utils/permissions';
|
import { canManageBilling, canUpdateOrganization } from '@/utils/permissions';
|
||||||
import PrimaryButton from '../packages/ui/src/Buttons/PrimaryButton.vue';
|
import PrimaryButton from '../packages/ui/src/Buttons/PrimaryButton.vue';
|
||||||
import { Link } from '@inertiajs/vue3';
|
import { Link } from '@inertiajs/vue3';
|
||||||
|
import { fetchAllReports } from '@/utils/useReportsQuery';
|
||||||
|
|
||||||
const pageLimit = 15;
|
const { data: reportsData } = useQuery({
|
||||||
const currentPage = ref(1);
|
queryKey: computed(() => ['reports', getCurrentOrganizationId()]),
|
||||||
|
|
||||||
const { data: reportsResponse } = useQuery<ReportIndexResponse>({
|
|
||||||
queryKey: ['reports', currentPage],
|
|
||||||
enabled: !!getCurrentOrganizationId(),
|
enabled: !!getCurrentOrganizationId(),
|
||||||
queryFn: () =>
|
queryFn: async () => {
|
||||||
api.getReports({
|
const organizationId = getCurrentOrganizationId();
|
||||||
params: {
|
if (!organizationId) throw new Error('No organization');
|
||||||
organization: getCurrentOrganizationId() || '',
|
const data = await fetchAllReports(organizationId);
|
||||||
|
return { data };
|
||||||
},
|
},
|
||||||
}),
|
staleTime: 1000 * 30,
|
||||||
});
|
});
|
||||||
|
|
||||||
const reports = computed(() => {
|
const reports = computed(() => {
|
||||||
return reportsResponse.value?.data ?? [];
|
return reportsData.value?.data ?? [];
|
||||||
});
|
|
||||||
|
|
||||||
const totalPages = computed(() => {
|
|
||||||
return 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
async function updateFilteredTimeEntries() {
|
|
||||||
await queryClient.invalidateQueries({
|
|
||||||
queryKey: ['reports'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
watch(currentPage, () => {
|
|
||||||
updateFilteredTimeEntries();
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<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="py-3 sm:py-5 min-h-[79px] border-b border-default-background-separator flex justify-between items-center">
|
class="h-14 sm:h-16 border-b border-default-background-separator flex flex-wrap gap-y-3 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"></ReportingTabNavbar>
|
<ReportingTabNavbar active="shared" class="hidden sm:flex"></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">
|
||||||
@@ -107,64 +76,5 @@ watch(currentPage, () => {
|
|||||||
<ReportTable
|
<ReportTable
|
||||||
v-if="reports.length > 0 || isAllowedToPerformPremiumAction()"
|
v-if="reports.length > 0 || isAllowedToPerformPremiumAction()"
|
||||||
:reports="reports"></ReportTable>
|
:reports="reports"></ReportTable>
|
||||||
|
|
||||||
<PaginationRoot
|
|
||||||
v-if="reports.length > 0 || isAllowedToPerformPremiumAction()"
|
|
||||||
v-model:page="currentPage"
|
|
||||||
:total="totalPages"
|
|
||||||
:items-per-page="pageLimit"
|
|
||||||
class="flex justify-center items-center py-8"
|
|
||||||
:sibling-count="1"
|
|
||||||
show-edges>
|
|
||||||
<PaginationList v-slot="{ items }" class="flex items-center space-x-1 relative">
|
|
||||||
<div class="pr-2 flex items-center space-x-1 border-r border-border-primary mr-1">
|
|
||||||
<PaginationFirst class="navigation-item">
|
|
||||||
<ChevronDoubleLeftIcon class="w-4"> </ChevronDoubleLeftIcon>
|
|
||||||
</PaginationFirst>
|
|
||||||
<PaginationPrev class="mr-4 navigation-item">
|
|
||||||
<ChevronLeftIcon class="w-4 text-text-tertiary hover:text-text-primary">
|
|
||||||
</ChevronLeftIcon>
|
|
||||||
</PaginationPrev>
|
|
||||||
</div>
|
|
||||||
<template v-for="(page, index) in items">
|
|
||||||
<PaginationListItem
|
|
||||||
v-if="page.type === 'page'"
|
|
||||||
:key="index"
|
|
||||||
class="pagination-item"
|
|
||||||
:value="page.value">
|
|
||||||
{{ page.value }}
|
|
||||||
</PaginationListItem>
|
|
||||||
<PaginationEllipsis
|
|
||||||
v-else
|
|
||||||
:key="page.type"
|
|
||||||
:index="index"
|
|
||||||
class="PaginationEllipsis">
|
|
||||||
<div class="px-2">…</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>
|
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ onMounted(async () => {
|
|||||||
:key="entry.description ?? 'none'"
|
:key="entry.description ?? 'none'"
|
||||||
:currency="reportCurrency"
|
:currency="reportCurrency"
|
||||||
:currency-format="reportCurrencyFormat"
|
:currency-format="reportCurrencyFormat"
|
||||||
|
:show-cost="true"
|
||||||
:entry="entry"></ReportingRow>
|
:entry="entry"></ReportingRow>
|
||||||
<div
|
<div
|
||||||
class="contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]">
|
class="contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]">
|
||||||
@@ -239,12 +240,15 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="justify-end pr-6 flex items-center font-medium">
|
<div class="justify-end pr-6 flex items-center font-medium">
|
||||||
{{
|
{{
|
||||||
formatCents(
|
aggregatedTableTimeEntries.cost
|
||||||
|
? formatCents(
|
||||||
aggregatedTableTimeEntries.cost,
|
aggregatedTableTimeEntries.cost,
|
||||||
reportCurrency,
|
reportCurrency,
|
||||||
reportCurrencyFormat,
|
reportCurrencyFormat,
|
||||||
reportCurrencySymbol
|
reportCurrencySymbol,
|
||||||
|
reportNumberFormat
|
||||||
)
|
)
|
||||||
|
: '--'
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,9 +9,31 @@ import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
|
|||||||
import PageTitle from '@/Components/Common/PageTitle.vue';
|
import PageTitle from '@/Components/Common/PageTitle.vue';
|
||||||
import { canCreateTags } from '@/utils/permissions';
|
import { canCreateTags } from '@/utils/permissions';
|
||||||
import { useTagsStore } from '@/utils/useTags';
|
import { useTagsStore } from '@/utils/useTags';
|
||||||
|
import { useStorage } from '@vueuse/core';
|
||||||
|
import type { SortColumn, SortDirection } from '@/Components/Common/Tag/TagTable.vue';
|
||||||
|
|
||||||
const showCreateTagModal = ref(false);
|
const showCreateTagModal = ref(false);
|
||||||
|
|
||||||
|
interface TagTableState {
|
||||||
|
sortColumn: SortColumn;
|
||||||
|
sortDirection: SortDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableState = useStorage<TagTableState>(
|
||||||
|
'tag-table-state',
|
||||||
|
{
|
||||||
|
sortColumn: 'name',
|
||||||
|
sortDirection: 'asc',
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ mergeDefaults: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleSort(column: SortColumn, direction: SortDirection) {
|
||||||
|
tableState.value.sortColumn = column;
|
||||||
|
tableState.value.sortDirection = direction;
|
||||||
|
}
|
||||||
|
|
||||||
async function createTag(tag: string) {
|
async function createTag(tag: string) {
|
||||||
return await useTagsStore().createTag(tag);
|
return await useTagsStore().createTag(tag);
|
||||||
}
|
}
|
||||||
@@ -34,6 +56,10 @@ async function createTag(tag: string) {
|
|||||||
v-model:show="showCreateTagModal"
|
v-model:show="showCreateTagModal"
|
||||||
:create-tag="createTag"></TagCreateModal>
|
:create-tag="createTag"></TagCreateModal>
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
<TagTable :create-tag="createTag"></TagTable>
|
<TagTable
|
||||||
|
:create-tag="createTag"
|
||||||
|
:sort-column="tableState.sortColumn"
|
||||||
|
:sort-direction="tableState.sortDirection"
|
||||||
|
@sort="handleSort"></TagTable>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ 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 } = useTimeEntriesInfiniteQuery();
|
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending } =
|
||||||
|
useTimeEntriesInfiniteQuery();
|
||||||
const {
|
const {
|
||||||
createTimeEntry: createTimeEntryMutation,
|
createTimeEntry: createTimeEntryMutation,
|
||||||
updateTimeEntry,
|
updateTimeEntry,
|
||||||
@@ -145,7 +146,10 @@ function deleteSelected() {
|
|||||||
:currency="getOrganizationCurrencyString()"
|
:currency="getOrganizationCurrencyString()"
|
||||||
:time-entries="timeEntries"
|
:time-entries="timeEntries"
|
||||||
:tags="tags"></TimeEntryGroupedTable>
|
:tags="tags"></TimeEntryGroupedTable>
|
||||||
<div v-if="timeEntries.length === 0" class="text-center pt-12">
|
<div v-if="isPending" class="flex justify-center items-center py-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>
|
||||||
@@ -158,7 +162,7 @@ function deleteSelected() {
|
|||||||
<span> Loading more time entries... </span>
|
<span> Loading more time entries... </span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="!hasNextPage"
|
v-else-if="!hasNextPage && timeEntries.length > 0"
|
||||||
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>
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ const ClientResource = z
|
|||||||
updated_at: z.string(),
|
updated_at: z.string(),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const ClientCollection = z.array(ClientResource);
|
|
||||||
const ClientStoreRequest = z.object({ name: z.string().min(1).max(255) }).passthrough();
|
const ClientStoreRequest = z.object({ name: z.string().min(1).max(255) }).passthrough();
|
||||||
const ClientUpdateRequest = z
|
const ClientUpdateRequest = z
|
||||||
.object({ name: z.string().min(1).max(255), is_archived: z.boolean().optional() })
|
.object({ name: z.string().min(1).max(255), is_archived: z.boolean().optional() })
|
||||||
@@ -598,7 +597,6 @@ const DetailedWithDataReportResource = z
|
|||||||
const TagResource = z
|
const TagResource = z
|
||||||
.object({ id: z.string(), name: z.string(), created_at: z.string(), updated_at: z.string() })
|
.object({ id: z.string(), name: z.string(), created_at: z.string(), updated_at: z.string() })
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const TagCollection = z.array(TagResource);
|
|
||||||
const TagStoreRequest = z.object({ name: z.string().min(1).max(255) }).passthrough();
|
const TagStoreRequest = z.object({ name: z.string().min(1).max(255) }).passthrough();
|
||||||
const TagUpdateRequest = z.object({ name: z.string().min(1).max(255) }).passthrough();
|
const TagUpdateRequest = z.object({ name: z.string().min(1).max(255) }).passthrough();
|
||||||
const TaskResource = z
|
const TaskResource = z
|
||||||
@@ -711,7 +709,6 @@ export const schemas = {
|
|||||||
ApiTokenStoreRequest,
|
ApiTokenStoreRequest,
|
||||||
ApiTokenWithAccessTokenResource,
|
ApiTokenWithAccessTokenResource,
|
||||||
ClientResource,
|
ClientResource,
|
||||||
ClientCollection,
|
|
||||||
ClientStoreRequest,
|
ClientStoreRequest,
|
||||||
ClientUpdateRequest,
|
ClientUpdateRequest,
|
||||||
ImportRequest,
|
ImportRequest,
|
||||||
@@ -755,7 +752,6 @@ export const schemas = {
|
|||||||
ReportUpdateRequest,
|
ReportUpdateRequest,
|
||||||
DetailedWithDataReportResource,
|
DetailedWithDataReportResource,
|
||||||
TagResource,
|
TagResource,
|
||||||
TagCollection,
|
|
||||||
TagStoreRequest,
|
TagStoreRequest,
|
||||||
TagUpdateRequest,
|
TagUpdateRequest,
|
||||||
TaskResource,
|
TaskResource,
|
||||||
@@ -1201,7 +1197,39 @@ const endpoints = makeApi([
|
|||||||
schema: z.enum(['true', 'false', 'all']).optional(),
|
schema: z.enum(['true', 'false', 'all']).optional(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
response: z.object({ data: ClientCollection }).passthrough(),
|
response: z
|
||||||
|
.object({
|
||||||
|
data: z.array(ClientResource),
|
||||||
|
links: z
|
||||||
|
.object({
|
||||||
|
first: z.union([z.string(), z.null()]),
|
||||||
|
last: z.union([z.string(), z.null()]),
|
||||||
|
prev: z.union([z.string(), z.null()]),
|
||||||
|
next: z.union([z.string(), z.null()]),
|
||||||
|
})
|
||||||
|
.passthrough(),
|
||||||
|
meta: z
|
||||||
|
.object({
|
||||||
|
current_page: z.number().int(),
|
||||||
|
from: z.union([z.number(), z.null()]),
|
||||||
|
last_page: z.number().int(),
|
||||||
|
links: z.array(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
url: z.union([z.string(), z.null()]),
|
||||||
|
label: z.string(),
|
||||||
|
active: z.boolean(),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
),
|
||||||
|
path: z.union([z.string(), z.null()]),
|
||||||
|
per_page: z.number().int(),
|
||||||
|
to: z.union([z.number(), z.null()]),
|
||||||
|
total: z.number().int(),
|
||||||
|
})
|
||||||
|
.passthrough(),
|
||||||
|
})
|
||||||
|
.passthrough(),
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
@@ -1512,6 +1540,11 @@ const endpoints = makeApi([
|
|||||||
type: 'Path',
|
type: 'Path',
|
||||||
schema: z.string(),
|
schema: z.string(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'page',
|
||||||
|
type: 'Query',
|
||||||
|
schema: z.number().int().gte(1).lte(2147483647).optional(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
response: z
|
response: z
|
||||||
.object({
|
.object({
|
||||||
@@ -2137,6 +2170,11 @@ const endpoints = makeApi([
|
|||||||
type: 'Path',
|
type: 'Path',
|
||||||
schema: z.string(),
|
schema: z.string(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'page',
|
||||||
|
type: 'Query',
|
||||||
|
schema: z.number().int().gte(1).lte(2147483647).optional(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
response: z
|
response: z
|
||||||
.object({
|
.object({
|
||||||
@@ -2742,6 +2780,11 @@ const endpoints = makeApi([
|
|||||||
type: 'Path',
|
type: 'Path',
|
||||||
schema: z.string(),
|
schema: z.string(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'page',
|
||||||
|
type: 'Query',
|
||||||
|
schema: z.number().int().gte(1).lte(2147483647).optional(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
response: z
|
response: z
|
||||||
.object({
|
.object({
|
||||||
@@ -2792,6 +2835,13 @@ const endpoints = makeApi([
|
|||||||
description: `Not found`,
|
description: `Not found`,
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
status: 422,
|
||||||
|
description: `Validation error`,
|
||||||
|
schema: z
|
||||||
|
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||||
|
.passthrough(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2860,6 +2910,11 @@ const endpoints = makeApi([
|
|||||||
type: 'Path',
|
type: 'Path',
|
||||||
schema: z.string(),
|
schema: z.string(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'page',
|
||||||
|
type: 'Query',
|
||||||
|
schema: z.number().int().gte(1).lte(2147483647).optional(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
response: z
|
response: z
|
||||||
.object({
|
.object({
|
||||||
@@ -2910,6 +2965,13 @@ const endpoints = makeApi([
|
|||||||
description: `Not found`,
|
description: `Not found`,
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
status: 422,
|
||||||
|
description: `Validation error`,
|
||||||
|
schema: z
|
||||||
|
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||||
|
.passthrough(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -3086,8 +3148,45 @@ const endpoints = makeApi([
|
|||||||
type: 'Path',
|
type: 'Path',
|
||||||
schema: z.string(),
|
schema: z.string(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'page',
|
||||||
|
type: 'Query',
|
||||||
|
schema: z.number().int().gte(1).lte(2147483647).optional(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
response: z.object({ data: TagCollection }).passthrough(),
|
response: z
|
||||||
|
.object({
|
||||||
|
data: z.array(TagResource),
|
||||||
|
links: z
|
||||||
|
.object({
|
||||||
|
first: z.union([z.string(), z.null()]),
|
||||||
|
last: z.union([z.string(), z.null()]),
|
||||||
|
prev: z.union([z.string(), z.null()]),
|
||||||
|
next: z.union([z.string(), z.null()]),
|
||||||
|
})
|
||||||
|
.passthrough(),
|
||||||
|
meta: z
|
||||||
|
.object({
|
||||||
|
current_page: z.number().int(),
|
||||||
|
from: z.union([z.number(), z.null()]),
|
||||||
|
last_page: z.number().int(),
|
||||||
|
links: z.array(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
url: z.union([z.string(), z.null()]),
|
||||||
|
label: z.string(),
|
||||||
|
active: z.boolean(),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
),
|
||||||
|
path: z.union([z.string(), z.null()]),
|
||||||
|
per_page: z.number().int(),
|
||||||
|
to: z.union([z.number(), z.null()]),
|
||||||
|
total: z.number().int(),
|
||||||
|
})
|
||||||
|
.passthrough(),
|
||||||
|
})
|
||||||
|
.passthrough(),
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
@@ -3104,6 +3203,13 @@ const endpoints = makeApi([
|
|||||||
description: `Not found`,
|
description: `Not found`,
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
status: 422,
|
||||||
|
description: `Validation error`,
|
||||||
|
schema: z
|
||||||
|
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
||||||
|
.passthrough(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -3251,6 +3357,11 @@ const endpoints = makeApi([
|
|||||||
type: 'Path',
|
type: 'Path',
|
||||||
schema: z.string(),
|
schema: z.string(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'page',
|
||||||
|
type: 'Query',
|
||||||
|
schema: z.number().int().gte(1).lte(2147483647).optional(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'project_id',
|
name: 'project_id',
|
||||||
type: 'Query',
|
type: 'Query',
|
||||||
@@ -4230,6 +4341,11 @@ If the group parameters are all set to `null` 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',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@solidtime/ui",
|
"name": "@solidtime/ui",
|
||||||
"version": "0.0.15",
|
"version": "0.0.16",
|
||||||
"description": "Package containing the solidtime ui components",
|
"description": "Package containing the solidtime ui components",
|
||||||
"main": "./dist/solidtime-ui-lib.umd.cjs",
|
"main": "./dist/solidtime-ui-lib.umd.cjs",
|
||||||
"module": "./dist/solidtime-ui-lib.js",
|
"module": "./dist/solidtime-ui-lib.js",
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
'postcss-import': {},
|
|
||||||
'tailwindcss/nesting': {},
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -21,7 +21,7 @@ const props = withDefaults(
|
|||||||
<button
|
<button
|
||||||
:type="type"
|
:type="type"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
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">
|
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">
|
||||||
<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
|
||||||
|
|||||||
@@ -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: 'text-xs sm:text-sm px-3 py-2',
|
base: 'h-9 px-3 text-sm',
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
<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>
|
||||||
@@ -6,10 +6,10 @@ import type { Dayjs } from 'dayjs';
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
date: Dayjs;
|
date: Dayjs;
|
||||||
totalMinutes?: number;
|
totalSeconds?: number;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const totalSeconds = computed(() => (props.totalMinutes ?? 0) * 60);
|
const totalSecondsValue = computed(() => props.totalSeconds ?? 0);
|
||||||
|
|
||||||
// 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(totalSeconds, intervalFormat, numberFormat) }}
|
{{ formatHumanReadableDuration(totalSecondsValue, intervalFormat, numberFormat) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -15,13 +15,22 @@ 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 { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
|
import {
|
||||||
|
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,
|
||||||
@@ -81,6 +90,22 @@ 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;
|
||||||
@@ -179,20 +204,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 duration: number;
|
let durationSeconds: number;
|
||||||
|
|
||||||
if (entry.end !== null) {
|
if (entry.end !== null) {
|
||||||
// Completed entry
|
// Completed entry
|
||||||
duration = getDayJsInstance()(entry.end).diff(
|
durationSeconds = getDayJsInstance()(entry.end).diff(
|
||||||
getDayJsInstance()(entry.start),
|
getDayJsInstance()(entry.start),
|
||||||
'minutes'
|
'seconds'
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Running entry - use current time
|
// Running entry - use current time
|
||||||
duration = currentTime.value.diff(getDayJsInstance()(entry.start), 'minutes');
|
durationSeconds = currentTime.value.diff(getDayJsInstance()(entry.start), 'seconds');
|
||||||
}
|
}
|
||||||
|
|
||||||
totals[date] = (totals[date] || 0) + duration;
|
totals[date] = (totals[date] || 0) + durationSeconds;
|
||||||
});
|
});
|
||||||
return totals;
|
return totals;
|
||||||
});
|
});
|
||||||
@@ -204,16 +229,19 @@ function emitDatesChange(arg: DatesSetArg) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleDateSelect(arg: { start: Date; end: Date }) {
|
function handleDateSelect(arg: { start: Date; end: Date }) {
|
||||||
const startTime = getDayJsInstance()(arg.start.toISOString())
|
stopVisualSnap();
|
||||||
|
const snap = calendarSettings.value.snapMinutes;
|
||||||
|
const startLocal = getDayJsInstance()(arg.start.toISOString())
|
||||||
.utc()
|
.utc()
|
||||||
.tz(getUserTimezone(), true)
|
.tz(getUserTimezone(), true);
|
||||||
.utc();
|
const endLocal = getDayJsInstance()(arg.end.toISOString()).utc().tz(getUserTimezone(), true);
|
||||||
const endTime = getDayJsInstance()(arg.end.toISOString())
|
const snappedStart = snapStartToGrid(startLocal, snap);
|
||||||
.utc()
|
let snappedEnd = snapEndToGrid(endLocal, snap);
|
||||||
.tz(getUserTimezone(), true)
|
if (!snappedEnd.isAfter(snappedStart)) {
|
||||||
.utc();
|
snappedEnd = snappedStart.add(snap, 'minute');
|
||||||
newEventStart.value = startTime;
|
}
|
||||||
newEventEnd.value = endTime;
|
newEventStart.value = snappedStart.utc();
|
||||||
|
newEventEnd.value = snappedEnd.utc();
|
||||||
showCreateTimeEntryModal.value = true;
|
showCreateTimeEntryModal.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,56 +255,106 @@ function handleEventClick(arg: EventClickArg) {
|
|||||||
showEditTimeEntryModal.value = true;
|
showEditTimeEntryModal.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Snap a dayjs time down to the previous snap boundary (for start times)
|
||||||
|
function snapStartToGrid(time: Dayjs, snapMinutes: number): Dayjs {
|
||||||
|
const minutes = time.hour() * 60 + time.minute();
|
||||||
|
const snapped = Math.floor(minutes / snapMinutes) * snapMinutes;
|
||||||
|
return time.startOf('day').add(snapped, 'minute');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snap a dayjs time up to the next snap boundary (for end times)
|
||||||
|
function snapEndToGrid(time: Dayjs, snapMinutes: number): Dayjs {
|
||||||
|
const minutes = time.hour() * 60 + time.minute();
|
||||||
|
const snapped = Math.ceil(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 = snapStartToGrid(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: getDayJsInstance()(arg.event.start.toISOString())
|
start: snappedStart.utc().format(),
|
||||||
.utc()
|
end: snappedEnd.utc().format(),
|
||||||
.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 ? snapStartToGrid(newStartLocal, snap) : null;
|
||||||
|
const snappedEnd = !startChanged && !ext.isRunning ? snapEndToGrid(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: getDayJsInstance()(arg.event.start.toISOString())
|
start: snappedStart ? snappedStart.utc().format() : timeEntry.start,
|
||||||
.utc()
|
end: ext.isRunning ? null : snappedEnd ? snappedEnd.utc().format() : timeEntry.end,
|
||||||
.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;
|
||||||
|
|
||||||
|
return {
|
||||||
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, activityStatusPlugin],
|
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, activityStatusPlugin],
|
||||||
initialView: 'timeGridWeek',
|
initialView: 'timeGridWeek',
|
||||||
headerToolbar: {
|
headerToolbar: {
|
||||||
@@ -285,9 +363,9 @@ const calendarOptions = computed(() => ({
|
|||||||
right: 'timeGridWeek,timeGridDay',
|
right: 'timeGridWeek,timeGridDay',
|
||||||
},
|
},
|
||||||
height: 'parent',
|
height: 'parent',
|
||||||
slotMinTime: '00:00:00',
|
slotMinTime: formatDuration(s.startHour * 3600),
|
||||||
slotMaxTime: '24:00:00',
|
slotMaxTime: formatDuration(s.endHour * 3600),
|
||||||
slotDuration: '00:15:00',
|
slotDuration: formatDuration(s.slotMinutes * 60),
|
||||||
slotLabelInterval: '01:00:00',
|
slotLabelInterval: '01:00:00',
|
||||||
slotLabelFormat: getSlotLabelFormat(),
|
slotLabelFormat: getSlotLabelFormat(),
|
||||||
snapDuration: '00:01:00',
|
snapDuration: '00:01:00',
|
||||||
@@ -304,13 +382,16 @@ const calendarOptions = computed(() => ({
|
|||||||
eventStartEditable: true,
|
eventStartEditable: true,
|
||||||
select: handleDateSelect,
|
select: handleDateSelect,
|
||||||
eventClick: handleEventClick,
|
eventClick: handleEventClick,
|
||||||
|
eventDragStart: startVisualDragSnap,
|
||||||
eventDrop: handleEventDrop,
|
eventDrop: handleEventDrop,
|
||||||
|
eventResizeStart: startVisualResizeSnap,
|
||||||
eventResize: handleEventResize,
|
eventResize: handleEventResize,
|
||||||
datesSet: emitDatesChange,
|
datesSet: emitDatesChange,
|
||||||
|
|
||||||
events: events.value,
|
events: events.value,
|
||||||
activityPeriods: props.activityPeriods || [],
|
activityPeriods: props.activityPeriods || [],
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
watch(showCreateTimeEntryModal, (value) => {
|
watch(showCreateTimeEntryModal, (value) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -376,7 +457,6 @@ onActivated(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
// Clean up interval
|
|
||||||
if (currentTimeInterval) {
|
if (currentTimeInterval) {
|
||||||
clearInterval(currentTimeInterval);
|
clearInterval(currentTimeInterval);
|
||||||
currentTimeInterval = null;
|
currentTimeInterval = null;
|
||||||
@@ -424,6 +504,11 @@ 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
|
||||||
@@ -444,7 +529,7 @@ onUnmounted(() => {
|
|||||||
:date="
|
:date="
|
||||||
getDayJsInstance()(arg.date.toISOString()).utc().tz(getUserTimezone(), true)
|
getDayJsInstance()(arg.date.toISOString()).utc().tz(getUserTimezone(), true)
|
||||||
"
|
"
|
||||||
:total-minutes="
|
:total-seconds="
|
||||||
dailyTotals[
|
dailyTotals[
|
||||||
getDayJsInstance()(arg.date)
|
getDayJsInstance()(arg.date)
|
||||||
.utc()
|
.utc()
|
||||||
@@ -458,6 +543,13 @@ 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);
|
||||||
@@ -482,6 +574,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,36 +673,65 @@ onUnmounted(() => {
|
|||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Enhanced FullCalendar resize handles */
|
/* Resize handle hit areas */
|
||||||
.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: 4px;
|
height: 12px;
|
||||||
left: 0;
|
left: 0;
|
||||||
transition: all 0.2s ease;
|
cursor: row-resize;
|
||||||
|
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) {
|
.fullcalendar :deep(.fc-event-resizer:hover::after) {
|
||||||
background: '#FFF';
|
background: rgba(255, 255, 255, 0.9);
|
||||||
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 */
|
||||||
@@ -632,6 +754,10 @@ onUnmounted(() => {
|
|||||||
border: 1px solid var(--primary);
|
border: 1px solid var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fullcalendar :deep(.fc-event-mirror) {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.fullcalendar :deep(.fc-scrollgrid) {
|
.fullcalendar :deep(.fc-scrollgrid) {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-left: 1px solid transparent;
|
border-left: 1px solid transparent;
|
||||||
@@ -763,3 +889,11 @@ 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>
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export interface CalendarSettings {
|
||||||
|
snapMinutes: number;
|
||||||
|
startHour: number;
|
||||||
|
endHour: number;
|
||||||
|
slotMinutes: number;
|
||||||
|
}
|
||||||
189
resources/js/packages/ui/src/FullCalendar/useVisualSnap.ts
Normal file
189
resources/js/packages/ui/src/FullCalendar/useVisualSnap.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
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;
|
||||||
|
if (harness) {
|
||||||
|
harness.style.pointerEvents = 'none';
|
||||||
|
}
|
||||||
|
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.floor(top / snapPx) * snapPx;
|
||||||
|
const snappedEnd = Math.ceil(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.floor(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.ceil(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.floor(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed, inject, type ComputedRef } from 'vue';
|
||||||
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
|
import type { Organization } from '@/packages/api/src';
|
||||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
|
||||||
import DollarIcon from './DollarIcon.vue';
|
import DollarIcon from './DollarIcon.vue';
|
||||||
import EuroIcon from './EuroIcon.vue';
|
import EuroIcon from './EuroIcon.vue';
|
||||||
|
|
||||||
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
|
const organization = inject<ComputedRef<Organization>>('organization');
|
||||||
const icon = computed(() => (organization.value?.currency === 'EUR' ? EuroIcon : DollarIcon));
|
const icon = computed(() => (organization?.value?.currency === 'EUR' ? EuroIcon : DollarIcon));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const model = defineModel();
|
|||||||
v-model="model"
|
v-model="model"
|
||||||
:class="
|
:class="
|
||||||
twMerge(
|
twMerge(
|
||||||
'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',
|
'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',
|
||||||
props.class
|
props.class
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user