diff --git a/api/app/Http/Controllers/Api/V1/CrowdTypeController.php b/api/app/Http/Controllers/Api/V1/CrowdTypeController.php index 3045299..df38cfc 100644 --- a/api/app/Http/Controllers/Api/V1/CrowdTypeController.php +++ b/api/app/Http/Controllers/Api/V1/CrowdTypeController.php @@ -20,7 +20,10 @@ final class CrowdTypeController extends Controller { Gate::authorize('viewAny', [CrowdType::class, $organisation]); - $crowdTypes = $organisation->crowdTypes()->where('is_active', true)->get(); + $crowdTypes = $organisation->crowdTypes() + ->orderByDesc('is_active') + ->orderBy('name') + ->get(); return CrowdTypeResource::collection($crowdTypes); } @@ -47,11 +50,7 @@ final class CrowdTypeController extends Controller { Gate::authorize('delete', [$crowdType, $organisation]); - if ($crowdType->persons()->exists()) { - $crowdType->update(['is_active' => false]); - } else { - $crowdType->delete(); - } + $crowdType->update(['is_active' => false]); return response()->json(null, 204); } diff --git a/api/app/Http/Requests/Api/V1/StoreCrowdTypeRequest.php b/api/app/Http/Requests/Api/V1/StoreCrowdTypeRequest.php index 4ddfce6..e5c1ef0 100644 --- a/api/app/Http/Requests/Api/V1/StoreCrowdTypeRequest.php +++ b/api/app/Http/Requests/Api/V1/StoreCrowdTypeRequest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Requests\Api\V1; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; final class StoreCrowdTypeRequest extends FormRequest { @@ -17,7 +18,12 @@ final class StoreCrowdTypeRequest extends FormRequest public function rules(): array { return [ - 'name' => ['required', 'string', 'max:100'], + 'name' => [ + 'required', + 'string', + 'max:100', + Rule::unique('crowd_types')->where('organisation_id', $this->route('organisation')->id), + ], 'system_type' => ['required', 'in:CREW,GUEST,ARTIST,VOLUNTEER,PRESS,PARTNER,SUPPLIER'], 'color' => ['required', 'regex:/^#[0-9A-Fa-f]{6}$/'], 'icon' => ['nullable', 'string', 'max:50'], diff --git a/api/tests/Feature/CrowdType/CrowdTypeTest.php b/api/tests/Feature/CrowdType/CrowdTypeTest.php index 5165e5e..f855b48 100644 --- a/api/tests/Feature/CrowdType/CrowdTypeTest.php +++ b/api/tests/Feature/CrowdType/CrowdTypeTest.php @@ -123,10 +123,11 @@ class CrowdTypeTest extends TestCase ->assertJson(['data' => ['name' => 'New Name', 'color' => '#ff0000']]); } - public function test_destroy_deletes_crowd_type_without_persons(): void + public function test_destroy_deactivates_crowd_type(): void { $crowdType = CrowdType::factory()->create([ 'organisation_id' => $this->organisation->id, + 'is_active' => true, ]); Sanctum::actingAs($this->orgAdmin); @@ -134,6 +135,47 @@ class CrowdTypeTest extends TestCase $response = $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/crowd-types/{$crowdType->id}"); $response->assertNoContent(); - $this->assertDatabaseMissing('crowd_types', ['id' => $crowdType->id]); + $this->assertDatabaseHas('crowd_types', [ + 'id' => $crowdType->id, + 'is_active' => false, + ]); + } + + public function test_store_duplicate_name_returns_422(): void + { + CrowdType::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'name' => 'Vrijwilliger', + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/crowd-types", [ + 'name' => 'Vrijwilliger', + 'system_type' => 'VOLUNTEER', + 'color' => '#10b981', + ]); + + $response->assertUnprocessable() + ->assertJsonValidationErrors('name'); + } + + public function test_index_includes_inactive_crowd_types(): void + { + CrowdType::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'is_active' => true, + ]); + CrowdType::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'is_active' => false, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/crowd-types"); + + $response->assertOk(); + $this->assertCount(2, $response->json('data')); } } diff --git a/apps/app/src/components/organisations/CrowdTypesManager.vue b/apps/app/src/components/organisations/CrowdTypesManager.vue new file mode 100644 index 0000000..44c762b --- /dev/null +++ b/apps/app/src/components/organisations/CrowdTypesManager.vue @@ -0,0 +1,393 @@ + + + diff --git a/apps/app/src/composables/api/useCrowdTypes.ts b/apps/app/src/composables/api/useCrowdTypes.ts index 10a4e8d..ea79665 100644 --- a/apps/app/src/composables/api/useCrowdTypes.ts +++ b/apps/app/src/composables/api/useCrowdTypes.ts @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/vue-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' import type { Ref } from 'vue' import { apiClient } from '@/lib/axios' import type { CrowdType } from '@/types/organisation' @@ -14,6 +14,12 @@ interface PaginatedResponse { } } +interface ApiResponse { + success: boolean + data: T + message?: string +} + export function useCrowdTypeList(orgId: Ref) { return useQuery({ queryKey: ['crowd-types', orgId], @@ -28,3 +34,50 @@ export function useCrowdTypeList(orgId: Ref) { staleTime: Infinity, }) } + +export function useCreateCrowdType(orgId: Ref) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (payload: { name: string; system_type: string; color: string; icon?: string | null }) => { + const { data } = await apiClient.post>( + `/organisations/${orgId.value}/crowd-types`, + payload, + ) + return data.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['crowd-types', orgId] }) + }, + }) +} + +export function useUpdateCrowdType(orgId: Ref) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ id, ...payload }: { id: string; name?: string; color?: string; icon?: string | null; is_active?: boolean }) => { + const { data } = await apiClient.put>( + `/organisations/${orgId.value}/crowd-types/${id}`, + payload, + ) + return data.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['crowd-types', orgId] }) + }, + }) +} + +export function useDeleteCrowdType(orgId: Ref) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (id: string) => { + await apiClient.delete(`/organisations/${orgId.value}/crowd-types/${id}`) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['crowd-types', orgId] }) + }, + }) +} diff --git a/apps/app/src/pages/organisation/index.vue b/apps/app/src/pages/organisation/index.vue index 844c726..0beb081 100644 --- a/apps/app/src/pages/organisation/index.vue +++ b/apps/app/src/pages/organisation/index.vue @@ -2,6 +2,7 @@ import { useMyOrganisation } from '@/composables/api/useOrganisations' import { useAuthStore } from '@/stores/useAuthStore' import EditOrganisationDialog from '@/components/organisations/EditOrganisationDialog.vue' +import CrowdTypesManager from '@/components/organisations/CrowdTypesManager.vue' import type { Organisation } from '@/types/organisation' const authStore = useAuthStore() @@ -123,6 +124,13 @@ function formatDate(iso: string) { + + +