Compare commits

...

4 Commits

Author SHA1 Message Date
philipp lang 66b6a549c3 Add Backend for displaying memberships
continuous-integration/drone/push Build is failing Details
2025-06-13 16:04:04 +02:00
philipp lang 878de3f566 Add Name to MemberFactory 2025-06-13 15:48:18 +02:00
philipp lang aa55b56df7 Fix table css 2025-06-13 14:18:49 +02:00
philipp lang 31c582e81d Add macros for TestResponse 2025-06-13 14:17:28 +02:00
13 changed files with 236 additions and 20 deletions

29
app/Lib/Data/DateData.php Normal file
View File

@ -0,0 +1,29 @@
<?php
namespace App\Lib\Data;
use Spatie\LaravelData\Normalizers\Normalizer;
use App\Lib\Normalizers\DateNormalizer;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Attributes\WithTransformer;
use App\Lib\Transformers\DateTransformer;
use Carbon\Carbon;
class DateData extends Data
{
public function __construct(
#[WithTransformer(DateTransformer::class)]
public Carbon $raw,
public string $human,
) {}
/**
* @return array<int, class-string<Normalizer>>
*/
public static function normalizers(): array
{
return [
DateNormalizer::class,
];
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Lib\Data;
use Spatie\LaravelData\Data;
class RecordData extends Data {
public function __construct(
public int $id,
public string $name,
) {}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Lib\Normalizers;
use Spatie\LaravelData\Normalizers\Normalizer;
use Carbon\Carbon;
class DateNormalizer implements Normalizer
{
/**
* @return array<string, mixed>
*/
public function normalize(mixed $value): ?array
{
if (!$value instanceof Carbon) {
return null;
}
return [
'raw' => $value,
'human' => $value->format('d.m.Y'),
];
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Lib\Transformers;
use Spatie\LaravelData\Transformers\Transformer;
use Spatie\LaravelData\Support\DataProperty;
use Spatie\LaravelData\Support\Transformation\TransformationContext;
use Carbon\Carbon;
class DateTransformer implements Transformer
{
public function transform(DataProperty $property, mixed $value, TransformationContext $context): string
{
return Carbon::parse($value)->format('Y-m-d');
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Member\Data;
use Spatie\LaravelData\Data;
use App\Lib\Data\DateData;
use App\Lib\Data\RecordData;
use App\Member\Membership;
class MembershipData extends Data
{
public function __construct(
public int $id,
public RecordData $activity,
public ?RecordData $subactivity,
public RecordData $group,
public ?DateData $promisedAt,
public DateData $from,
public bool $isActive,
public array $links,
) {}
public static function fromModel(Membership $membership): static
{
return static::factory()->withoutMagicalCreation()->from([
'id' => $membership->id,
'activity' => $membership->activity,
'subactivity' => $membership->subactivity,
'isActive' => $membership->isActive(),
'from' => $membership->from,
'group' => $membership->group,
'promisedAt' => $membership->promised_at,
'links' => [
'update' => route('membership.update', $membership),
'destroy' => route('membership.destroy', $membership),
]
]);
}
}

View File

@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Lorisleiva\Actions\Concerns\AsAction;
class IndexAction
class MemberIndexAction
{
use AsAction;

View File

@ -0,0 +1,23 @@
<?php
namespace App\Membership\Actions;
use App\Member\Data\MembershipData;
use App\Member\Membership;
use Inertia\Inertia;
use Inertia\Response;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\LaravelData\PaginatedDataCollection;
class MembershipIndexAction
{
use AsAction;
public function asController(): Response
{
return Inertia::render(
'membership/Index',
MembershipData::collect(Membership::orderByRaw('member_id, activity_id, subactivity_id')->paginate(20), PaginatedDataCollection::class)->wrap('data')
);
}
}

View File

@ -98,6 +98,12 @@ class MemberFactory extends Factory
return $this->state(['nami_id' => null]);
}
public function name(string $name): self
{
[$firstname, $lastname] = explode(' ', $name);
return $this->state(compact('firstname', 'lastname'));
}
public function withBankAccount(BankAccountFactory $factory): self
{
return $this->afterCreating(function ($member) use ($factory) {

View File

@ -1,10 +1,10 @@
.custom-table {
width: 100%;
& > thead > th {
& > thead > th, & > thead > tr > th {
@apply text-left px-6 text-gray-200 font-semibold py-3 border-gray-600 border-b;
}
& > tr {
& > tr, & > tbody > tr {
@apply text-gray-200 transition-all duration-300 rounded hover:bg-gray-800;
& > td {
@apply py-1 px-6;
@ -12,10 +12,10 @@
}
&.custom-table-sm {
& > thead > th {
& > thead > th, & > thead > tr > th {
@apply px-3 py-2;
}
& > tr {
& > tr, & > tbody > tr {
& > td {
@apply py-1 px-3;
}
@ -23,20 +23,11 @@
}
&.custom-table-light {
& > thead > th {
& > thead > th, & > thead > tr > th {
@apply border-gray-500;
}
& > td {
&:hover {
@apply bg-gray-700;
}
& > tr, & > tbody > tr {
@apply hover:bg-gray-700;
}
}
}
.custom-table > * {
display: table-row;
}
.custom-table > * > * {
display: table-cell;
}

View File

@ -73,7 +73,9 @@ use App\Membership\Actions\IndexAction as MembershipIndexAction;
use App\Membership\Actions\ListForGroupAction;
use App\Membership\Actions\MassListAction;
use App\Membership\Actions\MassStoreAction;
use App\Membership\Actions\MemberIndexAction;
use App\Membership\Actions\MembershipDestroyAction;
use App\Membership\Actions\MembershipIndexAction as ActionsMembershipIndexAction;
use App\Membership\Actions\MembershipStoreAction;
use App\Membership\Actions\MembershipUpdateAction;
use App\Payment\SubscriptionController;
@ -140,13 +142,14 @@ Route::group(['middleware' => 'auth:web'], function (): void {
Route::get('/member/{member}/invoice-position', PaymentPositionIndexAction::class)->name('member.invoice-position.index');
// --------------------------------- membership --------------------------------
Route::get('/member/{member}/membership', MembershipIndexAction::class)->name('member.membership.index');
Route::get('/member/{member}/membership', MemberIndexAction::class)->name('member.membership.index');
Route::post('/member/{member}/membership', MembershipStoreAction::class)->name('member.membership.store');
Route::patch('/membership/{membership}', MembershipUpdateAction::class)->name('membership.update');
Route::delete('/membership/{membership}', MembershipDestroyAction::class)->name('membership.destroy');
Route::post('/api/membership/member-list', ListForGroupAction::class)->name('membership.member-list');
Route::post('/api/membership/masslist', MassStoreAction::class)->name('membership.masslist.store');
Route::get('/membership/masslist', MassListAction::class)->name('membership.masslist.index');
Route::get('/membership', ActionsMembershipIndexAction::class)->name('membership.index');
// ----------------------------------- group ----------------------------------
Route::get('/group', GroupIndexAction::class)->name('group.index');

View File

@ -63,7 +63,7 @@ it('cannot create membership when activity and subactivity doesnt belong togethe
])->assertJsonValidationErrors(['activity_id' => 'Tätigkeit ist nicht vorhanden.']);
});
it('testItDeletesAMembership', function() {
it('deletes a membership', function() {
MembershipDestroyAction::partialMock()->shouldReceive('handle')->once();
MembershipStoreAction::partialMock()->shouldReceive('handle')->never();
ResyncAction::partialMock()->shouldReceive('handle')->once();
@ -73,7 +73,7 @@ it('testItDeletesAMembership', function() {
MassStoreAction::run($member->memberships->first()->group, $member->memberships->first()->activity, $member->memberships->first()->subactivity, []);
});
it('testItRollsbackWhenDeletionFails', function() {
it('rolls back when deletion fails', function() {
app(MembershipFake::class)
->shows(3, ['id' => 55])
->shows(3, ['id' => 56])

View File

@ -0,0 +1,49 @@
<?php
namespace Tests\Feature\Membership;
use App\Activity;
use App\Group;
use App\Member\Data\MembershipData;
use App\Member\Member;
use App\Member\Membership;
use App\Subactivity;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Inertia\Testing\AssertableInertia as Assert;
uses(DatabaseTransactions::class);
mutates(MembershipData::class);
it('lists memberships of users', function () {
$this->login()->loginNami()->withoutExceptionHandling();
$activity = Activity::factory()
->hasAttached(Subactivity::factory()->name('SubAct'))
->name('Act')
->create();
$group = Group::factory()->name('GG')->create();
$member = Member::factory()->defaults()
->for($group)
->has(Membership::factory()->for($activity)->for($activity->subactivities->first())->for($group))
->male()
->name('Max Muster')
->create();
$activity->subactivities()->first();
$this->callFilter('membership.index', [])
->assertInertia(fn(Assert $page) => $page
->has('data.0', fn(Assert $page) => $page
->where('activity.name', 'Act')
->where('subactivity.name', 'SubAct')
->where('group.name', 'GG')
->where('promisedAt', null)
->where('links.update', route('membership.update', $member->memberships->first()))
->where('links.destroy', route('membership.destroy', $member->memberships->first()))
->etc()
)->has('meta', fn (Assert $page) => $page
->where('current_page', 1)
->etc()
)
);
});

View File

@ -119,12 +119,24 @@ class TestCase extends BaseTestCase
/** @var TestResponse */
$response = $this;
$props = data_get($response->viewData('page'), 'props');
Assert::assertTrue(Arr::has($props, $path), 'Failed that key ' . $path . ' is in Response.');
Assert::assertNotNull($props);
$json = new AssertableJsonString($props);
$json->assertPath($path, $value);
return $this;
});
TestResponse::macro('assertInertiaPathArray', function ($arr) {
/** @var TestResponse */
$response = $this;
foreach ($arr as $key => $value) {
$response->assertInertiaPath($key, $value);
}
return $response;
});
TestResponse::macro('assertInertiaCount', function ($path, $count) {
/** @var TestResponse */
$response = $this;
@ -171,5 +183,13 @@ class TestCase extends BaseTestCase
return $this;
});
TestResponse::macro('assertNull', function (string $path) {
/** @var TestResponse */
$response = $this;
$response->assertHasJsonPath($path)->assertJsonPath($path, null);
return $this;
});
}
}