This commit was merged in pull request #84.
This commit is contained in:
+21
-1
@@ -1,5 +1,23 @@
|
||||
<template>
|
||||
<NcContent app-name="mitgliederverwaltung">
|
||||
<NcAppNavigation>
|
||||
<template #list>
|
||||
<NcAppNavigationItem name="Mitglieder"
|
||||
:to="{ name: 'members' }"
|
||||
:active="$route.name === 'members' || $route.name === 'member-detail'">
|
||||
<template #icon>
|
||||
<AccountGroup :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem name="Familien"
|
||||
:to="{ name: 'families' }"
|
||||
:active="$route.name === 'families' || $route.name === 'family-detail'">
|
||||
<template #icon>
|
||||
<AccountMultiple :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
</template>
|
||||
</NcAppNavigation>
|
||||
<NcAppContent>
|
||||
<router-view />
|
||||
</NcAppContent>
|
||||
@@ -7,5 +25,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NcContent, NcAppContent } from '@nextcloud/vue'
|
||||
import { NcContent, NcAppContent, NcAppNavigation, NcAppNavigationItem } from '@nextcloud/vue'
|
||||
import AccountGroup from 'vue-material-design-icons/AccountGroup.vue'
|
||||
import AccountMultiple from 'vue-material-design-icons/AccountMultiple.vue'
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<div class="family-form">
|
||||
<div class="family-form__row">
|
||||
<label class="family-form__label">Familienname *</label>
|
||||
<NcTextField :value="family.name"
|
||||
:disabled="!editing"
|
||||
placeholder="z.B. Mueller"
|
||||
@update:value="$emit('update', 'name', $event)" />
|
||||
</div>
|
||||
|
||||
<!-- Banking section (only visible with banking permission) -->
|
||||
<div class="family-form__section">
|
||||
<h4>Bankverbindung</h4>
|
||||
|
||||
<div class="family-form__row">
|
||||
<label class="family-form__label">Kontoinhaber</label>
|
||||
<NcTextField :value="family.kontoinhaberEncrypted || ''"
|
||||
:disabled="!editing"
|
||||
placeholder="Name des Kontoinhabers"
|
||||
@update:value="$emit('update', 'kontoinhaberEncrypted', $event)" />
|
||||
</div>
|
||||
|
||||
<div class="family-form__row">
|
||||
<label class="family-form__label">IBAN</label>
|
||||
<div class="family-form__iban-wrapper">
|
||||
<NcTextField :value="family.ibanEncrypted || ''"
|
||||
:disabled="!editing"
|
||||
placeholder="DE89 3704 0044 0532 0130 00"
|
||||
@update:value="onIbanInput" />
|
||||
<span v-if="ibanValidation !== null" :class="ibanValidationClass">
|
||||
{{ ibanValidation }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="family-form__row">
|
||||
<label class="family-form__label">BIC</label>
|
||||
<NcTextField :value="family.bic || ''"
|
||||
:disabled="!editing"
|
||||
placeholder="COBADEFFXXX"
|
||||
@update:value="$emit('update', 'bic', $event)" />
|
||||
</div>
|
||||
|
||||
<div class="family-form__row">
|
||||
<label class="family-form__label">Kreditinstitut</label>
|
||||
<NcTextField :value="family.kreditinstitut || ''"
|
||||
:disabled="!editing"
|
||||
placeholder="Name der Bank"
|
||||
@update:value="$emit('update', 'kreditinstitut', $event)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { NcTextField } from '@nextcloud/vue'
|
||||
|
||||
const props = defineProps({
|
||||
family: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
editing: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update'])
|
||||
|
||||
const ibanValidation = ref(null)
|
||||
|
||||
const ibanValidationClass = computed(() => {
|
||||
if (ibanValidation.value === null) return ''
|
||||
return ibanValidation.value === 'Gueltig'
|
||||
? 'family-form__iban-valid'
|
||||
: 'family-form__iban-invalid'
|
||||
})
|
||||
|
||||
/**
|
||||
* Handle IBAN input with basic client-side validation feedback.
|
||||
*/
|
||||
function onIbanInput(value) {
|
||||
emit('update', 'ibanEncrypted', value)
|
||||
|
||||
if (!value || value.trim() === '') {
|
||||
ibanValidation.value = null
|
||||
return
|
||||
}
|
||||
|
||||
// Basic IBAN format check (will be properly validated on the backend)
|
||||
const cleaned = value.replace(/\s+/g, '').toUpperCase()
|
||||
if (cleaned.length < 15 || cleaned.length > 34) {
|
||||
ibanValidation.value = 'Ungueltige Laenge'
|
||||
return
|
||||
}
|
||||
|
||||
if (!/^[A-Z]{2}[0-9]{2}[A-Z0-9]+$/.test(cleaned)) {
|
||||
ibanValidation.value = 'Ungueltiges Format'
|
||||
return
|
||||
}
|
||||
|
||||
// MOD-97 check
|
||||
if (validateIbanChecksum(cleaned)) {
|
||||
ibanValidation.value = 'Gueltig'
|
||||
} else {
|
||||
ibanValidation.value = 'Ungueltige Pruefsumme'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate IBAN checksum using MOD-97 algorithm.
|
||||
*/
|
||||
function validateIbanChecksum(iban) {
|
||||
// Move first 4 chars to end
|
||||
const rearranged = iban.substring(4) + iban.substring(0, 4)
|
||||
|
||||
// Convert letters to numbers (A=10, B=11, etc.)
|
||||
let numericStr = ''
|
||||
for (const char of rearranged) {
|
||||
if (char >= 'A' && char <= 'Z') {
|
||||
numericStr += (char.charCodeAt(0) - 55).toString()
|
||||
} else {
|
||||
numericStr += char
|
||||
}
|
||||
}
|
||||
|
||||
// MOD-97 on the large number (process in chunks to avoid overflow)
|
||||
let remainder = 0
|
||||
for (let i = 0; i < numericStr.length; i++) {
|
||||
remainder = (remainder * 10 + parseInt(numericStr[i])) % 97
|
||||
}
|
||||
|
||||
return remainder === 1
|
||||
}
|
||||
|
||||
watch(() => props.family.ibanEncrypted, (val) => {
|
||||
if (!props.editing) {
|
||||
ibanValidation.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.family-form__row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.family-form__label {
|
||||
font-weight: 500;
|
||||
font-size: 0.9em;
|
||||
color: var(--color-text-lighter);
|
||||
}
|
||||
|
||||
.family-form__section {
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.family-form__section h4 {
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.family-form__iban-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.family-form__iban-valid {
|
||||
color: var(--color-success);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.family-form__iban-invalid {
|
||||
color: var(--color-error);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
</style>
|
||||
@@ -16,6 +16,17 @@ const routes = [
|
||||
component: () => import('./views/MemberDetail.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/families',
|
||||
name: 'families',
|
||||
component: () => import('./views/FamilyList.vue'),
|
||||
},
|
||||
{
|
||||
path: '/families/:id',
|
||||
name: 'family-detail',
|
||||
component: () => import('./views/FamilyDetail.vue'),
|
||||
props: true,
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Pinia store for family state management.
|
||||
*
|
||||
* Handles fetching, caching, and CRUD operations against the
|
||||
* /api/v1/families REST API.
|
||||
*
|
||||
* Part of Issue #29.
|
||||
*/
|
||||
import { defineStore } from 'pinia'
|
||||
import axios from '@nextcloud/axios'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
|
||||
export const useFamiliesStore = defineStore('families', {
|
||||
state: () => ({
|
||||
/** @type {Array} List of families */
|
||||
families: [],
|
||||
/** @type {Object|null} Currently selected family */
|
||||
currentFamily: null,
|
||||
/** @type {number} Total family count */
|
||||
total: 0,
|
||||
/** @type {boolean} Loading state */
|
||||
loading: false,
|
||||
/** @type {string|null} Error message */
|
||||
error: null,
|
||||
/** @type {number} Current offset */
|
||||
offset: 0,
|
||||
/** @type {number} Items per page */
|
||||
limit: 20,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
hasMore: (state) => state.offset + state.limit < state.total,
|
||||
currentPage: (state) => Math.floor(state.offset / state.limit) + 1,
|
||||
totalPages: (state) => Math.ceil(state.total / state.limit),
|
||||
},
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* Fetch families with pagination.
|
||||
*/
|
||||
async fetchFamilies(offset = 0) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
this.offset = offset
|
||||
|
||||
try {
|
||||
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/families')
|
||||
const response = await axios.get(url, {
|
||||
params: {
|
||||
limit: this.limit,
|
||||
offset: this.offset,
|
||||
},
|
||||
})
|
||||
|
||||
this.families = response.data.data
|
||||
this.total = response.data.total
|
||||
} catch (err) {
|
||||
this.error = err.response?.data?.error || 'Fehler beim Laden der Familien'
|
||||
console.error('Failed to fetch families:', err)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch a single family by ID with linked members.
|
||||
*/
|
||||
async fetchFamily(id) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const url = generateUrl(`/apps/mitgliederverwaltung/api/v1/families/${id}`)
|
||||
const response = await axios.get(url)
|
||||
this.currentFamily = response.data
|
||||
return response.data
|
||||
} catch (err) {
|
||||
this.error = err.response?.data?.error || 'Fehler beim Laden der Familie'
|
||||
console.error('Failed to fetch family:', err)
|
||||
throw err
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Search families by name.
|
||||
*/
|
||||
async searchFamilies(query) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/families')
|
||||
const response = await axios.get(url, {
|
||||
params: { search: query },
|
||||
})
|
||||
|
||||
this.families = response.data.data
|
||||
this.total = response.data.total
|
||||
} catch (err) {
|
||||
this.error = err.response?.data?.error || 'Fehler bei der Suche'
|
||||
console.error('Failed to search families:', err)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new family.
|
||||
*/
|
||||
async createFamily(data) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/families')
|
||||
const response = await axios.post(url, data)
|
||||
return response.data
|
||||
} catch (err) {
|
||||
this.error = err.response?.data?.error || 'Fehler beim Erstellen der Familie'
|
||||
throw err
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an existing family.
|
||||
*/
|
||||
async updateFamily(id, data) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const url = generateUrl(`/apps/mitgliederverwaltung/api/v1/families/${id}`)
|
||||
const response = await axios.put(url, data)
|
||||
this.currentFamily = response.data
|
||||
return response.data
|
||||
} catch (err) {
|
||||
this.error = err.response?.data?.error || 'Fehler beim Aktualisieren der Familie'
|
||||
throw err
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a family.
|
||||
*/
|
||||
async deleteFamily(id) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const url = generateUrl(`/apps/mitgliederverwaltung/api/v1/families/${id}`)
|
||||
await axios.delete(url)
|
||||
|
||||
this.families = this.families.filter(f => f.id !== id)
|
||||
this.total = Math.max(0, this.total - 1)
|
||||
|
||||
if (this.currentFamily?.id === id) {
|
||||
this.currentFamily = null
|
||||
}
|
||||
} catch (err) {
|
||||
this.error = err.response?.data?.error || 'Fehler beim Loeschen der Familie'
|
||||
throw err
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Link a member to a family.
|
||||
*/
|
||||
async linkMember(familyId, memberId) {
|
||||
try {
|
||||
const url = generateUrl(`/apps/mitgliederverwaltung/api/v1/families/${familyId}/members/${memberId}`)
|
||||
await axios.post(url)
|
||||
// Reload the family to get updated members list
|
||||
await this.fetchFamily(familyId)
|
||||
} catch (err) {
|
||||
this.error = err.response?.data?.error || 'Fehler beim Verknuepfen des Mitglieds'
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Unlink a member from a family.
|
||||
*/
|
||||
async unlinkMember(familyId, memberId) {
|
||||
try {
|
||||
const url = generateUrl(`/apps/mitgliederverwaltung/api/v1/families/${familyId}/members/${memberId}`)
|
||||
await axios.delete(url)
|
||||
// Reload the family to get updated members list
|
||||
await this.fetchFamily(familyId)
|
||||
} catch (err) {
|
||||
this.error = err.response?.data?.error || 'Fehler beim Entfernen des Mitglieds'
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Navigate to a specific page.
|
||||
*/
|
||||
async goToPage(page) {
|
||||
const newOffset = (page - 1) * this.limit
|
||||
await this.fetchFamilies(newOffset)
|
||||
},
|
||||
|
||||
clearError() {
|
||||
this.error = null
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,450 @@
|
||||
<template>
|
||||
<div class="family-detail">
|
||||
<!-- Header with back button and title -->
|
||||
<div class="family-detail__header">
|
||||
<NcButton @click="$router.push({ name: 'families' })">
|
||||
<template #icon>
|
||||
<ArrowLeft :size="20" />
|
||||
</template>
|
||||
Zurueck
|
||||
</NcButton>
|
||||
<h2 v-if="isNew">Neue Familie</h2>
|
||||
<h2 v-else-if="family">{{ family.name }}</h2>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<NcLoadingIcon v-if="store.loading && !family"
|
||||
:size="64"
|
||||
class="family-detail__loading" />
|
||||
|
||||
<!-- Error -->
|
||||
<NcEmptyContent v-else-if="store.error"
|
||||
name="Fehler"
|
||||
:description="store.error">
|
||||
<template #action>
|
||||
<NcButton @click="loadFamily">Erneut versuchen</NcButton>
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<!-- Content -->
|
||||
<div v-else-if="family || isNew" class="family-detail__content">
|
||||
<!-- Info banner -->
|
||||
<div v-if="!isNew && family" class="family-detail__info">
|
||||
<span class="family-detail__info-item">
|
||||
<strong>Mitglieder:</strong> {{ family.members ? family.members.length : 0 }}
|
||||
</span>
|
||||
<span class="family-detail__info-item">
|
||||
<strong>Aktive Kinder:</strong> {{ family.activeChildrenCount || 0 }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Family form -->
|
||||
<FamilyForm :family="formData"
|
||||
:editing="editing"
|
||||
@update="onFormUpdate" />
|
||||
|
||||
<!-- Linked members section -->
|
||||
<div v-if="!isNew" class="family-detail__section">
|
||||
<h3>Verknuepfte Mitglieder</h3>
|
||||
|
||||
<!-- Members list -->
|
||||
<div v-if="formData.members && formData.members.length > 0" class="family-detail__members">
|
||||
<div v-for="member in formData.members"
|
||||
:key="member.id"
|
||||
class="family-detail__member-row">
|
||||
<span class="family-detail__member-name"
|
||||
@click="$router.push({ name: 'member-detail', params: { id: member.id } })">
|
||||
{{ member.nachname }}, {{ member.vorname }}
|
||||
</span>
|
||||
<span :class="'family-detail__role family-detail__role--' + member.rolle">
|
||||
{{ formatRolle(member.rolle) }}
|
||||
</span>
|
||||
<span :class="'family-detail__status family-detail__status--' + member.status">
|
||||
{{ formatStatus(member.status) }}
|
||||
</span>
|
||||
<NcButton v-if="editing"
|
||||
type="error"
|
||||
@click="removeMember(member.id)">
|
||||
Entfernen
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="family-detail__no-members">Keine Mitglieder verknuepft.</p>
|
||||
|
||||
<!-- Add member -->
|
||||
<div v-if="editing" class="family-detail__add-member">
|
||||
<NcTextField :value.sync="memberSearchQuery"
|
||||
placeholder="Mitglied suchen (Name eingeben)..."
|
||||
@update:value="onMemberSearch" />
|
||||
<div v-if="memberSearchResults.length > 0" class="family-detail__search-results">
|
||||
<div v-for="result in memberSearchResults"
|
||||
:key="result.id"
|
||||
class="family-detail__search-result"
|
||||
@click="addMember(result.id)">
|
||||
{{ result.nachname }}, {{ result.vorname }}
|
||||
<span v-if="result.familyId" class="family-detail__already-linked">
|
||||
(bereits in Familie)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="family-detail__actions">
|
||||
<template v-if="!editing">
|
||||
<NcButton type="primary" @click="startEditing">
|
||||
Bearbeiten
|
||||
</NcButton>
|
||||
<NcButton v-if="!isNew"
|
||||
type="error"
|
||||
@click="confirmDelete">
|
||||
Loeschen
|
||||
</NcButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<NcButton type="primary"
|
||||
:disabled="store.loading"
|
||||
@click="save">
|
||||
Speichern
|
||||
</NcButton>
|
||||
<NcButton @click="cancelEditing">
|
||||
Abbrechen
|
||||
</NcButton>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { NcButton, NcEmptyContent, NcLoadingIcon, NcTextField } from '@nextcloud/vue'
|
||||
import { useFamiliesStore } from '../stores/families.js'
|
||||
import { useMembersStore } from '../stores/members.js'
|
||||
import FamilyForm from '../components/FamilyForm.vue'
|
||||
import ArrowLeft from 'vue-material-design-icons/ArrowLeft.vue'
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: [String, Number],
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const store = useFamiliesStore()
|
||||
const membersStore = useMembersStore()
|
||||
|
||||
const editing = ref(false)
|
||||
const family = ref(null)
|
||||
const formData = ref(createEmptyFamily())
|
||||
const memberSearchQuery = ref('')
|
||||
const memberSearchResults = ref([])
|
||||
let memberSearchTimeout = null
|
||||
|
||||
const isNew = computed(() => props.id === 'new')
|
||||
|
||||
onMounted(async () => {
|
||||
if (isNew.value) {
|
||||
editing.value = true
|
||||
formData.value = createEmptyFamily()
|
||||
} else {
|
||||
await loadFamily()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.id, async () => {
|
||||
if (isNew.value) {
|
||||
editing.value = true
|
||||
formData.value = createEmptyFamily()
|
||||
family.value = null
|
||||
} else {
|
||||
await loadFamily()
|
||||
}
|
||||
})
|
||||
|
||||
function createEmptyFamily() {
|
||||
return {
|
||||
name: '',
|
||||
kontoinhaberEncrypted: null,
|
||||
ibanEncrypted: null,
|
||||
bic: null,
|
||||
kreditinstitut: null,
|
||||
members: [],
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFamily() {
|
||||
try {
|
||||
const data = await store.fetchFamily(Number(props.id))
|
||||
family.value = data
|
||||
formData.value = JSON.parse(JSON.stringify(data))
|
||||
} catch {
|
||||
// Error handled by store
|
||||
}
|
||||
}
|
||||
|
||||
function startEditing() {
|
||||
formData.value = JSON.parse(JSON.stringify(family.value))
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
function cancelEditing() {
|
||||
if (isNew.value) {
|
||||
router.push({ name: 'families' })
|
||||
} else {
|
||||
formData.value = JSON.parse(JSON.stringify(family.value))
|
||||
editing.value = false
|
||||
}
|
||||
memberSearchQuery.value = ''
|
||||
memberSearchResults.value = []
|
||||
}
|
||||
|
||||
function onFormUpdate(field, value) {
|
||||
formData.value[field] = value
|
||||
}
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
if (isNew.value) {
|
||||
const data = { ...formData.value }
|
||||
delete data.members
|
||||
const created = await store.createFamily(data)
|
||||
router.push({ name: 'family-detail', params: { id: created.id } })
|
||||
} else {
|
||||
const data = { ...formData.value }
|
||||
delete data.members
|
||||
delete data.id
|
||||
delete data.activeChildrenCount
|
||||
delete data.createdAt
|
||||
delete data.updatedAt
|
||||
await store.updateFamily(Number(props.id), data)
|
||||
await loadFamily()
|
||||
editing.value = false
|
||||
}
|
||||
memberSearchQuery.value = ''
|
||||
memberSearchResults.value = []
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (confirm('Familie wirklich loeschen? Mitglieder werden nicht geloescht, nur die Verknuepfung wird aufgehoben.')) {
|
||||
try {
|
||||
await store.deleteFamily(Number(props.id))
|
||||
router.push({ name: 'families' })
|
||||
} catch {
|
||||
// Error displayed by store
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for members to link.
|
||||
*/
|
||||
function onMemberSearch(value) {
|
||||
memberSearchQuery.value = value
|
||||
clearTimeout(memberSearchTimeout)
|
||||
|
||||
if (!value || value.trim().length < 2) {
|
||||
memberSearchResults.value = []
|
||||
return
|
||||
}
|
||||
|
||||
memberSearchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
await membersStore.searchMembers(value.trim())
|
||||
// Filter out already-linked members
|
||||
const linkedIds = (formData.value.members || []).map(m => m.id)
|
||||
memberSearchResults.value = membersStore.members.filter(m => !linkedIds.includes(m.id))
|
||||
} catch {
|
||||
memberSearchResults.value = []
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
async function addMember(memberId) {
|
||||
try {
|
||||
await store.linkMember(Number(props.id), memberId)
|
||||
family.value = store.currentFamily
|
||||
formData.value = JSON.parse(JSON.stringify(store.currentFamily))
|
||||
memberSearchQuery.value = ''
|
||||
memberSearchResults.value = []
|
||||
} catch {
|
||||
// Error displayed by store
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMember(memberId) {
|
||||
try {
|
||||
await store.unlinkMember(Number(props.id), memberId)
|
||||
family.value = store.currentFamily
|
||||
formData.value = JSON.parse(JSON.stringify(store.currentFamily))
|
||||
} catch {
|
||||
// Error displayed by store
|
||||
}
|
||||
}
|
||||
|
||||
function formatRolle(rolle) {
|
||||
const map = {
|
||||
mitglied: 'Kind',
|
||||
erziehungsberechtigter: 'Erziehungsberechtigter',
|
||||
}
|
||||
return map[rolle] || rolle
|
||||
}
|
||||
|
||||
function formatStatus(status) {
|
||||
const map = { aktiv: 'Aktiv', inaktiv: 'Inaktiv', geloescht: 'Geloescht' }
|
||||
return map[status] || status
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.family-detail {
|
||||
padding: 20px;
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.family-detail__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.family-detail__header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.family-detail__loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 60px;
|
||||
}
|
||||
|
||||
.family-detail__info {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
padding: 12px 16px;
|
||||
background: var(--color-background-dark);
|
||||
border-radius: var(--border-radius-large);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.family-detail__info-item {
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.family-detail__section {
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.family-detail__section h3 {
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.family-detail__members {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.family-detail__member-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-background-dark);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.family-detail__member-name {
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.family-detail__member-name:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.family-detail__role {
|
||||
font-size: 0.85em;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--border-radius-pill);
|
||||
background: var(--color-background-darker);
|
||||
}
|
||||
|
||||
.family-detail__role--erziehungsberechtigter {
|
||||
background: var(--color-primary-element-light);
|
||||
color: var(--color-primary-element-light-text);
|
||||
}
|
||||
|
||||
.family-detail__status {
|
||||
font-size: 0.85em;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--border-radius-pill);
|
||||
}
|
||||
|
||||
.family-detail__status--aktiv {
|
||||
background-color: var(--color-success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.family-detail__status--inaktiv {
|
||||
background-color: var(--color-warning);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.family-detail__no-members {
|
||||
color: var(--color-text-lighter);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.family-detail__add-member {
|
||||
margin-top: 12px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.family-detail__search-results {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-main-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.family-detail__search-result {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.family-detail__search-result:hover {
|
||||
background-color: var(--color-background-hover);
|
||||
}
|
||||
|
||||
.family-detail__already-linked {
|
||||
color: var(--color-text-lighter);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.family-detail__actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,283 @@
|
||||
<template>
|
||||
<div class="family-list">
|
||||
<div class="family-list__header">
|
||||
<h2>Familien</h2>
|
||||
<div class="family-list__actions">
|
||||
<NcTextField :value.sync="searchQuery"
|
||||
:placeholder="'Familien suchen...'"
|
||||
:show-trailing-button="searchQuery !== ''"
|
||||
trailing-button-icon="close"
|
||||
@trailing-button-click="clearSearch"
|
||||
@update:value="onSearch" />
|
||||
<NcButton type="primary"
|
||||
@click="$router.push({ name: 'family-detail', params: { id: 'new' } })">
|
||||
<template #icon>
|
||||
<Plus :size="20" />
|
||||
</template>
|
||||
Neue Familie
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<NcEmptyContent v-if="store.error && !store.loading"
|
||||
name="Fehler"
|
||||
:description="store.error">
|
||||
<template #icon>
|
||||
<AlertCircle :size="64" />
|
||||
</template>
|
||||
<template #action>
|
||||
<NcButton @click="reload">
|
||||
Erneut versuchen
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<!-- Loading state -->
|
||||
<NcLoadingIcon v-else-if="store.loading && store.families.length === 0"
|
||||
:size="64"
|
||||
appearance="dark"
|
||||
class="family-list__loading" />
|
||||
|
||||
<!-- Empty state -->
|
||||
<NcEmptyContent v-else-if="!store.loading && store.families.length === 0"
|
||||
name="Keine Familien"
|
||||
:description="searchQuery ? 'Keine Familien fuer diese Suche gefunden.' : 'Noch keine Familien angelegt.'">
|
||||
<template #icon>
|
||||
<AccountMultiple :size="64" />
|
||||
</template>
|
||||
<template #action>
|
||||
<NcButton v-if="!searchQuery"
|
||||
type="primary"
|
||||
@click="$router.push({ name: 'family-detail', params: { id: 'new' } })">
|
||||
Erste Familie anlegen
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<!-- Family table -->
|
||||
<table v-else class="family-list__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="family-list__th family-list__th--sortable"
|
||||
@click="toggleSort('name')">
|
||||
Familienname
|
||||
<SortIcon :field="'name'" :current-sort="sortField" :sort-asc="sortAsc" />
|
||||
</th>
|
||||
<th class="family-list__th">
|
||||
Mitglieder
|
||||
</th>
|
||||
<th class="family-list__th">
|
||||
Ansprechpartner
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="family in sortedFamilies"
|
||||
:key="family.id"
|
||||
class="family-list__row"
|
||||
@click="$router.push({ name: 'family-detail', params: { id: family.id } })">
|
||||
<td class="family-list__td">
|
||||
{{ family.name }}
|
||||
</td>
|
||||
<td class="family-list__td">
|
||||
{{ family.members ? family.members.length : 0 }}
|
||||
<span v-if="family.activeChildrenCount" class="family-list__children-count">
|
||||
({{ family.activeChildrenCount }} aktive Kinder)
|
||||
</span>
|
||||
</td>
|
||||
<td class="family-list__td">
|
||||
{{ getAnsprechpartner(family) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="store.totalPages > 1" class="family-list__pagination">
|
||||
<NcButton :disabled="store.currentPage <= 1"
|
||||
@click="store.goToPage(store.currentPage - 1)">
|
||||
Zurueck
|
||||
</NcButton>
|
||||
<span class="family-list__page-info">
|
||||
Seite {{ store.currentPage }} von {{ store.totalPages }}
|
||||
({{ store.total }} Familien)
|
||||
</span>
|
||||
<NcButton :disabled="!store.hasMore"
|
||||
@click="store.goToPage(store.currentPage + 1)">
|
||||
Weiter
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { NcButton, NcTextField, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
|
||||
import { useFamiliesStore } from '../stores/families.js'
|
||||
import Plus from 'vue-material-design-icons/Plus.vue'
|
||||
import AlertCircle from 'vue-material-design-icons/AlertCircle.vue'
|
||||
import AccountMultiple from 'vue-material-design-icons/AccountMultiple.vue'
|
||||
import SortIcon from '../components/SortIcon.vue'
|
||||
|
||||
const store = useFamiliesStore()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const sortField = ref('name')
|
||||
const sortAsc = ref(true)
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(() => {
|
||||
store.fetchFamilies()
|
||||
})
|
||||
|
||||
const sortedFamilies = computed(() => {
|
||||
const families = [...store.families]
|
||||
const field = sortField.value
|
||||
const asc = sortAsc.value
|
||||
|
||||
return families.sort((a, b) => {
|
||||
let valA = a[field] ?? ''
|
||||
let valB = b[field] ?? ''
|
||||
|
||||
if (typeof valA === 'string') {
|
||||
valA = valA.toLowerCase()
|
||||
valB = String(valB).toLowerCase()
|
||||
}
|
||||
|
||||
if (valA < valB) return asc ? -1 : 1
|
||||
if (valA > valB) return asc ? 1 : -1
|
||||
return 0
|
||||
})
|
||||
})
|
||||
|
||||
function toggleSort(field) {
|
||||
if (sortField.value === field) {
|
||||
sortAsc.value = !sortAsc.value
|
||||
} else {
|
||||
sortField.value = field
|
||||
sortAsc.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch(value) {
|
||||
searchQuery.value = value
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
if (value.trim()) {
|
||||
store.searchFamilies(value.trim())
|
||||
} else {
|
||||
store.fetchFamilies()
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchQuery.value = ''
|
||||
store.fetchFamilies()
|
||||
}
|
||||
|
||||
function reload() {
|
||||
store.clearError()
|
||||
store.fetchFamilies()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the primary contact (Erziehungsberechtigter) from a family's members.
|
||||
*/
|
||||
function getAnsprechpartner(family) {
|
||||
if (!family.members || family.members.length === 0) {
|
||||
return '—'
|
||||
}
|
||||
const contact = family.members.find(m => m.rolle === 'erziehungsberechtigter')
|
||||
if (contact) {
|
||||
return `${contact.vorname} ${contact.nachname}`
|
||||
}
|
||||
// Fallback to first member
|
||||
const first = family.members[0]
|
||||
return `${first.vorname} ${first.nachname}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.family-list {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.family-list__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.family-list__header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.family-list__actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.family-list__loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 60px;
|
||||
}
|
||||
|
||||
.family-list__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.family-list__th {
|
||||
text-align: left;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 2px solid var(--color-border-dark);
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.family-list__th--sortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.family-list__th--sortable:hover {
|
||||
background-color: var(--color-background-hover);
|
||||
}
|
||||
|
||||
.family-list__row {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.family-list__row:hover {
|
||||
background-color: var(--color-background-hover);
|
||||
}
|
||||
|
||||
.family-list__td {
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.family-list__children-count {
|
||||
color: var(--color-text-lighter);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.family-list__pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 20px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.family-list__page-info {
|
||||
color: var(--color-text-lighter);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user