Compare commits

..

1 Commits

Author SHA1 Message Date
Constantin Graf
d68c30476e Add base request class with generic rule sets 2025-05-14 15:52:26 +02:00
70 changed files with 162 additions and 1617 deletions

View File

@@ -10,8 +10,6 @@ on:
- '.github/workflows/build-private.yml' - '.github/workflows/build-private.yml'
- 'docker/prod/**' - 'docker/prod/**'
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
name: Build - Private name: Build - Private
jobs: jobs:
@@ -19,7 +17,6 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20 timeout-minutes: 20
steps: steps:
- name: "Check out code" - name: "Check out code"
uses: actions/checkout@v4 uses: actions/checkout@v4

View File

@@ -11,12 +11,6 @@ on:
- 'docker/prod/**' - 'docker/prod/**'
workflow_dispatch: workflow_dispatch:
permissions:
packages: write
contents: read
attestations: write
id-token: write
env: env:
DOCKERHUB_REPO: solidtime/solidtime DOCKERHUB_REPO: solidtime/solidtime
GHCR_REPO: ghcr.io/solidtime-io/solidtime GHCR_REPO: ghcr.io/solidtime-io/solidtime
@@ -32,6 +26,11 @@ jobs:
- runs-on: "ubuntu-24.04" - runs-on: "ubuntu-24.04"
platform: "linux/amd64" platform: "linux/amd64"
runs-on: ${{ matrix.runs-on }} runs-on: ${{ matrix.runs-on }}
permissions:
packages: write
contents: read
attestations: write
id-token: write
timeout-minutes: 90 timeout-minutes: 90
steps: steps:
@@ -164,6 +163,11 @@ jobs:
merge: merge:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
packages: write
contents: read
attestations: write
id-token: write
timeout-minutes: 90 timeout-minutes: 90
needs: needs:
- build - build

View File

@@ -3,9 +3,6 @@ on:
push: push:
branches: branches:
- main - main
permissions:
contents: read
jobs: jobs:
api_docs: api_docs:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -1,8 +1,6 @@
name: NPM Build name: NPM Build
on: [push] on: [push]
permissions:
contents: read
jobs: jobs:
build: build:

View File

@@ -1,8 +1,6 @@
name: NPM Lint name: NPM Lint
on: [push] on: [push]
permissions:
contents: read
jobs: jobs:
build: build:

View File

@@ -1,8 +1,6 @@
name: Publish API package to NPM name: Publish API package to NPM
on: on:
workflow_dispatch workflow_dispatch
permissions:
contents: read
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -1,8 +1,6 @@
name: Publish UI package to NPM name: Publish UI package to NPM
on: on:
workflow_dispatch workflow_dispatch
permissions:
contents: read
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -1,8 +1,7 @@
name: NPM Typecheck name: NPM Typecheck
on: [push] on: [push]
permissions:
contents: read
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -1,7 +1,5 @@
name: Static code analysis (PHPStan) name: Static code analysis (PHPStan)
on: push on: push
permissions:
contents: read
jobs: jobs:
phpstan: phpstan:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -1,18 +1,13 @@
name: PHPUnit Tests name: PHPUnit Tests
on: push on: push
permissions:
contents: read
jobs: jobs:
phpunit: phpunit:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
strategy:
matrix:
postgres_version: [ 15, 16, 17 ]
services: services:
pgsql_test: pgsql_test:
image: postgres:${{ matrix.postgres_version }} image: postgres:15
env: env:
PGPASSWORD: 'root' PGPASSWORD: 'root'
POSTGRES_DB: 'laravel' POSTGRES_DB: 'laravel'
@@ -68,7 +63,7 @@ jobs:
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
- name: "Upload coverage reports to Codecov" - name: "Upload coverage reports to Codecov"
uses: codecov/codecov-action@v5.4.3 uses: codecov/codecov-action@v5.4.2
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime slug: solidtime-io/solidtime

View File

@@ -1,7 +1,5 @@
name: PHP Linting name: PHP Linting
on: push on: push
permissions:
contents: read
jobs: jobs:
pint: pint:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -1,7 +1,5 @@
name: Playwright Tests name: Playwright Tests
on: [push] on: [push]
permissions:
contents: read
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -1,123 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\SelfHost;
use Illuminate\Console\Command;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class SelfHostDatabaseConsistency extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'self-host:database-consistency';
/**
* The console command description.
*
* @var string
*/
protected $description = '';
/**
* Execute the console command.
*/
public function handle(): int
{
$hadAProblem = false;
// Task need to be part of project in time entries
$problems = DB::table('time_entries')
->select(['time_entries.id as id'])
->join('tasks', 'time_entries.task_id', '=', 'tasks.id')
->where('tasks.project_id', '!=', DB::raw('time_entries.project_id'))
->get();
$this->logProblems($problems, 'Time entries have a task that does not belong to the project of the time entry', $hadAProblem);
// Client id is the client id of the project
$problems = DB::table('time_entries')
->select(['time_entries.id as id'])
->join('projects', 'time_entries.project_id', '=', 'projects.id')
->where(DB::raw('coalesce(projects.client_id::varchar, \'\')'), '!=', DB::raw('coalesce(time_entries.client_id::varchar, \'\')'))
->get();
$this->logProblems($problems, 'Time entries have a client that does not match the client of the project', $hadAProblem);
// Client id can only be not null if the project id is not null
$problems = DB::table('time_entries')
->select(['time_entries.id as id'])
->whereNotNull('client_id')
->whereNull('project_id')
->get();
$this->logProblems($problems, 'Time entries have a client but no project', $hadAProblem);
// Every user needs to be a member of at least one organization
$problems = DB::table('users')
->select(['users.id as id'])
->leftJoin('members', 'users.id', '=', 'members.user_id')
->whereNull('members.id')
->get();
$this->logProblems($problems, 'Users are not member of any organization', $hadAProblem);
// Every organization needs at least an owner
$problems = DB::table('organizations')
->select(['organizations.id as id'])
->leftJoin('members', function (JoinClause $join): void {
$join->on('organizations.id', '=', 'members.organization_id')
->where('members.role', '=', 'owner');
})
->whereNull('members.id')
->get();
$this->logProblems($problems, 'Organizations without an owner', $hadAProblem);
// Every member can only have one running time entry
$problems = DB::table('time_entries')
->select(['user_id as id'])
->whereNull('end')
->groupBy('user_id')
->havingRaw('count(*) > 1')
->get(['user_id', DB::raw('count(*) as count')]);
$this->logProblems($problems, 'Users with more than one running time entry', $hadAProblem);
// Users have a current organization that they are not a member of
$problems = DB::table('users')
->select(['users.id as id'])
->whereNotNull('current_team_id')
->whereNotIn('current_team_id', function (Builder $query): void {
$query->select('organization_id')
->from('members')
->whereColumn('members.user_id', 'users.id');
})->get();
$this->logProblems($problems, 'Users have a current organization that they are not a member of', $hadAProblem);
return $hadAProblem ? self::FAILURE : self::SUCCESS;
}
/**
* @param Collection<int, \stdClass> $problems
*/
private function logProblems(Collection $problems, string $message, bool &$hadAProblem): void
{
$message = 'Consistency problem: '.$message;
if ($problems->isNotEmpty()) {
$ids = $problems->pluck('id');
$hadAProblem = true;
Log::error($message, [
'ids' => $ids,
]);
$error = $message;
foreach ($ids as $id) {
$error .= "\n - ".$id;
}
$this->error($error);
}
}
}

View File

@@ -25,10 +25,6 @@ class Kernel extends ConsoleKernel
$schedule->command('self-host:telemetry') $schedule->command('self-host:telemetry')
->when(fn (): bool => config('scheduling.tasks.self_hosting_telemetry')) ->when(fn (): bool => config('scheduling.tasks.self_hosting_telemetry'))
->twiceDaily(); ->twiceDaily();
$schedule->command('self-host:database-consistency')
->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency'))
->everySixHours();
} }
/** /**

View File

@@ -1,14 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Events;
use Illuminate\Foundation\Events\Dispatchable;
class DatabaseSeederAfterSeed
{
use Dispatchable;
public function __construct() {}
}

View File

@@ -1,14 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Events;
use Illuminate\Foundation\Events\Dispatchable;
class DatabaseSeederBeforeDelete
{
use Dispatchable;
public function __construct() {}
}

View File

@@ -1,10 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class InvitationForTheEmailAlreadyExistsApiException extends ApiException
{
public const string KEY = 'invitation_for_the_email_already_exists';
}

View File

@@ -23,7 +23,6 @@ use Filament\Tables;
use Filament\Tables\Filters\TernaryFilter; use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
@@ -208,14 +207,6 @@ class UserResource extends Resource
}), }),
]) ])
->bulkActions([ ->bulkActions([
Tables\Actions\BulkAction::make('Resend verification email')
->icon('heroicon-o-paper-airplane')
->action(function (Collection $records): void {
foreach ($records as $user) {
/** @var User $user */
$user->sendEmailVerificationNotification();
}
}),
]); ]);
} }

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException; use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Http\Requests\V1\Invitation\InvitationIndexRequest; use App\Http\Requests\V1\Invitation\InvitationIndexRequest;
use App\Http\Requests\V1\Invitation\InvitationStoreRequest; use App\Http\Requests\V1\Invitation\InvitationStoreRequest;
@@ -51,7 +50,6 @@ class InvitationController extends Controller
* *
* @throws AuthorizationException * @throws AuthorizationException
* @throws UserIsAlreadyMemberOfOrganizationApiException * @throws UserIsAlreadyMemberOfOrganizationApiException
* @throws InvitationForTheEmailAlreadyExistsApiException
* *
* @operationId invite * @operationId invite
*/ */

View File

