Add membership status to member view

This commit is contained in:
Philipp Lang 2023-09-05 16:29:22 +02:00
parent 59c2c527fb
commit 0de90be8c3
10 changed files with 117 additions and 58 deletions

View File

@ -4,26 +4,26 @@ namespace App\Member;
use App\Country; use App\Country;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Setting\GeneralSettings;
use App\Setting\NamiSettings; use App\Setting\NamiSettings;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Response; use Inertia\Response;
use Zoomyboy\LaravelNami\Exceptions\ConflictException; use Zoomyboy\LaravelNami\Exceptions\ConflictException;
use Inertia;
class MemberController extends Controller class MemberController extends Controller
{ {
public function index(Request $request, GeneralSettings $settings): Response public function index(Request $request): Response
{ {
session()->put('menu', 'member'); session()->put('menu', 'member');
session()->put('title', 'Mitglieder'); session()->put('title', 'Mitglieder');
$filter = FilterScope::fromRequest($request->input('filter', '')); $filter = FilterScope::fromRequest($request->input('filter', ''));
return \Inertia::render('member/VIndex', [ return Inertia::render('member/VIndex', [
'data' => MemberResource::collection(Member::search($filter->search)->query( 'data' => MemberResource::collection(Member::search($filter->search)->query(
fn ($q) => $q->select('*') fn ($q) => $q->select('*')
->withFilter($filter) ->withFilter($filter)
->with('payments.subscription')->with(['memberships' => fn ($query) => $query->active()])->with('courses')->with('subscription')->with('leaderMemberships')->with('ageGroupMemberships') ->with(['payments.subscription', 'memberships', 'courses', 'subscription', 'leaderMemberships', 'ageGroupMemberships'])
->withPendingPayment() ->withPendingPayment()
->ordered() ->ordered()
)->paginate(15)), )->paginate(15)),

View File

@ -61,6 +61,11 @@ class Membership extends Model
return $this->belongsTo(Member::class); return $this->belongsTo(Member::class);
} }
public function isActive(): bool
{
return $this->from->isBefore(now()) && (null === $this->to || $this->to->isAfter(now()));
}
/** /**
* @param Builder<Membership> $query * @param Builder<Membership> $query
* *
@ -68,7 +73,8 @@ class Membership extends Model
*/ */
public function scopeActive(Builder $query): Builder public function scopeActive(Builder $query): Builder
{ {
return $query->whereNull('to'); return $query->where('from', '<=', now())
->where(fn ($query) => $query->whereNull('to')->orWhere('to', '>=', now()));
} }
/** /**
@ -106,7 +112,7 @@ class Membership extends Model
* *
* @return Builder<Membership> * @return Builder<Membership>
*/ */
public function scopeTrying(Builder $query): Builder public function scopeIsTrying(Builder $query): Builder
{ {
return $query->active()->whereHas('activity', fn ($builder) => $builder->where('is_try', true)); return $query->active()->whereHas('activity', fn ($builder) => $builder->where('is_try', true));
} }

View File

@ -27,6 +27,7 @@ class MembershipResource extends JsonResource
'subactivity_name' => $this->subactivity?->name, 'subactivity_name' => $this->subactivity?->name,
'human_date' => $this->from->format('d.m.Y'), 'human_date' => $this->from->format('d.m.Y'),
'promised_at' => $this->promised_at?->format('Y-m-d'), 'promised_at' => $this->promised_at?->format('Y-m-d'),
'is_active' => $this->isActive(),
]; ];
} }
} }

View File

