Add Membership management
continuous-integration/drone/push Build is failing Details
continuous-integration/drone/tag Build was killed Details

This commit is contained in:
philipp lang 2023-08-25 00:23:38 +02:00
parent a134be5f5b
commit 2e9ab78203
16 changed files with 453 additions and 63 deletions

View File

@ -0,0 +1,27 @@
<?php
namespace App\Group\Actions;
use App\Activity;
use App\Group;
use Inertia\Inertia;
use Inertia\Response;
use Lorisleiva\Actions\Concerns\AsAction;
class ListAction
{
use AsAction;
public function asController(): Response
{
session()->put('menu', 'group');
session()->put('title', 'Gruppen');
$activities = Activity::with('subactivities')->get();
return Inertia::render('group/Index', [
'activities' => $activities->pluck('name', 'id'),
'subactivities' => $activities->mapWithKeys(fn (Activity $activity) => [$activity->id => $activity->subactivities()->pluck('name', 'id')]),
'groups' => Group::pluck('name', 'id'),
]);
}
}

View File

@ -17,16 +17,17 @@ class SearchAction
/**
* @return LengthAwarePaginator<int, Member>
*/
public function handle(FilterScope $filter): LengthAwarePaginator
public function handle(FilterScope $filter, int $perPage): LengthAwarePaginator
{
return Member::search($filter->search)->query(fn ($q) => $q->select('*')
->withFilter($filter)
->ordered()
)->paginate(15);
return Member::search($filter->search)->query(
fn ($q) => $q->select('*')
->withFilter($filter)
->ordered()
)->paginate($perPage);
}
public function asController(ActionRequest $request): AnonymousResourceCollection
{
return MemberResource::collection($this->handle(FilterScope::fromRequest($request->input('filter', ''))));
return MemberResource::collection($this->handle(FilterScope::fromRequest($request->input('filter', '')), $request->input('per_page', 15)));
}
}

View File

