Merge pull request '#15-messages' (#22) from #15-messages into main

Reviewed-on: Ehrenamt/member-administration-ui#22
This commit is contained in:
Julian Krauser 2024-12-31 13:25:26 +00:00
commit fc33a7dae7
84 changed files with 3168 additions and 80 deletions

View file

@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg" href="/favicon.svg" /> <link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mitgliederverwaltung</title> <title>Mitgliederverwaltung</title>
</head> </head>

124
package-lock.json generated
View file

@ -1,11 +1,11 @@
{ {
"name": "fireportal-ui", "name": "member-administration-ui",
"version": "0.0.11", "version": "0.0.11",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "fireportal-ui", "name": "member-administration-ui",
"version": "0.0.11", "version": "0.0.11",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"dependencies": { "dependencies": {
@ -18,6 +18,9 @@
"@heroicons/vue": "^2.1.5", "@heroicons/vue": "^2.1.5",
"@vueup/vue-quill": "^1.2.0", "@vueup/vue-quill": "^1.2.0",
"axios": "^0.26.1", "axios": "^0.26.1",
"event-source-polyfill": "^1.0.31",
"grapesjs": "^0.22.4",
"grapesjs-preset-newsletter": "^1.0.2",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lodash.clonedeep": "^4.5.0", "lodash.clonedeep": "^4.5.0",
"lodash.difference": "^4.5.0", "lodash.difference": "^4.5.0",
@ -37,6 +40,7 @@
"@rushstack/eslint-patch": "^1.8.0", "@rushstack/eslint-patch": "^1.8.0",
"@tsconfig/node20": "^20.1.4", "@tsconfig/node20": "^20.1.4",
"@types/eslint": "~9.6.0", "@types/eslint": "~9.6.0",
"@types/event-source-polyfill": "^1.0.5",
"@types/lodash.clonedeep": "^4.5.9", "@types/lodash.clonedeep": "^4.5.9",
"@types/lodash.difference": "^4.5.9", "@types/lodash.difference": "^4.5.9",
"@types/lodash.differencewith": "^4.5.9", "@types/lodash.differencewith": "^4.5.9",
@ -3073,6 +3077,16 @@
"integrity": "sha512-sqgsT69YFeLWf5NtJ4Xq/xAF8p4ZQHlmGW74Nu2tD4+g5fAsposc4ZfaaPixVu4y01BEiDCWLRDCvDM5JOsRxg==", "integrity": "sha512-sqgsT69YFeLWf5NtJ4Xq/xAF8p4ZQHlmGW74Nu2tD4+g5fAsposc4ZfaaPixVu4y01BEiDCWLRDCvDM5JOsRxg==",
"dev": true "dev": true
}, },
"node_modules/@types/backbone": {
"version": "1.4.15",
"resolved": "https://registry.npmjs.org/@types/backbone/-/backbone-1.4.15.tgz",
"integrity": "sha512-WWeKtYlsIMtDyLbbhkb96taJMEbfQBnuz7yw1u0pkphCOtksemoWhIXhK74VRCY9hbjnsH3rsJu2uUiFtnsEYg==",
"license": "MIT",
"dependencies": {
"@types/jquery": "*",
"@types/underscore": "*"
}
},
"node_modules/@types/eslint": { "node_modules/@types/eslint": {
"version": "9.6.0", "version": "9.6.0",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.0.tgz", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.0.tgz",
@ -3089,6 +3103,22 @@
"integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
"dev": true "dev": true
}, },
"node_modules/@types/event-source-polyfill": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/event-source-polyfill/-/event-source-polyfill-1.0.5.tgz",
"integrity": "sha512-iaiDuDI2aIFft7XkcwMzDWLqo7LVDixd2sR6B4wxJut9xcp/Ev9bO4EFg4rm6S9QxATLBj5OPxdeocgmhjwKaw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/jquery": {
"version": "3.5.32",
"resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.32.tgz",
"integrity": "sha512-b9Xbf4CkMqS02YH8zACqN1xzdxc3cO735Qe5AbSUFmyOiaWAbcpqh9Wna+Uk0vgACvoQHpWDg2rGdHkYPLmCiQ==",
"license": "MIT",
"dependencies": {
"@types/sizzle": "*"
}
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@ -3178,12 +3208,24 @@
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
"dev": true "dev": true
}, },
"node_modules/@types/sizzle": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.9.tgz",
"integrity": "sha512-xzLEyKB50yqCUPUJkIsrVvoWNfFUbIZI+RspLWt8u+tIW/BetMBZtgV2LY/2o+tYH8dRvQ+eoPf3NdhQCcLE2w==",
"license": "MIT"
},
"node_modules/@types/trusted-types": { "node_modules/@types/trusted-types": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"dev": true "dev": true
}, },
"node_modules/@types/underscore": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/@types/underscore/-/underscore-1.13.0.tgz",
"integrity": "sha512-L6LBgy1f0EFQZ+7uSA57+n2g/s4Qs5r06Vwrwn0/nuK1de+adz00NWaztRQ30aEqw5qOaWbPI8u2cGQ52lj6VA==",
"license": "MIT"
},
"node_modules/@types/uuid": { "node_modules/@types/uuid": {
"version": "9.0.8", "version": "9.0.8",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz",
@ -4019,6 +4061,26 @@
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
} }
}, },
"node_modules/backbone": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/backbone/-/backbone-1.4.1.tgz",
"integrity": "sha512-ADy1ztN074YkWbHi8ojJVFe3vAanO/lrzMGZWUClIP7oDD/Pjy2vrASraUP+2EVCfIiTtCW4FChVow01XneivA==",
"license": "MIT",
"dependencies": {
"underscore": ">=1.8.3"
}
},
"node_modules/backbone-undo": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/backbone-undo/-/backbone-undo-0.2.6.tgz",
"integrity": "sha512-AsfpNiljLXlk7TcffDUu3EAUq7CxWbyTNwARWrql5XTzN4vh6WzEEBZYaKK4kTTz+iW1tSzqUooaGRIwO83kWA==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT",
"dependencies": {
"backbone": ">=1.0.0",
"underscore": ">=1.4.4"
}
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -4383,6 +4445,18 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/codemirror": {
"version": "5.63.0",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.63.0.tgz",
"integrity": "sha512-KlLWRPggDg2rBD1Mx7/EqEhaBdy+ybBCVh/efgjBDsPpMeEu6MbTAJzIT4TuCzvmbTEgvKOGzVT6wdBTNusqrg==",
"license": "MIT"
},
"node_modules/codemirror-formatting": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/codemirror-formatting/-/codemirror-formatting-1.0.0.tgz",
"integrity": "sha512-br9yM6eJI3pJHekEnoyHaBEb1B7XxxDjju+vRyBe8QGLp5saTIXXkZ+eFCTqXSAtI8QEZDFVEX2/SOjH2sVWRQ==",
"license": "MIT"
},
"node_modules/color": { "node_modules/color": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
@ -5335,6 +5409,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/event-source-polyfill": {
"version": "1.0.31",
"resolved": "https://registry.npmjs.org/event-source-polyfill/-/event-source-polyfill-1.0.31.tgz",
"integrity": "sha512-4IJSItgS/41IxN5UVAVuAyczwZF7ZIEsM1XAoUzIHA6A+xzusEZUutdXz2Nr+MQPLxfTiCvqE79/C8HT8fKFvA==",
"license": "MIT"
},
"node_modules/eventemitter3": { "node_modules/eventemitter3": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
@ -5889,6 +5969,28 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true "dev": true
}, },
"node_modules/grapesjs": {
"version": "0.22.4",
"resolved": "https://registry.npmjs.org/grapesjs/-/grapesjs-0.22.4.tgz",
"integrity": "sha512-4ea7T5FguyPC2fLytpSBgPXcSGreRKKisknXUbsgHBCzv4G11Z0oBJNM5jRucupBr2CRxt/3U2zixeEHEisfbw==",
"license": "BSD-3-Clause",
"dependencies": {
"@types/backbone": "1.4.15",
"backbone": "1.4.1",
"backbone-undo": "0.2.6",
"codemirror": "5.63.0",
"codemirror-formatting": "1.0.0",
"html-entities": "~1.4.0",
"promise-polyfill": "8.3.0",
"underscore": "1.13.1"
}
},
"node_modules/grapesjs-preset-newsletter": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/grapesjs-preset-newsletter/-/grapesjs-preset-newsletter-1.0.2.tgz",
"integrity": "sha512-z8KJ1ZrTXfASSJZ/tHOcnpcWu4AMr2F/ZfQit+QjimNi3UGowwl7+Yjefuh3R7lbDTrXMMaxhCannCaJo/kPJw==",
"license": "BSD-3-Clause"
},
"node_modules/graphemer": { "node_modules/graphemer": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
@ -5987,6 +6089,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/html-entities": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-1.4.0.tgz",
"integrity": "sha512-8nxjcBcd8wovbeKx7h3wTji4e6+rhaVuPNpMqwWgnHh+N9ToqsCs6XztWRBPQ+UtzsoMAdKZtUENoVzU/EMtZA==",
"license": "MIT"
},
"node_modules/html-tags": { "node_modules/html-tags": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz",
@ -7825,6 +7933,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/promise-polyfill": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz",
"integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==",
"license": "MIT"
},
"node_modules/pump": { "node_modules/pump": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
@ -9330,6 +9444,12 @@
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
} }
}, },
"node_modules/underscore": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.1.tgz",
"integrity": "sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g==",
"license": "MIT"
},
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "5.26.5", "version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",

View file

@ -33,6 +33,9 @@
"@heroicons/vue": "^2.1.5", "@heroicons/vue": "^2.1.5",
"@vueup/vue-quill": "^1.2.0", "@vueup/vue-quill": "^1.2.0",
"axios": "^0.26.1", "axios": "^0.26.1",
"event-source-polyfill": "^1.0.31",
"grapesjs": "^0.22.4",
"grapesjs-preset-newsletter": "^1.0.2",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lodash.clonedeep": "^4.5.0", "lodash.clonedeep": "^4.5.0",
"lodash.difference": "^4.5.0", "lodash.difference": "^4.5.0",
@ -52,6 +55,7 @@
"@rushstack/eslint-patch": "^1.8.0", "@rushstack/eslint-patch": "^1.8.0",
"@tsconfig/node20": "^20.1.4", "@tsconfig/node20": "^20.1.4",
"@types/eslint": "~9.6.0", "@types/eslint": "~9.6.0",
"@types/event-source-polyfill": "^1.0.5",
"@types/lodash.clonedeep": "^4.5.9", "@types/lodash.clonedeep": "^4.5.9",
"@types/lodash.difference": "^4.5.9", "@types/lodash.difference": "^4.5.9",
"@types/lodash.differencewith": "^4.5.9", "@types/lodash.differencewith": "^4.5.9",

View file

@ -21,7 +21,7 @@
</div> </div>
<div class="px-1 py-1 w-full flex flex-col gap-2"> <div class="px-1 py-1 w-full flex flex-col gap-2">
<MenuItem v-slot="{ close }"> <MenuItem v-slot="{ close }">
<RouterLink to="/account"> <RouterLink to="/account/me">
<button button primary @click="close">Mein Account</button> <button button primary @click="close">Mein Account</button>
</RouterLink> </RouterLink>
</MenuItem> </MenuItem>

View file

@ -72,6 +72,14 @@
</p> </p>
<br /> <br />
<TextCopy :copyText="generatedLink" /> <TextCopy :copyText="generatedLink" />
<div v-if="selectedTypes.length != 0" class="flex flex-row gap-2 items-center">
<input
type="checkbox"
id="nscdr"
v-model="provideNSCDR"
/>
<label for="nscdr">Standard-Typen trotz Auswahl ausliefern</label>
</div>
<br /> <br />
</div> </div>
@ -111,6 +119,7 @@ export default defineComponent({
data() { data() {
return { return {
selectedTypes: [] as Array<CalendarTypeViewModel>, selectedTypes: [] as Array<CalendarTypeViewModel>,
provideNSCDR: false as boolean
}; };
}, },
computed: { computed: {
@ -124,7 +133,7 @@ export default defineComponent({
}, },
generatedLink() { generatedLink() {
let extend = this.selectedTypes.map((t) => [t.type, t.passphrase].filter((at) => at).join(":")); let extend = this.selectedTypes.map((t) => [t.type, t.passphrase].filter((at) => at).join(":"));
return `webcal://${host || window.location.host}/api/public/calendar${extend.length == 0 ? "" : "?types=" + extend.join("&types=")}`; return `webcal://${host || window.location.host}/api/public/calendar${extend.length == 0 ? "" : "?types=" + extend.join("&types=")}${this.provideNSCDR && extend.length != 0 ? '&nscdr=true':''}`;
}, },
}, },
mounted() { mounted() {

View file

@ -0,0 +1,78 @@
<template>
<div class="w-full md:max-w-md">
<div class="flex flex-col items-center">
<p class="text-xl font-medium">Newsletter erstellen</p>
</div>
<br />
<form ref="form" class="flex flex-col gap-4 py-2" @submit.prevent="triggerCreate">
<div>
<label for="title">Titel</label>
<input type="text" id="title" required autocomplete="false" />
</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 { useProtocolStore } from "@/stores/admin/protocol";
import type { CreateProtocolViewModel } from "@/viewmodels/admin/protocol.models";
import { useNewsletterStore } from "../../../../stores/admin/newsletter";
import type { CreateNewsletterViewModel } from "../../../../viewmodels/admin/newsletter.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(useNewsletterStore, ["createNewsletter"]),
triggerCreate(e: any) {
let formData = e.target.elements;
let createNewsletter: CreateNewsletterViewModel = {
title: formData.title.value,
};
this.createNewsletter(createNewsletter)
.then(() => {
this.status = { status: "success" };
this.timeout = setTimeout(() => {
(this.$refs.form as HTMLFormElement).reset();
this.closeModal();
}, 1500);
})
.catch(() => {
this.status = { status: "failed" };
});
},
},
});
</script>

View file

@ -0,0 +1,26 @@
<template>
<div class="w-full md:max-w-md">
<div class="flex flex-col items-center">
<p class="text-xl font-medium">Newsletter wird noch synchronisiert</p>
</div>
<br />
<p>Es gibt noch Daten, welche synchronisiert werden müssen.</p>
<p>Dieses PopUp entfernt sich von selbst nach erfolgreicher Synchronisierung.</p>
</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 { useProtocolStore } from "@/stores/admin/protocol";
import type { CreateProtocolViewModel } from "@/viewmodels/admin/protocol.models";
</script>
<script lang="ts">
export default defineComponent({});
</script>

View file

@ -0,0 +1,29 @@
<template>
<div class="flex flex-col h-fit w-full border border-primary rounded-md">
<RouterLink
: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>
<PaperAirplaneIcon v-if="newsletter.isSent" class="w-5 h-5" />
</RouterLink>
<div class="p-2 max-h-48 overflow-y-auto">
<p v-html="newsletter.description"></p>
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent, type PropType } from "vue";
import { mapState, mapActions } from "pinia";
import type { NewsletterViewModel } from "@/viewmodels/admin/newsletter.models";
import { PaperAirplaneIcon } from "@heroicons/vue/24/outline";
</script>
<script lang="ts">
export default defineComponent({
props: {
newsletter: { type: Object as PropType<NewsletterViewModel>, default: {} },
},
});
</script>

View file

@ -0,0 +1,57 @@
<template>
<div class="w-full h-full flex flex-col gap-2">
<Spinner v-if="status == 'loading'" />
<div class="grow">
<iframe ref="viewer" class="w-full h-full" />
</div>
<button primary-outline class="!w-fit self-end" @click="closeModal">schließen</button>
</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 type { AxiosResponse } from "axios";
import { useNewsletterPrintoutStore } from "@/stores/admin/newsletterPrintout";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
status: null as null | "loading" | { status: "success" | "failed"; reason?: string },
};
},
computed: {
...mapState(useModalStore, ["data"]),
},
mounted() {
this.fetchItem();
},
methods: {
...mapActions(useModalStore, ["closeModal"]),
...mapActions(useNewsletterPrintoutStore, ["fetchNewsletterPrintoutPreview", "fetchNewsletterPrintoutById"]),
fetchItem() {
this.status = "loading";
let query: Promise<AxiosResponse<any, any>>;
if (this.data) {
query = this.fetchNewsletterPrintoutById(this.data);
} else {
query = this.fetchNewsletterPrintoutPreview();
}
query
.then((response) => {
this.status = { status: "success" };
const blob = new Blob([response.data], { type: "application/pdf" });
(this.$refs.viewer as HTMLIFrameElement).src = window.URL.createObjectURL(blob);
})
.catch(() => {
this.status = { status: "failed" };
});
},
},
});
</script>

