Merge pull request '#2-protocol' (#6) from #2-protocol into main

Reviewed-on: Ehrenamt/member-administration-ui#6
This commit is contained in:
Julian Krauser 2024-10-29 14:43:28 +00:00
commit 0f6c699f10
38 changed files with 2228 additions and 64 deletions

192
package-lock.json generated
View file

@ -11,10 +11,12 @@
"dependencies": {
"@headlessui/vue": "^1.7.13",
"@heroicons/vue": "^2.1.5",
"@types/lodash.isequal": "^4.5.8",
"@vueup/vue-quill": "^1.2.0",
"axios": "^0.26.1",
"jwt-decode": "^4.0.0",
"lodash.clonedeep": "^4.5.0",
"lodash.difference": "^4.5.0",
"lodash.differencewith": "^4.5.0",
"lodash.isequal": "^4.5.0",
"nprogress": "^0.2.0",
"pdf-dist": "^1.0.0",
@ -31,6 +33,9 @@
"@tsconfig/node20": "^20.1.4",
"@types/eslint": "~9.6.0",
"@types/lodash.clonedeep": "^4.5.9",
"@types/lodash.difference": "^4.5.9",
"@types/lodash.differencewith": "^4.5.9",
"@types/lodash.isequal": "^4.5.8",
"@types/node": "^20.14.5",
"@types/nprogress": "^0.2.0",
"@types/qrcode": "^1.5.5",
@ -3070,6 +3075,7 @@
"version": "4.17.7",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz",
"integrity": "sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/lodash.clonedeep": {
@ -3082,10 +3088,31 @@
"@types/lodash": "*"
}
},
"node_modules/@types/lodash.difference": {
"version": "4.5.9",
"resolved": "https://registry.npmjs.org/@types/lodash.difference/-/lodash.difference-4.5.9.tgz",
"integrity": "sha512-MNlajcjtwzLpXk+cw38UkBvEXJNEPhULgS8A4EHwtUwT7f7yFH/SFKD0iw5Rfilwh60yJIgFo0vsMr7xsa5+aw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/lodash": "*"
}
},
"node_modules/@types/lodash.differencewith": {
"version": "4.5.9",
"resolved": "https://registry.npmjs.org/@types/lodash.differencewith/-/lodash.differencewith-4.5.9.tgz",
"integrity": "sha512-nMaREKoe7J3WvnsO7HDRxvnPT3mWmZD3EAECpy7gBGJ6S5nQ66uVlkRe+ZXs6261ZNb2fH9Ny4oUUiSOCmTnLw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/lodash": "*"
}
},
"node_modules/@types/lodash.isequal": {
"version": "4.5.8",
"resolved": "https://registry.npmjs.org/@types/lodash.isequal/-/lodash.isequal-4.5.8.tgz",
"integrity": "sha512-uput6pg4E/tj2LGxCZo9+y27JNyB2OZuuI/T5F+ylVDYuqICLG2/ktjxx0v6GvVntAf8TvEzeQLcV0ffRirXuA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/lodash": "*"
@ -3680,6 +3707,19 @@
"integrity": "sha512-VcZK7MvpjuTPx2w6blwnwZAu5/LgBUtejFOi3pPGQFXQN5Ela03FUtd2Qtg4yWGGissVL0dr6Ro1LfOFh+PCuQ==",
"dev": true
},
"node_modules/@vueup/vue-quill": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@vueup/vue-quill/-/vue-quill-1.2.0.tgz",
"integrity": "sha512-kd5QPSHMDpycklojPXno2Kw2JSiKMYduKYQckTm1RJoVDA557MnyUXgcuuDpry4HY/Rny9nGNcK+m3AHk94wag==",
"license": "MIT",
"dependencies": {
"quill": "^1.3.7",
"quill-delta": "^4.2.2"
},
"peerDependencies": {
"vue": "^3.2.41"
}
},
"node_modules/acorn": {
"version": "8.12.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
@ -4311,6 +4351,15 @@
"wrap-ansi": "^6.2.0"
}
},
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
@ -4589,6 +4638,26 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-equal": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz",
"integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==",
"license": "MIT",
"dependencies": {
"is-arguments": "^1.1.1",
"is-date-object": "^1.0.5",
"is-regex": "^1.1.4",
"object-is": "^1.1.5",
"object-keys": "^1.1.1",
"regexp.prototype.flags": "^1.5.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
@ -4673,7 +4742,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
"dev": true,
"dependencies": {
"define-data-property": "^1.0.1",
"has-property-descriptors": "^1.0.0",
@ -5237,6 +5305,12 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
"integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==",
"license": "MIT"
},
"node_modules/execa": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
@ -5269,6 +5343,12 @@
"node": ">=6"
}
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -5550,7 +5630,6 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
"integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
@ -5815,7 +5894,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"dependencies": {
"has-symbols": "^1.0.3"
},
@ -5976,6 +6054,22 @@
"node": ">= 0.4"
}
},
"node_modules/is-arguments": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz",
"integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.2",
"has-tostringtag": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-array-buffer": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz",
@ -6099,7 +6193,6 @@
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz",
"integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==",
"dev": true,
"dependencies": {
"has-tostringtag": "^1.0.0"
},
@ -6236,7 +6329,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
"integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==",
"dev": true,
"dependencies": {
"call-bind": "^1.0.2",
"has-tostringtag": "^1.0.0"
@ -6634,6 +6726,18 @@
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
"dev": true
},
"node_modules/lodash.difference": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz",
"integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==",
"license": "MIT"
},
"node_modules/lodash.differencewith": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.differencewith/-/lodash.differencewith-4.5.0.tgz",
"integrity": "sha512-/8JFjydAS+4bQuo3CpLMBv7WxGFyk7/etOAsrQUCu0a9QVDemxv0YQ0rFyeZvqlUD314SERfNlgnlqqHmaQ0Cg==",
"license": "MIT"
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
@ -6996,11 +7100,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object-is": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
"integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.7",
"define-properties": "^1.2.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"dev": true,
"engines": {
"node": ">= 0.4"
}
@ -7126,6 +7245,12 @@
"integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==",
"dev": true
},
"node_modules/parchment": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz",
"integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
"license": "BSD-3-Clause"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -7669,6 +7794,57 @@
"integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==",
"dev": true
},
"node_modules/quill": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz",
"integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==",
"license": "BSD-3-Clause",
"dependencies": {
"clone": "^2.1.1",
"deep-equal": "^1.0.1",
"eventemitter3": "^2.0.3",
"extend": "^3.0.2",
"parchment": "^1.1.4",
"quill-delta": "^3.6.2"
}
},
"node_modules/quill-delta": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-4.2.2.tgz",
"integrity": "sha512-qjbn82b/yJzOjstBgkhtBjN2TNK+ZHP/BgUQO+j6bRhWQQdmj2lH6hXG7+nwwLF41Xgn//7/83lxs9n2BkTtTg==",
"license": "MIT",
"dependencies": {
"fast-diff": "1.2.0",
"lodash.clonedeep": "^4.5.0",
"lodash.isequal": "^4.5.0"
}
},
"node_modules/quill-delta/node_modules/fast-diff": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz",
"integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==",
"license": "Apache-2.0"
},
"node_modules/quill/node_modules/fast-diff": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz",
"integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==",
"license": "Apache-2.0"
},
"node_modules/quill/node_modules/quill-delta": {
"version": "3.6.3",
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz",
"integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==",
"license": "MIT",
"dependencies": {
"deep-equal": "^1.0.1",
"extend": "^3.0.2",
"fast-diff": "1.1.2"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@ -7787,7 +7963,6 @@
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz",
"integrity": "sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==",
"dev": true,
"dependencies": {
"call-bind": "^1.0.6",
"define-properties": "^1.2.1",
@ -8090,7 +8265,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
"integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
"dev": true,
"dependencies": {
"define-data-property": "^1.1.4",
"es-errors": "^1.3.0",

View file

@ -26,9 +26,12 @@
"dependencies": {
"@headlessui/vue": "^1.7.13",
"@heroicons/vue": "^2.1.5",
"@vueup/vue-quill": "^1.2.0",
"axios": "^0.26.1",
"jwt-decode": "^4.0.0",
"lodash.clonedeep": "^4.5.0",
"lodash.difference": "^4.5.0",
"lodash.differencewith": "^4.5.0",
"lodash.isequal": "^4.5.0",
"nprogress": "^0.2.0",
"pdf-dist": "^1.0.0",
@ -45,6 +48,8 @@
"@tsconfig/node20": "^20.1.4",
"@types/eslint": "~9.6.0",
"@types/lodash.clonedeep": "^4.5.9",
"@types/lodash.difference": "^4.5.9",
"@types/lodash.differencewith": "^4.5.9",
"@types/lodash.isequal": "^4.5.8",
"@types/node": "^20.14.5",
"@types/nprogress": "^0.2.0",

View file

@ -1,5 +1,5 @@
<template>
<header class="flex flex-row h-16 justify-between p-3 md:px-5 bg-white shadow-sm">
<header class="flex flex-row h-16 min-h-16 justify-between p-3 md:px-5 bg-white shadow-sm">
<RouterLink to="/" class="flex flex-row gap-2 align-bottom w-fit h-full">
<img src="/FFW-Logo.svg" alt="LOGO" class="h-full w-auto" />
<h1 v-if="false" class="font-bold text-3xl w-fit whitespace-nowrap">Mitgliederverwaltung</h1>

View file

@ -0,0 +1,246 @@
<template>
<div class="grow flex flex-col gap-2 overflow-hidden">
<div v-if="useSearch" class="relative self-end">
<input
type="text"
class="w-64 rounded-md shadow-sm relative block px-3 py-2 pr-5 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="Suche"
v-model="searchString"
/>
<XMarkIcon
class="absolute h-4 stroke-2 right-2 top-1/2 -translate-y-1/2 cursor-pointer z-10"
@click="searchString = ''"
/>
</div>
<div class="flex flex-col w-full grow gap-2 pr-2 overflow-y-scroll">
<div v-if="indicateLoading" class="flex flex-row justify-center items-center w-full p-1">
<Spinner />
</div>
<p v-if="visibleRows.length == 0" class="flex flex-row w-full gap-2 p-1">Kein Inhalt</p>
<slot
v-else
name="pageRow"
v-for="(item, index) in items"
:key="index"
:row="item"
@click="$emit('clickRow', item.id)"
>
<p>{{ item }}</p>
</slot>
</div>
<div class="flex flex-row w-full justify-between select-none">
<p class="text-sm font-normal text-gray-500">
Elemente <span class="font-semibold text-gray-900">{{ showingText }}</span> von
<span class="font-semibold text-gray-900">{{ entryCount }}</span>
</p>
<ul class="flex flex-row text-sm h-8">
<li
class="flex h-8 w-8 items-center justify-center text-gray-500 bg-white border border-gray-300 first:rounded-s-lg last:rounded-e-lg"
:class="[currentPage > 0 ? 'cursor-pointer hover:bg-gray-100 hover:text-gray-700' : 'opacity-50']"
@click="loadPage(currentPage - 1)"
>
<ChevronLeftIcon class="h-4" />
</li>
<li
v-for="page in displayedPagesNumbers"
:key="page"
class="flex h-8 w-8 items-center justify-center text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 first:rounded-s-lg last:rounded-e-lg"
:class="[currentPage == page ? 'font-bold border-primary' : '', page != '.' ? ' cursor-pointer' : '']"
@click="loadPage(page)"
>
{{ typeof page == "number" ? page + 1 : "..." }}
</li>
<li
class="flex h-8 w-8 items-center justify-center text-gray-500 bg-white border border-gray-300 first:rounded-s-lg last:rounded-e-lg"
:class="[
currentPage + 1 < countOfPages ? 'cursor-pointer hover:bg-gray-100 hover:text-gray-700' : 'opacity-50',
]"
@click="loadPage(currentPage + 1)"
>
<ChevronRightIcon class="h-4" />
</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts" generic="T">
import { computed, ref, watch } from "vue";
import { ChevronRightIcon, ChevronLeftIcon, XMarkIcon } from "@heroicons/vue/20/solid";
import Spinner from "./Spinner.vue";
const props = defineProps({
items: { type: Array<T>, default: [] },
maxEntriesPerPage: { type: Number, default: 25 },
totalCount: { type: Number, default: null },
config: { type: Array<{ key: string }>, default: [] },
useSearch: { type: Boolean, default: false },
enablePreSearch: { type: Boolean, default: false },
indicateLoading: { type: Boolean, default: false },
});
const slots = defineSlots<{
pageRow(props: { row: T }): void;
}>();
const timer = ref(undefined) as undefined | any;
const currentPage = ref(0);
const searchString = ref("");
watch(searchString, async () => {
clearTimeout(timer.value);
timer.value = setTimeout(() => {
currentPage.value = 0;
emit("search", searchString.value);
}, 600);
});
const emit = defineEmits({
submit(id: number) {
return typeof id == "number";
},
loadData(offset: number, count: number, searchString: string) {
return typeof offset == "number" && typeof offset == "number" && typeof searchString == "number";
},
search(search: string) {
return typeof search == "number";
},
});
const entryCount = computed(() => props.totalCount ?? props.items.length);
const showingStart = computed(() => currentPage.value * props.maxEntriesPerPage);
const showingEnd = computed(() => {
let max = currentPage.value * props.maxEntriesPerPage + props.maxEntriesPerPage;
if (max > entryCount.value) max = entryCount.value;
return max;
});
const showingText = computed(() => `${entryCount.value != 0 ? showingStart.value + 1 : 0} - ${showingEnd.value}`);
const countOfPages = computed(() => Math.ceil(entryCount.value / props.maxEntriesPerPage));
const displayedPagesNumbers = computed(() => {
let stateOfPush = false;
return [...new Array(countOfPages.value)].reduce((acc, curr, index) => {
if (
index <= 1 ||
index >= countOfPages.value - 2 ||
(currentPage.value - 1 <= index && index <= currentPage.value + 1)
) {
acc.push(index);
stateOfPush = false;
return acc;
}
if (stateOfPush == true) return acc;
acc.push(".");
stateOfPush = true;
return acc;
}, []);
});
const visibleRows = computed(() => filterData(props.items, searchString.value, showingStart.value, showingEnd.value));
const loadPage = (newPage: number | ".") => {
if (newPage == ".") return;
if (newPage < 0 || newPage >= countOfPages.value) return;
let pageStart = newPage * props.maxEntriesPerPage;
let pageEnd = newPage * props.maxEntriesPerPage + props.maxEntriesPerPage;
if (pageEnd > entryCount.value) pageEnd = entryCount.value;
let loadedElementCount = filterData(props.items, searchString.value, pageStart, pageEnd).length;
if (loadedElementCount < props.maxEntriesPerPage)
emit("loadData", pageStart, props.maxEntriesPerPage, searchString.value);
currentPage.value = newPage;
};
const filterData = (array: Array<any>, searchString: string, start: number, end: number): Array<any> => {
return array
.filter(
(elem) =>
!props.enablePreSearch ||
searchString.trim() == "" ||
props.config.some((col) => typeof elem?.[col.key] == "string" && elem[col.key].includes(searchString.trim()))
)
.filter((elem, index) => (elem?.tab_pos ?? index) >= start && (elem?.tab_pos ?? index) < end);
};
</script>
<!--
<script lang="ts">
export default defineComponent({
computed: {
entryCount() {
return this.totalCount ?? this.items.length;
},
showingStart() {
return this.currentPage * this.maxEntriesPerPage;
},
showingEnd() {
let max = this.currentPage * this.maxEntriesPerPage + this.maxEntriesPerPage;
if (max > this.entryCount) max = this.entryCount;
return max;
},
showingText() {
return `${this.entryCount != 0 ? this.showingStart + 1 : 0} - ${this.showingEnd}`;
},
countOfPages() {
return Math.ceil(this.entryCount / this.maxEntriesPerPage);
},
displayedPagesNumbers(): Array<number | "."> {
//indicate if "." or page number gets pushed
let stateOfPush = false;
return [...new Array(this.countOfPages)].reduce((acc, curr, index) => {
if (
// always display first 2 pages
index <= 1 ||
// always display last 2 pages
index >= this.countOfPages - 2 ||
// always display 1 pages around current page
(this.currentPage - 1 <= index && index <= this.currentPage + 1)
) {
acc.push(index);
stateOfPush = false;
return acc;
}
// abort if placeholder already added to array
if (stateOfPush == true) return acc;
// show placeholder if pagenumber is not actively rendered
acc.push(".");
stateOfPush = true;
return acc;
}, []);
},
visibleRows() {
return this.filterData(this.items, this.searchString, this.showingStart, this.showingEnd);
},
},
methods: {
loadPage(newPage: number | ".") {
if (newPage == ".") return;
if (newPage < 0 || newPage >= this.countOfPages) return;
let pageStart = newPage * this.maxEntriesPerPage;
let pageEnd = newPage * this.maxEntriesPerPage + this.maxEntriesPerPage;
if (pageEnd > this.entryCount) pageEnd = this.entryCount;
let loadedElementCount = this.filterData(this.items, this.searchString, pageStart, pageEnd).length;
if (loadedElementCount < this.maxEntriesPerPage)
this.$emit("loadData", { offset: pageStart, count: this.maxEntriesPerPage, search: this.searchString });
this.currentPage = newPage;
},
filterData(array: Array<any>, searchString: string, start: number, end: number): Array<any> {
return array
.filter(
(elem) =>
!this.enablePreSearch ||
searchString.trim() == "" ||
this.config.some((col) => typeof elem?.[col.key] == "string" && elem[col.key].includes(searchString.trim()))
)
.filter((elem, index) => (elem?.tab_pos ?? index) >= start && (elem?.tab_pos ?? index) < end);
},
},
});
</script> -->

View file

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

View file

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

View file

@ -0,0 +1,27 @@
<template>
<div class="flex flex-col h-fit w-full border border-primary rounded-md">
<RouterLink
:to="{ name: 'admin-club-protocol-overview', params: { protocolId: protocol.id } }"
class="bg-primary p-2 text-white flex flex-row justify-between items-center"
>
<p>{{ protocol.title }} - {{ protocol.date }}</p>
</RouterLink>
<div class="p-2 max-h-48 overflow-y-auto">
<p v-html="protocol.summary"></p>
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent, type PropType } from "vue";
import { mapState, mapActions } from "pinia";
import type { ProtocolViewModel } from "../../../../viewmodels/admin/protocol.models";
</script>
<script lang="ts">
export default defineComponent({
props: {
protocol: { type: Object as PropType<ProtocolViewModel>, default: {} },
},
});
</script>

View file

@ -0,0 +1,40 @@
<template>
<div class="w-full h-full flex flex-col gap-2">
<div class="grow">
<iframe ref="viewer" class="w-full h-full" />
</div>
<button primary-outline class="!w-fit self-end" @click="closeModal">schließen</button>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapState, mapActions } from "pinia";
import { useModalStore } from "@/stores/modal";
import Spinner from "@/components/Spinner.vue";
import { useProtocolPrintoutStore } from "@/stores/admin/protocolPrintout";
</script>
<script lang="ts">
export default defineComponent({
computed: {
...mapState(useModalStore, ["data"]),
},
mounted() {
this.fetchItem();
},
methods: {
...mapActions(useModalStore, ["closeModal"]),
...mapActions(useProtocolPrintoutStore, ["fetchProtocolPrintoutById"]),
fetchItem() {
this.fetchProtocolPrintoutById(this.data)
.then((response) => {
const blob = new Blob([response.data], { type: "application/pdf" });
(this.$refs.viewer as HTMLIFrameElement).src = window.URL.createObjectURL(blob);
})
.catch(() => {});
},
},
});
</script>

View file

@ -0,0 +1,161 @@
<template>
<CloudIcon v-if="syncing == 'synced'" class="w-5 h-5" />
<CloudArrowUpIcon
v-else-if="syncing == 'detectedChanges'"
class="w-5 h-5 cursor-pointer animate-bounce"
@click="syncAll"
/>
<ArrowPathIcon v-else-if="syncing == 'syncing'" class="w-5 h-5 animate-spin" />
<ExclamationTriangleIcon
v-else
class="w-5 h-5 animate-[ping_1s_ease-in-out_3] text-red-500 cursor-pointer"
@click="syncAll"
/>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapState, mapActions } from "pinia";
import { useProtocolStore } from "@/stores/admin/protocol";
import { ArrowPathIcon, CloudArrowUpIcon, CloudIcon, ExclamationTriangleIcon } from "@heroicons/vue/24/outline";
import { useProtocolAgendaStore } from "@/stores/admin/protocolAgenda";
import { useProtocolPresenceStore } from "@/stores/admin/protocolPresence";
import { useProtocolDecisionStore } from "../../../../stores/admin/protocolDecision";
import { useProtocolVotingStore } from "../../../../stores/admin/protocolVoting";
</script>
<script lang="ts">
export default defineComponent({
props: ["executeSyncAll"],
watch: {
executeSyncAll() {
this.syncAll();
},
syncing() {
this.$emit("syncState", this.syncing);
},
detectedChangeProtocol() {
clearTimeout(this.protocolTimer);
this.setProtocolSyncingState("synced");
if (this.detectedChangeProtocol == false) {
return;
}
this.setProtocolSyncingState("detectedChanges");
this.protocolTimer = setTimeout(() => {
this.synchronizeActiveProtocol();
}, 10000);
},
detectedChangeProtocolAgenda() {
clearTimeout(this.protocolAgendaTimer);
if (this.detectedChangeProtocolAgenda == false) {
this.setProtocolAgendaSyncingState("synced");
return;
}
this.setProtocolAgendaSyncingState("detectedChanges");
this.protocolAgendaTimer = setTimeout(() => {
this.synchronizeActiveProtocolAgenda();
}, 10000);
},
detectedChangeProtocolPresence() {
clearTimeout(this.protocolPresenceTimer);
this.setProtocolPresenceSyncingState("synced");
if (this.detectedChangeProtocolPresence == false) {
return;
}
this.setProtocolPresenceSyncingState("detectedChanges");
this.protocolPresenceTimer = setTimeout(() => {
this.synchronizeActiveProtocolPresence();
}, 10000);
},
detectedChangeProtocolDecision() {
clearTimeout(this.protocolDecisionTimer);
this.setProtocolDecisionSyncingState("synced");
if (this.detectedChangeProtocolDecision == false) {
return;
}
this.setProtocolDecisionSyncingState("detectedChanges");
this.protocolDecisionTimer = setTimeout(() => {
this.synchronizeActiveProtocolDecision();
}, 10000);
},
detectedChangeProtocolVoting() {
clearTimeout(this.protocolVotingTimer);
this.setProtocolVotingSyncingState("synced");
if (this.detectedChangeProtocolVoting == false) {
return;
}
this.setProtocolVotingSyncingState("detectedChanges");
this.protocolVotingTimer = setTimeout(() => {
this.synchronizeActiveProtocolVoting();
}, 10000);
},
},
emits: {
syncState(state: "synced" | "syncing" | "detectedChanges" | "failed") {
return typeof state == "string";
},
},
data() {
return {
protocolTimer: undefined as undefined | any,
protocolAgendaTimer: undefined as undefined | any,
protocolPresenceTimer: undefined as undefined | any,
protocolDecisionTimer: undefined as undefined | any,
protocolVotingTimer: undefined as undefined | any,
};
},
mounted() {
this.$emit("syncState", this.syncing);
},
beforeUnmount() {
if (!this.protocolTimer) clearTimeout(this.protocolTimer);
if (!this.protocolAgendaTimer) clearTimeout(this.protocolAgendaTimer);
if (!this.protocolPresenceTimer) clearTimeout(this.protocolPresenceTimer);
if (!this.protocolDecisionTimer) clearTimeout(this.protocolDecisionTimer);
if (!this.protocolVotingTimer) clearTimeout(this.protocolVotingTimer);
},
computed: {
...mapState(useProtocolStore, ["syncingProtocol", "detectedChangeProtocol"]),
...mapState(useProtocolAgendaStore, ["syncingProtocolAgenda", "detectedChangeProtocolAgenda"]),
...mapState(useProtocolPresenceStore, ["syncingProtocolPresence", "detectedChangeProtocolPresence"]),
...mapState(useProtocolDecisionStore, ["syncingProtocolDecision", "detectedChangeProtocolDecision"]),
...mapState(useProtocolVotingStore, ["syncingProtocolVoting", "detectedChangeProtocolVoting"]),
syncing(): "synced" | "syncing" | "detectedChanges" | "failed" {
let states = [
this.syncingProtocol,
this.syncingProtocolAgenda,
this.syncingProtocolPresence,
this.syncingProtocolDecision,
this.syncingProtocolVoting,
];
if (states.includes("failed")) return "failed";
else if (states.includes("syncing")) return "syncing";
else if (states.includes("detectedChanges")) return "detectedChanges";
else return "synced";
},
},
methods: {
...mapActions(useProtocolStore, ["synchronizeActiveProtocol", "setProtocolSyncingState"]),
...mapActions(useProtocolAgendaStore, ["synchronizeActiveProtocolAgenda", "setProtocolAgendaSyncingState"]),
...mapActions(useProtocolPresenceStore, ["synchronizeActiveProtocolPresence", "setProtocolPresenceSyncingState"]),
...mapActions(useProtocolDecisionStore, ["synchronizeActiveProtocolDecision", "setProtocolDecisionSyncingState"]),
...mapActions(useProtocolVotingStore, ["synchronizeActiveProtocolVoting", "setProtocolVotingSyncingState"]),
syncAll() {
if (!this.protocolTimer) clearTimeout(this.protocolTimer);
if (!this.protocolAgendaTimer) clearTimeout(this.protocolAgendaTimer);
if (!this.protocolPresenceTimer) clearTimeout(this.protocolPresenceTimer);
if (!this.protocolDecisionTimer) clearTimeout(this.protocolDecisionTimer);
if (!this.protocolVotingTimer) clearTimeout(this.protocolVotingTimer);
this.synchronizeActiveProtocol();
this.synchronizeActiveProtocolAgenda();
this.synchronizeActiveProtocolPresence();
this.synchronizeActiveProtocolDecision();
this.synchronizeActiveProtocolVoting();
},
},
});
</script>

View file

@ -1,12 +1,10 @@
import type { AxiosInstance } from "axios";
import type { NProgress } from "nprogress";
import type { RouteLocationNormalizedLoaded, Router } from "vue-router";
declare module "@vue/runtime-core" {
interface ComponentCustomProperties {
$dev: boolean;
$http: AxiosInstance;
$progress: NProgress;
$router: Router;
$route: RouteLocationNormalizedLoaded;
}

View file

@ -0,0 +1,8 @@
export const toolbarOptions = [
[/*{ header: [1, 2, false] },*/ { font: [] }],
//[{ header: 1 }, { header: 2 }],
["bold", "italic", "underline", "strike"],
["blockquote", "code-block", "link"],
[{ list: "ordered" }, { list: "bullet" }, { list: "check" }],
["clean"],
];

View file

@ -56,8 +56,8 @@ body {
@apply w-full h-full overflow-hidden flex flex-col;
}
button:not([headlessui]),
a[button]:not([headlessui]) {
button:not([headlessui]):not([id*="headlessui"]):not([class*="headlessui"]):not([class*="ql"] *),
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;
}
@ -91,3 +91,30 @@ input[disabled],
textarea[disabled] {
@apply opacity-75 pointer-events-none;
}
details {
user-select: none;
& summary svg {
transform: rotate(90deg);
}
}
details[open] {
& summary svg {
transform: rotate(-90deg);
}
}
details[open] summary ~ * {
animation: ease-opacity-t-b 0.5s ease;
}
summary {
cursor: pointer;
}
summary > svg {
transition: all 0.3s;
}
summary::-webkit-details-marker {
display: none;
}

View file

@ -68,7 +68,7 @@ export async function isAuthenticatedPromise(): Promise<Payload> {
var { firstname, lastname, mail, username, permissions } = decoded;
if (Object.keys(permissions).length === 0) {
if (Object.keys(permissions ?? {}).length === 0) {
auth.setFailed();
reject("nopermissions");
}

View file

@ -7,6 +7,7 @@ import { isSetup } from "./setupGuard";
import { abilityAndNavUpdate } from "./adminGuard";
import type { PermissionType, PermissionSection, PermissionModule } from "@/types/permissionTypes";
import { resetMemberStores, setMemberId } from "./memberGuard";
import { resetProtocolStores, setProtocolId } from "./protocolGuard";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@ -148,10 +149,63 @@ const router = createRouter({
},
{
path: "protocol",
name: "admin-club-protocol",
component: () => import("@/views/admin/members/Overview.vue"),
meta: { type: "read", section: "club", module: "protocoll" },
name: "admin-club-protocol-route",
component: () => import("@/views/RouterView.vue"),
meta: { type: "read", section: "club", module: "protocol" },
beforeEnter: [abilityAndNavUpdate],
children: [
{
path: "",
name: "admin-club-protocol",
component: () => import("@/views/admin/club/protocol/Protocol.vue"),
beforeEnter: [resetProtocolStores],
},
{
path: ":protocolId",
name: "admin-club-protocol-routing",
component: () => import("@/views/admin/club/protocol/ProtocolRouting.vue"),
beforeEnter: [setProtocolId],
props: true,
children: [
{
path: "overview",
name: "admin-club-protocol-overview",
component: () => import("@/views/admin/club/protocol/ProtocolOverview.vue"),
props: true,
},
{
path: "presence",
name: "admin-club-protocol-presence",
component: () => import("@/views/admin/club/protocol/ProtocolPresence.vue"),
props: true,
},
{
path: "voting",
name: "admin-club-protocol-voting",
component: () => import("@/views/admin/club/protocol/ProtocolVoting.vue"),
props: true,
},
{
path: "decisions",
name: "admin-club-protocol-decisions",
component: () => import("@/views/admin/club/protocol/ProtocolDecisions.vue"),
props: true,
},
{
path: "agenda",
name: "admin-club-protocol-agenda",
component: () => import("@/views/admin/club/protocol/ProtocolAgenda.vue"),
props: true,
},
{
path: "printout",
name: "admin-club-protocol-printout",
component: () => import("@/views/admin/club/protocol/ProtocolPrintout.vue"),
props: true,
},
],
},
],
},
],
},

View file

@ -0,0 +1,33 @@
import { useProtocolStore } from "@/stores/admin/protocol";
import { useProtocolAgendaStore } from "@/stores/admin/protocolAgenda";
import { useProtocolDecisionStore } from "@/stores/admin/protocolDecision";
import { useProtocolPresenceStore } from "@/stores/admin/protocolPresence";
import { useProtocolVotingStore } from "@/stores/admin/protocolVoting";
import { useProtocolPrintoutStore } from "../stores/admin/protocolPrintout";
export async function setProtocolId(to: any, from: any, next: any) {
const protocol = useProtocolStore();
protocol.activeProtocol = to.params?.protocolId ?? null;
useProtocolAgendaStore().$reset();
useProtocolDecisionStore().$reset();
useProtocolPresenceStore().$reset();
useProtocolVotingStore().$reset();
useProtocolPrintoutStore().$reset();
next();
}
export async function resetProtocolStores(to: any, from: any, next: any) {
const protocol = useProtocolStore();
protocol.activeProtocol = null;
protocol.activeProtocolObj = null;
useProtocolAgendaStore().$reset();
useProtocolDecisionStore().$reset();
useProtocolPresenceStore().$reset();
useProtocolVotingStore().$reset();
useProtocolPrintoutStore().$reset();
next();
}

View file

@ -0,0 +1,105 @@
import { defineStore } from "pinia";
import type { CreateProtocolViewModel, SyncProtocolViewModel } from "@/viewmodels/admin/protocol.models";
import { http } from "@/serverCom";
import type { AxiosResponse } from "axios";
import type { ProtocolViewModel } from "@/viewmodels/admin/protocol.models";
import cloneDeep from "lodash.clonedeep";
import isEqual from "lodash.isEqual";
import difference from "lodash.difference";
export const useProtocolStore = defineStore("protocol", {
state: () => {
return {
protocols: [] as Array<ProtocolViewModel & { tab_pos: number }>,
totalCount: 0 as number,
loading: "loading" as "loading" | "fetched" | "failed",
activeProtocol: null as number | null,
activeProtocolObj: null as ProtocolViewModel | null,
origin: null as ProtocolViewModel | null,
loadingActive: "loading" as "loading" | "fetched" | "failed",
syncingProtocol: "synced" as "synced" | "syncing" | "detectedChanges" | "failed",
};
},
getters: {
detectedChangeProtocol: (state) =>
!isEqual(state.origin, state.activeProtocolObj) && state.syncingProtocol != "syncing",
},
actions: {
setProtocolSyncingState(state: "synced" | "syncing" | "detectedChanges" | "failed") {
this.syncingProtocol = state;
},
fetchProtocols(offset = 0, count = 25, clear = false) {
if (clear) this.protocols = [];
this.loading = "loading";
http
.get(`/admin/protocol?offset=${offset}&count=${count}`)
.then((result) => {
this.totalCount = result.data.total;
result.data.protocols
.filter((elem: ProtocolViewModel) => this.protocols.findIndex((m) => m.id == elem.id) == -1)
.map((elem: ProtocolViewModel, index: number): ProtocolViewModel & { tab_pos: number } => {
return {
...elem,
tab_pos: index + offset,
};
})
.forEach((elem: ProtocolViewModel & { tab_pos: number }) => {
this.protocols.push(elem);
});
this.loading = "fetched";
})
.catch((err) => {
this.loading = "failed";
});
},
fetchProtocolByActiveId() {
this.loadingActive = "loading";
http
.get(`/admin/protocol/${this.activeProtocol}`)
.then((res) => {
this.origin = res.data;
this.activeProtocolObj = cloneDeep(this.origin);
this.loadingActive = "fetched";
})
.catch((err) => {
this.loadingActive = "failed";
});
},
fetchProtocolById(id: number) {
return http.get(`/admin/protocol/${id}`);
},
async createProtocol(protocol: CreateProtocolViewModel): Promise<AxiosResponse<any, any>> {
const result = await http.post(`/admin/protocol`, {
title: protocol.title,
date: protocol.date,
});
this.fetchProtocols();
return result;
},
async synchronizeActiveProtocol(): Promise<void> {
if (this.origin == null || this.activeProtocolObj == null) return;
this.syncingProtocol = "syncing";
await http
.patch(`/admin/protocol/${this.origin.id}/synchronize`, {
title: this.activeProtocolObj.title,
date: this.activeProtocolObj.date,
starttime: this.activeProtocolObj.starttime,
endtime: this.activeProtocolObj.endtime,
summary: this.activeProtocolObj.summary,
})
.then((res) => {
this.syncingProtocol = "synced";
})
.catch((err) => {
this.syncingProtocol = "failed";
});
this.fetchProtocolById(this.origin.id)
.then((res) => {
this.origin = res.data;
if (this.detectedChangeProtocol) this.syncingProtocol = "detectedChanges";
})
.catch((err) => {});
},
},
});

View file

@ -0,0 +1,82 @@
import { defineStore } from "pinia";
import { http } from "@/serverCom";
import type {
ProtocolAgendaViewModel,
SyncProtocolAgendaViewModel,
} from "../../viewmodels/admin/protocolAgenda.models";
import { useProtocolStore } from "./protocol";
import cloneDeep from "lodash.clonedeep";
import isEqual from "lodash.isEqual";
import differenceWith from "lodash.differencewith";
export const useProtocolAgendaStore = defineStore("protocolAgenda", {
state: () => {
return {
agenda: [] as Array<ProtocolAgendaViewModel>,
origin: [] as Array<ProtocolAgendaViewModel>,
loading: "loading" as "loading" | "fetched" | "failed",
syncingProtocolAgenda: "synced" as "synced" | "syncing" | "detectedChanges" | "failed",
};
},
getters: {
detectedChangeProtocolAgenda: (state) =>
!isEqual(state.origin, state.agenda) && state.syncingProtocolAgenda != "syncing",
},
actions: {
setProtocolAgendaSyncingState(state: "synced" | "syncing" | "detectedChanges" | "failed") {
this.syncingProtocolAgenda = state;
},
fetchProtocolAgenda() {
this.loading = "loading";
this.fetchProtocolAgendaPromise()
.then((result) => {
this.origin = result.data;
this.agenda = cloneDeep(this.origin);
this.loading = "fetched";
})
.catch((err) => {
this.loading = "failed";
});
},
fetchProtocolAgendaPromise() {
const protocolId = useProtocolStore().activeProtocol;
return http.get(`/admin/protocol/${protocolId}/agenda`);
},
createProtocolAgenda() {
const protocolId = useProtocolStore().activeProtocol;
if (protocolId == null) return;
return http
.post(`/admin/protocol/${protocolId}/agenda`)
.then((res) => {
this.agenda.push({
id: Number(res.data),
topic: "",
context: "",
protocolId: Number(protocolId),
});
})
.catch((err) => {});
},
async synchronizeActiveProtocolAgenda() {
this.syncingProtocolAgenda = "syncing";
const protocolId = useProtocolStore().activeProtocol;
await http
.patch(`/admin/protocol/${protocolId}/synchronize/agenda`, {
agenda: differenceWith(this.agenda, this.origin, isEqual),
})
.then((res) => {
this.syncingProtocolAgenda = "synced";
})
.catch((err) => {
this.syncingProtocolAgenda = "failed";
});
this.fetchProtocolAgendaPromise()
.then((res) => {
this.origin = res.data;
if (this.detectedChangeProtocolAgenda) this.syncingProtocolAgenda = "detectedChanges";
})
.catch((err) => {});
},
},
});

View file

@ -0,0 +1,83 @@
import { defineStore } from "pinia";
import { http } from "@/serverCom";
import type { AxiosResponse } from "axios";
import type {
ProtocolDecisionViewModel,
SyncProtocolDecisionViewModel,
} from "../../viewmodels/admin/protocolDecision.models";
import { useProtocolStore } from "./protocol";
import cloneDeep from "lodash.clonedeep";
import isEqual from "lodash.isEqual";
import differenceWith from "lodash.differencewith";
export const useProtocolDecisionStore = defineStore("protocolDecision", {
state: () => {
return {
decision: [] as Array<ProtocolDecisionViewModel>,
origin: [] as Array<ProtocolDecisionViewModel>,
loading: "loading" as "loading" | "fetched" | "failed",
syncingProtocolDecision: "synced" as "synced" | "syncing" | "detectedChanges" | "failed",
};
},
getters: {
detectedChangeProtocolDecision: (state) =>
!isEqual(state.origin, state.decision) && state.syncingProtocolDecision != "syncing",
},
actions: {
setProtocolDecisionSyncingState(state: "synced" | "syncing" | "detectedChanges" | "failed") {
this.syncingProtocolDecision = state;
},
fetchProtocolDecision() {
this.loading = "loading";
this.fetchProtocolDecisionPromise()
.then((result) => {
this.origin = result.data;
this.decision = cloneDeep(this.origin);
this.loading = "fetched";
})
.catch((err) => {
this.loading = "failed";
});
},
fetchProtocolDecisionPromise() {
const protocolId = useProtocolStore().activeProtocol;
return http.get(`/admin/protocol/${protocolId}/decisions`);
},
createProtocolDecision() {
const protocolId = useProtocolStore().activeProtocol;
if (protocolId == null) return;
return http
.post(`/admin/protocol/${protocolId}/decision`)
.then((res) => {
this.decision.push({
id: Number(res.data),
topic: "",
context: "",
protocolId: Number(protocolId),
});
})
.catch((err) => {});
},
async synchronizeActiveProtocolDecision() {
this.syncingProtocolDecision = "syncing";
const protocolId = useProtocolStore().activeProtocol;
await http
.patch(`/admin/protocol/${protocolId}/synchronize/decisions`, {
decisions: differenceWith(this.decision, this.origin, isEqual),
})
.then((res) => {
this.syncingProtocolDecision = "synced";
})
.catch((err) => {
this.syncingProtocolDecision = "failed";
});
this.fetchProtocolDecisionPromise()
.then((res) => {
this.origin = res.data;
if (this.detectedChangeProtocolDecision) this.syncingProtocolDecision = "detectedChanges";
})
.catch((err) => {});
},
},
});

View file

@ -0,0 +1,66 @@
import { defineStore } from "pinia";
import { http } from "@/serverCom";
import type { AxiosResponse } from "axios";
import type {
ProtocolPresenceViewModel,
SyncProtocolPresenceViewModel,
} from "../../viewmodels/admin/protocolPresence.models";
import { useProtocolStore } from "./protocol";
import cloneDeep from "lodash.clonedeep";
import isEqual from "lodash.isEqual";
export const useProtocolPresenceStore = defineStore("protocolPresence", {
state: () => {
return {
presence: [] as Array<number>,
origin: [] as Array<number>,
loading: "loading" as "loading" | "fetched" | "failed",
syncingProtocolPresence: "synced" as "synced" | "syncing" | "detectedChanges" | "failed",
};
},
getters: {
detectedChangeProtocolPresence: (state) =>
!isEqual(state.origin, state.presence) && state.syncingProtocolPresence != "syncing",
},
actions: {
setProtocolPresenceSyncingState(state: "synced" | "syncing" | "detectedChanges" | "failed") {
this.syncingProtocolPresence = state;
},
fetchProtocolPresence() {
this.loading = "loading";
this.fetchProtocolPresencePromise()
.then((result) => {
this.origin = result.data.map((d: ProtocolPresenceViewModel) => d.memberId);
this.presence = cloneDeep(this.origin);
this.loading = "fetched";
})
.catch((err) => {
this.loading = "failed";
});
},
fetchProtocolPresencePromise() {
const protocolId = useProtocolStore().activeProtocol;
return http.get(`/admin/protocol/${protocolId}/presence`);
},
async synchronizeActiveProtocolPresence() {
this.syncingProtocolPresence = "syncing";
const protocolId = useProtocolStore().activeProtocol;
await http
.put(`/admin/protocol/${protocolId}/synchronize/presence`, {
presence: this.presence,
})
.then((res) => {
this.syncingProtocolPresence = "synced";
})
.catch((err) => {
this.syncingProtocolPresence = "failed";
});
this.fetchProtocolPresencePromise()
.then((result) => {
this.origin = result.data.map((d: ProtocolPresenceViewModel) => d.memberId);
if (this.detectedChangeProtocolPresence) this.syncingProtocolPresence = "detectedChanges";
})
.catch((err) => {});
},
},
});

View file

@ -0,0 +1,55 @@
import { defineStore } from "pinia";
import { http } from "@/serverCom";
import type { ProtocolPrintoutViewModel } from "../../viewmodels/admin/protocolPrintout.models";
import { useProtocolStore } from "./protocol";
import type { AxiosResponse } from "axios";
export const useProtocolPrintoutStore = defineStore("protocolPrintout", {
state: () => {
return {
printout: [] as Array<ProtocolPrintoutViewModel>,
loading: "loading" as "loading" | "fetched" | "failed",
printing: undefined as undefined | "loading" | "success" | "failed",
};
},
actions: {
fetchProtocolPrintout() {
const protocolId = useProtocolStore().activeProtocol;
this.loading = "loading";
http
.get(`/admin/protocol/${protocolId}/printouts`)
.then((result) => {
this.printout = result.data;
this.loading = "fetched";
})
.catch((err) => {
this.loading = "failed";
});
},
fetchProtocolPrintoutById(printoutId: number): Promise<AxiosResponse<any, any>> {
const protocolId = useProtocolStore().activeProtocol;
return http.get(`/admin/protocol/${protocolId}/printout/${printoutId}`, {
responseType: "blob",
});
},
createProtocolPrintout() {
this.printing = "loading";
const protocolId = useProtocolStore().activeProtocol;
if (protocolId == null) return;
return http
.post(`/admin/protocol/${protocolId}/printout`)
.then((res) => {
this.fetchProtocolPrintout();
this.printing = "success";
})
.catch((err) => {
this.printing = "failed";
})
.finally(() => {
setTimeout(() => {
this.printing = undefined;
}, 1500);
});
},
},
});

View file

@ -0,0 +1,86 @@
import { defineStore } from "pinia";
import { http } from "@/serverCom";
import type { AxiosResponse } from "axios";
import type {
ProtocolVotingViewModel,
SyncProtocolVotingViewModel,
} from "../../viewmodels/admin/protocolVoting.models";
import { useProtocolStore } from "./protocol";
import cloneDeep from "lodash.clonedeep";
import isEqual from "lodash.isEqual";
import differenceWith from "lodash.differencewith";
export const useProtocolVotingStore = defineStore("protocolVoting", {
state: () => {
return {
voting: [] as Array<ProtocolVotingViewModel>,
origin: [] as Array<ProtocolVotingViewModel>,
loading: "loading" as "loading" | "fetched" | "failed",
syncingProtocolVoting: "synced" as "synced" | "syncing" | "detectedChanges" | "failed",
};
},
getters: {
detectedChangeProtocolVoting: (state) =>
!isEqual(state.origin, state.voting) && state.syncingProtocolVoting != "syncing",
},
actions: {
setProtocolVotingSyncingState(state: "synced" | "syncing" | "detectedChanges" | "failed") {
this.syncingProtocolVoting = state;
},
fetchProtocolVoting() {
this.loading = "loading";
this.fetchProtocolVotingPromise()
.then((result) => {
this.origin = result.data;
this.voting = cloneDeep(this.origin);
this.loading = "fetched";
})
.catch((err) => {
this.loading = "failed";
});
},
fetchProtocolVotingPromise() {
const protocolId = useProtocolStore().activeProtocol;
return http.get(`/admin/protocol/${protocolId}/votings`);
},
createProtocolVoting() {
const protocolId = useProtocolStore().activeProtocol;
if (protocolId == null) return;
return http
.post(`/admin/protocol/${protocolId}/voting`)
.then((res) => {
this.voting.push({
id: Number(res.data),
topic: "",
context: "",
favour: 0,
abstain: 0,
against: 0,
protocolId: Number(protocolId),
});
})
.catch((err) => {});
},
async synchronizeActiveProtocolVoting() {
this.syncingProtocolVoting = "syncing";
const protocolId = useProtocolStore().activeProtocol;
await http
.patch(`/admin/protocol/${protocolId}/synchronize/votings`, {
votings: differenceWith(this.voting, this.origin, isEqual),
})
.then((res) => {
this.syncingProtocolVoting = "synced";
})
.catch((err) => {
this.syncingProtocolVoting = "failed";
});
this.fetchProtocolVotingPromise()
.then((res) => {
this.origin = res.data;
if (this.detectedChangeProtocolVoting) this.syncingProtocolVoting = "detectedChanges";
})
.catch((err) => {});
},
},
});

View file

@ -4,7 +4,7 @@ export type PermissionModule =
| "member"
| "calendar"
| "newsletter"
| "protocoll"
| "protocol"
| "qualification"
| "award"
| "executive_position"
@ -39,7 +39,7 @@ export const permissionModules: Array<PermissionModule> = [
"member",
"calendar",
"newsletter",
"protocoll",
"protocol",
"qualification",
"award",
"executive_position",
@ -50,7 +50,7 @@ export const permissionModules: Array<PermissionModule> = [
];
export const permissionTypes: Array<PermissionType> = ["read", "create", "update", "delete"];
export const sectionsAndModules: SectionsAndModulesObject = {
club: ["member", "calendar", "newsletter", "protocoll"],
club: ["member", "calendar", "newsletter", "protocol"],
settings: ["qualification", "award", "executive_position", "communication", "membership_status"],
user: ["user", "role"],
};

View file

@ -0,0 +1,22 @@
export interface ProtocolViewModel {
id: number;
title: string;
date: Date;
starttime: Date;
endtime: Date;
summary: string;
}
export interface CreateProtocolViewModel {
title: string;
date: Date;
}
export interface SyncProtocolViewModel {
id: number;
title: string;
date: Date;
starttime: Date;
endtime: Date;
summary: string;
}

View file

@ -0,0 +1,12 @@
export interface ProtocolAgendaViewModel {
id: number;
topic: string;
context: string;
protocolId: number;
}
export interface SyncProtocolAgendaViewModel {
id?: number;
topic: string;
context: string;
}

View file

@ -0,0 +1,12 @@
export interface ProtocolDecisionViewModel {
id: number;
topic: string;
context: string;
protocolId: number;
}
export interface SyncProtocolDecisionViewModel {
id?: number;
topic: string;
context: string;
}

View file

@ -0,0 +1,11 @@
import type { MemberViewModel } from "./member.models";
export interface ProtocolPresenceViewModel {
memberId: number;
member: MemberViewModel;
protocolId: number;
}
export interface SyncProtocolPresenceViewModel {
memberIds: Array<number>;
}

View file

@ -0,0 +1,7 @@
export interface ProtocolPrintoutViewModel {
id: number;
title: string;
iteration: number;
createdAt: Date;
protocolId: number;
}

View file

@ -0,0 +1,19 @@
export interface ProtocolVotingViewModel {
id: number;
topic: string;
context: string;
favour: number;
abstain: number;
against: number;
protocolId: number;
}
export interface SyncProtocolVotingViewModel {
id?: number;
topic: string;
context: string;
favour: number;
abstain: number;
against: number;
protocolId: number;
}

View file

@ -12,7 +12,15 @@
<input id="username" name="username" type="text" required placeholder="Benutzer" class="!rounded-b-none" />
</div>
<div>
<input id="totp" name="totp" type="text" required placeholder="TOTP" class="!rounded-t-none" />
<input
id="totp"
name="totp"
type="text"
required
placeholder="TOTP"
class="!rounded-t-none"
autocomplete="off"
/>
</div>
</div>
@ -69,8 +77,8 @@ export default defineComponent({
})
.then((result) => {
this.loginStatus = "success";
localStorage.setItem("accessToken", result.data.accessToken),
localStorage.setItem("refreshToken", result.data.refreshToken),
localStorage.setItem("accessToken", result.data.accessToken);
localStorage.setItem("refreshToken", result.data.refreshToken);
setTimeout(() => {
this.$router.push(`/admin`);
}, 1000);

View file

@ -0,0 +1,66 @@
<template>
<MainTemplate>
<template #topBar>
<div class="flex flex-row items-center justify-between pt-5 pb-3 px-7">
<h1 class="font-bold text-xl h-8">Protokolle</h1>
</div>
</template>
<template #diffMain>
<div class="flex flex-col w-full h-full gap-2 justify-center px-7">
<Pagination
:items="protocols"
:totalCount="totalCount"
:indicateLoading="loading == 'loading'"
@load-data="(offset, count, search) => fetchProtocols(offset, count)"
@search="(search) => fetchProtocols(0, 25, true)"
>
<template #pageRow="{ row }: { row: ProtocolViewModel }">
<ProtocolListItem :protocol="row" />
</template>
</Pagination>
<div class="flex flex-row gap-4">
<button primary class="!w-fit" @click="openCreateModal">Protokoll erstellen</button>
</div>
</div>
</template>
</MainTemplate>
</template>
<script setup lang="ts">
import { defineAsyncComponent, defineComponent, markRaw } from "vue";
import { mapActions, mapState } from "pinia";
import MainTemplate from "@/templates/Main.vue";
import { ChevronRightIcon, ChevronLeftIcon } from "@heroicons/vue/20/solid";
import { useProtocolStore } from "@/stores/admin/protocol";
import ProtocolListItem from "@/components/admin/club/protocol/ProtocolListItem.vue";
import { useModalStore } from "@/stores/modal";
import Pagination from "../../../../components/Pagination.vue";
import type { ProtocolViewModel } from "../../../../viewmodels/admin/protocol.models";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
currentPage: 0,
maxEntriesPerPage: 25,
};
},
computed: {
...mapState(useProtocolStore, ["protocols", "totalCount", "loading"]),
},
mounted() {
this.fetchProtocols(0, this.maxEntriesPerPage, true);
},
methods: {
...mapActions(useProtocolStore, ["fetchProtocols"]),
...mapActions(useModalStore, ["openModal"]),
openCreateModal() {
this.openModal(
markRaw(defineAsyncComponent(() => import("@/components/admin/club/protocol/CreateProtocolModal.vue")))
);
},
},
});
</script>

View file

@ -0,0 +1,74 @@
<template>
<div class="flex flex-col gap-2 h-full w-full overflow-y-auto">
<Spinner v-if="loading == 'loading'" class="mx-auto" />
<p v-else-if="loading == 'failed'" @click="fetchProtocolAgenda" class="cursor-pointer">
&#8634; laden fehlgeschlagen
</p>
<div class="flex flex-col gap-2 h-full overflow-y-auto">
<details
v-for="item in agenda"
class="flex flex-col gap-2 rounded-lg w-full justify-between border border-primary overflow-hidden min-h-fit"
>
<summary class="flex flex-row gap-2 bg-primary p-2 w-full justify-between items-center cursor-pointer">
<svg
class="fill-white stroke-white opacity-75 w-4 h-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
>
<path d="M12.95 10.707l.707-.707L8 4.343 6.586 5.757 10.828 10l-4.242 4.243L8 15.657l4.95-4.95z" />
</svg>
<input
type="text"
name="title"
id="title"
placeholder="TOP"
autocomplete="off"
v-model="item.topic"
@keyup.prevent
/>
</summary>
<QuillEditor
id="top"
theme="snow"
placeholder="TOP Inhalt..."
style="height: 250px; max-height: 250px; min-height: 250px"
contentType="html"
:toolbar="toolbarOptions"
v-model:content="item.context"
/>
</details>
</div>
<button primary class="!w-fit" @click="createProtocolAgenda">Eintrag hinzufügen</button>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapActions, mapState, mapWritableState } from "pinia";
import Spinner from "@/components/Spinner.vue";
import { QuillEditor } from "@vueup/vue-quill";
import "@vueup/vue-quill/dist/vue-quill.snow.css";
import { toolbarOptions } from "@/helpers/quillConfig";
import { useProtocolAgendaStore } from "@/stores/admin/protocolAgenda";
import type { ProtocolAgendaViewModel } from "@/viewmodels/admin/protocolAgenda.models";
</script>
<script lang="ts">
export default defineComponent({
props: {
protocolId: String,
},
computed: {
...mapWritableState(useProtocolAgendaStore, ["agenda", "loading"]),
},
mounted() {
this.fetchProtocolAgenda();
},
methods: {
...mapActions(useProtocolAgendaStore, ["fetchProtocolAgenda", "createProtocolAgenda"]),
},
});
</script>

View file

@ -0,0 +1,74 @@
<template>
<div class="flex flex-col gap-2 h-full w-full overflow-y-auto">
<Spinner v-if="loading == 'loading'" class="mx-auto" />
<p v-else-if="loading == 'failed'" @click="fetchProtocolDecision" class="cursor-pointer">
&#8634; laden fehlgeschlagen
</p>
<div class="flex flex-col gap-2 h-full overflow-y-auto">
<details
v-for="item in decision"
class="flex flex-col gap-2 rounded-lg w-full justify-between border border-primary overflow-hidden min-h-fit"
>
<summary class="flex flex-row gap-2 bg-primary p-2 w-full justify-between items-center cursor-pointer">
<svg
class="fill-white stroke-white opacity-75 w-4 h-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
>
<path d="M12.95 10.707l.707-.707L8 4.343 6.586 5.757 10.828 10l-4.242 4.243L8 15.657l4.95-4.95z" />
</svg>
<input
type="text"
name="title"
id="title"
placeholder="Einscheidung"
autocomplete="off"
v-model="item.topic"
@keyup.prevent
/>
</summary>
<QuillEditor
id="top"
theme="snow"
placeholder="Entscheidung Inhalt..."
style="height: 250px; max-height: 250px; min-height: 250px"
contentType="html"
:toolbar="toolbarOptions"
v-model:content="item.context"
/>
</details>
</div>
<button primary class="!w-fit" @click="createProtocolDecision">Eintrag hinzufügen</button>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapActions, mapState, mapWritableState } from "pinia";
import Spinner from "@/components/Spinner.vue";
import { useProtocolStore } from "@/stores/admin/protocol";
import { QuillEditor } from "@vueup/vue-quill";
import "@vueup/vue-quill/dist/vue-quill.snow.css";
import { toolbarOptions } from "@/helpers/quillConfig";
import { useProtocolDecisionStore } from "../../../../stores/admin/protocolDecision";
</script>
<script lang="ts">
export default defineComponent({
props: {
protocolId: String,
},
computed: {
...mapWritableState(useProtocolDecisionStore, ["decision", "loading"]),
},
mounted() {
this.fetchProtocolDecision();
},
methods: {
...mapActions(useProtocolDecisionStore, ["fetchProtocolDecision", "createProtocolDecision"]),
},
});
</script>

