Compare commits

..

2 Commits

Author SHA1 Message Date
Constantin Graf
c7b0aa0b55 Rename DB_SSLMODE env to DB_SSL_MODE and add DATABASE_URL fallback
The Laravel 13 config update made sslmode read DB_SSLMODE, which the
self-hosting examples set to require, breaking instances whose database
does not support SSL. Use DB_SSL_MODE instead so existing values are
ignored again, and fall back to DATABASE_URL when DB_URL is not set.
2026-10-07 15:11:58 +02:00
Gregor Vostrak
bb5a7fb9f9 improve API docs 2026-10-05 17:27:04 +02:00
98 changed files with 175 additions and 7840 deletions

View File

@@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum GoalComparison: string
{
use LaravelEnumHelper;
/**
* The goal is reached when at least the target amount of time was tracked in the period.
*/
case AtLeast = 'at_least';
/**
* The goal is reached when less than the target amount of time was tracked in the period.
*/
case LessThan = 'less_than';
}

View File

@@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum GoalPeriod: string
{
use LaravelEnumHelper;
case Day = 'day';
case Week = 'week';
case Month = 'month';
}

View File

@@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum GoalStatus: string
{
use LaravelEnumHelper;
/**
* "At least" goal, the target has not been reached yet in the current period.
*/
case InProgress = 'in_progress';
/**
* "At least" goal, the target has been reached in the current period.
*/
case Achieved = 'achieved';
/**
* "Less than" goal, the tracked time is still below the limit.
*/
case OnTrack = 'on_track';
/**
* "Less than" goal, the tracked time reached or exceeded the limit.
*/
case Exceeded = 'exceeded';
}

View File

@@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum GoalType: string
{
use LaravelEnumHelper;
/**
* A goal a member set for themselves. Only that member can see and manage it, no permission reaches it.
*/
case Personal = 'personal';
/**
* A goal the organization set for a member or for the whole organization.
* Managed by the members that may manage goals, visible to them and to the member the goal is for.
* Requires the team goals extension.
*/
case Organization = 'organization';
}

View File

@@ -1,174 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Enums\GoalComparison;
use App\Enums\GoalPeriod;
use App\Enums\GoalType;
use App\Enums\Weekday;
use App\Filament\Resources\GoalResource\Pages;
use App\Models\Goal;
use App\Service\Dto\GoalFiltersDto;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Forms;
use Filament\Forms\Components\DateTimePicker;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
class GoalResource extends Resource
{
protected static ?string $model = Goal::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-viewfinder-circle';
protected static string|\UnitEnum|null $navigationGroup = 'Timetracking';
protected static ?int $navigationSort = 8;
public static function form(Schema $schema): Schema
{
return $schema
->columns(1)
->components([
Forms\Components\TextInput::make('name')
->label('Name')
->required()
->maxLength(255),
Forms\Components\Select::make('type')
->label('Type')
->options(collect(GoalType::cases())->mapWithKeys(fn (GoalType $type): array => [$type->value => $type->name])->all())
->disabled()
->required(),
Forms\Components\Select::make('comparison')
->label('Comparison')
->options(collect(GoalComparison::cases())->mapWithKeys(fn (GoalComparison $comparison): array => [$comparison->value => $comparison->name])->all())
->required(),
Forms\Components\TextInput::make('target_seconds')
->label('Target (seconds)')
->numeric()
->minValue(1)
->required(),
Forms\Components\Select::make('period')
->label('Period')
->options(collect(GoalPeriod::cases())->mapWithKeys(fn (GoalPeriod $period): array => [$period->value => $period->name])->all())
->required(),
Forms\Components\Select::make('timezone')
->label('Timezone')
->options(fn (): array => collect(\DateTimeZone::listIdentifiers())->mapWithKeys(fn (string $timezone): array => [$timezone => $timezone])->all())
->searchable()
->required(),
Forms\Components\Select::make('week_start')
->label('Week start')
->options(collect(Weekday::cases())->mapWithKeys(fn (Weekday $weekday): array => [$weekday->value => $weekday->name])->all())
->required(),
DateTimePicker::make('archived_at')
->label('Archived At'),
Forms\Components\Select::make('organization_id')
->label('Organization')
->relationship(name: 'organization', titleAttribute: 'name')
->searchable(['name'])
->disabled()
->required(),
Forms\Components\Select::make('member_id')
->label('Member (empty = every member)')
->relationship(name: 'member', titleAttribute: 'id')
->disabled(),
PrettyJsonField::make('filters')
->formatStateUsing(function (GoalFiltersDto $state, Goal $record): string {
return $record->getRawOriginal('filters');
})
->disabled(),
DateTimePicker::make('created_at')
->label('Created At')
->hiddenOn(['create'])
->disabled(),
DateTimePicker::make('updated_at')
->label('Updated At')
->hiddenOn(['create'])
->disabled(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->searchable()
->sortable(),
TextColumn::make('comparison')
->sortable(),
TextColumn::make('target_seconds')
->label('Target')
->formatStateUsing(fn (int $state): string => sprintf('%dh %02dm', intdiv($state, 3600), intdiv($state % 3600, 60)))
->sortable(),
TextColumn::make('period')
->sortable(),
TextColumn::make('type')
->sortable(),
TextColumn::make('member.user.email')
->label('Member')
->placeholder('Every member')
->searchable(),
TextColumn::make('archived_at')
->dateTime()
->placeholder('Not archived')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('organization.name')
->searchable()
->sortable(),
TextColumn::make('created_at')
->dateTime()
->sortable(),
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('created_at', 'desc')
->filters([
SelectFilter::make('type')
->label('Type')
->options(collect(GoalType::cases())->mapWithKeys(fn (GoalType $type): array => [$type->value => $type->name])->all()),
SelectFilter::make('organization')
->label('Organization')
->relationship('organization', 'name')
->searchable(),
SelectFilter::make('organization_id')
->label('Organization ID')
->relationship('organization', 'id')
->searchable(),
])
->recordActions([
ViewAction::make(),
EditAction::make(),
DeleteAction::make(),
])
->toolbarActions([
]);
}
public static function getRelations(): array
{
return [
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListGoals::route('/'),
'edit' => Pages\EditGoal::route('/{record}/edit'),
'view' => Pages\ViewGoal::route('/{record}'),
];
}
}

View File

@@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\GoalResource\Pages;
use App\Filament\Resources\GoalResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditGoal extends EditRecord
{
protected static string $resource = GoalResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
->icon('heroicon-m-trash'),
];
}
}

View File

@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\GoalResource\Pages;
use App\Filament\Resources\GoalResource;
use Filament\Resources\Pages\ListRecords;
class ListGoals extends ListRecords
{
protected static string $resource = GoalResource::class;
protected function getHeaderActions(): array
{
return [
];
}
}

View File

@@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\GoalResource\Pages;
use App\Filament\Resources\GoalResource;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
class ViewGoal extends ViewRecord
{
protected static string $resource = GoalResource::class;
protected function getHeaderActions(): array
{
return [
EditAction::make('edit')
->icon('heroicon-s-pencil'),
];
}
}

View File

@@ -1,243 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\GoalType;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Http\Requests\V1\Goal\GoalIndexRequest;
use App\Http\Requests\V1\Goal\GoalStoreRequest;
use App\Http\Requests\V1\Goal\GoalUpdateRequest;
use App\Http\Resources\V1\Goal\GoalCollection;
use App\Http\Resources\V1\Goal\GoalResource;
use App\Models\Goal;
use App\Models\Organization;
use App\Service\BillingContract;
use App\Service\GoalProgressService;
use App\Service\GoalsContract;
use App\Service\TimezoneService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Carbon;
class GoalController extends Controller
{
/**
* @throws AuthorizationException
*/
protected function checkPermission(Organization $organization, string $permission, ?Goal $goal = null): void
{
parent::checkPermission($organization, $permission);
if ($goal !== null) {
$this->checkGoalBelongsToOrganization($organization, $goal);
}
}
/**
* @throws AuthorizationException
*/
private function checkGoalBelongsToOrganization(Organization $organization, Goal $goal): void
{
if ($goal->organization_id !== $organization->id) {
throw new AuthorizationException('Goal does not belong to organization');
}
}
/**
* Enforces the goal limit provided by the BillingContract, if any. Archived goals count toward the limit.
*
* @throws FeatureIsNotAvailableInFreePlanApiException
*/
private function checkGoalLimit(Organization $organization): void
{
$limit = app(BillingContract::class)->getGoalLimit($organization);
if ($limit === null) {
return;
}
$goals = Goal::query()
->whereBelongsTo($organization, 'organization')
->count();
if ($goals >= $limit) {
throw new FeatureIsNotAvailableInFreePlanApiException;
}
}
/**
* Get goals
*
* Returns the goals the current member is allowed to see, including the progress in the current period.
*
* @return GoalCollection<GoalResource>
*
* @throws AuthorizationException
*
* @operationId getGoals
*/
public function index(Organization $organization, GoalIndexRequest $request, GoalsContract $access, GoalProgressService $progressService): GoalCollection
{
// An organization goal is visible to the member it is for, who does not hold the :organization-type permission.
// Either permission is enough, the access contract decides which goals come back.
$this->checkAnyPermission($organization, ['goals:view:own', 'goals:view:organization-type']);
$member = $this->member($organization);
$query = Goal::query()
->whereBelongsTo($organization, 'organization')
->with(['member.user'])
->orderBy('created_at', 'desc')
->orderBy('id');
$query = $access->scopeVisibleGoals($query, $member);
if ($request->getType() !== null) {
$query->where('type', '=', $request->getType()->value);
}
if ($request->getArchivedFilter() === 'true') {
$query->archived();
} elseif ($request->getArchivedFilter() === 'false') {
$query->notArchived();
}
$goals = $query->paginate(config('app.pagination_per_page_default'));
$progressByGoalId = $progressService->getCurrentProgressForGoals($goals->getCollection(), Carbon::now());
return new GoalCollection($goals, $progressByGoalId);
}
/**
* Get goal
*
* @throws AuthorizationException
*
* @operationId getGoal
*/
public function show(Organization $organization, Goal $goal, GoalsContract $access, GoalProgressService $progressService): GoalResource
{
// Either permission is enough, see index
$this->checkAnyPermission($organization, ['goals:view:own', 'goals:view:organization-type']);
$this->checkGoalBelongsToOrganization($organization, $goal);
$member = $this->member($organization);
if (! $access->canViewGoal($member, $goal)) {
throw new AuthorizationException;
}
$goal->load('member.user');
return new GoalResource($goal, $progressService->getCurrentProgress($goal, Carbon::now()));
}
/**
* Create goal
*
* @throws AuthorizationException|FeatureIsNotAvailableInFreePlanApiException
*
* @operationId createGoal
*/
public function store(Organization $organization, GoalStoreRequest $request, GoalsContract $access, GoalProgressService $progressService): JsonResponse
{
if ($request->getType() === GoalType::Personal) {
$this->checkPermission($organization, 'goals:create:own');
} else {
$this->checkPermission($organization, 'goals:create:organization-type');
}
$member = $this->member($organization);
$type = $request->getType();
$targetMemberId = $request->hasMemberId() ? $request->getMemberId() : $member->getKey();
if (! $access->canCreateGoal($member, $type, $targetMemberId)) {
throw new AuthorizationException;
}
$this->checkGoalLimit($organization);
$user = $member->user;
$goal = new Goal;
$goal->name = $request->getName();
$goal->type = $type;
$goal->comparison = $request->getComparison();
$goal->target_seconds = $request->getTargetSeconds();
$goal->period = $request->getPeriod();
$goal->filters = $request->getFilters();
$goal->timezone = $request->getTimezone() ?? app(TimezoneService::class)->getTimezoneFromUser($user)->getName();
$goal->week_start = $request->getWeekStart() ?? $user->week_start;
$goal->organization()->associate($organization);
$goal->member_id = $targetMemberId;
$goal->save();
$goal->load('member.user');
return (new GoalResource($goal, $progressService->getCurrentProgress($goal, Carbon::now())))
->response()
->setStatusCode(201);
}
/**
* Update goal
*
* The type of a goal and the member it is for can not be changed after creation.
*
* @throws AuthorizationException
*
* @operationId updateGoal
*/
public function update(Organization $organization, Goal $goal, GoalUpdateRequest $request, GoalsContract $access, GoalProgressService $progressService): GoalResource
{
if ($goal->type === GoalType::Personal) {
$this->checkPermission($organization, 'goals:update:own', $goal);
} else {
$this->checkPermission($organization, 'goals:update:organization-type', $goal);
}
$member = $this->member($organization);
if (! $access->canUpdateGoal($member, $goal)) {
throw new AuthorizationException;
}
if ($request->has('filters')) {
$goal->filters = $request->getFilters();
}
if ($request->has('name')) {
$goal->name = $request->getName();
}
if ($request->has('comparison')) {
$goal->comparison = $request->getComparison();
}
if ($request->has('target_seconds')) {
$goal->target_seconds = $request->getTargetSeconds();
}
if ($request->has('period')) {
$goal->period = $request->getPeriod();
}
if ($request->has('timezone')) {
$goal->timezone = $request->getTimezone();
}
if ($request->has('week_start')) {
$goal->week_start = $request->getWeekStart();
}
if ($request->has('is_archived')) {
$goal->archived_at = $request->getIsArchived() ? Carbon::now() : null;
}
$goal->save();
$goal->load('member.user');
return new GoalResource($goal, $progressService->getCurrentProgress($goal, Carbon::now()));
}
/**
* Delete goal
*
* @throws AuthorizationException
*
* @operationId deleteGoal
*/
public function destroy(Organization $organization, Goal $goal, GoalsContract $access): JsonResponse
{
if ($goal->type === GoalType::Personal) {
$this->checkPermission($organization, 'goals:delete:own', $goal);
} else {
$this->checkPermission($organization, 'goals:delete:organization-type', $goal);
}
$member = $this->member($organization);
if (! $access->canDeleteGoal($member, $goal)) {
throw new AuthorizationException;
}
$goal->delete();
return response()->json(null, 204);
}
}

View File

@@ -88,6 +88,8 @@ class InvitationController extends Controller
/** /**
* Remove a pending invitation * Remove a pending invitation
* *
* This revokes the invitation: the link in the invitation email stops working. Find the invitation ID with `GET /organizations/{organization}/invitations`.
*
* @throws AuthorizationException * @throws AuthorizationException
* *
* @operationId removeInvitation * @operationId removeInvitation

View File

@@ -145,6 +145,9 @@ class MemberController extends Controller
/** /**
* Merge one member into another * Merge one member into another
* *
* Only placeholder members (for example people created by an import) can be merged. All time entries and other data of the placeholder
* are reassigned to the member given in `member_id`, and the placeholder is removed. Find both member IDs with `GET /organizations/{organization}/members`.
*
* @throws AuthorizationException * @throws AuthorizationException
* @throws OnlyPlaceholdersCanBeMergedIntoAnotherMember * @throws OnlyPlaceholdersCanBeMergedIntoAnotherMember
* @throws Throwable * @throws Throwable

View File

@@ -71,6 +71,8 @@ class ReportController extends Controller
/** /**
* Create report * Create report
* *
* A report is a saved set of filters. Set `is_public` to `true` to share it: the response then contains the `shareable_link` that can be opened without logging in.
*
* @throws AuthorizationException * @throws AuthorizationException
* *
* @operationId createReport * @operationId createReport

View File

@@ -109,9 +109,14 @@ class TimeEntryController extends Controller
/** /**
* Get time entries in organization * Get time entries in organization
* *
* If you only need time entries for a specific user, you can filter by `member_id`. * Without a member filter this returns the time entries of all members of the organization (for users who may view all time entries, such as owners and admins), not only your own.
* To get only your own time entries, pass your member ID as `member_id`. Your member ID is the `id` returned for this organization by `GET /v1/users/me/memberships`; it is not your user ID.
* Users with the permission `time-entries:view:own` can only use this endpoint with their own member ID in the member_id filter. * Users with the permission `time-entries:view:own` can only use this endpoint with their own member ID in the member_id filter.
* *
* The `start` and `end` filters both apply to the start time of an entry, in UTC. Convert the user's local day boundaries to UTC first.
* Results are paginated with `limit` (default 100, max 500) and `offset`; check `meta.total` and fetch further pages when needed.
* To find the running timer, use `active=true` (or `GET /v1/users/me/time-entries/active`).
*
* @return TimeEntryCollection<TimeEntryResource> * @return TimeEntryCollection<TimeEntryResource>
* *
* @throws AuthorizationException * @throws AuthorizationException
@@ -351,6 +356,11 @@ class TimeEntryController extends Controller
* The parameters `group` and `sub_group` allow you to group the time entries by different criteria. * The parameters `group` and `sub_group` allow you to group the time entries by different criteria.
* If the group parameters are all set to `null` or are all missing, the endpoint will aggregate all filtered time entries. * If the group parameters are all set to `null` or are all missing, the endpoint will aggregate all filtered time entries.
* *
* Durations are returned in `seconds` (divide by 3600 for hours) and amounts in `cost` as cents in the organization's currency (divide by 100 for money).
* Filter by member with `member_id`. Your member ID is the `id` returned for this organization by `GET /v1/users/me/memberships`; it is not your user ID.
* Array filters use the query format `client_ids[]=<id>`.
* Example: billable hours per client for a period: `group=client&billable=true&start=...&end=...`.
*
* @operationId getAggregatedTimeEntries * @operationId getAggregatedTimeEntries
* *
* @return array{ * @return array{
@@ -584,6 +594,12 @@ class TimeEntryController extends Controller
/** /**
* Create time entry * Create time entry
* *
* `billable` is not taken from the project. To match the web app, set it to the project's `is_billable` value (or `false` without a project).
*
* A member can only have one running time entry (an entry with `end` set to `null`). Creating a running entry while another one runs fails with `time_entry_still_running`.
* To start a new timer, first stop the running entry by updating its `end` to the current time, then create the new entry.
* To log past work, send both `start` and `end` (UTC). Create one entry per block of work.
*
* @throws AuthorizationException * @throws AuthorizationException
* @throws TimeEntryStillRunningApiException * @throws TimeEntryStillRunningApiException
* *
@@ -634,6 +650,8 @@ class TimeEntryController extends Controller
/** /**
* Update time entry * Update time entry
* *
* To stop a running timer, set `end` to the stop time (UTC). Times the user gives in their own timezone must be converted to UTC first.
*
* @throws AuthorizationException|TimeEntryCanNotBeRestartedApiException * @throws AuthorizationException|TimeEntryCanNotBeRestartedApiException
* *
* @operationId updateTimeEntry * @operationId updateTimeEntry
@@ -702,6 +720,10 @@ class TimeEntryController extends Controller
/** /**
* Update multiple time entries * Update multiple time entries
* *
* Applies the same `changes` to every entry in `ids`. To find the IDs, list entries with `GET /organizations/{organization}/time-entries`
* (filtered by `member_id`, the project and the other criteria), then send their IDs here.
* When `changes.project_id` moves entries to another project, also set `changes.task_id` to a task of the new project or to `null`, because tasks belong to a project.
*
* @operationId updateMultipleTimeEntries * @operationId updateMultipleTimeEntries
* *
* @throws AuthorizationException * @throws AuthorizationException

View File

@@ -12,7 +12,7 @@ class TimeZoneController extends Controller
/** /**
* Get all timezones * Get all timezones
* *
* @response array{key: string}[] * @response object{key: string}[]
* *
* @operationId getTimezones * @operationId getTimezones
*/ */

View File

@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\Http\Controllers\Web; namespace App\Http\Controllers\Web;
use App\Enums\Weekday;
use App\Service\Dto\UserAgentDto; use App\Service\Dto\UserAgentDto;
use App\Service\TimezoneService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Routing\Controller; use Illuminate\Routing\Controller;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@@ -92,6 +94,8 @@ class UserProfileController extends Controller
$this->validateTwoFactorAuthenticationState($request); $this->validateTwoFactorAuthenticationState($request);
return Inertia::render('Profile/Show', [ return Inertia::render('Profile/Show', [
'timezones' => app(TimezoneService::class)->getSelectOptions(),
'weekdays' => Weekday::toSelectArray(),
'confirmsTwoFactorAuthentication' => Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'), 'confirmsTwoFactorAuthentication' => Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'),
'sessions' => $this->sessions($request), 'sessions' => $this->sessions($request),
]); ]);

View File

