Compare commits

...

20 commits
v1.0.2 ... main

Author SHA1 Message Date
6e82675557 1.1.2 2025-01-21 09:00:51 +01:00
1b531b1152 Merge pull request 'patches v1.1.2' (#47) from develop into main
Reviewed-on: #47
2025-01-21 08:00:11 +00:00
131b3747de enhance: status displayed by member search 2025-01-21 08:58:47 +01:00
883559d8a5 fix: get all members for newletter recipients query 2025-01-21 08:58:30 +01:00
626a355c5c 1.1.1 2025-01-20 12:49:08 +01:00
4ecb39ceff Merge pull request 'patches v1.1.1' (#46) from develop into main
Reviewed-on: #46
2025-01-20 11:48:27 +00:00
45ad07a906 fix: create of calendar type with optional passphrase 2025-01-20 12:43:35 +01:00
5bcb76a60e change: enable calendar entry details for public 2025-01-20 12:43:18 +01:00
c40b53b200 member search component 2025-01-20 09:43:48 +01:00
c9c6df20e0 1.1.0 2025-01-19 13:58:02 +01:00
05e464e825 Merge pull request 'minor: v1.1.0' (#45) from develop into main
Reviewed-on: #45
2025-01-19 12:56:43 +00:00
4dc183f52b Merge branch 'main' into develop 2025-01-19 12:56:07 +00:00
363b5bb541 Merge pull request 'feature/#38-protocol-presence-status' (#44) from feature/#38-protocol-presence-status into develop
Reviewed-on: #44
2025-01-19 12:46:42 +00:00
c2b495f8a7 set excused state to presece members 2025-01-19 13:42:42 +01:00
8a85cc054d fix: display of table structure cleared query builder request 2025-01-19 12:35:21 +01:00
afa834739c 1.0.3 2025-01-18 16:51:23 +01:00
8d2e0deee6 Merge pull request 'patches v1.0.3' (#43) from develop into main
Reviewed-on: #43
2025-01-18 15:49:13 +00:00
9e50d95d7b official logo 2025-01-18 16:45:03 +01:00
020c7c6cb9 weburl change 2025-01-18 14:58:38 +01:00
065b0aa6d5 fix: set default name to AppNameOverwrite to allow pwa install 2025-01-13 12:45:45 +01:00
28 changed files with 407 additions and 216 deletions

View file

@ -6,7 +6,7 @@ Administration für Feuerwehren und Vereine.
Dieses Repository dient hauptsächlich zur Verwaltung der Mitgliederdaten, aber auch zur Verwaltung weiterer Daten der Feuerwehr oder eines Vereins. Es ist ein Frontend-Client, der auf die Daten des [ff-admin-server Backends](https://forgejo.jk-effects.cloud/Ehrenamt/ff-admin-server) zugreift. Die Webapp bietet eine Möglichkeit Mitgliederdaten zu verwalten, Protokolle zu schreiben und Kaledereinträge zu erstellen. Benutzer können eingeladen und Rollen zugewiesen werden. Dieses Repository dient hauptsächlich zur Verwaltung der Mitgliederdaten, aber auch zur Verwaltung weiterer Daten der Feuerwehr oder eines Vereins. Es ist ein Frontend-Client, der auf die Daten des [ff-admin-server Backends](https://forgejo.jk-effects.cloud/Ehrenamt/ff-admin-server) zugreift. Die Webapp bietet eine Möglichkeit Mitgliederdaten zu verwalten, Protokolle zu schreiben und Kaledereinträge zu erstellen. Benutzer können eingeladen und Rollen zugewiesen werden.
Eine Demo dieser Seite finden Sie unter [https://ff-admin-demo.jk-effects.cloud](https://ff-admin-demo.jk-effects.cloud). Eine Demo dieser Seite finden Sie unter [https://admin-demo.ff-admin.de](https://admin-demo.ff-admin.de).
Für die Verwendung muss ein TOTP-Code eingegeben werden. Für die Verwendung muss ein TOTP-Code eingegeben werden.
@ -37,9 +37,9 @@ services:
# - PRIVACYLINK=https://mywebsite-privacy-url # - PRIVACYLINK=https://mywebsite-privacy-url
# - CUSTOMLOGINMESSAGE=betrieben von xy # - CUSTOMLOGINMESSAGE=betrieben von xy
#volumes: #volumes:
# - <volume|local path>/myfavicon.ico:/usr/share/nginx/html/favicon.ico # 48x48 px Auflösung # - <volume|local path>/favicon.ico:/usr/share/nginx/html/favicon.ico # 48x48 px Auflösung
# - <volume|local path>/myfavicon.png:/usr/share/nginx/html/favicon.png # 512x512 px Auflösung - wird als pwa Icon genutzt # - <volume|local path>/favicon.png:/usr/share/nginx/html/favicon.png # 512x512 px Auflösung - wird als pwa Icon genutzt
# - <volume|local path>/mylogo.png:/usr/share/nginx/html/Logo.png # - <volume|local path>/Logo.png:/usr/share/nginx/html/Logo.png
``` ```
Wenn keine Server-Adresse angegeben wird, wird versucht das Backend unter der URL des Frontends zu erreichen. Dazu muss das Backend auf der gleichen URL wie das Frontend laufen. Zur Unterscheidung von Frontend und Backend bei gleicher URL müssen alle Anfragen mit dem PathPrefix `/api` an das Backend weitergeleitet werden. Wenn keine Server-Adresse angegeben wird, wird versucht das Backend unter der URL des Frontends zu erreichen. Dazu muss das Backend auf der gleichen URL wie das Frontend laufen. Zur Unterscheidung von Frontend und Backend bei gleicher URL müssen alle Anfragen mit dem PathPrefix `/api` an das Backend weitergeleitet werden.

View file

@ -11,6 +11,12 @@ do
do do
# Get environment variable # Get environment variable
value=$(eval echo "\$$key") value=$(eval echo "\$$key")
# Set default value for APPNAMEOVERWRITE if empty
if [ "$key" = "APPNAMEOVERWRITE" ] && [ -z "$value" ]; then
value="FF Admin"
fi
echo "replace $key by $value" echo "replace $key by $value"
# replace __[variable_name]__ value with environment variable # replace __[variable_name]__ value with environment variable

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "ff-admin", "name": "ff-admin",
"version": "1.0.2", "version": "1.1.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ff-admin", "name": "ff-admin",
"version": "1.0.2", "version": "1.1.2",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"dependencies": { "dependencies": {
"@fullcalendar/core": "^6.1.15", "@fullcalendar/core": "^6.1.15",

View file

@ -1,6 +1,6 @@
{ {
"name": "ff-admin", "name": "ff-admin",
"version": "1.0.2", "version": "1.1.2",
"description": "Feuerwehr/Verein Mitgliederverwaltung UI", "description": "Feuerwehr/Verein Mitgliederverwaltung UI",
"type": "module", "type": "module",
"scripts": { "scripts": {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 516 KiB

View file

@ -0,0 +1,182 @@
<template>
<div class="w-full">
<Combobox v-model="selected" :disabled="disabled" multiple>
<ComboboxLabel>{{ title }}</ComboboxLabel>
<div class="relative mt-1">
<ComboboxInput
class="rounded-md shadow-sm relative block w-full px-3 py-2 border border-gray-300 focus:border-primary placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-0 focus:z-10 sm:text-sm resize-none"
@input="query = $event.target.value"
/>
<ComboboxButton class="absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon class="h-5 w-5 text-gray-400" aria-hidden="true" />
</ComboboxButton>
<TransitionRoot
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
@after-leave="query = ''"
>
<ComboboxOptions
class="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-md ring-1 ring-black/5 focus:outline-none sm:text-sm"
>
<ComboboxOption v-if="loading || deferingSearch" as="template" disabled>
<li class="flex flex-row gap-2 text-text relative cursor-default select-none py-2 pl-3 pr-4">
<Spinner />
<span class="font-normal block truncate">suche</span>
</li>
</ComboboxOption>
<ComboboxOption v-else-if="filtered.length === 0 && query == ''" as="template" disabled>
<li class="text-text relative cursor-default select-none py-2 pl-3 pr-4">
<span class="font-normal block truncate">tippe, um zu suchen...</span>
</li>
</ComboboxOption>
<ComboboxOption v-else-if="filtered.length === 0" as="template" disabled>
<li class="text-text relative cursor-default select-none py-2 pl-3 pr-4">
<span class="font-normal block truncate">Keine Auswahl gefunden.</span>
</li>
</ComboboxOption>
<ComboboxOption
v-if="!(loading || deferingSearch)"
v-for="member in filtered"
as="template"
:key="member.id"
:value="member.id"
v-slot="{ selected, active }"
>
<li
class="relative cursor-default select-none py-2 pl-10 pr-4"
:class="{
'bg-primary text-white': active,
'text-gray-900': !active,
}"
>
<span class="block truncate" :class="{ 'font-medium': selected, 'font-normal': !selected }">
{{ member.firstname }} {{ member.lastname }} {{ member.nameaffix }}
</span>
<span
v-if="selected"
class="absolute inset-y-0 left-0 flex items-center pl-3"
:class="{ 'text-white': active, 'text-primary': !active }"
>
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ComboboxOption>
</ComboboxOptions>
</TransitionRoot>
</div>
</Combobox>
</div>
</template>
<script setup lang="ts">
import { defineComponent, type PropType } from "vue";
import { mapState, mapActions } from "pinia";
import {
Combobox,
ComboboxLabel,
ComboboxInput,
ComboboxButton,
ComboboxOptions,
ComboboxOption,
TransitionRoot,
} from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { useMemberStore } from "@/stores/admin/club/member/member";
import type { MemberViewModel } from "@/viewmodels/admin/club/member/member.models";
import difference from "lodash.difference";
import Spinner from "../Spinner.vue";
</script>
<script lang="ts">
export default defineComponent({
props: {
modelValue: {
type: Array as PropType<Array<number>>,
default: [],
},
title: String,
disabled: {
type: Boolean,
default: false,
},
},
emits: ["update:model-value", "add:difference", "remove:difference", "add:member", "add:memberByArray"],
watch: {
modelValue() {
if (this.initialLoaded) return;
this.initialLoaded = true;
this.loadMembersInitial();
},
query() {
this.deferingSearch = true;
clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.deferingSearch = false;
this.search();
}, 600);
},
},
data() {
return {
initialLoaded: false as boolean,
loading: false as boolean,
deferingSearch: false as boolean,
timer: undefined as any,
query: "" as string,
filtered: [] as Array<MemberViewModel>,
};
},
computed: {
selected: {
get() {
return this.modelValue;
},
set(val: Array<number>) {
this.$emit("update:model-value", val);
if (this.modelValue.length < val.length) {
let diff = difference(val, this.modelValue);
if (diff.length != 1) return;
this.$emit("add:difference", diff[0]);
this.$emit("add:member", this.getMemberFromSearch(diff[0]));
} else {
let diff = difference(this.modelValue, val);
if (diff.length != 1) return;
this.$emit("remove:difference", diff[0]);
}
},
},
},
mounted() {
this.loadMembersInitial();
},
methods: {
...mapActions(useMemberStore, ["searchMembers", "getMembersByIds"]),
search() {
this.filtered = [];
if (this.query == "") return;
this.loading = true;
this.searchMembers(this.query)
.then((res) => {
this.filtered = res.data;
})
.catch((err) => {})
.finally(() => {
this.loading = false;
});
},
getMemberFromSearch(id: number) {
return this.filtered.find((f) => f.id == id);
},
loadMembersInitial() {
if (this.modelValue.length == 0) return;
this.getMembersByIds(this.modelValue)
.then((res) => {
this.$emit("add:memberByArray", res.data);
})
.catch(() => {});
},
},
});
</script>

View file

@ -1,7 +1,7 @@
<template> <template>
<div class="w-full md:max-w-md"> <div class="w-full md:max-w-md">
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<p class="text-xl font-medium">Termintyp erstellen</p> <p class="text-xl font-medium">Termin erstellen</p>
</div> </div>
<br /> <br />
<form class="flex flex-col gap-4 py-2" @submit.prevent="triggerCreate"> <form class="flex flex-col gap-4 py-2" @submit.prevent="triggerCreate">

View file

@ -6,7 +6,7 @@
@click="deleteCalendar" @click="deleteCalendar"
/> />
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<p class="text-xl font-medium">Termintyp erstellen</p> <p class="text-xl font-medium">Termin erstellen</p>
</div> </div>
<br /> <br />
<Spinner v-if="loading == 'loading'" class="mx-auto" /> <Spinner v-if="loading == 'loading'" class="mx-auto" />

View file

@ -49,8 +49,6 @@ import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue"; import FailureXMark from "@/components/FailureXMark.vue";
import { useCalendarTypeStore } from "@/stores/admin/settings/calendarType"; import { useCalendarTypeStore } from "@/stores/admin/settings/calendarType";
import type { CreateCalendarTypeViewModel } from "@/viewmodels/admin/settings/calendarType.models"; import type { CreateCalendarTypeViewModel } from "@/viewmodels/admin/settings/calendarType.models";
import { Listbox, ListboxButton, ListboxOptions, ListboxOption, ListboxLabel } from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
</script> </script>
<script lang="ts"> <script lang="ts">
@ -76,7 +74,7 @@ export default defineComponent({
type: formData.type.value, type: formData.type.value,
color: formData.color.value, color: formData.color.value,
nscdr: formData.nscdr.checked, nscdr: formData.nscdr.checked,
passphrase: formData.passphrase.value, passphrase: formData.passphrase?.value,
}; };
this.status = "loading"; this.status = "loading";
this.createCalendarType(createCalendarType) this.createCalendarType(createCalendarType)

View file

@ -0,0 +1,97 @@
<template>
<div class="relative w-full md:max-w-md">
<div class="flex flex-col items-center">
<p class="text-xl font-medium">Termin</p>
</div>
<br />
<div class="flex flex-col gap-4 py-2">
<div>
<label for="title">Terminart</label>
<input type="text" id="title" readonly :value="data.type?.type" />
</div>
<div>
<label for="title">Titel</label>
<input type="text" id="title" readonly :value="data.title" />
</div>
<div>
<label for="content">Beschreibung</label>
<textarea id="content" class="h-18" readonly :value="data.content"></textarea>
</div>
<div v-if="data.allDay" class="flex flex-row gap-2 items-center">Der Termin findet ganztägig statt.</div>
<div v-if="data.allDay == false" class="flex flex-row gap-2">
<div class="w-full">
<label for="starttime">Startzeit</label>
<input type="datetime-local" id="starttime" readonly :value="formatForDateTimeLocalInput(data.starttime)" />
</div>
<div class="w-full">
<label for="endtime">Endzeit</label>
<input
ref="endtime"
type="datetime-local"
id="endtime"
readonly
:value="formatForDateTimeLocalInput(data.endtime)"
/>
</div>
</div>
<div v-else class="flex flex-row gap-2">
<div class="w-full">
<label for="startdate">Startdatum</label>
<input type="date" id="startdate" readonly :value="formatForDateInput(data.starttime)" />
</div>
<div class="w-full">
<label for="enddate">Enddatum</label>
<input ref="enddate" type="date" id="enddate" readonly :value="formatForDateInput(data.endtime)" />
</div>
</div>
<div>
<label for="location">Ort</label>
<input type="text" id="location" readonly :value="data.location" />
</div>
</div>
<div class="flex flex-row justify-end">
<div class="flex flex-row gap-4 py-2">
<button primary-outline @click="closeModal">schließen</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineAsyncComponent, defineComponent, markRaw } from "vue";
import { mapState, mapActions } from "pinia";
import { useModalStore } from "@/stores/modal";
import Spinner from "@/components/Spinner.vue";
</script>
<script lang="ts">
export default defineComponent({
computed: {
...mapState(useModalStore, ["data"]),
},
methods: {
...mapActions(useModalStore, ["closeModal"]),
formatForDateTimeLocalInput(utcDateString: string) {
const localDate = new Date(utcDateString);
const year = localDate.getFullYear();
const month = String(localDate.getMonth() + 1).padStart(2, "0");
const day = String(localDate.getDate()).padStart(2, "0");
const hours = String(localDate.getHours()).padStart(2, "0");
const minutes = String(localDate.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`;
},
formatForDateInput(utcDateString: string) {
const localDate = new Date(utcDateString);
const year = localDate.getFullYear();
const month = String(localDate.getMonth() + 1).padStart(2, "0");
const day = String(localDate.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
},
},
});
</script>

View file

@ -46,18 +46,13 @@
</div> </div>
</div> </div>
<div class="grow max-lg:hidden"></div> <div class="grow max-lg:hidden"></div>
<div class="p-1 border border-gray-400 bg-gray-100 rounded-md" title="Schema-Struktur" @click="showStructure">
<SparklesIcon class="text-gray-500 h-6 w-6 cursor-pointer" />
</div>
<div class="flex flex-row min-w-fit overflow-hidden border border-gray-400 rounded-md"> <div class="flex flex-row min-w-fit overflow-hidden border border-gray-400 rounded-md">
<div <div
class="p-1" class="p-1"
:class="queryMode == 'structure' ? 'bg-gray-200' : ''" :class="typeof value == 'object' ? 'bg-gray-200' : ''"
title="Schema-Struktur"
@click="queryMode = 'structure'"
>
<SparklesIcon class="text-gray-500 h-6 w-6 cursor-pointer" />
</div>
<div
class="p-1"
:class="typeof value == 'object' && queryMode != 'structure' ? 'bg-gray-200' : ''"
title="Visual Builder" title="Visual Builder"
@click="queryMode = 'builder'" @click="queryMode = 'builder'"
> >
@ -65,7 +60,7 @@
</div> </div>
<div <div
class="p-1" class="p-1"
:class="typeof value == 'string' && queryMode != 'structure' ? 'bg-gray-200' : ''" :class="typeof value == 'string' ? 'bg-gray-200' : ''"
title="SQL Editor" title="SQL Editor"
@click="queryMode = 'editor'" @click="queryMode = 'editor'"
> >
@ -74,10 +69,7 @@
</div> </div>
</div> </div>
<div class="p-2 h-44 md:h-60 w-full overflow-y-auto"> <div class="p-2 h-44 md:h-60 w-full overflow-y-auto">
<div v-if="queryMode == 'structure'"> <textarea v-if="typeof value == 'string'" v-model="value" placeholder="SQL Query" class="h-full w-full" />
<img src="/administration-db.png" class="h-full w-full cursor-pointer" @click="showStructure" />
</div>
<textarea v-else-if="typeof value == 'string'" v-model="value" placeholder="SQL Query" class="h-full w-full" />
<Table v-else v-model="value" /> <Table v-else v-model="value" />
</div> </div>
</div> </div>
@ -153,7 +145,7 @@ export default defineComponent({
data() { data() {
return { return {
autoChangeFlag: false as boolean, autoChangeFlag: false as boolean,
queryMode: "builder" as "builder" | "editor" | "structure", queryMode: "builder" as "builder" | "editor",
}; };
}, },
computed: { computed: {

View file

@ -11,7 +11,10 @@
<div class="flex flex-row justify-end"> <div class="flex flex-row justify-end">
<div class="flex flex-row gap-4 py-2"> <div class="flex flex-row gap-4 py-2">
<button primary-outline @click="closeModal">schnließen</button> <a href="/administration-db.png" button primary-outline download="Datenbank-Schema" class="!whitespace-nowrap"
>Bild herunterladen</a
>
<button primary-outline @click="closeModal">schließen</button>
</div> </div>
</div> </div>
</div> </div>

View file

@ -40,6 +40,21 @@ export const useMemberStore = defineStore("member", {
this.loading = "failed"; this.loading = "failed";
}); });
}, },
async getAllMembers(): Promise<AxiosResponse<any, any>> {
return await http.get(`/admin/member?noLimit=true`).then((res) => {
return { ...res, data: res.data.members };
});
},
async getMembersByIds(ids: Array<number>): Promise<AxiosResponse<any, any>> {
return await http.get(`/admin/member?ids=${ids.join(",")}&noLimit=true`).then((res) => {
return { ...res, data: res.data.members };
});
},
async searchMembers(search: string): Promise<AxiosResponse<any, any>> {
return await http.get(`/admin/member?search=${search}&noLimit=true`).then((res) => {
return { ...res, data: res.data.members };
});
},
fetchMemberByActiveId() { fetchMemberByActiveId() {
this.loadingActive = "loading"; this.loadingActive = "loading";
http http
@ -84,10 +99,10 @@ export const useMemberStore = defineStore("member", {
this.fetchMembers(); this.fetchMembers();
return result; return result;
}, },
async printMemberList(){ async printMemberList() {
return http.get(`/admin/member/print/namelist`, { return http.get(`/admin/member/print/namelist`, {
responseType: "blob", responseType: "blob",
}); });
} },
}, },
}); });

View file

@ -63,7 +63,7 @@ export const useNavigationStore = defineStore("navigation", {
{ {
key: "settings", key: "settings",
title: "Einstellungen", title: "Einstellungen",
levelDefault: "qualification", levelDefault: "award",
} as topLevelNavigationModel, } as topLevelNavigationModel,
] ]
: []), : []),

View file

@ -1,6 +1,7 @@
export interface ProtocolPresenceViewModel { export interface ProtocolPresenceViewModel {
memberId: number; memberId: number;
absent: boolean; absent: boolean;
excused: boolean;
protocolId: number; protocolId: number;
} }

View file

@ -2,8 +2,10 @@
<div class="grow flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8"> <div class="grow flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8 pb-20"> <div class="max-w-md w-full space-y-8 pb-20">
<div class="flex flex-col items-center gap-4"> <div class="flex flex-col items-center gap-4">
<img src="/Logo.png" alt="LOGO" class="h-36" /> <img src="/Logo.png" alt="LOGO" class="h-auto w-full" />
<h2 class="text-center text-4xl font-extrabold text-gray-900">{{config.app_name_overwrite || "FF Admin"}}</h2> <h2 class="text-center text-4xl font-extrabold text-gray-900">
{{ config.app_name_overwrite || "FF Admin" }}
</h2>
</div> </div>
<form class="flex flex-col gap-2" @submit.prevent="login"> <form class="flex flex-col gap-2" @submit.prevent="login">
@ -48,7 +50,7 @@ import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue"; import FailureXMark from "@/components/FailureXMark.vue";
import { resetAllPiniaStores } from "@/helpers/piniaReset"; import { resetAllPiniaStores } from "@/helpers/piniaReset";
import FormBottomBar from "@/components/FormBottomBar.vue"; import FormBottomBar from "@/components/FormBottomBar.vue";
import { config } from "@/config" import { config } from "@/config";
</script> </script>
<script lang="ts"> <script lang="ts">

View file

@ -23,63 +23,12 @@
</div> </div>
</div> </div>
<div class="w-full"> <MemberSearchSelect
<Combobox v-model="recipients" :disabled="!can('create', 'club', 'newsletter')" multiple> title="weitere Empfänger suchen"
<ComboboxLabel>weitere Empfänger suchen</ComboboxLabel> v-model="recipients"
<div class="relative mt-1"> :disabled="!can('create', 'club', 'newsletter')"
<ComboboxInput />
class="rounded-md shadow-sm relative block w-full px-3 py-2 border border-gray-300 focus:border-primary placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-0 focus:z-10 sm:text-sm resize-none"
@input="query = $event.target.value"
/>
<ComboboxButton class="absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon class="h-5 w-5 text-gray-400" aria-hidden="true" />
</ComboboxButton>
<TransitionRoot
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
@after-leave="query = ''"
>
<ComboboxOptions
class="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-md ring-1 ring-black/5 focus:outline-none sm:text-sm"
>
<ComboboxOption v-if="filtered.length === 0" as="template" disabled>
<li class="text-text relative cursor-default select-none py-2 pl-3 pr-4">
<span class="font-normal block truncate"> Keine Auswahl</span>
</li>
</ComboboxOption>
<ComboboxOption
v-for="member in filtered"
as="template"
:key="member.id"
:value="member.id"
v-slot="{ selected, active }"
>
<li
class="relative cursor-default select-none py-2 pl-10 pr-4"
:class="{
'bg-primary text-white': active,
'text-gray-900': !active,
}"
>
<span class="block truncate" :class="{ 'font-medium': selected, 'font-normal': !selected }">
{{ member.firstname }} {{ member.lastname }} {{ member.nameaffix }}
</span>
<span
v-if="selected"
class="absolute inset-y-0 left-0 flex items-center pl-3"
:class="{ 'text-white': active, 'text-primary': !active }"
>
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ComboboxOption>
</ComboboxOptions>
</TransitionRoot>
</div>
</Combobox>
</div>
<p>Ausgewählte Empfänger</p> <p>Ausgewählte Empfänger</p>
<div class="flex flex-col gap-2 grow overflow-y-auto"> <div class="flex flex-col gap-2 grow overflow-y-auto">
<div <div
@ -125,6 +74,7 @@ import { useAbilityStore } from "@/stores/ability";
import { useQueryStoreStore } from "@/stores/admin/settings/queryStore"; import { useQueryStoreStore } from "@/stores/admin/settings/queryStore";
import { useQueryBuilderStore } from "@/stores/admin/club/queryBuilder"; import { useQueryBuilderStore } from "@/stores/admin/club/queryBuilder";
import cloneDeep from "lodash.clonedeep"; import cloneDeep from "lodash.clonedeep";
import MemberSearchSelect from "@/components/admin/MemberSearchSelect.vue";
</script> </script>
<script lang="ts"> <script lang="ts">
@ -140,36 +90,25 @@ export default defineComponent({
data() { data() {
return { return {
query: "" as String, query: "" as String,
members: [] as Array<MemberViewModel>,
}; };
}, },
computed: { computed: {
...mapWritableState(useNewsletterRecipientsStore, ["recipients", "loading"]), ...mapWritableState(useNewsletterRecipientsStore, ["recipients", "loading"]),
...mapWritableState(useNewsletterStore, ["activeNewsletterObj"]), ...mapWritableState(useNewsletterStore, ["activeNewsletterObj"]),
...mapState(useMemberStore, ["members"]),
...mapState(useQueryStoreStore, ["queries"]), ...mapState(useQueryStoreStore, ["queries"]),
...mapState(useQueryBuilderStore, ["data"]), ...mapState(useQueryBuilderStore, ["data"]),
...mapState(useAbilityStore, ["can"]), ...mapState(useAbilityStore, ["can"]),
filtered(): Array<MemberViewModel> {
return this.query === ""
? this.members
: this.members.filter((member) =>
(member.firstname + " " + member.lastname)
.toLowerCase()
.replace(/\s+/g, "")
.includes(this.query.toLowerCase().replace(/\s+/g, ""))
);
},
sorted(): Array<MemberViewModel> {
return this.selected.sort((a, b) => {
if (a.lastname < b.lastname) return -1;
if (a.lastname > b.lastname) return 1;
if (a.firstname < b.firstname) return -1;
if (a.firstname > b.firstname) return 1;
return 0;
});
},
selected(): Array<MemberViewModel> { selected(): Array<MemberViewModel> {
return this.members.filter((m) => this.recipients.includes(m.id)); return this.members
.filter((m) => this.recipients.includes(m.id))
.sort((a, b) => {
if (a.lastname < b.lastname) return -1;
if (a.lastname > b.lastname) return 1;
if (a.firstname < b.firstname) return -1;
if (a.firstname > b.firstname) return 1;
return 0;
});
}, },
queried(): Array<MemberViewModel> { queried(): Array<MemberViewModel> {
if (this.recipientsByQueryId == "def") return []; if (this.recipientsByQueryId == "def") return [];
@ -205,13 +144,13 @@ export default defineComponent({
}, },
}, },
mounted() { mounted() {
this.fetchMembers(0, 1000, "", true);
// this.fetchNewsletterRecipients(); // this.fetchNewsletterRecipients();
this.fetchQueries(); this.fetchQueries();
this.loadQuery(); this.loadQuery();
this.loadMembers();
}, },
methods: { methods: {
...mapActions(useMemberStore, ["fetchMembers"]), ...mapActions(useMemberStore, ["getAllMembers"]),
...mapActions(useNewsletterRecipientsStore, ["fetchNewsletterRecipients"]), ...mapActions(useNewsletterRecipientsStore, ["fetchNewsletterRecipients"]),
...mapActions(useQueryStoreStore, ["fetchQueries"]), ...mapActions(useQueryStoreStore, ["fetchQueries"]),
...mapActions(useQueryBuilderStore, ["sendQuery"]), ...mapActions(useQueryBuilderStore, ["sendQuery"]),
@ -221,6 +160,13 @@ export default defineComponent({
this.recipients.splice(index, 1); this.recipients.splice(index, 1);
} }
}, },
loadMembers() {
this.getAllMembers()
.then((res) => {
this.members = res.data;
})
.catch(() => {});
},
loadQuery() { loadQuery() {
if (this.recipientsByQuery) { if (this.recipientsByQuery) {
this.sendQuery(0, 1000, this.recipientsByQuery.query); this.sendQuery(0, 1000, this.recipientsByQuery.query);

View file

@ -5,63 +5,19 @@
&#8634; laden fehlgeschlagen &#8634; laden fehlgeschlagen
</p> </p>
<div class="w-full"> <MemberSearchSelect
<Combobox v-model="presence" :disabled="!can('create', 'club', 'protocol')" multiple by="memberId"> title="Anwesende suchen"
<ComboboxLabel>Anwesende suchen</ComboboxLabel> :model-value="presence.map((p) => p.memberId)"
<div class="relative mt-1"> :disabled="!can('create', 'club', 'protocol')"
<ComboboxInput @add:difference="
class="rounded-md shadow-sm relative block w-full px-3 py-2 border border-gray-300 focus:border-primary placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-0 focus:z-10 sm:text-sm resize-none" (id: number) =>
@input="query = $event.target.value" presence.push({ memberId: id, absent: false, excused: true, protocolId: parseInt(protocolId ?? '') })
/> "
<ComboboxButton class="absolute inset-y-0 right-0 flex items-center pr-2"> @add:member="(s) => members.push(s)"
<ChevronUpDownIcon class="h-5 w-5 text-gray-400" aria-hidden="true" /> @add:member-by-array="(s) => members.push(...s)"
</ComboboxButton> @remove:difference="removeSelected"
<TransitionRoot />
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
@after-leave="query = ''"
>
<ComboboxOptions
class="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-md ring-1 ring-black/5 focus:outline-none sm:text-sm"
>
<ComboboxOption v-if="filtered.length === 0" as="template" disabled>
<li class="text-text relative cursor-default select-none py-2 pl-3 pr-4">
<span class="font-normal block truncate"> Keine Auswahl</span>
</li>
</ComboboxOption>
<ComboboxOption
v-for="member in filtered"
as="template"
:key="member.memberId"
:value="member"
v-slot="{ selected, active }"
>
<li
class="relative cursor-default select-none py-2 pl-10 pr-4"
:class="{
'bg-primary text-white': active,
'text-gray-900': !active,
}"
>
<span class="block truncate" :class="{ 'font-medium': selected, 'font-normal': !selected }">
{{ getMember(member.memberId)?.firstname }} {{ getMember(member.memberId)?.lastname }} {{ getMember(member.memberId)?.nameaffix }}
</span>
<span
v-if="selected"
class="absolute inset-y-0 left-0 flex items-center pl-3"
:class="{ 'text-white': active, 'text-primary': !active }"
>
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ComboboxOption>
</ComboboxOptions>
</TransitionRoot>
</div>
</Combobox>
</div>
<br /> <br />
<p>Anwesenheit</p> <p>Anwesenheit</p>
<div class="flex flex-col gap-2 grow overflow-y-auto"> <div class="flex flex-col gap-2 grow overflow-y-auto">
@ -71,12 +27,21 @@
class="flex flex-row h-fit w-full border border-primary rounded-md bg-primary p-2 text-white justify-between items-center" class="flex flex-row h-fit w-full border border-primary rounded-md bg-primary p-2 text-white justify-between items-center"
> >
<div class="flex flex-col items-start"> <div class="flex flex-col items-start">
<p>{{ getMember(member.memberId)?.lastname }}, {{ getMember(member.memberId)?.firstname }} {{ getMember(member.memberId)?.nameaffix ? `- ${getMember(member.memberId)?.nameaffix}` : "" }}</p> <p>
<label class="flex flex-row gap-2 items-center"> {{ getMember(member.memberId)?.lastname }}, {{ getMember(member.memberId)?.firstname }}
<input type="checkbox" v-model="member.absent" /> {{ getMember(member.memberId)?.nameaffix ? `- ${getMember(member.memberId)?.nameaffix}` : "" }}
war abwesend </p>
</label> <div class="flex flex-row gap-4">
</div> <label class="flex flex-row gap-2 items-center">
<input type="checkbox" v-model="member.absent" />
war abwesend
</label>
<label v-if="member.absent" class="flex flex-row gap-2 items-center">
<input type="checkbox" v-model="member.excused" />
ist entschuldigt
</label>
</div>
</div>
<TrashIcon <TrashIcon
v-if="can('create', 'club', 'protocol')" v-if="can('create', 'club', 'protocol')"
class="w-5 h-5 p-1 box-content cursor-pointer" class="w-5 h-5 p-1 box-content cursor-pointer"
@ -91,22 +56,11 @@
import { defineComponent } from "vue"; import { defineComponent } from "vue";
import { mapActions, mapState, mapWritableState } from "pinia"; import { mapActions, mapState, mapWritableState } from "pinia";
import Spinner from "@/components/Spinner.vue"; import Spinner from "@/components/Spinner.vue";
import {
Combobox,
ComboboxLabel,
ComboboxInput,
ComboboxButton,
ComboboxOptions,
ComboboxOption,
TransitionRoot,
} from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { TrashIcon } from "@heroicons/vue/24/outline"; import { TrashIcon } from "@heroicons/vue/24/outline";
import { useProtocolStore } from "@/stores/admin/club/protocol/protocol";
import { useMemberStore } from "@/stores/admin/club/member/member";
import type { MemberViewModel } from "@/viewmodels/admin/club/member/member.models";
import { useProtocolPresenceStore } from "@/stores/admin/club/protocol/protocolPresence"; import { useProtocolPresenceStore } from "@/stores/admin/club/protocol/protocolPresence";
import { useAbilityStore } from "@/stores/ability"; import { useAbilityStore } from "@/stores/ability";
import MemberSearchSelect from "@/components/admin/MemberSearchSelect.vue";
import type { MemberViewModel } from "@/viewmodels/admin/club/member/member.models";
</script> </script>
<script lang="ts"> <script lang="ts">
@ -117,34 +71,20 @@ export default defineComponent({
data() { data() {
return { return {
query: "" as String, query: "" as String,
members: [] as Array<MemberViewModel>,
}; };
}, },
computed: { computed: {
...mapWritableState(useProtocolPresenceStore, ["presence", "loading"]), ...mapWritableState(useProtocolPresenceStore, ["presence", "loading"]),
...mapState(useMemberStore, ["members"]),
...mapState(useAbilityStore, ["can"]), ...mapState(useAbilityStore, ["can"]),
filtered(): Array<{memberId:number, absent:boolean; protocolId:number}> { getMember() {
return (this.query === "" return (memberId: number) => {
? this.members return this.members.find((m) => memberId == m.id);
: this.members.filter((member) => };
(member.firstname + " " + member.lastname)
.toLowerCase()
.replace(/\s+/g, "")
.includes(this.query.toLowerCase().replace(/\s+/g, ""))
)).map(m =>({memberId: m.id, absent:false, protocolId:parseInt(this.protocolId ?? "")}));
}, },
getMember(){
return (memberId:number) => {
return this.members.find(m => memberId == m.id)
}
}
},
mounted() {
this.fetchMembers(0, 1000, "", true);
// this.fetchProtocolPresence();
}, },
mounted() {},
methods: { methods: {
...mapActions(useMemberStore, ["fetchMembers"]),
...mapActions(useProtocolPresenceStore, ["fetchProtocolPresence"]), ...mapActions(useProtocolPresenceStore, ["fetchProtocolPresence"]),
removeSelected(id: number) { removeSelected(id: number) {
let index = this.presence.findIndex((s) => s.memberId == id); let index = this.presence.findIndex((s) => s.memberId == id);

View file

@ -58,8 +58,10 @@ import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue"; import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue"; import FailureXMark from "@/components/FailureXMark.vue";
import { RouterLink } from "vue-router"; import { RouterLink } from "vue-router";
import type { CalendarTypeViewModel, UpdateCalendarTypeViewModel } from "@/viewmodels/admin/settings/calendarType.models"; import type {
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid"; CalendarTypeViewModel,
UpdateCalendarTypeViewModel,
} from "@/viewmodels/admin/settings/calendarType.models";
import cloneDeep from "lodash.clonedeep"; import cloneDeep from "lodash.clonedeep";
import isEqual from "lodash.isequal"; import isEqual from "lodash.isequal";
</script> </script>
@ -115,7 +117,7 @@ export default defineComponent({
type: formData.type.value, type: formData.type.value,
color: formData.color.value, color: formData.color.value,
nscdr: formData.nscdr.checked, nscdr: formData.nscdr.checked,
passphrase: formData.passphrase.value, passphrase: formData.passphrase?.value,
}; };
this.status = "loading"; this.status = "loading";
this.updateActiveCalendarType(updateCalendarType) this.updateActiveCalendarType(updateCalendarType)

View file

@ -2,7 +2,7 @@
<div class="grow flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8"> <div class="grow flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8 pb-20"> <div class="max-w-md w-full space-y-8 pb-20">
<div class="flex flex-col items-center gap-4"> <div class="flex flex-col items-center gap-4">
<img src="/Logo.png" alt="LOGO" class="h-36" /> <img src="/Logo.png" alt="LOGO" class="h-auto w-full" />
<h2 class="text-center text-4xl font-extrabold text-gray-900">Einrichtung</h2> <h2 class="text-center text-4xl font-extrabold text-gray-900">Einrichtung</h2>
</div> </div>

View file

@ -73,6 +73,7 @@ export default defineComponent({
weekText: "KW", weekText: "KW",
allDaySlot: false, allDaySlot: false,
events: this.formattedItems, events: this.formattedItems,
eventClick: this.eventClick,
}; };
}, },
}, },
@ -92,6 +93,12 @@ export default defineComponent({
openLinkModal(e: any) { openLinkModal(e: any) {
this.openModal(markRaw(defineAsyncComponent(() => import("@/components/public/calendar/CalendarLinkModal.vue")))); this.openModal(markRaw(defineAsyncComponent(() => import("@/components/public/calendar/CalendarLinkModal.vue"))));
}, },
eventClick(e: any) {
this.openModal(
markRaw(defineAsyncComponent(() => import("@/components/public/calendar/ShowCalendarEntryModal.vue"))),
this.calendars.find((c) => c.id == e.event.id)
);
},
}, },
}); });
</script> </script>

View file

@ -2,7 +2,7 @@
<div class="grow flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8"> <div class="grow flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8 pb-20"> <div class="max-w-md w-full space-y-8 pb-20">
<div class="flex flex-col items-center gap-4"> <div class="flex flex-col items-center gap-4">
<img src="/Logo.png" alt="LOGO" class="h-36" /> <img src="/Logo.png" alt="LOGO" class="h-auto w-full" />
<h2 class="text-center text-4xl font-extrabold text-gray-900">TOTP zurücksetzen</h2> <h2 class="text-center text-4xl font-extrabold text-gray-900">TOTP zurücksetzen</h2>
</div> </div>

View file

@ -2,7 +2,7 @@
<div class="grow flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8"> <div class="grow flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8 pb-20"> <div class="max-w-md w-full space-y-8 pb-20">
<div class="flex flex-col items-center gap-4"> <div class="flex flex-col items-center gap-4">
<img src="/Logo.png" alt="LOGO" class="h-36" /> <img src="/Logo.png" alt="LOGO" class="h-auto w-full" />
<h2 class="text-center text-4xl font-extrabold text-gray-900">TOTP zurücksetzen</h2> <h2 class="text-center text-4xl font-extrabold text-gray-900">TOTP zurücksetzen</h2>
</div> </div>

View file

@ -2,7 +2,7 @@
<div class="grow flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8"> <div class="grow flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8 pb-20"> <div class="max-w-md w-full space-y-8 pb-20">
<div class="flex flex-col items-center gap-4"> <div class="flex flex-col items-center gap-4">
<img src="/Logo.png" alt="LOGO" class="h-36" /> <img src="/Logo.png" alt="LOGO" class="h-auto w-full" />
<h2 class="text-center text-4xl font-extrabold text-gray-900">Einrichtung</h2> <h2 class="text-center text-4xl font-extrabold text-gray-900">Einrichtung</h2>
</div> </div>

View file

@ -2,7 +2,7 @@
<div class="grow flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8"> <div class="grow flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8 pb-20"> <div class="max-w-md w-full space-y-8 pb-20">
<div class="flex flex-col items-center gap-4"> <div class="flex flex-col items-center gap-4">
<img src="/Logo.png" alt="LOGO" class="h-36" /> <img src="/Logo.png" alt="LOGO" class="h-auto w-full" />
<h2 class="text-center text-4xl font-extrabold text-gray-900">Einrichtung</h2> <h2 class="text-center text-4xl font-extrabold text-gray-900">Einrichtung</h2>
</div> </div>