@ -13,11 +13,8 @@ class TestersBlock extends Block
*/ */
public function query(): Builder public function query(): Builder
{ {
return Member::whereHas('memberships', fn ($q) => $q return Member::whereHas('memberships', fn ($q) => $q->isTrying())
->where('created_at', '<=', now()->subWeeks(7)) ->with('memberships', fn ($q) => $q->isTrying());
->trying()
)
->with(['memberships' => fn ($query) => $query->trying()]);
} }
/** /**
@ -28,8 +25,8 @@ class TestersBlock extends Block
return [ return [
'members' => $this->query()->get()->map(fn ($member) => [ 'members' => $this->query()->get()->map(fn ($member) => [
'name' => $member->fullname, 'name' => $member->fullname,
'try_ends_at' => $member->memberships->first()->created_at->addWeeks(8)->format('d.m.Y'), 'try_ends_at' => $member->memberships->first()->from->addWeeks(8)->format('d.m.Y'),
'try_ends_at_human' => $member->memberships->first()->created_at->addWeeks(8)->diffForHumans(), 'try_ends_at_human' => $member->memberships->first()->from->addWeeks(8)->diffForHumans(),
])->toArray(), ])->toArray(),
]; ];
} }

View File

@ -1,7 +1,10 @@
<template> <template>
<div v-tooltip="longLabel" class="flex space-x-2 items-center"> <div v-tooltip="longLabel" class="flex space-x-2 items-center">
<div class="border-2 rounded-full w-5 h-5 flex items-center justify-center" :class="value ? 'border-green-700' : 'border-red-700'"> <div class="border-2 rounded-full w-5 h-5 flex items-center justify-center"
<ui-sprite :src="value ? 'check' : 'close'" :class="value ? 'text-green-800' : 'text-red-800'" class="w-3 h-3 flex-none"></ui-sprite> :class="value ? (dark ? 'border-green-500' : 'border-green-700') : dark ? 'border-red-500' : 'border-red-700'">
<ui-sprite :src="value ? 'check' : 'close'"
:class="value ? (dark ? 'text-green-600' : 'text-green-800') : dark ? 'text-red-600' : 'text-red-800'"
class="w-3 h-3 flex-none"></ui-sprite>
</div> </div>
<div class="text-gray-400 text-xs" v-text="label"></div> <div class="text-gray-400 text-xs" v-text="label"></div>
</div> </div>
@ -23,6 +26,10 @@ export default {
return null; return null;
}, },
}, },
dark: {
type: Boolean,
default: () => false,
},
}, },
}; };
</script> </script>

View File

@ -1,35 +1,35 @@
<template> <template>
<div class="sidebar flex flex-col group is-bright"> <div class="sidebar flex flex-col group is-bright">
<page-header @close="$emit('close')" title="Mitgliedschaften"> <page-header title="Mitgliedschaften" @close="$emit('close')">
<template #toolbar> <template #toolbar>
<page-toolbar-button @click.prevent="create" color="primary" icon="plus" v-if="single === null">Neue Mitgliedschaft</page-toolbar-button> <page-toolbar-button v-if="single === null" color="primary" icon="plus" @click.prevent="create">Neue
<page-toolbar-button @click.prevent="cancel" color="primary" icon="undo" v-if="single !== null">Zurück</page-toolbar-button> Mitgliedschaft</page-toolbar-button>
<page-toolbar-button v-if="single !== null" color="primary" icon="undo"
@click.prevent="cancel">Zurück</page-toolbar-button>
</template> </template>
</page-header> </page-header>
<form v-if="single" class="p-6 grid gap-4 justify-start" @submit.prevent="submit"> <form v-if="single" class="p-6 grid gap-4 justify-start" @submit.prevent="submit">
<f-select id="group_id" name="group_id" :options="groups" v-model="single.group_id" label="Gruppierung" required></f-select> <f-select id="group_id" v-model="single.group_id" name="group_id" :options="groups" label="Gruppierung"
<f-select id="activity_id" name="activity_id" :options="activities" v-model="single.activity_id" label="Tätigkeit" required></f-select> required></f-select>
<f-select <f-select id="activity_id" v-model="single.activity_id" name="activity_id" :options="activities"
v-if="single.activity_id" label="Tätigkeit" required></f-select>
name="subactivity_id" <f-select v-if="single.activity_id" id="subactivity_id" v-model="single.subactivity_id" name="subactivity_id"
:options="subactivities[single.activity_id]" :options="subactivities[single.activity_id]" label="Untertätigkeit" size="sm"></f-select>
id="subactivity_id" <f-switch id="has_promise" :model-value="single.promised_at !== null" label="Hat Versprechen"
v-model="single.subactivity_id" @update:modelValue="single.promised_at = $event ? '2000-02-02' : null"></f-switch>
label="Untertätigkeit" <f-text v-show="single.promised_at !== null" id="promised_at" v-model="single.promised_at" type="date"
size="sm" label="Versprechensdatum" size="sm"></f-text>
></f-select>
<f-switch id="has_promise" :modelValue="single.promised_at !== null" @update:modelValue="single.promised_at = $event ? '2000-02-02' : null" label="Hat Versprechen"></f-switch>
<f-text v-show="single.promised_at !== null" type="date" id="promised_at" v-model="single.promised_at" label="Versprechensdatum" size="sm"></f-text>
<button type="submit" class="btn btn-primary">Absenden</button> <button type="submit" class="btn btn-primary">Absenden</button>
</form> </form>
<div class="grow" v-else> <div v-else class="grow">
<table class="custom-table custom-table-light custom-table-sm text-sm"> <table class="custom-table custom-table-light custom-table-sm text-sm">
<thead> <thead>
<th>Tätigkeit</th> <th>Tätigkeit</th>
<th>Untertätigkeit</th> <th>Untertätigkeit</th>
<th>Datum</th> <th>Datum</th>
<th>Aktiv</th>
<th></th> <th></th>
</thead> </thead>
@ -37,17 +37,14 @@
<td v-text="membership.activity_name"></td> <td v-text="membership.activity_name"></td>
<td v-text="membership.subactivity_name"></td> <td v-text="membership.subactivity_name"></td>
<td v-text="membership.human_date"></td> <td v-text="membership.human_date"></td>
<td><ui-boolean-display :value="membership.is_active" dark></ui-boolean-display></td>
<td class="flex"> <td class="flex">
<a <a href="#" class="inline-flex btn btn-warning btn-sm" @click.prevent="
href="#" single = membership;
@click.prevent=" mode = 'edit';
single = membership; "><ui-sprite src="pencil"></ui-sprite></a>
mode = 'edit'; <i-link href="#" class="inline-flex btn btn-danger btn-sm"
" @click.prevent="remove(membership)"><ui-sprite src="trash"></ui-sprite></i-link>
class="inline-flex btn btn-warning btn-sm"
><ui-sprite src="pencil"></ui-sprite
></a>
<i-link href="#" @click.prevent="remove(membership)" class="inline-flex btn btn-danger btn-sm"><ui-sprite src="trash"></ui-sprite></i-link>
</td> </td>
</tr> </tr>
</table> </table>
@ -57,6 +54,12 @@
<script> <script>
export default { export default {
props: {
value: {},
activities: {},
subactivities: {},
groups: {},
},
data: function () { data: function () {
return { return {
mode: null, mode: null,
@ -78,7 +81,7 @@ export default {
methods: { methods: {
create() { create() {
this.mode = 'create'; this.mode = 'create';
this.single = {...this.def}; this.single = { ...this.def };
}, },
cancel() { cancel() {
this.mode = this.single = null; this.mode = this.single = null;
@ -88,7 +91,7 @@ export default {
}, },
accept(payment) { accept(payment) {
this.$inertia.patch(`/member/${this.value.id}/payment/${payment.id}`, {...payment, status_id: 3}); this.$inertia.patch(`/member/${this.value.id}/payment/${payment.id}`, { ...payment, status_id: 3 });
}, },
openLink(link) { openLink(link) {
@ -114,12 +117,5 @@ export default {
: this.$inertia.patch(`/member/${this.value.id}/membership/${this.single.id}`, this.single, options); : this.$inertia.patch(`/member/${this.value.id}/membership/${this.single.id}`, this.single, options);
}, },
}, },
props: {
value: {},
activities: {},
subactivities: {},
groups: {},
},
}; };
</script> </script>

View File

@ -5,11 +5,13 @@
<th>Tätigkeit</th> <th>Tätigkeit</th>
<th>Untertätigkeit</th> <th>Untertätigkeit</th>
<th>Datum</th> <th>Datum</th>
<th>Aktiv</th>
</thead> </thead>
<tr v-for="(membership, index) in inner" :key="index"> <tr v-for="(membership, index) in inner" :key="index">
<td v-text="membership.activity_name"></td> <td v-text="membership.activity_name"></td>
<td v-text="membership.subactivity_name"></td> <td v-text="membership.subactivity_name"></td>
<td v-text="membership.human_date"></td> <td v-text="membership.human_date"></td>
<td><ui-boolean-display :value="membership.is_active"></ui-boolean-display></td>
</tr> </tr>
</table> </table>
@ -17,6 +19,7 @@
<ui-box class="relative" :heading="membership.activity_name" v-for="(membership, index) in inner" :key="index" second> <ui-box class="relative" :heading="membership.activity_name" v-for="(membership, index) in inner" :key="index" second>
<div class="text-xs text-gray-200" v-text="membership.subactivity_name"></div> <div class="text-xs text-gray-200" v-text="membership.subactivity_name"></div>
<div class="text-xs text-gray-200" v-text="membership.human_date"></div> <div class="text-xs text-gray-200" v-text="membership.human_date"></div>
<div class="text-xs text-gray-200"><ui-boolean-display :value="membership.is_active"></ui-boolean-display></div>
</ui-box> </ui-box>
</div> </div>
</div> </div>

View File

@ -8,6 +8,8 @@ use App\Member\Member;
use App\Member\Membership; use App\Member\Membership;
use App\Payment\Payment; use App\Payment\Payment;
use App\Subactivity; use App\Subactivity;
use Carbon\Carbon;
use Generator;
use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\RequestFactories\Child; use Tests\RequestFactories\Child;
use Tests\TestCase; use Tests\TestCase;
@ -80,6 +82,29 @@ class IndexTest extends TestCase
$this->assertInertiaHas(false, $response, 'data.data.2.is_leader'); $this->assertInertiaHas(false, $response, 'data.data.2.is_leader');
} }
public function membershipDataProvider(): Generator
{
yield [now()->subMonth(), null, true];
yield [now()->subMonth(), now()->subDay(), false];
yield [now()->addDay(), null, false];
}
/**
* @dataProvider membershipDataProvider
*/
public function testItShowsIfMembershipIsActive(Carbon $from, ?Carbon $to, bool $isActive): void
{
$this->withoutExceptionHandling()->login()->loginNami();
$member = Member::factory()
->defaults()
->has(Membership::factory()->in('€ LeiterIn', 455, 'Pfadfinder', 15)->state(['from' => $from, 'to' => $to]))
->create();
$response = $this->get('/member');
$this->assertInertiaHas($isActive, $response, 'data.data.0.memberships.0.is_active');
}
public function testItHasNoEfzLinkWhenAddressIsMissing(): void public function testItHasNoEfzLinkWhenAddressIsMissing(): void
{ {
$this->withoutExceptionHandling()->login()->loginNami(); $this->withoutExceptionHandling()->login()->loginNami();
@ -187,11 +212,11 @@ class IndexTest extends TestCase
'subscription_id' => $member->payments->first()->subscription->id, 'subscription_id' => $member->payments->first()->subscription->id,
'status_name' => 'Nicht bezahlt', 'status_name' => 'Nicht bezahlt',
'nr' => '2019', 'nr' => '2019',
], $response, 'data.data.0.payments.0'); ], $response, 'data.data.0.payments.0');
$this->assertInertiaHas([ $this->assertInertiaHas([
'id' => $member->subscription->id, 'id' => $member->subscription->id,
'name' => $member->subscription->name, 'name' => $member->subscription->name,
], $response, 'data.data.0.subscription'); ], $response, 'data.data.0.subscription');
} }
public function testItCanFilterForBillKinds(): void public function testItCanFilterForBillKinds(): void

