Compare commits

...

41 commits
v1.5.0 ... main

Author SHA1 Message Date
291d04182c 1.7.0 2025-06-07 15:56:43 +02:00
37d73d7c74 Merge pull request 'minor v1.7.0' (#108) from develop into main
Reviewed-on: #108
2025-06-07 13:55:06 +00:00
31c0b2a4c1 Merge branch 'main' into develop 2025-06-07 13:52:57 +00:00
defa732212 update packages 2025-06-07 15:44:41 +02:00
1452456138 1.6.0 2025-06-06 09:37:01 +02:00
0e0f86adce Merge pull request 'minor v1.6.0' (#106) from develop into main
Reviewed-on: #106
2025-06-06 07:36:01 +00:00
d9a24eb723 fix: spelling 2025-06-06 09:00:27 +02:00
d1ff313754 fix: protocol sync 2025-06-06 08:59:15 +02:00
87cb4252ec Merge branch 'main' into develop 2025-06-05 14:34:59 +00:00
90f5ef3b1a add: show count of newer versions 2025-06-05 07:53:03 +02:00
0db141cd13 add: logo and icon fallback on server error 2025-06-05 07:48:01 +02:00
583d4913d9 Merge pull request 'feature/#97-member-extend-data' (#105) from feature/#97-member-extend-data into develop
Reviewed-on: #105
2025-06-03 13:33:18 +00:00
f6252901cd spelling 2025-06-03 15:27:39 +02:00
ff53d2d4d9 fix view errors 2025-06-03 15:20:59 +02:00
05ec4afadb Education views in member and config 2025-06-02 13:57:06 +02:00
3b89262ce9 add note field to member 2025-06-02 13:30:16 +02:00
516c6a9e92 enhance: add calendar link to login screen 2025-05-31 07:34:19 +02:00
ec0222ff2f enhance: add membership total view in member 2025-05-30 15:13:50 +02:00
0defc9b0ba change: redirect to edit page after create of newsletter and protocol 2025-05-29 11:20:54 +02:00
caf8e71a51 enhance: double confirm deletion for manual added newsletter recipients 2025-05-21 10:36:01 +02:00
d11f0d50c6 enhance: double confirm deletion for newsletter dates 2025-05-21 10:33:24 +02:00
2ce66da1d1 enhance: enable deletion of protocol content 2025-05-21 10:32:56 +02:00
f3913a906c 1.5.3 2025-05-19 13:26:46 +02:00
cfe621debd Merge pull request 'patches v1.5.3' (#96) from develop into main
Reviewed-on: #96
2025-05-19 11:25:49 +00:00
5b7a9a3ace Merge branch 'main' into develop 2025-05-17 05:36:51 +00:00
12b1d08ea4 enhance: navigation optimization 2025-05-16 13:34:24 +02:00
04c01b6780 change: standardisation of UI 2025-05-16 13:32:40 +02:00
4ee16c624a enhance: permission handling 2025-05-16 11:12:18 +02:00
35fd8a8e82 enhance: unified ui 2025-05-16 10:27:38 +02:00
832f5053a0 1.5.2 2025-05-10 13:40:54 +02:00
362fc80891 Merge pull request 'patches v1.5.2' (#94) from develop into main
Reviewed-on: #94
2025-05-10 11:40:26 +00:00
2d35e2416b Merge branch 'main' into develop 2025-05-10 11:39:39 +00:00
738765bcb4 fix: possible reset of config store after redirect to login 2025-05-09 14:54:47 +02:00
a39044dffc change: remove logging 2025-05-08 08:17:14 +02:00
369a67abda 1.5.1 2025-05-07 09:29:50 +02:00
9bac6e2f97 Merge pull request 'patches v1.5.1' (#93) from develop into main
Reviewed-on: #93
2025-05-07 07:29:01 +00:00
f64397862c Merge branch 'main' into develop 2025-05-07 07:28:50 +00:00
18d52e4bab change: refactor imports 2025-05-07 09:20:32 +02:00
b4fdd5fc60 enhance: permission handling 2025-05-07 09:05:25 +02:00
c17355fcd1 change: request method for account credential change 2025-05-07 08:27:31 +02:00
fa5fb54876 update: ReadMe 2025-05-07 08:27:03 +02:00
141 changed files with 2099 additions and 1513 deletions

View file

@ -8,12 +8,9 @@ Dieses Repository dient hauptsächlich zur Verwaltung der Mitgliederdaten, aber
Eine Demo dieser Seite finden Sie unter [https://admin-demo.ff-admin.de](https://admin-demo.ff-admin.de).
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: ![alt text](demo-totp-qrcode.png)\
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

1213
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "ff-admin",
"version": "1.5.0",
"version": "1.7.0",
"description": "Feuerwehr/Verein Mitgliederverwaltung UI",
"type": "module",
"scripts": {
@ -32,7 +32,7 @@
"@fullcalendar/vue3": "^6.1.17",
"@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.2.0",
"@tailwindcss/vite": "^4.1.5",
"@tailwindcss/vite": "^4.1.8",
"@vueup/vue-quill": "^1.2.0",
"axios": "^1.9.0",
"event-source-polyfill": "^1.0.31",
@ -49,19 +49,19 @@
"markdown-it-prism": "^3.0.0",
"nprogress": "^0.2.0",
"pdf-dist": "^1.0.0",
"pinia": "^3.0.2",
"pinia": "^3.0.3",
"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.5.13",
"vue": "^3.5.16",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.11.0",
"@tailwindcss/postcss": "^4.1.5",
"@tailwindcss/postcss": "^4.1.8",
"@tsconfig/node20": "^20.1.5",
"@types/eslint": "~9.6.1",
"@types/event-source-polyfill": "^1.0.5",
@ -70,21 +70,21 @@
"@types/lodash.differencewith": "^4.5.9",
"@types/lodash.isequal": "^4.5.8",
"@types/markdown-it": "^14.1.2",
"@types/node": "^22.15.12",
"@types/node": "^22.15.30",
"@types/nprogress": "^0.2.3",
"@types/qrcode": "^1.5.5",
"@types/qs": "^6.9.18",
"@types/qs": "^6.14.0",
"@types/uuid": "^10.0.0",
"@vite-pwa/assets-generator": "^1.0.0",
"@vitejs/plugin-vue": "^5.2.3",
"@vitejs/plugin-vue": "^5.2.4",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.5.0",
"@vue/tsconfig": "^0.7.0",
"eslint": "^9.26.0",
"eslint-plugin-vue": "^10.1.0",
"npm-run-all2": "^8.0.1",
"eslint": "^9.28.0",
"eslint-plugin-vue": "^10.2.0",
"npm-run-all2": "^8.0.4",
"prettier": "^3.5.3",
"tailwindcss": "^4.1.5",
"tailwindcss": "^4.1.8",
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vite-plugin-pwa": "^1.0.0",

BIN
public/admin-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View file

@ -12,6 +12,7 @@
<Teleport to="head">
<title>{{ clubName }}</title>
<link rel="icon" type="image/ico" :href="config.server_address + '/api/public/favicon.ico'" />
<link rel="icon" type="image/png" href="/icon.png" />
<link rel="manifest" :href="config.server_address + '/api/public/manifest.webmanifest'" />
</Teleport>
</template>
@ -29,6 +30,7 @@ import Modal from "./components/Modal.vue";
import Notification from "./components/Notification.vue";
import { config } from "./config";
import { useConfigurationStore } from "@/stores/configuration";
import { resetAllPiniaStores } from "@/helpers/piniaReset";
</script>
<script lang="ts">
@ -38,6 +40,7 @@ export default defineComponent({
...mapState(useConfigurationStore, ["clubName"]),
},
mounted() {
resetAllPiniaStores();
this.configure();
if (!this.authCheck && localStorage.getItem("access_token")) {

View file

@ -1,5 +1,13 @@
<template>
<img ref="icon" :src="url + '/api/public/icon.png'" alt="LOGO" class="h-full w-auto" />
<img v-if="useFallback" ref="fallback" src="/icon.png" alt="LOGO" class="h-full w-auto" />
<img
v-else
ref="icon"
:src="url + '/api/public/icon.png'"
alt="LOGO"
class="h-full w-auto"
@error="useFallback = true"
/>
</template>
<script setup lang="ts">
@ -16,6 +24,11 @@ export default defineComponent({
(this.$refs.icon as HTMLImageElement).src = url + "/api/public/icon.png?" + new Date().getTime();
},
},
data() {
return {
useFallback: false,
};
},
computed: {
...mapState(useSettingStore, ["readSetting"]),
icon() {

View file

@ -1,5 +1,13 @@
<template>
<img ref="logo" :src="url + '/api/public/applogo.png'" alt="LOGO" class="h-full w-auto" />
<img v-if="useFallback" ref="fallback" src="/admin-logo.png" alt="LOGO" class="h-full w-auto" />
<img
v-else
ref="logo"
:src="url + '/api/public/applogo.png'"
alt="LOGO"
class="h-full w-auto"
@error="useFallback = true"
/>
</template>
<script setup lang="ts">
@ -16,6 +24,11 @@ export default defineComponent({
(this.$refs.logo as HTMLImageElement).src = url + "/api/public/applogo.png?t=" + new Date().getTime();
},
},
data() {
return {
useFallback: false,
};
},
computed: {
...mapState(useSettingStore, ["readSetting"]),
logo() {

View file

@ -0,0 +1,64 @@
<template>
<div
class="cursor-pointer"
:class="{ 'text-white': light, 'animate-pulse': isSensitive }"
:title="`2 mal klicken für ${action}`"
@click.prevent="handleClick"
>
<slot :isSensitive="isSensitive">
<CursorArrowRaysIcon v-if="!isSensitive" class="h-5 w-5" />
<CursorArrowRippleIcon v-else class="h-5 w-5" />
</slot>
</div>
</template>
<script setup lang="ts">
import { CursorArrowRaysIcon, CursorArrowRippleIcon } from "@heroicons/vue/24/outline";
import { defineComponent } from "vue";
</script>
<script lang="ts">
export default defineComponent({
props: {
light: {
type: Boolean,
default: false,
},
action: {
type: String,
default: "Bestätigung",
},
},
emits: ["click:first", "click:submit", "click:reset"],
data() {
return {
isSensitive: false as boolean,
timeout: undefined as any,
};
},
beforeUnmount() {
try {
clearTimeout(this.timeout);
} catch (error) {}
},
methods: {
handleClick() {
if (this.isSensitive) {
clearTimeout(this.timeout);
this.isSensitive = false;
this.$emit("click:submit");
} else {
this.timeout = setTimeout(() => {
this.isSensitive = true;
this.$emit("click:first");
this.timeout = setTimeout(() => {
this.isSensitive = false;
this.$emit("click:reset");
}, 2000);
}, 500);
}
},
},
});
</script>

View file

@ -42,7 +42,7 @@ import UserMenu from "./UserMenu.vue";
<script lang="ts">
import { defineComponent } from "vue";
import AppLogo from "./AppLogo.vue";
import { useConfigurationStore } from "../stores/configuration";
import { useConfigurationStore } from "@/stores/configuration";
export default defineComponent({
computed: {
...mapState(useAuthStore, ["authCheck"]),

View file

@ -48,7 +48,7 @@ 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";
import { hashString } from "@/helpers/crypto";
</script>
<script lang="ts">
@ -84,7 +84,7 @@ export default defineComponent({
this.changeStatus = "loading";
this.changeError = "";
this.$http
.post(`/user/changeToPW`, {
.patch(`/user/changeToPW`, {
newpassword: await hashString(formData.new.value),
})
.then((result) => {

View file

@ -69,7 +69,7 @@ export default defineComponent({
this.verifyStatus = "loading";
this.verifyError = "";
this.$http
.post(`/user/changeToTOTP`, {
.patch(`/user/changeToTOTP`, {
otp: this.otp,
totp: formData.totp.value,
})

View file

@ -58,7 +58,7 @@ 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";
import { hashString } from "@/helpers/crypto";
</script>
<script lang="ts">
@ -87,7 +87,7 @@ export default defineComponent({
this.changeStatus = "loading";
this.changeError = "";
this.$http
.post(`/user/changepw`, {
.patch(`/user/changepw`, {
current: await hashString(formData.current.value),
newpassword: await hashString(formData.new.value),
})

View file

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

View file

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

View file

@ -80,6 +80,10 @@
<input type="text" id="internalId" />
</div>
<div>
<label for="note">Notiz (optional)</label>
<textarea type="text" id="note" />
</div>
<div class="flex flex-row gap-2">
<button primary type="submit" :disabled="status == 'loading' || status?.status == 'success'">erstellen</button>
<Spinner v-if="status == 'loading'" class="my-auto" />
@ -109,8 +113,8 @@ 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>
@ -154,6 +158,7 @@ export default defineComponent({
nameaffix: formData.nameaffix.value,
birthdate: formData.birthdate.value,
internalId: formData.internalId.value,
note: formData.note.value,
};
this.status = "loading";
this.createMember(createMember)

View file

@ -0,0 +1,164 @@
<template>
<div class="w-full md:max-w-md">
<div class="flex flex-col items-center">
<p class="text-xl font-medium">Mitglied-Aus-/Fortbildung hinzufügen</p>
</div>
<br />
<form class="flex flex-col gap-4 py-2" @submit.prevent="triggerCreate">
<div>
<Listbox v-model="selectedEducation" name="education">
<ListboxLabel>Aus-/Fortbildung</ListboxLabel>
<div class="relative mt-1">
<ListboxButton
class="rounded-md shadow-xs relative block w-full px-3 py-2 border border-gray-300 focus:border-primary placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-hidden focus:ring-0 focus:z-10 sm:text-sm resize-none"
>
<span class="block truncate w-full text-start">
{{
educations.length != 0
? (selectedEducation?.education ?? "bitte auswählen")
: "keine Auswahl vorhanden"
}}</span
>
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon class="h-5 w-5 text-gray-400" aria-hidden="true" />
</span>
</ListboxButton>
<transition
leave-active-class="transition duration-100 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute mt-1 max-h-60 z-20 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-hidden sm:text-sm h-32 overflow-y-auto"
>
<ListboxOption v-if="educations.length == 0" disabled as="template">
<li :class="['relative cursor-default select-none py-2 pl-10 pr-4']">
<span :class="['font-normal', 'block truncate']">keine Auswahl vorhanden</span>
</li>
</ListboxOption>
<ListboxOption
v-slot="{ active, selected }"
v-for="education in educations"
:key="education.id"
:value="education"
as="template"
>
<li
:class="[
active ? 'bg-red-200 text-amber-900' : 'text-gray-900',
'relative cursor-default select-none py-2 pl-10 pr-4',
]"
>
<span :class="[selected ? 'font-medium' : 'font-normal', 'block truncate']">{{
education.education
}}</span>
<span v-if="selected" class="absolute inset-y-0 left-0 flex items-center pl-3 text-primary">
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
</div>
<div>
<label for="start">Start</label>
<input type="date" id="start" required />
</div>
<div>
<label for="end">Ende (optional)</label>
<input type="date" id="end" />
</div>
<div>
<label for="place">Ort (optional)</label>
<input type="text" id="place" />
</div>
<div>
<label for="note">Notiz (optional)</label>
<input type="text" id="note" />
</div>
<div class="flex flex-row gap-2">
<button primary type="submit" :disabled="status == 'loading' || status?.status == 'success'">erstellen</button>
<Spinner v-if="status == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="status?.status == 'success'" />
<FailureXMark v-else-if="status?.status == 'failed'" />
</div>
</form>
<div class="flex flex-row justify-end">
<div class="flex flex-row gap-4 py-2">
<button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
abbrechen
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapState, mapActions } from "pinia";
import { useModalStore } from "@/stores/modal";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { Listbox, ListboxButton, ListboxOptions, ListboxOption, ListboxLabel } from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { useEducationStore } from "@/stores/admin/configuration/education";
import type { EducationViewModel } from "@/viewmodels/admin/configuration/education.models";
import type { CreateMemberEducationViewModel } from "@/viewmodels/admin/club/member/memberEducation.models";
import { useMemberEducationStore } from "@/stores/admin/club/member/memberEducation";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
status: null as null | "loading" | { status: "success" | "failed"; reason?: string },
timeout: undefined as any,
selectedEducation: undefined as undefined | EducationViewModel,
};
},
computed: {
...mapState(useEducationStore, ["educations"]),
},
mounted() {
this.fetchEducations();
},
beforeUnmount() {
try {
clearTimeout(this.timeout);
} catch (error) {}
},
methods: {
...mapActions(useModalStore, ["closeModal"]),
...mapActions(useMemberEducationStore, ["createMemberEducation"]),
...mapActions(useEducationStore, ["fetchEducations"]),
triggerCreate(e: any) {
if (this.selectedEducation == undefined) return;
let formData = e.target.elements;
let createMemberEducation: CreateMemberEducationViewModel = {
start: formData.start.value,
end: formData.end.value,
note: formData.note.value,
place: formData.place.value,
educationId: this.selectedEducation.id,
};
this.status = "loading";
this.createMemberEducation(createMemberEducation)
.then(() => {
this.status = { status: "success" };
this.timeout = setTimeout(() => {
this.closeModal();
}, 1500);
})
.catch(() => {
this.status = { status: "failed" };
});
},
},
});
</script>

View file

@ -0,0 +1,82 @@
<template>
<div class="w-full md:max-w-md">
<div class="flex flex-col items-center">
<p class="text-xl font-medium">Mitglied-Aus-/Fortbildung löschen</p>
</div>
<br />
<p class="text-center">Aus-/Fortbildung {{ memberEducation?.education }} löschen?</p>
<br />
<div class="flex flex-row gap-2">
<button
primary
type="submit"
:disabled="status == 'loading' || status?.status == 'success'"
@click="triggerDelete"
>
löschen
</button>
<Spinner v-if="status == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="status?.status == 'success'" />
<FailureXMark v-else-if="status?.status == 'failed'" />
</div>
<div class="flex flex-row justify-end">
<div class="flex flex-row gap-4 py-2">
<button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
abbrechen
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapState, mapActions } from "pinia";
import { useModalStore } from "@/stores/modal";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { useMemberEducationStore } from "@/stores/admin/club/member/memberEducation";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
status: null as null | "loading" | { status: "success" | "failed"; reason?: string },
timeout: undefined as any,
};
},
computed: {
...mapState(useModalStore, ["data"]),
...mapState(useMemberEducationStore, ["memberEducations"]),
memberEducation() {
return this.memberEducations.find((m) => m.id == this.data);
},
},
beforeUnmount() {
try {
clearTimeout(this.timeout);
} catch (error) {}
},
methods: {
...mapActions(useModalStore, ["closeModal"]),
...mapActions(useMemberEducationStore, ["deleteMemberEducation"]),
triggerDelete() {
this.status = "loading";
this.deleteMemberEducation(this.data)
.then(() => {
this.status = { status: "success" };
this.timeout = setTimeout(() => {
this.closeModal();
}, 1500);
})
.catch(() => {
this.status = { status: "failed" };
});
},
},
});
</script>

View file

@ -0,0 +1,198 @@
<template>
<div class="w-full md:max-w-md">
<div class="flex flex-col items-center">
<p class="text-xl font-medium">Mitglied-Aus-/Fortbildung bearbeiten</p>
</div>
<br />
<Spinner v-if="loading == 'loading'" class="mx-auto" />
<p v-else-if="loading == 'failed'" @click="fetchItem" class="cursor-pointer">&#8634; laden fehlgeschlagen</p>
<form v-else-if="memberEducation != null" class="flex flex-col gap-4 py-2" @submit.prevent="triggerCreate">
<div>
<Listbox v-model="memberEducation.educationId" name="education">
<ListboxLabel>Aus-/Fortbildung</ListboxLabel>
<div class="relative mt-1">
<ListboxButton
class="rounded-md shadow-xs relative block w-full px-3 py-2 border border-gray-300 focus:border-primary placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-hidden focus:ring-0 focus:z-10 sm:text-sm resize-none"
>
<span class="block truncate w-full text-start">
{{
educations.length != 0 ? (selectedEducation ?? "bitte auswählen") : "keine Auswahl vorhanden"
}}</span
>
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon class="h-5 w-5 text-gray-400" aria-hidden="true" />
</span>
</ListboxButton>
<transition
leave-active-class="transition duration-100 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute mt-1 max-h-60 z-20 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-hidden sm:text-sm h-32 overflow-y-auto"
>
<ListboxOption v-if="educations.length == 0" disabled as="template">
<li :class="['relative cursor-default select-none py-2 pl-10 pr-4']">
<span :class="['font-normal', 'block truncate']">keine Auswahl vorhanden</span>
</li>
</ListboxOption>
<ListboxOption
v-slot="{ active, selected }"
v-for="education in educations"
:key="education.id"
:value="education.id"
as="template"
>
<li
:class="[
active ? 'bg-red-200 text-amber-900' : 'text-gray-900',
'relative cursor-default select-none py-2 pl-10 pr-4',
]"
>
<span :class="[selected ? 'font-medium' : 'font-normal', 'block truncate']">{{
education.education
}}</span>
<span v-if="selected" class="absolute inset-y-0 left-0 flex items-center pl-3 text-primary">
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
</div>
<div>
<label for="start">Start</label>
<input type="date" id="start" required v-model="memberEducation.start" />
</div>
<div>
<label for="end">Ende (optional)</label>
<input type="date" id="end" v-model="memberEducation.end" />
</div>
<div>
<label for="place">Ort (optional)</label>
<input type="text" id="place" v-model="memberEducation.place" />
</div>
<div>
<label for="note">Notiz (optional)</label>
<input type="text" id="note" v-model="memberEducation.note" />
</div>
<div class="flex flex-row gap-2">
<button primary-outline type="reset" :disabled="canSaveOrReset" @click="resetForm">verwerfen</button>
<button primary type="submit" :disabled="status == 'loading' || canSaveOrReset">speichern</button>
<Spinner v-if="status == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="status?.status == 'success'" />
<FailureXMark v-else-if="status?.status == 'failed'" />
</div>
</form>
<div class="flex flex-row justify-end">
<div class="flex flex-row gap-4 py-2">
<button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
schließen
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapState, mapActions } from "pinia";
import { useModalStore } from "@/stores/modal";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { Listbox, ListboxButton, ListboxOptions, ListboxOption, ListboxLabel } from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { useEducationStore } from "@/stores/admin/configuration/education";
import type {
CreateMemberEducationViewModel,
MemberEducationViewModel,
UpdateMemberEducationViewModel,
} from "@/viewmodels/admin/club/member/memberEducation.models";
import { useMemberEducationStore } from "@/stores/admin/club/member/memberEducation";
import isEqual from "lodash.isequal";
import cloneDeep from "lodash.clonedeep";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
loading: "loading" as "loading" | "fetched" | "failed",
status: null as null | "loading" | { status: "success" | "failed"; reason?: string },
origin: null as null | MemberEducationViewModel,
memberEducation: null as null | MemberEducationViewModel,
timeout: undefined as any,
};
},
computed: {
...mapState(useEducationStore, ["educations"]),
...mapState(useModalStore, ["data"]),
canSaveOrReset(): boolean {
return isEqual(this.origin, this.memberEducation);
},
selectedEducation() {
return this.educations.find((ms) => ms.id == this.memberEducation?.educationId)?.education;
},
},
mounted() {
this.fetchEducations();
this.fetchItem();
},
beforeUnmount() {
try {
clearTimeout(this.timeout);
} catch (error) {}
},
methods: {
...mapActions(useModalStore, ["closeModal"]),
...mapActions(useMemberEducationStore, ["updateMemberEducation", "fetchMemberEducationById"]),
...mapActions(useEducationStore, ["fetchEducations"]),
resetForm() {
this.memberEducation = cloneDeep(this.origin);
},
fetchItem() {
this.fetchMemberEducationById(this.data)
.then((result) => {
this.memberEducation = result.data;
this.origin = cloneDeep(result.data);
this.loading = "fetched";
})
.catch((err) => {
this.loading = "failed";
});
},
triggerCreate(e: any) {
if (this.memberEducation == null) return;
let formData = e.target.elements;
let updateMemberEducation: UpdateMemberEducationViewModel = {
id: this.memberEducation.id,
start: formData.start.value,
end: formData.end.value,
note: formData.note.value,
place: formData.place.value,
educationId: this.memberEducation.educationId,
};
this.status = "loading";
this.updateMemberEducation(updateMemberEducation)
.then(() => {
this.fetchItem();
this.status = { status: "success" };
})
.catch((err) => {
this.status = { status: "failed" };
})
.finally(() => {
this.timeout = setTimeout(() => {
this.status = null;
}, 2000);
});
},
},
});
</script>

View file

@ -0,0 +1,54 @@
<template>
<div class="flex flex-col h-fit w-full border border-primary rounded-md">
<div class="bg-primary p-2 text-white flex flex-row gap-2 justify-between items-center">
<p class="grow">{{ education.education }}</p>
<PencilIcon v-if="can('update', 'club', 'member')" class="w-5 h-5 cursor-pointer" @click="openEditModal" />
<TrashIcon v-if="can('delete', 'club', 'member')" class="w-5 h-5 cursor-pointer" @click="openDeleteModal" />
</div>
<div class="p-2">
<p>
besucht: {{ education.start }} <span v-if="education.end">bis {{ education.end }}</span>
</p>
<p v-if="education.place">Ort: {{ education.place }}</p>
<p v-if="education.note">Notiz: {{ education.note }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { defineAsyncComponent, defineComponent, markRaw, type PropType } from "vue";
import { mapState, mapActions } from "pinia";
import type { MemberEducationViewModel } from "@/viewmodels/admin/club/member/memberEducation.models";
import { PencilIcon, TrashIcon } from "@heroicons/vue/24/outline";
import { useModalStore } from "@/stores/modal";
import { useAbilityStore } from "@/stores/ability";
</script>
<script lang="ts">
export default defineComponent({
props: {
education: {
type: Object as PropType<MemberEducationViewModel>,
default: {},
},
},
computed: {
...mapState(useAbilityStore, ["can"]),
},
methods: {
...mapActions(useModalStore, ["openModal"]),
openEditModal() {
this.openModal(
markRaw(defineAsyncComponent(() => import("@/components/admin/club/member/MemberEducationEditModal.vue"))),
this.education.id
);
},
openDeleteModal() {
this.openModal(
markRaw(defineAsyncComponent(() => import("@/components/admin/club/member/MemberEducationDeleteModal.vue"))),
this.education.id
);
},
},
});
</script>

View file

@ -1,17 +1,19 @@
<template>
<RouterLink
<RouterLink
:to="{ name: 'admin-club-member-overview', params: { memberId: member.id } }"
class="flex flex-col h-fit w-full border border-primary rounded-md"
>
<div
class="bg-primary p-2 text-white flex flex-row justify-between items-center"
>
<div class="bg-primary p-2 text-white flex flex-row justify-between items-center">
<p>{{ member.lastname }}, {{ member.firstname }} {{ member.nameaffix ? `- ${member.nameaffix}` : "" }}</p>
</div>
<div class="p-2">
<p v-if="member.internalId">Interne ID: {{ member.internalId }}</p>
<p v-if="member.note">Notiz: {{ member.note }}</p>
<p>beigetreten: {{ member.firstMembershipEntry?.start }}</p>
<p v-if="member.lastMembershipEntry?.end">ausgetreten: {{ member.lastMembershipEntry?.end }}, da {{member.lastMembershipEntry?.terminationReason ?? '- kein Grund angegeben'}}</p>
<p v-if="member.lastMembershipEntry?.end">
ausgetreten: {{ member.lastMembershipEntry?.end }}, da
{{ member.lastMembershipEntry?.terminationReason ?? "- kein Grund angegeben" }}
</p>
</div>
</RouterLink>
</template>

View file

@ -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">
@ -63,11 +63,12 @@ export default defineComponent({
};
this.status = "loading";
this.createNewsletter(createNewsletter)
.then(() => {
.then((res) => {
this.status = { status: "success" };
this.timeout = setTimeout(() => {
(this.$refs.form as HTMLFormElement).reset();
this.closeModal();
this.$router.push({ name: "admin-club-newsletter-overview", params: { newsletterId: res.data } });
}, 1500);
})
.catch(() => {

View file

@ -4,7 +4,15 @@
:to="{ name: 'admin-club-newsletter-overview', params: { newsletterId: newsletter.id } }"
class="bg-primary p-2 text-white flex flex-row justify-between items-center"
>
<p>{{ newsletter.title }}</p>
<p>
{{ newsletter.title }} ({{
new Date(newsletter.createdAt).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
})
}})
</p>
<PaperAirplaneIcon v-if="newsletter.isSent" class="w-5 h-5" />
</RouterLink>
<div class="p-2 max-h-48 overflow-y-auto">

View file

@ -66,11 +66,12 @@ export default defineComponent({
};
this.status = "loading";
this.createProtocol(createProtocol)
.then(() => {
.then((res) => {
this.status = { status: "success" };
this.timeout = setTimeout(() => {
(this.$refs.form as HTMLFormElement).reset();
this.closeModal();
this.$router.push({ name: "admin-club-protocol-overview", params: { protocolId: res.data } });
}, 1500);
})
.catch(() => {

View file

@ -0,0 +1,81 @@
<template>
<div class="w-full md:max-w-md">
<div class="flex flex-col items-center">
<p class="text-xl font-medium">Aus-/Fortbildung erstellen</p>
</div>
<br />
<form class="flex flex-col gap-4 py-2" @submit.prevent="triggerCreate">
<div>
<label for="education">Bezeichnung</label>
<input type="text" id="education" required />
</div>
<div>
<label for="description">Beschreibung (optional)</label>
<input type="text" id="description" />
</div>
<div class="flex flex-row gap-2">
<button primary type="submit" :disabled="status == 'loading' || status?.status == 'success'">erstellen</button>
<Spinner v-if="status == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="status?.status == 'success'" />
<FailureXMark v-else-if="status?.status == 'failed'" />
</div>
</form>
<div class="flex flex-row justify-end">
<div class="flex flex-row gap-4 py-2">
<button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
abbrechen
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapState, mapActions } from "pinia";
import { useModalStore } from "@/stores/modal";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { useEducationStore } from "@/stores/admin/configuration/education";
import type { CreateEducationViewModel } from "@/viewmodels/admin/configuration/education.models";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
status: null as null | "loading" | { status: "success" | "failed"; reason?: string },
timeout: undefined as any,
};
},
beforeUnmount() {
try {
clearTimeout(this.timeout);
} catch (error) {}
},
methods: {
...mapActions(useModalStore, ["closeModal"]),
...mapActions(useEducationStore, ["createEducation"]),
triggerCreate(e: any) {
let formData = e.target.elements;
let createEducation: CreateEducationViewModel = {
education: formData.education.value,
description: formData.description.value,
};
this.status = "loading";
this.createEducation(createEducation)
.then(() => {
this.status = { status: "success" };
this.timeout = setTimeout(() => {
this.closeModal();
}, 1500);
})
.catch(() => {
this.status = { status: "failed" };
});
},
},
});
</script>

View file

@ -0,0 +1,75 @@
<template>
<div class="w-full md:max-w-md">
<div class="flex flex-col items-center">
<p class="text-xl font-medium">Aus-/Fortbildung {{ education?.education }} löschen?</p>
</div>
<br />
<div class="flex flex-row gap-2">
<button primary :disabled="status == 'loading' || status?.status == 'success'" @click="triggerDelete">
unwiederuflich löschen
</button>
<Spinner v-if="status == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="status?.status == 'success'" />
<FailureXMark v-else-if="status?.status == 'failed'" />
</div>
<div class="flex flex-row justify-end">
<div class="flex flex-row gap-4 py-2">
<button primary-outline @click="closeModal" :disabled="status == 'loading' || status?.status == 'success'">
abbrechen
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapState, mapActions } from "pinia";
import { useModalStore } from "@/stores/modal";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { useEducationStore } from "@/stores/admin/configuration/education";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
status: null as null | "loading" | { status: "success" | "failed"; reason?: string },
timeout: undefined as any,
};
},
beforeUnmount() {
try {
clearTimeout(this.timeout);
} catch (error) {}
},
computed: {
...mapState(useModalStore, ["data"]),
...mapState(useEducationStore, ["educations"]),
education() {
return this.educations.find((r) => r.id == this.data);
},
},
methods: {
...mapActions(useModalStore, ["closeModal"]),
...mapActions(useEducationStore, ["deleteEducation"]),
triggerDelete() {
this.status = "loading";
this.deleteEducation(this.data)
.then(() => {
this.status = { status: "success" };
this.timeout = setTimeout(() => {
this.closeModal();
}, 1500);
})
.catch(() => {
this.status = { status: "failed" };
});
},
},
});
</script>

View file

@ -0,0 +1,55 @@
<template>
<div class="flex flex-col h-fit w-full border border-primary rounded-md">
<div class="bg-primary p-2 text-white flex flex-row justify-between items-center">
<p>{{ education.education }}</p>
<div class="flex flex-row">
<RouterLink
v-if="can('update', 'configuration', 'education')"
:to="{ name: 'admin-configuration-education-edit', params: { id: education.id } }"
>
<PencilIcon class="w-5 h-5 p-1 box-content cursor-pointer" />
</RouterLink>
<div v-if="can('delete', 'configuration', 'education')" @click="openDeleteModal">
<TrashIcon class="w-5 h-5 p-1 box-content cursor-pointer" />
</div>
</div>
</div>
<div class="flex flex-col p-2">
<div class="flex flex-row gap-2">
<p class="min-w-16">Beschreibung:</p>
<p class="grow overflow-hidden">{{ education.description }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent, defineAsyncComponent, markRaw, type PropType } from "vue";
import { mapState, mapActions } from "pinia";
import { PencilIcon, TrashIcon } from "@heroicons/vue/24/outline";
import { useAbilityStore } from "@/stores/ability";
import { useModalStore } from "@/stores/modal";
import type { EducationViewModel } from "@/viewmodels/admin/configuration/education.models";
</script>
<script lang="ts">
export default defineComponent({
props: {
education: { type: Object as PropType<EducationViewModel>, default: {} },
},
computed: {
...mapState(useAbilityStore, ["can"]),
},
methods: {
...mapActions(useModalStore, ["openModal"]),
openDeleteModal() {
this.openModal(
markRaw(
defineAsyncComponent(() => import("@/components/admin/configuration/education/DeleteEducationModal.vue"))
),
this.education.id
);
},
},
});
</script>

View file

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

View file

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

View file

@ -58,7 +58,7 @@ export default defineComponent({
},
{
key: "app.show_link_to_calendar",
value: formData.show_link_to_calendar.checked || null,
value: formData.show_link_to_calendar.checked,
},
]);
},

View file

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

View file

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

View file

@ -149,7 +149,6 @@ export default defineComponent({
this.value.id = uuid();
}
if (!this.value.type) {
console.log("setting type");
this.type = "defined";
}
},

View file

@ -34,7 +34,7 @@ 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";
import { useSetupStore } from "@/stores/setup";
</script>
<script lang="ts">

View file

@ -32,7 +32,7 @@ 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";
import { useSetupStore } from "@/stores/setup";
</script>
<script lang="ts">

View file

@ -63,7 +63,7 @@ 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";
import { useSetupStore } from "@/stores/setup";
</script>
<script lang="ts">

View file

@ -37,7 +37,7 @@ 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";
import { useSetupStore } from "@/stores/setup";
</script>
<script lang="ts">

View file

@ -57,7 +57,7 @@ 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";
import { useSetupStore } from "@/stores/setup";
</script>
<script lang="ts">

View file

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

View file

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

View file

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

View file

@ -15,7 +15,7 @@ const router = createRouter({
routes: [
{
path: "/",
redirect: { name: "admin" },
redirect: { name: "admin-default" },
},
{
path: "/login",
@ -76,12 +76,13 @@ const router = createRouter({
path: "/admin",
name: "admin",
component: () => import("@/views/admin/View.vue"),
beforeEnter: [isAuthenticated],
beforeEnter: [isAuthenticated, abilityAndNavUpdate],
children: [
{
path: "",
name: "admin-default",
component: () => import("@/views/admin/ViewSelect.vue"),
beforeEnter: [abilityAndNavUpdate],
},
{
path: "club",
@ -141,6 +142,12 @@ const router = createRouter({
component: () => import("@/views/admin/club/members/MemberAwards.vue"),
props: true,
},
{
path: "educations",
name: "admin-club-member-educations",
component: () => import("@/views/admin/club/members/MemberEducations.vue"),
props: true,
},
{
path: "qualifications",
name: "admin-club-member-qualifications",
@ -360,6 +367,30 @@ const router = createRouter({
},
],
},
{
path: "education",
name: "admin-configuration-education-route",
component: () => import("@/views/RouterView.vue"),
meta: { type: "read", section: "configuration", module: "education" },
beforeEnter: [abilityAndNavUpdate],
children: [
{
path: "",
name: "admin-configuration-education",
component: () => import("@/views/admin/configuration/education/Education.vue"),
meta: { type: "read", section: "configuration", module: "education" },
beforeEnter: [abilityAndNavUpdate],
},
{
path: ":id/edit",
name: "admin-configuration-education-edit",
component: () => import("@/views/admin/configuration/education/EducationEdit.vue"),
meta: { type: "update", section: "configuration", module: "education" },
beforeEnter: [abilityAndNavUpdate],
props: true,
},
],
},
{
path: "executive-position",
name: "admin-configuration-executive_position-route",

View file

@ -4,6 +4,7 @@ import { useMemberAwardStore } from "@/stores/admin/club/member/memberAward";
import { useMemberExecutivePositionStore } from "@/stores/admin/club/member/memberExecutivePosition";
import { useMemberQualificationStore } from "@/stores/admin/club/member/memberQualification";
import { useMembershipStore } from "@/stores/admin/club/member/membership";
import { useMemberEducationStore } from "../stores/admin/club/member/memberEducation";
export async function setMemberId(to: any, from: any, next: any) {
const member = useMemberStore();
@ -14,6 +15,7 @@ export async function setMemberId(to: any, from: any, next: any) {
useMemberAwardStore().$reset();
useMemberExecutivePositionStore().$reset();
useMemberQualificationStore().$reset();
useMemberEducationStore().$reset();
next();
}
@ -28,6 +30,7 @@ export async function resetMemberStores(to: any, from: any, next: any) {
useMemberAwardStore().$reset();
useMemberExecutivePositionStore().$reset();
useMemberQualificationStore().$reset();
useMemberEducationStore().$reset();
next();
}

View file

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

View file

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

View file

@ -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,16 +30,24 @@ 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) ||
(permissions[section]?.all == "*" || permissions[section]?.all?.includes(type)) &&
permissions[section] != undefined
)
return true;
return false;
},
canAccessSection:
(state) =>
(section: PermissionSection): boolean => {
const permissions = state.permissions;
if (state.isOwner) return true;
if (permissions?.admin || permissions?.adminByOwner) return true;
if (permissions[section] != undefined) return true;
return false;
},
isAdmin: (state) => (): boolean => {
const permissions = state.permissions;
if (state.isOwner) return true;
@ -54,23 +59,41 @@ 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;
},
_canAccessSection:
() =>
(permissions: PermissionObject, section: PermissionSection): boolean => {
// ignores ownership
if (permissions?.admin || permissions?.adminByOwner) return true;
if (permissions[section] != undefined) return true;
return false;
},
},
actions: {
setAbility(permissions: PermissionObject, isOwner: boolean) {

View file

@ -106,6 +106,7 @@ export const useMemberStore = defineStore("member", {
nameaffix: member.nameaffix,
birthdate: member.birthdate,
internalId: member.internalId,
note: member.note,
});
this.fetchMembers();
return result;
@ -118,6 +119,7 @@ export const useMemberStore = defineStore("member", {
nameaffix: member.nameaffix,
birthdate: member.birthdate,
internalId: member.internalId,
note: member.note,
});
this.fetchMembers();
return result;

View file

@ -0,0 +1,67 @@
import { defineStore } from "pinia";
import { http } from "@/serverCom";
import type { AxiosResponse } from "axios";
import { useMemberStore } from "./member";
import type {
CreateMemberEducationViewModel,
MemberEducationViewModel,
UpdateMemberEducationViewModel,
} from "@/viewmodels/admin/club/member/memberEducation.models";
export const useMemberEducationStore = defineStore("memberEducation", {
state: () => {
return {
memberEducations: [] as Array<MemberEducationViewModel>,
loading: "loading" as "loading" | "fetched" | "failed",
};
},
actions: {
fetchMemberEducationsForMember() {
const memberId = useMemberStore().activeMember;
this.loading = "loading";
http
.get(`/admin/member/${memberId}/educations`)
.then((result) => {
this.memberEducations = result.data;
this.loading = "fetched";
})
.catch((err) => {
this.loading = "failed";
});
},
fetchMemberEducationById(id: number) {
const memberId = useMemberStore().activeMember;
return http.get(`/admin/member/${memberId}/education/${id}`);
},
async createMemberEducation(memberEducation: CreateMemberEducationViewModel): Promise<AxiosResponse<any, any>> {
const memberId = useMemberStore().activeMember;
const result = await http.post(`/admin/member/${memberId}/education`, {
start: memberEducation.start,
end: memberEducation.end,
place: memberEducation.place,
note: memberEducation.note,
educationId: memberEducation.educationId,
});
this.fetchMemberEducationsForMember();
return result;
},
async updateMemberEducation(memberEducation: UpdateMemberEducationViewModel): Promise<AxiosResponse<any, any>> {
const memberId = useMemberStore().activeMember;
const result = await http.patch(`/admin/member/${memberId}/education/${memberEducation.id}`, {
start: memberEducation.start,
end: memberEducation.end,
place: memberEducation.place,
note: memberEducation.note,
educationId: memberEducation.educationId,
});
this.fetchMemberEducationsForMember();
return result;
},
async deleteMemberEducation(memberEducation: number): Promise<AxiosResponse<any, any>> {
const memberId = useMemberStore().activeMember;
const result = await http.delete(`/admin/member/${memberId}/education/${memberEducation}`);
this.fetchMemberEducationsForMember();
return result;
},
},
});

View file

@ -7,6 +7,7 @@ import { useMemberStore } from "./member";
import type {
CreateMembershipViewModel,
MembershipStatisticsViewModel,
MembershipTotalStatisticsViewModel,
MembershipViewModel,
UpdateMembershipViewModel,
} from "@/viewmodels/admin/club/member/membership.models";
@ -16,6 +17,7 @@ export const useMembershipStore = defineStore("membership", {
return {
memberships: [] as Array<MembershipViewModel>,
membershipStatistics: [] as Array<MembershipStatisticsViewModel>,
totalMembershipStatistics: undefined as undefined | MembershipTotalStatisticsViewModel,
loading: "loading" as "loading" | "fetched" | "failed",
};
},
@ -42,6 +44,15 @@ export const useMembershipStore = defineStore("membership", {
})
.catch((err) => {});
},
fetchMembershipTotalStatisticsForMember() {
const memberId = useMemberStore().activeMember;
http
.get(`/admin/member/${memberId}/memberships/totalstatistics`)
.then((result) => {
this.totalMembershipStatistics = result.data;
})
.catch((err) => {});
},
fetchMembershipById(id: number) {
const memberId = useMemberStore().activeMember;
return http.get(`/admin/member/${memberId}/membership/${id}`);

View file

@ -1,11 +1,10 @@
import { defineStore } from "pinia";
import type { CreateNewsletterViewModel, SyncNewsletterViewModel } from "@/viewmodels/admin/club/newsletter/newsletter.models";
import type { CreateNewsletterViewModel } from "@/viewmodels/admin/club/newsletter/newsletter.models";
import { http } from "@/serverCom";
import type { AxiosResponse } from "axios";
import type { NewsletterViewModel } from "@/viewmodels/admin/club/newsletter/newsletter.models";
import cloneDeep from "lodash.clonedeep";
import isEqual from "lodash.isequal";
import difference from "lodash.difference";
export const useNewsletterStore = defineStore("newsletter", {
state: () => {
@ -72,7 +71,6 @@ export const useNewsletterStore = defineStore("newsletter", {
const result = await http.post(`/admin/newsletter`, {
title: newsletter.title,
});
this.fetchNewsletters();
return result;
},
async synchronizeActiveNewsletter(): Promise<void> {

View file

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

View file

@ -1,11 +1,10 @@
import { defineStore } from "pinia";
import type { CreateProtocolViewModel, SyncProtocolViewModel } from "@/viewmodels/admin/club/protocol/protocol.models";
import type { CreateProtocolViewModel } from "@/viewmodels/admin/club/protocol/protocol.models";
import { http } from "@/serverCom";
import type { AxiosResponse } from "axios";
import type { ProtocolViewModel } from "@/viewmodels/admin/club/protocol/protocol.models";
import cloneDeep from "lodash.clonedeep";
import isEqual from "lodash.isequal";
import difference from "lodash.difference";
export const useProtocolStore = defineStore("protocol", {
state: () => {
@ -73,7 +72,6 @@ export const useProtocolStore = defineStore("protocol", {
title: protocol.title,
date: protocol.date,
});
this.fetchProtocols();
return result;
},
async synchronizeActiveProtocol(): Promise<void> {

View file

@ -71,7 +71,7 @@ export const useProtocolAgendaStore = defineStore("protocolAgenda", {
await http
.patch(`/admin/protocol/${protocolId}/synchronize/agenda`, {
agenda: differenceWith(this.agenda, this.origin, isEqual),
agenda: this.agenda,
})
.then((res) => {
this.syncingProtocolAgenda = "synced";

View file

@ -72,7 +72,7 @@ export const useProtocolDecisionStore = defineStore("protocolDecision", {
await http
.patch(`/admin/protocol/${protocolId}/synchronize/decisions`, {
decisions: differenceWith(this.decision, this.origin, isEqual),
decisions: this.decision,
})
.then((res) => {
this.syncingProtocolDecision = "synced";

View file

@ -75,7 +75,7 @@ export const useProtocolVotingStore = defineStore("protocolVoting", {
await http
.patch(`/admin/protocol/${protocolId}/synchronize/votings`, {
votings: differenceWith(this.voting, this.origin, isEqual),
votings: this.voting,
})
.then((res) => {
this.syncingProtocolVoting = "synced";

View file

@ -0,0 +1,55 @@
import { defineStore } from "pinia";
import type {
CreateEducationViewModel,
UpdateEducationViewModel,
EducationViewModel,
} from "@/viewmodels/admin/configuration/education.models";
import { http } from "@/serverCom";
import type { AxiosResponse } from "axios";
export const useEducationStore = defineStore("education", {
state: () => {
return {
educations: [] as Array<EducationViewModel>,
loading: "loading" as "loading" | "fetched" | "failed",
};
},
actions: {
fetchEducations() {
this.loading = "loading";
http
.get("/admin/education")
.then((result) => {
this.educations = result.data;
this.loading = "fetched";
})
.catch((err) => {
this.loading = "failed";
});
},
fetchEducationById(id: number): Promise<AxiosResponse<any, any>> {
return http.get(`/admin/education/${id}`);
},
async createEducation(education: CreateEducationViewModel): Promise<AxiosResponse<any, any>> {
const result = await http.post(`/admin/education`, {
education: education.education,
description: education.description,
});
this.fetchEducations();
return result;
},
async updateActiveEducation(education: UpdateEducationViewModel): Promise<AxiosResponse<any, any>> {
const result = await http.patch(`/admin/education/${education.id}`, {
education: education.education,
description: education.description,
});
this.fetchEducations();
return result;
},
async deleteEducation(education: number): Promise<AxiosResponse<any, any>> {
const result = await http.delete(`/admin/education/${education}`);
this.fetchEducations();
return result;
},
},
});

View file

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

View file

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

View file

@ -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;
@ -48,7 +48,7 @@ export const useNavigationStore = defineStore("navigation", {
updateTopLevel() {
const abilityStore = useAbilityStore();
this.topLevel = [
...(abilityStore.canSection("read", "club")
...(abilityStore.canAccessSection("club")
? [
{
key: "club",
@ -57,7 +57,7 @@ export const useNavigationStore = defineStore("navigation", {
} as topLevelNavigationModel,
]
: []),
...(abilityStore.canSection("read", "configuration")
...(abilityStore.canAccessSection("configuration")
? [
{
key: "configuration",
@ -66,7 +66,7 @@ export const useNavigationStore = defineStore("navigation", {
} as topLevelNavigationModel,
]
: []),
...(abilityStore.canSection("read", "management")
...(abilityStore.canAccessSection("management")
? [
{
key: "management",
@ -112,6 +112,9 @@ export const useNavigationStore = defineStore("navigation", {
...(abilityStore.can("read", "configuration", "qualification")
? [{ key: "qualification", title: "Qualifikationen" }]
: []),
...(abilityStore.can("read", "configuration", "education")
? [{ key: "education", title: "Aus-/Fortbildungen" }]
: []),
...(abilityStore.can("read", "configuration", "executive_position")
? [{ key: "executive_position", title: "Vereinsämter" }]
: []),
@ -147,7 +150,8 @@ export const useNavigationStore = defineStore("navigation", {
this.activeNavigationObject.main.findIndex((e) => e.key == this.activeLink) == -1 ||
this.activeLink == "default"
) {
let link = this.activeNavigationObject.main[0].key;
let link = this.activeNavigationObject.main.filter((m) => !m.key.startsWith("divider"))[0].key;
this.activeLink = link;
router.push({ name: `admin-${this.activeNavigation}-${link}` });
}
},

View file

@ -1,5 +1,5 @@
import { defineStore } from "pinia";
import { http } from "../serverCom";
import { http } from "@/serverCom";
export const useConfigurationStore = defineStore("configuration", {
state: () => {

View file

@ -1,5 +1,5 @@
import { defineStore } from "pinia";
import { http } from "../serverCom";
import { http } from "@/serverCom";
import type { AxiosResponse } from "axios";
import { useConfigurationStore } from "./configuration";

View file

@ -16,10 +16,13 @@
<div
class="max-w-full w-full grow flex flex-col divide-y-2 divide-gray-300 bg-white rounded-lg justify-center overflow-hidden"
>
<slot name="topBar"></slot>
<div v-if="topBar || title" class="flex flex-row items-center justify-between pt-5 pb-3 px-7">
<h1 v-if="title" class="font-bold text-xl h-8 min-h-fit">{{ title }}</h1>
<slot name="topBar"></slot>
</div>
<div class="flex flex-col gap-2 grow py-5 overflow-hidden">
<slot name="diffMain"></slot>
<div v-if="!diffMain" class="flex flex-col gap-2 grow px-7 overflow-y-scroll">
<div v-if="!diffMain" class="flex flex-col gap-2 grow px-7 overflow-y-auto">
<slot name="main"></slot>
</div>
</div>
@ -51,6 +54,10 @@ export default defineComponent({
type: Boolean,
default: true,
},
title: {
type: String,
default: "",
},
},
computed: {
...mapState(useNavigationStore, ["activeLink", "activeNavigation"]),
@ -60,6 +67,9 @@ export default defineComponent({
rootRoute() {
return ((this.$route?.name as string) ?? "").split("-")[0];
},
topBar() {
return this.$slots.topBar;
},
diffMain() {
return this.$slots.diffMain;
},

View file

@ -13,6 +13,7 @@ export type PermissionModule =
| "communication_type"
| "membership_status"
| "salutation"
| "education"
| "calendar_type"
| "user"
| "role"
@ -31,6 +32,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 = {
@ -39,10 +41,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", "configuration", "management"];
@ -59,6 +71,7 @@ export const permissionModules: Array<PermissionModule> = [
"communication_type",
"membership_status",
"salutation",
"education",
"calendar_type",
"user",
"role",
@ -80,6 +93,7 @@ export const sectionsAndModules: SectionsAndModulesObject = {
"communication_type",
"membership_status",
"salutation",
"education",
"calendar_type",
"query_store",
"template",
@ -87,4 +101,7 @@ export const sectionsAndModules: SectionsAndModulesObject = {
"newsletter_config",
],
management: ["user", "role", "webapi", "backup", "setting"],
additional: [
//{ key: "val", name: "name", type: "number", emptyIfAdmin: true },
],
};

View file

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

View file

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

View file

@ -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;
@ -15,6 +15,7 @@ export interface MemberViewModel {
sendNewsletter?: CommunicationViewModel;
smsAlarming?: Array<CommunicationViewModel>;
preferredCommunication?: Array<CommunicationViewModel>;
note?: string;
}
export interface MemberStatisticsViewModel {
@ -36,6 +37,7 @@ export interface CreateMemberViewModel {
nameaffix: string;
birthdate: Date;
internalId?: string;
note?: string;
}
export interface UpdateMemberViewModel {
@ -46,4 +48,5 @@ export interface UpdateMemberViewModel {
nameaffix: string;
birthdate: Date;
internalId?: string;
note?: string;
}

View file

@ -0,0 +1,26 @@
export interface MemberEducationViewModel {
id: number;
start: Date;
end?: Date;
place?: string;
note?: string;
education: string;
educationId: number;
}
export interface CreateMemberEducationViewModel {
start: Date;
end?: Date;
place?: string;
note?: string;
educationId: number;
}
export interface UpdateMemberEducationViewModel {
id: number;
start: Date;
end?: Date;
place?: string;
note?: string;
educationId: number;
}

View file

@ -21,6 +21,18 @@ export interface MembershipStatisticsViewModel {
memberBirthdate: Date;
}
export interface MembershipTotalStatisticsViewModel {
durationInDays: number;
durationInYears: number;
exactDuration: string;
memberId: string;
memberSalutation: string;
memberFirstname: string;
memberLastname: string;
memberNameaffix: string;
memberBirthdate: Date;
}
export interface CreateMembershipViewModel {
start: Date;
statusId: number;

View file

@ -7,6 +7,7 @@ export interface NewsletterViewModel {
newsletterSignatur: string;
isSent: boolean;
recipientsByQueryId?: string | null;
createdAt: Date;
}
export interface CreateNewsletterViewModel {

View file

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

View file

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

View file

@ -0,0 +1,16 @@
export interface EducationViewModel {
id: number;
education: string;
description: string | null;
}
export interface CreateEducationViewModel {
education: string;
description: string | null;
}
export interface UpdateEducationViewModel {
id: number;
education: string;
description: string | null;
}

View file

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

View file

@ -65,6 +65,11 @@
</div>
<p v-if="loginError" class="text-center">{{ loginError }}</p>
</form>
<div class="flex flex-col gap-2 empty:hidden">
<RouterLink v-if="appShow_link_to_calendar" :to="{ name: 'public-calendar' }" button primary-outline>
zum Kalender
</RouterLink>
</div>
<FormBottomBar />
</div>
@ -76,12 +81,11 @@ import { defineComponent } from "vue";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { resetAllPiniaStores } from "@/helpers/piniaReset";
import FormBottomBar from "@/components/FormBottomBar.vue";
import AppLogo from "@/components/AppLogo.vue";
import { mapState } from "pinia";
import { useConfigurationStore } from "@/stores/configuration";
import { hashString } from "../helpers/crypto";
import { hashString } from "@/helpers/crypto";
</script>
<script lang="ts">
@ -96,10 +100,9 @@ export default defineComponent({
};
},
computed: {
...mapState(useConfigurationStore, ["clubName"]),
...mapState(useConfigurationStore, ["clubName", "appShow_link_to_calendar"]),
},
mounted() {
resetAllPiniaStores();
this.username = localStorage.getItem("username") ?? "";
this.routine = localStorage.getItem("routine") ?? "";

View file

@ -1,10 +1,5 @@
<template>
<MainTemplate :useStagedOverviewLink="false">
<template #topBar>
<div class="flex flex-row items-center justify-between pt-5 pb-3 px-7">
<h1 class="font-bold text-xl h-8">Administration übertragen</h1>
</div>
</template>
<MainTemplate title="Administration übertragen" :useStagedOverviewLink="false">
<template #main>
<Spinner v-if="loading == 'loading'" class="mx-auto" />
<p v-else-if="loading == 'failed'">laden fehlgeschlagen</p>

View file

@ -1,10 +1,5 @@
<template>
<MainTemplate :useStagedOverviewLink="false">
<template #topBar>
<div class="flex flex-row items-center justify-between pt-5 pb-3 px-7">
<h1 class="font-bold text-xl h-8">Meine Anmeldedaten</h1>
</div>
</template>
<MainTemplate title="Meine Anmeldedaten" :useStagedOverviewLink="false">
<template #diffMain>
<Spinner v-if="loading" class="mx-auto" />
<div v-else class="flex flex-col w-full h-full gap-2 px-7 overflow-hidden">
@ -54,10 +49,10 @@ 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";
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">

View file

@ -1,10 +1,5 @@
<template>
<MainTemplate :useStagedOverviewLink="false">
<template #topBar>
<div class="flex flex-row items-center justify-between pt-5 pb-3 px-7">
<h1 class="font-bold text-xl h-8">Mein Account</h1>
</div>
</template>
<MainTemplate title="Mein Account" :useStagedOverviewLink="false">
<template #main>
<Spinner v-if="loading == 'loading'" class="mx-auto" />
<p v-else-if="loading == 'failed'">laden fehlgeschlagen</p>

View file

@ -1,18 +1,13 @@
<template>
<MainTemplate :useStagedOverviewLink="false">
<template #topBar>
<div class="flex flex-row items-center justify-between pt-5 pb-3 px-7">
<h1 class="font-bold text-xl h-8">Meine Berechtigungen</h1>
</div>
</template>
<MainTemplate title="Meine Berechtigungen" :useStagedOverviewLink="false">
<template #main>
<Permission :permissions="permissions" :disableEdit="true" />
<Permission :permissions="permissions" disableEdit />
</template>
</MainTemplate>
</template>
<script setup lang="ts">
import { defineComponent, markRaw, defineAsyncComponent } from "vue";
import { defineComponent } from "vue";
import { mapActions, mapState } from "pinia";
import MainTemplate from "@/templates/Main.vue";
import Permission from "@/components/admin/Permission.vue";

View file

@ -1,15 +1,9 @@
<template>
<MainTemplate>
<MainTemplate title="Kalender">
<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">Kalender</h1>
<div class="flex flex-row gap-2">
<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 class="flex flex-row gap-2">
<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>
</template>
<template #diffMain>

View file

@ -1,84 +1,77 @@
<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">Liste Drucken</h1>
</div>
</template>
<template #diffMain>
<div class="flex flex-col w-full h-full gap-2 px-7 overflow-y-auto">
<form
class="flex flex-col h-fit w-full border border-primary rounded-md p-2 gap-2"
@submit.prevent="sendPrintJob"
>
<div class="flex flex-row gap-2 items-center">
<p class="min-w-16">Titel:</p>
<input id="title" type="text" required />
</div>
<div class="flex flex-row gap-2 items-center">
<p class="min-w-16">Query:</p>
<select id="query" value="member">
<option value="member">(system) alle Mitglieder</option>
<option value="memberByRunningMembership">(system) alle Mitglieder mit laufender Mitgliedschaft</option>
<option v-for="query in queries" :key="query.id" :value="query.id">
{{ query.title }}
</option>
</select>
</div>
<div class="flex flex-col md:flex-row gap-2 md:items-center">
<div class="flex flex-row w-full gap-2 items-center">
<p class="min-w-16">Kopfzeile:</p>
<select id="header" value="def">
<option value="def">Standard-Vorlage verwenden</option>
<option v-for="template in templates" :key="template.id" :value="template.id">
{{ template.template }}
</option>
</select>
</div>
<div class="flex flex-row gap-2 items-center">
<p class="whitespace-nowrap">Höhe [mm]:</p>
<input id="headerHeight" type="number" :min="15" class="w-24!" placeholder="15" />
</div>
</div>
<div class="flex flex-row gap-2 items-center">
<p class="min-w-16">Hauptteil:</p>
<select id="body" value="def">
<MainTemplate title="Liste Drucken">
<template #main>
<form
class="flex flex-col h-fit w-full border border-primary rounded-md p-2 gap-2"
@submit.prevent="sendPrintJob"
>
<div class="flex flex-row gap-2 items-center">
<p class="min-w-16">Titel:</p>
<input id="title" type="text" required />
</div>
<div class="flex flex-row gap-2 items-center">
<p class="min-w-16">Query:</p>
<select id="query" value="member">
<option value="member">(system) alle Mitglieder</option>
<option value="memberByRunningMembership">(system) alle Mitglieder mit laufender Mitgliedschaft</option>
<option v-for="query in queries" :key="query.id" :value="query.id">
{{ query.title }}
</option>
</select>
</div>
<div class="flex flex-col md:flex-row gap-2 md:items-center">
<div class="flex flex-row w-full gap-2 items-center">
<p class="min-w-16">Kopfzeile:</p>
<select id="header" value="def">
<option value="def">Standard-Vorlage verwenden</option>
<option value="listprint.member">(system) Mitgliederliste</option>
<option v-for="template in templates" :key="template.id" :value="template.id">
{{ template.template }}
</option>
</select>
</div>
<div class="flex flex-col md:flex-row gap-2 md:items-center">
<div class="flex flex-row w-full gap-2 items-center">
<p class="min-w-16">Fußzeile:</p>
<select id="footer" value="def">
<option value="def">Standard-Vorlage verwenden</option>
<option v-for="template in templates" :key="template.id" :value="template.id">
{{ template.template }}
</option>
</select>
</div>
<div class="flex flex-row gap-2 items-center">
<p class="whitespace-nowrap">Höhe [mm]:</p>
<input id="footerHeight" type="number" :min="15" class="w-24!" placeholder="15" />
</div>
<div class="flex flex-row gap-2 items-center">
<p class="whitespace-nowrap">Höhe [mm]:</p>
<input id="headerHeight" type="number" :min="15" class="w-24!" placeholder="15" />
</div>
<div class="flex flex-row gap-2">
<button type="submit" primary class="w-fit!">Liste drucken</button>
<button type="reset" primary-outline class="w-fit!">zurücksetzen</button>
</div>
<div class="flex flex-row gap-2 items-center">
<p class="min-w-16">Hauptteil:</p>
<select id="body" value="def">
<option value="def">Standard-Vorlage verwenden</option>
<option value="listprint.member">(system) Mitgliederliste</option>
<option v-for="template in templates" :key="template.id" :value="template.id">
{{ template.template }}
</option>
</select>
</div>
<div class="flex flex-col md:flex-row gap-2 md:items-center">
<div class="flex flex-row w-full gap-2 items-center">
<p class="min-w-16">Fußzeile:</p>
<select id="footer" value="def">
<option value="def">Standard-Vorlage verwenden</option>
<option v-for="template in templates" :key="template.id" :value="template.id">
{{ template.template }}
</option>
</select>
</div>
</form>
<div class="w-full grow min-h-[50%] flex flex-col gap-2">
<Spinner v-if="status == 'loading'" />
<div class="grow">
<iframe v-show="status == 'success'" ref="viewer" class="w-full h-full" />
<div class="flex flex-row gap-2 items-center">
<p class="whitespace-nowrap">Höhe [mm]:</p>
<input id="footerHeight" type="number" :min="15" class="w-24!" placeholder="15" />
</div>
</div>
<div class="flex flex-row gap-2">
<button type="submit" primary class="w-fit!">Liste drucken</button>
<button type="reset" primary-outline class="w-fit!">zurücksetzen</button>
</div>
</form>
<div class="w-full grow min-h-[50%] flex flex-col gap-2">
<Spinner v-if="status == 'loading'" />
<div class="grow">
<iframe v-show="status == 'success'" ref="viewer" class="w-full h-full" />
</div>
<div v-show="status == 'success'" class="flex flex-row gap-2 justify-end">
<a ref="download" button primary class="w-fit!">download</a>
</div>
<div v-show="status == 'success'" class="flex flex-row gap-2 justify-end">
<a ref="download" button primary class="w-fit!">download</a>
</div>
</div>
</template>

View file

@ -1,10 +1,5 @@
<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">Mitglieder</h1>
</div>
</template>
<MainTemplate title="Mitglieder">
<template #diffMain>
<div class="flex flex-col w-full h-full gap-2 justify-center px-7">
<Pagination

View file

@ -75,6 +75,10 @@
<label for="internalId">Interne ID (optional)</label>
<input type="text" id="internalId" v-model="member.internalId" />
</div>
<div>
<label for="note">Notiz (optional)</label>
<textarea type="text" id="note" v-model="member.note" />
</div>
<div class="flex flex-row justify-end gap-2">
<button primary-outline type="reset" class="w-fit!" :disabled="canSaveOrReset" @click="resetForm">
verwerfen
@ -93,7 +97,6 @@
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapActions, mapState } from "pinia";
import MainTemplate from "@/templates/Main.vue";
import { useMemberStore } from "@/stores/admin/club/member/member";
import type { MemberViewModel, UpdateMemberViewModel } from "@/viewmodels/admin/club/member/member.models";
import Spinner from "@/components/Spinner.vue";
@ -103,7 +106,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">
@ -163,6 +166,7 @@ export default defineComponent({
nameaffix: formData.nameaffix.value,
birthdate: formData.birthdate.value,
internalId: formData.internalId.value,
note: formData.note.value,
};
this.status = "loading";
this.updateActiveMember(updateMember)

View file

@ -0,0 +1,51 @@
<template>
<div class="flex flex-col gap-2 h-full w-full overflow-y-auto">
<div v-if="memberEducations != null" class="flex flex-col gap-2 w-full">
<MemberEducationListItem v-for="education in memberEducations" :key="education.id" :education="education" />
</div>
<Spinner v-if="loading == 'loading'" class="mx-auto" />
<p v-else-if="loading == 'failed'" @click="fetchItem" class="cursor-pointer">&#8634; laden fehlgeschlagen</p>
</div>
<div class="flex flex-row gap-4">
<button v-if="can('create', 'club', 'member')" primary class="w-fit!" @click="openCreateModal">
Aus-/Fortbildung hinzufügen
</button>
</div>
</template>
<script setup lang="ts">
import { defineAsyncComponent, defineComponent, markRaw } from "vue";
import { mapActions, mapState } from "pinia";
import Spinner from "@/components/Spinner.vue";
import { useMemberEducationStore } from "@/stores/admin/club/member/memberEducation";
import MemberEducationListItem from "@/components/admin/club/member/MemberEducationListItem.vue";
import { useModalStore } from "@/stores/modal";
import { useAbilityStore } from "@/stores/ability";
</script>
<script lang="ts">
export default defineComponent({
props: {
memberId: String,
},
computed: {
...mapState(useMemberEducationStore, ["memberEducations", "loading"]),
...mapState(useAbilityStore, ["can"]),
},
mounted() {
this.fetchItem();
},
methods: {
...mapActions(useMemberEducationStore, ["fetchMemberEducationsForMember"]),
...mapActions(useModalStore, ["openModal"]),
fetchItem() {
this.fetchMemberEducationsForMember();
},
openCreateModal() {
this.openModal(
markRaw(defineAsyncComponent(() => import("@/components/admin/club/member/MemberEducationCreateModal.vue")))
);
},
},
});
</script>

View file

@ -25,9 +25,19 @@
<label for="birthdate">Geburtsdatum</label>
<input type="date" id="birthdate" :value="activeMemberObj.birthdate" readonly />
</div>
<div v-if="membershipStatistics.length != 0">
<div>
<label for="note">Notiz</label>
<textarea type="text" id="note" v-model="activeMemberObj.note" readonly />
</div>
<div v-if="membershipStatistics.length != 0 || totalMembershipStatistics != undefined">
<p>Statistiken zur Mitgliedschaft</p>
<div class="flex flex-col h-fit w-full border border-primary rounded-md">
<div class="flex flex-col h-fit w-full rounded-md overflow-hidden divide-y divide-white">
<div class="bg-primary p-2 text-white flex flex-row justify-between items-center">
<p>
gesamt {{ totalMembershipStatistics?.durationInDays }} Tage
<span class="whitespace-nowrap"> ~> {{ totalMembershipStatistics?.exactDuration }}</span>
</p>
</div>
<div
v-for="stat in membershipStatistics"
class="bg-primary p-2 text-white flex flex-row justify-between items-center"
@ -149,16 +159,20 @@ export default defineComponent({
},
computed: {
...mapState(useMemberStore, ["activeMemberObj", "activeMemberStatistics", "loadingActive"]),
...mapState(useMembershipStore, ["membershipStatistics"]),
...mapState(useMembershipStore, ["membershipStatistics", "totalMembershipStatistics"]),
},
mounted() {
this.fetchMemberByActiveId();
this.fetchMemberStatisticsByActiveId();
this.fetchMembershipStatisticsForMember();
this.fetchMembershipTotalStatisticsForMember();
},
methods: {
...mapActions(useMemberStore, ["fetchMemberByActiveId", "fetchMemberStatisticsByActiveId"]),
...mapActions(useMembershipStore, ["fetchMembershipStatisticsForMember"]),
...mapActions(useMembershipStore, [
"fetchMembershipStatisticsForMember",
"fetchMembershipTotalStatisticsForMember",
]),
},
});
</script>

View file

@ -4,12 +4,11 @@
<RouterLink to="../" class="text-primary">zurück zur Liste</RouterLink>
</template>
<template #topBar>
<div class="flex flex-row gap-2 items-center justify-between pt-5 pb-3 px-7">
<h1 class="font-bold text-xl h-8 min-h-fit grow">
{{ activeMemberObj?.lastname }}, {{ activeMemberObj?.firstname }}
{{ activeMemberObj?.nameaffix ? `- ${activeMemberObj?.nameaffix}` : "" }}
</h1>
<h1 class="font-bold text-xl h-8 min-h-fit">
{{ activeMemberObj?.lastname }}, {{ activeMemberObj?.firstname }}
{{ activeMemberObj?.nameaffix ? `- ${activeMemberObj?.nameaffix}` : "" }}
</h1>
<div class="flex flex-row gap-2">
<div title="Mitgliederliste drucken" @click="openPrintModal">
<DocumentTextIcon class="w-5 h-5 cursor-pointer" />
</div>
@ -22,7 +21,7 @@
<template #diffMain>
<div class="flex flex-col gap-2 grow px-7 overflow-hidden">
<div class="flex flex-col grow gap-2 overflow-hidden">
<div class="w-full flex flex-row max-lg:flex-wrap justify-center">
<div class="w-full flex flex-row max-lg:flex-wrap justify-center items-stretch">
<RouterLink
v-for="tab in tabs"
:key="tab.route"
@ -32,7 +31,7 @@
>
<p
:class="[
'w-full rounded-lg py-2.5 text-sm text-center font-medium leading-5 focus:ring-0 outline-hidden',
'flex w-full h-full items-center justify-center rounded-lg py-2.5 text-sm text-center font-medium leading-5 focus:ring-0 outline-hidden',
isActive ? 'bg-red-200 shadow-sm border-b-2 border-primary rounded-b-none' : ' hover:bg-red-200',
]"
>
@ -69,8 +68,9 @@ export default defineComponent({
{ route: "admin-club-member-overview", title: "Übersicht" },
{ route: "admin-club-member-membership", title: "Mitgliedschaft" },
{ route: "admin-club-member-communication", title: "Kommunikation" },
{ route: "admin-club-member-awards", title: "Auszeichnungen" },
{ route: "admin-club-member-qualifications", title: "Qualifikationen" },
{ route: "admin-club-member-awards", title: "Auszeichnungen / Ehrungen" },
{ route: "admin-club-member-educations", title: "Aus- / Fortbildungen" },
{ route: "admin-club-member-qualifications", title: "Qualifikationen / Funktionen" },
{ route: "admin-club-member-positions", title: "Vereinsämter" },
],
};

View file

@ -1,24 +0,0 @@
<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">Übersicht</h1>
</div>
</template>
<template #diffMain>
<div class="flex flex-col gap-2 justify-center items-center h-full"></div>
</template>
</MainTemplate>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapState } from "pinia";
import MainTemplate from "@/templates/Main.vue";
</script>
<script lang="ts">
export default defineComponent({
computed: {},
});
</script>

View file

@ -1,10 +1,5 @@
<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">Newsletter</h1>
</div>
</template>
<MainTemplate title="Newsletter">
<template #diffMain>
<div class="flex flex-col w-full h-full gap-2 justify-center px-7">
<Pagination
@ -38,7 +33,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">

View file

@ -37,11 +37,15 @@
})
}}
</p>
<TrashIcon
<DoubleConfirmClick
v-if="can('create', 'club', 'newsletter')"
class="w-5 h-5 p-1 box-content cursor-pointer text-white"
@click.prevent="removeSelected(item.calendarId)"
/>
light
v-slot="{ isSensitive }"
@click:submit="removeSelected(item.calendarId)"
>
<TrashIcon v-if="!isSensitive" class="h-5 w-5" />
<TrashIconSolid v-else class="h-5 w-5" />
</DoubleConfirmClick>
</summary>
<div class="flex flex-col gap-2 px-1">
<input
@ -111,8 +115,10 @@ import { useAbilityStore } from "@/stores/ability";
import { useCalendarStore } from "@/stores/admin/club/calendar";
import type { CalendarViewModel } from "@/viewmodels/admin/club/calendar.models";
import { TrashIcon } from "@heroicons/vue/24/outline";
import { TrashIcon as TrashIconSolid } from "@heroicons/vue/24/solid";
import cloneDeep from "lodash.clonedeep";
import type { NewsletterDatesViewModel } from "@/viewmodels/admin/club/newsletter/newsletterDates.models";
import DoubleConfirmClick from "@/components/DoubleConfirmClick.vue";
</script>
<script lang="ts">

View file

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

View file

@ -42,11 +42,15 @@
<p>Newsletter senden an Typ: {{ member.sendNewsletter?.type.type ?? "---" }}</p>
</div>
<TrashIcon
<DoubleConfirmClick
v-if="can('create', 'club', 'newsletter') && showMemberSelect"
class="w-5 h-5 p-1 box-content cursor-pointer"
@click="removeSelected(member.id)"
/>
light
v-slot="{ isSensitive }"
@click:submit="removeSelected(member.id)"
>
<TrashIcon v-if="!isSensitive" class="h-5 w-5" />
<TrashIconSolid v-else class="h-5 w-5" />
</DoubleConfirmClick>
</div>
</div>
@ -61,17 +65,8 @@
import { defineComponent } from "vue";
import { mapActions, mapState, mapWritableState } from "pinia";
import Spinner from "@/components/Spinner.vue";
import {
Combobox,
ComboboxLabel,
ComboboxInput,
ComboboxButton,
ComboboxOptions,
ComboboxOption,
TransitionRoot,
} from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { ArchiveBoxIcon, ExclamationTriangleIcon, TrashIcon, UserPlusIcon } from "@heroicons/vue/24/outline";
import { TrashIcon as TrashIconSolid } from "@heroicons/vue/24/solid";
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";
@ -79,9 +74,9 @@ import { useNewsletterRecipientsStore } from "@/stores/admin/club/newsletter/new
import { useAbilityStore } from "@/stores/ability";
import { useQueryStoreStore } from "@/stores/admin/configuration/queryStore";
import { useQueryBuilderStore } from "@/stores/admin/club/queryBuilder";
import cloneDeep from "lodash.clonedeep";
import MemberSearchSelect from "@/components/admin/MemberSearchSelect.vue";
import type { FieldType } from "@/types/dynamicQueries";
import DoubleConfirmClick from "@/components/DoubleConfirmClick.vue";
</script>
<script lang="ts">

View file

@ -1,20 +1,17 @@
<template>
<MainTemplate>
<MainTemplate :title="origin?.title">
<template #headerInsert>
<RouterLink to="../" class="text-primary w-fit">zurück zur Liste</RouterLink>
</template>
<template #topBar>
<div class="flex flex-row gap-2 items-center justify-between pt-5 pb-3 px-7">
<h1 class="font-bold text-xl h-8 min-h-fit grow">{{ origin?.title }}</h1>
<NewsletterSyncing
:executeSyncAll="executeSyncAll"
@syncState="
(state) => {
syncState = state;
}
"
/>
</div>
<NewsletterSyncing
:executeSyncAll="executeSyncAll"
@syncState="
(state) => {
syncState = state;
}
"
/>
</template>
<template #diffMain>
<div class="flex flex-col gap-2 grow px-7 overflow-hidden">
@ -53,8 +50,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">

View file

@ -1,10 +1,5 @@
<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">Protokolle</h1>
</div>
</template>
<MainTemplate title="Protokolle">
<template #diffMain>
<div class="flex flex-col w-full h-full gap-2 justify-center px-7">
<Pagination

View file

@ -31,6 +31,16 @@
:disabled="!can('create', 'club', 'protocol')"
/>
<DoubleConfirmClick
v-if="can('create', 'club', 'protocol')"
light
v-slot="{ isSensitive }"
@click:submit="removeFromArray(item.id)"
>
<TrashIcon v-if="!isSensitive" class="h-5 w-5" />
<TrashIconSolid v-else class="h-5 w-5" />
</DoubleConfirmClick>
<div class="flex flex-col">
<ChevronUpIcon
v-if="index != 0"
@ -73,7 +83,9 @@ import "@vueup/vue-quill/dist/vue-quill.snow.css";
import { toolbarOptions } from "@/helpers/quillConfig";
import { useProtocolAgendaStore } from "@/stores/admin/club/protocol/protocolAgenda";
import { useAbilityStore } from "@/stores/ability";
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/vue/24/outline";
import { ChevronDownIcon, ChevronUpIcon, TrashIcon } from "@heroicons/vue/24/outline";
import { TrashIcon as TrashIconSolid } from "@heroicons/vue/24/solid";
import DoubleConfirmClick from "@/components/DoubleConfirmClick.vue";
</script>
<script lang="ts">
@ -109,6 +121,9 @@ export default defineComponent({
});
}
},
removeFromArray(thisId: number) {
this.agenda = this.agenda.filter((item) => item.id !== thisId);
},
},
});
</script>

View file

@ -31,6 +31,16 @@
:disabled="!can('create', 'club', 'protocol')"
/>
<DoubleConfirmClick
v-if="can('create', 'club', 'protocol')"
light
v-slot="{ isSensitive }"
@click:submit="removeFromArray(item.id)"
>
<TrashIcon v-if="!isSensitive" class="h-5 w-5" />
<TrashIconSolid v-else class="h-5 w-5" />
</DoubleConfirmClick>
<div class="flex flex-col">
<ChevronUpIcon
v-if="index != 0"
@ -73,7 +83,9 @@ import "@vueup/vue-quill/dist/vue-quill.snow.css";
import { toolbarOptions } from "@/helpers/quillConfig";
import { useProtocolDecisionStore } from "@/stores/admin/club/protocol/protocolDecision";
import { useAbilityStore } from "@/stores/ability";
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/vue/24/outline";
import { ChevronDownIcon, ChevronUpIcon, TrashIcon } from "@heroicons/vue/24/outline";
import { TrashIcon as TrashIconSolid } from "@heroicons/vue/24/solid";
import DoubleConfirmClick from "@/components/DoubleConfirmClick.vue";
</script>
<script lang="ts">
@ -109,6 +121,9 @@ export default defineComponent({
});
}
},
removeFromArray(thisId: number) {
this.decision = this.decision.filter((item) => item.id !== thisId);
},
},
});
</script>

View file

@ -42,11 +42,15 @@
</label>
</div>
</div>
<TrashIcon
<DoubleConfirmClick
v-if="can('create', 'club', 'protocol')"
class="w-5 h-5 p-1 box-content cursor-pointer"
@click="removeSelected(member.memberId)"
/>
light
v-slot="{ isSensitive }"
@click:submit="removeSelected(member.memberId)"
>
<TrashIcon v-if="!isSensitive" class="h-5 w-5" />
<TrashIconSolid v-else class="h-5 w-5" />
</DoubleConfirmClick>
</div>
</div>
</div>
@ -57,10 +61,12 @@ import { defineComponent } from "vue";
import { mapActions, mapState, mapWritableState } from "pinia";
import Spinner from "@/components/Spinner.vue";
import { TrashIcon } from "@heroicons/vue/24/outline";
import { TrashIcon as TrashIconSolid } from "@heroicons/vue/24/solid";
import { useProtocolPresenceStore } from "@/stores/admin/club/protocol/protocolPresence";
import { useAbilityStore } from "@/stores/ability";
import MemberSearchSelect from "@/components/admin/MemberSearchSelect.vue";
import type { MemberViewModel } from "@/viewmodels/admin/club/member/member.models";
import DoubleConfirmClick from "@/components/DoubleConfirmClick.vue";
</script>
<script lang="ts">

View file

@ -1,20 +1,17 @@
<template>
<MainTemplate>
<MainTemplate :title="`${origin?.title}, ${origin?.date}`">
<template #headerInsert>
<RouterLink to="../" class="text-primary w-fit">zurück zur Liste</RouterLink>
</template>
<template #topBar>
<div class="flex flex-row gap-2 items-center justify-between pt-5 pb-3 px-7">
<h1 class="font-bold text-xl h-8 min-h-fit grow">{{ origin?.title }}, {{ origin?.date }}</h1>
<ProtocolSyncing
:executeSyncAll="executeSyncAll"
@syncState="
(state) => {
syncState = state;
}
"
/>
</div>
<ProtocolSyncing
:executeSyncAll="executeSyncAll"
@syncState="
(state) => {
syncState = state;
}
"
/>
</template>
<template #diffMain>
<div class="flex flex-col gap-2 grow px-7 overflow-hidden">
@ -92,10 +89,10 @@ export default defineComponent({
},
mounted() {
this.fetchProtocolByActiveId();
this.fetchProtocolAgenda()
this.fetchProtocolDecision()
this.fetchProtocolPresence()
this.fetchProtocolVoting()
this.fetchProtocolAgenda();
this.fetchProtocolDecision();
this.fetchProtocolPresence();
this.fetchProtocolVoting();
},
// this.syncState is undefined, so it will never work
// beforeRouteLeave(to, from, next) {
@ -118,8 +115,8 @@ export default defineComponent({
...mapActions(useProtocolStore, ["fetchProtocolByActiveId"]),
...mapActions(useProtocolAgendaStore, ["fetchProtocolAgenda"]),
...mapActions(useProtocolDecisionStore, ["fetchProtocolDecision"]),
...mapActions(useProtocolPresenceStore,["fetchProtocolPresence"]),
...mapActions(useProtocolVotingStore,["fetchProtocolVoting"]),
...mapActions(useProtocolPresenceStore, ["fetchProtocolPresence"]),
...mapActions(useProtocolVotingStore, ["fetchProtocolVoting"]),
...mapActions(useModalStore, ["openModal"]),
openInfoModal() {
this.openModal(

View file

@ -31,6 +31,16 @@
:disabled="!can('create', 'club', 'protocol')"
/>
<DoubleConfirmClick
v-if="can('create', 'club', 'protocol')"
light
v-slot="{ isSensitive }"
@click:submit="removeFromArray(item.id)"
>
<TrashIcon v-if="!isSensitive" class="h-5 w-5" />
<TrashIconSolid v-else class="h-5 w-5" />
</DoubleConfirmClick>
<div class="flex flex-col">
<ChevronUpIcon
v-if="index != 0"
@ -83,14 +93,16 @@
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapActions, mapState } from "pinia";
import { mapActions, mapState, mapWritableState } from "pinia";
import Spinner from "@/components/Spinner.vue";
import { QuillEditor } from "@vueup/vue-quill";
import "@vueup/vue-quill/dist/vue-quill.snow.css";
import { toolbarOptions } from "@/helpers/quillConfig";
import { useProtocolVotingStore } from "@/stores/admin/club/protocol/protocolVoting";
import { useAbilityStore } from "@/stores/ability";
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/vue/24/outline";
import { ChevronDownIcon, ChevronUpIcon, TrashIcon } from "@heroicons/vue/24/outline";
import { TrashIcon as TrashIconSolid } from "@heroicons/vue/24/solid";
import DoubleConfirmClick from "@/components/DoubleConfirmClick.vue";
</script>
<script lang="ts">
@ -99,7 +111,7 @@ export default defineComponent({
protocolId: String,
},
computed: {
...mapState(useProtocolVotingStore, ["voting", "loading"]),
...mapWritableState(useProtocolVotingStore, ["voting", "loading"]),
...mapState(useAbilityStore, ["can"]),
sortedVoting() {
return this.voting.slice().sort((a, b) => a.sort - b.sort);
@ -126,6 +138,9 @@ export default defineComponent({
});
}
},
removeFromArray(thisId: number) {
this.voting = this.voting.filter((item) => item.id !== thisId);
},
},
});
</script>

View file

@ -1,57 +1,50 @@
<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">Query Builder</h1>
</div>
</template>
<template #diffMain>
<div class="flex flex-col w-full h-full gap-2 px-7 overflow-y-auto">
<BuilderHost
v-model="query"
allow-predefined-select
@query:run="sendQuery"
@query:save="triggerSave"
@results:clear="clearResults"
@results:export="exportData"
/>
<p>Ergebnisse:</p>
<div
v-if="loadingData == 'failed'"
class="flex flex-col p-2 border border-red-600 bg-red-200 rounded-md select-none"
>
<p v-if="typeof queryError == 'string'">
{{ queryError }}
<MainTemplate title="Query Builder">
<template #main>
<BuilderHost
v-model="query"
allow-predefined-select
@query:run="sendQuery"
@query:save="triggerSave"
@results:clear="clearResults"
@results:export="exportData"
/>
<p>Ergebnisse:</p>
<div
v-if="loadingData == 'failed'"
class="flex flex-col p-2 border border-red-600 bg-red-200 rounded-md select-none"
>
<p v-if="typeof queryError == 'string'">
{{ queryError }}
</p>
<div v-else>
<p>
CODE: <br />
{{ queryError.code }}
</p>
<br />
<p>
MSG: <br />
{{ queryError.msg }}
</p>
<br />
<p>
RESULTING SQL: <br />
{{ queryError.sql }}
</p>
<div v-else>
<p>
CODE: <br />
{{ queryError.code }}
</p>
<br />
<p>
MSG: <br />
{{ queryError.msg }}
</p>
<br />
<p>
RESULTING SQL: <br />
{{ queryError.sql }}
</p>
</div>
</div>
<Pagination
v-else
:items="data"
:totalCount="totalLength"
:indicateLoading="loadingData == 'loading'"
@load-data="(offset, count) => sendQuery(offset, count)"
>
<template #pageRow="{ row }: { row: { id: FieldType; [key: string]: FieldType } }">
<p>{{ row }}</p>
</template>
</Pagination>
</div>
<Pagination
v-else
:items="data"
:totalCount="totalLength"
:indicateLoading="loadingData == 'loading'"
@load-data="(offset, count) => sendQuery(offset, count)"
>
<template #pageRow="{ row }: { row: { id: FieldType; [key: string]: FieldType } }">
<p>{{ row }}</p>
</template>
</Pagination>
</template>
</MainTemplate>
</template>

View file

@ -1,10 +1,5 @@
<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">Auszeichnungen</h1>
</div>
</template>
<MainTemplate title="Auszeichnungen">
<template #diffMain>
<div class="flex flex-col gap-4 h-full pl-7">
<div class="flex flex-col gap-2 grow overflow-y-scroll pr-7">

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