#3-calendar #7

Merged
jkeffects merged 8 commits from #3-calendar into main 2024-11-07 10:07:29 +00:00
25 changed files with 1478 additions and 27 deletions

64
package-lock.json generated
View file

@ -9,6 +9,11 @@
"version": "0.0.3",
"license": "GPL-3.0-only",
"dependencies": {
"@fullcalendar/core": "^6.1.15",
"@fullcalendar/daygrid": "^6.1.15",
"@fullcalendar/interaction": "^6.1.15",
"@fullcalendar/timegrid": "^6.1.15",
"@fullcalendar/vue3": "^6.1.15",
"@headlessui/vue": "^1.7.13",
"@heroicons/vue": "^2.1.5",
"@vueup/vue-quill": "^1.2.0",
@ -2414,6 +2419,55 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@fullcalendar/core": {
"version": "6.1.15",
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.15.tgz",
"integrity": "sha512-BuX7o6ALpLb84cMw1FCB9/cSgF4JbVO894cjJZ6kP74jzbUZNjtwffwRdA+Id8rrLjT30d/7TrkW90k4zbXB5Q==",
"license": "MIT",
"dependencies": {
"preact": "~10.12.1"
}
},
"node_modules/@fullcalendar/daygrid": {
"version": "6.1.15",
"resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.15.tgz",
"integrity": "sha512-j8tL0HhfiVsdtOCLfzK2J0RtSkiad3BYYemwQKq512cx6btz6ZZ2RNc/hVnIxluuWFyvx5sXZwoeTJsFSFTEFA==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.15"
}
},
"node_modules/@fullcalendar/interaction": {
"version": "6.1.15",
"resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.15.tgz",
"integrity": "sha512-DOTSkofizM7QItjgu7W68TvKKvN9PSEEvDJceyMbQDvlXHa7pm/WAVtAc6xSDZ9xmB1QramYoWGLHkCYbTW1rQ==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.15"
}
},
"node_modules/@fullcalendar/timegrid": {
"version": "6.1.15",
"resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.15.tgz",
"integrity": "sha512-61ORr3A148RtxQ2FNG7JKvacyA/TEVZ7z6I+3E9Oeu3dqTf6M928bFcpehRTIK6zIA6Yifs7BeWHgOE9dFnpbw==",
"license": "MIT",
"dependencies": {
"@fullcalendar/daygrid": "~6.1.15"
},
"peerDependencies": {
"@fullcalendar/core": "~6.1.15"
}
},
"node_modules/@fullcalendar/vue3": {
"version": "6.1.15",
"resolved": "https://registry.npmjs.org/@fullcalendar/vue3/-/vue3-6.1.15.tgz",
"integrity": "sha512-ctfTICGrNEIj7gmLHQcUYe0WzDTSW5Vd9hyOnVChxPU75AZU9WqdDMkHwJYnfNxNhT6QQuiMHq/qsRRd5zQwOw==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.15",
"vue": "^3.0.11"
}
},
"node_modules/@headlessui/vue": {
"version": "1.7.22",
"resolved": "https://registry.npmjs.org/@headlessui/vue/-/vue-1.7.22.tgz",
@ -7617,6 +7671,16 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true
},
"node_modules/preact": {
"version": "10.12.1",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/prebuild-install": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.2.tgz",

View file

@ -24,6 +24,11 @@
"author": "JK Effects",
"license": "GPL-3.0-only",
"dependencies": {
"@fullcalendar/core": "^6.1.15",
"@fullcalendar/daygrid": "^6.1.15",
"@fullcalendar/interaction": "^6.1.15",
"@fullcalendar/timegrid": "^6.1.15",
"@fullcalendar/vue3": "^6.1.15",
"@headlessui/vue": "^1.7.13",
"@heroicons/vue": "^2.1.5",
"@vueup/vue-quill": "^1.2.0",

View file

@ -1,7 +1,7 @@
<template>
<footer
v-if="authCheck && routeName.includes('admin')"
class="md:hidden flex flex-row 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">
<TopLevelLink v-for="item in topLevel" :key="item.key" :link="item" :disableSubLink="true" />

View file

@ -0,0 +1,219 @@
<template>
<div class="w-full md:max-w-md">
<div class="flex flex-col items-center">
<p class="text-xl font-medium">Termintyp erstellen</p>
</div>
<br />
<form class="flex flex-col gap-4 py-2" @submit.prevent="triggerCreate">
<div>
<Listbox v-model="selectedType" name="type">
<ListboxLabel>Termintyp</ListboxLabel>
<div class="relative mt-1">
<ListboxButton
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"
>
<span class="block truncate w-full text-start">
{{
calendarTypes.length != 0 ? (selectedType?.type ?? "bitte auswählen") : "keine Auswahl vorhanden"
}}</span
>
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon class="h-5 w-5 text-gray-400" aria-hidden="true" />
</span>
</ListboxButton>
<transition
leave-active-class="transition duration-100 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute mt-1 max-h-60 z-20 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-none sm:text-sm h-32 overflow-y-auto"
>
<ListboxOption v-if="calendarTypes.length == 0" disabled as="template">
<li :class="['relative cursor-default select-none py-2 pl-10 pr-4']">
<span :class="['font-normal', 'block truncate']">keine Auswahl vorhanden</span>
</li>
</ListboxOption>
<ListboxOption
v-slot="{ active, selected }"
v-for="type in calendarTypes"
:key="type.id"
:value="type"
as="template"
>
<li
:class="[
active ? 'bg-red-200 text-amber-900' : 'text-gray-900',
'relative cursor-default select-none py-2 pl-10 pr-4',
]"
>
<span :class="[selected ? 'font-medium' : 'font-normal', 'block truncate']">{{ type.type }}</span>
<span v-if="selected" class="absolute inset-y-0 left-0 flex items-center pl-3 text-primary">
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
</div>
<div>
<label for="title">Titel</label>
<input type="text" id="title" required />
</div>
<div>
<label for="content">Beschreibung (optional)</label>
<textarea id="content" class="h-18"></textarea>
</div>
<div class="flex flex-row gap-2 items-center">
<input
type="checkbox"
id="allDay"
:checked="data.allDay"
@change="(e) => (allDay = (e.target as HTMLInputElement).checked)"
/>
<label for="allDay">ganztägig</label>
</div>
<div v-if="!allDay" class="flex flex-row gap-2">
<div class="w-full">
<label for="starttime">Startzeit</label>
<input type="datetime-local" id="starttime" required :value="data.start.split(':00+')[0]" />
</div>
<div class="w-full">
<label for="endtime">Endzeit</label>
<input type="datetime-local" id="endtime" required :value="data.end.split(':00+')[0]" />
</div>
</div>
<div v-else class="flex flex-row gap-2">
<div class="w-full">
<label for="startdate">Startdatum</label>
<input
type="date"
id="startdate"
required
:value="data.start"
@change="($event) => ($refs.enddate.max = ($event.target as HTMLInputElement).value)"
/>
</div>
<div class="w-full">
<label for="enddate">Enddatum</label>
<input
ref="enddate"
type="date"
id="enddate"
required
:value="decrementEndDate(data.end)"
:min="data.start"
/>
</div>
</div>
<div>
<label for="location">Ort (optional)</label>
<input type="text" id="location" />
</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 && status != 'loading' && status?.status != 'failed'"
>
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 { useCalendarStore } from "@/stores/admin/calendar";
import type { CreateCalendarViewModel } from "@/viewmodels/admin/calendar.models";
import { Listbox, ListboxButton, ListboxOptions, ListboxOption, ListboxLabel } from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { useCalendarTypeStore } from "@/stores/admin/calendarType";
import type { CalendarTypeViewModel } from "@/viewmodels/admin/calendarType.models";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
status: null as null | "loading" | { status: "success" | "failed"; reason?: string },
timeout: undefined as any,
allDay: false,
selectedType: undefined as undefined | CalendarTypeViewModel,
};
},
computed: {
...mapState(useModalStore, ["data"]),
...mapState(useCalendarTypeStore, ["calendarTypes"]),
},
mounted() {
this.allDay = this.data.allDay;
this.fetchCalendarTypes();
},
beforeUnmount() {
try {
clearTimeout(this.timeout);
} catch (error) {}
},
methods: {
...mapActions(useModalStore, ["closeModal"]),
...mapActions(useCalendarStore, ["createCalendar"]),
...mapActions(useCalendarTypeStore, ["fetchCalendarTypes"]),
triggerCreate(e: any) {
if (!this.selectedType) return;
let formData = e.target.elements;
let createCalendar: CreateCalendarViewModel = {
typeId: this.selectedType.id,
starttime: this.allDay
? new Date(new Date(formData.startdate.value).setHours(0, 0, 0, 0))
: formData.starttime.value,
endtime: this.allDay
? new Date(new Date(formData.enddate.value).setHours(23, 59, 59, 999))
: formData.endtime.value,
title: formData.title.value,
content: formData.content.value,
location: formData.location.value,
allDay: this.allDay,
};
this.createCalendar(createCalendar)
.then(() => {
this.status = { status: "success" };
this.timeout = setTimeout(() => {
this.closeModal();
}, 1500);
})
.catch(() => {
this.status = { status: "failed" };
});
},
decrementEndDate(utcDateString: string) {
const localDate = new Date(utcDateString);
const year = localDate.getFullYear();
const month = String(localDate.getMonth() + 1).padStart(2, "0");
const day = String(localDate.getDate() - 1).padStart(2, "0");
return `${year}-${month}-${day}`;
},
},
});
</script>