View File

@ -14,6 +14,7 @@ use App\Payment\Payment;
use App\Payment\Subscription; use App\Payment\Subscription;
use App\Region; use App\Region;
use Carbon\Carbon; use Carbon\Carbon;
use Generator;
use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\RequestFactories\Child; use Tests\RequestFactories\Child;
use Tests\TestCase; use Tests\TestCase;
@ -119,7 +120,7 @@ class ShowTest extends TestCase
'id' => $member->memberships->first()->id, 'id' => $member->memberships->first()->id,
'human_date' => '19.11.2022', 'human_date' => '19.11.2022',
'promised_at' => now()->format('Y-m-d'), 'promised_at' => now()->format('Y-m-d'),
], $response, 'data.memberships.0'); ], $response, 'data.memberships.0');
$this->assertInertiaHas([ $this->assertInertiaHas([
'organizer' => 'DPSG', 'organizer' => 'DPSG',
'event_name' => 'Wochenende', 'event_name' => 'Wochenende',
@ -128,7 +129,7 @@ class ShowTest extends TestCase
'name' => ' Baustein 2e - Gewalt gegen Kinder und Jugendliche: Vertiefung, Prävention ', 'name' => ' Baustein 2e - Gewalt gegen Kinder und Jugendliche: Vertiefung, Prävention ',
'short_name' => '2e', 'short_name' => '2e',
], ],
], $response, 'data.courses.0'); ], $response, 'data.courses.0');
$this->assertInertiaHas([ $this->assertInertiaHas([
'subscription' => [ 'subscription' => [
'name' => 'Free', 'name' => 'Free',
@ -138,7 +139,7 @@ class ShowTest extends TestCase
], ],
'status_name' => 'Nicht bezahlt', 'status_name' => 'Nicht bezahlt',
'nr' => '2019', 'nr' => '2019',
], $response, 'data.payments.0'); ], $response, 'data.payments.0');
} }
public function testItShowsMinimalSingleMember(): void public function testItShowsMinimalSingleMember(): void
@ -169,4 +170,27 @@ class ShowTest extends TestCase
'multiply_more_pv' => false, 'multiply_more_pv' => false,
], $response, 'data'); ], $response, 'data');
} }
public function membershipDataProvider(): Generator
{
yield [now()->subMonth(), null, true];
yield [now()->subMonth(), now()->subDay(), false];
yield [now()->addDay(), null, false];
}
/**
* @dataProvider membershipDataProvider
*/
public function testItShowsIfMembershipIsActive(Carbon $from, ?Carbon $to, bool $isActive): void
{
$this->withoutExceptionHandling()->login()->loginNami();
$member = Member::factory()
->defaults()
->has(Membership::factory()->in('€ LeiterIn', 455, 'Pfadfinder', 15)->state(['from' => $from, 'to' => $to]))
->create();
$response = $this->get("/member/{$member->id}");
$this->assertInertiaHas($isActive, $response, 'data.memberships.0.is_active');
}
} }

