feat: add "Lid toevoegen als deelnemer" shortcut for org members
Adds two new API endpoints to quickly add organisation members as event
persons with user_id pre-linked and status approved:
- GET /organisations/{org}/members/available-for-event/{event}
- POST /organisations/{org}/events/{event}/persons/from-member
Includes frontend dialog with member search, crowd type selection, and
click-to-add behavior in the Personen tab.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,9 @@ use App\Http\Controllers\Controller;
|
|||||||
use App\Http\Requests\Api\V1\UpdateMemberRequest;
|
use App\Http\Requests\Api\V1\UpdateMemberRequest;
|
||||||
use App\Http\Resources\Api\V1\MemberCollection;
|
use App\Http\Resources\Api\V1\MemberCollection;
|
||||||
use App\Http\Resources\Api\V1\MemberResource;
|
use App\Http\Resources\Api\V1\MemberResource;
|
||||||
|
use App\Models\Event;
|
||||||
use App\Models\Organisation;
|
use App\Models\Organisation;
|
||||||
|
use App\Models\Person;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\EmailChangeService;
|
use App\Services\EmailChangeService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -84,6 +86,35 @@ final class MemberController extends Controller
|
|||||||
return response()->json(null, 204);
|
return response()->json(null, 204);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function availableForEvent(Organisation $organisation, Event $event): JsonResponse
|
||||||
|
{
|
||||||
|
if ($event->organisation_id !== $organisation->id) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
Gate::authorize('viewAny', [Person::class, $event]);
|
||||||
|
|
||||||
|
$existingUserIds = Person::withoutGlobalScopes()
|
||||||
|
->where('event_id', $event->id)
|
||||||
|
->whereNotNull('user_id')
|
||||||
|
->pluck('user_id');
|
||||||
|
|
||||||
|
$members = $organisation->users()
|
||||||
|
->whereNotIn('users.id', $existingUserIds)
|
||||||
|
->select('users.id', 'users.first_name', 'users.last_name', 'users.email')
|
||||||
|
->orderBy('users.first_name')
|
||||||
|
->get()
|
||||||
|
->map(fn (User $user) => [
|
||||||
|
'id' => $user->id,
|
||||||
|
'first_name' => $user->first_name,
|
||||||
|
'last_name' => $user->last_name,
|
||||||
|
'full_name' => $user->full_name,
|
||||||
|
'email' => $user->email,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['data' => $members]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/v1/organisations/{organisation}/members/{user}/change-email
|
* POST /api/v1/organisations/{organisation}/members/{user}/change-email
|
||||||
* Admin changes a member's email (sends verification to new address).
|
* Admin changes a member's email (sends verification to new address).
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\PersonStatus;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Controllers\Api\V1\Traits\VerifiesOrganisationEvent;
|
use App\Http\Controllers\Api\V1\Traits\VerifiesOrganisationEvent;
|
||||||
|
use App\Http\Requests\Api\V1\StorePersonFromMemberRequest;
|
||||||
use App\Http\Requests\Api\V1\StorePersonRequest;
|
use App\Http\Requests\Api\V1\StorePersonRequest;
|
||||||
use App\Http\Requests\Api\V1\UpdatePersonRequest;
|
use App\Http\Requests\Api\V1\UpdatePersonRequest;
|
||||||
use App\Http\Resources\Api\V1\PersonCollection;
|
use App\Http\Resources\Api\V1\PersonCollection;
|
||||||
@@ -15,12 +17,14 @@ use App\Mail\RegistrationRejectedMail;
|
|||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use App\Models\Organisation;
|
use App\Models\Organisation;
|
||||||
use App\Models\Person;
|
use App\Models\Person;
|
||||||
|
use App\Models\User;
|
||||||
use App\Services\PersonIdentityService;
|
use App\Services\PersonIdentityService;
|
||||||
use App\Services\TagSyncService;
|
use App\Services\TagSyncService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
final class PersonController extends Controller
|
final class PersonController extends Controller
|
||||||
{
|
{
|
||||||
@@ -115,6 +119,46 @@ final class PersonController extends Controller
|
|||||||
return response()->json(null, 204);
|
return response()->json(null, 204);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function createFromMember(StorePersonFromMemberRequest $request, Organisation $organisation, Event $event): JsonResponse
|
||||||
|
{
|
||||||
|
$this->verifyEventBelongsToOrganisation($organisation, $event);
|
||||||
|
Gate::authorize('create', [Person::class, $event]);
|
||||||
|
|
||||||
|
$user = User::findOrFail($request->validated('user_id'));
|
||||||
|
|
||||||
|
if (Person::withoutGlobalScopes()->where('event_id', $event->id)->where('user_id', $user->id)->exists()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'user_id' => ['Dit lid is al toegevoegd aan dit evenement.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$user->organisations()->where('organisations.id', $event->organisation_id)->exists()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'user_id' => ['Dit lid behoort niet tot deze organisatie.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$person = Person::create([
|
||||||
|
'event_id' => $event->id,
|
||||||
|
'crowd_type_id' => $request->validated('crowd_type_id'),
|
||||||
|
'first_name' => $user->first_name,
|
||||||
|
'last_name' => $user->last_name,
|
||||||
|
'email' => $user->email,
|
||||||
|
'status' => PersonStatus::APPROVED->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$person->user_id = $user->id;
|
||||||
|
$person->save();
|
||||||
|
|
||||||
|
activity()
|
||||||
|
->causedBy(auth()->user())
|
||||||
|
->performedOn($person)
|
||||||
|
->withProperties(['source' => 'from_member', 'user_name' => $user->full_name])
|
||||||
|
->log('person.created_from_member');
|
||||||
|
|
||||||
|
return $this->created(new PersonResource($person->load('crowdType')));
|
||||||
|
}
|
||||||
|
|
||||||
public function approve(Organisation $organisation, Event $event, Person $person): JsonResponse
|
public function approve(Organisation $organisation, Event $event, Person $person): JsonResponse
|
||||||
{
|
{
|
||||||
$this->verifyEventBelongsToOrganisation($organisation, $event);
|
$this->verifyEventBelongsToOrganisation($organisation, $event);
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
final class StorePersonFromMemberRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$orgId = $this->route('event')->organisation_id;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'user_id' => ['required', 'ulid', 'exists:users,id'],
|
||||||
|
'crowd_type_id' => ['required', 'ulid', Rule::exists('crowd_types', 'id')->where('organisation_id', $orgId)],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -160,6 +160,7 @@ Route::middleware('auth:sanctum')->group(function () {
|
|||||||
Route::put('members/{user}', [MemberController::class, 'update']);
|
Route::put('members/{user}', [MemberController::class, 'update']);
|
||||||
Route::delete('members/{user}', [MemberController::class, 'destroy']);
|
Route::delete('members/{user}', [MemberController::class, 'destroy']);
|
||||||
Route::post('members/{user}/change-email', [MemberController::class, 'changeEmail']);
|
Route::post('members/{user}/change-email', [MemberController::class, 'changeEmail']);
|
||||||
|
Route::get('members/available-for-event/{event}', [MemberController::class, 'availableForEvent']);
|
||||||
|
|
||||||
// Event sub-resources (all nested under organisation prefix — A01-13)
|
// Event sub-resources (all nested under organisation prefix — A01-13)
|
||||||
Route::prefix('events/{event}')->group(function () {
|
Route::prefix('events/{event}')->group(function () {
|
||||||
@@ -199,6 +200,7 @@ Route::middleware('auth:sanctum')->group(function () {
|
|||||||
|
|
||||||
// Persons
|
// Persons
|
||||||
Route::apiResource('persons', PersonController::class);
|
Route::apiResource('persons', PersonController::class);
|
||||||
|
Route::post('persons/from-member', [PersonController::class, 'createFromMember']);
|
||||||
Route::post('persons/{person}/approve', [PersonController::class, 'approve']);
|
Route::post('persons/{person}/approve', [PersonController::class, 'approve']);
|
||||||
Route::post('persons/{person}/reject', [PersonController::class, 'reject']);
|
Route::post('persons/{person}/reject', [PersonController::class, 'reject']);
|
||||||
Route::post('persons/{person}/manual-link', [PersonIdentityMatchController::class, 'manualLink']);
|
Route::post('persons/{person}/manual-link', [PersonIdentityMatchController::class, 'manualLink']);
|
||||||
|
|||||||
253
api/tests/Feature/Person/CreatePersonFromMemberTest.php
Normal file
253
api/tests/Feature/Person/CreatePersonFromMemberTest.php
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\Person;
|
||||||
|
|
||||||
|
use App\Models\CrowdType;
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use App\Models\Person;
|
||||||
|
use App\Models\User;
|
||||||
|
use Database\Seeders\RoleSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class CreatePersonFromMemberTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private User $orgAdmin;
|
||||||
|
private User $member;
|
||||||
|
private User $outsider;
|
||||||
|
private Organisation $organisation;
|
||||||
|
private Organisation $otherOrganisation;
|
||||||
|
private Event $event;
|
||||||
|
private CrowdType $crowdType;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->seed(RoleSeeder::class);
|
||||||
|
|
||||||
|
$this->organisation = Organisation::factory()->create();
|
||||||
|
$this->otherOrganisation = Organisation::factory()->create();
|
||||||
|
|
||||||
|
$this->orgAdmin = User::factory()->create();
|
||||||
|
$this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']);
|
||||||
|
|
||||||
|
$this->member = User::factory()->create([
|
||||||
|
'first_name' => 'Jan',
|
||||||
|
'last_name' => 'de Vries',
|
||||||
|
'email' => 'jan@test.nl',
|
||||||
|
]);
|
||||||
|
$this->organisation->users()->attach($this->member, ['role' => 'org_member']);
|
||||||
|
|
||||||
|
$this->outsider = User::factory()->create();
|
||||||
|
$this->otherOrganisation->users()->attach($this->outsider, ['role' => 'org_admin']);
|
||||||
|
|
||||||
|
$this->event = Event::factory()->create(['organisation_id' => $this->organisation->id]);
|
||||||
|
|
||||||
|
$this->crowdType = CrowdType::factory()->systemType('CREW')->create([
|
||||||
|
'organisation_id' => $this->organisation->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Available for event ---
|
||||||
|
|
||||||
|
public function test_available_for_event_returns_members_not_yet_person(): void
|
||||||
|
{
|
||||||
|
Sanctum::actingAs($this->orgAdmin);
|
||||||
|
|
||||||
|
$response = $this->getJson(
|
||||||
|
"/api/v1/organisations/{$this->organisation->id}/members/available-for-event/{$this->event->id}"
|
||||||
|
);
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
|
||||||
|
$data = $response->json('data');
|
||||||
|
|
||||||
|
// Both orgAdmin and member should be available (neither is a person yet)
|
||||||
|
$this->assertCount(2, $data);
|
||||||
|
|
||||||
|
$ids = collect($data)->pluck('id')->all();
|
||||||
|
$this->assertContains($this->orgAdmin->id, $ids);
|
||||||
|
$this->assertContains($this->member->id, $ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_available_for_event_excludes_already_added_members(): void
|
||||||
|
{
|
||||||
|
// Add member as a person
|
||||||
|
$person = Person::factory()->create([
|
||||||
|
'event_id' => $this->event->id,
|
||||||
|
'crowd_type_id' => $this->crowdType->id,
|
||||||
|
]);
|
||||||
|
$person->user_id = $this->member->id;
|
||||||
|
$person->save();
|
||||||
|
|
||||||
|
Sanctum::actingAs($this->orgAdmin);
|
||||||
|
|
||||||
|
$response = $this->getJson(
|
||||||
|
"/api/v1/organisations/{$this->organisation->id}/members/available-for-event/{$this->event->id}"
|
||||||
|
);
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
|
||||||
|
$ids = collect($response->json('data'))->pluck('id')->all();
|
||||||
|
$this->assertNotContains($this->member->id, $ids);
|
||||||
|
$this->assertContains($this->orgAdmin->id, $ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_available_for_event_returns_correct_fields(): void
|
||||||
|
{
|
||||||
|
Sanctum::actingAs($this->orgAdmin);
|
||||||
|
|
||||||
|
$response = $this->getJson(
|
||||||
|
"/api/v1/organisations/{$this->organisation->id}/members/available-for-event/{$this->event->id}"
|
||||||
|
);
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJsonStructure([
|
||||||
|
'data' => [
|
||||||
|
'*' => ['id', 'first_name', 'last_name', 'full_name', 'email'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_available_for_event_unauthenticated_returns_401(): void
|
||||||
|
{
|
||||||
|
$response = $this->getJson(
|
||||||
|
"/api/v1/organisations/{$this->organisation->id}/members/available-for-event/{$this->event->id}"
|
||||||
|
);
|
||||||
|
|
||||||
|
$response->assertUnauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_available_for_event_wrong_org_returns_403(): void
|
||||||
|
{
|
||||||
|
Sanctum::actingAs($this->outsider);
|
||||||
|
|
||||||
|
$response = $this->getJson(
|
||||||
|
"/api/v1/organisations/{$this->organisation->id}/members/available-for-event/{$this->event->id}"
|
||||||
|
);
|
||||||
|
|
||||||
|
$response->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Create person from member ---
|
||||||
|
|
||||||
|
public function test_create_from_member_creates_person_with_user_id(): void
|
||||||
|
{
|
||||||
|
Sanctum::actingAs($this->orgAdmin);
|
||||||
|
|
||||||
|
$response = $this->postJson(
|
||||||
|
"/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/from-member",
|
||||||
|
[
|
||||||
|
'user_id' => $this->member->id,
|
||||||
|
'crowd_type_id' => $this->crowdType->id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$response->assertCreated()
|
||||||
|
->assertJsonPath('data.first_name', 'Jan')
|
||||||
|
->assertJsonPath('data.last_name', 'de Vries')
|
||||||
|
->assertJsonPath('data.email', 'jan@test.nl')
|
||||||
|
->assertJsonPath('data.status', 'approved')
|
||||||
|
->assertJsonPath('data.has_user_account', true);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('persons', [
|
||||||
|
'event_id' => $this->event->id,
|
||||||
|
'user_id' => $this->member->id,
|
||||||
|
'first_name' => 'Jan',
|
||||||
|
'last_name' => 'de Vries',
|
||||||
|
'status' => 'approved',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_from_member_duplicate_returns_422(): void
|
||||||
|
{
|
||||||
|
// Add member as a person first
|
||||||
|
$person = Person::factory()->create([
|
||||||
|
'event_id' => $this->event->id,
|
||||||
|
'crowd_type_id' => $this->crowdType->id,
|
||||||
|
]);
|
||||||
|
$person->user_id = $this->member->id;
|
||||||
|
$person->save();
|
||||||
|
|
||||||
|
Sanctum::actingAs($this->orgAdmin);
|
||||||
|
|
||||||
|
$response = $this->postJson(
|
||||||
|
"/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/from-member",
|
||||||
|
[
|
||||||
|
'user_id' => $this->member->id,
|
||||||
|
'crowd_type_id' => $this->crowdType->id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$response->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_from_member_user_not_in_org_returns_422(): void
|
||||||
|
{
|
||||||
|
Sanctum::actingAs($this->orgAdmin);
|
||||||
|
|
||||||
|
$response = $this->postJson(
|
||||||
|
"/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/from-member",
|
||||||
|
[
|
||||||
|
'user_id' => $this->outsider->id,
|
||||||
|
'crowd_type_id' => $this->crowdType->id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$response->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_from_member_unauthenticated_returns_401(): void
|
||||||
|
{
|
||||||
|
$response = $this->postJson(
|
||||||
|
"/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/from-member",
|
||||||
|
[
|
||||||
|
'user_id' => $this->member->id,
|
||||||
|
'crowd_type_id' => $this->crowdType->id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$response->assertUnauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_from_member_wrong_org_returns_403(): void
|
||||||
|
{
|
||||||
|
Sanctum::actingAs($this->outsider);
|
||||||
|
|
||||||
|
$response = $this->postJson(
|
||||||
|
"/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/from-member",
|
||||||
|
[
|
||||||
|
'user_id' => $this->member->id,
|
||||||
|
'crowd_type_id' => $this->crowdType->id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$response->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_from_member_logs_activity(): void
|
||||||
|
{
|
||||||
|
Sanctum::actingAs($this->orgAdmin);
|
||||||
|
|
||||||
|
$this->postJson(
|
||||||
|
"/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/from-member",
|
||||||
|
[
|
||||||
|
'user_id' => $this->member->id,
|
||||||
|
'crowd_type_id' => $this->crowdType->id,
|
||||||
|
],
|
||||||
|
)->assertCreated();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('activity_log', [
|
||||||
|
'description' => 'person.created_from_member',
|
||||||
|
'causer_id' => $this->orgAdmin->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/app/components.d.ts
vendored
1
apps/app/components.d.ts
vendored
@@ -10,6 +10,7 @@ declare module 'vue' {
|
|||||||
AddEditAddressDialog: typeof import('./src/components/dialogs/AddEditAddressDialog.vue')['default']
|
AddEditAddressDialog: typeof import('./src/components/dialogs/AddEditAddressDialog.vue')['default']
|
||||||
AddEditPermissionDialog: typeof import('./src/components/dialogs/AddEditPermissionDialog.vue')['default']
|
AddEditPermissionDialog: typeof import('./src/components/dialogs/AddEditPermissionDialog.vue')['default']
|
||||||
AddEditRoleDialog: typeof import('./src/components/dialogs/AddEditRoleDialog.vue')['default']
|
AddEditRoleDialog: typeof import('./src/components/dialogs/AddEditRoleDialog.vue')['default']
|
||||||
|
AddMemberAsPersonDialog: typeof import('./src/components/persons/AddMemberAsPersonDialog.vue')['default']
|
||||||
AddPersonToCrowdListDialog: typeof import('./src/components/crowd-lists/AddPersonToCrowdListDialog.vue')['default']
|
AddPersonToCrowdListDialog: typeof import('./src/components/crowd-lists/AddPersonToCrowdListDialog.vue')['default']
|
||||||
AppAutocomplete: typeof import('./src/@core/components/app-form-elements/AppAutocomplete.vue')['default']
|
AppAutocomplete: typeof import('./src/@core/components/app-form-elements/AppAutocomplete.vue')['default']
|
||||||
AppBarSearch: typeof import('./src/@core/components/AppBarSearch.vue')['default']
|
AppBarSearch: typeof import('./src/@core/components/AppBarSearch.vue')['default']
|
||||||
|
|||||||
231
apps/app/src/components/persons/AddMemberAsPersonDialog.vue
Normal file
231
apps/app/src/components/persons/AddMemberAsPersonDialog.vue
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useAvailableMembers, useCreatePersonFromMember } from '@/composables/api/usePersons'
|
||||||
|
import { useCrowdTypeList } from '@/composables/api/useCrowdTypes'
|
||||||
|
import { requiredValidator } from '@core/utils/validators'
|
||||||
|
import type { AvailableMember } from '@/types/member'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
eventId: string
|
||||||
|
orgId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modelValue = defineModel<boolean>({ required: true })
|
||||||
|
|
||||||
|
const eventIdRef = computed(() => props.eventId)
|
||||||
|
const orgIdRef = computed(() => props.orgId)
|
||||||
|
|
||||||
|
const { data: crowdTypes } = useCrowdTypeList(orgIdRef)
|
||||||
|
const { data: availableMembers, isLoading, isError, refetch } = useAvailableMembers(orgIdRef, eventIdRef, modelValue)
|
||||||
|
const { mutate: createFromMember, isPending: isCreating } = useCreatePersonFromMember(orgIdRef, eventIdRef)
|
||||||
|
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const selectedCrowdTypeId = ref('')
|
||||||
|
const showSuccess = ref(false)
|
||||||
|
const successName = ref('')
|
||||||
|
const creatingMemberId = ref<string | null>(null)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
const crowdTypeItems = computed(() =>
|
||||||
|
crowdTypes.value
|
||||||
|
?.filter(ct => ct.is_active && (ct.system_type === 'CREW' || ct.system_type === 'VOLUNTEER'))
|
||||||
|
.map(ct => ({
|
||||||
|
title: ct.name,
|
||||||
|
value: ct.id,
|
||||||
|
})) ?? [],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Default to first CREW type
|
||||||
|
watch(crowdTypeItems, items => {
|
||||||
|
if (!selectedCrowdTypeId.value && items.length > 0) {
|
||||||
|
const crewType = crowdTypes.value?.find(ct => ct.is_active && ct.system_type === 'CREW')
|
||||||
|
selectedCrowdTypeId.value = crewType?.id ?? items[0].value
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
const filteredMembers = computed(() => {
|
||||||
|
if (!availableMembers.value) return []
|
||||||
|
if (!searchQuery.value) return availableMembers.value
|
||||||
|
|
||||||
|
const q = searchQuery.value.toLowerCase()
|
||||||
|
return availableMembers.value.filter(
|
||||||
|
m => m.full_name.toLowerCase().includes(q) || m.email.toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function onAddMember(member: AvailableMember) {
|
||||||
|
if (!selectedCrowdTypeId.value) return
|
||||||
|
|
||||||
|
creatingMemberId.value = member.id
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
createFromMember(
|
||||||
|
{ user_id: member.id, crowd_type_id: selectedCrowdTypeId.value },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
successName.value = member.full_name
|
||||||
|
showSuccess.value = true
|
||||||
|
creatingMemberId.value = null
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
creatingMemberId.value = null
|
||||||
|
const axiosError = err as { response?: { data?: { message?: string; errors?: Record<string, string[]> } } }
|
||||||
|
const data = axiosError.response?.data
|
||||||
|
if (data?.errors?.user_id) {
|
||||||
|
errorMessage.value = data.errors.user_id[0]
|
||||||
|
}
|
||||||
|
else if (data?.message) {
|
||||||
|
errorMessage.value = data.message
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
errorMessage.value = 'Er ging iets mis bij het toevoegen.'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClose() {
|
||||||
|
searchQuery.value = ''
|
||||||
|
errorMessage.value = ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VDialog
|
||||||
|
v-model="modelValue"
|
||||||
|
max-width="550"
|
||||||
|
@after-leave="onClose"
|
||||||
|
>
|
||||||
|
<VCard title="Lid toevoegen als deelnemer">
|
||||||
|
<VCardText>
|
||||||
|
<VRow>
|
||||||
|
<VCol cols="12">
|
||||||
|
<AppSelect
|
||||||
|
v-model="selectedCrowdTypeId"
|
||||||
|
label="Crowd Type"
|
||||||
|
:items="crowdTypeItems"
|
||||||
|
:rules="[requiredValidator]"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12">
|
||||||
|
<AppTextField
|
||||||
|
v-model="searchQuery"
|
||||||
|
label="Zoek op naam of e-mail"
|
||||||
|
prepend-inner-icon="tabler-search"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
|
||||||
|
<VAlert
|
||||||
|
v-if="errorMessage"
|
||||||
|
type="error"
|
||||||
|
class="mt-4 mb-2"
|
||||||
|
closable
|
||||||
|
@click:close="errorMessage = ''"
|
||||||
|
>
|
||||||
|
{{ errorMessage }}
|
||||||
|
</VAlert>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<VSkeletonLoader
|
||||||
|
v-if="isLoading"
|
||||||
|
type="list-item-two-line, list-item-two-line, list-item-two-line"
|
||||||
|
class="mt-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<VAlert
|
||||||
|
v-else-if="isError"
|
||||||
|
type="error"
|
||||||
|
class="mt-4"
|
||||||
|
>
|
||||||
|
Kon beschikbare leden niet laden.
|
||||||
|
<template #append>
|
||||||
|
<VBtn
|
||||||
|
variant="text"
|
||||||
|
@click="refetch()"
|
||||||
|
>
|
||||||
|
Opnieuw proberen
|
||||||
|
</VBtn>
|
||||||
|
</template>
|
||||||
|
</VAlert>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div
|
||||||
|
v-else-if="!filteredMembers.length"
|
||||||
|
class="text-center pa-8"
|
||||||
|
>
|
||||||
|
<VIcon
|
||||||
|
icon="tabler-users-check"
|
||||||
|
size="48"
|
||||||
|
class="mb-4 text-disabled"
|
||||||
|
/>
|
||||||
|
<p class="text-body-1 text-disabled mb-0">
|
||||||
|
{{ searchQuery
|
||||||
|
? 'Geen leden gevonden voor deze zoekopdracht'
|
||||||
|
: 'Alle organisatieleden zijn al toegevoegd aan dit evenement'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Member list -->
|
||||||
|
<VList
|
||||||
|
v-else
|
||||||
|
class="mt-4"
|
||||||
|
>
|
||||||
|
<VListItem
|
||||||
|
v-for="member in filteredMembers"
|
||||||
|
:key="member.id"
|
||||||
|
:disabled="isCreating && creatingMemberId === member.id"
|
||||||
|
@click="onAddMember(member)"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<VAvatar
|
||||||
|
size="36"
|
||||||
|
color="primary"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
<span class="text-caption">
|
||||||
|
{{ member.first_name[0] }}{{ member.last_name[0] }}
|
||||||
|
</span>
|
||||||
|
</VAvatar>
|
||||||
|
</template>
|
||||||
|
<VListItemTitle>{{ member.full_name }}</VListItemTitle>
|
||||||
|
<VListItemSubtitle>{{ member.email }}</VListItemSubtitle>
|
||||||
|
<template #append>
|
||||||
|
<VProgressCircular
|
||||||
|
v-if="isCreating && creatingMemberId === member.id"
|
||||||
|
size="20"
|
||||||
|
width="2"
|
||||||
|
indeterminate
|
||||||
|
/>
|
||||||
|
<VIcon
|
||||||
|
v-else
|
||||||
|
icon="tabler-plus"
|
||||||
|
size="20"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</VListItem>
|
||||||
|
</VList>
|
||||||
|
</VCardText>
|
||||||
|
<VCardActions>
|
||||||
|
<VSpacer />
|
||||||
|
<VBtn
|
||||||
|
variant="text"
|
||||||
|
@click="modelValue = false"
|
||||||
|
>
|
||||||
|
Sluiten
|
||||||
|
</VBtn>
|
||||||
|
</VCardActions>
|
||||||
|
</VCard>
|
||||||
|
</VDialog>
|
||||||
|
|
||||||
|
<VSnackbar
|
||||||
|
v-model="showSuccess"
|
||||||
|
color="success"
|
||||||
|
:timeout="3000"
|
||||||
|
>
|
||||||
|
{{ successName }} toegevoegd als deelnemer
|
||||||
|
</VSnackbar>
|
||||||
|
</template>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
import { apiClient } from '@/lib/axios'
|
import { apiClient } from '@/lib/axios'
|
||||||
|
import type { AvailableMember } from '@/types/member'
|
||||||
import type { CreatePersonPayload, Person, UpdatePersonPayload } from '@/types/person'
|
import type { CreatePersonPayload, Person, UpdatePersonPayload } from '@/types/person'
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
@@ -128,3 +129,36 @@ export function useDeletePerson(orgId: Ref<string>, eventId: Ref<string>) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useAvailableMembers(orgId: Ref<string>, eventId: Ref<string>, enabled: Ref<boolean>) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['available-members', orgId, eventId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await apiClient.get<{ data: AvailableMember[] }>(
|
||||||
|
`/organisations/${orgId.value}/members/available-for-event/${eventId.value}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
},
|
||||||
|
enabled: () => !!orgId.value && !!eventId.value && enabled.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreatePersonFromMember(orgId: Ref<string>, eventId: Ref<string>) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (payload: { user_id: string; crowd_type_id: string }) => {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<Person>>(
|
||||||
|
`/organisations/${orgId.value}/events/${eventId.value}/persons/from-member`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['persons', eventId.value] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['available-members', orgId.value, eventId.value] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { usePersonList, useApprovePerson, useDeletePerson } from '@/composables/
|
|||||||
import { useCrowdTypeList } from '@/composables/api/useCrowdTypes'
|
import { useCrowdTypeList } from '@/composables/api/useCrowdTypes'
|
||||||
import { useAuthStore } from '@/stores/useAuthStore'
|
import { useAuthStore } from '@/stores/useAuthStore'
|
||||||
import EventTabsNav from '@/components/events/EventTabsNav.vue'
|
import EventTabsNav from '@/components/events/EventTabsNav.vue'
|
||||||
|
import AddMemberAsPersonDialog from '@/components/persons/AddMemberAsPersonDialog.vue'
|
||||||
import CreatePersonDialog from '@/components/persons/CreatePersonDialog.vue'
|
import CreatePersonDialog from '@/components/persons/CreatePersonDialog.vue'
|
||||||
import EditPersonDialog from '@/components/persons/EditPersonDialog.vue'
|
import EditPersonDialog from '@/components/persons/EditPersonDialog.vue'
|
||||||
import PersonDetailPanel from '@/components/persons/PersonDetailPanel.vue'
|
import PersonDetailPanel from '@/components/persons/PersonDetailPanel.vue'
|
||||||
@@ -112,6 +113,7 @@ function getInitials(name: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Dialogs & panel
|
// Dialogs & panel
|
||||||
|
const isAddMemberDialogOpen = ref(false)
|
||||||
const isCreateDialogOpen = ref(false)
|
const isCreateDialogOpen = ref(false)
|
||||||
const isEditDialogOpen = ref(false)
|
const isEditDialogOpen = ref(false)
|
||||||
const editingPerson = ref<Person | null>(null)
|
const editingPerson = ref<Person | null>(null)
|
||||||
@@ -194,12 +196,21 @@ const crowdTypeOptions = computed(() => [
|
|||||||
style="min-inline-size: 180px;"
|
style="min-inline-size: 180px;"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<VBtn
|
<div class="d-flex gap-x-2">
|
||||||
prepend-icon="tabler-plus"
|
<VBtn
|
||||||
@click="isCreateDialogOpen = true"
|
prepend-icon="tabler-user-plus"
|
||||||
>
|
variant="tonal"
|
||||||
Persoon toevoegen
|
@click="isAddMemberDialogOpen = true"
|
||||||
</VBtn>
|
>
|
||||||
|
Lid toevoegen
|
||||||
|
</VBtn>
|
||||||
|
<VBtn
|
||||||
|
prepend-icon="tabler-plus"
|
||||||
|
@click="isCreateDialogOpen = true"
|
||||||
|
>
|
||||||
|
Persoon toevoegen
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- KPI Tiles -->
|
<!-- KPI Tiles -->
|
||||||
@@ -378,6 +389,13 @@ const crowdTypeOptions = computed(() => [
|
|||||||
</VDataTable>
|
</VDataTable>
|
||||||
</VCard>
|
</VCard>
|
||||||
|
|
||||||
|
<!-- Add member dialog -->
|
||||||
|
<AddMemberAsPersonDialog
|
||||||
|
v-model="isAddMemberDialogOpen"
|
||||||
|
:event-id="eventId"
|
||||||
|
:org-id="orgId"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Create dialog -->
|
<!-- Create dialog -->
|
||||||
<CreatePersonDialog
|
<CreatePersonDialog
|
||||||
v-model="isCreateDialogOpen"
|
v-model="isCreateDialogOpen"
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ export type OrganisationRole =
|
|||||||
| 'staff_coordinator'
|
| 'staff_coordinator'
|
||||||
| 'volunteer_coordinator'
|
| 'volunteer_coordinator'
|
||||||
|
|
||||||
|
export interface AvailableMember {
|
||||||
|
id: string
|
||||||
|
first_name: string
|
||||||
|
last_name: string
|
||||||
|
full_name: string
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface MemberListResponse {
|
export interface MemberListResponse {
|
||||||
data: Member[]
|
data: Member[]
|
||||||
meta: {
|
meta: {
|
||||||
|
|||||||
@@ -355,6 +355,50 @@ Validates:
|
|||||||
- `PUT /organisations/{org}/events/{event}/persons/{person}`
|
- `PUT /organisations/{org}/events/{event}/persons/{person}`
|
||||||
- `POST /organisations/{org}/events/{event}/persons/{person}/approve`
|
- `POST /organisations/{org}/events/{event}/persons/{person}/approve`
|
||||||
- `DELETE /organisations/{org}/events/{event}/persons/{person}`
|
- `DELETE /organisations/{org}/events/{event}/persons/{person}`
|
||||||
|
- `POST /organisations/{org}/events/{event}/persons/from-member` — create person from org member
|
||||||
|
- `GET /organisations/{org}/members/available-for-event/{event}` — list members not yet added to event
|
||||||
|
|
||||||
|
### Create Person from Member
|
||||||
|
|
||||||
|
`POST /organisations/{org}/events/{event}/persons/from-member`
|
||||||
|
|
||||||
|
Creates a Person record from an existing organisation member. The person is created with `status: approved` and `user_id` pre-linked.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_id": "01JXYZ...",
|
||||||
|
"crowd_type_id": "01JXYZ..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Person data (first_name, last_name, email) is copied from the user account. Returns `PersonResource` (201).
|
||||||
|
|
||||||
|
**Validation:**
|
||||||
|
- User must belong to the organisation
|
||||||
|
- User must not already be a person at this event (422)
|
||||||
|
- crowd_type_id must belong to the organisation
|
||||||
|
|
||||||
|
### Available Members for Event
|
||||||
|
|
||||||
|
`GET /organisations/{org}/members/available-for-event/{event}`
|
||||||
|
|
||||||
|
Returns organisation members who do NOT yet have a Person record at the specified event. Used to populate the "Lid toevoegen" dialog.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "01JXYZ...",
|
||||||
|
"first_name": "Jan",
|
||||||
|
"last_name": "de Vries",
|
||||||
|
"full_name": "Jan de Vries",
|
||||||
|
"email": "jan@example.nl"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Auth: org member or higher on the organisation.
|
||||||
|
|
||||||
## Identity Matches
|
## Identity Matches
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user