Add member filter
continuous-integration/drone/push Build is failing Details

This commit is contained in:
philipp lang 2023-03-14 23:27:15 +01:00
parent 210172db64
commit bb9599dcf6
9 changed files with 217 additions and 147 deletions

View File

@ -4,6 +4,7 @@ namespace App\Http\Views;
use App\Activity;
use App\Course\Models\Course;
use App\Member\FilterScope;
use App\Member\Member;
use App\Member\MemberResource;
use App\Payment\Status;
@ -14,13 +15,14 @@ use Illuminate\Http\Request;
class MemberView
{
public function index(Request $request, array $filter): array
public function index(Request $request): array
{
$activities = Activity::with('subactivities')->get();
$filter = FilterScope::fromRequest($request->input('filter', ''));
return [
'data' => MemberResource::collection(Member::search($request->search)->query(fn ($q) => $q->select('*')
->filter($filter)
->withFilter($filter)
->with('payments.subscription')->with('memberships')->with('courses')->with('subscription')->with('leaderMemberships')->with('ageGroupMemberships')
->withPendingPayment()
->ordered()

85
app/Lib/Filter.php Normal file
View File

@ -0,0 +1,85 @@
<?php
namespace App\Lib;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Spatie\LaravelData\Data;
/**
* @template T of Model
*/
abstract class Filter extends Data
{
public string $unsetReplacer = 'yoNee3ainge4eetiier9ogaiChoe0ahcaR3Hu1uzah8xaiv7ael7yahphai7ruG9';
/**
* @return array<string, mixed>
*/
abstract protected function locks(): array;
/**
* @param array<string, mixed>|string|null $request
*/
public static function fromRequest(array|string|null $request = null): static
{
$payload = is_string($request)
? json_decode(base64_decode($request), true)
: $request;
return static::fromPost($payload);
}
/**
* @param array<string, mixed> $post
*/
public static function fromPost(?array $post = null): static
{
return static::withoutMagicalCreationFrom($post ?: [])->parseLocks();
}
public function parseLocks(): static
{
foreach ($this->locks() as $key => $value) {
if ($value === $this->unsetReplacer) {
continue;
}
$this->{$key} = $value;
}
return $this;
}
/**
* @param mixed $value
*
* @return mixed
*/
public function when(bool $when, $value)
{
return $when ? $value : $this->unsetReplacer;
}
/**
* @param Builder<T> $query
*
* @return Builder<T>
*/
protected function applyOwnOthers(Builder $query, bool $own, bool $others): Builder
{
if ($own && !$others) {
$query->where('user_id', auth()->id());
}
if (!$own && $others) {
$query->where('user_id', '!=', auth()->id());
}
if (!$own && !$others) {
$query->where('id', -1);
}
return $query;
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Member;
use App\Letter\BillKind;
use App\Lib\Filter;
use Illuminate\Database\Eloquent\Builder;
use Spatie\LaravelData\Attributes\MapInputName;
use Spatie\LaravelData\Attributes\MapOutputName;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
/**
* @extends Filter<Member>
*/
#[MapInputName(SnakeCaseMapper::class)]
#[MapOutputName(SnakeCaseMapper::class)]
class FilterScope extends Filter
{
public function __construct(
public bool $ausstand = false,
public ?string $billKind = null,
public ?int $activityId = null,
public ?int $subactivityId = null,
) {
}
/**
* {@inheritdoc}
*/
public function locks(): array
{
return [];
}
/**
* @param Builder<Member> $query
*
* @return Builder<Member>
*/
public function apply(Builder $query): Builder
{
if ($this->ausstand) {
$query->whereAusstand();
}
if ($this->billKind) {
$query->where('bill_kind', BillKind::fromValue($this->billKind));
}
if ($this->subactivityId || $this->activityId) {
$query->whereHas('memberships', function ($q) {
$q->active();
if ($this->subactivityId) {
$q->where('subactivity_id', $this->subactivityId);
}
if ($this->activityId) {
$q->where('activity_id', $this->activityId);
}
});
}
return $query;
}
}

View File

@ -381,27 +381,9 @@ class Member extends Model
*
* @return Builder<self>
*/
public function scopeFilter(Builder $query, array $filter): Builder
public function scopeWithFilter(Builder $query, FilterScope $filter): Builder
{
if (true === data_get($filter, 'ausstand', false)) {
$query->whereAusstand();
}
if (data_get($filter, 'bill_kind', false)) {
$query->where('bill_kind', BillKind::fromValue($filter['bill_kind']));
}
if (data_get($filter, 'subactivity_id', false) || data_get($filter, 'activity_id', false)) {
$query->whereHas('memberships', function ($q) use ($filter) {
$q->active();
if (data_get($filter, 'subactivity_id', false)) {
$q->where('subactivity_id', $filter['subactivity_id']);
}
if (data_get($filter, 'activity_id', false)) {
$q->where('activity_id', $filter['activity_id']);
}
});
}
return $query;
return $filter->apply($query);
}
/**

View File

@ -21,32 +21,17 @@ use Zoomyboy\LaravelNami\Exceptions\ConflictException;
class MemberController extends Controller
{
public array $filter = [
'ausstand' => false,
'bill_kind' => null,
'activity_id' => null,
'subactivity_id' => null,
];
public function index(Request $request, GeneralSettings $settings): Response
{
session()->put('menu', 'member');
session()->put('title', 'Mitglieder');
$query = [
'filter' => array_merge(
$this->filter,
json_decode($request->query('filter', '{}'), true)
),
];
$payload = app(MemberView::class)->index($request, $query['filter']);
$payload = app(MemberView::class)->index($request);
$payload['toolbar'] = [
['href' => route('member.create'), 'label' => 'Mitglied anlegen', 'color' => 'primary', 'icon' => 'plus'],
['href' => route('allpayment.page'), 'label' => 'Rechnungen erstellen', 'color' => 'primary', 'icon' => 'invoice', 'show' => $settings->hasModule('bill')],
['href' => route('sendpayment.create'), 'label' => 'Rechnungen versenden', 'color' => 'info', 'icon' => 'envelope', 'show' => $settings->hasModule('bill')],
];
$payload['query'] = $query;
$payload['billKinds'] = BillKind::forSelect();
return \Inertia::render('member/VIndex', $payload);

View File

@ -105,6 +105,7 @@ class MemberResource extends JsonResource
{
return [
'groups' => Group::select('name', 'id')->get(),
'filter' => FilterScope::fromRequest(request()->input('filter', '')),
];
}
}

View File

@ -1,73 +0,0 @@
<template>
<div class="px-6 py-2 flex border-b border-gray-600 space-x-3">
<f-switch
v-show="hasModule('bill')"
id="ausstand"
@input="reload"
v-model="inner.ausstand"
label="Nur Ausstände"
size="sm"
></f-switch>
<f-select
v-show="hasModule('bill')"
name="billKinds"
id="billKinds"
@input="reload"
:options="billKinds"
v-model="inner.bill_kind"
label="Rechnung"
size="sm"
></f-select>
<f-select
id="activity_id"
@input="reload"
:options="activities"
v-model="inner.activity_id"
label="Tätigkeit"
size="sm"
name="activity_id"
></f-select>
<f-select
id="subactivity_id"
@input="reload"
:options="subactivities"
v-model="inner.subactivity_id"
label="Untertätigkeit"
size="sm"
name="subactivity_id"
></f-select>
</div>
</template>
<script>
import mergesQueryString from '../../mixins/mergesQueryString.js';
export default {
data: function () {
return {
inner: {},
};
},
mixins: [mergesQueryString],
props: {
value: {},
billKinds: {},
activities: {},
subactivities: {},
},
methods: {
reload() {
this.$inertia.visit(this.qs({filter: JSON.stringify(this.inner)}), {
preserveState: true,
});
},
},
created() {
this.inner = this.value;
},
};
</script>

View File

@ -1,11 +1,36 @@
<template>
<div class="pb-6">
<member-filter
:value="query.filter"
:activities="filterActivities"
:subactivities="filterSubactivities"
:bill-kinds="billKinds"
></member-filter>
<div class="px-6 py-2 flex border-b border-gray-600 space-x-3">
<f-switch v-show="hasModule('bill')" id="ausstand" @input="setFilter('ausstand', $event)" :items="getFilter('ausstand')" label="Nur Ausstände" size="sm"></f-switch>
<f-select
v-show="hasModule('bill')"
name="billKinds"
id="billKinds"
@input="setFilter('bill_kind', $event)"
:options="billKinds"
:value="getFilter('bill_kind')"
label="Rechnung"
size="sm"
></f-select>
<f-select
id="activity_id"
@input="setFilter('activity_id', $event)"
:options="filterActivities"
:value="getFilter('activity_id')"
label="Tätigkeit"
size="sm"
name="activity_id"
></f-select>
<f-select
id="subactivity_id"
@input="setFilter('subactivity_id', $event)"
:options="filterSubactivities"
:value="getFilter('subactivity_id')"
label="Untertätigkeit"
size="sm"
name="subactivity_id"
></f-select>
</div>
<table cellspacing="0" cellpadding="0" border="0" class="custom-table custom-table-sm hidden md:table">
<thead>
@ -20,7 +45,7 @@
<th></th>
</thead>
<tr v-for="(member, index) in data.data" :key="index">
<tr v-for="(member, index) in inner.data" :key="index">
<td><age-groups :member="member"></age-groups></td>
<td v-text="member.lastname"></td>
<td v-text="member.firstname"></td>
@ -47,23 +72,11 @@
<div class="text-xs text-gray-200" v-text="member.full_address"></div>
<div class="flex items-center mt-1 space-x-4">
<tags :member="member"></tags>
<v-label
class="text-gray-100 block"
v-show="hasModule('bill')"
:value="member.pending_payment"
fallback=""
></v-label>
<v-label class="text-gray-100 block" v-show="hasModule('bill')" :value="member.pending_payment" fallback=""></v-label>
</div>
<actions
class="mt-2"
:member="member"
@sidebar="openSidebar(index, $event)"
@remove="remove(member)"
></actions>
<actions class="mt-2" :member="member" @sidebar="openSidebar(index, $event)" @remove="remove(member)"></actions>
<div class="absolute right-0 top-0 h-full flex items-center mr-2">
<i-link :href="member.links.show" v-tooltip="`Details`"
><svg-sprite src="chevron-down" class="w-6 h-6 text-teal-100 -rotate-90"></svg-sprite
></i-link>
<i-link :href="member.links.show" v-tooltip="`Details`"><svg-sprite src="chevron-down" class="w-6 h-6 text-teal-100 -rotate-90"></svg-sprite></i-link>
</div>
</box>
</div>
@ -88,12 +101,7 @@
:subactivities="subactivities"
:value="data.data[single]"
></member-memberships>
<member-courses
v-if="single !== null && sidebar === 'courses.index'"
@close="closeSidebar"
:courses="courses"
:value="data.data[single]"
></member-courses>
<member-courses v-if="single !== null && sidebar === 'courses.index'" @close="closeSidebar" :courses="courses" :value="data.data[single]"></member-courses>
</transition>
</div>
</template>
@ -102,8 +110,7 @@
import MemberPayments from './MemberPayments.vue';
import MemberMemberships from './MemberMemberships.vue';
import MemberCourses from './MemberCourses.vue';
import MemberFilter from './MemberFilter.vue';
import mergesQueryString from '../../mixins/mergesQueryString.js';
import indexHelpers from '../../mixins/indexHelpers.js';
export default {
data: function () {
@ -113,12 +120,11 @@ export default {
};
},
mixins: [mergesQueryString],
mixins: [indexHelpers],
components: {
MemberMemberships,
MemberPayments,
MemberFilter,
MemberCourses,
'age-groups': () => import(/* webpackChunkName: "member" */ './AgeGroups'),
'tags': () => import(/* webpackChunkName: "member" */ './Tags'),
@ -142,7 +148,6 @@ export default {
},
props: {
data: {},
subscriptions: {},
statuses: {},
paymentDefaults: {},

View File

@ -160,12 +160,32 @@ class IndexTest extends TestCase
$this->withoutExceptionHandling()->login()->loginNami();
Member::factory()->defaults()->emailBillKind()->create();
Member::factory()->defaults()->postBillKind()->create();
Member::factory()->defaults()->postBillKind()->create();
$emailResponse = $this->call('GET', '/member', ['filter' => '{"bill_kind": "E-Mail"}']);
$postResponse = $this->call('GET', '/member', ['filter' => '{"bill_kind": "Post"}']);
$emailResponse = $this->callFilter('member.index', ['bill_kind' => 'E-Mail']);
$postResponse = $this->callFilter('member.index', ['bill_kind' => 'Post']);
$this->assertCount(1, $this->inertia($emailResponse, 'data.data'));
$this->assertCount(1, $this->inertia($postResponse, 'data.data'));
$this->assertCount(2, $this->inertia($postResponse, 'data.data'));
$this->assertInertiaHas('E-Mail', $emailResponse, 'data.meta.filter.bill_kind');
}
public function testItFiltersForAusstand(): void
{
$this->withoutExceptionHandling()->login()->loginNami();
Member::factory()
->has(Payment::factory()->notPaid()->subscription('Free', [new Child('b', 50)]))
->defaults()->create();
Member::factory()->defaults()->create();
Member::factory()->defaults()->create();
$defaultResponse = $this->callFilter('member.index', []);
$ausstandResponse = $this->callFilter('member.index', ['ausstand' => true]);
$this->assertCount(3, $this->inertia($defaultResponse, 'data.data'));
$this->assertCount(1, $this->inertia($ausstandResponse, 'data.data'));
$this->assertInertiaHas(true, $ausstandResponse, 'data.meta.filter.ausstand');
$this->assertInertiaHas(false, $defaultResponse, 'data.meta.filter.ausstand');
}
public function testItLoadsGroups(): void