#23-cleanup-&-enhancements #28

Merged
jkeffects merged 3 commits from #23-cleanup-&-enhancements into main 2025-01-05 15:12:32 +00:00
37 changed files with 490 additions and 62 deletions

26
docs/calendar.md Normal file
View file

@ -0,0 +1,26 @@
# Kalender
Der Kalender bietet eine Möglichkeit der Organisation von Terminen in unterschiedlichen Kategorien.
Die Kategorien können in den Einstellungen gesetzt werden. Dabei gibt es folgende Einstellungsmöglichkeiten:
- Bezeichnung
- Farbe
- Standard Kalender Auslieferung
- Passphrase
Die Standard Kalender Auslieferung gibt immer Termine dieser Art in der Öffentlichen Ansicht, als auch in der externen Kalendern an, sofern der Link nicht weiter spezifiziert ist.
Die Passphrase kann zugangsbeschränkend für Termintypen angewandt werden.
## öffentlicher Kalender
Der öffentliche Kalender ist unter dem Pfad `/public/calendar` erreichbar. Dieser Kalender zeigt immer alle Termine an, welche einem Typ mit Standard-Auslieferung zugewiesen sind.
## WebCal
Der Kalender kann auch in den Kalender von zum Beispiel Google, Apple und co eingebunden werden, damit die Termine auch direkt im Kalender des Smartphones oder Outlook... verfügbar sind.
Hierfür kann der Link konfiguriert werden. Der Link kann dann so eingestellt werden, dass passwort-geschützte oder nicht standard Typen ausgeliefert werden. Zusätzlich können unter diesen Links auch die Standard-Typen hinzugefügt werden.
Genutzt werden kann das zum Beispiel, dass Vorstands-Interne Termine oder Mitglieds-Spezifische Termine über einen personalisierten Link ausgegeben werden können.
Wird der erstellte Link in einem Browser geöffnet, sollte automatisch eine ICS-Datei mit den Terminen heruntergeladen werden, oder die Möglichkeit vorgeschlagen werden, den Link in einen Kalender zu integrieren.

View file

@ -1 +1,25 @@
# FF Admin # FF Admin
## FF Admin ist eine Verwaltungsoberfläche für die Feuerwehr:
FF Admin bietet folgende Module:
- Mitgliederverwaltung
- Kalender
- Newsletter-Versand
- Protokolle
- Datenabfrage
- Templating-Engine
- Benutzerverwaltung
- Rollenverwaltung
<br>
-------
<br>
## Struktur
FF Admin ist in Verein, Wehr, Einstellungen und Nutzerverwaltung getrennt.
Die den Modulen zugrunde liegenden Daten können in den Einstellungen gesetzt werden.
Fast alle Daten lassen sich einstellen, damit es keine Einschränkungen in der Auswahl von Werten... gibt. Diese Modularität muss allerdings bei einigen Modulen gesondert eingestellt werden.

51
docs/member.md Normal file
View file

@ -0,0 +1,51 @@
# Mitgliederverwaltung
Die Mitgliederverwaltung bietet eine Startansicht, in welcher alle Mitglieder durch Pagination angezeigt werden. Die Suche ermöglicht eine Full-Text-Suche nach Vor- und Nachnamen.
Ist ein Mitglied ausgewählt, lassen sich innerhalb dessen alle Daten zu einem Mitglied verwalten.
- Allgemeine Daten des Mitglieds
- Mitgliedschaft
- Adressen bzw. Kommunikationswege
- Auszeichnungen
- Qualifikationen
- Vereinsämter
Jedes dieser Verwaltungsmöglichkeiten benötigt vorher eingestellte Werte, welche dann einem Mitglied hinzugefügt werden können.
## Allgemeine Daten des Mitglieds
Die allgemeinen Daten des Mitglieds umfassen die interne Id, Anrede, Vorname, Nachname, Nameaffix und das Geburtsdatum. Diese Daten können über den Stift oben rechts im Eck geändert werden.
Weiterhin zeigt die Übersicht des Mitglieds auch Informationen zu den Einträgen der übrigen Kategorien.
## Mitgliedschaft
Die auswählbaren Mitgliedsarten können in den Einstellungen gesetzt werden.
Im Mitglied können dann Zeiträume einer bestimmten Mitgliedschafts-Art angelegt werden. Wird ein neuer Zeitraum hinzugefügt, wird ein aktuell laufender Zeitraum mit dem Vortag des neuen Startdatums beendet.
Weiterhin kann bei manuellem setzen des Enddatums ein Grund angegeben werden.
## Adressen bzw. Kommunikationswege
Die auswählbaren Kommunikationsarten können in den Einstellungen erstellt werden. Hierfür muss zu jeder Kommunikationsart ausgewählt werden, welche Felder ausgefüllt werden sollen.
Im Miglied kann dann bei jedem kommunikationstyp gesetzt werden, ob dieser bevorzugt wird, und ob der Newsletter dorthin versandt werden soll.
Ist eine Telefonnummer in der Auswahl enthalten, besteht zusätzlich die Möglichkeit, diesen Kommunikationsweg für den Versand der SMS Alarmierung auszuwählen.
## Auszeichnungen
Die auswählbaren Auszeichnungen können in den Einstellungen erstellt werden.
Im Mitglied können Auszeichnungen mit dem Vergabedatum hinzugefügt werden. Wird eine Annahme verweigert oder Ausgabe verwehrt, kann ein Grund hierfür angegeben werden.
## Qualifikation
Die auswählbaren Qualifikationen können in den Einstellungen erstellt werden.
Im Mitglied können Qualifikationen mit einem Start und Enddatum hinzugefügt werden. Eine Notiz kann auch hinzugefügt werden. Zusätzlich zum Enddatum kann ein Grund für das Ende gesetzt werden.
## Vereinsämter
Die auswählbaren Vereinsämter können in den Einstellungen erstellt werden.
Im Mitglied können Qualifikationen mit einem Start und Enddatum hinzugefügt werden. Eine Notiz kann auch hinzugefügt werden.

