Compare commits
68 commits
Author | SHA1 | Date | |
---|---|---|---|
ccc6d47d9e | |||
0be649c3ba | |||
424f4772f0 | |||
f01c895d30 | |||
f04fabefb0 | |||
625a2df308 | |||
f65b3108ee | |||
196a92325a | |||
b39198c935 | |||
ee52363bde | |||
63d97d0b83 | |||
9cf2cf2d80 | |||
fa8f051252 | |||
d5193842d2 | |||
bbf5b65aab | |||
eb622658d9 | |||
751370fed4 | |||
939c982c40 | |||
91ede95530 | |||
0771b43f56 | |||
06380e48c5 | |||
6f155ada66 | |||
b7dd5a95cd | |||
9bd663f266 | |||
beaf6a5926 | |||
e607f8c599 | |||
8880af2880 | |||
5d9007f517 | |||
20a2a3ccd0 | |||
a20c0d3ed3 | |||
916e61897a | |||
b19e8df561 | |||
fb78360946 | |||
caf1919930 | |||
d25fa07512 | |||
48502efc1d | |||
9a9742597a | |||
ea38b1835c | |||
802b7d25f0 | |||
d1bde66e1e | |||
dea2a1c40f | |||
f94cc8b365 | |||
238a35da9f | |||
d39ebc5029 | |||
9a7785917c | |||
fc1185d1c8 | |||
68b0aeffa8 | |||
8087108b90 | |||
5ce7aa8a17 | |||
d018f97274 | |||
6d45325543 | |||
1296331796 | |||
303ce7a58d | |||
796909a92e | |||
815d5c16fa | |||
6494752058 | |||
e4c2f47eb0 | |||
387736721f | |||
2c61ca0c8c | |||
552b6f6438 | |||
7aa0db3684 | |||
fdbf9e7f0a | |||
517527258a | |||
e68544a362 | |||
f8192a187b | |||
d04dde688f | |||
8ec3b04824 | |||
b1daa7e64f |
167 changed files with 8388 additions and 4336 deletions
|
@ -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
|
|
||||||
|
|
|
@ -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__
|
|
|
@ -1,4 +1,4 @@
|
||||||
FROM node:18-alpine AS build
|
FROM node:22-alpine AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
|
8484
package-lock.json
generated
8484
package-lock.json
generated
File diff suppressed because it is too large
Load diff
88
package.json
88
package.json
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "ff-admin",
|
"name": "ff-admin",
|
||||||
"version": "1.3.4",
|
"version": "1.5.0",
|
||||||
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
BIN
public/Logo.png
BIN
public/Logo.png
Binary file not shown.
Before Width: | Height: | Size: 34 KiB |
Binary file not shown.
Before Width: | Height: | Size: 9.4 KiB |
Binary file not shown.
Before Width: | Height: | Size: 29 KiB |
16
src/App.vue
16
src/App.vue
|
@ -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,25 @@ 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";
|
||||||
</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() {
|
||||||
|
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 +47,8 @@ export default defineComponent({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
methods: {
|
||||||
|
...mapActions(useConfigurationStore, ["configure"]),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
26
src/components/AppIcon.vue
Normal file
26
src/components/AppIcon.vue
Normal 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>
|
26
src/components/AppLogo.vue
Normal file
26
src/components/AppLogo.vue
Normal 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>
|
53
src/components/CheckProgressBar.vue
Normal file
53
src/components/CheckProgressBar.vue
Normal 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>
|
181
src/components/CustomCalendar.vue
Normal file
181
src/components/CustomCalendar.vue
Normal 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>
|
|
@ -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>
|
||||||
|
|
|
@ -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 : "";
|
||||||
},
|
},
|
||||||
|
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
106
src/components/account/ChangeToPassword.vue
Normal file
106
src/components/account/ChangeToPassword.vue
Normal 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
|
||||||
|
.post(`/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>
|
92
src/components/account/ChangeToTOTP.vue
Normal file
92
src/components/account/ChangeToTOTP.vue
Normal 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
|
||||||
|
.post(`/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>
|
109
src/components/account/PasswordChange.vue
Normal file
109
src/components/account/PasswordChange.vue
Normal 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
|
||||||
|
.post(`/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>
|
83
src/components/account/TotpCheckAndScan.vue
Normal file
83
src/components/account/TotpCheckAndScan.vue
Normal 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>
|
|
@ -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">
|
||||||
|
@ -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: {
|
||||||
|
|
|
@ -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" />
|
||||||
|
|
|
@ -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']">
|
||||||
|
|
|
@ -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']">
|
||||||
|
|
|
@ -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" />
|
||||||
|
|
|
@ -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">
|
||||||
|
@ -103,6 +111,7 @@ 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;
|
||||||
|
|
|
@ -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']">
|
||||||
|
|
|
@ -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']">
|
||||||
|
|
|
@ -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']">
|
||||||
|
|
|
@ -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']">
|
||||||
|
|
|
@ -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']">
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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']">
|
||||||
|
|
|
@ -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']">
|
||||||
|
|
|
@ -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']">
|
||||||
|
|
|
@ -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']">
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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 }"
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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" />
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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"
|
||||||
|
|
67
src/components/admin/management/setting/AppSetting.vue
Normal file
67
src/components/admin/management/setting/AppSetting.vue
Normal 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>
|
53
src/components/admin/management/setting/BackupSetting.vue
Normal file
53
src/components/admin/management/setting/BackupSetting.vue
Normal 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>
|
87
src/components/admin/management/setting/BaseSetting.vue
Normal file
87
src/components/admin/management/setting/BaseSetting.vue
Normal 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>
|
152
src/components/admin/management/setting/ClubImageSetting.vue
Normal file
152
src/components/admin/management/setting/ClubImageSetting.vue
Normal 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>
|
89
src/components/admin/management/setting/ClubSetting.vue
Normal file
89
src/components/admin/management/setting/ClubSetting.vue
Normal 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>
|
100
src/components/admin/management/setting/MailSetting.vue
Normal file
100
src/components/admin/management/setting/MailSetting.vue
Normal 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>
|
76
src/components/admin/management/setting/SessionSetting.vue
Normal file
76
src/components/admin/management/setting/SessionSetting.vue
Normal 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>
|
|
@ -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">
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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]"
|
||||||
>
|
>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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: "",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
@ -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">
|
||||||
|
<div
|
||||||
|
v-if="false"
|
||||||
|
class="h-fit p-1 border border-gray-400 hover:bg-gray-200 rounded-md"
|
||||||
|
title="Join Modus wechseln"
|
||||||
|
@click="swapJoinType(value.type)"
|
||||||
|
>
|
||||||
|
<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 value="" disabled>Relation auswählen</option>
|
||||||
<option v-for="relation in activeTable?.relations" :value="relation.column">
|
<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) }}
|
{{ 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>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<Table v-model="value" disable-table-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;
|
|
||||||
}
|
|
||||||
>,
|
|
||||||
default: {
|
|
||||||
select: "*",
|
|
||||||
table: "",
|
|
||||||
where: [],
|
|
||||||
join: [],
|
|
||||||
orderBy: [],
|
|
||||||
foreignColumn: "",
|
|
||||||
},
|
},
|
||||||
|
alreadyJoined: {
|
||||||
|
type: Array as PropType<Array<string>>,
|
||||||
|
default: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
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,74 @@ 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) {
|
||||||
|
if (this.modelValue.type == "defined") {
|
||||||
let relTable = this.activeTable?.relations.find((r) => r.column == val);
|
let relTable = this.activeTable?.relations.find((r) => r.column == val);
|
||||||
this.$emit("update:model-value", {
|
this.$emit("update:model-value", {
|
||||||
...this.modelValue,
|
...this.modelValue,
|
||||||
foreignColumn: val,
|
foreignColumn: val,
|
||||||
table: joinTableName(relTable?.referencedTableName ?? ""),
|
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.modelValue,
|
||||||
|
type: val,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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) {
|
||||||
|
console.log("setting type");
|
||||||
|
this.type = "defined";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
swapJoinType(type: string) {
|
||||||
|
if (type == "defined") {
|
||||||
|
this.type = "custom";
|
||||||
|
} else {
|
||||||
|
this.type = "defined";
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 meta = this.tableMetas.find((tm) => tm.tableName == curr.table);
|
||||||
|
if (meta) {
|
||||||
let relCols = meta.relations.map((r) => r.column);
|
let relCols = meta.relations.map((r) => r.column);
|
||||||
return meta.columns.map((c) => c.column).filter((c) => !relCols.includes(c));
|
meta.columns
|
||||||
} else {
|
.map((c) => c.column)
|
||||||
return this.columns;
|
.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: {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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: "",
|
||||||
|
|
70
src/components/setup/Account.vue
Normal file
70
src/components/setup/Account.vue
Normal 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>
|
66
src/components/setup/App.vue
Normal file
66
src/components/setup/App.vue
Normal 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>
|
99
src/components/setup/Club.vue
Normal file
99
src/components/setup/Club.vue
Normal 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>
|
3
src/components/setup/Finished.vue
Normal file
3
src/components/setup/Finished.vue
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
<template>
|
||||||
|
<p class="text-center">Sie haben einen Verifizierungslink per Mail erhalten.</p>
|
||||||
|
</template>
|
87
src/components/setup/Images.vue
Normal file
87
src/components/setup/Images.vue
Normal 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>
|
95
src/components/setup/Mail.vue
Normal file
95
src/components/setup/Mail.vue
Normal 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>
|
|
@ -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,
|
|
||||||
};
|
};
|
||||||
|
|
5
src/enums/newsletterConfigEnum.ts
Normal file
5
src/enums/newsletterConfigEnum.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export enum NewsletterConfigEnum {
|
||||||
|
pdf = "pdf",
|
||||||
|
mail = "mail",
|
||||||
|
none = "none",
|
||||||
|
}
|
|
@ -1,4 +0,0 @@
|
||||||
export enum NewsletterConfigType {
|
|
||||||
pdf = "pdf",
|
|
||||||
mail = "mail",
|
|
||||||
}
|
|
7
src/helpers/crypto.ts
Normal file
7
src/helpers/crypto.ts
Normal 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;
|
||||||
|
}
|
52
src/main.css
52
src/main.css
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
|
@ -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({
|
||||||
|
@ -642,6 +640,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 +782,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" {
|
||||||
|
|
|
@ -135,4 +135,4 @@ async function* streamingFetch(path: string, abort?: AbortController) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { http, newEventSource, streamingFetch, host };
|
export { http, newEventSource, streamingFetch, host, url };
|
||||||
|
|
|
@ -87,6 +87,9 @@ export const useMemberStore = defineStore("member", {
|
||||||
})
|
})
|
||||||
.catch((err) => {});
|
.catch((err) => {});
|
||||||
},
|
},
|
||||||
|
fetchLastInternalId() {
|
||||||
|
return http.get(`/admin/member/last/internalId`);
|
||||||
|
},
|
||||||
async printMemberByActiveId() {
|
async printMemberByActiveId() {
|
||||||
return http.get(`/admin/member/${this.activeMember}/print`, {
|
return http.get(`/admin/member/${this.activeMember}/print`, {
|
||||||
responseType: "blob",
|
responseType: "blob",
|
||||||
|
|
|
@ -45,6 +45,14 @@ export const useNewsletterPrintoutStore = defineStore("newsletterPrintout", {
|
||||||
responseType: "blob",
|
responseType: "blob",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
fetchNewsletterPrintReceivers(): Promise<AxiosResponse<any, any>> {
|
||||||
|
const newsletterId = useNewsletterStore().activeNewsletter;
|
||||||
|
return http.get(`/admin/newsletter/${newsletterId}/printrecipients`);
|
||||||
|
},
|
||||||
|
fetchNewsletterMailReceivers(): Promise<AxiosResponse<any, any>> {
|
||||||
|
const newsletterId = useNewsletterStore().activeNewsletter;
|
||||||
|
return http.get(`/admin/newsletter/${newsletterId}/mailrecipients`);
|
||||||
|
},
|
||||||
createNewsletterMailPreview() {
|
createNewsletterMailPreview() {
|
||||||
this.sendingPreview = "loading";
|
this.sendingPreview = "loading";
|
||||||
const newsletterId = useNewsletterStore().activeNewsletter;
|
const newsletterId = useNewsletterStore().activeNewsletter;
|
||||||
|
@ -116,7 +124,7 @@ export const useNewsletterPrintoutStore = defineStore("newsletterPrintout", {
|
||||||
chunk.split("//").forEach((r) => {
|
chunk.split("//").forEach((r) => {
|
||||||
if (r.trim() != "") {
|
if (r.trim() != "") {
|
||||||
let data = JSON.parse(r);
|
let data = JSON.parse(r);
|
||||||
this.pdfSourceMessages.push(data);
|
this.pdfSourceMessages.unshift(data);
|
||||||
let type: NotificationType = "info";
|
let type: NotificationType = "info";
|
||||||
let timeout = undefined;
|
let timeout = undefined;
|
||||||
if (data.factor == "failed") {
|
if (data.factor == "failed") {
|
||||||
|
@ -138,7 +146,7 @@ export const useNewsletterPrintoutStore = defineStore("newsletterPrintout", {
|
||||||
chunk.split("//").forEach((r) => {
|
chunk.split("//").forEach((r) => {
|
||||||
if (r.trim() != "") {
|
if (r.trim() != "") {
|
||||||
let data = JSON.parse(r);
|
let data = JSON.parse(r);
|
||||||
this.mailSourceMessages.push(data);
|
this.mailSourceMessages.unshift(data);
|
||||||
let type: NotificationType = "info";
|
let type: NotificationType = "info";
|
||||||
let timeout = undefined;
|
let timeout = undefined;
|
||||||
if (data.factor == "failed") {
|
if (data.factor == "failed") {
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { defineStore } from "pinia";
|
||||||
import { http } from "@/serverCom";
|
import { http } from "@/serverCom";
|
||||||
import type { TableMeta } from "@/viewmodels/admin/configuration/query.models";
|
import type { TableMeta } from "@/viewmodels/admin/configuration/query.models";
|
||||||
import type { DynamicQueryStructure, FieldType } from "@/types/dynamicQueries";
|
import type { DynamicQueryStructure, FieldType } from "@/types/dynamicQueries";
|
||||||
|
import type { AxiosResponse } from "axios";
|
||||||
|
|
||||||
export const useQueryBuilderStore = defineStore("queryBuilder", {
|
export const useQueryBuilderStore = defineStore("queryBuilder", {
|
||||||
state: () => {
|
state: () => {
|
||||||
|
@ -13,8 +14,7 @@ export const useQueryBuilderStore = defineStore("queryBuilder", {
|
||||||
loadingData: "fetched" as "loading" | "fetched" | "failed",
|
loadingData: "fetched" as "loading" | "fetched" | "failed",
|
||||||
queryError: "" as string | { sql: string; code: string; msg: string },
|
queryError: "" as string | { sql: string; code: string; msg: string },
|
||||||
query: undefined as undefined | DynamicQueryStructure | string,
|
query: undefined as undefined | DynamicQueryStructure | string,
|
||||||
activeQueryId: undefined as undefined | number,
|
activeQueryId: undefined as undefined | string,
|
||||||
isLoadedQuery: undefined as undefined | number,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
|
@ -59,6 +59,16 @@ export const useQueryBuilderStore = defineStore("queryBuilder", {
|
||||||
this.loadingData = "failed";
|
this.loadingData = "failed";
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
async sendQueryByStoreId(
|
||||||
|
id: string,
|
||||||
|
offset = 0,
|
||||||
|
count = 25,
|
||||||
|
noLimit: boolean = false
|
||||||
|
): Promise<AxiosResponse<any, any>> {
|
||||||
|
return await http.post(
|
||||||
|
`/admin/querybuilder/query/${id}?` + (noLimit ? `noLimit=true` : `offset=${offset}&count=${count}`)
|
||||||
|
);
|
||||||
|
},
|
||||||
clearResults() {
|
clearResults() {
|
||||||
this.data = [];
|
this.data = [];
|
||||||
this.totalLength = 0;
|
this.totalLength = 0;
|
||||||
|
|
|
@ -6,7 +6,7 @@ import type {
|
||||||
import { http } from "@/serverCom";
|
import { http } from "@/serverCom";
|
||||||
import type { AxiosResponse } from "axios";
|
import type { AxiosResponse } from "axios";
|
||||||
|
|
||||||
export const useNewsletterConfigStore = defineStore("newsletterConfi", {
|
export const useNewsletterConfigStore = defineStore("newsletterConfig", {
|
||||||
state: () => {
|
state: () => {
|
||||||
return {
|
return {
|
||||||
config: [] as Array<NewsletterConfigViewModel>,
|
config: [] as Array<NewsletterConfigViewModel>,
|
||||||
|
|
|
@ -31,7 +31,7 @@ export const useQueryStoreStore = defineStore("queryStore", {
|
||||||
this.loading = "failed";
|
this.loading = "failed";
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
fetchQueryById(id: number): Promise<AxiosResponse<any, any>> {
|
fetchQueryById(id: string): Promise<AxiosResponse<any, any>> {
|
||||||
return http.get(`/admin/querystore/${id}`);
|
return http.get(`/admin/querystore/${id}`);
|
||||||
},
|
},
|
||||||
triggerSave() {
|
triggerSave() {
|
||||||
|
|
103
src/stores/admin/management/setting.ts
Normal file
103
src/stores/admin/management/setting.ts
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { http } from "@/serverCom";
|
||||||
|
import type { SettingString, SettingTopic, SettingValueMapping } from "@/types/settingTypes";
|
||||||
|
import type { AxiosResponse } from "axios";
|
||||||
|
|
||||||
|
export const useSettingStore = defineStore("setting", {
|
||||||
|
state: () => {
|
||||||
|
return {
|
||||||
|
settings: {} as { [key in SettingString]: SettingValueMapping[key] },
|
||||||
|
loading: "loading" as "loading" | "fetched" | "failed",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getters: {
|
||||||
|
readSetting:
|
||||||
|
(state) =>
|
||||||
|
<K extends SettingString>(key: K): SettingValueMapping[K] => {
|
||||||
|
return state.settings[key];
|
||||||
|
},
|
||||||
|
readByTopic:
|
||||||
|
(state) =>
|
||||||
|
<T extends SettingTopic>(
|
||||||
|
topic: T
|
||||||
|
): { [K in SettingString as K extends `${T}.${string}` ? K : never]: SettingValueMapping[K] } => {
|
||||||
|
return Object.entries(state.settings).reduce((acc, [key, value]) => {
|
||||||
|
const typedKey = key as SettingString;
|
||||||
|
if (key.startsWith(topic)) {
|
||||||
|
acc[typedKey] = value;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {} as any);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
fetchSettings() {
|
||||||
|
this.loading = "loading";
|
||||||
|
http
|
||||||
|
.get("/admin/setting")
|
||||||
|
.then((result) => {
|
||||||
|
this.settings = result.data;
|
||||||
|
this.loading = "fetched";
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
this.loading = "failed";
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async getSetting(key: SettingString): Promise<AxiosResponse<any, any>> {
|
||||||
|
return await http.get(`/admin/setting/${key}`).then((res) => {
|
||||||
|
//@ts-expect-error
|
||||||
|
this.settings[key] = res.data;
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async updateSetting<K extends SettingString>(
|
||||||
|
key: K,
|
||||||
|
value: SettingValueMapping[K]
|
||||||
|
): Promise<AxiosResponse<any, any>> {
|
||||||
|
return await http
|
||||||
|
.put("/admin/setting", {
|
||||||
|
setting: key,
|
||||||
|
value: value,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
this.settings[key] = value;
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async updateSettings<K extends SettingString>(
|
||||||
|
data: { key: K; value: SettingValueMapping[K] }[]
|
||||||
|
): Promise<AxiosResponse<any, any>> {
|
||||||
|
return await http.put("/admin/setting/multi", data).then((res) => {
|
||||||
|
for (const element of data) {
|
||||||
|
this.settings[element.key] = element.value;
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async uploadImage(
|
||||||
|
data: { key: "club.logo" | "club.icon"; value?: File | "keep" }[]
|
||||||
|
): Promise<AxiosResponse<any, any>> {
|
||||||
|
const formData = new FormData();
|
||||||
|
for (let entry of data) {
|
||||||
|
if (entry.value) {
|
||||||
|
formData.append(typeof entry.value == "string" ? entry.key : entry.key.split(".")[1], entry.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await http
|
||||||
|
.put("/admin/setting/images", formData, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "multipart/form-data",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
for (const element of data) {
|
||||||
|
this.settings[element.key] = element.value ? "configured" : "";
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async resetSetting(key: SettingString): Promise<AxiosResponse<any, any>> {
|
||||||
|
return await http.delete(`/admin/setting/${key}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
|
@ -137,6 +137,7 @@ export const useNavigationStore = defineStore("navigation", {
|
||||||
...(abilityStore.can("read", "management", "user") ? [{ key: "user", title: "Benutzer" }] : []),
|
...(abilityStore.can("read", "management", "user") ? [{ key: "user", title: "Benutzer" }] : []),
|
||||||
...(abilityStore.can("read", "management", "role") ? [{ key: "role", title: "Rollen" }] : []),
|
...(abilityStore.can("read", "management", "role") ? [{ key: "role", title: "Rollen" }] : []),
|
||||||
...(abilityStore.can("read", "management", "webapi") ? [{ key: "webapi", title: "Webapi-Token" }] : []),
|
...(abilityStore.can("read", "management", "webapi") ? [{ key: "webapi", title: "Webapi-Token" }] : []),
|
||||||
|
...(abilityStore.can("read", "management", "setting") ? [{ key: "setting", title: "Einstellungen" }] : []),
|
||||||
...(abilityStore.can("read", "management", "backup") ? [{ key: "backup", title: "Backups" }] : []),
|
...(abilityStore.can("read", "management", "backup") ? [{ key: "backup", title: "Backups" }] : []),
|
||||||
...(abilityStore.isAdmin() ? [{ key: "version", title: "Version" }] : []),
|
...(abilityStore.isAdmin() ? [{ key: "version", title: "Version" }] : []),
|
||||||
],
|
],
|
||||||
|
|
34
src/stores/configuration.ts
Normal file
34
src/stores/configuration.ts
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { http } from "../serverCom";
|
||||||
|
|
||||||
|
export const useConfigurationStore = defineStore("configuration", {
|
||||||
|
state: () => {
|
||||||
|
return {
|
||||||
|
clubName: "",
|
||||||
|
clubImprint: "",
|
||||||
|
clubPrivacy: "",
|
||||||
|
clubWebsite: "",
|
||||||
|
appCustom_login_message: "",
|
||||||
|
appShow_link_to_calendar: false as boolean,
|
||||||
|
|
||||||
|
serverOffline: false as boolean,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
configure() {
|
||||||
|
http
|
||||||
|
.get("/public/configuration")
|
||||||
|
.then((res) => {
|
||||||
|
this.clubName = res.data["club.name"];
|
||||||
|
this.clubImprint = res.data["club.imprint"];
|
||||||
|
this.clubPrivacy = res.data["club.privacy"];
|
||||||
|
this.clubWebsite = res.data["club.website"];
|
||||||
|
this.appCustom_login_message = res.data["app.custom_login_message"];
|
||||||
|
this.appShow_link_to_calendar = res.data["app.show_link_to_calendar"];
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
this.serverOffline = true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
130
src/stores/setup.ts
Normal file
130
src/stores/setup.ts
Normal file
|
@ -0,0 +1,130 @@
|
||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { http } from "../serverCom";
|
||||||
|
import type { AxiosResponse } from "axios";
|
||||||
|
import { useConfigurationStore } from "./configuration";
|
||||||
|
|
||||||
|
export const useSetupStore = defineStore("setup", {
|
||||||
|
state: () => {
|
||||||
|
return {
|
||||||
|
dictionary: ["club", "clubImages", "app", "mail", "account", "finished"],
|
||||||
|
step: 0 as number,
|
||||||
|
successfull: 0 as number,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getters: {
|
||||||
|
stepIndex: (state) => (dict: string) => state.dictionary.findIndex((d) => d == dict),
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
skip(dict: string) {
|
||||||
|
let myIndex = this.stepIndex(dict);
|
||||||
|
this.step += 1;
|
||||||
|
if (this.successfull <= myIndex) {
|
||||||
|
this.successfull = myIndex + 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async setClub(data: {
|
||||||
|
name?: string;
|
||||||
|
imprint?: string;
|
||||||
|
privacy?: string;
|
||||||
|
website?: string;
|
||||||
|
}): Promise<AxiosResponse<any, any>> {
|
||||||
|
let configStore = useConfigurationStore();
|
||||||
|
|
||||||
|
let myIndex = this.stepIndex("club");
|
||||||
|
const res = await http.post(`/setup/club`, {
|
||||||
|
name: data.name,
|
||||||
|
imprint: data.imprint,
|
||||||
|
privacy: data.privacy,
|
||||||
|
website: data.website,
|
||||||
|
});
|
||||||
|
configStore.configure();
|
||||||
|
|
||||||
|
this.step += 1;
|
||||||
|
if (this.successfull <= myIndex) {
|
||||||
|
this.successfull = myIndex;
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
async setClubImages(data: { icon?: File; logo?: File }): Promise<AxiosResponse<any, any>> {
|
||||||
|
let configStore = useConfigurationStore();
|
||||||
|
|
||||||
|
let myIndex = this.stepIndex("clubImages");
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
if (data.icon) {
|
||||||
|
formData.append("icon", data.icon);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.logo) {
|
||||||
|
formData.append("logo", data.logo);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await http.post(`/setup/club/images`, formData, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "multipart/form-data",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
configStore.configure();
|
||||||
|
|
||||||
|
this.step += 1;
|
||||||
|
if (this.successfull <= myIndex) {
|
||||||
|
this.successfull = myIndex;
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
async setApp(data: { login_message: string; show_cal_link: boolean }): Promise<AxiosResponse<any, any>> {
|
||||||
|
let myIndex = this.stepIndex("app");
|
||||||
|
const res = await http.post(`/setup/app`, {
|
||||||
|
custom_login_message: data.login_message,
|
||||||
|
show_link_to_calendar: data.show_cal_link,
|
||||||
|
});
|
||||||
|
this.step += 1;
|
||||||
|
if (this.successfull <= myIndex) {
|
||||||
|
this.successfull = myIndex;
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
async setMailConfig(data: {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
secure: boolean;
|
||||||
|
mail: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}): Promise<AxiosResponse<any, any>> {
|
||||||
|
let myIndex = this.stepIndex("mail");
|
||||||
|
const res = await http.post(`/setup/mail`, {
|
||||||
|
host: data.host,
|
||||||
|
port: data.port,
|
||||||
|
secure: data.secure,
|
||||||
|
mail: data.mail,
|
||||||
|
username: data.username,
|
||||||
|
password: data.password,
|
||||||
|
});
|
||||||
|
this.step += 1;
|
||||||
|
if (this.successfull <= myIndex) {
|
||||||
|
this.successfull = myIndex;
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
async createAdmin(credentials: {
|
||||||
|
username: string;
|
||||||
|
mail: string;
|
||||||
|
firstname: string;
|
||||||
|
lastname: string;
|
||||||
|
}): Promise<AxiosResponse<any, any>> {
|
||||||
|
let myIndex = this.stepIndex("account");
|
||||||
|
const res = await http.post(`/setup/me`, {
|
||||||
|
username: credentials.username,
|
||||||
|
mail: credentials.mail,
|
||||||
|
firstname: credentials.firstname,
|
||||||
|
lastname: credentials.lastname,
|
||||||
|
});
|
||||||
|
this.step += 1;
|
||||||
|
if (this.successfull < myIndex) {
|
||||||
|
this.successfull = myIndex;
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
|
@ -1,9 +1,10 @@
|
||||||
export interface DynamicQueryStructure {
|
export interface DynamicQueryStructure {
|
||||||
|
id: string;
|
||||||
select: string[] | "*";
|
select: string[] | "*";
|
||||||
table: string;
|
table: string;
|
||||||
where?: Array<ConditionStructure>;
|
where?: Array<ConditionStructure>;
|
||||||
join?: Array<DynamicQueryStructure & { foreignColumn: string }>;
|
join?: Array<DynamicQueryStructure & JoinStructure>;
|
||||||
orderBy?: Array<OrderByStructure>;
|
orderBy?: Array<OrderByStructure>; // only at top level
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConditionStructure = (
|
export type ConditionStructure = (
|
||||||
|
@ -47,7 +48,12 @@ export type WhereOperation =
|
||||||
| "timespanEq"; // Date before x years (YYYY-01-01 <bis> YYYY-12-31)
|
| "timespanEq"; // Date before x years (YYYY-01-01 <bis> YYYY-12-31)
|
||||||
// TODO: age between | age equals | age greater | age smaller
|
// TODO: age between | age equals | age greater | age smaller
|
||||||
|
|
||||||
|
export type JoinStructure = { foreignColumn: string; type: "defined" } | { condition: string; type: "custom" };
|
||||||
|
|
||||||
export type OrderByStructure = {
|
export type OrderByStructure = {
|
||||||
|
id: string;
|
||||||
|
depth: number;
|
||||||
|
table: string;
|
||||||
column: string;
|
column: string;
|
||||||
order: OrderByType;
|
order: OrderByType;
|
||||||
};
|
};
|
||||||
|
|
|
@ -21,7 +21,8 @@ export type PermissionModule =
|
||||||
| "query_store"
|
| "query_store"
|
||||||
| "template"
|
| "template"
|
||||||
| "template_usage"
|
| "template_usage"
|
||||||
| "backup";
|
| "backup"
|
||||||
|
| "setting";
|
||||||
|
|
||||||
export type PermissionType = "read" | "create" | "update" | "delete";
|
export type PermissionType = "read" | "create" | "update" | "delete";
|
||||||
|
|
||||||
|
@ -67,6 +68,7 @@ export const permissionModules: Array<PermissionModule> = [
|
||||||
"template",
|
"template",
|
||||||
"template_usage",
|
"template_usage",
|
||||||
"backup",
|
"backup",
|
||||||
|
"setting",
|
||||||
];
|
];
|
||||||
export const permissionTypes: Array<PermissionType> = ["read", "create", "update", "delete"];
|
export const permissionTypes: Array<PermissionType> = ["read", "create", "update", "delete"];
|
||||||
export const sectionsAndModules: SectionsAndModulesObject = {
|
export const sectionsAndModules: SectionsAndModulesObject = {
|
||||||
|
@ -84,5 +86,5 @@ export const sectionsAndModules: SectionsAndModulesObject = {
|
||||||
"template_usage",
|
"template_usage",
|
||||||
"newsletter_config",
|
"newsletter_config",
|
||||||
],
|
],
|
||||||
management: ["user", "role", "webapi", "backup"],
|
management: ["user", "role", "webapi", "backup", "setting"],
|
||||||
};
|
};
|
||||||
|
|
80
src/types/settingTypes.ts
Normal file
80
src/types/settingTypes.ts
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
export type SettingTopic = "club" | "app" | "session" | "mail" | "backup" | "security";
|
||||||
|
export type SettingString =
|
||||||
|
| "club.icon"
|
||||||
|
| "club.logo"
|
||||||
|
| "club.name"
|
||||||
|
| "club.imprint"
|
||||||
|
| "club.privacy"
|
||||||
|
| "club.website"
|
||||||
|
| "app.custom_login_message"
|
||||||
|
| "app.show_link_to_calendar"
|
||||||
|
| "session.jwt_expiration"
|
||||||
|
| "session.refresh_expiration"
|
||||||
|
| "session.pwa_refresh_expiration"
|
||||||
|
| "mail.email"
|
||||||
|
| "mail.username"
|
||||||
|
| "mail.password"
|
||||||
|
| "mail.host"
|
||||||
|
| "mail.port"
|
||||||
|
| "mail.secure"
|
||||||
|
| "backup.interval"
|
||||||
|
| "backup.copies";
|
||||||
|
|
||||||
|
export type SettingTypeAtom = "longstring" | "string" | "ms" | "number" | "boolean" | "url" | "email";
|
||||||
|
export type SettingType = SettingTypeAtom | `${SettingTypeAtom}/crypt` | `${SettingTypeAtom}/rand`;
|
||||||
|
|
||||||
|
export type SettingValueMapping = {
|
||||||
|
"club.icon": string;
|
||||||
|
"club.logo": string;
|
||||||
|
"club.name": string;
|
||||||
|
"club.imprint": string;
|
||||||
|
"club.privacy": string;
|
||||||
|
"club.website": string;
|
||||||
|
"app.custom_login_message": string;
|
||||||
|
"app.show_link_to_calendar": boolean;
|
||||||
|
"session.jwt_expiration": string;
|
||||||
|
"session.refresh_expiration": string;
|
||||||
|
"session.pwa_refresh_expiration": string;
|
||||||
|
"mail.email": string;
|
||||||
|
"mail.username": string;
|
||||||
|
"mail.password": string;
|
||||||
|
"mail.host": string;
|
||||||
|
"mail.port": number;
|
||||||
|
"mail.secure": boolean;
|
||||||
|
"backup.interval": number;
|
||||||
|
"backup.copies": number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Typsicherer Zugriff auf Settings
|
||||||
|
export type SettingDefinition<T extends SettingType | SettingTypeAtom[]> = {
|
||||||
|
type: T;
|
||||||
|
default?: string | number | boolean;
|
||||||
|
optional?: boolean;
|
||||||
|
min?: T extends "number" | `number/crypt` | `number/rand` ? number : never;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SettingsSchema = {
|
||||||
|
[key in SettingString]: SettingDefinition<SettingType | SettingTypeAtom[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const settingsType: SettingsSchema = {
|
||||||
|
"club.icon": { type: "string", optional: true },
|
||||||
|
"club.logo": { type: "string", optional: true },
|
||||||
|
"club.name": { type: "string", default: "FF Admin" },
|
||||||
|
"club.imprint": { type: "url", optional: true },
|
||||||
|
"club.privacy": { type: "url", optional: true },
|
||||||
|
"club.website": { type: "url", optional: true },
|
||||||
|
"app.custom_login_message": { type: "string", optional: true },
|
||||||
|
"app.show_link_to_calendar": { type: "boolean", default: true },
|
||||||
|
"session.jwt_expiration": { type: "ms", default: "15m" },
|
||||||
|
"session.refresh_expiration": { type: "ms", default: "1d" },
|
||||||
|
"session.pwa_refresh_expiration": { type: "ms", default: "5d" },
|
||||||
|
"mail.email": { type: "email", optional: false },
|
||||||
|
"mail.username": { type: "string", optional: false },
|
||||||
|
"mail.password": { type: "string/crypt", optional: false },
|
||||||
|
"mail.host": { type: "url", optional: false },
|
||||||
|
"mail.port": { type: "number", default: 587 },
|
||||||
|
"mail.secure": { type: "boolean", default: false },
|
||||||
|
"backup.interval": { type: "number", default: 1, min: 1 },
|
||||||
|
"backup.copies": { type: "number", default: 7, min: 1 },
|
||||||
|
};
|
|
@ -1,5 +1,3 @@
|
||||||
import type { QueryViewModel } from "../../configuration/query.models";
|
|
||||||
|
|
||||||
export interface NewsletterViewModel {
|
export interface NewsletterViewModel {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
@ -8,8 +6,7 @@ export interface NewsletterViewModel {
|
||||||
newsletterText: string;
|
newsletterText: string;
|
||||||
newsletterSignatur: string;
|
newsletterSignatur: string;
|
||||||
isSent: boolean;
|
isSent: boolean;
|
||||||
recipientsByQueryId?: number | null;
|
recipientsByQueryId?: string | null;
|
||||||
recipientsByQuery?: QueryViewModel | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateNewsletterViewModel {
|
export interface CreateNewsletterViewModel {
|
||||||
|
@ -23,7 +20,7 @@ export interface SyncNewsletterViewModel {
|
||||||
newsletterTitle: string;
|
newsletterTitle: string;
|
||||||
newsletterText: string;
|
newsletterText: string;
|
||||||
newsletterSignatur: string;
|
newsletterSignatur: string;
|
||||||
recipientsByQueryId?: number;
|
recipientsByQueryId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SendNewsletterViewModel {
|
export interface SendNewsletterViewModel {
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue