diff --git a/api/app/Http/Controllers/Api/V1/CompanyController.php b/api/app/Http/Controllers/Api/V1/CompanyController.php index fc4a5ae..1580708 100644 --- a/api/app/Http/Controllers/Api/V1/CompanyController.php +++ b/api/app/Http/Controllers/Api/V1/CompanyController.php @@ -20,7 +20,10 @@ final class CompanyController extends Controller { Gate::authorize('viewAny', [Company::class, $organisation]); - $companies = $organisation->companies()->get(); + $companies = $organisation->companies() + ->withCount('persons') + ->ordered() + ->get(); return CompanyResource::collection($companies); } @@ -34,6 +37,15 @@ final class CompanyController extends Controller return $this->created(new CompanyResource($company)); } + public function show(Organisation $organisation, Company $company): JsonResponse + { + Gate::authorize('view', [$company, $organisation]); + + $company->loadCount('persons'); + + return $this->success(new CompanyResource($company)); + } + public function update(UpdateCompanyRequest $request, Organisation $organisation, Company $company): JsonResponse { Gate::authorize('update', [$company, $organisation]); diff --git a/api/app/Http/Requests/Api/V1/StoreCompanyRequest.php b/api/app/Http/Requests/Api/V1/StoreCompanyRequest.php index 4f43970..a1e5680 100644 --- a/api/app/Http/Requests/Api/V1/StoreCompanyRequest.php +++ b/api/app/Http/Requests/Api/V1/StoreCompanyRequest.php @@ -17,10 +17,10 @@ final class StoreCompanyRequest extends FormRequest public function rules(): array { return [ - 'name' => ['required', 'string', 'max:255'], + 'name' => ['required', 'string', 'max:100'], 'type' => ['required', 'in:supplier,partner,agency,venue,other'], - 'contact_name' => ['nullable', 'string', 'max:255'], - 'contact_email' => ['nullable', 'email', 'max:255'], + 'contact_name' => ['nullable', 'string', 'max:100'], + 'contact_email' => ['nullable', 'email', 'max:100'], 'contact_phone' => ['nullable', 'string', 'max:30'], ]; } diff --git a/api/app/Http/Requests/Api/V1/UpdateCompanyRequest.php b/api/app/Http/Requests/Api/V1/UpdateCompanyRequest.php index b62b4d3..aee1a1f 100644 --- a/api/app/Http/Requests/Api/V1/UpdateCompanyRequest.php +++ b/api/app/Http/Requests/Api/V1/UpdateCompanyRequest.php @@ -17,10 +17,10 @@ final class UpdateCompanyRequest extends FormRequest public function rules(): array { return [ - 'name' => ['sometimes', 'string', 'max:255'], + 'name' => ['sometimes', 'string', 'max:100'], 'type' => ['sometimes', 'in:supplier,partner,agency,venue,other'], - 'contact_name' => ['nullable', 'string', 'max:255'], - 'contact_email' => ['nullable', 'email', 'max:255'], + 'contact_name' => ['nullable', 'string', 'max:100'], + 'contact_email' => ['nullable', 'email', 'max:100'], 'contact_phone' => ['nullable', 'string', 'max:30'], ]; } diff --git a/api/app/Http/Resources/Api/V1/CompanyResource.php b/api/app/Http/Resources/Api/V1/CompanyResource.php index 07e4859..9a1fc21 100644 --- a/api/app/Http/Resources/Api/V1/CompanyResource.php +++ b/api/app/Http/Resources/Api/V1/CompanyResource.php @@ -19,6 +19,7 @@ final class CompanyResource extends JsonResource 'contact_name' => $this->contact_name, 'contact_email' => $this->contact_email, 'contact_phone' => $this->contact_phone, + 'persons_count' => $this->whenCounted('persons'), 'created_at' => $this->created_at->toIso8601String(), ]; } diff --git a/api/app/Models/Company.php b/api/app/Models/Company.php index 9db1e89..13fe8d8 100644 --- a/api/app/Models/Company.php +++ b/api/app/Models/Company.php @@ -7,6 +7,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; @@ -35,4 +36,10 @@ final class Company extends Model { return $this->hasMany(Person::class); } + + /** @param Builder $query */ + public function scopeOrdered(Builder $query): Builder + { + return $query->orderBy('name'); + } } diff --git a/api/app/Policies/CompanyPolicy.php b/api/app/Policies/CompanyPolicy.php index d929bf7..ab3e65d 100644 --- a/api/app/Policies/CompanyPolicy.php +++ b/api/app/Policies/CompanyPolicy.php @@ -16,6 +16,16 @@ final class CompanyPolicy || $organisation->users()->where('user_id', $user->id)->exists(); } + public function view(User $user, Company $company, Organisation $organisation): bool + { + if ($company->organisation_id !== $organisation->id) { + return false; + } + + return $user->hasRole('super_admin') + || $organisation->users()->where('user_id', $user->id)->exists(); + } + public function create(User $user, Organisation $organisation): bool { return $this->canManageOrganisation($user, $organisation); diff --git a/api/tests/Feature/Company/CompanyTest.php b/api/tests/Feature/Company/CompanyTest.php new file mode 100644 index 0000000..9a7a4ee --- /dev/null +++ b/api/tests/Feature/Company/CompanyTest.php @@ -0,0 +1,252 @@ +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->outsider = User::factory()->create(); + $this->otherOrganisation->users()->attach($this->outsider, ['role' => 'org_admin']); + } + + public function test_index_returns_companies_for_organisation(): void + { + Company::factory()->count(3)->create(['organisation_id' => $this->organisation->id]); + + // Company on other org should not appear + Company::factory()->create(['organisation_id' => $this->otherOrganisation->id]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/companies"); + + $response->assertOk(); + $this->assertCount(3, $response->json('data')); + } + + public function test_index_returns_companies_ordered_by_name(): void + { + Company::factory()->create(['organisation_id' => $this->organisation->id, 'name' => 'Zebra BV']); + Company::factory()->create(['organisation_id' => $this->organisation->id, 'name' => 'Alpha BV']); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/companies"); + + $response->assertOk(); + $names = collect($response->json('data'))->pluck('name')->all(); + $this->assertSame(['Alpha BV', 'Zebra BV'], $names); + } + + public function test_index_includes_persons_count(): void + { + $company = Company::factory()->create(['organisation_id' => $this->organisation->id]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/companies"); + + $response->assertOk(); + $this->assertArrayHasKey('persons_count', $response->json('data.0')); + } + + public function test_index_unauthenticated_returns_401(): void + { + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/companies"); + + $response->assertUnauthorized(); + } + + public function test_index_cross_org_returns_403(): void + { + Sanctum::actingAs($this->outsider); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/companies"); + + $response->assertForbidden(); + } + + public function test_store_creates_company(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/companies", [ + 'name' => 'Catering Janssen', + 'type' => 'supplier', + 'contact_name' => 'Jan Janssen', + 'contact_email' => 'jan@janssen.nl', + 'contact_phone' => '+31612345678', + ]); + + $response->assertCreated() + ->assertJson(['data' => ['name' => 'Catering Janssen', 'type' => 'supplier']]); + + $this->assertDatabaseHas('companies', [ + 'organisation_id' => $this->organisation->id, + 'name' => 'Catering Janssen', + 'type' => 'supplier', + ]); + } + + public function test_store_unauthenticated_returns_401(): void + { + $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/companies", [ + 'name' => 'Test BV', + 'type' => 'supplier', + ]); + + $response->assertUnauthorized(); + } + + public function test_store_cross_org_returns_403(): void + { + Sanctum::actingAs($this->outsider); + + $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/companies", [ + 'name' => 'Test BV', + 'type' => 'supplier', + ]); + + $response->assertForbidden(); + } + + public function test_store_missing_name_returns_422(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/companies", [ + 'type' => 'supplier', + ]); + + $response->assertUnprocessable() + ->assertJsonValidationErrors('name'); + } + + public function test_store_invalid_type_returns_422(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/companies", [ + 'name' => 'Test BV', + 'type' => 'invalid_type', + ]); + + $response->assertUnprocessable() + ->assertJsonValidationErrors('type'); + } + + public function test_show_returns_company(): void + { + $company = Company::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'name' => 'Test BV', + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/companies/{$company->id}"); + + $response->assertOk() + ->assertJson(['data' => ['name' => 'Test BV']]); + } + + public function test_show_cross_org_returns_403(): void + { + $company = Company::factory()->create([ + 'organisation_id' => $this->organisation->id, + ]); + + Sanctum::actingAs($this->outsider); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/companies/{$company->id}"); + + $response->assertForbidden(); + } + + public function test_update_company(): void + { + $company = Company::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'name' => 'Old Name', + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/companies/{$company->id}", [ + 'name' => 'New Name', + 'type' => 'partner', + ]); + + $response->assertOk() + ->assertJson(['data' => ['name' => 'New Name', 'type' => 'partner']]); + } + + public function test_update_cross_org_returns_403(): void + { + $company = Company::factory()->create([ + 'organisation_id' => $this->organisation->id, + ]); + + Sanctum::actingAs($this->outsider); + + $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/companies/{$company->id}", [ + 'name' => 'Hacked', + ]); + + $response->assertForbidden(); + } + + public function test_destroy_soft_deletes_company(): void + { + $company = Company::factory()->create([ + 'organisation_id' => $this->organisation->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/companies/{$company->id}"); + + $response->assertNoContent(); + $this->assertSoftDeleted('companies', ['id' => $company->id]); + } + + public function test_destroy_cross_org_returns_403(): void + { + $company = Company::factory()->create([ + 'organisation_id' => $this->organisation->id, + ]); + + Sanctum::actingAs($this->outsider); + + $response = $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/companies/{$company->id}"); + + $response->assertForbidden(); + } +} diff --git a/apps/app/src/components/organisation/CompanyDialog.vue b/apps/app/src/components/organisation/CompanyDialog.vue new file mode 100644 index 0000000..d0b160e --- /dev/null +++ b/apps/app/src/components/organisation/CompanyDialog.vue @@ -0,0 +1,190 @@ + + + diff --git a/apps/app/src/components/persons/CreatePersonDialog.vue b/apps/app/src/components/persons/CreatePersonDialog.vue index 44df087..af83aa6 100644 --- a/apps/app/src/components/persons/CreatePersonDialog.vue +++ b/apps/app/src/components/persons/CreatePersonDialog.vue @@ -2,6 +2,7 @@ import { VForm } from 'vuetify/components/VForm' import { useCreatePerson } from '@/composables/api/usePersons' import { useCrowdTypeList } from '@/composables/api/useCrowdTypes' +import { useCompanies } from '@/composables/api/useCompanies' import { requiredValidator, emailValidator } from '@core/utils/validators' import type { PersonStatus } from '@/types/person' @@ -16,6 +17,7 @@ const eventIdRef = computed(() => props.eventId) const orgIdRef = computed(() => props.orgId) const { data: crowdTypes } = useCrowdTypeList(orgIdRef) +const { data: companies } = useCompanies(orgIdRef) const form = ref({ crowd_type_id: '', @@ -34,9 +36,27 @@ const successName = ref('') const { mutate: createPerson, isPending } = useCreatePerson(eventIdRef) const crowdTypeItems = computed(() => - crowdTypes.value?.map(ct => ({ - title: ct.name, - value: ct.id, + crowdTypes.value + ?.filter(ct => ct.is_active) + .map(ct => ({ + title: ct.name, + value: ct.id, + })) ?? [], +) + +const typeLabel: Record = { + supplier: 'Leverancier', + partner: 'Partner', + agency: 'Bureau', + venue: 'Locatie', + other: 'Overig', +} + +const companyItems = computed(() => + companies.value?.map(c => ({ + title: c.name, + value: c.id, + subtitle: typeLabel[c.type] ?? c.type, })) ?? [], ) @@ -104,11 +124,11 @@ function onSubmit() { @after-leave="resetForm" > - - + + - - - Komt beschikbaar zodra bedrijven zijn aangemaakt - + - - - - - - Annuleren - - - Toevoegen - - + + + + + Annuleren + + + Toevoegen + + + diff --git a/apps/app/src/components/persons/EditPersonDialog.vue b/apps/app/src/components/persons/EditPersonDialog.vue index 7ab3819..048f8b3 100644 --- a/apps/app/src/components/persons/EditPersonDialog.vue +++ b/apps/app/src/components/persons/EditPersonDialog.vue @@ -2,6 +2,7 @@ import { VForm } from 'vuetify/components/VForm' import { useUpdatePerson } from '@/composables/api/usePersons' import { useCrowdTypeList } from '@/composables/api/useCrowdTypes' +import { useCompanies } from '@/composables/api/useCompanies' import { requiredValidator, emailValidator } from '@core/utils/validators' import type { Person, PersonStatus } from '@/types/person' @@ -17,6 +18,7 @@ const eventIdRef = computed(() => props.eventId) const orgIdRef = computed(() => props.orgId) const { data: crowdTypes } = useCrowdTypeList(orgIdRef) +const { data: companies } = useCompanies(orgIdRef) const { mutate: updatePerson, isPending } = useUpdatePerson(eventIdRef) const form = ref({ @@ -24,6 +26,7 @@ const form = ref({ name: '', email: '', phone: '', + company_id: '', status: 'pending' as PersonStatus, admin_notes: '', is_blacklisted: false, @@ -39,6 +42,7 @@ watch(() => props.person, (p) => { name: p.name, email: p.email, phone: p.phone ?? '', + company_id: p.company?.id ?? '', status: p.status, admin_notes: p.admin_notes ?? '', is_blacklisted: p.is_blacklisted, @@ -46,9 +50,27 @@ watch(() => props.person, (p) => { }, { immediate: true }) const crowdTypeItems = computed(() => - crowdTypes.value?.map(ct => ({ - title: ct.name, - value: ct.id, + crowdTypes.value + ?.filter(ct => ct.is_active) + .map(ct => ({ + title: ct.name, + value: ct.id, + })) ?? [], +) + +const typeLabel: Record = { + supplier: 'Leverancier', + partner: 'Partner', + agency: 'Bureau', + venue: 'Locatie', + other: 'Overig', +} + +const companyItems = computed(() => + companies.value?.map(c => ({ + title: c.name, + value: c.id, + subtitle: typeLabel[c.type] ?? c.type, })) ?? [], ) @@ -73,6 +95,7 @@ function onSubmit() { name: form.value.name, email: form.value.email, phone: form.value.phone || undefined, + company_id: form.value.company_id || undefined, status: form.value.status, admin_notes: form.value.admin_notes || undefined, is_blacklisted: form.value.is_blacklisted, @@ -103,11 +126,11 @@ function onSubmit() { max-width="550" > - - + + + + + - - - - - - Annuleren - - - Opslaan - - + + + + + Annuleren + + + Opslaan + + + diff --git a/apps/app/src/composables/api/useCompanies.ts b/apps/app/src/composables/api/useCompanies.ts new file mode 100644 index 0000000..e63cf6e --- /dev/null +++ b/apps/app/src/composables/api/useCompanies.ts @@ -0,0 +1,74 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' +import type { Ref } from 'vue' +import { apiClient } from '@/lib/axios' +import type { Company, CreateCompanyPayload, UpdateCompanyPayload } from '@/types/organisation' + +interface ApiResponse { + success: boolean + data: T + message?: string +} + +export function useCompanies(orgId: Ref) { + return useQuery({ + queryKey: ['companies', orgId], + queryFn: async () => { + const { data } = await apiClient.get<{ data: Company[] }>( + `/organisations/${orgId.value}/companies`, + ) + + return data.data + }, + enabled: () => !!orgId.value, + staleTime: Infinity, + }) +} + +export function useCreateCompany(orgId: Ref) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (payload: CreateCompanyPayload) => { + const { data } = await apiClient.post>( + `/organisations/${orgId.value}/companies`, + payload, + ) + + return data.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['companies', orgId] }) + }, + }) +} + +export function useUpdateCompany(orgId: Ref) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ id, ...payload }: UpdateCompanyPayload & { id: string }) => { + const { data } = await apiClient.put>( + `/organisations/${orgId.value}/companies/${id}`, + payload, + ) + + return data.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['companies', orgId] }) + }, + }) +} + +export function useDeleteCompany(orgId: Ref) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (id: string) => { + await apiClient.delete(`/organisations/${orgId.value}/companies/${id}`) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['companies', orgId] }) + }, + }) +} diff --git a/apps/app/src/navigation/vertical/index.ts b/apps/app/src/navigation/vertical/index.ts index b3e6dd6..46f423b 100644 --- a/apps/app/src/navigation/vertical/index.ts +++ b/apps/app/src/navigation/vertical/index.ts @@ -24,4 +24,9 @@ export default [ action: 'read', subject: 'members', }, + { + title: 'Bedrijven', + to: { name: 'organisation-companies' }, + icon: { icon: 'tabler-building' }, + }, ] diff --git a/apps/app/src/pages/organisation/companies.vue b/apps/app/src/pages/organisation/companies.vue new file mode 100644 index 0000000..8b0c336 --- /dev/null +++ b/apps/app/src/pages/organisation/companies.vue @@ -0,0 +1,301 @@ + + + diff --git a/apps/app/src/types/organisation.ts b/apps/app/src/types/organisation.ts index 9c40a1b..e037909 100644 --- a/apps/app/src/types/organisation.ts +++ b/apps/app/src/types/organisation.ts @@ -33,7 +33,19 @@ export interface CrowdType { export interface Company { id: string name: string - type: string + type: 'supplier' | 'partner' | 'agency' | 'venue' | 'other' contact_name: string | null contact_email: string | null + contact_phone: string | null + persons_count?: number } + +export interface CreateCompanyPayload { + name: string + type: Company['type'] + contact_name?: string | null + contact_email?: string | null + contact_phone?: string | null +} + +export interface UpdateCompanyPayload extends Partial {}