feat: add Family Vue components with list, detail, and navigation (Closes #29) (#84)

This commit was merged in pull request #84.
This commit is contained in:
2026-04-07 13:16:03 +02:00
parent c903faf6a4
commit 95eb8a0721
6 changed files with 1164 additions and 1 deletions
+21 -1
View File
@@ -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>
+184
View File
@@ -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>
+11
View File
@@ -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({
+215
View File
@@ -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
},
},
})
+450
View File
@@ -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>
+283
View File
@@ -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>