@ -67,10 +67,11 @@ class FilterScope extends Filter
}
if (false === $this->hasFullAddress) {
$query->where(fn ($q) => $q
->orWhere('address', '')->orWhereNull('address')
->orWhere('zip', '')->orWhereNull('zip')
->orWhere('location', '')->orWhereNull('location')
$query->where(
fn ($q) => $q
->orWhere('address', '')->orWhereNull('address')
->orWhere('zip', '')->orWhereNull('zip')
->orWhere('location', '')->orWhereNull('location')
);
}

View File

@ -54,7 +54,7 @@ class MemberResource extends JsonResource
'subscription' => new SubscriptionResource($this->whenLoaded('subscription')),
'gender_id' => $this->gender_id,
'gender_name' => $this->gender?->name ?: 'keine Angabe',
'fullname' => ($this->gender ? $this->gender->salutation.' ' : '').$this->fullname,
'fullname' => ($this->gender ? $this->gender->salutation . ' ' : '') . $this->fullname,
'further_address' => $this->further_address,
'work_phone' => $this->work_phone,
'mobile_phone' => $this->mobile_phone,
@ -74,7 +74,7 @@ class MemberResource extends JsonResource
'children_phone' => $this->children_phone,
'payments' => PaymentResource::collection($this->whenLoaded('payments')),
'memberships' => MembershipResource::collection($this->whenLoaded('memberships')),
'pending_payment' => $this->pending_payment ? number_format($this->pending_payment / 100, 2, ',', '.').' €' : null,
'pending_payment' => $this->pending_payment ? number_format($this->pending_payment / 100, 2, ',', '.') . ' €' : null,
'age_group_icon' => $this->ageGroupMemberships->first()?->subactivity->slug,
'courses' => CourseMemberResource::collection($this->whenLoaded('courses')),
'nationality' => new NationalityResource($this->whenLoaded('nationality')),

View File

@ -53,6 +53,14 @@ class Membership extends Model
return $this->belongsTo(Subactivity::class);
}
/**
* @return BelongsTo<Member, self>
*/
public function member(): BelongsTo
{
return $this->belongsTo(Member::class);
}
/**
* @param Builder<Membership> $query
*

View File

@ -0,0 +1,22 @@
<?php
namespace App\Membership\Actions;
use App\Member\Membership;
use Illuminate\Http\JsonResponse;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
class ApiListAction
{
use AsAction;
public function asController(ActionRequest $request): JsonResponse
{
return response()->json(Membership::active()->where([
'group_id' => $request->group_id,
'activity_id' => $request->activity_id,
'subactivity_id' => $request->subactivity_id,
])->pluck('member_id'));
}
}

View File

@ -0,0 +1,99 @@
<?php
namespace App\Membership\Actions;
use App\Activity;
use App\Group;
use App\Lib\JobMiddleware\WithJobState;
use App\Lib\Queue\TracksJob;
use App\Maildispatcher\Actions\ResyncAction;
use App\Member\Member;
use App\Member\Membership;
use App\Setting\NamiSettings;
use App\Subactivity;
use Illuminate\Support\Facades\DB;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
class SyncAction
{
use AsAction;
use TracksJob;
/**
* @return array<string, string>
*/
public function rules(): array
{
return [
'group_id' => 'required|numeric|exists:groups,id',
'activity_id' => 'required|numeric|exists:activities,id',
'subactivity_id' => 'required|numeric|exists:subactivities,id',
'members' => 'array',
'members.*' => 'numeric|exists:members,id',
];
}
/**
* @param array<int, int> $members
*/
public function handle(Group $group, Activity $activity, Subactivity $subactivity, array $members): void
{
DB::transaction(function () use ($activity, $subactivity, $group, $members) {
$attributes = [
'group_id' => $group->id,
'activity_id' => $activity->id,
'subactivity_id' => $subactivity->id,
];
Membership::where($attributes)->active()->whereNotIn('member_id', $members)->get()
->each(fn ($membership) => MembershipDestroyAction::run($membership->member, $membership, app(NamiSettings::class)));
collect($members)
->except(Membership::where($attributes)->active()->pluck('member_id'))
->map(fn ($memberId) => Member::findOrFail($memberId))
->each(fn ($member) => MembershipStoreAction::run(
$member,
$activity,
$subactivity,
$group,
null,
app(NamiSettings::class),
));
ResyncAction::dispatch();
});
}
public function asController(ActionRequest $request): void
{
/**
* @var array{members: array<int, int>, group_id: int, activity_id: int, subactivity_id: int}
*/
$input = $request->validated();
$this->startJob(
Group::findOrFail($input['group_id']),
Activity::findOrFail($input['activity_id']),
Subactivity::findOrFail($input['subactivity_id']),
$input['members'],
);
}
/**
* @param mixed $parameters
*/
public function jobState(WithJobState $jobState, ...$parameters): WithJobState
{
return $jobState
->before('Gruppen werden aktualisiert')
->after('Gruppen aktualisiert')
->failed('Aktualisieren von Gruppen fehlgeschlagen');
}
public function jobChannel(): string
{
return 'group';
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -3,14 +3,14 @@
<div class="text-sm text-gray-500" v-html="desc"></div>
<div class="-mx-1 items-baseline" :class="{hidden: value.last_page == 1, flex: value.last_page > 1}">
<div class="pl-1 pr-3 text-gray-500 text-sm">Seite:</div>
<div class="px-1" v-for="(link, index) in links" :key="index">
<div v-for="(link, index) in links" :key="index" class="px-1">
<button
href="#"
@click.prevent="goto(link)"
class="rounded text-sm w-8 h-8 text-primary-100 flex items-center justify-center leading-none shadow"
:key="index"
v-text="link.page"
href="#"
class="rounded text-sm w-8 h-8 text-primary-100 flex items-center justify-center leading-none shadow"
:class="{'bg-primary-700': link.current, 'bg-primary-900': !link.current}"
@click.prevent="goto(link)"
v-text="link.page"
></button>
</div>
</div>
@ -32,22 +32,6 @@ export default {
type: Boolean,
},
},
methods: {
goto(page) {
if (this.$attrs.onReload) {
this.$emit('reload', page.page);
return;
}
var params = new URLSearchParams(window.location.search);
params.set('page', page.page);
this.$inertia.visit(window.location.pathname + '?' + params.toString(), {
only: this.only,
preserveState: this.preserve,
});
},
},
computed: {
links() {
@ -69,5 +53,21 @@ export default {
return `${this.value.from} - ${this.value.to} von ${this.value.total} Einträgen`;
},
},
methods: {
goto(page) {
if (this.$attrs.onReload) {
this.$emit('reload', page.page);
return;
}
var params = new URLSearchParams(window.location.search);
params.set('page', page.page);
this.$inertia.visit(window.location.pathname + '?' + params.toString(), {
only: this.only,
preserveState: this.preserve,
});
},
},
};
</script>

