Compare commits
34 commits
Author | SHA1 | Date | |
---|---|---|---|
98141d0d58 | |||
4be998074b | |||
97331c9b73 | |||
23ca4bcb29 | |||
20190bdb49 | |||
0492100ef7 | |||
e4c6ae836d | |||
033504b8d8 | |||
4f13b70ac8 | |||
7ded4a21bb | |||
ee42625d66 | |||
f715a4ab9d | |||
ab3e2b9dc4 | |||
924a6bf647 | |||
6e82675557 | |||
1b531b1152 | |||
131b3747de | |||
883559d8a5 | |||
626a355c5c | |||
4ecb39ceff | |||
45ad07a906 | |||
5bcb76a60e | |||
c40b53b200 | |||
c9c6df20e0 | |||
05e464e825 | |||
4dc183f52b | |||
363b5bb541 | |||
c2b495f8a7 | |||
8a85cc054d | |||
afa834739c | |||
8d2e0deee6 | |||
9e50d95d7b | |||
020c7c6cb9 | |||
065b0aa6d5 |
|
@ -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.
|
||||||
|
|
|
@ -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
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "ff-admin",
|
"name": "ff-admin",
|
||||||
"version": "1.0.2",
|
"version": "1.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ff-admin",
|
"name": "ff-admin",
|
||||||
"version": "1.0.2",
|
"version": "1.2.0",
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fullcalendar/core": "^6.1.15",
|
"@fullcalendar/core": "^6.1.15",
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "ff-admin",
|
"name": "ff-admin",
|
||||||
"version": "1.0.2",
|
"version": "1.2.0",
|
||||||
"description": "Feuerwehr/Verein Mitgliederverwaltung UI",
|
"description": "Feuerwehr/Verein Mitgliederverwaltung UI",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
BIN
public/Logo.png
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 34 KiB |
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 9.4 KiB |
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 29 KiB |
Before Width: | Height: | Size: 516 KiB |
182
src/components/admin/MemberSearchSelect.vue
Normal 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>
|
|
@ -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">
|
||||||
|
|
|
@ -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" />
|
||||||
|
|
|
@ -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)
|
||||||
|
|
81
src/components/admin/user/webapi/CreateWebapiModal.vue
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
<template>
|
||||||
|
<div class="w-full md:max-w-md">
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<p class="text-xl font-medium">Webapi-Token erstellen</p>
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
<form class="flex flex-col gap-4 py-2" @submit.prevent="triggerCreateWebapi">
|
||||||
|
<div>
|
||||||
|
<label for="title">Bezeichnung</label>
|
||||||
|
<input type="text" id="title" required />
|
||||||
|
</div>
|
||||||
|
<div class="w-full">
|
||||||
|
<label for="expiry">Ablaufdatum (optional)</label>
|
||||||
|
<input type="date" id="expiry" step="1" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row gap-2">
|
||||||
|
<button primary type="submit" :disabled="status == 'loading' || status?.status == 'success'">erstellen</button>
|
||||||
|
<Spinner v-if="status == 'loading'" class="my-auto" />
|
||||||
|
<SuccessCheckmark v-else-if="status?.status == 'success'" />
|
||||||
|
<FailureXMark v-else-if="status?.status == 'failed'" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="flex flex-row justify-end">
|
||||||
|
<div class="flex flex-row gap-4 py-2">
|
||||||
|
<button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
|
||||||
|
abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { defineComponent } from "vue";
|
||||||
|
import { mapState, mapActions } from "pinia";
|
||||||
|
import { useModalStore } from "@/stores/modal";
|
||||||
|
import Spinner from "@/components/Spinner.vue";
|
||||||
|
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
|
||||||
|
import FailureXMark from "@/components/FailureXMark.vue";
|
||||||
|
import { useWebapiStore } from "@/stores/admin/user/webapi";
|
||||||
|
import type { CreateWebapiViewModel } from "../../../../viewmodels/admin/user/webapi.models";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export default defineComponent({
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
status: null as null | "loading" | { status: "success" | "failed"; reason?: string },
|
||||||
|
timeout: undefined as any,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
try {
|
||||||
|
clearTimeout(this.timeout);
|
||||||
|
} catch (error) {}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapActions(useModalStore, ["closeModal"]),
|
||||||
|
...mapActions(useWebapiStore, ["createWebapi"]),
|
||||||
|
triggerCreateWebapi(e: any) {
|
||||||
|
let formData = e.target.elements;
|
||||||
|
let createWebapi: CreateWebapiViewModel = {
|
||||||
|
title: formData.title.value,
|
||||||
|
expiry: formData.expiry.value,
|
||||||
|
};
|
||||||
|
this.status = "loading";
|
||||||
|
this.createWebapi(createWebapi)
|
||||||
|
.then(() => {
|
||||||
|
this.status = { status: "success" };
|
||||||
|
this.timeout = setTimeout(() => {
|
||||||
|
this.closeModal();
|
||||||
|
}, 1500);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
this.status = { status: "failed" };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
75
src/components/admin/user/webapi/DeleteWebapiModal.vue
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
<template>
|
||||||
|
<div class="w-full md:max-w-md">
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<p class="text-xl font-medium">Webapi-Token {{ webapi?.title }} löschen?</p>
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<div class="flex flex-row gap-2">
|
||||||
|
<button primary :disabled="status == 'loading' || status?.status == 'success'" @click="triggerDeleteWebapi">
|
||||||
|
unwiederuflich löschen
|
||||||
|
</button>
|
||||||
|
<Spinner v-if="status == 'loading'" class="my-auto" />
|
||||||
|
<SuccessCheckmark v-else-if="status?.status == 'success'" />
|
||||||
|
<FailureXMark v-else-if="status?.status == 'failed'" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-row justify-end">
|
||||||
|
<div class="flex flex-row gap-4 py-2">
|
||||||
|
<button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
|
||||||
|
abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { defineComponent } from "vue";
|
||||||
|
import { mapState, mapActions } from "pinia";
|
||||||
|
import { useModalStore } from "@/stores/modal";
|
||||||
|
import Spinner from "@/components/Spinner.vue";
|
||||||
|
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
|
||||||
|
import FailureXMark from "@/components/FailureXMark.vue";
|
||||||
|
import { useWebapiStore } from "@/stores/admin/user/webapi";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export default defineComponent({
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
status: null as null | "loading" | { status: "success" | "failed"; reason?: string },
|
||||||
|
timeout: undefined as any,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
try {
|
||||||
|
clearTimeout(this.timeout);
|
||||||
|
} catch (error) {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapState(useModalStore, ["data"]),
|
||||||
|
...mapState(useWebapiStore, ["webapis"]),
|
||||||
|
webapi() {
|
||||||
|
return this.webapis.find((r) => r.id == this.data);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapActions(useModalStore, ["closeModal"]),
|
||||||
|
...mapActions(useWebapiStore, ["deleteWebapi"]),
|
||||||
|
triggerDeleteWebapi() {
|
||||||
|
this.status = "loading";
|
||||||
|
this.deleteWebapi(this.data)
|
||||||
|
.then(() => {
|
||||||
|
this.status = { status: "success" };
|
||||||
|
this.timeout = setTimeout(() => {
|
||||||
|
this.closeModal();
|
||||||
|
}, 1500);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
this.status = { status: "failed" };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
107
src/components/admin/user/webapi/WebapiListItem.vue
Normal file
|
@ -0,0 +1,107 @@
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-fit w-full border border-primary rounded-md">
|
||||||
|
<div class="bg-primary p-2 text-white flex flex-row justify-between items-center">
|
||||||
|
<p>{{ webapi.title }} <small v-if="webapi.permissions.admin">(Admin)</small></p>
|
||||||
|
<div class="flex flex-row">
|
||||||
|
<div v-if="can('admin', 'user', 'webapi')" @click="openTokenViewModal">
|
||||||
|
<FingerPrintIcon class="w-5 h-5 p-1 box-content cursor-pointer" />
|
||||||
|
</div>
|
||||||
|
<RouterLink
|
||||||
|
v-if="can('admin', 'user', 'webapi')"
|
||||||
|
:to="{ name: 'admin-user-webapi-permission', params: { id: webapi.id } }"
|
||||||
|
>
|
||||||
|
<WrenchScrewdriverIcon class="w-5 h-5 p-1 box-content cursor-pointer" />
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink
|
||||||
|
v-if="can('update', 'user', 'webapi')"
|
||||||
|
:to="{ name: 'admin-user-webapi-edit', params: { id: webapi.id } }"
|
||||||
|
>
|
||||||
|
<PencilIcon class="w-5 h-5 p-1 box-content cursor-pointer" />
|
||||||
|
</RouterLink>
|
||||||
|
<div v-if="can('delete', 'user', 'webapi')" @click="openDeleteModal">
|
||||||
|
<TrashIcon class="w-5 h-5 p-1 box-content cursor-pointer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col p-2">
|
||||||
|
<div class="flex flex-row gap-2">
|
||||||
|
<p class="">erstellt:</p>
|
||||||
|
<p class="grow overflow-hidden">
|
||||||
|
{{
|
||||||
|
new Date(webapi.createdAt).toLocaleDateString("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row gap-2">
|
||||||
|
<p class="">letzte Verwendung:</p>
|
||||||
|
<p class="grow overflow-hidden">
|
||||||
|
{{
|
||||||
|
webapi.lastUsage
|
||||||
|
? new Date(webapi.lastUsage).toLocaleDateString("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
})
|
||||||
|
: "---"
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="webapi.expiry" class="flex flex-row gap-2">
|
||||||
|
<p class="">verwendbar bis:</p>
|
||||||
|
<p class="grow overflow-hidden">
|
||||||
|
{{
|
||||||
|
new Date(webapi.expiry).toLocaleDateString("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { defineComponent, defineAsyncComponent, markRaw, type PropType } from "vue";
|
||||||
|
import { mapState, mapActions } from "pinia";
|
||||||
|
import { PencilIcon, WrenchScrewdriverIcon, TrashIcon, FingerPrintIcon } from "@heroicons/vue/24/outline";
|
||||||
|
import type { WebapiViewModel } from "@/viewmodels/admin/user/webapi.models";
|
||||||
|
import { RouterLink } from "vue-router";
|
||||||
|
import { useAbilityStore } from "@/stores/ability";
|
||||||
|
import { useModalStore } from "@/stores/modal";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
webapi: { type: Object as PropType<WebapiViewModel>, default: {} },
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapState(useAbilityStore, ["can"]),
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapActions(useModalStore, ["openModal"]),
|
||||||
|
openTokenViewModal() {
|
||||||
|
this.openModal(
|
||||||
|
markRaw(defineAsyncComponent(() => import("@/components/admin/user/webapi/WebapiTokenModal.vue"))),
|
||||||
|
this.webapi.id
|
||||||
|
);
|
||||||
|
},
|
||||||
|
openDeleteModal() {
|
||||||
|
this.openModal(
|
||||||
|
markRaw(defineAsyncComponent(() => import("@/components/admin/user/webapi/DeleteWebapiModal.vue"))),
|
||||||
|
this.webapi.id
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
56
src/components/admin/user/webapi/WebapiTokenModal.vue
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
<template>
|
||||||
|
<div class="relative w-full md:max-w-md">
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<p class="text-xl font-medium">Webapi-Token</p>
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<TextCopy :copyText="token" />
|
||||||
|
</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 { defineComponent } from "vue";
|
||||||
|
import { mapState, mapActions } from "pinia";
|
||||||
|
import { RouterLink } from "vue-router";
|
||||||
|
import { useModalStore } from "@/stores/modal";
|
||||||
|
import { useCalendarTypeStore } from "@/stores/admin/settings/calendarType";
|
||||||
|
import type { CalendarTypeViewModel } from "@/viewmodels/admin/settings/calendarType.models";
|
||||||
|
import { Listbox, ListboxButton, ListboxOptions, ListboxOption, ListboxLabel } from "@headlessui/vue";
|
||||||
|
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
|
||||||
|
import TextCopy from "@/components/TextCopy.vue";
|
||||||
|
import { CalendarDaysIcon, InformationCircleIcon } from "@heroicons/vue/24/outline";
|
||||||
|
import { host } from "@/serverCom";
|
||||||
|
import { useWebapiStore } from "../../../../stores/admin/user/webapi";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export default defineComponent({
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
token: "" as string,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapState(useModalStore, ["data"]),
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.fetchWebapiTokenById(this.data)
|
||||||
|
.then((res) => {
|
||||||
|
this.token = res.data;
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapActions(useModalStore, ["closeModal"]),
|
||||||
|
...mapActions(useWebapiStore, ["fetchWebapiTokenById"]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
97
src/components/public/calendar/ShowCalendarEntryModal.vue
Normal 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>
|
|
@ -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: {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -582,6 +582,36 @@ const router = createRouter({
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "webapi",
|
||||||
|
name: "admin-user-webapi-route",
|
||||||
|
component: () => import("@/views/RouterView.vue"),
|
||||||
|
meta: { type: "read", section: "user", module: "webapi" },
|
||||||
|
beforeEnter: [abilityAndNavUpdate],
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "",
|
||||||
|
name: "admin-user-webapi",
|
||||||
|
component: () => import("@/views/admin/user/webapi/Webapi.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ":id/edit",
|
||||||
|
name: "admin-user-webapi-edit",
|
||||||
|
component: () => import("@/views/admin/user/webapi/WebapiEdit.vue"),
|
||||||
|
meta: { type: "update", section: "user", module: "webapi" },
|
||||||
|
beforeEnter: [abilityAndNavUpdate],
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ":id/permission",
|
||||||
|
name: "admin-user-webapi-permission",
|
||||||
|
component: () => import("@/views/admin/user/webapi/WebapiEditPermission.vue"),
|
||||||
|
meta: { type: "update", section: "user", module: "webapi" },
|
||||||
|
beforeEnter: [abilityAndNavUpdate],
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -622,6 +652,11 @@ const router = createRouter({
|
||||||
name: "account-administration",
|
name: "account-administration",
|
||||||
component: () => import("@/views/account/Administration.vue"),
|
component: () => import("@/views/account/Administration.vue"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "version",
|
||||||
|
name: "account-version",
|
||||||
|
component: () => import("@/views/account/VersionDisplay.vue"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: ":pathMatch(.*)*",
|
path: ":pathMatch(.*)*",
|
||||||
name: "account-404",
|
name: "account-404",
|
||||||
|
|
|
@ -1,5 +1,9 @@
|
||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import type { CreateMemberViewModel, UpdateMemberViewModel } from "@/viewmodels/admin/club/member/member.models";
|
import type {
|
||||||
|
CreateMemberViewModel,
|
||||||
|
MemberStatisticsViewModel,
|
||||||
|
UpdateMemberViewModel,
|
||||||
|
} from "@/viewmodels/admin/club/member/member.models";
|
||||||
import { http } from "@/serverCom";
|
import { http } from "@/serverCom";
|
||||||
import type { AxiosResponse } from "axios";
|
import type { AxiosResponse } from "axios";
|
||||||
import type { MemberViewModel } from "@/viewmodels/admin/club/member/member.models";
|
import type { MemberViewModel } from "@/viewmodels/admin/club/member/member.models";
|
||||||
|
@ -12,6 +16,7 @@ export const useMemberStore = defineStore("member", {
|
||||||
loading: "loading" as "loading" | "fetched" | "failed",
|
loading: "loading" as "loading" | "fetched" | "failed",
|
||||||
activeMember: null as number | null,
|
activeMember: null as number | null,
|
||||||
activeMemberObj: null as MemberViewModel | null,
|
activeMemberObj: null as MemberViewModel | null,
|
||||||
|
activeMemberStatistics: null as MemberStatisticsViewModel | null,
|
||||||
loadingActive: "loading" as "loading" | "fetched" | "failed",
|
loadingActive: "loading" as "loading" | "fetched" | "failed",
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
@ -40,6 +45,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
|
||||||
|
@ -55,6 +75,17 @@ export const useMemberStore = defineStore("member", {
|
||||||
fetchMemberById(id: number) {
|
fetchMemberById(id: number) {
|
||||||
return http.get(`/admin/member/${id}`);
|
return http.get(`/admin/member/${id}`);
|
||||||
},
|
},
|
||||||
|
fetchMemberStatisticsByActiveId() {
|
||||||
|
http
|
||||||
|
.get(`/admin/member/${this.activeMember}/statistics`)
|
||||||
|
.then((res) => {
|
||||||
|
this.activeMemberStatistics = res.data;
|
||||||
|
})
|
||||||
|
.catch((err) => {});
|
||||||
|
},
|
||||||
|
fetchMemberStatisticsById(id: number) {
|
||||||
|
return http.get(`/admin/member/${id}/statistics`);
|
||||||
|
},
|
||||||
async createMember(member: CreateMemberViewModel): Promise<AxiosResponse<any, any>> {
|
async createMember(member: CreateMemberViewModel): Promise<AxiosResponse<any, any>> {
|
||||||
const result = await http.post(`/admin/member`, {
|
const result = await http.post(`/admin/member`, {
|
||||||
salutation: member.salutation,
|
salutation: member.salutation,
|
||||||
|
@ -84,10 +115,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",
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -6,6 +6,7 @@ import type { MemberViewModel } from "@/viewmodels/admin/club/member/member.mode
|
||||||
import { useMemberStore } from "./member";
|
import { useMemberStore } from "./member";
|
||||||
import type {
|
import type {
|
||||||
CreateMembershipViewModel,
|
CreateMembershipViewModel,
|
||||||
|
MembershipStatisticsViewModel,
|
||||||
MembershipViewModel,
|
MembershipViewModel,
|
||||||
UpdateMembershipViewModel,
|
UpdateMembershipViewModel,
|
||||||
} from "@/viewmodels/admin/club/member/membership.models";
|
} from "@/viewmodels/admin/club/member/membership.models";
|
||||||
|
@ -14,6 +15,7 @@ export const useMembershipStore = defineStore("membership", {
|
||||||
state: () => {
|
state: () => {
|
||||||
return {
|
return {
|
||||||
memberships: [] as Array<MembershipViewModel>,
|
memberships: [] as Array<MembershipViewModel>,
|
||||||
|
membershipStatistics: [] as Array<MembershipStatisticsViewModel>,
|
||||||
loading: "loading" as "loading" | "fetched" | "failed",
|
loading: "loading" as "loading" | "fetched" | "failed",
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
@ -31,6 +33,15 @@ export const useMembershipStore = defineStore("membership", {
|
||||||
this.loading = "failed";
|
this.loading = "failed";
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
fetchMembershipStatisticsForMember() {
|
||||||
|
const memberId = useMemberStore().activeMember;
|
||||||
|
http
|
||||||
|
.get(`/admin/member/${memberId}/memberships/statistics`)
|
||||||
|
.then((result) => {
|
||||||
|
this.membershipStatistics = result.data;
|
||||||
|
})
|
||||||
|
.catch((err) => {});
|
||||||
|
},
|
||||||
fetchMembershipById(id: number) {
|
fetchMembershipById(id: number) {
|
||||||
const memberId = useMemberStore().activeMember;
|
const memberId = useMemberStore().activeMember;
|
||||||
return http.get(`/admin/member/${memberId}/membership/${id}`);
|
return http.get(`/admin/member/${memberId}/membership/${id}`);
|
||||||
|
|
|
@ -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,
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
@ -131,6 +131,7 @@ export const useNavigationStore = defineStore("navigation", {
|
||||||
main: [
|
main: [
|
||||||
...(abilityStore.can("read", "user", "user") ? [{ key: "user", title: "Benutzer" }] : []),
|
...(abilityStore.can("read", "user", "user") ? [{ key: "user", title: "Benutzer" }] : []),
|
||||||
...(abilityStore.can("read", "user", "role") ? [{ key: "role", title: "Rollen" }] : []),
|
...(abilityStore.can("read", "user", "role") ? [{ key: "role", title: "Rollen" }] : []),
|
||||||
|
...(abilityStore.can("read", "user", "webapi") ? [{ key: "webapi", title: "Webapi-Token" }] : []),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
} as navigationModel;
|
} as navigationModel;
|
||||||
|
|
63
src/stores/admin/user/webapi.ts
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
import { defineStore } from "pinia";
|
||||||
|
import type {
|
||||||
|
CreateWebapiViewModel,
|
||||||
|
UpdateWebapiViewModel,
|
||||||
|
WebapiViewModel,
|
||||||
|
} from "@/viewmodels/admin/user/webapi.models";
|
||||||
|
import { http } from "@/serverCom";
|
||||||
|
import type { PermissionObject } from "@/types/permissionTypes";
|
||||||
|
import type { AxiosResponse } from "axios";
|
||||||
|
|
||||||
|
export const useWebapiStore = defineStore("webapi", {
|
||||||
|
state: () => {
|
||||||
|
return {
|
||||||
|
webapis: [] as Array<WebapiViewModel>,
|
||||||
|
loading: null as null | "loading" | "success" | "failed",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
fetchWebapis() {
|
||||||
|
this.loading = "loading";
|
||||||
|
http
|
||||||
|
.get("/admin/webapi")
|
||||||
|
.then((result) => {
|
||||||
|
this.webapis = result.data;
|
||||||
|
this.loading = "success";
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
this.loading = "failed";
|
||||||
|
});
|
||||||
|
},
|
||||||
|
fetchWebapiById(id: number): Promise<AxiosResponse<any, any>> {
|
||||||
|
return http.get(`/admin/webapi/${id}`);
|
||||||
|
},
|
||||||
|
fetchWebapiTokenById(id: number): Promise<AxiosResponse<any, any>> {
|
||||||
|
return http.get(`/admin/webapi/${id}/token`);
|
||||||
|
},
|
||||||
|
async createWebapi(webapi: CreateWebapiViewModel): Promise<AxiosResponse<any, any>> {
|
||||||
|
const result = await http.post("/admin/webapi", webapi);
|
||||||
|
this.fetchWebapis();
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async updateActiveWebapi(id: number, webapi: UpdateWebapiViewModel): Promise<AxiosResponse<any, any>> {
|
||||||
|
const result = await http.patch(`/admin/webapi/${id}`, webapi);
|
||||||
|
this.fetchWebapis();
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async updateActiveWebapiPermissions(
|
||||||
|
webapi: number,
|
||||||
|
permission: PermissionObject
|
||||||
|
): Promise<AxiosResponse<any, any>> {
|
||||||
|
const result = await http.patch(`/admin/webapi/${webapi}/permissions`, {
|
||||||
|
permissions: permission,
|
||||||
|
});
|
||||||
|
this.fetchWebapis();
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async deleteWebapi(webapi: number): Promise<AxiosResponse<any, any>> {
|
||||||
|
const result = await http.delete(`/admin/webapi/${webapi}`);
|
||||||
|
this.fetchWebapis();
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
|
@ -14,6 +14,7 @@ export type PermissionModule =
|
||||||
| "calendar_type"
|
| "calendar_type"
|
||||||
| "user"
|
| "user"
|
||||||
| "role"
|
| "role"
|
||||||
|
| "webapi"
|
||||||
| "query"
|
| "query"
|
||||||
| "query_store"
|
| "query_store"
|
||||||
| "template"
|
| "template"
|
||||||
|
@ -55,6 +56,7 @@ export const permissionModules: Array<PermissionModule> = [
|
||||||
"calendar_type",
|
"calendar_type",
|
||||||
"user",
|
"user",
|
||||||
"role",
|
"role",
|
||||||
|
"webapi",
|
||||||
"query",
|
"query",
|
||||||
"query_store",
|
"query_store",
|
||||||
"template",
|
"template",
|
||||||
|
@ -75,5 +77,5 @@ export const sectionsAndModules: SectionsAndModulesObject = {
|
||||||
"template_usage",
|
"template_usage",
|
||||||
"newsletter_config",
|
"newsletter_config",
|
||||||
],
|
],
|
||||||
user: ["user", "role"],
|
user: ["user", "role", "webapi"],
|
||||||
};
|
};
|
||||||
|
|
|
@ -17,6 +17,18 @@ export interface MemberViewModel {
|
||||||
preferredCommunication?: Array<CommunicationViewModel>;
|
preferredCommunication?: Array<CommunicationViewModel>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MemberStatisticsViewModel {
|
||||||
|
id: number;
|
||||||
|
salutation: Salutation;
|
||||||
|
firstname: string;
|
||||||
|
lastname: string;
|
||||||
|
nameaffix: string;
|
||||||
|
birthdate: Date;
|
||||||
|
todayAge: number;
|
||||||
|
ageThisYear: number;
|
||||||
|
exactAge: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateMemberViewModel {
|
export interface CreateMemberViewModel {
|
||||||
salutation: Salutation;
|
salutation: Salutation;
|
||||||
firstname: string;
|
firstname: string;
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import type { Salutation } from "../../../../enums/salutation";
|
||||||
|
|
||||||
export interface MembershipViewModel {
|
export interface MembershipViewModel {
|
||||||
id: number;
|
id: number;
|
||||||
start: Date;
|
start: Date;
|
||||||
|
@ -7,6 +9,19 @@ export interface MembershipViewModel {
|
||||||
statusId: number;
|
statusId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MembershipStatisticsViewModel {
|
||||||
|
durationInDays: number;
|
||||||
|
durationInYears: string;
|
||||||
|
status: string;
|
||||||
|
statusId: number;
|
||||||
|
memberId: number;
|
||||||
|
memberSalutation: Salutation;
|
||||||
|
memberFirstname: string;
|
||||||
|
memberLastname: string;
|
||||||
|
memberNameaffix: string;
|
||||||
|
memberBirthdate: Date;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateMembershipViewModel {
|
export interface CreateMembershipViewModel {
|
||||||
start: Date;
|
start: Date;
|
||||||
statusId: number;
|
statusId: number;
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
export interface ProtocolPresenceViewModel {
|
export interface ProtocolPresenceViewModel {
|
||||||
memberId: number;
|
memberId: number;
|
||||||
absent: boolean;
|
absent: boolean;
|
||||||
|
excused: boolean;
|
||||||
protocolId: number;
|
protocolId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
20
src/viewmodels/admin/user/webapi.models.ts
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import type { PermissionObject } from "@/types/permissionTypes";
|
||||||
|
|
||||||
|
export interface WebapiViewModel {
|
||||||
|
id: number;
|
||||||
|
permissions: PermissionObject;
|
||||||
|
title: string;
|
||||||
|
createdAt: Date;
|
||||||
|
lastUsage?: Date;
|
||||||
|
expiry?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateWebapiViewModel {
|
||||||
|
title: string;
|
||||||
|
expiry?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateWebapiViewModel {
|
||||||
|
title: string;
|
||||||
|
expiry?: Date;
|
||||||
|
}
|
21
src/viewmodels/version.models.ts
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
export interface Release {
|
||||||
|
creator: string;
|
||||||
|
title: string;
|
||||||
|
link: string;
|
||||||
|
pubDate: string;
|
||||||
|
author: string;
|
||||||
|
"content:encoded": string;
|
||||||
|
"content:encodedSnippet": string;
|
||||||
|
content: string;
|
||||||
|
contentSnippet: string;
|
||||||
|
guid: string;
|
||||||
|
isoDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Releases {
|
||||||
|
items: Release[];
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
pubDate: string;
|
||||||
|
link: string;
|
||||||
|
}
|
|
@ -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">
|
||||||
|
|
|
@ -15,7 +15,9 @@
|
||||||
<div class="relative mt-1">
|
<div class="relative mt-1">
|
||||||
<ComboboxInput
|
<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"
|
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"
|
||||||
:displayValue="(person) => person.firstname + ' ' + person.lastname"
|
:displayValue="
|
||||||
|
(person) => (person as UserViewModel)?.firstname + ' ' + (person as UserViewModel)?.lastname
|
||||||
|
"
|
||||||
@input="query = $event.target.value"
|
@input="query = $event.target.value"
|
||||||
/>
|
/>
|
||||||
<ComboboxButton class="absolute inset-y-0 right-0 flex items-center pr-2">
|
<ComboboxButton class="absolute inset-y-0 right-0 flex items-center pr-2">
|
||||||
|
|
154
src/views/account/VersionDisplay.vue
Normal file
|
@ -0,0 +1,154 @@
|
||||||
|
<template>
|
||||||
|
<MainTemplate :useStagedOverviewLink="false">
|
||||||
|
<template #topBar>
|
||||||
|
<div class="flex flex-row items-center justify-between pt-5 pb-3 px-7">
|
||||||
|
<h1 class="font-bold text-xl h-8">Versions-Kontrolle</h1>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #diffMain>
|
||||||
|
<div class="h-full flex flex-col px-7 overflow-hidden">
|
||||||
|
<div class="h-1/2 flex flex-col gap-2 p-2 border border-gray-300 rounded-t-md">
|
||||||
|
<div class="flex flex-row justify-between border-b-2 border-gray-300">
|
||||||
|
<h1 class="text-xl font-semibold">Client</h1>
|
||||||
|
<p>V{{ clientVersion }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="grow flex flex-col gap-4 overflow-y-scroll">
|
||||||
|
<div v-for="version in newerClientVersions">
|
||||||
|
<p>
|
||||||
|
<span class="font-semibold text-lg">V{{ version.title }}</span> vom
|
||||||
|
{{
|
||||||
|
new Date(version.isoDate).toLocaleDateString("de", {
|
||||||
|
month: "long",
|
||||||
|
day: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col" v-html="version['content:encoded']"></div>
|
||||||
|
</div>
|
||||||
|
<div v-if="newerClientVersions.length == 0" class="flex items-center justify-center">
|
||||||
|
<p>Der Client ist auf der neuesten Version.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="h-1/2 flex flex-col gap-2 p-2 border border-gray-300 rounded-b-md">
|
||||||
|
<div class="flex flex-row justify-between border-b-2 border-gray-300">
|
||||||
|
<h1 class="text-xl font-semibold">Server</h1>
|
||||||
|
<p>V{{ serverVersion }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="grow flex flex-col gap-2 overflow-y-scroll">
|
||||||
|
<div v-for="version in newerServerVersions">
|
||||||
|
<p>
|
||||||
|
<span class="font-semibold text-lg">V{{ version.title }}</span> vom
|
||||||
|
{{
|
||||||
|
new Date(version.isoDate).toLocaleDateString("de", {
|
||||||
|
month: "long",
|
||||||
|
day: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col" v-html="version['content:encoded']"></div>
|
||||||
|
</div>
|
||||||
|
<div v-if="newerServerVersions.length == 0" class="flex items-center justify-center">
|
||||||
|
<p>Der Server ist auf der neuesten Version.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</MainTemplate>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { defineComponent } from "vue";
|
||||||
|
import MainTemplate from "@/templates/Main.vue";
|
||||||
|
import clientPackage from "../../../package.json";
|
||||||
|
import type { Releases } from "../../viewmodels/version.models";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export default defineComponent({
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
serverVersion: "" as string,
|
||||||
|
serverRss: null as null | Releases,
|
||||||
|
clientVersion: "" as string,
|
||||||
|
clientRss: null as null | Releases,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
newerServerVersions() {
|
||||||
|
if (!this.serverRss) return [];
|
||||||
|
return this.serverRss.items.filter((i) => this.compareVersionStrings(this.serverVersion, i.title) < 0);
|
||||||
|
},
|
||||||
|
newerClientVersions() {
|
||||||
|
if (!this.clientRss) return [];
|
||||||
|
return this.clientRss.items.filter((i) => this.compareVersionStrings(this.clientVersion, i.title) < 0);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.clientVersion = clientPackage.version;
|
||||||
|
this.getServerVersion();
|
||||||
|
this.getServerFeed();
|
||||||
|
this.getClientFeed();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getServerVersion() {
|
||||||
|
this.$http
|
||||||
|
.get("/server/version")
|
||||||
|
.then((res) => {
|
||||||
|
this.serverVersion = res.data.version;
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
},
|
||||||
|
async getServerFeed() {
|
||||||
|
this.$http
|
||||||
|
.get("/server/serverrss")
|
||||||
|
.then((res) => {
|
||||||
|
this.serverRss = res.data;
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
},
|
||||||
|
async getClientFeed() {
|
||||||
|
this.$http
|
||||||
|
.get("/server/clientrss")
|
||||||
|
.then((res) => {
|
||||||
|
this.clientRss = res.data;
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
},
|
||||||
|
compareVersionStrings(activeVersion: string, compareVersion: string) {
|
||||||
|
const parseVersion = (version: string) => {
|
||||||
|
const [main, tag] = version.split("-");
|
||||||
|
const [major, minor, patch] = main.split(".").map(Number);
|
||||||
|
return { major, minor, patch, tag };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!activeVersion || !compareVersion) return 0;
|
||||||
|
|
||||||
|
const versionA = parseVersion(activeVersion);
|
||||||
|
const versionB = parseVersion(compareVersion);
|
||||||
|
|
||||||
|
if (versionA.major !== versionB.major) {
|
||||||
|
return versionA.major - versionB.major;
|
||||||
|
}
|
||||||
|
if (versionA.minor !== versionB.minor) {
|
||||||
|
return versionA.minor - versionB.minor;
|
||||||
|
}
|
||||||
|
if (versionA.patch !== versionB.patch) {
|
||||||
|
return versionA.patch - versionB.patch;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (versionA.tag && !versionB.tag) return -1;
|
||||||
|
if (!versionA.tag && versionB.tag) return 1;
|
||||||
|
if (versionA.tag && versionB.tag) {
|
||||||
|
const tags = ["alpha", "beta", ""];
|
||||||
|
return tags.indexOf(versionA.tag) - tags.indexOf(versionB.tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
|
@ -1,13 +1,22 @@
|
||||||
<template>
|
<template>
|
||||||
<SidebarLayout>
|
<SidebarLayout>
|
||||||
<template #sidebar>
|
<template #sidebar>
|
||||||
<SidebarTemplate mainTitle="Mein Account" :topTitle="config.app_name_overwrite || 'FF Admin'" :showTopList="isOwner">
|
<SidebarTemplate
|
||||||
|
mainTitle="Mein Account"
|
||||||
|
:topTitle="config.app_name_overwrite || 'FF Admin'"
|
||||||
|
:showTopList="isOwner"
|
||||||
|
>
|
||||||
<template v-if="isOwner" #topList>
|
<template v-if="isOwner" #topList>
|
||||||
<RoutingLink
|
<RoutingLink
|
||||||
title="Administration"
|
title="Administration"
|
||||||
:link="{ name: 'account-administration' }"
|
:link="{ name: 'account-administration' }"
|
||||||
:active="activeRouteName == 'account-administration'"
|
:active="activeRouteName == 'account-administration'"
|
||||||
/>
|
/>
|
||||||
|
<RoutingLink
|
||||||
|
title="Versions-Verwaltung"
|
||||||
|
:link="{ name: 'account-version' }"
|
||||||
|
:active="activeRouteName == 'account-version'"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #list>
|
<template #list>
|
||||||
<RoutingLink title="Mein Account" :link="{ name: 'account-me' }" :active="activeRouteName == 'account-me'" />
|
<RoutingLink title="Mein Account" :link="{ name: 'account-me' }" :active="activeRouteName == 'account-me'" />
|
||||||
|
@ -38,7 +47,7 @@ import SidebarTemplate from "@/templates/Sidebar.vue";
|
||||||
import RoutingLink from "@/components/admin/RoutingLink.vue";
|
import RoutingLink from "@/components/admin/RoutingLink.vue";
|
||||||
import { RouterView } from "vue-router";
|
import { RouterView } from "vue-router";
|
||||||
import { useAbilityStore } from "@/stores/ability";
|
import { useAbilityStore } from "@/stores/ability";
|
||||||
import { config } from "@/config"
|
import { config } from "@/config";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
|
|
@ -25,6 +25,20 @@
|
||||||
<label for="birthdate">Geburtsdatum</label>
|
<label for="birthdate">Geburtsdatum</label>
|
||||||
<input type="date" id="birthdate" :value="activeMemberObj.birthdate" readonly />
|
<input type="date" id="birthdate" :value="activeMemberObj.birthdate" readonly />
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="membershipStatistics.length != 0">
|
||||||
|
<p>Statistiken zur Mitgliedschaft</p>
|
||||||
|
<div class="flex flex-col h-fit w-full border border-primary rounded-md">
|
||||||
|
<div
|
||||||
|
v-for="stat in membershipStatistics"
|
||||||
|
class="bg-primary p-2 text-white flex flex-row justify-between items-center"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
{{ stat.status }} für gesamt {{ stat.durationInDays }} Tage
|
||||||
|
<span class="whitespace-nowrap"> ~> {{ stat.durationInYears.replace("_", "") }} Jahre</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div v-if="activeMemberObj.firstMembershipEntry">
|
<div v-if="activeMemberObj.firstMembershipEntry">
|
||||||
<p>Erster Eintrag Mitgliedschaft</p>
|
<p>Erster Eintrag Mitgliedschaft</p>
|
||||||
<div class="flex flex-col h-fit w-full border border-primary rounded-md">
|
<div class="flex flex-col h-fit w-full border border-primary rounded-md">
|
||||||
|
@ -125,6 +139,7 @@ import { defineComponent } from "vue";
|
||||||
import { mapActions, mapState } from "pinia";
|
import { mapActions, mapState } from "pinia";
|
||||||
import Spinner from "@/components/Spinner.vue";
|
import Spinner from "@/components/Spinner.vue";
|
||||||
import { useMemberStore } from "@/stores/admin/club/member/member";
|
import { useMemberStore } from "@/stores/admin/club/member/member";
|
||||||
|
import { useMembershipStore } from "@/stores/admin/club/member/membership";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
@ -133,13 +148,17 @@ export default defineComponent({
|
||||||
memberId: String,
|
memberId: String,
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapState(useMemberStore, ["activeMemberObj", "loadingActive"]),
|
...mapState(useMemberStore, ["activeMemberObj", "activeMemberStatistics", "loadingActive"]),
|
||||||
|
...mapState(useMembershipStore, ["membershipStatistics"]),
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.fetchMemberByActiveId();
|
this.fetchMemberByActiveId();
|
||||||
|
this.fetchMemberStatisticsByActiveId();
|
||||||
|
this.fetchMembershipStatisticsForMember();
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(useMemberStore, ["fetchMemberByActiveId"]),
|
...mapActions(useMemberStore, ["fetchMemberByActiveId", "fetchMemberStatisticsByActiveId"]),
|
||||||
|
...mapActions(useMembershipStore, ["fetchMembershipStatisticsForMember"]),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -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,27 +90,19 @@ 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> {
|
selected(): Array<MemberViewModel> {
|
||||||
return this.query === ""
|
return this.members
|
||||||
? this.members
|
.filter((m) => this.recipients.includes(m.id))
|
||||||
: this.members.filter((member) =>
|
.sort((a, b) => {
|
||||||
(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.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;
|
||||||
|
@ -168,9 +110,6 @@ export default defineComponent({
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
selected(): Array<MemberViewModel> {
|
|
||||||
return this.members.filter((m) => this.recipients.includes(m.id));
|
|
||||||
},
|
|
||||||
queried(): Array<MemberViewModel> {
|
queried(): Array<MemberViewModel> {
|
||||||
if (this.recipientsByQueryId == "def") return [];
|
if (this.recipientsByQueryId == "def") return [];
|
||||||
let keys = Object.keys(this.data?.[0] ?? {});
|
let keys = Object.keys(this.data?.[0] ?? {});
|
||||||
|
@ -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);
|
||||||
|
|
|
@ -5,63 +5,19 @@
|
||||||
↺ laden fehlgeschlagen
|
↺ 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 ?? '') })
|
||||||
|
"
|
||||||
|
@add:member="(s) => members.push(s)"
|
||||||
|
@add:member-by-array="(s) => members.push(...s)"
|
||||||
|
@remove:difference="removeSelected"
|
||||||
/>
|
/>
|
||||||
<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.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,11 +27,20 @@
|
||||||
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>
|
||||||
|
{{ getMember(member.memberId)?.lastname }}, {{ getMember(member.memberId)?.firstname }}
|
||||||
|
{{ getMember(member.memberId)?.nameaffix ? `- ${getMember(member.memberId)?.nameaffix}` : "" }}
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-row gap-4">
|
||||||
<label class="flex flex-row gap-2 items-center">
|
<label class="flex flex-row gap-2 items-center">
|
||||||
<input type="checkbox" v-model="member.absent" />
|
<input type="checkbox" v-model="member.absent" />
|
||||||
war abwesend
|
war abwesend
|
||||||
</label>
|
</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>
|
</div>
|
||||||
<TrashIcon
|
<TrashIcon
|
||||||
v-if="can('create', 'club', 'protocol')"
|
v-if="can('create', 'club', 'protocol')"
|
||||||
|
@ -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);
|
||||||
|
|
|
@ -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)
|
||||||
|
|
52
src/views/admin/user/webapi/Webapi.vue
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
<template>
|
||||||
|
<MainTemplate>
|
||||||
|
<template #topBar>
|
||||||
|
<div class="flex flex-row items-center justify-between pt-5 pb-3 px-7">
|
||||||
|
<h1 class="font-bold text-xl h-8">Webapi-Token</h1>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #diffMain>
|
||||||
|
<div class="flex flex-col gap-4 h-full pl-7">
|
||||||
|
<div class="flex flex-col gap-2 grow overflow-y-scroll pr-7">
|
||||||
|
<WebapiListItem v-for="webapi in webapis" :key="webapi.id" :webapi="webapi" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row gap-4">
|
||||||
|
<button v-if="can('create', 'user', 'webapi')" primary class="!w-fit" @click="openCreateModal">
|
||||||
|
Webapi-Token erstellen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</MainTemplate>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { defineAsyncComponent, defineComponent, markRaw } from "vue";
|
||||||
|
import { mapState, mapActions } from "pinia";
|
||||||
|
import MainTemplate from "@/templates/Main.vue";
|
||||||
|
import { useWebapiStore } from "@/stores/admin/user/webapi";
|
||||||
|
import WebapiListItem from "@/components/admin/user/webapi/WebapiListItem.vue";
|
||||||
|
import { useModalStore } from "@/stores/modal";
|
||||||
|
import { useAbilityStore } from "@/stores/ability";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export default defineComponent({
|
||||||
|
computed: {
|
||||||
|
...mapState(useWebapiStore, ["webapis"]),
|
||||||
|
...mapState(useAbilityStore, ["can"]),
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.fetchWebapis();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapActions(useWebapiStore, ["fetchWebapis"]),
|
||||||
|
...mapActions(useModalStore, ["openModal"]),
|
||||||
|
openCreateModal() {
|
||||||
|
this.openModal(
|
||||||
|
markRaw(defineAsyncComponent(() => import("@/components/admin/user/webapi/CreateWebapiModal.vue")))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
126
src/views/admin/user/webapi/WebapiEdit.vue
Normal file
|
@ -0,0 +1,126 @@
|
||||||
|
<template>
|
||||||
|
<MainTemplate>
|
||||||
|
<template #headerInsert>
|
||||||
|
<RouterLink to="../" class="text-primary">zurück zur Liste (abbrechen)</RouterLink>
|
||||||
|
</template>
|
||||||
|
<template #topBar>
|
||||||
|
<div class="flex flex-row items-center justify-between pt-5 pb-3 px-7">
|
||||||
|
<h1 class="font-bold text-xl h-8">Webapi-Token {{ origin?.title }} - Daten bearbeiten</h1>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #main>
|
||||||
|
<Spinner v-if="loading == 'loading'" class="mx-auto" />
|
||||||
|
<p v-else-if="loading == 'failed'">laden fehlgeschlagen</p>
|
||||||
|
<form
|
||||||
|
v-else-if="webapi"
|
||||||
|
class="flex flex-col gap-4 py-2 w-full max-w-xl mx-auto"
|
||||||
|
@submit.prevent="triggerWebapiUpdate"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label for="title">Bezeichnung</label>
|
||||||
|
<input type="text" id="title" required v-model="webapi.title" />
|
||||||
|
</div>
|
||||||
|
<div class="w-full">
|
||||||
|
<label for="expiry">Ablaufdatum (optional)</label>
|
||||||
|
<input type="date" id="expiry" step="1" v-model="webapi.expiry" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row justify-end gap-2">
|
||||||
|
<button primary-outline type="reset" class="!w-fit" :disabled="canSaveOrReset" @click="resetForm">
|
||||||
|
verwerfen
|
||||||
|
</button>
|
||||||
|
<button primary type="submit" class="!w-fit" :disabled="status == 'loading' || canSaveOrReset">
|
||||||
|
speichern
|
||||||
|
</button>
|
||||||
|
<Spinner v-if="status == 'loading'" class="my-auto" />
|
||||||
|
<SuccessCheckmark v-else-if="status?.status == 'success'" />
|
||||||
|
<FailureXMark v-else-if="status?.status == 'failed'" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
</MainTemplate>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { defineComponent } from "vue";
|
||||||
|
import { mapState, mapActions } from "pinia";
|
||||||
|
import MainTemplate from "@/templates/Main.vue";
|
||||||
|
import { useWebapiStore } from "@/stores/admin/user/webapi";
|
||||||
|
import Spinner from "@/components/Spinner.vue";
|
||||||
|
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
|
||||||
|
import FailureXMark from "@/components/FailureXMark.vue";
|
||||||
|
import { RouterLink } from "vue-router";
|
||||||
|
import cloneDeep from "lodash.clonedeep";
|
||||||
|
import isEqual from "lodash.isequal";
|
||||||
|
import type { UpdateWebapiViewModel, WebapiViewModel } from "@/viewmodels/admin/user/webapi.models";
|
||||||
|
import type { Update } from "vite/types/hmrPayload.js";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
id: String,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: "loading" as "loading" | "fetched" | "failed",
|
||||||
|
status: null as null | "loading" | { status: "success" | "failed"; reason?: string },
|
||||||
|
origin: null as null | WebapiViewModel,
|
||||||
|
webapi: null as null | WebapiViewModel,
|
||||||
|
timeout: null as any,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
canSaveOrReset(): boolean {
|
||||||
|
return isEqual(this.origin, this.webapi);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.fetchItem();
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
try {
|
||||||
|
clearTimeout(this.timeout);
|
||||||
|
} catch (error) {}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapActions(useWebapiStore, ["fetchWebapiById", "updateActiveWebapi"]),
|
||||||
|
resetForm() {
|
||||||
|
this.webapi = cloneDeep(this.origin);
|
||||||
|
},
|
||||||
|
fetchItem() {
|
||||||
|
this.fetchWebapiById(parseInt(this.id ?? ""))
|
||||||
|
.then((result) => {
|
||||||
|
this.webapi = result.data;
|
||||||
|
this.origin = cloneDeep(result.data);
|
||||||
|
this.loading = "fetched";
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
this.loading = "failed";
|
||||||
|
});
|
||||||
|
},
|
||||||
|
triggerWebapiUpdate(e: any) {
|
||||||
|
if (this.webapi == null) return;
|
||||||
|
let formData = e.target.elements;
|
||||||
|
let updateWebapi: UpdateWebapiViewModel = {
|
||||||
|
title: formData.title.value,
|
||||||
|
expiry: formData.expiry.value,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.status = "loading";
|
||||||
|
this.updateActiveWebapi(this.webapi.id, updateWebapi)
|
||||||
|
.then(() => {
|
||||||
|
this.fetchItem();
|
||||||
|
this.status = { status: "success" };
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
this.status = { status: "failed" };
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.timeout = setTimeout(() => {
|
||||||
|
this.status = null;
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
87
src/views/admin/user/webapi/WebapiEditPermission.vue
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
<template>
|
||||||
|
<MainTemplate>
|
||||||
|
<template #headerInsert>
|
||||||
|
<RouterLink to="../" class="text-primary">zurück zur Liste (abbrechen)</RouterLink>
|
||||||
|
</template>
|
||||||
|
<template #topBar>
|
||||||
|
<div class="flex flex-row items-center justify-between pt-5 pb-3 px-7">
|
||||||
|
<h1 class="font-bold text-xl h-8">Webapi-Token {{ webapi?.title }} - Berechtigungen bearbeiten</h1>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #main>
|
||||||
|
<Spinner v-if="loading == 'loading'" class="mx-auto" />
|
||||||
|
<p v-else-if="loading == 'failed'">laden fehlgeschlagen</p>
|
||||||
|
<Permission
|
||||||
|
v-else-if="webapi != null"
|
||||||
|
:permissions="webapi.permissions"
|
||||||
|
:status="status"
|
||||||
|
@savePermissions="triggerUpdate"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MainTemplate>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { defineComponent } from "vue";
|
||||||
|
import { mapState, mapActions } from "pinia";
|
||||||
|
import MainTemplate from "@/templates/Main.vue";
|
||||||
|
import { useWebapiStore } from "@/stores/admin/user/webapi";
|
||||||
|
import Permission from "@/components/admin/Permission.vue";
|
||||||
|
import Spinner from "@/components/Spinner.vue";
|
||||||
|
import type { PermissionObject } from "@/types/permissionTypes";
|
||||||
|
import type { WebapiViewModel } from "@/viewmodels/admin/user/webapi.models";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
id: String,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: "loading" as "loading" | "fetched" | "failed",
|
||||||
|
status: null as null | "loading" | { status: "success" | "failed"; reason?: string },
|
||||||
|
webapi: null as null | WebapiViewModel,
|
||||||
|
timeout: null as any,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.fetchItem();
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
try {
|
||||||
|
clearTimeout(this.timeout);
|
||||||
|
} catch (error) {}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapActions(useWebapiStore, ["fetchWebapiById", "updateActiveWebapiPermissions"]),
|
||||||
|
fetchItem() {
|
||||||
|
this.fetchWebapiById(parseInt(this.id ?? ""))
|
||||||
|
.then((result) => {
|
||||||
|
this.webapi = result.data;
|
||||||
|
this.loading = "fetched";
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
this.loading = "failed";
|
||||||
|
});
|
||||||
|
},
|
||||||
|
triggerUpdate(e: PermissionObject) {
|
||||||
|
if (this.webapi == null) return;
|
||||||
|
this.status = "loading";
|
||||||
|
this.updateActiveWebapiPermissions(this.webapi.id, e)
|
||||||
|
.then(() => {
|
||||||
|
this.fetchItem();
|
||||||
|
this.status = { status: "success" };
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
this.status = { status: "failed" };
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.timeout = setTimeout(() => {
|
||||||
|
this.status = null;
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
{
|
{
|
||||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue", "config.example.ts", "config.ts"],
|
"include": ["env.d.ts", "src/**/*", "src/**/*.vue", "package.json", "config.example.ts", "config.ts"],
|
||||||
"exclude": ["src/**/__tests__/*"],
|
"exclude": ["src/**/__tests__/*"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
|
|