View file

@ -0,0 +1,123 @@
<template>
<CloudIcon v-if="syncing == 'synced'" class="w-5 h-5" />
<CloudArrowUpIcon
v-else-if="syncing == 'detectedChanges'"
class="w-5 h-5 cursor-pointer animate-bounce"
@click="syncAll"
/>
<ArrowPathIcon v-else-if="syncing == 'syncing'" class="w-5 h-5 animate-spin" />
<ExclamationTriangleIcon
v-else
class="w-5 h-5 animate-[ping_1s_ease-in-out_3] text-red-500 cursor-pointer"
@click="syncAll"
/>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapState, mapActions } from "pinia";
import { useNewsletterStore } from "@/stores/admin/newsletter";
import { ArrowPathIcon, CloudArrowUpIcon, CloudIcon, ExclamationTriangleIcon } from "@heroicons/vue/24/outline";
import { useNewsletterDatesStore } from "@/stores/admin/newsletterDates";
import { useNewsletterRecipientsStore } from "@/stores/admin/newsletterRecipients";
</script>
<script lang="ts">
export default defineComponent({
props: ["executeSyncAll"],
watch: {
executeSyncAll() {
this.syncAll();
},
syncing() {
this.$emit("syncState", this.syncing);
},
detectedChangeNewsletter() {
clearTimeout(this.newsletterTimer);
this.setNewsletterSyncingState("synced");
if (this.detectedChangeNewsletter == false) {
return;
}
this.setNewsletterSyncingState("detectedChanges");
this.newsletterTimer = setTimeout(() => {
this.synchronizeActiveNewsletter();
}, 10000);
},
detectedChangeNewsletterDates() {
clearTimeout(this.newsletterDatesTimer);
if (this.detectedChangeNewsletterDates == false) {
this.setNewsletterDatesSyncingState("synced");
return;
}
this.setNewsletterDatesSyncingState("detectedChanges");
this.newsletterDatesTimer = setTimeout(() => {
this.synchronizeActiveNewsletterDates();
}, 10000);
},
detectedChangeNewsletterRecipients() {
clearTimeout(this.newsletterRecipientsTimer);
this.setNewsletterRecipientsSyncingState("synced");
if (this.detectedChangeNewsletterRecipients == false) {
return;
}
this.setNewsletterRecipientsSyncingState("detectedChanges");
this.newsletterRecipientsTimer = setTimeout(() => {
this.synchronizeActiveNewsletterRecipients();
}, 10000);
},
},
emits: {
syncState(state: "synced" | "syncing" | "detectedChanges" | "failed") {
return typeof state == "string";
},
},
data() {
return {
newsletterTimer: undefined as undefined | any,
newsletterDatesTimer: undefined as undefined | any,
newsletterRecipientsTimer: undefined as undefined | any,
};
},
mounted() {
this.$emit("syncState", this.syncing);
},
beforeUnmount() {
if (!this.newsletterTimer) clearTimeout(this.newsletterTimer);
if (!this.newsletterDatesTimer) clearTimeout(this.newsletterDatesTimer);
if (!this.newsletterRecipientsTimer) clearTimeout(this.newsletterRecipientsTimer);
},
computed: {
...mapState(useNewsletterStore, ["syncingNewsletter", "detectedChangeNewsletter"]),
...mapState(useNewsletterDatesStore, ["syncingNewsletterDates", "detectedChangeNewsletterDates"]),
...mapState(useNewsletterRecipientsStore, ["syncingNewsletterRecipients", "detectedChangeNewsletterRecipients"]),
syncing(): "synced" | "syncing" | "detectedChanges" | "failed" {
let states = [
this.syncingNewsletter,
this.syncingNewsletterDates,
this.syncingNewsletterRecipients,
];
if (states.includes("failed")) return "failed";
else if (states.includes("syncing")) return "syncing";
else if (states.includes("detectedChanges")) return "detectedChanges";
else return "synced";
},
},
methods: {
...mapActions(useNewsletterStore, ["synchronizeActiveNewsletter", "setNewsletterSyncingState"]),
...mapActions(useNewsletterDatesStore, ["synchronizeActiveNewsletterDates", "setNewsletterDatesSyncingState"]),
...mapActions(useNewsletterRecipientsStore, ["synchronizeActiveNewsletterRecipients", "setNewsletterRecipientsSyncingState"]),
syncAll() {
if (!this.newsletterTimer) clearTimeout(this.newsletterTimer);
if (!this.newsletterDatesTimer) clearTimeout(this.newsletterDatesTimer);
if (!this.newsletterRecipientsTimer) clearTimeout(this.newsletterRecipientsTimer);
this.synchronizeActiveNewsletter();
this.synchronizeActiveNewsletterDates();
this.synchronizeActiveNewsletterRecipients();
},
},
});
</script>

View file

@ -15,7 +15,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import { mapState, mapActions } from "pinia"; import { mapState, mapActions } from "pinia";
import type { ProtocolViewModel } from "../../../../viewmodels/admin/protocol.models"; import type { ProtocolViewModel } from "@/viewmodels/admin/protocol.models";
</script> </script>
<script lang="ts"> <script lang="ts">

View file

@ -20,8 +20,8 @@ import { useProtocolStore } from "@/stores/admin/protocol";
import { ArrowPathIcon, CloudArrowUpIcon, CloudIcon, ExclamationTriangleIcon } from "@heroicons/vue/24/outline"; import { ArrowPathIcon, CloudArrowUpIcon, CloudIcon, ExclamationTriangleIcon } from "@heroicons/vue/24/outline";
import { useProtocolAgendaStore } from "@/stores/admin/protocolAgenda"; import { useProtocolAgendaStore } from "@/stores/admin/protocolAgenda";
import { useProtocolPresenceStore } from "@/stores/admin/protocolPresence"; import { useProtocolPresenceStore } from "@/stores/admin/protocolPresence";
import { useProtocolDecisionStore } from "../../../../stores/admin/protocolDecision"; import { useProtocolDecisionStore } from "@/stores/admin/protocolDecision";
import { useProtocolVotingStore } from "../../../../stores/admin/protocolVoting"; import { useProtocolVotingStore } from "@/stores/admin/protocolVoting";
</script> </script>
<script lang="ts"> <script lang="ts">

View file

@ -4,12 +4,12 @@
<p>{{ communicationType.type }}</p> <p>{{ communicationType.type }}</p>
<div class="flex flex-row"> <div class="flex flex-row">
<RouterLink <RouterLink
v-if="can('update', 'settings', 'communication')" v-if="can('update', 'settings', 'communication_type')"
:to="{ name: 'admin-settings-communication-edit', params: { id: communicationType.id } }" :to="{ name: 'admin-settings-communication_type-edit', params: { id: communicationType.id } }"
> >
<PencilIcon class="w-5 h-5 p-1 box-content cursor-pointer" /> <PencilIcon class="w-5 h-5 p-1 box-content cursor-pointer" />
</RouterLink> </RouterLink>
<div v-if="can('delete', 'settings', 'communication')" @click="openDeleteModal"> <div v-if="can('delete', 'settings', 'communication_type')" @click="openDeleteModal">
<TrashIcon class="w-5 h-5 p-1 box-content cursor-pointer" /> <TrashIcon class="w-5 h-5 p-1 box-content cursor-pointer" />
</div> </div>
</div> </div>

View file

@ -0,0 +1,102 @@
<template>
<form ref="form" class="flex flex-col h-fit w-full border border-primary rounded-md" @submit.prevent="updateUsage">
<div class="bg-primary p-2 text-white flex flex-row justify-between items-center">
<p>Newsletter bei Type "{{ comType.type }}" versenden/exportieren als</p>
<div v-if="can('create','settings','newsletter_config')" class="flex flex-row justify-end w-16">
<button v-if="status == null" type="submit" class="!p-0 !h-fit !w-fit" title="speichern">
<ArchiveBoxArrowDownIcon class="w-5 h-5 p-1 box-content pointer-events-none" />
</button>
<Spinner v-else-if="status == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="status?.status == 'success'" />
<FailureXMark v-else-if="status?.status == 'failed'" />
<button type="button" class="!p-0 !h-fit !w-fit" title="zurücksetzen" @click="resetForm">
<ArchiveBoxXMarkIcon class="w-5 h-5 p-1 box-content pointer-events-none" />
</button>
</div>
</div>
<div class="flex flex-col p-2 gap-2">
<div class="flex flex-row gap-2 items-center">
<select ref="config" id="config" :value="newsletterConfig?.config ?? 'def'">
<option value="def">Standard (pdf nur mit Name)</option>
<option v-for="config in configs" :key="config" :value="config">{{ config == "pdf" ? "pdf mit Adresse":config }}</option>
</select>
</div>
</div>
</form>
</template>
<script setup lang="ts">
import { defineComponent, type PropType } from "vue";
import { mapState, mapActions } from "pinia";
import { ArchiveBoxArrowDownIcon, ArchiveBoxXMarkIcon } from "@heroicons/vue/24/outline";
import { useNewsletterConfigStore } from "@/stores/admin/newsletterConfig";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { useModalStore } from "@/stores/modal";
import { NewsletterConfigType } from "@/enums/newsletterConfigType";
import type { AxiosResponse } from "axios";
import type { CommunicationTypeViewModel } from "@/viewmodels/admin/communicationType.models";
import { useAbilityStore } from "@/stores/ability";
</script>
<script lang="ts">
export default defineComponent({
props: {
comType: { type: Object as PropType<CommunicationTypeViewModel>, default: {} },
},
data() {
return {
status: null as null | "loading" | { status: "success" | "failed"; reason?: string },
timeout: undefined as any,
configs: [] as Array<string>,
};
},
computed:{
...mapState(useNewsletterConfigStore, ["config"]),
...mapState(useAbilityStore, ["can"]),
newsletterConfig() {
return this.config.find(c => c.comTypeId == this.comType.id)
}
},
mounted() {
this.configs = Object.values(NewsletterConfigType);
},
beforeUnmount() {
try {
clearTimeout(this.timeout);
} catch (error) {}
},
methods: {
...mapActions(useModalStore, ["openModal"]),
...mapActions(useNewsletterConfigStore, ["setNewsletterConfig", "deleteNewsletterConfig"]),
updateUsage(e: any) {
const fromData = e.target.elements;
const config = fromData.config.value === "def" ? null : fromData.config.value;
this.status = "loading"
let request: Promise<AxiosResponse<any, any>>
if(config){
request = this.setNewsletterConfig({
comTypeId: this.comType.id,
config: config
})
} else {
request = this.deleteNewsletterConfig(this.comType.id)
}
request.then(() => {
this.status = { status: "success" };
this.timeout = setTimeout(() => {
this.status = null;
}, 2000);
})
.catch(() => {
this.status = { status: "failed" };
});
},
resetForm() {
(this.$refs.config as HTMLSelectElement).value = String(this.newsletterConfig?.config ?? "def");
},
},
});
</script>

View file

@ -0,0 +1,79 @@
<template>
<div class="w-full md:max-w-md">
<div class="flex flex-col items-center">
<p class="text-xl font-medium">Template erstellen</p>
</div>
<br />
<form class="flex flex-col gap-4 py-2" @submit.prevent="triggerCreate">
<div>
<label for="template">Bezeichnung</label>
<input type="text" id="template" 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 != null">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 { useTemplateStore } from "@/stores/admin/template";
import type { CreateTemplateViewModel } from "@/viewmodels/admin/template.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(useTemplateStore, ["createTemplate"]),
triggerCreate(e: any) {
let formData = e.target.elements;
let createTemplate: CreateTemplateViewModel = {
template: formData.template.value,
description: formData.description.value,
};
this.createTemplate(createTemplate)
.then((res) => {
this.status = { status: "success" };
this.timeout = setTimeout(() => {
this.closeModal();
this.$router.push({ name: "admin-settings-template-edit", params: { id: res.data } });
}, 1500);
})
.catch(() => {
this.status = { status: "failed" };
});
},
},
});
</script>

View file

@ -0,0 +1,73 @@
<template>
<div class="w-full md:max-w-md">
<div class="flex flex-col items-center">
<p class="text-xl font-medium">Auszeichnung {{ template?.template }} 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 != null">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 { useQueryStoreStore } from "@/stores/admin/queryStore";
import { useTemplateStore } from "@/stores/admin/template";
</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(useTemplateStore, ["templates"]),
template() {
return this.templates.find((t) => t.id == this.data);
},
},
methods: {
...mapActions(useModalStore, ["closeModal"]),
...mapActions(useTemplateStore, ["deleteTemplate"]),
triggerDelete() {
this.deleteTemplate(this.data)
.then(() => {
this.status = { status: "success" };
this.timeout = setTimeout(() => {
this.closeModal();
}, 1500);
})
.catch(() => {
this.status = { status: "failed" };
});
},
},
});
</script>

View file

@ -0,0 +1,88 @@
<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>{{ template.template }}</p>
<div class="flex flex-row justify-end w-24">
<RouterLink
v-if="can('update', 'settings', 'template')"
:to="{ name: 'admin-settings-template-edit', params: { id: template.id } }"
>
<PencilIcon class="w-5 h-5 p-1 box-content cursor-pointer" />
</RouterLink>
<button v-if="status == null" class="!p-0 !h-fit !w-fit" title="duplizieren" @click="cloneElement">
<DocumentDuplicateIcon class="w-5 h-5 p-1 box-content pointer-events-none" />
</button>
<Spinner v-else-if="status == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="status?.status == 'success'" />
<FailureXMark v-else-if="status?.status == 'failed'" />
<div v-if="can('delete', 'settings', 'template')" @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">{{ template.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, DocumentDuplicateIcon } from "@heroicons/vue/24/outline";
import { useAbilityStore } from "@/stores/ability";
import { useModalStore } from "@/stores/modal";
import type { TemplateViewModel } from "@/viewmodels/admin/template.models";
import { useTemplateStore } from "@/stores/admin/template";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
</script>
<script lang="ts">
export default defineComponent({
props: {
template: { type: Object as PropType<TemplateViewModel>, default: {} },
},
data() {
return {
status: null as null | "loading" | { status: "success" | "failed"; reason?: string },
timeout: undefined as any,
};
},
computed: {
...mapState(useAbilityStore, ["can"]),
},
beforeUnmount() {
try {
clearTimeout(this.timeout);
} catch (error) {}
},
methods: {
...mapActions(useModalStore, ["openModal"]),
...mapActions(useTemplateStore,["cloneTemplate"]),
openDeleteModal() {
this.openModal(
markRaw(defineAsyncComponent(() => import("@/components/admin/settings/template/DeleteTemplateModal.vue"))),
this.template.id
);
},
cloneElement(){
this.cloneTemplate(this.template.id)
.then((res) => {
this.status = { status: "success" };
this.timeout = setTimeout(() => {
this.status = null;
this.$router.push({ name: "admin-settings-template-edit", params: { id: res.data } });
}, 2000);
})
.catch(() => {
this.status = { status: "failed" };
});
}
},
});
</script>

View file

@ -0,0 +1,50 @@
<template>
<div class="w-full h-full flex flex-col gap-2">
<Spinner v-if="status == 'loading'" />
<div class="grow">
<iframe ref="viewer" class="w-full h-full" />
</div>
<button primary-outline class="!w-fit self-end" @click="closeModal">schließen</button>
</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 { useTemplateUsageStore } from "@/stores/admin/templateUsage";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
status: null as null | "loading" | { status: "success" | "failed"; reason?: string },
}
},
computed: {
...mapState(useModalStore, ["data"]),
},
mounted() {
this.fetchItem();
},
methods: {
...mapActions(useModalStore, ["closeModal"]),
...mapActions(useTemplateUsageStore, ["previewTemplateUsage"]),
fetchItem() {
this.status = "loading"
this.previewTemplateUsage(this.data)
.then((response) => {
this.status = { status: "success" };
const blob = new Blob([response.data], { type: "application/pdf" });
(this.$refs.viewer as HTMLIFrameElement).src = window.URL.createObjectURL(blob);
})
.catch(() => {
this.status = { status: "failed" };
});
},
},
});
</script>