@@ -10,14 +10,12 @@ use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed; use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed; use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException; use App\Exceptions\Api\EntityStillInUseApiException;
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership; use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember; use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner; use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException; use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException; use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Exceptions\Api\UserNotPlaceholderApiException; use App\Exceptions\Api\UserNotPlaceholderApiException;
use App\Http\Requests\V1\Member\MemberDestroyRequest;
use App\Http\Requests\V1\Member\MemberIndexRequest; use App\Http\Requests\V1\Member\MemberIndexRequest;
use App\Http\Requests\V1\Member\MemberMergeIntoRequest; use App\Http\Requests\V1\Member\MemberMergeIntoRequest;
use App\Http\Requests\V1\Member\MemberUpdateRequest; use App\Http\Requests\V1\Member\MemberUpdateRequest;
@@ -102,13 +100,11 @@ class MemberController extends Controller
* *
* @operationId removeMember * @operationId removeMember
*/ */
public function destroy(MemberDestroyRequest $request, Organization $organization, Member $member, MemberService $memberService): JsonResponse public function destroy(Organization $organization, Member $member, MemberService $memberService): JsonResponse
{ {
$this->checkPermission($organization, 'members:delete', $member); $this->checkPermission($organization, 'members:delete', $member);
$deleteRelated = $request->getDeleteRelated(); $memberService->removeMember($member, $organization);
$memberService->removeMember($member, $organization, $deleteRelated);
return response() return response()
->json(null, 204); ->json(null, 204);
@@ -174,7 +170,6 @@ class MemberController extends Controller
* @throws UserNotPlaceholderApiException * @throws UserNotPlaceholderApiException
* @throws UserIsAlreadyMemberOfOrganizationApiException * @throws UserIsAlreadyMemberOfOrganizationApiException
* @throws ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException * @throws ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException
* @throws InvitationForTheEmailAlreadyExistsApiException
* *
* @operationId invitePlaceholder * @operationId invitePlaceholder
*/ */

View File

@@ -226,7 +226,6 @@ class TimeEntryController extends Controller
'start' => $request->getStart()->timezone($timezone), 'start' => $request->getStart()->timezone($timezone),
'end' => $request->getEnd()->timezone($timezone), 'end' => $request->getEnd()->timezone($timezone),
'localization' => $localizationService, 'localization' => $localizationService,
'showBillableRate' => $showBillableRate,
]); ]);
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index/pdf-footer.blade.php')); $footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index/pdf-footer.blade.php'));
if ($footerViewFile === false) { if ($footerViewFile === false) {
@@ -429,7 +428,6 @@ class TimeEntryController extends Controller
'end' => $request->getEnd()->timezone($timezone), 'end' => $request->getEnd()->timezone($timezone),
'debug' => $debug, 'debug' => $debug,
'localization' => $localizationService, 'localization' => $localizationService,
'showBillableRate' => $showBillableRate,
]); ]);
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate/pdf-footer.blade.php')); $footerViewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate/pdf-footer.blade.php'));
if ($footerViewFile === false) { if ($footerViewFile === false) {
@@ -458,7 +456,7 @@ class TimeEntryController extends Controller
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename); ->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
} else { } else {
Excel::store( Excel::store(
new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup, $showBillableRate), new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup),
$path, $path,
config('filesystems.private'), config('filesystems.private'),
$format->getExportPackageType(), $format->getExportPackageType(),

View File

@@ -43,10 +43,7 @@ class Controller extends BaseController
/** @var Member|null $member */ /** @var Member|null $member */
$member = Member::query()->whereBelongsTo($organization, 'organization')->whereBelongsTo($user, 'user')->first(); $member = Member::query()->whereBelongsTo($organization, 'organization')->whereBelongsTo($user, 'user')->first();
if ($member === null) { if ($member === null) {
Log::error('This function should only be called in authenticated context after checking the user is a member of the organization', [ Log::error('This function should only be called in authenticated context after checking the user is a member of the organization');
'user' => $user->getKey(),
'organization' => $organization->getKey(),
]);
throw new AuthorizationException; throw new AuthorizationException;
} }

View File

@@ -8,7 +8,9 @@ use Illuminate\Foundation\Http\FormRequest;
class BaseFormRequest extends FormRequest class BaseFormRequest extends FormRequest
{ {
/** /**
* @param bool $bigInt
* @return list<string> * @return list<string>
*/ */
protected function moneyRules(bool $bigInt = false): array protected function moneyRules(bool $bigInt = false): array

View File

@@ -7,8 +7,11 @@ namespace App\Http\Requests\V1\Invitation;
use App\Enums\Role; use App\Enums\Role;
use App\Http\Requests\V1\BaseFormRequest; use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization; use App\Models\Organization;
use App\Models\OrganizationInvitation;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/** /**
* @property Organization $organization * @property Organization $organization
@@ -26,6 +29,10 @@ class InvitationStoreRequest extends BaseFormRequest
'email' => [ 'email' => [
'required', 'required',
'email', 'email',
UniqueEloquent::make(OrganizationInvitation::class, 'email', function (Builder $builder): Builder {
/** @var Builder<OrganizationInvitation> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->withCustomTranslation('validation.invitation_already_exists'),
], ],
'role' => [ 'role' => [
'required', 'required',

View File

@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Member;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* @property Organization $organization
*/
class MemberDestroyRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'delete_related' => [
'string',
'in:true,false',
],
];
}
public function getDeleteRelated(): bool
{
return $this->input('delete_related', 'false') === 'true';
}
}

View File

@@ -187,7 +187,6 @@ class JetstreamServiceProvider extends ServiceProvider
'members:invite-placeholder', 'members:invite-placeholder',
'members:make-placeholder', 'members:make-placeholder',
'members:merge-into', 'members:merge-into',
'members:delete',
'members:update', 'members:update',
'reports:view', 'reports:view',
'reports:create', 'reports:create',

View File

@@ -100,18 +100,12 @@ class DeletionService
// Make sure all users have at least one organization and delete placeholders // Make sure all users have at least one organization and delete placeholders
foreach ($users as $user) { foreach ($users as $user) {
/** @var User $user */
if ($ignoreUser !== null && $user->is($ignoreUser)) { if ($ignoreUser !== null && $user->is($ignoreUser)) {
continue; continue;
} }
if ($user->is_placeholder) { if ($user->is_placeholder) {
$user->delete(); $user->delete();
} else { } else {
if ($user->current_team_id === $organization->getKey()) {
$user->currentOrganization()->disassociate();
$user->save();
}
$this->userService->makeSureUserHasAtLeastOneOrganization($user); $this->userService->makeSureUserHasAtLeastOneOrganization($user);
$this->userService->makeSureUserHasCurrentOrganization($user); $this->userService->makeSureUserHasCurrentOrganization($user);
} }

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Service; namespace App\Service;
use App\Enums\Role; use App\Enums\Role;
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\Member;
@@ -17,7 +16,7 @@ use Laravel\Jetstream\Events\InvitingTeamMember;
class InvitationService class InvitationService
{ {
/** /**
* @throws UserIsAlreadyMemberOfOrganizationApiException|InvitationForTheEmailAlreadyExistsApiException * @throws UserIsAlreadyMemberOfOrganizationApiException
*/ */
public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation
{ {
@@ -29,13 +28,6 @@ class InvitationService
throw new UserIsAlreadyMemberOfOrganizationApiException; throw new UserIsAlreadyMemberOfOrganizationApiException;
} }
if (OrganizationInvitation::query()
->where('email', $email)
->whereBelongsTo($organization, 'organization')
->exists()) {
throw new InvitationForTheEmailAlreadyExistsApiException;
}
InvitingTeamMember::dispatch($organization, $email, $role->value); InvitingTeamMember::dispatch($organization, $email, $role->value);
$invitation = new OrganizationInvitation; $invitation = new OrganizationInvitation;

View File

@@ -45,9 +45,6 @@ class MemberService
$member->organization()->associate($organization); $member->organization()->associate($organization);
$member->role = $role->value; $member->role = $role->value;
$member->save(); $member->save();
$user->currentOrganization()->associate($organization);
$user->save();
}); });
if (! $asSuperAdmin) { if (! $asSuperAdmin) {
@@ -61,41 +58,19 @@ class MemberService
* @throws CanNotRemoveOwnerFromOrganization * @throws CanNotRemoveOwnerFromOrganization
* @throws EntityStillInUseApiException * @throws EntityStillInUseApiException
*/ */
public function removeMember(Member $member, Organization $organization, bool $withRelations = false): void public function removeMember(Member $member, Organization $organization): void
{ {
if (TimeEntry::query()->where('user_id', $member->user_id)->whereBelongsTo($organization, 'organization')->exists()) {
throw new EntityStillInUseApiException('member', 'time_entry');
}
if (ProjectMember::query()->whereBelongsToOrganization($organization)->where('user_id', $member->user_id)->exists()) {
throw new EntityStillInUseApiException('member', 'project_member');
}
if ($member->role === Role::Owner->value) { if ($member->role === Role::Owner->value) {
throw new CanNotRemoveOwnerFromOrganization; throw new CanNotRemoveOwnerFromOrganization;
} }
$user = $member->user;
$isPlaceholder = $user->is_placeholder;
if (! $isPlaceholder && $user->current_team_id === $member->organization_id) {
$user->currentTeam()->disassociate();
$user->save();
}
if ($withRelations) {
TimeEntry::query()->where('user_id', $member->user_id)->whereBelongsTo($organization, 'organization')->delete();
ProjectMember::query()->whereBelongsToOrganization($organization)->where('user_id', $member->user_id)->delete();
} else {
if (TimeEntry::query()->where('user_id', $member->user_id)->whereBelongsTo($organization, 'organization')->exists()) {
throw new EntityStillInUseApiException('member', 'time_entry');
}
if (ProjectMember::query()->whereBelongsToOrganization($organization)->where('user_id', $member->user_id)->exists()) {
throw new EntityStillInUseApiException('member', 'project_member');
}
}
$member->delete(); $member->delete();
if ($isPlaceholder) {
$user->delete();
} else {
$this->userService->makeSureUserHasAtLeastOneOrganization($user);
$this->userService->makeSureUserHasCurrentOrganization($user);
}
MemberRemoved::dispatch($member, $organization); MemberRemoved::dispatch($member, $organization);
} }
@@ -189,11 +164,6 @@ class MemberService
public function makeMemberToPlaceholder(Member $member, bool $makeSureUserHasAtLeastOneOrganization = true): void public function makeMemberToPlaceholder(Member $member, bool $makeSureUserHasAtLeastOneOrganization = true): void
{ {
$user = $member->user; $user = $member->user;
if ($user->current_team_id === $member->organization_id) {
$user->currentTeam()->disassociate();
$user->save();
}
$placeholderUser = $user->replicate(); $placeholderUser = $user->replicate();
$placeholderUser->is_placeholder = true; $placeholderUser->is_placeholder = true;
$placeholderUser->save(); $placeholderUser->save();
@@ -205,7 +175,6 @@ class MemberService
$this->userService->assignOrganizationEntitiesToDifferentUser($member->organization, $user, $placeholderUser); $this->userService->assignOrganizationEntitiesToDifferentUser($member->organization, $user, $placeholderUser);
if ($makeSureUserHasAtLeastOneOrganization) { if ($makeSureUserHasAtLeastOneOrganization) {
$this->userService->makeSureUserHasAtLeastOneOrganization($user); $this->userService->makeSureUserHasAtLeastOneOrganization($user);
$this->userService->makeSureUserHasCurrentOrganization($user);
} }
} }
} }

View File

@@ -46,8 +46,6 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
private TimeEntryAggregationType $subGroup; private TimeEntryAggregationType $subGroup;
private bool $showBillableRate;
/** /**
* @param array{ * @param array{
* grouped_type: string|null, * grouped_type: string|null,
@@ -68,14 +66,13 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
* cost: int|null * cost: int|null
* } $data * } $data
*/ */
public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup, bool $showBillableRate) public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup)
{ {
$this->data = $data; $this->data = $data;
$this->exportFormat = $exportFormat; $this->exportFormat = $exportFormat;
$this->currency = $currency; $this->currency = $currency;
$this->group = $group; $this->group = $group;
$this->subGroup = $subGroup; $this->subGroup = $subGroup;
$this->showBillableRate = $showBillableRate;
} }
public function view(): View public function view(): View
@@ -86,7 +83,6 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
'group' => $this->group, 'group' => $this->group,
'subGroup' => $this->subGroup, 'subGroup' => $this->subGroup,
'exportFormat' => $this->exportFormat, 'exportFormat' => $this->exportFormat,
'showBillableRate' => $this->showBillableRate,
]); ]);
} }

View File