View file

@ -0,0 +1,68 @@
<template>
<div class="flex flex-col gap-2 h-full w-full overflow-y-auto">
<div v-if="activeProtocolObj != null" class="flex flex-col gap-2 w-full">
<div class="w-full">
<label for="title">Titel</label>
<input type="text" id="title" v-model="activeProtocolObj.title" />
</div>
<div class="w-full">
<label for="date">Datum</label>
<input type="date" id="date" v-model="activeProtocolObj.date" />
</div>
<div class="flex flex-row gap-2 w-full">
<div class="w-full">
<label for="starttime">Startzeit</label>
<input type="time" id="starttime" step="1" v-model="activeProtocolObj.starttime" />
</div>
<div class="w-full">
<label for="endtime">Endzeit</label>
<input type="time" id="endtime" step="1" v-model="activeProtocolObj.endtime" />
</div>
</div>
<div class="flex flex-col h-1/2">
<label for="summary">Zusammenfassung</label>
<QuillEditor
id="summary"
theme="snow"
placeholder="Zusammenfassung zur Sitzung..."
style="height: 250px; max-height: 250px; min-height: 250px"
contentType="html"
:toolbar="toolbarOptions"
v-model:content="activeProtocolObj.summary"
/>
</div>
</div>
<Spinner v-if="loadingActive == 'loading'" class="mx-auto" />
<p v-else-if="loadingActive == 'failed'" @click="fetchProtocolByActiveId" class="cursor-pointer">
&#8634; laden fehlgeschlagen
</p>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapActions, mapState, mapWritableState } from "pinia";
import Spinner from "@/components/Spinner.vue";
import { useProtocolStore } from "@/stores/admin/protocol";
import { QuillEditor } from "@vueup/vue-quill";
import "@vueup/vue-quill/dist/vue-quill.snow.css";
import { toolbarOptions } from "@/helpers/quillConfig";
</script>
<script lang="ts">
export default defineComponent({
props: {
protocolId: String,
},
computed: {
...mapWritableState(useProtocolStore, ["loadingActive", "activeProtocolObj"]),
},
mounted() {
this.fetchProtocolByActiveId();
},
methods: {
...mapActions(useProtocolStore, ["fetchProtocolByActiveId"]),
},
});
</script>

