Compare commits

..

54 commits
v1.0.0 ... main

Author SHA1 Message Date
98141d0d58 1.2.0 2025-01-23 13:15:19 +01:00
4be998074b Merge pull request 'minor v1.2.0' (#52) from develop into main
Reviewed-on: #52
2025-01-23 12:14:09 +00:00
97331c9b73 Merge pull request 'feature/#42-display-version' (#51) from feature/#42-display-version into develop
Reviewed-on: #51
2025-01-23 10:59:18 +00:00
23ca4bcb29 tell newest version if no feed 2025-01-23 11:59:04 +01:00
20190bdb49 fix height issue 2025-01-23 11:55:28 +01:00
0492100ef7 display release feed 2025-01-23 11:51:40 +01:00
e4c6ae836d display versions and filtered feed 2025-01-23 11:22:15 +01:00
033504b8d8 Merge pull request 'feature/#20-API-Tokens' (#50) from feature/#20-API-Tokens into develop
Reviewed-on: #50
2025-01-22 10:59:44 +00:00
4f13b70ac8 form update & token copy 2025-01-22 11:55:48 +01:00
7ded4a21bb basic structure for webapi 2025-01-22 10:17:11 +01:00
ee42625d66 api store and viewmodels 2025-01-22 09:11:39 +01:00
f715a4ab9d Merge pull request 'feature/#29-Membership-statistics' (#49) from feature/#29-Membership-statistics into develop
Reviewed-on: #49
2025-01-22 08:06:20 +00:00
ab3e2b9dc4 display membership stats in member overview 2025-01-22 08:57:28 +01:00
924a6bf647 get statistics 2025-01-22 08:56:52 +01:00
6e82675557 1.1.2 2025-01-21 09:00:51 +01:00
1b531b1152 Merge pull request 'patches v1.1.2' (#47) from develop into main
Reviewed-on: #47
2025-01-21 08:00:11 +00:00
131b3747de enhance: status displayed by member search 2025-01-21 08:58:47 +01:00
883559d8a5 fix: get all members for newletter recipients query 2025-01-21 08:58:30 +01:00
626a355c5c 1.1.1 2025-01-20 12:49:08 +01:00
4ecb39ceff Merge pull request 'patches v1.1.1' (#46) from develop into main
Reviewed-on: #46
2025-01-20 11:48:27 +00:00
45ad07a906 fix: create of calendar type with optional passphrase 2025-01-20 12:43:35 +01:00
5bcb76a60e change: enable calendar entry details for public 2025-01-20 12:43:18 +01:00
c40b53b200 member search component 2025-01-20 09:43:48 +01:00
c9c6df20e0 1.1.0 2025-01-19 13:58:02 +01:00
05e464e825 Merge pull request 'minor: v1.1.0' (#45) from develop into main
Reviewed-on: #45
2025-01-19 12:56:43 +00:00
4dc183f52b Merge branch 'main' into develop 2025-01-19 12:56:07 +00:00
363b5bb541 Merge pull request 'feature/#38-protocol-presence-status' (#44) from feature/#38-protocol-presence-status into develop
Reviewed-on: #44
2025-01-19 12:46:42 +00:00
c2b495f8a7 set excused state to presece members 2025-01-19 13:42:42 +01:00
8a85cc054d fix: display of table structure cleared query builder request 2025-01-19 12:35:21 +01:00
afa834739c 1.0.3 2025-01-18 16:51:23 +01:00
8d2e0deee6 Merge pull request 'patches v1.0.3' (#43) from develop into main
Reviewed-on: #43
2025-01-18 15:49:13 +00:00
9e50d95d7b official logo 2025-01-18 16:45:03 +01:00
020c7c6cb9 weburl change 2025-01-18 14:58:38 +01:00
065b0aa6d5 fix: set default name to AppNameOverwrite to allow pwa install 2025-01-13 12:45:45 +01:00
cd6c9bfd93 1.0.2 2025-01-13 11:23:15 +01:00
557ee051ab Merge pull request 'patches v1.0.2' (#37) from develop into main
Reviewed-on: #37
2025-01-13 10:22:30 +00:00
0ee5a92c95 fix: dialog status handling 2025-01-13 10:24:03 +01:00
22359c3bea change: ui sorting 2025-01-12 18:17:50 +01:00
3da0f4cd49 enhance: display process logs 2025-01-12 14:05:08 +01:00
a8e2b05d8e fix: display newsletter progress in client 2025-01-12 13:25:55 +01:00
52a35be6c5 change: removed dev logging 2025-01-12 11:43:10 +01:00
3f3aa040d9 enhance: enable pwa install 2025-01-11 14:45:48 +01:00
260478af69 enhance: set custom name instead of ff-admin 2025-01-10 17:46:42 +01:00
64806953b2 1.0.1 2025-01-10 12:10:37 +01:00
059b0fa9f2 Merge pull request 'patches v1.0.1' (#35) from develop into main
Reviewed-on: #35
2025-01-10 11:10:03 +00:00
3be63061a8 wrapping up: ff admin rename 2025-01-10 11:32:01 +01:00
6e69e84316 fix: read postal code if wanted 2025-01-10 11:26:00 +01:00
fbc039c35a fix: layout scrolling 2025-01-09 12:59:14 +01:00
9215ac6fd8 fix: text copy fields 2025-01-09 12:48:09 +01:00
c83b4670cc fix: display owner status 2025-01-08 18:09:02 +01:00
a749bfb45e fix: change diagram image to database changes 2025-01-08 16:52:01 +01:00
2176c6c3cf fix: allow server address as url without path 2025-01-06 16:10:47 +01:00
f74c10d4aa fix: availability of env variables 2025-01-06 15:04:47 +01:00
fa45559127 Rename to FF Admin 2025-01-05 16:36:52 +01:00
127 changed files with 3839 additions and 430 deletions

View file

@ -1 +1,5 @@
SERVER_ADDRESS = serveradress VITE_SERVER_ADDRESS = backend_url #ohne pfad
VITE_APP_NAME_OVERWRITE = Mitgliederverwaltung # overwrites FF Admin
VITE_IMPRINT_LINK = https://mywebsite-imprint-url
VITE_PRIVACY_LINK = https://mywebsite-privacy-url
VITE_CUSTOM_LOGIN_MESSAGE = betrieben von xy

5
.env.production Normal file
View file

@ -0,0 +1,5 @@
VITE_SERVER_ADDRESS = __SERVERADDRESS__
VITE_APP_NAME_OVERWRITE = __APPNAMEOVERWRITE__
VITE_IMPRINT_LINK = __IMPRINTLINK__
VITE_PRIVACY_LINK = __PRIVACYLINK__
VITE_CUSTOM_LOGIN_MESSAGE = __CUSTOMLOGINMESSAGE__

View file

@ -19,4 +19,7 @@ COPY ./nginx.conf /etc/nginx/nginx.conf
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] COPY ./entrypoint.sh /entrypoint.sh
RUN apk add --no-cache dos2unix
RUN dos2unix /entrypoint.sh && chmod +x /entrypoint.sh
ENTRYPOINT [ "/entrypoint.sh" ]

View file

@ -1,12 +1,12 @@
# member-administration-ui # FF Admin
Mitgliederverwaltung für Feuerwehren und Vereine. Administration für Feuerwehren und Vereine.
## Einleitung ## Einleitung
Dieses Repository dient zur Verwaltung der Mitgliederdaten. Es ist ein Frontend-Client, der auf die Daten des [member-administration-server Backends](https://forgejo.jk-effects.cloud/Ehrenamt/member-administration-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.
@ -25,16 +25,21 @@ Um den Container hochzufahren, erstellen Sie eine `docker-compose.yml` Datei mit
version: "3" version: "3"
services: services:
ff-member-administration-app: ff-admin-app:
image: docker.registry.jk-effects.cloud/ehrenamt/member-administration/app:latest image: docker.registry.jk-effects.cloud/ehrenamt/ff-admin/app:latest
container_name: ff_member_administration_ui container_name: ff_admin
restart: unless-stopped restart: unless-stopped
#environment: #environment:
# - SERVER_ADRESS=<backend_host> # wichtig: ohne https:// bzw http:// # - SERVERADDRESS=<backend_url (https://... | http://...)> # wichtig: ohne Pfad
# - APPNAMEOVERWRITE=Mitgliederverwaltung # ersetzt den Namen FF-Admin auf der Login-Seite und sonstigen Positionen in der Oberfläche
# - IMPRINTLINK=https://mywebsite-imprint-url
# - PRIVACYLINK=https://mywebsite-privacy-url
# - CUSTOMLOGINMESSAGE=betrieben von xy
#volumes: #volumes:
# - <volume|local path>/myfavicon.png:/app/public/favicon.png # - <volume|local path>/favicon.ico:/usr/share/nginx/html/favicon.ico # 48x48 px Auflösung
# - <volume|local path>/mylogo.png:/app/public/logo.png # - <volume|local path>/favicon.png:/usr/share/nginx/html/favicon.png # 512x512 px Auflösung - wird als pwa Icon genutzt
# - <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.
@ -50,8 +55,8 @@ docker-compose up -d
Klonen Sie dieses Repository und installieren Sie die Abhängigkeiten: Klonen Sie dieses Repository und installieren Sie die Abhängigkeiten:
```sh ```sh
git clone https://forgejo.jk-effects.cloud/Ehrenamt/member-administration-ui.git git clone https://forgejo.jk-effects.cloud/Ehrenamt/ff-admin.git
cd member-administration-ui cd ff-admin
npm install npm install
npm run build npm run build
npm run start npm run start
@ -59,11 +64,11 @@ npm run start
### Konfiguration ### Konfiguration
Ein eigenes favicon und Logo kann über ein volume ausgetauscht werden. Ein eigenes Favicon und Logo kann über das verwenden Volume ausgetauscht werden. Es dürfen jedoch nur einzelne Dateien ausgetauscht werden.
## Einrichtung ## Einrichtung
1. **Admin Benutzer erstellen**: Erstellen Sie einen Admin Benutzer unter dem Pfad [/setup](https://ff-admin-demo.jk-effects.cloud/setup), um auf die Migliederverwaltung Zugriff zu erhalten. Nach der Erstellung des ersten Benutzers wird der Pfad automatisch geblockt. 1. **Admin Benutzer erstellen**: Erstellen Sie einen Admin Benutzer unter dem Pfad /setup, um auf die Migliederverwaltung Zugriff zu erhalten. Nach der Erstellung des ersten Benutzers wird der Pfad automatisch geblockt.
2. **Rollen und Berechtigungen**: Unter `Benutzer > Rollen` können die Rollen und Berechtigungen für die Benutzer erstellt und angepasst werden. 2. **Rollen und Berechtigungen**: Unter `Benutzer > Rollen` können die Rollen und Berechtigungen für die Benutzer erstellt und angepasst werden.

View file

@ -1,6 +1,6 @@
# FF Admin # FF Admin
## FF Admin ist eine Verwaltungsoberfläche für die Feuerwehr: ## FF Admin ist eine Verwaltungsoberfläche für die Feuerwehr oder andere Vereine:
FF Admin bietet folgende Module: FF Admin bietet folgende Module:
- Mitgliederverwaltung - Mitgliederverwaltung
@ -23,3 +23,6 @@ FF Admin ist in Verein, Wehr, Einstellungen und Nutzerverwaltung getrennt.
Die den Modulen zugrunde liegenden Daten können in den Einstellungen gesetzt werden. Die den Modulen zugrunde liegenden Daten können in den Einstellungen gesetzt werden.
Fast alle Daten lassen sich einstellen, damit es keine Einschränkungen in der Auswahl von Werten... gibt. Diese Modularität muss allerdings bei einigen Modulen gesondert eingestellt werden. Fast alle Daten lassen sich einstellen, damit es keine Einschränkungen in der Auswahl von Werten... gibt. Diese Modularität muss allerdings bei einigen Modulen gesondert eingestellt werden.
## Verwendung
Damit FF Admin auch für andere Vereine genutzt werden kann, muss keine erweiterte Konfiguration vorgenommen werden. Am besten ist es alle nicht benötigten Module in der Berechtigungsverwaltung zu deaktivieren. So wird normalerweise der Abschnitt Wehr nicht außerhalb der Feuerwehr benötigt. So müssen hier lediglich keine Berechtigungen vergeben werden und das Modul ist außer für Administratoren oder Owner nicht sichtbar.

27
entrypoint.sh Normal file
View file

@ -0,0 +1,27 @@
#!/bin/sh
keys="SERVERADDRESS APPNAMEOVERWRITE IMPRINTLINK PRIVACYLINK CUSTOMLOGINMESSAGE"
files="/usr/share/nginx/html/assets/config-*.js /usr/share/nginx/html/manifest.webmanifest"
# Replace env vars in files served by NGINX
for file in $files
do
echo "Processing $file ...";
for key in $keys
do
# Get environment variable
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"
# replace __[variable_name]__ value with environment variable
sed -i 's|__'"$key"'__|'"$value"'|g' $file
done
done
nginx -g 'daemon off;'

View file

@ -4,7 +4,6 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon.png" /> <link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mitgliederverwaltung</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

8
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "member-administration-ui", "name": "ff-admin",
"version": "1.0.0", "version": "1.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "member-administration-ui", "name": "ff-admin",
"version": "1.0.0", "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",

View file

@ -1,6 +1,6 @@
{ {
"name": "member-administration-ui", "name": "ff-admin",
"version": "1.0.0", "version": "1.2.0",
"description": "Feuerwehr/Verein Mitgliederverwaltung UI", "description": "Feuerwehr/Verein Mitgliederverwaltung UI",
"type": "module", "type": "module",
"scripts": { "scripts": {
@ -12,11 +12,11 @@
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore", "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/", "format": "prettier --write src/",
"bnp": "npm run build-only && npm run preview", "bnp": "npm run build-only && npm run preview",
"generate-pwa-assets": "pwa-assets-generator --preset minimal-2023 public/CM.svg" "generate-pwa-assets": "pwa-assets-generator --preset minimal-2023 public/fw-wappen.png"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://forgejo.jk-effects.cloud/Ehrenamt/member-administration-ui.git" "url": "https://forgejo.jk-effects.cloud/Ehrenamt/ff-admin.git"
}, },
"keywords": [ "keywords": [
"Feuerwehr" "Feuerwehr"

File diff suppressed because it is too large Load diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 641 KiB

After

Width:  |  Height:  |  Size: 650 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 MiB

After

Width:  |  Height:  |  Size: 11 MiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 516 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View file

@ -1,9 +1,20 @@
<template> <template>
<div class="flex flex-col text-gray-400 text-sm mt-4 items-center"> <div class="flex flex-col text-gray-400 text-sm mt-4 items-center">
<div class="flex flex-row gap-2 justify-center"> <div class="flex flex-row gap-2 justify-center">
<a href="https://jk-effects.com/privacy" target="_blank">Datenschutz</a> <a v-if="config.imprint_link" :href="config.imprint_link" target="_blank">Datenschutz</a>
<a href="https://jk-effects.com/imprint" target="_blank">Impressum</a> <a v-if="config.privacy_link" :href="config.privacy_link" target="_blank">Impressum</a>
</div> </div>
<a href="https://jk-effects.com" target="_blank"> &copy; Admin-Portal by JK Effects </a> <p v-if="config.custom_login_message">{{ config.custom_login_message }}</p>
<p>
&copy;
<a href="https://forgejo.jk-effects.cloud/Ehrenamt/ff-admin" target="_blank">Admin-Portal</a>
by
<a href="https://jk-effects.com" target="_blank">JK Effects</a>
</p>
</div> </div>
</template> </template>
<script setup lang="ts">
import { config } from '@/config'
</script>

View file

@ -2,7 +2,7 @@
<header class="flex flex-row h-16 min-h-16 justify-between p-3 md:px-5 bg-white shadow-sm"> <header class="flex flex-row h-16 min-h-16 justify-between p-3 md:px-5 bg-white shadow-sm">
<RouterLink to="/" class="flex flex-row gap-2 align-bottom w-fit h-full"> <RouterLink to="/" class="flex flex-row gap-2 align-bottom w-fit h-full">
<img src="/Logo.png" alt="LOGO" class="h-full w-auto" /> <img src="/Logo.png" alt="LOGO" class="h-full w-auto" />
<h1 v-if="false" class="font-bold text-3xl w-fit whitespace-nowrap">Mitgliederverwaltung</h1> <h1 v-if="false" class="font-bold text-3xl w-fit whitespace-nowrap">{{config.app_name_overwrite || "FF Admin"}}</h1>
</RouterLink> </RouterLink>
<div class="flex flex-row gap-2 items-center"> <div class="flex flex-row gap-2 items-center">
<div v-if="authCheck" class="hidden md:flex flex-row gap-2 h-full align-middle"> <div v-if="authCheck" class="hidden md:flex flex-row gap-2 h-full align-middle">
@ -30,6 +30,7 @@ import { useAuthStore } from "@/stores/auth";
import { useNavigationStore } from "@/stores/admin/navigation"; import { useNavigationStore } from "@/stores/admin/navigation";
import TopLevelLink from "./admin/TopLevelLink.vue"; import TopLevelLink from "./admin/TopLevelLink.vue";
import UserMenu from "./UserMenu.vue"; import UserMenu from "./UserMenu.vue";
import { config } from "@/config"
</script> </script>
<script lang="ts"> <script lang="ts">

View file

@ -113,10 +113,10 @@ export default defineComponent({
}, },
methods: { methods: {
...mapActions(useNotificationStore, ["revoke"]), ...mapActions(useNotificationStore, ["revoke"]),
close(id: number) { close(id: string) {
this.revoke(id); this.revoke(id);
}, },
hovering(id: number, value: boolean, timeout?: number) { hovering(id: string, value: boolean, timeout?: number) {
if (value) { if (value) {
clearTimeout(this.timeouts[id]); clearTimeout(this.timeouts[id]);
} else { } else {

View file

@ -159,7 +159,7 @@ const loadPage = (newPage: number | ".") => {
if (pageEnd > entryCount.value) pageEnd = entryCount.value; if (pageEnd > entryCount.value) pageEnd = entryCount.value;
let loadedElementCount = filterData(props.items, searchString.value, pageStart, pageEnd).length; let loadedElementCount = filterData(props.items, searchString.value, pageStart, pageEnd).length;
console.log(loadedElementCount, props.maxEntriesPerPage, pageStart, pageEnd)
if (loadedElementCount < props.maxEntriesPerPage && (pageEnd != props.totalCount || loadedElementCount == 0)) if (loadedElementCount < props.maxEntriesPerPage && (pageEnd != props.totalCount || loadedElementCount == 0))
emit("loadData", pageStart, props.maxEntriesPerPage, searchString.value); emit("loadData", pageStart, props.maxEntriesPerPage, searchString.value);

View file

@ -26,7 +26,7 @@
</RouterLink> </RouterLink>
</MenuItem> </MenuItem>
<MenuItem v-slot="{ close }"> <MenuItem v-slot="{ close }">
<RouterLink to="/docs"> <RouterLink to="/docs" target="_blank">
<button button primary @click="close">Dokumentation</button> <button button primary @click="close">Dokumentation</button>
</RouterLink> </RouterLink>
</MenuItem> </MenuItem>

View file

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

View file

@ -24,7 +24,7 @@ export default defineComponent({
default: "LINK", default: "LINK",
}, },
link: { link: {
type: Object as PropType<string | { name: string }>, type: Object as PropType<string | { name: string, params?:{[key:string]:string} }>,
default: "/", default: "/",
}, },
active: { active: {

View file

@ -1,7 +1,7 @@
<template> <template>
<div class="w-full md:max-w-md"> <div class="w-full md:max-w-md">
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<p class="text-xl font-medium">Termintyp erstellen</p> <p class="text-xl font-medium">Termin erstellen</p>
</div> </div>
<br /> <br />
<form class="flex flex-col gap-4 py-2" @submit.prevent="triggerCreate"> <form class="flex flex-col gap-4 py-2" @submit.prevent="triggerCreate">
@ -124,11 +124,7 @@
<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 <button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
primary-outline
@click="closeModal"
:disabled="status != null && status != 'loading' && status?.status != 'failed'"
>
abbrechen abbrechen
</button> </button>
</div> </div>
@ -194,6 +190,7 @@ export default defineComponent({
location: formData.location.value, location: formData.location.value,
allDay: this.allDay, allDay: this.allDay,
}; };
this.status = "loading";
this.createCalendar(createCalendar) this.createCalendar(createCalendar)
.then(() => { .then(() => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -38,7 +38,9 @@
<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" :disabled="status != null">abbrechen</button> <button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
abbrechen
</button>
</div> </div>
</div> </div>
</div> </div>
@ -78,6 +80,7 @@ export default defineComponent({
...mapActions(useModalStore, ["closeModal"]), ...mapActions(useModalStore, ["closeModal"]),
...mapActions(useCalendarStore, ["deleteCalendar"]), ...mapActions(useCalendarStore, ["deleteCalendar"]),
triggerDelete() { triggerDelete() {
this.status = "loading";
this.deleteCalendar(this.data) this.deleteCalendar(this.data)
.then(() => { .then(() => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -6,7 +6,7 @@
@click="deleteCalendar" @click="deleteCalendar"
/> />
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<p class="text-xl font-medium">Termintyp erstellen</p> <p class="text-xl font-medium">Termin erstellen</p>
</div> </div>
<br /> <br />
<Spinner v-if="loading == 'loading'" class="mx-auto" /> <Spinner v-if="loading == 'loading'" class="mx-auto" />
@ -166,11 +166,7 @@
<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 <button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
primary-outline
@click="closeModal"
:disabled="status != null && status != 'loading' && status?.status != 'failed'"
>
abbrechen / schließen abbrechen / schließen
</button> </button>
</div> </div>
@ -264,6 +260,7 @@ export default defineComponent({
location: formData.location.value, location: formData.location.value,
allDay: this.calendar.allDay, allDay: this.calendar.allDay,
}; };
this.status = "loading";
this.updateCalendar(updateCalendar) this.updateCalendar(updateCalendar)
.then(() => { .then(() => {
this.fetchItem(); this.fetchItem();

View file

@ -133,6 +133,7 @@ export default defineComponent({
birthdate: formData.birthdate.value, birthdate: formData.birthdate.value,
internalId: formData.internalId.value, internalId: formData.internalId.value,
}; };
this.status = "loading";
this.createMember(createMember) this.createMember(createMember)
.then(() => { .then(() => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -72,6 +72,7 @@ export default defineComponent({
...mapActions(useModalStore, ["closeModal"]), ...mapActions(useModalStore, ["closeModal"]),
...mapActions(useMemberStore, ["deleteMember"]), ...mapActions(useMemberStore, ["deleteMember"]),
triggerDelete() { triggerDelete() {
this.status = "loading";
this.deleteMember(this.data) this.deleteMember(this.data)
.then(() => { .then(() => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -140,6 +140,7 @@ export default defineComponent({
given: formData.given.checked, given: formData.given.checked,
awardId: this.selectedAward.id, awardId: this.selectedAward.id,
}; };
this.status = "loading";
this.createMemberAward(createMemberAward) this.createMemberAward(createMemberAward)
.then(() => { .then(() => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -65,6 +65,7 @@ export default defineComponent({
...mapActions(useModalStore, ["closeModal"]), ...mapActions(useModalStore, ["closeModal"]),
...mapActions(useMemberAwardStore, ["deleteMemberAward"]), ...mapActions(useMemberAwardStore, ["deleteMemberAward"]),
triggerDelete() { triggerDelete() {
this.status = "loading";
this.deleteMemberAward(this.data) this.deleteMemberAward(this.data)
.then(() => { .then(() => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -83,7 +83,9 @@
<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" :disabled="status != null">schließen</button> <button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
schließen
</button>
</div> </div>
</div> </div>
</div> </div>
@ -167,6 +169,7 @@ export default defineComponent({
given: formData.given.checked, given: formData.given.checked,
awardId: this.memberAward.awardId, awardId: this.memberAward.awardId,
}; };
this.status = "loading";
this.updateMemberAward(updateMemberAward) this.updateMemberAward(updateMemberAward)
.then(() => { .then(() => {
this.fetchItem(); this.fetchItem();

View file

@ -166,7 +166,7 @@ export default defineComponent({
preferred: formData.preferred.checked, preferred: formData.preferred.checked,
mobile: formData.mobile?.value, mobile: formData.mobile?.value,
email: formData.email?.value, email: formData.email?.value,
postalCode: formData.postalCode.value, postalCode: formData.postalCode?.value,
city: formData.city?.value, city: formData.city?.value,
street: formData.street?.value, street: formData.street?.value,
streetNumber: formData.streetNumber?.value, streetNumber: formData.streetNumber?.value,
@ -175,6 +175,7 @@ export default defineComponent({
isSMSAlarming: formData.isSMSAlarming?.checked, isSMSAlarming: formData.isSMSAlarming?.checked,
typeId: this.selectedCommunicationType.id, typeId: this.selectedCommunicationType.id,
}; };
this.status = "loading";
this.createCommunication(createCommunication) this.createCommunication(createCommunication)
.then(() => { .then(() => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -68,6 +68,7 @@ export default defineComponent({
...mapActions(useModalStore, ["closeModal"]), ...mapActions(useModalStore, ["closeModal"]),
...mapActions(useCommunicationStore, ["deleteCommunication"]), ...mapActions(useCommunicationStore, ["deleteCommunication"]),
triggerDelete() { triggerDelete() {
this.status = "loading";
this.deleteCommunication(this.data) this.deleteCommunication(this.data)
.then(() => { .then(() => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -62,7 +62,9 @@
<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" :disabled="status != null">schließen</button> <button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
schließen
</button>
</div> </div>
</div> </div>
</div> </div>
@ -136,7 +138,7 @@ export default defineComponent({
preferred: formData.preferred.checked, preferred: formData.preferred.checked,
mobile: formData.mobile?.value, mobile: formData.mobile?.value,
email: formData.email?.value, email: formData.email?.value,
postalCode: formData.postalCode.value, postalCode: formData.postalCode?.value,
city: formData.city?.value, city: formData.city?.value,
street: formData.street?.value, street: formData.street?.value,
streetNumber: formData.streetNumber?.value, streetNumber: formData.streetNumber?.value,
@ -144,6 +146,7 @@ export default defineComponent({
isNewsletterMain: formData.isNewsletterMain.checked, isNewsletterMain: formData.isNewsletterMain.checked,
isSMSAlarming: formData.isSMSAlarming?.checked, isSMSAlarming: formData.isSMSAlarming?.checked,
}; };
this.status = "loading";
this.updateCommunication(updateCommunication) this.updateCommunication(updateCommunication)
.then(() => { .then(() => {
this.fetchItem(); this.fetchItem();

View file

@ -141,6 +141,7 @@ export default defineComponent({
note: formData.note.value, note: formData.note.value,
executivePositionId: this.selectedExecutivePosition.id, executivePositionId: this.selectedExecutivePosition.id,
}; };
this.status = "loading";
this.createMemberExecutivePosition(createMemberExecutivePosition) this.createMemberExecutivePosition(createMemberExecutivePosition)
.then(() => { .then(() => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -65,6 +65,7 @@ export default defineComponent({
...mapActions(useModalStore, ["closeModal"]), ...mapActions(useModalStore, ["closeModal"]),
...mapActions(useMemberExecutivePositionStore, ["deleteMemberExecutivePosition"]), ...mapActions(useMemberExecutivePositionStore, ["deleteMemberExecutivePosition"]),
triggerDelete() { triggerDelete() {
this.status = "loading";
this.deleteMemberExecutivePosition(this.data) this.deleteMemberExecutivePosition(this.data)
.then(() => { .then(() => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -89,7 +89,9 @@
<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" :disabled="status != null">schließen</button> <button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
schließen
</button>
</div> </div>
</div> </div>
</div> </div>
@ -176,6 +178,7 @@ export default defineComponent({
note: formData.note.value, note: formData.note.value,
executivePositionId: this.memberExecutivePosition.executivePositionId, executivePositionId: this.memberExecutivePosition.executivePositionId,
}; };
this.status = "loading";
this.updateMemberExecutivePosition(updateMemberExecutivePosition) this.updateMemberExecutivePosition(updateMemberExecutivePosition)
.then(() => { .then(() => {
this.fetchItem(); this.fetchItem();

View file

@ -148,6 +148,7 @@ export default defineComponent({
note: formData.note.value, note: formData.note.value,
qualificationId: this.selectedQualification.id, qualificationId: this.selectedQualification.id,
}; };
this.status = "loading";
this.createMemberQualification(createMemberQualification) this.createMemberQualification(createMemberQualification)
.then(() => { .then(() => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -69,6 +69,7 @@ export default defineComponent({
...mapActions(useModalStore, ["closeModal"]), ...mapActions(useModalStore, ["closeModal"]),
...mapActions(useMemberQualificationStore, ["deleteMemberQualification"]), ...mapActions(useMemberQualificationStore, ["deleteMemberQualification"]),
triggerDelete() { triggerDelete() {
this.status = "loading";
this.deleteMemberQualification(this.data) this.deleteMemberQualification(this.data)
.then(() => { .then(() => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -90,7 +90,9 @@
<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" :disabled="status != null">schließen</button> <button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
schließen
</button>
</div> </div>
</div> </div>
</div> </div>
@ -175,6 +177,7 @@ export default defineComponent({
terminationReason: formData.terminationReason.value, terminationReason: formData.terminationReason.value,
qualificationId: this.memberQualification.qualificationId, qualificationId: this.memberQualification.qualificationId,
}; };
this.status = "loading";
this.updateMemberQualification(updateMemberQualification) this.updateMemberQualification(updateMemberQualification)
.then(() => { .then(() => {
this.fetchItem(); this.fetchItem();

View file

@ -131,6 +131,7 @@ export default defineComponent({
start: formData.start.value, start: formData.start.value,
statusId: this.selectedStatus.id, statusId: this.selectedStatus.id,
}; };
this.status = "loading";
this.createMembership(createMember) this.createMembership(createMember)
.then(() => { .then(() => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -67,6 +67,7 @@ export default defineComponent({
...mapActions(useModalStore, ["closeModal"]), ...mapActions(useModalStore, ["closeModal"]),
...mapActions(useMembershipStore, ["deleteMembership"]), ...mapActions(useMembershipStore, ["deleteMembership"]),
triggerDelete() { triggerDelete() {
this.status = "loading";
this.deleteMembership(this.data) this.deleteMembership(this.data)
.then(() => { .then(() => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -86,7 +86,9 @@
<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" :disabled="status != null">schließen</button> <button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
schließen
</button>
</div> </div>
</div> </div>
</div> </div>
@ -170,6 +172,7 @@ export default defineComponent({
terminationReason: formData.terminationReason.value, terminationReason: formData.terminationReason.value,
statusId: this.membership.statusId, statusId: this.membership.statusId,
}; };
this.status = "loading";
this.updateMembership(updateMembership) this.updateMembership(updateMembership)
.then(() => { .then(() => {
this.fetchItem(); this.fetchItem();

View file

@ -61,6 +61,7 @@ export default defineComponent({
let createNewsletter: CreateNewsletterViewModel = { let createNewsletter: CreateNewsletterViewModel = {
title: formData.title.value, title: formData.title.value,
}; };
this.status = "loading";
this.createNewsletter(createNewsletter) this.createNewsletter(createNewsletter)
.then(() => { .then(() => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -0,0 +1,44 @@
<template>
<div class="w-full md:max-w-md">
<div class="flex flex-col items-center">
<p class="text-xl font-medium">Newsletter Mail-Versand Logs</p>
</div>
<br />
<div class="h-96 overflow-y-scroll">
<p v-for="entry in mailSourceMessages">
{{ entry }}
</p>
</div>
<div class="flex flex-row justify-end">
<div class="flex flex-row gap-4 py-2">
<button primary-outline @click="closeModal">
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 { useNewsletterPrintoutStore } from "@/stores/admin/club/newsletter/newsletterPrintout";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
};
},
computed:{
...mapState(useNewsletterPrintoutStore, ["mailSourceMessages"])
},
methods: {
...mapActions(useModalStore, ["closeModal"]),
},
});
</script>

View file

@ -0,0 +1,50 @@
<template>
<div class="w-full md:max-w-md">
<div class="flex flex-col items-center">
<p class="text-xl font-medium">Newsletter Druck-Prozess Logs</p>
</div>
<br />
<div class="flex flex-col gap-2 h-96 overflow-y-scroll">
<div
v-for="entry in pdfSourceMessages"
class="flex flex-row gap-2 border border-gray-200 rounded-md p-1 items-center"
>
<SuccessCheckmark v-if="entry.factor == 'success'" class="w-5 h-5" />
<InformationCircleIcon v-else-if="entry.factor == 'info'" class="w-5 h-5 min-h-5 min-w-5 text-gray-500" />
<FailureXMark v-else-if="entry.factor == 'failed'" class="w-5 h-5" />
<p>{{ entry.iteration }}/{{ entry.total }}: {{ entry.msg }}</p>
</div>
</div>
<div class="flex flex-row justify-end">
<div class="flex flex-row gap-4 py-2">
<button primary-outline @click="closeModal">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 { useNewsletterPrintoutStore } from "@/stores/admin/club/newsletter/newsletterPrintout";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import { InformationCircleIcon } from "@heroicons/vue/24/solid";
import FailureXMark from "@/components/FailureXMark.vue";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {};
},
computed: {
...mapState(useNewsletterPrintoutStore, ["pdfSourceMessages"]),
},
methods: {
...mapActions(useModalStore, ["closeModal"]),
},
});
</script>

View file

@ -64,6 +64,7 @@ export default defineComponent({
title: formData.title.value, title: formData.title.value,
date: formData.date.value, date: formData.date.value,
}; };
this.status = "loading";
this.createProtocol(createProtocol) this.createProtocol(createProtocol)
.then(() => { .then(() => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -19,7 +19,9 @@
<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" :disabled="status != null">abbrechen</button> <button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
abbrechen
</button>
</div> </div>
</div> </div>
</div> </div>
@ -57,6 +59,7 @@ export default defineComponent({
let createAward: CreateAwardViewModel = { let createAward: CreateAwardViewModel = {
award: formData.award.value, award: formData.award.value,
}; };
this.status = "loading";
this.createAward(createAward) this.createAward(createAward)
.then(() => { .then(() => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -16,7 +16,9 @@
<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" :disabled="status != null">abbrechen</button> <button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
abbrechen
</button>
</div> </div>
</div> </div>
</div> </div>
@ -57,6 +59,7 @@ export default defineComponent({
...mapActions(useModalStore, ["closeModal"]), ...mapActions(useModalStore, ["closeModal"]),
...mapActions(useAwardStore, ["deleteAward"]), ...mapActions(useAwardStore, ["deleteAward"]),
triggerDelete() { triggerDelete() {
this.status = "loading";
this.deleteAward(this.data) this.deleteAward(this.data)
.then(() => { .then(() => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -32,11 +32,7 @@
<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 <button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
primary-outline
@click="closeModal"
:disabled="status != null && status != 'loading' && status?.status != 'failed'"
>
abbrechen abbrechen
</button> </button>
</div> </div>
@ -53,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">
@ -80,8 +74,9 @@ 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.createCalendarType(createCalendarType) this.createCalendarType(createCalendarType)
.then(() => { .then(() => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -16,7 +16,9 @@
<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" :disabled="status != null">abbrechen</button> <button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
abbrechen
</button>
</div> </div>
</div> </div>
</div> </div>
@ -56,6 +58,7 @@ export default defineComponent({
...mapActions(useModalStore, ["closeModal"]), ...mapActions(useModalStore, ["closeModal"]),
...mapActions(useCalendarTypeStore, ["deleteCalendarType"]), ...mapActions(useCalendarTypeStore, ["deleteCalendarType"]),
triggerDelete() { triggerDelete() {
this.status = "loading";
this.deleteCalendarType(this.data) this.deleteCalendarType(this.data)
.then(() => { .then(() => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -65,7 +65,9 @@
<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" :disabled="status != null">abbrechen</button> <button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
abbrechen
</button>
</div> </div>
</div> </div>
</div> </div>
@ -114,6 +116,7 @@ export default defineComponent({
type: formData.communicationType.value, type: formData.communicationType.value,
fields: this.selectedFields, fields: this.selectedFields,
}; };
this.status = "loading";
this.createCommunicationType(createCommunicationType) this.createCommunicationType(createCommunicationType)
.then(() => { .then(() => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -16,7 +16,9 @@
<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" :disabled="status != null">abbrechen</button> <button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
abbrechen
</button>
</div> </div>
</div> </div>
</div> </div>
@ -56,6 +58,7 @@ export default defineComponent({
...mapActions(useModalStore, ["closeModal"]), ...mapActions(useModalStore, ["closeModal"]),
...mapActions(useCommunicationTypeStore, ["deleteCommunicationType"]), ...mapActions(useCommunicationTypeStore, ["deleteCommunicationType"]),
triggerDelete() { triggerDelete() {
this.status = "loading";
this.deleteCommunicationType(this.data) this.deleteCommunicationType(this.data)
.then(() => { .then(() => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -19,7 +19,9 @@
<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" :disabled="status != null">abbrechen</button> <button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
abbrechen
</button>
</div> </div>
</div> </div>
</div> </div>
@ -57,6 +59,7 @@ export default defineComponent({
let createExecutivePosition: CreateExecutivePositionViewModel = { let createExecutivePosition: CreateExecutivePositionViewModel = {
position: formData.executivePosition.value, position: formData.executivePosition.value,
}; };
this.status = "loading";
this.createExecutivePosition(createExecutivePosition) this.createExecutivePosition(createExecutivePosition)
.then(() => { .then(() => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -16,7 +16,9 @@
<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" :disabled="status != null">abbrechen</button> <button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
abbrechen
</button>
</div> </div>
</div> </div>
</div> </div>
@ -57,6 +59,7 @@ export default defineComponent({
...mapActions(useModalStore, ["closeModal"]), ...mapActions(useModalStore, ["closeModal"]),
...mapActions(useExecutivePositionStore, ["deleteExecutivePosition"]), ...mapActions(useExecutivePositionStore, ["deleteExecutivePosition"]),
triggerDelete() { triggerDelete() {
this.status = "loading";
this.deleteExecutivePosition(this.data) this.deleteExecutivePosition(this.data)
.then(() => { .then(() => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -19,7 +19,9 @@
<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" :disabled="status != null">abbrechen</button> <button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
abbrechen
</button>
</div> </div>
</div> </div>
</div> </div>
@ -57,6 +59,7 @@ export default defineComponent({
let createMembershipStatus: CreateMembershipStatusViewModel = { let createMembershipStatus: CreateMembershipStatusViewModel = {
status: formData.membershipStatus.value, status: formData.membershipStatus.value,
}; };
this.status = "loading";
this.createMembershipStatus(createMembershipStatus) this.createMembershipStatus(createMembershipStatus)
.then(() => { .then(() => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -16,7 +16,9 @@
<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" :disabled="status != null">abbrechen</button> <button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
abbrechen
</button>
</div> </div>
</div> </div>
</div> </div>
@ -57,6 +59,7 @@ export default defineComponent({
...mapActions(useModalStore, ["closeModal"]), ...mapActions(useModalStore, ["closeModal"]),
...mapActions(useMembershipStatusStore, ["deleteMembershipStatus"]), ...mapActions(useMembershipStatusStore, ["deleteMembershipStatus"]),
triggerDelete() { triggerDelete() {
this.status = "loading";
this.deleteMembershipStatus(this.data) this.deleteMembershipStatus(this.data)
.then(() => { .then(() => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -23,7 +23,9 @@
<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" :disabled="status != null">abbrechen</button> <button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
abbrechen
</button>
</div> </div>
</div> </div>
</div> </div>
@ -62,6 +64,7 @@ export default defineComponent({
qualification: formData.qualification.value, qualification: formData.qualification.value,
description: formData.description.value, description: formData.description.value,
}; };
this.status = "loading";
this.createQualification(createQualification) this.createQualification(createQualification)
.then(() => { .then(() => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -16,7 +16,9 @@
<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" :disabled="status != null">abbrechen</button> <button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
abbrechen
</button>
</div> </div>
</div> </div>
</div> </div>
@ -57,6 +59,7 @@ export default defineComponent({
...mapActions(useModalStore, ["closeModal"]), ...mapActions(useModalStore, ["closeModal"]),
...mapActions(useQualificationStore, ["deleteQualification"]), ...mapActions(useQualificationStore, ["deleteQualification"]),
triggerDelete() { triggerDelete() {
this.status = "loading";
this.deleteQualification(this.data) this.deleteQualification(this.data)
.then(() => { .then(() => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -19,7 +19,9 @@
<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" :disabled="status != null">abbrechen</button> <button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
abbrechen
</button>
</div> </div>
</div> </div>
</div> </div>
@ -62,6 +64,7 @@ export default defineComponent({
title: formData.title.value, title: formData.title.value,
query: this.query ?? "", query: this.query ?? "",
}; };
this.status = "loading";
this.createQueryStore(createAward) this.createQueryStore(createAward)
.then(() => { .then(() => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -16,7 +16,9 @@
<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" :disabled="status != null">abbrechen</button> <button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
abbrechen
</button>
</div> </div>
</div> </div>
</div> </div>
@ -56,6 +58,7 @@ export default defineComponent({
...mapActions(useModalStore, ["closeModal"]), ...mapActions(useModalStore, ["closeModal"]),
...mapActions(useQueryStoreStore, ["deleteQueryStore"]), ...mapActions(useQueryStoreStore, ["deleteQueryStore"]),
triggerDelete() { triggerDelete() {
this.status = "loading";
this.deleteQueryStore(this.data) this.deleteQueryStore(this.data)
.then(() => { .then(() => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -21,7 +21,9 @@
<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" :disabled="status != null">abbrechen</button> <button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
abbrechen
</button>
</div> </div>
</div> </div>
</div> </div>
@ -73,6 +75,7 @@ export default defineComponent({
id: this.data, id: this.data,
query: this.query ?? "", query: this.query ?? "",
}; };
this.status = "loading";
this.updateActiveQueryStore(updateQuery) this.updateActiveQueryStore(updateQuery)
.then(() => { .then(() => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -23,7 +23,9 @@
<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" :disabled="status != null">abbrechen</button> <button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
abbrechen
</button>
</div> </div>
</div> </div>
</div> </div>
@ -62,6 +64,7 @@ export default defineComponent({
template: formData.template.value, template: formData.template.value,
description: formData.description.value, description: formData.description.value,
}; };
this.status = "loading";
this.createTemplate(createTemplate) this.createTemplate(createTemplate)
.then((res) => { .then((res) => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -16,7 +16,9 @@
<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" :disabled="status != null">abbrechen</button> <button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
abbrechen
</button>
</div> </div>
</div> </div>
</div> </div>
@ -57,6 +59,7 @@ export default defineComponent({
...mapActions(useModalStore, ["closeModal"]), ...mapActions(useModalStore, ["closeModal"]),
...mapActions(useTemplateStore, ["deleteTemplate"]), ...mapActions(useTemplateStore, ["deleteTemplate"]),
triggerDelete() { triggerDelete() {
this.status = "loading";
this.deleteTemplate(this.data) this.deleteTemplate(this.data)
.then(() => { .then(() => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -19,7 +19,9 @@
<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" :disabled="status != null">abbrechen</button> <button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
abbrechen
</button>
</div> </div>
</div> </div>
</div> </div>
@ -53,6 +55,7 @@ export default defineComponent({
...mapActions(useRoleStore, ["createRole"]), ...mapActions(useRoleStore, ["createRole"]),
triggerCreateRole(e: any) { triggerCreateRole(e: any) {
let formData = e.target.elements; let formData = e.target.elements;
this.status = "loading";
this.createRole(formData.role.value) this.createRole(formData.role.value)
.then(() => { .then(() => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -16,7 +16,9 @@
<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" :disabled="status != null">abbrechen</button> <button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
abbrechen
</button>
</div> </div>
</div> </div>
</div> </div>
@ -56,6 +58,7 @@ export default defineComponent({
...mapActions(useModalStore, ["closeModal"]), ...mapActions(useModalStore, ["closeModal"]),
...mapActions(useRoleStore, ["deleteRole"]), ...mapActions(useRoleStore, ["deleteRole"]),
triggerDeleteRole() { triggerDeleteRole() {
this.status = "loading";
this.deleteRole(this.data) this.deleteRole(this.data)
.then(() => { .then(() => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -16,7 +16,9 @@
<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" :disabled="status != null">abbrechen</button> <button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
abbrechen
</button>
</div> </div>
</div> </div>
</div> </div>
@ -56,6 +58,7 @@ export default defineComponent({
...mapActions(useModalStore, ["closeModal"]), ...mapActions(useModalStore, ["closeModal"]),
...mapActions(useUserStore, ["deleteUser"]), ...mapActions(useUserStore, ["deleteUser"]),
triggerDeleteUser() { triggerDeleteUser() {
this.status = "loading";
this.deleteUser(this.data) this.deleteUser(this.data)
.then(() => { .then(() => {
this.status = { status: "success" }; this.status = { status: "success" };

View file

@ -32,7 +32,9 @@
<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" :disabled="status != null">abbrechen</button> <button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
abbrechen
</button>
</div> </div>
</div> </div>
</div> </div>
@ -70,24 +72,22 @@ export default defineComponent({
...mapActions(useInviteStore, ["createInvite"]), ...mapActions(useInviteStore, ["createInvite"]),
invite(e: any) { invite(e: any) {
let formData = e.target.elements; let formData = e.target.elements;
this.status = "loading";
let createInvite: CreateInviteViewModel = { let createInvite: CreateInviteViewModel = {
username: formData.username.value, username: formData.username.value,
mail: formData.mail.value, mail: formData.mail.value,
firstname: formData.firstname.value, firstname: formData.firstname.value,
lastname: formData.lastname.value, lastname: formData.lastname.value,
}; };
this.status = "loading";
this.createInvite(createInvite) this.createInvite(createInvite)
.then((result) => { .then((result) => {
this.status = { status: "success" }; this.status = { status: "success" };
})
.catch((err) => {
this.status = { status: "failed", reason: err.response.data };
})
.finally(() => {
setTimeout(() => { setTimeout(() => {
this.closeModal(); this.closeModal();
}, 2000); }, 2000);
})
.catch((err) => {
this.status = { status: "failed", reason: err.response.data };
}); });
}, },
}, },

View file

@ -3,7 +3,7 @@
<div class="bg-primary p-2 text-white flex flex-row justify-between items-center"> <div class="bg-primary p-2 text-white flex flex-row justify-between items-center">
<p> <p>
{{ user.firstname }} {{ user.lastname }} <small v-if="user.permissions_total.admin">(Admin)</small {{ user.firstname }} {{ user.lastname }} <small v-if="user.permissions_total.admin">(Admin)</small
><small v-if="isOwner"> (Owner)</small> ><small v-if="user.isOwner"> (Owner)</small>
</p> </p>
<div class="flex flex-row"> <div class="flex flex-row">
<RouterLink <RouterLink
@ -65,7 +65,7 @@ export default defineComponent({
user: { type: Object as PropType<UserViewModel>, default: {} }, user: { type: Object as PropType<UserViewModel>, default: {} },
}, },
computed: { computed: {
...mapState(useAbilityStore, ["can", "isOwner"]), ...mapState(useAbilityStore, ["can"]),
}, },
methods: { methods: {
...mapActions(useModalStore, ["openModal"]), ...mapActions(useModalStore, ["openModal"]),

View 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>

View 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>

View 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>

View 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>

View file

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

View file

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

View file

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

15
src/config.ts Normal file
View file

@ -0,0 +1,15 @@
export interface Config {
server_address: string;
app_name_overwrite: string;
imprint_link: string;
privacy_link: string;
custom_login_message: string;
}
export const config: Config = {
server_address: import.meta.env.VITE_SERVER_ADDRESS,
app_name_overwrite: import.meta.env.VITE_APP_NAME_OVERWRITE,
imprint_link: import.meta.env.VITE_IMPRINT_LINK,
privacy_link: import.meta.env.VITE_PRIVACY_LINK,
custom_login_message: import.meta.env.VITE_CUSTOM_LOGIN_MESSAGE,
};

View file

@ -14,7 +14,6 @@ export function flattenQueryResult(result: Array<QueryResult>): Array<{ [key: st
const newKey = prefix ? `${prefix}_${key}` : key; const newKey = prefix ? `${prefix}_${key}` : key;
if (Array.isArray(value) && value.every((item) => typeof item === "object" && item !== null)) { if (Array.isArray(value) && value.every((item) => typeof item === "object" && item !== null)) {
console.log(value, newKey);
const arrayResults: Array<{ [key: string]: FieldType }> = []; const arrayResults: Array<{ [key: string]: FieldType }> = [];
value.forEach((item) => { value.forEach((item) => {
const flattenedItems = flatten(item, newKey); const flattenedItems = flatten(item, newKey);
@ -29,7 +28,6 @@ export function flattenQueryResult(result: Array<QueryResult>): Array<{ [key: st
}); });
results = tempResults; results = tempResults;
} else if (value && typeof value === "object" && !Array.isArray(value)) { } else if (value && typeof value === "object" && !Array.isArray(value)) {
console.log(value, newKey);
const objResults = flatten(value as QueryResult, newKey); const objResults = flatten(value as QueryResult, newKey);
const tempResults: Array<{ [key: string]: FieldType }> = []; const tempResults: Array<{ [key: string]: FieldType }> = [];
results.forEach((res) => { results.forEach((res) => {

View file

@ -9,6 +9,7 @@ import type { PermissionType, PermissionSection, PermissionModule } from "@/type
import { resetMemberStores, setMemberId } from "./memberGuard"; import { resetMemberStores, setMemberId } from "./memberGuard";
import { resetProtocolStores, setProtocolId } from "./protocolGuard"; import { resetProtocolStores, setProtocolId } from "./protocolGuard";
import { resetNewsletterStores, setNewsletterId } from "./newsletterGuard"; import { resetNewsletterStores, setNewsletterId } from "./newsletterGuard";
import { config } from "../config";
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
@ -581,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,
},
],
},
], ],
}, },
{ {
@ -621,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",
@ -683,6 +719,10 @@ const router = createRouter({
], ],
}); });
router.afterEach((to, from) => {
document.title = config.app_name_overwrite || "FF Admin";
});
export default router; export default router;
declare module "vue-router" { declare module "vue-router" {

View file

@ -9,8 +9,6 @@ export async function setNewsletterId(to: any, from: any, next: any) {
useNewsletterDatesStore().$reset(); useNewsletterDatesStore().$reset();
useNewsletterRecipientsStore().$reset(); useNewsletterRecipientsStore().$reset();
useNewsletterPrintoutStore().unsubscribePdfPrintingProgress();
useNewsletterPrintoutStore().unsubscribeMailSendingProgress();
useNewsletterPrintoutStore().$reset(); useNewsletterPrintoutStore().$reset();
next(); next();
@ -23,8 +21,6 @@ export async function resetNewsletterStores(to: any, from: any, next: any) {
useNewsletterDatesStore().$reset(); useNewsletterDatesStore().$reset();
useNewsletterRecipientsStore().$reset(); useNewsletterRecipientsStore().$reset();
useNewsletterPrintoutStore().unsubscribePdfPrintingProgress();
useNewsletterPrintoutStore().unsubscribeMailSendingProgress();
useNewsletterPrintoutStore().$reset(); useNewsletterPrintoutStore().$reset();
next(); next();

View file

@ -3,11 +3,12 @@ import { isAuthenticatedPromise, type Payload } from "./router/authGuard";
import router from "./router"; import router from "./router";
import { useNotificationStore } from "./stores/notification"; import { useNotificationStore } from "./stores/notification";
import { EventSourcePolyfill } from "event-source-polyfill"; import { EventSourcePolyfill } from "event-source-polyfill";
import { config } from "./config";
let devMode = process.env.NODE_ENV === "development"; let devMode = process.env.NODE_ENV === "development";
let host = devMode ? "localhost:5000" : process.env.SERVER_ADDRESS || ""; let host = devMode ? "localhost:5000" : (config.server_address ?? "").replace(/(^\w+:|^)\/\//, "");
let url = devMode ? "http://" + host : (host ? "https://" : "") + host; let url = devMode ? "http://" + host : config.server_address;
const http = axios.create({ const http = axios.create({
baseURL: url + "/api", baseURL: url + "/api",
@ -27,6 +28,15 @@ http.interceptors.request.use(
} }
} }
const isPWA =
window.matchMedia("(display-mode: standalone)").matches ||
window.matchMedia("(display-mode: fullscreen)").matches;
if (isPWA) {
if (config.headers) {
config.headers["X-PWA-Client"] = isPWA ? "true" : "false";
}
}
return config; return config;
}, },
(error) => { (error) => {
@ -52,11 +62,15 @@ http.interceptors.response.use(
.then(() => { .then(() => {
return http(originalRequest); return http(originalRequest);
}) })
.catch(); .catch(() => {});
} }
const notificationStore = useNotificationStore(); const notificationStore = useNotificationStore();
if (error.toString().includes("Network Error")) {
notificationStore.push("Netzwerkfehler", "Server nicht erreichbar!", "error");
} else {
notificationStore.push("Fehler", error.response.data, "error"); notificationStore.push("Fehler", error.response.data, "error");
}
return Promise.reject(error); return Promise.reject(error);
} }
@ -98,4 +112,25 @@ function newEventSource(path: string) {
}); });
} }
export { http, newEventSource, host }; async function* streamingFetch(path: string, abort?: AbortController) {
await refreshToken()
.then(() => {})
.catch(() => {});
const token = localStorage.getItem("accessToken");
const response = await fetch(url + "/api" + path, {
signal: abort?.signal,
headers: {
Authorization: `Bearer ${token}`,
},
});
const reader = response.body?.getReader();
while (true && reader) {
const { done, value } = await reader.read();
if (done) break;
yield new TextDecoder().decode(value);
}
}
export { http, newEventSource, streamingFetch, host };

View file

@ -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",
}); });
} },
}, },
}); });

View file

@ -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}`);

View file

@ -1,8 +1,9 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { http, newEventSource } from "@/serverCom"; import { http, newEventSource, streamingFetch } from "@/serverCom";
import { useNewsletterStore } from "./newsletter"; import { useNewsletterStore } from "./newsletter";
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
import type { EventSourcePolyfill } from "event-source-polyfill"; import type { EventSourcePolyfill } from "event-source-polyfill";
import { useNotificationStore, type NotificationType } from "../../../notification";
export const useNewsletterPrintoutStore = defineStore("newsletterPrintout", { export const useNewsletterPrintoutStore = defineStore("newsletterPrintout", {
state: () => { state: () => {
@ -12,10 +13,10 @@ export const useNewsletterPrintoutStore = defineStore("newsletterPrintout", {
printing: undefined as undefined | "loading" | "success" | "failed", printing: undefined as undefined | "loading" | "success" | "failed",
sending: undefined as undefined | "loading" | "success" | "failed", sending: undefined as undefined | "loading" | "success" | "failed",
sendingPreview: undefined as undefined | "loading" | "success" | "failed", sendingPreview: undefined as undefined | "loading" | "success" | "failed",
pdfProgessSource: undefined as undefined | EventSourcePolyfill, pdfSourceMessages: [] as Array<{ kind: string; factor: string; [key: string]: string }>,
mailProgessSource: undefined as undefined | EventSourcePolyfill, mailSourceMessages: [] as Array<{ kind: string; factor: string; [key: string]: string }>,
pdfSourceMessages: [] as Array<Object>, pdfPrintingAbort: undefined as undefined | AbortController,
mailSourceMessages: [] as Array<Object>, mailSendingAbort: undefined as undefined | AbortController,
}; };
}, },
actions: { actions: {
@ -63,6 +64,7 @@ export const useNewsletterPrintoutStore = defineStore("newsletterPrintout", {
}); });
}, },
createNewsletterPrintout() { createNewsletterPrintout() {
this.subscribePdfPrintingProgress();
this.printing = "loading"; this.printing = "loading";
const newsletterId = useNewsletterStore().activeNewsletter; const newsletterId = useNewsletterStore().activeNewsletter;
if (newsletterId == null) return; if (newsletterId == null) return;
@ -78,10 +80,12 @@ export const useNewsletterPrintoutStore = defineStore("newsletterPrintout", {
.finally(() => { .finally(() => {
setTimeout(() => { setTimeout(() => {
this.printing = undefined; this.printing = undefined;
this.pdfPrintingAbort?.abort();
}, 1500); }, 1500);
}); });
}, },
createNewsletterSend() { createNewsletterSend() {
this.subscribeMailSendingProgress();
this.sending = "loading"; this.sending = "loading";
const newsletterId = useNewsletterStore().activeNewsletter; const newsletterId = useNewsletterStore().activeNewsletter;
if (newsletterId == null) return; if (newsletterId == null) return;
@ -96,32 +100,55 @@ export const useNewsletterPrintoutStore = defineStore("newsletterPrintout", {
.finally(() => { .finally(() => {
setTimeout(() => { setTimeout(() => {
this.sending = undefined; this.sending = undefined;
this.mailSendingAbort?.abort();
}, 1500); }, 1500);
}); });
}, },
subscribePdfPrintingProgress() { async subscribePdfPrintingProgress() {
// const newsletterId = useNewsletterStore().activeNewsletter; this.pdfSourceMessages = [];
// if (this.pdfProgessSource != undefined) return; const newsletterId = useNewsletterStore().activeNewsletter;
// this.pdfProgessSource = newEventSource(`/admin/newsletter/${newsletterId}/printoutprogress`); const notificationStore = useNotificationStore();
// this.pdfProgessSource.onmessage = (event) => { this.pdfPrintingAbort = new AbortController();
// console.log("pdf", event); for await (let chunk of streamingFetch(
// }; `/admin/newsletter/${newsletterId}/printoutprogress`,
this.pdfPrintingAbort
)) {
chunk.split("//").forEach((r) => {
if (r.trim() != "") {
let data = JSON.parse(r);
this.pdfSourceMessages.push(data);
let type: NotificationType = "info";
let timeout = undefined;
if (data.factor == "failed") {
type = "error";
timeout = 0;
}
notificationStore.push(`Druck: ${data.iteration}/${data.total}`, `${data.msg}`, type, timeout);
}
});
this.fetchNewsletterPrintout();
}
}, },
subscribeMailSendingProgress() { async subscribeMailSendingProgress() {
// const newsletterId = useNewsletterStore().activeNewsletter; this.mailSourceMessages = [];
// if (this.mailProgessSource != undefined) return; const newsletterId = useNewsletterStore().activeNewsletter;
// this.mailProgessSource = newEventSource(`/admin/newsletter/${newsletterId}/sendprogress`); const notificationStore = useNotificationStore();
// this.mailProgessSource.onmessage = (event) => { this.mailSendingAbort = new AbortController();
// console.log("mail", event); for await (let chunk of streamingFetch(`/admin/newsletter/${newsletterId}/sendprogress`, this.mailSendingAbort)) {
// }; chunk.split("//").forEach((r) => {
}, if (r.trim() != "") {
unsubscribePdfPrintingProgress() { let data = JSON.parse(r);
this.pdfProgessSource?.close(); this.mailSourceMessages.push(data);
this.pdfProgessSource = undefined; let type: NotificationType = "info";
}, let timeout = undefined;
unsubscribeMailSendingProgress() { if (data.factor == "failed") {
this.mailProgessSource?.close(); type = "error";
this.mailProgessSource = undefined; timeout = 0;
}
notificationStore.push(`Mailversand: ${data.iteration}/${data.total}`, `${data.msg}`, type, timeout);
}
});
}
}, },
}, },
}); });

View file

@ -38,7 +38,6 @@ export const useProtocolDecisionStore = defineStore("protocolDecision", {
this.loading = "fetched"; this.loading = "fetched";
}) })
.catch((err) => { .catch((err) => {
console.log(err);
this.loading = "failed"; this.loading = "failed";
}); });
}, },

View file

@ -63,7 +63,7 @@ export const useNavigationStore = defineStore("navigation", {
{ {
key: "settings", key: "settings",
title: "Einstellungen", title: "Einstellungen",
levelDefault: "qualification", levelDefault: "award",
} as topLevelNavigationModel, } as topLevelNavigationModel,
] ]
: []), : []),
@ -98,30 +98,32 @@ export const useNavigationStore = defineStore("navigation", {
settings: { settings: {
mainTitle: "Einstellungen", mainTitle: "Einstellungen",
main: [ main: [
...(abilityStore.can("read", "settings", "qualification") { key: "divider1", title: "Mitgliederdaten" },
? [{ key: "qualification", title: "Qualifikationen" }]
: []),
...(abilityStore.can("read", "settings", "award") ? [{ key: "award", title: "Auszeichnungen" }] : []), ...(abilityStore.can("read", "settings", "award") ? [{ key: "award", title: "Auszeichnungen" }] : []),
...(abilityStore.can("read", "settings", "executive_position")
? [{ key: "executive_position", title: "Vereinsämter" }]
: []),
...(abilityStore.can("read", "settings", "communication_type") ...(abilityStore.can("read", "settings", "communication_type")
? [{ key: "communication_type", title: "Kommunikationsarten" }] ? [{ key: "communication_type", title: "Kommunikationsarten" }]
: []), : []),
...(abilityStore.can("read", "settings", "membership_status") ...(abilityStore.can("read", "settings", "membership_status")
? [{ key: "membership_status", title: "Mitgliedsstatus" }] ? [{ key: "membership_status", title: "Mitgliedsstatus" }]
: []), : []),
...(abilityStore.can("read", "settings", "calendar_type") ...(abilityStore.can("read", "settings", "qualification")
? [{ key: "calendar_type", title: "Terminarten" }] ? [{ key: "qualification", title: "Qualifikationen" }]
: []),
...(abilityStore.can("read", "settings", "executive_position")
? [{ key: "executive_position", title: "Vereinsämter" }]
: []),
{ key: "divider2", title: "Einstellungen" },
...(abilityStore.can("read", "settings", "newsletter_config")
? [{ key: "newsletter_config", title: "Newsletter Konfiguration" }]
: []), : []),
...(abilityStore.can("read", "settings", "query") ? [{ key: "query_store", title: "Query Store" }] : []),
...(abilityStore.can("read", "settings", "template") ? [{ key: "template", title: "Templates" }] : []), ...(abilityStore.can("read", "settings", "template") ? [{ key: "template", title: "Templates" }] : []),
...(abilityStore.can("read", "settings", "template_usage") ...(abilityStore.can("read", "settings", "template_usage")
? [{ key: "template_usage", title: "Template-Verwendung" }] ? [{ key: "template_usage", title: "Template-Verwendung" }]
: []), : []),
...(abilityStore.can("read", "settings", "newsletter_config") ...(abilityStore.can("read", "settings", "calendar_type")
? [{ key: "newsletter_config", title: "Newsletter Konfiguration" }] ? [{ key: "calendar_type", title: "Terminarten" }]
: []), : []),
...(abilityStore.can("read", "settings", "query") ? [{ key: "query_store", title: "Query Store" }] : []),
], ],
}, },
user: { user: {
@ -129,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;

View 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;
},
},
});

View file

@ -1,7 +1,7 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
export interface Notification { export interface Notification {
id: number; id: string;
title: string; title: string;
text: string; text: string;
type: NotificationType; type: NotificationType;
@ -19,7 +19,7 @@ export const useNotificationStore = defineStore("notification", {
}, },
actions: { actions: {
push(title: string, text: string, type: NotificationType, timeout: number = 5000) { push(title: string, text: string, type: NotificationType, timeout: number = 5000) {
let id = Date.now(); let id = `${Date.now()}_${Math.random()}`;
this.notifications.push({ this.notifications.push({
id, id,
title, title,
@ -27,14 +27,16 @@ export const useNotificationStore = defineStore("notification", {
type, type,
indicator: false, indicator: false,
}); });
if (timeout != 0) {
setTimeout(() => { setTimeout(() => {
this.notifications[this.notifications.findIndex((n) => n.id === id)].indicator = true; this.notifications[this.notifications.findIndex((n) => n.id === id)].indicator = true;
}, 100); }, 100);
this.timeouts[id] = setTimeout(() => { this.timeouts[id] = setTimeout(() => {
this.revoke(id); this.revoke(id);
}, timeout); }, timeout);
}
}, },
revoke(id: number) { revoke(id: string) {
this.notifications.splice( this.notifications.splice(
this.notifications.findIndex((n) => n.id === id), this.notifications.findIndex((n) => n.id === id),
1 1

View file

@ -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"],
}; };

View file

@ -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;

View file

@ -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;

View file

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

View file

@ -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;
}

View 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;
}

View file

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

View file

@ -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">

View file

@ -10,16 +10,7 @@
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<img :src="image" alt="totp" class="w-56 h-56 self-center" /> <img :src="image" alt="totp" class="w-56 h-56 self-center" />
<div class="flex relative"> <TextCopy :copyText="otp" />
<input type="text" :value="otp" />
<ClipboardIcon
class="w-5 h-5 p-2 box-content absolute right-1 top-1/2 -translate-y-1/2 bg-white cursor-pointer"
@click="copyToClipboard"
/>
<div v-if="copySuccess" class="absolute w-5 h-5 right-3 top-[10px]">
<SuccessCheckmark />
</div>
</div>
</div> </div>
<form class="flex flex-col gap-2" @submit.prevent="verify"> <form class="flex flex-col gap-2" @submit.prevent="verify">
<div class="-space-y-px"> <div class="-space-y-px">
@ -44,13 +35,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { defineComponent, markRaw, defineAsyncComponent } from "vue"; import { defineComponent } from "vue";
import { mapActions, mapState } from "pinia"; import { mapActions, mapState } from "pinia";
import MainTemplate from "@/templates/Main.vue"; import MainTemplate from "@/templates/Main.vue";
import Spinner from "@/components/Spinner.vue"; 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 { ClipboardIcon } from "@heroicons/vue/24/outline"; import TextCopy from "@/components/TextCopy.vue";
</script> </script>
<script lang="ts"> <script lang="ts">
@ -62,8 +53,6 @@ export default defineComponent({
otp: undefined as undefined | string, otp: undefined as undefined | string,
verifyStatus: undefined as undefined | "loading" | "success" | "failed", verifyStatus: undefined as undefined | "loading" | "success" | "failed",
verifyError: "" as string, verifyError: "" as string,
copySuccess: false,
timeoutCopy: undefined as any,
}; };
}, },
mounted() { mounted() {
@ -100,13 +89,6 @@ export default defineComponent({
}, 2000); }, 2000);
}); });
}, },
copyToClipboard() {
navigator.clipboard.writeText(this.otp ?? "");
this.copySuccess = true;
this.timeoutCopy = setTimeout(() => {
this.copySuccess = false;
}, 2000);
},
}, },
}); });
</script> </script>

View 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>

View file

@ -1,13 +1,22 @@
<template> <template>
<SidebarLayout> <SidebarLayout>
<template #sidebar> <template #sidebar>
<SidebarTemplate mainTitle="Mein Account" topTitle="Mitgliederverwaltung" :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,6 +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";
</script> </script>
<script lang="ts"> <script lang="ts">

View file

@ -16,13 +16,15 @@
/> />
</template> </template>
<template #list> <template #list>
<div v-for="item in activeNavigationObject.main" :key="item.key">
<RoutingLink <RoutingLink
v-for="item in activeNavigationObject.main" v-if="!item.key.includes('divider')"
:key="item.key"
:title="item.title" :title="item.title"
:link="{ name: `admin-${activeNavigation}-${item.key}` }" :link="{ name: `admin-${activeNavigation}-${item.key}` }"
:active="activeLink == item.key" :active="activeLink == item.key"
/> />
<p v-else class="pt-4 border-b border-gray-300">{{ item.title }}</p>
</div>
</template> </template>
</SidebarTemplate> </SidebarTemplate>
</template> </template>

View file

@ -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>

Some files were not shown because too many files have changed in this diff Show more