@@ -114,15 +114,13 @@ class UserService
public function makeSureUserHasCurrentOrganization(User $user): void public function makeSureUserHasCurrentOrganization(User $user): void
{ {
if ($user->current_team_id !== null) { if ($user->currentOrganization !== null) {
return; return;
} }
$organization = $user->organizations()->first(); $organization = $user->organizations()->first();
if ($organization !== null) { $user->currentOrganization()->associate($organization);
$user->currentOrganization()->associate($organization); $user->save();
$user->save();
}
} }
/** /**

View File

@@ -8,6 +8,5 @@ return [
'time_entry_send_still_running_mails' => (bool) env('SCHEDULING_TASK_TIME_ENTRY_SEND_STILL_RUNNING_MAILS', true), 'time_entry_send_still_running_mails' => (bool) env('SCHEDULING_TASK_TIME_ENTRY_SEND_STILL_RUNNING_MAILS', true),
'self_hosting_check_for_update' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_CHECK_FOR_UPDATE', true), 'self_hosting_check_for_update' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_CHECK_FOR_UPDATE', true),
'self_hosting_telemetry' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_TELEMETRY', true), 'self_hosting_telemetry' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_TELEMETRY', true),
'self_hosting_database_consistency' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_DATABASE_CONSISTENCY', false),
], ],
]; ];

View File

@@ -1,43 +0,0 @@
<?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
{
DB::statement('
update users
set current_team_id = null
where id in (
select users.id from users
left join organizations on users.current_team_id = organizations.id
where users.current_team_id is not null and organizations.id is null
)
');
Schema::table('users', function (Blueprint $table): void {
$table->foreign('current_team_id', 'organizations_current_organization_id_foreign')
->references('id')
->on('organizations')
->onDelete('restrict')
->onUpdate('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->dropForeign('organizations_current_organization_id_foreign');
});
}
};

View File

@@ -5,8 +5,6 @@ declare(strict_types=1);
namespace Database\Seeders; namespace Database\Seeders;
use App\Enums\Role; use App\Enums\Role;
use App\Events\DatabaseSeederAfterSeed;
use App\Events\DatabaseSeederBeforeDelete;
use App\Models\Audit; use App\Models\Audit;
use App\Models\Client; use App\Models\Client;
use App\Models\Member; use App\Models\Member;
@@ -186,13 +184,10 @@ class DatabaseSeeder extends Seeder
'email' => 'admin@example.com', 'email' => 'admin@example.com',
]); ]);
DatabaseSeederAfterSeed::dispatch();
} }
private function deleteAll(): void private function deleteAll(): void
{ {
DatabaseSeederBeforeDelete::dispatch();
// Laravel Passport tables // Laravel Passport tables
DB::table((new RefreshToken)->getTable())->delete(); DB::table((new RefreshToken)->getTable())->delete();
DB::table((new Token)->getTable())->delete(); DB::table((new Token)->getTable())->delete();
@@ -218,9 +213,6 @@ class DatabaseSeeder extends Seeder
DB::table((new Client)->getTable())->delete(); DB::table((new Client)->getTable())->delete();
DB::table((new Member)->getTable())->delete(); DB::table((new Member)->getTable())->delete();
DB::table((new OrganizationInvitation)->getTable())->delete(); DB::table((new OrganizationInvitation)->getTable())->delete();
DB::table((new User)->getTable())->update([
'current_team_id' => null,
]);
DB::table((new Organization)->getTable())->delete(); DB::table((new Organization)->getTable())->delete();
DB::table((new User)->getTable())->delete(); DB::table((new User)->getTable())->delete();
} }

View File

@@ -102,7 +102,7 @@ test('test that updating billable rate works with existing time entries', async
await page.getByRole('row').first().getByRole('button').click(); await page.getByRole('row').first().getByRole('button').click();
await page.getByRole('menuitem').getByText('Edit').first().click(); await page.getByRole('menuitem').getByText('Edit').first().click();
await page.getByText('Non-Billable').click(); await page.getByText('Non-Billable').click();
await page.getByText('Custom Rate').click(); await page.getByText('Custom Rate').click();
await page await page
.getByPlaceholder('Billable Rate') .getByPlaceholder('Billable Rate')
@@ -111,8 +111,8 @@ test('test that updating billable rate works with existing time entries', async
await Promise.all([ await Promise.all([
page page
.locator('button').filter({ hasText: 'Yes, update existing time' }) .getByRole('button', { name: 'Yes, update existing time entries' })
.click(), .click(),
page.waitForRequest( page.waitForRequest(
async (request) => async (request) =>
request.url().includes('/projects/') && request.url().includes('/projects/') &&

View File

@@ -9,7 +9,6 @@ use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException; use App\Exceptions\Api\EntityStillInUseApiException;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException; use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException; use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership; use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember; use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException; use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
@@ -46,7 +45,6 @@ return [
ChangingRoleOfPlaceholderIsNotAllowed::KEY => 'Changing role of placeholder is not allowed', ChangingRoleOfPlaceholderIsNotAllowed::KEY => 'Changing role of placeholder is not allowed',
OnlyPlaceholdersCanBeMergedIntoAnotherMember::KEY => 'Only placeholders can be merged into another member', OnlyPlaceholdersCanBeMergedIntoAnotherMember::KEY => 'Only placeholders can be merged into another member',
ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::KEY => 'This placeholder can not be invited use the merge tool instead', ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::KEY => 'This placeholder can not be invited use the merge tool instead',
InvitationForTheEmailAlreadyExistsApiException::KEY => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.',
], ],
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.', 'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
]; ];

View File

@@ -9,8 +9,7 @@ return [
'2. In the same preferences page change the language of Clockfiy to English.<br>'. '2. In the same preferences page change the language of Clockfiy to English.<br>'.
'3. Go to REPORTS -> TIME -> Detailed in the navigation on the left. <br>'. '3. Go to REPORTS -> TIME -> Detailed in the navigation on the left. <br>'.
'4. Now select the date range that you want to export in the right top. '. '4. Now select the date range that you want to export in the right top. '.
'In the free Clockify plan it\'s currently not possible to select more than one year. '. 'It is currently not possible to select more than one year. You can export each year separately and import them one after another .'.
'You can export each year separately and import them one after another.'.
'<br> 4. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table left of the printer symbol. '. '<br> 4. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table left of the printer symbol. '.
'<br><br>Before you import make sure that the Timezone settings in Clockify are the same as in solidtime.', '<br><br>Before you import make sure that the Timezone settings in Clockify are the same as in solidtime.',
], ],

View File

@@ -44,7 +44,7 @@
--theme-color-input-select-active: rgb(var(--color-accent-300)); --theme-color-input-select-active: rgb(var(--color-accent-300));
--theme-color-input-select-active-hover: rgb(var(--color-accent-200)); --theme-color-input-select-active-hover: rgb(var(--color-accent-200));
--color-accent-default: rgba(var(--color-accent-300), 0.2); --color-accent-default: rgb(var(--color-accent-900));
--color-accent-foreground: rgb(var(--color-accent-100)); --color-accent-foreground: rgb(var(--color-accent-100));
} }

View File

@@ -1,151 +0,0 @@
<script setup lang="ts">
import type { Member } from '@/packages/api/src';
import { api } from '@/packages/api/src';
import { useForm } from '@tanstack/vue-form';
import { useMutation } from '@tanstack/vue-query';
import Modal from '@/packages/ui/src/Modal.vue';
import DangerButton from '@/packages/ui/src/Buttons/DangerButton.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import Checkbox from '@/packages/ui/src/Input/Checkbox.vue';
import { useNotificationsStore } from '@/utils/notification';
import { getCurrentOrganizationId } from '@/utils/useUser';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import InputError from '@/packages/ui/src/Input/InputError.vue';
import { useMembersStore } from '@/utils/useMembers';
const props = defineProps<{
show: boolean;
member: Member;
}>();
const emit = defineEmits<{
'update:show': [value: boolean];
}>();
const { handleApiRequestNotifications } = useNotificationsStore();
const deleteMutation = useMutation({
mutationFn: async () => {
const organizationId = getCurrentOrganizationId();
if (!organizationId) {
throw new Error('No organization ID found');
}
return api.removeMember(undefined, {
params: {
member: props.member.id,
organization: organizationId,
},
queries: {
delete_related: 'true',
},
});
},
onSuccess: () => {
close();
useMembersStore().fetchMembers();
}
});
const form = useForm({
canSubmitWhenInvalid: true,
defaultValues: {
confirmDelete: false,
},
onSubmit: async () => {
await handleApiRequestNotifications(
() => deleteMutation.mutateAsync(),
'Member deleted successfully',
'Error deleting member'
);
},
});
const close = () => {
emit('update:show', false);
form.reset();
};
</script>
<template>
<Modal :show="show" max-width="md" @close="close">
<div class="p-6">
<h2 class="text-lg font-medium text-text-primary">
Delete Member
</h2>
<div class="mt-4 text-sm text-text-secondary">
<p class="mb-4">
Are you sure you want to delete {{ member.name }}? This action cannot be undone.
</p>
<p class="mb-4">
This will permanently delete:
</p>
<ul class="list-disc ml-6 mt-2">
<li>All time entries created by this member</li>
<li>Their project assignments</li>
<li>Their organization membership</li>
</ul>
<p class="pt-4">
<strong>Note:</strong> Deleting time entries will affect all reports and statistics.
If you want to keep the time entries but remove the member from your organization, you can convert them to a placeholder user instead. Placeholder users are not charged and their time entries remain intact for reporting purposes.
</p>
</div>
<form
class="mt-6" @submit="
(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}
">
<div class="flex items-start">
<form.Field
name="confirmDelete"
:validators="{
onSubmit: ({value}) => {
if (!value) {
return 'You must confirm that you understand the consequences of this action';
}
return '';
}
}"
>
<template #default="{ field }">
<div class="flex flex-col">
<div class="flex items-center space-x-3 text-sm">
<Checkbox
:id="field.name"
:name="field.name"
:checked="field.state.value"
@update:checked="field.handleChange"
@blur="field.handleBlur"
/>
<InputLabel :for="field.name" class="font-medium text-text-primary">
I understand that this will permanently delete all data related to this member
</InputLabel>
</div>
<InputError class="pl-7 pt-2" :message="field.state.meta.errors[0]" />
</div>
</template>
</form.Field>
</div>
<div class="mt-6 flex justify-end space-x-3">
<SecondaryButton @click="close">Cancel</SecondaryButton>
<form.Subscribe>
<template #default="{ canSubmit, isSubmitting }">
<DangerButton
type="submit"
:disabled="!canSubmit"
>
{{ isSubmitting ? 'Deleting...' : 'Delete Member' }}
</DangerButton>
</template>
</form.Subscribe>
</div>
</form>
</div>
</Modal>
</template>

View File

@@ -49,6 +49,15 @@ const props = defineProps<{
<PencilSquareIcon class="w-5 text-icon-active" /> <PencilSquareIcon class="w-5 text-icon-active" />
<span>Edit</span> <span>Edit</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem
v-if="canDeleteMembers()"
:aria-label="'Delete Member ' + props.member.name"
data-testid="member_delete"
class="flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive"
@click="emit('delete')">
<TrashIcon class="w-5" />
<span>Delete</span>
</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
v-if="props.member.role === 'placeholder' && canMergeMembers()" v-if="props.member.role === 'placeholder' && canMergeMembers()"
:aria-label="'Merge Member ' + props.member.name" :aria-label="'Merge Member ' + props.member.name"
@@ -66,15 +75,6 @@ const props = defineProps<{
<UserCircleIcon class="w-5 text-icon-active" /> <UserCircleIcon class="w-5 text-icon-active" />
<span>Deactivate</span> <span>Deactivate</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem
v-if="canDeleteMembers()"
:aria-label="'Delete Member ' + props.member.name"
data-testid="member_delete"
class="flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive"
@click="emit('delete')">
<TrashIcon class="w-5" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</template> </template>

View File

@@ -8,30 +8,26 @@ import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification'; import { useNotificationsStore } from '@/utils/notification';
import { canInvitePlaceholderMembers } from '@/utils/permissions'; import { canInvitePlaceholderMembers } from '@/utils/permissions';
import { useMembersStore } from '@/utils/useMembers';
import { computed, type ComputedRef, inject, ref } from 'vue'; import { computed, type ComputedRef, inject, ref } from 'vue';
import MemberEditModal from '@/Components/Common/Member/MemberEditModal.vue'; import MemberEditModal from '@/Components/Common/Member/MemberEditModal.vue';
import MemberMergeModal from '@/Components/Common/Member/MemberMergeModal.vue'; import MemberMergeModal from '@/Components/Common/Member/MemberMergeModal.vue';
import MemberMakePlaceholderModal from '@/Components/Common/Member/MemberMakePlaceholderModal.vue'; import MemberMakePlaceholderModal from '@/Components/Common/Member/MemberMakePlaceholderModal.vue';
import MemberDeleteModal from '@/Components/Common/Member/MemberDeleteModal.vue';
import { capitalizeFirstLetter } from '../../../utils/format'; import { capitalizeFirstLetter } from '../../../utils/format';
import { formatCents } from '../../../packages/ui/src/utils/money'; import { formatCents } from '../../../packages/ui/src/utils/money';
import { useMembersStore } from '@/utils/useMembers';
const props = defineProps<{ const props = defineProps<{
member: Member; member: Member;
}>(); }>();
const organization = inject<ComputedRef<Organization>>('organization'); const organization = inject<ComputedRef<Organization>>('organization');
const memberStore = useMembersStore();
const showEditMemberModal = ref(false); const showEditMemberModal = ref(false);
const showMergeMemberModal = ref(false); const showMergeMemberModal = ref(false);
const showMakeMemberPlaceholderModal = ref(false); const showMakeMemberPlaceholderModal = ref(false);
const showDeleteMemberModal = ref(false);
function removeMember() { function removeMember() {
showDeleteMemberModal.value = true; useMembersStore().removeMember(props.member.id);
memberStore.fetchMembers();
} }
async function invitePlaceholder(id: string) { async function invitePlaceholder(id: string) {
@@ -125,9 +121,6 @@ const userHasValidMailAddress = computed(() => {
<MemberMakePlaceholderModal <MemberMakePlaceholderModal
v-model:show="showMakeMemberPlaceholderModal" v-model:show="showMakeMemberPlaceholderModal"
:member="member"></MemberMakePlaceholderModal> :member="member"></MemberMakePlaceholderModal>
<MemberDeleteModal
v-model:show="showDeleteMemberModal"
:member="member"></MemberDeleteModal>
</TableRow> </TableRow>
</template> </template>

View File

@@ -5,7 +5,6 @@ import { h, ref } from 'vue';
import type { CreateReportBodyProperties } from '@/packages/api/src'; import type { CreateReportBodyProperties } from '@/packages/api/src';
import { isAllowedToPerformPremiumAction } from '@/utils/billing'; import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import UpgradeModal from '@/Components/Common/UpgradeModal.vue'; import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
import { canCreateReports } from '@/utils/permissions';
defineProps<{ defineProps<{
reportProperties: CreateReportBodyProperties; reportProperties: CreateReportBodyProperties;
}>(); }>();
@@ -34,10 +33,7 @@ function onSaveReportClick() {
<strong>Sharable Reports</strong> is only available in solidtime <strong>Sharable Reports</strong> is only available in solidtime
Professional. Professional.
</UpgradeModal> </UpgradeModal>
<SecondaryButton <SecondaryButton :icon="SaveIcon" @click="onSaveReportClick"
v-if="canCreateReports()"
:icon="SaveIcon"
@click="onSaveReportClick"
>Save Report</SecondaryButton >Save Report</SecondaryButton
> >
</template> </template>

View File

@@ -17,7 +17,7 @@ import {
TooltipComponent, TooltipComponent,
} from 'echarts/components'; } from 'echarts/components';
import type { AggregatedTimeEntries, Organization } from '@/packages/api/src'; import type { AggregatedTimeEntries, Organization } from '@/packages/api/src';
import { useCssVariable } from '@/utils/useCssVariable'; import { useCssVar } from '@vueuse/core';
use([ use([
CanvasRenderer, CanvasRenderer,
@@ -47,10 +47,8 @@ const xAxisLabels = computed(() => {
formatDate(el.key ?? '', organization?.value?.date_format) formatDate(el.key ?? '', organization?.value?.date_format)
); );
}); });
const accentColor = useCssVariable('--theme-color-chart'); const accentColor = useCssVar('--theme-color-chart', null, { observe: true });
const labelColor = useCssVariable('--color-text-secondary'); const labelColor = useCssVar('--color-text-secondary', null, { observe: true });
const markLineColor = useCssVariable('--color-border-secondary');
const splitLineColor = useCssVariable('--color-border-tertiary');
const seriesData = computed(() => { const seriesData = computed(() => {
return props?.groupedData?.map((el) => { return props?.groupedData?.map((el) => {
@@ -113,7 +111,7 @@ const option = computed(() => ({
data: xAxisLabels.value, data: xAxisLabels.value,
markLine: { markLine: {
lineStyle: { lineStyle: {
color: markLineColor.value, color: 'rgba(125,156,188,0.1)',
type: 'dashed', type: 'dashed',
}, },
}, },
@@ -137,13 +135,9 @@ const option = computed(() => ({
}, },
yAxis: { yAxis: {
type: 'value', type: 'value',
axisLabel: {
color: labelColor.value,
fontFamily: 'Outfit, sans-serif',
},
splitLine: { splitLine: {
lineStyle: { lineStyle: {
color: splitLineColor.value, color: 'rgba(125,156,188,0.2)', // Set desired color here
}, },
}, },
}, },

View File

@@ -107,10 +107,6 @@ function getFilterAttributes(): AggregatedTimeEntriesQueryParams {
: undefined, : undefined,
tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined, tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,
billable: billable.value !== null ? billable.value : undefined, billable: billable.value !== null ? billable.value : undefined,
member_id:
getCurrentRole() === 'employee'
? getCurrentMembershipId()
: undefined,
}; };
return params; return params;
} }

View File

@@ -11,7 +11,7 @@ import {
TooltipComponent, TooltipComponent,
} from 'echarts/components'; } from 'echarts/components';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time'; import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { useCssVariable } from '@/utils/useCssVariable'; import { useCssVar } from '@vueuse/core';
import type { Organization } from '@/packages/api/src'; import type { Organization } from '@/packages/api/src';
use([ use([
@@ -36,7 +36,7 @@ type ReportingChartDataEntry = {
const props = defineProps<{ const props = defineProps<{
data: ReportingChartDataEntry | null; data: ReportingChartDataEntry | null;
}>(); }>();
const labelColor = useCssVariable('--color-text-secondary'); const labelColor = useCssVar('--color-text-secondary', null, { observe: true });
const seriesData = computed(() => { const seriesData = computed(() => {
return props.data?.map((el) => { return props.data?.map((el) => {

View File

@@ -19,7 +19,7 @@ import {
formatHumanReadableDuration, formatHumanReadableDuration,
getDayJsInstance, getDayJsInstance,
} from '@/packages/ui/src/utils/time'; } from '@/packages/ui/src/utils/time';
import { useCssVariable } from '@/utils/useCssVariable'; import { useCssVar } from '@vueuse/core';
import { useQuery } from '@tanstack/vue-query'; import { useQuery } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
import { api, type Organization } from '@/packages/api/src'; import { api, type Organization } from '@/packages/api/src';
@@ -64,9 +64,12 @@ const max = computed(() => {
} }
}); });
const backgroundColor = useCssVariable('--theme-color-card-background'); const backgroundColor = useCssVar('--color-card-background', null, {
const itemBackgroundColor = useCssVariable('--color-bg-tertiary'); observe: true,
const borderColor = useCssVariable('--color-border'); });
const itemBackgroundColor = useCssVar('--color-bg-tertiary', null, {
observe: true,
});
const option = computed(() => { const option = computed(() => {
return { return {
@@ -117,7 +120,7 @@ const option = computed(() => {
[], [],
itemStyle: { itemStyle: {
borderRadius: 5, borderRadius: 5,
borderColor: borderColor.value, borderColor: 'rgba(255,255,255,0.05)',
borderWidth: 1, borderWidth: 1,
}, },
tooltip: { tooltip: {

View File

@@ -1,14 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import VChart from 'vue-echarts'; import VChart from 'vue-echarts';
import { computed } from 'vue'; import { computed, ref } from 'vue';
import { useCssVariable } from '@/utils/useCssVariable'; import { useCssVar } from '@vueuse/core';
const props = defineProps<{ const props = defineProps<{
history: number[]; history: number[];
}>(); }>();
const accentColor = useCssVariable('--theme-color-chart'); const accentColor = useCssVar('--theme-color-chart', null, { observe: true });
const markLineColor = useCssVariable('--color-border-secondary');
const seriesData = computed(() => props.history.map((el) => { const seriesData = computed(() => props.history.map((el) => {
return { return {
@@ -23,7 +22,7 @@ const seriesData = computed(() => props.history.map((el) => {
}, },
}; };
})); }));
const option = computed(() => ({ const option = ref({
grid: { grid: {
top: 0, top: 0,
right: 0, right: 0,
@@ -36,7 +35,7 @@ const option = computed(() => ({
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'], data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
markLine: { markLine: {
lineStyle: { lineStyle: {
color: markLineColor.value, color: 'rgba(125,156,188,0.1)',
type: 'dashed', type: 'dashed',
}, },
}, },
@@ -67,11 +66,11 @@ const option = computed(() => ({
}, },
series: [ series: [
{ {
data: seriesData.value, data: seriesData,
type: 'bar', type: 'bar',
}, },
], ],
})); });
</script> </script>
<template> <template>

View File

@@ -11,7 +11,7 @@ import {
TooltipComponent, TooltipComponent,
} from 'echarts/components'; } from 'echarts/components';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time'; import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { useCssVariable } from '@/utils/useCssVariable'; import { useCssVar } from "@vueuse/core";
import type { Organization } from "@/packages/api/src"; import type { Organization } from "@/packages/api/src";
use([ use([
@@ -24,7 +24,7 @@ use([
]); ]);
provide(THEME_KEY, 'dark'); provide(THEME_KEY, 'dark');
const labelColor = useCssVariable('--color-text-secondary'); const labelColor = useCssVar('--color-text-secondary', null, { observe: true });
const props = defineProps<{ const props = defineProps<{
weeklyProjectOverview: { weeklyProjectOverview: {

View File

@@ -18,7 +18,7 @@ import ProjectsChartCard from '@/Components/Dashboard/ProjectsChartCard.vue';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time'; import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { formatCents } from '@/packages/ui/src/utils/money'; import { formatCents } from '@/packages/ui/src/utils/money';
import { getWeekStart } from '@/packages/ui/src/utils/settings'; import { getWeekStart } from '@/packages/ui/src/utils/settings';
import { useCssVariable } from '@/utils/useCssVariable'; import { useCssVar } from '@vueuse/core';
import { getOrganizationCurrencyString } from '@/utils/money'; import { getOrganizationCurrencyString } from '@/utils/money';
import { useQuery } from '@tanstack/vue-query'; import { useQuery } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
@@ -60,7 +60,7 @@ const weekdays = computed(() => {
} }
}); });
const accentColor = useCssVariable('--theme-color-chart'); const accentColor = useCssVar('--theme-color-chart', null, { observe: true });
// Get the organization ID using the utility function // Get the organization ID using the utility function
const organizationId = computed(() => getCurrentOrganizationId()); const organizationId = computed(() => getCurrentOrganizationId());
@@ -176,8 +176,10 @@ const seriesData = computed(() => {
}); });
}); });
const markLineColor = useCssVariable('--color-border-secondary'); const markLineColor = useCssVar('--color-border-secondary', null, {
const labelColor = useCssVariable('--color-text-secondary'); observe: true,
});
const labelColor = useCssVar('--color-text-secondary', null, { observe: true });
const option = computed(() => { const option = computed(() => {
return { return {
tooltip: { tooltip: {
@@ -213,10 +215,6 @@ const option = computed(() => {
}, },
yAxis: { yAxis: {
type: 'value', type: 'value',
axisLabel: {
color: labelColor.value,
fontFamily: 'Outfit, sans-serif',
},
splitLine: { splitLine: {
lineStyle: { lineStyle: {
color: markLineColor.value, color: markLineColor.value,

View File

@@ -30,21 +30,15 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<div <div
class="absolute inset-0 bg-default-background opacity-30" /> class="absolute inset-0 bg-default-background opacity-30" />
</DialogOverlay> </DialogOverlay>
<div <DialogContent
v-bind="forwarded"
: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 left-1/2 top-1/3 bg-default-background z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 border shadow-lg duration-200 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 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
)"
>
<DialogContent
v-bind="forwarded"
:class="cn(
'bg-default-background grid w-full max-w-lg border border-border-tertiary shadow-lg duration-200 sm:rounded-lg 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,
)" )"
> >
<slot /> <slot />
</DialogContent> </DialogContent>
</div>
</DialogPortal> </DialogPortal>
</template> </template>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import FormSection from '@/Components/FormSection.vue'; import FormSection from '@/Components/FormSection.vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue'; import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import {computed, ref, inject, type ComputedRef} from 'vue'; import {computed, ref} from 'vue';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue'; import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import { import {
api, api,
@@ -23,7 +23,6 @@ import {useNotificationsStore} from "@/utils/notification";
import {useClipboard} from "@vueuse/core"; import {useClipboard} from "@vueuse/core";
import { formatDateTimeLocalized} from "../../../packages/ui/src/utils/time"; import { formatDateTimeLocalized} from "../../../packages/ui/src/utils/time";
import {ClockIcon} from "@heroicons/vue/20/solid"; import {ClockIcon} from "@heroicons/vue/20/solid";
import type { Organization } from '@/packages/api/src';
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -35,8 +34,6 @@ const newToken = ref('');
const { copy, copied, isSupported } = useClipboard(); const { copy, copied, isSupported } = useClipboard();
const organization = inject<ComputedRef<Organization>>('organization');
async function createApiToken(){ async function createApiToken(){
await handleApiRequestNotifications( await handleApiRequestNotifications(
() => () =>
@@ -216,10 +213,10 @@ const revokeApiTokenMutation = useMutation({
<div>{{ token.name }}</div> <div>{{ token.name }}</div>
<div class="text-sm text-text-tertiary space-x-3"> <div class="text-sm text-text-tertiary space-x-3">
<span v-if="token.created_at"> <span v-if="token.created_at">
Created at {{ formatDateTimeLocalized(token.created_at, organization?.date_format, organization?.time_format) }} Created at {{ formatDateTimeLocalized(token.created_at) }}
</span> </span>
<span v-if="token.expires_at"> <span v-if="token.expires_at">
Expires at {{ formatDateTimeLocalized(token.expires_at, organization?.date_format, organization?.time_format) }} Expires at {{ formatDateTimeLocalized(token.expires_at) }}
</span> </span>
<span v-if="token.revoked"> <span v-if="token.revoked">
Revoked Revoked

View File

@@ -160,7 +160,10 @@ const tableData = computed(() => {
cost: el.cost, cost: el.cost,
description: description:
el.description ?? el.description ??
emptyPlaceholder[entry.grouped_type ?? 'project'], emptyPlaceholder[
aggregatedTableTimeEntries.value
?.grouped_type ?? 'project'
],
}; };
}) ?? [], }) ?? [],
}; };

View File

@@ -2407,11 +2407,6 @@ const endpoints = makeApi([
type: 'Path', type: 'Path',
schema: z.string(), schema: z.string(),
}, },
{
name: 'delete_related',
type: 'Query',
schema: z.enum(['true', 'false']).optional(),
},
], ],
response: z.void(), response: z.void(),
errors: [ errors: [
@@ -2441,16 +2436,6 @@ const endpoints = makeApi([
description: `Not found`, description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(), schema: z.object({ message: z.string() }).passthrough(),
}, },
{
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
], ],
}, },
{ {

View File

@@ -5,7 +5,10 @@ import {
PopoverTrigger, PopoverTrigger,
} from '@/Components/ui/popover'; } from '@/Components/ui/popover';
import { RangeCalendar } from '@/Components/ui/range-calendar'; import { RangeCalendar } from '@/Components/ui/range-calendar';
import { CalendarDate } from '@internationalized/date'; import {
CalendarDate,
getLocalTimeZone,
} from '@internationalized/date';
import { CalendarIcon } from 'lucide-vue-next'; import { CalendarIcon } from 'lucide-vue-next';
import { computed, ref, inject, type ComputedRef, watch } from 'vue'; import { computed, ref, inject, type ComputedRef, watch } from 'vue';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
@@ -13,9 +16,8 @@ import {
getDayJsInstance, getDayJsInstance,
getLocalizedDayJs, getLocalizedDayJs,
} from '@/packages/ui/src/utils/time'; } from '@/packages/ui/src/utils/time';
import { formatDateLocalized } from '@/packages/ui/src/utils/time';
import { type Organization } from '@/packages/api/src'; import { type Organization } from '@/packages/api/src';
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
import { formatDate } from '@/packages/ui/src/utils/time';
const props = defineProps<{ const props = defineProps<{
start: string; start: string;
@@ -57,13 +59,12 @@ const modelValue = computed<CalendarDateRange>({
}), }),
set: (newValue) => { set: (newValue) => {
if (newValue.start) { if (newValue.start) {
console.log(newValue.start); const date = newValue.start.toDate(getLocalTimeZone());
const date = newValue.start.toDate(getUserTimezone()); emit('update:start', getDayJsInstance()(date).format('YYYY-MM-DD'));
emit('update:start', getLocalizedDayJs(date.toString()).format());
} }
if (newValue.end) { if (newValue.end) {
const date = newValue.end.toDate(getUserTimezone()); const date = newValue.end.toDate(getLocalTimeZone());
emit('update:end', getLocalizedDayJs(date.toString()).format()); emit('update:end', getDayJsInstance()(date).format('YYYY-MM-DD'));
} }
}, },
}); });
@@ -73,18 +74,18 @@ const open = ref(false);
function setToday() { function setToday() {
emit( emit(
'update:start', 'update:start',
getLocalizedDayJs().startOf('day').format() getLocalizedDayJs().startOf('day').format('YYYY-MM-DD')
); );
emit('update:end', getLocalizedDayJs().endOf('day').format()); emit('update:end', getLocalizedDayJs().endOf('day').format('YYYY-MM-DD'));
open.value = false; open.value = false;
} }
function setThisWeek() { function setThisWeek() {
emit( emit(
'update:start', 'update:start',
getLocalizedDayJs().startOf('week').format() getLocalizedDayJs().startOf('week').format('YYYY-MM-DD')
); );
emit('update:end', getLocalizedDayJs().endOf('week').format()); emit('update:end', getLocalizedDayJs().endOf('week').format('YYYY-MM-DD'));
open.value = false; open.value = false;
} }
@@ -94,14 +95,14 @@ function setLastWeek() {
getLocalizedDayJs() getLocalizedDayJs()
.subtract(1, 'week') .subtract(1, 'week')
.startOf('week') .startOf('week')
.format() .format('YYYY-MM-DD')
); );
emit( emit(
'update:end', 'update:end',
getLocalizedDayJs() getLocalizedDayJs()
.subtract(1, 'week') .subtract(1, 'week')
.endOf('week') .endOf('week')
.format() .format('YYYY-MM-DD')
); );
open.value = false; open.value = false;
} }
@@ -109,18 +110,18 @@ function setLastWeek() {
function setLast14Days() { function setLast14Days() {
emit( emit(
'update:start', 'update:start',
getLocalizedDayJs().subtract(14, 'days').format() getLocalizedDayJs().subtract(14, 'days').format('YYYY-MM-DD')
); );
emit('update:end', getLocalizedDayJs().format()); emit('update:end', getLocalizedDayJs().format('YYYY-MM-DD'));
open.value = false; open.value = false;
} }
function setThisMonth() { function setThisMonth() {
emit( emit(
'update:start', 'update:start',
getLocalizedDayJs().startOf('month').format() getLocalizedDayJs().startOf('month').format('YYYY-MM-DD')
); );
emit('update:end', getLocalizedDayJs().endOf('month').format()); emit('update:end', getLocalizedDayJs().endOf('month').format('YYYY-MM-DD'));
open.value = false; open.value = false;
} }
@@ -130,14 +131,14 @@ function setLastMonth() {
getLocalizedDayJs() getLocalizedDayJs()
.subtract(1, 'month') .subtract(1, 'month')
.startOf('month') .startOf('month')
.format() .format('YYYY-MM-DD')
); );
emit( emit(
'update:end', 'update:end',
getLocalizedDayJs() getLocalizedDayJs()
.subtract(1, 'month') .subtract(1, 'month')
.endOf('month') .endOf('month')
.format() .format('YYYY-MM-DD')
); );
open.value = false; open.value = false;
} }
@@ -145,36 +146,36 @@ function setLastMonth() {
function setLast30Days() { function setLast30Days() {
emit( emit(
'update:start', 'update:start',
getLocalizedDayJs().subtract(30, 'days').format() getLocalizedDayJs().subtract(30, 'days').format('YYYY-MM-DD')
); );
emit('update:end', getLocalizedDayJs().format()); emit('update:end', getLocalizedDayJs().format('YYYY-MM-DD'));
open.value = false; open.value = false;
} }
function setLast90Days() { function setLast90Days() {
emit( emit(
'update:start', 'update:start',
getDayJsInstance()().subtract(90, 'days').format() getDayJsInstance()().subtract(90, 'days').format('YYYY-MM-DD')
); );
emit('update:end', getDayJsInstance()().format()); emit('update:end', getDayJsInstance()().format('YYYY-MM-DD'));
open.value = false; open.value = false;
} }
function setLast12Months() { function setLast12Months() {
emit( emit(
'update:start', 'update:start',
getLocalizedDayJs().subtract(12, 'months').format() getLocalizedDayJs().subtract(12, 'months').format('YYYY-MM-DD')
); );
emit('update:end', getLocalizedDayJs().format()); emit('update:end', getLocalizedDayJs().format('YYYY-MM-DD'));
open.value = false; open.value = false;
} }
function setThisYear() { function setThisYear() {
emit( emit(
'update:start', 'update:start',
getLocalizedDayJs().startOf('year').format() getLocalizedDayJs().startOf('year').format('YYYY-MM-DD')
); );
emit('update:end', getLocalizedDayJs().endOf('year').format()); emit('update:end', getLocalizedDayJs().endOf('year').format('YYYY-MM-DD'));
open.value = false; open.value = false;
} }
@@ -184,14 +185,14 @@ function setLastYear() {
getLocalizedDayJs() getLocalizedDayJs()
.subtract(1, 'year') .subtract(1, 'year')
.startOf('year') .startOf('year')
.format() .format('YYYY-MM-DD')
); );
emit( emit(
'update:end', 'update:end',
getLocalizedDayJs() getLocalizedDayJs()
.subtract(1, 'year') .subtract(1, 'year')
.endOf('year') .endOf('year')
.format() .format('YYYY-MM-DD')
); );
open.value = false; open.value = false;
} }
@@ -218,27 +219,12 @@ watch(open, (value) => {
<CalendarIcon class="mr-2 h-4 w-4" /> <CalendarIcon class="mr-2 h-4 w-4" />
<template v-if="modelValue.start"> <template v-if="modelValue.start">
<template v-if="modelValue.end"> <template v-if="modelValue.end">
{{ {{ formatDateLocalized(modelValue.start.toString(), organization?.date_format) }}
formatDate(
modelValue.start.toString(),
organization?.date_format
)
}}
- -
{{ {{ formatDateLocalized(modelValue.end.toString(), organization?.date_format) }}
formatDate(
modelValue.end.toString(),
organization?.date_format
)
}}
</template> </template>
<template v-else> <template v-else>
{{ {{ formatDateLocalized(modelValue.start.toString(), organization?.date_format) }}
formatDate(
modelValue.start.toString(),
organization?.date_format
)
}}
</template> </template>
</template> </template>
<template v-else> Pick a date </template> <template v-else> Pick a date </template>

View File

@@ -146,9 +146,8 @@ export function formatDateLocalized(date: string, format: DateFormat = 'point-se
return getLocalizedDayJs(date).format(dateFormatMap[format]); return getLocalizedDayJs(date).format(dateFormatMap[format]);
} }
export function formatDateTimeLocalized(date: string, dateFormat?: DateFormat, timeFormat?: TimeFormat): string { export function formatDateTimeLocalized(date: string): string {
const format = `${dateFormatMap[dateFormat ?? 'point-separated-d-m-yyyy']} ${timeFormat === '12-hours' ? 'hh:mm A' : 'HH:mm'}`; return getLocalizedDayJs(date).format('DD.MM.YYYY HH:mm');
return getLocalizedDayJs(date).format(format);
} }
export function formatWeek(date: string | null): string { export function formatWeek(date: string | null): string {

View File

@@ -125,6 +125,4 @@ export function canViewAllTimeEntries() {
export function canViewInvoices() { export function canViewInvoices() {
return currentUserHasPermission('invoices:view'); return currentUserHasPermission('invoices:view');
} }
export function canCreateReports() {
return currentUserHasPermission('reports:create');
}

View File

@@ -3,6 +3,13 @@ import { computed, watch } from "vue";
type themeOption = "system" | "light" | "dark"; type themeOption = "system" | "light" | "dark";
const themeSetting = useStorage<themeOption>("theme", "system"); const themeSetting = useStorage<themeOption>("theme", "system");
// reload page when themeSettingChanges
watch(
themeSetting,
() => {
location.reload();
}
)
const preferredColor = usePreferredColorScheme(); const preferredColor = usePreferredColorScheme();
const theme = computed(() => { const theme = computed(() => {
if(themeSetting.value === "system"){ if(themeSetting.value === "system"){

View File

@@ -1,49 +0,0 @@
import { ref, onMounted, onUnmounted } from 'vue'
export function useCssVariable(variableName: string) {
const value = ref('')
let observer: MutationObserver | null = null
let mediaQuery: MediaQueryList | null = null
const updateValue = () => {
const computedStyle = getComputedStyle(document.documentElement)
const cssValue = computedStyle.getPropertyValue(variableName).trim()
value.value = cssValue
}
onMounted(() => {
// Initialize with current value
updateValue()
// Watch for class changes on document.documentElement (where theme classes are applied)
observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
updateValue()
}
})
})
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
})
// Also watch for system color scheme changes
if (window.matchMedia) {
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQuery.addEventListener('change', updateValue)
}
})
onUnmounted(() => {
if (observer) {
observer.disconnect()
}
if (mediaQuery) {
mediaQuery.removeEventListener('change', updateValue)
}
})
return value
}

View File

@@ -6,11 +6,7 @@ import type {
AggregatedTimeEntriesQueryParams, AggregatedTimeEntriesQueryParams,
ReportingResponse, ReportingResponse,
} from '@/packages/api/src'; } from '@/packages/api/src';
import { import { getCurrentOrganizationId } from '@/utils/useUser';
getCurrentOrganizationId,
getCurrentRole,
getCurrentUser,
} from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification'; import { useNotificationsStore } from '@/utils/notification';
import { useProjectsStore } from '@/utils/useProjects'; import { useProjectsStore } from '@/utils/useProjects';
import { useMembersStore } from '@/utils/useMembers'; import { useMembersStore } from '@/utils/useMembers';
@@ -110,9 +106,6 @@ export const useReportingStore = defineStore('reporting', () => {
return projects.value.find((project) => project.id === key)?.name; return projects.value.find((project) => project.id === key)?.name;
} }
if (type === 'user') { if (type === 'user') {
if (getCurrentRole() === 'employee') {
return getCurrentUser().name;
}
const memberStore = useMembersStore(); const memberStore = useMembersStore();
const { members } = storeToRefs(memberStore); const { members } = storeToRefs(memberStore);
return members.value.find((member) => member.user_id === key)?.name; return members.value.find((member) => member.user_id === key)?.name;

View File

@@ -10,10 +10,6 @@ function getCurrentUserId() {
return page.props.auth.user.id; return page.props.auth.user.id;
} }
function getCurrentUser() {
return page.props.auth.user;
}
function getCurrentOrganizationId() { function getCurrentOrganizationId() {
return page.props.auth.user.current_team_id; return page.props.auth.user.current_team_id;
} }
@@ -35,5 +31,4 @@ export {
getCurrentUserId, getCurrentUserId,
getCurrentMembershipId, getCurrentMembershipId,
getCurrentRole, getCurrentRole,
getCurrentUser,
}; };

View File

@@ -152,13 +152,12 @@
<div <div
style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatInterval(CarbonInterval::seconds($aggregatedData['seconds'])) }} </div> style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatInterval(CarbonInterval::seconds($aggregatedData['seconds'])) }} </div>
</div> </div>
@if($showBillableRate)
<div style="padding: 8px 12px; border-radius: 8px;"> <div style="padding: 8px 12px; border-radius: 8px;">
<div style="color: #71717a; font-weight: 600;">Total cost</div> <div style="color: #71717a; font-weight: 600;">Total cost</div>
<div <div
style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatCurrency(Money::of(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->__toString(), $currency)) }} </div> style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatCurrency(Money::of(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->__toString(), $currency)) }} </div>
</div> </div>
@endif
</div> </div>
<div id="main-chart" style="width: 700px; height: 300px; margin: 20px auto;"></div> <div id="main-chart" style="width: 700px; height: 300px; margin: 20px auto;"></div>
@@ -178,9 +177,7 @@
{{ $group->description() }} {{ $group->description() }}
</th> </th>
<th>Duration</th> <th>Duration</th>
@if($showBillableRate)
<th style="text-align: right;">Cost</th> <th style="text-align: right;">Cost</th>
@endif
</tr> </tr>
</thead> </thead>
@foreach($aggregatedData['grouped_data'] as $group1Entry) @foreach($aggregatedData['grouped_data'] as $group1Entry)
@@ -191,21 +188,23 @@
}};"> }};">
</div> </div>
<span style="padding-left: 8px;"> <span style="padding-left: 8px;">
@if($group->is(\App\Enums\TimeEntryAggregationType::Billable)) @if($group->is(\App\Enums\TimeEntryAggregationType::Billable))
{{ $group1Entry['key'] === '1' ? 'Billable' : 'Non-billable' }} {{ $group1Entry['key'] === '1' ? 'Billable' : 'Non-billable' }}
@else @else
{{ $group1Entry['description'] ?? $group1Entry['key'] ?? 'No '.Str::lower($group->description()) }} {{ $group1Entry['description'] ?? $group1Entry['key'] ?? 'No '.Str::lower($group->description()) }}
@endif @endif
</span>
</span>
</td> </td>
<td style="text-align: left;"> <td style="text-align: left;">
{{ $localization->formatInterval(CarbonInterval::seconds($group1Entry['seconds'])) }} {{ $localization->formatInterval(CarbonInterval::seconds($group1Entry['seconds'])) }}
</td> </td>
@if($showBillableRate)
<td style="text-align: right;"> <td style="text-align: right;">
{{ $localization->formatCurrency(Money::of(BigDecimal::ofUnscaledValue($group1Entry['cost'], 2)->__toString(), $currency)) }} {{ $localization->formatCurrency(Money::of(BigDecimal::ofUnscaledValue($group1Entry['cost'], 2)->__toString(), $currency)) }}
</td> </td>
@endif
</tr> </tr>
@endforeach @endforeach
<tfoot> <tfoot>
@@ -216,11 +215,9 @@
<td style="font-weight: 500;color: #18181b;"> <td style="font-weight: 500;color: #18181b;">
{{ $localization->formatInterval(CarbonInterval::seconds($aggregatedData['seconds'])) }} {{ $localization->formatInterval(CarbonInterval::seconds($aggregatedData['seconds'])) }}
</td> </td>
@if($showBillableRate)
<td style="text-align: right; font-weight: 500;color: #18181b;"> <td style="text-align: right; font-weight: 500;color: #18181b;">
{{ $localization->formatCurrency(Money::of(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->__toString(), $currency)) }} {{ $localization->formatCurrency(Money::of(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->__toString(), $currency)) }}
</td> </td>
@endif
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
@@ -256,11 +253,9 @@
<th> <th>
Duration (h) Duration (h)
</th> </th>
@if($showBillableRate)
<th> <th>
Cost Cost
</th> </th>
@endif
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -287,17 +282,13 @@
<td> <td>
{{ $localization->formatNumber($duration->totalHours) }} {{ $localization->formatNumber($duration->totalHours) }}
</td> </td>
@if($showBillableRate)
<td> <td>
{{ $localization->formatCurrency(Money::of(BigDecimal::ofUnscaledValue($group2Entry['cost'], 2)->__toString(), $currency)) }} {{ $localization->formatCurrency(Money::of(BigDecimal::ofUnscaledValue($group2Entry['cost'], 2)->__toString(), $currency)) }}
</td> </td>
@endif
</tr> </tr>
@php @php
$totalDuration += $group2Entry['seconds']; $totalDuration += $group2Entry['seconds'];
if ($showBillableRate) { $totalCost += $group2Entry['cost'];
$totalCost += $group2Entry['cost'];
}
@endphp @endphp
@endforeach @endforeach
</tbody> </tbody>

View File

@@ -62,11 +62,9 @@
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}"> <td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
{{ round($duration->totalHours, 2) }} {{ round($duration->totalHours, 2) }}
</td> </td>
@if($showBillableRate)
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}"> <td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
{{ round(BigDecimal::ofUnscaledValue($group2Entry['cost'], 2)->toFloat(), 2) }} {{ round(BigDecimal::ofUnscaledValue($group2Entry['cost'], 2)->toFloat(), 2) }}
</td> </td>
@endif
@else @else
@if ($group === TimeEntryAggregationType::Billable) @if ($group === TimeEntryAggregationType::Billable)
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}"> <td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
@@ -94,20 +92,16 @@
data-format="{{ NumberFormat::FORMAT_NUMBER_00 }}"> data-format="{{ NumberFormat::FORMAT_NUMBER_00 }}">
{{ $duration->totalHours }} {{ $duration->totalHours }}
</td> </td>
@if($showBillableRate)
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_NUMERIC }}" <td style="border: 1px solid black;" data-type="{{ DataType::TYPE_NUMERIC }}"
data-format="{{ NumberFormat::FORMAT_NUMBER_COMMA_SEPARATED1 }}"> data-format="{{ NumberFormat::FORMAT_NUMBER_COMMA_SEPARATED1 }}">
{{ BigDecimal::ofUnscaledValue($group2Entry['cost'], 2)->__toString() }} {{ BigDecimal::ofUnscaledValue($group2Entry['cost'], 2)->__toString() }}
</td> </td>
@endif
@endif @endif
</tr> </tr>
@php @php
++$counter; ++$counter;
$totalDuration += $group2Entry['seconds']; $totalDuration += $group2Entry['seconds'];
if ($showBillableRate) { $totalCost += $group2Entry['cost'];
$totalCost += $group2Entry['cost'];
}
@endphp @endphp
@endforeach @endforeach
@endforeach @endforeach
@@ -126,11 +120,9 @@
<td style="border: 1px solid black; font-weight: bold;" data-type="{{ DataType::TYPE_STRING }}"> <td style="border: 1px solid black; font-weight: bold;" data-type="{{ DataType::TYPE_STRING }}">
{{ round($totalDurationInterval->totalHours, 2) }} {{ round($totalDurationInterval->totalHours, 2) }}
</td> </td>
@if($showBillableRate)
<td style="border: 1px solid black; font-weight: bold;" data-type="{{ DataType::TYPE_STRING }}"> <td style="border: 1px solid black; font-weight: bold;" data-type="{{ DataType::TYPE_STRING }}">
{{ round(BigDecimal::ofUnscaledValue($totalCost, 2)->toFloat(), 2) }} {{ round(BigDecimal::ofUnscaledValue($totalCost, 2)->toFloat(), 2) }}
</td> </td>
@endif
@else @else
<td style="border: 1px solid black; font-weight: bold;" data-type="{{ DataType::TYPE_FORMULA }}" <td style="border: 1px solid black; font-weight: bold;" data-type="{{ DataType::TYPE_FORMULA }}"
data-format="[hh]:mm:ss"> data-format="[hh]:mm:ss">

View File

@@ -140,14 +140,12 @@
<div <div
style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatInterval(CarbonInterval::seconds($aggregatedData['seconds'])) }} </div> style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatInterval(CarbonInterval::seconds($aggregatedData['seconds'])) }} </div>
</div> </div>
@if($showBillableRate)
<div style="padding: 8px 12px; border-radius: 8px;"> <div style="padding: 8px 12px; border-radius: 8px;">
<div style="color: #71717a; font-weight: 600;">Total cost</div> <div style="color: #71717a; font-weight: 600;">Total cost</div>
<div style="font-size: 24px; font-weight: 500; margin-top: 2px;"> <div
{{ $localization->formatCurrency(Money::of(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->__toString(), $currency)) }} style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatCurrency(Money::of(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->__toString(), $currency)) }} </div>
</div>
</div> </div>
@endif
</div> </div>
<div> <div>
<table style="width: 100%;"> <table style="width: 100%;">

View File

@@ -56,12 +56,10 @@ abstract class TestCaseWithDatabase extends TestCase
/** /**
* @return object{user: User, organization: Organization, member: Member, owner: User, ownerMember: Member} * @return object{user: User, organization: Organization, member: Member, owner: User, ownerMember: Member}
*/ */
public function createUserWithRole(Role $role, bool $employeesCanSeeBillableRates = false): object public function createUserWithRole(Role $role): object
{ {
$owner = User::factory()->create(); $owner = User::factory()->create();
$organization = Organization::factory()->withOwner($owner)->create([ $organization = Organization::factory()->withOwner($owner)->create();
'employees_can_see_billable_rates' => $employeesCanSeeBillableRates,
]);
$ownerMember = Member::factory()->forUser($owner)->forOrganization($organization)->role(Role::Owner)->create(); $ownerMember = Member::factory()->forUser($owner)->forOrganization($organization)->role(Role::Owner)->create();
$owner->currentOrganization()->associate($organization); $owner->currentOrganization()->associate($organization);
$owner->save(); $owner->save();

View File

@@ -1,163 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Console\Commands\SelfHost;
use App\Console\Commands\SelfHost\SelfHostDatabaseConsistency;
use App\Enums\Role;
use App\Models\Client;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\UsesClass;
use Tests\TestCaseWithDatabase;
#[CoversClass(SelfHostDatabaseConsistency::class)]
#[UsesClass(SelfHostDatabaseConsistency::class)]
class SelfHostDatabaseConsistencyCommandTest extends TestCaseWithDatabase
{
public function test_checks_that_task_need_to_be_part_of_project_in_time_entries(): void
{
// Arrange
$user = $this->createUserWithRole(Role::Owner);
$project1 = Project::factory()->forOrganization($user->organization)->create();
$project2 = Project::factory()->forOrganization($user->organization)->create();
$task = Task::factory()->forOrganization($user->organization)->forProject($project1)->create();
$timeEntry = TimeEntry::factory()->forMember($user->member)->forTask($task)->forProject($project2)->create();
// Act
$exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:database-consistency');
// Assert
$this->assertSame(Command::FAILURE, $exitCode);
$output = Artisan::output();
$this->assertSame("Consistency problem: Time entries have a task that does not belong to the project of the time entry\n - ".$timeEntry->getKey()."\n", $output);
}
public function test_checks_that_client_id_is_the_client_id_of_the_project(): void
{
// Arrange
$user = $this->createUserWithRole(Role::Owner);
$client1 = Client::factory()->forOrganization($user->organization)->create();
$client2 = Client::factory()->forOrganization($user->organization)->create();
$project = Project::factory()->forOrganization($user->organization)->forClient($client1)->create();
$timeEntry = TimeEntry::factory()->forMember($user->member)->forProject($project)->create([
'client_id' => $client2->id,
]);
// Act
$exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:database-consistency');
// Assert
$this->assertSame(Command::FAILURE, $exitCode);
$output = Artisan::output();
$this->assertSame("Consistency problem: Time entries have a client that does not match the client of the project\n - ".$timeEntry->getKey()."\n", $output);
}
public function test_checks_that_client_id_is_the_client_id_of_the_project_with_no_client_in_time_entry(): void
{
// Arrange
$user = $this->createUserWithRole(Role::Owner);
$client1 = Client::factory()->forOrganization($user->organization)->create();
$client2 = Client::factory()->forOrganization($user->organization)->create();
$project = Project::factory()->forOrganization($user->organization)->forClient($client1)->create();
$timeEntry = TimeEntry::factory()->forMember($user->member)->forProject($project)->create([
'client_id' => null,
]);
// Act
$exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:database-consistency');
// Assert
$this->assertSame(Command::FAILURE, $exitCode);
$output = Artisan::output();
$this->assertSame("Consistency problem: Time entries have a client that does not match the client of the project\n - ".$timeEntry->getKey()."\n", $output);
}
public function test_checks_that_client_id_is_only_null_if_project_is_also_null(): void
{
// Arrange
$user = $this->createUserWithRole(Role::Owner);
$client1 = Client::factory()->forOrganization($user->organization)->create();
$project = Project::factory()->forOrganization($user->organization)->forClient($client1)->create();
$timeEntry = TimeEntry::factory()->forMember($user->member)->create([
'client_id' => $client1->getKey(),
]);
// Act
$exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:database-consistency');
// Assert
$this->assertSame(Command::FAILURE, $exitCode);
$output = Artisan::output();
$this->assertSame("Consistency problem: Time entries have a client but no project\n - ".$timeEntry->getKey()."\n", $output);
}
public function test_checks_that_every_user_needs_to_be_a_member_of_at_least_one_organization(): void
{
// Arrange
$user = User::factory()->create();
// Act
$exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:database-consistency');
// Assert
$this->assertSame(Command::FAILURE, $exitCode);
$output = Artisan::output();
$this->assertSame("Consistency problem: Users are not member of any organization\n - ".$user->getKey()."\n", $output);
}
public function test_checks_that_every_organization_needs_at_least_an_owner(): void
{
// Arrange
$user = $this->createUserWithRole(Role::Owner);
$organization = Organization::factory()->withOwner($user->user)->create();
// Act
$exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:database-consistency');
// Assert
$this->assertSame(Command::FAILURE, $exitCode);
$output = Artisan::output();
$this->assertSame("Consistency problem: Organizations without an owner\n - ".$organization->getKey()."\n", $output);
}
public function test_checks_that_every_member_can_only_have_one_running_time_entry(): void
{
// Arrange
$user = $this->createUserWithRole(Role::Owner);
$timeEntry1 = TimeEntry::factory()->forMember($user->member)->active()->create();
$timeEntry2 = TimeEntry::factory()->forMember($user->member)->active()->create();
// Act
$exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:database-consistency');
// Assert
$this->assertSame(Command::FAILURE, $exitCode);
$output = Artisan::output();
$this->assertSame("Consistency problem: Users with more than one running time entry\n - ".$user->user->getKey()."\n", $output);
}
public function test_checks_that_users_have_a_current_organization_that_they_are_not_a_member_of(): void
{
// Arrange
$user1 = $this->createUserWithRole(Role::Owner);
$user2 = $this->createUserWithRole(Role::Owner);
$user1->user->currentOrganization()->associate($user2->organization);
$user1->user->save();
// Act
$exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:database-consistency');
// Assert
$this->assertSame(Command::FAILURE, $exitCode);
$output = Artisan::output();
$this->assertSame("Consistency problem: Users have a current organization that they are not a member of\n - ".$user1->user->getKey()."\n", $output);
}
}

View File

@@ -129,31 +129,26 @@ class InvitationEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonPath('message', 'User is already a member of the organization'); $response->assertJsonPath('message', 'User is already a member of the organization');
} }
public function test_store_fails_if_an_invitation_with_the_same_email_already_exists(): void public function test_store_fails_if_user_invites_user_who_is_already_invited_to_organization(): void
{ {
// Arrange // Arrange
$data = $this->createUserWithPermission([ $data = $this->createUserWithPermission([
'invitations:create', 'invitations:create',
]); ]);
Passport::actingAs($data->user); Passport::actingAs($data->user);
$email = 'user@email.test'; $invitation = OrganizationInvitation::factory()->forOrganization($data->organization)->create();
$invitation = OrganizationInvitation::factory()->forOrganization($data->organization)->create([
'email' => $email,
]);
// Act // Act
$response = $this->postJson(route('api.v1.invitations.store', $data->organization->getKey()), [ $response = $this->postJson(route('api.v1.invitations.store', $data->organization->getKey()), [
'email' => $email, 'email' => $invitation->email,
'role' => Role::Employee->value, 'role' => Role::Employee->value,
]); ]);
// Assert // Assert
$response->assertStatus(400); $response->assertInvalid([
$response->assertExactJson([ 'email' => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.',
'error' => true,
'key' => 'invitation_for_the_email_already_exists',
'message' => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.',
]); ]);
$response->assertStatus(422);
} }
public function test_store_works_if_user_invites_user_who_is_also_a_placeholder(): void public function test_store_works_if_user_invites_user_who_is_also_a_placeholder(): void

