mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-16 09:43:18 +01:00
Add goals feature for personal goals with filters
This commit is contained in:
@@ -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