View file

@ -0,0 +1,119 @@
<template>
<form ref="form" class="flex flex-col h-fit w-full border border-primary rounded-md" @submit.prevent="updateUsage">
<div class="bg-primary p-2 text-white flex flex-row justify-between items-center">
<p>Templates zu "{{ templateUsage.scope }}" zuweisen</p>
<div class="flex flex-row justify-end w-16">
<button type="button" class="!p-0 !h-fit !w-fit" title="Vorschau erzeugen" @click="previewUsage">
<EyeIcon class="w-5 h-5 p-1 box-content pointer-events-none" />
</button>
<button v-if="status == null && can('create','settings','newsletter_config')" type="submit" class="!p-0 !h-fit !w-fit" title="speichern">
<ArchiveBoxArrowDownIcon class="w-5 h-5 p-1 box-content pointer-events-none" />
</button>
<Spinner v-else-if="status == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="status?.status == 'success'" />
<FailureXMark v-else-if="status?.status == 'failed'" />
<button type="button" v-if="can('create','settings','newsletter_config')" class="!p-0 !h-fit !w-fit" title="zurücksetzen" @click="resetForm">
<ArchiveBoxXMarkIcon class="w-5 h-5 p-1 box-content pointer-events-none" />
</button>
</div>
</div>
<div class="flex flex-col p-2 gap-2">
<div class="flex flex-row gap-2 items-center">
<p class="min-w-16">Kopfzeile:</p>
<select ref="header" id="header" :value="templateUsage.header?.id ?? '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="min-w-16">Hauptteil:</p>
<select ref="body" id="body" :value="templateUsage.body?.id ?? '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="min-w-16">Fußzeile:</p>
<select ref="footer" id="footer" :value="templateUsage.footer?.id ?? '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>
</form>
</template>
<script setup lang="ts">
import { defineAsyncComponent, defineComponent, markRaw, type PropType } from "vue";
import { mapState, mapActions } from "pinia";
import { ArchiveBoxArrowDownIcon, ArchiveBoxXMarkIcon, EyeIcon } from "@heroicons/vue/24/outline";
import type { TemplateUsageViewModel } from "@/viewmodels/admin/templateUsage.models";
import { useTemplateStore } from "@/stores/admin/template";
import { useTemplateUsageStore } from "@/stores/admin/templateUsage";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { useModalStore } from "@/stores/modal";
import { useAbilityStore } from "@/stores/ability";
</script>
<script lang="ts">
export default defineComponent({
props: {
templateUsage: { type: Object as PropType<TemplateUsageViewModel>, default: {} },
},
data() {
return {
status: null as null | "loading" | { status: "success" | "failed"; reason?: string },
timeout: undefined as any,
};
},
computed: {
...mapState(useTemplateStore, ["templates"]),
...mapState(useAbilityStore, ["can"]),
},
beforeUnmount() {
try {
clearTimeout(this.timeout);
} catch (error) {}
},
methods: {
...mapActions(useModalStore, ["openModal"]),
...mapActions(useTemplateUsageStore, ["updateTemplateUsage"]),
previewUsage() {
this.openModal(
markRaw(defineAsyncComponent(() => import("@/components/admin/settings/templateUsage/TemplatePreviewModal.vue"))),
this.templateUsage.scope
)
},
updateUsage(e: any) {
const fromData = e.target.elements;
const headerId = fromData.header.value === "def" ? null : fromData.header.value;
const bodyId = fromData.body.value === "def" ? null : fromData.body.value;
const footerId = fromData.footer.value === "def" ? null : fromData.footer.value;
this.status = "loading"
this.updateTemplateUsage({
scope: this.templateUsage.scope,
headerId: headerId,
bodyId: bodyId,
footerId: footerId,
})
.then(() => {
this.status = { status: "success" };
this.timeout = setTimeout(() => {
this.status = null;
}, 2000);
})
.catch(() => {
this.status = { status: "failed" };
});
},
resetForm() {
(this.$refs.header as HTMLSelectElement).value = String(this.templateUsage.header?.id ?? "def");
(this.$refs.body as HTMLSelectElement).value = String(this.templateUsage.body?.id ?? "def");
(this.$refs.footer as HTMLSelectElement).value = String(this.templateUsage.footer?.id ?? "def");
},
},
});
</script>

View file

@ -86,7 +86,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { defineAsyncComponent, defineComponent, markRaw, type PropType } from "vue"; import { defineAsyncComponent, defineComponent, markRaw, type PropType } from "vue";
import { mapActions, mapState, mapWritableState } from "pinia"; import { mapActions, mapState, mapWritableState } from "pinia";
import type { DynamicQueryStructure } from "../../types/dynamicQueries"; import type { DynamicQueryStructure } from "@/types/dynamicQueries";
import { import {
ArchiveBoxArrowDownIcon, ArchiveBoxArrowDownIcon,
CommandLineIcon, CommandLineIcon,
@ -97,8 +97,8 @@ import {
TrashIcon, TrashIcon,
SparklesIcon, SparklesIcon,
} from "@heroicons/vue/24/outline"; } from "@heroicons/vue/24/outline";
import { useQueryBuilderStore } from "../../stores/admin/queryBuilder"; import { useQueryBuilderStore } from "@/stores/admin/queryBuilder";
import { useModalStore } from "../../stores/modal"; import { useModalStore } from "@/stores/modal";
import Table from "./Table.vue"; import Table from "./Table.vue";
import { useAbilityStore } from "@/stores/ability"; import { useAbilityStore } from "@/stores/ability";
import { useQueryStoreStore } from "@/stores/admin/queryStore"; import { useQueryStoreStore } from "@/stores/admin/queryStore";

View file

@ -25,7 +25,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import { mapActions, mapState } from "pinia"; import { mapActions, mapState } from "pinia";
import { useQueryBuilderStore } from "../../stores/admin/queryBuilder"; import { useQueryBuilderStore } from "@/stores/admin/queryBuilder";
</script> </script>
<script lang="ts"> <script lang="ts">

View file

@ -60,8 +60,8 @@ import {
type ConditionValue, type ConditionValue,
type WhereOperation, type WhereOperation,
type WhereType, type WhereType,
} from "../../types/dynamicQueries"; } from "@/types/dynamicQueries";
import { useQueryBuilderStore } from "../../stores/admin/queryBuilder"; import { useQueryBuilderStore } from "@/stores/admin/queryBuilder";
import { TrashIcon } from "@heroicons/vue/24/outline"; import { TrashIcon } from "@heroicons/vue/24/outline";
</script> </script>

View file

@ -21,8 +21,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import { mapActions, mapState } from "pinia"; import { mapActions, mapState } from "pinia";
import type { DynamicQueryStructure } from "../../types/dynamicQueries"; import type { DynamicQueryStructure } from "@/types/dynamicQueries";
import { useQueryBuilderStore } from "../../stores/admin/queryBuilder"; import { useQueryBuilderStore } from "@/stores/admin/queryBuilder";
import { PlusIcon } from "@heroicons/vue/24/outline"; import { PlusIcon } from "@heroicons/vue/24/outline";
import JoinTable from "./JoinTable.vue"; import JoinTable from "./JoinTable.vue";
</script> </script>

View file

@ -20,8 +20,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import { mapActions, mapState } from "pinia"; import { mapActions, mapState } from "pinia";
import type { DynamicQueryStructure } from "../../types/dynamicQueries"; import type { DynamicQueryStructure } from "@/types/dynamicQueries";
import { useQueryBuilderStore } from "../../stores/admin/queryBuilder"; import { useQueryBuilderStore } from "@/stores/admin/queryBuilder";
import Table from "./Table.vue"; import Table from "./Table.vue";
import { TrashIcon } from "@heroicons/vue/24/outline"; import { TrashIcon } from "@heroicons/vue/24/outline";
import { joinTableName } from "@/helpers/queryFormatter"; import { joinTableName } from "@/helpers/queryFormatter";

View file

@ -19,8 +19,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import { mapActions, mapState } from "pinia"; import { mapActions, mapState } from "pinia";
import type { ConditionStructure, WhereType } from "../../types/dynamicQueries"; import type { ConditionStructure, WhereType } from "@/types/dynamicQueries";
import { useQueryBuilderStore } from "../../stores/admin/queryBuilder"; import { useQueryBuilderStore } from "@/stores/admin/queryBuilder";
import { TrashIcon } from "@heroicons/vue/24/outline"; import { TrashIcon } from "@heroicons/vue/24/outline";
import NestedWhere from "./NestedWhere.vue"; import NestedWhere from "./NestedWhere.vue";
</script> </script>

View file

@ -32,8 +32,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import { mapActions, mapState } from "pinia"; import { mapActions, mapState } from "pinia";
import type { ConditionStructure } from "../../types/dynamicQueries"; import type { ConditionStructure } from "@/types/dynamicQueries";
import { useQueryBuilderStore } from "../../stores/admin/queryBuilder"; import { useQueryBuilderStore } from "@/stores/admin/queryBuilder";
import NestedCondition from "./NestedCondition.vue"; import NestedCondition from "./NestedCondition.vue";
import Condition from "./Condition.vue"; import Condition from "./Condition.vue";
import { PlusIcon, RectangleStackIcon } from "@heroicons/vue/24/outline"; import { PlusIcon, RectangleStackIcon } from "@heroicons/vue/24/outline";

View file

@ -22,8 +22,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import { mapActions, mapState } from "pinia"; import { mapActions, mapState } from "pinia";
import type { OrderByStructure } from "../../types/dynamicQueries"; import type { OrderByStructure } from "@/types/dynamicQueries";
import { useQueryBuilderStore } from "../../stores/admin/queryBuilder"; import { useQueryBuilderStore } from "@/stores/admin/queryBuilder";
import OrderStructure from "./OrderStructure.vue"; import OrderStructure from "./OrderStructure.vue";
import { PlusIcon } from "@heroicons/vue/24/outline"; import { PlusIcon } from "@heroicons/vue/24/outline";
</script> </script>

View file

@ -21,8 +21,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import { mapActions, mapState } from "pinia"; import { mapActions, mapState } from "pinia";
import type { OrderByStructure, OrderByType } from "../../types/dynamicQueries"; import type { OrderByStructure, OrderByType } from "@/types/dynamicQueries";
import { useQueryBuilderStore } from "../../stores/admin/queryBuilder"; import { useQueryBuilderStore } from "@/stores/admin/queryBuilder";
import { TrashIcon } from "@heroicons/vue/24/outline"; import { TrashIcon } from "@heroicons/vue/24/outline";
</script> </script>

View file

@ -11,8 +11,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import { mapActions, mapState } from "pinia"; import { mapActions, mapState } from "pinia";
import type { ConditionStructure, DynamicQueryStructure, OrderByStructure } from "../../types/dynamicQueries"; import type { ConditionStructure, DynamicQueryStructure, OrderByStructure } from "@/types/dynamicQueries";
import { useQueryBuilderStore } from "../../stores/admin/queryBuilder"; import { useQueryBuilderStore } from "@/stores/admin/queryBuilder";
import ColumnSelect from "./ColumnSelect.vue"; import ColumnSelect from "./ColumnSelect.vue";
import Where from "./Where.vue"; import Where from "./Where.vue";
import Order from "./Order.vue"; import Order from "./Order.vue";

View file

@ -13,7 +13,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { defineComponent } from "vue"; import { defineComponent } from "vue";
import { mapState } from "pinia"; import { mapState } from "pinia";
import { useQueryBuilderStore } from "../../stores/admin/queryBuilder"; import { useQueryBuilderStore } from "@/stores/admin/queryBuilder";
</script> </script>
<script lang="ts"> <script lang="ts">

View file

@ -33,8 +33,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import { mapActions, mapState } from "pinia"; import { mapActions, mapState } from "pinia";
import type { ConditionStructure } from "../../types/dynamicQueries"; import type { ConditionStructure } from "@/types/dynamicQueries";
import { useQueryBuilderStore } from "../../stores/admin/queryBuilder"; import { useQueryBuilderStore } from "@/stores/admin/queryBuilder";
import NestedCondition from "./NestedCondition.vue"; import NestedCondition from "./NestedCondition.vue";
import Condition from "./Condition.vue"; import Condition from "./Condition.vue";
import { PlusIcon, RectangleStackIcon } from "@heroicons/vue/24/outline"; import { PlusIcon, RectangleStackIcon } from "@heroicons/vue/24/outline";

View file

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

144
src/helpers/grapesEditor.ts Normal file
View file

@ -0,0 +1,144 @@
import type { AddComponentTypeOptions, BlockProperties, Editor } from "grapesjs";
export function configureEditor(editor: Editor): void {
//editor.Panels.getPanel("devices-c")?.set("visible", false);
editor.Panels.removeButton("devices-c", "set-device-mobile");
editor.Panels.removeButton("devices-c", "set-device-desktop");
editor.Panels.removeButton("views", "open-tm");
editor.Panels.removeButton("views", "open-layers");
editor.Panels.removeButton("options", "export-template");
editor.Panels.removeButton("options", "preview");
// editor.Panels.removeButton("options", "fullscreen");
editor.Panels.removeButton("options", "gjs-open-import-template");
editor.Panels.removeButton("options", "gjs-toggle-images");
editor.BlockManager.remove("button");
editor.BlockManager.remove("image");
editor.BlockManager.remove("link-block");
editor.BlockManager.remove("list-items");
editor.BlockManager.remove("grid-items");
editor.BlockManager.remove("sect37");
editor.BlockManager.remove("text-sect");
editor.BlockManager.get("text").set("category", "Text");
editor.BlockManager.get("quote").set("category", "Text");
editor.BlockManager.get("link").set("category", "Text");
editor.BlockManager.get("sect100").set("category", "Struktur");
editor.BlockManager.get("sect50").set("category", "Struktur");
editor.BlockManager.get("sect30").set("category", "Struktur");
editor.BlockManager.get("divider").set("category", "Struktur");
editor.BlockManager.add("heading1-block", heading1_block);
editor.BlockManager.add("heading2-block", heading2_block);
editor.BlockManager.add("heading3-block", heading3_block);
editor.BlockManager.add("list_start", list_start_block);
editor.DomComponents.addType("list_end", list_end_block.type);
editor.BlockManager.add("list_end", list_end_block.block);
editor.BlockManager.add("list-block", list_block);
editor.BlockManager.add("list-inner-block", list_inner_block);
}
const heading1_block: BlockProperties = {
label: "Heading1",
category: "Text",
media: `
<svg fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.243 4.493v7.5m0 0v7.502m0-7.501h10.5m0-7.5v7.5m0 0v7.501m4.501-8.627 2.25-1.5v10.126m0 0h-2.25m2.25 0h2.25" />
</svg>
`,
content: `
<h1 class="heading">Heading 1</h1>
`,
};
const heading2_block: BlockProperties = {
label: "Heading2",
category: "Text",
media: `
<svg fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" >
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 19.5H16.5v-1.609a2.25 2.25 0 0 1 1.244-2.012l2.89-1.445c.651-.326 1.116-.955 1.116-1.683 0-.498-.04-.987-.118-1.463-.135-.825-.835-1.422-1.668-1.489a15.202 15.202 0 0 0-3.464.12M2.243 4.492v7.5m0 0v7.502m0-7.501h10.5m0-7.5v7.5m0 0v7.501" />
</svg>
`,
content: `
<h2 class="heading">Heading 2</h2>
`,
};
const heading3_block: BlockProperties = {
label: "Heading3",
category: "Text",
media: `
<svg fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.905 14.626a4.52 4.52 0 0 1 .738 3.603c-.154.695-.794 1.143-1.504 1.208a15.194 15.194 0 0 1-3.639-.104m4.405-4.707a4.52 4.52 0 0 0 .738-3.603c-.154-.696-.794-1.144-1.504-1.209a15.19 15.19 0 0 0-3.639.104m4.405 4.708H18M2.243 4.493v7.5m0 0v7.502m0-7.501h10.5m0-7.5v7.5m0 0v7.501" />
</svg>
`,
content: `
<h3 class="heading">Heading 3</h3>
`,
};
const list_start_block: BlockProperties = {
label: "Anfang WDH",
category: "Struktur",
media: `
<svg fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0 3-3m0 0-3-3m3 3H9" />
</svg>
`,
content: `
<liststart style="font-style: italic; display: block;">
WDH Start: LISTENNAME
</liststart>
`,
};
const list_end_block: { type: AddComponentTypeOptions; block: BlockProperties } = {
type: {
model: {
defaults: {
tagName: "listend",
content: "WDH ENDE",
editable: false,
},
},
isComponent(el) {
return el.tagName === "listend";
},
},
block: {
label: "Ende WDH",
content: { type: "list_end" },
category: "Struktur",
media: `
<svg fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 9V5.25A2.25 2.25 0 0 1 10.5 3h6a2.25 2.25 0 0 1 2.25 2.25v13.5A2.25 2.25 0 0 1 16.5 21h-6a2.25 2.25 0 0 1-2.25-2.25V15m-3 0-3-3m0 0 3-3m-3 3H15" />
</svg>
`,
},
};
const list_block: BlockProperties = {
label: "Liste",
category: "Struktur",
media: `
<svg fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM3.75 12h.007v.008H3.75V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.375 5.25h.007v.008H3.75v-.008Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
</svg>
`,
content: `
<ul>
<li>Element</li>
</ul>
`,
};
const list_inner_block: BlockProperties = {
label: "Listenelement",
category: "Text",
media: `
<svg fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM12.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM18.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" />
</svg>
`,
content: `
<li>Element</li>
`,
};