View file

@ -0,0 +1,152 @@
<template>
<div class="flex flex-col gap-2 h-full w-full overflow-y-auto">
<Spinner v-if="loading == 'loading'" class="mx-auto" />
<p v-else-if="loading == 'failed'" @click="fetchProtocolPresence" class="cursor-pointer">
&#8634; laden fehlgeschlagen
</p>
<div class="w-full">
<Combobox v-model="presence" multiple>
<ComboboxLabel>Anwesende suchen</ComboboxLabel>
<div class="relative mt-1">
<ComboboxInput
class="rounded-md shadow-sm relative block w-full px-3 py-2 border border-gray-300 focus:border-primary placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-0 focus:z-10 sm:text-sm resize-none"
@input="query = $event.target.value"
/>
<ComboboxButton class="absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon class="h-5 w-5 text-gray-400" aria-hidden="true" />
</ComboboxButton>
<TransitionRoot
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
@after-leave="query = ''"
>
<ComboboxOptions
class="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-md ring-1 ring-black/5 focus:outline-none sm:text-sm"
>
<ComboboxOption v-if="filtered.length === 0" as="template" disabled>
<li class="text-text relative cursor-default select-none py-2 pl-3 pr-4">
<span class="font-normal block truncate"> Keine Auswahl</span>
</li>
</ComboboxOption>
<ComboboxOption
v-for="member in filtered"
as="template"
:key="member.id"
:value="member.id"
v-slot="{ selected, active }"
>
<li
class="relative cursor-default select-none py-2 pl-10 pr-4"
:class="{
'bg-primary text-white': active,
'text-gray-900': !active,
}"
>
<span class="block truncate" :class="{ 'font-medium': selected, 'font-normal': !selected }">
{{ member.firstname }} {{ member.lastname }} {{ member.nameaffix }}
</span>
<span
v-if="selected"
class="absolute inset-y-0 left-0 flex items-center pl-3"
:class="{ 'text-white': active, 'text-primary': !active }"
>
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ComboboxOption>
</ComboboxOptions>
</TransitionRoot>
</div>
</Combobox>
</div>
<br />
<p>Ausgewählte Anwesende</p>
<div class="flex flex-col gap-2 grow overflow-y-auto">
<div
v-for="member in selected"
:key="member.id"
class="flex flex-row h-fit w-full border border-primary rounded-md bg-primary p-2 text-white justify-between items-center"
>
<p>{{ member.lastname }}, {{ member.firstname }} {{ member.nameaffix ? `- ${member.nameaffix}` : "" }}</p>
<TrashIcon class="w-5 h-5 p-1 box-content cursor-pointer" @click="removeSelected(member.id)" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapActions, mapState, mapWritableState } from "pinia";
import Spinner from "@/components/Spinner.vue";
import {
Combobox,
ComboboxLabel,
ComboboxInput,
ComboboxButton,
ComboboxOptions,
ComboboxOption,
TransitionRoot,
} from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { TrashIcon } from "@heroicons/vue/24/outline";
import { useProtocolStore } from "@/stores/admin/protocol";
import { useMemberStore } from "@/stores/admin/member";
import type { MemberViewModel } from "@/viewmodels/admin/member.models";
import { useProtocolPresenceStore } from "../../../../stores/admin/protocolPresence";
</script>
<script lang="ts">
export default defineComponent({
props: {
protocolId: String,
},
data() {
return {
query: "" as String,
};
},
computed: {
...mapWritableState(useProtocolPresenceStore, ["presence", "loading"]),
...mapState(useMemberStore, ["members"]),
filtered(): Array<MemberViewModel> {
return this.query === ""
? this.members
: this.members.filter((member) =>
(member.firstname + " " + member.lastname)
.toLowerCase()
.replace(/\s+/g, "")
.includes(this.query.toLowerCase().replace(/\s+/g, ""))
);
},
sorted(): Array<MemberViewModel> {
return this.selected.sort((a, b) => {
if (a.lastname < b.lastname) return -1;
if (a.lastname > b.lastname) return 1;
if (a.firstname < b.firstname) return -1;
if (a.firstname > b.firstname) return 1;
return 0;
});
},
selected(): Array<MemberViewModel> {
return this.members.filter((m) => this.presence.includes(m.id));
},
},
mounted() {
this.fetchMembers();
this.fetchProtocolPresence();
},
methods: {
...mapActions(useMemberStore, ["fetchMembers"]),
...mapActions(useProtocolPresenceStore, ["fetchProtocolPresence"]),
removeSelected(id: number) {
let index = this.presence.findIndex((s) => s == id);
if (index != -1) {
this.presence.splice(index, 1);
}
},
},
});
</script>