@@ -42,7 +42,6 @@ class HandleInertiaRequests extends Middleware
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing'); $hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing'); $hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
$hasServices = Module::has('Services') && Module::isEnabled('Services'); $hasServices = Module::has('Services') && Module::isEnabled('Services');
$hasGoals = Module::has('Goals') && Module::isEnabled('Goals');
/** @var BillingContract $billing */ /** @var BillingContract $billing */
$billing = app(BillingContract::class); $billing = app(BillingContract::class);
@@ -53,13 +52,11 @@ class HandleInertiaRequests extends Middleware
'has_billing_extension' => $hasBilling, 'has_billing_extension' => $hasBilling,
'has_invoicing_extension' => $hasInvoicing, 'has_invoicing_extension' => $hasInvoicing,
'has_services_extension' => $hasServices, 'has_services_extension' => $hasServices,
'has_goals_extension' => $hasGoals,
'billing' => $currentOrganization !== null ? [ 'billing' => $currentOrganization !== null ? [
'has_subscription' => $billing->hasSubscription($currentOrganization), 'has_subscription' => $billing->hasSubscription($currentOrganization),
'has_trial' => $billing->hasTrial($currentOrganization), 'has_trial' => $billing->hasTrial($currentOrganization),
'trial_until' => $billing->getTrialUntil($currentOrganization)?->toIso8601ZuluString(), 'trial_until' => $billing->getTrialUntil($currentOrganization)?->toIso8601ZuluString(),
'is_blocked' => $billing->isBlocked($currentOrganization), 'is_blocked' => $billing->isBlocked($currentOrganization),
'goal_limit' => $billing->getGoalLimit($currentOrganization),
] : null, ] : null,
'flash' => [ 'flash' => [
'message' => fn () => $request->session()->get('message'), 'message' => fn () => $request->session()->get('message'),

View File

@@ -1,143 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Goal;
use App\Enums\Role;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryType;
use App\Models\Client;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Service\Dto\GoalFiltersDto;
use App\Service\TimeEntryFilter;
use Illuminate\Contracts\Validation\Rule as LegacyValidationRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* Shared validation rules for the filters of a goal.
*
* @property Organization $organization Organization from model binding
*/
trait GoalFilterRules
{
/**
* @param bool $forEveryMember Whether the goal counts every member instead of one member
* @return array<string, array<string|ValidationRule|LegacyValidationRule|\Closure>>
*/
protected function filterRules(bool $forEveryMember): array
{
return [
'filters' => [
'sometimes',
'array',
],
// Filter by member IDs, member IDs are OR combined. Only for goals that count every member, null means every member.
'filters.member_ids' => [
'nullable',
'array',
// "prohibited" still lets null and an empty array through, both mean "no restriction"
...($forEveryMember ? [] : ['prohibited']),
],
'filters.member_ids.*' => [
'string',
'distinct',
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization')
->where('role', '!=', Role::Placeholder->value);
})->uuid(),
],
// Filter by project IDs, project IDs are OR combined, "none" matches entries without a project
'filters.project_ids' => [
'nullable',
'array',
],
'filters.project_ids.*' => [
'string',
$this->idOrNoneExistsInOrganization(Project::class),
],
// Filter by task IDs, task IDs are OR combined, "none" matches entries without a task
'filters.task_ids' => [
'nullable',
'array',
],
'filters.task_ids.*' => [
'string',
$this->idOrNoneExistsInOrganization(Task::class),
],
// Filter by tag IDs, tag IDs are OR combined, "none" matches entries without tags
'filters.tag_ids' => [
'nullable',
'array',
],
'filters.tag_ids.*' => [
'string',
$this->idOrNoneExistsInOrganization(Tag::class),
],
'filters.tag_match_type' => [
'nullable',
'string',
Rule::enum(TagMatchType::class),
],
// Filter by client IDs, client IDs are OR combined, "none" matches entries without a client
'filters.client_ids' => [
'nullable',
'array',
],
'filters.client_ids.*' => [
'string',
$this->idOrNoneExistsInOrganization(Client::class),
],
// Filter by billable status, null means both
'filters.billable' => [
'nullable',
'boolean',
],
// Filter by time entry type, null means both
'filters.time_entry_type' => [
'nullable',
'string',
Rule::enum(TimeEntryType::class),
],
];
}
/**
* @param class-string<Project|Task|Tag|Client> $modelClass
*/
private function idOrNoneExistsInOrganization(string $modelClass): \Closure
{
return function (string $attribute, mixed $value, \Closure $fail) use ($modelClass): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
ExistsEloquent::make($modelClass, null, function (Builder $builder): Builder {
/** @var Builder<Project|Task|Tag|Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
};
}
public function getFilters(): GoalFiltersDto
{
$filters = new GoalFiltersDto;
$filters->setMemberIds($this->input('filters.member_ids'));
$filters->setProjectIds($this->input('filters.project_ids'));
$filters->setTaskIds($this->input('filters.task_ids'));
$filters->setTagIds($this->input('filters.tag_ids'));
$filters->tagMatchType = $this->input('filters.tag_match_type') !== null ? TagMatchType::from($this->input('filters.tag_match_type')) : null;
$filters->setClientIds($this->input('filters.client_ids'));
$filters->billable = $this->input('filters.billable') !== null ? (bool) $this->input('filters.billable') : null;
$filters->timeEntryType = $this->input('filters.time_entry_type') !== null ? TimeEntryType::from($this->input('filters.time_entry_type')) : null;
return $filters;
}
}

View File

@@ -1,61 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Goal;
use App\Enums\GoalType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Contracts\Validation\Rule as LegacyValidationRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Validation\Rule;
/**
* @property Organization $organization Organization from model binding
*/
class GoalIndexRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|LegacyValidationRule|\Closure>>
*/
public function rules(): array
{
return [
// Only return goals of this type (personal, organization)
'type' => [
'nullable',
'string',
Rule::enum(GoalType::class),
],
// Filter by archived status, "true" only archived, "false" only not archived (default), "all" both
'archived' => [
'nullable',
'string',
'in:true,false,all',
],
'page' => [
'nullable',
'integer',
'min:1',
'max:2147483647',
],
];
}
public function getType(): ?GoalType
{
if ($this->input('type') === null) {
return null;
}
return GoalType::from($this->input('type'));
}
public function getArchivedFilter(): string
{
return (string) $this->input('archived', 'false');
}
}

View File

@@ -1,167 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Goal;
use App\Enums\GoalComparison;
use App\Enums\GoalPeriod;
use App\Enums\GoalType;
use App\Enums\Role;
use App\Enums\Weekday;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member;
use App\Models\Organization;
use Illuminate\Contracts\Validation\Rule as LegacyValidationRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class GoalStoreRequest extends BaseFormRequest
{
use GoalFilterRules;
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|LegacyValidationRule|In|\Closure>>
*/
public function rules(): array
{
$memberExistsInOrganization = ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization')
->where('role', '!=', Role::Placeholder->value);
})->uuid();
return array_merge([
'name' => [
'required',
'string',
'max:255',
],
// personal: a goal for yourself that nobody else can see, organization: a goal of the organization for a member or for every member. Organization goals require the team goals extension.
'type' => [
'required',
'string',
Rule::enum(GoalType::class),
],
// Whether the goal is reached when "at least" or "less than" the target time is tracked in the period
'comparison' => [
'required',
'string',
Rule::enum(GoalComparison::class),
],
// Target time in seconds
'target_seconds' => [
'required',
'integer',
'min:1',
'max:2147483647',
],
// The recurring time frame in which the target has to be reached
'period' => [
'required',
'string',
Rule::enum(GoalPeriod::class),
],
// ID of the member whose time entries count towards the goal, defaults to the current member. Send null for a goal that counts every member (organization goals only).
'member_id' => $this->input('type') === GoalType::Personal->value
// A personal goal is always for the current member, it can not count every member
? [
'sometimes',
'filled',
'string',
Rule::in([$this->currentMemberId()]),
]
: [
'nullable',
'string',
$memberExistsInOrganization,
],
// Timezone that defines the periods of the goal, defaults to the timezone of the current user
'timezone' => [
'nullable',
'timezone:all',
],
// Week start that defines weekly periods, defaults to the week start of the current user
'week_start' => [
'nullable',
'string',
Rule::enum(Weekday::class),
],
], $this->filterRules(
// Without a member_id the goal is for the current member
$this->has('member_id') && $this->input('member_id') === null,
));
}
private function currentMemberId(): ?string
{
/** @var string|null $memberId */
$memberId = Member::query()
->whereBelongsTo($this->organization, 'organization')
->where('user_id', $this->user()?->getKey())
->value('id');
return $memberId;
}
public function getName(): string
{
return (string) $this->input('name');
}
public function getType(): GoalType
{
return GoalType::from($this->input('type'));
}
public function getComparison(): GoalComparison
{
return GoalComparison::from($this->input('comparison'));
}
public function getTargetSeconds(): int
{
return (int) $this->input('target_seconds');
}
public function getPeriod(): GoalPeriod
{
return GoalPeriod::from($this->input('period'));
}
public function hasMemberId(): bool
{
return $this->has('member_id');
}
public function getMemberId(): ?string
{
return $this->input('member_id');
}
public function getTimezone(): ?string
{
if (! $this->has('timezone') || $this->input('timezone') === null) {
return null;
}
return (string) $this->input('timezone');
}
public function getWeekStart(): ?Weekday
{
if (! $this->has('week_start') || $this->input('week_start') === null) {
return null;
}
return Weekday::from($this->input('week_start'));
}
}

View File

@@ -1,129 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Goal;
use App\Enums\GoalComparison;
use App\Enums\GoalPeriod;
use App\Enums\Weekday;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Goal;
use App\Models\Organization;
use Illuminate\Contracts\Validation\Rule as LegacyValidationRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Validation\Rule;
/**
* @property Organization $organization Organization from model binding
*/
class GoalUpdateRequest extends BaseFormRequest
{
use GoalFilterRules;
/**
* Get the validation rules that apply to the request.
* The type of a goal and the member it is for can not be changed after creation.
*
* @return array<string, array<string|ValidationRule|LegacyValidationRule|\Closure>>
*/
public function rules(): array
{
/** @var Goal $goal */
$goal = $this->route('goal');
return array_merge([
'name' => [
'sometimes',
'string',
'max:255',
],
// Whether the goal is reached when "at least" or "less than" the target time is tracked in the period
'comparison' => [
'sometimes',
'string',
Rule::enum(GoalComparison::class),
],
// Target time in seconds
'target_seconds' => [
'sometimes',
'integer',
'min:1',
'max:2147483647',
],
// The recurring time frame in which the target has to be reached
'period' => [
'sometimes',
'string',
Rule::enum(GoalPeriod::class),
],
// Timezone that defines the periods of the goal
'timezone' => [
'sometimes',
'timezone:all',
],
// Week start that defines weekly periods
'week_start' => [
'sometimes',
'string',
Rule::enum(Weekday::class),
],
// Archived goals are hidden from the goal list by default, their progress is still calculated
'is_archived' => [
'sometimes',
'boolean',
],
// Only allowed if it matches the current type of the goal
'type' => [
function (string $attribute, mixed $value, \Closure $fail) use ($goal): void {
if ($value !== $goal->type->value) {
$fail('The '.$attribute.' of a goal can not be changed.');
}
},
],
// Only allowed if it matches the current member of the goal
'member_id' => [
function (string $attribute, mixed $value, \Closure $fail) use ($goal): void {
if ($value !== $goal->member_id) {
$fail('The '.$attribute.' of a goal can not be changed.');
}
},
],
], $this->filterRules($goal->member_id === null));
}
public function getName(): string
{
return (string) $this->input('name');
}
public function getComparison(): GoalComparison
{
return GoalComparison::from($this->input('comparison'));
}
public function getTargetSeconds(): int
{
return (int) $this->input('target_seconds');
}
public function getPeriod(): GoalPeriod
{
return GoalPeriod::from($this->input('period'));
}
public function getTimezone(): string
{
return (string) $this->input('timezone');
}
public function getWeekStart(): Weekday
{
return Weekday::from($this->input('week_start'));
}
public function getIsArchived(): bool
{
return (bool) $this->input('is_archived');
}
}

View File

@@ -27,6 +27,7 @@ class MemberUpdateRequest extends BaseFormRequest
'string', 'string',
Rule::enum(Role::class), Rule::enum(Role::class),
], ],
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge( 'billable_rate' => array_merge(
[ [
'nullable', 'nullable',

View File

@@ -36,6 +36,7 @@ class OrganizationUpdateRequest extends BaseFormRequest
'string', 'string',
new CurrencyRule, new CurrencyRule,
], ],
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge( 'billable_rate' => array_merge(
[ [
'nullable', 'nullable',

View File

@@ -55,6 +55,7 @@ class ProjectStoreRequest extends BaseFormRequest
'required', 'required',
'boolean', 'boolean',
], ],
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge( 'billable_rate' => array_merge(
[ [
'nullable', 'nullable',

View File

@@ -68,6 +68,7 @@ class ProjectUpdateRequest extends BaseFormRequest
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), })->uuid(),
], ],
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge([ 'billable_rate' => array_merge([
'nullable', 'nullable',
], ],

View File

@@ -31,6 +31,7 @@ class ProjectMemberStoreRequest extends BaseFormRequest
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), })->uuid(),
], ],
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge( 'billable_rate' => array_merge(
[ [
'nullable', 'nullable',

View File

@@ -21,6 +21,7 @@ class ProjectMemberUpdateRequest extends BaseFormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge( 'billable_rate' => array_merge(
[ [
'nullable', 'nullable',

View File

@@ -35,7 +35,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
// Filter by member ID // Filter by member ID. Without it, users who may view all time entries (owners, admins) get the entries of every member; pass your own member ID (from GET /v1/users/me/memberships) to get only yours
'member_id' => [ 'member_id' => [
'string', 'string',
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder { ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
@@ -155,7 +155,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
'string', 'string',
Rule::enum(TimeEntryType::class), Rule::enum(TimeEntryType::class),
], ],
// Limit the number of returned time entries (default: 150) // Limit the number of returned time entries (default: 100)
'limit' => [ 'limit' => [
'integer', 'integer',
'min:1', 'min:1',

View File

@@ -32,7 +32,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
// ID of the organization member that the time entry should belong to // ID of the organization member that the time entry should belong to (a member ID from GET /v1/users/me/memberships or the members list, not a user ID)
'member_id' => [ 'member_id' => [
'required', 'required',
'string', 'string',
@@ -86,7 +86,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
'date_format:Y-m-d\TH:i:s\Z', 'date_format:Y-m-d\TH:i:s\Z',
'after_or_equal:start', 'after_or_equal:start',
], ],
// Whether time entry is billable // Whether time entry is billable. Not derived from the project: set it to the project's is_billable value to match the web app
'billable' => [ 'billable' => [
'required', 'required',
'boolean', 'boolean',

View File

@@ -1,46 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\Goal;
use App\Http\Resources\PaginatedResourceCollection;
use App\Models\Goal;
use App\Service\Dto\GoalProgressDto;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class GoalCollection extends ResourceCollection implements PaginatedResourceCollection
{
/**
* @var array<string, GoalProgressDto>
*/
private array $progressByGoalId;
/**
* @param array<string, GoalProgressDto> $progressByGoalId
*/
public function __construct($resource, array $progressByGoalId)
{
parent::__construct($resource);
$this->progressByGoalId = $progressByGoalId;
}
protected function collects(): ?string
{
return null;
}
/**
* Transform the resource collection into an array.
*
* @return array<array<string, string|bool|int|null|array<string, string|bool|int|null|array<int, string>>>>
*/
public function toArray(Request $request): array
{
return $this->collection->map(function (Goal $goal) use ($request): array {
return (new GoalResource($goal, $this->progressByGoalId[$goal->getKey()]))
->toArray($request);
})->all();
}
}

View File

@@ -1,90 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\Goal;
use App\Http\Resources\V1\BaseResource;
use App\Models\Goal;
use App\Service\Dto\GoalProgressDto;
use Illuminate\Http\Request;
/**
* @property Goal $resource
*/
class GoalResource extends BaseResource
{
private GoalProgressDto $progress;
public function __construct(Goal $resource, GoalProgressDto $progress)
{
parent::__construct($resource);
$this->progress = $progress;
}
/**
* Transform the resource into an array.
*
* @return array<string, string|bool|int|null|array<string, string|bool|int|null|array<int, string>>>
*/
public function toArray(Request $request): array
{
return [
/** @var string $id ID of the goal */
'id' => $this->resource->id,
/** @var string $name Name */
'name' => $this->resource->name,
/** @var string $type personal: a goal a member set for themselves, organization: a goal of the organization (team goals extension) */
'type' => $this->resource->type->value,
/** @var string $comparison Whether the goal is reached with "at least" or "less than" the target time (at_least, less_than) */
'comparison' => $this->resource->comparison->value,
/** @var int $target_seconds Target time in seconds */
'target_seconds' => $this->resource->target_seconds,
/** @var string $period Recurring time frame (day, week, month) */
'period' => $this->resource->period->value,
/** @var string|null $member_id ID of the member whose time entries count towards the goal, null if every member counts */
'member_id' => $this->resource->member_id,
/** @var string|null $member_name Name of the member whose time entries count towards the goal, null if every member counts */
'member_name' => $this->resource->member?->user->name,
/** @var string $timezone Timezone that defines the periods of the goal */
'timezone' => $this->resource->timezone,
/** @var string $week_start Week start that defines weekly periods */
'week_start' => $this->resource->week_start->value,
/** @var bool $is_archived Whether the goal is archived */
'is_archived' => $this->resource->is_archived,
'filters' => [
/** @var array<string>|null $member_ids Filter by member IDs, member IDs are OR combined, only for goals that count every member */
'member_ids' => $this->resource->filters->memberIds?->toArray(),
/** @var array<string>|null $project_ids Filter by project IDs, project IDs are OR combined */
'project_ids' => $this->resource->filters->projectIds?->toArray(),
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
'task_ids' => $this->resource->filters->taskIds?->toArray(),
/** @var array<string>|null $tag_ids Filter by tag IDs, tag IDs are OR combined */
'tag_ids' => $this->resource->filters->tagIds?->toArray(),
/** @var string|null $tag_match_type Tag match type (contains, not_contains) */
'tag_match_type' => $this->resource->filters->tagMatchType?->value,
/** @var array<string>|null $client_ids Filter by client IDs, client IDs are OR combined */
'client_ids' => $this->resource->filters->clientIds?->toArray(),
/** @var bool|null $billable Filter by billable status */
'billable' => $this->resource->filters->billable,
/** @var string|null $time_entry_type Filter by time entry type (work, break) */
'time_entry_type' => $this->resource->filters->timeEntryType?->value,
],
'progress' => [
/** @var string $period_start Start of the current period (inclusive) */
'period_start' => $this->formatDateTime($this->progress->periodStart),
/** @var string $period_end End of the current period (exclusive) */
'period_end' => $this->formatDateTime($this->progress->periodEnd),
/** @var int $tracked_seconds Seconds tracked in the current period that match the filters, incl. the running time entry */
'tracked_seconds' => $this->progress->trackedSeconds,
/** @var string $status Status in the current period (in_progress, achieved, on_track, exceeded) */
'status' => $this->progress->status->value,
],
/** @var string $created_at Date when the goal was created */
'created_at' => $this->formatDateTime($this->resource->created_at),
/** @var string $updated_at Date when the goal was last updated */
'updated_at' => $this->formatDateTime($this->resource->updated_at),
];
}
}

View File

@@ -1,114 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Enums\GoalComparison;
use App\Enums\GoalPeriod;
use App\Enums\GoalType;
use App\Enums\Weekday;
use App\Models\Concerns\HasUuids;
use App\Service\Dto\GoalFiltersDto;
use Database\Factories\GoalFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/**
* @property string $id
* @property string $name
* @property GoalType $type
* @property GoalComparison $comparison
* @property int $target_seconds
* @property GoalPeriod $period
* @property GoalFiltersDto $filters
* @property string $timezone
* @property Weekday $week_start
* @property Carbon|null $archived_at
* @property-read bool $is_archived
* @property string $organization_id
* @property string|null $member_id
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read Organization $organization
* @property-read Member|null $member
*
* @method static GoalFactory factory()
*/
class Goal extends Model
{
/** @use HasFactory<GoalFactory> */
use HasFactory;
use HasUuids;
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'type' => GoalType::class,
'comparison' => GoalComparison::class,
'target_seconds' => 'int',
'period' => GoalPeriod::class,
'filters' => GoalFiltersDto::class,
'week_start' => Weekday::class,
'archived_at' => 'datetime',
];
/**
* @return BelongsTo<Organization, $this>
*/
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
/**
* The member whose time entries count towards the goal, null for goals that count every member.
*
* @return BelongsTo<Member, $this>
*/
public function member(): BelongsTo
{
return $this->belongsTo(Member::class, 'member_id');
}
public function isPersonalGoalOf(Member $member): bool
{
return $this->type === GoalType::Personal && $this->member_id === $member->getKey();
}
/**
* @return Attribute<bool, never>
*/
protected function isArchived(): Attribute
{
return Attribute::make(
get: fn (mixed $value, array $attributes) => isset($attributes['archived_at']),
);
}
/**
* @param Builder<Goal> $builder
* @return Builder<Goal>
*/
public function scopeArchived(Builder $builder): Builder
{
return $builder->whereNotNull('archived_at');
}
/**
* @param Builder<Goal> $builder
* @return Builder<Goal>
*/
public function scopeNotArchived(Builder $builder): Builder
{
return $builder->whereNull('archived_at');
}
}

View File

@@ -28,7 +28,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property-read User $user * @property-read User $user
* @property-read Collection<int, ProjectMember> $projectMembers * @property-read Collection<int, ProjectMember> $projectMembers
* @property-read Collection<int, TimeEntry> $timeEntries * @property-read Collection<int, TimeEntry> $timeEntries
* @property-read Collection<int, Goal> $goals
* *
* @method static MemberFactory factory() * @method static MemberFactory factory()
*/ */
@@ -79,14 +78,4 @@ class Member extends Pivot implements AuditableContract
{ {
return $this->hasMany(ProjectMember::class, 'member_id'); return $this->hasMany(ProjectMember::class, 'member_id');
} }
/**
* Goals whose progress counts the time entries of this member.
*
* @return HasMany<Goal, $this>
*/
public function goals(): HasMany
{
return $this->hasMany(Goal::class, 'member_id');
}
} }

View File

@@ -17,7 +17,6 @@ use App\Models\Task;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use App\Models\User; use App\Models\User;
use App\Service\BillingContract; use App\Service\BillingContract;
use App\Service\GoalsContract;
use App\Service\IpLookup\IpLookupServiceContract; use App\Service\IpLookup\IpLookupServiceContract;
use App\Service\IpLookup\NoIpLookupService; use App\Service\IpLookup\NoIpLookupService;
use App\Service\PermissionStore; use App\Service\PermissionStore;
@@ -104,7 +103,6 @@ class AppServiceProvider extends ServiceProvider
// Extensions // Extensions
$this->app->bind(IpLookupServiceContract::class, NoIpLookupService::class); $this->app->bind(IpLookupServiceContract::class, NoIpLookupService::class);
$this->app->bind(BillingContract::class); $this->app->bind(BillingContract::class);
$this->app->bind(GoalsContract::class);
// Storage // Storage
// The local driver ignores the ResponseContentDisposition option of temporaryUrl, // The local driver ignores the ResponseContentDisposition option of temporaryUrl,

View File

@@ -43,15 +43,6 @@ class BillingContract
return null; return null;
} }
/**
* The maximum number of goals the organization can have; null means unlimited.
* Archived goals count toward the limit.
*/
public function getGoalLimit(Organization $organization): ?int
{
return null;
}
/** /**
* Check if the organization is blocked * Check if the organization is blocked
* A blocked organization is an organization that has more than 1 non-placeholder member but no subscription/trial * A blocked organization is an organization that has more than 1 non-placeholder member but no subscription/trial

View File

@@ -8,7 +8,6 @@ use App\Enums\Role;
use App\Events\BeforeOrganizationDeletion; use App\Events\BeforeOrganizationDeletion;
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers; use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
use App\Models\Client; use App\Models\Client;
use App\Models\Goal;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\OrganizationInvitation; use App\Models\OrganizationInvitation;
@@ -77,9 +76,6 @@ class DeletionService
// Delete all reports // Delete all reports
Report::query()->whereBelongsTo($organization, 'organization')->delete(); Report::query()->whereBelongsTo($organization, 'organization')->delete();
// Delete all goals
Goal::query()->whereBelongsTo($organization, 'organization')->delete();
// Reset the current organization // Reset the current organization
$organization->owner() $organization->owner()
->where('current_team_id', $organization->getKey()) ->where('current_team_id', $organization->getKey())

View File

@@ -1,169 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service\Dto;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryType;
use App\Service\TimeEntryFilter;
use Illuminate\Contracts\Database\Eloquent\Castable;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
/**
* Persisted filter set of a goal. Defines which time entries count towards the goal.
* A null value means "no restriction" for that dimension. The ID setters store an empty array as null,
* so "no restriction" has a single representation.
*/
class GoalFiltersDto implements Castable
{
/**
* Members whose time entries count. Only used by goals that count every member, null means every member.
*
* @var Collection<int, string>|null
*/
public ?Collection $memberIds = null;
/**
* @var Collection<int, string>|null
*/
public ?Collection $projectIds = null;
/**
* @var Collection<int, string>|null
*/
public ?Collection $taskIds = null;
/**
* @var Collection<int, string>|null
*/
public ?Collection $tagIds = null;
public ?TagMatchType $tagMatchType = null;
/**
* @var Collection<int, string>|null
*/
public ?Collection $clientIds = null;
public ?bool $billable = null;
public ?TimeEntryType $timeEntryType = null;
/**
* Get the caster class to use when casting from / to this cast target.
*
* @param array<string, mixed> $arguments
* @return CastsAttributes<GoalFiltersDto, GoalFiltersDto>
*/
public static function castUsing(array $arguments): CastsAttributes
{
return new class implements CastsAttributes
{
private const array REQUIRED_PROPERTIES = [
'memberIds',
'projectIds',
'taskIds',
'tagIds',
'tagMatchType',
'clientIds',
'billable',
'timeEntryType',
];
public function get(Model $model, string $key, mixed $value, array $attributes): GoalFiltersDto
{
if (! is_string($value)) {
throw new \InvalidArgumentException('The given value is not a string');
}
$data = json_decode($value, false);
if (! is_object($data)) {
throw new \InvalidArgumentException('The given value is not a JSON object string');
}
foreach (self::REQUIRED_PROPERTIES as $property) {
if (! property_exists($data, $property)) {
throw new \InvalidArgumentException('The given JSON string does not contain the required property "'.$property.'"');
}
}
$dto = new GoalFiltersDto;
$dto->setMemberIds(isset($data->memberIds) ? (array) $data->memberIds : null);
$dto->setProjectIds(isset($data->projectIds) ? (array) $data->projectIds : null);
$dto->setTaskIds(isset($data->taskIds) ? (array) $data->taskIds : null);
$dto->setTagIds(isset($data->tagIds) ? (array) $data->tagIds : null);
$dto->tagMatchType = isset($data->tagMatchType) ? TagMatchType::from($data->tagMatchType) : null;
$dto->setClientIds(isset($data->clientIds) ? (array) $data->clientIds : null);
$dto->billable = isset($data->billable) ? (bool) $data->billable : null;
$dto->timeEntryType = isset($data->timeEntryType) ? TimeEntryType::from($data->timeEntryType) : null;
return $dto;
}
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
if (! ($value instanceof GoalFiltersDto)) {
throw new \InvalidArgumentException('The given value is not an instance of GoalFiltersDto');
}
$data = (object) [
'memberIds' => $value->memberIds?->toArray(),
'projectIds' => $value->projectIds?->toArray(),
'taskIds' => $value->taskIds?->toArray(),
'tagIds' => $value->tagIds?->toArray(),
'tagMatchType' => $value->tagMatchType?->value,
'clientIds' => $value->clientIds?->toArray(),
'billable' => $value->billable,
'timeEntryType' => $value->timeEntryType?->value,
];
$jsonString = json_encode($data);
if ($jsonString === false) {
throw new \InvalidArgumentException('Could not encode the given data to a JSON string');
}
return $jsonString;
}
};
}
/**
* @param array<mixed>|null $memberIds
*/
public function setMemberIds(?array $memberIds): void
{
$this->memberIds = $memberIds !== null && count($memberIds) > 0 ? TimeEntryFilter::idArrayToCollection($memberIds) : null;
}
/**
* @param array<mixed>|null $projectIds
*/
public function setProjectIds(?array $projectIds): void
{
$this->projectIds = $projectIds !== null && count($projectIds) > 0 ? TimeEntryFilter::idArrayToCollection($projectIds) : null;
}
/**
* @param array<mixed>|null $taskIds
*/
public function setTaskIds(?array $taskIds): void
{
$this->taskIds = $taskIds !== null && count($taskIds) > 0 ? TimeEntryFilter::idArrayToCollection($taskIds) : null;
}
/**
* @param array<mixed>|null $tagIds
*/
public function setTagIds(?array $tagIds): void
{
$this->tagIds = $tagIds !== null && count($tagIds) > 0 ? TimeEntryFilter::idArrayToCollection($tagIds) : null;
}
/**
* @param array<mixed>|null $clientIds
*/
public function setClientIds(?array $clientIds): void
{
$this->clientIds = $clientIds !== null && count($clientIds) > 0 ? TimeEntryFilter::idArrayToCollection($clientIds) : null;
}
}

View File

@@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service\Dto;
use App\Enums\GoalStatus;
use Illuminate\Support\Carbon;
readonly class GoalProgressDto
{
/**
* @param Carbon $periodStart Start of the period in UTC (inclusive)
* @param Carbon $periodEnd End of the period in UTC (exclusive)
*/
public function __construct(
public Carbon $periodStart,
public Carbon $periodEnd,
public int $trackedSeconds,
public GoalStatus $status,
) {}
}

View File

@@ -16,6 +16,7 @@ use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use InvalidArgumentException; use InvalidArgumentException;
class ReportPropertiesDto implements Castable class ReportPropertiesDto implements Castable
@@ -116,13 +117,13 @@ class ReportPropertiesDto implements Castable
$dto->end = $data->end !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $data->end) : null; $dto->end = $data->end !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $data->end) : null;
$dto->start = $data->start !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $data->start) : null; $dto->start = $data->start !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $data->start) : null;
$dto->active = $data->active; $dto->active = $data->active;
$dto->memberIds = $data->memberIds !== null ? TimeEntryFilter::idArrayToCollection($data->memberIds) : null; $dto->memberIds = $data->memberIds !== null ? ReportPropertiesDto::idArrayToCollection($data->memberIds) : null;
$dto->billable = $data->billable; $dto->billable = $data->billable;
$dto->clientIds = $data->clientIds !== null ? TimeEntryFilter::idArrayToCollection($data->clientIds) : null; $dto->clientIds = $data->clientIds !== null ? ReportPropertiesDto::idArrayToCollection($data->clientIds) : null;
$dto->projectIds = $data->projectIds !== null ? TimeEntryFilter::idArrayToCollection($data->projectIds) : null; $dto->projectIds = $data->projectIds !== null ? ReportPropertiesDto::idArrayToCollection($data->projectIds) : null;
$dto->tagIds = $data->tagIds !== null ? TimeEntryFilter::idArrayToCollection($data->tagIds) : null; $dto->tagIds = $data->tagIds !== null ? ReportPropertiesDto::idArrayToCollection($data->tagIds) : null;
$dto->tagMatchType = isset($data->tagMatchType) ? TagMatchType::from($data->tagMatchType) : null; $dto->tagMatchType = isset($data->tagMatchType) ? TagMatchType::from($data->tagMatchType) : null;
$dto->taskIds = $data->taskIds ? TimeEntryFilter::idArrayToCollection($data->taskIds) : null; $dto->taskIds = $data->taskIds ? ReportPropertiesDto::idArrayToCollection($data->taskIds) : null;
$dto->group = TimeEntryAggregationType::from($data->group); $dto->group = TimeEntryAggregationType::from($data->group);
$dto->subGroup = TimeEntryAggregationType::from($data->subGroup); $dto->subGroup = TimeEntryAggregationType::from($data->subGroup);
$dto->historyGroup = TimeEntryAggregationTypeInterval::from($data->historyGroup); $dto->historyGroup = TimeEntryAggregationTypeInterval::from($data->historyGroup);
@@ -179,12 +180,32 @@ class ReportPropertiesDto implements Castable
}; };
} }
/**
* @param array<mixed> $ids
* @return Collection<int, string>
*/
public static function idArrayToCollection(array $ids): Collection
{
$collection = new Collection;
foreach ($ids as $id) {
if (! is_string($id)) {
throw new InvalidArgumentException('The given ID is not a string');
}
if ($id !== TimeEntryFilter::NONE_VALUE && ! Str::isUuid($id)) {
throw new InvalidArgumentException('The given ID is not a valid UUID');
}
$collection->push($id);
}
return $collection;
}
/** /**
* @param array<mixed>|null $memberIds * @param array<mixed>|null $memberIds
*/ */
public function setMemberIds(?array $memberIds): void public function setMemberIds(?array $memberIds): void
{ {
$this->memberIds = $memberIds !== null ? TimeEntryFilter::idArrayToCollection($memberIds) : null; $this->memberIds = $memberIds !== null ? ReportPropertiesDto::idArrayToCollection($memberIds) : null;
} }
/** /**
@@ -192,7 +213,7 @@ class ReportPropertiesDto implements Castable
*/ */
public function setClientIds(?array $clientIds): void public function setClientIds(?array $clientIds): void
{ {
$this->clientIds = $clientIds !== null ? TimeEntryFilter::idArrayToCollection($clientIds) : null; $this->clientIds = $clientIds !== null ? ReportPropertiesDto::idArrayToCollection($clientIds) : null;
} }
/** /**
@@ -200,7 +221,7 @@ class ReportPropertiesDto implements Castable
*/ */
public function setProjectIds(?array $projectIds): void public function setProjectIds(?array $projectIds): void
{ {
$this->projectIds = $projectIds !== null ? TimeEntryFilter::idArrayToCollection($projectIds) : null; $this->projectIds = $projectIds !== null ? ReportPropertiesDto::idArrayToCollection($projectIds) : null;
} }
/** /**
@@ -208,7 +229,7 @@ class ReportPropertiesDto implements Castable
*/ */
public function setTagIds(?array $tagIds): void public function setTagIds(?array $tagIds): void
{ {
$this->tagIds = $tagIds !== null ? TimeEntryFilter::idArrayToCollection($tagIds) : null; $this->tagIds = $tagIds !== null ? ReportPropertiesDto::idArrayToCollection($tagIds) : null;
} }
public function setTagMatchType(?TagMatchType $tagMatchType): void public function setTagMatchType(?TagMatchType $tagMatchType): void
@@ -221,6 +242,6 @@ class ReportPropertiesDto implements Castable
*/ */
public function setTaskIds(?array $taskIds): void public function setTaskIds(?array $taskIds): void
{ {
$this->taskIds = $taskIds !== null ? TimeEntryFilter::idArrayToCollection($taskIds) : null; $this->taskIds = $taskIds !== null ? ReportPropertiesDto::idArrayToCollection($taskIds) : null;
} }
} }

View File

@@ -1,122 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Enums\GoalComparison;
use App\Enums\GoalPeriod;
use App\Enums\GoalStatus;
use App\Models\Goal;
use App\Models\TimeEntry;
use App\Service\Dto\GoalProgressDto;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
class GoalProgressService
{
/**
* Progress of the goal in the period that contains $now.
*/
public function getCurrentProgress(Goal $goal, Carbon $now): GoalProgressDto
{
[$periodStart, $periodEnd] = $this->getPeriodBounds($goal, $now);
$trackedSeconds = $this->getProgress($goal, $periodStart, $periodEnd);
return new GoalProgressDto($periodStart, $periodEnd, $trackedSeconds, $this->getStatus($goal, $trackedSeconds));
}
/**
* Progress of the goals in the period that contains $now, keyed by goal ID.
* Runs one query per goal, since every goal has its own filters, timezone and period.
*
* @param Collection<int, Goal> $goals
* @return array<string, GoalProgressDto>
*/
public function getCurrentProgressForGoals(Collection $goals, Carbon $now): array
{
$progress = [];
foreach ($goals as $goal) {
$progress[$goal->getKey()] = $this->getCurrentProgress($goal, $now);
}
return $progress;
}
/**
* Bounds of the period that contains $date, in UTC. The period is calculated in the timezone of the goal
* and respects the week start of the goal. The start is inclusive, the end exclusive.
*
* @return array{0: Carbon, 1: Carbon}
*/
public function getPeriodBounds(Goal $goal, Carbon $date): array
{
$dateInTimezone = $date->copy()->setTimezone($goal->timezone);
$start = match ($goal->period) {
GoalPeriod::Day => $dateInTimezone->copy()->startOfDay(),
GoalPeriod::Week => $dateInTimezone->copy()->startOfWeek($goal->week_start->carbonWeekDay()),
GoalPeriod::Month => $dateInTimezone->copy()->startOfMonth(),
};
$end = match ($goal->period) {
GoalPeriod::Day => $start->copy()->addDay(),
GoalPeriod::Week => $start->copy()->addWeek(),
GoalPeriod::Month => $start->copy()->addMonth(),
};
return [$start->utc(), $end->utc()];
}
/**
* Seconds tracked in the given period that count towards the goal.
* Counts the time entries of the member of the goal, or of every member for goals without a member.
* Time entries are assigned to the period their start is in, like in the reporting.
* A running time entry counts with its elapsed time up to now.
*
* @param Carbon $periodStart Start of the period (inclusive)
* @param Carbon $periodEnd End of the period (exclusive)
*/
private function getProgress(Goal $goal, Carbon $periodStart, Carbon $periodEnd): int
{
$filters = $goal->filters;
$query = TimeEntry::query()
->where('organization_id', '=', $goal->organization_id);
if ($goal->member_id !== null) {
$query->where('member_id', '=', $goal->member_id);
}
$timeEntryFilter = new TimeEntryFilter($query);
$timeEntryFilter
->addStart($periodStart)
->addEnd($periodEnd)
->addProjectIdsFilter($filters->projectIds?->toArray())
->addTaskIdsFilter($filters->taskIds?->toArray())
->addTagIdsFilter($filters->tagIds?->toArray(), $filters->tagMatchType)
->addClientIdsFilter($filters->clientIds?->toArray())
->addBillable($filters->billable)
->addType($filters->timeEntryType);
if ($goal->member_id === null) {
$timeEntryFilter->addMemberIdsFilter($filters->memberIds?->toArray());
}
/** @var object{tracked_seconds: int|float|string|null} $row */
$row = $timeEntryFilter->get()
->selectRaw('coalesce(round(sum(extract(epoch from (coalesce("end", ?::timestamp) - "start")))), 0) as tracked_seconds', [Carbon::now()])
->toBase()
->first();
return max(0, (int) $row->tracked_seconds);
}
public function getStatus(Goal $goal, int $trackedSeconds): GoalStatus
{
return match ($goal->comparison) {
GoalComparison::AtLeast => $trackedSeconds >= $goal->target_seconds
? GoalStatus::Achieved
: GoalStatus::InProgress,
GoalComparison::LessThan => $trackedSeconds < $goal->target_seconds
? GoalStatus::OnTrack
: GoalStatus::Exceeded,
};
}
}

View File

@@ -1,64 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Enums\GoalType;
use App\Models\Goal;
use App\Models\Member;
use Illuminate\Database\Eloquent\Builder;
/**
* Decides which goals a member can reach. The `goals:*:own` permissions gate the feature; this decides the rows.
* Core only has personal goals, reachable by their member alone (not even admins or owners).
* The team goals extension rebinds this to add organization goals.
*/
class GoalsContract
{
/**
* Whether $actor is allowed to create a goal of the given type for the given member.
*
* @param string|null $targetMemberId Member whose time entries count, null for every member (organization goals only)
*/
public function canCreateGoal(Member $actor, GoalType $type, ?string $targetMemberId): bool
{
return $type === GoalType::Personal && $targetMemberId === $actor->getKey();
}
/**
* Whether $actor is allowed to see the goal (including its progress).
*/
public function canViewGoal(Member $actor, Goal $goal): bool
{
return $goal->isPersonalGoalOf($actor);
}
/**
* Whether $actor is allowed to update the goal (including its filters and archiving it).
*/
public function canUpdateGoal(Member $actor, Goal $goal): bool
{
return $goal->isPersonalGoalOf($actor);
}
/**
* Whether $actor is allowed to delete the goal.
*/
public function canDeleteGoal(Member $actor, Goal $goal): bool
{
return $goal->isPersonalGoalOf($actor);
}
/**
* Restrict the query to the goals that $actor is allowed to see.
*
* @param Builder<Goal> $query
* @return Builder<Goal>
*/
public function scopeVisibleGoals(Builder $query, Member $actor): Builder
{
return $query->where('type', '=', GoalType::Personal->value)
->where('member_id', '=', $actor->getKey());
}
}

View File