View file

@ -11,7 +11,7 @@ export function flattenQueryResult(result: Array<QueryResult>): Array<{ [key: st
for (const key in row) { for (const key in row) {
const value = row[key]; const value = row[key];
const newKey = prefix ? `${prefix}.${key}` : key; const newKey = prefix ? `${prefix}_${key}` : key;
if (Array.isArray(value) && value.every((item) => typeof item === "object" && item !== null)) { if (Array.isArray(value) && value.every((item) => typeof item === "object" && item !== null)) {
console.log(value, newKey); console.log(value, newKey);

View file

@ -1,5 +1,5 @@
export const toolbarOptions = [ export const toolbarOptions = [
[/*{ header: [1, 2, false] },*/ { font: [] }], [{ header: [1, 2, 3, 4, false] }, { font: [] }],
//[{ header: 1 }, { header: 2 }], //[{ header: 1 }, { header: 2 }],
["bold", "italic", "underline", "strike"], ["bold", "italic", "underline", "strike"],
["blockquote", "code-block", "link"], ["blockquote", "code-block", "link"],

View file

@ -98,12 +98,12 @@ select[disabled] {
details { details {
user-select: none; user-select: none;
& summary svg { & summary svg[indicator] {
transform: rotate(90deg); transform: rotate(90deg);
} }
} }
details[open] { details[open] {
& summary svg { & summary svg[indicator] {
transform: rotate(-90deg); transform: rotate(-90deg);
} }
} }

View file

@ -19,7 +19,7 @@ export async function abilityAndNavUpdate(to: any, from: any, next: any) {
next(); next();
} else { } else {
NProgress.done(); NProgress.done();
next(false); next({ name: "admin-default" });
} }
} }

View file

@ -8,6 +8,7 @@ import { abilityAndNavUpdate } from "./adminGuard";
import type { PermissionType, PermissionSection, PermissionModule } from "@/types/permissionTypes"; import type { PermissionType, PermissionSection, PermissionModule } from "@/types/permissionTypes";
import { resetMemberStores, setMemberId } from "./memberGuard"; import { resetMemberStores, setMemberId } from "./memberGuard";
import { resetProtocolStores, setProtocolId } from "./protocolGuard"; import { resetProtocolStores, setProtocolId } from "./protocolGuard";
import { resetNewsletterStores, setNewsletterId } from "./newsletterGuard";
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
@ -174,10 +175,57 @@ const router = createRouter({
}, },
{ {
path: "newsletter", path: "newsletter",
name: "admin-club-newsletter", name: "admin-club-newsletter-route",
component: () => import("@/views/admin/ViewSelect.vue"), component: () => import("@/views/RouterView.vue"),
meta: { type: "read", section: "club", module: "newsletter" }, meta: { type: "read", section: "club", module: "newsletter" },
beforeEnter: [abilityAndNavUpdate], beforeEnter: [abilityAndNavUpdate],
children: [
{
path: "",
name: "admin-club-newsletter",
component: () => import("@/views/admin/club/newsletter/Newsletter.vue"),
beforeEnter: [resetNewsletterStores],
},
{
path: ":newsletterId",
name: "admin-club-newsletter-routing",
component: () => import("@/views/admin/club/newsletter/NewsletterRouting.vue"),
beforeEnter: [setNewsletterId],
props: true,
children: [
{
path: "overview",
name: "admin-club-newsletter-overview",
component: () => import("@/views/admin/club/newsletter/NewsletterOverview.vue"),
props: true,
},
{
path: "data",
name: "admin-club-newsletter-data",
component: () => import("@/views/admin/club/newsletter/NewsletterData.vue"),
props: true,
},
{
path: "recipients",
name: "admin-club-newsletter-recipients",
component: () => import("@/views/admin/club/newsletter/NewsletterRecipients.vue"),
props: true,
},
{
path: "dates",
name: "admin-club-newsletter-dates",
component: () => import("@/views/admin/club/newsletter/NewsletterDates.vue"),
props: true,
},
{
path: "printout",
name: "admin-club-newsletter-printout",
component: () => import("@/views/admin/club/newsletter/NewsletterPrintout.vue"),
props: true,
},
],
},
],
}, },
{ {
path: "protocol", path: "protocol",
@ -403,6 +451,48 @@ const router = createRouter({
meta: { type: "read", section: "settings", module: "query_store" }, meta: { type: "read", section: "settings", module: "query_store" },
beforeEnter: [abilityAndNavUpdate], beforeEnter: [abilityAndNavUpdate],
}, },
{
path: "template",
name: "admin-settings-template-route",
component: () => import("@/views/RouterView.vue"),
meta: { type: "read", section: "settings", module: "template" },
beforeEnter: [abilityAndNavUpdate],
children: [
{
path: "",
name: "admin-settings-template",
component: () => import("@/views/admin/settings/template/Template.vue"),
},
{
path: "info",
name: "admin-settings-template-info",
component: () => import("@/views/admin/settings/template/UsageInfo.vue"),
props: true,
},
{
path: ":id/edit",
name: "admin-settings-template-edit",
component: () => import("@/views/admin/settings/template/TemplateEdit.vue"),
meta: { type: "update", section: "settings", module: "template" },
beforeEnter: [abilityAndNavUpdate],
props: true,
},
],
},
{
path: "template-usage",
name: "admin-settings-template_usage",
component: () => import("@/views/admin/settings/templateUsage/TemplateUsage.vue"),
meta: { type: "read", section: "settings", module: "template_usage" },
beforeEnter: [abilityAndNavUpdate],
},
{
path: "newsletter-config",
name: "admin-settings-newsletter_config",
component: () => import("@/views/admin/settings/newsletterConfig/NewsletterConfig.vue"),
meta: { type: "read", section: "settings", module: "newsletter_config" },
beforeEnter: [abilityAndNavUpdate],
},
], ],
}, },
{ {

View file

@ -0,0 +1,31 @@
import { useNewsletterStore } from "@/stores/admin/newsletter";
import { useNewsletterDatesStore } from "@/stores/admin/newsletterDates";
import { useNewsletterRecipientsStore } from "@/stores/admin/newsletterRecipients";
import { useNewsletterPrintoutStore } from "../stores/admin/newsletterPrintout";
export async function setNewsletterId(to: any, from: any, next: any) {
const newsletter = useNewsletterStore();
newsletter.activeNewsletter = to.params?.newsletterId ?? null;
useNewsletterDatesStore().$reset();
useNewsletterRecipientsStore().$reset();
useNewsletterPrintoutStore().unsubscribePdfPrintingProgress();
useNewsletterPrintoutStore().unsubscribeMailSendingProgress();
useNewsletterPrintoutStore().$reset();
next();
}
export async function resetNewsletterStores(to: any, from: any, next: any) {
const newsletter = useNewsletterStore();
newsletter.activeNewsletter = null;
newsletter.activeNewsletterObj = null;
useNewsletterDatesStore().$reset();
useNewsletterRecipientsStore().$reset();
useNewsletterPrintoutStore().unsubscribePdfPrintingProgress();
useNewsletterPrintoutStore().unsubscribeMailSendingProgress();
useNewsletterPrintoutStore().$reset();
next();
}

View file

@ -2,6 +2,7 @@ import axios from "axios";
import { isAuthenticatedPromise, type Payload } from "./router/authGuard"; import { isAuthenticatedPromise, type Payload } from "./router/authGuard";
import router from "./router"; import router from "./router";
import { useNotificationStore } from "./stores/notification"; import { useNotificationStore } from "./stores/notification";
import { EventSourcePolyfill } from "event-source-polyfill";
let devMode = process.env.NODE_ENV === "development"; let devMode = process.env.NODE_ENV === "development";
@ -88,4 +89,13 @@ export async function refreshToken(): Promise<void> {
}); });
} }
export { http, host }; function newEventSource(path: string) {
const token = localStorage.getItem("accessToken");
return new EventSourcePolyfill(url + "/api" + path, {
headers: {
Authorization: `Bearer ${token}`,
},
});
}
export { http, newEventSource, host };

View file

@ -6,7 +6,7 @@ import type {
} from "@/viewmodels/admin/communicationType.models"; } from "@/viewmodels/admin/communicationType.models";
import { http } from "@/serverCom"; import { http } from "@/serverCom";
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
import type { CommunicationFieldType } from "../../types/fieldTypes"; import type { CommunicationFieldType } from "@/types/fieldTypes";
export const useCommunicationTypeStore = defineStore("communicationType", { export const useCommunicationTypeStore = defineStore("communicationType", {
state: () => { state: () => {

View file

@ -46,7 +46,7 @@ export const useNavigationStore = defineStore("navigation", {
resetNavigation() { resetNavigation() {
this.$reset(); this.$reset();
}, },
updateTopLevel(first: boolean = false) { updateTopLevel() {
const abilityStore = useAbilityStore(); const abilityStore = useAbilityStore();
this.topLevel = [ this.topLevel = [
...(abilityStore.canSection("read", "club") ...(abilityStore.canSection("read", "club")
@ -77,10 +77,12 @@ export const useNavigationStore = defineStore("navigation", {
] ]
: []), : []),
]; ];
if (this.topLevel.findIndex((e) => e.key == this.activeNavigation) == -1 && !first) if (this.topLevel.findIndex((e) => e.key == this.activeNavigation) == -1) {
this.activeNavigation = this.topLevel[0]?.key ?? "club";
router.push({ name: `admin-${this.topLevel[0]?.key ?? "club"}-default` }); router.push({ name: `admin-${this.topLevel[0]?.key ?? "club"}-default` });
}
}, },
updateNavigation(first: boolean = false) { updateNavigation() {
const abilityStore = useAbilityStore(); const abilityStore = useAbilityStore();
this.navigation = { this.navigation = {
club: { club: {
@ -113,6 +115,13 @@ export const useNavigationStore = defineStore("navigation", {
? [{ key: "calendar_type", title: "Terminarten" }] ? [{ key: "calendar_type", title: "Terminarten" }]
: []), : []),
...(abilityStore.can("read", "settings", "query") ? [{ key: "query_store", title: "Query Store" }] : []), ...(abilityStore.can("read", "settings", "query") ? [{ key: "query_store", title: "Query Store" }] : []),
...(abilityStore.can("read", "settings", "template") ? [{ key: "template", title: "Templates" }] : []),
...(abilityStore.can("read", "settings", "template_usage")
? [{ key: "template_usage", title: "Template-Verwendung" }]
: []),
...(abilityStore.can("read", "settings", "newsletter_config")
? [{ key: "newsletter_config", title: "Newsletter Konfiguration" }]
: []),
], ],
}, },
user: { user: {
@ -123,8 +132,13 @@ export const useNavigationStore = defineStore("navigation", {
], ],
}, },
} as navigationModel; } as navigationModel;
if (this.activeNavigationObject.main.findIndex((e) => e.key == this.activeLink) == -1 && !first) if (
router.push({ name: `admin-${this.activeNavigation}-default` }); this.activeNavigationObject.main.findIndex((e) => e.key == this.activeLink) == -1 ||
this.activeLink == "default"
) {
let link = this.activeNavigationObject.main[0].key;
router.push({ name: `admin-${this.activeNavigation}-${link}` });
}
}, },
}, },
}); });

View file

@ -0,0 +1,105 @@
import { defineStore } from "pinia";
import type { CreateNewsletterViewModel, SyncNewsletterViewModel } from "@/viewmodels/admin/newsletter.models";
import { http } from "@/serverCom";
import type { AxiosResponse } from "axios";
import type { NewsletterViewModel } from "@/viewmodels/admin/newsletter.models";
import cloneDeep from "lodash.clonedeep";
import isEqual from "lodash.isequal";
import difference from "lodash.difference";
export const useNewsletterStore = defineStore("newsletter", {
state: () => {
return {
newsletters: [] as Array<NewsletterViewModel & { tab_pos: number }>,
totalCount: 0 as number,
loading: "loading" as "loading" | "fetched" | "failed",
activeNewsletter: null as number | null,
activeNewsletterObj: null as NewsletterViewModel | null,
origin: null as NewsletterViewModel | null,
loadingActive: "loading" as "loading" | "fetched" | "failed",
syncingNewsletter: "synced" as "synced" | "syncing" | "detectedChanges" | "failed",
};
},
getters: {
detectedChangeNewsletter: (state) =>
!isEqual(state.origin, state.activeNewsletterObj) && state.syncingNewsletter != "syncing",
},
actions: {
setNewsletterSyncingState(state: "synced" | "syncing" | "detectedChanges" | "failed") {
this.syncingNewsletter = state;
},
fetchNewsletters(offset = 0, count = 25, clear = false) {
if (clear) this.newsletters = [];
this.loading = "loading";
http
.get(`/admin/newsletter?offset=${offset}&count=${count}`)
.then((result) => {
this.totalCount = result.data.total;
result.data.newsletters
.filter((elem: NewsletterViewModel) => this.newsletters.findIndex((m) => m.id == elem.id) == -1)
.map((elem: NewsletterViewModel, index: number): NewsletterViewModel & { tab_pos: number } => {
return {
...elem,
tab_pos: index + offset,
};
})
.forEach((elem: NewsletterViewModel & { tab_pos: number }) => {
this.newsletters.push(elem);
});
this.loading = "fetched";
})
.catch((err) => {
this.loading = "failed";
});
},
fetchNewsletterByActiveId() {
this.loadingActive = "loading";
http
.get(`/admin/newsletter/${this.activeNewsletter}`)
.then((res) => {
this.origin = res.data;
this.activeNewsletterObj = cloneDeep(this.origin);
this.loadingActive = "fetched";
})
.catch((err) => {
this.loadingActive = "failed";
});
},
fetchNewsletterById(id: number) {
return http.get(`/admin/newsletter/${id}`);
},
async createNewsletter(newsletter: CreateNewsletterViewModel): Promise<AxiosResponse<any, any>> {
const result = await http.post(`/admin/newsletter`, {
title: newsletter.title,
});
this.fetchNewsletters();
return result;
},
async synchronizeActiveNewsletter(): Promise<void> {
if (this.origin == null || this.activeNewsletterObj == null) return;
this.syncingNewsletter = "syncing";
await http
.patch(`/admin/newsletter/${this.origin.id}/synchronize`, {
title: this.activeNewsletterObj.title,
description: this.activeNewsletterObj.description,
newsletterTitle: this.activeNewsletterObj.newsletterTitle,
newsletterText: this.activeNewsletterObj.newsletterText,
newsletterSignatur: this.activeNewsletterObj.newsletterSignatur,
recipientsByQueryId: this.activeNewsletterObj.recipientsByQueryId,
})
.then((res) => {
this.syncingNewsletter = "synced";
})
.catch((err) => {
this.syncingNewsletter = "failed";
});
this.fetchNewsletterById(this.origin.id)
.then((res) => {
this.origin = res.data;
if (this.detectedChangeNewsletter) this.syncingNewsletter = "detectedChanges";
})
.catch((err) => {});
},
},
});