143
docs/newsletter.md Normal file
View file

@ -0,0 +1,143 @@
# Newsletter
Das Newsletter erlaubt den Druck und Versand von Inhalten zum Verein. Zu einem Newsletter können öffentliche Kalendereinträge hinzugefügt werden.
## Newsletter erstellen
Ein Newsletter besteht aus Titel und Zusammenfassung, um einen schnelleren Überblick in der Pagination zu erhalten.
Im Newsletter können Überschrift, Einleitung/Text und Signatur hinzugefügt werden.
Es können Daten ausgewählt werden, welche dann automatisch nach dem Text und vor der Signatur im Standard-Template angezeigt.
Und es können Empfänger über eine Vordefinierte Datenabfrage oder manuelles hinzufügen festgelegt werden.
Im Tab Druck und Versand wird eine Datei mit allen Hinzugefügten Kalendereinträgen angezeigt, wie auch alle erstellten pdfs wie auch eine pdf, die alle anderen pdfs enthält.
## Versand
In den Einstellungen kann festgelegt werden, welcher Kommunikationstyp wie versandt werden soll. Dies wird zusätzlich nochmals vor dem finalen Versand geprüft.
Es wird beim Druck unterschieden in Ausgaben mit und ohne Adresse. In der Fußzeile wird dann entweder nur der Name oder auch mit Adresse gedruckt.
Die Auswahl des Typs Mail versendet nur den Hauptteil des pdfs mit einer ics-Datei im Anhang.
## Template
Über die Templating-Engine können für den Newsletter abweichende Kopf- und Fußzeilen und ein abweichender Hauptteil festgelegt werden.
Ein Newsletter-Template erhält folgende Daten:
``` ts
// interface:
{
title: string;
description: string;
newsletterTitle: string;
newsletterText: string;
newsletterSignatur: string;
dates: Array<
{
title: string; // enthält alternativen Titel bzw. Titel des Kalendereintrags
content: string; // enthält alternative Beschreibung bzw. Beschreibung des Kalendereintrags
starttime: string;
endtime: string;
location: string;
formattedStarttime: string;
formattedFullStarttime: string;
formattedEndtime: string;
formattedFullEndtime: string;
}
>;
recipient: {
firstname: string;
lastname: string;
salutation: Salutation; // (sir | madam | divers | none)
nameaffix: string;
street: string;
streetNumber: string;
streetNumberAdd: string
};
}
// beispieldaten
{
title: "Beispiel Newsletter Daten",
description: "Zusammenfassung der Demodaten.",
newsletterTitle: "<h1>Sehr geehrtes Feuerwehrmitglied</h1>",
newsletterText: "<p>zu folgenden Terminen möchten wir recht herzlich zur Teilnahme einladen:</p>",
newsletterSignatur: "<p>Mit freundlichen Grüßen</p><p>...</p>",
dates: [
{
title: "Termin 1",
content: "<p>Beschreibung eines Termins</p>",
starttime: new Date(),
formattedStarttime: "Montag 20. Januar",
formattedFullStarttime: "Montag 20. Januar um 19:00",
endtime: new Date(),
formattedEndtime: "Montag 20. Januar",
formattedFullEndtime: "Montag 20. Januar um 21:00",
location: "Feuerwehrhaus",
},
],
recipient: {
firstname: "Julian",
lastname: "Krauser",
salutation: "sir",
nameaffix: "",
street: "Straße",
streetNumber: "Hausnummer",
streetNumberAdd: "Adresszusatz",
},
}
```
Das Template ist als HTML definiert und beinhaltet Platzhalter, welche durch `handlebarsjs` ausgetauscht werden.
``` html
<!-- Standard-Template -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Newsletter</title>
</head>
<body>
<h1>{{{newsletterTitle}}}</h1>
<p>{{{newsletterText}}}</p>
<br />
{{#each dates}}
<div>
<h2><b>{{this.formattedStarttime}}: {{this.title}}</b></h2>
<span>{{{this.content}}}</span>
</div>
<br />
{{/each}}
<br />
<br />
<p>{{{newsletterSignatur}}}</p>
</body>
<style>
h2,
h3,
p,
span,
ul,
li {
padding: 0;
margin: 0;
}
h1,
h2 {
color: #990b00;
}
h2 {
margin-bottom: 5px;
}
</style>
</html>
<!--Footer Template-->
<div style="font-size: 10pt; width: 100%; margin: 0 20px; padding-top: 5px; color: #888; border-top: 0.5px solid black">
{{recipient.lastname}}, {{recipient.firstname}}{{#if recipient.street}},{{/if}} {{recipient.street}}
{{recipient.streetNumber}} {{recipient.streetNumberAdd}}
</div>
```

1
docs/protocol.md Normal file
View file

@ -0,0 +1 @@
# Protokoll

1
docs/query.md Normal file
View file

@ -0,0 +1 @@
# Query Builder & Query Store

1
docs/role.md Normal file
View file

@ -0,0 +1 @@
# Rollenverwaltung

1
docs/templating.md Normal file
View file

@ -0,0 +1 @@
# Templating Engine

1
docs/user.md Normal file
View file

@ -0,0 +1 @@
# Benutzerverwaltung

View file

@ -1,6 +1,6 @@
<template> <template>
<footer <footer
v-if="authCheck && (routeName.includes('admin-') || routeName.includes('account-'))" v-if="authCheck && (routeName.includes('admin-') || routeName.includes('account-') || routeName.includes('docs-'))"
class="md:hidden flex flex-row h-16 min-h-16 justify-center md:justify-normal p-1 bg-white" class="md:hidden flex flex-row h-16 min-h-16 justify-center md:justify-normal p-1 bg-white"
> >
<div class="w-full flex flex-row gap-2 h-full align-middle"> <div class="w-full flex flex-row gap-2 h-full align-middle">
@ -12,7 +12,7 @@
:disableSubLink="true" :disableSubLink="true"
/> />
<TopLevelLink <TopLevelLink
v-else-if="routeName == 'account' || routeName.includes('account-')" v-else-if="routeName == 'account' || routeName.includes('account-') || routeName == 'docs' || routeName.includes('docs-')"
:link="{ key: 'club', title: 'Zur Verwaltung', levelDefault: '' }" :link="{ key: 'club', title: 'Zur Verwaltung', levelDefault: '' }"
:disableSubLink="true" :disableSubLink="true"
/> />