View File

@ -18,11 +18,11 @@ class TestersBlockTest extends TestCase
Member::factory() Member::factory()
->defaults() ->defaults()
->has(Membership::factory()->in('Schnuppermitgliedschaft', 7, 'Wölfling', 8)->state(['created_at' => now()->subMonths(10)])) ->has(Membership::factory()->in('Schnuppermitgliedschaft', 7, 'Wölfling', 8)->state(['from' => now()->subMonths(10)]))
->create(['firstname' => 'Max', 'lastname' => 'Muster']); ->create(['firstname' => 'Max', 'lastname' => 'Muster']);
$inactiveMember = Member::factory() $inactiveMember = Member::factory()
->defaults() ->defaults()
->has(Membership::factory()->ended()->in('Schnuppermitgliedschaft', 7, 'Wölfling', 8)->state(['created_at' => now()->subMonths(10)])) ->has(Membership::factory()->ended()->in('Schnuppermitgliedschaft', 7, 'Wölfling', 8)->state(['from' => now()->subMonths(10)]))
->create(['firstname' => 'Max', 'lastname' => 'Muster']); ->create(['firstname' => 'Max', 'lastname' => 'Muster']);
$data = app(TestersBlock::class)->render()['data']; $data = app(TestersBlock::class)->render()['data'];