View File

@ -1,9 +1,9 @@
import {ref, computed, onBeforeUnmount} from 'vue';
import {router} from '@inertiajs/vue3';
import {useToast} from 'vue-toastification';
const toast = useToast();
import useQueueEvents from './useQueueEvents.js';
export function useIndex(props, siteName) {
const {startListener, stopListener} = useQueueEvents(siteName, () => reload(false));
const rawProps = JSON.parse(JSON.stringify(props));
const inner = {
data: ref(rawProps.data),
@ -61,22 +61,8 @@ export function useIndex(props, siteName) {
};
}
function handleJobEvent(event, type = 'success') {
if (event.message) {
toast[type](event.message);
}
if (event.reload) {
reload(false);
}
}
window.Echo.channel('jobs').listen('\\App\\Lib\\Events\\ClientMessage', (e) => handleJobEvent(e));
window.Echo.channel(siteName)
.listen('\\App\\Lib\\Events\\JobStarted', (e) => handleJobEvent(e))
.listen('\\App\\Lib\\Events\\JobFinished', (e) => handleJobEvent(e))
.listen('\\App\\Lib\\Events\\JobFailed', (e) => handleJobEvent(e, 'error'));
onBeforeUnmount(() => window.Echo.leave(siteName));
onBeforeUnmount(() => window.Echo.leave('jobs'));
startListener(;
onBeforeUnmount(() => stopListener());
return {
data: inner.data,

View File

@ -0,0 +1,27 @@
import {useToast} from 'vue-toastification';
const toast = useToast();
function handleJobEvent(event, type = 'success', reloadCallback) {
if (event.message) {
toast[type](event.message);
}
if (event.reload) {
reloadCallback();
}
}
export default function (siteName, reloadCallback) {
return {
startListener: function () {
window.Echo.channel('jobs').listen('\\App\\Lib\\Events\\ClientMessage', (e) => handleJobEvent(e, 'success', reloadCallback));
window.Echo.channel(siteName)
.listen('\\App\\Lib\\Events\\JobStarted', (e) => handleJobEvent(e, 'success', reloadCallback))
.listen('\\App\\Lib\\Events\\JobFinished', (e) => handleJobEvent(e, 'success', reloadCallback))
.listen('\\App\\Lib\\Events\\JobFailed', (e) => handleJobEvent(e, 'error', reloadCallback));
},
stopListener() {
window.Echo.leave(siteName);
window.Echo.leave('jobs');
},
};
}

View File

@ -2,26 +2,25 @@
<v-notification class="fixed z-40 right-0 bottom-0 mb-3 mr-3"></v-notification>
<!-- ******************************** Sidebar ******************************** -->
<div
class="fixed z-40 bg-gray-800 p-6 w-56 top-0 h-screen border-r border-gray-600 border-solid flex flex-col justify-between transition-all"
<div class="fixed z-40 bg-gray-800 p-6 w-56 top-0 h-screen border-r border-gray-600 border-solid flex flex-col justify-between transition-all"
:class="{
'-left-[14rem]': !menuStore.isShifted,
'left-0': menuStore.isShifted,
}"
>
}">
<div class="grid gap-2">
<v-link href="/" menu="dashboard" icon="loss">Dashboard</v-link>
<v-link href="/member" menu="member" icon="user">Mitglieder</v-link>
<v-link href="/subscription" v-show="hasModule('bill')" menu="subscription" icon="money">Beiträge</v-link>
<v-link v-show="hasModule('bill')" href="/subscription" menu="subscription" icon="money">Beiträge</v-link>
<v-link href="/contribution" menu="contribution" icon="contribution">Zuschüsse</v-link>
<v-link href="/activity" menu="activity" icon="activity">Tätigkeiten</v-link>
<v-link href="/group" menu="group" icon="group">Gruppen</v-link>
<v-link href="/maildispatcher" menu="maildispatcher" icon="at">Mail-Verteiler</v-link>
</div>
<div class="grid gap-2">
<v-link href="/setting" menu="setting" icon="setting">Einstellungen</v-link>
<v-link @click.prevent="$inertia.post('/logout')" icon="logout" href="/logout">Abmelden</v-link>
<v-link icon="logout" href="/logout" @click.prevent="$inertia.post('/logout')">Abmelden</v-link>
</div>
<a href="#" @click.prevent="menuStore.hide()" v-if="menuStore.hideable" class="absolute right-0 top-0 mr-2 mt-2">
<a v-if="menuStore.hideable" href="#" class="absolute right-0 top-0 mr-2 mt-2" @click.prevent="menuStore.hide()">
<ui-sprite src="close" class="w-5 h-5 text-gray-300"></ui-sprite>
</a>
</div>
@ -31,19 +30,19 @@
<script>
import VLink from './_VLink.vue';
import {menuStore} from '../stores/menuStore.js';
import { menuStore } from '../stores/menuStore.js';
import VNotification from '../components/VNotification.vue';
export default {
components: {
VNotification,
VLink,
},
data: function () {
return {
menuStore: menuStore(),
};
},
components: {
VNotification,
VLink,
},
computed: {
filterMenu() {

View File

@ -0,0 +1,94 @@
<template>
<page-layout>
<template #right>
<f-save-button form="actionform"></f-save-button>
</template>
<form id="actionform" class="grow p-3" @submit.prevent="submit">
<div class="flex space-x-3">
<f-select :model-value="meta.activity_id" :options="props.activities" label="Tätigkeit" size="sm" name="activity_id" @update:model-value="setActivityId"></f-select>
<f-select
:model-value="meta.subactivity_id"
:options="props.subactivities[meta.activity_id]"
name="subactivity_id"
label="Untertätigkeit"
size="sm"
@update:model-value="reload('subactivity_id', $event)"
></f-select>
<f-select :model-value="meta.group_id" :options="props.groups" label="Gruppierung" size="sm" name="group_id" @update:model-value="reload('group_id', $event)"></f-select>
</div>
<div class="grid gap-2 grid-cols-6 mt-4">
<f-switch v-for="member in members.data" :id="`member-${member.id}`" :key="member.id" v-model="selected" :value="member.id" :label="member.fullname" size="sm"></f-switch>
</div>
<div v-if="members.meta.last_page" class="px-6">
<ui-pagination class="mt-4" :value="members.meta" :only="['data']" @reload="reloadReal($event, false)"></ui-pagination>
</div>
</form>
</page-layout>
</template>
<script lang="js" setup>
import { onBeforeUnmount, ref, defineProps, reactive, inject } from 'vue';
import useQueueEvents from '../../composables/useQueueEvents.js';
const {startListener, stopListener} = useQueueEvents('group', () => null);
const axios = inject('axios');
startListener();
onBeforeUnmount(() => stopListener());
const meta = reactive({
activity_id: null,
subactivity_id: null,
group_id: null,
});
const props = defineProps({
activities: {
type: Object,
default: () => { },
},
subactivities: {
type: Object,
default: () => { },
},
groups: {
type: Object,
default: () => { },
},
});
const members = ref({ meta: {}, data: [] });
const selected = ref([]);
async function reload(key, v) {
meta[key] = v;
reloadReal(1, true);
}
async function reloadReal(page, update) {
if (meta.activity_id && meta.subactivity_id && meta.group_id) {
const memberResponse = await axios.post('/api/member/search', {
page: page,
per_page: 80,
});
members.value = memberResponse.data;
if (update) {
const membershipResponse = await axios.post('/api/membership/member-list', meta);
selected.value = membershipResponse.data;
}
}
}
async function setActivityId(id) {
meta.subactivity_id = null;
await reload('activity_id', id);
}
async function submit() {
await axios.post('/api/membership/sync', {
...meta,
members: selected.value,
});
}
</script>

View File

@ -15,6 +15,7 @@ use App\Contribution\Actions\ValidateAction as ContributionValidateAction;
use App\Course\Controllers\CourseController;
use App\Dashboard\Actions\IndexAction as DashboardIndexAction;
use App\Efz\ShowEfzDocumentAction;
use App\Group\Actions\ListAction;
use App\Initialize\Actions\InitializeAction;
use App\Initialize\Actions\InitializeFormAction;
use App\Initialize\Actions\NamiGetSearchLayerAction;
@ -34,9 +35,11 @@ use App\Member\Actions\MemberResyncAction;
use App\Member\Actions\MemberShowAction;
use App\Member\Actions\SearchAction;
use App\Member\MemberController;
use App\Membership\Actions\ApiListAction;
use App\Membership\Actions\MembershipDestroyAction;
use App\Membership\Actions\MembershipStoreAction;
use App\Membership\Actions\MembershipUpdateAction;
use App\Membership\Actions\SyncAction;
use App\Payment\Actions\AllpaymentPageAction;
use App\Payment\Actions\AllpaymentStoreAction;
use App\Payment\PaymentController;
@ -54,6 +57,8 @@ Route::group(['middleware' => 'auth:web'], function (): void {
Route::post('/nami/get-search-layer', NamiGetSearchLayerAction::class)->name('nami.get-search-layer');
Route::post('/nami/search', NamiSearchAction::class)->name('nami.search');
Route::post('/api/member/search', SearchAction::class)->name('member.search');
Route::post('/api/membership/member-list', ApiListAction::class)->name('membership.member-list');
Route::post('/api/membership/sync', SyncAction::class)->name('membership.sync');
Route::get('/initialize', InitializeFormAction::class)->name('initialize.form');
Route::post('/initialize', InitializeAction::class)->name('initialize.store');
Route::resource('member', MemberController::class)->except('show', 'destroy');
@ -98,4 +103,7 @@ Route::group(['middleware' => 'auth:web'], function (): void {
Route::patch('/maildispatcher/{maildispatcher}', MaildispatcherUpdateAction::class)->name('maildispatcher.update');
Route::post('/maildispatcher', MaildispatcherStoreAction::class)->name('maildispatcher.store');
Route::delete('/maildispatcher/{maildispatcher}', DestroyAction::class)->name('maildispatcher.destroy');
// ----------------------------------- group -----------------------------------
Route::get('/group', ListAction::class)->name('group.index');
});

View File

@ -0,0 +1,86 @@
<?php
namespace Tests\EndToEnd;
use App\Activity;
use App\Group;
use App\Maildispatcher\Actions\ResyncAction;
use App\Member\Member;
use App\Member\Membership;
use App\Membership\Actions\MembershipDestroyAction;
use App\Membership\Actions\MembershipStoreAction;
use App\Membership\Actions\SyncAction;
use App\Subactivity;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
use Throwable;
use Zoomyboy\LaravelNami\Fakes\MembershipFake;
class SyncActionTest extends TestCase
{
use DatabaseMigrations;
public function testItFiresActionJobWhenUsingController(): void
{
Queue::fake();
$this->login()->loginNami()->withoutExceptionHandling();
$member = Member::factory()->defaults()->create();
$activity = Activity::factory()->create();
$subactivity = Subactivity::factory()->create();
$group = Group::factory()->create();
$this->postJson('/api/membership/sync', [
'members' => [$member->id],
'activity_id' => $activity->id,
'subactivity_id' => $subactivity->id,
'group_id' => $group->id,
]);
SyncAction::assertPushed(fn ($action, $params) => $params[0]->is($group) && $params[1]->is($activity) && $params[2]->is($subactivity) && $params[3][0] === $member->id);
}
public function testItCreatesAMembership(): void
{
MembershipDestroyAction::partialMock()->shouldReceive('handle')->never();
MembershipStoreAction::partialMock()->shouldReceive('handle')->once();
$member = Member::factory()->defaults()->create();
$activity = Activity::factory()->create();
$subactivity = Subactivity::factory()->create();
$group = Group::factory()->create();
SyncAction::run($group, $activity, $subactivity, [$member->id]);
}
public function testItDeletesAMembership(): void
{
MembershipDestroyAction::partialMock()->shouldReceive('handle')->once();
MembershipStoreAction::partialMock()->shouldReceive('handle')->never();
ResyncAction::partialMock()->shouldReceive('handle')->once();
$member = Member::factory()->defaults()->has(Membership::factory()->inLocal('Leiter*in', 'Rover'))->create();
SyncAction::run($member->memberships->first()->group, $member->memberships->first()->activity, $member->memberships->first()->subactivity, []);
}
public function testItRollsbackWhenDeletionFails(): void
{
app(MembershipFake::class)
->shows(3, ['id' => 55])
->shows(3, ['id' => 56])
->destroysSuccessfully(3, 55)
->failsDeleting(3, 56);
$this->login()->loginNami();
$member = Member::factory()->defaults()->inNami(3)
->has(Membership::factory()->in('Leiter*in', 10, 'Rover', 11)->inNami(55))
->has(Membership::factory()->in('Leiter*in', 10, 'Jungpfadfinder', 12)->inNami(56))
->create();
try {
SyncAction::run($member->memberships->first()->group, $member->memberships->first()->activity, $member->memberships->first()->subactivity, []);
} catch (Throwable $e) {
}
$this->assertDatabaseCount('memberships', 2);
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace Tests\Feature\Group;
use App\Activity;
use App\Group;
use App\Subactivity;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
class IndexTest extends TestCase
{
use DatabaseTransactions;
public function testItDisplaysAllActivitiesAndSubactivities(): void
{
$this->login()->loginNami();
$leiter = Activity::factory()->name('Leiter*in')->hasAttached(Subactivity::factory()->name('Rover'))->create();
$intern = Activity::factory()->name('Intern')->hasAttached(Subactivity::factory()->name('Lager'))->create();
$group = Group::factory()->create();
$response = $this->get('/group');
$this->assertInertiaHas('Leiter*in', $response, "activities.{$leiter->id}");
$this->assertInertiaHas('Intern', $response, "activities.{$intern->id}");
$this->assertInertiaHas('Rover', $response, "subactivities.{$leiter->id}.{$leiter->subactivities->first()->id}");
$this->assertInertiaHas('Lager', $response, "subactivities.{$intern->id}.{$intern->subactivities->first()->id}");
$this->assertInertiaHas($group->name, $response, "groups.{$group->id}");
}
}