View file

@ -13,7 +13,7 @@
:link="item" :link="item"
/> />
<TopLevelLink <TopLevelLink
v-else-if="routeName == 'account' || routeName.includes('account-')" v-else-if="routeName == 'account' || routeName.includes('account-') || routeName == 'docs' || routeName.includes('docs-')"
:link="{ key: 'club', title: 'Zur Verwaltung', levelDefault: '' }" :link="{ key: 'club', title: 'Zur Verwaltung', levelDefault: '' }"
:disable-sub-link="true" :disable-sub-link="true"
/> />

View file

@ -1,6 +1,6 @@
<template> <template>
<div <div
class="fixed right-0 flex flex-col gap-4 p-2 w-full md:w-80" class="fixed right-0 flex flex-col gap-4 p-2 w-full md:w-80 z-50"
:class="position == 'bottom' ? 'bottom-0' : 'top-0'" :class="position == 'bottom' ? 'bottom-0' : 'top-0'"
> >
<TransitionGroup <TransitionGroup

View file

@ -13,7 +13,7 @@
leave-to-class="transform scale-95 opacity-0" leave-to-class="transform scale-95 opacity-0"
> >
<MenuItems <MenuItems
class="absolute right-0 mt-2 w-56 z-10 origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none" class="absolute right-0 mt-2 w-56 z-20 origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
> >
<div class="px-3 py-1 pt-2"> <div class="px-3 py-1 pt-2">
<p class="text-xs">Angemeldet als</p> <p class="text-xs">Angemeldet als</p>
@ -25,6 +25,11 @@
<button button primary @click="close">Mein Account</button> <button button primary @click="close">Mein Account</button>
</RouterLink> </RouterLink>
</MenuItem> </MenuItem>
<MenuItem v-slot="{ close }">
<RouterLink to="/docs">
<button button primary @click="close">Dokumentation</button>
</RouterLink>
</MenuItem>
<MenuItem> <MenuItem>
<span> <span>
<button primary-outline @click="logoutAccount">ausloggen</button> <button primary-outline @click="logoutAccount">ausloggen</button>

View file

@ -0,0 +1,60 @@
<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>
<div class="flex flex-row gap-2 justify-end">
<a ref="download" button primary class="!w-fit">download</a>
<button primary-outline class="!w-fit" @click="closeModal">schließen</button>
</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 type { AxiosResponse } from "axios";
import { useMemberStore } from "@/stores/admin/club/member/member";
</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(useMemberStore, ["printMemberList"]),
fetchItem() {
this.status = "loading";
this.printMemberList()
.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);
const fileURL = window.URL.createObjectURL(new Blob([response.data]));
const fileLink = (this.$refs.download as HTMLAnchorElement)
fileLink.href = fileURL;
fileLink.setAttribute("download", "Mitgliederliste.pdf");
})
.catch(() => {
this.status = { status: "failed" };
});
},
},
});
</script>

View file

