mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-14 19:22:14 +01:00
Added billable rates; Added project members; Added visibility to projects
This commit is contained in:
@@ -5,18 +5,29 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Service\PermissionStore;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class Controller extends \App\Http\Controllers\Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected PermissionStore $permissionStore,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
protected function checkPermission(Organization $organization, string $permission): void
|
||||
{
|
||||
if (! Auth::user()->hasTeamPermission($organization, $permission)) {
|
||||
if (! $this->permissionStore->has($organization, $permission)) {
|
||||
throw new AuthorizationException();
|
||||
}
|
||||
}
|
||||
|
||||
protected function hasPermission(Organization $organization, string $permission): bool
|
||||
{
|
||||
return $this->permissionStore->has($organization, $permission);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Exceptions\Api\UserNotPlaceholderApiException;
|
||||
use App\Http\Requests\V1\User\UserIndexRequest;
|
||||
use App\Http\Requests\V1\Member\MemberIndexRequest;
|
||||
use App\Http\Resources\V1\User\MemberCollection;
|
||||
use App\Http\Resources\V1\User\MemberResource;
|
||||
use App\Models\Organization;
|
||||
@@ -24,14 +24,14 @@ class MemberController extends Controller
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function index(Organization $organization, UserIndexRequest $request): MemberCollection
|
||||
public function index(Organization $organization, MemberIndexRequest $request): MemberCollection
|
||||
{
|
||||
$this->checkPermission($organization, 'users:view');
|
||||
$this->checkPermission($organization, 'members:view');
|
||||
|
||||
$users = $organization->users()
|
||||
$members = $organization->users()
|
||||
->paginate();
|
||||
|
||||
return MemberCollection::make($users);
|
||||
return MemberCollection::make($members);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,7 +41,7 @@ class MemberController extends Controller
|
||||
*/
|
||||
public function invitePlaceholder(Organization $organization, User $user, Request $request): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'users:invite-placeholder');
|
||||
$this->checkPermission($organization, 'members:invite-placeholder');
|
||||
|
||||
if (! $user->is_placeholder) {
|
||||
throw new UserNotPlaceholderApiException();
|
||||
|
||||
@@ -33,6 +33,7 @@ class OrganizationController extends Controller
|
||||
$this->checkPermission($organization, 'organizations:update');
|
||||
|
||||
$organization->name = $request->input('name');
|
||||
$organization->billable_rate = $request->input('billable_rate');
|
||||
$organization->save();
|
||||
|
||||
return new OrganizationResource($organization);
|
||||
|
||||
@@ -10,9 +10,12 @@ use App\Http\Resources\V1\Project\ProjectCollection;
|
||||
use App\Http\Resources\V1\Project\ProjectResource;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class ProjectController extends Controller
|
||||
{
|
||||
@@ -36,9 +39,23 @@ class ProjectController extends Controller
|
||||
public function index(Organization $organization): ProjectCollection
|
||||
{
|
||||
$this->checkPermission($organization, 'projects:view');
|
||||
$projects = Project::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->paginate();
|
||||
$canViewAllProjects = $this->hasPermission($organization, 'projects:view:all');
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
$projectsQuery = Project::query()
|
||||
->whereBelongsTo($organization, 'organization');
|
||||
|
||||
if (! $canViewAllProjects) {
|
||||
$projectsQuery->where(function (Builder $builder) use ($user): Builder {
|
||||
return $builder->where('is_public', '=', true)
|
||||
->orWhereHas('members', function (Builder $builder) use ($user): Builder {
|
||||
return $builder->whereBelongsTo($user, 'user');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$projects = $projectsQuery->paginate();
|
||||
|
||||
return new ProjectCollection($projects);
|
||||
}
|
||||
@@ -72,6 +89,7 @@ class ProjectController extends Controller
|
||||
$project = new Project();
|
||||
$project->name = $request->input('name');
|
||||
$project->color = $request->input('color');
|
||||
$project->billable_rate = $request->input('billable_rate');
|
||||
$project->client_id = $request->input('client_id');
|
||||
$project->organization()->associate($organization);
|
||||
$project->save();
|
||||
@@ -91,7 +109,8 @@ class ProjectController extends Controller
|
||||
$this->checkPermission($organization, 'projects:update', $project);
|
||||
$project->name = $request->input('name');
|
||||
$project->color = $request->input('color');
|
||||
$project->client_id = $request->input('project_id');
|
||||
$project->billable_rate = $request->input('billable_rate');
|
||||
$project->client_id = $request->input('client_id');
|
||||
$project->save();
|
||||
|
||||
return new ProjectResource($project);
|
||||
|
||||
102
app/Http/Controllers/Api/V1/ProjectMemberController.php
Normal file
102
app/Http/Controllers/Api/V1/ProjectMemberController.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Requests\V1\ProjectMember\ProjectMemberStoreRequest;
|
||||
use App\Http\Requests\V1\ProjectMember\ProjectMemberUpdateRequest;
|
||||
use App\Http\Resources\V1\ProjectMember\ProjectMemberCollection;
|
||||
use App\Http\Resources\V1\ProjectMember\ProjectMemberResource;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class ProjectMemberController extends Controller
|
||||
{
|
||||
protected function checkPermission(Organization $organization, string $permission, ?Project $project = null, ?ProjectMember $projectMember = null): void
|
||||
{
|
||||
parent::checkPermission($organization, $permission);
|
||||
if ($project !== null && $project->organization_id !== $organization->id) {
|
||||
throw new AuthorizationException('Project does not belong to organization');
|
||||
}
|
||||
if ($projectMember !== null && $projectMember->project->organization_id !== $organization->id) {
|
||||
throw new AuthorizationException('Project member does not belong to organization');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project members for project
|
||||
*
|
||||
* @return ProjectMemberCollection<ProjectMemberResource>
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getProjectMembers
|
||||
*/
|
||||
public function index(Organization $organization, Project $project): ProjectMemberCollection
|
||||
{
|
||||
$this->checkPermission($organization, 'project-members:view', $project);
|
||||
|
||||
$projectMembers = ProjectMember::query()
|
||||
->whereBelongsTo($project, 'project')
|
||||
->paginate();
|
||||
|
||||
return new ProjectMemberCollection($projectMembers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add project member to project
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId createProjectMember
|
||||
*/
|
||||
public function store(Organization $organization, Project $project, ProjectMemberStoreRequest $request): JsonResource
|
||||
{
|
||||
$this->checkPermission($organization, 'project-members:create', $project);
|
||||
$projectMember = new ProjectMember();
|
||||
$projectMember->user_id = $request->input('user_id');
|
||||
$projectMember->billable_rate = $request->input('billable_rate');
|
||||
$projectMember->project()->associate($project);
|
||||
$projectMember->save();
|
||||
|
||||
return new ProjectMemberResource($projectMember);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update project member
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId updateProjectMember
|
||||
*/
|
||||
public function update(Organization $organization, ProjectMember $projectMember, ProjectMemberUpdateRequest $request): JsonResource
|
||||
{
|
||||
$this->checkPermission($organization, 'project-members:update', projectMember: $projectMember);
|
||||
$projectMember->billable_rate = $request->input('billable_rate');
|
||||
$projectMember->save();
|
||||
|
||||
return new ProjectMemberResource($projectMember);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete project member
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId deleteProjectMember
|
||||
*/
|
||||
public function destroy(Organization $organization, ProjectMember $projectMember): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'project-members:delete', projectMember: $projectMember);
|
||||
|
||||
$projectMember->delete();
|
||||
|
||||
return response()
|
||||
->json(null, 204);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
||||
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
||||
use App\Http\Requests\V1\TimeEntry\TimeEntryIndexRequest;
|
||||
use App\Http\Requests\V1\TimeEntry\TimeEntryStoreRequest;
|
||||
@@ -71,6 +72,7 @@ class TimeEntryController extends Controller
|
||||
$timeEntries = $timeEntriesQuery->get();
|
||||
|
||||
if ($timeEntries->count() === $limit && $request->has('only_full_dates') && (bool) $request->get('only_full_dates') === true) {
|
||||
// TODO: handle user timezone!
|
||||
$lastDate = null;
|
||||
/** @var TimeEntry $timeEntry */
|
||||
foreach ($timeEntries as $timeEntry) {
|
||||
@@ -125,6 +127,7 @@ class TimeEntryController extends Controller
|
||||
$timeEntry->fill($request->validated());
|
||||
$timeEntry->description = $request->get('description') ?? '';
|
||||
$timeEntry->organization()->associate($organization);
|
||||
$timeEntry->setComputedAttributeValue('billable_rate');
|
||||
$timeEntry->save();
|
||||
|
||||
return new TimeEntryResource($timeEntry);
|
||||
@@ -133,7 +136,7 @@ class TimeEntryController extends Controller
|
||||
/**
|
||||
* Update time entry
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws AuthorizationException|TimeEntryCanNotBeRestartedApiException
|
||||
*
|
||||
* @operationId updateTimeEntry
|
||||
*/
|
||||
@@ -145,7 +148,9 @@ class TimeEntryController extends Controller
|
||||
$this->checkPermission($organization, 'time-entries:update:all', $timeEntry);
|
||||
}
|
||||
|
||||
// TODO: TimeEntryStillRunningApiException
|
||||
if ($timeEntry->end !== null && $request->has('end') && $request->get('end') === null) {
|
||||
throw new TimeEntryCanNotBeRestartedApiException();
|
||||
}
|
||||
|
||||
$timeEntry->fill($request->validated());
|
||||
$timeEntry->description = $request->get('description', $timeEntry->description) ?? '';
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\DashboardService;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -16,27 +17,17 @@ class DashboardController extends Controller
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = auth()->user();
|
||||
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, 60);
|
||||
$weeklyHistory = $dashboardService->getWeeklyHistory($user);
|
||||
/** @var Organization $organization */
|
||||
$organization = $user->currentTeam;
|
||||
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 60);
|
||||
$weeklyHistory = $dashboardService->getWeeklyHistory($user, $organization);
|
||||
$totalWeeklyTime = $dashboardService->totalWeeklyTime($user, $organization);
|
||||
$totalWeeklyBillableTime = $dashboardService->totalWeeklyBillableTime($user, $organization);
|
||||
$totalWeeklyBillableAmount = $dashboardService->totalWeeklyBillableAmount($user, $organization);
|
||||
$weeklyProjectOverview = $dashboardService->weeklyProjectOverview($user, $organization);
|
||||
|
||||
return Inertia::render('Dashboard', [
|
||||
'weeklyProjectOverview' => [
|
||||
[
|
||||
'value' => 120,
|
||||
'name' => 'Project 11',
|
||||
'color' => '#26a69a',
|
||||
],
|
||||
[
|
||||
'value' => 200,
|
||||
'name' => 'Project 2',
|
||||
'color' => '#d4e157',
|
||||
],
|
||||
[
|
||||
'value' => 150,
|
||||
'name' => 'Project 3',
|
||||
'color' => '#ff7043',
|
||||
],
|
||||
],
|
||||
'weeklyProjectOverview' => $weeklyProjectOverview,
|
||||
'latestTasks' => [
|
||||
// the 4 tasks with the most recent time entries
|
||||
[
|
||||
@@ -210,12 +201,9 @@ class DashboardController extends Controller
|
||||
],
|
||||
],
|
||||
'dailyTrackedHours' => $dailyTrackedHours,
|
||||
'totalWeeklyTime' => 400,
|
||||
'totalWeeklyBillableTime' => 300,
|
||||
'totalWeeklyBillableAmount' => [
|
||||
'value' => 300.5,
|
||||
'currency' => 'USD',
|
||||
],
|
||||
'totalWeeklyTime' => $totalWeeklyTime,
|
||||
'totalWeeklyBillableTime' => $totalWeeklyBillableTime,
|
||||
'totalWeeklyBillableAmount' => $totalWeeklyBillableAmount,
|
||||
'weeklyHistory' => $weeklyHistory,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\User;
|
||||
namespace App\Http\Requests\V1\Member;
|
||||
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
@@ -11,7 +11,7 @@ use Illuminate\Foundation\Http\FormRequest;
|
||||
/**
|
||||
* @property Organization $organization
|
||||
*/
|
||||
class UserIndexRequest extends FormRequest
|
||||
class MemberIndexRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
@@ -26,6 +26,11 @@ class OrganizationUpdateRequest extends FormRequest
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
'billable_rate' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
'min:0',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,11 @@ class ProjectStoreRequest extends FormRequest
|
||||
'max:255',
|
||||
new ColorRule(),
|
||||
],
|
||||
'billable_rate' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
'min:0',
|
||||
],
|
||||
'client_id' => [
|
||||
'nullable',
|
||||
new ExistsEloquent(Client::class, null, function (Builder $builder): Builder {
|
||||
|
||||
@@ -37,6 +37,11 @@ class ProjectUpdateRequest extends FormRequest
|
||||
'max:255',
|
||||
new ColorRule(),
|
||||
],
|
||||
'billable_rate' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
'min:0',
|
||||
],
|
||||
'client_id' => [
|
||||
'nullable',
|
||||
new ExistsEloquent(Client::class, null, function (Builder $builder): Builder {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\ProjectMember;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
|
||||
/**
|
||||
* @property Organization $organization Organization from model binding
|
||||
*/
|
||||
class ProjectMemberStoreRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => [
|
||||
'required',
|
||||
'uuid',
|
||||
new ExistsEloquent(User::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<User> $builder */
|
||||
return $builder->belongsToOrganization($this->organization);
|
||||
}),
|
||||
],
|
||||
'billable_rate' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
'min:0',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\ProjectMember;
|
||||
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* @property Organization $organization Organization from model binding
|
||||
*/
|
||||
class ProjectMemberUpdateRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'billable_rate' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
'min:0',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Http\Requests\V1\Task;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
|
||||
/**
|
||||
* @property Organization $organization Organization from model binding
|
||||
@@ -31,12 +28,6 @@ class TaskUpdateRequest extends FormRequest
|
||||
'min:1',
|
||||
'max:255',
|
||||
],
|
||||
'project_id' => [
|
||||
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ class OrganizationResource extends BaseResource
|
||||
'name' => $this->resource->name,
|
||||
/** @var string $color Personal organizations automatically created after registration */
|
||||
'is_personal' => $this->resource->personal_team,
|
||||
/** @var int|null $billable_rate Billable rate in cents per hour */
|
||||
'billable_rate' => $this->resource->billable_rate,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ class ProjectResource extends BaseResource
|
||||
'color' => $this->resource->color,
|
||||
/** @var string|null $client_id ID of client */
|
||||
'client_id' => $this->resource->client_id,
|
||||
/** @var int|null $billable_rate Billable rate in cents per hour */
|
||||
'billable_rate' => $this->resource->billable_rate,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\ProjectMember;
|
||||
|
||||
use App\Http\Resources\PaginatedResourceCollection;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class ProjectMemberCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $collects = ProjectMemberResource::class;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\ProjectMember;
|
||||
|
||||
use App\Http\Resources\V1\BaseResource;
|
||||
use App\Models\ProjectMember;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* @property ProjectMember $resource
|
||||
*/
|
||||
class ProjectMemberResource extends BaseResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, string|bool|int|null>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** @var string $id ID of project member */
|
||||
'id' => $this->resource->id,
|
||||
/** @var int|null $billable_rate Billable rate in cents per hour */
|
||||
'billable_rate' => $this->resource->billable_rate,
|
||||
/** @var string $user_id ID of the user */
|
||||
'user_id' => $this->resource->user_id,
|
||||
/** @var string $project_id ID of the project */
|
||||
'project_id' => $this->resource->project_id,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,8 @@ class MemberResource extends BaseResource
|
||||
'role' => $membership->role,
|
||||
/** @var bool $is_placeholder Placeholder user for imports, user might not really exist and does not know about this placeholder membership */
|
||||
'is_placeholder' => $this->resource->is_placeholder,
|
||||
/** @var int|null $billable_rate Billable rate in cents per hour */
|
||||
'billable_rate' => $membership->billable_rate,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user