mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-14 19:22:14 +01:00
Compare commits
3 Commits
main
...
feature/ro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85aa182fce | ||
|
|
6c4256b27d | ||
|
|
a6e5d375a1 |
11
app/Enums/ProjectMemberRole.php
Normal file
11
app/Enums/ProjectMemberRole.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum ProjectMemberRole: string
|
||||||
|
{
|
||||||
|
case Manager = 'manager';
|
||||||
|
case Normal = 'normal';
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\ProjectMemberRole;
|
||||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||||
use App\Http\Requests\V1\Project\ProjectIndexRequest;
|
use App\Http\Requests\V1\Project\ProjectIndexRequest;
|
||||||
use App\Http\Requests\V1\Project\ProjectStoreRequest;
|
use App\Http\Requests\V1\Project\ProjectStoreRequest;
|
||||||
@@ -15,6 +16,8 @@ use App\Models\Project;
|
|||||||
use App\Models\ProjectMember;
|
use App\Models\ProjectMember;
|
||||||
use App\Service\BillableRateService;
|
use App\Service\BillableRateService;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
@@ -50,6 +53,12 @@ class ProjectController extends Controller
|
|||||||
|
|
||||||
if (! $canViewAllProjects) {
|
if (! $canViewAllProjects) {
|
||||||
$projectsQuery->visibleByEmployee($user);
|
$projectsQuery->visibleByEmployee($user);
|
||||||
|
$projectsQuery->with([
|
||||||
|
'members' => function (HasMany $query): void {
|
||||||
|
/** @var Builder<ProjectMember> $query */
|
||||||
|
$query->whereBelongsTo($this->user(), 'user');
|
||||||
|
},
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
$filterArchived = $request->getFilterArchived();
|
$filterArchived = $request->getFilterArchived();
|
||||||
if ($filterArchived === 'true') {
|
if ($filterArchived === 'true') {
|
||||||
@@ -60,6 +69,14 @@ class ProjectController extends Controller
|
|||||||
|
|
||||||
$projects = $projectsQuery->paginate(config('app.pagination_per_page_default'));
|
$projects = $projectsQuery->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
|
foreach ($projects->items() as $project) {
|
||||||
|
if ($canViewAllProjects) {
|
||||||
|
$project->setAttribute('limited_visibility', false);
|
||||||
|
} else {
|
||||||
|
$project->setAttribute('limited_visibility', $project->members->firstWhere('user_id', $this->user()->id)?->role !== ProjectMemberRole::Manager);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return new ProjectCollection($projects);
|
return new ProjectCollection($projects);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +90,26 @@ class ProjectController extends Controller
|
|||||||
public function show(Organization $organization, Project $project): JsonResource
|
public function show(Organization $organization, Project $project): JsonResource
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'projects:view', $project);
|
$this->checkPermission($organization, 'projects:view', $project);
|
||||||
|
$canViewAllProjects = $this->hasPermission($organization, 'projects:view:all');
|
||||||
|
|
||||||
|
$project->load([
|
||||||
|
'members' => function (HasMany $query): void {
|
||||||
|
/** @var Builder<ProjectMember> $query */
|
||||||
|
$query->whereBelongsTo($this->user(), 'user');
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $canViewAllProjects) {
|
||||||
|
if (! $project->is_public && $project->members->firstWhere('user_id', '=', $this->user()->id) === null) {
|
||||||
|
throw new AuthorizationException('No access to project');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($canViewAllProjects) {
|
||||||
|
$project->setAttribute('limited_visibility', false);
|
||||||
|
} else {
|
||||||
|
$project->setAttribute('limited_visibility', $project->members->firstWhere('user_id', $this->user()->id)?->role !== ProjectMemberRole::Manager);
|
||||||
|
}
|
||||||
|
|
||||||
$project->load('organization');
|
$project->load('organization');
|
||||||
|
|
||||||
@@ -101,6 +138,8 @@ class ProjectController extends Controller
|
|||||||
$project->organization()->associate($organization);
|
$project->organization()->associate($organization);
|
||||||
$project->save();
|
$project->save();
|
||||||
|
|
||||||
|
$project->setAttribute('limited_visibility', false);
|
||||||
|
|
||||||
return new ProjectResource($project);
|
return new ProjectResource($project);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +171,8 @@ class ProjectController extends Controller
|
|||||||
$billableRateService->updateTimeEntriesBillableRateForProject($project);
|
$billableRateService->updateTimeEntriesBillableRateForProject($project);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$project->setAttribute('limited_visibility', false);
|
||||||
|
|
||||||
return new ProjectResource($project);
|
return new ProjectResource($project);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ class ProjectMemberController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$projectMember = new ProjectMember;
|
$projectMember = new ProjectMember;
|
||||||
|
$projectMember->role = $request->getRole();
|
||||||
$projectMember->billable_rate = $request->getBillableRate();
|
$projectMember->billable_rate = $request->getBillableRate();
|
||||||
$projectMember->member()->associate($member);
|
$projectMember->member()->associate($member);
|
||||||
$projectMember->user()->associate($member->user);
|
$projectMember->user()->associate($member->user);
|
||||||
@@ -95,11 +96,17 @@ class ProjectMemberController extends Controller
|
|||||||
public function update(Organization $organization, ProjectMember $projectMember, ProjectMemberUpdateRequest $request, BillableRateService $billableRateService): JsonResource
|
public function update(Organization $organization, ProjectMember $projectMember, ProjectMemberUpdateRequest $request, BillableRateService $billableRateService): JsonResource
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'project-members:update', projectMember: $projectMember);
|
$this->checkPermission($organization, 'project-members:update', projectMember: $projectMember);
|
||||||
$oldBillableRate = $projectMember->billable_rate;
|
$hasBillableRate = $request->has('billable_rate');
|
||||||
$projectMember->billable_rate = $request->getBillableRate();
|
if ($hasBillableRate) {
|
||||||
|
$oldBillableRate = $projectMember->billable_rate;
|
||||||
|
$projectMember->billable_rate = $request->getBillableRate();
|
||||||
|
}
|
||||||
|
if ($request->getRole() !== null) {
|
||||||
|
$projectMember->role = $request->getRole();
|
||||||
|
}
|
||||||
$projectMember->save();
|
$projectMember->save();
|
||||||
|
|
||||||
if ($oldBillableRate !== $request->getBillableRate()) {
|
if ($hasBillableRate && $oldBillableRate !== $request->getBillableRate()) {
|
||||||
$billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember);
|
$billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Requests\V1\ProjectMember;
|
namespace App\Http\Requests\V1\ProjectMember;
|
||||||
|
|
||||||
|
use App\Enums\ProjectMemberRole;
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,7 +21,7 @@ class ProjectMemberStoreRequest extends FormRequest
|
|||||||
/**
|
/**
|
||||||
* Get the validation rules that apply to the request.
|
* Get the validation rules that apply to the request.
|
||||||
*
|
*
|
||||||
* @return array<string, array<string|ValidationRule>>
|
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
|
||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
@@ -37,6 +39,11 @@ class ProjectMemberStoreRequest extends FormRequest
|
|||||||
'integer',
|
'integer',
|
||||||
'min:0',
|
'min:0',
|
||||||
],
|
],
|
||||||
|
'role' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
Rule::enum(ProjectMemberRole::class),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,4 +53,9 @@ class ProjectMemberStoreRequest extends FormRequest
|
|||||||
|
|
||||||
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
|
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getRole(): ProjectMemberRole
|
||||||
|
{
|
||||||
|
return ProjectMemberRole::from($this->validated('role'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Requests\V1\ProjectMember;
|
namespace App\Http\Requests\V1\ProjectMember;
|
||||||
|
|
||||||
|
use App\Enums\ProjectMemberRole;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @property Organization $organization Organization from model binding
|
* @property Organization $organization Organization from model binding
|
||||||
@@ -16,7 +18,7 @@ class ProjectMemberUpdateRequest extends FormRequest
|
|||||||
/**
|
/**
|
||||||
* Get the validation rules that apply to the request.
|
* Get the validation rules that apply to the request.
|
||||||
*
|
*
|
||||||
* @return array<string, array<string|ValidationRule>>
|
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
|
||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
@@ -26,6 +28,10 @@ class ProjectMemberUpdateRequest extends FormRequest
|
|||||||
'integer',
|
'integer',
|
||||||
'min:0',
|
'min:0',
|
||||||
],
|
],
|
||||||
|
'role' => [
|
||||||
|
'string',
|
||||||
|
Rule::enum(ProjectMemberRole::class),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +39,11 @@ class ProjectMemberUpdateRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
$input = $this->input('billable_rate');
|
$input = $this->input('billable_rate');
|
||||||
|
|
||||||
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
|
return $input !== null && ((int) $input) !== 0 ? (int) $this->validated('billable_rate') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRole(): ?ProjectMemberRole
|
||||||
|
{
|
||||||
|
return $this->has('role') ? ProjectMemberRole::from($this->validated('role')) : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ class ProjectResource extends BaseResource
|
|||||||
*/
|
*/
|
||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
|
$limitedVisibility = is_bool($this->resource->getAttributeValue('limited_visibility')) ? $this->resource->getAttributeValue('limited_visibility') : true;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
/** @var string $id ID of project */
|
/** @var string $id ID of project */
|
||||||
'id' => $this->resource->id,
|
'id' => $this->resource->id,
|
||||||
@@ -32,13 +34,15 @@ class ProjectResource extends BaseResource
|
|||||||
/** @var bool $is_archived Whether the client is archived */
|
/** @var bool $is_archived Whether the client is archived */
|
||||||
'is_archived' => $this->resource->is_archived,
|
'is_archived' => $this->resource->is_archived,
|
||||||
/** @var int|null $billable_rate Billable rate in cents per hour */
|
/** @var int|null $billable_rate Billable rate in cents per hour */
|
||||||
'billable_rate' => $this->resource->billable_rate,
|
'billable_rate' => $limitedVisibility ? null : $this->resource->billable_rate,
|
||||||
/** @var bool $is_billable Project time entries billable default */
|
/** @var bool $is_billable Project time entries billable default */
|
||||||
'is_billable' => $this->resource->is_billable,
|
'is_billable' => $this->resource->is_billable,
|
||||||
/** @var int|null $estimated_time Estimated time in seconds */
|
/** @var int|null $estimated_time Estimated time in seconds */
|
||||||
'estimated_time' => $this->resource->estimated_time,
|
'estimated_time' => $limitedVisibility ? null : $this->resource->estimated_time,
|
||||||
/** @var int $spent_time Spent time on this project in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
|
/** @var int $spent_time Spent time on this project in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
|
||||||
'spent_time' => $this->resource->spent_time,
|
'spent_time' => $limitedVisibility ? null : $this->resource->spent_time,
|
||||||
|
/** @var bool $limited_visibility */
|
||||||
|
'limited_visibility' => $limitedVisibility,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ class ProjectMemberResource extends BaseResource
|
|||||||
'member_id' => $this->resource->member_id,
|
'member_id' => $this->resource->member_id,
|
||||||
/** @var string $project_id ID of the project */
|
/** @var string $project_id ID of the project */
|
||||||
'project_id' => $this->resource->project_id,
|
'project_id' => $this->resource->project_id,
|
||||||
|
/** @var string $role Role of the project member */
|
||||||
|
'role' => $this->resource->role->value,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\ProjectMemberRole;
|
||||||
use App\Models\Concerns\CustomAuditable;
|
use App\Models\Concerns\CustomAuditable;
|
||||||
use App\Models\Concerns\HasUuids;
|
use App\Models\Concerns\HasUuids;
|
||||||
use Database\Factories\ProjectMemberFactory;
|
use Database\Factories\ProjectMemberFactory;
|
||||||
@@ -22,6 +23,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
* @property string $user_id User ID (legacy)
|
* @property string $user_id User ID (legacy)
|
||||||
* @property Carbon|null $created_at
|
* @property Carbon|null $created_at
|
||||||
* @property Carbon|null $updated_at
|
* @property Carbon|null $updated_at
|
||||||
|
* @property ProjectMemberRole $role
|
||||||
* @property-read Project $project
|
* @property-read Project $project
|
||||||
* @property-read Member $member
|
* @property-read Member $member
|
||||||
* @property-read User $user
|
* @property-read User $user
|
||||||
@@ -45,6 +47,7 @@ class ProjectMember extends Model implements AuditableContract
|
|||||||
*/
|
*/
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'billable_rate' => 'int',
|
'billable_rate' => 'int',
|
||||||
|
'role' => ProjectMemberRole::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Database\Factories;
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Enums\ProjectMemberRole;
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\ProjectMember;
|
use App\Models\ProjectMember;
|
||||||
@@ -24,12 +25,22 @@ class ProjectMemberFactory extends Factory
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'billable_rate' => $this->faker->numberBetween(10, 10000) * 100,
|
'billable_rate' => $this->faker->numberBetween(10, 10000) * 100,
|
||||||
|
'role' => ProjectMemberRole::Normal,
|
||||||
'project_id' => Project::factory(),
|
'project_id' => Project::factory(),
|
||||||
'user_id' => User::factory(),
|
'user_id' => User::factory(),
|
||||||
'member_id' => Member::factory(),
|
'member_id' => Member::factory(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function role(ProjectMemberRole $role): self
|
||||||
|
{
|
||||||
|
return $this->state(function (array $attributes) use ($role) {
|
||||||
|
return [
|
||||||
|
'role' => $role,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @deprecated Use forMember instead
|
* @deprecated Use forMember instead
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('project_members', function (Blueprint $table): void {
|
||||||
|
$table->string('role')->default('normal');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('project_members', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('role');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -12,6 +12,8 @@ import { useProjectMembersStore } from '@/utils/useProjectMembers';
|
|||||||
import MemberCombobox from '@/Components/Common/Member/MemberCombobox.vue';
|
import MemberCombobox from '@/Components/Common/Member/MemberCombobox.vue';
|
||||||
import BillableRateInput from '@/packages/ui/src/Input/BillableRateInput.vue';
|
import BillableRateInput from '@/packages/ui/src/Input/BillableRateInput.vue';
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
|
import { InputLabel } from '@/packages/ui/src';
|
||||||
|
import ProjectMemberRoleSelect from '@/Components/Common/ProjectMember/ProjectMemberRoleSelect.vue';
|
||||||
const { createProjectMember } = useProjectMembersStore();
|
const { createProjectMember } = useProjectMembersStore();
|
||||||
const show = defineModel('show', { default: false });
|
const show = defineModel('show', { default: false });
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
@@ -24,6 +26,7 @@ const props = defineProps<{
|
|||||||
const projectMember = ref<CreateProjectMemberBody>({
|
const projectMember = ref<CreateProjectMemberBody>({
|
||||||
member_id: '',
|
member_id: '',
|
||||||
billable_rate: null,
|
billable_rate: null,
|
||||||
|
role: 'normal',
|
||||||
});
|
});
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
@@ -32,6 +35,7 @@ async function submit() {
|
|||||||
projectMember.value = {
|
projectMember.value = {
|
||||||
member_id: '',
|
member_id: '',
|
||||||
billable_rate: null,
|
billable_rate: null,
|
||||||
|
role: 'normal',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,13 +53,17 @@ useFocus(projectNameInput, { initialValue: true });
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #content>
|
<template #content>
|
||||||
<div class="grid grid-cols-3 items-center space-x-4">
|
<div class="items-center space-y-4">
|
||||||
<div class="col-span-3 sm:col-span-2">
|
<div>
|
||||||
|
<InputLabel value="Member" class="mb-2"></InputLabel>
|
||||||
<MemberCombobox
|
<MemberCombobox
|
||||||
:hidden-members="props.existingMembers"
|
:hidden-members="props.existingMembers"
|
||||||
v-model="projectMember.member_id"></MemberCombobox>
|
v-model="projectMember.member_id"></MemberCombobox>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-3 sm:col-span-1 flex-1">
|
<div>
|
||||||
|
<InputLabel
|
||||||
|
value="Billable Rate"
|
||||||
|
for="billable_rate"></InputLabel>
|
||||||
<BillableRateInput
|
<BillableRateInput
|
||||||
name="billable_rate"
|
name="billable_rate"
|
||||||
:currency="getOrganizationCurrencyString()"
|
:currency="getOrganizationCurrencyString()"
|
||||||
@@ -63,6 +71,11 @@ useFocus(projectNameInput, { initialValue: true });
|
|||||||
projectMember.billable_rate
|
projectMember.billable_rate
|
||||||
"></BillableRateInput>
|
"></BillableRateInput>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<InputLabel value="Role" class="mb-2"></InputLabel>
|
||||||
|
<ProjectMemberRoleSelect
|
||||||
|
v-model="projectMember.role"></ProjectMemberRoleSelect>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
|
|||||||
@@ -8,12 +8,15 @@ import type {
|
|||||||
} from '@/packages/api/src';
|
} from '@/packages/api/src';
|
||||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||||
import { useFocus } from '@vueuse/core';
|
import { useFocus } from '@vueuse/core';
|
||||||
import { useProjectMembersStore } from '@/utils/useProjectMembers';
|
import {
|
||||||
|
type ProjectMemberRole,
|
||||||
|
useProjectMembersStore,
|
||||||
|
} from '@/utils/useProjectMembers';
|
||||||
import BillableRateInput from '@/packages/ui/src/Input/BillableRateInput.vue';
|
import BillableRateInput from '@/packages/ui/src/Input/BillableRateInput.vue';
|
||||||
import { UserIcon } from '@heroicons/vue/24/solid';
|
|
||||||
import ProjectMemberBillableRateModal from '@/Components/Common/ProjectMember/ProjectMemberBillableRateModal.vue';
|
import ProjectMemberBillableRateModal from '@/Components/Common/ProjectMember/ProjectMemberBillableRateModal.vue';
|
||||||
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
|
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
|
import ProjectMemberRoleSelect from '@/Components/Common/ProjectMember/ProjectMemberRoleSelect.vue';
|
||||||
const { updateProjectMember } = useProjectMembersStore();
|
const { updateProjectMember } = useProjectMembersStore();
|
||||||
|
|
||||||
const show = defineModel('show', { default: false });
|
const show = defineModel('show', { default: false });
|
||||||
@@ -26,6 +29,7 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const projectMemberBody = ref<UpdateProjectMemberBody>({
|
const projectMemberBody = ref<UpdateProjectMemberBody>({
|
||||||
billable_rate: props.projectMember.billable_rate,
|
billable_rate: props.projectMember.billable_rate,
|
||||||
|
role: props.projectMember.role as ProjectMemberRole,
|
||||||
});
|
});
|
||||||
const showBillableRateModal = ref(false);
|
const showBillableRateModal = ref(false);
|
||||||
async function submit() {
|
async function submit() {
|
||||||
@@ -40,6 +44,7 @@ async function submit() {
|
|||||||
show.value = false;
|
show.value = false;
|
||||||
projectMemberBody.value = {
|
projectMemberBody.value = {
|
||||||
billable_rate: null,
|
billable_rate: null,
|
||||||
|
role: 'normal',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +60,7 @@ watch(
|
|||||||
if (value) {
|
if (value) {
|
||||||
projectMemberBody.value = {
|
projectMemberBody.value = {
|
||||||
billable_rate: props.projectMember.billable_rate,
|
billable_rate: props.projectMember.billable_rate,
|
||||||
|
role: props.projectMember.role as ProjectMemberRole,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,7 +75,7 @@ useFocus(projectNameInput, { initialValue: true });
|
|||||||
<DialogModal closeable :show="show" @close="show = false">
|
<DialogModal closeable :show="show" @close="show = false">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<span>Edit Project Member</span>
|
<span>Edit Project Member "{{ props.name }}"</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -80,23 +86,26 @@ useFocus(projectNameInput, { initialValue: true });
|
|||||||
:new-billable-rate="projectMemberBody.billable_rate"
|
:new-billable-rate="projectMemberBody.billable_rate"
|
||||||
@close="showBillableRateModal = false"
|
@close="showBillableRateModal = false"
|
||||||
@submit="submitBillableRate"></ProjectMemberBillableRateModal>
|
@submit="submitBillableRate"></ProjectMemberBillableRateModal>
|
||||||
<div class="grid grid-cols-3 items-center space-x-4">
|
<div>
|
||||||
<div
|
<div class="items-center space-y-4">
|
||||||
class="col-span-3 sm:col-span-2 space-x-2 flex items-center">
|
<div>
|
||||||
<UserIcon class="w-4 text-muted"></UserIcon>
|
<InputLabel
|
||||||
<span>{{ props.name }}</span>
|
value="Billable Rate"
|
||||||
</div>
|
for="billable_rate"></InputLabel>
|
||||||
<div class="col-span-3 sm:col-span-1 flex-1">
|
<BillableRateInput
|
||||||
<InputLabel
|
name="billable_rate"
|
||||||
for="billable_rate"
|
:currency="getOrganizationCurrencyString()"
|
||||||
value="Billable Rate"></InputLabel>
|
v-model="
|
||||||
<BillableRateInput
|
projectMemberBody.billable_rate
|
||||||
@keydown.enter="submit"
|
"></BillableRateInput>
|
||||||
:currency="getOrganizationCurrencyString()"
|
</div>
|
||||||
name="billable_rate"
|
<div>
|
||||||
v-model="
|
<InputLabel value="Role" class="mb-2"></InputLabel>
|
||||||
projectMemberBody.billable_rate
|
<ProjectMemberRoleSelect
|
||||||
"></BillableRateInput>
|
v-model="
|
||||||
|
projectMemberBody.role
|
||||||
|
"></ProjectMemberRoleSelect>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import SelectDropdown from '@/packages/ui/src/Input/SelectDropdown.vue';
|
||||||
|
import Badge from '@/packages/ui/src/Badge.vue';
|
||||||
|
import { ChevronDownIcon } from '@heroicons/vue/20/solid';
|
||||||
|
import type { ProjectMemberRole } from '@/utils/useProjectMembers';
|
||||||
|
|
||||||
|
type ProjectMemberRoleItem = { key: ProjectMemberRole; name: string };
|
||||||
|
|
||||||
|
const projectMemberRoles: ProjectMemberRoleItem[] = [
|
||||||
|
{
|
||||||
|
key: 'normal',
|
||||||
|
name: 'Normal',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'manager',
|
||||||
|
name: 'Manager',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const model = defineModel<string>({
|
||||||
|
default: 'normal',
|
||||||
|
});
|
||||||
|
|
||||||
|
function getKeyFromItem(item: ProjectMemberRoleItem) {
|
||||||
|
return item.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNameFromItem(item: ProjectMemberRoleItem) {
|
||||||
|
return item.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNameForKey(key: string | undefined) {
|
||||||
|
return projectMemberRoles.find((item) => item.key === key)?.name ?? '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectDropdown
|
||||||
|
v-model="model"
|
||||||
|
:get-key-from-item="getKeyFromItem"
|
||||||
|
:get-name-for-item="getNameFromItem"
|
||||||
|
:items="projectMemberRoles">
|
||||||
|
<template #trigger>
|
||||||
|
<Badge size="xlarge" class="bg-input-background cursor-pointer">
|
||||||
|
<span>
|
||||||
|
{{ getNameForKey(model) }}
|
||||||
|
</span>
|
||||||
|
<ChevronDownIcon class="text-muted w-5"></ChevronDownIcon>
|
||||||
|
</Badge>
|
||||||
|
</template>
|
||||||
|
</SelectDropdown>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -57,7 +57,7 @@ const showEditModal = ref(false);
|
|||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted">
|
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted">
|
||||||
{{ capitalizeFirstLetter(member?.role ?? '') }}
|
{{ capitalizeFirstLetter(projectMember?.role ?? '') }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ async function startTaskTimer() {
|
|||||||
currentTimeEntry.value.project_id = props.project_id;
|
currentTimeEntry.value.project_id = props.project_id;
|
||||||
currentTimeEntry.value.task_id = props.task_id;
|
currentTimeEntry.value.task_id = props.task_id;
|
||||||
currentTimeEntry.value.start = getDayJsInstance().utc().format();
|
currentTimeEntry.value.start = getDayJsInstance().utc().format();
|
||||||
|
currentTimeEntry.value.billable = project.value?.is_billable ?? false;
|
||||||
await setActiveState(true);
|
await setActiveState(true);
|
||||||
useCurrentTimeEntryStore().fetchCurrentTimeEntry();
|
useCurrentTimeEntryStore().fetchCurrentTimeEntry();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ const ProjectResource = z
|
|||||||
is_billable: z.boolean(),
|
is_billable: z.boolean(),
|
||||||
estimated_time: z.union([z.number(), z.null()]),
|
estimated_time: z.union([z.number(), z.null()]),
|
||||||
spent_time: z.number().int(),
|
spent_time: z.number().int(),
|
||||||
|
limited_visibility: z.boolean(),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const ProjectStoreRequest = z
|
const ProjectStoreRequest = z
|
||||||
@@ -99,16 +100,22 @@ const ProjectMemberResource = z
|
|||||||
billable_rate: z.union([z.number(), z.null()]),
|
billable_rate: z.union([z.number(), z.null()]),
|
||||||
member_id: z.string(),
|
member_id: z.string(),
|
||||||
project_id: z.string(),
|
project_id: z.string(),
|
||||||
|
role: z.string(),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
|
const ProjectMemberRole = z.enum(['manager', 'normal']);
|
||||||
const ProjectMemberStoreRequest = z
|
const ProjectMemberStoreRequest = z
|
||||||
.object({
|
.object({
|
||||||
member_id: z.string().uuid(),
|
member_id: z.string().uuid(),
|
||||||
billable_rate: z.union([z.number(), z.null()]).optional(),
|
billable_rate: z.union([z.number(), z.null()]).optional(),
|
||||||
|
role: ProjectMemberRole,
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const ProjectMemberUpdateRequest = z
|
const ProjectMemberUpdateRequest = z
|
||||||
.object({ billable_rate: z.union([z.number(), z.null()]) })
|
.object({
|
||||||
|
billable_rate: z.union([z.number(), z.null()]),
|
||||||
|
role: ProjectMemberRole,
|
||||||
|
})
|
||||||
.partial()
|
.partial()
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const TagResource = z
|
const TagResource = z
|
||||||
@@ -257,6 +264,7 @@ export const schemas = {
|
|||||||
ProjectStoreRequest,
|
ProjectStoreRequest,
|
||||||
ProjectUpdateRequest,
|
ProjectUpdateRequest,
|
||||||
ProjectMemberResource,
|
ProjectMemberResource,
|
||||||
|
ProjectMemberRole,
|
||||||
ProjectMemberStoreRequest,
|
ProjectMemberStoreRequest,
|
||||||
ProjectMemberUpdateRequest,
|
ProjectMemberUpdateRequest,
|
||||||
TagResource,
|
TagResource,
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ watchEffect(() => {
|
|||||||
tasks: [],
|
tasks: [],
|
||||||
estimated_time: null,
|
estimated_time: null,
|
||||||
spent_time: 0,
|
spent_time: 0,
|
||||||
|
limited_visibility: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
} from '@/packages/api/src';
|
} from '@/packages/api/src';
|
||||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
import { useNotificationsStore } from '@/utils/notification';
|
import { useNotificationsStore } from '@/utils/notification';
|
||||||
|
export type ProjectMemberRole = 'normal' | 'manager';
|
||||||
|
|
||||||
export const useProjectMembersStore = defineStore('project-members', () => {
|
export const useProjectMembersStore = defineStore('project-members', () => {
|
||||||
const projectMemberResponse = ref<ProjectMemberResponse | null>(null);
|
const projectMemberResponse = ref<ProjectMemberResponse | null>(null);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tests\Unit\Endpoint\Api\V1;
|
namespace Tests\Unit\Endpoint\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\ProjectMemberRole;
|
||||||
use App\Http\Controllers\Api\V1\ProjectController;
|
use App\Http\Controllers\Api\V1\ProjectController;
|
||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
@@ -159,6 +160,54 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response->assertJsonCount(4, 'data');
|
$response->assertJsonCount(4, 'data');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_index_endpoint_returns_limited_visibility_flag_for_projects(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'projects:view',
|
||||||
|
'projects:view:all',
|
||||||
|
]);
|
||||||
|
Project::factory()->forOrganization($data->organization)->createMany(2);
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.projects.index', [$data->organization->getKey()]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertJsonPath('data.0.limited_visibility', false);
|
||||||
|
$response->assertJsonPath('data.1.limited_visibility', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_index_endpoint_returns_limit_visibility_flag_for_projects_for_user_with_restricted_permission(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'projects:view',
|
||||||
|
]);
|
||||||
|
$project1 = Project::factory()->forOrganization($data->organization)->create([
|
||||||
|
'created_at' => now()->subDays(4),
|
||||||
|
]);
|
||||||
|
ProjectMember::factory()->forProject($project1)->forMember($data->member)->role(ProjectMemberRole::Normal)->create();
|
||||||
|
$project2 = Project::factory()->forOrganization($data->organization)->create([
|
||||||
|
'created_at' => now()->subDays(3),
|
||||||
|
]);
|
||||||
|
ProjectMember::factory()->forProject($project2)->forMember($data->member)->role(ProjectMemberRole::Manager)->create();
|
||||||
|
$project3 = Project::factory()->forOrganization($data->organization)->isPublic()->create([
|
||||||
|
'created_at' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.projects.index', [$data->organization->getKey()]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertJsonPath('data.0.limited_visibility', true);
|
||||||
|
$response->assertJsonPath('data.1.limited_visibility', false);
|
||||||
|
$response->assertJsonPath('data.2.limited_visibility', true);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_show_endpoint_fails_if_user_is_not_part_of_project_organization(): void
|
public function test_show_endpoint_fails_if_user_is_not_part_of_project_organization(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -190,7 +239,82 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response->assertForbidden();
|
$response->assertForbidden();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_show_endpoint_returns_project(): void
|
public function test_show_endpoint_returns_project_if_user_has_access_to_all_projects_in_organization(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'projects:view',
|
||||||
|
'projects:view:all',
|
||||||
|
]);
|
||||||
|
$project = Project::factory()->forOrganization($data->organization)->create();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.projects.show', [$data->organization->getKey(), $project->getKey()]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertJsonPath('data.id', $project->getKey());
|
||||||
|
$response->assertJsonPath('data.limited_visibility', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_show_endpoint_returns_project_if_user_can_view_projects_and_project_is_public(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'projects:view',
|
||||||
|
]);
|
||||||
|
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.projects.show', [$data->organization->getKey(), $project->getKey()]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertJsonPath('data.id', $project->getKey());
|
||||||
|
$response->assertJsonPath('data.limited_visibility', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_show_endpoint_returns_project_if_user_can_view_projects_and_user_is_member_of_project(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'projects:view',
|
||||||
|
]);
|
||||||
|
$project = Project::factory()->forOrganization($data->organization)->create();
|
||||||
|
ProjectMember::factory()->forProject($project)->forMember($data->member)->create();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.projects.show', [$data->organization->getKey(), $project->getKey()]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertJsonPath('data.id', $project->getKey());
|
||||||
|
$response->assertJsonPath('data.limited_visibility', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_show_endpoint_returns_project_with_no_limited_visibility_is_user_is_project_manager(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'projects:view',
|
||||||
|
]);
|
||||||
|
$project = Project::factory()->forOrganization($data->organization)->create();
|
||||||
|
ProjectMember::factory()->forProject($project)->forMember($data->member)->role(ProjectMemberRole::Manager)->create();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.projects.show', [$data->organization->getKey(), $project->getKey()]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertJsonPath('data.id', $project->getKey());
|
||||||
|
$response->assertJsonPath('data.limited_visibility', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_show_endpoint_fails_for_user_with_access_to_not_all_projects_for_project_that_is_private_and_the_user_is_not_a_member_of(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
$data = $this->createUserWithPermission([
|
$data = $this->createUserWithPermission([
|
||||||
@@ -203,8 +327,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response = $this->getJson(route('api.v1.projects.show', [$data->organization->getKey(), $project->getKey()]));
|
$response = $this->getJson(route('api.v1.projects.show', [$data->organization->getKey(), $project->getKey()]));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertStatus(200);
|
$response->assertStatus(403);
|
||||||
$response->assertJsonPath('data.id', $project->getKey());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_store_endpoint_fails_if_user_has_no_permission_to_create_projects(): void
|
public function test_store_endpoint_fails_if_user_has_no_permission_to_create_projects(): void
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tests\Unit\Endpoint\Api\V1;
|
namespace Tests\Unit\Endpoint\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\ProjectMemberRole;
|
||||||
use App\Http\Controllers\Api\V1\ProjectMemberController;
|
use App\Http\Controllers\Api\V1\ProjectMemberController;
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
@@ -93,6 +94,7 @@ class ProjectMemberEndpointTest extends ApiEndpointTestAbstract
|
|||||||
// Act
|
// Act
|
||||||
$response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [
|
$response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [
|
||||||
'billable_rate' => $projectMemberFake->billable_rate,
|
'billable_rate' => $projectMemberFake->billable_rate,
|
||||||
|
'role' => $projectMemberFake->role->value,
|
||||||
'member_id' => $member->getKey(),
|
'member_id' => $member->getKey(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -118,6 +120,7 @@ class ProjectMemberEndpointTest extends ApiEndpointTestAbstract
|
|||||||
// Act
|
// Act
|
||||||
$response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [
|
$response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [
|
||||||
'billable_rate' => $projectMemberFake->billable_rate,
|
'billable_rate' => $projectMemberFake->billable_rate,
|
||||||
|
'role' => $projectMemberFake->role->value,
|
||||||
'member_id' => $member->getKey(),
|
'member_id' => $member->getKey(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -165,6 +168,7 @@ class ProjectMemberEndpointTest extends ApiEndpointTestAbstract
|
|||||||
// Act
|
// Act
|
||||||
$response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [
|
$response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [
|
||||||
'billable_rate' => $projectMemberFake->billable_rate,
|
'billable_rate' => $projectMemberFake->billable_rate,
|
||||||
|
'role' => $projectMemberFake->role->value,
|
||||||
'member_id' => $member->getKey(),
|
'member_id' => $member->getKey(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -179,6 +183,7 @@ class ProjectMemberEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'billable_rate' => $projectMemberFake->billable_rate,
|
'billable_rate' => $projectMemberFake->billable_rate,
|
||||||
'member_id' => $member->getKey(),
|
'member_id' => $member->getKey(),
|
||||||
'project_id' => $project->getKey(),
|
'project_id' => $project->getKey(),
|
||||||
|
'role' => $projectMemberFake->role->value,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +202,7 @@ class ProjectMemberEndpointTest extends ApiEndpointTestAbstract
|
|||||||
// Act
|
// Act
|
||||||
$response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [
|
$response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [
|
||||||
'billable_rate' => $projectMemberFake->billable_rate,
|
'billable_rate' => $projectMemberFake->billable_rate,
|
||||||
|
'role' => $projectMemberFake->role->value,
|
||||||
'member_id' => $member->getKey(),
|
'member_id' => $member->getKey(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -211,6 +217,7 @@ class ProjectMemberEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'billable_rate' => $projectMemberFake->billable_rate,
|
'billable_rate' => $projectMemberFake->billable_rate,
|
||||||
'member_id' => $member->getKey(),
|
'member_id' => $member->getKey(),
|
||||||
'project_id' => $project->getKey(),
|
'project_id' => $project->getKey(),
|
||||||
|
'role' => $projectMemberFake->role->value,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,6 +243,7 @@ class ProjectMemberEndpointTest extends ApiEndpointTestAbstract
|
|||||||
// Act
|
// Act
|
||||||
$response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [
|
$response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [
|
||||||
'billable_rate' => $projectMemberFake->billable_rate,
|
'billable_rate' => $projectMemberFake->billable_rate,
|
||||||
|
'role' => $projectMemberFake->role->value,
|
||||||
'member_id' => $member->getKey(),
|
'member_id' => $member->getKey(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -245,6 +253,36 @@ class ProjectMemberEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'billable_rate' => $projectMemberFake->billable_rate,
|
'billable_rate' => $projectMemberFake->billable_rate,
|
||||||
'member_id' => $member->getKey(),
|
'member_id' => $member->getKey(),
|
||||||
'project_id' => $project->getKey(),
|
'project_id' => $project->getKey(),
|
||||||
|
'role' => $projectMemberFake->role->value,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_store_endpoint_can_create_a_new_project_member_with_role_manager(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'project-members:create',
|
||||||
|
]);
|
||||||
|
$project = Project::factory()->forOrganization($data->organization)->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$member = Member::factory()->forOrganization($data->organization)->forUser($user)->create();
|
||||||
|
$this->assertBillableRateServiceIsUnused();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [
|
||||||
|
'billable_rate' => null,
|
||||||
|
'role' => ProjectMemberRole::Manager->value,
|
||||||
|
'member_id' => $member->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(201);
|
||||||
|
$this->assertDatabaseHas(ProjectMember::class, [
|
||||||
|
'billable_rate' => null,
|
||||||
|
'member_id' => $member->getKey(),
|
||||||
|
'project_id' => $project->getKey(),
|
||||||
|
'role' => ProjectMemberRole::Manager->value,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,6 +304,7 @@ class ProjectMemberEndpointTest extends ApiEndpointTestAbstract
|
|||||||
// Act
|
// Act
|
||||||
$response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [
|
$response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [
|
||||||
'billable_rate' => $projectMemberFake->billable_rate,
|
'billable_rate' => $projectMemberFake->billable_rate,
|
||||||
|
'role' => $projectMemberFake->role->value,
|
||||||
'member_id' => $member->getKey(),
|
'member_id' => $member->getKey(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -275,6 +314,7 @@ class ProjectMemberEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'billable_rate' => null,
|
'billable_rate' => null,
|
||||||
'member_id' => $member->getKey(),
|
'member_id' => $member->getKey(),
|
||||||
'project_id' => $project->getKey(),
|
'project_id' => $project->getKey(),
|
||||||
|
'role' => $projectMemberFake->role->value,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,6 +414,32 @@ class ProjectMemberEndpointTest extends ApiEndpointTestAbstract
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_update_endpoint_can_update_role(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'project-members:update',
|
||||||
|
]);
|
||||||
|
$project = Project::factory()->forOrganization($data->organization)->create();
|
||||||
|
$projectMember = ProjectMember::factory()->forProject($project)->role(ProjectMemberRole::Normal)->create();
|
||||||
|
$this->assertBillableRateServiceIsUnused();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->putJson(route('api.v1.project-members.update', [$data->organization->getKey(), $projectMember->getKey()]), [
|
||||||
|
'role' => ProjectMemberRole::Manager->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$this->assertDatabaseHas(ProjectMember::class, [
|
||||||
|
'id' => $projectMember->getKey(),
|
||||||
|
'billable_rate' => $projectMember->billable_rate,
|
||||||
|
'member_id' => $projectMember->member_id,
|
||||||
|
'role' => ProjectMemberRole::Manager->value,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_destroy_endpoint_fails_if_user_is_not_part_of_project_members_organization(): void
|
public function test_destroy_endpoint_fails_if_user_is_not_part_of_project_members_organization(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class ClientModelTest extends ModelTestAbstract
|
|||||||
// Assert
|
// Assert
|
||||||
$this->assertNotNull($projectsRel);
|
$this->assertNotNull($projectsRel);
|
||||||
$this->assertCount(4, $projectsRel);
|
$this->assertCount(4, $projectsRel);
|
||||||
$this->assertTrue($projectsRel->first()->is($projects->first()));
|
$this->assertNotEquals($projectsOtherClient->pluck('id'), $projectsRel->pluck('id'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_accessor_is_archived_is_true_if_archived_at_is_not_null(): void
|
public function test_accessor_is_archived_is_true_if_archived_at_is_not_null(): void
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ class ProjectModelTest extends ModelTestAbstract
|
|||||||
// Assert
|
// Assert
|
||||||
$this->assertNotNull($tasksRel);
|
$this->assertNotNull($tasksRel);
|
||||||
$this->assertCount(3, $tasksRel);
|
$this->assertCount(3, $tasksRel);
|
||||||
$this->assertTrue($tasksRel->first()->is($tasks->first()));
|
$this->assertEqualsIdsOfEloquentCollection($tasks->pluck('id')->toArray(), $tasksRel);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_has_many_members(): void
|
public function test_it_has_many_members(): void
|
||||||
@@ -91,7 +91,7 @@ class ProjectModelTest extends ModelTestAbstract
|
|||||||
// Assert
|
// Assert
|
||||||
$this->assertNotNull($membersRel);
|
$this->assertNotNull($membersRel);
|
||||||
$this->assertCount(3, $membersRel);
|
$this->assertCount(3, $membersRel);
|
||||||
$this->assertTrue($membersRel->first()->is($members->first()));
|
$this->assertEqualsIdsOfEloquentCollection($members->pluck('id')->toArray(), $membersRel);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_scope_visible_by_user_filters_so_that_only_public_projects_or_projects_where_the_user_is_member_are_shown(): void
|
public function test_scope_visible_by_user_filters_so_that_only_public_projects_or_projects_where_the_user_is_member_are_shown(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user