@ -45,7 +45,6 @@ const router = createRouter({
path: "/reset", path: "/reset",
name: "reset", name: "reset",
component: () => import("@/views/RouterView.vue"), component: () => import("@/views/RouterView.vue"),
children: [ children: [
{ {
path: "", path: "",
@ -655,6 +654,7 @@ const router = createRouter({
path: "/docs", path: "/docs",
name: "docs", name: "docs",
component: () => import("@/views/docs/View.vue"), component: () => import("@/views/docs/View.vue"),
beforeEnter: [isAuthenticated],
props: true, props: true,
children: [ children: [
{ {

View file

@ -84,5 +84,10 @@ export const useMemberStore = defineStore("member", {
this.fetchMembers(); this.fetchMembers();
return result; return result;
}, },
async printMemberList(){
return http.get(`/admin/member/print/namelist`, {
responseType: "blob",
});
}
}, },
}); });

View file

@ -1,6 +1,9 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { http } from "@/serverCom"; import { http } from "@/serverCom";
import type { NewsletterDatesViewModel, SyncNewsletterDatesViewModel } from "@/viewmodels/admin/club/newsletter/newsletterDates.models"; import type {
NewsletterDatesViewModel,
SyncNewsletterDatesViewModel,
} from "@/viewmodels/admin/club/newsletter/newsletterDates.models";
import { useNewsletterStore } from "./newsletter"; import { useNewsletterStore } from "./newsletter";
import cloneDeep from "lodash.clonedeep"; import cloneDeep from "lodash.clonedeep";
import isEqual from "lodash.isequal"; import isEqual from "lodash.isequal";
@ -9,6 +12,7 @@ import differenceWith from "lodash.differencewith";
export const useNewsletterDatesStore = defineStore("newsletterDates", { export const useNewsletterDatesStore = defineStore("newsletterDates", {
state: () => { state: () => {
return { return {
initialized: false as boolean,
dates: [] as Array<NewsletterDatesViewModel>, dates: [] as Array<NewsletterDatesViewModel>,
origin: [] as Array<NewsletterDatesViewModel>, origin: [] as Array<NewsletterDatesViewModel>,
loading: "loading" as "loading" | "fetched" | "failed", loading: "loading" as "loading" | "fetched" | "failed",
@ -38,6 +42,7 @@ export const useNewsletterDatesStore = defineStore("newsletterDates", {
.then((result) => { .then((result) => {
this.origin = result.data; this.origin = result.data;
this.dates = cloneDeep(this.origin); this.dates = cloneDeep(this.origin);
this.initialized = true;
this.loading = "fetched"; this.loading = "fetched";
}) })
.catch((err) => { .catch((err) => {
@ -49,6 +54,8 @@ export const useNewsletterDatesStore = defineStore("newsletterDates", {
return http.get(`/admin/newsletter/${newsletterId}/dates`); return http.get(`/admin/newsletter/${newsletterId}/dates`);
}, },
async synchronizeActiveNewsletterDates() { async synchronizeActiveNewsletterDates() {
if (!this.initialized) return;
this.syncingNewsletterDates = "syncing"; this.syncingNewsletterDates = "syncing";
const newsletterId = useNewsletterStore().activeNewsletter; const newsletterId = useNewsletterStore().activeNewsletter;

View file

@ -11,6 +11,7 @@ import isEqual from "lodash.isequal";
export const useNewsletterRecipientsStore = defineStore("newsletterRecipients", { export const useNewsletterRecipientsStore = defineStore("newsletterRecipients", {
state: () => { state: () => {
return { return {
initialized: false as boolean,
recipients: [] as Array<number>, recipients: [] as Array<number>,
origin: [] as Array<number>, origin: [] as Array<number>,
loading: "loading" as "loading" | "fetched" | "failed", loading: "loading" as "loading" | "fetched" | "failed",
@ -31,6 +32,7 @@ export const useNewsletterRecipientsStore = defineStore("newsletterRecipients",
.then((result) => { .then((result) => {
this.origin = result.data.map((d: NewsletterRecipientsViewModel) => d.memberId); this.origin = result.data.map((d: NewsletterRecipientsViewModel) => d.memberId);
this.recipients = cloneDeep(this.origin); this.recipients = cloneDeep(this.origin);
this.initialized = true;
this.loading = "fetched"; this.loading = "fetched";
}) })
.catch((err) => { .catch((err) => {
@ -42,8 +44,11 @@ export const useNewsletterRecipientsStore = defineStore("newsletterRecipients",
return http.get(`/admin/newsletter/${newsletterId}/recipients`); return http.get(`/admin/newsletter/${newsletterId}/recipients`);
}, },
async synchronizeActiveNewsletterRecipients() { async synchronizeActiveNewsletterRecipients() {
if (!this.initialized) return;
this.syncingNewsletterRecipients = "syncing"; this.syncingNewsletterRecipients = "syncing";
const newsletterId = useNewsletterStore().activeNewsletter; const newsletterId = useNewsletterStore().activeNewsletter;
await http await http
.patch(`/admin/newsletter/${newsletterId}/synchronize/recipients`, { .patch(`/admin/newsletter/${newsletterId}/synchronize/recipients`, {
recipients: this.recipients, recipients: this.recipients,

View file

@ -1,6 +1,9 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { http } from "@/serverCom"; import { http } from "@/serverCom";
import type { ProtocolAgendaViewModel, SyncProtocolAgendaViewModel } from "@/viewmodels/admin/club/protocol/protocolAgenda.models"; import type {
ProtocolAgendaViewModel,
SyncProtocolAgendaViewModel,
} from "@/viewmodels/admin/club/protocol/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";
@ -9,6 +12,7 @@ import differenceWith from "lodash.differencewith";
export const useProtocolAgendaStore = defineStore("protocolAgenda", { export const useProtocolAgendaStore = defineStore("protocolAgenda", {
state: () => { state: () => {
return { return {
initialized: false as boolean,
agenda: [] as Array<ProtocolAgendaViewModel>, agenda: [] as Array<ProtocolAgendaViewModel>,
origin: [] as Array<ProtocolAgendaViewModel>, origin: [] as Array<ProtocolAgendaViewModel>,
loading: "loading" as "loading" | "fetched" | "failed", loading: "loading" as "loading" | "fetched" | "failed",
@ -29,6 +33,7 @@ export const useProtocolAgendaStore = defineStore("protocolAgenda", {
.then((result) => { .then((result) => {
this.origin = result.data; this.origin = result.data;
this.agenda = cloneDeep(this.origin); this.agenda = cloneDeep(this.origin);
this.initialized = true;
this.loading = "fetched"; this.loading = "fetched";
}) })
.catch((err) => { .catch((err) => {
@ -55,6 +60,8 @@ export const useProtocolAgendaStore = defineStore("protocolAgenda", {
.catch((err) => {}); .catch((err) => {});
}, },
async synchronizeActiveProtocolAgenda() { async synchronizeActiveProtocolAgenda() {
if (!this.initialized) return;
this.syncingProtocolAgenda = "syncing"; this.syncingProtocolAgenda = "syncing";
const protocolId = useProtocolStore().activeProtocol; const protocolId = useProtocolStore().activeProtocol;

View file

@ -13,6 +13,7 @@ import differenceWith from "lodash.differencewith";
export const useProtocolDecisionStore = defineStore("protocolDecision", { export const useProtocolDecisionStore = defineStore("protocolDecision", {
state: () => { state: () => {
return { return {
initialized: false as boolean,
decision: [] as Array<ProtocolDecisionViewModel>, decision: [] as Array<ProtocolDecisionViewModel>,
origin: [] as Array<ProtocolDecisionViewModel>, origin: [] as Array<ProtocolDecisionViewModel>,
loading: "loading" as "loading" | "fetched" | "failed", loading: "loading" as "loading" | "fetched" | "failed",
@ -33,9 +34,11 @@ export const useProtocolDecisionStore = defineStore("protocolDecision", {
.then((result) => { .then((result) => {
this.origin = result.data; this.origin = result.data;
this.decision = cloneDeep(this.origin); this.decision = cloneDeep(this.origin);
this.initialized = true;
this.loading = "fetched"; this.loading = "fetched";
}) })
.catch((err) => { .catch((err) => {
console.log(err);
this.loading = "failed"; this.loading = "failed";
}); });
}, },
@ -59,6 +62,8 @@ export const useProtocolDecisionStore = defineStore("protocolDecision", {
.catch((err) => {}); .catch((err) => {});
}, },
async synchronizeActiveProtocolDecision() { async synchronizeActiveProtocolDecision() {
if (!this.initialized) return;
this.syncingProtocolDecision = "syncing"; this.syncingProtocolDecision = "syncing";
const protocolId = useProtocolStore().activeProtocol; const protocolId = useProtocolStore().activeProtocol;

View file

@ -12,15 +12,19 @@ import isEqual from "lodash.isequal";
export const useProtocolPresenceStore = defineStore("protocolPresence", { export const useProtocolPresenceStore = defineStore("protocolPresence", {
state: () => { state: () => {
return { return {
presence: [] as Array<number>, initialized: false as boolean,
origin: [] as Array<number>, presence: [] as Array<ProtocolPresenceViewModel>,
origin: [] as Array<ProtocolPresenceViewModel>,
loading: "loading" as "loading" | "fetched" | "failed", loading: "loading" as "loading" | "fetched" | "failed",
syncingProtocolPresence: "synced" as "synced" | "syncing" | "detectedChanges" | "failed", syncingProtocolPresence: "synced" as "synced" | "syncing" | "detectedChanges" | "failed",
}; };
}, },
getters: { getters: {
detectedChangeProtocolPresence: (state) => detectedChangeProtocolPresence: (state) =>
!isEqual(state.origin, state.presence) && state.syncingProtocolPresence != "syncing", !isEqual(
state.origin.sort((a: ProtocolPresenceViewModel, b: ProtocolPresenceViewModel) => a.memberId - b.memberId),
state.presence.sort((a: ProtocolPresenceViewModel, b: ProtocolPresenceViewModel) => a.memberId - b.memberId)
) && state.syncingProtocolPresence != "syncing",
}, },
actions: { actions: {
setProtocolPresenceSyncingState(state: "synced" | "syncing" | "detectedChanges" | "failed") { setProtocolPresenceSyncingState(state: "synced" | "syncing" | "detectedChanges" | "failed") {
@ -30,8 +34,9 @@ export const useProtocolPresenceStore = defineStore("protocolPresence", {
this.loading = "loading"; this.loading = "loading";
this.fetchProtocolPresencePromise() this.fetchProtocolPresencePromise()
.then((result) => { .then((result) => {
this.origin = result.data.map((d: ProtocolPresenceViewModel) => d.memberId); this.origin = result.data;
this.presence = cloneDeep(this.origin); this.presence = cloneDeep(this.origin);
this.initialized = true;
this.loading = "fetched"; this.loading = "fetched";
}) })
.catch((err) => { .catch((err) => {
@ -43,8 +48,11 @@ export const useProtocolPresenceStore = defineStore("protocolPresence", {
return http.get(`/admin/protocol/${protocolId}/presence`); return http.get(`/admin/protocol/${protocolId}/presence`);
}, },
async synchronizeActiveProtocolPresence() { async synchronizeActiveProtocolPresence() {
if (!this.initialized) return;
this.syncingProtocolPresence = "syncing"; this.syncingProtocolPresence = "syncing";
const protocolId = useProtocolStore().activeProtocol; const protocolId = useProtocolStore().activeProtocol;
await http await http
.put(`/admin/protocol/${protocolId}/synchronize/presence`, { .put(`/admin/protocol/${protocolId}/synchronize/presence`, {
presence: this.presence, presence: this.presence,
@ -57,7 +65,7 @@ export const useProtocolPresenceStore = defineStore("protocolPresence", {
}); });
this.fetchProtocolPresencePromise() this.fetchProtocolPresencePromise()
.then((result) => { .then((result) => {
this.origin = result.data.map((d: ProtocolPresenceViewModel) => d.memberId); this.origin = result.data;
if (this.detectedChangeProtocolPresence) this.syncingProtocolPresence = "detectedChanges"; if (this.detectedChangeProtocolPresence) this.syncingProtocolPresence = "detectedChanges";
}) })
.catch((err) => {}); .catch((err) => {});

View file

@ -19,7 +19,7 @@ export const useProtocolPrintoutStore = defineStore("protocolPrintout", {
http http
.get(`/admin/protocol/${protocolId}/printouts`) .get(`/admin/protocol/${protocolId}/printouts`)
.then((result) => { .then((result) => {
this.printout = result.data; this.printout = result.data.reverse();
this.loading = "fetched"; this.loading = "fetched";
}) })
.catch((err) => { .catch((err) => {

View file

@ -1,7 +1,10 @@
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 { ProtocolVotingViewModel, SyncProtocolVotingViewModel } from "@/viewmodels/admin/club/protocol/protocolVoting.models"; import type {
ProtocolVotingViewModel,
SyncProtocolVotingViewModel,
} from "@/viewmodels/admin/club/protocol/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";
@ -10,6 +13,7 @@ import differenceWith from "lodash.differencewith";
export const useProtocolVotingStore = defineStore("protocolVoting", { export const useProtocolVotingStore = defineStore("protocolVoting", {
state: () => { state: () => {
return { return {
initialized: false as boolean,
voting: [] as Array<ProtocolVotingViewModel>, voting: [] as Array<ProtocolVotingViewModel>,
origin: [] as Array<ProtocolVotingViewModel>, origin: [] as Array<ProtocolVotingViewModel>,
loading: "loading" as "loading" | "fetched" | "failed", loading: "loading" as "loading" | "fetched" | "failed",
@ -30,6 +34,7 @@ export const useProtocolVotingStore = defineStore("protocolVoting", {
.then((result) => { .then((result) => {
this.origin = result.data; this.origin = result.data;
this.voting = cloneDeep(this.origin); this.voting = cloneDeep(this.origin);
this.initialized = true;
this.loading = "fetched"; this.loading = "fetched";
}) })
.catch((err) => { .catch((err) => {
@ -59,6 +64,8 @@ export const useProtocolVotingStore = defineStore("protocolVoting", {
.catch((err) => {}); .catch((err) => {});
}, },
async synchronizeActiveProtocolVoting() { async synchronizeActiveProtocolVoting() {
if (!this.initialized) return;
this.syncingProtocolVoting = "syncing"; this.syncingProtocolVoting = "syncing";
const protocolId = useProtocolStore().activeProtocol; const protocolId = useProtocolStore().activeProtocol;

View file

@ -1,8 +1,6 @@
import type { MemberViewModel } from "../member/member.models";
export interface ProtocolPresenceViewModel { export interface ProtocolPresenceViewModel {
memberId: number; memberId: number;
member: MemberViewModel; absent: boolean;
protocolId: number; protocolId: number;
} }

View file

@ -3,6 +3,9 @@
<template #topBar> <template #topBar>
<div class="flex flex-row items-center justify-between pt-5 pb-3 px-7"> <div class="flex flex-row items-center justify-between pt-5 pb-3 px-7">
<h1 class="font-bold text-xl h-8">Mitglieder</h1> <h1 class="font-bold text-xl h-8">Mitglieder</h1>
<div title="Mitgliederliste drucken" @click="openPrintModal">
<DocumentTextIcon class="w-5 h-5 cursor-pointer" />
</div>
</div> </div>
</template> </template>
<template #diffMain> <template #diffMain>
@ -40,6 +43,7 @@ import { useModalStore } from "@/stores/modal";
import Pagination from "@/components/Pagination.vue"; import Pagination from "@/components/Pagination.vue";
import type { MemberViewModel } from "@/viewmodels/admin/club/member/member.models"; import type { MemberViewModel } from "@/viewmodels/admin/club/member/member.models";
import { useAbilityStore } from "@/stores/ability"; import { useAbilityStore } from "@/stores/ability";
import { DocumentTextIcon, PencilIcon } from "@heroicons/vue/24/outline";
</script> </script>
<script lang="ts"> <script lang="ts">
@ -65,6 +69,11 @@ export default defineComponent({
markRaw(defineAsyncComponent(() => import("@/components/admin/club/member/CreateMemberModal.vue"))) markRaw(defineAsyncComponent(() => import("@/components/admin/club/member/CreateMemberModal.vue")))
); );
}, },
openPrintModal() {
this.openModal(
markRaw(defineAsyncComponent(() => import("@/components/admin/club/member/MemberNameListModal.vue")))
);
},
}, },
}); });
</script> </script>

View file

@ -1,6 +1,10 @@
<template> <template>
<div class="flex flex-col gap-2 h-full w-full overflow-y-auto"> <div class="flex flex-col gap-2 h-full w-full overflow-y-auto">
<div v-if="activeMemberObj != null" class="flex flex-col gap-2 w-full"> <div v-if="activeMemberObj != null" class="flex flex-col gap-2 w-full">
<div>
<label for="id">Interne Id</label>
<input type="text" id="id" :value="activeMemberObj.internalId" readonly />
</div>
<div> <div>
<label for="salutation">Anrede</label> <label for="salutation">Anrede</label>
<input type="text" id="salutation" :value="activeMemberObj.salutation" readonly /> <input type="text" id="salutation" :value="activeMemberObj.salutation" readonly />

View file

@ -140,7 +140,7 @@ export default defineComponent({
}, },
}, },
mounted() { mounted() {
this.fetchNewsletterDates(); // this.fetchNewsletterDates();
this.fetchCalendars(); this.fetchCalendars();
}, },
methods: { methods: {

View file

@ -58,7 +58,7 @@ export default defineComponent({
...mapState(useAbilityStore, ["can"]), ...mapState(useAbilityStore, ["can"]),
}, },
mounted() { mounted() {
this.fetchNewsletterByActiveId(); // this.fetchNewsletterByActiveId();
}, },
methods: { methods: {
...mapActions(useNewsletterStore, ["fetchNewsletterByActiveId"]), ...mapActions(useNewsletterStore, ["fetchNewsletterByActiveId"]),

View file

@ -205,8 +205,8 @@ export default defineComponent({
}, },
}, },
mounted() { mounted() {
this.fetchMembers(0, 1000, true); this.fetchMembers(0, 1000, "", true);
this.fetchNewsletterRecipients(); // this.fetchNewsletterRecipients();
this.fetchQueries(); this.fetchQueries();
this.loadQuery(); this.loadQuery();
}, },

View file

@ -73,7 +73,7 @@ export default defineComponent({
...mapState(useAbilityStore, ["can"]), ...mapState(useAbilityStore, ["can"]),
}, },
mounted() { mounted() {
this.fetchProtocolAgenda(); // this.fetchProtocolAgenda();
}, },
methods: { methods: {
...mapActions(useProtocolAgendaStore, ["fetchProtocolAgenda", "createProtocolAgenda"]), ...mapActions(useProtocolAgendaStore, ["fetchProtocolAgenda", "createProtocolAgenda"]),

View file

@ -73,7 +73,7 @@ export default defineComponent({
...mapState(useAbilityStore, ["can"]), ...mapState(useAbilityStore, ["can"]),
}, },
mounted() { mounted() {
this.fetchProtocolDecision(); // this.fetchProtocolDecision();
}, },
methods: { methods: {
...mapActions(useProtocolDecisionStore, ["fetchProtocolDecision", "createProtocolDecision"]), ...mapActions(useProtocolDecisionStore, ["fetchProtocolDecision", "createProtocolDecision"]),

View file

@ -80,7 +80,7 @@ export default defineComponent({
...mapState(useAbilityStore, ["can"]), ...mapState(useAbilityStore, ["can"]),
}, },
mounted() { mounted() {
this.fetchProtocolByActiveId(); // this.fetchProtocolByActiveId();
}, },
methods: { methods: {
...mapActions(useProtocolStore, ["fetchProtocolByActiveId"]), ...mapActions(useProtocolStore, ["fetchProtocolByActiveId"]),

View file

@ -6,7 +6,7 @@
</p> </p>
<div class="w-full"> <div class="w-full">
<Combobox v-model="presence" :disabled="!can('create', 'club', 'protocol')" multiple> <Combobox v-model="presence" :disabled="!can('create', 'club', 'protocol')" multiple by="memberId">
<ComboboxLabel>Anwesende suchen</ComboboxLabel> <ComboboxLabel>Anwesende suchen</ComboboxLabel>
<div class="relative mt-1"> <div class="relative mt-1">
<ComboboxInput <ComboboxInput
@ -34,8 +34,8 @@
<ComboboxOption <ComboboxOption
v-for="member in filtered" v-for="member in filtered"
as="template" as="template"
:key="member.id" :key="member.memberId"
:value="member.id" :value="member"
v-slot="{ selected, active }" v-slot="{ selected, active }"
> >
<li <li
@ -46,7 +46,7 @@
}" }"
> >
<span class="block truncate" :class="{ 'font-medium': selected, 'font-normal': !selected }"> <span class="block truncate" :class="{ 'font-medium': selected, 'font-normal': !selected }">
{{ member.firstname }} {{ member.lastname }} {{ member.nameaffix }} {{ getMember(member.memberId)?.firstname }} {{ getMember(member.memberId)?.lastname }} {{ getMember(member.memberId)?.nameaffix }}
</span> </span>
<span <span
v-if="selected" v-if="selected"
@ -63,18 +63,24 @@
</Combobox> </Combobox>
</div> </div>
<br /> <br />
<p>Ausgewählte Anwesende</p> <p>Anwesenheit</p>
<div class="flex flex-col gap-2 grow overflow-y-auto"> <div class="flex flex-col gap-2 grow overflow-y-auto">
<div <div
v-for="member in selected" v-for="member in presence"
:key="member.id" :key="member.memberId"
class="flex flex-row h-fit w-full border border-primary rounded-md bg-primary p-2 text-white justify-between items-center" class="flex flex-row h-fit w-full border border-primary rounded-md bg-primary p-2 text-white justify-between items-center"
> >
<p>{{ member.lastname }}, {{ member.firstname }} {{ member.nameaffix ? `- ${member.nameaffix}` : "" }}</p> <div class="flex flex-col items-start">
<p>{{ getMember(member.memberId)?.lastname }}, {{ getMember(member.memberId)?.firstname }} {{ getMember(member.memberId)?.nameaffix ? `- ${getMember(member.memberId)?.nameaffix}` : "" }}</p>
<label class="flex flex-row gap-2 items-center">
<input type="checkbox" v-model="member.absent" />
war abwesend
</label>
</div>
<TrashIcon <TrashIcon
v-if="can('create', 'club', 'protocol')" v-if="can('create', 'club', 'protocol')"
class="w-5 h-5 p-1 box-content cursor-pointer" class="w-5 h-5 p-1 box-content cursor-pointer"
@click="removeSelected(member.id)" @click="removeSelected(member.memberId)"
/> />
</div> </div>
</div> </div>
@ -117,38 +123,31 @@ export default defineComponent({
...mapWritableState(useProtocolPresenceStore, ["presence", "loading"]), ...mapWritableState(useProtocolPresenceStore, ["presence", "loading"]),
...mapState(useMemberStore, ["members"]), ...mapState(useMemberStore, ["members"]),
...mapState(useAbilityStore, ["can"]), ...mapState(useAbilityStore, ["can"]),
filtered(): Array<MemberViewModel> { filtered(): Array<{memberId:number, absent:boolean; protocolId:number}> {
return this.query === "" return (this.query === ""
? this.members ? this.members
: this.members.filter((member) => : this.members.filter((member) =>
(member.firstname + " " + member.lastname) (member.firstname + " " + member.lastname)
.toLowerCase() .toLowerCase()
.replace(/\s+/g, "") .replace(/\s+/g, "")
.includes(this.query.toLowerCase().replace(/\s+/g, "")) .includes(this.query.toLowerCase().replace(/\s+/g, ""))
); )).map(m =>({memberId: m.id, absent:false, protocolId:parseInt(this.protocolId ?? "")}));
},
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.presence.includes(m.id));
}, },
getMember(){
return (memberId:number) => {
return this.members.find(m => memberId == m.id)
}
}
}, },
mounted() { mounted() {
this.fetchMembers(0, 1000, true); this.fetchMembers(0, 1000, "", true);
this.fetchProtocolPresence(); // this.fetchProtocolPresence();
}, },
methods: { methods: {
...mapActions(useMemberStore, ["fetchMembers"]), ...mapActions(useMemberStore, ["fetchMembers"]),
...mapActions(useProtocolPresenceStore, ["fetchProtocolPresence"]), ...mapActions(useProtocolPresenceStore, ["fetchProtocolPresence"]),
removeSelected(id: number) { removeSelected(id: number) {
let index = this.presence.findIndex((s) => s == id); let index = this.presence.findIndex((s) => s.memberId == id);
if (index != -1) { if (index != -1) {
this.presence.splice(index, 1); this.presence.splice(index, 1);
} }

View file

@ -53,6 +53,10 @@ import { useProtocolStore } from "@/stores/admin/club/protocol/protocol";
import { useModalStore } from "@/stores/modal"; import { useModalStore } from "@/stores/modal";
import ProtocolSyncing from "@/components/admin/club/protocol/ProtocolSyncing.vue"; import ProtocolSyncing from "@/components/admin/club/protocol/ProtocolSyncing.vue";
import { PrinterIcon } from "@heroicons/vue/24/outline"; import { PrinterIcon } from "@heroicons/vue/24/outline";
import { useProtocolAgendaStore } from "@/stores/admin/club/protocol/protocolAgenda";
import { useProtocolDecisionStore } from "@/stores/admin/club/protocol/protocolDecision";
import { useProtocolPresenceStore } from "@/stores/admin/club/protocol/protocolPresence";
import { useProtocolVotingStore } from "@/stores/admin/club/protocol/protocolVoting";
</script> </script>
<script lang="ts"> <script lang="ts">
@ -88,6 +92,10 @@ export default defineComponent({
}, },
mounted() { mounted() {
this.fetchProtocolByActiveId(); this.fetchProtocolByActiveId();
this.fetchProtocolAgenda()
this.fetchProtocolDecision()
this.fetchProtocolPresence()
this.fetchProtocolVoting()
}, },
// this.syncState is undefined, so it will never work // this.syncState is undefined, so it will never work
// beforeRouteLeave(to, from, next) { // beforeRouteLeave(to, from, next) {
@ -108,6 +116,10 @@ export default defineComponent({
// }, // },
methods: { methods: {
...mapActions(useProtocolStore, ["fetchProtocolByActiveId"]), ...mapActions(useProtocolStore, ["fetchProtocolByActiveId"]),
...mapActions(useProtocolAgendaStore, ["fetchProtocolAgenda"]),
...mapActions(useProtocolDecisionStore, ["fetchProtocolDecision"]),
...mapActions(useProtocolPresenceStore,["fetchProtocolPresence"]),
...mapActions(useProtocolVotingStore,["fetchProtocolVoting"]),
...mapActions(useModalStore, ["openModal"]), ...mapActions(useModalStore, ["openModal"]),
openInfoModal() { openInfoModal() {
this.openModal( this.openModal(

View file

@ -90,7 +90,7 @@ export default defineComponent({
...mapState(useAbilityStore, ["can"]), ...mapState(useAbilityStore, ["can"]),
}, },
mounted() { mounted() {
this.fetchProtocolVoting(); // this.fetchProtocolVoting();
}, },
methods: { methods: {
...mapActions(useProtocolVotingStore, ["fetchProtocolVoting", "createProtocolVoting"]), ...mapActions(useProtocolVotingStore, ["fetchProtocolVoting", "createProtocolVoting"]),

View file

@ -1,15 +1,17 @@
<template> <template>
<MainTemplate :useStagedOverviewLink="false"> <MainTemplate :useStagedOverviewLink="false">
<template #topBar> <!-- <template #topBar>
<div class="flex flex-row items-center justify-between pt-5 pb-3 px-7"> <div class="flex flex-row items-center justify-between pt-5 pb-3 px-7">
<h1 class="font-bold text-xl h-8">{{page}}</h1> <h1 class="font-bold text-xl h-8">{{page}}</h1>
</div> </div>
</template> </template> -->
<template #main> <template #diffMain>
<div class="markdown-container"> <div class="flex flex-col gap-2 h-full px-4 overflow-hidden">
<div class="markdown-container overflow-y-scroll">
<component v-if="markdownComponent" :is="markdownComponent" /> <component v-if="markdownComponent" :is="markdownComponent" />
<p v-else>Diese Seite existiert nicht.</p> <p v-else>Diese Seite existiert nicht.</p>
</div> </div>
</div>
</template> </template>
</MainTemplate> </MainTemplate>
</template> </template>
@ -40,8 +42,46 @@ export default defineComponent({
}, },
methods:{ methods:{
loadPage(){ loadPage(){
this.markdownComponent = null
this.markdownComponent = markRaw(defineAsyncComponent(() => import(`$/${this.page?.toLowerCase()}.md`))); this.markdownComponent = markRaw(defineAsyncComponent(() => import(`$/${this.page?.toLowerCase()}.md`)));
} }
} }
}); });
</script> </script>
<style>
.markdown-container {
font-family: 'Open Sans', sans-serif;
color: #555;
background-color: #f8f8f8;
border: 2px solid gray;
padding: 20px;
border-radius: 5px;
height: 100%
}
.markdown-container h1 {
font-size: 2rem !important;
color: #222;
}
.markdown-container h2 {
font-size: 1.25rem !important;
color: #222;
}
.markdown-container hr {
background-color: #222;
margin: 8px 0;
padding: 1px;
}
.markdown-container p {
margin: 10px 0;
}
.markdown-container ul {
list-style-type: disc;
margin-left: 20px;
}
</style>

View file

@ -4,6 +4,14 @@
<SidebarTemplate mainTitle="Dokumentation"> <SidebarTemplate mainTitle="Dokumentation">
<template #list> <template #list>
<RoutingLink title="FF Admin" :link="{ name: 'docs-page', params: { page: 'ff-admin' } }" :active="page == 'ff-admin'" /> <RoutingLink title="FF Admin" :link="{ name: 'docs-page', params: { page: 'ff-admin' } }" :active="page == 'ff-admin'" />
<RoutingLink title="Mitgliederverwaltung" :link="{ name: 'docs-page', params: { page: 'member' } }" :active="page == 'member'" />
<RoutingLink title="Kalendar" :link="{ name: 'docs-page', params: { page: 'calendar' } }" :active="page == 'calendar'" />
<RoutingLink title="Newsletter-Versand" :link="{ name: 'docs-page', params: { page: 'newsletter' } }" :active="page == 'newsletter'" />
<RoutingLink title="Protokolle" :link="{ name: 'docs-page', params: { page: 'protocol' } }" :active="page == 'protocol'" />
<RoutingLink title="Datenabfrage" :link="{ name: 'docs-page', params: { page: 'query' } }" :active="page == 'query'" />
<RoutingLink title="Templating-Engine" :link="{ name: 'docs-page', params: { page: 'templating' } }" :active="page == 'templating'" />
<RoutingLink title="Benutzerverwaltung" :link="{ name: 'docs-page', params: { page: 'user' } }" :active="page == 'user'" />
<RoutingLink title="Rollenverwaltung" :link="{ name: 'docs-page', params: { page: 'role' } }" :active="page == 'role'" />
</template> </template>
</SidebarTemplate> </SidebarTemplate>
</template> </template>