@@ -14,7 +14,6 @@ use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException; use App\Exceptions\Api\EntityStillInUseApiException;
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership; use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner; use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
use App\Models\Goal;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
@@ -110,12 +109,6 @@ class MemberService
} }
} }
// A goal is deleted together with the member it is for, it is not part of the "still in use" check
Goal::query()
->whereBelongsTo($organization, 'organization')
->where('member_id', '=', $member->getKey())
->delete();
$member->delete(); $member->delete();
if ($isPlaceholder) { if ($isPlaceholder) {
@@ -188,30 +181,6 @@ class MemberService
->whereBelongsToOrganization($organization) ->whereBelongsToOrganization($organization)
->whereBelongsTo($fromMember, 'member') ->whereBelongsTo($fromMember, 'member')
->delete(); ->delete();
// Goals: the goal belongs to the person it is for, like time entries it moves along
Goal::query()
->whereBelongsTo($organization, 'organization')
->where('member_id', '=', $fromMember->getKey())
->update([
'member_id' => $toMember->getKey(),
]);
// Goal member filters: the time entries of $fromMember now belong to $toMember, so the filters follow them
$goalsWithMemberFilter = Goal::query()
->whereBelongsTo($organization, 'organization')
->whereJsonContains('filters->memberIds', $fromMember->getKey())
->get();
foreach ($goalsWithMemberFilter as $goal) {
$filters = $goal->filters;
$memberIds = $filters->memberIds?->map(
fn (string $memberId): string => $memberId === $fromMember->getKey() ? $toMember->getKey() : $memberId
);
// $toMember may already be in the filter
$filters->setMemberIds($memberIds?->unique()->values()->all());
$goal->filters = $filters;
$goal->save();
}
} }
/** /**

View File

@@ -75,14 +75,6 @@ class PermissionStore
'reports:create', 'reports:create',
'reports:update', 'reports:update',
'reports:delete', 'reports:delete',
'goals:view:own',
'goals:create:own',
'goals:update:own',
'goals:delete:own',
'goals:view:organization-type',
'goals:create:organization-type',
'goals:update:organization-type',
'goals:delete:organization-type',
'invoices:view', 'invoices:view',
'invoices:create', 'invoices:create',
'invoices:update', 'invoices:update',
@@ -154,14 +146,6 @@ class PermissionStore
'reports:create', 'reports:create',
'reports:update', 'reports:update',
'reports:delete', 'reports:delete',
'goals:view:own',
'goals:create:own',
'goals:update:own',
'goals:delete:own',
'goals:view:organization-type',
'goals:create:organization-type',
'goals:update:organization-type',
'goals:delete:organization-type',
'invoices:view', 'invoices:view',
'invoices:create', 'invoices:create',
'invoices:update', 'invoices:update',
@@ -222,14 +206,6 @@ class PermissionStore
'reports:create', 'reports:create',
'reports:update', 'reports:update',
'reports:delete', 'reports:delete',
'goals:view:own',
'goals:create:own',
'goals:update:own',
'goals:delete:own',
'goals:view:organization-type',
'goals:create:organization-type',
'goals:update:organization-type',
'goals:delete:organization-type',
'invoices:view', 'invoices:view',
'invoices:create', 'invoices:create',
'invoices:update', 'invoices:update',
@@ -257,10 +233,6 @@ class PermissionStore
'time-entries:update:own', 'time-entries:update:own',
'time-entries:delete:own', 'time-entries:delete:own',
'organizations:view', 'organizations:view',
'goals:view:own',
'goals:create:own',
'goals:update:own',
'goals:delete:own',
], ],
'description' => 'Employees have the ability to read, create, and update their own time entries, they can see the projects that they are members of and the clients they are assigned to.', 'description' => 'Employees have the ability to read, create, and update their own time entries, they can see the projects that they are members of and the clients they are assigned to.',
], ],

View File

@@ -10,9 +10,7 @@ use App\Models\Member;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class TimeEntryFilter class TimeEntryFilter
{ {
@@ -31,26 +29,6 @@ class TimeEntryFilter
$this->builder = $builder; $this->builder = $builder;
} }
/**
* @param array<mixed> $ids
* @return Collection<int, string>
*/
public static function idArrayToCollection(array $ids): Collection
{
$collection = new Collection;
foreach ($ids as $id) {
if (! is_string($id)) {
throw new \InvalidArgumentException('The given ID is not a string');
}
if ($id !== self::NONE_VALUE && ! Str::isUuid($id)) {
throw new \InvalidArgumentException('The given ID is not a valid UUID');
}
$collection->push($id);
}
return $collection;
}
public function addEndFilter(?string $dateTime): self public function addEndFilter(?string $dateTime): self
{ {
if ($dateTime === null) { if ($dateTime === null) {

View File

@@ -35,7 +35,7 @@ return [
'sqlite' => [ 'sqlite' => [
'driver' => 'sqlite', 'driver' => 'sqlite',
'url' => env('DB_URL'), 'url' => env('DB_URL', env('DATABASE_URL')),
'database' => env('DB_DATABASE', database_path('database.sqlite')), 'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '', 'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
@@ -47,7 +47,7 @@ return [
'pgsql' => [ 'pgsql' => [
'driver' => 'pgsql', 'driver' => 'pgsql',
'url' => env('DB_URL'), 'url' => env('DB_URL', env('DATABASE_URL')),
'host' => env('DB_HOST', '127.0.0.1'), 'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'), 'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'forge'), 'database' => env('DB_DATABASE', 'forge'),
@@ -57,12 +57,12 @@ return [
'prefix' => '', 'prefix' => '',
'prefix_indexes' => true, 'prefix_indexes' => true,
'search_path' => 'public', 'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'), 'sslmode' => env('DB_SSL_MODE', 'prefer'),
], ],
'pgsql_test' => [ 'pgsql_test' => [
'driver' => 'pgsql', 'driver' => 'pgsql',
'url' => env('DB_URL'), 'url' => env('DB_URL', env('DATABASE_URL')),
'host' => env('DB_TEST_HOST', '127.0.0.1'), 'host' => env('DB_TEST_HOST', '127.0.0.1'),
'port' => env('DB_TEST_PORT', '5432'), 'port' => env('DB_TEST_PORT', '5432'),
'database' => env('DB_TEST_DATABASE', 'forge'), 'database' => env('DB_TEST_DATABASE', 'forge'),
@@ -72,12 +72,12 @@ return [
'prefix' => '', 'prefix' => '',
'prefix_indexes' => true, 'prefix_indexes' => true,
'search_path' => 'public', 'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'), 'sslmode' => env('DB_SSL_MODE', 'prefer'),
], ],
'sqlsrv' => [ 'sqlsrv' => [
'driver' => 'sqlsrv', 'driver' => 'sqlsrv',
'url' => env('DB_URL'), 'url' => env('DB_URL', env('DATABASE_URL')),
'host' => env('DB_HOST', 'localhost'), 'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'), 'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'), 'database' => env('DB_DATABASE', 'laravel'),

View File

@@ -28,7 +28,30 @@ return [
/* /*
* Description rendered on the home page of the API documentation (`/docs/api`). * Description rendered on the home page of the API documentation (`/docs/api`).
*/ */
'description' => '', 'description' => <<<'MD'
## Getting started
All organization endpoints live under `/v1/organizations/{organization}`, where `{organization}` is the organization's ID. Authenticate with `Authorization: Bearer <token>` and send `Accept: application/json`.
**1. Find yourself.** Call `GET /v1/users/me/memberships`. Each membership contains the **organization ID** (use it as `{organization}` in paths) and your **member ID** in that organization (the membership `id`). Most endpoints filter by member ID, not by user ID.
**2. Scope to your own data.** For owners and admins, `GET /time-entries` and `GET /time-entries/aggregate` return the whole organization's time entries unless you pass `member_id`. When acting for "me", always pass your member ID, both when reading and before changing entries.
**3. Resolve names to IDs.** Look up projects, clients, tags, tasks and members by name with their list endpoints (`GET /projects`, `GET /clients`, `GET /tags`, `GET /tasks`, `GET /members`). Never guess IDs.
**4. Use UTC.** All timestamps are sent and returned in UTC as `Y-m-d\TH:i:s\Z` (example: `2026-10-02T07:30:00Z`). Convert the user's local times and day boundaries to UTC before sending them.
**5. Money is in cents.** Billable rates and costs are integers in cents of the organization's currency (`8000` means 80.00).
## Common tasks
- **Start a timer:** stop the running entry first (find it with `GET /v1/users/me/time-entries/active`, then `PUT` its `end`), then `POST /time-entries` with `start` and `end: null`. Only one entry can run per member.
- **Log past work:** `POST /time-entries` once per block with `member_id`, `start`, `end`, `project_id` and `billable` set to the project's `is_billable` (it is not derived automatically).
- **Fix or stop an entry:** `PUT /time-entries/{timeEntry}` with the new `start` or `end` in UTC.
- **Move entries to another project:** list them with `member_id` and filters, then `PATCH /time-entries` with their `ids` and `changes.project_id`, plus `changes.task_id` set to a task of the new project or `null`.
- **Totals and reports:** `GET /time-entries/aggregate` with `group` (for example `client` or `project`) and `start`/`end`; durations are in `seconds`, amounts in `cost` (cents).
- **Share a report:** `POST /reports` with `is_public: true` and use the returned `shareable_link`.
MD,
], ],
/* /*

View File

@@ -1,161 +0,0 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Enums\GoalComparison;
use App\Enums\GoalPeriod;
use App\Enums\GoalType;
use App\Enums\TimeEntryType;
use App\Enums\Weekday;
use App\Models\Goal;
use App\Models\Member;
use App\Models\Organization;
use App\Service\Dto\GoalFiltersDto;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Goal>
*/
class GoalFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$filters = new GoalFiltersDto;
$filters->timeEntryType = TimeEntryType::Work;
return [
'name' => $this->faker->sentence(3),
'type' => GoalType::Personal,
'comparison' => $this->faker->randomElement(GoalComparison::cases()),
'target_seconds' => $this->faker->numberBetween(1, 40) * 3600,
'period' => $this->faker->randomElement(GoalPeriod::cases()),
'filters' => $filters,
'timezone' => 'UTC',
'week_start' => Weekday::Monday,
'archived_at' => null,
'organization_id' => Organization::factory(),
// The member has to belong to the organization of the goal
'member_id' => fn (array $attributes) => Member::factory()->state([
'organization_id' => $attributes['organization_id'],
]),
];
}
/**
* Goal in the given organization, for a new member of that organization unless forMember() is used.
*/
public function forOrganization(Organization $organization): self
{
return $this->state(fn (array $attributes): array => [
'organization_id' => $organization->getKey(),
'member_id' => Member::factory()->forOrganization($organization),
]);
}
/**
* Personal goal of the given member.
*/
public function forMember(Member $member): self
{
return $this->state(fn (array $attributes): array => [
'type' => GoalType::Personal,
'member_id' => $member->getKey(),
'organization_id' => $member->organization_id,
]);
}
/**
* Organization goal that counts the time of the given member.
*/
public function organizationGoalForMember(Member $member): self
{
return $this->state(fn (array $attributes): array => [
'type' => GoalType::Organization,
'member_id' => $member->getKey(),
'organization_id' => $member->organization_id,
]);
}
/**
* Organization goal that counts the time of every member.
*/
public function forEveryMember(): self
{
return $this->state(fn (array $attributes): array => [
'type' => GoalType::Organization,
'member_id' => null,
]);
}
public function type(GoalType $type): self
{
return $this->state(fn (array $attributes): array => [
'type' => $type,
]);
}
public function archived(): self
{
return $this->state(fn (array $attributes): array => [
'archived_at' => $this->faker->dateTimeBetween('-1 year', 'now'),
]);
}
public function timezone(string $timezone): self
{
return $this->state(fn (array $attributes): array => [
'timezone' => $timezone,
]);
}
public function weekStart(Weekday $weekStart): self
{
return $this->state(fn (array $attributes): array => [
'week_start' => $weekStart,
]);
}
public function atLeast(int $targetSeconds): self
{
return $this->state(fn (array $attributes): array => [
'comparison' => GoalComparison::AtLeast,
'target_seconds' => $targetSeconds,
]);
}
public function lessThan(int $targetSeconds): self
{
return $this->state(fn (array $attributes): array => [
'comparison' => GoalComparison::LessThan,
'target_seconds' => $targetSeconds,
]);
}
public function period(GoalPeriod $period): self
{
return $this->state(fn (array $attributes): array => [
'period' => $period,
]);
}
public function filters(GoalFiltersDto $filters): self
{
return $this->state(fn (array $attributes): array => [
'filters' => $filters,
]);
}
public function randomCreatedAt(): self
{
return $this->state(fn (array $attributes): array => [
'created_at' => $this->faker->dateTimeBetween('-1 year', 'now'),
]);
}
}

View File

@@ -1,56 +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
{
Schema::create('goals', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->string('name');
// personal: a goal a member set for themselves, organization: a goal the organization set (team goals extension)
$table->string('type');
$table->string('comparison');
$table->unsignedBigInteger('target_seconds');
$table->string('period');
$table->jsonb('filters');
// Timezone and week start define the periods of the goal, they are pinned on the goal like on a report
$table->string('timezone');
$table->string('week_start');
$table->dateTime('archived_at')->nullable();
$table->uuid('organization_id');
$table->foreign('organization_id')
->references('id')
->on('organizations')
->restrictOnDelete()
->cascadeOnUpdate();
// Member whose time entries count towards the goal, null means every member of the organization
// (organization goals only). Personal goals always have a member.
$table->uuid('member_id')->nullable();
$table->foreign('member_id')
->references('id')
->on('members')
->restrictOnDelete()
->cascadeOnUpdate();
$table->timestamps();
$table->index(['organization_id', 'member_id']);
$table->index(['organization_id', 'type']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('goals');
}
};

View File

@@ -4,14 +4,11 @@ declare(strict_types=1);
namespace Database\Seeders; namespace Database\Seeders;
use App\Enums\GoalPeriod;
use App\Enums\Role; use App\Enums\Role;
use App\Enums\TimeEntryType;
use App\Events\DatabaseSeederAfterSeed; use App\Events\DatabaseSeederAfterSeed;
use App\Events\DatabaseSeederBeforeDelete; use App\Events\DatabaseSeederBeforeDelete;
use App\Models\Audit; use App\Models\Audit;
use App\Models\Client; use App\Models\Client;
use App\Models\Goal;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\OrganizationInvitation; use App\Models\OrganizationInvitation;
@@ -22,7 +19,6 @@ use App\Models\Tag;
use App\Models\Task; use App\Models\Task;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use App\Models\User; use App\Models\User;
use App\Service\Dto\GoalFiltersDto;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Laravel\Passport\AuthCode; use Laravel\Passport\AuthCode;
@@ -118,28 +114,13 @@ class DatabaseSeeder extends Seeder
Tag::factory()->forOrganization($organizationAcme)->create([ Tag::factory()->forOrganization($organizationAcme)->create([
'name' => 'Code Review', 'name' => 'Code Review',
]); ]);
$tagMeeting = Tag::factory()->forOrganization($organizationAcme)->create([ Tag::factory()->forOrganization($organizationAcme)->create([
'name' => 'Meeting', 'name' => 'Meeting',
]); ]);
Tag::factory()->forOrganization($organizationAcme)->create([ Tag::factory()->forOrganization($organizationAcme)->create([
'name' => 'Research', 'name' => 'Research',
]); ]);
$workFilters = new GoalFiltersDto;
$workFilters->timeEntryType = TimeEntryType::Work;
Goal::factory()->forMember($userAcmeOwnerMember)->atLeast(6 * 3600)->period(GoalPeriod::Day)->filters($workFilters)->create([
'name' => 'Track at least 6 hours per day',
]);
$meetingFilters = new GoalFiltersDto;
$meetingFilters->setTagIds([$tagMeeting->getKey()]);
$meetingFilters->timeEntryType = TimeEntryType::Work;
Goal::factory()->forMember($userAcmeOwnerMember)->lessThan(5 * 3600)->period(GoalPeriod::Week)->filters($meetingFilters)->create([
'name' => 'Less than 5 hours of meetings per week',
]);
Goal::factory()->forMember($userAcmeEmployeeMember)->atLeast(30 * 3600)->period(GoalPeriod::Week)->filters($workFilters)->create([
'name' => 'Track 30 hours per week',
]);
TimeEntry::factory() TimeEntry::factory()
->count(10) ->count(10)
->forMember($userAcmeAdminMember) ->forMember($userAcmeAdminMember)
@@ -238,7 +219,6 @@ class DatabaseSeeder extends Seeder
// Application tables // Application tables
DB::table((new Audit)->getTable())->delete(); DB::table((new Audit)->getTable())->delete();
DB::table((new Report)->getTable())->delete(); DB::table((new Report)->getTable())->delete();
DB::table((new Goal)->getTable())->delete();
DB::table((new TimeEntry)->getTable())->delete(); DB::table((new TimeEntry)->getTable())->delete();
DB::table((new Task)->getTable())->delete(); DB::table((new Task)->getTable())->delete();
DB::table((new Tag)->getTable())->delete(); DB::table((new Tag)->getTable())->delete();

View File

@@ -292,16 +292,6 @@ test.describe('Command Palette', () => {
await expect(page.locator('[role="dialog"]').getByText('Create Tags')).toBeVisible(); await expect(page.locator('[role="dialog"]').getByText('Create Tags')).toBeVisible();
}); });
test('opens create goal modal', async ({ page }) => {
await goToDashboard(page);
await openCommandPalette(page);
await searchInCommandPalette(page, 'Create Goal');
await selectCommand(page, 'Create Goal');
await expect(
page.locator('[role="dialog"]').getByRole('heading', { name: 'Create Goal' })
).toBeVisible();
});
test('opens invite member modal', async ({ page }) => { test('opens invite member modal', async ({ page }) => {
await goToDashboard(page); await goToDashboard(page);
await openCommandPalette(page); await openCommandPalette(page);
@@ -476,9 +466,6 @@ test.describe('Employee Command Palette Restrictions', () => {
employee.page.getByRole('option', { name: 'Invite Member' }) employee.page.getByRole('option', { name: 'Invite Member' })
).not.toBeVisible(); ).not.toBeVisible();
// Employees can set personal goals for themselves
await expect(employee.page.getByRole('option', { name: 'Create Goal' })).toBeVisible();
// Should still see Create Time Entry (employees can create time entries) // Should still see Create Time Entry (employees can create time entries)
await expect( await expect(
employee.page.getByRole('option', { name: 'Create Time Entry' }) employee.page.getByRole('option', { name: 'Create Time Entry' })

View File

@@ -1,688 +0,0 @@
import { expect } from '@playwright/test';
import type { Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
import {
createClientViaApi,
createGoalViaApi,
createProjectViaApi,
createProjectMemberViaApi,
setupTestContext,
createRunningTimeEntryWithStartViaApi,
createTagViaApi,
createTaskViaApi,
createTimeEntryViaApi,
deleteClientViaApi,
deleteProjectViaApi,
deleteTagViaApi,
deleteTaskViaApi,
updateGoalViaApi,
updateOrganizationSettingViaApi,
updateUserProfileViaApi,
} from './utils/api';
import {
selectTimezone,
selectWeekStart,
timezoneField,
weekStartField,
} from './utils/userSettingsFields';
async function goToGoalsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/goals');
await expect(page.getByTestId('goals_view')).toBeVisible();
}
function goalRow(page: Page, goalId: string) {
return page.getByTestId('goal_row_' + goalId);
}
function goalResponse(page: Page, method: 'POST' | 'PUT' | 'DELETE', status: number) {
return page.waitForResponse(
(response) =>
response.url().includes('/goals') &&
response.request().method() === method &&
response.status() === status
);
}
test('test that the goals page shows an empty state and the sidebar link works', async ({
page,
}) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await page.getByRole('link', { name: 'Goals' }).first().click();
await expect(page).toHaveURL(/\/goals$/);
await expect(page.getByTestId('goal_table')).toContainText('No goals found');
await expect(page.getByRole('button', { name: 'Create your first goal' })).toBeVisible();
});
test('test that creating a goal via the modal works and shows zero progress', async ({ page }) => {
const goalName = 'Deep work ' + Math.floor(1 + Math.random() * 10000);
await goToGoalsOverview(page);
await page.getByRole('button', { name: 'Create Goal' }).click();
await page.getByPlaceholder('e.g. Deep work on Project X').fill(goalName);
await page.getByTestId('duration_seconds_input').fill('10h');
await page.getByTestId('duration_seconds_input').press('Tab');
await page.getByRole('combobox', { name: 'Period' }).click();
await page.getByRole('option', { name: 'per day' }).click();
const [response] = await Promise.all([
goalResponse(page, 'POST', 201),
page.getByRole('button', { name: 'Create Goal', exact: true }).last().click(),
]);
const body = await response.json();
expect(body.data.name).toBe(goalName);
expect(body.data.target_seconds).toBe(36000);
expect(body.data.period).toBe('day');
expect(body.data.comparison).toBe('at_least');
expect(body.data.filters.time_entry_type).toBe('work');
expect(body.data.type).toBe('personal');
expect(body.data.is_archived).toBe(false);
const row = goalRow(page, body.data.id);
await expect(row).toBeVisible();
await expect(row.getByTestId('goal_name')).toHaveText(goalName);
await expect(row.getByTestId('goal_target_description')).toHaveText(
'At least 10h 00min per day'
);
await expect(row.getByTestId('goal_tracked_time')).toHaveText('0h 00min');
await expect(row).toContainText('of 10h 00min');
await expect(row).toContainText('In progress');
});
test('test that the create goal modal validates name and target', async ({ page }) => {
await goToGoalsOverview(page);
await page.getByRole('button', { name: 'Create Goal' }).click();
await page.getByRole('button', { name: 'Create Goal', exact: true }).last().click();
await expect(page.getByRole('alert').filter({ hasText: 'name' })).toBeVisible();
await expect(page.getByRole('alert').filter({ hasText: 'target time' })).toBeVisible();
});
test('test that goal progress reflects tracked time entries that match the filters', async ({
page,
ctx,
}) => {
const project = await createProjectViaApi(ctx, { name: 'Goal Project', is_public: true });
const otherProject = await createProjectViaApi(ctx, { name: 'Other Project', is_public: true });
await createTimeEntryViaApi(ctx, { duration: '2h', projectId: project.id });
await createTimeEntryViaApi(ctx, { duration: '1h 30min', projectId: otherProject.id });
await createTimeEntryViaApi(ctx, { duration: '45min' });
const projectGoal = await createGoalViaApi(ctx, {
name: 'Project goal',
target_seconds: 4 * 3600,
period: 'day',
filters: { project_ids: [project.id], time_entry_type: 'work' },
});
const allGoal = await createGoalViaApi(ctx, {
name: 'All time goal',
target_seconds: 4 * 3600,
period: 'day',
});
const lessThanGoal = await createGoalViaApi(ctx, {
name: 'Less than goal',
comparison: 'less_than',
target_seconds: 3600,
period: 'day',
filters: { project_ids: [project.id], time_entry_type: 'work' },
});
expect(projectGoal.progress.tracked_seconds).toBe(2 * 3600);
expect(allGoal.progress.tracked_seconds).toBe(2 * 3600 + 90 * 60 + 45 * 60);
expect(lessThanGoal.progress.status).toBe('exceeded');
await goToGoalsOverview(page);
await expect(goalRow(page, projectGoal.id).getByTestId('goal_tracked_time')).toHaveText(
'2h 00min'
);
await expect(goalRow(page, projectGoal.id)).toContainText('Goal Project');
await expect(goalRow(page, projectGoal.id)).toContainText('In progress');
await expect(goalRow(page, allGoal.id).getByTestId('goal_tracked_time')).toHaveText('4h 15min');
await expect(goalRow(page, allGoal.id)).toContainText('Achieved');
await expect(goalRow(page, allGoal.id)).toContainText('All time entries');
await expect(goalRow(page, lessThanGoal.id)).toContainText('Exceeded');
await expect(goalRow(page, lessThanGoal.id)).toContainText('Less than 1h 00min per day');
});
async function openCreateGoalModal(page: Page, name: string) {
await goToGoalsOverview(page);
await page.getByRole('button', { name: 'Create Goal' }).click();
await page.getByPlaceholder('e.g. Deep work on Project X').fill(name);
await page.getByTestId('duration_seconds_input').fill('10h');
await page.getByTestId('duration_seconds_input').press('Tab');
}
async function submitCreateGoalModal(page: Page) {
const [response] = await Promise.all([
goalResponse(page, 'POST', 201),
page.getByRole('button', { name: 'Create Goal', exact: true }).last().click(),
]);
return (await response.json()).data;
}
async function selectGoalFilterOption(
page: Page,
filter: string,
searchPlaceholder: string,
option: string
) {
await page.getByTestId('goal_filter_' + filter).click();
await page.getByRole('option', { name: option }).click();
await page.getByPlaceholder(searchPlaceholder).press('Escape');
}
test('test that the task filter of a goal only counts entries of the selected tasks', async ({
page,
ctx,
}) => {
const project = await createProjectViaApi(ctx, {
name: 'Task filter project',
is_public: true,
});
const task = await createTaskViaApi(ctx, { name: 'Counted task', project_id: project.id });
const otherTask = await createTaskViaApi(ctx, { name: 'Other task', project_id: project.id });
await createTimeEntryViaApi(ctx, { duration: '1h', projectId: project.id, taskId: task.id });
await createTimeEntryViaApi(ctx, {
duration: '2h',
projectId: project.id,
taskId: otherTask.id,
});
await createTimeEntryViaApi(ctx, { duration: '30min', projectId: project.id });
await openCreateGoalModal(page, 'Task goal');
await selectGoalFilterOption(page, 'task_ids', 'Search for a Task...', 'Counted task');
await expect(page.getByTestId('goal_filter_task_ids')).toHaveText(/Tasks\s*1/);
const goal = await submitCreateGoalModal(page);
expect(goal.filters.task_ids).toEqual([task.id]);
expect(goal.progress.tracked_seconds).toBe(3600);
await expect(goalRow(page, goal.id).getByTestId('goal_tracked_time')).toHaveText('1h 00min');
});
test('test that the client filter of a goal only counts entries of projects of the selected clients', async ({
page,
ctx,
}) => {
const client = await createClientViaApi(ctx, { name: 'Counted client' });
const otherClient = await createClientViaApi(ctx, { name: 'Other client' });
const project = await createProjectViaApi(ctx, {
name: 'Client project',
client_id: client.id,
is_public: true,
});
const otherProject = await createProjectViaApi(ctx, {
name: 'Other client project',
client_id: otherClient.id,
is_public: true,
});
await createTimeEntryViaApi(ctx, { duration: '1h', projectId: project.id });
await createTimeEntryViaApi(ctx, { duration: '2h', projectId: otherProject.id });
await createTimeEntryViaApi(ctx, { duration: '30min' });
await openCreateGoalModal(page, 'Client goal');
await selectGoalFilterOption(page, 'client_ids', 'Search for a Client...', 'Counted client');
await expect(page.getByTestId('goal_filter_client_ids')).toHaveText(/Clients\s*1/);
const goal = await submitCreateGoalModal(page);
expect(goal.filters.client_ids).toEqual([client.id]);
expect(goal.progress.tracked_seconds).toBe(3600);
await expect(goalRow(page, goal.id).getByTestId('goal_tracked_time')).toHaveText('1h 00min');
});
test('test that the tag filter of a goal respects the tag match type', async ({ page, ctx }) => {
const tag = await createTagViaApi(ctx, { name: 'Counted tag' });
const otherTag = await createTagViaApi(ctx, { name: 'Other tag' });
await createTimeEntryViaApi(ctx, { duration: '1h', tags: [tag.id] });
await createTimeEntryViaApi(ctx, { duration: '2h', tags: [otherTag.id] });
await createTimeEntryViaApi(ctx, { duration: '30min' });
await openCreateGoalModal(page, 'Contains tag goal');
await selectGoalFilterOption(page, 'tag_ids', 'Search for a Tag...', 'Counted tag');
await expect(page.getByTestId('goal_filter_tag_ids')).toHaveText(/Tags\s*1/);
const containsGoal = await submitCreateGoalModal(page);
expect(containsGoal.filters.tag_ids).toEqual([tag.id]);
expect(containsGoal.filters.tag_match_type).toBe('contains');
expect(containsGoal.progress.tracked_seconds).toBe(3600);
await openCreateGoalModal(page, 'Not contains tag goal');
await page.getByTestId('goal_filter_tag_ids').click();
await page.getByRole('radio', { name: 'Does Not Contain' }).click();
await page.getByRole('option', { name: 'Counted tag' }).click();
await page.getByPlaceholder('Search for a Tag...').press('Escape');
const notContainsGoal = await submitCreateGoalModal(page);
expect(notContainsGoal.filters.tag_ids).toEqual([tag.id]);
expect(notContainsGoal.filters.tag_match_type).toBe('not_contains');
// Entries with other tags and entries without tags count
expect(notContainsGoal.progress.tracked_seconds).toBe(2 * 3600 + 30 * 60);
await expect(goalRow(page, containsGoal.id).getByTestId('goal_tracked_time')).toHaveText(
'1h 00min'
);
await expect(goalRow(page, notContainsGoal.id).getByTestId('goal_tracked_time')).toHaveText(
'2h 30min'
);
});
test('test that the billable filter of a goal only counts entries with the selected billable status', async ({
page,
ctx,
}) => {
await createTimeEntryViaApi(ctx, { duration: '1h', billable: true });
await createTimeEntryViaApi(ctx, { duration: '2h', billable: false });
await openCreateGoalModal(page, 'Billable goal');
await page.getByRole('combobox').filter({ hasText: 'Billable' }).click();
await page.getByRole('option', { name: 'Billable', exact: true }).click();
const billableGoal = await submitCreateGoalModal(page);
expect(billableGoal.filters.billable).toBe(true);
expect(billableGoal.progress.tracked_seconds).toBe(3600);
await openCreateGoalModal(page, 'Non billable goal');
await page.getByRole('combobox').filter({ hasText: 'Billable' }).click();
await page.getByRole('option', { name: 'Non Billable' }).click();
const nonBillableGoal = await submitCreateGoalModal(page);
expect(nonBillableGoal.filters.billable).toBe(false);
expect(nonBillableGoal.progress.tracked_seconds).toBe(2 * 3600);
await expect(goalRow(page, billableGoal.id).getByTestId('goal_tracked_time')).toHaveText(
'1h 00min'
);
await expect(goalRow(page, nonBillableGoal.id).getByTestId('goal_tracked_time')).toHaveText(
'2h 00min'
);
});
test('test that the type filter of a goal only counts entries of the selected type', async ({
page,
ctx,
}) => {
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
await createTimeEntryViaApi(ctx, { duration: '1h' });
await createTimeEntryViaApi(ctx, { duration: '30min', type: 'break' });
await openCreateGoalModal(page, 'Break goal');
await page.getByRole('combobox').filter({ hasText: 'Work time' }).click();
await page.getByRole('option', { name: 'Breaks' }).click();
const breakGoal = await submitCreateGoalModal(page);
expect(breakGoal.filters.time_entry_type).toBe('break');
expect(breakGoal.progress.tracked_seconds).toBe(30 * 60);
await openCreateGoalModal(page, 'Work and break goal');
await page.getByRole('combobox').filter({ hasText: 'Work time' }).click();
await page.getByRole('option', { name: 'Both' }).click();
const bothGoal = await submitCreateGoalModal(page);
expect(bothGoal.filters.time_entry_type).toBeNull();
expect(bothGoal.progress.tracked_seconds).toBe(3600 + 30 * 60);
await expect(goalRow(page, breakGoal.id).getByTestId('goal_tracked_time')).toHaveText(
'0h 30min'
);
await expect(goalRow(page, bothGoal.id).getByTestId('goal_tracked_time')).toHaveText(
'1h 30min'
);
});
test('test that a running time entry counts towards the goal progress', async ({ page, ctx }) => {
const start = new Date(Date.now() - 30 * 60 * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z');
await createRunningTimeEntryWithStartViaApi(ctx, 'Running work', start);
// Close to midnight UTC the entry would start on the previous day, so the day of the goal
// is moved to a timezone (without daylight saving time) where it is late morning instead
const utcHour = new Date().getUTCHours();
const goal = await createGoalViaApi(ctx, {
name: 'Running goal',
target_seconds: 3600,
period: 'day',
timezone: utcHour >= 2 && utcHour < 22 ? 'UTC' : 'Asia/Tokyo',
});
expect(goal.progress.tracked_seconds).toBeGreaterThanOrEqual(30 * 60);
await goToGoalsOverview(page);
// The entry keeps running while the page loads
await expect(goalRow(page, goal.id).getByTestId('goal_tracked_time')).toHaveText(
/^0h 3[0-2]min$/
);
});
test('test that editing a goal via the modal works', async ({ page, ctx }) => {
const goal = await createGoalViaApi(ctx, {
name: 'Old goal name',
target_seconds: 3600,
period: 'week',
});
await goToGoalsOverview(page);
await page.locator("[aria-label='Actions for Goal Old goal name']").click();
await page.locator("[aria-label='Edit Goal Old goal name']").click();
await page.getByPlaceholder('e.g. Deep work on Project X').fill('New goal name');
await page.getByRole('combobox', { name: 'Target type' }).click();
await page.getByRole('option', { name: 'Less than' }).click();
const [response] = await Promise.all([
goalResponse(page, 'PUT', 200),
page.getByRole('button', { name: 'Update Goal' }).click(),
]);
const body = await response.json();
expect(body.data.name).toBe('New goal name');
expect(body.data.comparison).toBe('less_than');
const row = goalRow(page, goal.id);
await expect(row.getByTestId('goal_name')).toHaveText('New goal name');
await expect(row).toContainText('Less than 1h 00min per week');
await expect(row).toContainText('On track');
});
test('test that a goal whose filters point to deleted entities can still be edited', async ({
page,
ctx,
}) => {
const keptProject = await createProjectViaApi(ctx, { name: 'Kept project' });
const deletedProject = await createProjectViaApi(ctx, { name: 'Deleted project' });
const keptTask = await createTaskViaApi(ctx, { name: 'Kept task', project_id: keptProject.id });
const deletedTask = await createTaskViaApi(ctx, {
name: 'Deleted task',
project_id: keptProject.id,
});
const keptTag = await createTagViaApi(ctx, { name: 'Kept tag' });
const deletedTag = await createTagViaApi(ctx, { name: 'Deleted tag' });
const deletedClient = await createClientViaApi(ctx, { name: 'Deleted client' });
const filters = {
project_ids: [keptProject.id, deletedProject.id],
task_ids: [keptTask.id, deletedTask.id],
// "none" is no entity, it has to survive the cleanup
tag_ids: ['none', keptTag.id, deletedTag.id],
// Every client of this filter gets deleted
client_ids: [deletedClient.id],
time_entry_type: 'work' as const,
};
const goal = await createGoalViaApi(ctx, {
name: 'Stale filters',
target_seconds: 3600,
filters,
});
await deleteTaskViaApi(ctx, deletedTask.id);
await deleteProjectViaApi(ctx, deletedProject.id);
await deleteTagViaApi(ctx, deletedTag.id);
await deleteClientViaApi(ctx, deletedClient.id);
// Resending deleted IDs fails; unrelated edits can omit filters to preserve the scope.
const rejected = await updateGoalViaApi(ctx, goal.id, { filters });
expect(rejected.status()).toBe(422);
const errors = (await rejected.json()).errors;
expect(Object.keys(errors).sort()).toEqual([
'filters.client_ids.0',
'filters.project_ids.1',
'filters.tag_ids.2',
'filters.task_ids.1',
]);
await goToGoalsOverview(page);
await page.locator("[aria-label='Actions for Goal Stale filters']").click();
await page.locator("[aria-label='Edit Goal Stale filters']").click();
// Opening the form must preserve the scope, even after lists have loaded.
await expect(page.getByTestId('goal_filter_project_ids')).toHaveText(/Projects\s*2/);
await expect(page.getByTestId('goal_filter_task_ids')).toHaveText(/Tasks\s*2/);
await expect(page.getByTestId('goal_filter_tag_ids')).toHaveText(/Tags\s*3/);
await expect(page.getByTestId('goal_filter_client_ids')).toHaveText(/Clients\s*1/);
await page.getByPlaceholder('e.g. Deep work on Project X').fill('Preserved filters');
await page.getByRole('button', { name: 'Update Goal' }).click();
await expect(page.getByTestId('goal_unavailable_filters')).toContainText(
'This will count all clients'
);
const [preservedResponse] = await Promise.all([
goalResponse(page, 'PUT', 200),
page.getByRole('button', { name: 'Keep existing filters' }).click(),
]);
expect(preservedResponse.request().postDataJSON()).not.toHaveProperty('filters');
expect((await preservedResponse.json()).data.filters).toMatchObject(filters);
await page.locator("[aria-label='Actions for Goal Preserved filters']").click();
await page.locator("[aria-label='Edit Goal Preserved filters']").click();
await page.getByPlaceholder('e.g. Deep work on Project X').fill('Cleaned filters');
await page.getByRole('button', { name: 'Update Goal' }).click();
await expect(page.getByTestId('goal_unavailable_filters')).toBeVisible();
const [response] = await Promise.all([
goalResponse(page, 'PUT', 200),
page.getByRole('button', { name: 'Remove unavailable items' }).click(),
]);
const body = await response.json();
expect(body.data.name).toBe('Cleaned filters');
expect(body.data.filters.project_ids).toEqual([keptProject.id]);
expect(body.data.filters.task_ids).toEqual([keptTask.id]);
expect(body.data.filters.tag_ids).toEqual(['none', keptTag.id]);
// A filter whose entities are all gone is dropped, the goal no longer filters by client
expect(body.data.filters.client_ids).toBeNull();
await expect(goalRow(page, goal.id).getByTestId('goal_name')).toHaveText('Cleaned filters');
});
test('losing access to a private project warns without silently changing a personal goal', async ({
ctx,
employee,
}) => {
test.setTimeout(120 * 1000);
const project = await createProjectViaApi(ctx, {
name: 'Private goal project',
is_public: false,
});
const membership = await createProjectMemberViaApi(ctx, project.id, {
member_id: employee.memberId,
});
const employeeCtx = {
...(await setupTestContext(employee.page)),
orgId: ctx.orgId,
memberId: employee.memberId,
};
const goal = await createGoalViaApi(employeeCtx, {
name: 'Private project goal',
target_seconds: 3600,
filters: { project_ids: [project.id], time_entry_type: 'work' },
});
const removed = await ctx.request.delete(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/project-members/${membership.id}`
);
expect(removed.status()).toBe(204);
// The project still exists, but the employee's selectable list no longer contains it.
const projects = await employeeCtx.request.get(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/projects?archived=all`
);
expect(projects.status()).toBe(200);
expect((await projects.json()).data.map((item: { id: string }) => item.id)).not.toContain(
project.id
);
const page = employee.page;
await goToGoalsOverview(page);
await page.locator("[aria-label='Actions for Goal Private project goal']").click();
await page.locator("[aria-label='Edit Goal Private project goal']").click();
await expect(page.getByTestId('goal_filter_project_ids')).toHaveText(/Projects\s*1/);
await page.getByPlaceholder('e.g. Deep work on Project X').fill('Still scoped');
await page.getByRole('button', { name: 'Update Goal' }).click();
await expect(page.getByTestId('goal_unavailable_filters')).toContainText(
'Unavailable projects: 1'
);
await expect(page.getByTestId('goal_unavailable_filters')).toContainText(
'This will count all projects'
);
await page.getByRole('button', { name: 'Back', exact: true }).click();
await expect(page.getByTestId('goal_filter_project_ids')).toHaveText(/Projects\s*1/);
await page.getByRole('button', { name: 'Update Goal' }).click();
const [response] = await Promise.all([
goalResponse(page, 'PUT', 200),
page.getByRole('button', { name: 'Keep existing filters' }).click(),
]);
expect(response.request().postDataJSON()).not.toHaveProperty('filters');
expect((await response.json()).data.filters.project_ids).toEqual([project.id]);
await expect(goalRow(page, goal.id).getByTestId('goal_name')).toHaveText('Still scoped');
});
test('test that archiving a goal moves it to the archived tab and back', async ({ page, ctx }) => {
const goal = await createGoalViaApi(ctx, {
name: 'Goal to archive',
target_seconds: 3600,
});
await goToGoalsOverview(page);
await expect(goalRow(page, goal.id)).toBeVisible();
await page.locator("[aria-label='Actions for Goal Goal to archive']").click();
await Promise.all([
goalResponse(page, 'PUT', 200),
page.locator("[aria-label='Archive Goal Goal to archive']").click(),
]);
await expect(goalRow(page, goal.id)).not.toBeVisible();
await expect(page.getByTestId('goal_table')).toContainText('No goals found');
await page.getByRole('tab', { name: 'Archived' }).click();
const archivedRow = goalRow(page, goal.id);
await expect(archivedRow).toBeVisible();
await expect(archivedRow.getByTestId('goal_archived_badge')).toBeVisible();
await page.locator("[aria-label='Actions for Goal Goal to archive']").click();
await Promise.all([
goalResponse(page, 'PUT', 200),
page.locator("[aria-label='Unarchive Goal Goal to archive']").click(),
]);
await expect(goalRow(page, goal.id)).not.toBeVisible();
await page.getByRole('tab', { name: 'Active' }).click();
await expect(goalRow(page, goal.id)).toBeVisible();
});
test('test that the period settings of a goal can be changed in the accordion', async ({
page,
ctx,
}) => {
const goal = await createGoalViaApi(ctx, {
name: 'Goal with timezone',
target_seconds: 3600,
period: 'week',
timezone: 'Asia/Tokyo',
week_start: 'friday',
});
await goToGoalsOverview(page);
await page.locator("[aria-label='Actions for Goal Goal with timezone']").click();
await page.locator("[aria-label='Edit Goal Goal with timezone']").click();
await page.getByTestId('goal_period_settings').click();
const dialog = page.getByRole('dialog');
await expect(timezoneField(dialog)).toHaveText('Asia/Tokyo');
await expect(weekStartField(dialog)).toHaveText('Friday');
await selectTimezone(page, dialog, 'America/New_York', 'new york');
await selectWeekStart(page, dialog, 'Sunday');
const [response] = await Promise.all([
goalResponse(page, 'PUT', 200),
page.getByRole('button', { name: 'Update Goal' }).click(),
]);
const body = await response.json();
expect(body.data.id).toBe(goal.id);
expect(body.data.timezone).toBe('America/New_York');
expect(body.data.week_start).toBe('sunday');
});
test('test that the period settings of a new goal default to the user settings', async ({
page,
ctx,
}) => {
// Same UTC offset as the browser, so the timezone mismatch modal stays closed
await updateUserProfileViaApi(ctx, { timezone: 'Atlantic/Reykjavik', week_start: 'wednesday' });
await goToGoalsOverview(page);
await page.getByRole('button', { name: 'Create Goal' }).click();
await page.getByPlaceholder('e.g. Deep work on Project X').fill('Goal with defaults');
await page.getByTestId('duration_seconds_input').fill('10h');
await page.getByTestId('duration_seconds_input').press('Tab');
await page.getByTestId('goal_period_settings').click();
await expect(timezoneField(page.getByRole('dialog'))).toHaveText('Atlantic/Reykjavik');
await expect(weekStartField(page.getByRole('dialog'))).toHaveText('Wednesday');
const [response] = await Promise.all([
goalResponse(page, 'POST', 201),
page.getByRole('button', { name: 'Create Goal', exact: true }).last().click(),
]);
const body = await response.json();
expect(body.data.timezone).toBe('Atlantic/Reykjavik');
expect(body.data.week_start).toBe('wednesday');
});
test('test that the period settings of a new goal can be set in the create modal', async ({
page,
}) => {
await goToGoalsOverview(page);
await page.getByRole('button', { name: 'Create Goal' }).click();
await page.getByPlaceholder('e.g. Deep work on Project X').fill('Goal in New York');
await page.getByTestId('duration_seconds_input').fill('10h');
await page.getByTestId('duration_seconds_input').press('Tab');
await page.getByTestId('goal_period_settings').click();
const dialog = page.getByRole('dialog');
await selectTimezone(page, dialog, 'America/New_York', 'new york');
await selectWeekStart(page, dialog, 'Saturday');
const [response] = await Promise.all([
goalResponse(page, 'POST', 201),
page.getByRole('button', { name: 'Create Goal', exact: true }).last().click(),
]);
const body = await response.json();
expect(body.data.name).toBe('Goal in New York');
expect(body.data.timezone).toBe('America/New_York');
expect(body.data.week_start).toBe('saturday');
});
test('test that the search of a goal filter can be used with the keyboard', async ({
page,
ctx,
}) => {
await createProjectViaApi(ctx, { name: 'Alpha keyboard project', is_public: true });
await createProjectViaApi(ctx, { name: 'Beta keyboard project', is_public: true });
await goToGoalsOverview(page);
await page.getByRole('button', { name: 'Create Goal' }).click();
await page.getByTestId('goal_filter_project_ids').click();
const search = page.getByPlaceholder('Search for a Project...');
await search.click();
await search.pressSequentially('alpha');
await expect(search).toHaveValue('alpha');
await expect(page.getByRole('option', { name: 'Beta keyboard project' })).toBeHidden();
// The arrow keys highlight an option while the focus stays in the search
const alpha = page.getByRole('option', { name: 'Alpha keyboard project' });
await search.press('ArrowDown');
await expect(alpha).toHaveAttribute('data-highlighted', '');
await search.press('Enter');
await expect(search).toBeFocused();
await expect(page.getByTestId('goal_filter_project_ids')).toHaveText(/Projects\s*1/);
// Escape closes the dropdown but keeps the modal and the selection
await search.press('Escape');
await expect(search).toBeHidden();
await expect(page.getByPlaceholder('e.g. Deep work on Project X')).toBeVisible();
await expect(page.getByTestId('goal_filter_project_ids')).toHaveText(/Projects\s*1/);
});
test('test that deleting a goal via the more options dropdown works', async ({ page, ctx }) => {
const goal = await createGoalViaApi(ctx, {
name: 'Goal to delete',
target_seconds: 3600,
});
await goToGoalsOverview(page);
await expect(goalRow(page, goal.id)).toBeVisible();
await page.locator("[aria-label='Actions for Goal Goal to delete']").click();
await Promise.all([
goalResponse(page, 'DELETE', 204),
page.locator("[aria-label='Delete Goal Goal to delete']").click(),
]);
await expect(goalRow(page, goal.id)).not.toBeVisible();
await expect(page.getByTestId('goal_table')).toContainText('No goals found');
});
test.describe('employee', () => {
test('employee can create and see their own goals', async ({ employee }) => {
await goToGoalsOverview(employee.page);
await employee.page.getByRole('button', { name: 'Create Goal' }).click();
await employee.page.getByPlaceholder('e.g. Deep work on Project X').fill('Employee goal');
await employee.page.getByTestId('duration_seconds_input').fill('2h');
await employee.page.getByTestId('duration_seconds_input').press('Tab');
const [response] = await Promise.all([
goalResponse(employee.page, 'POST', 201),
employee.page.getByRole('button', { name: 'Create Goal', exact: true }).last().click(),
]);
const body = await response.json();
expect(body.data.type).toBe('personal');
expect(body.data.member_id).toBe(employee.memberId);
await expect(goalRow(employee.page, body.data.id)).toContainText('Employee goal');
});
});

View File

@@ -7,12 +7,6 @@ import {
} from './utils/mailpit'; } from './utils/mailpit';
import { getCurrentUserViaApi } from './utils/api'; import { getCurrentUserViaApi } from './utils/api';
import { registerUser } from './utils/members'; import { registerUser } from './utils/members';
import {
selectTimezone,
selectWeekStart,
timezoneField,
weekStartField,
} from './utils/userSettingsFields';
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
import path from 'path'; import path from 'path';
@@ -46,51 +40,20 @@ test('user name can be updated', async ({ page }) => {
await expect(page.getByLabel('Name', { exact: true })).toHaveValue('NEW NAME'); await expect(page.getByLabel('Name', { exact: true })).toHaveValue('NEW NAME');
}); });
// The tests below pick UTC+0 timezones: the browser runs in UTC, and any other offset
// opens the timezone mismatch modal after the reload.
test('timezone change persists across reload', async ({ page }) => { test('timezone change persists across reload', async ({ page }) => {
await goToProfilePage(page); await goToProfilePage(page);
await selectTimezone(page, page, 'Africa/Sao_Tome', 'sao tome'); await page.getByLabel('Timezone').selectOption('America/New_York');
await saveProfileForm(page); await saveProfileForm(page);
await page.reload(); await page.reload();
await expect(timezoneField(page)).toHaveText('Africa/Sao_Tome'); await expect(page.getByLabel('Timezone')).toHaveValue('America/New_York');
});
test('timezone can be selected with the keyboard', async ({ page }) => {
await goToProfilePage(page);
await expect(timezoneField(page)).toBeEnabled();
await timezoneField(page).focus();
await page.keyboard.press('Enter');
const search = page.getByRole('combobox', { name: 'Search timezones' });
await expect(search).toBeFocused();
await search.fill('Africa/Abid');
await expect(page.getByRole('option')).toHaveText(['Africa/Abidjan']);
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');
await expect(timezoneField(page)).toHaveText('Africa/Abidjan');
await saveProfileForm(page);
await page.reload();
await expect(timezoneField(page)).toHaveText('Africa/Abidjan');
});
test('timezone search shows an empty state when nothing matches', async ({ page }) => {
await goToProfilePage(page);
await timezoneField(page).click();
const search = page.getByRole('combobox', { name: 'Search timezones' });
// Opening starts with an empty search, not one prefilled with the current timezone
await expect(search).toHaveValue('');
await expect(page.getByRole('option').nth(1)).toBeVisible();
await search.fill('Atlantis');
await expect(page.getByText('No timezone found.')).toBeVisible();
await expect(page.getByRole('option')).toHaveCount(0);
}); });
test('week-start change persists across reload', async ({ page }) => { test('week-start change persists across reload', async ({ page }) => {
await goToProfilePage(page); await goToProfilePage(page);
await selectWeekStart(page, page, 'Sunday'); await page.getByLabel('Start of the week').selectOption('sunday');
await saveProfileForm(page); await saveProfileForm(page);
await page.reload(); await page.reload();
await expect(weekStartField(page)).toHaveText('Sunday'); await expect(page.getByLabel('Start of the week')).toHaveValue('sunday');
}); });
test('still-running email notification setting persists across reload', async ({ page }) => { test('still-running email notification setting persists across reload', async ({ page }) => {
@@ -602,10 +565,7 @@ test('test that theme can be changed to dark and light', async ({ page }) => {
await goToProfilePage(page); await goToProfilePage(page);
// The theme select is a Reka UI combobox (button), not a native <select> // The theme select is a Reka UI combobox (button), not a native <select>
const themeSelect = page const themeSelect = page.locator('button[role="combobox"]');
.getByRole('heading', { name: 'Theme', exact: true })
.locator('xpath=ancestor::*[descendant::form][1]')
.getByRole('combobox');
// Change theme to dark // Change theme to dark
await themeSelect.click(); await themeSelect.click();
@@ -631,7 +591,7 @@ test('test that theme can be changed to dark and light', async ({ page }) => {
await expect(page.locator('html')).toHaveClass(/light/); await expect(page.locator('html')).toHaveClass(/light/);
// Reset to system // Reset to system
await themeSelect.click(); await page.locator('button[role="combobox"]').click();
await page.getByRole('option', { name: 'System' }).click(); await page.getByRole('option', { name: 'System' }).click();
await expect(page.getByText('System default:')).toBeVisible(); await expect(page.getByText('System default:')).toBeVisible();
}); });

View File

@@ -990,113 +990,3 @@ export async function getInvoicesViaApi(ctx: TestContext) {
paid_date: string | null; paid_date: string | null;
}>; }>;
} }
export async function createGoalViaApi(
ctx: TestContext,
data: {
name: string;
type?: 'personal' | 'organization';
comparison?: 'at_least' | 'less_than';
target_seconds: number;
period?: 'day' | 'week' | 'month';
// Member the goal is for, null counts every member (organization goals only)
member_id?: string | null;
timezone?: string;
week_start?: string;
filters?: {
member_ids?: string[] | null;
project_ids?: string[] | null;
task_ids?: string[] | null;
tag_ids?: string[] | null;
tag_match_type?: 'contains' | 'not_contains' | null;
client_ids?: string[] | null;
billable?: boolean | null;
time_entry_type?: 'work' | 'break' | null;
};
}
) {
const response = await ctx.request.post(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/goals`,
{
data: {
name: data.name,
type: data.type ?? 'personal',
comparison: data.comparison ?? 'at_least',
target_seconds: data.target_seconds,
period: data.period ?? 'week',
...('member_id' in data ? { member_id: data.member_id } : {}),
...(data.timezone ? { timezone: data.timezone } : {}),
...(data.week_start ? { week_start: data.week_start } : {}),
filters: data.filters ?? { time_entry_type: 'work' },
},
}
);
expect(response.status()).toBe(201);
const body = await response.json();
return body.data as {
id: string;
name: string;
type: 'personal' | 'organization';
comparison: 'at_least' | 'less_than';
target_seconds: number;
period: 'day' | 'week' | 'month';
member_id: string | null;
member_name: string | null;
timezone: string;
week_start: string;
is_archived: boolean;
progress: {
tracked_seconds: number;
status: 'in_progress' | 'achieved' | 'on_track' | 'exceeded';
};
};
}
export async function updateGoalViaApi(
ctx: TestContext,
goalId: string,
data: Record<string, unknown>
) {
return ctx.request.put(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/goals/${goalId}`,
{ data }
);
}
// ──────────────────────────────────────────────────
// Entity deletion
// ──────────────────────────────────────────────────
async function deleteEntityViaApi(ctx: TestContext, path: string) {
const response = await ctx.request.delete(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/${path}`
);
expect(response.status()).toBe(204);
}
export async function deleteProjectViaApi(ctx: TestContext, projectId: string) {
await deleteEntityViaApi(ctx, `projects/${projectId}`);
}
export async function deleteTaskViaApi(ctx: TestContext, taskId: string) {
await deleteEntityViaApi(ctx, `tasks/${taskId}`);
}
export async function deleteTagViaApi(ctx: TestContext, tagId: string) {
await deleteEntityViaApi(ctx, `tags/${tagId}`);
}
export async function deleteClientViaApi(ctx: TestContext, clientId: string) {
await deleteEntityViaApi(ctx, `clients/${clientId}`);
}
export async function deleteMemberViaApi(ctx: TestContext, memberId: string) {
await deleteEntityViaApi(ctx, `members/${memberId}`);
}
export async function makeMemberPlaceholderViaApi(ctx: TestContext, memberId: string) {
const response = await ctx.request.post(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/members/${memberId}/make-placeholder`
);
expect(response.status()).toBe(204);
}

View File

@@ -1,34 +0,0 @@
import { expect } from '@playwright/test';
import type { Locator, Page } from '@playwright/test';
/**
* Helpers for the shared TimezoneCombobox and WeekStartSelect components used in
* the profile form and the goal form.
*/
export function timezoneField(scope: Page | Locator) {
// The trigger is named "Timezone: <current value>"
return scope.getByRole('button', { name: /^Timezone/ });
}
export function weekStartField(scope: Page | Locator) {
return scope.getByRole('combobox', { name: 'Start of the week' });
}
export async function selectTimezone(
page: Page,
scope: Page | Locator,
timezone: string,
search: string = timezone
) {
await timezoneField(scope).click();
await page.getByRole('combobox', { name: 'Search timezones' }).fill(search);
await page.getByRole('option', { name: timezone, exact: true }).click();
await expect(timezoneField(scope)).toHaveText(timezone);
}
export async function selectWeekStart(page: Page, scope: Page | Locator, weekdayLabel: string) {
await weekStartField(scope).click();
await page.getByRole('option', { name: weekdayLabel, exact: true }).click();
await expect(weekStartField(scope)).toHaveText(weekdayLabel);
}

View File

@@ -28,7 +28,6 @@ import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue
import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue'; import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';
import TaskCreateModal from '@/Components/Common/Task/TaskCreateModal.vue'; import TaskCreateModal from '@/Components/Common/Task/TaskCreateModal.vue';
import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue'; import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
import GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue';
import MemberInviteModal from '@/Components/Common/Member/MemberInviteModal.vue'; import MemberInviteModal from '@/Components/Common/Member/MemberInviteModal.vue';
import TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue'; import TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue';
@@ -52,7 +51,6 @@ const {
showCreateClientModal, showCreateClientModal,
showCreateTaskModal, showCreateTaskModal,
showCreateTagModal, showCreateTagModal,
showCreateGoalModal,
showInviteMemberModal, showInviteMemberModal,
showCreateTimeEntryModal, showCreateTimeEntryModal,
showProjectSelector, showProjectSelector,
@@ -183,9 +181,6 @@ const firstProjectId = computed(() => projects.value[0]?.id ?? '');
<!-- Tag Create Modal --> <!-- Tag Create Modal -->
<TagCreateModal v-model:show="showCreateTagModal" :create-tag="createTag" /> <TagCreateModal v-model:show="showCreateTagModal" :create-tag="createTag" />
<!-- Goal Create Modal -->
<GoalCreateModal v-model:show="showCreateGoalModal" />
<!-- Member Invite Modal --> <!-- Member Invite Modal -->
<MemberInviteModal v-model:show="showInviteMemberModal" :available-roles="availableRoles" /> <MemberInviteModal v-model:show="showInviteMemberModal" :available-roles="availableRoles" />

View File

@@ -1,83 +0,0 @@
<script setup lang="ts">
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { ref, watch } from 'vue';
import GoalFormFields from '@/Components/Common/Goal/GoalFormFields.vue';
import {
type GoalFormErrors,
emptyGoalFormData,
goalFormDataToCreateBody,
goalFormErrorsFromApi,
validateGoalFormData,
} from '@/Components/Common/Goal/goalForm';
import { useGoalsStore } from '@/utils/useGoals';
import { useNotificationsStore } from '@/utils/notification';
import { getApiValidationFieldErrors, isApiValidationError } from '@/utils/apiValidation';
const { createGoal } = useGoalsStore();
const { addNotification } = useNotificationsStore();
const show = defineModel<boolean>('show', { default: false });
const saving = ref(false);
const goal = ref(emptyGoalFormData());
const errors = ref<GoalFormErrors>({});
watch(show, (isShown) => {
if (isShown) {
goal.value = emptyGoalFormData();
errors.value = {};
}
});
async function submit() {
errors.value = validateGoalFormData(goal.value);
if (Object.keys(errors.value).length > 0) {
return;
}
saving.value = true;
try {
const created = await createGoal(goalFormDataToCreateBody(goal.value));
if (created) {
show.value = false;
}
} catch (error) {
// Other errors were already shown as a notification by the store
if (isApiValidationError(error)) {
const apiErrors = goalFormErrorsFromApi(getApiValidationFieldErrors(error), goal.value);
errors.value = apiErrors.errors;
if (apiErrors.unmapped.length > 0) {
addNotification('error', 'Failed to create goal', apiErrors.unmapped[0]);
}
}
} finally {
saving.value = false;
}
}
</script>
<template>
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Create Goal </span>
</div>
</template>
<template #content>
<GoalFormFields v-model="goal" :errors="errors" @submit="submit"></GoalFormFields>
</template>
<template #footer>
<SecondaryButton @click="show = false"> Cancel </SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': saving }"
:disabled="saving"
@click="submit">
Create Goal
</PrimaryButton>
</template>
</DialogModal>
</template>
<style scoped></style>

View File

@@ -1,206 +0,0 @@
<script setup lang="ts">
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { computed, ref, watch } from 'vue';
import type { Goal } from '@/packages/api/src';
import GoalFormFields from '@/Components/Common/Goal/GoalFormFields.vue';
import {
type GoalFormErrors,
type FilterIdKey,
type GoalFormData,
getUnavailableGoalFilters,
goalFormDataToUpdateBody,
goalFormErrorsFromApi,
goalToFormData,
type KnownFilterIds,
type UnavailableGoalFilter,
validateGoalFormData,
withoutUnavailableGoalFilters,
} from '@/Components/Common/Goal/goalForm';
import { useGoalsStore } from '@/utils/useGoals';
import { useNotificationsStore } from '@/utils/notification';
import { getApiValidationFieldErrors, isApiValidationError } from '@/utils/apiValidation';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
import { useTasksQuery } from '@/utils/useTasksQuery';
import { useClientsQuery } from '@/utils/useClientsQuery';
import { useTagsQuery } from '@/utils/useTagsQuery';
import { useMembersQuery } from '@/utils/useMembersQuery';
import { canViewMembers } from '@/utils/permissions';
const { updateGoal } = useGoalsStore();
const { addNotification } = useNotificationsStore();
const show = defineModel<boolean>('show', { default: false });
const saving = ref(false);
const props = defineProps<{
goal: Goal;
}>();
const projectsQuery = useProjectsQuery();
const tasksQuery = useTasksQuery();
const clientsQuery = useClientsQuery();
const tagsQuery = useTagsQuery();
const membersQuery = canViewMembers() ? useMembersQuery() : null;
function idSet(entities: { id: string }[] | undefined): Set<string> | null {
return entities === undefined ? null : new Set(entities.map((entity) => entity.id));
}
const filterQueries = {
project_ids: projectsQuery,
task_ids: tasksQuery,
client_ids: clientsQuery,
tag_ids: tagsQuery,
member_ids: membersQuery,
};
const knownFilterIds = computed<KnownFilterIds>(() => ({
project_ids: projectsQuery.isSuccess.value ? idSet(projectsQuery.data.value?.data) : null,
task_ids: tasksQuery.isSuccess.value ? idSet(tasksQuery.data.value?.data) : null,
client_ids: clientsQuery.isSuccess.value ? idSet(clientsQuery.data.value?.data) : null,
tag_ids: tagsQuery.isSuccess.value ? idSet(tagsQuery.data.value?.data) : null,
// Placeholders can not be selected in the member filter either
member_ids: membersQuery?.isSuccess.value
? idSet(membersQuery.data.value?.data.filter((member) => member.is_placeholder === false))
: null,
}));
const form = ref(goalToFormData(props.goal));
const errors = ref<GoalFormErrors>({});
const pendingForm = ref<GoalFormData | null>(null);
const unavailableFilters = ref<UnavailableGoalFilter[]>([]);
const checkingFilters = computed(() =>
(Object.keys(filterQueries) as FilterIdKey[]).some(
(key) => form.value[key].some((id) => id !== 'none') && filterQueries[key]?.isFetching.value
)
);
watch(show, (isShown) => {
pendingForm.value = null;
if (isShown) {
form.value = goalToFormData(props.goal);
errors.value = {};
}
});
async function submit() {
if (saving.value || checkingFilters.value) return;
errors.value = validateGoalFormData(form.value);
if (Object.keys(errors.value).length > 0) {
return;
}
unavailableFilters.value = getUnavailableGoalFilters(form.value, knownFilterIds.value);
if (unavailableFilters.value.length > 0) {
pendingForm.value = { ...form.value };
return;
}
await save(form.value);
}
async function save(data: GoalFormData, keepExistingFilters = false) {
if (saving.value) return;
const body = goalFormDataToUpdateBody(data);
const originalFilters = goalFormDataToUpdateBody(goalToFormData(props.goal)).filters;
// Omitting unchanged filters also lets unrelated edits preserve deleted references.
if (keepExistingFilters || JSON.stringify(body.filters) === JSON.stringify(originalFilters)) {
delete body.filters;
}
saving.value = true;
try {
const updated = await updateGoal(props.goal.id, body);
if (updated) {
show.value = false;
}
} catch (error) {
// Other errors were already shown as a notification by the store
if (isApiValidationError(error)) {
const apiErrors = goalFormErrorsFromApi(getApiValidationFieldErrors(error), form.value);
errors.value = apiErrors.errors;
// Back from the unavailable filters warning to the form that shows the errors
pendingForm.value = null;
if (apiErrors.unmapped.length > 0) {
addNotification('error', 'Failed to update goal', apiErrors.unmapped[0]);
}
}
} finally {
saving.value = false;
}
}
async function keepExistingFilters() {
if (pendingForm.value) await save(pendingForm.value, true);
}
async function removeUnavailableFilters() {
if (pendingForm.value) {
await save(withoutUnavailableGoalFilters(pendingForm.value, unavailableFilters.value));
}
}
</script>
<template>
<DialogModal :closeable="!saving" :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span v-if="pendingForm">Unavailable filter items</span>
<span v-else> Edit Goal {{ props.goal.name }} </span>
</div>
</template>
<template #content>
<div v-if="pendingForm" class="space-y-4" data-testid="goal_unavailable_filters">
<p>
Some selected filter items are unavailable. They may have been deleted or you
may no longer have access. Removing them changes which time entries count toward
this goal.
</p>
<ul class="list-disc pl-5 space-y-2">
<li v-for="filter in unavailableFilters" :key="filter.key">
Unavailable {{ filter.label }}: {{ filter.ids.length }}.
<strong v-if="filter.removesRestriction">
This will count all {{ filter.label }}, subject to the remaining
filters.
</strong>
</li>
</ul>
<p>
Keeping existing filters saves your other edits and discards any filter edits
made in this form.
</p>
</div>
<GoalFormFields
v-else
v-model="form"
:errors="errors"
disable-target
@submit="submit"></GoalFormFields>
</template>
<template #footer>
<template v-if="pendingForm">
<SecondaryButton :disabled="saving" @click="pendingForm = null">
Back
</SecondaryButton>
<SecondaryButton class="ms-3" :disabled="saving" @click="removeUnavailableFilters">
Remove unavailable items
</SecondaryButton>
<PrimaryButton class="ms-3" :disabled="saving" @click="keepExistingFilters">
Keep existing filters
</PrimaryButton>
</template>
<template v-else>
<SecondaryButton :disabled="saving" @click="show = false"> Cancel </SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': saving }"
:disabled="saving || checkingFilters"
@click="submit">
Update Goal
</PrimaryButton>
</template>
</template>
</DialogModal>
</template>
<style scoped></style>

View File

@@ -1,271 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import EstimatedTimeInput from '@/packages/ui/src/Input/EstimatedTimeInput.vue';
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from '@/packages/ui/src/field';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/packages/ui/src';
import { QuestionMarkCircleIcon } from '@heroicons/vue/20/solid';
import GoalTimeEntryFilters from '@/Components/Common/Goal/GoalTimeEntryFilters.vue';
import TimezoneCombobox from '@/Components/Common/TimezoneCombobox.vue';
import WeekStartSelect from '@/Components/Common/WeekStartSelect.vue';
import {
goalComparisonOptions,
goalPeriodOptions,
goalTargetOptions,
type GoalTarget,
} from '@/utils/goals';
import { isGoalsExtensionActivated } from '@/utils/billing';
import { canCreateOrganizationGoals, canViewMembers } from '@/utils/permissions';
import { useMembersQuery } from '@/utils/useMembersQuery';
import { getCurrentMembershipId } from '@/utils/useUser';
import type { GoalFormData, GoalFormErrors } from '@/Components/Common/Goal/goalForm';
import { useFocus } from '@vueuse/core';
const model = defineModel<GoalFormData>({ required: true });
const props = defineProps<{
errors: GoalFormErrors;
// Who a goal is for is decided once, it can not be changed after creation
disableTarget?: boolean;
}>();
const emit = defineEmits<{
submit: [];
}>();
const nameInput = ref<HTMLInputElement | null>(null);
useFocus(nameInput, { initialValue: true });
const goalsExtensionActivated = isGoalsExtensionActivated();
// Goals for another member and goals that count every member are organization goals of the team goals extension
const canChooseOrganizationTarget = goalsExtensionActivated && canCreateOrganizationGoals();
const { members } = useMembersQuery({
enabled: () => goalsExtensionActivated && canViewMembers(),
});
const currentMembershipId = getCurrentMembershipId() ?? null;
const otherMembers = computed(() =>
members.value.filter(
(member) => member.id !== currentMembershipId && member.is_placeholder === false
)
);
// Without the permission to create organization goals there is nothing to choose
const targetOptions = computed(() =>
canChooseOrganizationTarget
? goalTargetOptions
: goalTargetOptions.filter((option) => option.value === 'me')
);
const targetDescriptions: Record<GoalTarget, string> = {
me: 'Only you can see this goal.',
other: 'The member and everyone who manages goals can see it.',
organization: 'Only people who manage goals can see it.',
};
// Only a goal that counts every member can be narrowed down to a subset of the members, placeholders can not be selected
const memberFilterOptions = computed(() =>
model.value.target === 'organization'
? members.value
.filter((member) => member.is_placeholder === false)
.map((member) => ({ id: member.id, name: member.name }))
: null
);
// A goal for one member has no member filter, and a goal that counts every member has no single member
watch(
() => model.value.target,
(target) => {
if (target !== 'other') {
model.value.other_member_id = null;
}
if (target !== 'organization') {
model.value.member_ids = [];
}
}
);
const errors = computed(() => props.errors);
// The settings are collapsed by default, open them when one of their fields has an error
const openSettings = ref<string | undefined>(undefined);
watch(errors, (newErrors) => {
if (newErrors.timezone || newErrors.week_start) {
openSettings.value = 'period-settings';
}
});
</script>
<template>
<FieldGroup>
<Field>
<FieldLabel for="goalName">Goal name</FieldLabel>
<TextInput
id="goalName"
ref="nameInput"
v-model="model.name"
type="text"
placeholder="e.g. Deep work on Project X"
class="block w-full"
required
@keydown.enter="emit('submit')" />
<FieldError v-if="errors.name">{{ errors.name }}</FieldError>
</Field>
<Field v-if="canChooseOrganizationTarget">
<FieldLabel for="goalFor">This goal is for</FieldLabel>
<div class="flex flex-col gap-2 sm:flex-row sm:items-start">
<Select v-model="model.target" :disabled="props.disableTarget">
<SelectTrigger
id="goalFor"
class="w-full sm:w-1/2"
aria-label="This goal is for">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in targetOptions"
:key="option.value"
:value="option.value"
>{{ option.label }}</SelectItem
>
</SelectContent>
</Select>
<div v-if="model.target === 'other'" class="flex flex-col gap-1.5 sm:w-1/2">
<Select v-model="model.other_member_id" :disabled="props.disableTarget">
<SelectTrigger id="goalMember" class="w-full" aria-label="Member">
<SelectValue placeholder="Choose a member" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="member in otherMembers"
:key="member.id"
:value="member.id"
>{{ member.name }}</SelectItem
>
</SelectContent>
</Select>
<FieldError v-if="errors.other_member_id">{{
errors.other_member_id
}}</FieldError>
</div>
</div>
<FieldDescription>{{ targetDescriptions[model.target] }}</FieldDescription>
</Field>
<FieldGroup class="gap-4 sm:flex-row sm:items-start">
<Field class="sm:w-1/3">
<FieldLabel for="goalComparison">Target type</FieldLabel>
<Select v-model="model.comparison">
<SelectTrigger id="goalComparison" class="w-full" aria-label="Target type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in goalComparisonOptions"
:key="option.value"
:value="option.value"
>{{ option.label }}</SelectItem
>
</SelectContent>
</Select>
<FieldError v-if="errors.comparison">{{ errors.comparison }}</FieldError>
</Field>
<Field class="sm:w-1/3">
<div class="flex items-center gap-1">
<FieldLabel for="goalTarget">Target time</FieldLabel>
<TooltipProvider :delay-duration="100">
<Tooltip>
<TooltipTrigger as-child>
<button
type="button"
class="text-icon-default hover:text-text-primary cursor-default rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="How to enter the target time">
<QuestionMarkCircleIcon class="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent class="max-w-64">
You can type natural language for the target time like
<span class="font-semibold">2h 30m</span> or
<span class="font-semibold">1.5</span> hours.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<EstimatedTimeInput
id="goalTarget"
v-model="model.target_seconds"
@submit="emit('submit')"></EstimatedTimeInput>
<FieldError v-if="errors.target_seconds">{{ errors.target_seconds }}</FieldError>
</Field>
<Field class="sm:w-1/3">
<FieldLabel for="goalPeriod">Period</FieldLabel>
<Select v-model="model.period">
<SelectTrigger id="goalPeriod" class="w-full" aria-label="Period">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in goalPeriodOptions"
:key="option.value"
:value="option.value"
>{{ option.label }}</SelectItem
>
</SelectContent>
</Select>
<FieldError v-if="errors.period">{{ errors.period }}</FieldError>
</Field>
</FieldGroup>
<Field>
<FieldLabel>Only count time entries with</FieldLabel>
<GoalTimeEntryFilters v-model="model" :member-options="memberFilterOptions" />
<FieldError v-if="errors.filters">{{ errors.filters }}</FieldError>
<FieldDescription>
Values within a filter use "or", filters use "and". Without filters every entry
counts.
</FieldDescription>
</Field>
<Accordion v-model="openSettings" type="single" collapsible>
<AccordionItem value="period-settings">
<AccordionTrigger data-testid="goal_period_settings">
Additional Settings
</AccordionTrigger>
<AccordionContent>
<FieldGroup class="sm:flex-row sm:items-start pt-2">
<Field class="sm:w-1/2">
<FieldLabel for="goalTimezone">Timezone</FieldLabel>
<TimezoneCombobox id="goalTimezone" v-model="model.timezone" />
<FieldError v-if="errors.timezone">{{ errors.timezone }}</FieldError>
</Field>
<Field class="sm:w-1/2">
<FieldLabel for="goalWeekStart">Start of the week</FieldLabel>
<WeekStartSelect id="goalWeekStart" v-model="model.week_start" />
<FieldError v-if="errors.week_start">{{
errors.week_start
}}</FieldError>
</Field>
</FieldGroup>
<FieldDescription class="pt-2">
Timezone and start of the week decide when a period of this goal begins and
ends. They default to your own settings.
</FieldDescription>
</AccordionContent>
</AccordionItem>
</Accordion>
</FieldGroup>
</template>
<style scoped></style>

View File

@@ -1,77 +0,0 @@
<script setup lang="ts">
import { ArchiveBoxIcon, TrashIcon, PencilSquareIcon } from '@heroicons/vue/20/solid';
import type { Goal } from '@/packages/api/src';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/packages/ui/src';
const emit = defineEmits<{
edit: [];
archive: [];
delete: [];
}>();
const props = defineProps<{
goal: Goal;
canEdit: boolean;
canDelete: boolean;
}>();
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button
class="focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-2 focus-visible:ring-ring focus-visible:opacity-100 hover:bg-card-background group-hover:opacity-100 opacity-20 transition-opacity text-text-secondary"
:aria-label="'Actions for Goal ' + props.goal.name">
<svg
class="h-8 w-8 p-1 rounded-full"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" />
</svg>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent class="min-w-[150px]" align="end">
<DropdownMenuItem
v-if="props.canEdit"
:aria-label="'Edit Goal ' + props.goal.name"
data-testid="goal_edit"
class="flex items-center space-x-3 cursor-pointer"
@click="emit('edit')">
<PencilSquareIcon class="w-5 text-icon-active" />
<span>Edit</span>
</DropdownMenuItem>
<DropdownMenuItem
v-if="props.canEdit"
:aria-label="
(props.goal.is_archived ? 'Unarchive' : 'Archive') + ' Goal ' + props.goal.name
"
data-testid="goal_archive"
class="flex items-center space-x-3 cursor-pointer"
@click="emit('archive')">
<ArchiveBoxIcon class="w-5 text-icon-active" />
<span>{{ props.goal.is_archived ? 'Unarchive' : 'Archive' }}</span>
</DropdownMenuItem>
<DropdownMenuItem
v-if="props.canDelete"
:aria-label="'Delete Goal ' + props.goal.name"
data-testid="goal_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>
</DropdownMenu>
</template>
<style scoped></style>

View File

@@ -1,59 +0,0 @@
<script setup lang="ts">
import { computed, inject, type ComputedRef } from 'vue';
import type { Goal, Organization } from '@/packages/api/src';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
const props = defineProps<{
goal: Goal;
hideTarget?: boolean;
}>();
const organization = inject<ComputedRef<Organization>>('organization');
// Progress is computed on the server, it is refreshed by the goals query while the page is open
const percentage = computed(() => {
if (props.goal.target_seconds <= 0) {
return 0;
}
return Math.min(100, (props.goal.progress.tracked_seconds / props.goal.target_seconds) * 100);
});
const barClass = computed(() => {
if (props.goal.progress.status === 'exceeded') {
return 'bg-red-500';
}
if (props.goal.progress.status === 'achieved') {
return 'bg-green-500';
}
return 'bg-accent-200';
});
function format(seconds: number) {
return formatHumanReadableDuration(
seconds,
organization?.value?.interval_format,
organization?.value?.number_format
);
}
</script>
<template>
<div class="w-full min-w-0" data-testid="goal_progress">
<div class="bg-tertiary h-1 rounded relative overflow-hidden w-full">
<div
class="h-full transition-all duration-500"
:class="barClass"
:style="{ width: percentage + '%' }"></div>
</div>
<div class="text-xs pt-1.5 flex items-center justify-between space-x-2">
<span class="text-text-primary tabular-nums" data-testid="goal_tracked_time">{{
format(goal.progress.tracked_seconds)
}}</span>
<span v-if="!hideTarget" class="text-text-secondary">
of {{ format(goal.target_seconds) }}
</span>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -1,150 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { Goal } from '@/packages/api/src';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
import { useTasksQuery } from '@/utils/useTasksQuery';
import { useClientsQuery } from '@/utils/useClientsQuery';
import { useTagsQuery } from '@/utils/useTagsQuery';
import { useMembersQuery } from '@/utils/useMembersQuery';
import { canViewMembers } from '@/utils/permissions';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/packages/ui/src';
import { FolderIcon } from '@heroicons/vue/16/solid';
import { CheckCircleIcon, TagIcon, UserCircleIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { Coffee } from '@lucide/vue';
const props = defineProps<{
goal: Goal;
}>();
const { projects } = useProjectsQuery();
const { tasks } = useTasksQuery();
const { clients } = useClientsQuery();
const { tags } = useTagsQuery();
// Only goals that count every member can be narrowed down to a subset of the members
const { members } = useMembersQuery({ enabled: canViewMembers });
function names(
ids: string[] | null,
items: { id: string; name: string }[],
noneLabel: string
): { id: string; name: string }[] {
if (ids === null) {
return [];
}
return ids.map((id) => {
if (id === 'none') {
return { id, name: noneLabel };
}
return { id, name: items.find((item) => item.id === id)?.name ?? 'Unknown' };
});
}
const badges = computed(() => {
const filters = props.goal.filters;
const notPrefix = filters.tag_match_type === 'not_contains' ? 'Not ' : '';
// Fixed display order: members, projects, tasks, clients, tags
const sources = [
{
kind: 'member',
ids: filters.member_ids,
items: members.value,
none: 'Unknown member',
icon: UserGroupIcon,
prefix: '',
},
{
kind: 'project',
ids: filters.project_ids,
items: projects.value,
none: 'No project',
icon: FolderIcon,
prefix: '',
},
{
kind: 'task',
ids: filters.task_ids,
items: tasks.value,
none: 'No task',
icon: CheckCircleIcon,
prefix: '',
},
{
kind: 'client',
ids: filters.client_ids,
items: clients.value,
none: 'No client',
icon: UserCircleIcon,
prefix: '',
},
{
kind: 'tag',
ids: filters.tag_ids,
items: tags.value,
none: 'No tag',
icon: TagIcon,
prefix: notPrefix,
},
];
const result: { key: string; icon: unknown; label: string }[] = sources.flatMap((source) =>
names(source.ids, source.items, source.none).map((entry) => ({
key: source.kind + '-' + entry.id,
icon: source.icon,
label: source.prefix + entry.name,
}))
);
if (filters.billable !== null) {
result.push({
key: 'billable',
icon: BillableIcon,
label: filters.billable ? 'Billable' : 'Non billable',
});
}
if (filters.time_entry_type === 'break') {
result.push({ key: 'type', icon: Coffee, label: 'Breaks' });
}
return result;
});
// The list keeps every row on a single line, the first badge is shown and all of them are listed on hover
const firstBadge = computed(() => badges.value[0] ?? null);
const hiddenBadges = computed(() => badges.value.slice(1));
</script>
<template>
<div class="flex items-center min-w-0 text-sm" data-testid="goal_scope">
<span v-if="firstBadge === null" class="text-text-secondary whitespace-nowrap"
>All time entries</span
>
<TooltipProvider v-else :delay-duration="100">
<Tooltip :disabled="hiddenBadges.length === 0">
<TooltipTrigger as-child>
<span class="flex items-center gap-1.5 max-w-full min-w-0 cursor-default">
<component
:is="firstBadge.icon"
class="w-3.5 h-3.5 text-icon-default shrink-0"></component>
<span class="truncate">{{ firstBadge.label }}</span>
<span v-if="hiddenBadges.length > 0" class="text-text-secondary shrink-0"
>+{{ hiddenBadges.length }}</span
>
</span>
</TooltipTrigger>
<TooltipContent class="min-w-48 px-3 py-2">
<div class="flex flex-col gap-1.5">
<span
v-for="badge in badges"
:key="badge.key"
class="flex items-center gap-1.5">
<component
:is="badge.icon"
class="w-3.5 h-3.5 text-icon-default shrink-0"></component>
<span>{{ badge.label }}</span>
</span>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</template>
<style scoped></style>

View File

@@ -1,41 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { CheckCircleIcon, ExclamationCircleIcon, ClockIcon } from '@heroicons/vue/20/solid';
import type { GoalStatus } from '@/packages/api/src';
import { goalStatusIsPositive, goalStatusLabel } from '@/utils/goals';
const props = defineProps<{
status: GoalStatus;
}>();
const icon = computed(() => {
if (props.status === 'exceeded') {
return ExclamationCircleIcon;
}
if (props.status === 'achieved') {
return CheckCircleIcon;
}
return ClockIcon;
});
const colorClass = computed(() => {
if (props.status === 'exceeded') {
return 'text-red-500';
}
if (goalStatusIsPositive(props.status)) {
return 'text-green-500';
}
return 'text-icon-default';
});
</script>
<template>
<span
class="inline-flex items-center space-x-1.5 text-sm text-text-primary"
:data-status="status">
<component :is="icon" class="w-4" :class="colorClass"></component>
<span>{{ goalStatusLabel(status) }}</span>
</span>
</template>
<style scoped></style>

View File

@@ -1,71 +0,0 @@
<script setup lang="ts">
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { Target } from '@lucide/vue';
import { PlusIcon } from '@heroicons/vue/16/solid';
import { ref } from 'vue';
import type { Goal } from '@/packages/api/src';
import GoalTableRow from '@/Components/Common/Goal/GoalTableRow.vue';
import GoalTableHeading from '@/Components/Common/Goal/GoalTableHeading.vue';
import GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue';
import { canCreateGoals } from '@/utils/permissions';
import { LoadingSpinner } from '@/packages/ui/src';
import { isGoalsExtensionActivated } from '@/utils/billing';
const props = defineProps<{
goals: Goal[];
isLoading: boolean;
canCreateMoreGoals: boolean;
showingArchived: boolean;
}>();
const showCreateGoalModal = ref(false);
const showTargetColumn = isGoalsExtensionActivated();
// Every column gets a share of the free space so the columns are spread evenly over the table
const gridTemplateColumns = [
'minmax(180px, 1.25fr)',
...(showTargetColumn ? ['minmax(120px, 0.75fr)'] : []),
'minmax(200px, 1fr)',
'minmax(180px, 1fr)',
'minmax(160px, 1fr)',
'minmax(120px, 0.75fr)',
'80px',
].join(' ');
</script>
<template>
<GoalCreateModal v-model:show="showCreateGoalModal"></GoalCreateModal>
<div class="flow-root max-w-[100vw] overflow-x-auto">
<div class="inline-block min-w-full align-middle">
<div data-testid="goal_table" class="grid min-w-full" :style="{ gridTemplateColumns }">
<GoalTableHeading :show-target-column="showTargetColumn"></GoalTableHeading>
<div
v-if="props.isLoading"
class="col-span-full flex justify-center items-center py-24">
<LoadingSpinner></LoadingSpinner>
</div>
<div v-else-if="props.goals.length === 0" class="col-span-full py-24 text-center">
<Target class="w-8 h-8 text-icon-default inline pb-2"></Target>
<h3 class="text-text-primary font-semibold">
{{ props.showingArchived ? 'No archived goals found' : 'No goals found' }}
</h3>
<p v-if="canCreateGoals() && !props.showingArchived" class="pb-5">
Set yourself a target and track your progress every day, week or month.
</p>
<SecondaryButton
v-if="
canCreateGoals() && !props.showingArchived && props.canCreateMoreGoals
"
:icon="PlusIcon"
@click="showCreateGoalModal = true"
>Create your first goal
</SecondaryButton>
</div>
<template v-for="goal in props.goals" :key="goal.id">
<GoalTableRow
:goal="goal"
:show-target-column="showTargetColumn"></GoalTableRow>
</template>
</div>
</div>
</div>
</template>

View File

@@ -1,26 +0,0 @@
<script setup lang="ts">
import TableHeading from '@/Components/Common/TableHeading.vue';
defineProps<{
showTargetColumn: boolean;
}>();
</script>
<template>
<TableHeading>
<div
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Goal
</div>
<div v-if="showTargetColumn" class="px-3 py-1.5 text-left font-semibold text-text-primary">
For
</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Target</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Scope</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Progress</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span>
</div>
</TableHeading>
</template>

View File

@@ -1,145 +0,0 @@
<script setup lang="ts">
import type { Goal, Organization } from '@/packages/api/src';
import { computed, inject, ref, type ComputedRef } from 'vue';
import { ArchiveBoxIcon, PencilSquareIcon, TrashIcon } from '@heroicons/vue/20/solid';
import TableRow from '@/Components/TableRow.vue';
import GoalMoreOptionsDropdown from '@/Components/Common/Goal/GoalMoreOptionsDropdown.vue';
import GoalEditModal from '@/Components/Common/Goal/GoalEditModal.vue';
import GoalProgressBar from '@/Components/Common/Goal/GoalProgressBar.vue';
import GoalScopeBadges from '@/Components/Common/Goal/GoalScopeBadges.vue';
import GoalStatusBadge from '@/Components/Common/Goal/GoalStatusBadge.vue';
import { useGoalsStore } from '@/utils/useGoals';
import { canDeleteGoals, canUpdateGoals } from '@/utils/permissions';
import {
canManageGoal,
goalComparisonLabel,
goalPeriodOptions,
goalTypeLabel,
} from '@/utils/goals';
import { Badge } from '@/packages/ui/src';
import { getCurrentMembershipId } from '@/utils/useUser';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/packages/ui/src';
const props = defineProps<{
goal: Goal;
showTargetColumn: boolean;
}>();
const organization = inject<ComputedRef<Organization>>('organization');
const showEditModal = ref(false);
// A personal goal is managed by its member, an organization goal by the members that may manage goals
const isManager = computed(() => canManageGoal(props.goal));
const canEdit = computed(() => isManager.value && canUpdateGoals());
const canDelete = computed(() => isManager.value && canDeleteGoals());
const targetLabel = computed(() => {
if (props.goal.member_id === null) return 'Every member';
if (props.goal.member_id === getCurrentMembershipId()) return 'You';
return props.goal.member_name ?? 'Member';
});
const typeDescription = computed(() => goalTypeLabel(props.goal));
const targetDescription = computed(() => {
const period = goalPeriodOptions.find((option) => option.value === props.goal.period)?.label;
return (
goalComparisonLabel(props.goal.comparison) +
' ' +
formatHumanReadableDuration(
props.goal.target_seconds,
organization?.value?.interval_format,
organization?.value?.number_format
) +
' ' +
period
);
});
function deleteGoal() {
useGoalsStore().deleteGoal(props.goal.id);
}
function toggleArchived() {
useGoalsStore().setGoalArchived(props.goal.id, !props.goal.is_archived);
}
</script>
<template>
<ContextMenu>
<ContextMenuTrigger as-child>
<TableRow :data-testid="'goal_row_' + goal.id">
<div
class="whitespace-nowrap min-w-0 flex items-center gap-2 py-4 pr-3 text-sm pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<span
class="font-medium text-text-primary overflow-ellipsis overflow-hidden"
data-testid="goal_name"
>{{ goal.name }}</span
>
<Badge v-if="goal.is_archived" size="base" data-testid="goal_archived_badge"
>Archived</Badge
>
</div>
<div
v-if="showTargetColumn"
class="min-w-0 flex items-center px-3 py-4 text-sm text-text-primary"
data-testid="goal_target">
<span class="sr-only">{{ typeDescription }}</span>
<span class="truncate">{{ targetLabel }}</span>
</div>
<div
class="whitespace-nowrap flex items-center px-3 py-4 text-sm text-text-primary"
data-testid="goal_target_description">
{{ targetDescription }}
</div>
<!-- Like the progress in the project table, the cells without vertical padding keep the row as high as the other tables -->
<div class="min-w-0 flex items-center px-3 text-sm text-text-primary">
<GoalScopeBadges :goal="goal"></GoalScopeBadges>
</div>
<div class="whitespace-nowrap flex items-center px-3 text-sm text-text-primary">
<GoalProgressBar :goal="goal" class="max-w-44"></GoalProgressBar>
</div>
<div
class="whitespace-nowrap flex items-center px-3 py-4 text-sm text-text-primary">
<GoalStatusBadge :status="goal.progress.status"></GoalStatusBadge>
</div>
<div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<GoalMoreOptionsDropdown
v-if="canDelete || canEdit"
:goal="goal"
:can-edit="canEdit"
:can-delete="canDelete"
@edit="showEditModal = true"
@archive="toggleArchived"
@delete="deleteGoal"></GoalMoreOptionsDropdown>
</div>
<GoalEditModal v-model:show="showEditModal" :goal="goal"></GoalEditModal>
</TableRow>
</ContextMenuTrigger>
<ContextMenuContent class="min-w-[160px]">
<ContextMenuItem v-if="canEdit" class="space-x-3" @select="showEditModal = true">
<PencilSquareIcon class="w-4 h-4 text-icon-default" />
<span>Edit</span>
</ContextMenuItem>
<ContextMenuItem v-if="canEdit" class="space-x-3" @select="toggleArchived()">
<ArchiveBoxIcon class="w-4 h-4 text-icon-default" />
<span>{{ goal.is_archived ? 'Unarchive' : 'Archive' }}</span>
</ContextMenuItem>
<ContextMenuSeparator v-if="canDelete" />
<ContextMenuItem
v-if="canDelete"
class="space-x-3 text-destructive"
@select="deleteGoal()">
<TrashIcon class="w-4 h-4 text-icon-default" />
<span>Delete</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</template>
<style scoped></style>

View File

@@ -1,243 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { CheckCircleIcon, TagIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
import { FolderIcon, UserCircleIcon } from '@heroicons/vue/16/solid';
import { Check, Coffee } from '@lucide/vue';
import { RadioGroupIndicator, RadioGroupItem, RadioGroupRoot, type AcceptableValue } from 'reka-ui';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/packages/ui/src';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import MultiselectDropdown from '@/packages/ui/src/Input/MultiselectDropdown.vue';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import ReportingFilterBadge from '@/Components/Common/Reporting/ReportingFilterBadge.vue';
import type { GoalFormData } from '@/Components/Common/Goal/goalForm';
import type { TagMatchType } from '@/types/reporting';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
import { useTasksQuery } from '@/utils/useTasksQuery';
import { useClientsQuery } from '@/utils/useClientsQuery';
import { useTagsQuery } from '@/utils/useTagsQuery';
import { useTagsStore } from '@/utils/useTags';
import { useBreaksEnabled } from '@/packages/ui/src/utils/useBreaksEnabled';
type FilterOption = { id: string; name: string };
const model = defineModel<GoalFormData>({ required: true });
defineProps<{
// Members that can be selected in the member filter, the filter is hidden without them
memberOptions?: FilterOption[] | null;
}>();
const breaksEnabled = useBreaksEnabled();
const { projects } = useProjectsQuery();
const { tasks } = useTasksQuery();
const { clients } = useClientsQuery();
const { tags } = useTagsQuery();
// Client and project filters are ANDed, so only offer projects of the selected clients. Projects
// that are already selected stay listed so they can still be unselected.
const visibleProjects = computed(() => {
const clientIds = model.value.client_ids;
if (clientIds.length === 0) return projects.value;
return projects.value.filter(
(project) =>
(project.client_id !== null && clientIds.includes(project.client_id)) ||
model.value.project_ids.includes(project.id)
);
});
// Project and task filters are ANDed, so only offer tasks from the selected projects. Tasks
// that are already selected stay listed so they can still be unselected.
const visibleTasks = computed(() => {
const projectIds = model.value.project_ids;
if (projectIds.length === 0) return tasks.value;
return tasks.value.filter(
(task) => projectIds.includes(task.project_id) || model.value.task_ids.includes(task.id)
);
});
function getKeyFromItem(item: FilterOption) {
return item.id;
}
function getNameForItem(item: FilterOption) {
return item.name;
}
const tagMatchOptions: { value: TagMatchType; label: string }[] = [
{ value: 'contains', label: 'Contains' },
{ value: 'not_contains', label: 'Does Not Contain' },
];
function selectTagMatchType(value: AcceptableValue) {
model.value.tag_match_type = value as TagMatchType;
}
async function createTag(name: string) {
return await useTagsStore().createTag(name);
}
</script>
<template>
<div class="space-y-1.5">
<div class="flex flex-wrap items-center gap-2" data-testid="goal_filters">
<MultiselectDropdown
v-if="memberOptions"
v-model="model.member_ids"
search-placeholder="Search for a Member..."
:items="memberOptions"
:get-key-from-item="getKeyFromItem"
:get-name-for-item="getNameForItem">
<template #trigger>
<ReportingFilterBadge
data-testid="goal_filter_member_ids"
:count="model.member_ids.length"
:active="model.member_ids.length > 0"
title="Members"
:icon="UserGroupIcon" />
</template>
</MultiselectDropdown>
<MultiselectDropdown
v-model="model.project_ids"
search-placeholder="Search for a Project..."
:items="visibleProjects"
:get-key-from-item="getKeyFromItem"
:get-name-for-item="getNameForItem"
no-item-label="No Project">
<template #trigger>
<ReportingFilterBadge
data-testid="goal_filter_project_ids"
:count="model.project_ids.length"
:active="model.project_ids.length > 0"
title="Projects"
:icon="FolderIcon" />
</template>
</MultiselectDropdown>
<MultiselectDropdown
v-model="model.task_ids"
search-placeholder="Search for a Task..."
:items="visibleTasks"
:get-key-from-item="getKeyFromItem"
:get-name-for-item="getNameForItem"
no-item-label="No Task">
<template #trigger>
<ReportingFilterBadge
data-testid="goal_filter_task_ids"
:count="model.task_ids.length"
:active="model.task_ids.length > 0"
title="Tasks"
:icon="CheckCircleIcon" />
</template>
</MultiselectDropdown>
<MultiselectDropdown
v-model="model.client_ids"
search-placeholder="Search for a Client..."
:items="clients"
:get-key-from-item="getKeyFromItem"
:get-name-for-item="getNameForItem"
no-item-label="No Client">
<template #trigger>
<ReportingFilterBadge
data-testid="goal_filter_client_ids"
:count="model.client_ids.length"
:active="model.client_ids.length > 0"
title="Clients"
:icon="UserCircleIcon" />
</template>
</MultiselectDropdown>
<TagDropdown v-model="model.tag_ids" :create-tag :tags="tags">
<template #trigger>
<ReportingFilterBadge
data-testid="goal_filter_tag_ids"
:count="model.tag_ids.length"
:active="model.tag_ids.length > 0"
title="Tags"
:icon="TagIcon" />
</template>
<template #content-before-list>
<div class="mt-2 border-b border-card-background-separator pb-2">
<div
id="goal-tag-match-type-label"
class="mb-1.5 px-2 text-xs font-medium text-text-tertiary uppercase">
Match
</div>
<RadioGroupRoot
:model-value="model.tag_match_type"
aria-labelledby="goal-tag-match-type-label"
class="space-y-1"
@update:model-value="selectTagMatchType">
<RadioGroupItem
v-for="option in tagMatchOptions"
:key="option.value"
:value="option.value"
class="relative flex w-full items-center rounded-md py-1.5 pl-2 pr-8 text-left text-sm font-medium text-text-secondary hover:bg-card-background-active data-[state=checked]:text-text-primary">
{{ option.label }}
<span
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<RadioGroupIndicator>
<Check class="h-4 w-4" />
</RadioGroupIndicator>
</span>
</RadioGroupItem>
</RadioGroupRoot>
</div>
</template>
</TagDropdown>
<Select v-model="model.billable">
<SelectTrigger
size="sm"
variant="outline"
:active="model.billable !== null"
:show-chevron="false">
<SelectValue class="flex items-center gap-2">
<BillableIcon
class="h-4"
:class="
model.billable !== null
? 'dark:text-accent-300/80 text-accent-400/80'
: 'text-text-quaternary'
" />
<span class="text-text-secondary">{{
model.billable === 'false' ? 'Non Billable' : 'Billable'
}}</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">Both</SelectItem>
<SelectItem value="true">Billable</SelectItem>
<SelectItem value="false">Non Billable</SelectItem>
</SelectContent>
</Select>
<Select v-if="breaksEnabled" v-model="model.time_entry_type">
<SelectTrigger
size="sm"
variant="outline"
:active="model.time_entry_type !== null"
:show-chevron="false">
<SelectValue class="flex items-center gap-2">
<Coffee
class="h-4 w-4"
:class="
model.time_entry_type !== null
? 'dark:text-accent-300/80 text-accent-400/80'
: 'text-text-quaternary'
" />
<span class="text-text-secondary">{{
model.time_entry_type === null
? 'Type'
: model.time_entry_type === 'break'
? 'Breaks'
: 'Work time'
}}</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">Both</SelectItem>
<SelectItem value="work">Work time</SelectItem>
<SelectItem value="break">Breaks</SelectItem>
</SelectContent>
</Select>
</div>
<p v-if="memberOptions && model.member_ids.length > 0" class="text-xs text-text-tertiary">
Selected members do not get access to the goal.
</p>
</div>
</template>

View File

@@ -1,254 +0,0 @@
import type {
CreateGoalBody,
Goal,
GoalComparison,
GoalPeriod,
GoalType,
GoalWeekStart,
UpdateGoalBody,
} from '@/packages/api/src';
import type { TagMatchType } from '@/types/reporting';
import type { GoalTarget } from '@/utils/goals';
import { getCurrentMembershipId, getCurrentUser } from '@/utils/useUser';
export interface GoalFormData {
name: string;
// Who the goal is for, this decides the type and the member of the goal
target: GoalTarget;
comparison: GoalComparison;
target_seconds: number | null;
period: GoalPeriod;
// ID of the member the goal is for, only used for the target "other"
other_member_id: string | null;
timezone: string;
week_start: GoalWeekStart;
// Members whose time counts, only used for organization goals, empty = every member
member_ids: string[];
project_ids: string[];
task_ids: string[];
tag_ids: string[];
tag_match_type: TagMatchType;
client_ids: string[];
billable: 'true' | 'false' | null;
time_entry_type: 'work' | 'break' | null;
}
export function emptyGoalFormData(): GoalFormData {
return {
name: '',
target: 'me',
comparison: 'at_least',
target_seconds: null,
period: 'week',
other_member_id: null,
timezone: getCurrentUser().timezone,
week_start: getCurrentUser().week_start as GoalWeekStart,
member_ids: [],
project_ids: [],
task_ids: [],
tag_ids: [],
tag_match_type: 'contains',
client_ids: [],
billable: null,
// Breaks are excluded by default, the user can widen the scope explicitly
time_entry_type: 'work',
};
}
export function goalTarget(goal: Goal): GoalTarget {
if (goal.type === 'personal') {
return 'me';
}
return goal.member_id === null ? 'organization' : 'other';
}
export function goalToFormData(goal: Goal): GoalFormData {
const target = goalTarget(goal);
return {
name: goal.name,
target,
comparison: goal.comparison,
target_seconds: goal.target_seconds,
period: goal.period,
other_member_id: target === 'other' ? goal.member_id : null,
timezone: goal.timezone,
week_start: goal.week_start,
member_ids: goal.filters.member_ids ?? [],
project_ids: goal.filters.project_ids ?? [],
task_ids: goal.filters.task_ids ?? [],
tag_ids: goal.filters.tag_ids ?? [],
tag_match_type: goal.filters.tag_match_type ?? 'contains',
client_ids: goal.filters.client_ids ?? [],
billable: goal.filters.billable === null ? null : goal.filters.billable ? 'true' : 'false',
time_entry_type: goal.filters.time_entry_type,
};
}
export type FilterIdKey = 'member_ids' | 'project_ids' | 'task_ids' | 'tag_ids' | 'client_ids';
// Selectable IDs, or null when the list is unavailable. Missing IDs may still exist.
export type KnownFilterIds = Record<FilterIdKey, Set<string> | null>;
export interface UnavailableGoalFilter {
key: FilterIdKey;
label: string;
ids: string[];
removesRestriction: boolean;
}
const filterLabels: Record<FilterIdKey, string> = {
member_ids: 'members',
project_ids: 'projects',
task_ids: 'tasks',
tag_ids: 'tags',
client_ids: 'clients',
};
export function getUnavailableGoalFilters(
data: GoalFormData,
known: KnownFilterIds
): UnavailableGoalFilter[] {
return (Object.keys(known) as FilterIdKey[]).flatMap((key) => {
const knownIds = known[key];
if (knownIds === null) return [];
const ids = data[key].filter((id) => id !== 'none' && !knownIds.has(id));
return ids.length === 0
? []
: [
{
key,
label: filterLabels[key],
ids,
removesRestriction: ids.length === data[key].length,
},
];
});
}
// Remove only the IDs the user has reviewed and explicitly chosen to remove.
export function withoutUnavailableGoalFilters(
data: GoalFormData,
unavailable: UnavailableGoalFilter[]
): GoalFormData {
const result = { ...data };
for (const { key, ids } of unavailable) {
result[key] = data[key].filter((id) => !ids.includes(id));
}
return result;
}
function idsOrNull(ids: string[]): string[] | null {
return ids.length > 0 ? ids : null;
}
function goalType(target: GoalTarget): GoalType {
return target === 'me' ? 'personal' : 'organization';
}
function goalMemberId(data: GoalFormData): string | null {
if (data.target === 'me') {
return getCurrentMembershipId() ?? null;
}
return data.target === 'other' ? data.other_member_id : null;
}
function goalFilters(data: GoalFormData) {
return {
// Only a goal that counts every member can be narrowed down to a subset of the members
member_ids: data.target === 'organization' ? idsOrNull(data.member_ids) : null,
project_ids: idsOrNull(data.project_ids),
task_ids: idsOrNull(data.task_ids),
tag_ids: idsOrNull(data.tag_ids),
tag_match_type: data.tag_ids.length > 0 ? data.tag_match_type : null,
client_ids: idsOrNull(data.client_ids),
billable: data.billable === null ? null : data.billable === 'true',
time_entry_type: data.time_entry_type,
};
}
export function goalFormDataToCreateBody(data: GoalFormData): CreateGoalBody {
return {
name: data.name.trim(),
type: goalType(data.target),
comparison: data.comparison,
target_seconds: data.target_seconds ?? 0,
period: data.period,
member_id: goalMemberId(data),
timezone: data.timezone,
week_start: data.week_start,
filters: goalFilters(data),
};
}
/**
* The type of a goal and the member it is for can not be changed after creation.
*/
export function goalFormDataToUpdateBody(data: GoalFormData): UpdateGoalBody {
return {
name: data.name.trim(),
comparison: data.comparison,
target_seconds: data.target_seconds ?? 0,
period: data.period,
timezone: data.timezone,
week_start: data.week_start,
filters: goalFilters(data),
};
}
export interface GoalFormErrors {
name?: string;
target_seconds?: string;
other_member_id?: string;
comparison?: string;
period?: string;
filters?: string;
timezone?: string;
week_start?: string;
}
/**
* Maps the field errors of a 422 response (see getApiValidationFieldErrors) to the fields of the goal form.
* Errors that no visible field can show (e.g. member_id of a goal for yourself) are returned as unmapped.
*/
export function goalFormErrorsFromApi(
fieldErrors: Record<string, string>,
data: GoalFormData
): { errors: GoalFormErrors; unmapped: string[] } {
const errors: GoalFormErrors = {};
const unmapped: string[] = [];
const direct = [
'name',
'target_seconds',
'comparison',
'period',
'timezone',
'week_start',
] as const;
for (const [field, message] of Object.entries(fieldErrors)) {
const directField = direct.find((key) => key === field);
if (directField !== undefined) {
errors[directField] ??= message;
} else if (field === 'member_id' && data.target === 'other') {
errors.other_member_id ??= message;
} else if (field === 'filters' || field.startsWith('filters.')) {
errors.filters ??= message;
} else {
unmapped.push(message);
}
}
return { errors, unmapped };
}
export function validateGoalFormData(data: GoalFormData): GoalFormErrors {
const errors: GoalFormErrors = {};
if (data.name.trim() === '') {
errors.name = 'Please enter a name for the goal.';
}
if (data.target_seconds === null || data.target_seconds <= 0) {
errors.target_seconds = 'Please enter a target time greater than zero.';
}
if (data.target === 'other' && data.other_member_id === null) {
errors.other_member_id = 'Please choose the member this goal is for.';
}
return errors;
}

View File

@@ -3,15 +3,11 @@ import { XMarkIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
import type { Component } from 'vue'; import type { Component } from 'vue';
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/packages/ui/src'; import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/packages/ui/src';
withDefaults( defineProps<{
defineProps<{ icon: Component;
icon: Component; label: string;
label: string; filterName: string;
filterName: string; }>();
operator?: string;
}>(),
{ operator: 'is' }
);
defineEmits<{ defineEmits<{
remove: []; remove: [];
@@ -30,7 +26,7 @@ defineSlots<{
class="inline-flex items-center gap-1.5 px-2 py-1 text-sm hover:bg-quaternary dark:hover:bg-tertiary rounded-l-md transition-colors whitespace-nowrap"> class="inline-flex items-center gap-1.5 px-2 py-1 text-sm hover:bg-quaternary dark:hover:bg-tertiary rounded-l-md transition-colors whitespace-nowrap">
<component :is="icon" class="h-3.5 w-3.5 text-icon-default" /> <component :is="icon" class="h-3.5 w-3.5 text-icon-default" />
<span class="font-medium text-foreground">{{ filterName }}</span> <span class="font-medium text-foreground">{{ filterName }}</span>
<span class="text-muted-foreground">{{ operator }}</span> <span class="text-muted-foreground">is</span>
<span class="text-foreground">{{ label }}</span> <span class="text-foreground">{{ label }}</span>
<ChevronDownIcon class="h-3 w-3 text-muted-foreground" /> <ChevronDownIcon class="h-3 w-3 text-muted-foreground" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -40,8 +36,6 @@ defineSlots<{
</DropdownMenu> </DropdownMenu>
<button <button
type="button"
:aria-label="`Remove ${filterName} filter`"
class="px-1.5 py-1 hover:bg-quaternary dark:hover:bg-tertiary h-full rounded-r-md transition-colors group border-l border-border-secondary" class="px-1.5 py-1 hover:bg-quaternary dark:hover:bg-tertiary h-full rounded-r-md transition-colors group border-l border-border-secondary"
@click="$emit('remove')"> @click="$emit('remove')">
<XMarkIcon class="h-3.5 w-3.5 text-muted-foreground group-hover:text-foreground" /> <XMarkIcon class="h-3.5 w-3.5 text-muted-foreground group-hover:text-foreground" />

View File

@@ -1,94 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Check, ChevronDown } from '@lucide/vue';
import {
Combobox,
ComboboxAnchor,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
ComboboxViewport,
ComboboxVirtualizer,
} from '@/packages/ui/src/combobox';
import { Button } from '@/packages/ui/src/Buttons';
import { useTimezonesQuery } from '@/utils/useTimezonesQuery';
// height of one row (py-1.5 text-sm → 12px padding + 20px line box)
const ROW_HEIGHT = 32;
const model = defineModel<string>({ required: true });
defineProps<{
id?: string;
disabled?: boolean;
}>();
const { timezones } = useTimezonesQuery();
const open = ref(false);
const searchValue = ref('');
watch(open, (isOpen) => {
if (isOpen) searchValue.value = '';
});
const filteredTimezones = computed(() => {
// Match "new york" against "America/New_York"
const search = searchValue.value.toLowerCase().trim().replace(/\s+/g, '_');
if (!search) return timezones.value;
return timezones.value.filter((timezone) => timezone.toLowerCase().includes(search));
});
// Reka fills the input with the selected value on open, which would filter the list down to it
function emptySearch() {
return '';
}
function timezoneName(timezone: string) {
return timezone;
}
</script>
<template>
<Combobox v-model="model" v-model:open="open" :ignore-filter="true" :disabled="disabled">
<ComboboxAnchor>
<ComboboxTrigger as-child>
<!-- aria-label replaces Reka's hardcoded "Show popup" name -->
<Button
:id="id"
type="button"
variant="input"
:aria-label="model ? `Timezone: ${model}` : 'Timezone'"
class="w-full justify-between font-normal">
<span v-if="model" class="truncate">{{ model }}</span>
<span v-else class="truncate text-muted-foreground">Select a timezone</span>
<ChevronDown class="w-4 h-4 text-icon-default shrink-0" />
</Button>
</ComboboxTrigger>
</ComboboxAnchor>
<ComboboxList>
<ComboboxInput
v-model="searchValue"
:display-value="emptySearch"
auto-focus
aria-label="Search timezones"
placeholder="Search timezones..." />
<ComboboxViewport class="p-1">
<ComboboxVirtualizer
v-slot="{ option }"
:options="filteredTimezones"
:estimate-size="ROW_HEIGHT"
:text-content="timezoneName">
<ComboboxItem :value="option" class="justify-between gap-2">
<span class="truncate">{{ option }}</span>
<Check v-if="option === model" class="h-4 w-4 shrink-0" />
</ComboboxItem>
</ComboboxVirtualizer>
</ComboboxViewport>
<div v-if="filteredTimezones.length === 0" class="px-3 py-2 text-sm text-text-tertiary">
No timezone found.
</div>
</ComboboxList>
</Combobox>
</template>

View File

@@ -7,9 +7,7 @@ const showUpgradeModal = ref(false);
<template> <template>
<UpgradeModal v-model:show="showUpgradeModal"> <UpgradeModal v-model:show="showUpgradeModal">
<slot> <strong>Project and Task Estimates</strong> is only available in solidtime Professional.
<strong>Project and Task Estimates</strong> is only available in solidtime Professional.
</slot>
</UpgradeModal> </UpgradeModal>
<button <button
class="inline-flex bg-secondary hover:bg-tertiary px-2 py-1 rounded border border-border-secondary hover:border-border-tertiary items-center space-x-1" class="inline-flex bg-secondary hover:bg-tertiary px-2 py-1 rounded border border-border-secondary hover:border-border-tertiary items-center space-x-1"

View File

@@ -1,28 +0,0 @@
<script setup lang="ts">
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/packages/ui/src';
import { weekdayOptions } from '@/utils/weekdays';
import type { Weekday } from '@/packages/api/src';
const model = defineModel<Weekday | ''>({ required: true });
defineProps<{
id?: string;
disabled?: boolean;
}>();
</script>
<template>
<Select v-model="model" :disabled="disabled">
<SelectTrigger :id="id" class="w-full font-normal">
<SelectValue placeholder="Select a week day" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="weekday in weekdayOptions"
:key="weekday.value"
:value="weekday.value">
{{ weekday.label }}
</SelectItem>
</SelectContent>
</Select>
</template>

View File

@@ -19,7 +19,7 @@ import {
DocumentTextIcon, DocumentTextIcon,
TableCellsIcon, TableCellsIcon,
} from '@heroicons/vue/20/solid'; } from '@heroicons/vue/20/solid';
import { PanelLeft, Target } from '@lucide/vue'; import { PanelLeft } from '@lucide/vue';
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue'; import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
import UserSettingsIcon from '@/Components/UserSettingsIcon.vue'; import UserSettingsIcon from '@/Components/UserSettingsIcon.vue';
import MainContainer from '@/packages/ui/src/MainContainer.vue'; import MainContainer from '@/packages/ui/src/MainContainer.vue';
@@ -31,7 +31,6 @@ import {
canManageBilling, canManageBilling,
canUpdateOrganization, canUpdateOrganization,
canViewClients, canViewClients,
canViewGoals,
canViewInvoices, canViewInvoices,
canViewMembers, canViewMembers,
canViewProjects, canViewProjects,
@@ -219,12 +218,6 @@ const page = usePage<{
" "
:href="route('reporting')"> :href="route('reporting')">
</NavigationSidebarItem> </NavigationSidebarItem>
<NavigationSidebarItem
v-if="canViewGoals()"
title="Goals"
:icon="Target"
:current="route().current('goals')"
:href="route('goals')"></NavigationSidebarItem>
</ul> </ul>
</nav> </nav>

View File

@@ -1,65 +0,0 @@
<script setup lang="ts">
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import AppLayout from '@/Layouts/AppLayout.vue';
import { Target } from '@lucide/vue';
import { PlusIcon } from '@heroicons/vue/16/solid';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { computed, ref } from 'vue';
import GoalTable from '@/Components/Common/Goal/GoalTable.vue';
import GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue';
import PageTitle from '@/Components/Common/PageTitle.vue';
import UpgradeBadge from '@/Components/Common/UpgradeBadge.vue';
import { canCreateGoals } from '@/utils/permissions';
import { canCreateMoreGoals, goalLimit } from '@/utils/billing';
import { useGoalsQuery } from '@/utils/useGoalsQuery';
import { TabBar, TabBarItem } from '@/packages/ui/src';
const showCreateGoalModal = ref(false);
const activeTab = ref('active');
const showArchived = computed(() => activeTab.value === 'archived');
// Archived goals count towards the goal limit, so the whole list is loaded and split here
const { goals, isLoading } = useGoalsQuery('all');
const visibleGoals = computed(() =>
goals.value.filter((goal) => goal.is_archived === showArchived.value)
);
const archivedCount = computed(() => goals.value.filter((goal) => goal.is_archived).length);
const canCreateMore = computed(() => canCreateMoreGoals(goals.value.length));
const limit = goalLimit();
</script>
<template>
<AppLayout title="Goals" data-testid="goals_view">
<MainContainer
class="py-5 border-b border-default-background-separator flex justify-between items-center">
<div class="flex items-center space-x-6">
<PageTitle :icon="Target" title="Goals"></PageTitle>
<TabBar v-model="activeTab">
<TabBarItem value="active">Active</TabBarItem>
<TabBarItem value="archived">
Archived
<span v-if="archivedCount > 0" class="pl-1">({{ archivedCount }})</span>
</TabBarItem>
</TabBar>
</div>
<div class="flex items-center space-x-3">
<UpgradeBadge v-if="canCreateGoals() && !canCreateMore">
<strong>More than {{ limit }} goal{{ limit === 1 ? '' : 's' }}</strong> is only
available in solidtime Professional.
</UpgradeBadge>
<SecondaryButton
v-if="canCreateGoals() && canCreateMore"
:icon="PlusIcon"
@click="showCreateGoalModal = true"
>Create Goal
</SecondaryButton>
</div>
<GoalCreateModal v-model:show="showCreateGoalModal"></GoalCreateModal>
</MainContainer>
<GoalTable
:goals="visibleGoals"
:is-loading="isLoading"
:can-create-more-goals="canCreateMore"
:showing-archived="showArchived"></GoalTable>
</AppLayout>
</template>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'; import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { usePage } from '@inertiajs/vue3';
import ActionMessage from '@/Components/ActionMessage.vue'; import ActionMessage from '@/Components/ActionMessage.vue';
import FormSection from '@/Components/FormSection.vue'; import FormSection from '@/Components/FormSection.vue';
import { Field, FieldError, FieldLabel } from '@/packages/ui/src/field'; import { Field, FieldError, FieldLabel } from '@/packages/ui/src/field';
@@ -13,10 +14,8 @@ import {
useUpdateUserMutation, useUpdateUserMutation,
useUserQuery, useUserQuery,
} from '@/utils/useUserQuery'; } from '@/utils/useUserQuery';
import type { UpdateUserBody, User, Weekday } from '@/packages/api/src'; import type { UpdateUserBody, User } from '@/packages/api/src';
import { getApiValidationFieldErrors } from '@/utils/apiValidation'; import { getApiValidationFieldErrors } from '@/utils/apiValidation';
import TimezoneCombobox from '@/Components/Common/TimezoneCombobox.vue';
import WeekStartSelect from '@/Components/Common/WeekStartSelect.vue';
const { user } = useUserQuery(); const { user } = useUserQuery();
const updateUser = useUpdateUserMutation(); const updateUser = useUpdateUserMutation();
@@ -26,7 +25,7 @@ const resetPendingEmail = useResetUserPendingEmailMutation();
const name = ref(''); const name = ref('');
const email = ref(''); const email = ref('');
const timezone = ref(''); const timezone = ref('');
const weekStart = ref<Weekday | ''>(''); const weekStart = ref('');
const photoBase64 = ref<string | null>(null); const photoBase64 = ref<string | null>(null);
const photoPreview = ref<string | null>(null); const photoPreview = ref<string | null>(null);
@@ -167,6 +166,11 @@ function flashSaved() {
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (resendCooldownTimer) clearTimeout(resendCooldownTimer); if (resendCooldownTimer) clearTimeout(resendCooldownTimer);
}); });
const page = usePage<{
timezones: Record<string, string>;
weekdays: Record<string, string>;
}>();
</script> </script>
<template> <template>
@@ -288,14 +292,42 @@ onBeforeUnmount(() => {
<!-- Timezone --> <!-- Timezone -->
<Field class="col-span-6 sm:col-span-4"> <Field class="col-span-6 sm:col-span-4">
<FieldLabel for="timezone">Timezone</FieldLabel> <FieldLabel for="timezone">Timezone</FieldLabel>
<TimezoneCombobox id="timezone" v-model="timezone" :disabled="!isUserLoaded" /> <select
id="timezone"
v-model="timezone"
name="timezone"
required
:disabled="!isUserLoaded"
class="block w-full border-input-border bg-input-background text-text-primary focus:border-input-border-active rounded-md shadow-sm">
<option value="" disabled>Select a Timezone</option>
<option
v-for="(timezoneTranslated, timezoneValue) in page.props.timezones"
:key="timezoneValue"
:value="timezoneValue">
{{ timezoneTranslated }}
</option>
</select>
<FieldError v-if="fieldErrors.timezone">{{ fieldErrors.timezone }}</FieldError> <FieldError v-if="fieldErrors.timezone">{{ fieldErrors.timezone }}</FieldError>
</Field> </Field>
<!-- Week start --> <!-- Week start -->
<Field class="col-span-6 sm:col-span-4"> <Field class="col-span-6 sm:col-span-4">
<FieldLabel for="week_start">Start of the week</FieldLabel> <FieldLabel for="week_start">Start of the week</FieldLabel>
<WeekStartSelect id="week_start" v-model="weekStart" :disabled="!isUserLoaded" /> <select
id="week_start"
v-model="weekStart"
name="week_start"
required
:disabled="!isUserLoaded"
class="block w-full border-input-border bg-input-background text-text-primary focus:border-input-border-active rounded-md shadow-sm">
<option value="" disabled>Select a week day</option>
<option
v-for="(weekdayTranslated, weekdayValue) in page.props.weekdays"
:key="weekdayValue"
:value="weekdayValue">
{{ weekdayTranslated }}
</option>
</select>
<FieldError v-if="fieldErrors.week_start">{{ fieldErrors.week_start }}</FieldError> <FieldError v-if="fieldErrors.week_start">{{ fieldErrors.week_start }}</FieldError>
</Field> </Field>
</template> </template>

View File

@@ -108,19 +108,6 @@ export type UpdateReportBody = ZodiosBodyByAlias<SolidTimeApi, 'updateReport'>;
export type CreateReportBodyProperties = CreateReportBody['properties']; export type CreateReportBodyProperties = CreateReportBody['properties'];
export type Report = ReportIndexResponse['data'][0]; export type Report = ReportIndexResponse['data'][0];
export type GoalIndexResponse = ZodiosResponseByAlias<SolidTimeApi, 'getGoals'>;
export type Goal = GoalIndexResponse['data'][0];
export type GoalFilters = Goal['filters'];
export type GoalProgress = Goal['progress'];
export type GoalComparison = Goal['comparison'];
export type GoalPeriod = Goal['period'];
export type GoalType = Goal['type'];
export type GoalWeekStart = Goal['week_start'];
export type Weekday = GoalWeekStart;
export type GoalStatus = GoalProgress['status'];
export type CreateGoalBody = ZodiosBodyByAlias<SolidTimeApi, 'createGoal'>;
export type UpdateGoalBody = ZodiosBodyByAlias<SolidTimeApi, 'updateGoal'>;
export type ApiTokenIndexResponse = ZodiosResponseByAlias<SolidTimeApi, 'getApiTokens'>; export type ApiTokenIndexResponse = ZodiosResponseByAlias<SolidTimeApi, 'getApiTokens'>;
export type CreateApiTokenBody = ZodiosBodyByAlias<SolidTimeApi, 'createApiToken'>; export type CreateApiTokenBody = ZodiosBodyByAlias<SolidTimeApi, 'createApiToken'>;

View File

@@ -412,94 +412,6 @@ const ProjectMemberUpdateRequest = z
.object({ billable_rate: z.union([z.number(), z.null()]) }) .object({ billable_rate: z.union([z.number(), z.null()]) })
.partial() .partial()
.passthrough(); .passthrough();
const GoalComparison = z.enum(['at_least', 'less_than']);
const GoalPeriod = z.enum(['day', 'week', 'month']);
const GoalType = z.enum(['personal', 'organization']);
const Weekday = z.enum([
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
]);
const GoalStatus = z.enum(['in_progress', 'achieved', 'on_track', 'exceeded']);
const GoalResource = z
.object({
id: z.string(),
name: z.string(),
type: GoalType,
comparison: GoalComparison,
target_seconds: z.number().int(),
period: GoalPeriod,
member_id: z.union([z.string(), z.null()]),
member_name: z.union([z.string(), z.null()]),
timezone: z.string(),
week_start: Weekday,
is_archived: z.boolean(),
filters: z
.object({
member_ids: z.union([z.array(z.string()), z.null()]),
project_ids: z.union([z.array(z.string()), z.null()]),
task_ids: z.union([z.array(z.string()), z.null()]),
tag_ids: z.union([z.array(z.string()), z.null()]),
tag_match_type: z.union([z.enum(['contains', 'not_contains']), z.null()]),
client_ids: z.union([z.array(z.string()), z.null()]),
billable: z.union([z.boolean(), z.null()]),
time_entry_type: z.union([z.enum(['work', 'break']), z.null()]),
})
.passthrough(),
progress: z
.object({
period_start: z.string(),
period_end: z.string(),
tracked_seconds: z.number().int(),
status: GoalStatus,
})
.passthrough(),
created_at: z.string(),
updated_at: z.string(),
})
.passthrough();
const GoalFiltersRequest = z
.object({
member_ids: z.union([z.array(z.string().uuid()), z.null()]).optional(),
project_ids: z.union([z.array(z.string()), z.null()]).optional(),
task_ids: z.union([z.array(z.string()), z.null()]).optional(),
tag_ids: z.union([z.array(z.string()), z.null()]).optional(),
tag_match_type: z.union([z.enum(['contains', 'not_contains']), z.null()]).optional(),
client_ids: z.union([z.array(z.string()), z.null()]).optional(),
billable: z.union([z.boolean(), z.null()]).optional(),
time_entry_type: z.union([z.enum(['work', 'break']), z.null()]).optional(),
})
.passthrough();
const GoalStoreRequest = z
.object({
name: z.string().max(255),
type: GoalType,
comparison: GoalComparison,
target_seconds: z.number().int().gte(1).lte(2147483647),
period: GoalPeriod,
member_id: z.union([z.string().uuid(), z.null()]).optional(),
timezone: z.union([z.string(), z.null()]).optional(),
week_start: z.union([Weekday, z.null()]).optional(),
filters: GoalFiltersRequest.optional(),
})
.passthrough();
const GoalUpdateRequest = z
.object({
name: z.string().max(255),
comparison: GoalComparison,
target_seconds: z.number().int().gte(1).lte(2147483647),
period: GoalPeriod,
timezone: z.string(),
week_start: Weekday,
is_archived: z.boolean(),
filters: GoalFiltersRequest,
})
.partial()
.passthrough();
const ReportResource = z const ReportResource = z
.object({ .object({
id: z.string(), id: z.string(),
@@ -527,6 +439,15 @@ const TimeEntryAggregationType = z.enum([
'type', 'type',
]); ]);
const TimeEntryAggregationTypeInterval = z.enum(['day', 'week', 'month', 'year']); const TimeEntryAggregationTypeInterval = z.enum(['day', 'week', 'month', 'year']);
const Weekday = z.enum([
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
]);
const TimeEntryRoundingType = z.enum(['up', 'down', 'nearest']); const TimeEntryRoundingType = z.enum(['up', 'down', 'nearest']);
const ReportStoreRequest = z const ReportStoreRequest = z
.object({ .object({
@@ -861,18 +782,10 @@ export const schemas = {
ProjectMemberResource, ProjectMemberResource,
ProjectMemberStoreRequest, ProjectMemberStoreRequest,
ProjectMemberUpdateRequest, ProjectMemberUpdateRequest,
GoalComparison,
GoalPeriod,
GoalType,
Weekday,
GoalStatus,
GoalResource,
GoalFiltersRequest,
GoalStoreRequest,
GoalUpdateRequest,
ReportResource, ReportResource,
TimeEntryAggregationType, TimeEntryAggregationType,
TimeEntryAggregationTypeInterval, TimeEntryAggregationTypeInterval,
Weekday,
TimeEntryRoundingType, TimeEntryRoundingType,
ReportStoreRequest, ReportStoreRequest,
DetailedReportResource, DetailedReportResource,
@@ -920,13 +833,6 @@ const endpoints = makeApi([
z.object({ code: z.string(), name: z.string(), symbol: z.string() }).passthrough() z.object({ code: z.string(), name: z.string(), symbol: z.string() }).passthrough()
), ),
}, },
{
method: 'get',
path: '/v1/time-zones',
alias: 'getTimezones',
requestFormat: 'json',
response: z.array(z.object({ key: z.string() }).passthrough()),
},
{ {
method: 'post', method: 'post',
path: '/v1/organizations', path: '/v1/organizations',
@@ -3276,254 +3182,6 @@ const endpoints = makeApi([
}, },
], ],
}, },
{
method: 'get',
path: '/v1/organizations/:organization/goals',
alias: 'getGoals',
requestFormat: 'json',
parameters: [
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
{
name: 'type',
type: 'Query',
schema: z.union([GoalType, z.null()]).optional(),
},
{
name: 'archived',
type: 'Query',
schema: z.union([z.enum(['true', 'false', 'all']), z.null()]).optional(),
},
{
name: 'page',
type: 'Query',
schema: z.number().int().gte(1).lte(2147483647).optional(),
},
],
response: z
.object({
data: z.array(GoalResource),
links: z
.object({
first: z.union([z.string(), z.null()]),
last: z.union([z.string(), z.null()]),
prev: z.union([z.string(), z.null()]),
next: z.union([z.string(), z.null()]),
})
.passthrough(),
meta: z
.object({
current_page: z.number().int(),
from: z.union([z.number(), z.null()]),
last_page: z.number().int(),
links: z.array(
z
.object({
url: z.union([z.string(), z.null()]),
label: z.string(),
active: z.boolean(),
})
.passthrough()
),
path: z.union([z.string(), z.null()]),
per_page: z.number().int(),
to: z.union([z.number(), z.null()]),
total: z.number().int(),
})
.passthrough(),
})
.passthrough(),
errors: [
{
status: 401,
description: `Unauthenticated`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
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(),
},
],
},
{
method: 'post',
path: '/v1/organizations/:organization/goals',
alias: 'createGoal',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: GoalStoreRequest,
},
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
],
response: z.object({ data: GoalResource }).passthrough(),
errors: [
{
status: 401,
description: `Unauthenticated`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
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(),
},
],
},
{
method: 'get',
path: '/v1/organizations/:organization/goals/:goal',
alias: 'getGoal',
requestFormat: 'json',
parameters: [
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
{
name: 'goal',
type: 'Path',
schema: z.string(),
},
],
response: z.object({ data: GoalResource }).passthrough(),
errors: [
{
status: 401,
description: `Unauthenticated`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
],
},
{
method: 'put',
path: '/v1/organizations/:organization/goals/:goal',
alias: 'updateGoal',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: GoalUpdateRequest,
},
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
{
name: 'goal',
type: 'Path',
schema: z.string(),
},
],
response: z.object({ data: GoalResource }).passthrough(),
errors: [
{
status: 401,
description: `Unauthenticated`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
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(),
},
],
},
{
method: 'delete',
path: '/v1/organizations/:organization/goals/:goal',
alias: 'deleteGoal',
requestFormat: 'json',
parameters: [
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
{
name: 'goal',
type: 'Path',
schema: z.string(),
},
],
response: z.void(),
errors: [
{
status: 401,
description: `Unauthenticated`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
],
},
{ {
method: 'get', method: 'get',
path: '/v1/organizations/:organization/reports', path: '/v1/organizations/:organization/reports',

View File

@@ -17,14 +17,6 @@ export function isInvoicingActivated() {
return page.props.has_invoicing_extension; return page.props.has_invoicing_extension;
} }
export function isGoalsExtensionActivated() {
const page = usePage<{
has_goals_extension: boolean;
}>();
return page.props.has_goals_extension;
}
export function isInTrial() { export function isInTrial() {
const page = usePage<{ const page = usePage<{
billing: { billing: {
@@ -57,26 +49,6 @@ export function isBlocked() {
return page.props.billing.is_blocked; return page.props.billing.is_blocked;
} }
/**
* The maximum number of goals the organization can have, null means unlimited.
* Archived goals count towards the limit as well.
*/
export function goalLimit(): number | null {
const page = usePage<{
billing: {
goal_limit: number | null;
} | null;
}>();
return page.props.billing?.goal_limit ?? null;
}
export function canCreateMoreGoals(goalCount: number): boolean {
const limit = goalLimit();
return limit === null || goalCount < limit;
}
export function isFreePlan() { export function isFreePlan() {
return !hasActiveSubscription() && !isInTrial(); return !hasActiveSubscription() && !isInTrial();
} }

View File

@@ -23,7 +23,6 @@ import {
ClipboardDocumentListIcon, ClipboardDocumentListIcon,
BuildingOfficeIcon, BuildingOfficeIcon,
} from '@heroicons/vue/20/solid'; } from '@heroicons/vue/20/solid';
import { Target } from '@lucide/vue';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue'; import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import type { Organization } from '@/types/models'; import type { Organization } from '@/types/models';
@@ -62,7 +61,6 @@ export function createNavigationCommands(
canViewTags: () => boolean; canViewTags: () => boolean;
canViewReport: () => boolean; canViewReport: () => boolean;
canViewInvoices: () => boolean; canViewInvoices: () => boolean;
canViewGoals: () => boolean;
canManageBilling: () => boolean; canManageBilling: () => boolean;
canUpdateOrganization: () => boolean; canUpdateOrganization: () => boolean;
}, },
@@ -128,16 +126,6 @@ export function createNavigationCommands(
permission: permissions.canViewReport, permission: permissions.canViewReport,
priority: GROUP_PRIORITIES.navigation + 5, priority: GROUP_PRIORITIES.navigation + 5,
}, },
{
id: 'nav-goals',
label: 'Go to Goals',
icon: Target,
keywords: ['goals', 'targets', 'habits', 'progress'],
group: 'navigation',
action: () => navigate('goals'),
permission: permissions.canViewGoals,
priority: GROUP_PRIORITIES.navigation + 4.5,
},
{ {
id: 'nav-projects', id: 'nav-projects',
label: 'Go to Projects', label: 'Go to Projects',
@@ -397,7 +385,6 @@ export function createCreateCommands(
openClientModal: () => void; openClientModal: () => void;
openTaskModal: () => void; openTaskModal: () => void;
openTagModal: () => void; openTagModal: () => void;
openGoalModal: () => void;
openInviteModal: () => void; openInviteModal: () => void;
}, },
permissions: { permissions: {
@@ -405,7 +392,6 @@ export function createCreateCommands(
canCreateClients: () => boolean; canCreateClients: () => boolean;
canCreateTasks: () => boolean; canCreateTasks: () => boolean;
canCreateTags: () => boolean; canCreateTags: () => boolean;
canCreateGoals: () => boolean;
canCreateInvitations: () => boolean; canCreateInvitations: () => boolean;
} }
): Command[] { ): Command[] {
@@ -450,16 +436,6 @@ export function createCreateCommands(
permission: permissions.canCreateTags, permission: permissions.canCreateTags,
priority: GROUP_PRIORITIES.create + 2, priority: GROUP_PRIORITIES.create + 2,
}, },
{
id: 'create-goal',
label: 'Create Goal',
icon: Target,
keywords: ['new goal', 'add goal', 'create', 'target'],
group: 'create',
action: createActions.openGoalModal,
permission: permissions.canCreateGoals,
priority: GROUP_PRIORITIES.create + 1.5,
},
{ {
id: 'create-invite', id: 'create-invite',
label: 'Invite Member', label: 'Invite Member',

View File

@@ -1,63 +0,0 @@
import type { Goal, GoalComparison, GoalPeriod, GoalStatus } from '@/packages/api/src';
import { canDeleteOrganizationGoals, canUpdateOrganizationGoals } from '@/utils/permissions';
import { getCurrentMembershipId } from '@/utils/useUser';
export const goalComparisonOptions: { value: GoalComparison; label: string }[] = [
{ value: 'at_least', label: 'At least' },
{ value: 'less_than', label: 'Less than' },
];
export const goalPeriodOptions: { value: GoalPeriod; label: string }[] = [
{ value: 'day', label: 'per day' },
{ value: 'week', label: 'per week' },
{ value: 'month', label: 'per month' },
];
/**
* Who a goal is for. The three answers map to the goal type and the member of the goal:
* me = a personal goal for yourself, other = an organization goal for one member,
* organization = an organization goal that counts every member.
*/
export type GoalTarget = 'me' | 'other' | 'organization';
export const goalTargetOptions: { value: GoalTarget; label: string }[] = [
{ value: 'me', label: 'For me' },
{ value: 'other', label: 'For someone else' },
{ value: 'organization', label: 'Organization goal' },
];
/**
* Mirrors the access rules on the server: a personal goal belongs to its member, an organization goal
* to the members that may manage goals. The member an organization goal is for never manages it.
*/
export function canManageGoal(goal: Goal): boolean {
if (goal.type === 'personal') {
return goal.member_id === getCurrentMembershipId();
}
return canUpdateOrganizationGoals() || canDeleteOrganizationGoals();
}
export function goalTypeLabel(goal: Goal): string {
return goal.type === 'personal' ? 'Personal goal' : 'Organization goal';
}
export function goalComparisonLabel(comparison: GoalComparison): string {
return goalComparisonOptions.find((option) => option.value === comparison)?.label ?? comparison;
}
export function goalStatusLabel(status: GoalStatus): string {
switch (status) {
case 'in_progress':
return 'In progress';
case 'achieved':
return 'Achieved';
case 'on_track':
return 'On track';
case 'exceeded':
return 'Exceeded';
}
}
export function goalStatusIsPositive(status: GoalStatus): boolean {
return status === 'achieved' || status === 'on_track';
}

View File

@@ -132,43 +132,3 @@ export function canViewInvoices() {
export function canCreateReports() { export function canCreateReports() {
return currentUserHasPermission('reports:create'); return currentUserHasPermission('reports:create');
} }
// Goal permissions are split by the type of goal they reach: `:own` covers the goals a member sets for
// themselves, `:organization-type` the organization goals of the team goals extension. The helpers without a
// suffix answer whether the member reaches at least one of the two, which is what the UI entry points need.
export function canViewOwnGoals() {
return currentUserHasPermission('goals:view:own');
}
export function canViewOrganizationGoals() {
return currentUserHasPermission('goals:view:organization-type');
}
export function canViewGoals() {
return canViewOwnGoals() || canViewOrganizationGoals();
}
export function canCreateOwnGoals() {
return currentUserHasPermission('goals:create:own');
}
export function canCreateOrganizationGoals() {
return currentUserHasPermission('goals:create:organization-type');
}
export function canCreateGoals() {
return canCreateOwnGoals() || canCreateOrganizationGoals();
}
export function canUpdateOwnGoals() {
return currentUserHasPermission('goals:update:own');
}
export function canUpdateOrganizationGoals() {
return currentUserHasPermission('goals:update:organization-type');
}
export function canUpdateGoals() {
return canUpdateOwnGoals() || canUpdateOrganizationGoals();
}
export function canDeleteOwnGoals() {
return currentUserHasPermission('goals:delete:own');
}
export function canDeleteOrganizationGoals() {
return currentUserHasPermission('goals:delete:organization-type');
}
export function canDeleteGoals() {
return canDeleteOwnGoals() || canDeleteOrganizationGoals();
}

View File

@@ -11,14 +11,12 @@ import {
canViewTags, canViewTags,
canViewReport, canViewReport,
canViewInvoices, canViewInvoices,
canViewGoals,
canManageBilling, canManageBilling,
canUpdateOrganization, canUpdateOrganization,
canCreateProjects, canCreateProjects,
canCreateClients, canCreateClients,
canCreateTasks, canCreateTasks,
canCreateTags, canCreateTags,
canCreateGoals,
canCreateInvitations, canCreateInvitations,
} from '@/utils/permissions'; } from '@/utils/permissions';
import { isBillingActivated, isInvoicingActivated } from '@/utils/billing'; import { isBillingActivated, isInvoicingActivated } from '@/utils/billing';
@@ -65,7 +63,6 @@ const showCreateProjectModal = ref(false);
const showCreateClientModal = ref(false); const showCreateClientModal = ref(false);
const showCreateTaskModal = ref(false); const showCreateTaskModal = ref(false);
const showCreateTagModal = ref(false); const showCreateTagModal = ref(false);
const showCreateGoalModal = ref(false);
const showInviteMemberModal = ref(false); const showInviteMemberModal = ref(false);
const showCreateTimeEntryModal = ref(false); const showCreateTimeEntryModal = ref(false);
@@ -240,11 +237,6 @@ export function useCommandPalette() {
showCreateTagModal.value = true; showCreateTagModal.value = true;
} }
function openCreateGoalModal() {
closePaletteAfterAction();
showCreateGoalModal.value = true;
}
function openInviteMemberModal() { function openInviteMemberModal() {
closePaletteAfterAction(); closePaletteAfterAction();
showInviteMemberModal.value = true; showInviteMemberModal.value = true;
@@ -267,7 +259,6 @@ export function useCommandPalette() {
canViewTags, canViewTags,
canViewReport, canViewReport,
canViewInvoices, canViewInvoices,
canViewGoals,
canManageBilling, canManageBilling,
canUpdateOrganization, canUpdateOrganization,
}, },
@@ -318,7 +309,6 @@ export function useCommandPalette() {
openClientModal: openCreateClientModal, openClientModal: openCreateClientModal,
openTaskModal: openCreateTaskModal, openTaskModal: openCreateTaskModal,
openTagModal: openCreateTagModal, openTagModal: openCreateTagModal,
openGoalModal: openCreateGoalModal,
openInviteModal: openInviteMemberModal, openInviteModal: openInviteMemberModal,
}, },
{ {
@@ -326,7 +316,6 @@ export function useCommandPalette() {
canCreateClients, canCreateClients,
canCreateTasks, canCreateTasks,
canCreateTags, canCreateTags,
canCreateGoals,
canCreateInvitations, canCreateInvitations,
} }
) )
@@ -524,7 +513,6 @@ export function useCommandPalette() {
showCreateClientModal, showCreateClientModal,
showCreateTaskModal, showCreateTaskModal,
showCreateTagModal, showCreateTagModal,
showCreateGoalModal,
showInviteMemberModal, showInviteMemberModal,
showCreateTimeEntryModal, showCreateTimeEntryModal,
showProjectSelector, showProjectSelector,

View File

@@ -205,7 +205,6 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
stopLiveTimer(); stopLiveTimer();
} }
queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
queryClient.invalidateQueries({ queryKey: ['goals'] });
} }
async function resumeWorkAfterBreak(context: ResumeTimeEntryContext) { async function resumeWorkAfterBreak(context: ResumeTimeEntryContext) {
@@ -224,7 +223,6 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
startLiveTimer(); startLiveTimer();
await startTimer(); await startTimer();
queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
queryClient.invalidateQueries({ queryKey: ['goals'] });
} }
async function updateTimer() { async function updateTimer() {
@@ -304,7 +302,6 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
await stopTimer(); await stopTimer();
} }
queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
queryClient.invalidateQueries({ queryKey: ['goals'] });
} }
return { return {

View File

@@ -1,126 +0,0 @@
import { defineStore } from 'pinia';
import { api } from '@/packages/api/src';
import type { CreateGoalBody, Goal, UpdateGoalBody } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
import { useQueryClient } from '@tanstack/vue-query';
import { isApiValidationError } from '@/utils/apiValidation';
export const useGoalsStore = defineStore('goals', () => {
const { addNotification, handleApiRequestNotifications } = useNotificationsStore();
const queryClient = useQueryClient();
/**
* Like handleApiRequestNotifications, but a 422 response is rethrown as the AxiosError
* without a toast, so that the goal form can show the errors next to its fields.
*/
async function handleGoalFormRequest<T>(
apiRequest: () => Promise<T>,
successMessage: string,
errorMessage: string
): Promise<T | undefined> {
let validationError: unknown = null;
const response = await handleApiRequestNotifications(
async () => {
try {
return await apiRequest();
} catch (error) {
if (isApiValidationError(error)) {
validationError = error;
return undefined;
}
throw error;
}
},
undefined,
errorMessage
);
if (validationError !== null) {
throw validationError;
}
addNotification('success', successMessage);
return response;
}
async function createGoal(goalBody: CreateGoalBody): Promise<Goal | undefined> {
const organization = getCurrentOrganizationId();
if (organization) {
const response = await handleGoalFormRequest(
() =>
api.createGoal(goalBody, {
params: {
organization: organization,
},
}),
'Goal created successfully',
'Failed to create goal'
);
queryClient.invalidateQueries({ queryKey: ['goals'] });
return response?.data;
}
}
async function updateGoal(goalId: string, goalBody: UpdateGoalBody): Promise<Goal | undefined> {
const organization = getCurrentOrganizationId();
if (organization) {
const response = await handleGoalFormRequest(
() =>
api.updateGoal(goalBody, {
params: {
organization: organization,
goal: goalId,
},
}),
'Goal updated successfully',
'Failed to update goal'
);
queryClient.invalidateQueries({ queryKey: ['goals'] });
return response?.data;
}
}
/**
* Archived goals are hidden from the goal list by default, archiving is not deleting.
*/
async function setGoalArchived(goalId: string, isArchived: boolean): Promise<Goal | undefined> {
const organization = getCurrentOrganizationId();
if (organization) {
const response = await handleApiRequestNotifications(
() =>
api.updateGoal(
{ is_archived: isArchived },
{
params: {
organization: organization,
goal: goalId,
},
}
),
isArchived ? 'Goal archived successfully' : 'Goal unarchived successfully',
isArchived ? 'Failed to archive goal' : 'Failed to unarchive goal'
);
queryClient.invalidateQueries({ queryKey: ['goals'] });
return response?.data;
}
}
async function deleteGoal(goalId: string) {
const organization = getCurrentOrganizationId();
if (organization) {
await handleApiRequestNotifications(
() =>
api.deleteGoal(undefined, {
params: {
organization: organization,
goal: goalId,
},
}),
'Goal deleted successfully',
'Failed to delete goal'
);
queryClient.invalidateQueries({ queryKey: ['goals'] });
}
}
return { createGoal, updateGoal, setGoalArchived, deleteGoal };
});

View File

@@ -1,50 +0,0 @@
import { useQuery, useQueryClient } from '@tanstack/vue-query';
import { api } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import type { Goal } from '@/packages/api/src';
import { computed } from 'vue';
import { fetchAllPages } from '@/utils/fetchAllPages';
export type GoalArchivedFilter = 'true' | 'false' | 'all';
export async function fetchAllGoals(
organizationId: string,
archived: GoalArchivedFilter = 'false'
): Promise<Goal[]> {
return fetchAllPages((page) =>
api.getGoals({
params: { organization: organizationId },
queries: { page, archived },
})
);
}
export function useGoalsQuery(archived: GoalArchivedFilter = 'false') {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: computed(() => ['goals', getCurrentOrganizationId(), archived]),
queryFn: async () => {
const organizationId = getCurrentOrganizationId();
if (!organizationId) throw new Error('No organization');
const data = await fetchAllGoals(organizationId, archived);
return { data };
},
enabled: () => !!getCurrentOrganizationId(),
staleTime: 1000 * 30, // 30 seconds
// Progress is computed server side, keep it fresh while the page is open
refetchInterval: 1000 * 60,
});
const goals = computed<Goal[]>(() => query.data.value?.data ?? []);
const invalidateGoals = () => {
queryClient.invalidateQueries({ queryKey: ['goals'] });
};
return {
...query,
goals,
invalidateGoals,
};
}

View File

@@ -14,7 +14,7 @@ export async function fetchAllMembers(organizationId: string): Promise<Member[]>
); );
} }
export function useMembersQuery(options: { enabled?: () => boolean } = {}) { export function useMembersQuery() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const query = useQuery({ const query = useQuery({
@@ -25,7 +25,7 @@ export function useMembersQuery(options: { enabled?: () => boolean } = {}) {
const data = await fetchAllMembers(organizationId); const data = await fetchAllMembers(organizationId);
return { data }; return { data };
}, },
enabled: () => !!getCurrentOrganizationId() && (options.enabled?.() ?? true), enabled: () => !!getCurrentOrganizationId(),
staleTime: 1000 * 30, // 30 seconds staleTime: 1000 * 30, // 30 seconds
}); });

View File

@@ -1,21 +0,0 @@
import { useQuery } from '@tanstack/vue-query';
import { api } from '@/packages/api/src';
import { computed } from 'vue';
export function useTimezonesQuery() {
const query = useQuery({
queryKey: ['timezones'],
queryFn: () => api.getTimezones(),
// The list of timezones does not change while the app is open
staleTime: Infinity,
});
const timezones = computed<string[]>(
() => query.data.value?.map((timezone) => timezone.key) ?? []
);
return {
...query,
timezones,
};
}

View File

@@ -1,11 +0,0 @@
import type { Weekday } from '@/packages/api/src';
export const weekdayOptions: { value: Weekday; label: string }[] = [
{ value: 'monday', label: 'Monday' },
{ value: 'tuesday', label: 'Tuesday' },
{ value: 'wednesday', label: 'Wednesday' },
{ value: 'thursday', label: 'Thursday' },
{ value: 'friday', label: 'Friday' },
{ value: 'saturday', label: 'Saturday' },
{ value: 'sunday', label: 'Sunday' },
];

View File

@@ -7,7 +7,6 @@ use App\Http\Controllers\Api\V1\ChartController;
use App\Http\Controllers\Api\V1\ClientController; use App\Http\Controllers\Api\V1\ClientController;
use App\Http\Controllers\Api\V1\CurrencyController; use App\Http\Controllers\Api\V1\CurrencyController;
use App\Http\Controllers\Api\V1\ExportController; use App\Http\Controllers\Api\V1\ExportController;
use App\Http\Controllers\Api\V1\GoalController;
use App\Http\Controllers\Api\V1\ImportController; use App\Http\Controllers\Api\V1\ImportController;
use App\Http\Controllers\Api\V1\InvitationController; use App\Http\Controllers\Api\V1\InvitationController;
use App\Http\Controllers\Api\V1\MemberController; use App\Http\Controllers\Api\V1\MemberController;
@@ -135,15 +134,6 @@ Route::prefix('v1')->name('v1.')->group(static function (): void {
Route::delete('/reports/{report}', [ReportController::class, 'destroy'])->name('destroy'); Route::delete('/reports/{report}', [ReportController::class, 'destroy'])->name('destroy');
}); });
// Goal routes
Route::name('goals.')->prefix('/organizations/{organization}')->group(static function (): void {
Route::get('/goals', [GoalController::class, 'index'])->name('index');
Route::get('/goals/{goal}', [GoalController::class, 'show'])->name('show');
Route::post('/goals', [GoalController::class, 'store'])->name('store');
Route::put('/goals/{goal}', [GoalController::class, 'update'])->name('update');
Route::delete('/goals/{goal}', [GoalController::class, 'destroy'])->name('destroy');
});
// Chart routes // Chart routes
Route::name('charts.')->prefix('/organizations/{organization}/charts')->group(static function (): void { Route::name('charts.')->prefix('/organizations/{organization}/charts')->group(static function (): void {
Route::get('/weekly-project-overview', [ChartController::class, 'weeklyProjectOverview'])->name('weekly-project-overview'); Route::get('/weekly-project-overview', [ChartController::class, 'weeklyProjectOverview'])->name('weekly-project-overview');

View File

@@ -62,10 +62,6 @@ Route::middleware([
return Inertia::render('ReportingShared'); return Inertia::render('ReportingShared');
})->name('reporting.shared'); })->name('reporting.shared');
Route::get('/goals', function () {
return Inertia::render('Goals');
})->name('goals');
Route::get('/projects', function () { Route::get('/projects', function () {
return Inertia::render('Projects'); return Inertia::render('Projects');
})->name('projects'); })->name('projects');

View File

@@ -88,7 +88,6 @@ abstract class TestCase extends BaseTestCase
$mock->shouldReceive('hasSubscription')->andReturn(true); $mock->shouldReceive('hasSubscription')->andReturn(true);
$mock->shouldReceive('hasTrial')->andReturn(false); $mock->shouldReceive('hasTrial')->andReturn(false);
$mock->shouldReceive('getTrialUntil')->andReturn(null); $mock->shouldReceive('getTrialUntil')->andReturn(null);
$mock->shouldReceive('getGoalLimit')->andReturn(null);
$mock->shouldReceive('isBlocked')->andReturn(false); $mock->shouldReceive('isBlocked')->andReturn(false);
}); });
} }
@@ -99,8 +98,6 @@ abstract class TestCase extends BaseTestCase
$mock->shouldReceive('hasSubscription')->andReturn(false); $mock->shouldReceive('hasSubscription')->andReturn(false);
$mock->shouldReceive('hasTrial')->andReturn(false); $mock->shouldReceive('hasTrial')->andReturn(false);
$mock->shouldReceive('getTrialUntil')->andReturn(null); $mock->shouldReceive('getTrialUntil')->andReturn(null);
// Core has no goal limit, the limit of the free plan is implemented in the billing extension
$mock->shouldReceive('getGoalLimit')->andReturn(null);
$mock->shouldReceive('isBlocked')->andReturn(false); $mock->shouldReceive('isBlocked')->andReturn(false);
}); });
} }

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Unit\Endpoint\Web; namespace Tests\Unit\Endpoint\Web;
use App\Enums\Weekday;
use App\Http\Controllers\Web\UserProfileController; use App\Http\Controllers\Web\UserProfileController;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@@ -28,6 +29,8 @@ class UserProfileEndpointTest extends EndpointTestAbstract
$response->assertOk(); $response->assertOk();
$response->assertInertia(fn (Assert $page) => $page $response->assertInertia(fn (Assert $page) => $page
->component('Profile/Show') ->component('Profile/Show')
->has('timezones')
->where('weekdays', Weekday::toSelectArray())
->where('confirmsTwoFactorAuthentication', true) ->where('confirmsTwoFactorAuthentication', true)
->where('sessions', []) ->where('sessions', [])
); );

