Compare commits

..

87 commits
v1.3.4 ... main

Author SHA1 Message Date
f3913a906c 1.5.3 2025-05-19 13:26:46 +02:00
cfe621debd Merge pull request 'patches v1.5.3' () from develop into main
Reviewed-on: 
2025-05-19 11:25:49 +00:00
5b7a9a3ace Merge branch 'main' into develop 2025-05-17 05:36:51 +00:00
12b1d08ea4 enhance: navigation optimization 2025-05-16 13:34:24 +02:00
04c01b6780 change: standardisation of UI 2025-05-16 13:32:40 +02:00
4ee16c624a enhance: permission handling 2025-05-16 11:12:18 +02:00
35fd8a8e82 enhance: unified ui 2025-05-16 10:27:38 +02:00
832f5053a0 1.5.2 2025-05-10 13:40:54 +02:00
362fc80891 Merge pull request 'patches v1.5.2' () from develop into main
Reviewed-on: 
2025-05-10 11:40:26 +00:00
2d35e2416b Merge branch 'main' into develop 2025-05-10 11:39:39 +00:00
738765bcb4 fix: possible reset of config store after redirect to login 2025-05-09 14:54:47 +02:00
a39044dffc change: remove logging 2025-05-08 08:17:14 +02:00
369a67abda 1.5.1 2025-05-07 09:29:50 +02:00
9bac6e2f97 Merge pull request 'patches v1.5.1' () from develop into main
Reviewed-on: 
2025-05-07 07:29:01 +00:00
f64397862c Merge branch 'main' into develop 2025-05-07 07:28:50 +00:00
18d52e4bab change: refactor imports 2025-05-07 09:20:32 +02:00
b4fdd5fc60 enhance: permission handling 2025-05-07 09:05:25 +02:00
c17355fcd1 change: request method for account credential change 2025-05-07 08:27:31 +02:00
fa5fb54876 update: ReadMe 2025-05-07 08:27:03 +02:00
ccc6d47d9e 1.5.0 2025-05-06 09:53:15 +02:00
0be649c3ba Merge pull request 'minor v1.5.0' () from develop into main
Reviewed-on: 
2025-05-06 07:52:22 +00:00
424f4772f0 Merge branch 'main' into develop 2025-05-06 07:52:03 +00:00
f01c895d30 update all packages 2025-05-06 09:30:50 +02:00
f04fabefb0 Merge pull request 'feature/#66-static-user-login' () from feature/#66-static-user-login into develop
Reviewed-on: 
2025-05-06 07:20:18 +00:00
625a2df308 fix: temporary fix on tailwind hover 2025-05-06 09:17:26 +02:00
f65b3108ee check if password and repeat match 2025-05-06 09:02:55 +02:00
196a92325a fix: redirect to nopermission screen 2025-05-06 08:38:49 +02:00
b39198c935 enable password on invite or reset 2025-05-06 08:38:28 +02:00
ee52363bde enable switch to pw totp in account settings 2025-05-05 17:44:03 +02:00
63d97d0b83 login by password or totp 2025-05-05 14:21:22 +02:00
9cf2cf2d80 fix: compatability layer for query builder joins 2025-05-02 09:16:29 +02:00
fa8f051252 Merge pull request 'feature/#67-settings-store' () from feature/#67-settings-store into develop
Reviewed-on: 
2025-05-01 15:48:37 +00:00
d5193842d2 Merge branch 'develop' into feature/#67-settings-store 2025-04-30 10:29:18 +00:00
bbf5b65aab enhance: provide latest inserted internal Id 2025-04-30 12:22:41 +02:00
eb622658d9 no form reset on failure 2025-04-30 11:36:48 +02:00
751370fed4 reload images if updated 2025-04-30 10:51:07 +02:00
939c982c40 image upload and keep if not changed 2025-04-30 10:43:11 +02:00
91ede95530 Image Upload 2025-04-29 18:32:08 +02:00
0771b43f56 change url 2025-04-29 13:19:50 +02:00
06380e48c5 Settings form and handling 2025-04-29 13:10:30 +02:00
6f155ada66 Display Settings 2025-04-28 14:36:47 +02:00
b7dd5a95cd settings Sceleton 2025-04-28 12:39:32 +02:00
9bd663f266 base settings operations 2025-04-26 09:21:27 +02:00
beaf6a5926 base structure settings inside Admin UI 2025-04-25 12:31:49 +02:00
e607f8c599 fix: false positive auth true by existing expired jwt 2025-04-25 12:22:04 +02:00
8880af2880 Setup wizard for Settings 2025-04-25 12:13:02 +02:00
5d9007f517 display app configuration values 2025-04-25 08:18:27 +02:00
20a2a3ccd0 move pwa manifest to backend 2025-04-24 16:49:14 +02:00
a20c0d3ed3 enhance: use QueryStoreId to fetch query 2025-04-19 10:05:01 +02:00
916e61897a Merge pull request 'feature/#87-newsletter-no-sendto-entry' () from feature/#87-newsletter-no-sendto-entry into develop
Reviewed-on: 
2025-04-19 07:43:49 +00:00
b19e8df561 add send none to newsletter config 2025-04-19 09:42:25 +02:00
fb78360946 show who does not have newsletter configured 2025-04-19 09:24:55 +02:00
caf1919930 1.4.1 2025-04-18 11:10:49 +02:00
d25fa07512 Merge pull request 'patches v1.4.1' () from develop into main
Reviewed-on: 
2025-04-18 09:10:26 +00:00
48502efc1d Merge branch 'main' into develop 2025-04-18 09:10:15 +00:00
9a9742597a fix: calendar height after custom calendar header 2025-04-17 15:34:27 +02:00
ea38b1835c 1.4.0 2025-04-16 17:02:28 +02:00
802b7d25f0 Merge pull request 'minor v1.4.0' () from develop into main
Reviewed-on: 
2025-04-16 15:01:07 +00:00
d1bde66e1e Merge branch 'main' into develop 2025-04-16 15:00:49 +00:00
dea2a1c40f Merge pull request 'feature/#41-query-builder-joins' () from feature/#41-query-builder-joins into develop
Reviewed-on: 
2025-04-16 14:39:42 +00:00
f94cc8b365 deactivate custom join switch
deactivated custom join switch as data is not passed down
2025-04-16 16:37:24 +02:00
238a35da9f extend query builder by custom join 2025-04-16 16:11:10 +02:00
d39ebc5029 Merge pull request '#40-query-builder-sorting' () from #40-query-builder-sorting into develop
Reviewed-on: 
2025-04-15 08:47:14 +00:00
9a7785917c show changes detected 2025-04-15 10:45:15 +02:00
fc1185d1c8 upgrade query to ids by default 2025-04-15 10:29:25 +02:00
68b0aeffa8 show correct columns to table 2025-04-15 10:02:30 +02:00
8087108b90 change order of sorts 2025-04-15 09:35:08 +02:00
5ce7aa8a17 add global sort 2025-04-15 09:26:25 +02:00
d018f97274 Merge pull request 'feature/#69-calendar-view' () from feature/#69-calendar-view into develop
Reviewed-on: 
2025-04-14 07:18:01 +00:00
6d45325543 add styling option for wide calendar view 2025-04-14 09:17:32 +02:00
1296331796 extend calendar by list 2025-04-14 09:12:00 +02:00
303ce7a58d Merge pull request 'version-update' () from version-update into develop
Reviewed-on: 
2025-04-13 14:39:34 +00:00
796909a92e styling update 2025-04-13 16:28:31 +02:00
815d5c16fa upgrade tailwind 2025-04-12 15:17:44 +02:00
6494752058 update packages 2025-04-12 15:17:33 +02:00
e4c2f47eb0 1.3.6 2025-04-10 12:48:38 +02:00
387736721f Merge pull request 'patches v1.3.6' () from develop into main
Reviewed-on: 
2025-04-10 10:47:10 +00:00
2c61ca0c8c Merge branch 'main' into develop 2025-04-10 10:46:41 +00:00
552b6f6438 enhance: count of receivers in modal 2025-04-10 11:01:29 +02:00
7aa0db3684 enhance: prevent accidental newsletter job start 2025-04-10 08:29:25 +02:00
fdbf9e7f0a enhance: show newsletter recipients 2025-04-10 08:29:13 +02:00
517527258a 1.3.5 2025-04-07 15:34:46 +02:00
e68544a362 Merge pull request 'patches v1.3.5' () from develop into main
Reviewed-on: 
2025-04-07 13:33:29 +00:00
f8192a187b Merge branch 'main' into develop 2025-04-07 13:33:06 +00:00
d04dde688f fix: query builder query select 2025-04-07 15:30:12 +02:00
8ec3b04824 change: sw caching 2025-03-31 09:58:54 +02:00
b1daa7e64f change: make query id to uuid 2025-03-26 11:11:42 +01:00
200 changed files with 8695 additions and 4863 deletions
.env.example.env.productionDockerfileREADME.mddemo-totp-qrcode.pngentrypoint.shindex.htmlpackage-lock.jsonpackage.jsonpostcss.config.js
public
src
App.vue
components
AppIcon.vueAppLogo.vueCheckProgressBar.vueCustomCalendar.vueFormBottomBar.vueHeader.vuePagination.vueUserMenu.vue
account
admin
queryBuilder
setup
config.ts
enums
helpers
main.cssmain.ts
router
serverCom.ts
stores

View file

@ -1,5 +1 @@
VITE_SERVER_ADDRESS = backend_url #ohne pfad 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

View file

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

View file

@ -1,4 +1,4 @@
FROM node:18-alpine AS build FROM node:22-alpine AS build
WORKDIR /app WORKDIR /app

View file

