feat: add list view with GottesdienstCard components
- New GottesdienstCard component shows each service with its info - Shows the currently logged-in user's mark as a toggle checkbox - Lists ministrants who have responded with their mark status - Admin view hides the checkbox, shows responses only - New ListView wrapper renders all cards - PlanView gets a Tabelle/Liste toggle between table and card views
This commit is contained in:
@@ -0,0 +1,345 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type {Gottesdienst, Mark, SimplifiedMinistrant} from "@/models/models"
|
||||||
|
import {computed} from "vue"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
gottesdienst: Gottesdienst
|
||||||
|
ministranten: SimplifiedMinistrant[]
|
||||||
|
marks: Mark[]
|
||||||
|
editable: string[]
|
||||||
|
currentUsername: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
toggleMark: [gid: number, mid: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isAdmin = computed(() => props.currentUsername === "admin")
|
||||||
|
const currentMini = computed(() => props.ministranten.find(m => m.username === props.currentUsername))
|
||||||
|
const showCheckbox = computed(() => !!currentMini.value && !isAdmin.value)
|
||||||
|
|
||||||
|
const relevantMarks = computed(() =>
|
||||||
|
props.marks.filter(m => m.gid === props.gottesdienst.id && m.value !== 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
const sortedMinistrants = computed(() => {
|
||||||
|
const miniMap = new Map(props.ministranten.map(m => [m.id, m]))
|
||||||
|
return relevantMarks.value
|
||||||
|
.map(m => ({ministrant: miniMap.get(m.mid), mark: m}))
|
||||||
|
.filter((entry): entry is {ministrant: SimplifiedMinistrant; mark: Mark} => !!entry.ministrant)
|
||||||
|
})
|
||||||
|
|
||||||
|
function getMark(gid: number, mid: number): Mark {
|
||||||
|
const mark = props.marks.find(m => m.mid === mid && m.gid === gid)
|
||||||
|
return mark ?? {gid, mid, value: 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIconForMark(gid: number, mid: number): string {
|
||||||
|
const mark = getMark(gid, mid).value
|
||||||
|
switch (mark) {
|
||||||
|
case -1: return "remove"
|
||||||
|
case 0: return "question_mark"
|
||||||
|
case 1: return "close"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMarkClass(gid: number, mid: number): Record<string, boolean> {
|
||||||
|
const mark = getMark(gid, mid).value
|
||||||
|
return {
|
||||||
|
minus: mark === -1,
|
||||||
|
neutral: mark === 0,
|
||||||
|
cross: mark === 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHintForMark(gid: number, mid: number): string {
|
||||||
|
const mark = getMark(gid, mid).value
|
||||||
|
switch (mark) {
|
||||||
|
case -1: return "Ich kann nicht"
|
||||||
|
case 0: return "Egal"
|
||||||
|
case 1: return "Ich komme"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function two(s: number): string {
|
||||||
|
return (s < 10 ? "0" : "") + s
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDay(time: number | Date): string {
|
||||||
|
const date = new Date(time)
|
||||||
|
return two(date.getDate()) + "." + two(date.getMonth() + 1) + "."
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(time: number | Date): string {
|
||||||
|
const date = new Date(time)
|
||||||
|
return two(date.getHours()) + ":" + two(date.getMinutes())
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWeekday(time: number | Date): string {
|
||||||
|
const date = new Date(time)
|
||||||
|
switch (date.getDay()) {
|
||||||
|
case 1: return "Montag"
|
||||||
|
case 2: return "Dienstag"
|
||||||
|
case 3: return "Mittwoch"
|
||||||
|
case 4: return "Donnerstag"
|
||||||
|
case 5: return "Freitag"
|
||||||
|
case 6: return "Samstag"
|
||||||
|
case 0: return "Sonntag"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOwnMark() {
|
||||||
|
if (!currentMini.value) return
|
||||||
|
emit("toggleMark", props.gottesdienst.id, currentMini.value.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOwnMarkIcon(): string {
|
||||||
|
if (!currentMini.value) return ""
|
||||||
|
const mark = getMark(props.gottesdienst.id, currentMini.value.id).value
|
||||||
|
switch (mark) {
|
||||||
|
case -1: return "close"
|
||||||
|
case 0: return ""
|
||||||
|
case 1: return "check"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOwnMarkClass(): Record<string, boolean> {
|
||||||
|
if (!currentMini.value) return {}
|
||||||
|
const mark = getMark(props.gottesdienst.id, currentMini.value.id).value
|
||||||
|
return {
|
||||||
|
minus: mark === -1,
|
||||||
|
neutral: mark === 0,
|
||||||
|
cross: mark === 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOwnMarkHint(): string {
|
||||||
|
if (!currentMini.value) return ""
|
||||||
|
const mark = getMark(props.gottesdienst.id, currentMini.value.id).value
|
||||||
|
switch (mark) {
|
||||||
|
case -1: return "Ich kann nicht"
|
||||||
|
case 0: return "keine Angabe"
|
||||||
|
case 1: return "Ich komme"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="title-row">
|
||||||
|
<button
|
||||||
|
v-if="showCheckbox"
|
||||||
|
class="own-mark"
|
||||||
|
:class="getOwnMarkClass()"
|
||||||
|
@click="toggleOwnMark"
|
||||||
|
:title="getOwnMarkHint()"
|
||||||
|
>
|
||||||
|
<i v-if="getOwnMarkIcon()" class="icon">{{ getOwnMarkIcon() }}</i>
|
||||||
|
</button>
|
||||||
|
<span class="name">{{ gottesdienst.name !== "" ? gottesdienst.name : "Gottesdienst" }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info">
|
||||||
|
<span class="date">am {{ formatWeekday(gottesdienst.date) }} {{ formatDay(gottesdienst.date) }}</span>
|
||||||
|
<span class="time">um {{ formatTime(gottesdienst.date) }} Uhr</span>
|
||||||
|
<span class="attendance">Anwesenheit: {{ formatTime(gottesdienst.attendance) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body" v-if="sortedMinistrants.length > 0">
|
||||||
|
<div
|
||||||
|
v-for="{ministrant, mark} in sortedMinistrants"
|
||||||
|
:key="ministrant.id"
|
||||||
|
class="ministrant-row"
|
||||||
|
:class="getMarkClass(gottesdienst.id, ministrant.id)"
|
||||||
|
>
|
||||||
|
<span class="mini-name">{{ ministrant.firstname }} {{ ministrant.lastname }}</span>
|
||||||
|
<span class="mark-badge">
|
||||||
|
<i class="icon">{{ getIconForMark(gottesdienst.id, ministrant.id) }}</i>
|
||||||
|
<span class="hint">{{ getHintForMark(gottesdienst.id, ministrant.id) }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body empty" v-else>
|
||||||
|
<span class="no-marks">Keine Rückmeldungen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.card {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #d7d5d5;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #e8e8e8;
|
||||||
|
|
||||||
|
.title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.own-mark {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: 2px solid #cecece;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
transition: 100ms;
|
||||||
|
|
||||||
|
i {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #919191;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.minus {
|
||||||
|
border-color: #690b0b;
|
||||||
|
background: #fdd5d5;
|
||||||
|
color: #690b0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.cross {
|
||||||
|
border-color: #045b04;
|
||||||
|
background: #d1fcd1;
|
||||||
|
color: #045b04;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.neutral {
|
||||||
|
border-color: #9f9f9f;
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: #9f9f9f;
|
||||||
|
}
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 20px;
|
||||||
|
font-variation-settings: "wght" 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #656565;
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 8px 16px;
|
||||||
|
|
||||||
|
&.empty {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: #9f9f9f;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ministrant-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
i {
|
||||||
|
border-radius: 100%;
|
||||||
|
padding: 2px;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.minus {
|
||||||
|
.mark-badge {
|
||||||
|
color: #690b0b;
|
||||||
|
|
||||||
|
i {
|
||||||
|
background: #fdd5d5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: #690b0b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.cross {
|
||||||
|
.mark-badge {
|
||||||
|
color: #045b04;
|
||||||
|
|
||||||
|
i {
|
||||||
|
background: #d1fcd1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: #045b04;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.neutral {
|
||||||
|
.mark-badge {
|
||||||
|
color: #9f9f9f;
|
||||||
|
|
||||||
|
i {
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: #9f9f9f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type {Gottesdienst, Mark, SimplifiedMinistrant} from "@/models/models"
|
||||||
|
import GottesdienstCard from "@/components/GottesdienstCard.vue"
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
gottesdienste: Gottesdienst[]
|
||||||
|
ministranten: SimplifiedMinistrant[]
|
||||||
|
marks: Mark[]
|
||||||
|
editable: string[]
|
||||||
|
currentUsername: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
toggleMark: [gid: number, mid: number]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="list-view">
|
||||||
|
<GottesdienstCard
|
||||||
|
v-for="godi in gottesdienste"
|
||||||
|
:key="godi.id"
|
||||||
|
:gottesdienst="godi"
|
||||||
|
:ministranten="ministranten"
|
||||||
|
:marks="marks"
|
||||||
|
:editable="editable"
|
||||||
|
:current-username="currentUsername"
|
||||||
|
@toggle-mark="(gid, mid) => emit('toggleMark', gid, mid)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.list-view {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,6 +5,7 @@ import TablePlan from "@/components/TablePlan.vue";
|
|||||||
import {API, UnauthorizedError} from "@/services/api";
|
import {API, UnauthorizedError} from "@/services/api";
|
||||||
import type {Gottesdienst, GottesdienstGroup, Mark, SimplifiedMinistrant} from "@/models/models";
|
import type {Gottesdienst, GottesdienstGroup, Mark, SimplifiedMinistrant} from "@/models/models";
|
||||||
import MobilePlan from "@/components/MobilePlan.vue";
|
import MobilePlan from "@/components/MobilePlan.vue";
|
||||||
|
import ListView from "@/components/ListView.vue";
|
||||||
import PlanActionBar from "@/components/PlanActionBar.vue";
|
import PlanActionBar from "@/components/PlanActionBar.vue";
|
||||||
import {Auth} from "@/services/auth";
|
import {Auth} from "@/services/auth";
|
||||||
import GroupView from "@/components/GroupView.vue";
|
import GroupView from "@/components/GroupView.vue";
|
||||||
@@ -38,6 +39,8 @@ const plan = reactive<{
|
|||||||
groups: []
|
groups: []
|
||||||
})
|
})
|
||||||
const mobile = ref(window.innerWidth <= MAX_WIDTH_MOBILE)
|
const mobile = ref(window.innerWidth <= MAX_WIDTH_MOBILE)
|
||||||
|
const viewMode = ref<"table" | "list">("table")
|
||||||
|
const currentUsername = ref("")
|
||||||
const editedMarks = reactive<Mark[]>([]);
|
const editedMarks = reactive<Mark[]>([]);
|
||||||
const editPlanAdmin = ref(false)
|
const editPlanAdmin = ref(false)
|
||||||
const planId = ref(parseInt(route.params.id as string))
|
const planId = ref(parseInt(route.params.id as string))
|
||||||
@@ -79,6 +82,7 @@ async function loadPlan() {
|
|||||||
Auth.loggedInSubject.subscribe(async (loggedIn) => {
|
Auth.loggedInSubject.subscribe(async (loggedIn) => {
|
||||||
console.log("logged in " + loggedIn)
|
console.log("logged in " + loggedIn)
|
||||||
if (loggedIn) {
|
if (loggedIn) {
|
||||||
|
currentUsername.value = Auth.getUser()
|
||||||
plan.editable = Auth.getPrivileges()
|
plan.editable = Auth.getPrivileges()
|
||||||
if (Auth.getUser() == "admin") {
|
if (Auth.getUser() == "admin") {
|
||||||
editPlanAdmin.value = true
|
editPlanAdmin.value = true
|
||||||
@@ -86,6 +90,7 @@ Auth.loggedInSubject.subscribe(async (loggedIn) => {
|
|||||||
console.log("Plan", plan.ministranten)
|
console.log("Plan", plan.ministranten)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
currentUsername.value = ""
|
||||||
editPlanAdmin.value = false
|
editPlanAdmin.value = false
|
||||||
plan.editable = []
|
plan.editable = []
|
||||||
}
|
}
|
||||||
@@ -312,10 +317,39 @@ async function importZelebrationsplan() {
|
|||||||
<template>
|
<template>
|
||||||
<main>
|
<main>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container" :class="{
|
||||||
|
'mobile-view': mobile && viewMode === 'table'
|
||||||
|
}">
|
||||||
|
|
||||||
<GroupView :groups="plan.groups" :admin="editPlanAdmin"
|
<GroupView :groups="plan.groups" :admin="editPlanAdmin"
|
||||||
@new="createNewPlan" @delete="deletePlan" @edit="editPlan" class="no-print"/>
|
@new="createNewPlan" @delete="deletePlan" @edit="editPlan" class="no-print"/>
|
||||||
|
|
||||||
|
<div class="view-toggle no-print" v-if="plan.gottesdienste.length > 0">
|
||||||
|
<button
|
||||||
|
class="flat"
|
||||||
|
:class="{ active: viewMode === 'table' }"
|
||||||
|
@click="viewMode = 'table'"
|
||||||
|
><i>table_rows</i> Tabelle</button>
|
||||||
|
<button
|
||||||
|
class="flat"
|
||||||
|
:class="{ active: viewMode === 'list' }"
|
||||||
|
@click="viewMode = 'list'"
|
||||||
|
><i>list</i> Liste</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ListView
|
||||||
|
:gottesdienste="sortedGottesdienste"
|
||||||
|
:ministranten="plan.ministranten"
|
||||||
|
:marks="getMarks()"
|
||||||
|
:editable="plan.editable"
|
||||||
|
:current-username="currentUsername"
|
||||||
|
@toggle-mark="toggleMark"
|
||||||
|
class="plan list"
|
||||||
|
v-if="viewMode === 'list'">
|
||||||
|
|
||||||
|
</ListView>
|
||||||
|
|
||||||
|
<template v-if="viewMode === 'table'">
|
||||||
<TablePlan
|
<TablePlan
|
||||||
:gottesdienste="sortedGottesdienste"
|
:gottesdienste="sortedGottesdienste"
|
||||||
:ministranten="plan.ministranten"
|
:ministranten="plan.ministranten"
|
||||||
@@ -346,6 +380,7 @@ async function importZelebrationsplan() {
|
|||||||
v-else>
|
v-else>
|
||||||
|
|
||||||
</MobilePlan>
|
</MobilePlan>
|
||||||
|
</template>
|
||||||
|
|
||||||
<PlanActionBar
|
<PlanActionBar
|
||||||
class="action-bar no-print"
|
class="action-bar no-print"
|
||||||
@@ -385,6 +420,36 @@ async function importZelebrationsplan() {
|
|||||||
//width: 100%;
|
//width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.container .view-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-bottom: 1px solid #d7d5d5;
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #c4c4c4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.container.mobile-view {
|
||||||
|
.plan.list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.action-bar {
|
.action-bar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user