View file

@ -0,0 +1,46 @@
import { defineStore } from "pinia";
import type {
SetNewsletterConfigViewModel,
NewsletterConfigViewModel,
} from "@/viewmodels/admin/newsletterConfig.models";
import { http } from "@/serverCom";
import type { AxiosResponse } from "axios";
export const useNewsletterConfigStore = defineStore("newsletterConfi", {
state: () => {
return {
config: [] as Array<NewsletterConfigViewModel>,
loading: "loading" as "loading" | "fetched" | "failed",
};
},
actions: {
fetchNewsletterConfigs() {
this.loading = "loading";
http
.get("/admin/newsletterconfig")
.then((result) => {
this.config = result.data;
this.loading = "fetched";
})
.catch((err) => {
this.loading = "failed";
});
},
fetchNewsletterConfigById(id: number): Promise<AxiosResponse<any, any>> {
return http.get(`/admin/newsletterconfig/${id}`);
},
async setNewsletterConfig(setConfig: SetNewsletterConfigViewModel): Promise<AxiosResponse<any, any>> {
const result = await http.put(`/admin/newsletterconfig`, {
comTypeId: setConfig.comTypeId,
config: setConfig.config,
});
this.fetchNewsletterConfigs();
return result;
},
async deleteNewsletterConfig(newsletterConfigStore: number): Promise<AxiosResponse<any, any>> {
const result = await http.delete(`/admin/newsletterconfig/${newsletterConfigStore}`);
this.fetchNewsletterConfigs();
return result;
},
},
});

View file

@ -0,0 +1,73 @@
import { defineStore } from "pinia";
import { http } from "@/serverCom";
import type { NewsletterDatesViewModel, SyncNewsletterDatesViewModel } from "@/viewmodels/admin/newsletterDates.models";
import { useNewsletterStore } from "./newsletter";
import cloneDeep from "lodash.clonedeep";
import isEqual from "lodash.isequal";
import differenceWith from "lodash.differencewith";
export const useNewsletterDatesStore = defineStore("newsletterDates", {
state: () => {
return {
dates: [] as Array<NewsletterDatesViewModel>,
origin: [] as Array<NewsletterDatesViewModel>,
loading: "loading" as "loading" | "fetched" | "failed",
syncingNewsletterDates: "synced" as "synced" | "syncing" | "detectedChanges" | "failed",
};
},
getters: {
detectedChangeNewsletterDates: (state) =>
!isEqual(
state.origin.sort(
(a: NewsletterDatesViewModel, b: NewsletterDatesViewModel) =>
new Date(a.calendar.starttime).getTime() - new Date(b.calendar.starttime).getTime()
),
state.dates.sort(
(a: NewsletterDatesViewModel, b: NewsletterDatesViewModel) =>
new Date(a.calendar.starttime).getTime() - new Date(b.calendar.starttime).getTime()
)
) && state.syncingNewsletterDates != "syncing",
},
actions: {
setNewsletterDatesSyncingState(state: "synced" | "syncing" | "detectedChanges" | "failed") {
this.syncingNewsletterDates = state;
},
fetchNewsletterDates() {
this.loading = "loading";
this.fetchNewsletterDatesPromise()
.then((result) => {
this.origin = result.data;
this.dates = cloneDeep(this.origin);
this.loading = "fetched";
})
.catch((err) => {
this.loading = "failed";
});
},
fetchNewsletterDatesPromise() {
const newsletterId = useNewsletterStore().activeNewsletter;
return http.get(`/admin/newsletter/${newsletterId}/dates`);
},
async synchronizeActiveNewsletterDates() {
this.syncingNewsletterDates = "syncing";
const newsletterId = useNewsletterStore().activeNewsletter;
await http
.patch(`/admin/newsletter/${newsletterId}/synchronize/dates`, {
dates: this.dates,
})
.then((res) => {
this.syncingNewsletterDates = "synced";
})
.catch((err) => {
this.syncingNewsletterDates = "failed";
});
this.fetchNewsletterDatesPromise()
.then((res) => {
this.origin = res.data;
if (this.detectedChangeNewsletterDates) this.syncingNewsletterDates = "detectedChanges";
})
.catch((err) => {});
},
},
});

View file

@ -0,0 +1,127 @@
import { defineStore } from "pinia";
import { http, newEventSource } from "@/serverCom";
import { useNewsletterStore } from "./newsletter";
import type { AxiosResponse } from "axios";
import type { EventSourcePolyfill } from "event-source-polyfill";
export const useNewsletterPrintoutStore = defineStore("newsletterPrintout", {
state: () => {
return {
printout: [] as Array<string>,
loading: "loading" as "loading" | "fetched" | "failed",
printing: undefined as undefined | "loading" | "success" | "failed",
sending: undefined as undefined | "loading" | "success" | "failed",
sendingPreview: undefined as undefined | "loading" | "success" | "failed",
pdfProgessSource: undefined as undefined | EventSourcePolyfill,
mailProgessSource: undefined as undefined | EventSourcePolyfill,
pdfSourceMessages: [] as Array<Object>,
mailSourceMessages: [] as Array<Object>,
};
},
actions: {
fetchNewsletterPrintout() {
const newsletterId = useNewsletterStore().activeNewsletter;
this.loading = "loading";
http
.get(`/admin/newsletter/${newsletterId}/printouts`)
.then((result) => {
this.printout = result.data;
this.loading = "fetched";
})
.catch((err) => {
this.loading = "failed";
});
},
fetchNewsletterPrintoutById(printout: string): Promise<AxiosResponse<any, any>> {
const newsletterId = useNewsletterStore().activeNewsletter;
return http.get(`/admin/newsletter/${newsletterId}/printout/${printout}`, {
responseType: "blob",
});
},
fetchNewsletterPrintoutPreview(): Promise<AxiosResponse<any, any>> {
const newsletterId = useNewsletterStore().activeNewsletter;
return http.get(`/admin/newsletter/${newsletterId}/printoutpreview`, {
responseType: "blob",
});
},
createNewsletterMailPreview() {
this.sendingPreview = "loading";
const newsletterId = useNewsletterStore().activeNewsletter;
if (newsletterId == null) return;
return http
.post(`/admin/newsletter/${newsletterId}/mailpreview`)
.then((res) => {
this.sendingPreview = "success";
})
.catch((err) => {
this.sendingPreview = "failed";
})
.finally(() => {
setTimeout(() => {
this.sendingPreview = undefined;
}, 1500);
});
},
createNewsletterPrintout() {
this.printing = "loading";
const newsletterId = useNewsletterStore().activeNewsletter;
if (newsletterId == null) return;
return http
.post(`/admin/newsletter/${newsletterId}/printout`)
.then((res) => {
this.fetchNewsletterPrintout();
this.printing = "success";
})
.catch((err) => {
this.printing = "failed";
})
.finally(() => {
setTimeout(() => {
this.printing = undefined;
}, 1500);
});
},
createNewsletterSend() {
this.sending = "loading";
const newsletterId = useNewsletterStore().activeNewsletter;
if (newsletterId == null) return;
return http
.post(`/admin/newsletter/${newsletterId}/send`)
.then((res) => {
this.sending = "success";
})
.catch((err) => {
this.sending = "failed";
})
.finally(() => {
setTimeout(() => {
this.sending = undefined;
}, 1500);
});
},
subscribePdfPrintingProgress() {
// const newsletterId = useNewsletterStore().activeNewsletter;
// if (this.pdfProgessSource != undefined) return;
// this.pdfProgessSource = newEventSource(`/admin/newsletter/${newsletterId}/printoutprogress`);
// this.pdfProgessSource.onmessage = (event) => {
// console.log("pdf", event);
// };
},
subscribeMailSendingProgress() {
// const newsletterId = useNewsletterStore().activeNewsletter;
// if (this.mailProgessSource != undefined) return;
// this.mailProgessSource = newEventSource(`/admin/newsletter/${newsletterId}/sendprogress`);
// this.mailProgessSource.onmessage = (event) => {
// console.log("mail", event);
// };
},
unsubscribePdfPrintingProgress() {
this.pdfProgessSource?.close();
this.pdfProgessSource = undefined;
},
unsubscribeMailSendingProgress() {
this.mailProgessSource?.close();
this.mailProgessSource = undefined;
},
},
});

View file

@ -0,0 +1,65 @@
import { defineStore } from "pinia";
import { http } from "@/serverCom";
import type {
NewsletterRecipientsViewModel,
SyncNewsletterRecipientsViewModel,
} from "@/viewmodels/admin/newsletterRecipients.models";
import { useNewsletterStore } from "./newsletter";
import cloneDeep from "lodash.clonedeep";
import isEqual from "lodash.isequal";
export const useNewsletterRecipientsStore = defineStore("newsletterRecipients", {
state: () => {
return {
recipients: [] as Array<number>,
origin: [] as Array<number>,
loading: "loading" as "loading" | "fetched" | "failed",
syncingNewsletterRecipients: "synced" as "synced" | "syncing" | "detectedChanges" | "failed",
};
},
getters: {
detectedChangeNewsletterRecipients: (state) =>
!isEqual(state.origin, state.recipients) && state.syncingNewsletterRecipients != "syncing",
},
actions: {
setNewsletterRecipientsSyncingState(state: "synced" | "syncing" | "detectedChanges" | "failed") {
this.syncingNewsletterRecipients = state;
},
fetchNewsletterRecipients() {
this.loading = "loading";
this.fetchNewsletterRecipientsPromise()
.then((result) => {
this.origin = result.data.map((d: NewsletterRecipientsViewModel) => d.memberId);
this.recipients = cloneDeep(this.origin);
this.loading = "fetched";
})
.catch((err) => {
this.loading = "failed";
});
},
fetchNewsletterRecipientsPromise() {
const newsletterId = useNewsletterStore().activeNewsletter;
return http.get(`/admin/newsletter/${newsletterId}/recipients`);
},
async synchronizeActiveNewsletterRecipients() {
this.syncingNewsletterRecipients = "syncing";
const newsletterId = useNewsletterStore().activeNewsletter;
await http
.patch(`/admin/newsletter/${newsletterId}/synchronize/recipients`, {
recipients: this.recipients,
})
.then((res) => {
this.syncingNewsletterRecipients = "synced";
})
.catch((err) => {
this.syncingNewsletterRecipients = "failed";
});
this.fetchNewsletterRecipientsPromise()
.then((result) => {
this.origin = result.data.map((d: NewsletterRecipientsViewModel) => d.memberId);
if (this.detectedChangeNewsletterRecipients) this.syncingNewsletterRecipients = "detectedChanges";
})
.catch((err) => {});
},
},
});

View file

@ -1,9 +1,6 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { http } from "@/serverCom"; import { http } from "@/serverCom";
import type { import type { ProtocolAgendaViewModel, SyncProtocolAgendaViewModel } from "@/viewmodels/admin/protocolAgenda.models";
ProtocolAgendaViewModel,
SyncProtocolAgendaViewModel,
} from "../../viewmodels/admin/protocolAgenda.models";
import { useProtocolStore } from "./protocol"; import { useProtocolStore } from "./protocol";
import cloneDeep from "lodash.clonedeep"; import cloneDeep from "lodash.clonedeep";
import isEqual from "lodash.isequal"; import isEqual from "lodash.isequal";

View file

@ -4,7 +4,7 @@ import type { AxiosResponse } from "axios";
import type { import type {
ProtocolDecisionViewModel, ProtocolDecisionViewModel,
SyncProtocolDecisionViewModel, SyncProtocolDecisionViewModel,
} from "../../viewmodels/admin/protocolDecision.models"; } from "@/viewmodels/admin/protocolDecision.models";
import { useProtocolStore } from "./protocol"; import { useProtocolStore } from "./protocol";
import cloneDeep from "lodash.clonedeep"; import cloneDeep from "lodash.clonedeep";
import isEqual from "lodash.isequal"; import isEqual from "lodash.isequal";

View file

@ -4,7 +4,7 @@ import type { AxiosResponse } from "axios";
import type { import type {
ProtocolPresenceViewModel, ProtocolPresenceViewModel,
SyncProtocolPresenceViewModel, SyncProtocolPresenceViewModel,
} from "../../viewmodels/admin/protocolPresence.models"; } from "@/viewmodels/admin/protocolPresence.models";
import { useProtocolStore } from "./protocol"; import { useProtocolStore } from "./protocol";
import cloneDeep from "lodash.clonedeep"; import cloneDeep from "lodash.clonedeep";
import isEqual from "lodash.isequal"; import isEqual from "lodash.isequal";

View file

@ -1,6 +1,6 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { http } from "@/serverCom"; import { http } from "@/serverCom";
import type { ProtocolPrintoutViewModel } from "../../viewmodels/admin/protocolPrintout.models"; import type { ProtocolPrintoutViewModel } from "@/viewmodels/admin/protocolPrintout.models";
import { useProtocolStore } from "./protocol"; import { useProtocolStore } from "./protocol";
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";

View file

@ -1,10 +1,7 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { http } from "@/serverCom"; import { http } from "@/serverCom";
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
import type { import type { ProtocolVotingViewModel, SyncProtocolVotingViewModel } from "@/viewmodels/admin/protocolVoting.models";
ProtocolVotingViewModel,
SyncProtocolVotingViewModel,
} from "../../viewmodels/admin/protocolVoting.models";
import { useProtocolStore } from "./protocol"; import { useProtocolStore } from "./protocol";
import cloneDeep from "lodash.clonedeep"; import cloneDeep from "lodash.clonedeep";
import isEqual from "lodash.isequal"; import isEqual from "lodash.isequal";

View file

