Merge branch 'milestone/ff-admin-unit' into unit/#70-build-ui-demo

# Conflicts:
#	package-lock.json
#	package.json
#	src/router/club/newsletterGuard.ts
#	src/router/club/protocolGuard.ts
#	src/router/index.ts
#	src/types/permissionTypes.ts
#	src/views/admin/club/newsletter/NewsletterRecipients.vue
This commit is contained in:
Julian Krauser 2025-05-09 12:29:30 +02:00
commit bdc139f37f
107 changed files with 4984 additions and 1742 deletions

View file

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

View file

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

View file

@ -8,12 +8,9 @@ Dieses Repository dient hauptsächlich zur Verwaltung der Mitgliederdaten, aber
Eine Demo dieser Seite finden Sie unter [https://admin-demo.ff-admin.de](https://admin-demo.ff-admin.de). Eine Demo dieser Seite finden Sie unter [https://admin-demo.ff-admin.de](https://admin-demo.ff-admin.de).
Für die Verwendung muss ein TOTP-Code eingegeben werden. Die Zugangsdaten (Lesebeschränkt) sind unterhalb dem Login angegeben.
Die Zugangsdaten (Lesebeschränkt) sind:\ Das Handbuch zur Anwendung finden sie unter [https://ff-admin.de/ff-admin-handbook](https://ff-admin.de/ff-admin-handbook).
EMAIL: demo-besucher\
TOTP: ![alt text](demo-totp-qrcode.png)\
TOTP-Code: FBMDAJKFOYQXM2DNH47GWWBGJ5KWOUCW
## Installation ## Installation
@ -32,14 +29,6 @@ services:
#environment: #environment:
# - SERVERADDRESS=<backend_url (https://... | http://...)> # wichtig: ohne Pfad # - SERVERADDRESS=<backend_url (https://... | http://...)> # wichtig: ohne Pfad
# - APPNAMEOVERWRITE=<appname> # ersetzt den Namen FF-Admin auf der Login-Seite und sonstigen Positionen in der Oberfläche
# - IMPRINTLINK=<imprint link>
# - PRIVACYLINK=<privacy link>
# - CUSTOMLOGINMESSAGE=betrieben von xy
#volumes:
# - <volume|local path>/favicon.ico:/usr/share/nginx/html/favicon.ico # 48x48 px Auflösung
# - <volume|local path>/favicon.png:/usr/share/nginx/html/favicon.png # 512x512 px Auflösung - wird als pwa Icon genutzt
# - <volume|local path>/Logo.png:/usr/share/nginx/html/Logo.png
``` ```
Wenn keine Server-Adresse angegeben wird, wird versucht das Backend unter der URL des Frontends zu erreichen. Dazu muss das Backend auf der gleichen URL wie das Frontend laufen. Zur Unterscheidung von Frontend und Backend bei gleicher URL müssen alle Anfragen mit dem PathPrefix `/api` an das Backend weitergeleitet werden. Wenn keine Server-Adresse angegeben wird, wird versucht das Backend unter der URL des Frontends zu erreichen. Dazu muss das Backend auf der gleichen URL wie das Frontend laufen. Zur Unterscheidung von Frontend und Backend bei gleicher URL müssen alle Anfragen mit dem PathPrefix `/api` an das Backend weitergeleitet werden.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

View file

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

View file

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

2930
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{ {
"name": "ff-admin", "name": "ff-admin",
"version": "1.3.5", "version": "1.5.0",
"description": "Feuerwehr/Verein Mitgliederverwaltung UI", "description": "Feuerwehr/Verein Mitgliederverwaltung UI",
"type": "module", "type": "module",
"scripts": { "scripts": {
@ -24,18 +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",
"@fullcalendar/vue3": "^6.1.17",
"@headlessui/vue": "^1.7.23", "@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.1.5", "@heroicons/vue": "^2.2.0",
"@tailwindcss/vite": "^4.1.3", "@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",
@ -49,45 +50,46 @@
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"pdf-dist": "^1.0.0", "pdf-dist": "^1.0.0",
"pinia": "^3.0.2", "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",
"socket.io-client": "^4.8.1",
"unplugin-vue-markdown": "^28.3.1", "unplugin-vue-markdown": "^28.3.1",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"vue": "^3.4.29", "vue": "^3.5.13",
"vue-qrcode-reader": "^5.7.1", "vue-qrcode-reader": "^5.7.1",
"vue-router": "^4.3.3" "vue-router": "^4.5.1"
}, },
"devDependencies": { "devDependencies": {
"@rushstack/eslint-patch": "^1.8.0", "@rushstack/eslint-patch": "^1.11.0",
"@tailwindcss/postcss": "^4.1.3", "@tailwindcss/postcss": "^4.1.5",
"@tsconfig/node20": "^20.1.4", "@tsconfig/node20": "^20.1.5",
"@types/eslint": "~9.6.0", "@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": "^22.14.0", "@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": "^10.0.0", "@types/uuid": "^10.0.0",
"@vite-pwa/assets-generator": "^1.0.0", "@vite-pwa/assets-generator": "^1.0.0",
"@vitejs/plugin-vue": "^5.0.5", "@vitejs/plugin-vue": "^5.2.3",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.5.0", "@vue/eslint-config-typescript": "^14.5.0",
"@vue/tsconfig": "^0.7.0", "@vue/tsconfig": "^0.7.0",
"eslint": "^9.24.0", "eslint": "^9.26.0",
"eslint-plugin-vue": "^10.0.0", "eslint-plugin-vue": "^10.1.0",
"npm-run-all2": "^7.0.2", "npm-run-all2": "^8.0.1",
"prettier": "^3.2.5", "prettier": "^3.5.3",
"tailwindcss": "^4.1.3", "tailwindcss": "^4.1.5",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^6.2.6", "vite": "^6.3.5",
"vite-plugin-pwa": "^1.0.0", "vite-plugin-pwa": "^1.0.0",
"vite-plugin-vue-devtools": "^7.6.8", "vite-plugin-vue-devtools": "^7.7.6",
"vue-tsc": "^2.0.21" "vue-tsc": "^2.2.10"
} }
} }

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

View file

@ -8,6 +8,12 @@
</div> </div>
<Footer @contextmenu.prevent /> <Footer @contextmenu.prevent />
<Notification /> <Notification />
<Teleport to="head">
<title>{{ clubName }}</title>
<link rel="icon" type="image/ico" :href="config.server_address + '/api/public/favicon.ico'" />
<link rel="manifest" :href="config.server_address + '/api/public/manifest.webmanifest'" />
</Teleport>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -15,20 +21,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>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +1,9 @@
<template> <template>
<header class="flex flex-row h-16 min-h-16 justify-between p-3 md:px-5 bg-white shadow-xs"> <header class="flex flex-row h-16 min-h-16 justify-between p-3 md:px-5 bg-white shadow-xs">
<RouterLink to="/" class="flex flex-row gap-2 align-bottom w-fit h-full"> <RouterLink to="/" class="flex flex-row gap-2 align-bottom w-fit h-full">
<img src="/Logo.png" alt="LOGO" class="h-full w-auto" /> <AppLogo />
<h1 v-if="false" class="font-bold text-3xl w-fit whitespace-nowrap"> <h1 v-if="false" class="font-bold text-3xl w-fit whitespace-nowrap">
{{ config.app_name_overwrite || "FF Admin" }} {{ clubName }}
</h1> </h1>
</RouterLink> </RouterLink>
<div class="flex flex-row gap-2 items-center"> <div class="flex flex-row gap-2 items-center">
@ -37,15 +37,17 @@ import { useAuthStore } from "@/stores/auth";
import { useNavigationStore } from "@/stores/admin/navigation"; import { useNavigationStore } from "@/stores/admin/navigation";
import TopLevelLink from "./admin/TopLevelLink.vue"; import TopLevelLink from "./admin/TopLevelLink.vue";
import UserMenu from "./UserMenu.vue"; import UserMenu from "./UserMenu.vue";
import { config } from "@/config";
</script> </script>
<script lang="ts"> <script lang="ts">
import { defineComponent } from "vue"; import { defineComponent } from "vue";
import AppLogo from "./AppLogo.vue";
import { useConfigurationStore } from "@/stores/configuration";
export default defineComponent({ export default defineComponent({
computed: { computed: {
...mapState(useAuthStore, ["authCheck"]), ...mapState(useAuthStore, ["authCheck"]),
...mapState(useNavigationStore, ["topLevel"]), ...mapState(useNavigationStore, ["topLevel"]),
...mapState(useConfigurationStore, ["clubName"]),
routeName() { routeName() {
return typeof this.$route.name == "string" ? this.$route.name : ""; return typeof this.$route.name == "string" ? this.$route.name : "";
}, },

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -18,22 +18,22 @@
<div class="flex flex-row border border-white rounded-md overflow-hidden"> <div class="flex flex-row border border-white rounded-md overflow-hidden">
<EyeIcon <EyeIcon
class="w-5 h-5 p-1 box-content cursor-pointer" class="w-5 h-5 p-1 box-content cursor-pointer"
:class="_can(permissionUpdate, 'read', section) ? 'bg-success' : ''" :class="_canSection(permissionUpdate, 'read', section) ? 'bg-success' : ''"
@click="togglePermission('read', section)" @click="togglePermission('read', section)"
/> />
<PlusIcon <PlusIcon
class="w-5 h-5 p-1 box-content cursor-pointer" class="w-5 h-5 p-1 box-content cursor-pointer"
:class="_can(permissionUpdate, 'create', section) ? 'bg-success' : ''" :class="_canSection(permissionUpdate, 'create', section) ? 'bg-success' : ''"
@click="togglePermission('create', section)" @click="togglePermission('create', section)"
/> />
<PencilIcon <PencilIcon
class="w-5 h-5 p-1 box-content cursor-pointer" class="w-5 h-5 p-1 box-content cursor-pointer"
:class="_can(permissionUpdate, 'update', section) ? 'bg-success' : ''" :class="_canSection(permissionUpdate, 'update', section) ? 'bg-success' : ''"
@click="togglePermission('update', section)" @click="togglePermission('update', section)"
/> />
<TrashIcon <TrashIcon
class="w-5 h-5 p-1 box-content cursor-pointer" class="w-5 h-5 p-1 box-content cursor-pointer"
:class="_can(permissionUpdate, 'delete', section) ? 'bg-success' : ''" :class="_canSection(permissionUpdate, 'delete', section) ? 'bg-success' : ''"
@click="togglePermission('delete', section)" @click="togglePermission('delete', section)"
/> />
</div> </div>
@ -132,7 +132,7 @@ export default defineComponent({
}; };
}, },
computed: { computed: {
...mapState(useAbilityStore, ["_can"]), ...mapState(useAbilityStore, ["_can", "_canSection"]),
canSaveOrReset(): boolean { canSaveOrReset(): boolean {
return isEqual(this.permissions, this.permissionUpdate); return isEqual(this.permissions, this.permissionUpdate);
}, },

View file

@ -69,7 +69,15 @@
<input type="date" id="birthdate" required /> <input type="date" id="birthdate" required />
</div> </div>
<div> <div>
<label for="internalId">Interne ID (optional)</label> <div class="flex flex-row">
<label for="internalId" class="grow">
Interne ID (optional{{ lastId ? ` - zuletzte verwendet: ${lastId}` : "" }})
</label>
<div title="Es empfiehlt sich, die Interne Id mit Platzhaltern wie '0' vorne aufzufüllen.">
<InformationCircleIcon class="h-5 w-5" />
</div>
</div>
<input type="text" id="internalId" /> <input type="text" id="internalId" />
</div> </div>
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
@ -101,8 +109,9 @@ import { Listbox, ListboxButton, ListboxOptions, ListboxOption, ListboxLabel } f
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid"; import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { useMemberStore } from "@/stores/admin/club/member/member"; import { useMemberStore } from "@/stores/admin/club/member/member";
import type { CreateMemberViewModel } from "@/viewmodels/admin/club/member/member.models"; import type { CreateMemberViewModel } from "@/viewmodels/admin/club/member/member.models";
import { useSalutationStore } from "../../../../stores/admin/configuration/salutation"; import { useSalutationStore } from "@/stores/admin/configuration/salutation";
import type { SalutationViewModel } from "../../../../viewmodels/admin/configuration/salutation.models"; import type { SalutationViewModel } from "@/viewmodels/admin/configuration/salutation.models";
import { InformationCircleIcon } from "@heroicons/vue/24/outline";
</script> </script>
<script lang="ts"> <script lang="ts">
@ -112,6 +121,7 @@ export default defineComponent({
status: null as null | "loading" | { status: "success" | "failed"; reason?: string }, status: null as null | "loading" | { status: "success" | "failed"; reason?: string },
timeout: undefined as any, timeout: undefined as any,
selectedSalutation: null as null | SalutationViewModel, selectedSalutation: null as null | SalutationViewModel,
lastId: "" as string,
}; };
}, },
computed: { computed: {
@ -119,6 +129,11 @@ export default defineComponent({
}, },
mounted() { mounted() {
this.fetchSalutations(); this.fetchSalutations();
this.fetchLastInternalId()
.then((res) => {
this.lastId = res.data;
})
.catch(() => {});
}, },
beforeUnmount() { beforeUnmount() {
try { try {
@ -127,7 +142,7 @@ export default defineComponent({
}, },
methods: { methods: {
...mapActions(useModalStore, ["closeModal"]), ...mapActions(useModalStore, ["closeModal"]),
...mapActions(useMemberStore, ["createMember"]), ...mapActions(useMemberStore, ["createMember", "fetchLastInternalId"]),
...mapActions(useSalutationStore, ["fetchSalutations"]), ...mapActions(useSalutationStore, ["fetchSalutations"]),
triggerCreate(e: any) { triggerCreate(e: any) {
if (!this.selectedSalutation) return; if (!this.selectedSalutation) return;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -39,7 +39,7 @@ import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue"; import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue"; import FailureXMark from "@/components/FailureXMark.vue";
import { useWebapiStore } from "@/stores/admin/management/webapi"; import { useWebapiStore } from "@/stores/admin/management/webapi";
import type { CreateWebapiViewModel } from "../../../../viewmodels/admin/management/webapi.models"; import type { CreateWebapiViewModel } from "@/viewmodels/admin/management/webapi.models";
</script> </script>
<script lang="ts"> <script lang="ts">

View file

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

View file

@ -10,7 +10,7 @@
</p> </p>
</div> </div>
<div class="p-2"> <div class="p-2">
<p v-if="respiratoryWearer.internalId">ID: {{ respiratoryWearer.member.internalId }}</p> <p v-if="respiratoryWearer.member.internalId">ID: {{ respiratoryWearer.member.internalId }}</p>
</div> </div>
</RouterLink> </RouterLink>
</template> </template>

View file

@ -30,6 +30,9 @@
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"
> >
<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!"> <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"> <option v-for="query in queries" :key="query.id" :value="query.id">
@ -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: [],
@ -126,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();
} }
}, },
}, },
@ -149,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();
@ -163,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: [],
@ -182,6 +200,7 @@ export default defineComponent({
this.activeQueryId = undefined; this.activeQueryId = undefined;
if (this.queryMode == "builder") { if (this.queryMode == "builder") {
this.value = { this.value = {
id: uuid(),
select: "*", select: "*",
table: "", table: "",
where: [], where: [],

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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-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" 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">
@ -86,7 +87,7 @@ import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { useMemberStore } from "@/stores/admin/club/member/member"; import { useMemberStore } from "@/stores/admin/club/member/member";
import type { MemberViewModel } from "@/viewmodels/admin/club/member/member.models"; import type { MemberViewModel } from "@/viewmodels/admin/club/member/member.models";
import difference from "lodash.difference"; import difference from "lodash.difference";
import Spinner from "../Spinner.vue"; import Spinner from "@/components/Spinner.vue";
</script> </script>
<script lang="ts"> <script lang="ts">
@ -101,6 +102,10 @@ export default defineComponent({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
showTitleAsPlaceholder: {
type: Boolean,
default: false,
},
}, },
emits: ["update:model-value", "add:difference", "remove:difference", "add:member", "add:memberByArray"], emits: ["update:model-value", "add:difference", "remove:difference", "add:member", "add:memberByArray"],
watch: { watch: {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

@ -18,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;
@ -27,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 */
* { * {
@ -72,7 +74,7 @@ a[button] {
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"]),
@ -131,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-hidden! ring-0! hover:bg-red-700! hover:border-red-700! h-10 text-center;
}
.fc-button-active {
@apply bg-red-500! border-red-500!;
}
.fc-toolbar {
@apply flex-wrap;
}
/* For screens between 850px and 768px */
@media (max-width: 850px) and (min-width: 768px) {
.fc-header-toolbar.fc-toolbar.fc-toolbar-ltr > .fc-toolbar-chunk:nth-child(2) {
@apply order-1!;
}
/* Your styles for this range */
}
/* For screens between 525px and 0px */
@media (max-width: 525px) and (min-width: 0px) {
/* Your styles for this range */
.fc-header-toolbar.fc-toolbar.fc-toolbar-ltr > .fc-toolbar-chunk:nth-child(2) {
@apply order-1!;
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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 "./club/memberGuard"; import { resetMemberStores, setMemberId } from "./club/memberGuard";
import { resetProtocolStores, setProtocolId } from "./club/protocolGuard"; import { resetProtocolStores, setProtocolId } from "./club/protocolGuard";
import { resetNewsletterStores, setNewsletterId } from "./club/newsletterGuard"; import { resetNewsletterStores, setNewsletterId } from "./club/newsletterGuard";
import { config } from "../config";
import { setBackupPage } from "./management/backupGuard"; import { setBackupPage } from "./management/backupGuard";
import { resetEquipmentTypeStores, setEquipmentTypeId } from "./unit/equipmentType"; import { resetEquipmentTypeStores, setEquipmentTypeId } from "./unit/equipmentType";
import { resetEquipmentStores, setEquipmentId } from "./unit/equipment"; import { resetEquipmentStores, setEquipmentId } from "./unit/equipment";
@ -1191,6 +1189,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",
@ -1326,10 +1331,6 @@ const router = createRouter({
], ],
}); });
router.afterEach((to, from) => {
document.title = config.app_name_overwrite || "FF Admin";
});
export default router; export default router;
declare module "vue-router" { declare module "vue-router" {

View file

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

View file

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

View file

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

View file

@ -1,9 +1,8 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { http, newEventSource, streamingFetch } from "@/serverCom"; import { http, streamingFetch } from "@/serverCom";
import { useNewsletterStore } from "./newsletter"; import { useNewsletterStore } from "./newsletter";
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
import type { EventSourcePolyfill } from "event-source-polyfill"; import { useNotificationStore, type NotificationType } from "@/stores/notification";
import { useNotificationStore, type NotificationType } from "../../../notification";
export const useNewsletterPrintoutStore = defineStore("newsletterPrintout", { export const useNewsletterPrintoutStore = defineStore("newsletterPrintout", {
state: () => { state: () => {

View file

@ -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: () => {
@ -58,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;

View file

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

View file

@ -6,10 +6,10 @@ import type {
} from "@/viewmodels/admin/configuration/query.models"; } from "@/viewmodels/admin/configuration/query.models";
import { http } from "@/serverCom"; import { http } from "@/serverCom";
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
import { useQueryBuilderStore } from "../club/queryBuilder"; import { useQueryBuilderStore } from "@/stores/admin/club/queryBuilder";
import { useModalStore } from "../../modal"; import { useModalStore } from "@/stores/modal";
import { defineAsyncComponent, markRaw } from "vue"; import { defineAsyncComponent, markRaw } from "vue";
import { useAbilityStore } from "../../ability"; import { useAbilityStore } from "@/stores/ability";
export const useQueryStoreStore = defineStore("queryStore", { export const useQueryStoreStore = defineStore("queryStore", {
state: () => { state: () => {

View file

@ -1,7 +1,7 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { http } from "@/serverCom"; import { http } from "@/serverCom";
import type { AxiosResponse, AxiosProgressEvent } from "axios"; import type { AxiosResponse, AxiosProgressEvent } from "axios";
import type { BackupRestoreViewModel } from "../../../viewmodels/admin/management/backup.models"; import type { BackupRestoreViewModel } from "@/viewmodels/admin/management/backup.models";
export const useBackupStore = defineStore("backup", { export const useBackupStore = defineStore("backup", {
state: () => { state: () => {

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

View file

@ -1,7 +1,7 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { useAbilityStore } from "@/stores/ability"; import { useAbilityStore } from "@/stores/ability";
import router from "@/router"; import router from "@/router";
import type { PermissionSection } from "../../types/permissionTypes"; import type { PermissionSection } from "@/types/permissionTypes";
export type navigationModel = { export type navigationModel = {
[key in topLevelNavigationType]: navigationSplitModel; [key in topLevelNavigationType]: navigationSplitModel;
@ -179,6 +179,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" }] : []),
], ],

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

View file

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

View file

@ -36,7 +36,8 @@ export type PermissionModule =
// management // management
| "user" | "user"
| "role" | "role"
| "webapi"; | "webapi"
| "setting";
export type PermissionType = "read" | "create" | "update" | "delete"; export type PermissionType = "read" | "create" | "update" | "delete";
@ -45,6 +46,7 @@ export type PermissionString =
| `${PermissionSection}.${PermissionModule}.*` // für alle Berechtigungen in einem Modul | `${PermissionSection}.${PermissionModule}.*` // für alle Berechtigungen in einem Modul
| `${PermissionSection}.${PermissionType}` // für spezifische Berechtigungen in einem Abschnitt | `${PermissionSection}.${PermissionType}` // für spezifische Berechtigungen in einem Abschnitt
| `${PermissionSection}.*` // für alle Berechtigungen in einem Abschnitt | `${PermissionSection}.*` // für alle Berechtigungen in einem Abschnitt
| `additional.${string}.${string}` // additional
| "*"; // für Admin | "*"; // für Admin
export type PermissionObject = { export type PermissionObject = {
@ -53,10 +55,20 @@ export type PermissionObject = {
} & { all?: Array<PermissionType> | "*" }; } & { all?: Array<PermissionType> | "*" };
} & { } & {
admin?: boolean; admin?: boolean;
adminByOwner?: boolean;
} & {
additional?: { [key: string]: string };
}; };
export type SectionsAndModulesObject = { export type SectionsAndModulesObject = {
[section in PermissionSection]: Array<PermissionModule>; [section in PermissionSection]: Array<PermissionModule>;
} & {
additional?: Array<{
key: string;
name: string;
type: "number" | "string";
emptyIfAdmin: boolean;
}>;
}; };
export const permissionSections: Array<PermissionSection> = ["club", "unit", "configuration", "management"]; export const permissionSections: Array<PermissionSection> = ["club", "unit", "configuration", "management"];
@ -97,6 +109,7 @@ export const permissionModules: Array<PermissionModule> = [
"user", "user",
"role", "role",
"webapi", "webapi",
"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 = {
@ -127,5 +140,8 @@ export const sectionsAndModules: SectionsAndModulesObject = {
"template_usage", "template_usage",
"newsletter_config", "newsletter_config",
], ],
management: ["user", "role", "webapi", "backup"], management: ["user", "role", "webapi", "backup", "setting"],
additional: [
//{ key: "val", name: "name", type: "number", emptyIfAdmin: true },
],
}; };

80
src/types/settingTypes.ts Normal file
View 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 },
};

View file

@ -1,4 +1,4 @@
import type { CalendarTypeViewModel } from "../configuration/calendarType.models"; import type { CalendarTypeViewModel } from "@/viewmodels/admin/configuration/calendarType.models";
export interface CalendarViewModel { export interface CalendarViewModel {
id: string; id: string;

View file

@ -1,4 +1,4 @@
import type { CommunicationTypeViewModel } from "../../configuration/communicationType.models"; import type { CommunicationTypeViewModel } from "@/viewmodels/admin/configuration/communicationType.models";
export interface CommunicationViewModel { export interface CommunicationViewModel {
id: number; id: number;

View file

@ -1,6 +1,6 @@
import type { CommunicationViewModel } from "./communication.models"; import type { CommunicationViewModel } from "./communication.models";
import type { MembershipViewModel } from "./membership.models"; import type { MembershipViewModel } from "./membership.models";
import type { SalutationViewModel } from "../../configuration/salutation.models"; import type { SalutationViewModel } from "@/viewmodels/admin/configuration/salutation.models";
export interface MemberViewModel { export interface MemberViewModel {
id: string; id: string;

View file

@ -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;
@ -9,7 +7,6 @@ export interface NewsletterViewModel {
newsletterSignatur: string; newsletterSignatur: string;
isSent: boolean; isSent: boolean;
recipientsByQueryId?: string | null; recipientsByQueryId?: string | null;
recipientsByQuery?: QueryViewModel | null;
} }
export interface CreateNewsletterViewModel { export interface CreateNewsletterViewModel {

View file

@ -1,4 +1,4 @@
import type { CalendarViewModel } from "../calendar.models"; import type { CalendarViewModel } from "@/viewmodels/admin/club/calendar.models";
export interface NewsletterDatesViewModel { export interface NewsletterDatesViewModel {
newsletterId: number; newsletterId: number;

View file

@ -1,4 +1,4 @@
import type { MemberViewModel } from "../member/member.models"; import type { MemberViewModel } from "@/viewmodels/admin/club/member/member.models";
export interface NewsletterRecipientsViewModel { export interface NewsletterRecipientsViewModel {
newsletterId: number; newsletterId: number;

View file

@ -1,13 +1,13 @@
import type { NewsletterConfigType } from "@/enums/newsletterConfigType"; import type { NewsletterConfigEnum } from "@/enums/newsletterConfigEnum";
import type { CommunicationTypeViewModel } from "./communicationType.models"; import type { CommunicationTypeViewModel } from "./communicationType.models";
export interface NewsletterConfigViewModel { export interface NewsletterConfigViewModel {
comTypeId: number; comTypeId: number;
config: NewsletterConfigType; config: NewsletterConfigEnum;
comType: CommunicationTypeViewModel; comType: CommunicationTypeViewModel;
} }
export interface SetNewsletterConfigViewModel { export interface SetNewsletterConfigViewModel {
comTypeId: number; comTypeId: number;
config: NewsletterConfigType; config: NewsletterConfigEnum;
} }

View file

@ -1,4 +1,4 @@
import type { BackupSection } from "../../../types/backupTypes"; import type { BackupSection } from "@/types/backupTypes";
export interface BackupRestoreViewModel { export interface BackupRestoreViewModel {
filename: string; filename: string;

View file

@ -2,19 +2,34 @@
<div class="grow flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8"> <div class="grow flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8 pb-20"> <div class="max-w-md w-full space-y-8 pb-20">
<div class="flex flex-col items-center gap-4"> <div class="flex flex-col items-center gap-4">
<img src="/Logo.png" alt="LOGO" class="h-auto w-full" /> <AppLogo />
<h2 class="text-center text-4xl font-extrabold text-gray-900"> <h2 class="text-center text-4xl font-extrabold text-gray-900">
{{ config.app_name_overwrite || "FF Admin" }} {{ clubName }}
</h2> </h2>
</div> </div>
<form class="flex flex-col gap-2" @submit.prevent="login"> <form class="flex flex-col gap-2" @submit.prevent="submit">
<div class="-space-y-px"> <div class="-space-y-px">
<div> <div class="relative">
<input id="username" name="username" type="text" required placeholder="Benutzer" class="rounded-b-none!" />
</div>
<div>
<input <input
id="username"
name="username"
type="text"
required
placeholder="Benutzer"
:class="routine == '' ? '' : 'rounded-b-none!'"
:value="username"
:disabled="username != ''"
/>
<div v-if="usernameStatus" class="h-full flex items-center justify-center w-5 absolute top-0 right-2">
<Spinner v-if="usernameStatus == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="usernameStatus == 'success'" />
<FailureXMark v-else-if="usernameStatus == 'failed'" />
</div>
</div>
<div v-if="routine != ''">
<input
v-if="routine == 'totp'"
id="totp" id="totp"
name="totp" name="totp"
type="text" type="text"
@ -23,13 +38,26 @@
class="rounded-t-none!" class="rounded-t-none!"
autocomplete="off" autocomplete="off"
/> />
<input
v-else
id="password"
name="password"
type="password"
required
placeholder="Passwort"
class="rounded-t-none!"
autocomplete="current-password"
/>
</div> </div>
</div> </div>
<RouterLink :to="{ name: 'reset-start' }" class="w-fit self-end text-primary">TOTP verloren</RouterLink> <p v-if="username != ''" class="w-fit self-end text-primary cursor-pointer" @click="resetRoutine">
Benutzer wechseln
</p>
<RouterLink :to="{ name: 'reset-start' }" class="w-fit self-end text-primary">Zugang verloren</RouterLink>
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
<button type="submit" primary :disabled="loginStatus == 'loading' || loginStatus == 'success'"> <button type="submit" primary :disabled="loginStatus == 'loading' || loginStatus == 'success'">
anmelden {{ routine == "" ? "Benutzer prüfen" : "anmelden" }}
</button> </button>
<Spinner v-if="loginStatus == 'loading'" class="my-auto" /> <Spinner v-if="loginStatus == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="loginStatus == 'success'" /> <SuccessCheckmark v-else-if="loginStatus == 'success'" />
@ -50,7 +78,10 @@ import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue"; import FailureXMark from "@/components/FailureXMark.vue";
import { resetAllPiniaStores } from "@/helpers/piniaReset"; import { resetAllPiniaStores } from "@/helpers/piniaReset";
import FormBottomBar from "@/components/FormBottomBar.vue"; import FormBottomBar from "@/components/FormBottomBar.vue";
import { config } from "@/config"; import AppLogo from "@/components/AppLogo.vue";
import { mapState } from "pinia";
import { useConfigurationStore } from "@/stores/configuration";
import { hashString } from "@/helpers/crypto";
</script> </script>
<script lang="ts"> <script lang="ts">
@ -58,21 +89,90 @@ export default defineComponent({
data() { data() {
return { return {
loginStatus: undefined as undefined | "loading" | "success" | "failed", loginStatus: undefined as undefined | "loading" | "success" | "failed",
usernameStatus: undefined as undefined | "loading" | "success" | "failed",
loginError: "" as string, loginError: "" as string,
username: "" as string,
routine: "" as string,
}; };
}, },
computed: {
...mapState(useConfigurationStore, ["clubName"]),
},
mounted() { mounted() {
resetAllPiniaStores(); resetAllPiniaStores();
this.username = localStorage.getItem("username") ?? "";
this.routine = localStorage.getItem("routine") ?? "";
if (this.username != "") {
this.$http
.post(`/auth/kickof`, {
username: this.username,
})
.then((result) => {
this.usernameStatus = "success";
this.routine = result.data.routine;
localStorage.setItem("routine", result.data.routine);
})
.catch((err) => {
this.usernameStatus = "failed";
this.loginError = err.response?.data;
});
}
}, },
methods: { methods: {
login(e: any) { resetRoutine() {
this.routine = "";
this.username = "";
localStorage.removeItem("routine");
localStorage.removeItem("username");
},
submit(e: any) {
if (this.routine == "") this.kickof(e);
else this.login(e);
},
kickof(e: any) {
let formData = e.target.elements;
let username = formData.username.value;
this.usernameStatus = "loading";
this.loginError = "";
this.$http
.post(`/auth/kickof`, {
username: username,
})
.then((result) => {
this.usernameStatus = "success";
this.routine = result.data.routine;
this.username = username;
localStorage.setItem("routine", result.data.routine);
localStorage.setItem("username", username);
})
.catch((err) => {
this.usernameStatus = "failed";
this.loginError = err.response?.data;
})
.finally(() => {
setTimeout(() => {
this.usernameStatus = undefined;
this.loginError = "";
}, 2000);
});
},
async login(e: any) {
let formData = e.target.elements; let formData = e.target.elements;
this.loginStatus = "loading"; this.loginStatus = "loading";
this.loginError = ""; this.loginError = "";
let secret = "";
if (this.routine == "totp") {
secret = formData.totp.value;
} else {
secret = await hashString(formData.password.value);
}
this.$http this.$http
.post(`/auth/login`, { .post(`/auth/login`, {
username: formData.username.value, username: formData.username.value,
totp: formData.totp.value, secret: secret,
}) })
.then((result) => { .then((result) => {
this.loginStatus = "success"; this.loginStatus = "success";

View file

@ -6,29 +6,41 @@
</div> </div>
</template> </template>
<template #diffMain> <template #diffMain>
<div class="flex flex-col w-full h-full gap-2 justify-between px-7 overflow-hidden"> <Spinner v-if="loading" class="mx-auto" />
<div class="flex flex-col gap-2"> <div v-else class="flex flex-col w-full h-full gap-2 px-7 overflow-hidden">
<img :src="image" alt="totp" class="w-56 h-56 self-center" /> <div class="w-full flex flex-row gap-2 justify-center">
<p
<TextCopy :copyText="otp" /> class="w-1/2 p-0.5 pl-0 rounded-lg py-2.5 text-sm text-center font-medium leading-5 outline-hidden cursor-pointer"
:class="
tab == 'totp' ? 'bg-red-200 shadow-sm border-b-2 border-primary rounded-b-none' : ' hover:bg-red-200'
"
@click="tab = 'totp'"
>
TOTP
</p>
<p
class="w-1/2 p-0.5 rounded-lg py-2.5 text-sm text-center font-medium leading-5 outline-hidden cursor-pointer"
:class="
tab == 'password' ? 'bg-red-200 shadow-sm border-b-2 border-primary rounded-b-none' : 'hover:bg-red-200'
"
@click="tab = 'password'"
>
Passwort
</p>
</div> </div>
<form class="flex flex-col gap-2" @submit.prevent="verify"> <ChangeToTOTP
<div class="-space-y-px"> v-if="currentRoutine == 'password' && tab == 'totp'"
<div> :currentRoutine="currentRoutine"
<input id="totp" name="totp" type="text" required placeholder="TOTP prüfen" /> @updateCurrent="currentRoutine = 'totp'"
</div> />
</div> <ChangeToPassword
v-else-if="currentRoutine == 'totp' && tab == 'password'"
<div class="flex flex-row gap-2"> :currentRoutine="currentRoutine"
<button type="submit" primary :disabled="verifyStatus == 'loading' || verifyStatus == 'success'"> @updateCurrent="currentRoutine = 'password'"
TOTP prüfen />
</button> <TotpCheckAndScan v-else-if="tab == 'totp'" />
<Spinner v-if="verifyStatus == 'loading'" class="my-auto" /> <PasswordChange v-else-if="tab == 'password'" />
<SuccessCheckmark v-else-if="verifyStatus == 'success'" /> <p v-else>etwas ist schief gelaufen</p>
<FailureXMark v-else-if="verifyStatus == 'failed'" />
</div>
<p v-if="verifyError" class="text-center">{{ verifyError }}</p>
</form>
</div> </div>
</template> </template>
</MainTemplate> </MainTemplate>
@ -42,53 +54,34 @@ 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 TextCopy from "@/components/TextCopy.vue"; import TextCopy from "@/components/TextCopy.vue";
import TotpCheckAndScan from "@/components/account/TotpCheckAndScan.vue";
import PasswordChange from "@/components/account/PasswordChange.vue";
import ChangeToPassword from "@/components/account/ChangeToPassword.vue";
import ChangeToTOTP from "@/components/account/ChangeToTOTP.vue";
</script> </script>
<script lang="ts"> <script lang="ts">
export default defineComponent({ export default defineComponent({
data() { data() {
return { return {
verification: "loading" as "success" | "loading" | "failed", loading: false,
image: undefined as undefined | string, tab: "",
otp: undefined as undefined | string, currentRoutine: "",
verifyStatus: undefined as undefined | "loading" | "success" | "failed",
verifyError: "" as string,
}; };
}, },
mounted() { mounted() {
this.loading = true;
this.$http this.$http
.get(`/user/totp`) .get(`/user/routine`)
.then((result) => { .then((result) => {
this.verification = "success"; this.tab = result.data.routine;
this.image = result.data.dataUrl; this.currentRoutine = result.data.routine;
this.otp = result.data.otp;
}) })
.catch((err) => { .catch((err) => {})
this.verification = "failed"; .finally(() => {
this.loading = false;
}); });
}, },
methods: { 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> </script>

View file

@ -1,11 +1,7 @@
<template> <template>
<SidebarLayout> <SidebarLayout>
<template #sidebar> <template #sidebar>
<SidebarTemplate <SidebarTemplate mainTitle="Mein Account" :topTitle="clubName" :showTopList="isOwner">
mainTitle="Mein Account"
:topTitle="config.app_name_overwrite || 'FF Admin'"
:showTopList="isOwner"
>
<template v-if="isOwner" #topList> <template v-if="isOwner" #topList>
<RoutingLink <RoutingLink
title="Administration" title="Administration"
@ -42,13 +38,14 @@ import SidebarTemplate from "@/templates/Sidebar.vue";
import RoutingLink from "@/components/admin/RoutingLink.vue"; import RoutingLink from "@/components/admin/RoutingLink.vue";
import { RouterView } from "vue-router"; import { RouterView } from "vue-router";
import { useAbilityStore } from "@/stores/ability"; import { useAbilityStore } from "@/stores/ability";
import { config } from "@/config"; import { useConfigurationStore } from "@/stores/configuration";
</script> </script>
<script lang="ts"> <script lang="ts">
export default defineComponent({ export default defineComponent({
computed: { computed: {
...mapState(useAbilityStore, ["isOwner"]), ...mapState(useAbilityStore, ["isOwner"]),
...mapState(useConfigurationStore, ["clubName"]),
activeRouteName() { activeRouteName() {
return this.$route.name; return this.$route.name;
}, },

View file

@ -4,14 +4,17 @@
<div class="flex flex-row items-center justify-between pt-5 pb-3 px-7"> <div class="flex flex-row items-center justify-between pt-5 pb-3 px-7">
<h1 class="font-bold text-xl h-8">Kalender</h1> <h1 class="font-bold text-xl h-8">Kalender</h1>
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
<PlusIcon class="text-gray-500 h-5 w-5 cursor-pointer" @click="select" /> <PlusIcon
class="text-gray-500 h-5 w-5 cursor-pointer"
@click="select({ start: '', end: '', allDay: false })"
/>
<LinkIcon class="text-gray-500 h-5 w-5 cursor-pointer" @click="openLinkModal" /> <LinkIcon class="text-gray-500 h-5 w-5 cursor-pointer" @click="openLinkModal" />
</div> </div>
</div> </div>
</template> </template>
<template #diffMain> <template #diffMain>
<div class="flex flex-col w-full h-full gap-2 justify-between px-7 overflow-hidden"> <div class="flex flex-col w-full h-full gap-2 justify-between px-7 overflow-hidden">
<FullCalendar :options="calendarOptions" class="max-h-full h-full" /> <CustomCalendar :items="formattedItems" @date-select="select" @event-select="eventClick" />
</div> </div>
</template> </template>
</MainTemplate> </MainTemplate>
@ -22,14 +25,10 @@ import { defineComponent, markRaw, defineAsyncComponent } from "vue";
import { mapActions, mapState } from "pinia"; import { mapActions, mapState } from "pinia";
import { useModalStore } from "@/stores/modal"; import { useModalStore } from "@/stores/modal";
import MainTemplate from "@/templates/Main.vue"; import MainTemplate from "@/templates/Main.vue";
import FullCalendar from "@fullcalendar/vue3";
import deLocale from "@fullcalendar/core/locales/de";
import dayGridPlugin from "@fullcalendar/daygrid";
import timeGridPlugin from "@fullcalendar/timegrid";
import interactionPlugin from "@fullcalendar/interaction";
import { useCalendarStore } from "@/stores/admin/club/calendar"; import { useCalendarStore } from "@/stores/admin/club/calendar";
import { useAbilityStore } from "@/stores/ability"; import { useAbilityStore } from "@/stores/ability";
import { LinkIcon, PlusIcon } from "@heroicons/vue/24/outline"; import { LinkIcon, PlusIcon } from "@heroicons/vue/24/outline";
import CustomCalendar from "@/components/CustomCalendar.vue";
</script> </script>
<script lang="ts"> <script lang="ts">
@ -40,33 +39,6 @@ export default defineComponent({
computed: { computed: {
...mapState(useCalendarStore, ["formattedItems"]), ...mapState(useCalendarStore, ["formattedItems"]),
...mapState(useAbilityStore, ["can"]), ...mapState(useAbilityStore, ["can"]),
calendarOptions() {
return {
timeZone: "local",
locale: deLocale,
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
initialView: "dayGridMonth",
headerToolbar: {
left: "dayGridMonth,timeGridWeek",
center: "title",
right: "prev,today,next",
},
eventDisplay: "block",
weekends: true,
editable: true,
selectable: true,
selectMirror: false,
dayMaxEvents: true,
weekNumbers: true,
displayEventTime: true,
nowIndicator: true,
weekText: "KW",
allDaySlot: false,
events: this.formattedItems,
select: this.select,
eventClick: this.eventClick,
};
},
}, },
mounted() { mounted() {
this.fetchCalendars(); this.fetchCalendars();
@ -74,22 +46,22 @@ export default defineComponent({
methods: { methods: {
...mapActions(useModalStore, ["openModal"]), ...mapActions(useModalStore, ["openModal"]),
...mapActions(useCalendarStore, ["fetchCalendars"]), ...mapActions(useCalendarStore, ["fetchCalendars"]),
select(e: any) { select({ start, end, allDay }: { start: string; end: string; allDay: boolean }) {
if (!this.can("create", "club", "calendar")) return; if (!this.can("create", "club", "calendar")) return;
this.openModal( this.openModal(
markRaw(defineAsyncComponent(() => import("@/components/admin/club/calendar/CreateCalendarModal.vue"))), markRaw(defineAsyncComponent(() => import("@/components/admin/club/calendar/CreateCalendarModal.vue"))),
{ {
start: e?.startStr ?? new Date().toISOString(), start,
end: e?.endStr ?? new Date().toISOString(), end,
allDay: e?.allDay ?? false, allDay,
} }
); );
}, },
eventClick(e: any) { eventClick(id: string) {
if (!this.can("update", "club", "calendar")) return; if (!this.can("update", "club", "calendar")) return;
this.openModal( this.openModal(
markRaw(defineAsyncComponent(() => import("@/components/admin/club/calendar/UpdateCalendarModal.vue"))), markRaw(defineAsyncComponent(() => import("@/components/admin/club/calendar/UpdateCalendarModal.vue"))),
e.event.id id
); );
}, },
openLinkModal(e: any) { openLinkModal(e: any) {
@ -99,55 +71,4 @@ export default defineComponent({
}, },
}, },
}); });
/**
locale: deLocale,
events: this.absencesList.map((x) => ({
id: x.absenceId,
start: x.startDate,
end: x.endDate,
allday: true,
backgroundColor: this.getColorForAbsenceType(x.absenceType),
borderColor: '#ffffff',
title: this.getAbsenceType(x.absenceType) + ' ' + x.fullName,
})),
plugins: [
interactionPlugin,
dayGridPlugin,
timeGridPlugin,
listPlugin,
multiMonthPlugin,
],
initialView: 'dayGridMonth',
eventDisplay: 'block',
weekends: false,
editable: true,
selectable: true,
selectMirror: true,
dayMaxEvents: true,
weekNumbers: true,
displayEventTime: false,
weekText: 'KW',
validRange: { start: '2023-01-01', end: '' },
headerToolbar: {
left: 'today prev,next',
center: 'title',
right: 'listMonth,dayGridMonth,multiMonthYear,customview',
},
views: {
customview: {
type: 'multiMonth',
multiMonthMaxColumns: 1,
duration: { month: 12 },
buttonText: 'grid',
},
},
dateClick: this.handleDateSelect.bind(this),
datesSet: this.handleMonthChange.bind(this),
select: this.handleDateSelect.bind(this),
eventClick: this.handleEventClick.bind(this),
eventsSet: this.handleEvents.bind(this),
};
*/
</script> </script>

View file

@ -103,7 +103,7 @@ import { Listbox, ListboxButton, ListboxOptions, ListboxOption, ListboxLabel } f
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid"; import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import cloneDeep from "lodash.clonedeep"; import cloneDeep from "lodash.clonedeep";
import isEqual from "lodash.isequal"; import isEqual from "lodash.isequal";
import { useSalutationStore } from "../../../../stores/admin/configuration/salutation"; import { useSalutationStore } from "@/stores/admin/configuration/salutation";
</script> </script>
<script lang="ts"> <script lang="ts">

View file

@ -38,7 +38,7 @@ import Pagination from "@/components/Pagination.vue";
import { useAbilityStore } from "@/stores/ability"; import { useAbilityStore } from "@/stores/ability";
import { useNewsletterStore } from "@/stores/admin/club/newsletter/newsletter"; import { useNewsletterStore } from "@/stores/admin/club/newsletter/newsletter";
import type { NewsletterViewModel } from "@/viewmodels/admin/club/newsletter/newsletter.models"; import type { NewsletterViewModel } from "@/viewmodels/admin/club/newsletter/newsletter.models";
import NewsletterListItem from "../../../../components/admin/club/newsletter/NewsletterListItem.vue"; import NewsletterListItem from "@/components/admin/club/newsletter/NewsletterListItem.vue";
</script> </script>
<script lang="ts"> <script lang="ts">

View file

@ -81,7 +81,7 @@ import FailureXMark from "@/components/FailureXMark.vue";
import { ArrowDownTrayIcon, ViewfinderCircleIcon } from "@heroicons/vue/24/outline"; import { ArrowDownTrayIcon, ViewfinderCircleIcon } from "@heroicons/vue/24/outline";
import { useModalStore } from "@/stores/modal"; import { useModalStore } from "@/stores/modal";
import { useAbilityStore } from "@/stores/ability"; import { useAbilityStore } from "@/stores/ability";
import { useNewsletterPrintoutStore } from "../../../../stores/admin/club/newsletter/newsletterPrintout"; import { useNewsletterPrintoutStore } from "@/stores/admin/club/newsletter/newsletterPrintout";
</script> </script>
<script lang="ts"> <script lang="ts">

View file

@ -4,52 +4,56 @@
<p v-else-if="loading == 'failed'" @click="fetchNewsletterRecipients" class="cursor-pointer"> <p v-else-if="loading == 'failed'" @click="fetchNewsletterRecipients" class="cursor-pointer">
&#8634; laden fehlgeschlagen &#8634; laden fehlgeschlagen
</p> </p>
<div class="flex flex-col gap-2 h-1/2">
<div v-if="!showMemberSelect" class="flex flex-row gap-2 items-center">
<select v-model="recipientsByQueryId"> <select v-model="recipientsByQueryId">
<option value="def">Optional</option> <option value="def">Optional</option>
<option v-for="query in queries" :key="query.id" :value="query.id">{{ query.title }}</option> <option v-for="query in queries" :key="query.id" :value="query.id">{{ query.title }}</option>
</select> </select>
<p>Empfänger durch gespeicherte Abfrage</p> <div title="Empfänger manuell hinzufügen" @click="showMemberSelect = true">
<div class="flex flex-col gap-2 grow overflow-y-auto"> <UserPlusIcon class="w-7 h-7 cursor-pointer" />
<div
v-for="member in queried"
:key="member.id"
class="flex flex-row h-fit w-full border border-primary rounded-md bg-primary p-2 text-white justify-between items-center"
>
<div>
<p>{{ member.lastname }}, {{ member.firstname }} {{ member.nameaffix ? `- ${member.nameaffix}` : "" }}</p>
<p>Newsletter senden an Typ: {{ member.sendNewsletter?.type.type }}</p>
</div>
</div>
</div> </div>
</div> </div>
<div class="flex flex-col gap-2 h-1/2"> <div v-else class="flex flex-row gap-2 items-center">
<MemberSearchSelectMultiple <MemberSearchSelectMultiple
title="weitere Empfänger suchen" title="weitere Empfänger suchen"
showTitleAsPlaceholder
v-model="recipients" v-model="recipients"
:disabled="!can('create', 'club', 'newsletter')" :disabled="!can('create', 'club', 'newsletter')"
/> />
<div title="Empfänger über Query hinzufügen" @click="showMemberSelect = false">
<p>Ausgewählte Empfänger</p> <ArchiveBoxIcon class="w-7 h-7 cursor-pointer" />
<div class="flex flex-col gap-2 grow overflow-y-auto">
<div
v-for="member in selected"
:key="member.id"
class="flex flex-row h-fit w-full border border-primary rounded-md bg-primary p-2 text-white justify-between items-center"
>
<div>
<p>{{ member.lastname }}, {{ member.firstname }} {{ member.nameaffix ? `- ${member.nameaffix}` : "" }}</p>
<p>Newsletter senden an Typ: {{ member.sendNewsletter?.type.type }}</p>
</div>
<TrashIcon
v-if="can('create', 'club', 'newsletter')"
class="w-5 h-5 p-1 box-content cursor-pointer"
@click="removeSelected(member.id)"
/>
</div>
</div> </div>
</div> </div>
<p v-if="!showMemberSelect">Empfänger durch gespeicherte Abfrage</p>
<p v-else>Ausgewählte Empfänger</p>
<div class="flex flex-col gap-2 grow overflow-y-auto">
<div
v-for="member in showRecipientsByMode"
:key="member.id"
class="flex flex-row gap-2 h-fit w-full border border-primary rounded-md bg-primary p-2 text-white items-center"
>
<ExclamationTriangleIcon v-if="member.sendNewsletter == null" class="w-7 h-7" />
<div class="grow">
<p>{{ member.lastname }}, {{ member.firstname }} {{ member.nameaffix ? `- ${member.nameaffix}` : "" }}</p>
<p>Newsletter senden an Typ: {{ member.sendNewsletter?.type.type ?? "---" }}</p>
</div>
<TrashIcon
v-if="can('create', 'club', 'newsletter') && showMemberSelect"
class="w-5 h-5 p-1 box-content cursor-pointer"
@click="removeSelected(member.id)"
/>
</div>
</div>
<div v-if="countOfNoConfig != 0" class="flex flex-row items-center gap-2 pt-3">
<ExclamationTriangleIcon class="text-red-500 w-5 h-5" />
<p>{{ countOfNoConfig }} Mitglieder der Auswahl haben keinen Newsletter-Versand konfiguriert!</p>
</div>
</div> </div>
</template> </template>
@ -67,7 +71,7 @@ import {
TransitionRoot, TransitionRoot,
} from "@headlessui/vue"; } from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid"; import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { TrashIcon } from "@heroicons/vue/24/outline"; import { ArchiveBoxIcon, ExclamationTriangleIcon, TrashIcon, UserPlusIcon } from "@heroicons/vue/24/outline";
import { useMemberStore } from "@/stores/admin/club/member/member"; import { useMemberStore } from "@/stores/admin/club/member/member";
import type { MemberViewModel } from "@/viewmodels/admin/club/member/member.models"; import type { MemberViewModel } from "@/viewmodels/admin/club/member/member.models";
import { useNewsletterStore } from "@/stores/admin/club/newsletter/newsletter"; import { useNewsletterStore } from "@/stores/admin/club/newsletter/newsletter";
@ -77,6 +81,8 @@ import { useQueryStoreStore } from "@/stores/admin/configuration/queryStore";
import { useQueryBuilderStore } from "@/stores/admin/club/queryBuilder"; import { useQueryBuilderStore } from "@/stores/admin/club/queryBuilder";
import cloneDeep from "lodash.clonedeep"; import cloneDeep from "lodash.clonedeep";
import MemberSearchSelectMultiple from "@/components/search/MemberSearchSelectMultiple.vue"; import MemberSearchSelectMultiple from "@/components/search/MemberSearchSelectMultiple.vue";
import MemberSearchSelect from "@/components/search/MemberSearchSelect.vue";
import type { FieldType } from "@/types/dynamicQueries";
</script> </script>
<script lang="ts"> <script lang="ts">
@ -84,22 +90,17 @@ export default defineComponent({
props: { props: {
newsletterId: String, newsletterId: String,
}, },
watch: {
recipientsByQuery() {
this.loadQuery();
},
},
data() { data() {
return { return {
query: "" as String, queryResult: [] as Array<{ id: FieldType; [key: string]: FieldType }>,
members: [] as Array<MemberViewModel>, members: [] as Array<MemberViewModel>,
showMemberSelect: false as boolean,
}; };
}, },
computed: { computed: {
...mapWritableState(useNewsletterRecipientsStore, ["recipients", "loading"]), ...mapWritableState(useNewsletterRecipientsStore, ["recipients", "loading"]),
...mapWritableState(useNewsletterStore, ["activeNewsletterObj"]), ...mapWritableState(useNewsletterStore, ["activeNewsletterObj"]),
...mapState(useQueryStoreStore, ["queries"]), ...mapState(useQueryStoreStore, ["queries"]),
...mapState(useQueryBuilderStore, ["data"]),
...mapState(useAbilityStore, ["can"]), ...mapState(useAbilityStore, ["can"]),
selected(): Array<MemberViewModel> { selected(): Array<MemberViewModel> {
return this.members return this.members
@ -114,10 +115,10 @@ export default defineComponent({
}, },
queried(): Array<MemberViewModel> { queried(): Array<MemberViewModel> {
if (this.recipientsByQueryId == "def") return []; if (this.recipientsByQueryId == "def") return [];
let keys = Object.keys(this.data?.[0] ?? {}); let keys = Object.keys(this.queryResult?.[0] ?? {});
let memberKey = keys.find((k) => k.includes("member_id")); let memberKey = keys.find((k) => k.includes("member_id"));
return this.members.filter((m) => return this.members.filter((m) =>
this.data this.queryResult
.map((t) => ({ .map((t) => ({
id: t.id, id: t.id,
...(memberKey ? { memberId: t[memberKey] } : {}), ...(memberKey ? { memberId: t[memberKey] } : {}),
@ -125,6 +126,17 @@ export default defineComponent({
.some((d) => (d.memberId ?? d.id) == m.id) .some((d) => (d.memberId ?? d.id) == m.id)
); );
}, },
showRecipientsByMode() {
return (this.showMemberSelect ? this.selected : this.queried).sort((a, b) => {
const aHasConfig = a.sendNewsletter != null;
const bHasConfig = b.sendNewsletter != null;
if (aHasConfig === bHasConfig) return 0;
return aHasConfig ? -1 : 1;
});
},
countOfNoConfig() {
return this.showRecipientsByMode.filter((member) => member.sendNewsletter == null).length;
},
recipientsByQueryId: { recipientsByQueryId: {
get() { get() {
return this.activeNewsletterObj?.recipientsByQueryId ?? "def"; return this.activeNewsletterObj?.recipientsByQueryId ?? "def";
@ -133,17 +145,12 @@ export default defineComponent({
if (this.activeNewsletterObj == undefined) return; if (this.activeNewsletterObj == undefined) return;
if (val == "def") { if (val == "def") {
this.activeNewsletterObj.recipientsByQueryId = null; this.activeNewsletterObj.recipientsByQueryId = null;
this.activeNewsletterObj.recipientsByQuery = null;
} else if (this.queries.find((q) => q.id == val)) { } else if (this.queries.find((q) => q.id == val)) {
this.activeNewsletterObj.recipientsByQueryId = val; this.activeNewsletterObj.recipientsByQueryId = val;
this.activeNewsletterObj.recipientsByQuery = cloneDeep(this.queries.find((q) => q.id == val)); this.loadQuery();
this.sendQuery(0, 0, this.recipientsByQuery?.query, true);
} }
}, },
}, },
recipientsByQuery() {
return this.activeNewsletterObj?.recipientsByQuery;
},
}, },
mounted() { mounted() {
// this.fetchNewsletterRecipients(); // this.fetchNewsletterRecipients();
@ -155,7 +162,7 @@ export default defineComponent({
...mapActions(useMemberStore, ["getAllMembers"]), ...mapActions(useMemberStore, ["getAllMembers"]),
...mapActions(useNewsletterRecipientsStore, ["fetchNewsletterRecipients"]), ...mapActions(useNewsletterRecipientsStore, ["fetchNewsletterRecipients"]),
...mapActions(useQueryStoreStore, ["fetchQueries"]), ...mapActions(useQueryStoreStore, ["fetchQueries"]),
...mapActions(useQueryBuilderStore, ["sendQuery"]), ...mapActions(useQueryBuilderStore, ["sendQueryByStoreId"]),
removeSelected(id: string) { removeSelected(id: string) {
let index = this.recipients.findIndex((s) => s == id); let index = this.recipients.findIndex((s) => s == id);
if (index != -1) { if (index != -1) {
@ -170,8 +177,12 @@ export default defineComponent({
.catch(() => {}); .catch(() => {});
}, },
loadQuery() { loadQuery() {
if (this.recipientsByQuery) { if (this.recipientsByQueryId != "def") {
this.sendQuery(0, 0, this.recipientsByQuery.query, true); this.sendQueryByStoreId(this.recipientsByQueryId, 0, 0, true)
.then((result) => {
this.queryResult = result.data.rows;
})
.catch(() => {});
} }
}, },
}, },

View file

@ -53,8 +53,8 @@ import { useNewsletterStore } from "@/stores/admin/club/newsletter/newsletter";
import { useModalStore } from "@/stores/modal"; import { useModalStore } from "@/stores/modal";
import NewsletterSyncing from "@/components/admin/club/newsletter/NewsletterSyncing.vue"; import NewsletterSyncing from "@/components/admin/club/newsletter/NewsletterSyncing.vue";
import { PrinterIcon } from "@heroicons/vue/24/outline"; import { PrinterIcon } from "@heroicons/vue/24/outline";
import { useNewsletterDatesStore } from "../../../../stores/admin/club/newsletter/newsletterDates"; import { useNewsletterDatesStore } from "@/stores/admin/club/newsletter/newsletterDates";
import { useNewsletterRecipientsStore } from "../../../../stores/admin/club/newsletter/newsletterRecipients"; import { useNewsletterRecipientsStore } from "@/stores/admin/club/newsletter/newsletterRecipients";
</script> </script>
<script lang="ts"> <script lang="ts">

View file

@ -70,14 +70,11 @@ import { useQueryStoreStore } from "@/stores/admin/configuration/queryStore";
<script lang="ts"> <script lang="ts">
export default defineComponent({ export default defineComponent({
computed: { computed: {
...mapState(useQueryBuilderStore, ["loading", "loadingData", "tableMetas", "data", "totalLength", "queryError"]), ...mapState(useQueryBuilderStore, ["loading", "loadingData", "data", "totalLength", "queryError"]),
...mapWritableState(useQueryBuilderStore, ["query"]), ...mapWritableState(useQueryBuilderStore, ["query"]),
}, },
mounted() {
this.fetchTableMetas();
},
methods: { methods: {
...mapActions(useQueryBuilderStore, ["fetchTableMetas", "sendQuery", "clearResults", "exportData"]), ...mapActions(useQueryBuilderStore, ["sendQuery", "clearResults", "exportData"]),
...mapActions(useQueryStoreStore, ["triggerSave"]), ...mapActions(useQueryStoreStore, ["triggerSave"]),
}, },
}); });

View file

@ -0,0 +1,46 @@
<template>
<MainTemplate>
<template #topBar>
<div class="flex flex-row items-center justify-between pt-5 pb-3 px-7">
<h1 class="font-bold text-xl h-8">Einstellungen</h1>
</div>
</template>
<template #main>
<p>Hinweis: Optionale Felder können leer gelassen werden und nutzen dann einen Fallback-Werte.</p>
<ClubImageSetting />
<ClubSetting />
<AppSetting />
<MailSetting />
<SessionSetting />
<BackupSetting />
</template>
</MainTemplate>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapState, mapActions } from "pinia";
import MainTemplate from "@/templates/Main.vue";
import { useAbilityStore } from "@/stores/ability";
import { useSettingStore } from "@/stores/admin/management/setting";
import ClubSetting from "@/components/admin/management/setting/ClubSetting.vue";
import AppSetting from "@/components/admin/management/setting/AppSetting.vue";
import MailSetting from "@/components/admin/management/setting/MailSetting.vue";
import SessionSetting from "@/components/admin/management/setting/SessionSetting.vue";
import BackupSetting from "@/components/admin/management/setting/BackupSetting.vue";
import ClubImageSetting from "@/components/admin/management/setting/ClubImageSetting.vue";
</script>
<script lang="ts">
export default defineComponent({
computed: {
...mapState(useAbilityStore, ["can"]),
},
mounted() {
this.fetchSettings();
},
methods: {
...mapActions(useSettingStore, ["fetchSettings"]),
},
});
</script>

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