View file

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

View file

@ -0,0 +1,120 @@
<template>
<MainTemplate>
<template #headerInsert>
<RouterLink to="../" class="text-primary w-fit">zurück zur Liste</RouterLink>
</template>
<template #topBar>
<div class="flex flex-row gap-2 items-center justify-between pt-5 pb-3 px-7">
<h1 class="font-bold text-xl h-8 min-h-fit grow">{{ origin?.title }}, {{ origin?.date }}</h1>
<ProtocolSyncing
:executeSyncAll="executeSyncAll"
@syncState="
(state) => {
syncState = state;
}
"
/>
<!-- <TrashIcon class="w-5 h-5 cursor-pointer" @click="openDeleteModal" /> -->
</div>
</template>
<template #diffMain>
<div class="flex flex-col gap-2 grow px-7 overflow-hidden">
<div class="flex flex-col grow gap-2 overflow-hidden">
<div class="w-full flex flex-row max-lg:flex-wrap justify-center">
<RouterLink
v-for="tab in tabs"
:key="tab.route"
v-slot="{ isActive }"
:to="{ name: tab.route }"
class="w-1/2 md:w-1/3 lg:w-full p-0.5 first:pl-0 last:pr-0"
>
<p
:class="[
'w-full rounded-lg py-2.5 text-sm text-center font-medium leading-5 focus:ring-0 outline-none',
isActive ? 'bg-red-200 shadow border-b-2 border-primary rounded-b-none' : ' hover:bg-red-200',
]"
>
{{ tab.title }}
</p>
</RouterLink>
</div>
<RouterView />
</div>
</div>
</template>
</MainTemplate>
</template>
<script setup lang="ts">
import { defineAsyncComponent, defineComponent, markRaw } from "vue";
import { mapActions, mapState } from "pinia";
import MainTemplate from "@/templates/Main.vue";
import { RouterLink, RouterView } from "vue-router";
import { useProtocolStore } from "@/stores/admin/protocol";
import { useModalStore } from "@/stores/modal";
import ProtocolSyncing from "@/components/admin/club/protocol/ProtocolSyncing.vue";
import { PrinterIcon } from "@heroicons/vue/24/outline";
</script>
<script lang="ts">
export default defineComponent({
props: {
protocolId: String,
},
watch: {
syncState() {
if (this.wantToClose && this.syncState == "synced") {
this.wantToClose = false;
this.closeModal();
}
},
},
data() {
return {
tabs: [
{ route: "admin-club-protocol-overview", title: "Übersicht" },
{ route: "admin-club-protocol-presence", title: "Anwesenheit" },
{ route: "admin-club-protocol-voting", title: "Abstimmungen" },
{ route: "admin-club-protocol-decisions", title: "Beschlüsse" },
{ route: "admin-club-protocol-agenda", title: "Protokoll" },
{ route: "admin-club-protocol-printout", title: "Druck" },
],
wantToClose: false as boolean,
executeSyncAll: undefined as any,
syncState: "synced" as "synced" | "syncing" | "detectedChanges" | "failed",
};
},
computed: {
...mapState(useProtocolStore, ["origin"]),
},
mounted() {
this.fetchProtocolByActiveId();
},
// this.syncState is undefined, so it will never work
// beforeRouteLeave(to, from, next) {
// if (this.syncState != "synced") {
// this.executeSyncAll = Date.now();
// this.wantToClose = true;
// this.openInfoModal();
// next(false);
// } else {
// next();
// }
// },
methods: {
...mapActions(useProtocolStore, ["fetchProtocolByActiveId"]),
...mapActions(useModalStore, ["openModal"]),
openInfoModal() {
this.openModal(
markRaw(defineAsyncComponent(() => import("@/components/admin/club/protocol/CurrentlySyncingModal.vue")))
);
},
openDeleteModal() {
// this.openModal(
// markRaw(defineAsyncComponent(() => import("@/components/admin/club/protocol/DeleteProtocolModal.vue"))),
// parseInt(this.protocolId ?? "")
// );
},
},
});
</script>