@ -1,8 +1,7 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { http } from "@/serverCom"; import { http } from "@/serverCom";
import type { TableMeta } from "../../viewmodels/admin/query.models"; import type { TableMeta } from "@/viewmodels/admin/query.models";
import type { DynamicQueryStructure, FieldType } from "../../types/dynamicQueries"; import type { DynamicQueryStructure, FieldType } from "@/types/dynamicQueries";
import { flattenQueryResult } from "../../helpers/queryFormatter";
export const useQueryBuilderStore = defineStore("queryBuilder", { export const useQueryBuilderStore = defineStore("queryBuilder", {
state: () => { state: () => {
@ -31,23 +30,21 @@ export const useQueryBuilderStore = defineStore("queryBuilder", {
this.loading = "failed"; this.loading = "failed";
}); });
}, },
sendQuery(offset = 0, count = 25) { sendQuery(offset = 0, count = 25, query?: DynamicQueryStructure | string) {
this.queryError = ""; this.queryError = "";
this.data = []; this.data = [];
this.totalLength = 0; this.totalLength = 0;
if (this.query == undefined || this.query == "" || (typeof this.query != "string" && this.query.table == "")) let queryToSend = query ?? this.query;
if (queryToSend == undefined || queryToSend == "" || (typeof queryToSend != "string" && queryToSend.table == ""))
return; return;
this.loadingData = "loading"; this.loadingData = "loading";
http http
.post(`/admin/querybuilder/query?offset=${offset}&count=${count}`, { .post(`/admin/querybuilder/query?offset=${offset}&count=${count}`, {
query: this.query, query: queryToSend,
}) })
.then((result) => { .then((result) => {
if (result.data.stats == "success") { if (result.data.stats == "success") {
this.data = flattenQueryResult(result.data.rows).map((row) => ({ this.data = result.data.rows;
id: row.id ?? "", // Ensure id is present
...row,
}));
this.totalLength = result.data.total; this.totalLength = result.data.total;
this.loadingData = "fetched"; this.loadingData = "fetched";
} else { } else {

View file

@ -0,0 +1,64 @@
import { defineStore } from "pinia";
import { http } from "@/serverCom";
import type { AxiosResponse } from "axios";
import type {
CreateTemplateViewModel,
TemplateViewModel,
UpdateTemplateViewModel,
} from "@/viewmodels/admin/template.models";
export const useTemplateStore = defineStore("template", {
state: () => {
return {
templates: [] as Array<TemplateViewModel>,
loading: "loading" as "loading" | "fetched" | "failed",
};
},
actions: {
fetchTemplates() {
this.loading = "loading";
http
.get("/admin/template")
.then((result) => {
this.templates = result.data;
this.loading = "fetched";
})
.catch((err) => {
this.loading = "failed";
});
},
fetchTemplateById(id: number): Promise<AxiosResponse<any, any>> {
return http.get(`/admin/template/${id}`);
},
async createTemplate(template: CreateTemplateViewModel): Promise<AxiosResponse<any, any>> {
const result = await http.post(`/admin/template`, {
template: template.template,
description: template.description,
});
this.fetchTemplates();
return result;
},
async updateActiveTemplate(template: UpdateTemplateViewModel): Promise<AxiosResponse<any, any>> {
const result = await http.patch(`/admin/template/${template.id}`, {
template: template.template,
description: template.description,
design: template.design,
html: template.html,
});
this.fetchTemplates();
return result;
},
async cloneTemplate(cloneId: number): Promise<AxiosResponse<any, any>> {
const result = await http.post(`/admin/template/clone`, {
cloneId: cloneId,
});
this.fetchTemplates();
return result;
},
async deleteTemplate(template: number): Promise<AxiosResponse<any, any>> {
const result = await http.delete(`/admin/template/${template}`);
this.fetchTemplates();
return result;
},
},
});

View file

@ -0,0 +1,43 @@
import { defineStore } from "pinia";
import { http } from "@/serverCom";
import type { AxiosResponse } from "axios";
import type { CreateTemplateViewModel, UpdateTemplateViewModel } from "@/viewmodels/admin/template.models";
import type { TemplateUsageViewModel, UpdateTemplateUsageViewModel } from "@/viewmodels/admin/templateUsage.models";
import type { PermissionModule } from "@/types/permissionTypes";
export const useTemplateUsageStore = defineStore("templateUsage", {
state: () => {
return {
templateUsages: [] as Array<TemplateUsageViewModel>,
loading: "loading" as "loading" | "fetched" | "failed",
};
},
actions: {
fetchTemplateUsages() {
this.loading = "loading";
http
.get("/admin/templateusage")
.then((result) => {
this.templateUsages = result.data;
this.loading = "fetched";
})
.catch((err) => {
this.loading = "failed";
});
},
async previewTemplateUsage(scope: PermissionModule): Promise<AxiosResponse<any, any>> {
return await http.get(`/admin/templateusage/${scope}`, {
responseType: "blob",
});
},
async updateTemplateUsage(templateUsage: UpdateTemplateUsageViewModel): Promise<AxiosResponse<any, any>> {
const result = await http.patch(`/admin/templateusage/${templateUsage.scope}`, {
headerId: templateUsage.headerId,
bodyId: templateUsage.bodyId,
footerId: templateUsage.footerId,
});
this.fetchTemplateUsages();
return result;
},
},
});

View file

@ -4,6 +4,7 @@ export type PermissionModule =
| "member" | "member"
| "calendar" | "calendar"
| "newsletter" | "newsletter"
| "newsletter_config"
| "protocol" | "protocol"
| "qualification" | "qualification"
| "award" | "award"
@ -14,7 +15,9 @@ export type PermissionModule =
| "user" | "user"
| "role" | "role"
| "query" | "query"
| "query_store"; | "query_store"
| "template"
| "template_usage";
export type PermissionType = "read" | "create" | "update" | "delete"; export type PermissionType = "read" | "create" | "update" | "delete";
@ -42,6 +45,7 @@ export const permissionModules: Array<PermissionModule> = [
"member", "member",
"calendar", "calendar",
"newsletter", "newsletter",
"newsletter_config",
"protocol", "protocol",
"qualification", "qualification",
"award", "award",
@ -53,6 +57,8 @@ export const permissionModules: Array<PermissionModule> = [
"role", "role",
"query", "query",
"query_store", "query_store",
"template",
"template_usage",
]; ];
export const permissionTypes: Array<PermissionType> = ["read", "create", "update", "delete"]; export const permissionTypes: Array<PermissionType> = ["read", "create", "update", "delete"];
export const sectionsAndModules: SectionsAndModulesObject = { export const sectionsAndModules: SectionsAndModulesObject = {
@ -65,6 +71,9 @@ export const sectionsAndModules: SectionsAndModulesObject = {
"membership_status", "membership_status",
"calendar_type", "calendar_type",
"query_store", "query_store",
"template",
"template_usage",
"newsletter_config",
], ],
user: ["user", "role"], user: ["user", "role"],
}; };

View file

@ -1,4 +1,4 @@
import type { CommunicationFieldType } from "../../types/fieldTypes"; import type { CommunicationFieldType } from "@/types/fieldTypes";
export interface CommunicationTypeViewModel { export interface CommunicationTypeViewModel {
id: number; id: number;

View file

@ -0,0 +1,31 @@
import type { QueryViewModel } from "./query.models";
export interface NewsletterViewModel {
id: number;
title: string;
description: string;
newsletterTitle: string;
newsletterText: string;
newsletterSignatur: string;
isSent: boolean;
recipientsByQueryId?: number | null;
recipientsByQuery?: QueryViewModel | null;
}
export interface CreateNewsletterViewModel {
title: string;
}
export interface SyncNewsletterViewModel {
id: number;
title: string;
description: string;
newsletterTitle: string;
newsletterText: string;
newsletterSignatur: string;
recipientsByQueryId?: number;
}
export interface SendNewsletterViewModel {
id: number;
}

View file

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

View file

@ -0,0 +1,15 @@
import type { CalendarViewModel } from "./calendar.models";
export interface NewsletterDatesViewModel {
newsletterId: number;
calendarId: string;
diffTitle: string | null;
diffDescription: string | null;
calendar: CalendarViewModel;
}
export interface SyncNewsletterDatesViewModel {
calendarId: string;
diffTitle?: string;
diffDescription?: string;
}

View file

@ -0,0 +1,11 @@
import type { MemberViewModel } from "./member.models";
export interface NewsletterRecipientsViewModel {
newsletterId: number;
memberId: number;
member: MemberViewModel;
}
export interface SyncNewsletterRecipientsViewModel {
memberId: number;
}

View file

@ -1,4 +1,4 @@
import type { DynamicQueryStructure } from "../../types/dynamicQueries"; import type { DynamicQueryStructure } from "@/types/dynamicQueries";
export interface TableMeta { export interface TableMeta {
tableName: string; tableName: string;

View file

@ -0,0 +1,20 @@
export interface TemplateViewModel {
id: number;
template: string;
description: string | null;
design: object;
html: string;
}
export interface CreateTemplateViewModel {
template: string;
description: string | null;
}
export interface UpdateTemplateViewModel {
id: number;
template: string;
description: string | null;
design: object;
html: string;
}

View file

@ -0,0 +1,15 @@
import type { PermissionModule } from "@/types/permissionTypes";
export interface TemplateUsageViewModel {
scope: PermissionModule;
header: { id: number; template: string } | null;
body: { id: number; template: string } | null;
footer: { id: number; template: string } | null;
}
export interface UpdateTemplateUsageViewModel {
scope: PermissionModule;
headerId: number | null;
bodyId: number | null;
footerId: number | null;
}

View file

@ -58,8 +58,8 @@ export default defineComponent({
this.updateTopLevel(); this.updateTopLevel();
this.updateNavigation(); this.updateNavigation();
}); });
this.updateTopLevel(true); this.updateTopLevel();
this.updateNavigation(true); this.updateNavigation();
}, },
beforeUnmount() { beforeUnmount() {
this.resetNavigation(); this.resetNavigation();

View file

@ -0,0 +1,69 @@
<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>
<template #diffMain>
<div class="flex flex-col w-full h-full gap-2 justify-center px-7">
<Pagination
:items="newsletters"
:totalCount="totalCount"
:indicateLoading="loading == 'loading'"
@load-data="(offset, count, search) => fetchNewsletters(offset, count)"
@search="(search) => fetchNewsletters(0, 25, true)"
>
<template #pageRow="{ row }: { row: NewsletterViewModel }">
<NewsletterListItem :newsletter="row" />
</template>
</Pagination>
<div class="flex flex-row gap-4">
<button v-if="can('create', 'club', 'newsletter')" primary class="!w-fit" @click="openCreateModal">
Newsletter erstellen
</button>
</div>
</div>
</template>
</MainTemplate>
</template>
<script setup lang="ts">
import { defineAsyncComponent, defineComponent, markRaw } from "vue";
import { mapActions, mapState } from "pinia";
import MainTemplate from "@/templates/Main.vue";
import { useModalStore } from "@/stores/modal";
import Pagination from "@/components/Pagination.vue";
import { useAbilityStore } from "@/stores/ability";
import { useNewsletterStore } from "@/stores/admin/newsletter";
import type { NewsletterViewModel } from "@/viewmodels/admin/newsletter.models";
import NewsletterListItem from "../../../../components/admin/club/newsletter/NewsletterListItem.vue";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
currentPage: 0,
maxEntriesPerPage: 25,
};
},
computed: {
...mapState(useNewsletterStore, ["newsletters", "totalCount", "loading"]),
...mapState(useAbilityStore, ["can"]),
},
mounted() {
this.fetchNewsletters(0, this.maxEntriesPerPage, true);
},
methods: {
...mapActions(useNewsletterStore, ["fetchNewsletters"]),
...mapActions(useModalStore, ["openModal"]),
openCreateModal() {
this.openModal(
markRaw(defineAsyncComponent(() => import("@/components/admin/club/newsletter/CreateNewsletterModal.vue")))
);
},
},
});
</script>

View file

@ -0,0 +1,71 @@
<template>
<div class="flex flex-col gap-2 h-full w-full overflow-y-auto">
<div v-if="activeNewsletterObj != null" class="flex flex-col gap-2 w-full">
<div class="w-full">
<label for="title">Überschrift</label>
<QuillEditor
id="summary"
theme="snow"
placeholder="Überschrift des Newsletters..."
style="height: 150px; max-height: 150px; min-height: 150px"
contentType="html"
:toolbar="toolbarOptions"
v-model:content="activeNewsletterObj.newsletterTitle"
:enable="can('create', 'club', 'newsletter')"
:style="!can('create', 'club', 'newsletter') ? 'opacity: 75%; background: rgb(243 244 246)' : ''"
/>
</div>
<div class="flex flex-col h-1/2">
<label for="summary">Text</label>
<QuillEditor
id="summary"
theme="snow"
placeholder="Text zum Newsletter..."
style="height: 150px; max-height: 150px; min-height: 150px"
contentType="html"
:toolbar="toolbarOptions"
v-model:content="activeNewsletterObj.newsletterText"
:enable="can('create', 'club', 'newsletter')"
:style="!can('create', 'club', 'newsletter') ? 'opacity: 75%; background: rgb(243 244 246)' : ''"
/>
</div>
<div class="flex flex-col h-1/2">
<label for="summary">Signatur</label>
<QuillEditor
id="summary"
theme="snow"
placeholder="Zusammenfassung zum Newsletter..."
style="height: 150px; max-height: 150px; min-height: 150px"
contentType="html"
:toolbar="toolbarOptions"
v-model:content="activeNewsletterObj.newsletterSignatur"
:enable="can('create', 'club', 'newsletter')"
:style="!can('create', 'club', 'newsletter') ? 'opacity: 75%; background: rgb(243 244 246)' : ''"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapActions, mapState, mapWritableState } from "pinia";
import Spinner from "@/components/Spinner.vue";
import { useNewsletterStore } from "@/stores/admin/newsletter";
import { QuillEditor } from "@vueup/vue-quill";
import "@vueup/vue-quill/dist/vue-quill.snow.css";
import { toolbarOptions } from "@/helpers/quillConfig";
import { useAbilityStore } from "@/stores/ability";
</script>
<script lang="ts">
export default defineComponent({
props: {
newsletterId: String,
},
computed: {
...mapWritableState(useNewsletterStore, ["loadingActive", "activeNewsletterObj"]),
...mapState(useAbilityStore, ["can"]),
},
});
</script>

View file

@ -0,0 +1,171 @@
<template>
<div class="flex flex-col gap-2 h-full w-full overflow-y-auto">
<Spinner v-if="loading == 'loading'" class="mx-auto" />
<p v-else-if="loading == 'failed'" @click="fetchNewsletterDates" class="cursor-pointer">
&#8634; laden fehlgeschlagen
</p>
<div class="flex flex-col gap-2 h-full overflow-y-auto">
<details
v-for="item in dates"
class="flex flex-col gap-2 rounded-lg w-full justify-between border border-primary overflow-hidden min-h-fit"
>
<summary class="flex flex-row gap-2 bg-primary p-2 w-full justify-between items-center cursor-pointer">
<svg
indicator
class="fill-white stroke-white opacity-75 w-4 h-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
>
<path d="M12.95 10.707l.707-.707L8 4.343 6.586 5.757 10.828 10l-4.242 4.243L8 15.657l4.95-4.95z" />
</svg>
<p class="w-full text-white">
{{ item.calendar.title }}:
{{
item.calendar.allDay
? new Date(item.calendar.starttime ?? "").toLocaleDateString("de-DE", {
day: "2-digit",
month: "long",
year: "numeric",
})
: new Date(item.calendar.starttime ?? "").toLocaleDateString("de-DE", {
day: "2-digit",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}}
</p>
<TrashIcon
v-if="can('create', 'club', 'newsletter')"
class="w-5 h-5 p-1 box-content cursor-pointer text-white"
@click.prevent="removeSelected(item.calendarId)"
/>
</summary>
<div class="flex flex-col gap-2 px-1">
<input
type="text"
name="title"
id="title"
placeholder="alternativer Titel"
autocomplete="off"
v-model="item.diffTitle"
@keyup.prevent
:disabled="!can('create', 'club', 'newsletter')"
/>
<div>
<QuillEditor
id="top"
theme="snow"
placeholder="alternative Beschreibung..."
style="height: 150px; max-height: 150px; min-height: 150px"
contentType="html"
:toolbar="toolbarOptions"
v-model:content="item.diffDescription"
:enable="can('create', 'club', 'newsletter')"
:style="!can('create', 'club', 'newsletter') ? 'opacity: 75%; background: rgb(243 244 246)' : ''"
/>
</div>
</div>
</details>
</div>
<form class="flex flex-row md:flex-row gap-2" @submit.prevent="addEntry">
<select id="date" ref="date" value="" required>
<option value="" disabled>Datum wählen</option>
<option v-for="cal in filteredCalendar" :key="cal.id" :value="cal.id">
{{
cal.allDay
? new Date(cal.starttime).toLocaleDateString("de-DE", {
day: "2-digit",
month: "long",
year: "numeric",
})
: new Date(cal.starttime).toLocaleDateString("de-DE", {
day: "2-digit",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}}
-
{{ cal.title }}
</option>
</select>
<button type="submit" primary class="!w-fit">hinzufügen</button>
</form>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
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 { useNewsletterDatesStore } from "@/stores/admin/newsletterDates";
import { useAbilityStore } from "@/stores/ability";
import { useCalendarStore } from "@/stores/admin/calendar";
import type { CalendarViewModel } from "@/viewmodels/admin/calendar.models";
import { TrashIcon } from "@heroicons/vue/24/outline";
import cloneDeep from "lodash.clonedeep";
import type { NewsletterDatesViewModel } from "@/viewmodels/admin/newsletterDates.models";
</script>
<script lang="ts">
export default defineComponent({
props: {
newsletterId: String,
},
computed: {
...mapWritableState(useNewsletterDatesStore, ["dates", "loading"]),
...mapState(useCalendarStore, ["calendars"]),
...mapState(useAbilityStore, ["can"]),
filteredCalendar() {
return this.calendars.filter(
(c) => !this.dates.map((d) => d.calendarId).includes(c.id) && new Date(c.starttime) >= new Date()
);
},
sortedDates() {
return this.dates.sort(
(a: NewsletterDatesViewModel, b: NewsletterDatesViewModel) =>
new Date(a.calendar.starttime).getTime() - new Date(b.calendar.starttime).getTime()
);
},
calendarData() {
return (dateId: string) => this.calendars.find((c) => c.id == dateId);
},
},
mounted() {
this.fetchNewsletterDates();
this.fetchCalendars();
},
methods: {
...mapActions(useNewsletterDatesStore, ["fetchNewsletterDates"]),
...mapActions(useCalendarStore, ["fetchCalendars"]),
addEntry(e: any) {
const formData = e.target.elements;
const dateId = formData.date.value;
this.dates.push({
newsletterId: parseInt(this.newsletterId ?? "0"),
calendarId: dateId,
diffTitle: "",
diffDescription: "",
calendar: cloneDeep(this.calendarData(dateId)) as CalendarViewModel,
});
(this.$refs.date as HTMLSelectElement).value = "";
},
removeSelected(id: string) {
let index = this.dates.findIndex((d) => d.calendarId == id);
if (index != -1) {
this.dates.splice(index, 1);
}
},
},
});
</script>

View file

@ -0,0 +1,67 @@
<template>
<div class="flex flex-col gap-2 h-full w-full overflow-y-auto">
<div v-if="activeNewsletterObj != null" class="flex flex-col gap-2 w-full">
<p class="italic">
Titel und Zusammenfassung werden standardmäßig nicht im Newsletter angezeit, können aber bei Verwendung eines
eigenen Templates verwendet werden.
</p>
<div class="w-full">
<label for="title">Titel</label>
<input
type="text"
id="title"
v-model="activeNewsletterObj.title"
:disabled="!can('create', 'club', 'newsletter')"
/>
</div>
<div class="flex flex-col h-1/2">
<label for="summary">Zusammenfassung</label>
<QuillEditor
id="summary"
theme="snow"
placeholder="Zusammenfassung zum Newsletter..."
style="height: 250px; max-height: 250px; min-height: 250px"
contentType="html"
:toolbar="toolbarOptions"
v-model:content="activeNewsletterObj.description"
:enable="can('create', 'club', 'newsletter')"
:style="!can('create', 'club', 'newsletter') ? 'opacity: 75%; background: rgb(243 244 246)' : ''"
/>
</div>
</div>
<Spinner v-if="loadingActive == 'loading'" class="mx-auto" />
<p v-else-if="loadingActive == 'failed'" @click="fetchNewsletterByActiveId" class="cursor-pointer">
&#8634; laden fehlgeschlagen
</p>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapActions, mapState, mapWritableState } from "pinia";
import Spinner from "@/components/Spinner.vue";
import { useNewsletterStore } from "@/stores/admin/newsletter";
import { QuillEditor } from "@vueup/vue-quill";
import "@vueup/vue-quill/dist/vue-quill.snow.css";
import { toolbarOptions } from "@/helpers/quillConfig";
import { useAbilityStore } from "@/stores/ability";
</script>
<script lang="ts">
export default defineComponent({
props: {
newsletterId: String,
},
computed: {
...mapWritableState(useNewsletterStore, ["loadingActive", "activeNewsletterObj"]),
...mapState(useAbilityStore, ["can"]),
},
mounted() {
this.fetchNewsletterByActiveId();
},
methods: {
...mapActions(useNewsletterStore, ["fetchNewsletterByActiveId"]),
},
});
</script>

View file

@ -0,0 +1,127 @@
<template>
<div class="flex flex-col gap-2 h-full w-full overflow-y-auto">
<Spinner v-if="loading == 'loading'" class="mx-auto" />
<p v-else-if="loading == 'failed'" @click="fetchNewsletterPrintout" class="cursor-pointer">
&#8634; laden fehlgeschlagen
</p>
<div class="flex flex-col gap-2 h-full overflow-y-auto">
<div v-for="print in printout" :key="print" 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>{{ print }}</p>
<div class="flex flex-row">
<div v-if="print.endsWith('.pdf')">
<ViewfinderCircleIcon class="w-5 h-5 p-1 box-content cursor-pointer" @click="openPdfShow(print)" />
</div>
<div>
<ArrowDownTrayIcon class="w-5 h-5 p-1 box-content cursor-pointer" @click="downloadPdf(print)" />
</div>
</div>
</div>
</div>
</div>
<div class="flex flex-row flex-wrap justify-start gap-2">
<button
v-if="can('create', 'club', 'newsletter')"
primary
class="!w-fit whitespace-nowrap flex flex-row gap-2"
:disabled="printing != undefined"
@click="createNewsletterPrintout"
>
Newsletter drucken
<Spinner v-if="printing == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="printing == 'success'" />
<FailureXMark v-else-if="printing == 'failed'" />
</button>
<button
v-if="can('create', 'club', 'newsletter')"
primary
class="!w-fit whitespace-nowrap flex flex-row gap-2"
:disabled="sending != undefined"
@click="createNewsletterSend"
>
Mails versenden
<Spinner v-if="sending == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="sending == 'success'" />
<FailureXMark v-else-if="sending == 'failed'" />
</button>
<button v-if="can('create', 'club', 'newsletter')" primary-outline class="!w-fit" @click="openPdfShow()">
Newsletter Vorschau
</button>
<button
v-if="can('create', 'club', 'newsletter')"
primary-outline
class="!w-fit whitespace-nowrap flex flex-row gap-2"
:disabled="sendingPreview != undefined"
@click="createNewsletterMailPreview"
>
Mail Vorschau
<Spinner v-if="sendingPreview == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="sendingPreview == 'success'" />
<FailureXMark v-else-if="sendingPreview == 'failed'" />
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { defineAsyncComponent, defineComponent, markRaw } from "vue";
import { mapActions, mapState } from "pinia";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
// import { useNewsletterPrintoutStore } from "@/stores/admin/newsletterPrintout";
import { ArrowDownTrayIcon, ViewfinderCircleIcon } from "@heroicons/vue/24/outline";
import { useModalStore } from "@/stores/modal";
import { useAbilityStore } from "@/stores/ability";
import { useNewsletterPrintoutStore } from "../../../../stores/admin/newsletterPrintout";
</script>
<script lang="ts">
export default defineComponent({
props: {
newsletterId: String,
},
computed: {
...mapState(useNewsletterPrintoutStore, ["printout", "loading", "printing", "sending", "sendingPreview"]),
...mapState(useAbilityStore, ["can"]),
},
mounted() {
this.fetchNewsletterPrintout();
this.subscribeMailSendingProgress();
this.subscribePdfPrintingProgress();
},
methods: {
...mapActions(useModalStore, ["openModal"]),
...mapActions(useNewsletterPrintoutStore, [
"fetchNewsletterPrintout",
"createNewsletterPrintout",
"fetchNewsletterPrintoutById",
"createNewsletterMailPreview",
"createNewsletterSend",
"subscribeMailSendingProgress",
"subscribePdfPrintingProgress",
]),
openPdfShow(filename?: string) {
this.openModal(
markRaw(defineAsyncComponent(() => import("@/components/admin/club/newsletter/NewsletterPreviewModal.vue"))),
filename
);
},
downloadPdf(filename: string) {
this.fetchNewsletterPrintoutById(filename)
.then((response) => {
const fileURL = window.URL.createObjectURL(new Blob([response.data]));
const fileLink = document.createElement("a");
fileLink.href = fileURL;
fileLink.setAttribute("download", filename);
document.body.appendChild(fileLink);
fileLink.click();
fileLink.remove();
})
.catch(() => {});
},
},
});
</script>

View file

@ -0,0 +1,231 @@
<template>
<div class="flex flex-col gap-2 h-full w-full overflow-y-auto">
<Spinner v-if="loading == 'loading'" class="mx-auto" />
<p v-else-if="loading == 'failed'" @click="fetchNewsletterRecipients" class="cursor-pointer">
&#8634; laden fehlgeschlagen
</p>
<select v-model="recipientsByQueryId">
<option value="def">Optional</option>
<option v-for="query in queries" :key="query.id" :value="query.id">{{ query.title }}</option>
</select>
<p>Empfänger durch gespeicherte Abfrage</p>
<div class="flex flex-col gap-2 grow overflow-y-auto">
<div
v-for="member in queried"
:key="member.id"
class="flex flex-row h-fit w-full border border-primary rounded-md bg-primary p-2 text-white justify-between items-center"
>
<div>
<p>{{ member.lastname }}, {{ member.firstname }} {{ member.nameaffix ? `- ${member.nameaffix}` : "" }}</p>
<p>Newsletter senden an Typ: {{ member.sendNewsletter?.type.type }}</p>
</div>
</div>
</div>
<div class="w-full">
<Combobox v-model="recipients" :disabled="!can('create', 'club', 'newsletter')" multiple>
<ComboboxLabel>weitere Empfänger suchen</ComboboxLabel>
<div class="relative mt-1">
<ComboboxInput
class="rounded-md shadow-sm relative block w-full px-3 py-2 border border-gray-300 focus:border-primary placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-0 focus:z-10 sm:text-sm resize-none"
@input="query = $event.target.value"
/>
<ComboboxButton class="absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon class="h-5 w-5 text-gray-400" aria-hidden="true" />
</ComboboxButton>
<TransitionRoot
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
@after-leave="query = ''"
>
<ComboboxOptions
class="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-md ring-1 ring-black/5 focus:outline-none sm:text-sm"
>
<ComboboxOption v-if="filtered.length === 0" as="template" disabled>
<li class="text-text relative cursor-default select-none py-2 pl-3 pr-4">
<span class="font-normal block truncate"> Keine Auswahl</span>
</li>
</ComboboxOption>
<ComboboxOption
v-for="member in filtered"
as="template"
:key="member.id"
:value="member.id"
v-slot="{ selected, active }"
>
<li
class="relative cursor-default select-none py-2 pl-10 pr-4"
:class="{
'bg-primary text-white': active,
'text-gray-900': !active,
}"
>
<span class="block truncate" :class="{ 'font-medium': selected, 'font-normal': !selected }">
{{ member.firstname }} {{ member.lastname }} {{ member.nameaffix }}
</span>
<span
v-if="selected"
class="absolute inset-y-0 left-0 flex items-center pl-3"
:class="{ 'text-white': active, 'text-primary': !active }"
>
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ComboboxOption>
</ComboboxOptions>
</TransitionRoot>
</div>
</Combobox>
</div>
<p>Ausgewählte Empfänger</p>
<div class="flex flex-col gap-2 grow overflow-y-auto">
<div
v-for="member in selected"
:key="member.id"
class="flex flex-row h-fit w-full border border-primary rounded-md bg-primary p-2 text-white justify-between items-center"
>
<div>
<p>{{ member.lastname }}, {{ member.firstname }} {{ member.nameaffix ? `- ${member.nameaffix}` : "" }}</p>
<p>Newsletter senden an Typ: {{ member.sendNewsletter?.type.type }}</p>
</div>
<TrashIcon
v-if="can('create', 'club', 'newsletter')"
class="w-5 h-5 p-1 box-content cursor-pointer"
@click="removeSelected(member.id)"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
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 { TrashIcon } from "@heroicons/vue/24/outline";
import { useMemberStore } from "@/stores/admin/member";
import type { MemberViewModel } from "@/viewmodels/admin/member.models";
import { useNewsletterStore } from "@/stores/admin/newsletter";
import { useNewsletterRecipientsStore } from "@/stores/admin/newsletterRecipients";
import { useAbilityStore } from "@/stores/ability";
import { useQueryStoreStore } from "@/stores/admin/queryStore";
import { useQueryBuilderStore } from "@/stores/admin/queryBuilder";
import cloneDeep from "lodash.clonedeep";
</script>
<script lang="ts">
export default defineComponent({
props: {
newsletterId: String,
},
watch: {
recipientsByQuery() {
this.loadQuery();
},
},
data() {
return {
query: "" as String,
};
},
computed: {
...mapWritableState(useNewsletterRecipientsStore, ["recipients", "loading"]),
...mapWritableState(useNewsletterStore, ["activeNewsletterObj"]),
...mapState(useMemberStore, ["members"]),
...mapState(useQueryStoreStore, ["queries"]),
...mapState(useQueryBuilderStore, ["data"]),
...mapState(useAbilityStore, ["can"]),
filtered(): Array<MemberViewModel> {
return this.query === ""
? this.members
: this.members.filter((member) =>
(member.firstname + " " + member.lastname)
.toLowerCase()
.replace(/\s+/g, "")
.includes(this.query.toLowerCase().replace(/\s+/g, ""))
);
},
sorted(): Array<MemberViewModel> {
return this.selected.sort((a, b) => {
if (a.lastname < b.lastname) return -1;
if (a.lastname > b.lastname) return 1;
if (a.firstname < b.firstname) return -1;
if (a.firstname > b.firstname) return 1;
return 0;
});
},
selected(): Array<MemberViewModel> {
return this.members.filter((m) => this.recipients.includes(m.id));
},
queried(): Array<MemberViewModel> {
if (this.recipientsByQueryId == "def") return [];
let keys = Object.keys(this.data?.[0] ?? {});
let memberKey = keys.find((k) => k.includes("member_id"));
return this.members.filter((m) =>
this.data
.map((t) => ({
id: t.id,
...(memberKey ? { memberId: t[memberKey] } : {}),
}))
.some((d) => (d.memberId ?? d.id) == m.id)
);
},
recipientsByQueryId: {
get() {
return this.activeNewsletterObj?.recipientsByQueryId ?? "def";
},
set(val: string) {
if (this.activeNewsletterObj == undefined) return;
if (val == "def") {
this.activeNewsletterObj.recipientsByQueryId = null;
this.activeNewsletterObj.recipientsByQuery = null;
} else if (this.queries.find((q) => q.id == parseInt(val))) {
this.activeNewsletterObj.recipientsByQueryId = parseInt(val);
this.activeNewsletterObj.recipientsByQuery = cloneDeep(this.queries.find((q) => q.id == parseInt(val)));
this.sendQuery(0, 1000, this.recipientsByQuery?.query);
}
},
},
recipientsByQuery() {
return this.activeNewsletterObj?.recipientsByQuery;
},
},
mounted() {
this.fetchMembers(0, 1000, true);
this.fetchNewsletterRecipients();
this.fetchQueries();
this.loadQuery();
},
methods: {
...mapActions(useMemberStore, ["fetchMembers"]),
...mapActions(useNewsletterRecipientsStore, ["fetchNewsletterRecipients"]),
...mapActions(useQueryStoreStore, ["fetchQueries"]),
...mapActions(useQueryBuilderStore, ["sendQuery"]),
removeSelected(id: number) {
let index = this.recipients.findIndex((s) => s == id);
if (index != -1) {
this.recipients.splice(index, 1);
}
},
loadQuery() {
if (this.recipientsByQuery) {
this.sendQuery(0, 1000, this.recipientsByQuery.query);
}
},
},
});
</script>

View file

@ -0,0 +1,130 @@
<template>
<MainTemplate>
<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>
</template>
<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">
<RouterLink
v-for="tab in tabs"
:key="tab.route"
v-slot="{ isActive }"
:to="{ name: tab.route }"
class="w-1/2 md:w-1/3 lg:w-full p-0.5 first:pl-0 last:pr-0"
>
<p
:class="[
'w-full rounded-lg py-2.5 text-sm text-center font-medium leading-5 focus:ring-0 outline-none',
isActive ? 'bg-red-200 shadow border-b-2 border-primary rounded-b-none' : ' hover:bg-red-200',
]"
>
{{ tab.title }}
</p>
</RouterLink>
</div>
<RouterView />
</div>
</div>
</template>
</MainTemplate>
</template>
<script setup lang="ts">
import { defineAsyncComponent, defineComponent, markRaw } from "vue";
import { mapActions, mapState } from "pinia";
import MainTemplate from "@/templates/Main.vue";
import { RouterLink, RouterView } from "vue-router";
import { useNewsletterStore } from "@/stores/admin/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/newsletterDates";
import { useNewsletterRecipientsStore } from "../../../../stores/admin/newsletterRecipients";
</script>
<script lang="ts">
export default defineComponent({
props: {
newsletterId: String,
},
watch: {
syncState() {
if (this.wantToClose && this.syncState == "synced") {
this.wantToClose = false;
this.closeModal();
}
},
},
data() {
return {
tabs: [
{ route: "admin-club-newsletter-overview", title: "Übersicht" },
{ route: "admin-club-newsletter-data", title: "Daten" },
{ route: "admin-club-newsletter-dates", title: "Termine" },
{ route: "admin-club-newsletter-recipients", title: "Empfänger" },
{ route: "admin-club-newsletter-printout", title: "Druck/Versand" },
],
wantToClose: false as boolean,
executeSyncAll: undefined as any,
syncState: "synced" as "synced" | "syncing" | "detectedChanges" | "failed",
};
},
computed: {
...mapState(useNewsletterStore, ["origin"]),
},
mounted() {
this.fetchNewsletterByActiveId();
this.fetchNewsletterDates();
this.fetchNewsletterRecipients();
},
// this.syncState is undefined, so it will never work
// beforeRouteLeave(to, from, next) {
// const answer = window.confirm('Do you really want to leave? you have unsaved changes!')
// if (answer) {
// next()
// } else {
// next(false)
// }
// if (this.syncState != "synced") {
// this.executeSyncAll = Date.now();
// this.wantToClose = true;
// this.openInfoModal();
// next(false);
// } else {
// next();
// }
// },
methods: {
...mapActions(useNewsletterStore, ["fetchNewsletterByActiveId"]),
...mapActions(useNewsletterDatesStore, ["fetchNewsletterDates"]),
...mapActions(useNewsletterRecipientsStore, ["fetchNewsletterRecipients"]),
...mapActions(useModalStore, ["openModal"]),
openInfoModal() {
this.openModal(
markRaw(defineAsyncComponent(() => import("@/components/admin/club/newsletter/CurrentlySyncingModal.vue")))
);
},
openDeleteModal() {
// this.openModal(
// markRaw(defineAsyncComponent(() => import("@/components/admin/club/newsletter/DeleteNewsletterModal.vue"))),
// parseInt(this.newsletterId ?? "")
// );
},
},
});
</script>

View file

@ -12,6 +12,7 @@
> >
<summary class="flex flex-row gap-2 bg-primary p-2 w-full justify-between items-center cursor-pointer"> <summary class="flex flex-row gap-2 bg-primary p-2 w-full justify-between items-center cursor-pointer">
<svg <svg
indicator
class="fill-white stroke-white opacity-75 w-4 h-4" class="fill-white stroke-white opacity-75 w-4 h-4"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" viewBox="0 0 20 20"

View file

@ -12,6 +12,7 @@
> >
<summary class="flex flex-row gap-2 bg-primary p-2 w-full justify-between items-center cursor-pointer"> <summary class="flex flex-row gap-2 bg-primary p-2 w-full justify-between items-center cursor-pointer">
<svg <svg
indicator
class="fill-white stroke-white opacity-75 w-4 h-4" class="fill-white stroke-white opacity-75 w-4 h-4"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@ -23,7 +24,7 @@
type="text" type="text"
name="title" name="title"
id="title" id="title"
placeholder="Einscheidung" placeholder="Entscheidung"
autocomplete="off" autocomplete="off"
v-model="item.topic" v-model="item.topic"
@keyup.prevent @keyup.prevent

View file

@ -141,7 +141,7 @@ export default defineComponent({
}, },
}, },
mounted() { mounted() {
this.fetchMembers(); this.fetchMembers(0, 1000, true);
this.fetchProtocolPresence(); this.fetchProtocolPresence();
}, },
methods: { methods: {

View file

@ -12,6 +12,7 @@
> >
<summary class="flex flex-row gap-2 bg-primary p-2 w-full justify-between items-center cursor-pointer"> <summary class="flex flex-row gap-2 bg-primary p-2 w-full justify-between items-center cursor-pointer">
<svg <svg
indicator
class="fill-white stroke-white opacity-75 w-4 h-4" class="fill-white stroke-white opacity-75 w-4 h-4"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@ -23,7 +24,7 @@
type="text" type="text"
name="title" name="title"
id="title" id="title"
placeholder="Einscheidung" placeholder="Abstimmung"
autocomplete="off" autocomplete="off"
v-model="item.topic" v-model="item.topic"
@keyup.prevent @keyup.prevent

View file

@ -62,7 +62,7 @@ import { mapActions, mapState, mapWritableState } from "pinia";
import MainTemplate from "@/templates/Main.vue"; import MainTemplate from "@/templates/Main.vue";
import Pagination from "@/components/Pagination.vue"; import Pagination from "@/components/Pagination.vue";
import { useQueryBuilderStore } from "@/stores/admin/queryBuilder"; import { useQueryBuilderStore } from "@/stores/admin/queryBuilder";
import BuilderHost from "../../../../components/queryBuilder/BuilderHost.vue"; import BuilderHost from "@/components/queryBuilder/BuilderHost.vue";
import type { DynamicQueryStructure, FieldType } from "@/types/dynamicQueries"; import type { DynamicQueryStructure, FieldType } from "@/types/dynamicQueries";
import { useQueryStoreStore } from "@/stores/admin/queryStore"; import { useQueryStoreStore } from "@/stores/admin/queryStore";
</script> </script>

View file

@ -15,7 +15,7 @@
/> />
</div> </div>
<div class="flex flex-row gap-4"> <div class="flex flex-row gap-4">
<button v-if="can('create', 'settings', 'communication')" primary class="!w-fit" @click="openCreateModal"> <button v-if="can('create', 'settings', 'communication_type')" primary class="!w-fit" @click="openCreateModal">
Kommunikationsart erstellen Kommunikationsart erstellen
</button> </button>
</div> </div>

View file

@ -0,0 +1,46 @@
<template>
<MainTemplate>
<template #topBar>
<div class="flex flex-row items-center justify-between pt-5 pb-3 px-7">
<h1 class="font-bold text-xl h-8">Newsletter Konfiguration</h1>
</div>
</template>
<template #main>
<p>
Ein Newsletter kann als pdf exportiert oder per Mail versandt werden. <br>
Die Entscheidung für den Export geschieht anhand der Einstellung "Newsletter hier hin versenden?". <br>
Wird keine Adresse gefunden oder sind die Typen mit den falschen Versandoptionen konfiguriert,
erstellt das System als Fallback pdfs mit nur dem Namen des Mitglieds.
</p>
<NewsletterConfigListItem v-for="comType in communicationTypes" :key="comType.id" :comType="comType" />
</template>
</MainTemplate>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapState, mapActions } from "pinia";
import MainTemplate from "@/templates/Main.vue";
import { useAbilityStore } from "@/stores/ability";
import { useCommunicationTypeStore } from "@/stores/admin/communicationType";
import { useNewsletterConfigStore } from "@/stores/admin/newsletterConfig";
import NewsletterConfigListItem from "@/components/admin/settings/newsletterConfig/NewsletterConfigListItem.vue";
</script>
<script lang="ts">
export default defineComponent({
computed: {
...mapState(useCommunicationTypeStore, ["communicationTypes"]),
...mapState(useNewsletterConfigStore,["config"]),
...mapState(useAbilityStore, ["can"]),
},
mounted() {
this.fetchCommunicationTypes();
this.fetchNewsletterConfigs()
},
methods: {
...mapActions(useCommunicationTypeStore, ["fetchCommunicationTypes"]),
...mapActions(useNewsletterConfigStore, ["fetchNewsletterConfigs"])
},
});
</script>

View file

@ -0,0 +1,57 @@
<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">Templates</h1>
<RouterLink :to="{ name: 'admin-settings-template-info' }">
<InformationCircleIcon class="text-gray-500 h-5 w-5" />
</RouterLink>
</div>
</template>
<template #diffMain>
<div class="flex flex-col gap-4 grow pl-7">
<div class="flex flex-col gap-2 grow overflow-y-scroll pr-7">
<TemplateListItem v-for="template in templates" :key="template.id" :template="template" />
</div>
<div class="flex flex-row gap-4">
<button v-if="can('create', 'settings', 'template')" primary class="!w-fit" @click="openCreateModal">
Template erstellen
</button>
</div>
</div>
</template>
</MainTemplate>
</template>
<script setup lang="ts">
import { defineAsyncComponent, defineComponent, markRaw } from "vue";
import { mapState, mapActions } from "pinia";
import MainTemplate from "@/templates/Main.vue";
import TemplateListItem from "@/components/admin/settings/template/TemplateListItem.vue";
import { useTemplateStore } from "@/stores/admin/template";
import { useAbilityStore } from "@/stores/ability";
import { useModalStore } from "@/stores/modal";
import { RouterLink } from "vue-router";
import { InformationCircleIcon } from "@heroicons/vue/24/outline";
</script>
<script lang="ts">
export default defineComponent({
computed: {
...mapState(useTemplateStore, ["templates"]),
...mapState(useAbilityStore, ["can"]),
},
mounted() {
this.fetchTemplates();
},
methods: {
...mapActions(useTemplateStore, ["fetchTemplates"]),
...mapActions(useModalStore, ["openModal"]),
openCreateModal() {
this.openModal(
markRaw(defineAsyncComponent(() => import("@/components/admin/settings/template/CreateTemplateModal.vue")))
);
},
},
});
</script>

View file

@ -0,0 +1,177 @@
<template>
<MainTemplate>
<template #headerInsert>
<RouterLink to="../" class="text-primary">zurück zur Liste (abbrechen)</RouterLink>
</template>
<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">Template {{ origin?.template }} - Daten bearbeiten</h1>
</div>
</template>
<template #main>
<Spinner v-if="loading == 'loading'" class="mx-auto" />
<p v-else-if="loading == 'failed'">laden fehlgeschlagen</p>
<form
v-show="loading == 'fetched' && template != null"
class="flex flex-col gap-4 py-2 w-full h-full mx-auto"
@submit.prevent="triggerUpdate"
>
<div v-if="template != null" class="flex flex-col xl:flex-row gap-4">
<div class="w-full">
<label for="template">Bezeichnung</label>
<input type="text" id="template" required v-model="template.template" />
</div>
<div class="w-full">
<label for="description">Beschreibung (optional)</label>
<input type="text" id="description" v-model="template.description" />
</div>
</div>
<div v-if="contextLoading == 'loading'" class="flex flex-col gap-2 items-center">
<p>Lade Template-Anzeige</p>
<Spinner />
</div>
<div ref="grapesEditor" id="grapesEditor"></div>
<div class="flex flex-row justify-end gap-2">
<button primary-outline type="reset" class="!w-fit" :disabled="status == 'loading'" @click="resetForm">
verwerfen
</button>
<button primary type="submit" class="!w-fit" :disabled="status == 'loading'">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>
</template>
</MainTemplate>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapState, mapActions } from "pinia";
import MainTemplate from "@/templates/Main.vue";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { RouterLink } from "vue-router";
import { configureEditor } from "@/helpers/grapesEditor";
import type { TemplateViewModel, UpdateTemplateViewModel } from "@/viewmodels/admin/template.models";
import { useTemplateStore } from "@/stores/admin/template";
import cloneDeep from "lodash.clonedeep";
import isEqual from "lodash.isequal";
import grapesjs, { Editor } from "grapesjs";
import grapesNewsletter from "grapesjs-preset-newsletter";
import "grapesjs/dist/css/grapes.min.css";
</script>
<script lang="ts">
export default defineComponent({
props: {
id: String,
},
data() {
return {
loading: "loading" as "loading" | "fetched" | "failed",
status: null as null | "loading" | { status: "success" | "failed"; reason?: string },
origin: null as null | TemplateViewModel,
template: null as null | TemplateViewModel,
timeout: null as any,
editor: null as null | Editor,
contextLoading: "loading" as "loading" | "loaded" | "failed",
};
},
computed: {
canSaveOrReset(): boolean {
return isEqual(this.origin, this.template);
},
},
mounted() {
this.fetchItem();
},
beforeUnmount() {
try {
clearTimeout(this.timeout);
} catch (error) {}
localStorage.removeItem("gjsProject");
},
methods: {
...mapActions(useTemplateStore, ["fetchTemplateById", "updateActiveTemplate"]),
initEditor() {
this.editor = grapesjs.init({
container: "#grapesEditor",
height: "100%",
width: "100%",
fromElement: true,
telemetry: false,
showDevices: false,
deviceManager: {
devices: [
{
id: "tablet",
name: "Tablet",
width: "768px",
widthMedia: "992px",
},
],
},
plugins: [grapesNewsletter],
});
configureEditor(this.editor);
},
loadDesign() {
this.contextLoading = "loading";
this.editor?.destroy();
localStorage.setItem("gjsProject", JSON.stringify(this.origin?.design ?? {}));
setTimeout(() => {
this.initEditor();
this.contextLoading = "loaded";
}, 1000);
},
resetForm() {
this.template = cloneDeep(this.origin);
this.loadDesign();
},
fetchItem(fromSave: boolean = false) {
this.fetchTemplateById(parseInt(this.id ?? ""))
.then((result) => {
this.template = result.data;
this.origin = cloneDeep(result.data);
this.loading = "fetched";
if (!fromSave) this.loadDesign();
})
.catch((err) => {
console.log(err);
this.loading = "failed";
});
},
triggerUpdate(e: any) {
if (this.template == null) return;
let formData = e.target.elements;
const htmlContent = this.editor?.getHtml();
const cssContent = this.editor?.getCss();
const html = `<style>${cssContent}</style>${htmlContent}`;
let updateTemplate: UpdateTemplateViewModel = {
id: this.template.id,
template: formData.template.value,
description: formData.description.value,
design: this.editor?.getProjectData() ?? {},
html,
};
this.status = "loading";
this.updateActiveTemplate(updateTemplate)
.then(() => {
this.fetchItem(true);
this.status = { status: "success" };
})
.catch((err) => {
this.status = { status: "failed" };
})
.finally(() => {
this.timeout = setTimeout(() => {
this.status = null;
}, 2000);
});
},
},
});
</script>

View file

@ -0,0 +1,24 @@
<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">Templates - Verwendungsinformation</h1>
</div>
</template>
<template #main>
<p>
Mit diesem Editor können Vorlagen erstellt werden, welche später dafür genutzt werden können, um pdfs zu drucken
oder Mails zu versenden.
</p>
<p>
Es können Platzhalter in das Design integriert werden. Dort werden dann später Werte automatisch eingetragen.
Diese Werte stammen zum Beispiel aus dem Kalender oder aus Abfragen, welche mit dem Query-Builder erstellt
wurden.
</p>
</template>
</MainTemplate>
</template>
<script setup lang="ts">
import MainTemplate from "@/templates/Main.vue";
</script>

View file

@ -0,0 +1,37 @@
<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">Template-Verwendung</h1>
</div>
</template>
<template #main>
<TemplateUsageListItem v-for="usage in templateUsages" :key="usage.scope" :templateUsage="usage" />
</template>
</MainTemplate>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapState, mapActions } from "pinia";
import MainTemplate from "@/templates/Main.vue";
import { useTemplateUsageStore } from "@/stores/admin/templateUsage";
import TemplateUsageListItem from "@/components/admin/settings/templateUsage/TemplateUsageListItem.vue";
import { useTemplateStore } from "@/stores/admin/template";
</script>
<script lang="ts">
export default defineComponent({
computed: {
...mapState(useTemplateUsageStore, ["templateUsages"]),
},
mounted() {
this.fetchTemplateUsages();
this.fetchTemplates();
},
methods: {
...mapActions(useTemplateUsageStore, ["fetchTemplateUsages"]),
...mapActions(useTemplateStore, ["fetchTemplates"]),
},
});
</script>

View file

@ -8,5 +8,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { RouterView } from "vue-router"; import { RouterView } from "vue-router";
import FullContent from "../../layouts/FullContent.vue"; import FullContent from "@/layouts/FullContent.vue";
</script> </script>