@ -8,12 +8,9 @@ Dieses Repository dient hauptsächlich zur Verwaltung der Mitgliederdaten, aber
Eine Demo dieser Seite finden Sie unter [https://admin-demo.ff-admin.de](https://admin-demo.ff-admin.de). 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. Die Zugangsdaten (Lesebeschränkt) sind unterhalb dem Login angegeben.
Die Zugangsdaten (Lesebeschränkt) sind:\ Das Handbuch zur Anwendung finden sie unter [https://ff-admin.de/ff-admin-handbook](https://ff-admin.de/ff-admin-handbook).
EMAIL: demo-besucher\
TOTP: ![alt text](demo-totp-qrcode.png)\
TOTP-Code: FBMDAJKFOYQXM2DNH47GWWBGJ5KWOUCW
## Installation ## Installation
@ -29,17 +26,9 @@ services:
image: docker.registry.jk-effects.cloud/ehrenamt/ff-admin/app:latest image: docker.registry.jk-effects.cloud/ehrenamt/ff-admin/app:latest
container_name: ff_admin container_name: ff_admin
restart: unless-stopped restart: unless-stopped
#environment: #environment:
# - SERVERADDRESS=<backend_url (https://... | http://...)> # wichtig: ohne Pfad # - SERVERADDRESS=<backend_url (https://... | http://...)> # wichtig: ohne Pfad
# - APPNAMEOVERWRITE=<appname> # ersetzt den Namen FF-Admin auf der Login-Seite und sonstigen Positionen in der Oberfläche
# - IMPRINTLINK=<imprint link>
# - PRIVACYLINK=<privacy link>
# - CUSTOMLOGINMESSAGE=betrieben von xy
#volumes:
# - <volume|local path>/favicon.ico:/usr/share/nginx/html/favicon.ico # 48x48 px Auflösung
# - <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.

Binary file not shown.

Before

(image error) Size: 1.9 KiB

View file

@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
keys="SERVERADDRESS APPNAMEOVERWRITE IMPRINTLINK PRIVACYLINK CUSTOMLOGINMESSAGE" keys="SERVERADDRESS"
files="/usr/share/nginx/html/assets/config-*.js /usr/share/nginx/html/manifest.webmanifest" files="/usr/share/nginx/html/assets/config-*.js"
# Replace env vars in files served by NGINX # Replace env vars in files served by NGINX
for file in $files for file in $files
@ -12,11 +12,6 @@ do
# Get environment variable # Get environment variable
value=$(eval echo "\$$key") value=$(eval echo "\$$key")
# Set default value for APPNAMEOVERWRITE if empty
if [ "$key" = "APPNAMEOVERWRITE" ] && [ -z "$value" ]; then
value="FF Admin"
fi
echo "replace $key by $value" echo "replace $key by $value"
# replace __[variable_name]__ value with environment variable # replace __[variable_name]__ value with environment variable

View file

@ -2,11 +2,14 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon.png" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <!-- icon and manifest are provided by App.vue -->
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
<script>
// screen.orientation.lock("portrait-primary").catch(() => {});
</script>
</body> </body>
</html> </html>

8490
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{ {
"name": "ff-admin", "name": "ff-admin",
"version": "1.3.4", "version": "1.5.3",
"description": "Feuerwehr/Verein Mitgliederverwaltung UI", "description": "Feuerwehr/Verein Mitgliederverwaltung UI",
"type": "module", "type": "module",
"scripts": { "scripts": {
@ -24,17 +24,19 @@
"author": "JK Effects", "author": "JK Effects",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@fullcalendar/core": "^6.1.15", "@fullcalendar/core": "^6.1.17",
"@fullcalendar/daygrid": "^6.1.15", "@fullcalendar/daygrid": "^6.1.17",
"@fullcalendar/interaction": "^6.1.15", "@fullcalendar/interaction": "^6.1.17",
"@fullcalendar/timegrid": "^6.1.15", "@fullcalendar/list": "^6.1.17",
"@fullcalendar/vue3": "^6.1.15", "@fullcalendar/timegrid": "^6.1.17",
"@headlessui/vue": "^1.7.13", "@fullcalendar/vue3": "^6.1.17",
"@heroicons/vue": "^2.1.5", "@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.2.0",
"@tailwindcss/vite": "^4.1.5",
"@vueup/vue-quill": "^1.2.0", "@vueup/vue-quill": "^1.2.0",
"axios": "^1.7.9", "axios": "^1.9.0",
"event-source-polyfill": "^1.0.31", "event-source-polyfill": "^1.0.31",
"grapesjs": "^0.22.4", "grapesjs": "^0.22.7",
"grapesjs-preset-newsletter": "^1.0.2", "grapesjs-preset-newsletter": "^1.0.2",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
@ -44,49 +46,49 @@
"lodash.isequal": "^4.5.0", "lodash.isequal": "^4.5.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"markdown-it-anchor": "^9.2.0", "markdown-it-anchor": "^9.2.0",
"markdown-it-prism": "^2.3.0", "markdown-it-prism": "^3.0.0",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"pdf-dist": "^1.0.0", "pdf-dist": "^1.0.0",
"pinia": "^2.3.0", "pinia": "^3.0.2",
"qrcode": "^1.5.3", "pwacompat": "^2.0.17",
"qs": "^6.11.2", "qrcode": "^1.5.4",
"socket.io-client": "^4.5.0", "qs": "^6.14.0",
"unplugin-vue-markdown": "^0.28.0", "socket.io-client": "^4.8.1",
"uuid": "^9.0.0", "unplugin-vue-markdown": "^28.3.1",
"vue": "^3.4.29", "uuid": "^11.1.0",
"vue-router": "^4.3.3" "vue": "^3.5.13",
"vue-router": "^4.5.1"
}, },
"devDependencies": { "devDependencies": {
"@rushstack/eslint-patch": "^1.8.0", "@rushstack/eslint-patch": "^1.11.0",
"@tsconfig/node20": "^20.1.4", "@tailwindcss/postcss": "^4.1.5",
"@types/eslint": "~9.6.0", "@tsconfig/node20": "^20.1.5",
"@types/eslint": "~9.6.1",
"@types/event-source-polyfill": "^1.0.5", "@types/event-source-polyfill": "^1.0.5",
"@types/lodash.clonedeep": "^4.5.9", "@types/lodash.clonedeep": "^4.5.9",
"@types/lodash.difference": "^4.5.9", "@types/lodash.difference": "^4.5.9",
"@types/lodash.differencewith": "^4.5.9", "@types/lodash.differencewith": "^4.5.9",
"@types/lodash.isequal": "^4.5.8", "@types/lodash.isequal": "^4.5.8",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@types/node": "^20.14.5", "@types/node": "^22.15.12",
"@types/nprogress": "^0.2.0", "@types/nprogress": "^0.2.3",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@types/qs": "^6.9.11", "@types/qs": "^6.9.18",
"@types/uuid": "^9.0.3", "@types/uuid": "^10.0.0",
"@vite-pwa/assets-generator": "^0.2.2", "@vite-pwa/assets-generator": "^1.0.0",
"@vitejs/plugin-vue": "^5.0.5", "@vitejs/plugin-vue": "^5.2.3",
"@vue/eslint-config-prettier": "^9.0.0", "@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^13.0.0", "@vue/eslint-config-typescript": "^14.5.0",
"@vue/tsconfig": "^0.5.1", "@vue/tsconfig": "^0.7.0",
"autoprefixer": "^10.4.20", "eslint": "^9.26.0",
"eslint": "^8.57.0", "eslint-plugin-vue": "^10.1.0",
"eslint-plugin-vue": "^9.23.0", "npm-run-all2": "^8.0.1",
"npm-run-all2": "^6.2.0", "prettier": "^3.5.3",
"postcss": "^8.4.41", "tailwindcss": "^4.1.5",
"prettier": "^3.2.5", "typescript": "^5.8.3",
"tailwindcss": "^3.4.10", "vite": "^6.3.5",
"typescript": "~5.4.0", "vite-plugin-pwa": "^1.0.0",
"vite": "^5.3.1", "vite-plugin-vue-devtools": "^7.7.6",
"vite-plugin-pwa": "^0.17.4", "vue-tsc": "^2.2.10"
"vite-plugin-vue-devtools": "^7.6.8",
"vue-tsc": "^2.0.21"
} }
} }

View file

@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

Before

(image error) Size: 34 KiB

Binary file not shown.

Before

Width: 48px  |  Height: 48px  |  Size: 9.4 KiB

Binary file not shown.

Before

(image error) Size: 29 KiB

View file

@ -8,6 +8,12 @@
</div> </div>
<Footer @contextmenu.prevent /> <Footer @contextmenu.prevent />
<Notification /> <Notification />
<Teleport to="head">
<title>{{ clubName }}</title>
<link rel="icon" type="image/ico" :href="config.server_address + '/api/public/favicon.ico'" />
<link rel="manifest" :href="config.server_address + '/api/public/manifest.webmanifest'" />
</Teleport>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -15,20 +21,27 @@ import { defineComponent } from "vue";
import { RouterView } from "vue-router"; import { RouterView } from "vue-router";
import Header from "./components/Header.vue"; import Header from "./components/Header.vue";
import Footer from "./components/Footer.vue"; import Footer from "./components/Footer.vue";
import { mapState } from "pinia"; import { mapActions, mapState } from "pinia";
import { useAuthStore } from "./stores/auth"; import { useAuthStore } from "./stores/auth";
import { isAuthenticatedPromise } from "./router/authGuard"; import { isAuthenticatedPromise } from "./router/authGuard";
import ContextMenu from "./components/ContextMenu.vue"; import ContextMenu from "./components/ContextMenu.vue";
import Modal from "./components/Modal.vue"; import Modal from "./components/Modal.vue";
import Notification from "./components/Notification.vue"; import Notification from "./components/Notification.vue";
import { config } from "./config";
import { useConfigurationStore } from "@/stores/configuration";
import { resetAllPiniaStores } from "@/helpers/piniaReset";
</script> </script>
<script lang="ts"> <script lang="ts">
export default defineComponent({ export default defineComponent({
computed: { computed: {
...mapState(useAuthStore, ["authCheck"]), ...mapState(useAuthStore, ["authCheck"]),
...mapState(useConfigurationStore, ["clubName"]),
}, },
mounted() { mounted() {
resetAllPiniaStores();
this.configure();
if (!this.authCheck && localStorage.getItem("access_token")) { if (!this.authCheck && localStorage.getItem("access_token")) {
isAuthenticatedPromise().catch(() => { isAuthenticatedPromise().catch(() => {
localStorage.removeItem("access_token"); localStorage.removeItem("access_token");
@ -36,5 +49,8 @@ export default defineComponent({
}); });
} }
}, },
methods: {
...mapActions(useConfigurationStore, ["configure"]),
},
}); });
</script> </script>

View file

@ -0,0 +1,26 @@
<template>
<img ref="icon" :src="url + '/api/public/icon.png'" alt="LOGO" class="h-full w-auto" />
</template>
<script setup lang="ts">
import { url } from "@/serverCom";
import { useSettingStore } from "@/stores/admin/management/setting";
import { mapState } from "pinia";
import { defineComponent } from "vue";
</script>
<script lang="ts">
export default defineComponent({
watch: {
icon() {
(this.$refs.icon as HTMLImageElement).src = url + "/api/public/icon.png?" + new Date().getTime();
},
},
computed: {
...mapState(useSettingStore, ["readSetting"]),
icon() {
return this.readSetting("club.icon");
},
},
});
</script>

View file

@ -0,0 +1,26 @@
<template>
<img ref="logo" :src="url + '/api/public/applogo.png'" alt="LOGO" class="h-full w-auto" />
</template>
<script setup lang="ts">
import { url } from "@/serverCom";
import { useSettingStore } from "@/stores/admin/management/setting";
import { mapState } from "pinia";
import { defineComponent } from "vue";
</script>
<script lang="ts">
export default defineComponent({
watch: {
logo() {
(this.$refs.logo as HTMLImageElement).src = url + "/api/public/applogo.png?t=" + new Date().getTime();
},
},
computed: {
...mapState(useSettingStore, ["readSetting"]),
logo() {
return this.readSetting("club.logo");
},
},
});
</script>

View file

@ -0,0 +1,53 @@
<template>
<div class="w-full flex flex-row items-center">
<div class="contents" v-for="(i, index) in total" :key="index">
<div
v-if="index <= successfull && index != step"
class="relative flex items-center justify-center h-8 w-8 border-4 border-success rounded-full"
>
<SuccessCheckmark class="h-8! asolute top-0 m-0!" />
</div>
<div
v-else-if="index <= step"
class="flex items-center justify-center h-8 w-8 border-4 border-success rounded-full"
>
<div class="h-2 w-2 border-4 border-success bg-success rounded-full"></div>
</div>
<div v-else class="h-8 w-8 border-4 border-gray-400 rounded-full"></div>
<div v-if="i != total" class="grow border-2" :class="index < step ? ' border-success' : 'border-gray-400'"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import SuccessCheckmark from "./SuccessCheckmark.vue";
</script>
<script lang="ts">
export default defineComponent({
props: {
step: {
type: Number,
default: 0,
validator(value: number) {
return value >= 0;
},
},
successfull: {
type: Number,
default: 0,
validator(value: number) {
return value >= 0;
},
},
total: {
type: Number,
default: 1,
validator(value: number) {
return value >= 1;
},
},
},
});
</script>

View file

@ -0,0 +1,181 @@
<template>
<div class="flex flex-col w-full h-full gap-2 justify-between overflow-hidden">
<div
class="flex flex-row gap-2 justify-between max-sm:justify-center"
:class="smallStyling ? 'max-lg:flex-wrap' : 'max-xl:flex-wrap'"
>
<div class="flex flex-row" :class="smallStyling ? 'max-lg:order-2' : 'max-xl:order-2'">
<button
:primary="view == 'dayGridMonth'"
:primary-outline="view != 'dayGridMonth'"
class="rounded-r-none!"
@click="setView('dayGridMonth')"
>
Monat
</button>
<button
:primary="view == 'timeGridWeek'"
:primary-outline="view != 'timeGridWeek'"
class="rounded-none! border-x-0!"
@click="setView('timeGridWeek')"
>
Woche
</button>
<button
:primary="view == 'listMonth'"
:primary-outline="view != 'listMonth'"
class="rounded-l-none!"
@click="setView('listMonth')"
>
Liste
</button>
</div>
<p class="text-3xl w-full text-center" :class="smallStyling ? 'max-lg:order-1' : 'max-xl:order-1'">
{{ currentTitle }}
</p>
<div class="flex flex-row" :class="smallStyling ? 'max-lg:order-3' : 'max-xl:order-3'">
<button primary-outline class="rounded-r-none!" @click="navigateView('prev')">
<ChevronLeftIcon />
</button>
<button
:primary="containsToday"
:primary-outline="!containsToday"
class="rounded-none! border-x-0!"
@click="
calendarApi?.today();
containsToday = true;
"
>
heute
</button>
<button primary-outline class="rounded-l-none!" @click="navigateView('next')">
<ChevronRightIcon />
</button>
</div>
</div>
<div class="flex flex-col w-full grow overflow-hidden">
<FullCalendar ref="fullCalendar" :options="calendarOptions" class="max-h-full h-full" />
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent, type PropType } from "vue";
import FullCalendar from "@fullcalendar/vue3";
import deLocale from "@fullcalendar/core/locales/de";
import dayGridPlugin from "@fullcalendar/daygrid";
import timeGridPlugin from "@fullcalendar/timegrid";
import listPlugin from "@fullcalendar/list";
import interactionPlugin from "@fullcalendar/interaction";
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/vue/24/outline";
import type { CalendarOptions } from "@fullcalendar/core/index.js";
</script>
<script lang="ts">
export default defineComponent({
props: {
items: {
type: Array as PropType<
{
id: string;
title: string;
start: string;
end: string;
backgroundColor: string;
}[]
>,
default: [],
},
allowInteraction: {
type: Boolean,
default: true,
},
smallStyling: {
type: Boolean,
default: false,
},
},
emits: {
dateSelect: ({ start, end, allDay }: { start: string; end: string; allDay: boolean }) => {
return typeof start == "string" && typeof end == "string" && typeof allDay === "boolean";
},
eventSelect: (id: string) => {
return typeof id == "string";
},
},
data() {
return {
view: "dayGridMonth" as "dayGridMonth" | "timeGridWeek" | "listMonth",
calendarApi: null as null | typeof FullCalendar,
currentTitle: "" as string,
containsToday: false as boolean,
};
},
computed: {
calendarOptions() {
return {
timeZone: "local",
locale: deLocale,
plugins: [dayGridPlugin, timeGridPlugin, listPlugin, interactionPlugin],
initialView: "dayGridMonth",
eventDisplay: "block",
headerToolbar: false,
weekends: true,
editable: this.allowInteraction,
selectable: this.allowInteraction,
selectMirror: false,
dayMaxEvents: true,
weekNumbers: true,
displayEventTime: true,
nowIndicator: true,
weekText: "KW",
allDaySlot: false,
events: this.items,
select: this.select,
eventClick: this.eventClick,
} as CalendarOptions;
},
},
mounted() {
this.calendarApi = (this.$refs.fullCalendar as typeof FullCalendar).getApi();
this.setTitle();
this.setContainsToday();
},
methods: {
setTitle() {
this.currentTitle = this.calendarApi?.view.title ?? "";
},
setView(view: "dayGridMonth" | "timeGridWeek" | "listMonth") {
this.calendarApi?.changeView(view);
this.view = view;
this.setTitle();
this.setContainsToday();
},
navigateView(change: "prev" | "next") {
if (change == "prev") {
this.calendarApi?.prev();
} else {
this.calendarApi?.next();
}
this.setTitle();
this.setContainsToday();
},
setContainsToday() {
const start = this.calendarApi?.view.currentStart;
const end = this.calendarApi?.view.currentEnd;
const today = new Date();
this.containsToday = today >= start && today < end;
},
select(e: any) {
this.$emit("dateSelect", {
start: e?.startStr ?? new Date().toISOString(),
end: e?.endStr ?? new Date().toISOString(),
allDay: e?.allDay ?? false,
});
},
eventClick(e: any) {
this.$emit("eventSelect", e.event.id);
},
},
});
</script>

View file

@ -1,10 +1,11 @@
<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"> <p v-if="appCustom_login_message">{{ appCustom_login_message }}</p>
<a v-if="config.imprint_link" :href="config.imprint_link" target="_blank">Datenschutz</a> <div class="flex flex-row gap-2 justify-center mb-3">
<a v-if="config.privacy_link" :href="config.privacy_link" target="_blank">Impressum</a> <a v-if="clubWebsite" :href="clubWebsite" target="_blank">Webseite</a>
<a v-if="clubImprint" :href="clubImprint" target="_blank">Datenschutz</a>
<a v-if="clubPrivacy" :href="clubPrivacy" target="_blank">Impressum</a>
</div> </div>
<p v-if="config.custom_login_message">{{ config.custom_login_message }}</p>
<p> <p>
<a href="https://ff-admin.de/admin" target="_blank">FF Admin</a> <a href="https://ff-admin.de/admin" target="_blank">FF Admin</a>
entwickelt von entwickelt von
@ -14,5 +15,21 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { config } from "@/config"; import { defineComponent } from "vue";
import { mapActions, mapState } from "pinia";
import { useConfigurationStore } from "@/stores/configuration";
</script>
<script lang="ts">
export default defineComponent({
computed: {
...mapState(useConfigurationStore, [
"appCustom_login_message",
"appShow_link_to_calendar",
"clubImprint",
"clubPrivacy",
"clubWebsite",
]),
},
});
</script> </script>

View file

@ -1,9 +1,9 @@
<template> <template>
<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-xs">
<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" /> <AppLogo />
<h1 v-if="false" class="font-bold text-3xl w-fit whitespace-nowrap"> <h1 v-if="false" class="font-bold text-3xl w-fit whitespace-nowrap">
{{ config.app_name_overwrite || "FF Admin" }} {{ clubName }}
</h1> </h1>
</RouterLink> </RouterLink>
<div class="flex flex-row gap-2 items-center"> <div class="flex flex-row gap-2 items-center">
@ -37,15 +37,17 @@ 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">
import { defineComponent } from "vue"; import { defineComponent } from "vue";
import AppLogo from "./AppLogo.vue";
import { useConfigurationStore } from "@/stores/configuration";
export default defineComponent({ export default defineComponent({
computed: { computed: {
...mapState(useAuthStore, ["authCheck"]), ...mapState(useAuthStore, ["authCheck"]),
...mapState(useNavigationStore, ["topLevel"]), ...mapState(useNavigationStore, ["topLevel"]),
...mapState(useConfigurationStore, ["clubName"]),
routeName() { routeName() {
return typeof this.$route.name == "string" ? this.$route.name : ""; return typeof this.$route.name == "string" ? this.$route.name : "";
}, },

View file

@ -4,7 +4,7 @@
<Spinner v-if="deferingSearch" /> <Spinner v-if="deferingSearch" />
<input <input
type="text" type="text"
class="!max-w-64 !w-64 rounded-md shadow-sm relative block px-3 py-2 pr-5 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm" class="max-w-64! w-64! rounded-md shadow-xs relative block px-3 py-2 pr-5 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-hidden focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="Suche" placeholder="Suche"
v-model="searchString" v-model="searchString"
/> />

View file

@ -13,7 +13,7 @@
leave-to-class="transform scale-95 opacity-0" leave-to-class="transform scale-95 opacity-0"
> >
<MenuItems <MenuItems
class="absolute right-0 mt-2 w-56 z-20 origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none" class="absolute right-0 mt-2 w-56 z-20 origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg drop-shadow-lg border border-gray-300 focus:outline-hidden"
> >
<div class="px-3 py-1 pt-2"> <div class="px-3 py-1 pt-2">
<p class="text-xs">Angemeldet als</p> <p class="text-xs">Angemeldet als</p>
@ -25,7 +25,7 @@
<button button primary @click="close">Mein Account</button> <button button primary @click="close">Mein Account</button>
</RouterLink> </RouterLink>
</MenuItem> </MenuItem>
<MenuItem v-slot="{ close }"> <MenuItem v-if="false" v-slot="{ close }">
<RouterLink to="/docs" target="_blank"> <RouterLink to="/docs" target="_blank">
<button button primary @click="close">Dokumentation</button> <button button primary @click="close">Dokumentation</button>
</RouterLink> </RouterLink>

View file

@ -0,0 +1,106 @@
<template>
<form class="flex flex-col gap-2" @submit.prevent="change">
<div class="-space-y-px">
<div>
<input
id="new"
name="new"
type="password"
required
placeholder="neues Passwort"
autocomplete="new-password"
class="rounded-b-none!"
:class="notMatching ? 'border-red-600!' : ''"
/>
</div>
<div>
<input
id="new_rep"
name="new_rep"
type="password"
required
placeholder="neues Passwort wiederholen"
autocomplete="new-password"
class="rounded-t-none!"
:class="notMatching ? 'border-red-600!' : ''"
/>
</div>
<p v-if="notMatching">Passwörter stimmen nicht überein</p>
</div>
<div class="flex flex-row gap-2">
<button type="submit" primary :disabled="changeStatus == 'loading' || changeStatus == 'success'">
zu Passwort wechseln
</button>
<Spinner v-if="changeStatus == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="changeStatus == 'success'" />
<FailureXMark v-else-if="changeStatus == 'failed'" />
</div>
<p v-if="changeError" class="text-center">{{ changeError }}</p>
</form>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapActions, mapState } from "pinia";
import MainTemplate from "@/templates/Main.vue";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import TextCopy from "@/components/TextCopy.vue";
import { hashString } from "@/helpers/crypto";
</script>
<script lang="ts">
export default defineComponent({
props: {
currentRoutine: {
type: String,
default: "",
},
},
emits: ["updateCurrent"],
data() {
return {
verification: "loading" as "success" | "loading" | "failed",
changeStatus: undefined as undefined | "loading" | "success" | "failed",
changeError: "" as string,
notMatching: false as boolean,
};
},
mounted() {},
methods: {
async change(e: any) {
let formData = e.target.elements;
let new_pw = await hashString(formData.new.value);
let new_rep = await hashString(formData.new_rep.value);
if (new_pw != new_rep) {
this.notMatching = true;
return;
}
this.notMatching = false;
this.changeStatus = "loading";
this.changeError = "";
this.$http
.patch(`/user/changeToPW`, {
newpassword: await hashString(formData.new.value),
})
.then((result) => {
this.changeStatus = "success";
})
.catch((err) => {
this.changeStatus = "failed";
this.changeError = err.response.data;
})
.finally(() => {
setTimeout(() => {
this.changeStatus = undefined;
this.$emit("updateCurrent");
}, 2000);
});
},
},
});
</script>

View file

@ -0,0 +1,92 @@
<template>
<div class="flex flex-col gap-2 grow">
<img :src="image" alt="totp" class="w-56 h-56 self-center" />
<TextCopy :copyText="otp" />
</div>
<form class="flex flex-col gap-2" @submit.prevent="verify">
<div class="-space-y-px">
<div>
<input id="totp" name="totp" type="text" required placeholder="TOTP" />
</div>
</div>
<div class="flex flex-row gap-2">
<button type="submit" primary :disabled="verifyStatus == 'loading' || verifyStatus == 'success'">
zu TOTP wechseln
</button>
<Spinner v-if="verifyStatus == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="verifyStatus == 'success'" />
<FailureXMark v-else-if="verifyStatus == 'failed'" />
</div>
<p v-if="verifyError" class="text-center">{{ verifyError }}</p>
</form>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapActions, mapState } from "pinia";
import MainTemplate from "@/templates/Main.vue";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import TextCopy from "@/components/TextCopy.vue";
</script>
<script lang="ts">
export default defineComponent({
props: {
currentRoutine: {
type: String,
default: "",
},
},
emits: ["updateCurrent"],
data() {
return {
verification: "loading" as "success" | "loading" | "failed",
image: undefined as undefined | string,
otp: undefined as undefined | string,
verifyStatus: undefined as undefined | "loading" | "success" | "failed",
verifyError: "" as string,
};
},
mounted() {
this.$http
.get(`/user/changeToTOTP`)
.then((result) => {
this.verification = "success";
this.image = result.data.dataUrl;
this.otp = result.data.otp;
})
.catch((err) => {
this.verification = "failed";
});
},
methods: {
verify(e: any) {
let formData = e.target.elements;
this.verifyStatus = "loading";
this.verifyError = "";
this.$http
.patch(`/user/changeToTOTP`, {
otp: this.otp,
totp: formData.totp.value,
})
.then((result) => {
this.verifyStatus = "success";
})
.catch((err) => {
this.verifyStatus = "failed";
this.verifyError = err.response.data;
})
.finally(() => {
setTimeout(() => {
this.verifyStatus = undefined;
this.$emit("updateCurrent");
}, 2000);
});
},
},
});
</script>

View file

@ -0,0 +1,109 @@
<template>
<form class="flex flex-col gap-2" @submit.prevent="change">
<div class="-space-y-px">
<div>
<input
id="current"
name="current"
type="password"
required
placeholder="aktuelles Passwort"
autocomplete="current-password"
class="rounded-b-none!"
/>
</div>
<div>
<input
id="new"
name="new"
type="password"
required
placeholder="neues Passwort"
autocomplete="new-password"
class="rounded-none!"
:class="notMatching ? 'border-red-600!' : ''"
/>
</div>
<div>
<input
id="new_rep"
name="new_rep"
type="password"
required
placeholder="neues Passwort wiederholen"
autocomplete="new-password"
class="rounded-t-none!"
:class="notMatching ? 'border-red-600!' : ''"
/>
</div>
<p v-if="notMatching">Passwörter stimmen nicht überein</p>
</div>
<div class="flex flex-row gap-2">
<button type="submit" primary :disabled="changeStatus == 'loading' || changeStatus == 'success'">
Passwort ändern
</button>
<Spinner v-if="changeStatus == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="changeStatus == 'success'" />
<FailureXMark v-else-if="changeStatus == 'failed'" />
</div>
<p v-if="changeError" class="text-center">{{ changeError }}</p>
</form>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapActions, mapState } from "pinia";
import MainTemplate from "@/templates/Main.vue";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { hashString } from "@/helpers/crypto";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
verification: "loading" as "success" | "loading" | "failed",
changeStatus: undefined as undefined | "loading" | "success" | "failed",
changeError: "" as string,
notMatching: false as boolean,
};
},
mounted() {},
methods: {
async change(e: any) {
let formData = e.target.elements;
let new_pw = await hashString(formData.new.value);
let new_rep = await hashString(formData.new_rep.value);
if (new_pw != new_rep) {
this.notMatching = true;
return;
}
this.notMatching = false;
this.changeStatus = "loading";
this.changeError = "";
this.$http
.patch(`/user/changepw`, {
current: await hashString(formData.current.value),
newpassword: await hashString(formData.new.value),
})
.then((result) => {
this.changeStatus = "success";
})
.catch((err) => {
this.changeStatus = "failed";
this.changeError = err.response.data;
})
.finally(() => {
setTimeout(() => {
this.changeStatus = undefined;
}, 2000);
});
},
},
});
</script>

View file

@ -0,0 +1,83 @@
<template>
<div class="flex flex-col gap-2 grow">
<img :src="image" alt="totp" class="w-56 h-56 self-center" />
<TextCopy :copyText="otp" />
</div>
<form class="flex flex-col gap-2" @submit.prevent="verify">
<div class="-space-y-px">
<div>
<input id="totp" name="totp" type="text" required placeholder="TOTP prüfen" />
</div>
</div>
<div class="flex flex-row gap-2">
<button type="submit" primary :disabled="verifyStatus == 'loading' || verifyStatus == 'success'">
TOTP prüfen
</button>
<Spinner v-if="verifyStatus == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="verifyStatus == 'success'" />
<FailureXMark v-else-if="verifyStatus == 'failed'" />
</div>
<p v-if="verifyError" class="text-center">{{ verifyError }}</p>
</form>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapActions, mapState } from "pinia";
import MainTemplate from "@/templates/Main.vue";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import TextCopy from "@/components/TextCopy.vue";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
verification: "loading" as "success" | "loading" | "failed",
image: undefined as undefined | string,
otp: undefined as undefined | string,
verifyStatus: undefined as undefined | "loading" | "success" | "failed",
verifyError: "" as string,
};
},
mounted() {
this.$http
.get(`/user/totp`)
.then((result) => {
this.verification = "success";
this.image = result.data.dataUrl;
this.otp = result.data.otp;
})
.catch((err) => {
this.verification = "failed";
});
},
methods: {
verify(e: any) {
let formData = e.target.elements;
this.verifyStatus = "loading";
this.verifyError = "";
this.$http
.post(`/user/verify`, {
totp: formData.totp.value,
})
.then((result) => {
this.verifyStatus = "success";
})
.catch((err) => {
this.verifyStatus = "failed";
this.verifyError = err.response.data;
})
.finally(() => {
setTimeout(() => {
this.verifyStatus = undefined;
}, 2000);
});
},
},
});
</script>