View file

@ -0,0 +1,91 @@
<template>
<div class="flex flex-col gap-2 h-full w-full overflow-y-auto">
<Spinner v-if="loading == 'loading'" class="mx-auto" />
<p v-else-if="loading == 'failed'" @click="fetchProtocolVoting" class="cursor-pointer">
&#8634; laden fehlgeschlagen
</p>
<div class="flex flex-col gap-2 h-full overflow-y-auto">
<details
v-for="item in voting"
class="flex flex-col gap-2 rounded-lg w-full justify-between border border-primary overflow-hidden min-h-fit"
>
<summary class="flex flex-row gap-2 bg-primary p-2 w-full justify-between items-center cursor-pointer">
<svg
class="fill-white stroke-white opacity-75 w-4 h-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
>
<path d="M12.95 10.707l.707-.707L8 4.343 6.586 5.757 10.828 10l-4.242 4.243L8 15.657l4.95-4.95z" />
</svg>
<input
type="text"
name="title"
id="title"
placeholder="Einscheidung"
autocomplete="off"
v-model="item.topic"
@keyup.prevent
/>
</summary>
<QuillEditor
id="top"
theme="snow"
placeholder="Entscheidung Inhalt..."
style="height: 100px; max-height: 100px; min-height: 100px"
contentType="html"
:toolbar="toolbarOptions"
v-model:content="item.context"
/>
<div class="px-2 pb-2">
<p>Ergebnis:</p>
<div class="flex flex-row gap-2">
<div class="w-full">
<p>dafür</p>
<input type="number" v-model="item.favour" />
</div>
<div class="w-full">
<p>dagegen</p>
<input type="number" v-model="item.against" />
</div>
<div class="w-full">
<p>enthalten</p>
<input type="number" v-model="item.abstain" />
</div>
</div>
</div>
</details>
</div>
<button primary class="!w-fit" @click="createProtocolVoting">Abstimmung hinzufügen</button>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapActions, mapState } from "pinia";
import Spinner from "@/components/Spinner.vue";
import { useProtocolStore } from "@/stores/admin/protocol";
import { QuillEditor } from "@vueup/vue-quill";
import "@vueup/vue-quill/dist/vue-quill.snow.css";
import { toolbarOptions } from "@/helpers/quillConfig";
import { useProtocolVotingStore } from "../../../../stores/admin/protocolVoting";
</script>
<script lang="ts">
export default defineComponent({
props: {
protocolId: String,
},
computed: {
...mapState(useProtocolVotingStore, ["voting", "loading"]),
},
mounted() {
this.fetchProtocolVoting();
},
methods: {
...mapActions(useProtocolVotingStore, ["fetchProtocolVoting", "createProtocolVoting"]),
},
});
</script>

