#15-messages #22

Merged
jkeffects merged 19 commits from #15-messages into main 2024-12-31 13:25:27 +00:00
14 changed files with 941 additions and 14 deletions
Showing only changes of commit be2bd5e6e3 - Show all commits

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

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

View file

@ -0,0 +1,24 @@
import { useNewsletterStore } from "@/stores/admin/newsletter";
import { useNewsletterDatesStore } from "@/stores/admin/newsletterDates";
import { useNewsletterRecipientsStore } from "@/stores/admin/newsletterRecipients";
export async function setNewsletterId(to: any, from: any, next: any) {
const newsletter = useNewsletterStore();
newsletter.activeNewsletter = to.params?.newsletterId ?? null;
useNewsletterDatesStore().$reset();
useNewsletterRecipientsStore().$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();
next();
}

View file

@ -1,7 +1,7 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { http } from "@/serverCom"; import { http } from "@/serverCom";
import type { NewsletterDatesViewModel, SyncNewsletterDatesViewModel } from "@/viewmodels/admin/newsletterDates.models"; import type { NewsletterDatesViewModel, SyncNewsletterDatesViewModel } from "@/viewmodels/admin/newsletterDates.models";
import { useProtocolStore } from "./protocol"; import { useNewsletterStore } from "./newsletter";
import cloneDeep from "lodash.clonedeep"; import cloneDeep from "lodash.clonedeep";
import isEqual from "lodash.isequal"; import isEqual from "lodash.isequal";
import differenceWith from "lodash.differencewith"; import differenceWith from "lodash.differencewith";
@ -36,16 +36,16 @@ export const useNewsletterDatesStore = defineStore("newsletterDates", {
}); });
}, },
fetchNewsletterDatesPromise() { fetchNewsletterDatesPromise() {
const protocolId = useProtocolStore().activeProtocol; const newsletterId = useNewsletterStore().activeNewsletter;
return http.get(`/admin/protocol/${protocolId}/agenda`); return http.get(`/admin/newsletter/${newsletterId}/dates`);
}, },
async synchronizeActiveNewsletterDates() { async synchronizeActiveNewsletterDates() {
this.syncingNewsletterDates = "syncing"; this.syncingNewsletterDates = "syncing";
const protocolId = useProtocolStore().activeProtocol; const newsletterId = useNewsletterStore().activeNewsletter;
await http await http
.patch(`/admin/protocol/${protocolId}/synchronize/agenda`, { .patch(`/admin/newsletter/${newsletterId}/synchronize/dates`, {
agenda: differenceWith(this.dates, this.origin, isEqual), dates: differenceWith(this.dates, this.origin, isEqual),
}) })
.then((res) => { .then((res) => {
this.syncingNewsletterDates = "synced"; this.syncingNewsletterDates = "synced";

View file

@ -4,7 +4,7 @@ import type {
NewsletterRecipientsViewModel, NewsletterRecipientsViewModel,
SyncNewsletterRecipientsViewModel, SyncNewsletterRecipientsViewModel,
} from "@/viewmodels/admin/newsletterRecipients.models"; } from "@/viewmodels/admin/newsletterRecipients.models";
import { useProtocolStore } from "./protocol"; import { useNewsletterStore } from "./newsletter";
import cloneDeep from "lodash.clonedeep"; import cloneDeep from "lodash.clonedeep";
import isEqual from "lodash.isequal"; import isEqual from "lodash.isequal";
@ -38,15 +38,15 @@ export const useNewsletterRecipientsStore = defineStore("newsletterRecipients",
}); });
}, },
fetchNewsletterRecipientsPromise() { fetchNewsletterRecipientsPromise() {
const protocolId = useProtocolStore().activeProtocol; const newsletterId = useNewsletterStore().activeNewsletter;
return http.get(`/admin/protocol/${protocolId}/presence`); return http.get(`/admin/newsletter/${newsletterId}/recipients`);
}, },
async synchronizeActiveNewsletterRecipients() { async synchronizeActiveNewsletterRecipients() {
this.syncingNewsletterRecipients = "syncing"; this.syncingNewsletterRecipients = "syncing";
const protocolId = useProtocolStore().activeProtocol; const newsletterId = useNewsletterStore().activeNewsletter;
await http await http
.put(`/admin/protocol/${protocolId}/synchronize/presence`, { .patch(`/admin/newsletter/${newsletterId}/synchronize/recipients`, {
presence: this.recipients, recipients: this.recipients,
}) })
.then((res) => { .then((res) => {
this.syncingNewsletterRecipients = "synced"; this.syncingNewsletterRecipients = "synced";

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,84 @@
<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
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>{{ item.calendar.title }} {{ item.calendar.starttime }}</p>
</summary>
<input
type="text"
name="title"
id="title"
placeholder="Einscheidung"
autocomplete="off"
v-model="item.diffTitle"
@keyup.prevent
:disabled="!can('create', 'club', 'newsletter')"
/>
<QuillEditor
id="top"
theme="snow"
placeholder="Entscheidung Inhalt..."
style="height: 250px; max-height: 250px; min-height: 250px"
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)' : ''"
/>
</details>
</div>
<button v-if="can('create', 'club', 'newsletter')" primary class="!w-fit" @click="addEntry">
Eintrag hinzufügen
</button>
</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 { useNewsletterDatesStore } from "@/stores/admin/newsletterDates";
import { useAbilityStore } from "@/stores/ability";
</script>
<script lang="ts">
export default defineComponent({
props: {
newsletterId: String,
},
computed: {
...mapWritableState(useNewsletterDatesStore, ["dates", "loading"]),
...mapState(useAbilityStore, ["can"]),
},
mounted() {
this.fetchNewsletterDates();
},
methods: {
...mapActions(useNewsletterDatesStore, ["fetchNewsletterDates"]),
addEntry(){
// modal to select date
}
},
});
</script>

View file

@ -0,0 +1,63 @@
<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">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 zur Sitzung..."
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,102 @@
<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="fetchProtocolPrintout" 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.id"
class="flex flex-col h-fit w-full border border-primary rounded-md"
>
<div class="bg-primary p-2 text-white flex flex-row justify-between items-center">
<p>{{ print.title }}</p>
<div class="flex flex-row">
<div>
<ViewfinderCircleIcon class="w-5 h-5 p-1 box-content cursor-pointer" @click="openPdfShow(print.id)" />
</div>
<div>
<ArrowDownTrayIcon class="w-5 h-5 p-1 box-content cursor-pointer" @click="downloadPdf(print.id)" />
</div>
</div>
</div>
<div class="p-2">
<p>Ausdruck Nummer: {{ print.iteration }}</p>
<p>Ausdruck erstellt: {{ print.createdAt }}</p>
</div>
</div>
</div>
<div class="flex flex-row justify-start gap-2">
<button
v-if="can('create', 'club', 'newsletter')"
primary
class="!w-fit"
:disabled="printing != undefined"
@click="createProtocolPrintout"
>
Ausdruck erstellen
</button>
<Spinner v-if="printing == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="printing == 'success'" />
<FailureXMark v-else-if="printing == 'failed'" />
</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 { useProtocolPrintoutStore } from "@/stores/admin/newsletterPrintout";
import { ArrowDownTrayIcon, ViewfinderCircleIcon } from "@heroicons/vue/24/outline";
import { useModalStore } from "@/stores/modal";
import { useAbilityStore } from "@/stores/ability";
</script>
<script lang="ts">
export default defineComponent({
props: {
newsletterId: String,
},
// computed: {
// ...mapState(useProtocolPrintoutStore, ["printout", "loading", "printing"]),
// ...mapState(useAbilityStore, ["can"]),
// },
// mounted() {
// this.fetchProtocolPrintout();
// },
// methods: {
// ...mapActions(useModalStore, ["openModal"]),
// ...mapActions(useProtocolPrintoutStore, [
// "fetchProtocolPrintout",
// "createProtocolPrintout",
// "fetchProtocolPrintoutById",
// ]),
// openPdfShow(id: number) {
// this.openModal(
// markRaw(defineAsyncComponent(() => import("@/components/admin/club/newsletter/ProtocolPrintoutViewerModal.vue"))),
// id
// );
// },
// downloadPdf(id: number) {
// let clickedOn = this.printout.find((p) => p.id == id);
// this.fetchProtocolPrintoutById(id)
// .then((response) => {
// const fileURL = window.URL.createObjectURL(new Blob([response.data]));
// const fileLink = document.createElement("a");
// fileLink.href = fileURL;
// fileLink.setAttribute("download", clickedOn?.title ? clickedOn.title + ".pdf" : "Protokoll.pdf");
// document.body.appendChild(fileLink);
// fileLink.click();
// fileLink.remove();
// })
// .catch(() => {});
// },
// },
});
</script>

View file

@ -0,0 +1,164 @@
<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 members by query
<div class="w-full">
<Combobox v-model="recipients" :disabled="!can('create', 'club', 'newsletter')" multiple>
<ComboboxLabel>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>
<br />
<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 { useNewsletterStore } from "@/stores/admin/newsletter";
import { useMemberStore } from "@/stores/admin/member";
import type { MemberViewModel } from "@/viewmodels/admin/member.models";
import { useNewsletterRecipientsStore } from "@/stores/admin/newsletterRecipients";
import { useAbilityStore } from "@/stores/ability";
</script>
<script lang="ts">
export default defineComponent({
props: {
newsletterId: String,
},
data() {
return {
query: "" as String,
};
},
computed: {
...mapWritableState(useNewsletterRecipientsStore, ["recipients", "loading"]),
...mapState(useMemberStore, ["members"]),
...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));
},
},
mounted() {
this.fetchMembers();
this.fetchNewsletterRecipients();
},
methods: {
...mapActions(useMemberStore, ["fetchMembers"]),
...mapActions(useNewsletterRecipientsStore, ["fetchNewsletterRecipients"]),
removeSelected(id: number) {
let index = this.recipients.findIndex((s) => s == id);
if (index != -1) {
this.recipients.splice(index, 1);
}
},
},
});
</script>

View file

@ -0,0 +1,123 @@
<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";
</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-dates", title: "Termine" },
{ route: "admin-club-newsletter-recipients", title: "Empfänger" },
{ route: "admin-club-newsletter-printout", title: "Druck" },
],
wantToClose: false as boolean,
executeSyncAll: undefined as any,
syncState: "synced" as "synced" | "syncing" | "detectedChanges" | "failed",
};
},
computed: {
...mapState(useNewsletterStore, ["origin"]),
},
mounted() {
this.fetchNewsletterByActiveId();
},
// 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(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>