mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-15 17:23:16 +01:00
Add goals feature for personal goals with filters
This commit is contained in:
22
app/Enums/GoalComparison.php
Normal file
22
app/Enums/GoalComparison.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?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';
|
||||
}
|
||||
16
app/Enums/GoalPeriod.php
Normal file
16
app/Enums/GoalPeriod.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?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';
|
||||
}
|
||||
32
app/Enums/GoalStatus.php
Normal file
32
app/Enums/GoalStatus.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?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';
|
||||
}
|
||||
24
app/Enums/GoalType.php
Normal file
24
app/Enums/GoalType.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?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';
|
||||
}
|
||||
173
app/Filament/Resources/GoalResource.php
Normal file
173
app/Filament/Resources/GoalResource.php
Normal file
@@ -0,0 +1,173 @@
|
||||
<?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 (seconds)')
|
||||
->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}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
22
app/Filament/Resources/GoalResource/Pages/EditGoal.php
Normal file
22
app/Filament/Resources/GoalResource/Pages/EditGoal.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Filament/Resources/GoalResource/Pages/ListGoals.php
Normal file
19
app/Filament/Resources/GoalResource/Pages/ListGoals.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?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 [
|
||||
];
|
||||
}
|
||||
}
|
||||
22
app/Filament/Resources/GoalResource/Pages/ViewGoal.php
Normal file
22
app/Filament/Resources/GoalResource/Pages/ViewGoal.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
241
app/Http/Controllers/Api/V1/GoalController.php
Normal file
241
app/Http/Controllers/Api/V1/GoalController.php
Normal file
@@ -0,0 +1,241 @@
|
||||
<?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\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): 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'));
|
||||
|
||||
return new GoalCollection($goals);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get goal
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getGoal
|
||||
*/
|
||||
public function show(Organization $organization, Goal $goal, GoalsContract $access): 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create goal
|
||||
*
|
||||
* @throws AuthorizationException|FeatureIsNotAvailableInFreePlanApiException
|
||||
*
|
||||
* @operationId createGoal
|
||||
*/
|
||||
public function store(Organization $organization, GoalStoreRequest $request, GoalsContract $access): 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))
|
||||
->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): 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ class TimeZoneController extends Controller
|
||||
/**
|
||||
* Get all timezones
|
||||
*
|
||||
* @response object{key: string}[]
|
||||
* @response array{key: string}[]
|
||||
*
|
||||
* @operationId getTimezones
|
||||
*/
|
||||
|
||||
@@ -4,9 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Enums\Weekday;
|
||||
use App\Service\Dto\UserAgentDto;
|
||||
use App\Service\TimezoneService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -94,8 +92,6 @@ class UserProfileController extends Controller
|
||||
$this->validateTwoFactorAuthenticationState($request);
|
||||
|
||||
return Inertia::render('Profile/Show', [
|
||||
'timezones' => app(TimezoneService::class)->getSelectOptions(),
|
||||
'weekdays' => Weekday::toSelectArray(),
|
||||
'confirmsTwoFactorAuthentication' => Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'),
|
||||
'sessions' => $this->sessions($request),
|
||||
]);
|
||||
|
||||
@@ -42,6 +42,7 @@ class HandleInertiaRequests extends Middleware
|
||||
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
|
||||
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
|
||||
$hasServices = Module::has('Services') && Module::isEnabled('Services');
|
||||
$hasGoals = Module::has('Goals') && Module::isEnabled('Goals');
|
||||
|
||||
/** @var BillingContract $billing */
|
||||
$billing = app(BillingContract::class);
|
||||
@@ -52,11 +53,13 @@ class HandleInertiaRequests extends Middleware
|
||||
'has_billing_extension' => $hasBilling,
|
||||
'has_invoicing_extension' => $hasInvoicing,
|
||||
'has_services_extension' => $hasServices,
|
||||
'has_goals_extension' => $hasGoals,
|
||||
'billing' => $currentOrganization !== null ? [
|
||||
'has_subscription' => $billing->hasSubscription($currentOrganization),
|
||||
'has_trial' => $billing->hasTrial($currentOrganization),
|
||||
'trial_until' => $billing->getTrialUntil($currentOrganization)?->toIso8601ZuluString(),
|
||||
'is_blocked' => $billing->isBlocked($currentOrganization),
|
||||
'goal_limit' => $billing->getGoalLimit($currentOrganization),
|
||||
] : null,
|
||||
'flash' => [
|
||||
'message' => fn () => $request->session()->get('message'),
|
||||
|
||||
143
app/Http/Requests/V1/Goal/GoalFilterRules.php
Normal file
143
app/Http/Requests/V1/Goal/GoalFilterRules.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
61
app/Http/Requests/V1/Goal/GoalIndexRequest.php
Normal file
61
app/Http/Requests/V1/Goal/GoalIndexRequest.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
167
app/Http/Requests/V1/Goal/GoalStoreRequest.php
Normal file
167
app/Http/Requests/V1/Goal/GoalStoreRequest.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?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'));
|
||||
}
|
||||
}
|
||||
129
app/Http/Requests/V1/Goal/GoalUpdateRequest.php
Normal file
129
app/Http/Requests/V1/Goal/GoalUpdateRequest.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
18
app/Http/Resources/V1/Goal/GoalCollection.php
Normal file
18
app/Http/Resources/V1/Goal/GoalCollection.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\Goal;
|
||||
|
||||
use App\Http\Resources\PaginatedResourceCollection;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class GoalCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $collects = GoalResource::class;
|
||||
}
|
||||
87
app/Http/Resources/V1/Goal/GoalResource.php
Normal file
87
app/Http/Resources/V1/Goal/GoalResource.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\Goal;
|
||||
|
||||
use App\Http\Resources\V1\BaseResource;
|
||||
use App\Models\Goal;
|
||||
use App\Service\GoalProgressService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @property Goal $resource
|
||||
*/
|
||||
class GoalResource extends BaseResource
|
||||
{
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$progressService = app(GoalProgressService::class);
|
||||
$now = Carbon::now();
|
||||
[$periodStart, $periodEnd] = $progressService->getPeriodBounds($this->resource, $now);
|
||||
$trackedSeconds = $progressService->getProgress($this->resource, $periodStart, $periodEnd);
|
||||
|
||||
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($periodStart),
|
||||
/** @var string $period_end End of the current period (exclusive) */
|
||||
'period_end' => $this->formatDateTime($periodEnd),
|
||||
/** @var int $tracked_seconds Seconds tracked in the current period that match the filters, incl. the running time entry */
|
||||
'tracked_seconds' => $trackedSeconds,
|
||||
/** @var string $status Status in the current period (in_progress, achieved, on_track, exceeded) */
|
||||
'status' => $progressService->getStatus($this->resource, $trackedSeconds)->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),
|
||||
];
|
||||
}
|
||||
}
|
||||
114
app/Models/Goal.php
Normal file
114
app/Models/Goal.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?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,6 +28,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @property-read User $user
|
||||
* @property-read Collection<int, ProjectMember> $projectMembers
|
||||
* @property-read Collection<int, TimeEntry> $timeEntries
|
||||
* @property-read Collection<int, Goal> $goals
|
||||
*
|
||||
* @method static MemberFactory factory()
|
||||
*/
|
||||
@@ -78,4 +79,14 @@ class Member extends Pivot implements AuditableContract
|
||||
{
|
||||
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,6 +17,7 @@ use App\Models\Task;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
use App\Service\BillingContract;
|
||||
use App\Service\GoalsContract;
|
||||
use App\Service\IpLookup\IpLookupServiceContract;
|
||||
use App\Service\IpLookup\NoIpLookupService;
|
||||
use App\Service\PermissionStore;
|
||||
@@ -103,6 +104,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
// Extensions
|
||||
$this->app->bind(IpLookupServiceContract::class, NoIpLookupService::class);
|
||||
$this->app->bind(BillingContract::class);
|
||||
$this->app->bind(GoalsContract::class);
|
||||
|
||||
// Storage
|
||||
// The local driver ignores the ResponseContentDisposition option of temporaryUrl,
|
||||
|
||||
@@ -43,6 +43,15 @@ class BillingContract
|
||||
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
|
||||
* A blocked organization is an organization that has more than 1 non-placeholder member but no subscription/trial
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Enums\Role;
|
||||
use App\Events\BeforeOrganizationDeletion;
|
||||
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
||||
use App\Models\Client;
|
||||
use App\Models\Goal;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
@@ -76,6 +77,9 @@ class DeletionService
|
||||
// Delete all reports
|
||||
Report::query()->whereBelongsTo($organization, 'organization')->delete();
|
||||
|
||||
// Delete all goals
|
||||
Goal::query()->whereBelongsTo($organization, 'organization')->delete();
|
||||
|
||||
// Reset the current organization
|
||||
$organization->owner()
|
||||
->where('current_team_id', $organization->getKey())
|
||||
|
||||
169
app/Service/Dto/GoalFiltersDto.php
Normal file
169
app/Service/Dto/GoalFiltersDto.php
Normal file
@@ -0,0 +1,169 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@ use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class ReportPropertiesDto implements Castable
|
||||
@@ -117,13 +116,13 @@ class ReportPropertiesDto implements Castable
|
||||
$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->active = $data->active;
|
||||
$dto->memberIds = $data->memberIds !== null ? ReportPropertiesDto::idArrayToCollection($data->memberIds) : null;
|
||||
$dto->memberIds = $data->memberIds !== null ? TimeEntryFilter::idArrayToCollection($data->memberIds) : null;
|
||||
$dto->billable = $data->billable;
|
||||
$dto->clientIds = $data->clientIds !== null ? ReportPropertiesDto::idArrayToCollection($data->clientIds) : null;
|
||||
$dto->projectIds = $data->projectIds !== null ? ReportPropertiesDto::idArrayToCollection($data->projectIds) : null;
|
||||
$dto->tagIds = $data->tagIds !== null ? ReportPropertiesDto::idArrayToCollection($data->tagIds) : null;
|
||||
$dto->clientIds = $data->clientIds !== null ? TimeEntryFilter::idArrayToCollection($data->clientIds) : null;
|
||||
$dto->projectIds = $data->projectIds !== null ? TimeEntryFilter::idArrayToCollection($data->projectIds) : null;
|
||||
$dto->tagIds = $data->tagIds !== null ? TimeEntryFilter::idArrayToCollection($data->tagIds) : null;
|
||||
$dto->tagMatchType = isset($data->tagMatchType) ? TagMatchType::from($data->tagMatchType) : null;
|
||||
$dto->taskIds = $data->taskIds ? ReportPropertiesDto::idArrayToCollection($data->taskIds) : null;
|
||||
$dto->taskIds = $data->taskIds ? TimeEntryFilter::idArrayToCollection($data->taskIds) : null;
|
||||
$dto->group = TimeEntryAggregationType::from($data->group);
|
||||
$dto->subGroup = TimeEntryAggregationType::from($data->subGroup);
|
||||
$dto->historyGroup = TimeEntryAggregationTypeInterval::from($data->historyGroup);
|
||||
@@ -180,32 +179,12 @@ 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
|
||||
*/
|
||||
public function setMemberIds(?array $memberIds): void
|
||||
{
|
||||
$this->memberIds = $memberIds !== null ? ReportPropertiesDto::idArrayToCollection($memberIds) : null;
|
||||
$this->memberIds = $memberIds !== null ? TimeEntryFilter::idArrayToCollection($memberIds) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -213,7 +192,7 @@ class ReportPropertiesDto implements Castable
|
||||
*/
|
||||
public function setClientIds(?array $clientIds): void
|
||||
{
|
||||
$this->clientIds = $clientIds !== null ? ReportPropertiesDto::idArrayToCollection($clientIds) : null;
|
||||
$this->clientIds = $clientIds !== null ? TimeEntryFilter::idArrayToCollection($clientIds) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -221,7 +200,7 @@ class ReportPropertiesDto implements Castable
|
||||
*/
|
||||
public function setProjectIds(?array $projectIds): void
|
||||
{
|
||||
$this->projectIds = $projectIds !== null ? ReportPropertiesDto::idArrayToCollection($projectIds) : null;
|
||||
$this->projectIds = $projectIds !== null ? TimeEntryFilter::idArrayToCollection($projectIds) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -229,7 +208,7 @@ class ReportPropertiesDto implements Castable
|
||||
*/
|
||||
public function setTagIds(?array $tagIds): void
|
||||
{
|
||||
$this->tagIds = $tagIds !== null ? ReportPropertiesDto::idArrayToCollection($tagIds) : null;
|
||||
$this->tagIds = $tagIds !== null ? TimeEntryFilter::idArrayToCollection($tagIds) : null;
|
||||
}
|
||||
|
||||
public function setTagMatchType(?TagMatchType $tagMatchType): void
|
||||
@@ -242,6 +221,6 @@ class ReportPropertiesDto implements Castable
|
||||
*/
|
||||
public function setTaskIds(?array $taskIds): void
|
||||
{
|
||||
$this->taskIds = $taskIds !== null ? ReportPropertiesDto::idArrayToCollection($taskIds) : null;
|
||||
$this->taskIds = $taskIds !== null ? TimeEntryFilter::idArrayToCollection($taskIds) : null;
|
||||
}
|
||||
}
|
||||
|
||||
92
app/Service/GoalProgressService.php
Normal file
92
app/Service/GoalProgressService.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?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 Illuminate\Support\Carbon;
|
||||
|
||||
class GoalProgressService
|
||||
{
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
public 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
64
app/Service/GoalsContract.php
Normal file
64
app/Service/GoalsContract.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?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,6 +14,7 @@ use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
|
||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
||||
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
||||
use App\Models\Goal;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
@@ -109,6 +110,12 @@ 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();
|
||||
|
||||
if ($isPlaceholder) {
|
||||
@@ -181,6 +188,30 @@ class MemberService
|
||||
->whereBelongsToOrganization($organization)
|
||||
->whereBelongsTo($fromMember, 'member')
|
||||
->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,6 +75,14 @@ class PermissionStore
|
||||
'reports:create',
|
||||
'reports:update',
|
||||
'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:create',
|
||||
'invoices:update',
|
||||
@@ -146,6 +154,14 @@ class PermissionStore
|
||||
'reports:create',
|
||||
'reports:update',
|
||||
'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:create',
|
||||
'invoices:update',
|
||||
@@ -206,6 +222,14 @@ class PermissionStore
|
||||
'reports:create',
|
||||
'reports:update',
|
||||
'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:create',
|
||||
'invoices:update',
|
||||
@@ -233,6 +257,10 @@ class PermissionStore
|
||||
'time-entries:update:own',
|
||||
'time-entries:delete:own',
|
||||
'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.',
|
||||
],
|
||||
|
||||
@@ -10,7 +10,9 @@ use App\Models\Member;
|
||||
use App\Models\TimeEntry;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class TimeEntryFilter
|
||||
{
|
||||
@@ -29,6 +31,26 @@ class TimeEntryFilter
|
||||
$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
|
||||
{
|
||||
if ($dateTime === null) {
|
||||
|
||||
Reference in New Issue
Block a user