View file

@ -7,42 +7,17 @@
</template>
<template #diffMain>
<div class="flex flex-col w-full h-full gap-2 justify-center px-7">
<div class="flex flex-col w-full grow gap-2 pr-2 overflow-y-scroll">
<MemberListItem v-for="member in members" :key="member.id" :member="member" />
</div>
<div class="flex flex-row w-full justify-between select-none">
<p class="text-sm font-normal text-gray-500">
Elemente <span class="font-semibold text-gray-900">{{ showingText }}</span> von
<span class="font-semibold text-gray-900">{{ entryCount }}</span>
</p>
<ul class="flex flex-row text-sm h-8">
<li
class="flex h-8 w-8 items-center justify-center text-gray-500 bg-white border border-gray-300 first:rounded-s-lg last:rounded-e-lg"
:class="[currentPage > 0 ? 'cursor-pointer hover:bg-gray-100 hover:text-gray-700' : 'opacity-50']"
@click="loadPage(currentPage - 1)"
<Pagination
:items="members"
:totalCount="totalCount"
:indicateLoading="loading == 'loading'"
@load-data="(offset, count, search) => fetchMembers(offset, count)"
@search="(search) => fetchMembers(0, 25, true)"
>
<ChevronLeftIcon class="h-4" />
</li>
<li
v-for="page in displayedPagesNumbers"
:key="page"
class="flex h-8 w-8 items-center justify-center text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 first:rounded-s-lg last:rounded-e-lg"
:class="[currentPage == page ? 'font-bold border-primary' : '', page != '.' ? ' cursor-pointer' : '']"
@click="loadPage(page)"
>
{{ typeof page == "number" ? page + 1 : "..." }}
</li>
<li
class="flex h-8 w-8 items-center justify-center text-gray-500 bg-white border border-gray-300 first:rounded-s-lg last:rounded-e-lg"
:class="[
currentPage + 1 < countOfPages ? 'cursor-pointer hover:bg-gray-100 hover:text-gray-700' : 'opacity-50',
]"
@click="loadPage(currentPage + 1)"
>
<ChevronRightIcon class="h-4" />
</li>
</ul>
</div>
<template #pageRow="{ row }: { row: MemberViewModel }">
<MemberListItem :member="row" />
</template>
</Pagination>
<div class="flex flex-row gap-4">
<button primary class="!w-fit" @click="openCreateModal">Mitglied erstellen</button>
@ -60,6 +35,8 @@ import { ChevronRightIcon, ChevronLeftIcon } from "@heroicons/vue/20/solid";
import { useMemberStore } from "@/stores/admin/member";
import MemberListItem from "@/components/admin/club/member/MemberListItem.vue";
import { useModalStore } from "@/stores/modal";
import Pagination from "../../../components/Pagination.vue";
import type { MemberViewModel } from "../../../viewmodels/admin/member.models";
</script>
<script lang="ts">
@ -71,7 +48,7 @@ export default defineComponent({
};
},
computed: {
...mapState(useMemberStore, ["members", "totalCount"]),
...mapState(useMemberStore, ["members", "totalCount", "loading"]),
entryCount() {
return this.totalCount ?? this.members.length;
},