Compare commits

...

3 Commits

Author SHA1 Message Date
philipp lang 33149e8b79 --wip-- [skip ci] 2025-06-12 21:18:43 +02:00
philipp lang 50878a9a3c Add ts for Tabs 2025-06-12 18:31:46 +02:00
philipp lang 63799c87ec Fix table structure 2025-06-12 18:30:44 +02:00
17 changed files with 203 additions and 197 deletions

View File

@ -2,6 +2,7 @@
namespace App\Member\Data;
use App\Group;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Attributes\MapInputName;
use Spatie\LaravelData\Attributes\MapOutputName;
@ -16,4 +17,13 @@ class GroupData extends Data {
public string $name,
) {}
public static function fromId(int $id): static {
$group = Group::findOrFail($id);
return static::from([
'name' => $group->name,
'id' => $group->id,
]);
}
}

View File

@ -3,78 +3,44 @@
namespace App\Member\Data;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Attributes\MapInputName;
use Spatie\LaravelData\Attributes\MapOutputName;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
use App\Member\Membership;
use App\Member\Member;
use App\Activity;
use App\Lib\Data\DateData;
#[MapInputName(SnakeCaseMapper::class)]
#[MapOutputName(SnakeCaseMapper::class)]
class MembershipData extends Data
{
public function __construct(
public int $id,
public ActivityData $activity,
public SubactivityData $subactivity,
public GroupData $group,
public ?DateData $promisedAt,
public DateData $from,
public bool $isActive,
public ?ActivityData $activity = null,
public ?SubactivityData $subactivity = null,
public ?GroupData $group = null,
public ?DateData $promisedAt = null,
public ?DateData $from = null,
public bool $isActive = false,
public array $links = [],
) {}
public static function fromModel(Membership $membership): static
{
return static::factory()->withoutMagicalCreation()->from([
'id' => $membership->id,
'activity' => $membership->activity,
'subactivity' => $membership->subactivity,
'is_active' => $membership->isActive(),
'subactivity' => $membership?->subactivity,
'isActive' => $membership->isActive(),
'from' => $membership->from,
'group' => $membership->group,
'promised_at' => $membership->promised_at,
'promisedAt' => $membership->promised_at,
'links' => [
'update' => route('membership.update', $membership),
'destroy' => route('membership.destroy', $membership),
]
]);
}
/**
* @return array<string, mixed>
*/
public function with(): array
public static function memberMeta(Member $member): MembershipMeta
{
return [
// 'human_date' => $this->from->format('d.m.Y'),
// 'promised_at_human' => $this->promisedAt?->format('d.m.Y'),
// 'promised_at' => $this->promisedAt?->format('Y-m-d'),
'links' => [
'update' => route('membership.update', ['membership' => $this->id]),
'destroy' => route('membership.destroy', ['membership' => $this->id]),
]
];
}
/**
* @return array<string, mixed>
*/
public static function memberMeta(Member $member): array
{
$activities = Activity::with('subactivities')->get();
return [
'links' => [
'store' => route('member.membership.store', ['member' => $member]),
],
'groups' => NestedGroup::cacheForSelect(),
'activities' => $activities->map(fn($activity) => ['id' => $activity->id, 'name' => $activity->name]),
'subactivities' => $activities->mapWithKeys(fn($activity) => [$activity->id => $activity->subactivities->map(fn($subactivity) => ['id' => $subactivity->id, 'name' => $subactivity->name, 'is_age_group' => $subactivity->is_age_group])]),
'default' => [
'group_id' => $member->group_id,
'activity_id' => null,
'subactivity_id' => null,
'promised_at' => null,
],
];
return MembershipMeta::fromMember($member);
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Member\Data;
use App\Activity;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Attributes\MapInputName;
use Spatie\LaravelData\Attributes\MapOutputName;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
use App\Member\Member;
use Illuminate\Support\Collection;
#[MapInputName(SnakeCaseMapper::class)]
#[MapOutputName(SnakeCaseMapper::class)]
class MembershipMeta extends Data
{
public function __construct(
public array $links,
public Collection $groups,
public Collection $activities,
public Collection $subactivities,
public MembershipData $default,
) {}
public static function fromMember(Member $member): static
{
$activities = Activity::with('subactivities')->get();
return static::factory()->withoutMagicalCreation()->from([
'links' => [
'store' => route('member.membership.store', ['member' => $member]),
],
'groups' => NestedGroup::cacheForSelect(),
'activities' => $activities->map(fn($activity) => ['id' => $activity->id, 'name' => $activity->name]),
'subactivities' => $activities->mapWithKeys(fn($activity) => [$activity->id => $activity->subactivities->map(fn($subactivity) => ['id' => $subactivity->id, 'name' => $subactivity->name, 'is_age_group' => $subactivity->is_age_group])]),
'default' => MembershipData::from(['group' => $member->group_id]),
]);
}
}

View File

@ -103,6 +103,8 @@ services:
- ./data/redis:/data
meilisearch:
ports:
- "7700:7700"
image: getmeili/meilisearch:v1.6
volumes:
- ./data/meilisearch:/meili_data

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

@ -1,17 +1,17 @@
<template>
<label class="flex flex-col group" :for="id" :class="sizeClass(size)">
<f-label v-if="label" :required="required" :value="label"></f-label>
<f-label v-if="label" :required="required" :value="label" />
<div class="relative flex-none flex">
<select v-model="inner" :disabled="disabled" :name="name" :class="[fieldHeight, fieldAppearance, selectAppearance]">
<option v-if="placeholder" :value="def">{{ placeholder }}</option>
<option v-for="option in parsedOptions" :key="option.id" :value="option.id">{{ option.name }}</option>
</select>
<f-hint v-if="hint" :value="hint"></f-hint>
<f-hint v-if="hint" :value="hint" />
</div>
</label>
</template>
<script setup>
<script lang="ts" setup>
import {computed, ref} from 'vue';
import useFieldSize from '../../composables/useFieldSize.js';
import map from 'lodash/map';
@ -59,11 +59,6 @@ const props = defineProps({
default: '--kein--',
type: String,
},
def: {
required: false,
type: Number,
default: -1,
},
name: {
required: true,
},
@ -79,8 +74,8 @@ const parsedOptions = computed(() => {
return Array.isArray(props.options)
? props.options
: map(props.options, (value, key) => {
return {name: value, id: key};
});
return {name: value, id: key};
});
});
const def = ref('iu1Feixah5AeKai3ewooJahjeaegee0eiD4maeth1oul4Hei7u');

View File

@ -1,28 +1,14 @@
<template>
<a v-tooltip="tooltip" :href="href" :target="blank ? '_BLANK' : '_SELF'" class="inline-flex btn btn-sm">
<ui-sprite :src="icon"></ui-sprite>
<ui-sprite :src="icon" />
</a>
</template>
<script setup>
defineProps({
tooltip: {
required: true,
type: String,
},
href: {
type: String,
default: () => '#',
required: false,
},
blank: {
type: Boolean,
default: () => false,
required: false,
},
icon: {
type: String,
required: true,
},
});
<script lang="ts" setup>
const {tooltip, icon, blank = false, href = '#'} = defineProps<{
tooltip: string,
href?: string,
blank?: boolean,
icon: string,
}>();
</script>

View File

@ -2,6 +2,5 @@
<svg v-bind="$attrs" class="fill-current"><use :xlink:href="`/sprite.svg#${$attrs.src}`" /></svg>
</template>
<script>
export default {};
<script lang="ts" setup>
</script>

View File

@ -9,7 +9,7 @@
</div>
</template>
<script lang="ts" setup>
<script lang="ts" setup>
defineProps<{
modelValue: number,
entries: {title: string}[]

View File

@ -1,84 +1,73 @@
import {ref, inject, onBeforeUnmount} from 'vue';
import {router} from '@inertiajs/vue3';
import type {Ref} from 'vue';
import useQueueEvents from './useQueueEvents.js';
import { Axios } from 'axios';
export function useApiIndex(firstUrl, siteName = null) {
const axios = inject('axios');
export function useApiIndex<D, M extends Custom.PageMetadata>(firstUrl, siteName = null) {
const axios = inject<Axios>('axios');
if (siteName !== null) {
var {startListener, stopListener} = useQueueEvents(siteName, () => reload());
}
const single = ref(null);
const single: Ref<D|null> = ref(null);
const url = ref(firstUrl);
const inner = {
data: ref([]),
meta: ref({}),
const inner: {data: Ref<D[]|null>, meta: Ref<M|null>} = {
data: ref(null),
meta: ref(null),
};
async function reload(resetPage = true, p = {}) {
var params = {
page: resetPage ? 1 : inner.meta.value.current_page,
const params = {
page: resetPage ? 1 : inner.meta.value?.current_page,
...p,
};
var response = (await axios.get(url.value, {params})).data;
const response = (await axios.get(url.value, {params})).data;
inner.data.value = response.data;
inner.meta.value = response.meta;
}
async function reloadPage(page, p = {}) {
inner.meta.value.current_page = page;
async function reloadPage(page: number, p = {}) {
if (inner.meta.value?.current_page) {
inner.meta.value.current_page = page;
}
await reload(false, p);
}
function create() {
single.value = JSON.parse(JSON.stringify(inner.meta.value.default));
single.value = JSON.parse(JSON.stringify(inner.meta.value?.default));
}
function edit(model) {
function edit(model: D) {
single.value = JSON.parse(JSON.stringify(model));
}
async function submit() {
if (single.value === null) {
return;
}
single.value.id ? await axios.patch(single.value.links.update, single.value) : await axios.post(inner.meta.value.links.store, single.value);
await reload();
single.value = null;
}
async function remove(model) {
async function remove(model: D) {
await axios.delete(model.links.destroy);
await reload();
}
function can(permission) {
return inner.meta.value.can[permission];
}
function toFilterString(data) {
return btoa(encodeURIComponent(JSON.stringify(data)));
}
function requestCallback(successMessage, failureMessage) {
return {
onSuccess: () => {
this.$success(successMessage);
reload(false);
},
onFailure: () => {
this.$error(failureMessage);
reload(false);
},
preserveState: true,
};
}
function cancel() {
single.value = null;
}
function updateUrl(newUrl) {
function updateUrl(newUrl: string) {
url.value = newUrl;
}
@ -95,8 +84,6 @@ export function useApiIndex(firstUrl, siteName = null) {
edit,
reload,
reloadPage,
can,
requestCallback,
router,
submit,
remove,

View File

@ -8,10 +8,10 @@
</page-header>
<form v-if="single" class="p-6 grid gap-4 justify-start" @submit.prevent="submit">
<f-text id="completed_at" v-model="single.completed_at" type="date" label="Datum" required></f-text>
<f-select id="course_id" v-model="single.course_id" name="course_id" :options="meta.courses" label="Baustein" required></f-select>
<f-text id="event_name" v-model="single.event_name" label="Veranstaltung" required></f-text>
<f-text id="organizer" v-model="single.organizer" label="Veranstalter" required></f-text>
<f-text id="completed_at" v-model="single.completed_at" type="date" label="Datum" required />
<f-select id="course_id" v-model="single.course_id" name="course_id" :options="meta.courses" label="Baustein" required />
<f-text id="event_name" v-model="single.event_name" label="Veranstaltung" required />
<f-text id="organizer" v-model="single.organizer" label="Veranstalter" required />
<button type="submit" class="btn btn-primary">Absenden</button>
</form>
@ -23,18 +23,18 @@
<th>Veranstaltung</th>
<th>Veranstalter</th>
<th>Datum</th>
<th></th>
<th />
</tr>
</thead>
<tr v-for="(course, index) in data" :key="index">
<td v-text="course.course_name"></td>
<td v-text="course.event_name"></td>
<td v-text="course.organizer"></td>
<td v-text="course.completed_at_human"></td>
<td v-text="course.course_name" />
<td v-text="course.event_name" />
<td v-text="course.organizer" />
<td v-text="course.completed_at_human" />
<td class="flex">
<a href="#" class="inline-flex btn btn-warning btn-sm" @click.prevent="edit(course)"><ui-sprite src="pencil"></ui-sprite></a>
<a href="#" class="inline-flex btn btn-danger btn-sm" @click.prevent="remove(course)"><ui-sprite src="trash"></ui-sprite></a>
<a href="#" class="inline-flex btn btn-warning btn-sm" @click.prevent="edit(course)"><ui-sprite src="pencil" /></a>
<a href="#" class="inline-flex btn btn-danger btn-sm" @click.prevent="remove(course)"><ui-sprite src="trash" /></a>
</td>
</tr>
</table>
@ -44,7 +44,7 @@
<script lang="js" setup>
defineEmits(['close']);
import { useApiIndex } from '../../composables/useApiIndex.js';
import { useApiIndex } from '../../composables/useApiIndex.ts';
const props = defineProps({
url: {

View File

@ -1,5 +1,5 @@
<template>
<page-header title="Zahlungen" @close="$emit('close')"> </page-header>
<page-header title="Zahlungen" @close="$emit('close')" />
<div class="grow">
<table class="custom-table custom-table-light custom-table-sm text-sm">
@ -10,15 +10,15 @@
</thead>
<tr v-for="(position, index) in data" :key="index">
<td v-text="position.description"></td>
<td v-text="position.invoice.status"></td>
<td v-text="position.price_human"></td>
<td v-text="position.description" />
<td v-text="position.invoice.status" />
<td v-text="position.price_human" />
</tr>
</table>
</div>
</template>
<script lang="js" setup>
<script lang="ts" setup>
defineEmits(['close']);
import { useApiIndex } from '../../composables/useApiIndex.js';

View File

@ -6,58 +6,62 @@
</template>
</page-header>
<form v-if="single" class="p-6 grid gap-4 justify-start" @submit.prevent="submit">
<f-select id="group_id" v-model="single.group_id" name="group_id" :options="meta.groups" label="Gruppierung" required />
<f-select id="activity_id" v-model="single.activity_id" name="activity_id" :options="meta.activities" label="Tätigkeit" required />
<f-select v-if="single.activity_id"
<form v-if="single && meta !== null" class="p-6 grid gap-4 justify-start" @submit.prevent="submit">
<f-select id="group_id" v-model="single.group" name="group_id" :options="meta.groups" label="Gruppierung" required />
<f-select id="activity_id" v-model="single.activity" name="activity_id" :options="meta.activities" label="Tätigkeit" required />
<f-select v-if="single.activity"
id="subactivity_id"
:model-value="single.subactivity_id"
:model-value="single.subactivity"
name="subactivity_id"
:options="meta.subactivities[single.activity_id]"
:options="meta.subactivities[single.activity.id]"
label="Untertätigkeit"
@update:model-value="setSubactivityId(single, $event)"
/>
<f-switch v-if="displayPromisedAt"
id="has_promise"
name="has_promise"
:model-value="single.promised_at !== null"
:model-value="single.promisedAt !== null"
label="Hat Versprechen"
@update:model-value="setPromisedAtSwitch(single, $event)"
/>
<f-text v-show="displayPromisedAt && single.promised_at !== null" id="promised_at" v-model="single.promised_at" type="date" label="Versprechensdatum" size="sm" />
<f-text v-show="displayPromisedAt && single.promisedAt !== null" id="promised_at" v-model="single.promisedAt" type="date" label="Versprechensdatum" size="sm" />
<button type="submit" class="btn btn-primary">Absenden</button>
</form>
<div v-else class="grow">
<table class="custom-table custom-table-light custom-table-sm text-sm">
<thead>
<th>Tätigkeit</th>
<th>Untertätigkeit</th>
<th>Datum</th>
<th>Aktiv</th>
<th />
<tr>
<th>Tätigkeit</th>
<th>Untertätigkeit</th>
<th>Datum</th>
<th>Aktiv</th>
<th />
</tr>
</thead>
<tr v-for="(membership, index) in data" :key="index">
<td v-text="membership.activity_name" />
<td v-text="membership.subactivity_name" />
<td v-text="membership.human_date" />
<td><ui-boolean-display :value="membership.is_active" dark /></td>
<td class="flex space-x-1">
<a href="#" class="inline-flex btn btn-warning btn-sm" @click.prevent="edit(membership)"><ui-sprite src="pencil" /></a>
<a href="#" class="inline-flex btn btn-danger btn-sm" @click.prevent="remove(membership)"><ui-sprite src="trash" /></a>
</td>
</tr>
<tbody>
<tr v-for="(membership, index) in data" :key="index">
<td v-text="membership.activity?.name" />
<td v-text="membership.subactivity?.name" />
<td v-text="membership.from?.human" />
<td><ui-boolean-display :value="membership.isActive" dark /></td>
<td class="flex space-x-1">
<a href="#" class="inline-flex btn btn-warning btn-sm" @click.prevent="edit(membership)"><ui-sprite src="pencil" /></a>
<a href="#" class="inline-flex btn btn-danger btn-sm" @click.prevent="remove(membership)"><ui-sprite src="trash" /></a>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script lang="js" setup>
<script lang="ts" setup>
import { computed } from 'vue';
import dayjs from 'dayjs';
defineEmits(['close']);
import { useApiIndex } from '../../composables/useApiIndex.js';
import { useApiIndex } from '../../composables/useApiIndex.ts';
const props = defineProps({
url: {
@ -65,14 +69,14 @@ const props = defineProps({
required: true,
},
});
const { data, meta, reload, single, create, edit, submit, remove } = useApiIndex(props.url, 'membership');
const { data, meta, reload, single, create, edit, submit, remove } = useApiIndex<App.Member.Data.MembershipData, App.Member.Data.MembershipMeta>(props.url, 'membership');
function setPromisedAtSwitch(single, value) {
single.promised_at = value ? dayjs().format('YYYY-MM-DD') : null;
}
const displayPromisedAt = computed(function () {
if (!single.value || !single.value.activity_id || !single.value.subactivity_id) {
if (!single.value || !single.value.activity || !single.value.subactivity) {
return false;
}

6
resources/types/custom.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
declare namespace Custom {
export type PageMetadata = {
current_page: number;
default: object;
};
}

View File

@ -444,13 +444,20 @@ id: number;
name: string;
};
export type MembershipData = {
id: number;
activity: App.Member.Data.ActivityData;
subactivity: App.Member.Data.SubactivityData;
group: App.Member.Data.GroupData;
promised_at: App.Lib.Data.DateData | null;
from: App.Lib.Data.DateData;
is_active: boolean;
activity: App.Member.Data.ActivityData | null;
subactivity: App.Member.Data.SubactivityData | null;
group: App.Member.Data.GroupData | null;
promisedAt: App.Lib.Data.DateData | null;
from: App.Lib.Data.DateData | null;
isActive: boolean;
links: Array<any>;
};
export type MembershipMeta = {
links: Array<any>;
groups: any;
activities: any;
subactivities: any;
default: App.Member.Data.MembershipData;
};
export type NestedGroup = {
id: number;

View File

@ -37,14 +37,25 @@ it('testItShowsActivityAndSubactivityNamesOfMember', function () {
->assertJsonPath('data.0.subactivity.name', 'Wölfling')
->assertJsonPath('data.0.from.human', '02.11.2022')
->assertJsonPath('data.0.from.raw', '2022-11-02')
->assertJsonPath('data.0.promised_at.raw', now()->format('Y-m-d'))
->assertJsonPath('data.0.promised_at.human', now()->format('d.m.Y'))
->assertJsonPath('data.0.promisedAt.raw', now()->format('Y-m-d'))
->assertJsonPath('data.0.promisedAt.human', now()->format('d.m.Y'))
->assertJsonPath('data.0.group.id', $group->id)
->assertJsonPath('data.0.id', $membership->id)
->assertJsonPath('data.0.links.update', route('membership.update', ['membership' => $membership]))
->assertJsonPath('data.0.links.destroy', route('membership.destroy', ['membership' => $membership]));
});
it('activity and subactivity can be null', function () {
$this->withoutExceptionHandling()->login()->loginNami();
$group = Group::factory()->create(['name' => 'aaaaaaaa']);
$member = Member::factory()
->defaults()
->for($group)
->has(Membership::factory()->for($group)->in('€ Mitglied', 122)->from('2022-11-02')->promise(now()))
->create();
$this->get("/member/{$member->id}/membership")->assertNull('data.0.subactivity');
});
it('returns meta', function () {
$this->withoutExceptionHandling()->login()->loginNami();
$group = Group::factory()->create(['name' => 'aaaaaaaa']);
@ -55,10 +66,11 @@ it('returns meta', function () {
->create();
$this->get("/member/{$membership->member->id}/membership")
->assertNull('meta.default.activity_id')
->assertNull('meta.default.subactivity_id')
->assertNull('meta.default.promised_at')
->assertJsonPath('meta.default.group_id', $group->id)
->assertNull('meta.default.activity')
->assertNull('meta.default.subactivity')
->assertNull('meta.default.promisedAt')
->assertJsonPath('meta.default.group.id', $group->id)
->assertJsonPath('meta.default.group.name', $group->name)
->assertJsonPath('meta.groups.0.id', $group->id)
->assertJsonPath('meta.activities.0.id', $membership->activity->id)
->assertJsonPath('meta.activities.0.name', $membership->activity->name)
@ -76,7 +88,7 @@ it('promised at can be null', function () {
->create();
$this->get("/member/{$member->id}/membership")
->assertJsonPath('data.0.promised_at', null);
->assertJsonPath('data.0.promisedAt', null);
});
@ -88,7 +100,7 @@ it('testItShowsIfMembershipIsActive', function (Carbon $from, ?Carbon $to, bool
->create();
$this->get("/member/{$member->id}/membership")
->assertJsonPath('data.0.is_active', $isActive);
->assertJsonPath('data.0.isActive', $isActive);
})->with([
[now()->subMonths(2), null, true],
[now()->subMonths(2), now()->subDay(), false],

View File

@ -59,5 +59,5 @@
"@/*": ["./resources/js/*"]
}
},
"include": ["**/*", "resources/js/components/components.d.ts"]
"include": ["**/*", "resources/types/generated.d.ts", "resources/types/custom.d.ts"]
}