View File

@@ -1,65 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Filament\Resources;
use App\Filament\Resources\GoalResource;
use App\Models\Goal;
use App\Models\User;
use Illuminate\Support\Facades\Config;
use Livewire\Livewire;
use PHPUnit\Framework\Attributes\UsesClass;
use Tests\Unit\Filament\FilamentTestCase;
#[UsesClass(GoalResource::class)]
class GoalResourceTest extends FilamentTestCase
{
protected function setUp(): void
{
parent::setUp();
Config::set('auth.super_admins', ['admin@example.com']);
$user = User::factory()->withPersonalOrganization()->create([
'email' => 'admin@example.com',
]);
$this->actingAs($user);
}
public function test_can_list_goals(): void
{
// Arrange
$goals = Goal::factory()->createMany(5);
// Act
$response = Livewire::test(GoalResource\Pages\ListGoals::class);
// Assert
$response->assertSuccessful();
$response->assertCanSeeTableRecords($goals);
}
public function test_can_see_view_page_of_goal(): void
{
// Arrange
$goal = Goal::factory()->create();
// Act
$response = Livewire::test(GoalResource\Pages\ViewGoal::class, ['record' => $goal->getKey()]);
// Assert
$response->assertSuccessful();
}
public function test_can_see_edit_page_of_goal(): void
{
// Arrange
$goal = Goal::factory()->create();
// Act
$response = Livewire::test(GoalResource\Pages\EditGoal::class, ['record' => $goal->getKey()]);
// Assert
$response->assertSuccessful();
}
}

