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:
commit
bdc139f37f
107 changed files with 4984 additions and 1742 deletions
|
@ -1,5 +1 @@
|
|||
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
|
||||
VITE_SERVER_ADDRESS = backend_url #ohne pfad
|
|
@ -1,5 +1 @@
|
|||
VITE_SERVER_ADDRESS = __SERVERADDRESS__
|
||||
VITE_APP_NAME_OVERWRITE = __APPNAMEOVERWRITE__
|
||||
VITE_IMPRINT_LINK = __IMPRINTLINK__
|
||||
VITE_PRIVACY_LINK = __PRIVACYLINK__
|
||||
VITE_CUSTOM_LOGIN_MESSAGE = __CUSTOMLOGINMESSAGE__
|
||||
VITE_SERVER_ADDRESS = __SERVERADDRESS__
|
17
README.md
17
README.md
|
@ -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).
|
||||
|
||||
Für die Verwendung muss ein TOTP-Code eingegeben werden.
|
||||
Die Zugangsdaten (Lesebeschränkt) sind unterhalb dem Login angegeben.
|
||||
|
||||
Die Zugangsdaten (Lesebeschränkt) sind:\
|
||||
EMAIL: demo-besucher\
|
||||
TOTP: \
|
||||
TOTP-Code: FBMDAJKFOYQXM2DNH47GWWBGJ5KWOUCW
|
||||
Das Handbuch zur Anwendung finden sie unter [https://ff-admin.de/ff-admin-handbook](https://ff-admin.de/ff-admin-handbook).
|
||||
|
||||
## Installation
|
||||
|
||||
|
@ -29,17 +26,9 @@ services:
|
|||
image: docker.registry.jk-effects.cloud/ehrenamt/ff-admin/app:latest
|
||||
container_name: ff_admin
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
#environment:
|
||||
# - 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.
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 1.9 KiB |
|
@ -1,7 +1,7 @@
|
|||
#!/bin/sh
|
||||
|
||||
keys="SERVERADDRESS APPNAMEOVERWRITE IMPRINTLINK PRIVACYLINK CUSTOMLOGINMESSAGE"
|
||||
files="/usr/share/nginx/html/assets/config-*.js /usr/share/nginx/html/manifest.webmanifest"
|
||||
keys="SERVERADDRESS"
|
||||
files="/usr/share/nginx/html/assets/config-*.js"
|
||||
|
||||
# Replace env vars in files served by NGINX
|
||||
for file in $files
|
||||
|
@ -12,11 +12,6 @@ do
|
|||
# Get environment variable
|
||||
value=$(eval echo "\$$key")
|
||||
|
||||
# Set default value for APPNAMEOVERWRITE if empty
|
||||
if [ "$key" = "APPNAMEOVERWRITE" ] && [ -z "$value" ]; then
|
||||
value="FF Admin"
|
||||
fi
|
||||
|
||||
echo "replace $key by $value"
|
||||
|
||||
# replace __[variable_name]__ value with environment variable
|
||||
|
|
|
@ -2,11 +2,14 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<!-- icon and manifest are provided by App.vue -->
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<script>
|
||||
// screen.orientation.lock("portrait-primary").catch(() => {});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
2930
package-lock.json
generated
2930
package-lock.json
generated
File diff suppressed because it is too large
Load diff
64
package.json
64
package.json
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "ff-admin",
|
||||
"version": "1.3.5",
|
||||
"version": "1.5.0",
|
||||
"description": "Feuerwehr/Verein Mitgliederverwaltung UI",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
@ -24,18 +24,19 @@
|
|||
"author": "JK Effects",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@fullcalendar/core": "^6.1.15",
|
||||
"@fullcalendar/daygrid": "^6.1.15",
|
||||
"@fullcalendar/interaction": "^6.1.15",
|
||||
"@fullcalendar/timegrid": "^6.1.15",
|
||||
"@fullcalendar/vue3": "^6.1.15",
|
||||
"@fullcalendar/core": "^6.1.17",
|
||||
"@fullcalendar/daygrid": "^6.1.17",
|
||||
"@fullcalendar/interaction": "^6.1.17",
|
||||
"@fullcalendar/list": "^6.1.17",
|
||||
"@fullcalendar/timegrid": "^6.1.17",
|
||||
"@fullcalendar/vue3": "^6.1.17",
|
||||
"@headlessui/vue": "^1.7.23",
|
||||
"@heroicons/vue": "^2.1.5",
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@heroicons/vue": "^2.2.0",
|
||||
"@tailwindcss/vite": "^4.1.5",
|
||||
"@vueup/vue-quill": "^1.2.0",
|
||||
"axios": "^1.7.9",
|
||||
"axios": "^1.9.0",
|
||||
"event-source-polyfill": "^1.0.31",
|
||||
"grapesjs": "^0.22.4",
|
||||
"grapesjs": "^0.22.7",
|
||||
"grapesjs-preset-newsletter": "^1.0.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
|
@ -49,45 +50,46 @@
|
|||
"nprogress": "^0.2.0",
|
||||
"pdf-dist": "^1.0.0",
|
||||
"pinia": "^3.0.2",
|
||||
"qrcode": "^1.5.3",
|
||||
"qs": "^6.11.2",
|
||||
"socket.io-client": "^4.5.0",
|
||||
"pwacompat": "^2.0.17",
|
||||
"qrcode": "^1.5.4",
|
||||
"qs": "^6.14.0",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"unplugin-vue-markdown": "^28.3.1",
|
||||
"uuid": "^11.1.0",
|
||||
"vue": "^3.4.29",
|
||||
"vue": "^3.5.13",
|
||||
"vue-qrcode-reader": "^5.7.1",
|
||||
"vue-router": "^4.3.3"
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rushstack/eslint-patch": "^1.8.0",
|
||||
"@tailwindcss/postcss": "^4.1.3",
|
||||
"@tsconfig/node20": "^20.1.4",
|
||||
"@types/eslint": "~9.6.0",
|
||||
"@rushstack/eslint-patch": "^1.11.0",
|
||||
"@tailwindcss/postcss": "^4.1.5",
|
||||
"@tsconfig/node20": "^20.1.5",
|
||||
"@types/eslint": "~9.6.1",
|
||||
"@types/event-source-polyfill": "^1.0.5",
|
||||
"@types/lodash.clonedeep": "^4.5.9",
|
||||
"@types/lodash.difference": "^4.5.9",
|
||||
"@types/lodash.differencewith": "^4.5.9",
|
||||
"@types/lodash.isequal": "^4.5.8",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/nprogress": "^0.2.0",
|
||||
"@types/node": "^22.15.12",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/qs": "^6.9.11",
|
||||
"@types/qs": "^6.9.18",
|
||||
"@types/uuid": "^10.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-typescript": "^14.5.0",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint-plugin-vue": "^10.0.0",
|
||||
"npm-run-all2": "^7.0.2",
|
||||
"prettier": "^3.2.5",
|
||||
"tailwindcss": "^4.1.3",
|
||||
"eslint": "^9.26.0",
|
||||
"eslint-plugin-vue": "^10.1.0",
|
||||
"npm-run-all2": "^8.0.1",
|
||||
"prettier": "^3.5.3",
|
||||
"tailwindcss": "^4.1.5",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.2.6",
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-pwa": "^1.0.0",
|
||||
"vite-plugin-vue-devtools": "^7.6.8",
|
||||
"vue-tsc": "^2.0.21"
|
||||
"vite-plugin-vue-devtools": "^7.7.6",
|
||||
"vue-tsc": "^2.2.10"
|
||||
}
|
||||
}
|
||||
|
|
BIN
public/Logo.png
BIN
public/Logo.png
Binary file not shown.
Before Width: | Height: | Size: 34 KiB |
Binary file not shown.
Before Width: | Height: | Size: 9.4 KiB |
Binary file not shown.
Before Width: | Height: | Size: 29 KiB |
16
src/App.vue
16
src/App.vue
|
@ -8,6 +8,12 @@
|
|||
</div>
|
||||
<Footer @contextmenu.prevent />
|
||||
<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>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
@ -15,20 +21,25 @@ import { defineComponent } from "vue";
|
|||
import { RouterView } from "vue-router";
|
||||
import Header from "./components/Header.vue";
|
||||
import Footer from "./components/Footer.vue";
|
||||
import { mapState } from "pinia";
|
||||
import { mapActions, mapState } from "pinia";
|
||||
import { useAuthStore } from "./stores/auth";
|
||||
import { isAuthenticatedPromise } from "./router/authGuard";
|
||||
import ContextMenu from "./components/ContextMenu.vue";
|
||||
import Modal from "./components/Modal.vue";
|
||||
import Notification from "./components/Notification.vue";
|
||||
import { config } from "./config";
|
||||
import { useConfigurationStore } from "@/stores/configuration";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default defineComponent({
|
||||
computed: {
|
||||
...mapState(useAuthStore, ["authCheck"]),
|
||||
...mapState(useConfigurationStore, ["clubName"]),
|
||||
},
|
||||
mounted() {
|
||||
this.configure();
|
||||
|
||||
if (!this.authCheck && localStorage.getItem("access_token")) {
|
||||
isAuthenticatedPromise().catch(() => {
|
||||
localStorage.removeItem("access_token");
|
||||
|
@ -36,5 +47,8 @@ export default defineComponent({
|
|||
});
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useConfigurationStore, ["configure"]),
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
26
src/components/AppIcon.vue
Normal file
26
src/components/AppIcon.vue
Normal file
|
@ -0,0 +1,26 @@
|
|||
<template>
|
||||
<img ref="icon" :src="url + '/api/public/icon.png'" alt="LOGO" class="h-full w-auto" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { url } from "@/serverCom";
|
||||
import { useSettingStore } from "@/stores/admin/management/setting";
|
||||
import { mapState } from "pinia";
|
||||
import { defineComponent } from "vue";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default defineComponent({
|
||||
watch: {
|
||||
icon() {
|
||||
(this.$refs.icon as HTMLImageElement).src = url + "/api/public/icon.png?" + new Date().getTime();
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapState(useSettingStore, ["readSetting"]),
|
||||
icon() {
|
||||
return this.readSetting("club.icon");
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
26
src/components/AppLogo.vue
Normal file
26
src/components/AppLogo.vue
Normal file
|
@ -0,0 +1,26 @@
|
|||
<template>
|
||||
<img ref="logo" :src="url + '/api/public/applogo.png'" alt="LOGO" class="h-full w-auto" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { url } from "@/serverCom";
|
||||
import { useSettingStore } from "@/stores/admin/management/setting";
|
||||
import { mapState } from "pinia";
|
||||
import { defineComponent } from "vue";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default defineComponent({
|
||||
watch: {
|
||||
logo() {
|
||||
(this.$refs.logo as HTMLImageElement).src = url + "/api/public/applogo.png?t=" + new Date().getTime();
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapState(useSettingStore, ["readSetting"]),
|
||||
logo() {
|
||||
return this.readSetting("club.logo");
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
53
src/components/CheckProgressBar.vue
Normal file
53
src/components/CheckProgressBar.vue
Normal file
|
@ -0,0 +1,53 @@
|
|||
<template>
|
||||
<div class="w-full flex flex-row items-center">
|
||||
<div class="contents" v-for="(i, index) in total" :key="index">
|
||||
<div
|
||||
v-if="index <= successfull && index != step"
|
||||
class="relative flex items-center justify-center h-8 w-8 border-4 border-success rounded-full"
|
||||
>
|
||||
<SuccessCheckmark class="h-8! asolute top-0 m-0!" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="index <= step"
|
||||
class="flex items-center justify-center h-8 w-8 border-4 border-success rounded-full"
|
||||
>
|
||||
<div class="h-2 w-2 border-4 border-success bg-success rounded-full"></div>
|
||||
</div>
|
||||
<div v-else class="h-8 w-8 border-4 border-gray-400 rounded-full"></div>
|
||||
<div v-if="i != total" class="grow border-2" :class="index < step ? ' border-success' : 'border-gray-400'"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import SuccessCheckmark from "./SuccessCheckmark.vue";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default defineComponent({
|
||||
props: {
|
||||
step: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
validator(value: number) {
|
||||
return value >= 0;
|
||||
},
|
||||
},
|
||||
successfull: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
validator(value: number) {
|
||||
return value >= 0;
|
||||
},
|
||||
},
|
||||
total: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
validator(value: number) {
|
||||
return value >= 1;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
181
src/components/CustomCalendar.vue
Normal file
181
src/components/CustomCalendar.vue
Normal file
|
@ -0,0 +1,181 @@
|
|||
<template>
|
||||
<div class="flex flex-col w-full h-full gap-2 justify-between overflow-hidden">
|
||||
<div
|
||||
class="flex flex-row gap-2 justify-between max-sm:justify-center"
|
||||
:class="smallStyling ? 'max-lg:flex-wrap' : 'max-xl:flex-wrap'"
|
||||
>
|
||||
<div class="flex flex-row" :class="smallStyling ? 'max-lg:order-2' : 'max-xl:order-2'">
|
||||
<button
|
||||
:primary="view == 'dayGridMonth'"
|
||||
:primary-outline="view != 'dayGridMonth'"
|
||||
class="rounded-r-none!"
|
||||
@click="setView('dayGridMonth')"
|
||||
>
|
||||
Monat
|
||||
</button>
|
||||
<button
|
||||
:primary="view == 'timeGridWeek'"
|
||||
:primary-outline="view != 'timeGridWeek'"
|
||||
class="rounded-none! border-x-0!"
|
||||
@click="setView('timeGridWeek')"
|
||||
>
|
||||
Woche
|
||||
</button>
|
||||
<button
|
||||
:primary="view == 'listMonth'"
|
||||
:primary-outline="view != 'listMonth'"
|
||||
class="rounded-l-none!"
|
||||
@click="setView('listMonth')"
|
||||
>
|
||||
Liste
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-3xl w-full text-center" :class="smallStyling ? 'max-lg:order-1' : 'max-xl:order-1'">
|
||||
{{ currentTitle }}
|
||||
</p>
|
||||
<div class="flex flex-row" :class="smallStyling ? 'max-lg:order-3' : 'max-xl:order-3'">
|
||||
<button primary-outline class="rounded-r-none!" @click="navigateView('prev')">
|
||||
<ChevronLeftIcon />
|
||||
</button>
|
||||
<button
|
||||
:primary="containsToday"
|
||||
:primary-outline="!containsToday"
|
||||
class="rounded-none! border-x-0!"
|
||||
@click="
|
||||
calendarApi?.today();
|
||||
containsToday = true;
|
||||
"
|
||||
>
|
||||
heute
|
||||
</button>
|
||||
<button primary-outline class="rounded-l-none!" @click="navigateView('next')">
|
||||
<ChevronRightIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col w-full grow overflow-hidden">
|
||||
<FullCalendar ref="fullCalendar" :options="calendarOptions" class="max-h-full h-full" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
import FullCalendar from "@fullcalendar/vue3";
|
||||
import deLocale from "@fullcalendar/core/locales/de";
|
||||
import dayGridPlugin from "@fullcalendar/daygrid";
|
||||
import timeGridPlugin from "@fullcalendar/timegrid";
|
||||
import listPlugin from "@fullcalendar/list";
|
||||
import interactionPlugin from "@fullcalendar/interaction";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/vue/24/outline";
|
||||
import type { CalendarOptions } from "@fullcalendar/core/index.js";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default defineComponent({
|
||||
props: {
|
||||
items: {
|
||||
type: Array as PropType<
|
||||
{
|
||||
id: string;
|
||||
title: string;
|
||||
start: string;
|
||||
end: string;
|
||||
backgroundColor: string;
|
||||
}[]
|
||||
>,
|
||||
default: [],
|
||||
},
|
||||
allowInteraction: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
smallStyling: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: {
|
||||
dateSelect: ({ start, end, allDay }: { start: string; end: string; allDay: boolean }) => {
|
||||
return typeof start == "string" && typeof end == "string" && typeof allDay === "boolean";
|
||||
},
|
||||
eventSelect: (id: string) => {
|
||||
return typeof id == "string";
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
view: "dayGridMonth" as "dayGridMonth" | "timeGridWeek" | "listMonth",
|
||||
calendarApi: null as null | typeof FullCalendar,
|
||||
currentTitle: "" as string,
|
||||
containsToday: false as boolean,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
calendarOptions() {
|
||||
return {
|
||||
timeZone: "local",
|
||||
locale: deLocale,
|
||||
plugins: [dayGridPlugin, timeGridPlugin, listPlugin, interactionPlugin],
|
||||
initialView: "dayGridMonth",
|
||||
eventDisplay: "block",
|
||||
headerToolbar: false,
|
||||
weekends: true,
|
||||
editable: this.allowInteraction,
|
||||
selectable: this.allowInteraction,
|
||||
selectMirror: false,
|
||||
dayMaxEvents: true,
|
||||
weekNumbers: true,
|
||||
displayEventTime: true,
|
||||
nowIndicator: true,
|
||||
weekText: "KW",
|
||||
allDaySlot: false,
|
||||
events: this.items,
|
||||
select: this.select,
|
||||
eventClick: this.eventClick,
|
||||
} as CalendarOptions;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.calendarApi = (this.$refs.fullCalendar as typeof FullCalendar).getApi();
|
||||
this.setTitle();
|
||||
this.setContainsToday();
|
||||
},
|
||||
methods: {
|
||||
setTitle() {
|
||||
this.currentTitle = this.calendarApi?.view.title ?? "";
|
||||
},
|
||||
setView(view: "dayGridMonth" | "timeGridWeek" | "listMonth") {
|
||||
this.calendarApi?.changeView(view);
|
||||
this.view = view;
|
||||
this.setTitle();
|
||||
this.setContainsToday();
|
||||
},
|
||||
navigateView(change: "prev" | "next") {
|
||||
if (change == "prev") {
|
||||
this.calendarApi?.prev();
|
||||
} else {
|
||||
this.calendarApi?.next();
|
||||
}
|
||||
this.setTitle();
|
||||
this.setContainsToday();
|
||||
},
|
||||
setContainsToday() {
|
||||
const start = this.calendarApi?.view.currentStart;
|
||||
const end = this.calendarApi?.view.currentEnd;
|
||||
const today = new Date();
|
||||
this.containsToday = today >= start && today < end;
|
||||
},
|
||||
select(e: any) {
|
||||
this.$emit("dateSelect", {
|
||||
start: e?.startStr ?? new Date().toISOString(),
|
||||
end: e?.endStr ?? new Date().toISOString(),
|
||||
allDay: e?.allDay ?? false,
|
||||
});
|
||||
},
|
||||
eventClick(e: any) {
|
||||
this.$emit("eventSelect", e.event.id);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -1,10 +1,11 @@
|
|||
<template>
|
||||
<div class="flex flex-col text-gray-400 text-sm mt-4 items-center">
|
||||
<div class="flex flex-row gap-2 justify-center">
|
||||
<a v-if="config.imprint_link" :href="config.imprint_link" target="_blank">Datenschutz</a>
|
||||
<a v-if="config.privacy_link" :href="config.privacy_link" target="_blank">Impressum</a>
|
||||
<p v-if="appCustom_login_message">{{ appCustom_login_message }}</p>
|
||||
<div class="flex flex-row gap-2 justify-center mb-3">
|
||||
<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>
|
||||
<p v-if="config.custom_login_message">{{ config.custom_login_message }}</p>
|
||||
<p>
|
||||
<a href="https://ff-admin.de/admin" target="_blank">FF Admin</a>
|
||||
entwickelt von
|
||||
|
@ -14,5 +15,21 @@
|
|||
</template>
|
||||
|
||||
<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>
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
<template>
|
||||
<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">
|
||||
<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">
|
||||
{{ config.app_name_overwrite || "FF Admin" }}
|
||||
{{ clubName }}
|
||||
</h1>
|
||||
</RouterLink>
|
||||
<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 TopLevelLink from "./admin/TopLevelLink.vue";
|
||||
import UserMenu from "./UserMenu.vue";
|
||||
import { config } from "@/config";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import AppLogo from "./AppLogo.vue";
|
||||
import { useConfigurationStore } from "@/stores/configuration";
|
||||
export default defineComponent({
|
||||
computed: {
|
||||
...mapState(useAuthStore, ["authCheck"]),
|
||||
...mapState(useNavigationStore, ["topLevel"]),
|
||||
...mapState(useConfigurationStore, ["clubName"]),
|
||||
routeName() {
|
||||
return typeof this.$route.name == "string" ? this.$route.name : "";
|
||||
},
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
<button button primary @click="close">Mein Account</button>
|
||||
</RouterLink>
|
||||
</MenuItem>
|
||||
<MenuItem v-slot="{ close }">
|
||||
<MenuItem v-if="false" v-slot="{ close }">
|
||||
<RouterLink to="/docs" target="_blank">
|
||||
<button button primary @click="close">Dokumentation</button>
|
||||
</RouterLink>
|
||||
|
|
106
src/components/account/ChangeToPassword.vue
Normal file
106
src/components/account/ChangeToPassword.vue
Normal file
|
@ -0,0 +1,106 @@
|
|||
<template>
|
||||
<form class="flex flex-col gap-2" @submit.prevent="change">
|
||||
<div class="-space-y-px">
|
||||
<div>
|
||||
<input
|
||||
id="new"
|
||||
name="new"
|
||||
type="password"
|
||||
required
|
||||
placeholder="neues Passwort"
|
||||
autocomplete="new-password"
|
||||
class="rounded-b-none!"
|
||||
:class="notMatching ? 'border-red-600!' : ''"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
id="new_rep"
|
||||
name="new_rep"
|
||||
type="password"
|
||||
required
|
||||
placeholder="neues Passwort wiederholen"
|
||||
autocomplete="new-password"
|
||||
class="rounded-t-none!"
|
||||
:class="notMatching ? 'border-red-600!' : ''"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="notMatching">Passwörter stimmen nicht überein</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row gap-2">
|
||||
<button type="submit" primary :disabled="changeStatus == 'loading' || changeStatus == 'success'">
|
||||
zu Passwort wechseln
|
||||
</button>
|
||||
<Spinner v-if="changeStatus == 'loading'" class="my-auto" />
|
||||
<SuccessCheckmark v-else-if="changeStatus == 'success'" />
|
||||
<FailureXMark v-else-if="changeStatus == 'failed'" />
|
||||
</div>
|
||||
<p v-if="changeError" class="text-center">{{ changeError }}</p>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import { mapActions, mapState } from "pinia";
|
||||
import MainTemplate from "@/templates/Main.vue";
|
||||
import Spinner from "@/components/Spinner.vue";
|
||||
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
|
||||
import FailureXMark from "@/components/FailureXMark.vue";
|
||||
import TextCopy from "@/components/TextCopy.vue";
|
||||
import { hashString } from "@/helpers/crypto";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default defineComponent({
|
||||
props: {
|
||||
currentRoutine: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
emits: ["updateCurrent"],
|
||||
data() {
|
||||
return {
|
||||
verification: "loading" as "success" | "loading" | "failed",
|
||||
changeStatus: undefined as undefined | "loading" | "success" | "failed",
|
||||
changeError: "" as string,
|
||||
notMatching: false as boolean,
|
||||
};
|
||||
},
|
||||
mounted() {},
|
||||
methods: {
|
||||
async change(e: any) {
|
||||
let formData = e.target.elements;
|
||||
|
||||
let new_pw = await hashString(formData.new.value);
|
||||
let new_rep = await hashString(formData.new_rep.value);
|
||||
if (new_pw != new_rep) {
|
||||
this.notMatching = true;
|
||||
return;
|
||||
}
|
||||
this.notMatching = false;
|
||||
|
||||
this.changeStatus = "loading";
|
||||
this.changeError = "";
|
||||
this.$http
|
||||
.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>
|
92
src/components/account/ChangeToTOTP.vue
Normal file
92
src/components/account/ChangeToTOTP.vue
Normal file
|
@ -0,0 +1,92 @@
|
|||
<template>
|
||||
<div class="flex flex-col gap-2 grow">
|
||||
<img :src="image" alt="totp" class="w-56 h-56 self-center" />
|
||||
|
||||
<TextCopy :copyText="otp" />
|
||||
</div>
|
||||
<form class="flex flex-col gap-2" @submit.prevent="verify">
|
||||
<div class="-space-y-px">
|
||||
<div>
|
||||
<input id="totp" name="totp" type="text" required placeholder="TOTP" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row gap-2">
|
||||
<button type="submit" primary :disabled="verifyStatus == 'loading' || verifyStatus == 'success'">
|
||||
zu TOTP wechseln
|
||||
</button>
|
||||
<Spinner v-if="verifyStatus == 'loading'" class="my-auto" />
|
||||
<SuccessCheckmark v-else-if="verifyStatus == 'success'" />
|
||||
<FailureXMark v-else-if="verifyStatus == 'failed'" />
|
||||
</div>
|
||||
<p v-if="verifyError" class="text-center">{{ verifyError }}</p>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import { mapActions, mapState } from "pinia";
|
||||
import MainTemplate from "@/templates/Main.vue";
|
||||
import Spinner from "@/components/Spinner.vue";
|
||||
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
|
||||
import FailureXMark from "@/components/FailureXMark.vue";
|
||||
import TextCopy from "@/components/TextCopy.vue";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default defineComponent({
|
||||
props: {
|
||||
currentRoutine: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
emits: ["updateCurrent"],
|
||||
data() {
|
||||
return {
|
||||
verification: "loading" as "success" | "loading" | "failed",
|
||||
image: undefined as undefined | string,
|
||||
otp: undefined as undefined | string,
|
||||
verifyStatus: undefined as undefined | "loading" | "success" | "failed",
|
||||
verifyError: "" as string,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$http
|
||||
.get(`/user/changeToTOTP`)
|
||||
.then((result) => {
|
||||
this.verification = "success";
|
||||
this.image = result.data.dataUrl;
|
||||
this.otp = result.data.otp;
|
||||
})
|
||||
.catch((err) => {
|
||||
this.verification = "failed";
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
verify(e: any) {
|
||||
let formData = e.target.elements;
|
||||
this.verifyStatus = "loading";
|
||||
this.verifyError = "";
|
||||
this.$http
|
||||
.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>
|
109
src/components/account/PasswordChange.vue
Normal file
109
src/components/account/PasswordChange.vue
Normal file
|
@ -0,0 +1,109 @@
|
|||
<template>
|
||||
<form class="flex flex-col gap-2" @submit.prevent="change">
|
||||
<div class="-space-y-px">
|
||||
<div>
|
||||
<input
|
||||
id="current"
|
||||
name="current"
|
||||
type="password"
|
||||
required
|
||||
placeholder="aktuelles Passwort"
|
||||
autocomplete="current-password"
|
||||
class="rounded-b-none!"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
id="new"
|
||||
name="new"
|
||||
type="password"
|
||||
required
|
||||
placeholder="neues Passwort"
|
||||
autocomplete="new-password"
|
||||
class="rounded-none!"
|
||||
:class="notMatching ? 'border-red-600!' : ''"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
id="new_rep"
|
||||
name="new_rep"
|
||||
type="password"
|
||||
required
|
||||
placeholder="neues Passwort wiederholen"
|
||||
autocomplete="new-password"
|
||||
class="rounded-t-none!"
|
||||
:class="notMatching ? 'border-red-600!' : ''"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="notMatching">Passwörter stimmen nicht überein</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row gap-2">
|
||||
<button type="submit" primary :disabled="changeStatus == 'loading' || changeStatus == 'success'">
|
||||
Passwort ändern
|
||||
</button>
|
||||
<Spinner v-if="changeStatus == 'loading'" class="my-auto" />
|
||||
<SuccessCheckmark v-else-if="changeStatus == 'success'" />
|
||||
<FailureXMark v-else-if="changeStatus == 'failed'" />
|
||||
</div>
|
||||
<p v-if="changeError" class="text-center">{{ changeError }}</p>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import { mapActions, mapState } from "pinia";
|
||||
import MainTemplate from "@/templates/Main.vue";
|
||||
import Spinner from "@/components/Spinner.vue";
|
||||
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
|
||||
import FailureXMark from "@/components/FailureXMark.vue";
|
||||
import { hashString } from "@/helpers/crypto";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default defineComponent({
|
||||
data() {
|
||||
return {
|
||||
verification: "loading" as "success" | "loading" | "failed",
|
||||
changeStatus: undefined as undefined | "loading" | "success" | "failed",
|
||||
changeError: "" as string,
|
||||
notMatching: false as boolean,
|
||||
};
|
||||
},
|
||||
mounted() {},
|
||||
methods: {
|
||||
async change(e: any) {
|
||||
let formData = e.target.elements;
|
||||
|
||||
let new_pw = await hashString(formData.new.value);
|
||||
let new_rep = await hashString(formData.new_rep.value);
|
||||
if (new_pw != new_rep) {
|
||||
this.notMatching = true;
|
||||
return;
|
||||
}
|
||||
this.notMatching = false;
|
||||
|
||||
this.changeStatus = "loading";
|
||||
this.changeError = "";
|
||||
this.$http
|
||||
.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>
|
83
src/components/account/TotpCheckAndScan.vue
Normal file
83
src/components/account/TotpCheckAndScan.vue
Normal file
|
@ -0,0 +1,83 @@
|
|||
<template>
|
||||
<div class="flex flex-col gap-2 grow">
|
||||
<img :src="image" alt="totp" class="w-56 h-56 self-center" />
|
||||
|
||||
<TextCopy :copyText="otp" />
|
||||
</div>
|
||||
<form class="flex flex-col gap-2" @submit.prevent="verify">
|
||||
<div class="-space-y-px">
|
||||
<div>
|
||||
<input id="totp" name="totp" type="text" required placeholder="TOTP prüfen" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row gap-2">
|
||||
<button type="submit" primary :disabled="verifyStatus == 'loading' || verifyStatus == 'success'">
|
||||
TOTP prüfen
|
||||
</button>
|
||||
<Spinner v-if="verifyStatus == 'loading'" class="my-auto" />
|
||||
<SuccessCheckmark v-else-if="verifyStatus == 'success'" />
|
||||
<FailureXMark v-else-if="verifyStatus == 'failed'" />
|
||||
</div>
|
||||
<p v-if="verifyError" class="text-center">{{ verifyError }}</p>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import { mapActions, mapState } from "pinia";
|
||||
import MainTemplate from "@/templates/Main.vue";
|
||||
import Spinner from "@/components/Spinner.vue";
|
||||
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
|
||||
import FailureXMark from "@/components/FailureXMark.vue";
|
||||
import TextCopy from "@/components/TextCopy.vue";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default defineComponent({
|
||||
data() {
|
||||
return {
|
||||
verification: "loading" as "success" | "loading" | "failed",
|
||||
image: undefined as undefined | string,
|
||||
otp: undefined as undefined | string,
|
||||
verifyStatus: undefined as undefined | "loading" | "success" | "failed",
|
||||
verifyError: "" as string,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$http
|
||||
.get(`/user/totp`)
|
||||
.then((result) => {
|
||||
this.verification = "success";
|
||||
this.image = result.data.dataUrl;
|
||||
this.otp = result.data.otp;
|
||||
})
|
||||
.catch((err) => {
|
||||
this.verification = "failed";
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
verify(e: any) {
|
||||
let formData = e.target.elements;
|
||||
this.verifyStatus = "loading";
|
||||
this.verifyError = "";
|
||||
this.$http
|
||||
.post(`/user/verify`, {
|
||||
totp: formData.totp.value,
|
||||
})
|
||||
.then((result) => {
|
||||
this.verifyStatus = "success";
|
||||
})
|
||||
.catch((err) => {
|
||||
this.verifyStatus = "failed";
|
||||
this.verifyError = err.response.data;
|
||||
})
|
||||
.finally(() => {
|
||||
setTimeout(() => {
|
||||
this.verifyStatus = undefined;
|
||||
}, 2000);
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -18,22 +18,22 @@
|
|||
<div class="flex flex-row border border-white rounded-md overflow-hidden">
|
||||
<EyeIcon
|
||||
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)"
|
||||
/>
|
||||
<PlusIcon
|
||||
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)"
|
||||
/>
|
||||
<PencilIcon
|
||||
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)"
|
||||
/>
|
||||
<TrashIcon
|
||||
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)"
|
||||
/>
|
||||
</div>
|
||||
|
@ -132,7 +132,7 @@ export default defineComponent({
|
|||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState(useAbilityStore, ["_can"]),
|
||||
...mapState(useAbilityStore, ["_can", "_canSection"]),
|
||||
canSaveOrReset(): boolean {
|
||||
return isEqual(this.permissions, this.permissionUpdate);
|
||||
},
|
||||
|
|
|
@ -69,7 +69,15 @@
|
|||
<input type="date" id="birthdate" required />
|
||||
</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" />
|
||||
</div>
|
||||
<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 { useMemberStore } from "@/stores/admin/club/member/member";
|
||||
import type { CreateMemberViewModel } from "@/viewmodels/admin/club/member/member.models";
|
||||
import { useSalutationStore } from "../../../../stores/admin/configuration/salutation";
|
||||
import type { SalutationViewModel } from "../../../../viewmodels/admin/configuration/salutation.models";
|
||||
import { useSalutationStore } from "@/stores/admin/configuration/salutation";
|
||||
import type { SalutationViewModel } from "@/viewmodels/admin/configuration/salutation.models";
|
||||
import { InformationCircleIcon } from "@heroicons/vue/24/outline";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
|
@ -112,6 +121,7 @@ export default defineComponent({
|
|||
status: null as null | "loading" | { status: "success" | "failed"; reason?: string },
|
||||
timeout: undefined as any,
|
||||
selectedSalutation: null as null | SalutationViewModel,
|
||||
lastId: "" as string,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
@ -119,6 +129,11 @@ export default defineComponent({
|
|||
},
|
||||
mounted() {
|
||||
this.fetchSalutations();
|
||||
this.fetchLastInternalId()
|
||||
.then((res) => {
|
||||
this.lastId = res.data;
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
beforeUnmount() {
|
||||
try {
|
||||
|
@ -127,7 +142,7 @@ export default defineComponent({
|
|||
},
|
||||
methods: {
|
||||
...mapActions(useModalStore, ["closeModal"]),
|
||||
...mapActions(useMemberStore, ["createMember"]),
|
||||
...mapActions(useMemberStore, ["createMember", "fetchLastInternalId"]),
|
||||
...mapActions(useSalutationStore, ["fetchSalutations"]),
|
||||
triggerCreate(e: any) {
|
||||
if (!this.selectedSalutation) return;
|
||||
|
|
|
@ -36,8 +36,8 @@ import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
|
|||
import FailureXMark from "@/components/FailureXMark.vue";
|
||||
import { useProtocolStore } from "@/stores/admin/club/protocol/protocol";
|
||||
import type { CreateProtocolViewModel } from "@/viewmodels/admin/club/protocol/protocol.models";
|
||||
import { useNewsletterStore } from "../../../../stores/admin/club/newsletter/newsletter";
|
||||
import type { CreateNewsletterViewModel } from "../../../../viewmodels/admin/club/newsletter/newsletter.models";
|
||||
import { useNewsletterStore } from "@/stores/admin/club/newsletter/newsletter";
|
||||
import type { CreateNewsletterViewModel } from "@/viewmodels/admin/club/newsletter/newsletter.models";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
|
|
|
@ -3,13 +3,13 @@
|
|||
<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>
|
||||
<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" />
|
||||
</button>
|
||||
<Spinner v-else-if="status == 'loading'" class="my-auto" />
|
||||
<SuccessCheckmark v-else-if="status?.status == 'success'" />
|
||||
<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" />
|
||||
</button>
|
||||
</div>
|
||||
|
@ -36,7 +36,7 @@ import Spinner from "@/components/Spinner.vue";
|
|||
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
|
||||
import FailureXMark from "@/components/FailureXMark.vue";
|
||||
import { useModalStore } from "@/stores/modal";
|
||||
import { NewsletterConfigType } from "@/enums/newsletterConfigType";
|
||||
import { NewsletterConfigEnum } from "@/enums/newsletterConfigEnum";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import type { CommunicationTypeViewModel } from "@/viewmodels/admin/configuration/communicationType.models";
|
||||
import { useAbilityStore } from "@/stores/ability";
|
||||
|
@ -62,7 +62,7 @@ export default defineComponent({
|
|||
},
|
||||
},
|
||||
mounted() {
|
||||
this.configs = Object.values(NewsletterConfigType);
|
||||
this.configs = Object.values(NewsletterConfigEnum);
|
||||
},
|
||||
beforeUnmount() {
|
||||
try {
|
||||
|
|
|
@ -20,7 +20,7 @@ import { mapState, mapActions } from "pinia";
|
|||
import { ArchiveBoxArrowDownIcon, ArrowDownTrayIcon, BarsArrowUpIcon } from "@heroicons/vue/24/outline";
|
||||
import { useAbilityStore } from "@/stores/ability";
|
||||
import { useModalStore } from "@/stores/modal";
|
||||
import { useBackupStore } from "../../../../stores/admin/management/backup";
|
||||
import { useBackupStore } from "@/stores/admin/management/backup";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
|
|
|
@ -57,9 +57,9 @@ import Spinner from "@/components/Spinner.vue";
|
|||
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
|
||||
import FailureXMark from "@/components/FailureXMark.vue";
|
||||
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 { backupSections, type BackupSection } from "../../../../types/backupTypes";
|
||||
import { backupSections, type BackupSection } from "@/types/backupTypes";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
|
|
67
src/components/admin/management/setting/AppSetting.vue
Normal file
67
src/components/admin/management/setting/AppSetting.vue
Normal file
|
@ -0,0 +1,67 @@
|
|||
<template>
|
||||
<BaseSetting title="Anwendungs Einstellungen" :submit-function="submit" v-slot="{ enableEdit }">
|
||||
<div class="w-full">
|
||||
<label for="custom_login_message">Nachricht unter Login (optional)</label>
|
||||
<input
|
||||
id="custom_login_message"
|
||||
type="text"
|
||||
:readonly="!enableEdit"
|
||||
:value="appSettings['app.custom_login_message']"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full flex flex-row items-center gap-2">
|
||||
<div
|
||||
v-if="!enableEdit"
|
||||
class="border-2 border-gray-500 rounded-sm"
|
||||
:class="appSettings['app.show_link_to_calendar'] ? 'bg-gray-500' : 'h-3.5 w-3.5'"
|
||||
>
|
||||
<CheckIcon v-if="appSettings['app.show_link_to_calendar']" class="h-2.5 w-2.5 stroke-4 text-white" />
|
||||
</div>
|
||||
<input v-else id="show_link_to_calendar" type="checkbox" :checked="appSettings['app.show_link_to_calendar']" />
|
||||
<label for="show_link_to_calendar">Kalender-Link anzeigen</label>
|
||||
</div>
|
||||
</BaseSetting>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAbilityStore } from "@/stores/ability";
|
||||
import { useSettingStore } from "@/stores/admin/management/setting";
|
||||
import { CheckIcon } from "@heroicons/vue/24/outline";
|
||||
import { mapActions, mapState } from "pinia";
|
||||
import { defineComponent } from "vue";
|
||||
import BaseSetting from "./BaseSetting.vue";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default defineComponent({
|
||||
data() {
|
||||
return {
|
||||
enableEdit: false as boolean,
|
||||
status: undefined as undefined | "loading" | "success" | "failed",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState(useSettingStore, ["readByTopic"]),
|
||||
...mapState(useAbilityStore, ["can"]),
|
||||
appSettings() {
|
||||
return this.readByTopic("app");
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useSettingStore, ["updateSettings"]),
|
||||
submit(e: any) {
|
||||
const formData = e.target.elements;
|
||||
return this.updateSettings([
|
||||
{
|
||||
key: "app.custom_login_message",
|
||||
value: formData.custom_login_message.value || null,
|
||||
},
|
||||
{
|
||||
key: "app.show_link_to_calendar",
|
||||
value: formData.show_link_to_calendar.checked || null,
|
||||
},
|
||||
]);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
53
src/components/admin/management/setting/BackupSetting.vue
Normal file
53
src/components/admin/management/setting/BackupSetting.vue
Normal file
|
@ -0,0 +1,53 @@
|
|||
<template>
|
||||
<BaseSetting title="Backup Einstellungen" :submit-function="submit" v-slot="{ enableEdit }">
|
||||
<div class="w-full">
|
||||
<label for="copies">Anzahl paralleler Backups (optional)</label>
|
||||
<input id="copies" type="text" :readonly="!enableEdit" :value="backupSettings['backup.copies']" />
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<label for="interval">Intervall zur Backup-Erstellung (optional)</label>
|
||||
<input id="interval" type="text" :readonly="!enableEdit" :value="backupSettings['backup.interval']" /></div
|
||||
></BaseSetting>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAbilityStore } from "@/stores/ability";
|
||||
import { useSettingStore } from "@/stores/admin/management/setting";
|
||||
import { mapActions, mapState } from "pinia";
|
||||
import { defineComponent } from "vue";
|
||||
import BaseSetting from "./BaseSetting.vue";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default defineComponent({
|
||||
data() {
|
||||
return {
|
||||
enableEdit: false as boolean,
|
||||
status: undefined as undefined | "loading" | "success" | "failed",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState(useSettingStore, ["readByTopic"]),
|
||||
...mapState(useAbilityStore, ["can"]),
|
||||
backupSettings() {
|
||||
return this.readByTopic("backup");
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useSettingStore, ["updateSettings"]),
|
||||
submit(e: any) {
|
||||
const formData = e.target.elements;
|
||||
return this.updateSettings([
|
||||
{
|
||||
key: "backup.copies",
|
||||
value: formData.copies.value || null,
|
||||
},
|
||||
{
|
||||
key: "backup.interval",
|
||||
value: formData.interval.value || null,
|
||||
},
|
||||
]);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
87
src/components/admin/management/setting/BaseSetting.vue
Normal file
87
src/components/admin/management/setting/BaseSetting.vue
Normal file
|
@ -0,0 +1,87 @@
|
|||
<template>
|
||||
<form ref="form" class="flex flex-col w-full" @submit.prevent="submit">
|
||||
<div class="flex flex-row gap-2 items-center border-l-3 border-l-primary p-2 rounded-t-lg bg-red-200">
|
||||
<p class="text-lg font-semibold grow">{{ title }}</p>
|
||||
<Spinner v-if="status == 'loading'" />
|
||||
<SuccessCheckmark v-else-if="status == 'success'" />
|
||||
<FailureXMark v-else-if="status == 'failed'" />
|
||||
<div v-else-if="enableEdit" class="flex flex-row gap-2">
|
||||
<button type="submit" class="w-fit! h-fit! p-0!">
|
||||
<CheckIcon class="h-5 w-5 cursor-pointer" />
|
||||
</button>
|
||||
<button
|
||||
type="reset"
|
||||
class="w-fit! h-fit! p-0!"
|
||||
@click="
|
||||
enableEdit = false;
|
||||
$emit('reset');
|
||||
"
|
||||
>
|
||||
<XMarkIcon class="h-5 w-5 cursor-pointer" />
|
||||
</button>
|
||||
</div>
|
||||
<PencilSquareIcon
|
||||
v-else-if="can('create', 'management', 'setting')"
|
||||
class="h-5 w-5 cursor-pointer"
|
||||
@click="enableEdit = true"
|
||||
/>
|
||||
</div>
|
||||
<div class="border-l-3 border-l-primary p-2 rounded-b-lg">
|
||||
<slot :enableEdit="enableEdit"></slot>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import FailureXMark from "@/components/FailureXMark.vue";
|
||||
import Spinner from "@/components/Spinner.vue";
|
||||
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
|
||||
import { useAbilityStore } from "@/stores/ability";
|
||||
import { CheckIcon, PencilSquareIcon, XMarkIcon } from "@heroicons/vue/24/outline";
|
||||
import { mapActions, mapState } from "pinia";
|
||||
import { defineComponent } from "vue";
|
||||
import type { PropType } from "vue";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default defineComponent({
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
submitFunction: {
|
||||
type: Function as PropType<(e: any) => Promise<any>>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["reset"],
|
||||
data() {
|
||||
return {
|
||||
enableEdit: false as boolean,
|
||||
status: undefined as undefined | "loading" | "success" | "failed",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState(useAbilityStore, ["can"]),
|
||||
},
|
||||
methods: {
|
||||
submit(e: any) {
|
||||
this.status = "loading";
|
||||
this.submitFunction(e)
|
||||
.then(() => {
|
||||
this.status = "success";
|
||||
})
|
||||
.catch(() => {
|
||||
this.status = "failed";
|
||||
})
|
||||
.finally(() => {
|
||||
setTimeout(() => {
|
||||
if (this.status == "success") this.enableEdit = false;
|
||||
this.status = undefined;
|
||||
}, 2000);
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
152
src/components/admin/management/setting/ClubImageSetting.vue
Normal file
152
src/components/admin/management/setting/ClubImageSetting.vue
Normal file
|
@ -0,0 +1,152 @@
|
|||
<template>
|
||||
<BaseSetting title="Vereins-Auftritt Einstellungen" :submit-function="submit" v-slot="{ enableEdit }" @reset="reset">
|
||||
<div class="w-full">
|
||||
<p>Vereins-Icon</p>
|
||||
<div class="flex flex-row gap-2">
|
||||
<AppIcon v-if="icon != '' && !overwriteIcon" class="h-10! max-w-full mx-auto" />
|
||||
<div
|
||||
v-else-if="!overwriteIcon"
|
||||
class="flex h-10 w-full border-2 border-gray-300 rounded-md items-center justify-center text-sm"
|
||||
:class="{ 'cursor-pointer': enableEdit }"
|
||||
@click="enableEdit ? ($refs.icon as HTMLInputElement).click() : null"
|
||||
>
|
||||
Kein eigenes Icon ausgewählt
|
||||
</div>
|
||||
<img ref="icon_img" class="hidden w-full h-10 object-contain" />
|
||||
<XMarkIcon
|
||||
v-if="enableEdit && (icon != '' || overwriteIcon)"
|
||||
class="h-5 w-5 cursor-pointer"
|
||||
@click="resetImage('icon')"
|
||||
/>
|
||||
</div>
|
||||
<input class="hidden!" type="file" ref="icon" accept="image/png" @change="previewImage('icon')" />
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<p>Vereins-Logo</p>
|
||||
<div class="flex flex-row gap-2">
|
||||
<AppLogo v-if="logo != '' && !overwriteLogo" class="h-10! max-w-full mx-auto" />
|
||||
<div
|
||||
v-else-if="!overwriteLogo"
|
||||
class="flex h-10 w-full border-2 border-gray-300 rounded-md items-center justify-center text-sm"
|
||||
:class="{ 'cursor-pointer': enableEdit }"
|
||||
@click="enableEdit ? ($refs.logo as HTMLInputElement).click() : null"
|
||||
>
|
||||
Kein eigenes Logo ausgewählt
|
||||
</div>
|
||||
<img ref="logo_img" class="hidden w-full h-10 object-contain" />
|
||||
<XMarkIcon
|
||||
v-if="enableEdit && (logo != '' || overwriteLogo)"
|
||||
class="h-5 w-5 cursor-pointer"
|
||||
@click="resetImage('logo')"
|
||||
/>
|
||||
</div>
|
||||
<input class="hidden!" type="file" ref="logo" accept="image/png" @change="previewImage('logo')" />
|
||||
</div>
|
||||
</BaseSetting>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import { mapActions, mapState } from "pinia";
|
||||
import { useSettingStore } from "@/stores/admin/management/setting";
|
||||
import AppIcon from "@/components/AppIcon.vue";
|
||||
import AppLogo from "@/components/AppLogo.vue";
|
||||
import { useAbilityStore } from "@/stores/ability";
|
||||
import type { SettingString } from "@/types/settingTypes";
|
||||
import BaseSetting from "./BaseSetting.vue";
|
||||
import { XMarkIcon } from "@heroicons/vue/24/outline";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default defineComponent({
|
||||
watch: {
|
||||
clubSettings() {
|
||||
this.reset();
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
logo: "",
|
||||
icon: "",
|
||||
overwriteIcon: false as boolean,
|
||||
overwriteLogo: false as boolean,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState(useSettingStore, ["readByTopic"]),
|
||||
...mapState(useAbilityStore, ["can"]),
|
||||
clubSettings() {
|
||||
return this.readByTopic("club");
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.reset();
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useSettingStore, ["updateSettings", "uploadImage"]),
|
||||
reset() {
|
||||
this.icon = this.clubSettings["club.icon"];
|
||||
this.overwriteIcon = false;
|
||||
(this.$refs.icon_img as HTMLImageElement).style.display = "none";
|
||||
(this.$refs.icon as HTMLInputElement).value = "";
|
||||
|
||||
this.logo = this.clubSettings["club.logo"];
|
||||
this.overwriteLogo = false;
|
||||
(this.$refs.logo_img as HTMLImageElement).style.display = "none";
|
||||
(this.$refs.logo as HTMLInputElement).value = "";
|
||||
},
|
||||
resetImage(inputname: "icon" | "logo") {
|
||||
if (inputname == "icon") {
|
||||
this.icon = "";
|
||||
this.overwriteIcon = false;
|
||||
(this.$refs.icon_img as HTMLImageElement).style.display = "none";
|
||||
(this.$refs.icon as HTMLInputElement).value = "";
|
||||
} else {
|
||||
this.logo = "";
|
||||
this.overwriteLogo = false;
|
||||
(this.$refs.logo_img as HTMLImageElement).style.display = "none";
|
||||
(this.$refs.logo as HTMLInputElement).value = "";
|
||||
}
|
||||
},
|
||||
previewImage(inputname: "icon" | "logo") {
|
||||
let input = this.$refs[inputname] as HTMLInputElement;
|
||||
let previewElement = this.$refs[inputname + "_img"] as HTMLImageElement;
|
||||
if (input.files && input.files[0]) {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = function (e) {
|
||||
previewElement.src = e.target?.result as string;
|
||||
previewElement.style.display = "block";
|
||||
};
|
||||
|
||||
reader.readAsDataURL(input.files[0]);
|
||||
|
||||
if (inputname == "icon") {
|
||||
this.overwriteIcon = true;
|
||||
} else {
|
||||
this.overwriteLogo = true;
|
||||
}
|
||||
} else {
|
||||
previewElement.src = "";
|
||||
previewElement.style.display = "none";
|
||||
}
|
||||
},
|
||||
submit(e: any) {
|
||||
return this.uploadImage([
|
||||
{
|
||||
key: "club.icon",
|
||||
value:
|
||||
(this.$refs.icon as HTMLInputElement).files?.[0] ??
|
||||
(this.icon != "" && !this.overwriteIcon ? "keep" : undefined),
|
||||
},
|
||||
{
|
||||
key: "club.logo",
|
||||
value:
|
||||
(this.$refs.logo as HTMLInputElement).files?.[0] ??
|
||||
(this.logo != "" && !this.overwriteLogo ? "keep" : undefined),
|
||||
},
|
||||
]);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
89
src/components/admin/management/setting/ClubSetting.vue
Normal file
89
src/components/admin/management/setting/ClubSetting.vue
Normal file
|
@ -0,0 +1,89 @@
|
|||
<template>
|
||||
<BaseSetting title="Vereins Einstellungen" :submit-function="submit" v-slot="{ enableEdit }">
|
||||
<div class="w-full">
|
||||
<label for="clubname">Vereins-Name (optional)</label>
|
||||
<input id="clubname" type="text" :readonly="!enableEdit" :value="clubSettings['club.name']" />
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<label for="imprint">Vereins-Impressum Link (optional)</label>
|
||||
<input id="imprint" type="url" :readonly="!enableEdit" :value="clubSettings['club.imprint']" />
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<label for="privacy">Vereins-Datenschutz Link (optional)</label>
|
||||
<input id="privacy" type="url" :readonly="!enableEdit" :value="clubSettings['club.privacy']" />
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<label for="website">Vereins-Webseite Link (optional)</label>
|
||||
<input id="website" type="url" :readonly="!enableEdit" :value="clubSettings['club.website']" /></div
|
||||
></BaseSetting>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import { mapActions, mapState } from "pinia";
|
||||
import { useSettingStore } from "@/stores/admin/management/setting";
|
||||
import AppIcon from "@/components/AppIcon.vue";
|
||||
import AppLogo from "@/components/AppLogo.vue";
|
||||
import { useAbilityStore } from "@/stores/ability";
|
||||
import type { SettingString } from "@/types/settingTypes";
|
||||
import BaseSetting from "./BaseSetting.vue";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default defineComponent({
|
||||
data() {
|
||||
return {
|
||||
overwriteIcon: false as boolean,
|
||||
overwriteLogo: false as boolean,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState(useSettingStore, ["readByTopic"]),
|
||||
...mapState(useAbilityStore, ["can"]),
|
||||
clubSettings() {
|
||||
return this.readByTopic("club");
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useSettingStore, ["updateSettings", "uploadImage"]),
|
||||
previewImage(inputname: "icon" | "logo") {
|
||||
let input = this.$refs[inputname] as HTMLInputElement;
|
||||
let previewElement = this.$refs[inputname + "_img"] as HTMLImageElement;
|
||||
if (input.files && input.files[0]) {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = function (e) {
|
||||
previewElement.src = e.target?.result as string;
|
||||
previewElement.style.display = "block";
|
||||
};
|
||||
|
||||
reader.readAsDataURL(input.files[0]);
|
||||
} else {
|
||||
previewElement.src = "";
|
||||
previewElement.style.display = "none";
|
||||
}
|
||||
},
|
||||
submit(e: any) {
|
||||
const formData = e.target.elements;
|
||||
return this.updateSettings([
|
||||
{
|
||||
key: "club.name",
|
||||
value: formData.clubname.value || null,
|
||||
},
|
||||
{
|
||||
key: "club.imprint",
|
||||
value: formData.imprint.value || null,
|
||||
},
|
||||
{
|
||||
key: "club.privacy",
|
||||
value: formData.privacy.value || null,
|
||||
},
|
||||
{
|
||||
key: "club.website",
|
||||
value: formData.website.value || null,
|
||||
},
|
||||
]);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
100
src/components/admin/management/setting/MailSetting.vue
Normal file
100
src/components/admin/management/setting/MailSetting.vue
Normal file
|
@ -0,0 +1,100 @@
|
|||
<template>
|
||||
<BaseSetting title="E-Mail Einstellungen" :submit-function="submit" v-slot="{ enableEdit }">
|
||||
<div class="w-full">
|
||||
<label for="email">Mailadresse</label>
|
||||
<input id="email" type="email" autocomplete="email" :readonly="!enableEdit" :value="mailSettings['mail.email']" />
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<label for="username">Benutzername</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
:readonly="!enableEdit"
|
||||
autocomplete="username"
|
||||
:value="mailSettings['mail.username']"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<label for="host">Server-Host</label>
|
||||
<input id="host" type="text" :readonly="!enableEdit" :value="mailSettings['mail.host']" />
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<label for="port">Server-Port (25, 465, 587)</label>
|
||||
<input id="port" type="number" :readonly="!enableEdit" :value="mailSettings['mail.port']" />
|
||||
</div>
|
||||
<div class="w-full flex flex-row items-center gap-2">
|
||||
<div
|
||||
v-if="!enableEdit"
|
||||
class="border-2 border-gray-500 rounded-sm"
|
||||
:class="mailSettings['mail.secure'] ? 'bg-gray-500' : 'h-3.5 w-3.5'"
|
||||
>
|
||||
<CheckIcon v-if="mailSettings['mail.secure']" class="h-2.5 w-2.5 stroke-4 text-white" />
|
||||
</div>
|
||||
<input v-else id="secure" type="checkbox" :checked="mailSettings['mail.secure']" />
|
||||
<label for="secure">Secure-Verbindung (setzen bei Port 465)</label>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<label for="password">Passwort (optional - leeres Feld setzt Passwort nicht zurück)</label>
|
||||
<input id="password" type="password" :readonly="!enableEdit" autocomplete="new-password" />
|
||||
</div>
|
||||
</BaseSetting>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import { CheckIcon } from "@heroicons/vue/24/outline";
|
||||
import { mapActions, mapState } from "pinia";
|
||||
import { useSettingStore } from "@/stores/admin/management/setting";
|
||||
import { useAbilityStore } from "@/stores/ability";
|
||||
import BaseSetting from "./BaseSetting.vue";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default defineComponent({
|
||||
data() {
|
||||
return {
|
||||
enableEdit: false as boolean,
|
||||
status: undefined as undefined | "loading" | "success" | "failed",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState(useSettingStore, ["readByTopic"]),
|
||||
...mapState(useAbilityStore, ["can"]),
|
||||
mailSettings() {
|
||||
return this.readByTopic("mail");
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useSettingStore, ["updateSettings"]),
|
||||
submit(e: any) {
|
||||
const formData = e.target.elements;
|
||||
return this.updateSettings([
|
||||
{
|
||||
key: "mail.email",
|
||||
value: formData.email.value,
|
||||
},
|
||||
{
|
||||
key: "mail.username",
|
||||
value: formData.username.value,
|
||||
},
|
||||
{
|
||||
key: "mail.host",
|
||||
value: formData.host.value,
|
||||
},
|
||||
{
|
||||
key: "mail.port",
|
||||
value: formData.port.value,
|
||||
},
|
||||
{
|
||||
key: "mail.secure",
|
||||
value: formData.secure.checked,
|
||||
},
|
||||
{
|
||||
key: "mail.password",
|
||||
value: formData.password.value || null,
|
||||
},
|
||||
]);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
76
src/components/admin/management/setting/SessionSetting.vue
Normal file
76
src/components/admin/management/setting/SessionSetting.vue
Normal file
|
@ -0,0 +1,76 @@
|
|||
<template>
|
||||
<BaseSetting title="Login-Session Einstellungen" :submit-function="submit" v-slot="{ enableEdit }">
|
||||
<div class="w-full">
|
||||
<label for="jwt_expiration">JWT-Gültigkeitsdauer (optional)</label>
|
||||
<input
|
||||
id="jwt_expiration"
|
||||
type="text"
|
||||
:readonly="!enableEdit"
|
||||
:value="sessionSettings['session.jwt_expiration']"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<label for="refresh_expiration">Session-Gültigkeitsdauer (optional)</label>
|
||||
<input
|
||||
id="refresh_expiration"
|
||||
type="text"
|
||||
:readonly="!enableEdit"
|
||||
:value="sessionSettings['session.refresh_expiration']"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<label for="pwa_refresh_expiration">Sesion-Gültigkeitsdauer PWA (optional)</label>
|
||||
<input
|
||||
id="pwa_refresh_expiration"
|
||||
type="text"
|
||||
:readonly="!enableEdit"
|
||||
:value="sessionSettings['session.pwa_refresh_expiration']"
|
||||
/></div
|
||||
></BaseSetting>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAbilityStore } from "@/stores/ability";
|
||||
import { useSettingStore } from "@/stores/admin/management/setting";
|
||||
import { mapActions, mapState } from "pinia";
|
||||
import { defineComponent } from "vue";
|
||||
import BaseSetting from "./BaseSetting.vue";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default defineComponent({
|
||||
data() {
|
||||
return {
|
||||
enableEdit: false as boolean,
|
||||
status: undefined as undefined | "loading" | "success" | "failed",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState(useSettingStore, ["readByTopic"]),
|
||||
...mapState(useAbilityStore, ["can"]),
|
||||
sessionSettings() {
|
||||
return this.readByTopic("session");
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useSettingStore, ["updateSettings"]),
|
||||
submit(e: any) {
|
||||
const formData = e.target.elements;
|
||||
return this.updateSettings([
|
||||
{
|
||||
key: "session.jwt_expiration",
|
||||
value: formData.jwt_expiration.value || null,
|
||||
},
|
||||
{
|
||||
key: "session.refresh_expiration",
|
||||
value: formData.refresh_expiration.value || null,
|
||||
},
|
||||
{
|
||||
key: "session.pwa_refresh_expiration",
|
||||
value: formData.pwa_refresh_expiration.value || null,
|
||||
},
|
||||
]);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -39,7 +39,7 @@ import Spinner from "@/components/Spinner.vue";
|
|||
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
|
||||
import FailureXMark from "@/components/FailureXMark.vue";
|
||||
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 lang="ts">
|
||||
|
|
|
@ -28,7 +28,7 @@ import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
|
|||
import TextCopy from "@/components/TextCopy.vue";
|
||||
import { CalendarDaysIcon, InformationCircleIcon } from "@heroicons/vue/24/outline";
|
||||
import { host } from "@/serverCom";
|
||||
import { useWebapiStore } from "../../../../stores/admin/management/webapi";
|
||||
import { useWebapiStore } from "@/stores/admin/management/webapi";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
</p>
|
||||
</div>
|
||||
<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>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
|
|
@ -30,6 +30,9 @@
|
|||
v-if="allowPredefinedSelect && can('read', 'configuration', 'query_store')"
|
||||
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!">
|
||||
<option :value="undefined" disabled>gepeicherte Anfrage auswählen</option>
|
||||
<option v-for="query in queries" :key="query.id" :value="query.id">
|
||||
|
@ -70,7 +73,7 @@
|
|||
</div>
|
||||
<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" />
|
||||
<Table v-else v-model="value" />
|
||||
<Table v-else v-model="value" enableOrder />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -78,7 +81,7 @@
|
|||
<script setup lang="ts">
|
||||
import { defineAsyncComponent, defineComponent, markRaw, type PropType } from "vue";
|
||||
import { mapActions, mapState, mapWritableState } from "pinia";
|
||||
import type { DynamicQueryStructure } from "@/types/dynamicQueries";
|
||||
import { type DynamicQueryStructure } from "@/types/dynamicQueries";
|
||||
import {
|
||||
ArchiveBoxArrowDownIcon,
|
||||
CommandLineIcon,
|
||||
|
@ -88,12 +91,15 @@ import {
|
|||
RectangleGroupIcon,
|
||||
TrashIcon,
|
||||
SparklesIcon,
|
||||
DocumentCurrencyRupeeIcon,
|
||||
} from "@heroicons/vue/24/outline";
|
||||
import { useQueryBuilderStore } from "@/stores/admin/club/queryBuilder";
|
||||
import { useModalStore } from "@/stores/modal";
|
||||
import Table from "./Table.vue";
|
||||
import { useAbilityStore } from "@/stores/ability";
|
||||
import { useQueryStoreStore } from "@/stores/admin/configuration/queryStore";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import cloneDeep from "lodash.clonedeep";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
|
@ -102,6 +108,7 @@ export default defineComponent({
|
|||
modelValue: {
|
||||
type: [Object, String] as PropType<DynamicQueryStructure | string>,
|
||||
default: {
|
||||
id: uuid(),
|
||||
select: "*",
|
||||
table: "",
|
||||
where: [],
|
||||
|
@ -126,7 +133,12 @@ export default defineComponent({
|
|||
} else {
|
||||
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);
|
||||
},
|
||||
},
|
||||
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() {
|
||||
this.fetchTableMetas();
|
||||
|
@ -163,6 +180,7 @@ export default defineComponent({
|
|||
this.activeQueryId = undefined;
|
||||
if (typeof this.value != "string") {
|
||||
this.value = {
|
||||
id: uuid(),
|
||||
select: "*",
|
||||
table: "",
|
||||
where: [],
|
||||
|
@ -182,6 +200,7 @@ export default defineComponent({
|
|||
this.activeQueryId = undefined;
|
||||
if (this.queryMode == "builder") {
|
||||
this.value = {
|
||||
id: uuid(),
|
||||
select: "*",
|
||||
table: "",
|
||||
where: [],
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<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 v-for="operation in ['AND', 'OR']" :value="operation">
|
||||
{{ operation }}
|
||||
|
@ -68,6 +68,10 @@ import { TrashIcon } from "@heroicons/vue/24/outline";
|
|||
<script lang="ts">
|
||||
export default defineComponent({
|
||||
props: {
|
||||
isFirst: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
table: {
|
||||
type: String,
|
||||
default: "",
|
||||
|
@ -78,9 +82,6 @@ export default defineComponent({
|
|||
},
|
||||
},
|
||||
emits: ["update:model-value", "remove"],
|
||||
data() {
|
||||
return {};
|
||||
},
|
||||
computed: {
|
||||
...mapState(useQueryBuilderStore, ["tableMetas"]),
|
||||
activeTable() {
|
||||
|
@ -144,5 +145,10 @@ export default defineComponent({
|
|||
},
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (this.concat == "_") {
|
||||
this.concat = "AND";
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
<template>
|
||||
<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 justify-end w-full">
|
||||
<JoinTable
|
||||
v-for="(join, index) in value"
|
||||
:model-value="join"
|
||||
:table="table"
|
||||
:alreadyJoined="alreadyJoined"
|
||||
@update:model-value="($event) => (value[index] = $event)"
|
||||
@remove="removeAtIndex(index)"
|
||||
/>
|
||||
|
@ -21,10 +22,11 @@
|
|||
<script setup lang="ts">
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
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 { PlusIcon } from "@heroicons/vue/24/outline";
|
||||
import JoinTable from "./JoinTable.vue";
|
||||
import { v4 as uuid } from "uuid";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
|
@ -35,24 +37,22 @@ export default defineComponent({
|
|||
default: "",
|
||||
},
|
||||
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: [],
|
||||
},
|
||||
},
|
||||
emits: ["update:model-value"],
|
||||
data() {
|
||||
return {};
|
||||
},
|
||||
computed: {
|
||||
...mapState(useQueryBuilderStore, ["tableMetas"]),
|
||||
activeTable() {
|
||||
return this.tableMetas.find((tm) => tm.tableName == this.table);
|
||||
},
|
||||
value: {
|
||||
get() {
|
||||
return this.modelValue;
|
||||
},
|
||||
set(val: Array<DynamicQueryStructure & { foreignColumn: string }>) {
|
||||
set(val: Array<DynamicQueryStructure & JoinStructure>) {
|
||||
this.$emit("update:model-value", val);
|
||||
},
|
||||
},
|
||||
|
@ -60,11 +60,13 @@ export default defineComponent({
|
|||
methods: {
|
||||
addToValue() {
|
||||
this.value.push({
|
||||
id: uuid(),
|
||||
select: "*",
|
||||
table: "",
|
||||
where: [],
|
||||
join: [],
|
||||
orderBy: [],
|
||||
type: "defined",
|
||||
foreignColumn: "",
|
||||
});
|
||||
},
|
||||
|
|
|
@ -2,13 +2,52 @@
|
|||
<div class="flex flex-row gap-2 w-full">
|
||||
<div class="flex flex-row gap-2 w-full">
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<select v-model="foreignColumn" class="w-full">
|
||||
<option value="" disabled>Relation auswählen</option>
|
||||
<option v-for="relation in activeTable?.relations" :value="relation.column">
|
||||
{{ relation.column }} -> {{ joinTableName(relation.referencedTableName) }}
|
||||
</option>
|
||||
</select>
|
||||
<Table v-model="value" disable-table-select />
|
||||
<div class="flex flex-row gap-2 w-full">
|
||||
<div
|
||||
v-if="false"
|
||||
class="h-fit p-1 border border-gray-400 hover:bg-gray-200 rounded-md"
|
||||
title="Join Modus wechseln"
|
||||
@click="swapJoinType(value.type)"
|
||||
>
|
||||
<ArrowsUpDownIcon class="text-gray-500 h-6 w-6 cursor-pointer" />
|
||||
</div>
|
||||
|
||||
<select v-if="type == 'defined'" v-model="context" class="w-full">
|
||||
<option value="" disabled>Relation auswählen</option>
|
||||
<option
|
||||
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 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" />
|
||||
|
@ -20,11 +59,12 @@
|
|||
<script setup lang="ts">
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
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 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 { v4 as uuid } from "uuid";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
|
@ -35,25 +75,15 @@ export default defineComponent({
|
|||
default: "",
|
||||
},
|
||||
modelValue: {
|
||||
type: Object as PropType<
|
||||
DynamicQueryStructure & {
|
||||
foreignColumn: string;
|
||||
}
|
||||
>,
|
||||
default: {
|
||||
select: "*",
|
||||
table: "",
|
||||
where: [],
|
||||
join: [],
|
||||
orderBy: [],
|
||||
foreignColumn: "",
|
||||
},
|
||||
type: Object as PropType<DynamicQueryStructure & JoinStructure>,
|
||||
required: true,
|
||||
},
|
||||
alreadyJoined: {
|
||||
type: Array as PropType<Array<string>>,
|
||||
default: [],
|
||||
},
|
||||
},
|
||||
emits: ["update:model-value", "remove"],
|
||||
data() {
|
||||
return {};
|
||||
},
|
||||
computed: {
|
||||
...mapState(useQueryBuilderStore, ["tableMetas"]),
|
||||
activeTable() {
|
||||
|
@ -63,27 +93,73 @@ export default defineComponent({
|
|||
get() {
|
||||
return this.modelValue;
|
||||
},
|
||||
set(
|
||||
val: DynamicQueryStructure & {
|
||||
foreignColumn: string;
|
||||
}
|
||||
) {
|
||||
set(val: DynamicQueryStructure & JoinStructure) {
|
||||
this.$emit("update:model-value", val);
|
||||
},
|
||||
},
|
||||
foreignColumn: {
|
||||
context: {
|
||||
get() {
|
||||
return this.modelValue.foreignColumn;
|
||||
if (this.modelValue.type == "defined") {
|
||||
return this.modelValue.foreignColumn ?? "";
|
||||
} else {
|
||||
return this.modelValue.condition ?? "";
|
||||
}
|
||||
},
|
||||
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.modelValue,
|
||||
foreignColumn: val,
|
||||
table: joinTableName(relTable?.referencedTableName ?? ""),
|
||||
type: val,
|
||||
});
|
||||
},
|
||||
},
|
||||
joinTable: {
|
||||
get(): string {
|
||||
return this.modelValue.table;
|
||||
},
|
||||
set(val: string) {
|
||||
this.$emit("update:model-value", {
|
||||
...this.modelValue,
|
||||
table: val,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (!this.value.id) {
|
||||
this.value.id = uuid();
|
||||
}
|
||||
if (!this.value.type) {
|
||||
this.type = "defined";
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
swapJoinType(type: string) {
|
||||
if (type == "defined") {
|
||||
this.type = "custom";
|
||||
} else {
|
||||
this.type = "defined";
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<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 v-for="operation in ['AND', 'OR']" :value="operation">
|
||||
{{ operation }}
|
||||
|
@ -28,6 +28,10 @@ import NestedWhere from "./NestedWhere.vue";
|
|||
<script lang="ts">
|
||||
export default defineComponent({
|
||||
props: {
|
||||
isFirst: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
table: {
|
||||
type: String,
|
||||
default: "",
|
||||
|
@ -38,9 +42,6 @@ export default defineComponent({
|
|||
},
|
||||
},
|
||||
emits: ["update:model-value", "remove"],
|
||||
data() {
|
||||
return {};
|
||||
},
|
||||
computed: {
|
||||
...mapState(useQueryBuilderStore, ["tableMetas"]),
|
||||
concat: {
|
||||
|
@ -60,5 +61,10 @@ export default defineComponent({
|
|||
},
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (this.concat == "_") {
|
||||
this.concat = "AND";
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -1,12 +1,17 @@
|
|||
<template>
|
||||
<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">
|
||||
<OrderStructure
|
||||
v-for="(order, index) in value"
|
||||
:model-value="order"
|
||||
:table="table"
|
||||
: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)"
|
||||
@remove="removeAtIndex(index)"
|
||||
/>
|
||||
|
@ -35,9 +40,15 @@ export default defineComponent({
|
|||
type: String,
|
||||
default: "",
|
||||
},
|
||||
// columns: {
|
||||
// type: [Array, String] as PropType<"*" | Array<string>>,
|
||||
// default: "*",
|
||||
// },
|
||||
columns: {
|
||||
type: [Array, String] as PropType<"*" | Array<string>>,
|
||||
default: "*",
|
||||
type: Array as PropType<
|
||||
Array<{ table: string; id: string; depth: number; path: string[]; columns: "*" | string[] }>
|
||||
>,
|
||||
default: [],
|
||||
},
|
||||
modelValue: {
|
||||
type: Array as PropType<Array<OrderByStructure>>,
|
||||
|
@ -50,6 +61,9 @@ export default defineComponent({
|
|||
},
|
||||
computed: {
|
||||
...mapState(useQueryBuilderStore, ["tableMetas"]),
|
||||
alreadySorted() {
|
||||
return this.modelValue.map((m) => ({ id: m.id, col: m.column }));
|
||||
},
|
||||
value: {
|
||||
get() {
|
||||
return this.modelValue;
|
||||
|
@ -62,6 +76,9 @@ export default defineComponent({
|
|||
methods: {
|
||||
addToValue() {
|
||||
this.value.push({
|
||||
id: "",
|
||||
depth: 0,
|
||||
table: "",
|
||||
column: "",
|
||||
order: "ASC",
|
||||
});
|
||||
|
@ -69,6 +86,12 @@ export default defineComponent({
|
|||
removeAtIndex(index: number) {
|
||||
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>
|
||||
|
|
|
@ -1,15 +1,26 @@
|
|||
<template>
|
||||
<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">
|
||||
<option value="" disabled>Spalte auswählen</option>
|
||||
<option v-for="column in selectableColumns" :value="column">
|
||||
{{ column }}
|
||||
<option
|
||||
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>
|
||||
</select>
|
||||
<select v-model="order">
|
||||
<option value="" disabled>Sortierung auswählen</option>
|
||||
<option v-for="order in ['ASC', 'DESC']" :value="order">
|
||||
{{ order }}
|
||||
<option v-for="order in orderable" :value="order.key">
|
||||
{{ order.val }}
|
||||
</option>
|
||||
</select>
|
||||
<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 type { OrderByStructure, OrderByType } from "@/types/dynamicQueries";
|
||||
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 lang="ts">
|
||||
export default defineComponent({
|
||||
props: {
|
||||
notFirst: {
|
||||
type: Boolean,
|
||||
defailt: false,
|
||||
},
|
||||
notLast: {
|
||||
type: Boolean,
|
||||
defailt: false,
|
||||
},
|
||||
table: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
// columns: {
|
||||
// type: [Array, String] as PropType<"*" | Array<string>>,
|
||||
// default: "*",
|
||||
// },
|
||||
columns: {
|
||||
type: [Array, String] as PropType<"*" | Array<string>>,
|
||||
default: "*",
|
||||
type: Array as PropType<
|
||||
Array<{ table: string; id: string; depth: number; path: string[]; columns: "*" | string[] }>
|
||||
>,
|
||||
default: [],
|
||||
},
|
||||
modelValue: {
|
||||
type: Object as PropType<OrderByStructure>,
|
||||
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() {
|
||||
return {};
|
||||
return {
|
||||
orderable: [
|
||||
{ key: "ASC", val: "Aufsteigend (ABC)" },
|
||||
{ key: "DESC", val: "Absteigend (CBA)" },
|
||||
],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...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() {
|
||||
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;
|
||||
}
|
||||
return this.columns.reduce(
|
||||
(acc, curr) => {
|
||||
if (curr.columns == "*") {
|
||||
let meta = this.tableMetas.find((tm) => tm.tableName == curr.table);
|
||||
if (meta) {
|
||||
let relCols = meta.relations.map((r) => r.column);
|
||||
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: {
|
||||
get() {
|
||||
return this.modelValue.column;
|
||||
return `${this.modelValue.id}_${this.modelValue.column}`;
|
||||
},
|
||||
set(val: string) {
|
||||
this.$emit("update:model-value", { ...this.modelValue, column: val });
|
||||
set(val: `${string}_${string}`) {
|
||||
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: {
|
||||
|
|
|
@ -1,23 +1,22 @@
|
|||
<template>
|
||||
<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" />
|
||||
<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" />
|
||||
<Join v-if="table != ''" v-model="modelValue.join" :table="table" :alreadyJoined="alreadyJoined" />
|
||||
<Order v-if="table != '' && enableOrder" v-model="order" :table="table" :columns="nestedTablesByDepth" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
import { mapActions, mapState } from "pinia";
|
||||
import type { ConditionStructure, DynamicQueryStructure, OrderByStructure } from "@/types/dynamicQueries";
|
||||
import { useQueryBuilderStore } from "@/stores/admin/club/queryBuilder";
|
||||
import { type ConditionStructure, type DynamicQueryStructure, type OrderByStructure } from "@/types/dynamicQueries";
|
||||
import ColumnSelect from "./ColumnSelect.vue";
|
||||
import Where from "./Where.vue";
|
||||
import Order from "./Order.vue";
|
||||
import Join from "./Join.vue";
|
||||
import TableSelect from "./TableSelect.vue";
|
||||
import { v4 as uuid } from "uuid";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
|
@ -25,21 +24,50 @@ export default defineComponent({
|
|||
props: {
|
||||
modelValue: {
|
||||
type: Object as PropType<DynamicQueryStructure>,
|
||||
default: {
|
||||
select: "*",
|
||||
table: "",
|
||||
where: [],
|
||||
join: [],
|
||||
orderBy: [],
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
disableTableSelect: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
enableOrder: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showTableSelect: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
emits: ["update:model-value"],
|
||||
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: {
|
||||
get() {
|
||||
return this.modelValue.table || "";
|
||||
|
@ -81,5 +109,10 @@ export default defineComponent({
|
|||
},
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (!this.value.id) {
|
||||
this.value.id = uuid();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
<div v-for="(condition, index) in value" class="contents">
|
||||
<NestedCondition
|
||||
v-if="condition.structureType == 'nested'"
|
||||
:isFirst="index == 0"
|
||||
:model-value="condition"
|
||||
:table="table"
|
||||
@update:model-value="($event) => (value[index] = $event)"
|
||||
|
@ -12,6 +13,7 @@
|
|||
/>
|
||||
<Condition
|
||||
v-else
|
||||
:isFirst="index == 0"
|
||||
:model-value="condition"
|
||||
:table="table"
|
||||
@update:model-value="($event) => (value[index] = $event)"
|
||||
|
@ -74,14 +76,14 @@ export default defineComponent({
|
|||
addNestedToValue() {
|
||||
this.value.push({
|
||||
structureType: "nested",
|
||||
concat: this.value.length == 0 ? "_" : "AND",
|
||||
concat: "AND",
|
||||
conditions: [],
|
||||
});
|
||||
},
|
||||
addConditionToValue() {
|
||||
this.value.push({
|
||||
structureType: "condition",
|
||||
concat: this.value.length == 0 ? "_" : "AND",
|
||||
concat: "AND",
|
||||
operation: "eq",
|
||||
column: "",
|
||||
value: "",
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
<template>
|
||||
<div class="w-full">
|
||||
<Combobox v-model="selected" :disabled="disabled" multiple>
|
||||
<ComboboxLabel>{{ title }}</ComboboxLabel>
|
||||
<div class="relative mt-1">
|
||||
<ComboboxLabel v-if="!showTitleAsPlaceholder">{{ title }}</ComboboxLabel>
|
||||
<div class="relative" :class="{ 'mt-1': !showTitleAsPlaceholder }">
|
||||
<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"
|
||||
:placeholder="showTitleAsPlaceholder ? title : ''"
|
||||
@input="query = $event.target.value"
|
||||
/>
|
||||
<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 type { MemberViewModel } from "@/viewmodels/admin/club/member/member.models";
|
||||
import difference from "lodash.difference";
|
||||
import Spinner from "../Spinner.vue";
|
||||
import Spinner from "@/components/Spinner.vue";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
|
@ -101,6 +102,10 @@ export default defineComponent({
|
|||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showTitleAsPlaceholder: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["update:model-value", "add:difference", "remove:difference", "add:member", "add:memberByArray"],
|
||||
watch: {
|
||||
|
|
70
src/components/setup/Account.vue
Normal file
70
src/components/setup/Account.vue
Normal file
|
@ -0,0 +1,70 @@
|
|||
<template>
|
||||
<form class="flex flex-col gap-2" @submit.prevent="setup">
|
||||
<p class="text-center">Admin Account</p>
|
||||
<div class="-space-y-px">
|
||||
<div>
|
||||
<input id="username" name="username" type="text" required placeholder="Benutzer" class="rounded-b-none!" />
|
||||
</div>
|
||||
<div>
|
||||
<input id="mail" name="mail" type="email" required placeholder="Mailadresse" class="rounded-none!" />
|
||||
</div>
|
||||
<div>
|
||||
<input id="firstname" name="firstname" type="text" required placeholder="Vorname" class="rounded-none!" />
|
||||
</div>
|
||||
<div>
|
||||
<input id="lastname" name="lastname" type="text" required placeholder="Nachname" class="rounded-t-none!" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row gap-2">
|
||||
<button type="submit" primary :disabled="setupStatus == 'loading' || setupStatus == 'success'">
|
||||
Admin-Account anlegen
|
||||
</button>
|
||||
<Spinner v-if="setupStatus == 'loading'" class="my-auto" />
|
||||
<SuccessCheckmark v-else-if="setupStatus == 'success'" />
|
||||
<FailureXMark v-else-if="setupStatus == 'failed'" />
|
||||
</div>
|
||||
<p v-if="setupMessage" class="text-center">{{ setupMessage }}</p>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import Spinner from "@/components/Spinner.vue";
|
||||
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
|
||||
import FailureXMark from "@/components/FailureXMark.vue";
|
||||
import { mapActions } from "pinia";
|
||||
import { useSetupStore } from "@/stores/setup";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default defineComponent({
|
||||
data() {
|
||||
return {
|
||||
setupStatus: undefined as undefined | "loading" | "success" | "failed",
|
||||
setupMessage: "" as string,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useSetupStore, ["createAdmin"]),
|
||||
setup(e: any) {
|
||||
let formData = e.target.elements;
|
||||
this.setupStatus = "loading";
|
||||
this.setupMessage = "";
|
||||
this.createAdmin({
|
||||
username: formData.username.value,
|
||||
mail: formData.mail.value,
|
||||
firstname: formData.firstname.value,
|
||||
lastname: formData.lastname.value,
|
||||
})
|
||||
.then((result) => {
|
||||
// this.setupStatus = "success";
|
||||
})
|
||||
.catch((err) => {
|
||||
this.setupStatus = "failed";
|
||||
this.setupMessage = err.response.data;
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
66
src/components/setup/App.vue
Normal file
66
src/components/setup/App.vue
Normal file
|
@ -0,0 +1,66 @@
|
|||
<template>
|
||||
<form class="flex flex-col gap-2" @submit.prevent="setup">
|
||||
<p class="text-center">App Konfiguration</p>
|
||||
<div class="-space-y-px">
|
||||
<div>
|
||||
<input id="login_message" name="login_message" type="text" placeholder="Nachricht unter Login (optional)" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-center gap-2 pt-1">
|
||||
<input type="checkbox" id="show_cal_link" checked />
|
||||
<label for="show_cal_link">Link zum Kalender anzeigen (optional)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-primary cursor-pointer ml-auto" @click="skip('app')">überspringen</p>
|
||||
|
||||
<div class="flex flex-row gap-2">
|
||||
<button type="submit" primary :disabled="setupStatus == 'loading' || setupStatus == 'success'">
|
||||
Anwendungsdaten speichern
|
||||
</button>
|
||||
<Spinner v-if="setupStatus == 'loading'" class="my-auto" />
|
||||
<SuccessCheckmark v-else-if="setupStatus == 'success'" />
|
||||
<FailureXMark v-else-if="setupStatus == 'failed'" />
|
||||
</div>
|
||||
<p v-if="setupMessage" class="text-center">{{ setupMessage }}</p>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import Spinner from "@/components/Spinner.vue";
|
||||
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
|
||||
import FailureXMark from "@/components/FailureXMark.vue";
|
||||
import { mapActions } from "pinia";
|
||||
import { useSetupStore } from "@/stores/setup";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default defineComponent({
|
||||
data() {
|
||||
return {
|
||||
setupStatus: undefined as undefined | "loading" | "success" | "failed",
|
||||
setupMessage: "" as string,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useSetupStore, ["setApp", "skip"]),
|
||||
setup(e: any) {
|
||||
let formData = e.target.elements;
|
||||
this.setupStatus = "loading";
|
||||
this.setupMessage = "";
|
||||
this.setApp({
|
||||
login_message: formData.login_message.value,
|
||||
show_cal_link: formData.show_cal_link.checked,
|
||||
})
|
||||
.then((result) => {
|
||||
// this.setupStatus = "success";
|
||||
})
|
||||
.catch((err) => {
|
||||
this.setupStatus = "failed";
|
||||
this.setupMessage = err.response.data;
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
99
src/components/setup/Club.vue
Normal file
99
src/components/setup/Club.vue
Normal file
|
@ -0,0 +1,99 @@
|
|||
<template>
|
||||
<form class="flex flex-col gap-2" @submit.prevent="setup">
|
||||
<p class="text-center">Feuerwehr-/Vereinsdaten</p>
|
||||
<div class="-space-y-px">
|
||||
<div>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder="Feuerwehr-/Vereinsname (optional)"
|
||||
class="rounded-b-none!"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input
|
||||
id="imprint"
|
||||
name="imprint"
|
||||
type="url"
|
||||
placeholder="Link zum Impressum (optional)"
|
||||
class="rounded-none!"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input
|
||||
id="privacy"
|
||||
name="privacy"
|
||||
type="url"
|
||||
placeholder="Link zur Datenschutzerklärung (optional)"
|
||||
class="rounded-none!"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input
|
||||
id="website"
|
||||
name="website"
|
||||
type="url"
|
||||
placeholder="Link zur Webseite (optional)"
|
||||
class="rounded-t-none!"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-primary cursor-pointer ml-auto" @click="skip('club')">überspringen</p>
|
||||
|
||||
<div class="flex flex-row gap-2">
|
||||
<button type="submit" primary :disabled="setupStatus == 'loading' || setupStatus == 'success'">
|
||||
Vereinsdaten speichern
|
||||
</button>
|
||||
<Spinner v-if="setupStatus == 'loading'" class="my-auto" />
|
||||
<SuccessCheckmark v-else-if="setupStatus == 'success'" />
|
||||
<FailureXMark v-else-if="setupStatus == 'failed'" />
|
||||
</div>
|
||||
<p v-if="setupMessage" class="text-center">{{ setupMessage }}</p>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import Spinner from "@/components/Spinner.vue";
|
||||
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
|
||||
import FailureXMark from "@/components/FailureXMark.vue";
|
||||
import { mapActions } from "pinia";
|
||||
import { useSetupStore } from "@/stores/setup";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default defineComponent({
|
||||
data() {
|
||||
return {
|
||||
setupStatus: undefined as undefined | "loading" | "success" | "failed",
|
||||
setupMessage: "" as string,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useSetupStore, ["setClub", "skip"]),
|
||||
setup(e: any) {
|
||||
let formData = e.target.elements;
|
||||
this.setupStatus = "loading";
|
||||
this.setupMessage = "";
|
||||
this.setClub({
|
||||
name: formData.name.value,
|
||||
imprint: formData.imprint.value,
|
||||
privacy: formData.privacy.value,
|
||||
website: formData.website.value,
|
||||
})
|
||||
.then((result) => {
|
||||
// this.setupStatus = "success";
|
||||
})
|
||||
.catch((err) => {
|
||||
this.setupStatus = "failed";
|
||||
this.setupMessage = err.response.data;
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
3
src/components/setup/Finished.vue
Normal file
3
src/components/setup/Finished.vue
Normal file
|
@ -0,0 +1,3 @@
|
|||
<template>
|
||||
<p class="text-center">Sie haben einen Verifizierungslink per Mail erhalten.</p>
|
||||
</template>
|
87
src/components/setup/Images.vue
Normal file
87
src/components/setup/Images.vue
Normal file
|
@ -0,0 +1,87 @@
|
|||
<template>
|
||||
<form class="flex flex-col gap-2" @submit.prevent="setup">
|
||||
<p class="text-center">Feuerwehr-/Vereins-Auftritt</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<p>quadratisches Icon für App (optional)</p>
|
||||
<img ref="icon_img" class="hidden w-full h-20 object-contain" />
|
||||
<input class="hidden!" type="file" ref="icon" accept="image/*" @change="previewImage('icon')" />
|
||||
<button type="button" primary-outline @click="($refs.icon as HTMLInputElement).click()">Icon auswählen</button>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<p>Logo (optional)</p>
|
||||
<img ref="logo_img" class="hidden w-full h-20 object-contain" />
|
||||
<input class="hidden!" type="file" ref="logo" accept="image/*" @change="previewImage('logo')" />
|
||||
<button type="button" primary-outline @click="($refs.logo as HTMLInputElement).click()">Logo auswählen</button>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
<p class="text-primary cursor-pointer ml-auto" @click="skip('appImages')">überspringen</p>
|
||||
|
||||
<div class="flex flex-row gap-2">
|
||||
<button type="submit" primary :disabled="setupStatus == 'loading' || setupStatus == 'success'">
|
||||
Bilder speichern
|
||||
</button>
|
||||
<Spinner v-if="setupStatus == 'loading'" class="my-auto" />
|
||||
<SuccessCheckmark v-else-if="setupStatus == 'success'" />
|
||||
<FailureXMark v-else-if="setupStatus == 'failed'" />
|
||||
</div>
|
||||
<p v-if="setupMessage" class="text-center">{{ setupMessage }}</p>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import Spinner from "@/components/Spinner.vue";
|
||||
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
|
||||
import FailureXMark from "@/components/FailureXMark.vue";
|
||||
import { mapActions } from "pinia";
|
||||
import { useSetupStore } from "@/stores/setup";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default defineComponent({
|
||||
data() {
|
||||
return {
|
||||
setupStatus: undefined as undefined | "loading" | "success" | "failed",
|
||||
setupMessage: "" as string,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useSetupStore, ["setClubImages", "skip"]),
|
||||
previewImage(inputname: "icon" | "logo") {
|
||||
let input = this.$refs[inputname] as HTMLInputElement;
|
||||
let previewElement = this.$refs[inputname + "_img"] as HTMLImageElement;
|
||||
if (input.files && input.files[0]) {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = function (e) {
|
||||
previewElement.src = e.target?.result as string;
|
||||
previewElement.style.display = "block";
|
||||
};
|
||||
|
||||
reader.readAsDataURL(input.files[0]);
|
||||
} else {
|
||||
previewElement.src = "";
|
||||
previewElement.style.display = "none";
|
||||
}
|
||||
},
|
||||
setup(e: any) {
|
||||
this.setupStatus = "loading";
|
||||
this.setupMessage = "";
|
||||
this.setClubImages({
|
||||
icon: (this.$refs.icon as HTMLInputElement).files?.[0],
|
||||
logo: (this.$refs.logo as HTMLInputElement).files?.[0],
|
||||
})
|
||||
.then((result) => {
|
||||
// this.setupStatus = "success";
|
||||
})
|
||||
.catch((err) => {
|
||||
this.setupStatus = "failed";
|
||||
this.setupMessage = err.response.data;
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
95
src/components/setup/Mail.vue
Normal file
95
src/components/setup/Mail.vue
Normal file
|
@ -0,0 +1,95 @@
|
|||
<template>
|
||||
<form class="flex flex-col gap-2" @submit.prevent="setup">
|
||||
<p class="text-center">Mailversand</p>
|
||||
<div class="-space-y-px">
|
||||
<div class="mb-2">
|
||||
<input id="mail" name="mail" type="email" placeholder="Mailadresse" required autocomplete="email" />
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
id="user"
|
||||
name="user"
|
||||
type="text"
|
||||
placeholder="Benutzername (kann auch Mail sein)"
|
||||
required
|
||||
autocomplete="username"
|
||||
class="rounded-b-none!"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Passwort"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
class="rounded-none!"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<input id="host" name="host" type="text" placeholder="Server-Host" required class="rounded-none!" />
|
||||
</div>
|
||||
<div>
|
||||
<input id="port" name="port" type="number" placeholder="Port (25, 465, 587)" required class="rounded-t-none!" />
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2 pt-1">
|
||||
<input type="checkbox" id="secure" />
|
||||
<label for="secure">SSL-Verbindung (setzen bei Port 465)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row gap-2">
|
||||
<button type="submit" primary :disabled="setupStatus == 'loading' || setupStatus == 'success'">
|
||||
Mailversand speichern
|
||||
</button>
|
||||
<Spinner v-if="setupStatus == 'loading'" class="my-auto" />
|
||||
<SuccessCheckmark v-else-if="setupStatus == 'success'" />
|
||||
<FailureXMark v-else-if="setupStatus == 'failed'" />
|
||||
</div>
|
||||
<p v-if="setupMessage" class="text-center">{{ setupMessage }}</p>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import Spinner from "@/components/Spinner.vue";
|
||||
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
|
||||
import FailureXMark from "@/components/FailureXMark.vue";
|
||||
import { mapActions } from "pinia";
|
||||
import { useSetupStore } from "@/stores/setup";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default defineComponent({
|
||||
data() {
|
||||
return {
|
||||
setupStatus: undefined as undefined | "loading" | "success" | "failed",
|
||||
setupMessage: "" as string,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useSetupStore, ["setMailConfig", "skip"]),
|
||||
setup(e: any) {
|
||||
let formData = e.target.elements;
|
||||
this.setupStatus = "loading";
|
||||
this.setupMessage = "";
|
||||
this.setMailConfig({
|
||||
host: formData.host.value,
|
||||
port: formData.port.value,
|
||||
secure: formData.secure.checked,
|
||||
mail: formData.mail.value,
|
||||
username: formData.user.value,
|
||||
password: formData.password.value,
|
||||
})
|
||||
.then((result) => {
|
||||
// this.setupStatus = "success";
|
||||
})
|
||||
.catch((err) => {
|
||||
this.setupStatus = "failed";
|
||||
this.setupMessage = err.response.data;
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -1,15 +1,7 @@
|
|||
export interface Config {
|
||||
server_address: string;
|
||||
app_name_overwrite: string;
|
||||
imprint_link: string;
|
||||
privacy_link: string;
|
||||
custom_login_message: string;
|
||||
}
|
||||
|
||||
export const config: Config = {
|
||||
server_address: import.meta.env.VITE_SERVER_ADDRESS,
|
||||
app_name_overwrite: import.meta.env.VITE_APP_NAME_OVERWRITE,
|
||||
imprint_link: import.meta.env.VITE_IMPRINT_LINK,
|
||||
privacy_link: import.meta.env.VITE_PRIVACY_LINK,
|
||||
custom_login_message: import.meta.env.VITE_CUSTOM_LOGIN_MESSAGE,
|
||||
};
|
||||
|
|
5
src/enums/newsletterConfigEnum.ts
Normal file
5
src/enums/newsletterConfigEnum.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export enum NewsletterConfigEnum {
|
||||
pdf = "pdf",
|
||||
mail = "mail",
|
||||
none = "none",
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
export enum NewsletterConfigType {
|
||||
pdf = "pdf",
|
||||
mail = "mail",
|
||||
}
|
7
src/helpers/crypto.ts
Normal file
7
src/helpers/crypto.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
export async function hashString(message = ""): Promise<string> {
|
||||
const msgUint8 = new TextEncoder().encode(message);
|
||||
const hashBuffer = await window.crypto.subtle.digest("SHA-256", msgUint8);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
return hashHex;
|
||||
}
|
|
@ -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 {
|
||||
let normalized = joinTableFormatter[name];
|
||||
|
|
34
src/main.css
34
src/main.css
|
@ -18,7 +18,7 @@
|
|||
--error: #9a0d55;
|
||||
--warning: #bb6210;
|
||||
--info: #388994;
|
||||
--success: #73ad0f;
|
||||
--success: #7ac142;
|
||||
}
|
||||
.dark {
|
||||
--primary: #ff0d00;
|
||||
|
@ -27,10 +27,12 @@
|
|||
--error: #9a0d55;
|
||||
--warning: #bb6210;
|
||||
--info: #4ccbda;
|
||||
--success: #73ad0f;
|
||||
--success: #7ac142;
|
||||
}
|
||||
}
|
||||
|
||||
@custom-variant hover (&:hover);
|
||||
|
||||
/* ===== Scrollbar CSS ===== */
|
||||
/* Firefox */
|
||||
* {
|
||||
|
@ -72,7 +74,7 @@ a[button] {
|
|||
|
||||
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"]),
|
||||
|
@ -131,29 +133,3 @@ summary > svg {
|
|||
summary::-webkit-details-marker {
|
||||
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!;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,9 @@ import "../node_modules/nprogress/nprogress.css";
|
|||
import { http } from "./serverCom";
|
||||
import "./main.css";
|
||||
|
||||
// auto generates splash screen for iOS
|
||||
import "pwacompat";
|
||||
|
||||
NProgress.configure({ showSpinner: false });
|
||||
|
||||
const app = createApp(App);
|
||||
|
|
|
@ -55,6 +55,7 @@ export async function isAuthenticatedPromise(forceRefresh: boolean = false): Pro
|
|||
// check jwt expiry
|
||||
const exp = decoded.exp ?? 0;
|
||||
const correctedLocalTime = new Date().getTime();
|
||||
let failedRefresh = false;
|
||||
if (exp < Math.floor(correctedLocalTime / 1000) || forceRefresh) {
|
||||
await refreshToken()
|
||||
.then(() => {
|
||||
|
@ -63,13 +64,16 @@ export async function isAuthenticatedPromise(forceRefresh: boolean = false): Pro
|
|||
.catch((err: string) => {
|
||||
console.log("expired");
|
||||
auth.setFailed();
|
||||
failedRefresh = true;
|
||||
reject(err);
|
||||
});
|
||||
}
|
||||
|
||||
if (failedRefresh) return;
|
||||
|
||||
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();
|
||||
reject("nopermissions");
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { useNewsletterStore } from "@/stores/admin/club/newsletter/newsletter";
|
||||
import { useNewsletterDatesStore } from "@/stores/admin/club/newsletter/newsletterDates";
|
||||
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) {
|
||||
const newsletter = useNewsletterStore();
|
||||
|
|
|
@ -3,7 +3,7 @@ import { useProtocolAgendaStore } from "@/stores/admin/club/protocol/protocolAge
|
|||
import { useProtocolDecisionStore } from "@/stores/admin/club/protocol/protocolDecision";
|
||||
import { useProtocolPresenceStore } from "@/stores/admin/club/protocol/protocolPresence";
|
||||
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) {
|
||||
const protocol = useProtocolStore();
|
||||
|
|
|
@ -2,14 +2,12 @@ import { createRouter, createWebHistory } from "vue-router";
|
|||
import Login from "@/views/Login.vue";
|
||||
|
||||
import { isAuthenticated } from "./authGuard";
|
||||
import { loadAccountData } from "./accountGuard";
|
||||
import { isSetup } from "./setupGuard";
|
||||
import { abilityAndNavUpdate } from "./adminGuard";
|
||||
import type { PermissionType, PermissionSection, PermissionModule } from "@/types/permissionTypes";
|
||||
import { resetMemberStores, setMemberId } from "./club/memberGuard";
|
||||
import { resetProtocolStores, setProtocolId } from "./club/protocolGuard";
|
||||
import { resetNewsletterStores, setNewsletterId } from "./club/newsletterGuard";
|
||||
import { config } from "../config";
|
||||
import { setBackupPage } from "./management/backupGuard";
|
||||
import { resetEquipmentTypeStores, setEquipmentTypeId } from "./unit/equipmentType";
|
||||
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",
|
||||
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;
|
||||
|
||||
declare module "vue-router" {
|
||||
|
|
|
@ -135,4 +135,4 @@ async function* streamingFetch(path: string, abort?: AbortController) {
|
|||
}
|
||||
}
|
||||
|
||||
export { http, newEventSource, streamingFetch, host };
|
||||
export { http, newEventSource, streamingFetch, host, url };
|
||||
|
|
|
@ -11,21 +11,18 @@ export const useAbilityStore = defineStore("ability", {
|
|||
getters: {
|
||||
can:
|
||||
(state) =>
|
||||
(type: PermissionType | "admin", section: PermissionSection, module?: PermissionModule): boolean => {
|
||||
(type: PermissionType | "admin", section: PermissionSection, module: PermissionModule): boolean => {
|
||||
const permissions = state.permissions;
|
||||
if (state.isOwner) return true;
|
||||
if (type == "admin") return permissions?.admin ?? false;
|
||||
if (permissions?.admin) return true;
|
||||
if (type == "admin") return permissions?.admin ?? permissions?.adminByOwner ?? false;
|
||||
if (permissions?.admin || permissions?.adminByOwner) return true;
|
||||
if (
|
||||
(!module &&
|
||||
permissions[section] != undefined &&
|
||||
(permissions[section]?.all == "*" || permissions[section]?.all?.includes(type))) ||
|
||||
permissions[section]?.all == "*" ||
|
||||
permissions[section]?.all?.includes(type)
|
||||
permissions[section]?.all?.includes(type) ||
|
||||
permissions[section]?.[module] == "*" ||
|
||||
permissions[section]?.[module]?.includes(type)
|
||||
)
|
||||
return true;
|
||||
if (module && (permissions[section]?.[module] == "*" || permissions[section]?.[module]?.includes(type)))
|
||||
return true;
|
||||
return false;
|
||||
},
|
||||
canSection:
|
||||
|
@ -33,8 +30,8 @@ export const useAbilityStore = defineStore("ability", {
|
|||
(type: PermissionType | "admin", section: PermissionSection): boolean => {
|
||||
const permissions = state.permissions;
|
||||
if (state.isOwner) return true;
|
||||
if (type == "admin") return permissions?.admin ?? false;
|
||||
if (permissions?.admin) return true;
|
||||
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) ||
|
||||
|
@ -54,20 +51,31 @@ export const useAbilityStore = defineStore("ability", {
|
|||
permissions: PermissionObject,
|
||||
type: PermissionType | "admin",
|
||||
section: PermissionSection,
|
||||
module?: PermissionModule
|
||||
module: PermissionModule
|
||||
): boolean => {
|
||||
// ignores ownership
|
||||
if (type == "admin") return permissions?.admin ?? false;
|
||||
if (permissions?.admin) return true;
|
||||
if (type == "admin") return permissions?.admin ?? permissions?.adminByOwner ?? false;
|
||||
if (permissions?.admin || permissions?.adminByOwner) return true;
|
||||
if (
|
||||
(!module &&
|
||||
permissions[section] != undefined &&
|
||||
(permissions[section]?.all == "*" || permissions[section]?.all?.includes(type))) ||
|
||||
permissions[section]?.all == "*" ||
|
||||
permissions[section]?.all?.includes(type)
|
||||
permissions[section]?.all?.includes(type) ||
|
||||
permissions[section]?.[module] == "*" ||
|
||||
permissions[section]?.[module]?.includes(type)
|
||||
)
|
||||
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 false;
|
||||
},
|
||||
|
|
|
@ -87,6 +87,9 @@ export const useMemberStore = defineStore("member", {
|
|||
})
|
||||
.catch((err) => {});
|
||||
},
|
||||
fetchLastInternalId() {
|
||||
return http.get(`/admin/member/last/internalId`);
|
||||
},
|
||||
async printMemberByActiveId() {
|
||||
return http.get(`/admin/member/${this.activeMember}/print`, {
|
||||
responseType: "blob",
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import { defineStore } from "pinia";
|
||||
import { http, newEventSource, streamingFetch } from "@/serverCom";
|
||||
import { http, streamingFetch } from "@/serverCom";
|
||||
import { useNewsletterStore } from "./newsletter";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import type { EventSourcePolyfill } from "event-source-polyfill";
|
||||
import { useNotificationStore, type NotificationType } from "../../../notification";
|
||||
import { useNotificationStore, type NotificationType } from "@/stores/notification";
|
||||
|
||||
export const useNewsletterPrintoutStore = defineStore("newsletterPrintout", {
|
||||
state: () => {
|
||||
|
|
|
@ -2,6 +2,7 @@ import { defineStore } from "pinia";
|
|||
import { http } from "@/serverCom";
|
||||
import type { TableMeta } from "@/viewmodels/admin/configuration/query.models";
|
||||
import type { DynamicQueryStructure, FieldType } from "@/types/dynamicQueries";
|
||||
import type { AxiosResponse } from "axios";
|
||||
|
||||
export const useQueryBuilderStore = defineStore("queryBuilder", {
|
||||
state: () => {
|
||||
|
@ -58,6 +59,16 @@ export const useQueryBuilderStore = defineStore("queryBuilder", {
|
|||
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() {
|
||||
this.data = [];
|
||||
this.totalLength = 0;
|
||||
|
|
|
@ -6,7 +6,7 @@ import type {
|
|||
import { http } from "@/serverCom";
|
||||
import type { AxiosResponse } from "axios";
|
||||
|
||||
export const useNewsletterConfigStore = defineStore("newsletterConfi", {
|
||||
export const useNewsletterConfigStore = defineStore("newsletterConfig", {
|
||||
state: () => {
|
||||
return {
|
||||
config: [] as Array<NewsletterConfigViewModel>,
|
||||
|
|
|
@ -6,10 +6,10 @@ import type {
|
|||
} from "@/viewmodels/admin/configuration/query.models";
|
||||
import { http } from "@/serverCom";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useQueryBuilderStore } from "../club/queryBuilder";
|
||||
import { useModalStore } from "../../modal";
|
||||
import { useQueryBuilderStore } from "@/stores/admin/club/queryBuilder";
|
||||
import { useModalStore } from "@/stores/modal";
|
||||
import { defineAsyncComponent, markRaw } from "vue";
|
||||
import { useAbilityStore } from "../../ability";
|
||||
import { useAbilityStore } from "@/stores/ability";
|
||||
|
||||
export const useQueryStoreStore = defineStore("queryStore", {
|
||||
state: () => {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { defineStore } from "pinia";
|
||||
import { http } from "@/serverCom";
|
||||
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", {
|
||||
state: () => {
|
||||
|
|
103
src/stores/admin/management/setting.ts
Normal file
103
src/stores/admin/management/setting.ts
Normal file
|
@ -0,0 +1,103 @@
|
|||
import { defineStore } from "pinia";
|
||||
import { http } from "@/serverCom";
|
||||
import type { SettingString, SettingTopic, SettingValueMapping } from "@/types/settingTypes";
|
||||
import type { AxiosResponse } from "axios";
|
||||
|
||||
export const useSettingStore = defineStore("setting", {
|
||||
state: () => {
|
||||
return {
|
||||
settings: {} as { [key in SettingString]: SettingValueMapping[key] },
|
||||
loading: "loading" as "loading" | "fetched" | "failed",
|
||||
};
|
||||
},
|
||||
getters: {
|
||||
readSetting:
|
||||
(state) =>
|
||||
<K extends SettingString>(key: K): SettingValueMapping[K] => {
|
||||
return state.settings[key];
|
||||
},
|
||||
readByTopic:
|
||||
(state) =>
|
||||
<T extends SettingTopic>(
|
||||
topic: T
|
||||
): { [K in SettingString as K extends `${T}.${string}` ? K : never]: SettingValueMapping[K] } => {
|
||||
return Object.entries(state.settings).reduce((acc, [key, value]) => {
|
||||
const typedKey = key as SettingString;
|
||||
if (key.startsWith(topic)) {
|
||||
acc[typedKey] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {} as any);
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
fetchSettings() {
|
||||
this.loading = "loading";
|
||||
http
|
||||
.get("/admin/setting")
|
||||
.then((result) => {
|
||||
this.settings = result.data;
|
||||
this.loading = "fetched";
|
||||
})
|
||||
.catch((err) => {
|
||||
this.loading = "failed";
|
||||
});
|
||||
},
|
||||
async getSetting(key: SettingString): Promise<AxiosResponse<any, any>> {
|
||||
return await http.get(`/admin/setting/${key}`).then((res) => {
|
||||
//@ts-expect-error
|
||||
this.settings[key] = res.data;
|
||||
return res;
|
||||
});
|
||||
},
|
||||
async updateSetting<K extends SettingString>(
|
||||
key: K,
|
||||
value: SettingValueMapping[K]
|
||||
): Promise<AxiosResponse<any, any>> {
|
||||
return await http
|
||||
.put("/admin/setting", {
|
||||
setting: key,
|
||||
value: value,
|
||||
})
|
||||
.then((res) => {
|
||||
this.settings[key] = value;
|
||||
return res;
|
||||
});
|
||||
},
|
||||
async updateSettings<K extends SettingString>(
|
||||
data: { key: K; value: SettingValueMapping[K] }[]
|
||||
): Promise<AxiosResponse<any, any>> {
|
||||
return await http.put("/admin/setting/multi", data).then((res) => {
|
||||
for (const element of data) {
|
||||
this.settings[element.key] = element.value;
|
||||
}
|
||||
return res;
|
||||
});
|
||||
},
|
||||
async uploadImage(
|
||||
data: { key: "club.logo" | "club.icon"; value?: File | "keep" }[]
|
||||
): Promise<AxiosResponse<any, any>> {
|
||||
const formData = new FormData();
|
||||
for (let entry of data) {
|
||||
if (entry.value) {
|
||||
formData.append(typeof entry.value == "string" ? entry.key : entry.key.split(".")[1], entry.value);
|
||||
}
|
||||
}
|
||||
return await http
|
||||
.put("/admin/setting/images", formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
})
|
||||
.then((res) => {
|
||||
for (const element of data) {
|
||||
this.settings[element.key] = element.value ? "configured" : "";
|
||||
}
|
||||
return res;
|
||||
});
|
||||
},
|
||||
async resetSetting(key: SettingString): Promise<AxiosResponse<any, any>> {
|
||||
return await http.delete(`/admin/setting/${key}`);
|
||||
},
|
||||
},
|
||||
});
|
|
@ -1,7 +1,7 @@
|
|||
import { defineStore } from "pinia";
|
||||
import { useAbilityStore } from "@/stores/ability";
|
||||
import router from "@/router";
|
||||
import type { PermissionSection } from "../../types/permissionTypes";
|
||||
import type { PermissionSection } from "@/types/permissionTypes";
|
||||
|
||||
export type navigationModel = {
|
||||
[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", "role") ? [{ key: "role", title: "Rollen" }] : []),
|
||||
...(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.isAdmin() ? [{ key: "version", title: "Version" }] : []),
|
||||
],
|
||||
|
|
34
src/stores/configuration.ts
Normal file
34
src/stores/configuration.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { defineStore } from "pinia";
|
||||
import { http } from "@/serverCom";
|
||||
|
||||
export const useConfigurationStore = defineStore("configuration", {
|
||||
state: () => {
|
||||
return {
|
||||
clubName: "",
|
||||
clubImprint: "",
|
||||
clubPrivacy: "",
|
||||
clubWebsite: "",
|
||||
appCustom_login_message: "",
|
||||
appShow_link_to_calendar: false as boolean,
|
||||
|
||||
serverOffline: false as boolean,
|
||||
};
|
||||
},
|
||||
actions: {
|
||||
configure() {
|
||||
http
|
||||
.get("/public/configuration")
|
||||
.then((res) => {
|
||||
this.clubName = res.data["club.name"];
|
||||
this.clubImprint = res.data["club.imprint"];
|
||||
this.clubPrivacy = res.data["club.privacy"];
|
||||
this.clubWebsite = res.data["club.website"];
|
||||
this.appCustom_login_message = res.data["app.custom_login_message"];
|
||||
this.appShow_link_to_calendar = res.data["app.show_link_to_calendar"];
|
||||
})
|
||||
.catch(() => {
|
||||
this.serverOffline = true;
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
130
src/stores/setup.ts
Normal file
130
src/stores/setup.ts
Normal file
|
@ -0,0 +1,130 @@
|
|||
import { defineStore } from "pinia";
|
||||
import { http } from "@/serverCom";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useConfigurationStore } from "./configuration";
|
||||
|
||||
export const useSetupStore = defineStore("setup", {
|
||||
state: () => {
|
||||
return {
|
||||
dictionary: ["club", "clubImages", "app", "mail", "account", "finished"],
|
||||
step: 0 as number,
|
||||
successfull: 0 as number,
|
||||
};
|
||||
},
|
||||
getters: {
|
||||
stepIndex: (state) => (dict: string) => state.dictionary.findIndex((d) => d == dict),
|
||||
},
|
||||
actions: {
|
||||
skip(dict: string) {
|
||||
let myIndex = this.stepIndex(dict);
|
||||
this.step += 1;
|
||||
if (this.successfull <= myIndex) {
|
||||
this.successfull = myIndex + 1;
|
||||
}
|
||||
},
|
||||
async setClub(data: {
|
||||
name?: string;
|
||||
imprint?: string;
|
||||
privacy?: string;
|
||||
website?: string;
|
||||
}): Promise<AxiosResponse<any, any>> {
|
||||
let configStore = useConfigurationStore();
|
||||
|
||||
let myIndex = this.stepIndex("club");
|
||||
const res = await http.post(`/setup/club`, {
|
||||
name: data.name,
|
||||
imprint: data.imprint,
|
||||
privacy: data.privacy,
|
||||
website: data.website,
|
||||
});
|
||||
configStore.configure();
|
||||
|
||||
this.step += 1;
|
||||
if (this.successfull <= myIndex) {
|
||||
this.successfull = myIndex;
|
||||
}
|
||||
return res;
|
||||
},
|
||||
async setClubImages(data: { icon?: File; logo?: File }): Promise<AxiosResponse<any, any>> {
|
||||
let configStore = useConfigurationStore();
|
||||
|
||||
let myIndex = this.stepIndex("clubImages");
|
||||
const formData = new FormData();
|
||||
|
||||
if (data.icon) {
|
||||
formData.append("icon", data.icon);
|
||||
}
|
||||
|
||||
if (data.logo) {
|
||||
formData.append("logo", data.logo);
|
||||
}
|
||||
|
||||
const res = await http.post(`/setup/club/images`, formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
configStore.configure();
|
||||
|
||||
this.step += 1;
|
||||
if (this.successfull <= myIndex) {
|
||||
this.successfull = myIndex;
|
||||
}
|
||||
return res;
|
||||
},
|
||||
async setApp(data: { login_message: string; show_cal_link: boolean }): Promise<AxiosResponse<any, any>> {
|
||||
let myIndex = this.stepIndex("app");
|
||||
const res = await http.post(`/setup/app`, {
|
||||
custom_login_message: data.login_message,
|
||||
show_link_to_calendar: data.show_cal_link,
|
||||
});
|
||||
this.step += 1;
|
||||
if (this.successfull <= myIndex) {
|
||||
this.successfull = myIndex;
|
||||
}
|
||||
return res;
|
||||
},
|
||||
async setMailConfig(data: {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
mail: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}): Promise<AxiosResponse<any, any>> {
|
||||
let myIndex = this.stepIndex("mail");
|
||||
const res = await http.post(`/setup/mail`, {
|
||||
host: data.host,
|
||||
port: data.port,
|
||||
secure: data.secure,
|
||||
mail: data.mail,
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
});
|
||||
this.step += 1;
|
||||
if (this.successfull <= myIndex) {
|
||||
this.successfull = myIndex;
|
||||
}
|
||||
return res;
|
||||
},
|
||||
async createAdmin(credentials: {
|
||||
username: string;
|
||||
mail: string;
|
||||
firstname: string;
|
||||
lastname: string;
|
||||
}): Promise<AxiosResponse<any, any>> {
|
||||
let myIndex = this.stepIndex("account");
|
||||
const res = await http.post(`/setup/me`, {
|
||||
username: credentials.username,
|
||||
mail: credentials.mail,
|
||||
firstname: credentials.firstname,
|
||||
lastname: credentials.lastname,
|
||||
});
|
||||
this.step += 1;
|
||||
if (this.successfull < myIndex) {
|
||||
this.successfull = myIndex;
|
||||
}
|
||||
return res;
|
||||
},
|
||||
},
|
||||
});
|
|
@ -1,9 +1,10 @@
|
|||
export interface DynamicQueryStructure {
|
||||
id: string;
|
||||
select: string[] | "*";
|
||||
table: string;
|
||||
where?: Array<ConditionStructure>;
|
||||
join?: Array<DynamicQueryStructure & { foreignColumn: string }>;
|
||||
orderBy?: Array<OrderByStructure>;
|
||||
join?: Array<DynamicQueryStructure & JoinStructure>;
|
||||
orderBy?: Array<OrderByStructure>; // only at top level
|
||||
}
|
||||
|
||||
export type ConditionStructure = (
|
||||
|
@ -47,7 +48,12 @@ export type WhereOperation =
|
|||
| "timespanEq"; // Date before x years (YYYY-01-01 <bis> YYYY-12-31)
|
||||
// TODO: age between | age equals | age greater | age smaller
|
||||
|
||||
export type JoinStructure = { foreignColumn: string; type: "defined" } | { condition: string; type: "custom" };
|
||||
|
||||
export type OrderByStructure = {
|
||||
id: string;
|
||||
depth: number;
|
||||
table: string;
|
||||
column: string;
|
||||
order: OrderByType;
|
||||
};
|
||||
|
|
|
@ -36,7 +36,8 @@ export type PermissionModule =
|
|||
// management
|
||||
| "user"
|
||||
| "role"
|
||||
| "webapi";
|
||||
| "webapi"
|
||||
| "setting";
|
||||
|
||||
export type PermissionType = "read" | "create" | "update" | "delete";
|
||||
|
||||
|
@ -45,6 +46,7 @@ export type PermissionString =
|
|||
| `${PermissionSection}.${PermissionModule}.*` // für alle Berechtigungen in einem Modul
|
||||
| `${PermissionSection}.${PermissionType}` // für spezifische Berechtigungen in einem Abschnitt
|
||||
| `${PermissionSection}.*` // für alle Berechtigungen in einem Abschnitt
|
||||
| `additional.${string}.${string}` // additional
|
||||
| "*"; // für Admin
|
||||
|
||||
export type PermissionObject = {
|
||||
|
@ -53,10 +55,20 @@ export type PermissionObject = {
|
|||
} & { all?: Array<PermissionType> | "*" };
|
||||
} & {
|
||||
admin?: boolean;
|
||||
adminByOwner?: boolean;
|
||||
} & {
|
||||
additional?: { [key: string]: string };
|
||||
};
|
||||
|
||||
export type SectionsAndModulesObject = {
|
||||
[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"];
|
||||
|
@ -97,6 +109,7 @@ export const permissionModules: Array<PermissionModule> = [
|
|||
"user",
|
||||
"role",
|
||||
"webapi",
|
||||
"setting",
|
||||
];
|
||||
export const permissionTypes: Array<PermissionType> = ["read", "create", "update", "delete"];
|
||||
export const sectionsAndModules: SectionsAndModulesObject = {
|
||||
|
@ -127,5 +140,8 @@ export const sectionsAndModules: SectionsAndModulesObject = {
|
|||
"template_usage",
|
||||
"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
80
src/types/settingTypes.ts
Normal file
|
@ -0,0 +1,80 @@
|
|||
export type SettingTopic = "club" | "app" | "session" | "mail" | "backup" | "security";
|
||||
export type SettingString =
|
||||
| "club.icon"
|
||||
| "club.logo"
|
||||
| "club.name"
|
||||
| "club.imprint"
|
||||
| "club.privacy"
|
||||
| "club.website"
|
||||
| "app.custom_login_message"
|
||||
| "app.show_link_to_calendar"
|
||||
| "session.jwt_expiration"
|
||||
| "session.refresh_expiration"
|
||||
| "session.pwa_refresh_expiration"
|
||||
| "mail.email"
|
||||
| "mail.username"
|
||||
| "mail.password"
|
||||
| "mail.host"
|
||||
| "mail.port"
|
||||
| "mail.secure"
|
||||
| "backup.interval"
|
||||
| "backup.copies";
|
||||
|
||||
export type SettingTypeAtom = "longstring" | "string" | "ms" | "number" | "boolean" | "url" | "email";
|
||||
export type SettingType = SettingTypeAtom | `${SettingTypeAtom}/crypt` | `${SettingTypeAtom}/rand`;
|
||||
|
||||
export type SettingValueMapping = {
|
||||
"club.icon": string;
|
||||
"club.logo": string;
|
||||
"club.name": string;
|
||||
"club.imprint": string;
|
||||
"club.privacy": string;
|
||||
"club.website": string;
|
||||
"app.custom_login_message": string;
|
||||
"app.show_link_to_calendar": boolean;
|
||||
"session.jwt_expiration": string;
|
||||
"session.refresh_expiration": string;
|
||||
"session.pwa_refresh_expiration": string;
|
||||
"mail.email": string;
|
||||
"mail.username": string;
|
||||
"mail.password": string;
|
||||
"mail.host": string;
|
||||
"mail.port": number;
|
||||
"mail.secure": boolean;
|
||||
"backup.interval": number;
|
||||
"backup.copies": number;
|
||||
};
|
||||
|
||||
// Typsicherer Zugriff auf Settings
|
||||
export type SettingDefinition<T extends SettingType | SettingTypeAtom[]> = {
|
||||
type: T;
|
||||
default?: string | number | boolean;
|
||||
optional?: boolean;
|
||||
min?: T extends "number" | `number/crypt` | `number/rand` ? number : never;
|
||||
};
|
||||
|
||||
export type SettingsSchema = {
|
||||
[key in SettingString]: SettingDefinition<SettingType | SettingTypeAtom[]>;
|
||||
};
|
||||
|
||||
export const settingsType: SettingsSchema = {
|
||||
"club.icon": { type: "string", optional: true },
|
||||
"club.logo": { type: "string", optional: true },
|
||||
"club.name": { type: "string", default: "FF Admin" },
|
||||
"club.imprint": { type: "url", optional: true },
|
||||
"club.privacy": { type: "url", optional: true },
|
||||
"club.website": { type: "url", optional: true },
|
||||
"app.custom_login_message": { type: "string", optional: true },
|
||||
"app.show_link_to_calendar": { type: "boolean", default: true },
|
||||
"session.jwt_expiration": { type: "ms", default: "15m" },
|
||||
"session.refresh_expiration": { type: "ms", default: "1d" },
|
||||
"session.pwa_refresh_expiration": { type: "ms", default: "5d" },
|
||||
"mail.email": { type: "email", optional: false },
|
||||
"mail.username": { type: "string", optional: false },
|
||||
"mail.password": { type: "string/crypt", optional: false },
|
||||
"mail.host": { type: "url", optional: false },
|
||||
"mail.port": { type: "number", default: 587 },
|
||||
"mail.secure": { type: "boolean", default: false },
|
||||
"backup.interval": { type: "number", default: 1, min: 1 },
|
||||
"backup.copies": { type: "number", default: 7, min: 1 },
|
||||
};
|
|
@ -1,4 +1,4 @@
|
|||
import type { CalendarTypeViewModel } from "../configuration/calendarType.models";
|
||||
import type { CalendarTypeViewModel } from "@/viewmodels/admin/configuration/calendarType.models";
|
||||
|
||||
export interface CalendarViewModel {
|
||||
id: string;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { CommunicationTypeViewModel } from "../../configuration/communicationType.models";
|
||||
import type { CommunicationTypeViewModel } from "@/viewmodels/admin/configuration/communicationType.models";
|
||||
|
||||
export interface CommunicationViewModel {
|
||||
id: number;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import type { CommunicationViewModel } from "./communication.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 {
|
||||
id: string;
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import type { QueryViewModel } from "../../configuration/query.models";
|
||||
|
||||
export interface NewsletterViewModel {
|
||||
id: number;
|
||||
title: string;
|
||||
|
@ -9,7 +7,6 @@ export interface NewsletterViewModel {
|
|||
newsletterSignatur: string;
|
||||
isSent: boolean;
|
||||
recipientsByQueryId?: string | null;
|
||||
recipientsByQuery?: QueryViewModel | null;
|
||||
}
|
||||
|
||||
export interface CreateNewsletterViewModel {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { CalendarViewModel } from "../calendar.models";
|
||||
import type { CalendarViewModel } from "@/viewmodels/admin/club/calendar.models";
|
||||
|
||||
export interface NewsletterDatesViewModel {
|
||||
newsletterId: number;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { MemberViewModel } from "../member/member.models";
|
||||
import type { MemberViewModel } from "@/viewmodels/admin/club/member/member.models";
|
||||
|
||||
export interface NewsletterRecipientsViewModel {
|
||||
newsletterId: number;
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
import type { NewsletterConfigType } from "@/enums/newsletterConfigType";
|
||||
import type { NewsletterConfigEnum } from "@/enums/newsletterConfigEnum";
|
||||
import type { CommunicationTypeViewModel } from "./communicationType.models";
|
||||
|
||||
export interface NewsletterConfigViewModel {
|
||||
comTypeId: number;
|
||||
config: NewsletterConfigType;
|
||||
config: NewsletterConfigEnum;
|
||||
comType: CommunicationTypeViewModel;
|
||||
}
|
||||
|
||||
export interface SetNewsletterConfigViewModel {
|
||||
comTypeId: number;
|
||||
config: NewsletterConfigType;
|
||||
config: NewsletterConfigEnum;
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { BackupSection } from "../../../types/backupTypes";
|
||||
import type { BackupSection } from "@/types/backupTypes";
|
||||
|
||||
export interface BackupRestoreViewModel {
|
||||
filename: string;
|
||||
|
|
|
@ -2,19 +2,34 @@
|
|||
<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="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">
|
||||
{{ config.app_name_overwrite || "FF Admin" }}
|
||||
{{ clubName }}
|
||||
</h2>
|
||||
</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>
|
||||
<input id="username" name="username" type="text" required placeholder="Benutzer" class="rounded-b-none!" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="relative">
|
||||
<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"
|
||||
name="totp"
|
||||
type="text"
|
||||
|
@ -23,13 +38,26 @@
|
|||
class="rounded-t-none!"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
placeholder="Passwort"
|
||||
class="rounded-t-none!"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</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">
|
||||
<button type="submit" primary :disabled="loginStatus == 'loading' || loginStatus == 'success'">
|
||||
anmelden
|
||||
{{ routine == "" ? "Benutzer prüfen" : "anmelden" }}
|
||||
</button>
|
||||
<Spinner v-if="loginStatus == 'loading'" class="my-auto" />
|
||||
<SuccessCheckmark v-else-if="loginStatus == 'success'" />
|
||||
|
@ -50,7 +78,10 @@ import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
|
|||
import FailureXMark from "@/components/FailureXMark.vue";
|
||||
import { resetAllPiniaStores } from "@/helpers/piniaReset";
|
||||
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 lang="ts">
|
||||
|
@ -58,21 +89,90 @@ export default defineComponent({
|
|||
data() {
|
||||
return {
|
||||
loginStatus: undefined as undefined | "loading" | "success" | "failed",
|
||||
usernameStatus: undefined as undefined | "loading" | "success" | "failed",
|
||||
loginError: "" as string,
|
||||
username: "" as string,
|
||||
routine: "" as string,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState(useConfigurationStore, ["clubName"]),
|
||||
},
|
||||
mounted() {
|
||||
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: {
|
||||
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;
|
||||
this.loginStatus = "loading";
|
||||
this.loginError = "";
|
||||
|
||||
let secret = "";
|
||||
if (this.routine == "totp") {
|
||||
secret = formData.totp.value;
|
||||
} else {
|
||||
secret = await hashString(formData.password.value);
|
||||
}
|
||||
|
||||
this.$http
|
||||
.post(`/auth/login`, {
|
||||
username: formData.username.value,
|
||||
totp: formData.totp.value,
|
||||
secret: secret,
|
||||
})
|
||||
.then((result) => {
|
||||
this.loginStatus = "success";
|
||||
|
|
|
@ -6,29 +6,41 @@
|
|||
</div>
|
||||
</template>
|
||||
<template #diffMain>
|
||||
<div class="flex flex-col w-full h-full gap-2 justify-between px-7 overflow-hidden">
|
||||
<div class="flex flex-col gap-2">
|
||||
<img :src="image" alt="totp" class="w-56 h-56 self-center" />
|
||||
|
||||
<TextCopy :copyText="otp" />
|
||||
<Spinner v-if="loading" class="mx-auto" />
|
||||
<div v-else class="flex flex-col w-full h-full gap-2 px-7 overflow-hidden">
|
||||
<div class="w-full flex flex-row gap-2 justify-center">
|
||||
<p
|
||||
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>
|
||||
<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>
|
||||
<ChangeToTOTP
|
||||
v-if="currentRoutine == 'password' && tab == 'totp'"
|
||||
:currentRoutine="currentRoutine"
|
||||
@updateCurrent="currentRoutine = 'totp'"
|
||||
/>
|
||||
<ChangeToPassword
|
||||
v-else-if="currentRoutine == 'totp' && tab == 'password'"
|
||||
:currentRoutine="currentRoutine"
|
||||
@updateCurrent="currentRoutine = 'password'"
|
||||
/>
|
||||
<TotpCheckAndScan v-else-if="tab == 'totp'" />
|
||||
<PasswordChange v-else-if="tab == 'password'" />
|
||||
<p v-else>etwas ist schief gelaufen</p>
|
||||
</div>
|
||||
</template>
|
||||
</MainTemplate>
|
||||
|
@ -42,53 +54,34 @@ 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 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 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,
|
||||
loading: false,
|
||||
tab: "",
|
||||
currentRoutine: "",
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.loading = true;
|
||||
this.$http
|
||||
.get(`/user/totp`)
|
||||
.get(`/user/routine`)
|
||||
.then((result) => {
|
||||
this.verification = "success";
|
||||
this.image = result.data.dataUrl;
|
||||
this.otp = result.data.otp;
|
||||
this.tab = result.data.routine;
|
||||
this.currentRoutine = result.data.routine;
|
||||
})
|
||||
.catch((err) => {
|
||||
this.verification = "failed";
|
||||
.catch((err) => {})
|
||||
.finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
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);
|
||||
});
|
||||
},
|
||||
},
|
||||
methods: {},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -1,11 +1,7 @@
|
|||
<template>
|
||||
<SidebarLayout>
|
||||
<template #sidebar>
|
||||
<SidebarTemplate
|
||||
mainTitle="Mein Account"
|
||||
:topTitle="config.app_name_overwrite || 'FF Admin'"
|
||||
:showTopList="isOwner"
|
||||
>
|
||||
<SidebarTemplate mainTitle="Mein Account" :topTitle="clubName" :showTopList="isOwner">
|
||||
<template v-if="isOwner" #topList>
|
||||
<RoutingLink
|
||||
title="Administration"
|
||||
|
@ -42,13 +38,14 @@ import SidebarTemplate from "@/templates/Sidebar.vue";
|
|||
import RoutingLink from "@/components/admin/RoutingLink.vue";
|
||||
import { RouterView } from "vue-router";
|
||||
import { useAbilityStore } from "@/stores/ability";
|
||||
import { config } from "@/config";
|
||||
import { useConfigurationStore } from "@/stores/configuration";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default defineComponent({
|
||||
computed: {
|
||||
...mapState(useAbilityStore, ["isOwner"]),
|
||||
...mapState(useConfigurationStore, ["clubName"]),
|
||||
activeRouteName() {
|
||||
return this.$route.name;
|
||||
},
|
||||
|
|
|
@ -4,14 +4,17 @@
|
|||
<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>
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #diffMain>
|
||||
<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>
|
||||
</template>
|
||||
</MainTemplate>
|
||||
|
@ -22,14 +25,10 @@ import { defineComponent, markRaw, defineAsyncComponent } from "vue";
|
|||
import { mapActions, mapState } from "pinia";
|
||||
import { useModalStore } from "@/stores/modal";
|
||||
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 { useAbilityStore } from "@/stores/ability";
|
||||
import { LinkIcon, PlusIcon } from "@heroicons/vue/24/outline";
|
||||
import CustomCalendar from "@/components/CustomCalendar.vue";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
|
@ -40,33 +39,6 @@ export default defineComponent({
|
|||
computed: {
|
||||
...mapState(useCalendarStore, ["formattedItems"]),
|
||||
...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() {
|
||||
this.fetchCalendars();
|
||||
|
@ -74,22 +46,22 @@ export default defineComponent({
|
|||
methods: {
|
||||
...mapActions(useModalStore, ["openModal"]),
|
||||
...mapActions(useCalendarStore, ["fetchCalendars"]),
|
||||
select(e: any) {
|
||||
select({ start, end, allDay }: { start: string; end: string; allDay: boolean }) {
|
||||
if (!this.can("create", "club", "calendar")) return;
|
||||
this.openModal(
|
||||
markRaw(defineAsyncComponent(() => import("@/components/admin/club/calendar/CreateCalendarModal.vue"))),
|
||||
{
|
||||
start: e?.startStr ?? new Date().toISOString(),
|
||||
end: e?.endStr ?? new Date().toISOString(),
|
||||
allDay: e?.allDay ?? false,
|
||||
start,
|
||||
end,
|
||||
allDay,
|
||||
}
|
||||
);
|
||||
},
|
||||
eventClick(e: any) {
|
||||
eventClick(id: string) {
|
||||
if (!this.can("update", "club", "calendar")) return;
|
||||
this.openModal(
|
||||
markRaw(defineAsyncComponent(() => import("@/components/admin/club/calendar/UpdateCalendarModal.vue"))),
|
||||
e.event.id
|
||||
id
|
||||
);
|
||||
},
|
||||
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>
|
||||
|
|
|
@ -103,7 +103,7 @@ import { Listbox, ListboxButton, ListboxOptions, ListboxOption, ListboxLabel } f
|
|||
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
|
||||
import cloneDeep from "lodash.clonedeep";
|
||||
import isEqual from "lodash.isequal";
|
||||
import { useSalutationStore } from "../../../../stores/admin/configuration/salutation";
|
||||
import { useSalutationStore } from "@/stores/admin/configuration/salutation";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
|
|
|
@ -38,7 +38,7 @@ import Pagination from "@/components/Pagination.vue";
|
|||
import { useAbilityStore } from "@/stores/ability";
|
||||
import { useNewsletterStore } from "@/stores/admin/club/newsletter/newsletter";
|
||||
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 lang="ts">
|
||||
|
|
|
@ -81,7 +81,7 @@ import FailureXMark from "@/components/FailureXMark.vue";
|
|||
import { ArrowDownTrayIcon, ViewfinderCircleIcon } from "@heroicons/vue/24/outline";
|
||||
import { useModalStore } from "@/stores/modal";
|
||||
import { useAbilityStore } from "@/stores/ability";
|
||||
import { useNewsletterPrintoutStore } from "../../../../stores/admin/club/newsletter/newsletterPrintout";
|
||||
import { useNewsletterPrintoutStore } from "@/stores/admin/club/newsletter/newsletterPrintout";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
|
|
|
@ -4,52 +4,56 @@
|
|||
<p v-else-if="loading == 'failed'" @click="fetchNewsletterRecipients" class="cursor-pointer">
|
||||
↺ laden fehlgeschlagen
|
||||
</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">
|
||||
<option value="def">Optional</option>
|
||||
<option v-for="query in queries" :key="query.id" :value="query.id">{{ query.title }}</option>
|
||||
</select>
|
||||
<p>Empfänger durch gespeicherte Abfrage</p>
|
||||
<div class="flex flex-col gap-2 grow overflow-y-auto">
|
||||
<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 title="Empfänger manuell hinzufügen" @click="showMemberSelect = true">
|
||||
<UserPlusIcon class="w-7 h-7 cursor-pointer" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 h-1/2">
|
||||
<div v-else class="flex flex-row gap-2 items-center">
|
||||
<MemberSearchSelectMultiple
|
||||
title="weitere Empfänger suchen"
|
||||
showTitleAsPlaceholder
|
||||
v-model="recipients"
|
||||
:disabled="!can('create', 'club', 'newsletter')"
|
||||
/>
|
||||
|
||||
<p>Ausgewählte Empfänger</p>
|
||||
<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 title="Empfänger über Query hinzufügen" @click="showMemberSelect = false">
|
||||
<ArchiveBoxIcon class="w-7 h-7 cursor-pointer" />
|
||||
</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>
|
||||
</template>
|
||||
|
||||
|
@ -67,7 +71,7 @@ import {
|
|||
TransitionRoot,
|
||||
} from "@headlessui/vue";
|
||||
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 type { MemberViewModel } from "@/viewmodels/admin/club/member/member.models";
|
||||
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 cloneDeep from "lodash.clonedeep";
|
||||
import MemberSearchSelectMultiple from "@/components/search/MemberSearchSelectMultiple.vue";
|
||||
import MemberSearchSelect from "@/components/search/MemberSearchSelect.vue";
|
||||
import type { FieldType } from "@/types/dynamicQueries";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
|
@ -84,22 +90,17 @@ export default defineComponent({
|
|||
props: {
|
||||
newsletterId: String,
|
||||
},
|
||||
watch: {
|
||||
recipientsByQuery() {
|
||||
this.loadQuery();
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
query: "" as String,
|
||||
queryResult: [] as Array<{ id: FieldType; [key: string]: FieldType }>,
|
||||
members: [] as Array<MemberViewModel>,
|
||||
showMemberSelect: false as boolean,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapWritableState(useNewsletterRecipientsStore, ["recipients", "loading"]),
|
||||
...mapWritableState(useNewsletterStore, ["activeNewsletterObj"]),
|
||||
...mapState(useQueryStoreStore, ["queries"]),
|
||||
...mapState(useQueryBuilderStore, ["data"]),
|
||||
...mapState(useAbilityStore, ["can"]),
|
||||
selected(): Array<MemberViewModel> {
|
||||
return this.members
|
||||
|
@ -114,10 +115,10 @@ export default defineComponent({
|
|||
},
|
||||
queried(): Array<MemberViewModel> {
|
||||
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"));
|
||||
return this.members.filter((m) =>
|
||||
this.data
|
||||
this.queryResult
|
||||
.map((t) => ({
|
||||
id: t.id,
|
||||
...(memberKey ? { memberId: t[memberKey] } : {}),
|
||||
|
@ -125,6 +126,17 @@ export default defineComponent({
|
|||
.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: {
|
||||
get() {
|
||||
return this.activeNewsletterObj?.recipientsByQueryId ?? "def";
|
||||
|
@ -133,17 +145,12 @@ export default defineComponent({
|
|||
if (this.activeNewsletterObj == undefined) return;
|
||||
if (val == "def") {
|
||||
this.activeNewsletterObj.recipientsByQueryId = null;
|
||||
this.activeNewsletterObj.recipientsByQuery = null;
|
||||
} else if (this.queries.find((q) => q.id == val)) {
|
||||
this.activeNewsletterObj.recipientsByQueryId = val;
|
||||
this.activeNewsletterObj.recipientsByQuery = cloneDeep(this.queries.find((q) => q.id == val));
|
||||
this.sendQuery(0, 0, this.recipientsByQuery?.query, true);
|
||||
this.loadQuery();
|
||||
}
|
||||
},
|
||||
},
|
||||
recipientsByQuery() {
|
||||
return this.activeNewsletterObj?.recipientsByQuery;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
// this.fetchNewsletterRecipients();
|
||||
|
@ -155,7 +162,7 @@ export default defineComponent({
|
|||
...mapActions(useMemberStore, ["getAllMembers"]),
|
||||
...mapActions(useNewsletterRecipientsStore, ["fetchNewsletterRecipients"]),
|
||||
...mapActions(useQueryStoreStore, ["fetchQueries"]),
|
||||
...mapActions(useQueryBuilderStore, ["sendQuery"]),
|
||||
...mapActions(useQueryBuilderStore, ["sendQueryByStoreId"]),
|
||||
removeSelected(id: string) {
|
||||
let index = this.recipients.findIndex((s) => s == id);
|
||||
if (index != -1) {
|
||||
|
@ -170,8 +177,12 @@ export default defineComponent({
|
|||
.catch(() => {});
|
||||
},
|
||||
loadQuery() {
|
||||
if (this.recipientsByQuery) {
|
||||
this.sendQuery(0, 0, this.recipientsByQuery.query, true);
|
||||
if (this.recipientsByQueryId != "def") {
|
||||
this.sendQueryByStoreId(this.recipientsByQueryId, 0, 0, true)
|
||||
.then((result) => {
|
||||
this.queryResult = result.data.rows;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
@ -53,8 +53,8 @@ import { useNewsletterStore } from "@/stores/admin/club/newsletter/newsletter";
|
|||
import { useModalStore } from "@/stores/modal";
|
||||
import NewsletterSyncing from "@/components/admin/club/newsletter/NewsletterSyncing.vue";
|
||||
import { PrinterIcon } from "@heroicons/vue/24/outline";
|
||||
import { useNewsletterDatesStore } from "../../../../stores/admin/club/newsletter/newsletterDates";
|
||||
import { useNewsletterRecipientsStore } from "../../../../stores/admin/club/newsletter/newsletterRecipients";
|
||||
import { useNewsletterDatesStore } from "@/stores/admin/club/newsletter/newsletterDates";
|
||||
import { useNewsletterRecipientsStore } from "@/stores/admin/club/newsletter/newsletterRecipients";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
|
|
|
@ -70,14 +70,11 @@ import { useQueryStoreStore } from "@/stores/admin/configuration/queryStore";
|
|||
<script lang="ts">
|
||||
export default defineComponent({
|
||||
computed: {
|
||||
...mapState(useQueryBuilderStore, ["loading", "loadingData", "tableMetas", "data", "totalLength", "queryError"]),
|
||||
...mapState(useQueryBuilderStore, ["loading", "loadingData", "data", "totalLength", "queryError"]),
|
||||
...mapWritableState(useQueryBuilderStore, ["query"]),
|
||||
},
|
||||
mounted() {
|
||||
this.fetchTableMetas();
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useQueryBuilderStore, ["fetchTableMetas", "sendQuery", "clearResults", "exportData"]),
|
||||
...mapActions(useQueryBuilderStore, ["sendQuery", "clearResults", "exportData"]),
|
||||
...mapActions(useQueryStoreStore, ["triggerSave"]),
|
||||
},
|
||||
});
|
||||
|
|
46
src/views/admin/management/setting/Setting.vue
Normal file
46
src/views/admin/management/setting/Setting.vue
Normal 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
Loading…
Add table
Reference in a new issue