mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-08 05:43:18 +01:00
Compare commits
2 Commits
feature/go
...
fix/db-ssl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7b0aa0b55 | ||
|
|
bb5a7fb9f9 |
@@ -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';
|
|
||||||
}
|
|
||||||
@@ -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';
|
|
||||||
}
|
|
||||||
@@ -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';
|
|
||||||
}
|
|
||||||
@@ -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';
|
|
||||||
}
|
|
||||||
@@ -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}'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 [
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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.',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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,
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -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'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
|||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
110
e2e/utils/api.ts
110
e2e/utils/api.ts
@@ -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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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" />
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'>;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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';
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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' },
|
|
||||||
];
|
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
@@ -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', [])
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user