View File

@@ -35,7 +35,6 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
$mock->shouldReceive('hasTrial')->andReturn(false); $mock->shouldReceive('hasTrial')->andReturn(false);
$mock->shouldReceive('getTrialUntil')->andReturn(null); $mock->shouldReceive('getTrialUntil')->andReturn(null);
$mock->shouldReceive('isBlocked')->andReturn(false); $mock->shouldReceive('isBlocked')->andReturn(false);
$mock->shouldReceive('getGoalLimit')->andReturn(1);
}); });
Passport::actingAs($user->user); Passport::actingAs($user->user);
@@ -48,7 +47,6 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
->where('billing.has_trial', false) ->where('billing.has_trial', false)
->where('billing.trial_until', null) ->where('billing.trial_until', null)
->where('billing.is_blocked', false) ->where('billing.is_blocked', false)
->where('billing.goal_limit', 1)
); );
} }
@@ -63,7 +61,6 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
$mock->shouldReceive('hasTrial')->andReturn(true); $mock->shouldReceive('hasTrial')->andReturn(true);
$mock->shouldReceive('getTrialUntil')->andReturn($trialUntil); $mock->shouldReceive('getTrialUntil')->andReturn($trialUntil);
$mock->shouldReceive('isBlocked')->andReturn(false); $mock->shouldReceive('isBlocked')->andReturn(false);
$mock->shouldReceive('getGoalLimit')->andReturn(null);
}); });
Passport::actingAs($user->user); Passport::actingAs($user->user);
@@ -76,7 +73,6 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
->where('billing.has_trial', true) ->where('billing.has_trial', true)
->where('billing.trial_until', $trialUntil->toIso8601ZuluString()) ->where('billing.trial_until', $trialUntil->toIso8601ZuluString())
->where('billing.is_blocked', false) ->where('billing.is_blocked', false)
->where('billing.goal_limit', null)
); );
} }
} }