View File

@@ -10,7 +10,6 @@ use App\Events\MemberRemoved;
use App\Http\Controllers\Api\V1\MemberController; use App\Http\Controllers\Api\V1\MemberController;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectMember; use App\Models\ProjectMember;
use App\Models\TimeEntry; use App\Models\TimeEntry;
@@ -654,182 +653,6 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
Event::assertNotDispatched(MemberRemoved::class); Event::assertNotDispatched(MemberRemoved::class);
} }
public function test_destroy_endpoint_also_deletes_user_if_member_is_placeholder(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:delete',
]);
$user = User::factory()->placeholder()->create();
$member = Member::factory()->forUser($user)->forOrganization($data->organization)->role(Role::Placeholder)->create();
Passport::actingAs($data->user);
Event::fake([
MemberRemoved::class,
]);
// Act
$response = $this->deleteJson(route('api.v1.members.destroy', [$data->organization->getKey(), $member->getKey()]));
// Assert
$response->assertStatus(204);
$this->assertDatabaseMissing(Member::class, [
'id' => $member->getKey(),
]);
$this->assertDatabaseMissing(User::class, [
'id' => $user->getKey(),
]);
Event::assertDispatched(function (MemberRemoved $event) use ($data, $member): bool {
return $event->organization->is($data->organization) &&
$event->member->is($member);
}, 1);
}
public function test_destroy_endpoint_sets_current_organization_to_organization_the_user_is_still_member_of(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:delete',
]);
$user = $data->user;
$otherOrganization = Organization::factory()->create();
$otherMember = Member::factory()->forOrganization($otherOrganization)->forUser($user)->role(Role::Employee)->create();
Passport::actingAs($user);
Event::fake([
MemberRemoved::class,
]);
// Act
$response = $this->deleteJson(route('api.v1.members.destroy', [$data->organization->getKey(), $data->member->getKey()]));
// Assert
$response->assertStatus(204);
$this->assertDatabaseMissing(Member::class, [
'id' => $data->member->getKey(),
]);
$user->refresh();
$this->assertSame($otherOrganization->getKey(), $user->currentOrganization->getKey());
Event::assertDispatched(function (MemberRemoved $event) use ($data): bool {
return $event->organization->is($data->organization) &&
$event->member->is($data->member);
}, 1);
}
public function test_destroy_endpoint_creates_new_organization_and_sets_the_current_organization_to_it_if_user_is_not_member_of_any_other_organization(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:delete',
]);
$organization = $data->organization;
$user = $data->user;
Passport::actingAs($user);
Event::fake([
MemberRemoved::class,
]);
$this->assertDatabaseCount(Organization::class, 1);
// Act
$response = $this->deleteJson(route('api.v1.members.destroy', [$data->organization->getKey(), $data->member->getKey()]));
// Assert
$response->assertStatus(204);
$this->assertDatabaseCount(Organization::class, 2);
$newOrganization = Organization::where('id', '!=', $organization->getKey())->first();
$this->assertNotNull($newOrganization);
$this->assertDatabaseMissing(Member::class, [
'id' => $data->member->getKey(),
]);
$this->assertDatabaseHas(Member::class, [
'organization_id' => $newOrganization->getKey(),
'user_id' => $user->getKey(),
]);
$user->refresh();
$this->assertNotNull($user->currentOrganization);
Event::assertDispatched(function (MemberRemoved $event) use ($data): bool {
return $event->organization->is($data->organization) &&
$event->member->is($data->member);
}, 1);
}
public function test_destroy_endpoint_succeeds_if_member_is_still_in_use_by_a_project_member_and_delete_related_is_active(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:delete',
]);
$otherMember = Member::factory()->forOrganization($data->organization)->role(Role::Employee)->create();
$project = Project::factory()->forOrganization($data->organization)->create();
$projectMember = ProjectMember::factory()->forProject($project)->forMember($data->member)->create();
$otherProjectMember = ProjectMember::factory()->forProject($project)->forMember($otherMember)->create();
Passport::actingAs($data->user);
Event::fake([
MemberRemoved::class,
]);
// Act
$response = $this->deleteJson(route('api.v1.members.destroy', [
'organization' => $data->organization->getKey(),
'member' => $data->member->getKey(),
'delete_related' => 'true',
]));
// Assert
$response->assertStatus(204);
$this->assertDatabaseMissing(Member::class, [
'id' => $data->member->getKey(),
]);
$this->assertDatabaseHas(ProjectMember::class, [
'id' => $otherProjectMember->getKey(),
'member_id' => $otherMember->getKey(),
'user_id' => $otherMember->user_id,
]);
$this->assertDatabaseMissing(ProjectMember::class, [
'id' => $projectMember->getKey(),
]);
Event::assertDispatched(function (MemberRemoved $event) use ($data): bool {
return $event->organization->is($data->organization) &&
$event->member->is($data->member);
}, 1);
}
public function test_destroy_endpoint_succeeds_if_member_is_still_in_use_by_a_time_entry_and_delete_related_is_active(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:delete',
]);
$otherMember = Member::factory()->forOrganization($data->organization)->role(Role::Employee)->create();
$timeEntry = TimeEntry::factory()->forMember($data->member)->forOrganization($data->organization)->create();
$otherTimeEntry = TimeEntry::factory()->forMember($otherMember)->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
Event::fake([
MemberRemoved::class,
]);
// Act
$response = $this->deleteJson(route('api.v1.members.destroy', [
'organization' => $data->organization->getKey(),
'member' => $data->member->getKey(),
'delete_related' => 'true',
]));
// Assert
$response->assertStatus(204);
$this->assertDatabaseMissing(Member::class, [
'id' => $data->member->getKey(),
]);
$this->assertDatabaseHas(TimeEntry::class, [
'id' => $otherTimeEntry->getKey(),
]);
$this->assertDatabaseMissing(TimeEntry::class, [
'id' => $timeEntry->getKey(),
]);
Event::assertDispatched(function (MemberRemoved $event) use ($data): bool {
return $event->organization->is($data->organization) &&
$event->member->is($data->member);
}, 1);
}
public function test_destroy_member_succeeds_if_data_is_valid(): void public function test_destroy_member_succeeds_if_data_is_valid(): void
{ {
// Arrange // Arrange
@@ -1035,37 +858,6 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
$response->assertForbidden(); $response->assertForbidden();
} }
public function test_invite_placeholder_fails_if_there_is_already_an_invitation_with_the_same_email(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:invite-placeholder',
'invitations:create',
]);
$placeholder = User::factory()->placeholder()->create([
'email' => 'user@mail.test',
]);
$placeholderMember = Member::factory()->forUser($placeholder)->forOrganization($data->organization)->role(Role::Placeholder)->create();
OrganizationInvitation::factory()->forOrganization($data->organization)->create([
'email' => $placeholder->email,
]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.members.invite-placeholder', [
'organization' => $data->organization->id,
'member' => $placeholderMember->id,
]));
// Assert
$response->assertStatus(400);
$response->assertExactJson([
'error' => true,
'key' => 'invitation_for_the_email_already_exists',
'message' => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.',
]);
}
public function test_invite_placeholder_returns_400_if_user_is_not_placeholder(): void public function test_invite_placeholder_returns_400_if_user_is_not_placeholder(): void
{ {
// Arrange // Arrange

View File

@@ -686,10 +686,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
'time-entries:view:all', 'time-entries:view:all',
]); ]);
Passport::actingAs($data->user); Passport::actingAs($data->user);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$this->actAsOrganizationWithSubscription(); $this->actAsOrganizationWithSubscription();
// Act // Act
@@ -704,192 +700,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$this->assertResponseCode($response, 200); $this->assertResponseCode($response, 200);
} }
public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_csv_as_employee_role_with_show_billable_rate(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee, true);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index-export', [
$data->organization->getKey(),
'format' => ExportFormat::CSV,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
'member_id' => $data->member->id,
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_ods_as_employee_role_with_show_billable_rate(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee, true);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index-export', [
$data->organization->getKey(),
'format' => ExportFormat::ODS,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
'member_id' => $data->member->id,
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_xlxs_as_employee_role_with_show_billable_rate(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee, true);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index-export', [
$data->organization->getKey(),
'format' => ExportFormat::XLSX,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
'member_id' => $data->member->id,
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_pdf_as_employee_role_with_show_billable_rate(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee, true);
Passport::actingAs($data->user);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$this->actAsOrganizationWithSubscription();
// Act
$response = $this->getJson(route('api.v1.time-entries.index-export', [
$data->organization->getKey(),
'format' => ExportFormat::PDF,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
'member_id' => $data->member->id,
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_csv_as_employee_role_without_show_billable_rate(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee, false);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index-export', [
$data->organization->getKey(),
'format' => ExportFormat::CSV,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
'member_id' => $data->member->id,
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_ods_as_employee_role_without_show_billable_rate(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee, false);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index-export', [
$data->organization->getKey(),
'format' => ExportFormat::ODS,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
'member_id' => $data->member->id,
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_xlxs_as_employee_role_without_show_billable_rate(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee, false);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index-export', [
$data->organization->getKey(),
'format' => ExportFormat::XLSX,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
'member_id' => $data->member->id,
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_pdf_as_employee_role_without_show_billable_rate(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee, false);
Passport::actingAs($data->user);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$this->actAsOrganizationWithSubscription();
// Act
$response = $this->getJson(route('api.v1.time-entries.index-export', [
$data->organization->getKey(),
'format' => ExportFormat::PDF,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
'member_id' => $data->member->id,
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_aggregate_export_endpoints_fails_if_user_no_permission_to_view_time_entries(): void public function test_aggregate_export_endpoints_fails_if_user_no_permission_to_view_time_entries(): void
{ {
// Arrange // Arrange
@@ -1005,58 +815,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$this->assertResponseCode($response, 200); $this->assertResponseCode($response, 200);
} }
public function test_aggregate_export_endpoints_can_create_a_csv_report_as_employee_role_with_show_billable_rate(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee, true);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
$data->organization->getKey(),
'format' => ExportFormat::CSV,
'group' => TimeEntryAggregationType::Client,
'sub_group' => TimeEntryAggregationType::Project,
'history_group' => TimeEntryAggregationTypeInterval::Month,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
'member_id' => $data->member->getKey(),
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_aggregate_export_endpoints_can_create_a_csv_report_as_employee_role_without_show_billable_rate(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee, false);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
$data->organization->getKey(),
'format' => ExportFormat::CSV,
'group' => TimeEntryAggregationType::Client,
'sub_group' => TimeEntryAggregationType::Project,
'history_group' => TimeEntryAggregationTypeInterval::Month,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
'member_id' => $data->member->getKey(),
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_aggregate_export_endpoints_can_create_a_xlsx_report(): void public function test_aggregate_export_endpoints_can_create_a_xlsx_report(): void
{ {
// Arrange // Arrange
@@ -1084,58 +842,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$this->assertResponseCode($response, 200); $this->assertResponseCode($response, 200);
} }
public function test_aggregate_export_endpoints_can_create_a_xlsx_report_as_employee_role_with_show_billable_rate(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee, true);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
$data->organization->getKey(),
'format' => ExportFormat::XLSX,
'group' => TimeEntryAggregationType::Client,
'sub_group' => TimeEntryAggregationType::Project,
'history_group' => TimeEntryAggregationTypeInterval::Month,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
'member_id' => $data->member->getKey(),
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_aggregate_export_endpoints_can_create_a_xlsx_report_as_employee_role_without_show_billable_rate(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee, false);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
$data->organization->getKey(),
'format' => ExportFormat::XLSX,
'group' => TimeEntryAggregationType::Client,
'sub_group' => TimeEntryAggregationType::Project,
'history_group' => TimeEntryAggregationTypeInterval::Month,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
'member_id' => $data->member->getKey(),
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_aggregate_export_endpoints_can_create_a_ods_report(): void public function test_aggregate_export_endpoints_can_create_a_ods_report(): void
{ {
// Arrange // Arrange
@@ -1163,58 +869,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$this->assertResponseCode($response, 200); $this->assertResponseCode($response, 200);
} }
public function test_aggregate_export_endpoints_can_create_a_ods_report_as_employee_role_with_show_billable_rate(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee, true);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
$data->organization->getKey(),
'format' => ExportFormat::ODS,
'group' => TimeEntryAggregationType::User,
'sub_group' => TimeEntryAggregationType::Project,
'history_group' => TimeEntryAggregationTypeInterval::Month,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
'member_id' => $data->member->getKey(),
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_aggregate_export_endpoints_can_create_a_ods_report_as_employee_role_without_show_billable_rate(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee, false);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
$data->organization->getKey(),
'format' => ExportFormat::ODS,
'group' => TimeEntryAggregationType::User,
'sub_group' => TimeEntryAggregationType::Project,
'history_group' => TimeEntryAggregationTypeInterval::Month,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
'member_id' => $data->member->getKey(),
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_aggregate_export_endpoint_fails_if_pdf_renderer_is_not_configured_but_a_user_want_a_pdf_report(): void public function test_aggregate_export_endpoint_fails_if_pdf_renderer_is_not_configured_but_a_user_want_a_pdf_report(): void
{ {
// Arrange // Arrange
@@ -1273,60 +927,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$this->assertResponseCode($response, 200); $this->assertResponseCode($response, 200);
} }
public function test_aggregate_export_endpoints_can_create_a_pdf_report_as_employee_role_with_show_billable_rate(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee, true);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
Passport::actingAs($data->user);
$this->actAsOrganizationWithSubscription();
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
$data->organization->getKey(),
'format' => ExportFormat::PDF,
'group' => TimeEntryAggregationType::User,
'sub_group' => TimeEntryAggregationType::Project,
'history_group' => TimeEntryAggregationTypeInterval::Month,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
'member_id' => $data->member->getKey(),
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_aggregate_export_endpoints_can_create_a_pdf_report_as_employee_role_without_show_billable_rate(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee, false);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
Passport::actingAs($data->user);
$this->actAsOrganizationWithSubscription();
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
$data->organization->getKey(),
'format' => ExportFormat::PDF,
'group' => TimeEntryAggregationType::User,
'sub_group' => TimeEntryAggregationType::Project,
'history_group' => TimeEntryAggregationTypeInterval::Month,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
'member_id' => $data->member->getKey(),
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_aggregate_endpoint_fails_if_user_has_only_access_to_own_time_entries_but_does_not_filter_for_this(): void public function test_aggregate_endpoint_fails_if_user_has_only_access_to_own_time_entries_but_does_not_filter_for_this(): void
{ {
// Arrange // Arrange

View File

@@ -150,24 +150,6 @@ class DeletionServiceTest extends TestCaseWithDatabase
$this->assertSame($specialCase ? 7 : 6, TimeEntry::query()->whereBelongsTo($organization, 'organization')->count()); $this->assertSame($specialCase ? 7 : 6, TimeEntry::query()->whereBelongsTo($organization, 'organization')->count());
} }
public function test_delete_organization_resets_the_current_organization_of_users_that_had_the_deleted_organization_as_current_organization(): void
{
// Arrange
$userOwner = User::factory()->create();
$organization = Organization::factory()->withOwner($userOwner)->create();
$userOwner->currentOrganization()->associate($organization);
$userOwner->save();
// Act
$this->deletionService->deleteOrganization($organization);
// Assert
$this->assertOrganizationDeleted($organization);
$userOwner->refresh();
$this->assertNull($userOwner->current_team_id);
$this->assertNotSame($organization->id, $userOwner->current_team_id);
}
public function test_delete_organization_deletes_all_resources_of_the_organization_but_does_not_delete_other_resources(): void public function test_delete_organization_deletes_all_resources_of_the_organization_but_does_not_delete_other_resources(): void
{ {
// Arrange // Arrange

View File

@@ -114,41 +114,6 @@ class MemberServiceTest extends TestCaseWithDatabase
$this->assertSame(1, $otherUser->organizations()->count()); $this->assertSame(1, $otherUser->organizations()->count());
} }
public function test_make_member_to_placeholder_resets_current_organization_of_user_if_user_is_no_longer_member_to_newly_created_organization(): void
{
// Arrange
$organization = Organization::factory()->create();
$user = User::factory()->forCurrentOrganization($organization)->create();
$member = Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Employee)->create();
// Act
$this->memberService->makeMemberToPlaceholder($member);
// Assert
$user->refresh();
$this->assertNotNull($user->current_team_id);
$this->assertNotSame($organization->id, $user->current_team_id);
}
public function test_make_member_to_placeholder_resets_current_organization_of_user_if_user_is_no_longer_member_to_already_existing_other_organization(): void
{
// Arrange
$organization = Organization::factory()->create();
$user = User::factory()->forCurrentOrganization($organization)->create();
$member = Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Employee)->create();
$otherOrganization = Organization::factory()->create();
$otherMember = Member::factory()->forOrganization($otherOrganization)->forUser($user)->role(Role::Employee)->create();
// Act
$this->memberService->makeMemberToPlaceholder($member);
// Assert
$user->refresh();
$this->assertNotNull($user->current_team_id);
$this->assertSame($otherOrganization->id, $user->current_team_id);
}
public function test_assign_organization_entities_to_different_member_without_any_entries(): void public function test_assign_organization_entities_to_different_member_without_any_entries(): void
{ {
// Arrange // Arrange