View file

@ -1,10 +1,11 @@
<template> <template>
<div class="w-full"> <div class="w-full">
<Combobox v-model="selected" :disabled="disabled" multiple> <Combobox v-model="selected" :disabled="disabled" multiple>
<ComboboxLabel>{{ title }}</ComboboxLabel> <ComboboxLabel v-if="!showTitleAsPlaceholder">{{ title }}</ComboboxLabel>
<div class="relative mt-1"> <div class="relative" :class="{ 'mt-1': !showTitleAsPlaceholder }">
<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-xs 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-hidden focus:ring-0 focus:z-10 sm:text-sm resize-none"
:placeholder="showTitleAsPlaceholder ? title : ''"
@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">
@ -17,7 +18,7 @@
@after-leave="query = ''" @after-leave="query = ''"
> >
<ComboboxOptions <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" 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-hidden sm:text-sm"
> >
<ComboboxOption v-if="loading || deferingSearch" as="template" disabled> <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"> <li class="flex flex-row gap-2 text-text relative cursor-default select-none py-2 pl-3 pr-4">
@ -86,7 +87,7 @@ import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { useMemberStore } from "@/stores/admin/club/member/member"; import { useMemberStore } from "@/stores/admin/club/member/member";
import type { MemberViewModel } from "@/viewmodels/admin/club/member/member.models"; import type { MemberViewModel } from "@/viewmodels/admin/club/member/member.models";
import difference from "lodash.difference"; import difference from "lodash.difference";
import Spinner from "../Spinner.vue"; import Spinner from "@/components/Spinner.vue";
</script> </script>
<script lang="ts"> <script lang="ts">
@ -101,6 +102,10 @@ export default defineComponent({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
showTitleAsPlaceholder: {
type: Boolean,
default: false,
},
}, },
emits: ["update:model-value", "add:difference", "remove:difference", "add:member", "add:memberByArray"], emits: ["update:model-value", "add:difference", "remove:difference", "add:member", "add:memberByArray"],
watch: { watch: {

View file

@ -18,22 +18,22 @@
<div class="flex flex-row border border-white rounded-md overflow-hidden"> <div class="flex flex-row border border-white rounded-md overflow-hidden">
<EyeIcon <EyeIcon
class="w-5 h-5 p-1 box-content cursor-pointer" class="w-5 h-5 p-1 box-content cursor-pointer"
:class="_can(permissionUpdate, 'read', section) ? 'bg-success' : ''" :class="_canSection(permissionUpdate, 'read', section) ? 'bg-success' : ''"
@click="togglePermission('read', section)" @click="togglePermission('read', section)"
/> />
<PlusIcon <PlusIcon
class="w-5 h-5 p-1 box-content cursor-pointer" class="w-5 h-5 p-1 box-content cursor-pointer"
:class="_can(permissionUpdate, 'create', section) ? 'bg-success' : ''" :class="_canSection(permissionUpdate, 'create', section) ? 'bg-success' : ''"
@click="togglePermission('create', section)" @click="togglePermission('create', section)"
/> />
<PencilIcon <PencilIcon
class="w-5 h-5 p-1 box-content cursor-pointer" class="w-5 h-5 p-1 box-content cursor-pointer"
:class="_can(permissionUpdate, 'update', section) ? 'bg-success' : ''" :class="_canSection(permissionUpdate, 'update', section) ? 'bg-success' : ''"
@click="togglePermission('update', section)" @click="togglePermission('update', section)"
/> />
<TrashIcon <TrashIcon
class="w-5 h-5 p-1 box-content cursor-pointer" class="w-5 h-5 p-1 box-content cursor-pointer"
:class="_can(permissionUpdate, 'delete', section) ? 'bg-success' : ''" :class="_canSection(permissionUpdate, 'delete', section) ? 'bg-success' : ''"
@click="togglePermission('delete', section)" @click="togglePermission('delete', section)"
/> />
</div> </div>
@ -69,8 +69,8 @@
</div> </div>
</div> </div>
<div v-if="!disableEdit" class="flex flex-row gap-2 self-end pt-4"> <div v-if="!disableEdit" class="flex flex-row gap-2 self-end pt-4">
<button primary-outline class="!w-fit" @click="reset" :disabled="canSaveOrReset">verwerfen</button> <button primary-outline class="w-fit!" @click="reset" :disabled="canSaveOrReset">verwerfen</button>
<button primary class="!w-fit" @click="submit" :disabled="status == 'loading' || canSaveOrReset"> <button primary class="w-fit!" @click="submit" :disabled="status == 'loading' || canSaveOrReset">
speichern speichern
</button> </button>
<Spinner v-if="status == 'loading'" class="my-auto" /> <Spinner v-if="status == 'loading'" class="my-auto" />
@ -132,7 +132,7 @@ export default defineComponent({
}; };
}, },
computed: { computed: {
...mapState(useAbilityStore, ["_can"]), ...mapState(useAbilityStore, ["_can", "_canSection"]),
canSaveOrReset(): boolean { canSaveOrReset(): boolean {
return isEqual(this.permissions, this.permissionUpdate); return isEqual(this.permissions, this.permissionUpdate);
}, },

View file

@ -10,7 +10,7 @@
<ListboxLabel>Typen zur Anzeige auswählen</ListboxLabel> <ListboxLabel>Typen zur Anzeige auswählen</ListboxLabel>
<div class="relative mt-1"> <div class="relative mt-1">
<ListboxButton <ListboxButton
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-xs 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-hidden focus:ring-0 focus:z-10 sm:text-sm resize-none"
> >
<span class="block truncate w-full text-start"> <span class="block truncate w-full text-start">
{{ {{
@ -30,7 +30,7 @@
leave-to-class="opacity-0" leave-to-class="opacity-0"
> >
<ListboxOptions <ListboxOptions
class="absolute mt-1 max-h-60 z-20 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-none sm:text-sm h-32 overflow-y-auto" class="absolute mt-1 max-h-60 z-20 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-hidden sm:text-sm h-32 overflow-y-auto"
> >
<ListboxOption v-if="calendarTypes.length == 0" disabled as="template"> <ListboxOption v-if="calendarTypes.length == 0" disabled as="template">
<li :class="['relative cursor-default select-none py-2 pl-10 pr-4']"> <li :class="['relative cursor-default select-none py-2 pl-10 pr-4']">

View file

@ -10,7 +10,7 @@
<ListboxLabel>Termintyp</ListboxLabel> <ListboxLabel>Termintyp</ListboxLabel>
<div class="relative mt-1"> <div class="relative mt-1">
<ListboxButton <ListboxButton
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-xs 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-hidden focus:ring-0 focus:z-10 sm:text-sm resize-none"
> >
<span class="block truncate w-full text-start"> <span class="block truncate w-full text-start">
{{ {{
@ -28,7 +28,7 @@
leave-to-class="opacity-0" leave-to-class="opacity-0"
> >
<ListboxOptions <ListboxOptions
class="absolute mt-1 max-h-60 z-20 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-none sm:text-sm h-32 overflow-y-auto" class="absolute mt-1 max-h-60 z-20 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-hidden sm:text-sm h-32 overflow-y-auto"
> >
<ListboxOption v-if="calendarTypes.length == 0" disabled as="template"> <ListboxOption v-if="calendarTypes.length == 0" disabled as="template">
<li :class="['relative cursor-default select-none py-2 pl-10 pr-4']"> <li :class="['relative cursor-default select-none py-2 pl-10 pr-4']">

View file

@ -17,7 +17,7 @@
<ListboxLabel>Termintyp</ListboxLabel> <ListboxLabel>Termintyp</ListboxLabel>
<div class="relative mt-1"> <div class="relative mt-1">
<ListboxButton <ListboxButton
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-xs 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-hidden focus:ring-0 focus:z-10 sm:text-sm resize-none"
> >
<span class="block truncate w-full text-start"> <span class="block truncate w-full text-start">
{{ {{
@ -35,7 +35,7 @@
leave-to-class="opacity-0" leave-to-class="opacity-0"
> >
<ListboxOptions <ListboxOptions
class="absolute mt-1 max-h-60 z-20 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-none sm:text-sm h-32 overflow-y-auto" class="absolute mt-1 max-h-60 z-20 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-hidden sm:text-sm h-32 overflow-y-auto"
> >
<ListboxOption v-if="calendarTypes.length == 0" disabled as="template"> <ListboxOption v-if="calendarTypes.length == 0" disabled as="template">
<li :class="['relative cursor-default select-none py-2 pl-10 pr-4']"> <li :class="['relative cursor-default select-none py-2 pl-10 pr-4']">
@ -152,10 +152,10 @@
</div> </div>
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
<button primary-outline type="reset" class="!w-fit" :disabled="canSaveOrReset" @click="resetForm"> <button primary-outline type="reset" class="w-fit!" :disabled="canSaveOrReset" @click="resetForm">
verwerfen verwerfen
</button> </button>
<button primary type="submit" class="!w-fit" :disabled="status == 'loading' || canSaveOrReset"> <button primary type="submit" class="w-fit!" :disabled="status == 'loading' || canSaveOrReset">
speichern speichern
</button> </button>
<Spinner v-if="status == 'loading'" class="my-auto" /> <Spinner v-if="status == 'loading'" class="my-auto" />

View file

@ -10,7 +10,7 @@
<ListboxLabel>Anrede</ListboxLabel> <ListboxLabel>Anrede</ListboxLabel>
<div class="relative mt-1"> <div class="relative mt-1">
<ListboxButton <ListboxButton
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-xs 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-hidden focus:ring-0 focus:z-10 sm:text-sm resize-none"
> >
<span class="block truncate w-full text-start"> {{ selectedSalutation?.salutation }}</span> <span class="block truncate w-full text-start"> {{ selectedSalutation?.salutation }}</span>
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> <span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
@ -24,7 +24,7 @@
leave-to-class="opacity-0" leave-to-class="opacity-0"
> >
<ListboxOptions <ListboxOptions
class="absolute mt-1 max-h-60 z-20 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-none sm:text-sm h-32 overflow-y-auto" class="absolute mt-1 max-h-60 z-20 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-hidden sm:text-sm h-32 overflow-y-auto"
> >
<ListboxOption <ListboxOption
v-slot="{ active, selected }" v-slot="{ active, selected }"
@ -69,7 +69,15 @@
<input type="date" id="birthdate" required /> <input type="date" id="birthdate" required />
</div> </div>
<div> <div>
<label for="internalId">Interne ID (optional)</label> <div class="flex flex-row">
<label for="internalId" class="grow">
Interne ID (optional{{ lastId ? ` - zuletzte verwendet: ${lastId}` : "" }})
</label>
<div title="Es empfiehlt sich, die Interne Id mit Platzhaltern wie '0' vorne aufzufüllen.">
<InformationCircleIcon class="h-5 w-5" />
</div>
</div>
<input type="text" id="internalId" /> <input type="text" id="internalId" />
</div> </div>
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
@ -101,8 +109,9 @@ import { Listbox, ListboxButton, ListboxOptions, ListboxOption, ListboxLabel } f
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid"; import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { useMemberStore } from "@/stores/admin/club/member/member"; import { useMemberStore } from "@/stores/admin/club/member/member";
import type { CreateMemberViewModel } from "@/viewmodels/admin/club/member/member.models"; import type { CreateMemberViewModel } from "@/viewmodels/admin/club/member/member.models";
import { useSalutationStore } from "../../../../stores/admin/configuration/salutation"; import { useSalutationStore } from "@/stores/admin/configuration/salutation";
import type { SalutationViewModel } from "../../../../viewmodels/admin/configuration/salutation.models"; import type { SalutationViewModel } from "@/viewmodels/admin/configuration/salutation.models";
import { InformationCircleIcon } from "@heroicons/vue/24/outline";
</script> </script>
<script lang="ts"> <script lang="ts">
@ -112,6 +121,7 @@ export default defineComponent({
status: null as null | "loading" | { status: "success" | "failed"; reason?: string }, status: null as null | "loading" | { status: "success" | "failed"; reason?: string },
timeout: undefined as any, timeout: undefined as any,
selectedSalutation: null as null | SalutationViewModel, selectedSalutation: null as null | SalutationViewModel,
lastId: "" as string,
}; };
}, },
computed: { computed: {
@ -119,6 +129,11 @@ export default defineComponent({
}, },
mounted() { mounted() {
this.fetchSalutations(); this.fetchSalutations();
this.fetchLastInternalId()
.then((res) => {
this.lastId = res.data;
})
.catch(() => {});
}, },
beforeUnmount() { beforeUnmount() {
try { try {
@ -127,7 +142,7 @@ export default defineComponent({
}, },
methods: { methods: {
...mapActions(useModalStore, ["closeModal"]), ...mapActions(useModalStore, ["closeModal"]),
...mapActions(useMemberStore, ["createMember"]), ...mapActions(useMemberStore, ["createMember", "fetchLastInternalId"]),
...mapActions(useSalutationStore, ["fetchSalutations"]), ...mapActions(useSalutationStore, ["fetchSalutations"]),
triggerCreate(e: any) { triggerCreate(e: any) {
if (!this.selectedSalutation) return; if (!this.selectedSalutation) return;

View file

@ -10,7 +10,7 @@
<ListboxLabel>Auszeichnung</ListboxLabel> <ListboxLabel>Auszeichnung</ListboxLabel>
<div class="relative mt-1"> <div class="relative mt-1">
<ListboxButton <ListboxButton
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-xs 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-hidden focus:ring-0 focus:z-10 sm:text-sm resize-none"
> >
<span class="block truncate w-full text-start"> <span class="block truncate w-full text-start">
{{ awards.length != 0 ? (selectedAward?.award ?? "bitte auswählen") : "keine Auswahl vorhanden" }}</span {{ awards.length != 0 ? (selectedAward?.award ?? "bitte auswählen") : "keine Auswahl vorhanden" }}</span
@ -26,7 +26,7 @@
leave-to-class="opacity-0" leave-to-class="opacity-0"
> >
<ListboxOptions <ListboxOptions
class="absolute mt-1 max-h-60 z-20 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-none sm:text-sm h-32 overflow-y-auto" class="absolute mt-1 max-h-60 z-20 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-hidden sm:text-sm h-32 overflow-y-auto"
> >
<ListboxOption v-if="awards.length == 0" disabled as="template"> <ListboxOption v-if="awards.length == 0" disabled as="template">
<li :class="['relative cursor-default select-none py-2 pl-10 pr-4']"> <li :class="['relative cursor-default select-none py-2 pl-10 pr-4']">

View file

@ -12,7 +12,7 @@
<ListboxLabel>Auszeichnung</ListboxLabel> <ListboxLabel>Auszeichnung</ListboxLabel>
<div class="relative mt-1"> <div class="relative mt-1">
<ListboxButton <ListboxButton
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-xs 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-hidden focus:ring-0 focus:z-10 sm:text-sm resize-none"
> >
<span class="block truncate w-full text-start"> <span class="block truncate w-full text-start">
{{ awards.length != 0 ? (selectedAward ?? "bitte auswählen") : "keine Auswahl vorhanden" }}</span {{ awards.length != 0 ? (selectedAward ?? "bitte auswählen") : "keine Auswahl vorhanden" }}</span
@ -28,7 +28,7 @@
leave-to-class="opacity-0" leave-to-class="opacity-0"
> >
<ListboxOptions <ListboxOptions
class="absolute mt-1 max-h-60 z-20 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-none sm:text-sm h-32 overflow-y-auto" class="absolute mt-1 max-h-60 z-20 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-hidden sm:text-sm h-32 overflow-y-auto"
> >
<ListboxOption v-if="awards.length == 0" disabled as="template"> <ListboxOption v-if="awards.length == 0" disabled as="template">
<li :class="['relative cursor-default select-none py-2 pl-10 pr-4']"> <li :class="['relative cursor-default select-none py-2 pl-10 pr-4']">

View file

@ -10,7 +10,7 @@
<ListboxLabel>Kommunikationsart</ListboxLabel> <ListboxLabel>Kommunikationsart</ListboxLabel>
<div class="relative mt-1"> <div class="relative mt-1">
<ListboxButton <ListboxButton
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-xs 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-hidden focus:ring-0 focus:z-10 sm:text-sm resize-none"
> >
<span class="block truncate w-full text-start"> <span class="block truncate w-full text-start">
{{ {{
@ -30,7 +30,7 @@
leave-to-class="opacity-0" leave-to-class="opacity-0"
> >
<ListboxOptions <ListboxOptions
class="absolute mt-1 max-h-60 z-20 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-none sm:text-sm h-32 overflow-y-auto" class="absolute mt-1 max-h-60 z-20 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-hidden sm:text-sm h-32 overflow-y-auto"
> >
<ListboxOption v-if="communicationTypes.length == 0" disabled as="template"> <ListboxOption v-if="communicationTypes.length == 0" disabled as="template">
<li :class="['relative cursor-default select-none py-2 pl-10 pr-4']"> <li :class="['relative cursor-default select-none py-2 pl-10 pr-4']">

View file

@ -10,7 +10,7 @@
<ListboxLabel>Qualifikation</ListboxLabel> <ListboxLabel>Qualifikation</ListboxLabel>
<div class="relative mt-1"> <div class="relative mt-1">
<ListboxButton <ListboxButton
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-xs 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-hidden focus:ring-0 focus:z-10 sm:text-sm resize-none"
> >
<span class="block truncate w-full text-start"> <span class="block truncate w-full text-start">
{{ {{
@ -30,7 +30,7 @@
leave-to-class="opacity-0" leave-to-class="opacity-0"
> >
<ListboxOptions <ListboxOptions
class="absolute mt-1 max-h-60 z-20 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-none sm:text-sm h-32 overflow-y-auto" class="absolute mt-1 max-h-60 z-20 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-hidden sm:text-sm h-32 overflow-y-auto"
> >
<ListboxOption v-if="executivePositions.length == 0" disabled as="template"> <ListboxOption v-if="executivePositions.length == 0" disabled as="template">
<li :class="['relative cursor-default select-none py-2 pl-10 pr-4']"> <li :class="['relative cursor-default select-none py-2 pl-10 pr-4']">

View file

@ -12,7 +12,7 @@
<ListboxLabel>Auszeichnung</ListboxLabel> <ListboxLabel>Auszeichnung</ListboxLabel>
<div class="relative mt-1"> <div class="relative mt-1">
<ListboxButton <ListboxButton
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-xs 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-hidden focus:ring-0 focus:z-10 sm:text-sm resize-none"
> >
<span class="block truncate w-full text-start"> <span class="block truncate w-full text-start">
{{ {{
@ -32,7 +32,7 @@
leave-to-class="opacity-0" leave-to-class="opacity-0"
> >
<ListboxOptions <ListboxOptions
class="absolute mt-1 max-h-60 z-20 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-none sm:text-sm h-32 overflow-y-auto" class="absolute mt-1 max-h-60 z-20 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-hidden sm:text-sm h-32 overflow-y-auto"
> >
<ListboxOption v-if="executivePositions.length == 0" disabled as="template"> <ListboxOption v-if="executivePositions.length == 0" disabled as="template">
<li :class="['relative cursor-default select-none py-2 pl-10 pr-4']"> <li :class="['relative cursor-default select-none py-2 pl-10 pr-4']">

View file

@ -6,8 +6,8 @@
</div> </div>
<div class="flex flex-row gap-2 justify-end"> <div class="flex flex-row gap-2 justify-end">
<a ref="download" button primary class="!w-fit">download</a> <a ref="download" button primary class="w-fit!">download</a>
<button primary-outline class="!w-fit" @click="closeModal">schließen</button> <button primary-outline class="w-fit!" @click="closeModal">schließen</button>
</div> </div>
</div> </div>
</template> </template>

View file

@ -10,7 +10,7 @@
<ListboxLabel>Qualifikation</ListboxLabel> <ListboxLabel>Qualifikation</ListboxLabel>
<div class="relative mt-1"> <div class="relative mt-1">
<ListboxButton <ListboxButton
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-xs 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-hidden focus:ring-0 focus:z-10 sm:text-sm resize-none"
> >
<span class="block truncate w-full text-start"> <span class="block truncate w-full text-start">
{{ {{
@ -30,7 +30,7 @@
leave-to-class="opacity-0" leave-to-class="opacity-0"
> >
<ListboxOptions <ListboxOptions
class="absolute mt-1 max-h-60 z-20 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-none sm:text-sm h-32 overflow-y-auto" class="absolute mt-1 max-h-60 z-20 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-hidden sm:text-sm h-32 overflow-y-auto"
> >
<ListboxOption v-if="qualifications.length == 0" disabled as="template"> <ListboxOption v-if="qualifications.length == 0" disabled as="template">
<li :class="['relative cursor-default select-none py-2 pl-10 pr-4']"> <li :class="['relative cursor-default select-none py-2 pl-10 pr-4']">

View file

@ -12,7 +12,7 @@
<ListboxLabel>Qualifikation</ListboxLabel> <ListboxLabel>Qualifikation</ListboxLabel>
<div class="relative mt-1"> <div class="relative mt-1">
<ListboxButton <ListboxButton
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-xs 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-hidden focus:ring-0 focus:z-10 sm:text-sm resize-none"
> >
<span class="block truncate w-full text-start"> <span class="block truncate w-full text-start">
{{ {{
@ -30,7 +30,7 @@
leave-to-class="opacity-0" leave-to-class="opacity-0"
> >
<ListboxOptions <ListboxOptions
class="absolute mt-1 max-h-60 z-20 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-none sm:text-sm h-32 overflow-y-auto" class="absolute mt-1 max-h-60 z-20 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-hidden sm:text-sm h-32 overflow-y-auto"
> >
<ListboxOption v-if="qualifications.length == 0" disabled as="template"> <ListboxOption v-if="qualifications.length == 0" disabled as="template">
<li :class="['relative cursor-default select-none py-2 pl-10 pr-4']"> <li :class="['relative cursor-default select-none py-2 pl-10 pr-4']">

View file

@ -10,7 +10,7 @@
<ListboxLabel>Status</ListboxLabel> <ListboxLabel>Status</ListboxLabel>
<div class="relative mt-1"> <div class="relative mt-1">
<ListboxButton <ListboxButton
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-xs 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-hidden focus:ring-0 focus:z-10 sm:text-sm resize-none"
> >
<span class="block truncate w-full text-start"> <span class="block truncate w-full text-start">
{{ {{
@ -30,7 +30,7 @@
leave-to-class="opacity-0" leave-to-class="opacity-0"
> >
<ListboxOptions <ListboxOptions
class="absolute mt-1 max-h-60 z-20 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-none sm:text-sm h-32 overflow-y-auto" class="absolute mt-1 max-h-60 z-20 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-hidden sm:text-sm h-32 overflow-y-auto"
> >
<ListboxOption v-if="membershipStatus.length == 0" disabled as="template"> <ListboxOption v-if="membershipStatus.length == 0" disabled as="template">
<li :class="['relative cursor-default select-none py-2 pl-10 pr-4']"> <li :class="['relative cursor-default select-none py-2 pl-10 pr-4']">

View file

@ -12,7 +12,7 @@
<ListboxLabel>Status</ListboxLabel> <ListboxLabel>Status</ListboxLabel>
<div class="relative mt-1"> <div class="relative mt-1">
<ListboxButton <ListboxButton
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-xs 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-hidden focus:ring-0 focus:z-10 sm:text-sm resize-none"
> >
<span class="block truncate w-full text-start"> <span class="block truncate w-full text-start">
{{ {{
@ -30,7 +30,7 @@
leave-to-class="opacity-0" leave-to-class="opacity-0"
> >
<ListboxOptions <ListboxOptions
class="absolute mt-1 max-h-60 z-20 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-none sm:text-sm h-32 overflow-y-auto" class="absolute mt-1 max-h-60 z-20 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-hidden sm:text-sm h-32 overflow-y-auto"
> >
<ListboxOption v-if="membershipStatus.length == 0" disabled as="template"> <ListboxOption v-if="membershipStatus.length == 0" disabled as="template">
<li :class="['relative cursor-default select-none py-2 pl-10 pr-4']"> <li :class="['relative cursor-default select-none py-2 pl-10 pr-4']">

View file

@ -36,8 +36,8 @@ import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue"; import FailureXMark from "@/components/FailureXMark.vue";
import { useProtocolStore } from "@/stores/admin/club/protocol/protocol"; import { useProtocolStore } from "@/stores/admin/club/protocol/protocol";
import type { CreateProtocolViewModel } from "@/viewmodels/admin/club/protocol/protocol.models"; import type { CreateProtocolViewModel } from "@/viewmodels/admin/club/protocol/protocol.models";
import { useNewsletterStore } from "../../../../stores/admin/club/newsletter/newsletter"; import { useNewsletterStore } from "@/stores/admin/club/newsletter/newsletter";
import type { CreateNewsletterViewModel } from "../../../../viewmodels/admin/club/newsletter/newsletter.models"; import type { CreateNewsletterViewModel } from "@/viewmodels/admin/club/newsletter/newsletter.models";
</script> </script>
<script lang="ts"> <script lang="ts">

View file

@ -19,7 +19,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 primary-outline @click="closeModal">abbrechen</button> <button primary-outline @click="closeModal">schließen</button>
</div> </div>
</div> </div>
</div> </div>

View file

@ -0,0 +1,68 @@
<template>
<div class="w-full md:max-w-md">
<div class="flex flex-col items-center">
<p class="text-xl font-medium">Newsletter Mail Empfänger</p>
</div>
<br />
<p v-if="receivers.length == 0">keine Empfänger gefunden</p>
<p v-else>{{ receivers.length }} Empfänger gefunden</p>
<div class="flex flex-col gap-2 h-96 overflow-y-scroll">
<p
v-for="rec in receivers"
:key="rec.id"
class="bg-primary p-2 text-white flex flex-row justify-between items-center rounded-md"
>
{{ rec.lastname }}, {{ rec.firstname }}
</p>
</div>
<div class="flex flex-row gap-4 py-2 w-full">
<button primary @click="start">Versand starten</button>
<button primary-outline @click="closeModal">Versand abbrechen</button>
</div>
</div>
</template>
<script setup lang="ts">
import { defineAsyncComponent, defineComponent, markRaw } from "vue";
import { mapState, mapActions } from "pinia";
import { useModalStore } from "@/stores/modal";
import { useNewsletterPrintoutStore } from "@/stores/admin/club/newsletter/newsletterPrintout";
import type { MemberViewModel } from "@/viewmodels/admin/club/member/member.models";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
receivers: [] as Array<MemberViewModel>,
error: null as null | string,
};
},
mounted() {
this.fetchNewsletterMailReceivers()
.then((res) => {
this.receivers = res.data;
})
.catch((err) => {
this.error = "Fehler beim Laden der Empfänger";
});
},
methods: {
...mapActions(useNewsletterPrintoutStore, ["fetchNewsletterMailReceivers", "createNewsletterSend"]),
...mapActions(useModalStore, ["openModal", "closeModal"]),
start() {
this.createNewsletterSend();
this.openMailLogs();
},
openMailLogs() {
this.openModal(
markRaw(
defineAsyncComponent(() => import("@/components/admin/club/newsletter/NewsletterMailProgressModal.vue"))
)
);
},
},
});
</script>

View file

@ -5,7 +5,7 @@
<iframe ref="viewer" class="w-full h-full" /> <iframe ref="viewer" class="w-full h-full" />
</div> </div>
<button primary-outline class="!w-fit self-end" @click="closeModal">schließen</button> <button primary-outline class="w-fit! self-end" @click="closeModal">schließen</button>
</div> </div>
</template> </template>

View file

@ -19,7 +19,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 primary-outline @click="closeModal">abbrechen</button> <button primary-outline @click="closeModal">schließen</button>
</div> </div>
</div> </div>
</div> </div>

View file

@ -0,0 +1,68 @@
<template>
<div class="w-full md:max-w-md">
<div class="flex flex-col items-center">
<p class="text-xl font-medium">Newsletter Druck Empfänger</p>
</div>
<br />
<p v-if="receivers.length == 0">keine Empfänger gefunden</p>
<p v-else>{{ receivers.length }} Empfänger gefunden</p>
<div class="flex flex-col gap-2 h-96 overflow-y-scroll">
<p
v-for="rec in receivers"
:key="rec.id"
class="bg-primary p-2 text-white flex flex-row justify-between items-center rounded-md"
>
{{ rec.lastname }}, {{ rec.firstname }}
</p>
</div>
<div class="flex flex-row gap-4 py-2 w-full">
<button primary @click="start">Druck starten</button>
<button primary-outline @click="closeModal">Druck abbrechen</button>
</div>
</div>
</template>
<script setup lang="ts">
import { defineAsyncComponent, defineComponent, markRaw } from "vue";
import { mapState, mapActions } from "pinia";
import { useModalStore } from "@/stores/modal";
import { useNewsletterPrintoutStore } from "@/stores/admin/club/newsletter/newsletterPrintout";
import type { MemberViewModel } from "@/viewmodels/admin/club/member/member.models";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
receivers: [] as Array<MemberViewModel>,
error: null as null | string,
};
},
mounted() {
this.fetchNewsletterPrintReceivers()
.then((res) => {
this.receivers = res.data;
})
.catch((err) => {
this.error = "Fehler beim Laden der Empfänger";
});
},
methods: {
...mapActions(useNewsletterPrintoutStore, ["fetchNewsletterPrintReceivers", "createNewsletterPrintout"]),
...mapActions(useModalStore, ["openModal", "closeModal"]),
start() {
this.createNewsletterPrintout();
this.openPdfLogs();
},
openPdfLogs() {
this.openModal(
markRaw(
defineAsyncComponent(() => import("@/components/admin/club/newsletter/NewsletterPrintingProgressModal.vue"))
)
);
},
},
});
</script>

View file

@ -4,7 +4,7 @@
<iframe ref="viewer" class="w-full h-full" /> <iframe ref="viewer" class="w-full h-full" />
</div> </div>
<button primary-outline class="!w-fit self-end" @click="closeModal">schließen</button> <button primary-outline class="w-fit! self-end" @click="closeModal">schließen</button>
</div> </div>
</template> </template>

View file

@ -10,7 +10,7 @@
<input type="text" id="type" required /> <input type="text" id="type" required />
</div> </div>
<div class="flex flex-row items-center gap-2"> <div class="flex flex-row items-center gap-2">
<input type="color" id="color" required class="!px-1 !py-0 !w-10" /> <input type="color" id="color" required class="px-1! py-0! w-10!" />
<label for="color">Farbe</label> <label for="color">Farbe</label>
</div> </div>
<div class="flex flex-row items-center gap-2"> <div class="flex flex-row items-center gap-2">

View file

@ -14,7 +14,7 @@
<ListboxLabel>Felder</ListboxLabel> <ListboxLabel>Felder</ListboxLabel>
<div class="relative mt-1"> <div class="relative mt-1">
<ListboxButton <ListboxButton
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-xs 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-hidden focus:ring-0 focus:z-10 sm:text-sm resize-none"
> >
<span class="block truncate w-full text-start"> {{ selectedFields.join(", ") }}</span> <span class="block truncate w-full text-start"> {{ selectedFields.join(", ") }}</span>
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> <span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
@ -28,7 +28,7 @@
leave-to-class="opacity-0" leave-to-class="opacity-0"
> >
<ListboxOptions <ListboxOptions
class="absolute mt-1 max-h-60 z-20 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-none sm:text-sm h-32 overflow-y-auto" class="absolute mt-1 max-h-60 z-20 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-hidden sm:text-sm h-32 overflow-y-auto"
> >
<ListboxOption <ListboxOption
v-slot="{ active, selected }" v-slot="{ active, selected }"

View file

@ -3,13 +3,13 @@
<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>Newsletter bei Type "{{ comType.type }}" versenden/exportieren als</p> <p>Newsletter bei Type "{{ comType.type }}" versenden/exportieren als</p>
<div v-if="can('create', 'configuration', 'newsletter_config')" class="flex flex-row justify-end w-16"> <div v-if="can('create', 'configuration', 'newsletter_config')" class="flex flex-row justify-end w-16">
<button v-if="status == null" type="submit" class="!p-0 !h-fit !w-fit" title="speichern"> <button v-if="status == null" type="submit" class="p-0! h-fit! w-fit!" title="Änderung speichern">
<ArchiveBoxArrowDownIcon class="w-5 h-5 p-1 box-content pointer-events-none" /> <ArchiveBoxArrowDownIcon class="w-5 h-5 p-1 box-content pointer-events-none" />
</button> </button>
<Spinner v-else-if="status == 'loading'" class="my-auto" /> <Spinner v-else-if="status == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="status?.status == 'success'" /> <SuccessCheckmark v-else-if="status?.status == 'success'" />
<FailureXMark v-else-if="status?.status == 'failed'" /> <FailureXMark v-else-if="status?.status == 'failed'" />
<button type="button" class="!p-0 !h-fit !w-fit" title="zurücksetzen" @click="resetForm"> <button type="button" class="p-0! h-fit! w-fit!" title="Änderung zurücksetzen" @click="resetForm">
<ArchiveBoxXMarkIcon class="w-5 h-5 p-1 box-content pointer-events-none" /> <ArchiveBoxXMarkIcon class="w-5 h-5 p-1 box-content pointer-events-none" />
</button> </button>
</div> </div>
@ -36,7 +36,7 @@ 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 { useModalStore } from "@/stores/modal"; import { useModalStore } from "@/stores/modal";
import { NewsletterConfigType } from "@/enums/newsletterConfigType"; import { NewsletterConfigEnum } from "@/enums/newsletterConfigEnum";
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
import type { CommunicationTypeViewModel } from "@/viewmodels/admin/configuration/communicationType.models"; import type { CommunicationTypeViewModel } from "@/viewmodels/admin/configuration/communicationType.models";
import { useAbilityStore } from "@/stores/ability"; import { useAbilityStore } from "@/stores/ability";
@ -62,7 +62,7 @@ export default defineComponent({
}, },
}, },
mounted() { mounted() {
this.configs = Object.values(NewsletterConfigType); this.configs = Object.values(NewsletterConfigEnum);
}, },
beforeUnmount() { beforeUnmount() {
try { try {

View file

@ -9,7 +9,7 @@
> >
<PencilIcon class="w-5 h-5 p-1 box-content cursor-pointer" /> <PencilIcon class="w-5 h-5 p-1 box-content cursor-pointer" />
</RouterLink> </RouterLink>
<button v-if="status == null" class="!p-0 !h-fit !w-fit" title="duplizieren" @click="cloneElement"> <button v-if="status == null" class="p-0! h-fit! w-fit!" title="duplizieren" @click="cloneElement">
<DocumentDuplicateIcon class="w-5 h-5 p-1 box-content pointer-events-none" /> <DocumentDuplicateIcon class="w-5 h-5 p-1 box-content pointer-events-none" />
</button> </button>
<Spinner v-else-if="status == 'loading'" class="my-auto" /> <Spinner v-else-if="status == 'loading'" class="my-auto" />

View file

@ -5,7 +5,7 @@
<iframe ref="viewer" class="w-full h-full" /> <iframe ref="viewer" class="w-full h-full" />
</div> </div>
<button primary-outline class="!w-fit self-end" @click="closeModal">schließen</button> <button primary-outline class="w-fit! self-end" @click="closeModal">schließen</button>
</div> </div>
</template> </template>

View file

@ -3,13 +3,13 @@
<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>Templates zu "{{ templateUsage.scope }}" zuweisen</p> <p>Templates zu "{{ templateUsage.scope }}" zuweisen</p>
<div class="flex flex-row justify-end w-16"> <div class="flex flex-row justify-end w-16">
<button type="button" class="!p-0 !h-fit !w-fit" title="Vorschau erzeugen" @click="previewUsage"> <button type="button" class="p-0! h-fit! w-fit!" title="Vorschau erzeugen" @click="previewUsage">
<EyeIcon class="w-5 h-5 p-1 box-content pointer-events-none" /> <EyeIcon class="w-5 h-5 p-1 box-content pointer-events-none" />
</button> </button>
<button <button
v-if="status == null && can('create', 'configuration', 'newsletter_config')" v-if="status == null && can('create', 'configuration', 'newsletter_config')"
type="submit" type="submit"
class="!p-0 !h-fit !w-fit" class="p-0! h-fit! w-fit!"
title="speichern" title="speichern"
> >
<ArchiveBoxArrowDownIcon class="w-5 h-5 p-1 box-content pointer-events-none" /> <ArchiveBoxArrowDownIcon class="w-5 h-5 p-1 box-content pointer-events-none" />
@ -20,7 +20,7 @@
<button <button
type="button" type="button"
v-if="can('create', 'configuration', 'newsletter_config')" v-if="can('create', 'configuration', 'newsletter_config')"
class="!p-0 !h-fit !w-fit" class="p-0! h-fit! w-fit!"
title="zurücksetzen" title="zurücksetzen"
@click="resetForm" @click="resetForm"
> >
@ -47,7 +47,7 @@
type="number" type="number"
:min="15" :min="15"
:value="templateUsage.headerHeight" :value="templateUsage.headerHeight"
class="!w-24" class="w-24!"
placeholder="15" placeholder="15"
/> />
</div> </div>
@ -77,7 +77,7 @@
type="number" type="number"
:min="15" :min="15"
:value="templateUsage.footerHeight" :value="templateUsage.footerHeight"
class="!w-24" class="w-24!"
placeholder="15" placeholder="15"
/> />
</div> </div>

View file

@ -20,7 +20,7 @@ import { mapState, mapActions } from "pinia";
import { ArchiveBoxArrowDownIcon, ArrowDownTrayIcon, BarsArrowUpIcon } from "@heroicons/vue/24/outline"; import { ArchiveBoxArrowDownIcon, ArrowDownTrayIcon, BarsArrowUpIcon } from "@heroicons/vue/24/outline";
import { useAbilityStore } from "@/stores/ability"; import { useAbilityStore } from "@/stores/ability";
import { useModalStore } from "@/stores/modal"; import { useModalStore } from "@/stores/modal";
import { useBackupStore } from "../../../../stores/admin/management/backup"; import { useBackupStore } from "@/stores/admin/management/backup";
</script> </script>
<script lang="ts"> <script lang="ts">

View file

@ -57,9 +57,9 @@ 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 { useBackupStore } from "@/stores/admin/management/backup"; import { useBackupStore } from "@/stores/admin/management/backup";
import type { BackupRestoreViewModel } from "../../../../viewmodels/admin/management/backup.models"; import type { BackupRestoreViewModel } from "@/viewmodels/admin/management/backup.models";
import { InformationCircleIcon } from "@heroicons/vue/24/outline"; import { InformationCircleIcon } from "@heroicons/vue/24/outline";
import { backupSections, type BackupSection } from "../../../../types/backupTypes"; import { backupSections, type BackupSection } from "@/types/backupTypes";
</script> </script>
<script lang="ts"> <script lang="ts">

View file

@ -14,7 +14,7 @@
<p class="hidden md:block text-center">oder</p> <p class="hidden md:block text-center">oder</p>
<div class="flex flex-row gap-2 items-center"> <div class="flex flex-row gap-2 items-center">
<input <input
class="!hidden" class="hidden!"
type="file" type="file"
ref="fileSelect" ref="fileSelect"
accept="application/JSON" accept="application/JSON"

View file

@ -0,0 +1,67 @@
<template>
<BaseSetting title="Anwendungs Einstellungen" :submit-function="submit" v-slot="{ enableEdit }">
<div class="w-full">
<label for="custom_login_message">Nachricht unter Login (optional)</label>
<input
id="custom_login_message"
type="text"
:readonly="!enableEdit"
:value="appSettings['app.custom_login_message']"
/>
</div>
<div class="w-full flex flex-row items-center gap-2">
<div
v-if="!enableEdit"
class="border-2 border-gray-500 rounded-sm"
:class="appSettings['app.show_link_to_calendar'] ? 'bg-gray-500' : 'h-3.5 w-3.5'"
>
<CheckIcon v-if="appSettings['app.show_link_to_calendar']" class="h-2.5 w-2.5 stroke-4 text-white" />
</div>
<input v-else id="show_link_to_calendar" type="checkbox" :checked="appSettings['app.show_link_to_calendar']" />
<label for="show_link_to_calendar">Kalender-Link anzeigen</label>
</div>
</BaseSetting>
</template>
<script setup lang="ts">
import { useAbilityStore } from "@/stores/ability";
import { useSettingStore } from "@/stores/admin/management/setting";
import { CheckIcon } from "@heroicons/vue/24/outline";
import { mapActions, mapState } from "pinia";
import { defineComponent } from "vue";
import BaseSetting from "./BaseSetting.vue";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
enableEdit: false as boolean,
status: undefined as undefined | "loading" | "success" | "failed",
};
},
computed: {
...mapState(useSettingStore, ["readByTopic"]),
...mapState(useAbilityStore, ["can"]),
appSettings() {
return this.readByTopic("app");
},
},
methods: {
...mapActions(useSettingStore, ["updateSettings"]),
submit(e: any) {
const formData = e.target.elements;
return this.updateSettings([
{
key: "app.custom_login_message",
value: formData.custom_login_message.value || null,
},
{
key: "app.show_link_to_calendar",
value: formData.show_link_to_calendar.checked || null,
},
]);
},
},
});
</script>

View file

@ -0,0 +1,53 @@
<template>
<BaseSetting title="Backup Einstellungen" :submit-function="submit" v-slot="{ enableEdit }">
<div class="w-full">
<label for="copies">Anzahl paralleler Backups (optional)</label>
<input id="copies" type="text" :readonly="!enableEdit" :value="backupSettings['backup.copies']" />
</div>
<div class="w-full">
<label for="interval">Intervall zur Backup-Erstellung (optional)</label>
<input id="interval" type="text" :readonly="!enableEdit" :value="backupSettings['backup.interval']" /></div
></BaseSetting>
</template>
<script setup lang="ts">
import { useAbilityStore } from "@/stores/ability";
import { useSettingStore } from "@/stores/admin/management/setting";
import { mapActions, mapState } from "pinia";
import { defineComponent } from "vue";
import BaseSetting from "./BaseSetting.vue";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
enableEdit: false as boolean,
status: undefined as undefined | "loading" | "success" | "failed",
};
},
computed: {
...mapState(useSettingStore, ["readByTopic"]),
...mapState(useAbilityStore, ["can"]),
backupSettings() {
return this.readByTopic("backup");
},
},
methods: {
...mapActions(useSettingStore, ["updateSettings"]),
submit(e: any) {
const formData = e.target.elements;
return this.updateSettings([
{
key: "backup.copies",
value: formData.copies.value || null,
},
{
key: "backup.interval",
value: formData.interval.value || null,
},
]);
},
},
});
</script>

View file

@ -0,0 +1,87 @@
<template>
<form ref="form" class="flex flex-col w-full" @submit.prevent="submit">
<div class="flex flex-row gap-2 items-center border-l-3 border-l-primary p-2 rounded-t-lg bg-red-200">
<p class="text-lg font-semibold grow">{{ title }}</p>
<Spinner v-if="status == 'loading'" />
<SuccessCheckmark v-else-if="status == 'success'" />
<FailureXMark v-else-if="status == 'failed'" />
<div v-else-if="enableEdit" class="flex flex-row gap-2">
<button type="submit" class="w-fit! h-fit! p-0!">
<CheckIcon class="h-5 w-5 cursor-pointer" />
</button>
<button
type="reset"
class="w-fit! h-fit! p-0!"
@click="
enableEdit = false;
$emit('reset');
"
>
<XMarkIcon class="h-5 w-5 cursor-pointer" />
</button>
</div>
<PencilSquareIcon
v-else-if="can('create', 'management', 'setting')"
class="h-5 w-5 cursor-pointer"
@click="enableEdit = true"
/>
</div>
<div class="border-l-3 border-l-primary p-2 rounded-b-lg">
<slot :enableEdit="enableEdit"></slot>
</div>
</form>
</template>
<script setup lang="ts">
import FailureXMark from "@/components/FailureXMark.vue";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import { useAbilityStore } from "@/stores/ability";
import { CheckIcon, PencilSquareIcon, XMarkIcon } from "@heroicons/vue/24/outline";
import { mapActions, mapState } from "pinia";
import { defineComponent } from "vue";
import type { PropType } from "vue";
</script>
<script lang="ts">
export default defineComponent({
props: {
title: {
type: String,
required: true,
},
submitFunction: {
type: Function as PropType<(e: any) => Promise<any>>,
required: true,
},
},
emits: ["reset"],
data() {
return {
enableEdit: false as boolean,
status: undefined as undefined | "loading" | "success" | "failed",
};
},
computed: {
...mapState(useAbilityStore, ["can"]),
},
methods: {
submit(e: any) {
this.status = "loading";
this.submitFunction(e)
.then(() => {
this.status = "success";
})
.catch(() => {
this.status = "failed";
})
.finally(() => {
setTimeout(() => {
if (this.status == "success") this.enableEdit = false;
this.status = undefined;
}, 2000);
});
},
},
});
</script>

View file

@ -0,0 +1,152 @@
<template>
<BaseSetting title="Vereins-Auftritt Einstellungen" :submit-function="submit" v-slot="{ enableEdit }" @reset="reset">
<div class="w-full">
<p>Vereins-Icon</p>
<div class="flex flex-row gap-2">
<AppIcon v-if="icon != '' && !overwriteIcon" class="h-10! max-w-full mx-auto" />
<div
v-else-if="!overwriteIcon"
class="flex h-10 w-full border-2 border-gray-300 rounded-md items-center justify-center text-sm"
:class="{ 'cursor-pointer': enableEdit }"
@click="enableEdit ? ($refs.icon as HTMLInputElement).click() : null"
>
Kein eigenes Icon ausgewählt
</div>
<img ref="icon_img" class="hidden w-full h-10 object-contain" />
<XMarkIcon
v-if="enableEdit && (icon != '' || overwriteIcon)"
class="h-5 w-5 cursor-pointer"
@click="resetImage('icon')"
/>
</div>
<input class="hidden!" type="file" ref="icon" accept="image/png" @change="previewImage('icon')" />
</div>
<div class="w-full">
<p>Vereins-Logo</p>
<div class="flex flex-row gap-2">
<AppLogo v-if="logo != '' && !overwriteLogo" class="h-10! max-w-full mx-auto" />
<div
v-else-if="!overwriteLogo"
class="flex h-10 w-full border-2 border-gray-300 rounded-md items-center justify-center text-sm"
:class="{ 'cursor-pointer': enableEdit }"
@click="enableEdit ? ($refs.logo as HTMLInputElement).click() : null"
>
Kein eigenes Logo ausgewählt
</div>
<img ref="logo_img" class="hidden w-full h-10 object-contain" />
<XMarkIcon
v-if="enableEdit && (logo != '' || overwriteLogo)"
class="h-5 w-5 cursor-pointer"
@click="resetImage('logo')"
/>
</div>
<input class="hidden!" type="file" ref="logo" accept="image/png" @change="previewImage('logo')" />
</div>
</BaseSetting>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapActions, mapState } from "pinia";
import { useSettingStore } from "@/stores/admin/management/setting";
import AppIcon from "@/components/AppIcon.vue";
import AppLogo from "@/components/AppLogo.vue";
import { useAbilityStore } from "@/stores/ability";
import type { SettingString } from "@/types/settingTypes";
import BaseSetting from "./BaseSetting.vue";
import { XMarkIcon } from "@heroicons/vue/24/outline";
</script>
<script lang="ts">
export default defineComponent({
watch: {
clubSettings() {
this.reset();
},
},
data() {
return {
logo: "",
icon: "",
overwriteIcon: false as boolean,
overwriteLogo: false as boolean,
};
},
computed: {
...mapState(useSettingStore, ["readByTopic"]),
...mapState(useAbilityStore, ["can"]),
clubSettings() {
return this.readByTopic("club");
},
},
mounted() {
this.reset();
},
methods: {
...mapActions(useSettingStore, ["updateSettings", "uploadImage"]),
reset() {
this.icon = this.clubSettings["club.icon"];
this.overwriteIcon = false;
(this.$refs.icon_img as HTMLImageElement).style.display = "none";
(this.$refs.icon as HTMLInputElement).value = "";
this.logo = this.clubSettings["club.logo"];
this.overwriteLogo = false;
(this.$refs.logo_img as HTMLImageElement).style.display = "none";
(this.$refs.logo as HTMLInputElement).value = "";
},
resetImage(inputname: "icon" | "logo") {
if (inputname == "icon") {
this.icon = "";
this.overwriteIcon = false;
(this.$refs.icon_img as HTMLImageElement).style.display = "none";
(this.$refs.icon as HTMLInputElement).value = "";
} else {
this.logo = "";
this.overwriteLogo = false;
(this.$refs.logo_img as HTMLImageElement).style.display = "none";
(this.$refs.logo as HTMLInputElement).value = "";
}
},
previewImage(inputname: "icon" | "logo") {
let input = this.$refs[inputname] as HTMLInputElement;
let previewElement = this.$refs[inputname + "_img"] as HTMLImageElement;
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = function (e) {
previewElement.src = e.target?.result as string;
previewElement.style.display = "block";
};
reader.readAsDataURL(input.files[0]);
if (inputname == "icon") {
this.overwriteIcon = true;
} else {
this.overwriteLogo = true;
}
} else {
previewElement.src = "";
previewElement.style.display = "none";
}
},
submit(e: any) {
return this.uploadImage([
{
key: "club.icon",
value:
(this.$refs.icon as HTMLInputElement).files?.[0] ??
(this.icon != "" && !this.overwriteIcon ? "keep" : undefined),
},
{
key: "club.logo",
value:
(this.$refs.logo as HTMLInputElement).files?.[0] ??
(this.logo != "" && !this.overwriteLogo ? "keep" : undefined),
},
]);
},
},
});
</script>

View file

@ -0,0 +1,89 @@
<template>
<BaseSetting title="Vereins Einstellungen" :submit-function="submit" v-slot="{ enableEdit }">
<div class="w-full">
<label for="clubname">Vereins-Name (optional)</label>
<input id="clubname" type="text" :readonly="!enableEdit" :value="clubSettings['club.name']" />
</div>
<div class="w-full">
<label for="imprint">Vereins-Impressum Link (optional)</label>
<input id="imprint" type="url" :readonly="!enableEdit" :value="clubSettings['club.imprint']" />
</div>
<div class="w-full">
<label for="privacy">Vereins-Datenschutz Link (optional)</label>
<input id="privacy" type="url" :readonly="!enableEdit" :value="clubSettings['club.privacy']" />
</div>
<div class="w-full">
<label for="website">Vereins-Webseite Link (optional)</label>
<input id="website" type="url" :readonly="!enableEdit" :value="clubSettings['club.website']" /></div
></BaseSetting>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapActions, mapState } from "pinia";
import { useSettingStore } from "@/stores/admin/management/setting";
import AppIcon from "@/components/AppIcon.vue";
import AppLogo from "@/components/AppLogo.vue";
import { useAbilityStore } from "@/stores/ability";
import type { SettingString } from "@/types/settingTypes";
import BaseSetting from "./BaseSetting.vue";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
overwriteIcon: false as boolean,
overwriteLogo: false as boolean,
};
},
computed: {
...mapState(useSettingStore, ["readByTopic"]),
...mapState(useAbilityStore, ["can"]),
clubSettings() {
return this.readByTopic("club");
},
},
methods: {
...mapActions(useSettingStore, ["updateSettings", "uploadImage"]),
previewImage(inputname: "icon" | "logo") {
let input = this.$refs[inputname] as HTMLInputElement;
let previewElement = this.$refs[inputname + "_img"] as HTMLImageElement;
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = function (e) {
previewElement.src = e.target?.result as string;
previewElement.style.display = "block";
};
reader.readAsDataURL(input.files[0]);
} else {
previewElement.src = "";
previewElement.style.display = "none";
}
},
submit(e: any) {
const formData = e.target.elements;
return this.updateSettings([
{
key: "club.name",
value: formData.clubname.value || null,
},
{
key: "club.imprint",
value: formData.imprint.value || null,
},
{
key: "club.privacy",
value: formData.privacy.value || null,
},
{
key: "club.website",
value: formData.website.value || null,
},
]);
},
},
});
</script>

View file

@ -0,0 +1,100 @@
<template>
<BaseSetting title="E-Mail Einstellungen" :submit-function="submit" v-slot="{ enableEdit }">
<div class="w-full">
<label for="email">Mailadresse</label>
<input id="email" type="email" autocomplete="email" :readonly="!enableEdit" :value="mailSettings['mail.email']" />
</div>
<div class="w-full">
<label for="username">Benutzername</label>
<input
id="username"
type="text"
:readonly="!enableEdit"
autocomplete="username"
:value="mailSettings['mail.username']"
/>
</div>
<div class="w-full">
<label for="host">Server-Host</label>
<input id="host" type="text" :readonly="!enableEdit" :value="mailSettings['mail.host']" />
</div>
<div class="w-full">
<label for="port">Server-Port (25, 465, 587)</label>
<input id="port" type="number" :readonly="!enableEdit" :value="mailSettings['mail.port']" />
</div>
<div class="w-full flex flex-row items-center gap-2">
<div
v-if="!enableEdit"
class="border-2 border-gray-500 rounded-sm"
:class="mailSettings['mail.secure'] ? 'bg-gray-500' : 'h-3.5 w-3.5'"
>
<CheckIcon v-if="mailSettings['mail.secure']" class="h-2.5 w-2.5 stroke-4 text-white" />
</div>
<input v-else id="secure" type="checkbox" :checked="mailSettings['mail.secure']" />
<label for="secure">Secure-Verbindung (setzen bei Port 465)</label>
</div>
<div class="w-full">
<label for="password">Passwort (optional - leeres Feld setzt Passwort nicht zurück)</label>
<input id="password" type="password" :readonly="!enableEdit" autocomplete="new-password" />
</div>
</BaseSetting>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { CheckIcon } from "@heroicons/vue/24/outline";
import { mapActions, mapState } from "pinia";
import { useSettingStore } from "@/stores/admin/management/setting";
import { useAbilityStore } from "@/stores/ability";
import BaseSetting from "./BaseSetting.vue";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
enableEdit: false as boolean,
status: undefined as undefined | "loading" | "success" | "failed",
};
},
computed: {
...mapState(useSettingStore, ["readByTopic"]),
...mapState(useAbilityStore, ["can"]),
mailSettings() {
return this.readByTopic("mail");
},
},
methods: {
...mapActions(useSettingStore, ["updateSettings"]),
submit(e: any) {
const formData = e.target.elements;
return this.updateSettings([
{
key: "mail.email",
value: formData.email.value,
},
{
key: "mail.username",
value: formData.username.value,
},
{
key: "mail.host",
value: formData.host.value,
},
{
key: "mail.port",
value: formData.port.value,
},
{
key: "mail.secure",
value: formData.secure.checked,
},
{
key: "mail.password",
value: formData.password.value || null,
},
]);
},
},
});
</script>

View file

@ -0,0 +1,76 @@
<template>
<BaseSetting title="Login-Session Einstellungen" :submit-function="submit" v-slot="{ enableEdit }">
<div class="w-full">
<label for="jwt_expiration">JWT-Gültigkeitsdauer (optional)</label>
<input
id="jwt_expiration"
type="text"
:readonly="!enableEdit"
:value="sessionSettings['session.jwt_expiration']"
/>
</div>
<div class="w-full">
<label for="refresh_expiration">Session-Gültigkeitsdauer (optional)</label>
<input
id="refresh_expiration"
type="text"
:readonly="!enableEdit"
:value="sessionSettings['session.refresh_expiration']"
/>
</div>
<div class="w-full">
<label for="pwa_refresh_expiration">Sesion-Gültigkeitsdauer PWA (optional)</label>
<input
id="pwa_refresh_expiration"
type="text"
:readonly="!enableEdit"
:value="sessionSettings['session.pwa_refresh_expiration']"
/></div
></BaseSetting>
</template>
<script setup lang="ts">
import { useAbilityStore } from "@/stores/ability";
import { useSettingStore } from "@/stores/admin/management/setting";
import { mapActions, mapState } from "pinia";
import { defineComponent } from "vue";
import BaseSetting from "./BaseSetting.vue";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
enableEdit: false as boolean,
status: undefined as undefined | "loading" | "success" | "failed",
};
},
computed: {
...mapState(useSettingStore, ["readByTopic"]),
...mapState(useAbilityStore, ["can"]),
sessionSettings() {
return this.readByTopic("session");
},
},
methods: {
...mapActions(useSettingStore, ["updateSettings"]),
submit(e: any) {
const formData = e.target.elements;
return this.updateSettings([
{
key: "session.jwt_expiration",
value: formData.jwt_expiration.value || null,
},
{
key: "session.refresh_expiration",
value: formData.refresh_expiration.value || null,
},
{
key: "session.pwa_refresh_expiration",
value: formData.pwa_refresh_expiration.value || null,
},
]);
},
},
});
</script>

View file

@ -8,16 +8,16 @@
<form class="flex flex-col gap-4 py-2" @submit.prevent="invite"> <form class="flex flex-col gap-4 py-2" @submit.prevent="invite">
<div class="-space-y-px"> <div class="-space-y-px">
<div> <div>
<input id="username" name="username" type="text" required placeholder="Benutzer" class="!rounded-b-none" /> <input id="username" name="username" type="text" required placeholder="Benutzer" class="rounded-b-none!" />
</div> </div>
<div> <div>
<input id="mail" name="mail" type="email" required placeholder="Mailadresse" class="!rounded-none" /> <input id="mail" name="mail" type="email" required placeholder="Mailadresse" class="rounded-none!" />
</div> </div>
<div> <div>
<input id="firstname" name="firstname" type="text" required placeholder="Vorname" class="!rounded-none" /> <input id="firstname" name="firstname" type="text" required placeholder="Vorname" class="rounded-none!" />
</div> </div>
<div> <div>
<input id="lastname" name="lastname" type="text" required placeholder="Nachname" class="!rounded-t-none" /> <input id="lastname" name="lastname" type="text" required placeholder="Nachname" class="rounded-t-none!" />
</div> </div>
</div> </div>
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">

View file

@ -39,7 +39,7 @@ 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 { useWebapiStore } from "@/stores/admin/management/webapi"; import { useWebapiStore } from "@/stores/admin/management/webapi";
import type { CreateWebapiViewModel } from "../../../../viewmodels/admin/management/webapi.models"; import type { CreateWebapiViewModel } from "@/viewmodels/admin/management/webapi.models";
</script> </script>
<script lang="ts"> <script lang="ts">

View file

@ -28,7 +28,7 @@ import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import TextCopy from "@/components/TextCopy.vue"; import TextCopy from "@/components/TextCopy.vue";
import { CalendarDaysIcon, InformationCircleIcon } from "@heroicons/vue/24/outline"; import { CalendarDaysIcon, InformationCircleIcon } from "@heroicons/vue/24/outline";
import { host } from "@/serverCom"; import { host } from "@/serverCom";
import { useWebapiStore } from "../../../../stores/admin/management/webapi"; import { useWebapiStore } from "@/stores/admin/management/webapi";
</script> </script>
<script lang="ts"> <script lang="ts">

View file

@ -30,9 +30,12 @@
v-if="allowPredefinedSelect && can('read', 'configuration', 'query_store')" v-if="allowPredefinedSelect && can('read', 'configuration', 'query_store')"
class="flex flex-row gap-2 max-lg:w-full max-lg:order-10" class="flex flex-row gap-2 max-lg:w-full max-lg:order-10"
> >
<select v-model="activeQueryId" class="max-h-[34px] !py-0"> <div v-if="!isAsStored" class="p-1 border border-gray-400 bg-gray-100 rounded-md" title="Änderung erkannt">
<DocumentCurrencyRupeeIcon class="text-gray-500 h-6 w-6 cursor-pointer" />
</div>
<select v-model="activeQueryId" class="max-h-[34px] py-0!">
<option :value="undefined" disabled>gepeicherte Anfrage auswählen</option> <option :value="undefined" disabled>gepeicherte Anfrage auswählen</option>
<option v-for="query in queries" :key="query.id" :value="query.id" @click="value = query.query"> <option v-for="query in queries" :key="query.id" :value="query.id">
{{ query.title }} {{ query.title }}
</option> </option>
</select> </select>
@ -54,7 +57,7 @@
class="p-1" class="p-1"
:class="typeof value == 'object' ? 'bg-gray-200' : ''" :class="typeof value == 'object' ? 'bg-gray-200' : ''"
title="Visual Builder" title="Visual Builder"
@click="queryMode = 'builder'" @click="changeMode('builder')"
> >
<RectangleGroupIcon class="text-gray-500 h-6 w-6 cursor-pointer" /> <RectangleGroupIcon class="text-gray-500 h-6 w-6 cursor-pointer" />
</div> </div>
@ -62,7 +65,7 @@
class="p-1" class="p-1"
:class="typeof value == 'string' ? 'bg-gray-200' : ''" :class="typeof value == 'string' ? 'bg-gray-200' : ''"
title="SQL Editor" title="SQL Editor"
@click="queryMode = 'editor'" @click="changeMode('editor')"
> >
<CommandLineIcon class="text-gray-500 h-6 w-6 cursor-pointer" /> <CommandLineIcon class="text-gray-500 h-6 w-6 cursor-pointer" />
</div> </div>
@ -70,7 +73,7 @@
</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">
<textarea v-if="typeof value == 'string'" v-model="value" placeholder="SQL Query" class="h-full w-full" /> <textarea v-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" enableOrder />
</div> </div>
</div> </div>
</template> </template>
@ -78,7 +81,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { defineAsyncComponent, defineComponent, markRaw, type PropType } from "vue"; import { defineAsyncComponent, defineComponent, markRaw, type PropType } from "vue";
import { mapActions, mapState, mapWritableState } from "pinia"; import { mapActions, mapState, mapWritableState } from "pinia";
import type { DynamicQueryStructure } from "@/types/dynamicQueries"; import { type DynamicQueryStructure } from "@/types/dynamicQueries";
import { import {
ArchiveBoxArrowDownIcon, ArchiveBoxArrowDownIcon,
CommandLineIcon, CommandLineIcon,
@ -88,12 +91,15 @@ import {
RectangleGroupIcon, RectangleGroupIcon,
TrashIcon, TrashIcon,
SparklesIcon, SparklesIcon,
DocumentCurrencyRupeeIcon,
} from "@heroicons/vue/24/outline"; } from "@heroicons/vue/24/outline";
import { useQueryBuilderStore } from "@/stores/admin/club/queryBuilder"; import { useQueryBuilderStore } from "@/stores/admin/club/queryBuilder";
import { useModalStore } from "@/stores/modal"; import { useModalStore } from "@/stores/modal";
import Table from "./Table.vue"; import Table from "./Table.vue";
import { useAbilityStore } from "@/stores/ability"; import { useAbilityStore } from "@/stores/ability";
import { useQueryStoreStore } from "@/stores/admin/configuration/queryStore"; import { useQueryStoreStore } from "@/stores/admin/configuration/queryStore";
import { v4 as uuid } from "uuid";
import cloneDeep from "lodash.clonedeep";
</script> </script>
<script lang="ts"> <script lang="ts">
@ -102,6 +108,7 @@ export default defineComponent({
modelValue: { modelValue: {
type: [Object, String] as PropType<DynamicQueryStructure | string>, type: [Object, String] as PropType<DynamicQueryStructure | string>,
default: { default: {
id: uuid(),
select: "*", select: "*",
table: "", table: "",
where: [], where: [],
@ -116,21 +123,9 @@ export default defineComponent({
}, },
emits: ["update:model-value", "query:run", "query:save", "results:export", "results:clear"], emits: ["update:model-value", "query:run", "query:save", "results:export", "results:clear"],
watch: { watch: {
queryMode() {
if (this.queryMode == "builder") {
this.value = {
select: "*",
table: "",
where: [],
join: [],
orderBy: [],
};
} else {
this.value = "";
}
this.activeQueryId = undefined;
},
activeQueryId() { activeQueryId() {
if (this.activeQueryId == undefined) return;
let query = this.queries.find((t) => t.id == this.activeQueryId)?.query; let query = this.queries.find((t) => t.id == this.activeQueryId)?.query;
if (query != undefined) { if (query != undefined) {
if (typeof query == "string") { if (typeof query == "string") {
@ -138,7 +133,12 @@ export default defineComponent({
} else { } else {
this.queryMode = "builder"; this.queryMode = "builder";
} }
this.value = query; this.value = cloneDeep(query);
}
},
value() {
if (typeof this.value != "string" && !this.value.id) {
this.value.id = uuid();
} }
}, },
}, },
@ -161,6 +161,11 @@ export default defineComponent({
this.$emit("update:model-value", val); this.$emit("update:model-value", val);
}, },
}, },
isAsStored() {
let stored = this.queries.find((q) => q.id == this.activeQueryId);
if (!stored) return true;
return JSON.stringify(this.value) == JSON.stringify(stored.query);
},
}, },
mounted() { mounted() {
this.fetchTableMetas(); this.fetchTableMetas();
@ -175,6 +180,7 @@ export default defineComponent({
this.activeQueryId = undefined; this.activeQueryId = undefined;
if (typeof this.value != "string") { if (typeof this.value != "string") {
this.value = { this.value = {
id: uuid(),
select: "*", select: "*",
table: "", table: "",
where: [], where: [],
@ -188,6 +194,23 @@ export default defineComponent({
showStructure() { showStructure() {
this.openModal(markRaw(defineAsyncComponent(() => import("@/components/queryBuilder/StructureModal.vue")))); this.openModal(markRaw(defineAsyncComponent(() => import("@/components/queryBuilder/StructureModal.vue"))));
}, },
changeMode(mode: "editor" | "builder") {
this.queryMode = mode;
this.activeQueryId = undefined;
if (this.queryMode == "builder") {
this.value = {
id: uuid(),
select: "*",
table: "",
where: [],
join: [],
orderBy: [],
};
} else {
this.value = "";
}
},
}, },
}); });
</script> </script>

View file

@ -3,7 +3,7 @@
<p class="w-14 min-w-14 pt-2">SELECT</p> <p class="w-14 min-w-14 pt-2">SELECT</p>
<div class="flex flex-row flex-wrap gap-2 items-center"> <div class="flex flex-row flex-wrap gap-2 items-center">
<p <p
class="rounded-md shadow-sm relative block w-fit px-3 py-2 border border-gray-300 text-gray-900 rounded-b-md sm:text-sm" class="rounded-md shadow-xs relative block w-fit px-3 py-2 border border-gray-300 text-gray-900 rounded-b-md sm:text-sm"
:class="value == '*' ? 'border-gray-600 bg-gray-200' : ''" :class="value == '*' ? 'border-gray-600 bg-gray-200' : ''"
@click="value = '*'" @click="value = '*'"
> >
@ -12,7 +12,7 @@
<p <p
v-for="col in columns" v-for="col in columns"
:key="col.column" :key="col.column"
class="rounded-md shadow-sm relative block w-fit px-3 py-2 border border-gray-300 text-gray-900 rounded-b-md sm:text-sm" class="rounded-md shadow-xs relative block w-fit px-3 py-2 border border-gray-300 text-gray-900 rounded-b-md sm:text-sm"
:class="value.includes(col.column) ? 'border-gray-600 bg-gray-200' : ''" :class="value.includes(col.column) ? 'border-gray-600 bg-gray-200' : ''"
@click="value = [col.column]" @click="value = [col.column]"
> >

View file

@ -1,6 +1,6 @@
<template> <template>
<div class="flex flex-row gap-2 items-center w-full"> <div class="flex flex-row gap-2 items-center w-full">
<select v-if="concat != '_'" v-model="concat" class="!w-20 !h-fit"> <select v-if="!isFirst" v-model="concat" class="w-20! h-fit!">
<option value="" disabled>Verknüpfung auswählen</option> <option value="" disabled>Verknüpfung auswählen</option>
<option v-for="operation in ['AND', 'OR']" :value="operation"> <option v-for="operation in ['AND', 'OR']" :value="operation">
{{ operation }} {{ operation }}
@ -12,7 +12,7 @@
{{ foreignColumns?.includes(col.column) ? "FK:" : "" }} {{ col.column }}:{{ col.type }} {{ foreignColumns?.includes(col.column) ? "FK:" : "" }} {{ col.column }}:{{ col.type }}
</option> </option>
</select> </select>
<select v-model="operation" class="!w-fit !h-fit"> <select v-model="operation" class="w-fit! h-fit!">
<option value="" disabled>Vergleich auswählen</option> <option value="" disabled>Vergleich auswählen</option>
<option v-for="op in whereOperationArray" :value="op"> <option v-for="op in whereOperationArray" :value="op">
{{ op }} {{ op }}
@ -68,6 +68,10 @@ import { TrashIcon } from "@heroicons/vue/24/outline";
<script lang="ts"> <script lang="ts">
export default defineComponent({ export default defineComponent({
props: { props: {
isFirst: {
type: Boolean,
default: false,
},
table: { table: {
type: String, type: String,
default: "", default: "",
@ -78,9 +82,6 @@ export default defineComponent({
}, },
}, },
emits: ["update:model-value", "remove"], emits: ["update:model-value", "remove"],
data() {
return {};
},
computed: { computed: {
...mapState(useQueryBuilderStore, ["tableMetas"]), ...mapState(useQueryBuilderStore, ["tableMetas"]),
activeTable() { activeTable() {
@ -144,5 +145,10 @@ export default defineComponent({
}, },
}, },
}, },
mounted() {
if (this.concat == "_") {
this.concat = "AND";
}
},
}); });
</script> </script>

View file

@ -1,12 +1,13 @@
<template> <template>
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
<p class="w-14 min-w-14 pt-2">JOIN</p> <p class="w-14 min-w-14 pt-2">I_JOIN</p>
<div class="flex flex-row flex-wrap gap-2 items-center w-full"> <div class="flex flex-row flex-wrap gap-2 items-center w-full">
<div class="flex flex-row flex-wrap gap-2 items-center justify-end w-full"> <div class="flex flex-row flex-wrap gap-2 items-center justify-end w-full">
<JoinTable <JoinTable
v-for="(join, index) in value" v-for="(join, index) in value"
:model-value="join" :model-value="join"
:table="table" :table="table"
:alreadyJoined="alreadyJoined"
@update:model-value="($event) => (value[index] = $event)" @update:model-value="($event) => (value[index] = $event)"
@remove="removeAtIndex(index)" @remove="removeAtIndex(index)"
/> />
@ -21,10 +22,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import { mapActions, mapState } from "pinia"; import { mapActions, mapState } from "pinia";
import type { DynamicQueryStructure } from "@/types/dynamicQueries"; import { type DynamicQueryStructure, type JoinStructure } from "@/types/dynamicQueries";
import { useQueryBuilderStore } from "@/stores/admin/club/queryBuilder"; import { useQueryBuilderStore } from "@/stores/admin/club/queryBuilder";
import { PlusIcon } from "@heroicons/vue/24/outline"; import { PlusIcon } from "@heroicons/vue/24/outline";
import JoinTable from "./JoinTable.vue"; import JoinTable from "./JoinTable.vue";
import { v4 as uuid } from "uuid";
</script> </script>
<script lang="ts"> <script lang="ts">
@ -35,24 +37,22 @@ export default defineComponent({
default: "", default: "",
}, },
modelValue: { modelValue: {
type: Array as PropType<Array<DynamicQueryStructure & { foreignColumn: string }>>, type: Array as PropType<Array<DynamicQueryStructure & JoinStructure>>,
default: [],
},
alreadyJoined: {
type: Array as PropType<Array<string>>,
default: [], default: [],
}, },
}, },
emits: ["update:model-value"], emits: ["update:model-value"],
data() {
return {};
},
computed: { computed: {
...mapState(useQueryBuilderStore, ["tableMetas"]), ...mapState(useQueryBuilderStore, ["tableMetas"]),
activeTable() {
return this.tableMetas.find((tm) => tm.tableName == this.table);
},
value: { value: {
get() { get() {
return this.modelValue; return this.modelValue;
}, },
set(val: Array<DynamicQueryStructure & { foreignColumn: string }>) { set(val: Array<DynamicQueryStructure & JoinStructure>) {
this.$emit("update:model-value", val); this.$emit("update:model-value", val);
}, },
}, },
@ -60,11 +60,13 @@ export default defineComponent({
methods: { methods: {
addToValue() { addToValue() {
this.value.push({ this.value.push({
id: uuid(),
select: "*", select: "*",
table: "", table: "",
where: [], where: [],
join: [], join: [],
orderBy: [], orderBy: [],
type: "defined",
foreignColumn: "", foreignColumn: "",
}); });
}, },

View file

@ -2,13 +2,52 @@
<div class="flex flex-row gap-2 w-full"> <div class="flex flex-row gap-2 w-full">
<div class="flex flex-row gap-2 w-full"> <div class="flex flex-row gap-2 w-full">
<div class="flex flex-col gap-2 w-full"> <div class="flex flex-col gap-2 w-full">
<select v-model="foreignColumn" class="w-full"> <div class="flex flex-row gap-2 w-full">
<option value="" disabled>Relation auswählen</option> <div
<option v-for="relation in activeTable?.relations" :value="relation.column"> v-if="false"
{{ relation.column }} -> {{ joinTableName(relation.referencedTableName) }} class="h-fit p-1 border border-gray-400 hover:bg-gray-200 rounded-md"
</option> title="Join Modus wechseln"
</select> @click="swapJoinType(value.type)"
<Table v-model="value" disable-table-select /> >
<ArrowsUpDownIcon class="text-gray-500 h-6 w-6 cursor-pointer" />
</div>
<select v-if="type == 'defined'" v-model="context" class="w-full">
<option value="" disabled>Relation auswählen</option>
<option
v-for="relation in activeTable?.relations"
:value="relation.column"
:disabled="
alreadyJoined.includes(joinTableName(relation.referencedTableName)) &&
joinTableName(relation.referencedTableName) != value.table
"
>
{{ relation.column }} -> {{ joinTableName(relation.referencedTableName) }}
<span
v-if="
alreadyJoined.includes(joinTableName(relation.referencedTableName)) &&
joinTableName(relation.referencedTableName) != value.table
"
>
(Join auf dieser Ebene besteht schon)
</span>
</option>
</select>
<div v-else class="flex flex-col w-full">
<select v-model="joinTable">
<option value="" disabled>Tabelle auswählen</option>
<option
v-for="table in tableMetas"
:value="table.tableName"
:disabled="alreadyJoined.includes(table.tableName) && table.tableName != value.table"
>
{{ table.tableName }}
</option>
</select>
<input v-model="context" type="text" placeholder="Join Condition tabA.col = tabB.col" />
</div>
</div>
<Table v-model="value" disable-table-select :show-table-select="false" />
</div> </div>
<div class="h-fit p-1 border border-gray-400 hover:bg-gray-200 rounded-md" @click="$emit('remove')"> <div class="h-fit p-1 border border-gray-400 hover:bg-gray-200 rounded-md" @click="$emit('remove')">
<TrashIcon class="text-gray-500 h-6 w-6 cursor-pointer" /> <TrashIcon class="text-gray-500 h-6 w-6 cursor-pointer" />
@ -20,11 +59,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import { mapActions, mapState } from "pinia"; import { mapActions, mapState } from "pinia";
import type { DynamicQueryStructure } from "@/types/dynamicQueries"; import { type DynamicQueryStructure, type JoinStructure } from "@/types/dynamicQueries";
import { useQueryBuilderStore } from "@/stores/admin/club/queryBuilder"; import { useQueryBuilderStore } from "@/stores/admin/club/queryBuilder";
import Table from "./Table.vue"; import Table from "./Table.vue";
import { TrashIcon } from "@heroicons/vue/24/outline"; import { ArrowsUpDownIcon, TrashIcon } from "@heroicons/vue/24/outline";
import { joinTableName } from "@/helpers/queryFormatter"; import { joinTableName } from "@/helpers/queryFormatter";
import { v4 as uuid } from "uuid";
</script> </script>
<script lang="ts"> <script lang="ts">
@ -35,25 +75,15 @@ export default defineComponent({
default: "", default: "",
}, },
modelValue: { modelValue: {
type: Object as PropType< type: Object as PropType<DynamicQueryStructure & JoinStructure>,
DynamicQueryStructure & { required: true,
foreignColumn: string; },
} alreadyJoined: {
>, type: Array as PropType<Array<string>>,
default: { default: [],
select: "*",
table: "",
where: [],
join: [],
orderBy: [],
foreignColumn: "",
},
}, },
}, },
emits: ["update:model-value", "remove"], emits: ["update:model-value", "remove"],
data() {
return {};
},
computed: { computed: {
...mapState(useQueryBuilderStore, ["tableMetas"]), ...mapState(useQueryBuilderStore, ["tableMetas"]),
activeTable() { activeTable() {
@ -63,27 +93,73 @@ export default defineComponent({
get() { get() {
return this.modelValue; return this.modelValue;
}, },
set( set(val: DynamicQueryStructure & JoinStructure) {
val: DynamicQueryStructure & {
foreignColumn: string;
}
) {
this.$emit("update:model-value", val); this.$emit("update:model-value", val);
}, },
}, },
foreignColumn: { context: {
get() { get() {
return this.modelValue.foreignColumn; if (this.modelValue.type == "defined") {
return this.modelValue.foreignColumn ?? "";
} else {
return this.modelValue.condition ?? "";
}
}, },
set(val: string) { set(val: string) {
let relTable = this.activeTable?.relations.find((r) => r.column == val); if (this.modelValue.type == "defined") {
let relTable = this.activeTable?.relations.find((r) => r.column == val);
this.$emit("update:model-value", {
...this.modelValue,
foreignColumn: val,
table: joinTableName(relTable?.referencedTableName ?? ""),
});
} else {
this.$emit("update:model-value", {
...this.modelValue,
condition: val,
});
}
},
},
type: {
get(): string {
return this.modelValue.type ?? "defined";
},
set(val: "custom" | "defined") {
this.$emit("update:model-value", { this.$emit("update:model-value", {
...this.modelValue, ...this.modelValue,
foreignColumn: val, type: val,
table: joinTableName(relTable?.referencedTableName ?? ""), });
},
},
joinTable: {
get(): string {
return this.modelValue.table;
},
set(val: string) {
this.$emit("update:model-value", {
...this.modelValue,
table: val,
}); });
}, },
}, },
}, },
mounted() {
if (!this.value.id) {
this.value.id = uuid();
}
if (!this.value.type) {
this.type = "defined";
}
},
methods: {
swapJoinType(type: string) {
if (type == "defined") {
this.type = "custom";
} else {
this.type = "defined";
}
},
},
}); });
</script> </script>

View file

@ -1,6 +1,6 @@
<template> <template>
<div class="flex flex-row gap-2 w-full border border-gray-300 rounded-md p-1"> <div class="flex flex-row gap-2 w-full border border-gray-300 rounded-md p-1">
<select v-if="concat != '_'" v-model="concat" class="!w-20 !h-fit"> <select v-if="isFirst" v-model="concat" class="w-20! h-fit!">
<option value="" disabled>Verknüpfung auswählen</option> <option value="" disabled>Verknüpfung auswählen</option>
<option v-for="operation in ['AND', 'OR']" :value="operation"> <option v-for="operation in ['AND', 'OR']" :value="operation">
{{ operation }} {{ operation }}
@ -28,6 +28,10 @@ import NestedWhere from "./NestedWhere.vue";
<script lang="ts"> <script lang="ts">
export default defineComponent({ export default defineComponent({
props: { props: {
isFirst: {
type: Boolean,
default: false,
},
table: { table: {
type: String, type: String,
default: "", default: "",
@ -38,9 +42,6 @@ export default defineComponent({
}, },
}, },
emits: ["update:model-value", "remove"], emits: ["update:model-value", "remove"],
data() {
return {};
},
computed: { computed: {
...mapState(useQueryBuilderStore, ["tableMetas"]), ...mapState(useQueryBuilderStore, ["tableMetas"]),
concat: { concat: {
@ -60,5 +61,10 @@ export default defineComponent({
}, },
}, },
}, },
mounted() {
if (this.concat == "_") {
this.concat = "AND";
}
},
}); });
</script> </script>

View file

@ -1,12 +1,17 @@
<template> <template>
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
<p class="w-14 min-w-14 pt-2">ORDER</p> <p class="w-14 min-w-14 pt-2">SORT</p>
<div class="flex flex-row flex-wrap gap-2 items-center w-full"> <div class="flex flex-row flex-wrap gap-2 items-center w-full">
<OrderStructure <OrderStructure
v-for="(order, index) in value" v-for="(order, index) in value"
:model-value="order" :model-value="order"
:table="table" :table="table"
:columns="columns" :columns="columns"
:alreadySorted="alreadySorted"
:notFirst="index != 0"
:notLast="index != value.length - 1"
@up="changeSort('up', index)"
@down="changeSort('down', index)"
@update:model-value="($event) => (value[index] = $event)" @update:model-value="($event) => (value[index] = $event)"
@remove="removeAtIndex(index)" @remove="removeAtIndex(index)"
/> />
@ -35,9 +40,15 @@ export default defineComponent({
type: String, type: String,
default: "", default: "",
}, },
// columns: {
// type: [Array, String] as PropType<"*" | Array<string>>,
// default: "*",
// },
columns: { columns: {
type: [Array, String] as PropType<"*" | Array<string>>, type: Array as PropType<
default: "*", Array<{ table: string; id: string; depth: number; path: string[]; columns: "*" | string[] }>
>,
default: [],
}, },
modelValue: { modelValue: {
type: Array as PropType<Array<OrderByStructure>>, type: Array as PropType<Array<OrderByStructure>>,
@ -50,6 +61,9 @@ export default defineComponent({
}, },
computed: { computed: {
...mapState(useQueryBuilderStore, ["tableMetas"]), ...mapState(useQueryBuilderStore, ["tableMetas"]),
alreadySorted() {
return this.modelValue.map((m) => ({ id: m.id, col: m.column }));
},
value: { value: {
get() { get() {
return this.modelValue; return this.modelValue;
@ -62,6 +76,9 @@ export default defineComponent({
methods: { methods: {
addToValue() { addToValue() {
this.value.push({ this.value.push({
id: "",
depth: 0,
table: "",
column: "", column: "",
order: "ASC", order: "ASC",
}); });
@ -69,6 +86,12 @@ export default defineComponent({
removeAtIndex(index: number) { removeAtIndex(index: number) {
this.value.splice(index, 1); this.value.splice(index, 1);
}, },
changeSort(dir: "up" | "down", index: number) {
const swapIndex = dir === "up" ? index - 1 : index + 1;
if (swapIndex >= 0 && swapIndex < this.value.length) {
[this.value[index], this.value[swapIndex]] = [this.value[swapIndex], this.value[index]];
}
},
}, },
}); });
</script> </script>

View file

@ -1,15 +1,26 @@
<template> <template>
<div class="flex flex-row gap-2 items-center w-full"> <div class="flex flex-row gap-2 items-center w-full">
<div class="flex flex-col min-w-fit">
<ChevronUpIcon v-if="notFirst" class="w-4 h-4 stroke-2 cursor-pointer" @click.prevent="$emit('up')" />
<ChevronDownIcon v-if="notLast" class="w-4 h-4 stroke-2 cursor-pointer" @click.prevent="$emit('down')" />
</div>
<select v-model="column" class="w-full"> <select v-model="column" class="w-full">
<option value="" disabled>Spalte auswählen</option> <option value="" disabled>Spalte auswählen</option>
<option v-for="column in selectableColumns" :value="column"> <option
{{ column }} v-for="selectable in selectableColumns"
:value="`${selectable.id}_${selectable.column}`"
:disabled="
alreadySorted.some((as) => as.id == selectable.id && as.col == selectable.column) &&
`${selectable.id}_${selectable.column}` != column
"
>
{{ [...selectable.path, selectable.table].join("-") }} -> {{ selectable.column }}
</option> </option>
</select> </select>
<select v-model="order"> <select v-model="order">
<option value="" disabled>Sortierung auswählen</option> <option value="" disabled>Sortierung auswählen</option>
<option v-for="order in ['ASC', 'DESC']" :value="order"> <option v-for="order in orderable" :value="order.key">
{{ order }} {{ order.val }}
</option> </option>
</select> </select>
<div class="p-1 border border-gray-400 hover:bg-gray-200 rounded-md" @click="$emit('remove')"> <div class="p-1 border border-gray-400 hover:bg-gray-200 rounded-md" @click="$emit('remove')">
@ -23,47 +34,102 @@ import { defineComponent, type PropType } from "vue";
import { mapActions, mapState } from "pinia"; import { mapActions, mapState } from "pinia";
import type { OrderByStructure, OrderByType } from "@/types/dynamicQueries"; import type { OrderByStructure, OrderByType } from "@/types/dynamicQueries";
import { useQueryBuilderStore } from "@/stores/admin/club/queryBuilder"; import { useQueryBuilderStore } from "@/stores/admin/club/queryBuilder";
import { TrashIcon } from "@heroicons/vue/24/outline"; import { TrashIcon, ChevronDownIcon, ChevronUpIcon } from "@heroicons/vue/24/outline";
</script> </script>
<script lang="ts"> <script lang="ts">
export default defineComponent({ export default defineComponent({
props: { props: {
notFirst: {
type: Boolean,
defailt: false,
},
notLast: {
type: Boolean,
defailt: false,
},
table: { table: {
type: String, type: String,
default: "", default: "",
}, },
// columns: {
// type: [Array, String] as PropType<"*" | Array<string>>,
// default: "*",
// },
columns: { columns: {
type: [Array, String] as PropType<"*" | Array<string>>, type: Array as PropType<
default: "*", Array<{ table: string; id: string; depth: number; path: string[]; columns: "*" | string[] }>
>,
default: [],
}, },
modelValue: { modelValue: {
type: Object as PropType<OrderByStructure>, type: Object as PropType<OrderByStructure>,
default: {}, default: {},
}, },
alreadySorted: {
type: Array as PropType<Array<{ id: string; col: string }>>,
default: [],
},
},
emits: ["update:model-value", "remove", "up", "down"],
watch: {
columns() {
if (!this.columns.some((c) => c.id == this.modelValue.id)) {
this.$emit("remove");
}
},
}, },
emits: ["update:model-value", "remove"],
data() { data() {
return {}; return {
orderable: [
{ key: "ASC", val: "Aufsteigend (ABC)" },
{ key: "DESC", val: "Absteigend (CBA)" },
],
};
}, },
computed: { computed: {
...mapState(useQueryBuilderStore, ["tableMetas"]), ...mapState(useQueryBuilderStore, ["tableMetas"]),
// selectableColumns() {
// if (this.columns == "*") {
// let meta = this.tableMetas.find((tm) => tm.tableName == this.table);
// if (!meta) return [];
// let relCols = meta.relations.map((r) => r.column);
// return meta.columns.map((c) => c.column).filter((c) => !relCols.includes(c));
// } else {
// return this.columns;
// }
// },
selectableColumns() { selectableColumns() {
if (this.columns == "*") { return this.columns.reduce(
let meta = this.tableMetas.find((tm) => tm.tableName == this.table); (acc, curr) => {
if (!meta) return []; if (curr.columns == "*") {
let relCols = meta.relations.map((r) => r.column); let meta = this.tableMetas.find((tm) => tm.tableName == curr.table);
return meta.columns.map((c) => c.column).filter((c) => !relCols.includes(c)); if (meta) {
} else { let relCols = meta.relations.map((r) => r.column);
return this.columns; meta.columns
} .map((c) => c.column)
.filter((c) => !relCols.includes(c))
.forEach((c) =>
acc.push({ id: curr.id, depth: curr.depth, table: curr.table, column: c, path: curr.path })
);
}
} else {
curr.columns.forEach((c) =>
acc.push({ id: curr.id, depth: curr.depth, table: curr.table, column: c, path: curr.path })
);
}
return acc;
},
[] as Array<{ id: string; depth: number; table: string; column: string; path: string[] }>
);
}, },
column: { column: {
get() { get() {
return this.modelValue.column; return `${this.modelValue.id}_${this.modelValue.column}`;
}, },
set(val: string) { set(val: `${string}_${string}`) {
this.$emit("update:model-value", { ...this.modelValue, column: val }); let col = this.selectableColumns.find((sc) => sc.id == val.split("_")[0] && sc.column == val.split("_")[1]);
this.$emit("update:model-value", { ...this.modelValue, ...col });
}, },
}, },
order: { order: {

View file

@ -11,7 +11,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">
<a href="/administration-db.png" button primary-outline download="Datenbank-Schema" class="!whitespace-nowrap" <a href="/administration-db.png" button primary-outline download="Datenbank-Schema" class="whitespace-nowrap!"
>Bild herunterladen</a >Bild herunterladen</a
> >
<button primary-outline @click="closeModal">schließen</button> <button primary-outline @click="closeModal">schließen</button>

View file

@ -1,23 +1,22 @@
<template> <template>
<div class="flex flex-col gap-2 w-full"> <div class="flex flex-col gap-2 w-full">
<TableSelect v-model="table" :disableTableSelect="disableTableSelect" /> <TableSelect v-if="showTableSelect" v-model="table" :disableTableSelect="disableTableSelect" />
<ColumnSelect v-if="table != ''" v-model="columnSelect" :table="table" /> <ColumnSelect v-if="table != ''" v-model="columnSelect" :table="table" />
<Where v-if="table != ''" v-model="where" :table="table" /> <Where v-if="table != ''" v-model="where" :table="table" />
<Order v-if="table != ''" v-model="order" :table="table" :columns="columnSelect" /> <Join v-if="table != ''" v-model="modelValue.join" :table="table" :alreadyJoined="alreadyJoined" />
<Join v-if="table != ''" v-model="modelValue.join" :table="table" /> <Order v-if="table != '' && enableOrder" v-model="order" :table="table" :columns="nestedTablesByDepth" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import { mapActions, mapState } from "pinia"; import { type ConditionStructure, type DynamicQueryStructure, type OrderByStructure } from "@/types/dynamicQueries";
import type { ConditionStructure, DynamicQueryStructure, OrderByStructure } from "@/types/dynamicQueries";
import { useQueryBuilderStore } from "@/stores/admin/club/queryBuilder";
import ColumnSelect from "./ColumnSelect.vue"; import ColumnSelect from "./ColumnSelect.vue";
import Where from "./Where.vue"; import Where from "./Where.vue";
import Order from "./Order.vue"; import Order from "./Order.vue";
import Join from "./Join.vue"; import Join from "./Join.vue";
import TableSelect from "./TableSelect.vue"; import TableSelect from "./TableSelect.vue";
import { v4 as uuid } from "uuid";
</script> </script>
<script lang="ts"> <script lang="ts">
@ -25,21 +24,50 @@ export default defineComponent({
props: { props: {
modelValue: { modelValue: {
type: Object as PropType<DynamicQueryStructure>, type: Object as PropType<DynamicQueryStructure>,
default: { required: true,
select: "*",
table: "",
where: [],
join: [],
orderBy: [],
},
}, },
disableTableSelect: { disableTableSelect: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
enableOrder: {
type: Boolean,
default: false,
},
showTableSelect: {
type: Boolean,
default: true,
},
}, },
emits: ["update:model-value"], emits: ["update:model-value"],
computed: { computed: {
alreadyJoined() {
return this.modelValue.join?.map((j) => j.table);
},
nestedTablesByDepth() {
const tables: Array<{ table: string; id: string; depth: number; path: string[]; columns: "*" | string[] }> = [];
function recurse(item: DynamicQueryStructure, path: string[]) {
tables.push({ table: item.table, id: item.id, depth: path.length, path, columns: item.select });
if (item.join) {
item.join.forEach((child) => {
recurse(child, [...path, item.table]);
});
}
}
recurse(this.modelValue, []);
return tables;
},
value: {
get() {
return this.modelValue;
},
set(val: DynamicQueryStructure) {
this.$emit("update:model-value", val);
},
},
table: { table: {
get() { get() {
return this.modelValue.table || ""; return this.modelValue.table || "";
@ -81,5 +109,10 @@ export default defineComponent({
}, },
}, },
}, },
mounted() {
if (!this.value.id) {
this.value.id = uuid();
}
},
}); });
</script> </script>

View file

@ -5,6 +5,7 @@
<div v-for="(condition, index) in value" class="contents"> <div v-for="(condition, index) in value" class="contents">
<NestedCondition <NestedCondition
v-if="condition.structureType == 'nested'" v-if="condition.structureType == 'nested'"
:isFirst="index == 0"
:model-value="condition" :model-value="condition"
:table="table" :table="table"
@update:model-value="($event) => (value[index] = $event)" @update:model-value="($event) => (value[index] = $event)"
@ -12,6 +13,7 @@
/> />
<Condition <Condition
v-else v-else
:isFirst="index == 0"
:model-value="condition" :model-value="condition"
:table="table" :table="table"
@update:model-value="($event) => (value[index] = $event)" @update:model-value="($event) => (value[index] = $event)"
@ -74,14 +76,14 @@ export default defineComponent({
addNestedToValue() { addNestedToValue() {
this.value.push({ this.value.push({
structureType: "nested", structureType: "nested",
concat: this.value.length == 0 ? "_" : "AND", concat: "AND",
conditions: [], conditions: [],
}); });
}, },
addConditionToValue() { addConditionToValue() {
this.value.push({ this.value.push({
structureType: "condition", structureType: "condition",
concat: this.value.length == 0 ? "_" : "AND", concat: "AND",
operation: "eq", operation: "eq",
column: "", column: "",
value: "", value: "",

View file

@ -0,0 +1,70 @@
<template>
<form class="flex flex-col gap-2" @submit.prevent="setup">
<p class="text-center">Admin Account</p>
<div class="-space-y-px">
<div>
<input id="username" name="username" type="text" required placeholder="Benutzer" class="rounded-b-none!" />
</div>
<div>
<input id="mail" name="mail" type="email" required placeholder="Mailadresse" class="rounded-none!" />
</div>
<div>
<input id="firstname" name="firstname" type="text" required placeholder="Vorname" class="rounded-none!" />
</div>
<div>
<input id="lastname" name="lastname" type="text" required placeholder="Nachname" class="rounded-t-none!" />
</div>
</div>
<div class="flex flex-row gap-2">
<button type="submit" primary :disabled="setupStatus == 'loading' || setupStatus == 'success'">
Admin-Account anlegen
</button>
<Spinner v-if="setupStatus == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="setupStatus == 'success'" />
<FailureXMark v-else-if="setupStatus == 'failed'" />
</div>
<p v-if="setupMessage" class="text-center">{{ setupMessage }}</p>
</form>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { mapActions } from "pinia";
import { useSetupStore } from "@/stores/setup";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
setupStatus: undefined as undefined | "loading" | "success" | "failed",
setupMessage: "" as string,
};
},
methods: {
...mapActions(useSetupStore, ["createAdmin"]),
setup(e: any) {
let formData = e.target.elements;
this.setupStatus = "loading";
this.setupMessage = "";
this.createAdmin({
username: formData.username.value,
mail: formData.mail.value,
firstname: formData.firstname.value,
lastname: formData.lastname.value,
})
.then((result) => {
// this.setupStatus = "success";
})
.catch((err) => {
this.setupStatus = "failed";
this.setupMessage = err.response.data;
});
},
},
});
</script>

View file

@ -0,0 +1,66 @@
<template>
<form class="flex flex-col gap-2" @submit.prevent="setup">
<p class="text-center">App Konfiguration</p>
<div class="-space-y-px">
<div>
<input id="login_message" name="login_message" type="text" placeholder="Nachricht unter Login (optional)" />
</div>
<div class="flex flex-row items-center gap-2 pt-1">
<input type="checkbox" id="show_cal_link" checked />
<label for="show_cal_link">Link zum Kalender anzeigen (optional)</label>
</div>
</div>
<p class="text-primary cursor-pointer ml-auto" @click="skip('app')">überspringen</p>
<div class="flex flex-row gap-2">
<button type="submit" primary :disabled="setupStatus == 'loading' || setupStatus == 'success'">
Anwendungsdaten speichern
</button>
<Spinner v-if="setupStatus == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="setupStatus == 'success'" />
<FailureXMark v-else-if="setupStatus == 'failed'" />
</div>
<p v-if="setupMessage" class="text-center">{{ setupMessage }}</p>
</form>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { mapActions } from "pinia";
import { useSetupStore } from "@/stores/setup";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
setupStatus: undefined as undefined | "loading" | "success" | "failed",
setupMessage: "" as string,
};
},
methods: {
...mapActions(useSetupStore, ["setApp", "skip"]),
setup(e: any) {
let formData = e.target.elements;
this.setupStatus = "loading";
this.setupMessage = "";
this.setApp({
login_message: formData.login_message.value,
show_cal_link: formData.show_cal_link.checked,
})
.then((result) => {
// this.setupStatus = "success";
})
.catch((err) => {
this.setupStatus = "failed";
this.setupMessage = err.response.data;
});
},
},
});
</script>

View file

@ -0,0 +1,99 @@
<template>
<form class="flex flex-col gap-2" @submit.prevent="setup">
<p class="text-center">Feuerwehr-/Vereinsdaten</p>
<div class="-space-y-px">
<div>
<input
id="name"
name="name"
type="text"
placeholder="Feuerwehr-/Vereinsname (optional)"
class="rounded-b-none!"
/>
</div>
<div>
<input
id="imprint"
name="imprint"
type="url"
placeholder="Link zum Impressum (optional)"
class="rounded-none!"
/>
</div>
<div>
<input
id="privacy"
name="privacy"
type="url"
placeholder="Link zur Datenschutzerklärung (optional)"
class="rounded-none!"
/>
</div>
<div>
<input
id="website"
name="website"
type="url"
placeholder="Link zur Webseite (optional)"
class="rounded-t-none!"
/>
</div>
</div>
<p class="text-primary cursor-pointer ml-auto" @click="skip('club')">überspringen</p>
<div class="flex flex-row gap-2">
<button type="submit" primary :disabled="setupStatus == 'loading' || setupStatus == 'success'">
Vereinsdaten speichern
</button>
<Spinner v-if="setupStatus == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="setupStatus == 'success'" />
<FailureXMark v-else-if="setupStatus == 'failed'" />
</div>
<p v-if="setupMessage" class="text-center">{{ setupMessage }}</p>
</form>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { mapActions } from "pinia";
import { useSetupStore } from "@/stores/setup";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
setupStatus: undefined as undefined | "loading" | "success" | "failed",
setupMessage: "" as string,
};
},
methods: {
...mapActions(useSetupStore, ["setClub", "skip"]),
setup(e: any) {
let formData = e.target.elements;
this.setupStatus = "loading";
this.setupMessage = "";
this.setClub({
name: formData.name.value,
imprint: formData.imprint.value,
privacy: formData.privacy.value,
website: formData.website.value,
})
.then((result) => {
// this.setupStatus = "success";
})
.catch((err) => {
this.setupStatus = "failed";
this.setupMessage = err.response.data;
});
},
},
});
</script>

View file

@ -0,0 +1,3 @@
<template>
<p class="text-center">Sie haben einen Verifizierungslink per Mail erhalten.</p>
</template>

View file

@ -0,0 +1,87 @@
<template>
<form class="flex flex-col gap-2" @submit.prevent="setup">
<p class="text-center">Feuerwehr-/Vereins-Auftritt</p>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2">
<p>quadratisches Icon für App (optional)</p>
<img ref="icon_img" class="hidden w-full h-20 object-contain" />
<input class="hidden!" type="file" ref="icon" accept="image/*" @change="previewImage('icon')" />
<button type="button" primary-outline @click="($refs.icon as HTMLInputElement).click()">Icon auswählen</button>
</div>
<div class="flex flex-col gap-2">
<p>Logo (optional)</p>
<img ref="logo_img" class="hidden w-full h-20 object-contain" />
<input class="hidden!" type="file" ref="logo" accept="image/*" @change="previewImage('logo')" />
<button type="button" primary-outline @click="($refs.logo as HTMLInputElement).click()">Logo auswählen</button>
</div>
</div>
<br />
<p class="text-primary cursor-pointer ml-auto" @click="skip('appImages')">überspringen</p>
<div class="flex flex-row gap-2">
<button type="submit" primary :disabled="setupStatus == 'loading' || setupStatus == 'success'">
Bilder speichern
</button>
<Spinner v-if="setupStatus == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="setupStatus == 'success'" />
<FailureXMark v-else-if="setupStatus == 'failed'" />
</div>
<p v-if="setupMessage" class="text-center">{{ setupMessage }}</p>
</form>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { mapActions } from "pinia";
import { useSetupStore } from "@/stores/setup";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
setupStatus: undefined as undefined | "loading" | "success" | "failed",
setupMessage: "" as string,
};
},
methods: {
...mapActions(useSetupStore, ["setClubImages", "skip"]),
previewImage(inputname: "icon" | "logo") {
let input = this.$refs[inputname] as HTMLInputElement;
let previewElement = this.$refs[inputname + "_img"] as HTMLImageElement;
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = function (e) {
previewElement.src = e.target?.result as string;
previewElement.style.display = "block";
};
reader.readAsDataURL(input.files[0]);
} else {
previewElement.src = "";
previewElement.style.display = "none";
}
},
setup(e: any) {
this.setupStatus = "loading";
this.setupMessage = "";
this.setClubImages({
icon: (this.$refs.icon as HTMLInputElement).files?.[0],
logo: (this.$refs.logo as HTMLInputElement).files?.[0],
})
.then((result) => {
// this.setupStatus = "success";
})
.catch((err) => {
this.setupStatus = "failed";
this.setupMessage = err.response.data;
});
},
},
});
</script>

View file

@ -0,0 +1,95 @@
<template>
<form class="flex flex-col gap-2" @submit.prevent="setup">
<p class="text-center">Mailversand</p>
<div class="-space-y-px">
<div class="mb-2">
<input id="mail" name="mail" type="email" placeholder="Mailadresse" required autocomplete="email" />
</div>
<div>
<input
id="user"
name="user"
type="text"
placeholder="Benutzername (kann auch Mail sein)"
required
autocomplete="username"
class="rounded-b-none!"
/>
</div>
<div>
<input
id="password"
name="password"
type="password"
placeholder="Passwort"
required
autocomplete="new-password"
class="rounded-none!"
/>
</div>
<div>
<input id="host" name="host" type="text" placeholder="Server-Host" required class="rounded-none!" />
</div>
<div>
<input id="port" name="port" type="number" placeholder="Port (25, 465, 587)" required class="rounded-t-none!" />
</div>
<div class="flex flex-row items-center gap-2 pt-1">
<input type="checkbox" id="secure" />
<label for="secure">SSL-Verbindung (setzen bei Port 465)</label>
</div>
</div>
<div class="flex flex-row gap-2">
<button type="submit" primary :disabled="setupStatus == 'loading' || setupStatus == 'success'">
Mailversand speichern
</button>
<Spinner v-if="setupStatus == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="setupStatus == 'success'" />
<FailureXMark v-else-if="setupStatus == 'failed'" />
</div>
<p v-if="setupMessage" class="text-center">{{ setupMessage }}</p>
</form>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { mapActions } from "pinia";
import { useSetupStore } from "@/stores/setup";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
setupStatus: undefined as undefined | "loading" | "success" | "failed",
setupMessage: "" as string,
};
},
methods: {
...mapActions(useSetupStore, ["setMailConfig", "skip"]),
setup(e: any) {
let formData = e.target.elements;
this.setupStatus = "loading";
this.setupMessage = "";
this.setMailConfig({
host: formData.host.value,
port: formData.port.value,
secure: formData.secure.checked,
mail: formData.mail.value,
username: formData.user.value,
password: formData.password.value,
})
.then((result) => {
// this.setupStatus = "success";
})
.catch((err) => {
this.setupStatus = "failed";
this.setupMessage = err.response.data;
});
},
},
});
</script>

View file

@ -1,15 +1,7 @@
export interface Config { export interface Config {
server_address: string; server_address: string;
app_name_overwrite: string;
imprint_link: string;
privacy_link: string;
custom_login_message: string;
} }
export const config: Config = { export const config: Config = {
server_address: import.meta.env.VITE_SERVER_ADDRESS, 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

@ -0,0 +1,5 @@
export enum NewsletterConfigEnum {
pdf = "pdf",
mail = "mail",
none = "none",
}

View file

@ -1,4 +0,0 @@
export enum NewsletterConfigType {
pdf = "pdf",
mail = "mail",
}

7
src/helpers/crypto.ts Normal file
View file

@ -0,0 +1,7 @@
export async function hashString(message = ""): Promise<string> {
const msgUint8 = new TextEncoder().encode(message);
const hashBuffer = await window.crypto.subtle.digest("SHA-256", msgUint8);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
return hashHex;
}

View file

@ -1,4 +1,4 @@
import { joinTableFormatter, type FieldType, type QueryResult } from "../types/dynamicQueries"; import { joinTableFormatter, type FieldType, type QueryResult } from "@/types/dynamicQueries";
export function joinTableName(name: string): string { export function joinTableName(name: string): string {
let normalized = joinTableFormatter[name]; let normalized = joinTableFormatter[name];

View file

@ -1,6 +1,14 @@
@tailwind base; @import "tailwindcss";
@tailwind components;
@tailwind utilities; @theme {
--color-primary: var(--primary);
--color-secondary: var(--secondary);
--color-accent: var(--accent);
--color-error: var(--error);
--color-warning: var(--warning);
--color-info: var(--info);
--color-success: var(--success);
}
@layer base { @layer base {
:root { :root {
@ -10,7 +18,7 @@
--error: #9a0d55; --error: #9a0d55;
--warning: #bb6210; --warning: #bb6210;
--info: #388994; --info: #388994;
--success: #73ad0f; --success: #7ac142;
} }
.dark { .dark {
--primary: #ff0d00; --primary: #ff0d00;
@ -19,10 +27,12 @@
--error: #9a0d55; --error: #9a0d55;
--warning: #bb6210; --warning: #bb6210;
--info: #4ccbda; --info: #4ccbda;
--success: #73ad0f; --success: #7ac142;
} }
} }
@custom-variant hover (&:hover);
/* ===== Scrollbar CSS ===== */ /* ===== Scrollbar CSS ===== */
/* Firefox */ /* Firefox */
* { * {
@ -59,12 +69,12 @@ body {
/*:not([headlessui]):not([id*="headlessui"]):not([class*="headlessui"])*/ /*:not([headlessui]):not([id*="headlessui"]):not([class*="headlessui"])*/
button:not([class*="ql"] *):not([class*="fc"]):not([id*="headlessui-combobox"]), button:not([class*="ql"] *):not([class*="fc"]):not([id*="headlessui-combobox"]),
a[button] { a[button] {
@apply relative box-border h-10 w-full flex justify-center py-2 px-4 text-sm font-medium rounded-md focus:outline-none focus:ring-0; @apply cursor-pointer relative box-border h-10 w-full flex justify-center py-2 px-4 text-sm font-medium rounded-md focus:outline-hidden focus:ring-0;
} }
button[primary]:not([primary="false"]), button[primary]:not([primary="false"]),
a[button][primary]:not([primary="false"]) { a[button][primary]:not([primary="false"]) {
@apply border border-transparent text-white bg-primary hover:bg-primary; @apply border-2 border-transparent text-white bg-primary hover:bg-primary;
} }
button[primary-outline]:not([primary-outline="false"]), button[primary-outline]:not([primary-outline="false"]),
@ -81,7 +91,7 @@ a[button].disabled {
input:not([type="checkbox"]), input:not([type="checkbox"]),
textarea, textarea,
select { select {
@apply 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; @apply bg-white rounded-md shadow-xs 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-hidden focus:ring-0 focus:z-10 sm:text-sm resize-none;
} }
input[readonly], input[readonly],
@ -123,29 +133,3 @@ summary > svg {
summary::-webkit-details-marker { summary::-webkit-details-marker {
display: none; display: none;
} }
.fc-button-primary {
@apply !bg-primary !border-primary !outline-none !ring-0 hover:!bg-red-700 hover:!border-red-700 h-10 text-center;
}
.fc-button-active {
@apply !bg-red-500 !border-red-500;
}
.fc-toolbar {
@apply flex-wrap;
}
/* For screens between 850px and 768px */
@media (max-width: 850px) and (min-width: 768px) {
.fc-header-toolbar.fc-toolbar.fc-toolbar-ltr > .fc-toolbar-chunk:nth-child(2) {
@apply !order-1;
}
/* Your styles for this range */
}
/* For screens between 525px and 0px */
@media (max-width: 525px) and (min-width: 0px) {
/* Your styles for this range */
.fc-header-toolbar.fc-toolbar.fc-toolbar-ltr > .fc-toolbar-chunk:nth-child(2) {
@apply !order-1;
}
}

View file

@ -9,6 +9,9 @@ import "../node_modules/nprogress/nprogress.css";
import { http } from "./serverCom"; import { http } from "./serverCom";
import "./main.css"; import "./main.css";
// auto generates splash screen for iOS
import "pwacompat";
NProgress.configure({ showSpinner: false }); NProgress.configure({ showSpinner: false });
const app = createApp(App); const app = createApp(App);

View file

@ -12,7 +12,14 @@ export async function abilityAndNavUpdate(to: any, from: any, next: any) {
let section = to.meta.section; let section = to.meta.section;
let module = to.meta.module; let module = to.meta.module;
if ((admin && ability.isAdmin()) || ability.can(type, section, module)) { if (to.name == "admin-default") {
navigation.activeNavigation = "club";
navigation.activeLink = null;
navigation.updateTopLevel();
navigation.updateNavigation();
NProgress.done();
next();
} else if ((admin && ability.isAdmin()) || ability.can(type, section, module)) {
NProgress.done(); NProgress.done();
navigation.activeNavigation = to.name.split("-")[1]; navigation.activeNavigation = to.name.split("-")[1];
navigation.activeLink = to.name.split("-")[2]; navigation.activeLink = to.name.split("-")[2];

View file

@ -55,6 +55,7 @@ export async function isAuthenticatedPromise(forceRefresh: boolean = false): Pro
// check jwt expiry // check jwt expiry
const exp = decoded.exp ?? 0; const exp = decoded.exp ?? 0;
const correctedLocalTime = new Date().getTime(); const correctedLocalTime = new Date().getTime();
let failedRefresh = false;
if (exp < Math.floor(correctedLocalTime / 1000) || forceRefresh) { if (exp < Math.floor(correctedLocalTime / 1000) || forceRefresh) {
await refreshToken() await refreshToken()
.then(() => { .then(() => {
@ -63,13 +64,16 @@ export async function isAuthenticatedPromise(forceRefresh: boolean = false): Pro
.catch((err: string) => { .catch((err: string) => {
console.log("expired"); console.log("expired");
auth.setFailed(); auth.setFailed();
failedRefresh = true;
reject(err); reject(err);
}); });
} }
if (failedRefresh) return;
var { userId, firstname, lastname, mail, username, permissions, isOwner } = decoded; var { userId, firstname, lastname, mail, username, permissions, isOwner } = decoded;
if (Object.keys(permissions ?? {}).length === 0 && !isOwner) { if (Object.keys(permissions ?? {}).filter((p) => p != "adminByOwner").length === 0 && !isOwner) {
auth.setFailed(); auth.setFailed();
reject("nopermissions"); reject("nopermissions");
} }

View file

@ -1,4 +1,4 @@
import { useBackupStore } from "../stores/admin/management/backup"; import { useBackupStore } from "@/stores/admin/management/backup";
export async function setBackupPage(to: any, from: any, next: any) { export async function setBackupPage(to: any, from: any, next: any) {
const backup = useBackupStore(); const backup = useBackupStore();

View file

@ -2,14 +2,12 @@ import { createRouter, createWebHistory } from "vue-router";
import Login from "@/views/Login.vue"; import Login from "@/views/Login.vue";
import { isAuthenticated } from "./authGuard"; import { isAuthenticated } from "./authGuard";
import { loadAccountData } from "./accountGuard";
import { isSetup } from "./setupGuard"; import { isSetup } from "./setupGuard";
import { abilityAndNavUpdate } from "./adminGuard"; import { abilityAndNavUpdate } from "./adminGuard";
import type { PermissionType, PermissionSection, PermissionModule } from "@/types/permissionTypes"; import type { PermissionType, PermissionSection, PermissionModule } from "@/types/permissionTypes";
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";
import { setBackupPage } from "./backupGuard"; import { setBackupPage } from "./backupGuard";
const router = createRouter({ const router = createRouter({
@ -17,7 +15,7 @@ const router = createRouter({
routes: [ routes: [
{ {
path: "/", path: "/",
redirect: { name: "admin" }, redirect: { name: "admin-default" },
}, },
{ {
path: "/login", path: "/login",
@ -78,12 +76,13 @@ const router = createRouter({
path: "/admin", path: "/admin",
name: "admin", name: "admin",
component: () => import("@/views/admin/View.vue"), component: () => import("@/views/admin/View.vue"),
beforeEnter: [isAuthenticated], beforeEnter: [isAuthenticated, abilityAndNavUpdate],
children: [ children: [
{ {
path: "", path: "",
name: "admin-default", name: "admin-default",
component: () => import("@/views/admin/ViewSelect.vue"), component: () => import("@/views/admin/ViewSelect.vue"),
beforeEnter: [abilityAndNavUpdate],
}, },
{ {
path: "club", path: "club",
@ -642,6 +641,13 @@ const router = createRouter({
}, },
], ],
}, },
{
path: "settings",
name: "admin-management-setting",
component: () => import("@/views/admin/management/setting/Setting.vue"),
meta: { type: "read", section: "management", module: "setting" },
beforeEnter: [abilityAndNavUpdate],
},
{ {
path: "backup", path: "backup",
name: "admin-management-backup-route", name: "admin-management-backup-route",
@ -777,10 +783,6 @@ 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

@ -1,7 +1,7 @@
import { useNewsletterStore } from "@/stores/admin/club/newsletter/newsletter"; import { useNewsletterStore } from "@/stores/admin/club/newsletter/newsletter";
import { useNewsletterDatesStore } from "@/stores/admin/club/newsletter/newsletterDates"; import { useNewsletterDatesStore } from "@/stores/admin/club/newsletter/newsletterDates";
import { useNewsletterRecipientsStore } from "@/stores/admin/club/newsletter/newsletterRecipients"; import { useNewsletterRecipientsStore } from "@/stores/admin/club/newsletter/newsletterRecipients";
import { useNewsletterPrintoutStore } from "../stores/admin/club/newsletter/newsletterPrintout"; import { useNewsletterPrintoutStore } from "@/stores/admin/club/newsletter/newsletterPrintout";
export async function setNewsletterId(to: any, from: any, next: any) { export async function setNewsletterId(to: any, from: any, next: any) {
const newsletter = useNewsletterStore(); const newsletter = useNewsletterStore();

View file

@ -3,7 +3,7 @@ import { useProtocolAgendaStore } from "@/stores/admin/club/protocol/protocolAge
import { useProtocolDecisionStore } from "@/stores/admin/club/protocol/protocolDecision"; import { useProtocolDecisionStore } from "@/stores/admin/club/protocol/protocolDecision";
import { useProtocolPresenceStore } from "@/stores/admin/club/protocol/protocolPresence"; import { useProtocolPresenceStore } from "@/stores/admin/club/protocol/protocolPresence";
import { useProtocolVotingStore } from "@/stores/admin/club/protocol/protocolVoting"; import { useProtocolVotingStore } from "@/stores/admin/club/protocol/protocolVoting";
import { useProtocolPrintoutStore } from "../stores/admin/club/protocol/protocolPrintout"; import { useProtocolPrintoutStore } from "@/stores/admin/club/protocol/protocolPrintout";
export async function setProtocolId(to: any, from: any, next: any) { export async function setProtocolId(to: any, from: any, next: any) {
const protocol = useProtocolStore(); const protocol = useProtocolStore();

View file

@ -135,4 +135,4 @@ async function* streamingFetch(path: string, abort?: AbortController) {
} }
} }
export { http, newEventSource, streamingFetch, host }; export { http, newEventSource, streamingFetch, host, url };

View file

@ -11,21 +11,18 @@ export const useAbilityStore = defineStore("ability", {
getters: { getters: {
can: can:
(state) => (state) =>
(type: PermissionType | "admin", section: PermissionSection, module?: PermissionModule): boolean => { (type: PermissionType | "admin", section: PermissionSection, module: PermissionModule): boolean => {
const permissions = state.permissions; const permissions = state.permissions;
if (state.isOwner) return true; if (state.isOwner) return true;
if (type == "admin") return permissions?.admin ?? false; if (type == "admin") return permissions?.admin ?? permissions?.adminByOwner ?? false;
if (permissions?.admin) return true; if (permissions?.admin || permissions?.adminByOwner) return true;
if ( if (
(!module &&
permissions[section] != undefined &&
(permissions[section]?.all == "*" || permissions[section]?.all?.includes(type))) ||
permissions[section]?.all == "*" || permissions[section]?.all == "*" ||
permissions[section]?.all?.includes(type) permissions[section]?.all?.includes(type) ||
permissions[section]?.[module] == "*" ||
permissions[section]?.[module]?.includes(type)
) )
return true; return true;
if (module && (permissions[section]?.[module] == "*" || permissions[section]?.[module]?.includes(type)))
return true;
return false; return false;
}, },
canSection: canSection:
@ -33,16 +30,24 @@ export const useAbilityStore = defineStore("ability", {
(type: PermissionType | "admin", section: PermissionSection): boolean => { (type: PermissionType | "admin", section: PermissionSection): boolean => {
const permissions = state.permissions; const permissions = state.permissions;
if (state.isOwner) return true; if (state.isOwner) return true;
if (type == "admin") return permissions?.admin ?? false; if (type == "admin") return permissions?.admin ?? permissions?.adminByOwner ?? false;
if (permissions?.admin) return true; if (permissions?.admin || permissions?.adminByOwner) return true;
if ( if (
permissions[section]?.all == "*" || (permissions[section]?.all == "*" || permissions[section]?.all?.includes(type)) &&
permissions[section]?.all?.includes(type) ||
permissions[section] != undefined permissions[section] != undefined
) )
return true; return true;
return false; return false;
}, },
canAccessSection:
(state) =>
(section: PermissionSection): boolean => {
const permissions = state.permissions;
if (state.isOwner) return true;
if (permissions?.admin || permissions?.adminByOwner) return true;
if (permissions[section] != undefined) return true;
return false;
},
isAdmin: (state) => (): boolean => { isAdmin: (state) => (): boolean => {
const permissions = state.permissions; const permissions = state.permissions;
if (state.isOwner) return true; if (state.isOwner) return true;
@ -54,23 +59,41 @@ export const useAbilityStore = defineStore("ability", {
permissions: PermissionObject, permissions: PermissionObject,
type: PermissionType | "admin", type: PermissionType | "admin",
section: PermissionSection, section: PermissionSection,
module?: PermissionModule module: PermissionModule
): boolean => { ): boolean => {
// ignores ownership // ignores ownership
if (type == "admin") return permissions?.admin ?? false; if (type == "admin") return permissions?.admin ?? permissions?.adminByOwner ?? false;
if (permissions?.admin) return true; if (permissions?.admin || permissions?.adminByOwner) return true;
if ( if (
(!module &&
permissions[section] != undefined &&
(permissions[section]?.all == "*" || permissions[section]?.all?.includes(type))) ||
permissions[section]?.all == "*" || permissions[section]?.all == "*" ||
permissions[section]?.all?.includes(type) permissions[section]?.all?.includes(type) ||
permissions[section]?.[module] == "*" ||
permissions[section]?.[module]?.includes(type)
) )
return true; return true;
if (module && (permissions[section]?.[module] == "*" || permissions[section]?.[module]?.includes(type))) return false;
},
_canSection:
() =>
(permissions: PermissionObject, type: PermissionType | "admin", section: PermissionSection): boolean => {
// ignores ownership
if (type == "admin") return permissions?.admin ?? permissions?.adminByOwner ?? false;
if (permissions?.admin || permissions?.adminByOwner) return true;
if (
(permissions[section]?.all == "*" || permissions[section]?.all?.includes(type)) &&
permissions[section] != undefined
)
return true; return true;
return false; return false;
}, },
_canAccessSection:
() =>
(permissions: PermissionObject, section: PermissionSection): boolean => {
// ignores ownership
if (permissions?.admin || permissions?.adminByOwner) return true;
if (permissions[section] != undefined) return true;
return false;
},
}, },
actions: { actions: {
setAbility(permissions: PermissionObject, isOwner: boolean) { setAbility(permissions: PermissionObject, isOwner: boolean) {

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