View File

@@ -1,106 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Model;
use App\Enums\GoalType;
use App\Models\Goal;
use App\Models\Member;
use App\Models\Organization;
use Illuminate\Database\QueryException;
use PHPUnit\Framework\Attributes\CoversClass;
#[CoversClass(Goal::class)]
class GoalModelTest extends ModelTestAbstract
{
public function test_it_belongs_to_an_organization_and_a_member(): void
{
// Arrange
$organization = Organization::factory()->create();
$member = Member::factory()->forOrganization($organization)->create();
$goal = Goal::factory()->forMember($member)->create();
// Act
$goal->refresh();
// Assert
$this->assertTrue($goal->organization->is($organization));
$this->assertTrue($goal->member->is($member));
$this->assertTrue($goal->isPersonalGoalOf($member));
$this->assertTrue($member->goals->contains($goal));
}
public function test_factory_default_creates_the_member_in_the_organization_of_the_goal(): void
{
// Act
$goal = Goal::factory()->create();
// Assert
$this->assertNotNull($goal->member);
$this->assertSame($goal->organization_id, $goal->member->organization_id);
}
public function test_goal_for_every_member_has_no_member(): void
{
// Arrange
$organization = Organization::factory()->create();
$goal = Goal::factory()->forOrganization($organization)->forEveryMember()->create();
// Act
$goal->refresh();
// Assert
$this->assertNull($goal->member);
$this->assertNull($goal->member_id);
$this->assertSame(GoalType::Organization, $goal->type);
}
public function test_an_organization_goal_for_a_member_is_not_a_personal_goal_of_that_member(): void
{
// Arrange
$organization = Organization::factory()->create();
$member = Member::factory()->forOrganization($organization)->create();
$goal = Goal::factory()->organizationGoalForMember($member)->create();
// Act
$goal->refresh();
// Assert
$this->assertSame($member->getKey(), $goal->member_id);
$this->assertFalse($goal->isPersonalGoalOf($member));
}
public function test_archived_at_decides_the_is_archived_attribute_and_the_scopes(): void
{
// Arrange
$organization = Organization::factory()->create();
$goal = Goal::factory()->forOrganization($organization)->create();
$archivedGoal = Goal::factory()->forOrganization($organization)->archived()->create();
// Act
$notArchivedIds = Goal::query()->notArchived()->pluck('id')->all();
$archivedIds = Goal::query()->archived()->pluck('id')->all();
// Assert
$this->assertFalse($goal->refresh()->is_archived);
$this->assertTrue($archivedGoal->refresh()->is_archived);
$this->assertNotNull($archivedGoal->archived_at);
$this->assertSame([$goal->getKey()], $notArchivedIds);
$this->assertSame([$archivedGoal->getKey()], $archivedIds);
}
public function test_a_member_that_still_has_a_goal_can_not_be_deleted(): void
{
// Arrange
$organization = Organization::factory()->create();
$member = Member::factory()->forOrganization($organization)->create();
Goal::factory()->forMember($member)->create();
// Assert
$this->expectException(QueryException::class);
// Act
$member->delete();
}
}