View file

@ -0,0 +1,94 @@
<template>
<div class="w-full md:max-w-md">
<div class="flex flex-col items-center">
<p class="text-xl font-medium">Termin {{ calendar?.title }} löschen?</p>
</div>
<br />
<p class="text-center">
{{
new Date(calendar?.starttime ?? "").toLocaleString("de-De", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}}
bis
{{
new Date(calendar?.endtime ?? "").toLocaleString("de-De", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}}
</p>
<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 { useCalendarStore } from "@/stores/admin/calendar";
</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(useCalendarStore, ["calendars"]),
calendar() {
return this.calendars.find((r) => r.id == this.data);
},
},
methods: {
...mapActions(useModalStore, ["closeModal"]),
...mapActions(useCalendarStore, ["deleteCalendar"]),
triggerDelete() {
this.deleteCalendar(this.data)
.then(() => {
this.status = { status: "success" };
this.timeout = setTimeout(() => {
this.closeModal();
}, 1500);
})
.catch(() => {
this.status = { status: "failed" };
});
},
},
});
</script>

View file

@ -0,0 +1,302 @@
<template>
<div class="relative w-full md:max-w-md">
<TrashIcon class="absolute top-3 right-3 w-5 h-5 cursor-pointer" @click="deleteCalendar" />
<div class="flex flex-col items-center">
<p class="text-xl font-medium">Termintyp erstellen</p>
</div>
<br />
<Spinner v-if="loading == 'loading'" class="mx-auto" />
<p v-else-if="loading == 'failed'">laden fehlgeschlagen</p>
<form v-else-if="calendar != null" class="flex flex-col gap-4 py-2" @submit.prevent="triggerUpdate">
<div>
<Listbox v-model="calendar.type" name="type">
<ListboxLabel>Termintyp</ListboxLabel>
<div class="relative mt-1">
<ListboxButton
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"
>
<span class="block truncate w-full text-start">
{{
calendarTypes.length != 0 ? (calendar.type?.type ?? "bitte auswählen") : "keine Auswahl vorhanden"
}}</span
>
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon class="h-5 w-5 text-gray-400" aria-hidden="true" />
</span>
</ListboxButton>
<transition
leave-active-class="transition duration-100 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute mt-1 max-h-60 z-20 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-none sm:text-sm h-32 overflow-y-auto"
>
<ListboxOption v-if="calendarTypes.length == 0" disabled as="template">
<li :class="['relative cursor-default select-none py-2 pl-10 pr-4']">
<span :class="['font-normal', 'block truncate']">keine Auswahl vorhanden</span>
</li>
</ListboxOption>
<ListboxOption
v-slot="{ active, selected }"
v-for="type in calendarTypes"
:key="type.id"
:value="type"
as="template"
>
<li
:class="[
active ? 'bg-red-200 text-amber-900' : 'text-gray-900',
'relative cursor-default select-none py-2 pl-10 pr-4',
]"
>
<span :class="[selected ? 'font-medium' : 'font-normal', 'block truncate']">{{ type.type }}</span>
<span v-if="selected" class="absolute inset-y-0 left-0 flex items-center pl-3 text-primary">
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
</div>
<div>
<label for="title">Titel</label>
<input type="text" id="title" required v-model="calendar.title" />
</div>
<div>
<label for="content">Beschreibung (optional)</label>
<textarea id="content" class="h-18" v-model="calendar.content"></textarea>
</div>
<div class="flex flex-row gap-2 items-center">
<input type="checkbox" id="allDay" v-model="calendar.allDay" />
<label for="allDay">ganztägig</label>
</div>
<div v-if="calendar.allDay == false" class="flex flex-row gap-2">
<div class="w-full">
<label for="starttime">Startzeit</label>
<input
type="datetime-local"
id="starttime"
required
:value="formatForDateTimeLocalInput(calendar.starttime)"
@change="
($event) => {
calendar!.starttime = new Date(($event.target as HTMLInputElement).value).toISOString();
$refs.endtime.min = formatForDateTimeLocalInput(($event.target as HTMLInputElement).value);
}
"
/>
</div>
<div class="w-full">
<label for="endtime">Endzeit</label>
<input
ref="endtime"
type="datetime-local"
id="endtime"
required
:value="formatForDateTimeLocalInput(calendar.endtime)"
:min="formatForDateTimeLocalInput(calendar.starttime)"
@change="
($event) => {
calendar!.endtime = new Date(($event.target as HTMLInputElement).value).toISOString();
}
"
/>
</div>
</div>
<div v-else class="flex flex-row gap-2">
<div class="w-full">
<label for="startdate">Startdatum</label>
<input
type="date"
id="startdate"
required
:value="formatForDateInput(calendar.starttime)"
@change="
($event) => {
calendar!.starttime = new Date(($event.target as HTMLInputElement).value).toISOString();
$refs.enddate.min = formatForDateInput(($event.target as HTMLInputElement).value);
}
"
/>
</div>
<div class="w-full">
<label for="enddate">Enddatum</label>
<input
ref="enddate"
type="date"
id="enddate"
required
:min="formatForDateInput(calendar.starttime)"
:value="formatForDateInput(calendar.endtime)"
@change="
($event) => {
calendar!.endtime = new Date(($event.target as HTMLInputElement).value).toISOString();
}
"
/>
</div>
</div>
<div>
<label for="location">Ort (optional)</label>
<input type="text" id="location" v-model="calendar.location" />
</div>
<div class="flex flex-row gap-2">
<button primary-outline type="reset" class="!w-fit" :disabled="canSaveOrReset" @click="resetForm">
verwerfen
</button>
<button primary type="submit" class="!w-fit" :disabled="status == 'loading' || canSaveOrReset">
speichern
</button>
<Spinner v-if="status == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="status?.status == 'success'" />
<FailureXMark v-else-if="status?.status == 'failed'" />
</div>
</form>
<div class="flex flex-row justify-end">
<div class="flex flex-row gap-4 py-2">
<button
primary-outline
@click="closeModal"
:disabled="status != null && status != 'loading' && status?.status != 'failed'"
>
abbrechen / schließen
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineAsyncComponent, defineComponent, markRaw } 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 { useCalendarStore } from "@/stores/admin/calendar";
import type {
CalendarViewModel,
CreateCalendarViewModel,
UpdateCalendarViewModel,
} from "@/viewmodels/admin/calendar.models";
import { Listbox, ListboxButton, ListboxOptions, ListboxOption, ListboxLabel } from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon, TrashIcon } from "@heroicons/vue/20/solid";
import { useCalendarTypeStore } from "@/stores/admin/calendarType";
import type { CalendarTypeViewModel } from "@/viewmodels/admin/calendarType.models";
import cloneDeep from "lodash.clonedeep";
import isEqual from "lodash.isEqual";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
loading: "loading" as "loading" | "fetched" | "failed",
status: null as null | "loading" | { status: "success" | "failed"; reason?: string },
origin: null as null | CalendarViewModel,
calendar: null as null | CalendarViewModel,
timeout: undefined as any,
};
},
computed: {
...mapState(useModalStore, ["data"]),
...mapState(useCalendarStore, ["calendars"]),
...mapState(useCalendarTypeStore, ["calendarTypes"]),
canSaveOrReset(): boolean {
return isEqual(this.origin, this.calendar);
},
},
mounted() {
this.fetchCalendarTypes();
this.fetchItem();
},
beforeUnmount() {
try {
clearTimeout(this.timeout);
} catch (error) {}
},
methods: {
...mapActions(useModalStore, ["closeModal", "openModal"]),
...mapActions(useCalendarStore, ["updateCalendar", "fetchCalendarById"]),
...mapActions(useCalendarTypeStore, ["fetchCalendarTypes"]),
resetForm() {
this.calendar = cloneDeep(this.origin);
},
fetchItem() {
this.fetchCalendarById(this.data ?? "")
.then((result) => {
this.calendar = result.data;
this.origin = cloneDeep(result.data);
this.loading = "fetched";
})
.catch((err) => {
this.loading = "failed";
});
},
triggerUpdate(e: any) {
if (this.calendar == null) return;
let formData = e.target.elements;
let updateCalendar: UpdateCalendarViewModel = {
id: this.calendar.id,
typeId: this.calendar.type.id,
starttime: this.calendar.allDay
? new Date(new Date(formData.startdate.value).setHours(0, 0, 0, 0)).toISOString()
: new Date(formData.starttime.value).toISOString(),
endtime: this.calendar.allDay
? new Date(new Date(formData.enddate.value).setHours(23, 59, 59, 999)).toISOString()
: new Date(formData.endtime.value).toISOString(),
title: formData.title.value,
content: formData.content.value,
location: formData.location.value,
allDay: this.calendar.allDay,
};
this.updateCalendar(updateCalendar)
.then(() => {
this.fetchItem();
this.status = { status: "success" };
})
.catch(() => {
this.status = { status: "failed" };
})
.finally(() => {
this.timeout = setTimeout(() => {
this.status = null;
}, 2000);
});
},
deleteCalendar() {
if (this.origin == null) return;
this.openModal(
markRaw(defineAsyncComponent(() => import("@/components/admin/club/calendar/DeleteCalendarModal.vue"))),
this.origin.id
);
},
formatForDateTimeLocalInput(utcDateString: string) {
const localDate = new Date(utcDateString);
const year = localDate.getFullYear();
const month = String(localDate.getMonth() + 1).padStart(2, "0");
const day = String(localDate.getDate()).padStart(2, "0");
const hours = String(localDate.getHours()).padStart(2, "0");
const minutes = String(localDate.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`;
},
formatForDateInput(utcDateString: string) {
const localDate = new Date(utcDateString);
const year = localDate.getFullYear();
const month = String(localDate.getMonth() + 1).padStart(2, "0");
const day = String(localDate.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
},
},
});
</script>

View file

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

View file

@ -0,0 +1,90 @@
<template>
<div class="w-full md:max-w-md">
<div class="flex flex-col items-center">
<p class="text-xl font-medium">Termintyp erstellen</p>
</div>
<br />
<form class="flex flex-col gap-4 py-2" @submit.prevent="triggerCreate">
<div>
<label for="type">Bezeichnung</label>
<input type="text" id="type" required />
</div>
<div class="flex flex-row items-center gap-2">
<input type="color" id="color" required class="!px-1 !py-0 !w-10" />
<label for="color">Farbe</label>
</div>
<div class="flex flex-row items-center gap-2">
<input type="checkbox" id="nscdr" />
<label for="nscdr">Standard Kalender Auslieferung (optional)</label>
</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 && status?.status != 'failed'">
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 { useCalendarTypeStore } from "@/stores/admin/calendarType";
import type { CreateCalendarTypeViewModel } from "@/viewmodels/admin/calendarType.models";
import { Listbox, ListboxButton, ListboxOptions, ListboxOption, ListboxLabel } from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import type { CalendarFieldType } from "@/types/fieldTypes";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
status: null as null | "loading" | { status: "success" | "failed"; reason?: string },
timeout: undefined as any,
selectedFields: [] as Array<CalendarFieldType>,
};
},
beforeUnmount() {
try {
clearTimeout(this.timeout);
} catch (error) {}
},
methods: {
...mapActions(useModalStore, ["closeModal"]),
...mapActions(useCalendarTypeStore, ["createCalendarType"]),
triggerCreate(e: any) {
let formData = e.target.elements;
let createCalendarType: CreateCalendarTypeViewModel = {
type: formData.type.value,
color: formData.color.value,
nscdr: formData.nscdr.checked,
};
this.createCalendarType(createCalendarType)
.then(() => {
this.status = { status: "success" };
this.timeout = setTimeout(() => {
this.closeModal();
}, 1500);
})
.catch(() => {
this.status = { status: "failed" };
});
},
},
});
</script>

View file

@ -0,0 +1,72 @@
<template>
<div class="w-full md:max-w-md">
<div class="flex flex-col items-center">
<p class="text-xl font-medium">Termintyp {{ calendarType?.type }} 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 { useCalendarTypeStore } from "@/stores/admin/calendarType";
</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(useCalendarTypeStore, ["calendarTypes"]),
calendarType() {
return this.calendarTypes.find((r) => r.id == this.data);
},
},
methods: {
...mapActions(useModalStore, ["closeModal"]),
...mapActions(useCalendarTypeStore, ["deleteCalendarType"]),
triggerDelete() {
this.deleteCalendarType(this.data)
.then(() => {
this.status = { status: "success" };
this.timeout = setTimeout(() => {
this.closeModal();
}, 1500);
})
.catch(() => {
this.status = { status: "failed" };
});
},
},
});
</script>

View file

@ -1,7 +1,10 @@
<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>{{ user.firstname }} {{ user.lastname }} <small v-if="user.permissions_total.admin">(Admin)</small></p>
<p>
{{ user.firstname }} {{ user.lastname }} <small v-if="user.permissions_total.admin">(Admin)</small
><small v-if="isOwner"> (Owner)</small>
</p>
<div class="flex flex-row">
<RouterLink
v-if="can('update', 'user', 'user')"
@ -62,8 +65,7 @@ export default defineComponent({
user: { type: Object as PropType<UserViewModel>, default: {} },
},
computed: {
...mapState(useAbilityStore, ["can"]),
...mapState(useAbilityStore, ["can"]),
...mapState(useAbilityStore, ["can", "isOwner"]),
},
methods: {
...mapActions(useModalStore, ["openModal"]),

View file

@ -56,7 +56,7 @@ body {
@apply w-full h-full overflow-hidden flex flex-col;
}
button:not([headlessui]):not([id*="headlessui"]):not([class*="headlessui"]):not([class*="ql"] *),
button:not([headlessui]):not([id*="headlessui"]):not([class*="headlessui"]):not([class*="ql"] *):not([class*="fc"]),
a[button] {
@apply relative box-border h-10 w-full flex justify-center py-2 px-4 text-sm font-medium rounded-md focus:outline-none focus:ring-0;
}
@ -118,3 +118,13 @@ summary > svg {
summary::-webkit-details-marker {
display: none;
}
.fc-button-primary {
@apply !bg-primary !border-primary !outline-none !ring-0 hover:!bg-red-700 hover:!border-red-700;
}
.fc-button-active {
@apply !bg-red-500 !border-red-500;
}
.fc-toolbar {
@apply flex-wrap;
}

View file

@ -12,6 +12,7 @@ export type Payload = JwtPayload & {
firstname: string;
lastname: string;
mail: string;
isOwner: boolean;
permissions: PermissionObject;
};
@ -66,16 +67,16 @@ export async function isAuthenticatedPromise(): Promise<Payload> {
});
}
var { firstname, lastname, mail, username, permissions } = decoded;
var { firstname, lastname, mail, username, permissions, isOwner } = decoded;
if (Object.keys(permissions ?? {}).length === 0) {
if (Object.keys(permissions ?? {}).length === 0 && !isOwner) {
auth.setFailed();
reject("nopermissions");
}
auth.setSuccess();
account.setAccountData(firstname, lastname, mail, username);
ability.setAbility(permissions);
ability.setAbility(permissions, isOwner);
resolve(decoded);
}
});

View file

@ -136,7 +136,7 @@ const router = createRouter({
{
path: "calendar",
name: "admin-club-calendar",
component: () => import("@/views/admin/members/Overview.vue"),
component: () => import("@/views/admin/club/calendar/Calendar.vue"),
meta: { type: "read", section: "club", module: "calendar" },
beforeEnter: [abilityAndNavUpdate],
},
@ -267,13 +267,6 @@ const router = createRouter({
},
],
},
{
path: "executive-position",
name: "admin-settings-executive_position",
component: () => import("@/views/admin/settings/ExecutivePosition.vue"),
meta: { type: "read", section: "settings", module: "executive_position" },
beforeEnter: [abilityAndNavUpdate],
},
{
path: "executive-position",
name: "admin-settings-executive_position-route",
@ -285,6 +278,8 @@ const router = createRouter({
path: "",
name: "admin-settings-executive_position",
component: () => import("@/views/admin/settings/ExecutivePosition.vue"),
meta: { type: "read", section: "settings", module: "executive_position" },
beforeEnter: [abilityAndNavUpdate],
},
{
path: ":id/edit",
@ -340,6 +335,28 @@ const router = createRouter({
},
],
},
{
path: "calendar-type",
name: "admin-settings-calendar_type-route",
component: () => import("@/views/RouterView.vue"),
meta: { type: "read", section: "settings", module: "calendar_type" },
beforeEnter: [abilityAndNavUpdate],
children: [
{
path: "",
name: "admin-settings-calendar_type",
component: () => import("@/views/admin/settings/CalendarType.vue"),
},
{
path: ":id/edit",
name: "admin-settings-calendar_type-edit",
component: () => import("@/views/admin/settings/CalendarTypeEdit.vue"),
meta: { type: "update", section: "settings", module: "calendar_type" },
beforeEnter: [abilityAndNavUpdate],
props: true,
},
],
},
],
},
{

View file

@ -5,6 +5,7 @@ export const useAbilityStore = defineStore("ability", {
state: () => {
return {
permissions: {} as PermissionObject,
isOwner: false as boolean,
};
},
getters: {
@ -12,6 +13,7 @@ export const useAbilityStore = defineStore("ability", {
(state) =>
(type: PermissionType | "admin", section: PermissionSection, module?: PermissionModule): boolean => {
const permissions = state.permissions;
if (state.isOwner) return true;
if (type == "admin") return permissions?.admin ?? false;
if (permissions?.admin) return true;
if (
@ -30,6 +32,7 @@ export const useAbilityStore = defineStore("ability", {
(state) =>
(type: PermissionType | "admin", section: PermissionSection): boolean => {
const permissions = state.permissions;
if (state.isOwner) return true;
if (type == "admin") return permissions?.admin ?? false;
if (permissions?.admin) return true;
if (
@ -48,6 +51,7 @@ export const useAbilityStore = defineStore("ability", {
section: PermissionSection,
module?: PermissionModule
): boolean => {
// ignores ownership
if (type == "admin") return permissions?.admin ?? false;
if (permissions?.admin) return true;
if (
@ -64,8 +68,9 @@ export const useAbilityStore = defineStore("ability", {
},
},
actions: {
setAbility(permissions: PermissionObject) {
setAbility(permissions: PermissionObject, isOwner: boolean) {
this.permissions = permissions;
this.isOwner = isOwner;
},
},
});

View file

@ -0,0 +1,77 @@
import { defineStore } from "pinia";
import type {
CreateCalendarViewModel,
UpdateCalendarViewModel,
CalendarViewModel,
} from "@/viewmodels/admin/calendar.models";
import { http } from "@/serverCom";
import type { AxiosResponse } from "axios";
export const useCalendarStore = defineStore("calendar", {
state: () => {
return {
calendars: [] as Array<CalendarViewModel>,
loading: "loading" as "loading" | "fetched" | "failed",
loadingFields: "loading" as "loading" | "fetched" | "failed",
};
},
getters: {
formattedItems: (state) => {
return state.calendars.map((c) => ({
id: c.id,
title: c.title,
start: c.starttime,
end: c.endtime,
backgroundColor: c.type.color,
}));
},
},
actions: {
fetchCalendars() {
this.loading = "loading";
http
.get("/admin/calendar/items")
.then((result) => {
this.calendars = result.data;
this.loading = "fetched";
})
.catch((err) => {
this.loading = "failed";
});
},
fetchCalendarById(id: string): Promise<AxiosResponse<any, any>> {
return http.get(`/admin/calendar/item/${id}`);
},
async createCalendar(calendar: CreateCalendarViewModel): Promise<AxiosResponse<any, any>> {
const result = await http.post(`/admin/calendar/item`, {
starttime: calendar.starttime,
endtime: calendar.endtime,
title: calendar.title,
content: calendar.content,
allDay: calendar.allDay,
location: calendar.location,
typeId: calendar.typeId,
});
this.fetchCalendars();
return result;
},
async updateCalendar(calendar: UpdateCalendarViewModel): Promise<AxiosResponse<any, any>> {
const result = await http.patch(`/admin/calendar/item/${calendar.id}`, {
starttime: calendar.starttime,
endtime: calendar.endtime,
title: calendar.title,
content: calendar.content,
allDay: calendar.allDay,
location: calendar.location,
typeId: calendar.typeId,
});
this.fetchCalendars();
return result;
},
async deleteCalendar(calendar: number): Promise<AxiosResponse<any, any>> {
const result = await http.delete(`/admin/calendar/item/${calendar}`);
this.fetchCalendars();
return result;
},
},
});

View file

@ -0,0 +1,58 @@
import { defineStore } from "pinia";
import type {
CreateCalendarTypeViewModel,
UpdateCalendarTypeViewModel,
CalendarTypeViewModel,
} from "@/viewmodels/admin/calendarType.models";
import { http } from "@/serverCom";
import type { AxiosResponse } from "axios";
export const useCalendarTypeStore = defineStore("calendarType", {
state: () => {
return {
calendarTypes: [] as Array<CalendarTypeViewModel>,
loading: "loading" as "loading" | "fetched" | "failed",
loadingFields: "loading" as "loading" | "fetched" | "failed",
};
},
actions: {
fetchCalendarTypes() {
this.loading = "loading";
http
.get("/admin/calendar/types")
.then((result) => {
this.calendarTypes = result.data;
this.loading = "fetched";
})
.catch((err) => {
this.loading = "failed";
});
},
fetchCalendarTypeById(id: number): Promise<AxiosResponse<any, any>> {
return http.get(`/admin/calendar/type/${id}`);
},
async createCalendarType(calendarType: CreateCalendarTypeViewModel): Promise<AxiosResponse<any, any>> {
const result = await http.post(`/admin/calendar/type`, {
type: calendarType.type,
nscdr: calendarType.nscdr,
color: calendarType.color,
});
this.fetchCalendarTypes();
return result;
},
async updateActiveCalendarType(calendarType: UpdateCalendarTypeViewModel): Promise<AxiosResponse<any, any>> {
const result = await http.patch(`/admin/calendar/type/${calendarType.id}`, {
type: calendarType.type,
nscdr: calendarType.nscdr,
color: calendarType.color,
});
this.fetchCalendarTypes();
return result;
},
async deleteCalendarType(calendarType: number): Promise<AxiosResponse<any, any>> {
const result = await http.delete(`/admin/calendar/type/${calendarType}`);
this.fetchCalendarTypes();
return result;
},
},
});

View file

@ -87,9 +87,9 @@ export const useNavigationStore = defineStore("navigation", {
mainTitle: "Verein",
main: [
...(abilityStore.can("read", "club", "member") ? [{ key: "member", title: "Mitglieder" }] : []),
...(abilityStore.can("read", "club", "calendar") ? [{ key: "calendar", title: "Termine" }] : []),
...(abilityStore.can("read", "club", "newsletter") ? [{ key: "newsletter", title: "Newsletter" }] : []),
...(abilityStore.can("read", "club", "calendar") ? [{ key: "calendar", title: "Kalender" }] : []),
...(abilityStore.can("read", "club", "protocoll") ? [{ key: "protocol", title: "Protokolle" }] : []),
...(abilityStore.can("read", "club", "newsletter") ? [{ key: "newsletter", title: "Newsletter" }] : []),
],
},
settings: {
@ -108,6 +108,9 @@ export const useNavigationStore = defineStore("navigation", {
...(abilityStore.can("read", "settings", "membership_status")
? [{ key: "membership_status", title: "Mitgliedsstatus" }]
: []),
...(abilityStore.can("read", "settings", "calendar_type")
? [{ key: "calendar_type", title: "Terminarten" }]
: []),
],
},
user: {

View file

@ -10,6 +10,7 @@ export type PermissionModule =
| "executive_position"
| "communication"
| "membership_status"
| "calendar_type"
| "user"
| "role";
@ -45,12 +46,13 @@ export const permissionModules: Array<PermissionModule> = [
"executive_position",
"communication",
"membership_status",
"calendar_type",
"user",
"role",
];
export const permissionTypes: Array<PermissionType> = ["read", "create", "update", "delete"];
export const sectionsAndModules: SectionsAndModulesObject = {
club: ["member", "calendar", "newsletter", "protocol"],
settings: ["qualification", "award", "executive_position", "communication", "membership_status"],
settings: ["qualification", "award", "executive_position", "communication", "membership_status", "calendar_type"],
user: ["user", "role"],
};

View file

@ -0,0 +1,35 @@
import type { CalendarTypeViewModel } from "./calendarType.models";
export interface CalendarViewModel {
id: string;
starttime: string;
endtime: string;
title: string;
content: string;
location: string;
allDay: boolean;
createdAt: Date;
updatedAt: Date;
type: CalendarTypeViewModel;
}
export interface CreateCalendarViewModel {
starttime: string;
endtime: string;
title: string;
content: string;
location: string;
allDay: boolean;
typeId: string;
}
export interface UpdateCalendarViewModel {
id: string;
starttime: string;
endtime: string;
title: string;
content: string;
location: string;
allDay: boolean;
typeId: string;
}

View file

@ -0,0 +1,19 @@
export interface CalendarTypeViewModel {
id: string;
type: string;
nscdr: boolean;
color: string;
}
export interface CreateCalendarTypeViewModel {
type: string;
nscdr: boolean;
color: string;
}
export interface UpdateCalendarTypeViewModel {
id: string;
type: string;
nscdr: boolean;
color: string;
}

View file

@ -7,6 +7,7 @@ export interface UserViewModel {
mail: string;
firstname: string;
lastname: string;
isOwner: boolean;
permissions: PermissionObject;
roles: Array<RoleViewModel>;
permissions_total: PermissionObject;

View file

@ -0,0 +1,140 @@
<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">Kalender</h1>
</div>
</template>
<template #diffMain>
<div class="flex flex-col w-full h-full gap-2 justify-between px-7 overflow-hidden">
<FullCalendar :options="calendarOptions" class="max-h-full h-full" />
</div>
</template>
</MainTemplate>
</template>
<script setup lang="ts">
import { defineComponent, markRaw, defineAsyncComponent } from "vue";
import { mapActions, mapState } from "pinia";
import { useModalStore } from "@/stores/modal";
import MainTemplate from "@/templates/Main.vue";
import FullCalendar from "@fullcalendar/vue3";
import deLocale from "@fullcalendar/core/locales/de";
import dayGridPlugin from "@fullcalendar/daygrid";
import timeGridPlugin from "@fullcalendar/timegrid";
import interactionPlugin from "@fullcalendar/interaction";
import { useCalendarStore } from "../../../../stores/admin/calendar";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {};
},
computed: {
...mapState(useCalendarStore, ["formattedItems"]),
calendarOptions() {
return {
timeZone: "local",
locale: deLocale,
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
initialView: "dayGridMonth",
headerToolbar: {
left: "dayGridMonth,timeGridWeek",
center: "title",
right: "prev,today,next",
},
eventDisplay: "block",
weekends: true,
editable: true,
selectable: true,
selectMirror: false,
dayMaxEvents: true,
weekNumbers: true,
displayEventTime: true,
nowIndicator: true,
weekText: "KW",
allDaySlot: false,
events: this.formattedItems,
select: this.select,
eventClick: this.eventClick,
};
},
},
mounted() {
this.fetchCalendars();
},
methods: {
...mapActions(useModalStore, ["openModal"]),
...mapActions(useCalendarStore, ["fetchCalendars"]),
select(e: any) {
console.log(e);
this.openModal(
markRaw(defineAsyncComponent(() => import("@/components/admin/club/calendar/CreateCalendarModal.vue"))),
{
start: e.startStr,
end: e.endStr,
allDay: e.allDay,
}
);
},
eventClick(e: any) {
this.openModal(
markRaw(defineAsyncComponent(() => import("@/components/admin/club/calendar/UpdateCalendarModal.vue"))),
e.event.id
);
},
},
});
/**
locale: deLocale,
events: this.absencesList.map((x) => ({
id: x.absenceId,
start: x.startDate,
end: x.endDate,
allday: true,
backgroundColor: this.getColorForAbsenceType(x.absenceType),
borderColor: '#ffffff',
title: this.getAbsenceType(x.absenceType) + ' ' + x.fullName,
})),
plugins: [
interactionPlugin,
dayGridPlugin,
timeGridPlugin,
listPlugin,
multiMonthPlugin,
],
initialView: 'dayGridMonth',
eventDisplay: 'block',
weekends: false,
editable: true,
selectable: true,
selectMirror: true,
dayMaxEvents: true,
weekNumbers: true,
displayEventTime: false,
weekText: 'KW',
validRange: { start: '2023-01-01', end: '' },
headerToolbar: {
left: 'today prev,next',
center: 'title',
right: 'listMonth,dayGridMonth,multiMonthYear,customview',
},
views: {
customview: {
type: 'multiMonth',
multiMonthMaxColumns: 1,
duration: { month: 12 },
buttonText: 'grid',
},
},
dateClick: this.handleDateSelect.bind(this),
datesSet: this.handleMonthChange.bind(this),
select: this.handleDateSelect.bind(this),
eventClick: this.handleEventClick.bind(this),
eventsSet: this.handleEvents.bind(this),
};
*/
</script>

View file

@ -0,0 +1,54 @@
<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">Termintyp</h1>
</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">
<CalendarTypeListItem
v-for="calendarType in calendarTypes"
:key="calendarType.id"
:calendarType="calendarType"
/>
</div>
<div class="flex flex-row gap-4">
<button primary class="!w-fit" @click="openCreateModal">Termintyp erstellen</button>
</div>
</div>
</template>
</MainTemplate>
</template>
<script setup lang="ts">
import { defineComponent, defineAsyncComponent, markRaw } from "vue";
import { mapState, mapActions } from "pinia";
import MainTemplate from "@/templates/Main.vue";
import { useCalendarTypeStore } from "@/stores/admin/calendarType";
import CalendarTypeListItem from "@/components/admin/settings/calendarType/CalendarTypeListItem.vue";
import { useModalStore } from "@/stores/modal";
</script>
<script lang="ts">
export default defineComponent({
computed: {
...mapState(useCalendarTypeStore, ["calendarTypes"]),
},
mounted() {
this.fetchCalendarTypes();
},
methods: {
...mapActions(useCalendarTypeStore, ["fetchCalendarTypes"]),
...mapActions(useModalStore, ["openModal"]),
openCreateModal() {
this.openModal(
markRaw(
defineAsyncComponent(() => import("@/components/admin/settings/calendarType/CreateCalendarTypeModal.vue"))
)
);
},
},
});
</script>

View file

@ -0,0 +1,132 @@
<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">Termintyp {{ origin?.type }} - 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-else-if="calendarType != null"
class="flex flex-col gap-4 py-2 w-full max-w-xl mx-auto"
@submit.prevent="triggerUpdate"
>
<div>
<label for="type">Bezeichnung</label>
<input type="text" id="type" required v-model="calendarType.type" />
</div>
<div class="flex flex-row items-center gap-2">
<input type="color" id="color" required v-model="calendarType.color" class="!px-1 !py-0 !w-10" />
<label for="color">Farbe</label>
</div>
<div class="flex flex-row items-center gap-2">
<input type="checkbox" id="nscdr" v-model="calendarType.nscdr" />
<label for="nscdr">Standard Kalender Auslieferung (optional)</label>
</div>
<div class="flex flex-row justify-end gap-2">
<button primary-outline type="reset" class="!w-fit" :disabled="canSaveOrReset" @click="resetForm">
verwerfen
</button>
<button primary type="submit" class="!w-fit" :disabled="status == 'loading' || canSaveOrReset">
speichern
</button>
<Spinner v-if="status == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="status?.status == 'success'" />
<FailureXMark v-else-if="status?.status == 'failed'" />
</div>
</form>
</template>
</MainTemplate>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapState, mapActions } from "pinia";
import MainTemplate from "@/templates/Main.vue";
import { useCalendarTypeStore } from "@/stores/admin/calendarType";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { RouterLink } from "vue-router";
import type { CalendarTypeViewModel, UpdateCalendarTypeViewModel } from "@/viewmodels/admin/calendarType.models";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import cloneDeep from "lodash.clonedeep";
import isEqual from "lodash.isEqual";
</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 | CalendarTypeViewModel,
calendarType: null as null | CalendarTypeViewModel,
timeout: null as any,
};
},
computed: {
canSaveOrReset(): boolean {
return isEqual(this.origin, this.calendarType);
},
},
mounted() {
this.fetchItem();
},
beforeUnmount() {
try {
clearTimeout(this.timeout);
} catch (error) {}
},
methods: {
...mapActions(useCalendarTypeStore, ["fetchCalendarTypeById", "updateActiveCalendarType"]),
resetForm() {
this.calendarType = cloneDeep(this.origin);
},
fetchItem() {
this.fetchCalendarTypeById(parseInt(this.id ?? ""))
.then((result) => {
this.calendarType = result.data;
this.origin = cloneDeep(result.data);
this.loading = "fetched";
})
.catch((err) => {
this.loading = "failed";
});
},
triggerUpdate(e: any) {
if (this.calendarType == null) return;
let formData = e.target.elements;
let updateCalendarType: UpdateCalendarTypeViewModel = {
id: this.calendarType.id,
type: formData.type.value,
color: formData.color.value,
nscdr: formData.nscdr.checked,
};
this.status = "loading";
this.updateActiveCalendarType(updateCalendarType)
.then(() => {
this.fetchItem();
this.status = { status: "success" };
})
.catch((err) => {
this.status = { status: "failed" };
})
.finally(() => {
this.timeout = setTimeout(() => {
this.status = null;
}, 2000);
});
},
},
});
</script>

View file

@ -74,11 +74,6 @@ export default defineComponent({
props: {
id: String,
},
watch: {
origin() {
this.assigned = this.origin?.roles.map((r) => r.id) ?? [];
},
},
data() {
return {
loading: "loading" as "loading" | "fetched" | "failed",
@ -97,7 +92,7 @@ export default defineComponent({
return this.roles.filter((r) => !this.assigned.includes(r.id));
},
canSaveOrReset(): boolean {
return isEqual(this.origin?.roles, this.assigned);
return isEqual(this.origin?.roles.map((r) => r.id) ?? [], this.assigned);
},
},
mounted() {
@ -118,8 +113,8 @@ export default defineComponent({
fetchItem() {
this.fetchUserById(parseInt(this.id ?? ""))
.then((result) => {
this.assigned = this.origin?.roles.map((r) => r.id) ?? [];
this.origin = cloneDeep(result.data);
this.assigned = this.origin?.roles.map((r) => r.id) ?? [];
this.loading = "fetched";
})
.catch((err) => {