View File

@@ -8,7 +8,6 @@ use App\Enums\Role;
use App\Events\BeforeOrganizationDeletion; use App\Events\BeforeOrganizationDeletion;
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers; use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
use App\Models\Client; use App\Models\Client;
use App\Models\Goal;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Passport\Client as PassportClient; use App\Models\Passport\Client as PassportClient;
@@ -60,8 +59,7 @@ class DeletionServiceTest extends TestCaseWithDatabase
* tasks: Collection<Task>, * tasks: Collection<Task>,
* timeEntries: Collection<TimeEntry>, * timeEntries: Collection<TimeEntry>,
* owner: User, * owner: User,
* reports: Collection<Report>, * reports: Collection<Report>
* goals: Collection<Goal>
* } * }
*/ */
private function createOrganizationWithAllRelations(): object private function createOrganizationWithAllRelations(): object
@@ -106,10 +104,6 @@ class DeletionServiceTest extends TestCaseWithDatabase
$report2 = Report::factory()->forOrganization($organization)->create(); $report2 = Report::factory()->forOrganization($organization)->create();
$reports = collect([$report1, $report2]); $reports = collect([$report1, $report2]);
$goal1 = Goal::factory()->forMember($memberOwner)->create();
$goal2 = Goal::factory()->forMember($memberEmployee)->create();
$goals = collect([$goal1, $goal2]);
$timeEntries = TimeEntry::factory()->forOrganization($organization)->forMember($memberOwner)->createMany(2); $timeEntries = TimeEntry::factory()->forOrganization($organization)->forMember($memberOwner)->createMany(2);
$timeEntriesWithTask = TimeEntry::factory()->forTask($task1)->forOrganization($organization)->forMember($memberEmployee)->createMany(2); $timeEntriesWithTask = TimeEntry::factory()->forTask($task1)->forOrganization($organization)->forMember($memberEmployee)->createMany(2);
$timeEntriesWithProject = TimeEntry::factory()->forProject($projectWithClient)->forOrganization($organization)->forMember($memberPlaceholder)->createMany(2); $timeEntriesWithProject = TimeEntry::factory()->forProject($projectWithClient)->forOrganization($organization)->forMember($memberPlaceholder)->createMany(2);
@@ -126,7 +120,6 @@ class DeletionServiceTest extends TestCaseWithDatabase
'timeEntries' => $timeEntries, 'timeEntries' => $timeEntries,
'owner' => $userOwner, 'owner' => $userOwner,
'reports' => $reports, 'reports' => $reports,
'goals' => $goals,
]; ];
} }
@@ -143,7 +136,6 @@ class DeletionServiceTest extends TestCaseWithDatabase
$this->assertSame(0, Member::query()->whereBelongsTo($organization, 'organization')->count()); $this->assertSame(0, Member::query()->whereBelongsTo($organization, 'organization')->count());
$this->assertSame(0, Task::query()->whereBelongsTo($organization, 'organization')->count()); $this->assertSame(0, Task::query()->whereBelongsTo($organization, 'organization')->count());
$this->assertSame(0, Report::query()->whereBelongsTo($organization, 'organization')->count()); $this->assertSame(0, Report::query()->whereBelongsTo($organization, 'organization')->count());
$this->assertSame(0, Goal::query()->whereBelongsTo($organization, 'organization')->count());
$this->assertSame(0, TimeEntry::query()->whereBelongsTo($organization, 'organization')->count()); $this->assertSame(0, TimeEntry::query()->whereBelongsTo($organization, 'organization')->count());
} }
@@ -157,7 +149,6 @@ class DeletionServiceTest extends TestCaseWithDatabase
$this->assertSame(3, Member::query()->whereBelongsTo($organization, 'organization')->count()); $this->assertSame(3, Member::query()->whereBelongsTo($organization, 'organization')->count());
$this->assertSame(2, Task::query()->whereBelongsTo($organization, 'organization')->count()); $this->assertSame(2, Task::query()->whereBelongsTo($organization, 'organization')->count());
$this->assertSame(2, Report::query()->whereBelongsTo($organization, 'organization')->count()); $this->assertSame(2, Report::query()->whereBelongsTo($organization, 'organization')->count());
$this->assertSame(2, Goal::query()->whereBelongsTo($organization, 'organization')->count());
$this->assertSame($specialCase ? 7 : 6, TimeEntry::query()->whereBelongsTo($organization, 'organization')->count()); $this->assertSame($specialCase ? 7 : 6, TimeEntry::query()->whereBelongsTo($organization, 'organization')->count());
} }

View File

@@ -1,100 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Service\Dto;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryType;
use App\Models\Goal;
use App\Service\Dto\GoalFiltersDto;
use PHPUnit\Framework\Attributes\CoversClass;
use Tests\TestCase;
#[CoversClass(GoalFiltersDto::class)]
class GoalFiltersDtoTest extends TestCase
{
public function test_get_throws_when_json_is_missing_a_required_property(): void
{
// Arrange
$caster = GoalFiltersDto::castUsing([]);
// Assert
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('The given JSON string does not contain the required property "memberIds"');
// Act
$caster->get(new Goal, 'filters', '{}', []);
}
public function test_get_returns_null_filters_when_all_properties_are_null(): void
{
// Arrange
$caster = GoalFiltersDto::castUsing([]);
$json = '{"memberIds":null,"projectIds":null,"taskIds":null,"tagIds":null,"tagMatchType":null,"clientIds":null,"billable":null,"timeEntryType":null}';
// Act
$dto = $caster->get(new Goal, 'filters', $json, []);
// Assert
$this->assertNull($dto->memberIds);
$this->assertNull($dto->projectIds);
$this->assertNull($dto->taskIds);
$this->assertNull($dto->tagIds);
$this->assertNull($dto->tagMatchType);
$this->assertNull($dto->clientIds);
$this->assertNull($dto->billable);
$this->assertNull($dto->timeEntryType);
}
public function test_set_and_get_round_trip_keeps_all_values(): void
{
// Arrange
$caster = GoalFiltersDto::castUsing([]);
$dto = new GoalFiltersDto;
$dto->setProjectIds(['none']);
$dto->setTaskIds(['9e2a7a1e-5a5e-4a0c-9d6b-3d5b2a1c0f11']);
$dto->setTagIds([]);
$dto->tagMatchType = TagMatchType::Contains;
$dto->setClientIds(['9e2a7a1e-5a5e-4a0c-9d6b-3d5b2a1c0f13']);
$dto->billable = false;
$dto->timeEntryType = TimeEntryType::Work;
// Act
$json = $caster->set(new Goal, 'filters', $dto, []);
$result = $caster->get(new Goal, 'filters', $json, []);
// Assert
$this->assertSame(['none'], $result->projectIds?->toArray());
$this->assertSame(['9e2a7a1e-5a5e-4a0c-9d6b-3d5b2a1c0f11'], $result->taskIds?->toArray());
$this->assertNull($result->tagIds);
$this->assertSame(TagMatchType::Contains, $result->tagMatchType);
$this->assertSame(['9e2a7a1e-5a5e-4a0c-9d6b-3d5b2a1c0f13'], $result->clientIds?->toArray());
$this->assertFalse($result->billable);
$this->assertSame(TimeEntryType::Work, $result->timeEntryType);
}
public function test_get_throws_for_invalid_json(): void
{
// Arrange
$caster = GoalFiltersDto::castUsing([]);
// Assert
$this->expectException(\InvalidArgumentException::class);
// Act
$caster->get(new Goal, 'filters', 'not json', []);
}
public function test_id_setters_reject_invalid_ids(): void
{
// Arrange
$dto = new GoalFiltersDto;
// Assert
$this->expectException(\InvalidArgumentException::class);
// Act
$dto->setProjectIds(['not-a-uuid']);
}
}

View File

@@ -1,471 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Service;
use App\Enums\GoalComparison;
use App\Enums\GoalPeriod;
use App\Enums\GoalStatus;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryType;
use App\Enums\Weekday;
use App\Models\Client;
use App\Models\Goal;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Models\User;
use App\Service\Dto\GoalFiltersDto;
use App\Service\GoalProgressService;
use App\Service\TimeEntryFilter;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use PHPUnit\Framework\Attributes\CoversClass;
use Tests\TestCase;
#[CoversClass(GoalProgressService::class)]
class GoalProgressServiceTest extends TestCase
{
use RefreshDatabase;
private GoalProgressService $service;
protected function setUp(): void
{
parent::setUp();
$this->service = app(GoalProgressService::class);
}
private function progressAt(Goal $goal, Carbon $now): int
{
Carbon::setTestNow($now);
return $this->service->getCurrentProgress($goal, $now)->trackedSeconds;
}
public function test_period_bounds_of_day_goal_are_calculated_in_the_timezone_of_the_goal(): void
{
// Arrange
$member = Member::factory()->create();
$goal = Goal::factory()->forMember($member)->timezone('Europe/Vienna')->period(GoalPeriod::Day)->create();
$date = Carbon::create(2024, 1, 1, 0, 30, 0, 'Europe/Vienna');
// Act
[$start, $end] = $this->service->getPeriodBounds($goal, $date);
// Assert
$this->assertSame('2023-12-31T23:00:00Z', $start->toIso8601ZuluString());
$this->assertSame('2024-01-01T23:00:00Z', $end->toIso8601ZuluString());
$this->assertSame('UTC', $start->getTimezone()->getName());
}
public function test_period_bounds_of_week_goal_respect_the_week_start_of_the_goal(): void
{
// Arrange
$member = Member::factory()->create();
$goal = Goal::factory()->forMember($member)->weekStart(Weekday::Sunday)->period(GoalPeriod::Week)->create();
// Wednesday
$date = Carbon::create(2024, 1, 3, 12, 0, 0, 'UTC');
// Act
[$start, $end] = $this->service->getPeriodBounds($goal, $date);
// Assert
// Sunday before
$this->assertSame('2023-12-31T00:00:00Z', $start->toIso8601ZuluString());
$this->assertSame('2024-01-07T00:00:00Z', $end->toIso8601ZuluString());
}
public function test_period_bounds_of_month_goal_cover_the_calendar_month(): void
{
// Arrange
$member = Member::factory()->create();
$goal = Goal::factory()->forMember($member)->timezone('America/New_York')->period(GoalPeriod::Month)->create();
$date = Carbon::create(2024, 2, 15, 12, 0, 0, 'America/New_York');
// Act
[$start, $end] = $this->service->getPeriodBounds($goal, $date);
// Assert
$this->assertSame('2024-02-01T05:00:00Z', $start->toIso8601ZuluString());
$this->assertSame('2024-03-01T05:00:00Z', $end->toIso8601ZuluString());
}
public function test_period_bounds_use_the_settings_pinned_on_the_goal_not_the_settings_of_the_member(): void
{
// Arrange
$organization = Organization::factory()->create();
$memberUser = User::factory()->create(['timezone' => 'America/New_York', 'week_start' => Weekday::Monday]);
$member = Member::factory()->forUser($memberUser)->forOrganization($organization)->create();
$goal = Goal::factory()->organizationGoalForMember($member)
->timezone('Europe/Vienna')
->weekStart(Weekday::Sunday)
->period(GoalPeriod::Week)
->create();
// Wednesday
$date = Carbon::create(2024, 1, 3, 12, 0, 0, 'UTC');
// Act
[$start, $end] = $this->service->getPeriodBounds($goal, $date);
// Assert
// Sunday 00:00 Vienna time
$this->assertSame('2023-12-30T23:00:00Z', $start->toIso8601ZuluString());
$this->assertSame('2024-01-06T23:00:00Z', $end->toIso8601ZuluString());
}
public function test_period_bounds_of_week_goal_spanning_the_end_of_daylight_saving_time_start_and_end_at_local_midnight(): void
{
// Arrange
$member = Member::factory()->create();
$goal = Goal::factory()->forMember($member)
->timezone('Europe/Vienna')
->weekStart(Weekday::Monday)
->period(GoalPeriod::Week)
->create();
// Friday before the switch on Sunday 2024-10-27
$date = Carbon::create(2024, 10, 25, 12, 0, 0, 'Europe/Vienna');
// Act
[$start, $end] = $this->service->getPeriodBounds($goal, $date);
// Assert
// Monday 00:00 CEST (UTC+2) to Monday 00:00 CET (UTC+1)
$this->assertSame('2024-10-20T22:00:00Z', $start->toIso8601ZuluString());
$this->assertSame('2024-10-27T23:00:00Z', $end->toIso8601ZuluString());
}
public function test_period_bounds_of_week_goal_use_the_local_weekday_when_it_differs_from_the_utc_weekday(): void
{
// Arrange
$member = Member::factory()->create();
$goal = Goal::factory()->forMember($member)
->timezone('Europe/Vienna')
->weekStart(Weekday::Monday)
->period(GoalPeriod::Week)
->create();
// Sunday 23:30 in UTC, but already Monday 00:30 in Vienna
$date = Carbon::create(2024, 1, 7, 23, 30, 0, 'UTC');
// Act
[$start, $end] = $this->service->getPeriodBounds($goal, $date);
// Assert
// The new week that started on Monday 00:00 in Vienna, not the week of the UTC Sunday
$this->assertSame('2024-01-07T23:00:00Z', $start->toIso8601ZuluString());
$this->assertSame('2024-01-14T23:00:00Z', $end->toIso8601ZuluString());
}
public function test_progress_assigns_entries_around_local_midnight_to_the_week_their_start_is_in(): void
{
// Arrange
$organization = Organization::factory()->create();
$member = Member::factory()->forOrganization($organization)->create();
$goal = Goal::factory()->forMember($member)
->timezone('Europe/Vienna')
->weekStart(Weekday::Monday)
->period(GoalPeriod::Week)
->create();
// Sunday 23:59 in Vienna, previous week
TimeEntry::factory()->forMember($member)->startWithDuration(Carbon::create(2024, 1, 7, 22, 59, 0, 'UTC'), 600)->create();
// Monday 00:00 in Vienna, first second of the week
TimeEntry::factory()->forMember($member)->startWithDuration(Carbon::create(2024, 1, 7, 23, 0, 0, 'UTC'), 300)->create();
// Act
$progress = $this->progressAt($goal, Carbon::create(2024, 1, 8, 12, 0, 0, 'UTC'));
// Assert
$this->assertSame(300, $progress);
}
public function test_progress_does_not_count_entries_that_start_at_the_end_of_the_period(): void
{
// Arrange
$organization = Organization::factory()->create();
$member = Member::factory()->forOrganization($organization)->create();
$goal = Goal::factory()->forMember($member)->timezone('Europe/Vienna')->period(GoalPeriod::Day)->create();
// 22:59:59 UTC is the last second of the day in Vienna
TimeEntry::factory()->forMember($member)->startWithDuration(Carbon::create(2024, 1, 1, 22, 59, 59, 'UTC'), 600)->create();
// 23:00 UTC is the first second of the next day in Vienna
TimeEntry::factory()->forMember($member)->startWithDuration(Carbon::create(2024, 1, 1, 23, 0, 0, 'UTC'), 300)->create();
// Act
$progress = $this->progressAt($goal, Carbon::create(2024, 1, 1, 12, 0, 0, 'Europe/Vienna'));
// Assert
$this->assertSame(600, $progress);
}
public function test_progress_of_goal_for_a_member_only_counts_that_member(): void
{
// Arrange
$organization = Organization::factory()->create();
$creator = Member::factory()->forOrganization($organization)->create();
$memberA = Member::factory()->forOrganization($organization)->create();
$memberB = Member::factory()->forOrganization($organization)->create();
$goal = Goal::factory()->organizationGoalForMember($memberA)->period(GoalPeriod::Day)->atLeast(3600)->create();
$now = Carbon::create(2024, 1, 3, 12, 0, 0, 'UTC');
TimeEntry::factory()->forMember($memberA)->startWithDuration($now->copy()->subHours(2), 1000)->create();
TimeEntry::factory()->forMember($memberB)->startWithDuration($now->copy()->subHours(2), 2000)->create();
TimeEntry::factory()->forMember($creator)->startWithDuration($now->copy()->subHours(2), 4000)->create();
// Act
$trackedSeconds = $this->progressAt($goal, $now);
// Assert
$this->assertSame(1000, $trackedSeconds);
}
public function test_progress_of_goal_for_every_member_counts_all_members_of_the_organization(): void
{
// Arrange
$organization = Organization::factory()->create();
$creator = Member::factory()->forOrganization($organization)->create();
$memberOfOtherOrganization = Member::factory()->create();
$memberA = Member::factory()->forOrganization($organization)->create();
$memberB = Member::factory()->forOrganization($organization)->create();
$goal = Goal::factory()->forOrganization($organization)->forEveryMember()->period(GoalPeriod::Day)->atLeast(3600)->create();
$now = Carbon::create(2024, 1, 3, 12, 0, 0, 'UTC');
TimeEntry::factory()->forMember($memberA)->startWithDuration($now->copy()->subHours(2), 1000)->create();
TimeEntry::factory()->forMember($memberB)->startWithDuration($now->copy()->subHours(2), 2000)->create();
TimeEntry::factory()->forMember($creator)->startWithDuration($now->copy()->subHours(2), 4000)->create();
TimeEntry::factory()->forMember($memberOfOtherOrganization)->startWithDuration($now->copy()->subHours(2), 8000)->create();
// Act
$trackedSeconds = $this->progressAt($goal, $now);
// Assert
$this->assertSame(7000, $trackedSeconds);
}
public function test_progress_of_goal_for_every_member_can_be_narrowed_down_with_the_member_ids_filter(): void
{
// Arrange
$organization = Organization::factory()->create();
$memberA = Member::factory()->forOrganization($organization)->create();
$memberB = Member::factory()->forOrganization($organization)->create();
$memberC = Member::factory()->forOrganization($organization)->create();
$filters = new GoalFiltersDto;
$filters->setMemberIds([$memberA->getKey(), $memberB->getKey()]);
$goal = Goal::factory()->forOrganization($organization)->forEveryMember()
->period(GoalPeriod::Day)->atLeast(3600)->filters($filters)->create();
$now = Carbon::create(2024, 1, 3, 12, 0, 0, 'UTC');
TimeEntry::factory()->forMember($memberA)->startWithDuration($now->copy()->subHours(2), 1000)->create();
TimeEntry::factory()->forMember($memberB)->startWithDuration($now->copy()->subHours(2), 2000)->create();
TimeEntry::factory()->forMember($memberC)->startWithDuration($now->copy()->subHours(2), 4000)->create();
// Act
$trackedSeconds = $this->progressAt($goal, $now);
// Assert
$this->assertSame(3000, $trackedSeconds);
}
public function test_progress_of_goal_for_one_member_ignores_the_member_ids_filter(): void
{
// Arrange
$organization = Organization::factory()->create();
$member = Member::factory()->forOrganization($organization)->create();
$otherMember = Member::factory()->forOrganization($organization)->create();
$filters = new GoalFiltersDto;
$filters->setMemberIds([$otherMember->getKey()]);
$goal = Goal::factory()->forMember($member)->period(GoalPeriod::Day)->atLeast(3600)->filters($filters)->create();
$now = Carbon::create(2024, 1, 3, 12, 0, 0, 'UTC');
TimeEntry::factory()->forMember($member)->startWithDuration($now->copy()->subHours(2), 1000)->create();
TimeEntry::factory()->forMember($otherMember)->startWithDuration($now->copy()->subHours(2), 2000)->create();
// Act
$trackedSeconds = $this->progressAt($goal, $now);
// Assert
$this->assertSame(1000, $trackedSeconds);
}
public function test_progress_counts_running_time_entry_up_to_now(): void
{
// Arrange
$organization = Organization::factory()->create();
$member = Member::factory()->forOrganization($organization)->create();
$goal = Goal::factory()->forMember($member)->period(GoalPeriod::Day)->atLeast(3600)->create();
$now = Carbon::create(2024, 1, 3, 12, 0, 0, 'UTC');
TimeEntry::factory()->forMember($member)->forOrganization($organization)
->startWithDuration($now->copy()->subHours(3), 600)
->create();
TimeEntry::factory()->forMember($member)->forOrganization($organization)
->start($now->copy()->subMinutes(30))
->active()
->create();
// Act
$trackedSeconds = $this->progressAt($goal, $now);
// Assert
$this->assertSame(600 + 1800, $trackedSeconds);
}
public function test_progress_does_not_count_running_time_entry_that_started_before_the_period(): void
{
// Arrange
$organization = Organization::factory()->create();
$member = Member::factory()->forOrganization($organization)->create();
$goal = Goal::factory()->forMember($member)->period(GoalPeriod::Day)->atLeast(3600)->create();
$now = Carbon::create(2024, 1, 3, 1, 0, 0, 'UTC');
TimeEntry::factory()->forMember($member)->forOrganization($organization)
->start($now->copy()->subHours(3))
->active()
->create();
// Act
$trackedSeconds = $this->progressAt($goal, $now);
// Assert
$this->assertSame(0, $trackedSeconds);
}
public function test_progress_applies_project_task_client_and_none_filters(): void
{
// Arrange
$organization = Organization::factory()->create();
$member = Member::factory()->forOrganization($organization)->create();
$client = Client::factory()->forOrganization($organization)->create();
$project = Project::factory()->forOrganization($organization)->forClient($client)->create();
$otherProject = Project::factory()->forOrganization($organization)->create();
$task = Task::factory()->forOrganization($organization)->forProject($project)->create();
$now = Carbon::create(2024, 1, 3, 12, 0, 0, 'UTC');
$start = $now->copy()->subHours(2);
TimeEntry::factory()->forMember($member)->forOrganization($organization)->forTask($task)
->startWithDuration($start, 100)->create();
TimeEntry::factory()->forMember($member)->forOrganization($organization)->forProject($project)
->startWithDuration($start, 200)->create();
TimeEntry::factory()->forMember($member)->forOrganization($organization)->forProject($otherProject)
->startWithDuration($start, 400)->create();
TimeEntry::factory()->forMember($member)->forOrganization($organization)
->startWithDuration($start, 800)->create();
$projectFilters = new GoalFiltersDto;
$projectFilters->setProjectIds([$project->getKey()]);
$taskFilters = new GoalFiltersDto;
$taskFilters->setTaskIds([$task->getKey()]);
$clientFilters = new GoalFiltersDto;
$clientFilters->setClientIds([$client->getKey()]);
$noneProjectFilters = new GoalFiltersDto;
$noneProjectFilters->setProjectIds([TimeEntryFilter::NONE_VALUE]);
$noFilters = new GoalFiltersDto;
$projectGoal = Goal::factory()->forMember($member)->period(GoalPeriod::Day)->filters($projectFilters)->create();
$taskGoal = Goal::factory()->forMember($member)->period(GoalPeriod::Day)->filters($taskFilters)->create();
$clientGoal = Goal::factory()->forMember($member)->period(GoalPeriod::Day)->filters($clientFilters)->create();
$noneProjectGoal = Goal::factory()->forMember($member)->period(GoalPeriod::Day)->filters($noneProjectFilters)->create();
$allGoal = Goal::factory()->forMember($member)->period(GoalPeriod::Day)->filters($noFilters)->create();
// Act & Assert
$this->assertSame(300, $this->progressAt($projectGoal, $now));
$this->assertSame(100, $this->progressAt($taskGoal, $now));
$this->assertSame(300, $this->progressAt($clientGoal, $now));
$this->assertSame(800, $this->progressAt($noneProjectGoal, $now));
$this->assertSame(1500, $this->progressAt($allGoal, $now));
}
public function test_progress_applies_tag_filters_with_match_type(): void
{
// Arrange
$organization = Organization::factory()->create();
$member = Member::factory()->forOrganization($organization)->create();
$tag = Tag::factory()->forOrganization($organization)->create();
$otherTag = Tag::factory()->forOrganization($organization)->create();
$now = Carbon::create(2024, 1, 3, 12, 0, 0, 'UTC');
$start = $now->copy()->subHours(2);
TimeEntry::factory()->forMember($member)->forOrganization($organization)
->startWithDuration($start, 100)->create(['tags' => [$tag->getKey()]]);
TimeEntry::factory()->forMember($member)->forOrganization($organization)
->startWithDuration($start, 200)->create(['tags' => [$otherTag->getKey()]]);
TimeEntry::factory()->forMember($member)->forOrganization($organization)
->startWithDuration($start, 400)->create(['tags' => []]);
$containsFilters = new GoalFiltersDto;
$containsFilters->setTagIds([$tag->getKey()]);
$containsFilters->tagMatchType = TagMatchType::Contains;
$notContainsFilters = new GoalFiltersDto;
$notContainsFilters->setTagIds([$tag->getKey()]);
$notContainsFilters->tagMatchType = TagMatchType::NotContains;
$noneFilters = new GoalFiltersDto;
$noneFilters->setTagIds([TimeEntryFilter::NONE_VALUE]);
$containsGoal = Goal::factory()->forMember($member)->period(GoalPeriod::Day)->filters($containsFilters)->create();
$notContainsGoal = Goal::factory()->forMember($member)->period(GoalPeriod::Day)->filters($notContainsFilters)->create();
$noneGoal = Goal::factory()->forMember($member)->period(GoalPeriod::Day)->filters($noneFilters)->create();
// Act & Assert
$this->assertSame(100, $this->progressAt($containsGoal, $now));
$this->assertSame(600, $this->progressAt($notContainsGoal, $now));
$this->assertSame(400, $this->progressAt($noneGoal, $now));
}
public function test_progress_applies_billable_and_time_entry_type_filters(): void
{
// Arrange
$organization = Organization::factory()->create();
$member = Member::factory()->forOrganization($organization)->create();
$now = Carbon::create(2024, 1, 3, 12, 0, 0, 'UTC');
$start = $now->copy()->subHours(2);
TimeEntry::factory()->forMember($member)->forOrganization($organization)->billable()
->startWithDuration($start, 100)->create();
TimeEntry::factory()->forMember($member)->forOrganization($organization)->notBillable()
->startWithDuration($start, 200)->create();
TimeEntry::factory()->forMember($member)->forOrganization($organization)->isBreak()
->startWithDuration($start, 400)->create();
$billableFilters = new GoalFiltersDto;
$billableFilters->billable = true;
$nonBillableFilters = new GoalFiltersDto;
$nonBillableFilters->billable = false;
$workFilters = new GoalFiltersDto;
$workFilters->timeEntryType = TimeEntryType::Work;
$breakFilters = new GoalFiltersDto;
$breakFilters->timeEntryType = TimeEntryType::Break;
$allFilters = new GoalFiltersDto;
$billableGoal = Goal::factory()->forMember($member)->period(GoalPeriod::Day)->filters($billableFilters)->create();
$nonBillableGoal = Goal::factory()->forMember($member)->period(GoalPeriod::Day)->filters($nonBillableFilters)->create();
$workGoal = Goal::factory()->forMember($member)->period(GoalPeriod::Day)->filters($workFilters)->create();
$breakGoal = Goal::factory()->forMember($member)->period(GoalPeriod::Day)->filters($breakFilters)->create();
$allGoal = Goal::factory()->forMember($member)->period(GoalPeriod::Day)->filters($allFilters)->create();
// Act & Assert
$this->assertSame(100, $this->progressAt($billableGoal, $now));
$this->assertSame(600, $this->progressAt($nonBillableGoal, $now));
$this->assertSame(300, $this->progressAt($workGoal, $now));
$this->assertSame(400, $this->progressAt($breakGoal, $now));
$this->assertSame(700, $this->progressAt($allGoal, $now));
}
public function test_status_of_at_least_goal_is_achieved_once_target_is_reached(): void
{
// Arrange
$goal = new Goal;
$goal->comparison = GoalComparison::AtLeast;
$goal->target_seconds = 3600;
// Act & Assert
$this->assertSame(GoalStatus::InProgress, $this->service->getStatus($goal, 0));
$this->assertSame(GoalStatus::InProgress, $this->service->getStatus($goal, 3599));
$this->assertSame(GoalStatus::Achieved, $this->service->getStatus($goal, 3600));
}
public function test_status_of_less_than_goal_is_exceeded_once_target_is_reached(): void
{
// Arrange
$goal = new Goal;
$goal->comparison = GoalComparison::LessThan;
$goal->target_seconds = 3600;
// Act & Assert
$this->assertSame(GoalStatus::OnTrack, $this->service->getStatus($goal, 0));
$this->assertSame(GoalStatus::OnTrack, $this->service->getStatus($goal, 3599));
$this->assertSame(GoalStatus::Exceeded, $this->service->getStatus($goal, 3600));
}
}

View File

@@ -5,15 +5,12 @@ declare(strict_types=1);
namespace Tests\Unit\Service; namespace Tests\Unit\Service;
use App\Enums\Role; use App\Enums\Role;
use App\Enums\TimeEntryType;
use App\Models\Goal;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectMember; use App\Models\ProjectMember;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use App\Models\User; use App\Models\User;
use App\Service\Dto\GoalFiltersDto;
use App\Service\MemberService; use App\Service\MemberService;
use App\Service\UserService; use App\Service\UserService;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
@@ -228,141 +225,6 @@ class MemberServiceTest extends TestCaseWithDatabase
$this->assertSame(0, ProjectMember::query()->whereBelongsTo($fromUserMember, 'member')->count()); $this->assertSame(0, ProjectMember::query()->whereBelongsTo($fromUserMember, 'member')->count());
} }
public function test_assign_organization_entities_to_different_member_moves_all_goals_of_the_member(): void
{
// Arrange
$organization = Organization::factory()->create();
$otherOrganization = Organization::factory()->create();
$fromMember = Member::factory()->forOrganization($organization)->create();
$toMember = Member::factory()->forOrganization($organization)->create();
$otherMember = Member::factory()->forOrganization($organization)->create();
$personalGoal = Goal::factory()->forMember($fromMember)->create();
$organizationGoalForFromMember = Goal::factory()->organizationGoalForMember($fromMember)->create();
$organizationGoalForOtherMember = Goal::factory()->organizationGoalForMember($otherMember)->create();
$goalForEveryMember = Goal::factory()->forOrganization($organization)->forEveryMember()->create();
$personalGoalOfOtherMember = Goal::factory()->forMember($otherMember)->create();
$goalOfOtherOrganization = Goal::factory()->forOrganization($otherOrganization)->create();
// Act
$this->memberService->assignOrganizationEntitiesToDifferentMember($organization, $fromMember, $toMember);
// Assert
$this->assertSame($toMember->getKey(), $personalGoal->refresh()->member_id);
$this->assertSame($toMember->getKey(), $organizationGoalForFromMember->refresh()->member_id);
$this->assertSame($otherMember->getKey(), $organizationGoalForOtherMember->refresh()->member_id);
$this->assertNull($goalForEveryMember->refresh()->member_id);
$this->assertSame($otherMember->getKey(), $personalGoalOfOtherMember->refresh()->member_id);
$this->assertDatabaseHas(Goal::class, ['id' => $goalOfOtherOrganization->getKey()]);
$this->assertSame(0, $fromMember->goals()->count());
}
public function test_assign_organization_entities_to_different_member_replaces_the_member_in_goal_member_filters(): void
{
// Arrange
$organization = Organization::factory()->create();
$otherOrganization = Organization::factory()->create();
$fromMember = Member::factory()->forOrganization($organization)->create();
$toMember = Member::factory()->forOrganization($organization)->create();
$otherMember = Member::factory()->forOrganization($organization)->create();
$goalWithFromMember = Goal::factory()->forOrganization($organization)->forEveryMember()
->filters($this->memberFilter([$fromMember->getKey(), $otherMember->getKey()]))->create();
$goalWithBothMembers = Goal::factory()->forOrganization($organization)->forEveryMember()
->filters($this->memberFilter([$toMember->getKey(), $fromMember->getKey()]))->create();
$goalWithoutFromMember = Goal::factory()->forOrganization($organization)->forEveryMember()
->filters($this->memberFilter([$otherMember->getKey()]))->create();
$goalWithoutMemberFilter = Goal::factory()->forOrganization($organization)->forEveryMember()->create();
// Can not happen through the API, but a filter of another organization must stay untouched
$goalOfOtherOrganization = Goal::factory()->forOrganization($otherOrganization)->forEveryMember()
->filters($this->memberFilter([$fromMember->getKey()]))->create();
// Act
$this->memberService->assignOrganizationEntitiesToDifferentMember($organization, $fromMember, $toMember);
// Assert
$this->assertSame([$toMember->getKey(), $otherMember->getKey()], $goalWithFromMember->refresh()->filters->memberIds?->all());
// No duplicate if $toMember was already in the filter
$this->assertSame([$toMember->getKey()], $goalWithBothMembers->refresh()->filters->memberIds?->all());
$this->assertSame([$otherMember->getKey()], $goalWithoutFromMember->refresh()->filters->memberIds?->all());
$this->assertNull($goalWithoutMemberFilter->refresh()->filters->memberIds);
$this->assertSame([$fromMember->getKey()], $goalOfOtherOrganization->refresh()->filters->memberIds?->all());
}
/**
* @param array<string> $memberIds
*/
private function memberFilter(array $memberIds): GoalFiltersDto
{
$filters = new GoalFiltersDto;
$filters->timeEntryType = TimeEntryType::Work;
$filters->setMemberIds($memberIds);
return $filters;
}
public function test_make_member_to_placeholder_keeps_all_goals_of_the_member(): void
{
// Arrange
$organization = Organization::factory()->create();
$user = User::factory()->create();
$member = Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Employee)->create();
$otherMember = Member::factory()->forOrganization($organization)->create();
$personalGoal = Goal::factory()->forMember($member)->create();
$organizationGoal = Goal::factory()->organizationGoalForMember($member)->create();
$goalOfOtherMember = Goal::factory()->forMember($otherMember)->create();
// Act
$this->memberService->makeMemberToPlaceholder($member);
// Assert
$member->refresh();
$this->assertTrue($member->user->is_placeholder);
$this->assertDatabaseHas(Goal::class, ['id' => $personalGoal->getKey(), 'member_id' => $member->getKey()]);
$this->assertDatabaseHas(Goal::class, ['id' => $organizationGoal->getKey(), 'member_id' => $member->getKey()]);
$this->assertDatabaseHas(Goal::class, ['id' => $goalOfOtherMember->getKey()]);
}
public function test_remove_member_deletes_the_goals_of_the_member(): void
{
// Arrange
$organization = Organization::factory()->create();
$user = User::factory()->create();
$member = Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Employee)->create();
$otherMember = Member::factory()->forOrganization($organization)->create();
$personalGoal = Goal::factory()->forMember($member)->create();
$organizationGoal = Goal::factory()->organizationGoalForMember($member)->create();
$goalForEveryMember = Goal::factory()->forOrganization($organization)->forEveryMember()->create();
$goalOfOtherMember = Goal::factory()->forMember($otherMember)->create();
// Act
$this->memberService->removeMember($member, $organization);
// Assert
$this->assertDatabaseMissing(Member::class, ['id' => $member->getKey()]);
$this->assertDatabaseMissing(Goal::class, ['id' => $personalGoal->getKey()]);
$this->assertDatabaseMissing(Goal::class, ['id' => $organizationGoal->getKey()]);
$this->assertDatabaseHas(Goal::class, ['id' => $goalForEveryMember->getKey()]);
$this->assertDatabaseHas(Goal::class, ['id' => $goalOfOtherMember->getKey()]);
}
public function test_add_member_moves_all_goals_of_placeholder_with_same_email_to_new_member(): void
{
// Arrange
$organization = Organization::factory()->create();
$user = User::factory()->create();
$member = Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Employee)->create();
$personalGoal = Goal::factory()->forMember($member)->create();
$organizationGoal = Goal::factory()->organizationGoalForMember($member)->create();
$this->memberService->makeMemberToPlaceholder($member);
// Act
$newMember = $this->memberService->addMember($user, $organization, Role::Employee);
// Assert
$this->assertDatabaseMissing(Member::class, ['id' => $member->getKey()]);
$this->assertDatabaseHas(Goal::class, ['id' => $personalGoal->getKey(), 'member_id' => $newMember->getKey()]);
$this->assertDatabaseHas(Goal::class, ['id' => $organizationGoal->getKey(), 'member_id' => $newMember->getKey()]);
}
public function test_assign_organization_entities_to_different_member_with_entries(): void public function test_assign_organization_entities_to_different_member_with_entries(): void
{ {
// Arrange // Arrange